Skip to content

feat: dynamic widgets for iOS and Android#201

Draft
V3RON wants to merge 3 commits into
mainfrom
feat/dynamic-widgets
Draft

feat: dynamic widgets for iOS and Android#201
V3RON wants to merge 3 commits into
mainfrom
feat/dynamic-widgets

Conversation

@V3RON

@V3RON V3RON commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

No description provided.

burczu and others added 3 commits June 18, 2026 11:01
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-widgets`** — `WidgetEnvironment` 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.

---------

Co-authored-by: Szymon Chmal <szymon@chmal.it>
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 Android**: widgets written as plain
`(props, env) => JSX` functions, evaluated on-device in a **standalone
Hermes runtime** (custom JNI/CMake, built on Track 4's engine) on every
render. The `env` argument carries live device state (`widgetFamily`,
`colorScheme`, `locale`, `materialColors`, `configuration`, `build`)
captured per render from the Glance composition context, so a widget
reacts to the home-screen environment (size, dark-mode flip, Material
You colors) without a native rebuild — the same model as `expo-widgets`,
and the Android counterpart of #190.

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 + Glance renderer, so client-rendered widgets are visually
identical to server-rendered ones.

## The two goals (both met)

1. **Env parity with `expo-widgets`** — `WidgetEnvironment` flows into
the on-device `render(props, env)` automatically, captured in
`provideGlance`. Includes Material You dynamic colors
(`env.materialColors`) and user `configuration`.
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 component carrying the `'use voltra'` directive is
auto-detected at prebuild (via `@use-voltra/compiler`) 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 widget identity).

**Configuration.** Parameters declared in `app.json`
(`appIntent.parameters`, with code-defined defaults) are baked to
`assets/voltra/widget_config_defaults.json` and surface as
`env.configuration`. Runtime values set via `setWidgetConfiguration`
(stored in DataStore) override the defaults. Android has no system
widget-configuration UI, so this in-app API is the stand-in for iOS's
AppIntent "Edit Widget" sheet.

**Dev.** Widgets are discovered by a filesystem scan + watcher; the JS
bundle is served by Metro and fetched per render.
`enableWidgetHotReload()` (called once at host-app startup, DEV-only)
hooks Metro's `__accept` so a JSX edit triggers `reloadAndroidWidgets()`
→ `provideGlance` re-fetches the fresh bundle. A generated per-platform
**dev barrel** keeps widget modules in the host app's Metro graph so
Fast Refresh fires for widget-only edits.

**Production (release).** A release-only Gradle task
(`voltraBundleWidgets`) resolves `@use-voltra/metro/bundle-widgets` and
bakes each `voltra-widget-<id>.bundle` (plain JS for v1) into
`assets/voltra/`; the native release loader reads it from app assets.
Debug uses Metro; the task is wired only into release asset-merge.

## Android runtime (`packages/android-client/android/`)

- `cpp/voltra_js_renderer.cpp` + `VoltraJSRenderer.kt` — standalone
Hermes via custom JNI (`hermes-android` prefab); `evaluateBundle`
captures each widget's `render`, invoked per Glance render. Perf logs
gated behind `NDEBUG`/`BuildConfig.DEBUG`.
- `VoltraClientGlanceWidget.kt` — dual-path bundle loader (Metro in dev,
baked asset in release), env capture in `provideGlance`
(size/scheme/locale/Material You/configuration), DataStore-backed
configuration.

## Shared (`packages/metro`, `packages/compiler`)

- `@use-voltra/metro` generates a **per-platform render shim** so the
one generated widget entry renders via `renderAndroidVariantToJson` on
Android / `renderVoltraVariantToJson` on iOS.
- `@use-voltra/compiler` `scanVoltraDirectives` is the shared `'use
voltra'` detector used by the Android plugin.

## Plugin (`packages/android-client/expo-plugin/`)

`'use voltra'` detection, generated Glance receiver, prerendered initial
state, baked config defaults, and the release widget-bundling Gradle
task. Emits a one-time **EXPERIMENTAL** warning at prebuild.

## Verification (clean emulator, end-to-end)

- **Dev:** env capture, config code-default (`Hello`) + runtime override
(`World`), and hot reload (edit → ~1s → pinned widget updates) all
verified live.
- **Production bake:** **release APK with Metro stopped** renders the
full live env on-device from the baked asset bundle (verified visually —
live timestamp confirms a real render, not the placeholder). Stronger
than #190, which deferred release render to a real device.

## Known limitations

- **`.hbc` precompilation** is deferred — v1 bakes plain JS.
- Emulator widget hot reload is reliable for normal editor saves; tools
that replace files via atomic rename (e.g. `sed -i`) can be missed by
the file watcher.

## Stacking

Targets `main` and stacks on #190 (iOS). Until #190 merges, this PR's
diff includes the shared iOS work; it narrows to Android-only once #190
lands. Review/merge after #190.

## Changeset

`@use-voltra/android-client` minor — experimental client-rendered
widgets (render shim covered by the `@use-voltra/metro` bump).

---------

Co-authored-by: Szymon Chmal <szymon@chmal.it>
Document client-rendered and configurable widgets for iOS and Android.
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.

2 participants