Skip to content

PoC: JS-in-extension AppIntent reactivity (Track 2)#167

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

PoC: JS-in-extension AppIntent reactivity (Track 2)#167
burczu wants to merge 11 commits into
callstackincubator:mainfrom
burczu:poc/widget-reactivity-track-2

Conversation

@burczu
Copy link
Copy Markdown
Contributor

@burczu burczu commented May 29, 2026

Closes #165

Summary

Proof-of-concept for making Voltra widgets reactive to AppIntentConfiguration parameter changes
without a server push, by running a thin JS resolver inside the widget extension using JavaScriptCore.

The JS layer sits in front of the existing Swift interpreter — it does not replace it. Server-driven
layout is fully preserved; only AppIntent parameter substitution happens on-device.

server push (VoltraNode tree)
    ↓
JS resolver in extension (JSC — resolves {{ appIntent.X }})
    ↓
resolved VoltraNode tree
    ↓
existing Swift interpreter (unchanged)

JavaScriptCore was chosen over Hermes: it is a system framework — zero binary size cost, no new
native dependency, available in widget extensions without any linking changes.

Developer experience

A developer creating a reactive widget writes no Swift. The workflow:

  1. Write a JSX component using appIntentParam():

    import { Voltra, appIntentParam } from 'voltra'
    
    export const MyWidget = () => (
      <Voltra.VStack style={{ flex: 1, padding: 16 }}>
        <Voltra.Text style={{ fontSize: 22, color: 'primary' }}>
          {appIntentParam('city')}
        </Voltra.Text>
      </Voltra.VStack>
    )
  2. Add an appIntent block to app.json:

    {
      "id": "reactive",
      "appIntent": {
        "parameters": [{ "name": "city", "title": "City", "default": "New York" }]
      }
    }
  3. Run expo prebuild — the config plugin generates the full Swift boilerplate and copies
    the resolver bundle into the Xcode Resources phase automatically.

appIntentParam('city') returns {{ appIntent.city }}, which passes through the server renderer
unchanged and is resolved locally at render time.

What was built

Artifact Location Purpose
@use-voltra/ios-renderer packages/ios-renderer/ 894 B JS bundle — resolves {{ appIntent.X }}
appIntentParam() packages/ios/src/app-intent.ts Developer API
VoltraJSRenderer packages/voltra/ios/shared/VoltraJSRenderer.swift JSC evaluation layer — lazy singleton, NSLock thread safety
VoltraReactiveSupport packages/voltra/ios/shared/VoltraReactiveSupport.swift Shared Swift helpers for generated widget code
Config plugin extension packages/expo-plugin/src/ios-widget/files/swift.ts Generates AppIntent Swift from appIntent config
JS bundle copy packages/expo-plugin/src/ios-widget/files/index.ts Copies resolver bundle to extension on prebuild
E2E tests packages/ios-renderer/src/__tests__/resolve.node.test.ts 10 tests: resolution, passthrough, round-trip

Exit criterion — proved in simulator (2026-05-28)

  • AppIntent reactivity: user-configured parameters re-render the widget without a server push ✅
  • Zero Swift required from the developer ✅
  • Server-driven layout preserved ✅

Not yet proved (requires real device)

  • JSC initialisation within the widget extension's ~30 MB memory budget
  • Cold-start latency of JSC + bundle evaluation on first render

Implementation gotchas

  1. Intent struct must not be private — AppIntents metadata extraction silently skips private
    types at build time. The widget stays permanently in placeholder state with no crash or log.
    Generated struct uses internal access.

  2. Shared stylesheet must travel with the family node — The Voltra compact JSON format stores
    the shared style array at the payload root ("s": [...]); individual nodes reference styles by
    index. Extracting only the family-specific subtree drops the "s" array, making all style
    references unresolvable. Fix: copy "s" and "e" from root into the extracted family object,
    mirroring VoltraHomeWidget.reconstructWithSharedData.

Test plan

  • npm run test -w @use-voltra/ios-renderer — 10 unit tests pass
  • expo prebuild in example/ — Swift generated, bundle copied to Resources
  • Build in Xcode — BUILD SUCCEEDED
  • Add reactive widget in simulator → configure city → widget re-renders with new value

burczu added 11 commits May 29, 2026 21:47
Introduces packages/ios-renderer — a self-contained JS bundle that
runs inside JavaScriptCore/Hermes in the widget extension and resolves
variant-aware values in the Voltra payload against current device state
before handing off to the existing Swift interpreter.

Resolves: light-dark(<light>, <dark>) color expressions via colorScheme,
and {{ appIntent.<param> }} template expressions via AppIntent parameters.
Bundle output is 894B minified (esbuild, ES2019 target). The bundle/
directory is now git-ignored alongside build/.
…payload resolution

