Skip to content

feat: client-rendered widgets on iOS (Track 5)#190

Draft
burczu wants to merge 29 commits into
callstackincubator:mainfrom
burczu:poc/widget-reactivity-track-5
Draft

feat: client-rendered widgets on iOS (Track 5)#190
burczu wants to merge 29 commits into
callstackincubator:mainfrom
burczu:poc/widget-reactivity-track-5

Conversation

@burczu

@burczu burczu commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Closes #165

Summary

Adds support for client-rendered widgets on iOS: widgets defined as plain
(props, env) => JSX functions, evaluated at widget-extension render time via
JSC. The env second argument carries runtime device state (widgetFamily,
colorScheme, widgetRenderingMode, locale, ...) captured per render from
SwiftUI'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-widgets consumers 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

  • A widget JSX file marked with the 'use voltra' directive (function-level
    prologue) is auto-detected by the plugin and registered as a client-rendered
    widget. The widget ID in app.json must equal the JSX function name; the
    plugin fails loud on mismatch.
  • The widget function receives a typed env: WidgetEnvironment second argument
    from @use-voltra/ios (packages/core/src/widget-environment.ts). Runtime
    fields: 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.
  • An optional helper enableWidgetHotReload() exported from
    @use-voltra/ios-client, called once at host-app startup in DEV. Hooks
    Metro's __accept callback so widgets refresh automatically on every Fast
    Refresh 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 useUpdateOnHMR pattern used for Live Activity
    preview components.
  • A demo widget in the example app (ClientRenderedDemoWidget) showing env
    capture + hot-reload testing surface — plain black tile, white text, every
    env.* value rendered explicitly so the reviewer can verify each field
    reaches 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 VoltraHomeWidgetView so client-rendered widgets look identical to
server-rendered ones.

  • VoltraJSRenderer.swift — single shared JSContext for the widget-extension
    process. Each widget bundle's exports are captured under
    globalThis.__voltraWidgets[<id>] via a small bootstrap appended to the Metro
    bundle 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 from
    Metro 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 explicit
    WidgetCenter.reloadAllTimelines() calls (from enableWidgetHotReload) or
    by WidgetKit's natural lifecycle invocations.
  • VoltraClientWidgetRuntime.swift (ContentView) — this is where env
    capture happens.
    Captures @Environment(\.widgetFamily), \.colorScheme,
    \.widgetRenderingMode, \.showsWidgetContainerBackground, \.locale per
    body invocation, marshals them into JSON via VoltraClientWidgetEnvBuilder
    (shape matches the WidgetEnvironment TS type), and threads them into the
    JS render(propsJSON, envJSON) call. The Provider/View split is
    load-bearing here: SwiftUI @Environment reads are only valid inside a
    View body, not from a TimelineProvider, so Provider can't capture env
    itself.
  • The parsed compact {t, c, p} JSON output of the JS render call is
    rendered through the existing VoltraHomeWidgetView so client-rendered
    widgets 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 and
emits 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's
    initialStatePath file source; if a 'use voltra' directive is present in
    an 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 empty
    props + minimal env at prebuild time, runs renderVoltraVariantToJson,
    stringifies. Emitted alongside server-rendered prerender output into the
    existing VoltraWidgetInitialStates.swift so the placeholder is real UI
    from the moment the widget is added to the home screen.
  • ios-widget/files/swift.ts — per-widget Swift generator dispatches on the
    detected mode: server-rendered emits VoltraHomeWidgetProvider (unchanged),
    client-rendered emits VoltraClientWidgetProvider + VoltraClientWidgetContentView.
  • ios-widget/widgetPlist.ts — adds NSAppTransportSecurity localhost
    exception 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.json schema.

  • Implicit detection from initialStatePath file inspection (Babel parse for
    'use voltra' directive) — no new app.json schema. The widget entry is
    the 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)

  • 13 unit tests covering: detector edge cases (arrow fn, function declaration,
    default export, missing file, missing directive, id mismatch), Swift
    generator dispatch (server-only, client-only, mixed bundles), existing
    generateInitialStatesSwift behavior.

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 /hot endpoint

The first attempt — mirror RN's Fast Refresh model: subscribe from the host
app's RN runtime to Metro's /hot channel, call reloadWidgets() on push.
Metro's /hot is an internal endpoint designed for RN's own HMR client; naive
third-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 policy

Skip 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 .never now.

Path C — Silent push from Metro middleware via xcrun simctl push

Architecturally 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 __accept

Settled approach. Hooks Metro's global __accept callback — the same channel
RN's Fast Refresh uses to deliver patches in the host app — and calls
reloadWidgets() on each fire. Triggers
WidgetCenter.shared.reloadAllTimelines() → Provider re-runs → fresh bundle
fetched → widget renders new state. Mirrors the existing useUpdateOnHMR
pattern (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 only
on 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 worked
around with a side-effect import in example/app/_layout.tsx; a cleaner
long-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 prebuild succeeds; generated VoltraWidgetBundle.swift registers
    both server and client widgets
  • expo run:ios builds + installs cleanly
  • Add "Client-Rendered Demo" widget to home screen → shows env values
    matching device state (family, scheme, mode, locale, dev,
    time) — demonstrates end-to-end pipeline + env capture per-render
  • With the host app recently foregrounded, edit
    ClientRenderedDemoWidget.tsx (change hotReloadMarker) → save →
    widget reflects edit within ~1–2 seconds (verifies
    enableWidgetHotReload() / __accept). After prolonged background
    idle, expect to need a foreground tap.

Plugin / unit tests:

  • pnpm --filter @use-voltra/ios-client test — 13/13 green
  • pnpm --filter @use-voltra/ios-client typecheck clean
  • pnpm --filter @use-voltra/ios-client lint clean

V3RON and others added 29 commits June 2, 2026 14:09
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.
… (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.

#if DEBUG
let isDev = true
let metroUrl: String? = "http://localhost:8081"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}

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
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Widget reactivity: support on-device state changes without a server push

2 participants