PoC: JS-in-extension AppIntent reactivity (Track 2)#167
Draft
burczu wants to merge 11 commits into
Draft
Conversation
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
9c532b6 to
1e6b374
Compare
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
Proof-of-concept for making Voltra widgets reactive to
AppIntentConfigurationparameter changeswithout 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.
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:
Write a JSX component using
appIntentParam():Add an
appIntentblock toapp.json:{ "id": "reactive", "appIntent": { "parameters": [{ "name": "city", "title": "City", "default": "New York" }] } }Run
expo prebuild— the config plugin generates the full Swift boilerplate and copiesthe resolver bundle into the Xcode Resources phase automatically.
appIntentParam('city')returns{{ appIntent.city }}, which passes through the server rendererunchanged and is resolved locally at render time.
What was built
@use-voltra/ios-rendererpackages/ios-renderer/{{ appIntent.X }}appIntentParam()packages/ios/src/app-intent.tsVoltraJSRendererpackages/voltra/ios/shared/VoltraJSRenderer.swiftVoltraReactiveSupportpackages/voltra/ios/shared/VoltraReactiveSupport.swiftpackages/expo-plugin/src/ios-widget/files/swift.tsappIntentconfigpackages/expo-plugin/src/ios-widget/files/index.tspackages/ios-renderer/src/__tests__/resolve.node.test.tsExit criterion — proved in simulator (2026-05-28)
Not yet proved (requires real device)
Implementation gotchas
Intent struct must not be
private— AppIntents metadata extraction silently skipsprivatetypes at build time. The widget stays permanently in placeholder state with no crash or log.
Generated struct uses
internalaccess.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 byindex. Extracting only the family-specific subtree drops the
"s"array, making all stylereferences 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 passexpo prebuildinexample/— Swift generated, bundle copied to ResourcesBUILD SUCCEEDEDreactivewidget in simulator → configure city → widget re-renders with new value