Loads the ios-renderer bundle into a cached JavaScriptCore context and
exposes a resolve() method that rewrites variant-aware payload values
(light-dark() colors, AppIntent templates) against current device state
before the existing Swift interpreter renders the result.

Returns nil on any failure so callers can fall back to the unmodified
payload — no change to existing rendering behaviour until wired in.
…2 Step 3)

- Export appIntentParam(name) from @use-voltra/ios → ios-server → voltra;
  returns {{ appIntent.name }} template expression that passes through the
  server renderer unchanged and is resolved in-extension by VoltraJSRenderer
- Add IosReactiveWeatherWidget.tsx demonstrating appIntentParam + light-dark() colors
- Add end-to-end tests for resolve() covering template substitution,
  light-dark resolution, passthrough keys, and JSON round-trip
Adds widgetId=reactive handler to the example server that renders
IosReactiveWeatherWidget — a payload containing {{ appIntent.city }}
template expressions and light-dark() colors that the widget extension
resolves on-device without a server push.
…ppIntent widgets

Adds VoltraReactiveRenderer (extractNode, jsRenderingMode, voltraRenderingMode)
and VoltraReactiveContainerBackground so the config plugin can generate minimal
per-widget Swift without duplicating rendering logic.

Also adds poc/VoltraReactiveWidget.swift as a hand-written reference showing
the pattern that the config plugin will generate automatically.
… 2 automation)

Extends the config plugin so declaring appIntent in app.json produces a fully
working iOS 17+ AppIntentConfiguration widget — no Swift required from the developer.

- types: add AppIntentParameter, WidgetAppIntentConfig, appIntent on WidgetConfig,
  bundleResources on WidgetFiles
- swift.ts: generate Intent struct, entry, provider, reactive view, and widget struct
  for each appIntent widget; bundle them in VoltraWidgetBundle behind @available(iOS 17.0, *)
- fileDiscovery: collect .js files into bundleResources
- buildPhases: include bundleResources in Xcode Resources phase
- files/index.ts: copy ios-renderer.js bundle to extension target when appIntent widgets present
- ios-renderer: add ./bundle/ios-renderer.js subpath export for plugin resolution
- example: add reactive widget with city AppIntent param + initial state file
…undle path

- voltra/src/index.ts: export appIntentParam so widget JSX components can import
  it from 'voltra' (not just 'voltra/server') — needed for prebuild prerendering
- expo-plugin files/index.ts: fix monorepo bundle search path (one level up from
  project root, not two); add workspace root node_modules as candidate location
- WidgetConfigurationIntent instead of AppIntent for the intent struct
  (AppIntentTimelineProvider requires WidgetConfigurationIntent)
- Fix @Environment key paths — dedent uses raw template strings so \\.
  produced double-backslash in output; use kp() interpolation instead
- Add @available(iOS 17.0, *) to VoltraReactiveContainerBackground
- Include bundleResources in ensurePbxGroup (was missing; addPbxGroup
  already had it)
- Remove `private` from generated Intent struct — AppIntents metadata
  extraction cannot register private types, leaving the widget stuck in
  placeholder state indefinitely
- Fix extractNode to carry shared stylesheet ("s") and elements ("e")
  from the payload root into the family-specific node so VoltraNode.parse
  can resolve style index references (mirrors VoltraHomeWidget behaviour)
…or WidgetKit

Adds native dark/light mode adaptation for light-dark() color strings in VoltraText.
The JS resolver no longer resolves light-dark() strings — it passes them through
unchanged so Swift can handle adaptation at draw time.

On the Swift side:
- JSColorParser gains parseLightDarkComponents() which splits the string into
  separate light/dark Color values
- TextStyle gains a lightDarkColors field for the parsed pair
- StyleConverter.parseText() routes light-dark() strings to lightDarkColors
  instead of the flat color field
- LightDarkForeground: ShapeStyle resolves to the correct color in
  resolve(in: EnvironmentValues), called by the rendering engine at draw time
- VoltraText uses .foregroundStyle(LightDarkForeground(...)) when lightDarkColors
  is set, falling back to the existing resolvedColor path otherwise

Note: iOS simulator does not re-render widgets on appearance toggle (confirmed by
native Calendar widget also being unaffected). Real device verification required.
light-dark() color adaptation is a standalone rendering concern that belongs
on Track 1 (server-driven rendering improvements), not Track 2 (AppIntent
reactivity). Keeping it here conflated two independent features.

- Revert Swift changes from 82da057: JSColorParser, StyleConverter, TextStyle,
  VoltraText — light-dark() strings now pass through parse() as unrecognised
  and fall back to the default color
- index.ts unchanged: PASSTHROUGH_KEYS without 's', resolveString without
  resolveLightDark — correct for AppIntent-only resolution
- Example widget updated to use 'primary' semantic color
@burczu burczu force-pushed the poc/widget-reactivity-track-2 branch from 9c532b6 to 1e6b374 Compare May 29, 2026 20:02
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

1 participant