Skip to content

feat: client-rendered widgets on iOS#190

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

feat: client-rendered widgets on iOS#190
burczu wants to merge 40 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

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) => JSX functions, evaluated on-device in the widget extension via
JavaScriptCore on every render. The env argument 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.configuration on 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)

  1. Env parity with expo-widgetsWidgetEnvironment flows into the
    on-device render(props, env) automatically, captured from @Environment in
    the widget extension. Includes user configuration from a native AppIntent
    Edit Widget sheet (see below).
  2. Dev hot reload — editing a widget's JSX refreshes the pinned home-screen
    widget in dev, no rebuild.

How it works

Opt-in. A widget component carrying the 'use voltra' directive is
auto-detected at prebuild and registered as client-rendered. The app.json
widget id must equal the JSX component name (the plugin fails loud on
mismatch — 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 the
plugin generate a WidgetConfigurationIntent + AppIntentConfiguration, so users
edit parameters through the native Edit Widget sheet. Defaults come from code
(parameters[].default); the configured values arrive as env.configuration on
each 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 at
host-app startup, DEV-only) hooks Metro's __accept so a JSX edit triggers
WidgetCenter.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 each
voltra-widget-<id>.bundle (plain JS for v1) into the widget extension; the
native release loader reads it from Bundle.main. Debug uses Metro; the phase
no-ops there.

iOS runtime (packages/ios-client/ios/)

  • VoltraJSRenderer.swift — one shared JSContext per extension process;
    captures each bundle's exports under globalThis.__voltraWidgets[<id>].
    ensureEvaluated() lets the View re-evaluate from the entry's carried bundle
    source, 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 (@Environment reads
    are only valid in a View body, hence the Provider/View split), and the
    generated AppIntentTimelineProvider plumbing for configured widgets.

Plugin (packages/ios-client/expo-plugin/src/)

  • 'use voltra' detection (Babel scan), generated Swift (AppIntentConfiguration
    gated iOS 17+), prerendered initial state, and the release widget-bundling
    build phase. Emits a one-time EXPERIMENTAL warning at prebuild.

Verification

  • Dev (simulator): env capture, AppIntent config (Edit Widget → env.configuration),
    and hot reload all verified end-to-end.
  • Production bake: verified at build/artifact level — voltra-widget-<id>.bundle
    is baked into the installed app, and a full clean Release build is green
    (including app JS bundling).

Known limitations

  • Release render needs real-device verification. On the iOS Simulator, a
    release build does not invoke the AppIntentConfiguration timeline (WidgetKit
    stays 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.
  • Metro scaffolding lives in the example app (example/metro/) for now;
    consumers copy it. Productizing it into the package is a follow-up.
  • .hbc precompilation is deferred — v1 bakes plain JS.

Changeset

@use-voltra/ios-client minor — experimental client-rendered widgets.

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.
Comment thread packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift Outdated
Comment thread example/metro/widgetRegistry.js Outdated
@V3RON V3RON mentioned this pull request Jun 11, 2026
7 tasks
burczu added 8 commits June 11, 2026 17:16
…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.
@burczu burczu marked this pull request as ready for review June 12, 2026 10:43
…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.
@burczu burczu changed the title feat: client-rendered widgets on iOS (Track 5) feat: client-rendered widgets on iOS Jun 12, 2026
V3RON added 2 commits June 12, 2026 16:37
Extract directive scanning into @use-voltra/compiler and wire Metro plus iOS prebuild validation to the shared scanner.
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