feat: client-rendered widgets on iOS (Track 5)#190
Draft
burczu wants to merge 29 commits into
Draft
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
|
|
||
| #if DEBUG | ||
| let isDev = true | ||
| let metroUrl: String? = "http://localhost:8081" |
Contributor
There was a problem hiding this comment.
This is a snippet coming straight from react-native itself:
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
V3RON
reviewed
Jun 10, 2026
Comment on lines
+195
to
+205
| applyMetroDelta(delta) { | ||
| if (delta.deleted) { | ||
| for (const deletedPath of delta.deleted) { | ||
| removeSourcePath(deletedPath) | ||
| } | ||
| } | ||
|
|
||
| scanModuleMap(delta.added) | ||
| scanModuleMap(delta.modified) | ||
| ready = true | ||
| }, |
Contributor
There was a problem hiding this comment.
Alternative solution:
a) traverse the project synchronously, looking for 'use voltra' directive, create a widget map - widgetId <-> path
b) create a file watcher, follow the steps of a) for created and modified files, in case of deleted files - simply delete the entry
This should fix the: 'not present in dependency graph, not present in widget map'
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
Summary
Adds support for client-rendered widgets on iOS: widgets defined as plain
(props, env) => JSXfunctions, evaluated at widget-extension render time viaJSC. The
envsecond argument carries runtime device state (widgetFamily,colorScheme,widgetRenderingMode,locale, ...) captured per render fromSwiftUI's
@Environment— so widgets can react to the home-screen environment(family resize, dark-mode flip, tinted-stack rendering mode, etc.) without a
native rebuild, the same way
expo-widgetsconsumers do.Sits alongside the existing server-rendered widget path (opt-in, per-widget,
no migration of existing widgets). Uses Voltra's existing JSON wire format
and Swift renderer so the on-device UI is bit-for-bit identical to
server-rendered widgets.
Implementation is iOS-only in this PR. Android port (reusing Track 4's
standalone Hermes JNI infrastructure) is a follow-up.
What this PR adds
End-user surface
'use voltra'directive (function-levelprologue) is auto-detected by the plugin and registered as a client-rendered
widget. The widget ID in
app.jsonmust equal the JSX function name; theplugin fails loud on mismatch.
env: WidgetEnvironmentsecond argumentfrom
@use-voltra/ios(packages/core/src/widget-environment.ts). Runtimefields:
date,widgetFamily,colorScheme,locale,widgetRenderingMode,showsWidgetContainerBackground. Build-time fields:build.isDev,build.metroUrl,build.appVersion,build.voltraVersion.All values come from the widget extension at render time — no manual props
threading from the host app required.
enableWidgetHotReload()exported from@use-voltra/ios-client, called once at host-app startup in DEV. HooksMetro's
__acceptcallback so widgets refresh automatically on every FastRefresh patch while the host app's JS runtime is alive. Eliminates the
routine save-and-tap step during active iteration. No-op in release builds.
Mirrors the existing
useUpdateOnHMRpattern used for Live Activitypreview components.
ClientRenderedDemoWidget) showing envcapture + hot-reload testing surface — plain black tile, white text, every
env.*value rendered explicitly so the reviewer can verify each fieldreaches the JSX at runtime.
iOS runtime (
packages/ios-client/ios/)Native code that runs inside the widget-extension process — fetches the
widget's JS bundle from Metro, evaluates it in a shared JSContext, captures
runtime env per render, and feeds the resulting JSON tree through the
existing
VoltraHomeWidgetViewso client-rendered widgets look identical toserver-rendered ones.
VoltraJSRenderer.swift— single sharedJSContextfor the widget-extensionprocess. Each widget bundle's exports are captured under
globalThis.__voltraWidgets[<id>]via a small bootstrap appended to the Metrobundle source; subsequent widget bundle evals don't collide because the bootstrap
references the per-widget entry module id extracted from the bundle's trailing
__r(<n>);call.VoltraClientWidgetRuntime.swift(Provider) — fetches the JS bundle fromMetro in DEBUG (release path attempts a baked-asset load; the release-side
bundle writer is a future addition) and evaluates it once per WidgetKit
invocation. Timeline policy is
.never— refreshes are driven by explicitWidgetCenter.reloadAllTimelines()calls (fromenableWidgetHotReload) orby WidgetKit's natural lifecycle invocations.
VoltraClientWidgetRuntime.swift(ContentView) — this is where envcapture happens. Captures
@Environment(\.widgetFamily),\.colorScheme,\.widgetRenderingMode,\.showsWidgetContainerBackground,\.localeperbody invocation, marshals them into JSON via
VoltraClientWidgetEnvBuilder(shape matches the
WidgetEnvironmentTS type), and threads them into theJS
render(propsJSON, envJSON)call. The Provider/View split isload-bearing here: SwiftUI
@Environmentreads are only valid inside aView body, not from a
TimelineProvider, so Provider can't capture envitself.
{t, c, p}JSON output of the JS render call isrendered through the existing
VoltraHomeWidgetViewso client-renderedwidgets are visually indistinguishable from server-rendered ones at the
UI layer.
Plugin (
packages/ios-client/expo-plugin/src/)At
expo prebuild, the plugin detects which widgets are client-rendered andemits everything they need to run on iOS — generated Swift, prerendered
placeholders, and the Info.plist entries the Metro fetch needs.
ios-widget/clientRendered.ts— at prebuild, reads each widget'sinitialStatePathfile source; if a'use voltra'directive is present inan exported function whose name matches the widget id, the widget is marked
clientRendered: true. Throws on id/component-name mismatch.ios-widget/clientRenderedPrerender.ts— calls the JSX function with emptyprops + minimal env at prebuild time, runs
renderVoltraVariantToJson,stringifies. Emitted alongside server-rendered prerender output into the
existing
VoltraWidgetInitialStates.swiftso the placeholder is real UIfrom the moment the widget is added to the home screen.
ios-widget/files/swift.ts— per-widget Swift generator dispatches on thedetected mode: server-rendered emits
VoltraHomeWidgetProvider(unchanged),client-rendered emits
VoltraClientWidgetProvider+VoltraClientWidgetContentView.ios-widget/widgetPlist.ts— addsNSAppTransportSecuritylocalhostexception to the widget extension's plist whenever at least one client-rendered
widget exists, so the widget extension's Metro fetch over plaintext HTTP
succeeds in DEBUG.
Detection / scanner
How the plugin decides whether a widget is server- or client-rendered — kept
implicit and file-content-driven so consumers don't have to learn a new
app.jsonschema.initialStatePathfile inspection (Babel parse for'use voltra'directive) — no newapp.jsonschema. The widget entry isthe same shape as server-rendered widgets (id, displayName, description,
supportedFamilies, initialStatePath); the file contents determine the
rendering mode. Footgun: removing the directive silently flips the widget
to server-rendered mode. Acceptable for PoC; a future explicit
renderMode: 'server' | 'client'field would harden this.Test coverage (jest, expo-plugin)
default export, missing file, missing directive, id mismatch), Swift
generator dispatch (server-only, client-only, mixed bundles), existing
generateInitialStatesSwiftbehavior.Dev hot reload
The challenge: when a widget JSX file changes, the home-screen widget should
reflect the change without a full rebuild.
WidgetKit re-invokes the Provider on natural lifecycle events (e.g. the host
app coming to foreground). The Provider fetches the latest Metro bundle on
every invocation, so a foreground tap is enough to refresh the widget. To
improve on that, four approaches were explored:
Path A — Host-app JS WebSocket subscriber to Metro's
/hotendpointThe first attempt — mirror RN's Fast Refresh model: subscribe from the host
app's RN runtime to Metro's
/hotchannel, callreloadWidgets()on push.Metro's
/hotis an internal endpoint designed for RN's own HMR client; naivethird-party subscriptions didn't receive updates. Even with a working
subscription, iOS suspends the JS thread when the host app is backgrounded —
which is exactly when widget hot reload is needed.
Path B — Provider polling via
.after(1s)timeline policySkip the host app entirely; have the widget extension's Provider self-refresh
on a short timer. Worked for ~30 seconds, then iOS rate-limited
timeline-policy refreshes (Apple docs: "a hint to WidgetKit, which may invoke
the provider sooner or later based on system conditions"). Reverted; Provider
returns
.nevernow.Path C — Silent push from Metro middleware via
xcrun simctl pushArchitecturally clean — iOS delivers the wake signal regardless of host app
state. iOS Simulator's silent-push delivery turned out to be too unreliable in
practice; visible pushes work, silent pushes intermittently don't. Removed.
enableWidgetHotReload()— hook into Metro's__acceptSettled approach. Hooks Metro's global
__acceptcallback — the same channelRN's Fast Refresh uses to deliver patches in the host app — and calls
reloadWidgets()on each fire. TriggersWidgetCenter.shared.reloadAllTimelines()→ Provider re-runs → fresh bundlefetched → widget renders new state. Mirrors the existing
useUpdateOnHMRpattern (the only other HMR-aware code in Voltra, used for Live Activity
preview components).
Observed in the iOS Simulator under active iteration (saves close together,
host app recently foregrounded): edit JSX → save → widget updates within
~1–2 seconds, including when the simulator is showing the home screen. After
prolonged background idle iOS suspends the host app's JS thread; once that
happens the hook stops firing until the host app is foregrounded again, at
which point WidgetKit's natural lifecycle refresh handles the next render.
Real-device behavior is untested and may enforce stricter suspension.
Known limitations
enableWidgetHotReload()real-device behavior is untested. Verified onlyon the iOS Simulator. Real iOS may enforce stricter JS thread suspension when
the host app is backgrounded — in which case the hook would only fire while
the app is foregrounded, reducing the DX to "foreground tap required" after
periods of inactivity. Worth testing on a real device before promising the
simulator-grade DX to consumers.
Discoverability footgun. Metro's widget registry only sees
'use voltra'files reachable from the main bundle's dep graph. Widget files not imported
anywhere return 404 from
/voltra/widgets/<id>.bundle. Currently workedaround with a side-effect import in
example/app/_layout.tsx; a cleanerlong-term fix is plugin-generated registration that doesn't require the user
to write the import manually.
Test plan
iOS Simulator (iPhone 17 Pro, iOS 26):
expo prebuildsucceeds; generatedVoltraWidgetBundle.swiftregistersboth server and client widgets
expo run:iosbuilds + installs cleanlymatching device state (
family,scheme,mode,locale,dev,time) — demonstrates end-to-end pipeline + env capture per-renderClientRenderedDemoWidget.tsx(changehotReloadMarker) → save →widget reflects edit within ~1–2 seconds (verifies
enableWidgetHotReload()/__accept). After prolonged backgroundidle, expect to need a foreground tap.
Plugin / unit tests:
pnpm --filter @use-voltra/ios-client test— 13/13 greenpnpm --filter @use-voltra/ios-client typecheckcleanpnpm --filter @use-voltra/ios-client lintclean