feat: client-rendered widgets on iOS#190
Open
burczu wants to merge 40 commits into
Open
Conversation
Adds an example Metro setup that discovers use-voltra widget components, registers generated widget entries, and serves widget bundles from /voltra with a secondary Metro middleware.
Update generated widget entries to export a render function that accepts props and renders the discovered component through the Voltra renderer.
…oc/widget-reactivity-track-5
… (Phase 1) Defines the env shape consumed by client-rendered widgets in their `(props, env) => JSX` signature. Mirrors expo-widgets' WidgetEnvironment for runtime device fields, with a Voltra-specific env.build.* namespace for dev-mode tooling. - packages/core/src/widget-environment.ts (new): WidgetEnvironment<TConfig>, WidgetBuildEnvironment, MaterialColorScheme. iOS-only fields (widgetRenderingMode, showsWidgetContainerBackground) and Android-only fields (materialColors) are optional — undefined on the wrong platform. - isIosEnv / isAndroidEnv type guards for platform narrowing. - Re-exported from @use-voltra/core, @use-voltra/ios, @use-voltra/android. Phase 1 of the client-rendered widgets implementation; native runtime and generated entry plumbing land in later phases. Pure types — no runtime behavior changed. Build + typecheck + lint clean.
- example/metro/widgetRegistry.js: generated entry now emits
`render(props = {}, env = {})`. Env is passed to the widget via a
WidgetWithEnv closure wrapper because createElement does not accept
extra positional args.
- example/widgets/ios/IosWeatherWidget.tsx: accepts `env: WidgetEnvironment`
as the second arg and appends the current color scheme to the
description as a visible test marker.
Verified by curling /voltra/widgets/IosWeatherWidget.bundle and grepping
for WidgetWithEnv (×2), forwardedProps (×2), env.colorScheme (×1).
Bundle size +2.5 KB. No native side yet — that's Phase 3.
Cleared the critical risk gate — proven end-to-end on iOS simulator that the
shared JSContext + per-widget Metro bundle + namespaced-global pattern works:
JS fetches /voltra/widgets/IosWeatherWidget.bundle (~219 KB)
→ Swift evaluateBundle wraps source with a small bootstrap that captures
__r(0) into globalThis.__voltraWidgets[<widgetId>]
→ Swift render(widgetId, propsJSON, envJSON) calls the captured render fn
→ bundle's JSX runs with closure-passed env, renderVoltraVariantToJson
produces the compact JSON, JSON.stringify returns it across the boundary
- packages/ios-client/ios/shared/VoltraJSRenderer.swift (new): shared JSContext
singleton with NSLock; evaluateBundle / render API
- packages/ios-client/src/native/NativeVoltra.ts + ios/app/VoltraModule.swift +
ios/app/NativeVoltra.mm: two temporary TurboModule methods bridging the
runtime to JS — voltraWidgetEvalBundle / voltraWidgetRender. Removed in
Phase 3b once the widget extension calls the runtime directly.
- packages/ios-client/src/widgets/client-rendered-smoke.ts: JS wrapper exported
from @use-voltra/ios-client for the smoke screen.
- example/metro/widgetRegistry.js: generated entry now JSON.parses incoming
props/env (they cross the boundary as strings) and JSON.stringifies the
resolved tree before returning.
- example/screens/ios/IosClientRenderedSmokeScreen.tsx + route: in-app screen
that fetches the Metro bundle, evaluates, calls render, displays the JSON.
No WidgetKit involvement yet. Phase 3b adds env capture from @Environment,
dual dev/prod bundle source, and the actual widget extension hookup.
- Extend IosWeatherWidget env-suffix verification marker from just env.colorScheme to env.colorScheme + env.widgetFamily so multiple captured env values are visible in the widget output. - Add "Reload all widgets" button to the Phase 3a smoke screen that calls WidgetCenter.shared.reloadAllTimelines(), giving an instant dev loop for editing JSX and seeing it on the home-screen widget without waiting for the 60s timeline tick. The Swift-side env capture in VoltraClientWidget.swift (Provider only fetches + evals; SwiftUI View reads @Environment and calls render with the captured envJSON) lives in gitignored example/ios/ - Phase 3b-iii moves it into a config-plugin generator so it becomes committable.
…step 1) Add detectClientRenderedWidgets(widgets, projectRoot) that reads each widget's initialStatePath source, Babel-parses, and looks for a 'use voltra' directive on an exported function whose identifier matches the widget id. Returns the list augmented with a clientRendered flag and (when true) the component name + absolute source path for downstream plugin steps to consume. Q1 of the design grilling kept the app.json schema unified between server- and client-rendered paths; this implicit detection is how the plugin tells them apart at prebuild. Q2 locked id === componentName, so the detector throws on mismatch rather than silently picking one. No call sites yet — step 2 wires the static runtime helper Swift and step 3 dispatches the per-widget Swift generator on this flag.
…tep 2) Add a shared Swift runtime file compiled into the VoltraWidget pod, so per-widget code generated by the plugin in step 3 stays minimal: - VoltraClientWidgetEntry: TimelineEntry carrying bundleReady + widgetId - VoltraClientWidgetProvider: TimelineProvider that fetches the JS bundle and evaluates it via VoltraJSRenderer once per timeline tick - VoltraClientWidgetBundleSource: #if DEBUG Metro HTTP / #else baked-asset stub (Phase 5 fills in the build-time bundle writer) - VoltraClientWidgetEnvBuilder: produces envJSON matching the WidgetEnvironment type from @use-voltra/core, called from the View body so SwiftUI @Environment values are captured per render - VoltraClientWidgetContentView: captures @Environment, calls VoltraJSRenderer.render, parses the resolved JSON into a VoltraNode, and hands a VoltraHomeWidgetEntry to the existing VoltraHomeWidgetView so the rendered UI matches server-rendered widgets exactly (Q5) On bundle-load or render failure the View falls back to the prerendered initial state (Q6) so widgets always show real UI rather than a blank tile. Nothing in the plugin references these types yet — step 3 emits the per-widget Swift that imports VoltraWidget and uses VoltraClientWidget* for client-rendered entries.
…iii step 3)
Wire the step-1 detector into the iOS widget Swift generator. The
existing generateWidgetStruct now takes a DetectedIOSWidget and branches
inside StaticConfiguration:
- server-rendered → VoltraHomeWidgetProvider + VoltraHomeWidgetView
(existing path; no behavior change for current widgets)
- client-rendered → VoltraClientWidgetProvider + VoltraClientWidgetContentView
(Track 5 runtime helpers from step 2; the content view internally
feeds VoltraHomeWidgetView so the rendered UI is identical)
Everything outside the StaticConfiguration's provider/content closures
stays unified: WidgetKit kind, configurationDisplayName, description,
supportedFamilies, contentMarginsDisabled. Per Q7 grilling.
Adds 4 unit tests covering server-only, client-only, mixed bundles,
and that the WidgetKit wrapping stays identical across modes.
Next step (4): adapt prerenderWidgetState so client-rendered widgets'
initial state comes from calling the 'use voltra' function with default
props + minimal env, not from loading a separate initial-state default
export.
…e 3b-iii step 4)
Per Q6 grilling: client-rendered widgets reuse the existing prebuild
prerender path so WidgetKit's placeholder shows a real UI instead of
"Loading…". The new prerender path differs from the server one only in
how it drives the renderer:
- Server widgets: load module → exports.default (WidgetVariants) →
renderWidgetToString (multi-family JSON) — existing path
- Client widgets: load module → exports[componentName] (a function) →
call with empty props + minimal env → renderVoltraVariantToJson →
JSON.stringify (compact {t,c,p}) — new path
Two changes to support this:
1. @use-voltra/expo-plugin now exports evaluateWidgetModule so the iOS
plugin can reuse the Babel + Node VM loader without duplicating it.
Additive change; no API break.
2. New iOS plugin module clientRenderedPrerender.ts runs the
client-widget prerender, returning a PrerenderedWidgetStates map
shaped identically to prerenderWidgetState's output. swift.ts now
splits detected widgets into server vs client, runs each prerender
separately, and merges results into the same
VoltraWidgetInitialStates.swift output.
Placeholder env values are fixed at prebuild (systemMedium, light,
fullColor, en-US). They may not match what the user actually sees the
moment they add the widget, but the first real timeline tick replaces
this entry within milliseconds.
… 3b-iii step 5a)
Per maintainer direction: client-rendered widget hot reload should be
disabled by default and opted into via configuration. Auto-polling on a
60s timeline is the wrong model — the right pattern is push-driven via
Metro HMR (step 5b will wire that up).
What changes:
- New plugin prop IOSConfigPluginProps.clientWidgetHotReload (default
false), threaded through every layer that needs it (WithIOSProps,
GenerateWidgetExtensionFilesProps, GenerateSwiftFilesOptions,
ConfigureMainAppPlistProps).
- Generated per-widget Swift now emits devHotReloadEnabled: true|false
as the third arg to VoltraClientWidgetProvider.
- widgetPlist.ts only adds NSAppTransportSecurity (localhost exception)
when clientWidgetHotReload is on AND at least one widget is
client-rendered. Server-only and hot-reload-off configurations keep
the plist minimal.
- VoltraClientWidgetRuntime.swift refactor:
* VoltraClientWidgetProvider takes devHotReloadEnabled: Bool
* When false: skip Metro fetch entirely, emit bundleReady=false;
ContentView renders the prerendered initial state (same path as
Phase 5 release builds will use)
* Timeline policy is now .never in both cases — no more .after(60)
polling. The widget refreshes only when something explicitly
calls WidgetCenter.shared.reloadAllTimelines()
* Updated header comment explains the push-driven model
Tests +2 (default-off, explicit-on) — 15/15 pass.
Broaden the existing /packages/ios-client/ios/.build/ rule to /packages/*/ios/.build/ so Swift Package Manager caches stay out of git for every iOS-bearing package, not just ios-client. Switching between branches that have packages/voltra/, packages/ios-renderer/, etc. otherwise leaves thousands of orphan files in `git status`. No behavior change for tracked source.
The push-driven counterpart to the clientWidgetHotReload flag from
step 5a. Call once at app startup in DEBUG and any 'use voltra' widget
JSX change in Metro propagates to the home-screen widget within a
debounced ~250ms — no polling, no manual button.
Mechanism (intentionally minimal):
- Metro's HMR runtime, when it accepts a hot update, invokes a global
function called `__accept` inside the host app's RN runtime — the
same hook the existing useUpdateOnHMR uses for in-app components.
- We wrap that global so the previous subscriber still runs, debounce
a call to reloadWidgets(), which fires
WidgetCenter.shared.reloadAllTimelines() in the iOS extension.
- WidgetKit invokes each client-rendered Provider's getTimeline; with
devHotReloadEnabled=true (step 5a) the Provider re-fetches the
now-fresh bundle from Metro.
Caveats documented inline:
- Triggers on ANY HMR event, not just widget JSX. The extension
re-evaluates its bundle whether the change was widget-related or
not — harmless for the PoC, a future Voltra Metro middleware could
push widget-change events explicitly for finer-grained control.
- No-op outside iOS or __DEV__. Android counterpart lands in Phase 4.
…e 3b-iii step 6) Register the IosWeatherWidget client-rendered widget under voltra.ios.widgets in app.json and call enableClientWidgetHotReload() at example app startup. - app.json adds clientWidgetHotReload: true to opt into the dev hot reload behavior gated by the step 5a flag, plus a new widget entry with initialStatePath pointing at the existing JSX file. The plugin detects the 'use voltra' directive at prebuild and switches the generator to the Track 5 path (VoltraClientWidgetProvider + VoltraClientWidgetContentView). - _layout.tsx imports enableClientWidgetHotReload from @use-voltra/ios-client and invokes it once on iOS in __DEV__ so any Metro HMR event triggers WidgetCenter.reloadAllTimelines(). After expo prebuild + xcodebuild the existing hand-authored VoltraClientWidget_test (Phase 3b-i smoke-test scaffolding in gitignored example/ios/) is no longer wired into the bundle — the plugin-generated VoltraWidget_IosWeatherWidget takes its place. The hand-authored .swift file remains as dead code until cleanup in step 8.
…approaches This commit captures the end state of a multi-attempt investigation into how to make client-rendered widgets refresh automatically on JSX save. Both approaches we tried turned out to be unworkable for fundamentally different reasons (documented in DOCS/VOLTRA_CLIENT_RENDERED_WIDGETS.md "Hot reload exploration log"). Silent push via `xcrun simctl push` lands in a follow-up. Additions / fixes that survived: - Track5DemoWidget: a plain black demo widget showing captured env values (family, scheme, mode, locale, dev, render time) plus an editable `hotReloadMarker` literal, separate from IosWeatherWidget so hot reload testing isn't visually confused with the real-UI parity demo. - example/app.json: registers Track5DemoWidget alongside IosWeatherWidget. - example/app/_layout.tsx: side-effect import of Track5DemoWidget. The maintainer's Metro widget registry only sees `'use voltra'` files that are reachable from the main bundle's dep graph; without an import path, Metro returns 404 for /voltra/widgets/<id>.bundle. IosWeatherWidget is already reachable transitively via WeatherTestingScreen.tsx; Track5DemoWidget needs the explicit side-effect import. - VoltraJSRenderer.extractEntryModuleId: parses the actual entry module id from each bundle's trailing `__r(<n>);` invocation instead of hardcoding 0. Metro shares its module-id registry across bundles served from the same process, so the second widget's entry got id 74 (or similar), not 0. The old hardcoded `__r(0)` invoked some unrelated Metro polyfill, producing "did not expose render()" failures on every widget after the first. Removals (proven non-functional): - packages/ios-client/src/widgets/enableClientWidgetHotReload.ts: a JS-side helper that opened a WebSocket to Metro's /hot endpoint and called reloadWidgets() on update-start events. Native logs proved it received Metro's initial synthetic update on connect, then never again — likely because the register-entrypoints payload (scriptURL with query params) didn't match the identifier Metro uses to track HMR subscriptions. Even with the protocol fixed, the approach is fundamentally limited: RN's JS runtime suspends ~5s after the host app backgrounds, so any JS-side listener is offline exactly when hot reload matters (widget on home screen, app not in foreground). - VoltraClientWidgetProvider.refreshIntervalSeconds + `.after(1s)` timeline policy: an attempt at native-level polling that would have sidestepped the RN-suspend-in-background problem. iOS rate-limits timeline-policy-driven refresh aggressively; `.after(1s)` collapses to ~5-minute intervals even in the simulator (per Apple's docs, the policy is a hint, not a guarantee). Provider now always returns `.never` and relies on external WidgetCenter.reloadAllTimelines() calls. - "Reload all widgets" button in IosClientRenderedSmokeScreen: smoke-test scaffolding for manually triggering reloads while we figured out automatic ones. No longer needed. What stays in for the silent-push follow-up: - clientWidgetHotReload plugin flag (controls registration of push handler and ATS Info.plist entry) - VoltraClientWidgetProvider.devHotReloadEnabled (controls Metro fetch vs prerendered placeholder fallback) - ATS NSAllowsLocalNetworking + localhost exception in widget extension plist - The side-effect import pattern for Metro registry discoverability
…e 3b-iii) Add VoltraDevReloadHandler — an internal ExpoAppDelegateSubscriber that catches silent pushes carrying the `voltra-dev-reload` discriminator key and calls WidgetCenter.shared.reloadAllTimelines(). Forms the iOS side of the silent-push hot-reload mechanism. Metro-middleware push trigger and main-app UIBackgroundModes plist entry land in follow-up commits. The class is intentionally `internal` (not `public`) to avoid a Voltra pod bridging-header generation error: exposing the class via Voltra-Swift.h emits an ObjC `@interface VoltraDevReloadHandler : EXBaseAppDelegateSubscriber <EXAppDelegateSubscriberProtocol>` that fails to resolve because ExpoModulesCore's Swift→ObjC bridge symbols aren't visible through `@import ExpoModulesCore` from the Voltra pod's build context. Registration is done manually at VoltraModule.init via ExpoAppDelegateSubscriberRepository.registerSubscriber, rather than via expo-modules-autolinking + expo-module.config.json. Pushes without the `voltra-dev-reload` key pass through unchanged (.noData), so real notifications still reach expo-notifications and other registered subscribers.
…-iii) When `clientWidgetHotReload: true`, the iOS config plugin now adds `remote-notification` to the main app's UIBackgroundModes. Required so iOS will deliver silent pushes to the host app while it's backgrounded or suspended — the channel that VoltraDevReloadHandler (committed in c7edd32) listens on for widget reload triggers. Appends rather than replaces, so existing modes set by other plugins (e.g. expo-task-manager's `fetch`) are preserved. Verified by running `expo prebuild` in the example: Voltra/Info.plist now contains both `fetch` and `remote-notification` under UIBackgroundModes.
…(Phase 3b-iii)
Wires the example's Metro middleware to fire `xcrun simctl push` on every
client-rendered widget JSX save, so iOS Simulator's silent-push delivery
wakes the host app and triggers WidgetCenter.shared.reloadAllTimelines().
New: example/metro/voltraDevPush.js
- createDevPusher({ bundleId, debounceMs? }) returns { fire(), dispose() }
- Debounces ~100ms so a save that touches multiple modules produces
one push, not N
- Writes a temp .apns payload with a `voltra-dev-reload` discriminator
key alongside `aps.content-available: 1`; deletes it after exec
- Warns once on first failure (xcrun missing, no booted simulator,
push rejected), then silently no-ops for the rest of the process
- Logs each fire/success so the dev loop is observable in Metro stdout
Modified: example/metro/widgetRegistry.js
- createWidgetRegistry now accepts `onWidgetSourceChanged` callback
- registerWidgets attaches `fs.watch` to each tracked widget JSX file's
absolute path; removeSourcePath closes it
- Why fs.watch and not Metro's serializer hook: Fast Refresh patches
modules in-place without re-serializing the bundle, so the hook
never fires on saves. fs.watch fires on every save independent of
Metro's bundle pipeline
Modified: example/metro/createMetroConfig.js
- readVoltraDevConfig() reads `expo.ios.bundleIdentifier` + the
`@use-voltra/ios-client` plugin's `clientWidgetHotReload` flag from
app.json
- When the flag is on AND a bundle id is present, instantiates the
pusher and threads it as onWidgetSourceChanged into the registry
Modified: packages/ios-client/ios/app/VoltraDevReloadHandler.swift
- @objc on application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
because the protocol declares it `@objc optional` — without explicit
@objc, Swift's implicit conformance generation can skip exposing the
method to ObjC dispatch (ExpoAppDelegateSubscriberManager uses
responds(to: selector) to filter subscribers; misses ours otherwise)
- VoltraLogger.widget.info diagnostics in registerIfNeeded + the
didReceiveRemoteNotification handler so the dev loop is observable
via `xcrun simctl spawn booted log show ... 'subsystem == "com.voltra"'`
Status: mechanism is wired end-to-end. iOS Simulator silent-push delivery
is unreliable (confirmed visible pushes work, silent pushes drop). Real
device dev with APNs would deliver as designed. The next commit moves
the handler registration out of VoltraModule.init (lazy TurboModule) and
into an Objective-C +load hook so it runs at framework load time
regardless of whether JS touches the TurboModule.
…ase 3b-iii)
Move VoltraDevReloadHandler registration out of VoltraModule.init (lazy
TurboModule) and into an Objective-C `+load` hook in NativeVoltra.mm so
it runs at framework load time, before main(), regardless of whether
any JS code path touches the Voltra TurboModule.
Root cause this fixes:
- VoltraModule is a lazy TurboModule — only instantiated when JS
first accesses it
- Previous cleanup commit (c91bfc8) removed the enableClientWidgetHotReload
import from _layout.tsx, which was the only thing transitively
triggering VoltraModule instantiation at app startup
- As a result, registerIfNeeded() was never called → the silent-push
handler was never registered → simctl push had no recipient
- Symptom: zero `com.voltra` os_log output, no widget refresh on save
Implementation:
- @objc(VoltraDevReloadHandler) gives the class a stable ObjC name
that NSClassFromString can resolve (without the name, Swift mangles
the runtime class name with module prefix)
- NativeVoltra.mm's +load dispatches to main queue (BaseExpoAppDelegateSubscriber
init is @MainActor-isolated, same constraint that crashed our
earlier in-VoltraModule.init attempt) and uses dynamic ObjC
dispatch (NSClassFromString + objc_msgSend) to call
ExpoAppDelegateSubscriberRepository.registerSubscriber
- Dynamic dispatch avoids importing ExpoModulesCore ObjC headers
into this translation unit, which would re-trigger the
Voltra-Swift.h bridging visibility issue with EXBaseAppDelegateSubscriber
- registerIfNeeded() helper deleted as no longer needed
IosWeatherWidget was registered as a second client-rendered home-screen
widget alongside Track5DemoWidget. Track5DemoWidget is the better-designed
example for actually verifying the runtime (env values surfaced explicitly,
hot-reload marker line), so two demo widgets just added review surface
without buying anything.
Cascading changes:
- example/app.json: drop the IosWeatherWidget entry from voltra.ios.widgets.
Track5DemoWidget is now the only client-rendered widget in the example.
- example/screens/ios/IosClientRenderedSmokeScreen.tsx: smoke-test screen
now targets Track5DemoWidget instead of IosWeatherWidget. Props payload
dropped to {} since Track5DemoWidget ignores props (the hot-reload marker
is hardcoded in the JSX, env values come from the runtime). The smoke
test still exercises Metro fetch → JSC eval → render round-trip.
- example/widgets/ios/IosWeatherWidget.tsx: reverted to a pure React
component. Dropped the `'use voltra'` directive, dropped the env arg
+ env-suffix-on-description marker that was added in Phase 3b-ii.
WeatherTestingScreen.tsx imports it as a normal component and never
passed env anyway, so this is a no-op for that screen.
Net effect: Track5DemoWidget is the single canonical client-rendered widget
in the codebase. IosWeatherWidget is back to being a server-rendered preview
component, no dual-purpose, no implicit directive scanning relying on it.
…ty-track-5 # Conflicts: # packages/ios-client/ios/app/NativeVoltra.mm
…ents Remove "Track 5", "Phase 3a/3b/5", "PoC", and grilling-question references from comments, doc strings, log messages, and one user-visible label so the code reads agnostic of the spike's internal context.
Drop the spike codename from the demo widget identifier. Affects the file name, exported function, widget id in app.json, and the on-widget UI label.
…dgets The dev workflow can rely on WidgetKit's natural lifecycle to re-invoke the Provider, which re-fetches the latest bundle from Metro on every call. The silent-push mechanism was over-engineering for that workflow and proved unreliable on the iOS Simulator. A subsequent commit will add the proper __accept-based hook for the "app foregrounded" case. Removes: - VoltraDevReloadHandler.swift + the +load hook in NativeVoltra.mm - example/metro/voltraDevPush.js + the fs.watch wiring it depended on - clientWidgetHotReload flag from the plugin chain (always-fetch in DEBUG) - remote-notification UIBackgroundModes injection - devHotReloadEnabled flag from VoltraClientWidgetProvider - ExpoModulesCore podspec dependency NSAppTransportSecurity localhost exception is now added unconditionally when any client-rendered widget exists (still needed for the Metro fetch in DEBUG).
Adds enableWidgetHotReload() — a DEV-only helper that hooks Metro's global __accept callback and calls reloadWidgets() on every Fast Refresh patch, so widgets refresh while the host app is foregrounded without a manual reload. Mirrors the existing useUpdateOnHMR pattern used for in-app preview components. No-op in release builds. Wires the call in example/app/_layout.tsx so the demo app benefits.
…ty-track-5 # Conflicts: # packages/ios-client/ios/app/NativeVoltra.mm
Removes the in-app smoke test screen that exercised voltraWidgetEvalBundle + voltraWidgetRender directly. That code path is now exercised end-to-end via the widget extension's Provider in real WidgetKit context, so the debug surface has no remaining value. - example/screens/ios/IosClientRenderedSmokeScreen.tsx (deleted) - example/app/testing-grounds/client-rendered-smoke.tsx (deleted) - packages/ios-client/src/widgets/client-rendered-smoke.ts (deleted) - TurboModule spec methods + Swift/ObjC implementations removed - testing-grounds entry removed
After the upstream pnpm migration, two pieces of the client-rendered widget machinery broke under pnpm's strict node_modules layout: 1. `packages/ios-client/expo-plugin/src/ios-widget/clientRendered.ts` imports `@babel/parser` and `@babel/types` — transitives of `@babel/core`. pnpm doesn't expose transitive deps to a package's own files, so add both as direct dependencies of `@use-voltra/ios-client`. 2. The widget metro config in `example/metro/createWidgetMetroConfig.js` produces bundle entries under `.voltra/metro/entries/` — outside any pnpm-managed `node_modules`. The Babel-emitted helper imports (`interopRequireWildcard`, etc.) and Metro's async-require shim can't be resolved through the default resolver chain. Resolve `@babel/runtime` and `metro-runtime` explicitly via `require.resolve` from the repo root and add them to `resolver.extraNodeModules`. Also merge appConfig's `extraNodeModules` + `nodeModulesPaths` so the widget config inherits expo's pnpm-aware resolution.
V3RON
reviewed
Jun 10, 2026
V3RON
reviewed
Jun 10, 2026
7 tasks
…r (app-group relay) Addresses PR review: the client-widget runtime hardcoded http://localhost:8081, which breaks on a custom Metro port, a LAN dev server, or a physical device. The widget extension is React-free (can't call RCTBundleURLProvider itself), so the app resolves the dev-server base URL via RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot:) (DEBUG) and relays it to the extension through the app group; the extension reads it for both the bundle fetch and env.build.metroUrl, falling back to localhost. iOS analog of Android's AndroidInfoHelpers.getServerHost. - VoltraConstants: app-group key Voltra_DevServerURL. - VoltraWidgetDefaults: setDevServerURL/devServerURL (app group). - VoltraModuleImpl: import React; syncDevServerURL() resolves + relays, called on init and reloadWidgets. - VoltraClientWidgetRuntime: loadFromMetro + env metroUrl read the relayed URL. Verified: iOS build (app + widget extension) succeeds, 0 errors.
Addresses PR review: widget discovery was tied to Metro's dependency graph, so a 'use voltra'
file had to be import-reachable from the main bundle (side-effect imports in _layout) or it was
missing from the widget map ('not present in dependency graph').
The registry now scans the project filesystem for 'use voltra' files at startup and watches for
add/change/unlink via chokidar — discovery is independent of imports. Removes the side-effect
imports, and the registry is ready immediately (no main-bundle build needed first, fixing a
readiness race).
- widgetRegistry.js: synchronous project scan (cheap substring check before Babel) + chokidar
watcher; path-aware ignores (node_modules/dotdirs anywhere; ios/android/Pods/build only at the
project root, so widgets/ios and widgets/android are scanned); best-effort chokidar resolution;
close() to stop the watcher.
- createMetroConfig.js: drop the experimentalSerializerHook graph coupling.
- _layout.tsx: remove now-unnecessary side-effect widget imports.
Verified on the dev server: registry ready at startup, widget bundle serves 200 with no
side-effect import, watcher picks up live add/unlink.
Generate an AppIntentConfiguration for client-rendered widgets that declare appIntent.parameters, so users edit parameters through the native Edit Widget sheet. Defaults are declared in code (parameters[].default) and the configured values flow into the widget's env.configuration on each render. - types: AppIntentParameter / IOSWidgetAppIntentConfig / IOSWidgetConfig.appIntent - plugin: emit WidgetConfigurationIntent + AppIntentConfiguration + provider, gated behind iOS 17, with import AppIntents - runtime: VoltraClientWidgetEntry.configuration, loadEntry(configuration:), env builder emits configuration into the render env - example: ClientRenderedDemoWidget reads env.configuration.label
…ev barrel Editing a widget's JSX stopped refreshing the home-screen widget after discovery was moved to a filesystem scan: widgets were no longer in the host app's Metro graph, so Metro pushed no Fast Refresh patch to the app (`__accept` never fired) and the separate widget Metro server never invalidated its warm graph (served stale bundles). The widget registry now generates a per-platform dev barrel that side-effect-imports every discovered 'use voltra' widget; the host app imports it in __DEV__. This puts widgets back in the app graph, so Fast Refresh fires reloadWidgets() and the shared metro-file-map keeps the widget Metro server fresh — without the per-widget manual import the filesystem-scan rewrite removed. Verified end-to-end on the simulator.
…widgets Adds bundleWidgets.js: builds each discovered 'use voltra' widget into a self-contained, minified JS bundle (voltra-widget-<id>.bundle) using the same widget Metro config as the dev server, so the baked bundle's module format matches what the native JSC runtime expects. The release widget extension reads these from its own bundle when there is no Metro dev server. Resolution under pnpm's strict layout: a standalone `node` invocation (the upcoming Xcode build phase) can't resolve metro/@babel transitives the way the Expo/Metro CLI can. Adds resolveProjectModule.js — plain require first (dev), dependency-graph walk from expo/metro-config as fallback (standalone) — and routes scanVoltraDirectives + the widget config's metro-config/@babel-runtime resolution through it. Dev path verified unchanged.
Adds a release-only 'Bundle Voltra client widgets' shell-script phase to the widget extension target. In Debug it no-ops (widgets fetch from Metro and hot-reload); in Release it runs the project's metro/bundleWidgets.js, writing each voltra-widget-<id>.bundle into the extension's resources ($TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH) where the runtime's release loader reads it from Bundle.main. The phase is added only when at least one widget is client-rendered, and idempotently (added once across prebuilds). Mirrors how Expo's main 'Bundle React Native code and images' phase resolves NODE_BINARY and the project root.
Three fixes surfaced while verifying the production baked-bundle path: - Render across process boundaries: WidgetKit archives timeline entries and can re-render the View in a fresh extension process where the provider's bundle evaluation never ran, leaving the process-static JSContext empty -> render() returned nil -> blank widget. The entry now carries the bundle source, and the View calls VoltraJSRenderer.ensureEvaluated() (no-op when the process already has it) so render() always finds the widget's function. Latent since the Track 5 render architecture; exposed by the release path. - Release JS bundling: example/metro/createMetroConfig.js required 'connect'/'metro' at top level, which only resolve inside the Expo/Metro dev CLI. 'expo export:embed' (release app bundling) loads the config standalone and failed under pnpm. Route both through the shared pnpm-robust resolver. - Silence the 'runs every build' warning on the release widget-bundling phase by marking it alwaysOutOfDate (it intentionally re-bakes each build; it can't enumerate widget-source inputs). Note: bake pipeline verified at build/artifact level (bundle baked into the installed app). Reliable on-device render verification is pending a real device — the simulator does not invoke the AppIntentConfiguration timeline in release builds (WidgetKit stays on the redacted placeholder), independent of the baked bundle.
- Build-time warning: the config plugin logs a one-time EXPERIMENTAL notice during prebuild when it detects a 'use voltra' client-rendered widget (API/build output may change; verify release rendering on a real device). - README: experimental Features bullet + a warning section describing on-device rendering, dev hot reload vs release baking, and the real-device caveat. - Changeset: minor for @use-voltra/ios-client framing the feature as experimental.
…ase bundle phase - swift.node.test.ts: AppIntent configuration codegen — a configurable client widget emits WidgetConfigurationIntent + AppIntentConfiguration + a code-default @parameter, iOS 17 gating, import AppIntents, and threads the configured param into loadEntry; a client widget without appIntent emits none of it. - clientRendered.node.test.ts: the one-time EXPERIMENTAL warning fires for client widgets and not for server-only ones (+ silence the warning in other tests). - buildPhases.node.test.ts (new): ensureWidgetBundleScriptPhase adds the release-only bundling phase (skips Debug, runs bundleWidgets.js, bakes to the extension resources, alwaysOutOfDate), is idempotent, and no-ops when the target is absent. Example Metro scaffolding (bundler/registry/resolver) is deferred — it has no jest harness.
Extract directive scanning into @use-voltra/compiler and wire Metro plus iOS prebuild validation to the shared scanner.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #165
Warning
Experimental. Client-rendered widgets are usable in production at your own
risk — the public API and generated build output may change between releases.
Summary
Adds client-rendered widgets on iOS: widgets written as plain
(props, env) => JSXfunctions, evaluated on-device in the widget extension viaJavaScriptCore on every render. The
envargument carries live device state(
widgetFamily,colorScheme,widgetRenderingMode,locale,showsWidgetContainerBackground) captured per render from SwiftUI's@Environment, so a widget reacts to the home-screen environment (family resize,dark-mode flip, tinted-stack mode) without a native rebuild — the same model as
expo-widgets.Widgets can also be user-configurable via a native AppIntent "Edit Widget"
sheet (iOS 17+): parameters declared in
app.json(with code-defined defaults)are surfaced by WidgetKit's configuration UI and delivered to the widget as
env.configurationon each render.Sits alongside the existing server-rendered path (opt-in, per-widget, no
migration). The on-device UI goes through Voltra's existing JSON wire format +
Swift renderer, so client-rendered widgets are visually identical to
server-rendered ones.
iOS only in this PR; the Android port (reusing Track 4's standalone-Hermes JNI)
is a follow-up.
The two goals (both met)
expo-widgets—WidgetEnvironmentflows into theon-device
render(props, env)automatically, captured from@Environmentinthe widget extension. Includes user
configurationfrom a native AppIntentEdit Widget sheet (see below).
widget in dev, no rebuild.
How it works
Opt-in. A widget component carrying the
'use voltra'directive isauto-detected at prebuild and registered as client-rendered. The
app.jsonwidget
idmust equal the JSX component name (the plugin fails loud onmismatch — the id is both the bundle URL suffix and the WidgetKit
kind).Configuration via AppIntent (iOS 17+). Declaring
appIntent: { parameters: [{ name, title, default }] }on a widget makes theplugin generate a
WidgetConfigurationIntent+AppIntentConfiguration, so usersedit parameters through the native Edit Widget sheet. Defaults come from code
(
parameters[].default); the configured values arrive asenv.configurationoneach render.
Dev. Widgets are discovered by a filesystem scan + watcher (no manual
side-effect imports). The widget's JS bundle is served by Metro; the extension
fetches and evaluates it per render.
enableWidgetHotReload()(called once athost-app startup, DEV-only) hooks Metro's
__acceptso a JSX edit triggersWidgetCenter.reloadAllTimelines()→ the provider re-fetches the fresh bundle.A generated per-platform dev barrel keeps widget modules in the host app's
Metro graph so Fast Refresh actually fires for widget-only edits and the widget
Metro server stays fresh.
Production (release). A release-only Xcode build phase runs the project's
widget bundler (
example/metro/bundleWidgets.js), baking eachvoltra-widget-<id>.bundle(plain JS for v1) into the widget extension; thenative release loader reads it from
Bundle.main. Debug uses Metro; the phaseno-ops there.
iOS runtime (
packages/ios-client/ios/)VoltraJSRenderer.swift— one sharedJSContextper extension process;captures each bundle's exports under
globalThis.__voltraWidgets[<id>].ensureEvaluated()lets the View re-evaluate from the entry's carried bundlesource, so rendering survives WidgetKit re-rendering an archived entry in a
fresh process.
VoltraClientWidgetRuntime.swift— dual-path bundle loader (Metro in DEBUG,baked asset in release), env capture in the ContentView (
@Environmentreadsare only valid in a View body, hence the Provider/View split), and the
generated
AppIntentTimelineProviderplumbing for configured widgets.Plugin (
packages/ios-client/expo-plugin/src/)'use voltra'detection (Babel scan), generated Swift (AppIntentConfigurationgated
iOS 17+), prerendered initial state, and the release widget-bundlingbuild phase. Emits a one-time EXPERIMENTAL warning at prebuild.
Verification
env.configuration),and hot reload all verified end-to-end.
voltra-widget-<id>.bundleis baked into the installed app, and a full clean Release build is green
(including app JS bundling).
Known limitations
release build does not invoke the
AppIntentConfigurationtimeline (WidgetKitstays on the redacted placeholder) — independent of the baked bundle, and
consistent with the simulator's general unreliability for widget rendering. The
bake itself is verified; the on-device render should be confirmed on a physical
device.
example/metro/) for now;consumers copy it. Productizing it into the package is a follow-up.
.hbcprecompilation is deferred — v1 bakes plain JS.Changeset
@use-voltra/ios-clientminor — experimental client-rendered widgets.