Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/widget-react-native-imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@use-voltra/android-client': minor
'@use-voltra/ios-client': minor
'@use-voltra/expo-plugin': minor
'@use-voltra/compiler': minor
'@use-voltra/metro': minor
'voltra': minor
---

Widget files can now import `StyleSheet` and `Platform` from `react-native`, so widget styles
can live outside the element tree the same way they do elsewhere in an app. Previously any
import from `react-native` in a widget file failed `voltra apply` and `expo prebuild` with
`Unexpected token 'typeof'`, and Dynamic Widgets rejected the import at bundle time.

Inside a widget, `Platform.OS` is the platform being built for, and `StyleSheet.create` returns
the styles unchanged. Other `react-native` APIs — components, `Dimensions`, `Animated`,
`PixelRatio`, deep `react-native/...` paths — are rejected with a message naming the symbol
instead of misbehaving at render time.

Importing `@use-voltra/ios-client` or `@use-voltra/android-client` from a widget file now
resolves to the matching rendering package in `voltra apply` and in Dynamic Widget bundles too,
matching what prebuild already did.

Projects that keep their Babel setup in `babel.config.json`, `babel.config.ts`, or any other
filename Babel discovers on its own now have it applied to widget code by `voltra apply`, which
previously looked only for `babel.config.js`, `.cjs`, and `.mjs`.

`@use-voltra/expo-plugin`'s widget evaluation helpers changed shape for the config plugins that
consume them: `evaluateWidgetModuleExports` and `evaluateWidgetModule` now take
`(filePath, { projectRoot, platform })` instead of `(projectRoot, filePath, warnedRedirects)`,
`prerenderWidgetState` takes the target platform as a fourth argument, and `MODULE_EXTENSIONS`
is no longer exported — module resolution now lives in `@use-voltra/compiler`. Projects using
the published Expo plugins are unaffected; only direct callers of these helpers need updating.
74 changes: 74 additions & 0 deletions docs/adr/0003-widget-module-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 0003 — Widget module resolution

**Status:** Accepted

## Context

Widget source is loaded in three places, each with a different execution model:

1. `voltra apply` evaluates widget files in a Node VM to prerender initial states and to
detect Dynamic Widgets (`packages/cli`).
2. The Expo config plugins do the same during prebuild (`packages/expo-plugin`, driven by
`@use-voltra/ios-client` and `@use-voltra/android-client`).
3. Metro bundles Dynamic Widget entries for the device, where they run in a separate JS
engine with no bridge and no native modules (`packages/metro`).

These had three independent notions of what a widget file may import. The two Node loaders
were forked copies that had already drifted apart — different resolvable extensions,
different Babel configuration lookup, different fallback presets — and the package redirect
that lets widget code import a client package existed in only one of them. Metro had a third
answer: it rejected every `react-native` import outright.

The practical consequence was that `import { StyleSheet } from 'react-native'` — the ordinary
way to keep styles out of the element tree — crashed `voltra apply` with
`Unexpected token 'typeof'`, because React Native's published entry point is untranspiled
Flow that Node cannot parse.

The deeper problem is not the missing shim but the missing single answer. When the three
environments disagree, a widget can prerender successfully and still fail to bundle, or —
worse — render differently on device than the build-time placeholder it was prerendered
from.

## Decision

`@use-voltra/compiler` owns widget module resolution, and all three environments consume it.

- **Policy.** `resolveWidgetImport(specifier, platform)` is the single source of truth for
every bare import in widget code. It returns one of: pass the specifier through, resolve a
different specifier instead, or reject with a message. Client packages
(`@use-voltra/ios-client`, `@use-voltra/android-client`) resolve to their rendering package;
`react-native` resolves to Voltra's shim; deep `react-native/...` paths are rejected.
- **Loader.** `createWidgetModuleLoader` is the one Babel + VM implementation. The CLI and the
Expo plugins are thin adapters over it, supplying their own error type and warning sink.
- **Shim.** `@use-voltra/compiler/react-native/{ios,android}` is the `react-native` surface
widget code sees. Both the Node loader and the Metro resolver serve the same file, so
build-time evaluation and on-device rendering cannot diverge.

The shim is an allowlist, not a blocklist. It implements `StyleSheet` (`create` as identity,
`flatten`, `compose`, `absoluteFill`, `absoluteFillObject`, `hairlineWidth`) and `Platform`
(`OS`, `select`). Every other symbol — components, `Dimensions`, `Animated`, `PixelRatio`,
`Platform.Version` — is rejected with a message naming the symbol and pointing at the Voltra
equivalent.

Rejection happens at two levels, because the two environments offer different interception
points. The Node loader hands widget code a proxy over the shim, so _any_ unimplemented symbol
fails the moment the module is evaluated. Metro resolves the shim file directly and has no
such hook, so the shim additionally names the React Native exports widget code is most likely
to reach for and exports each as a stub that throws on use. Importing a stub is harmless;
rendering, calling, or reading a property off it is not. Build-time evaluation therefore
catches everything, and a symbol that survives to the device because it sits on a branch the
placeholder render never took still fails loudly instead of reading as `undefined`.

`Platform.OS` is the platform the widget is being built for, which every call site already
knows, so the loader takes it explicitly rather than guessing.

## Consequences

- Widget code can use `StyleSheet` and `Platform`, and the same file prerenders and bundles.
- Adding to the widget-visible surface means changing the shim, which serves all three
environments at once. There is no way to fix one and forget the others.
- The allowlist means a `react-native` API that would silently misbehave in a widget fails the
build instead. That is a deliberate trade: an explicit build error is cheaper than a widget
that renders wrong on someone's Home Screen.
- `@use-voltra/compiler` now depends on `@babel/core` and ships a module that is bundled onto
the device. Its scope is widget source tooling, not static analysis alone.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ Status values:
| [0000](0000-android-widget-kind-separation.md) | Separate payload-driven and Dynamic Android widget paths | Accepted |
| [0001](0001-dynamic-live-activities.md) | Dynamic Live Activities rendering | Accepted |
| [0002](0002-server-driven-dynamic-widgets.md) | Server-driven Dynamic Widgets | Accepted |
| [0003](0003-widget-module-resolution.md) | Widget module resolution | Accepted |
23 changes: 19 additions & 4 deletions example/widgets/android/AndroidClientDemoWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { Platform, StyleSheet } from 'react-native'

import { AndroidDynamicColors, VoltraAndroid, type WidgetEnvironment } from '@use-voltra/android'

// Static styles live outside the element tree; the Material You colors below are resolved
// per render and merged in with StyleSheet.flatten.
const styles = StyleSheet.create({
container: { width: '100%', height: '100%', padding: Platform.select({ android: 12, default: 16 }) },
title: { fontSize: 12 },
marker: { fontSize: 14 },
swatch: { width: 16, height: 16, borderRadius: 4 },
})

export type AndroidClientDemoWidgetProps = {
headline?: string
unreadCount?: number
Expand Down Expand Up @@ -44,16 +55,20 @@ export default function AndroidClientDemoWidget(
)

const swatch = (color: string) => (
<VoltraAndroid.Box style={{ width: 16, height: 16, backgroundColor: color, borderRadius: 4 }} />
<VoltraAndroid.Box style={StyleSheet.flatten([styles.swatch, { backgroundColor: color }])} />
)

return (
<VoltraAndroid.Column
style={{ backgroundColor: bg, width: '100%', height: '100%', padding: 12 }}
style={StyleSheet.flatten([styles.container, { backgroundColor: bg }])}
verticalAlignment="center-vertically"
>
<VoltraAndroid.Text style={{ fontSize: 12, color: fg }}>Dynamic Widget demo</VoltraAndroid.Text>
<VoltraAndroid.Text style={{ fontSize: 14, color: accent }}>{hotReloadMarker}</VoltraAndroid.Text>
<VoltraAndroid.Text style={StyleSheet.flatten([styles.title, { color: fg }])}>
Dynamic Widget demo
</VoltraAndroid.Text>
<VoltraAndroid.Text style={StyleSheet.flatten([styles.marker, { color: accent }])}>
{hotReloadMarker}
</VoltraAndroid.Text>
<VoltraAndroid.Spacer style={{ height: 6 }} />
{row('size:', env.widgetFamily ?? '?')}
{row('scheme:', env.colorScheme ?? '?')}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as fs from 'fs'
import * as path from 'path'

import { evaluateWidgetModuleExports } from '@use-voltra/expo-plugin'
import { createPrerenderWidgetModuleLoader, type WidgetModuleLoader } from '@use-voltra/expo-plugin'

import type { AndroidWidgetConfig } from '../types'

Expand All @@ -27,7 +27,8 @@ export function detectClientRenderedWidgets(
widgets: AndroidWidgetConfig[],
projectRoot: string
): DetectedAndroidWidget[] {
const detected = widgets.map((widget) => detectSingleWidget(widget, projectRoot))
const loader = createPrerenderWidgetModuleLoader(projectRoot, 'android')
const detected = widgets.map((widget) => detectSingleWidget(widget, projectRoot, loader))

if (!hasWarnedExperimental) {
const clientWidgetIds = detected.filter((widget) => widget.clientRendered).map((widget) => widget.id)
Expand All @@ -44,7 +45,11 @@ export function detectClientRenderedWidgets(
return detected
}

function detectSingleWidget(widget: AndroidWidgetConfig, projectRoot: string): DetectedAndroidWidget {
function detectSingleWidget(
widget: AndroidWidgetConfig,
projectRoot: string,
loader: WidgetModuleLoader
): DetectedAndroidWidget {
if (widget.entry === undefined) {
return {
...widget,
Expand All @@ -62,8 +67,7 @@ function detectSingleWidget(widget: AndroidWidgetConfig, projectRoot: string): D
)
}

const widgetModule = evaluateWidgetModuleExports(projectRoot, sourcePath)
const widgetFn = widgetModule?.default ?? widgetModule
const widgetFn = loader.loadDefaultExport(sourcePath)
if (typeof widgetFn !== 'function') {
throw new Error(
`[voltra] Dynamic Widget "${widget.id}" at ${path.relative(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
evaluateWidgetModuleExports,
createPrerenderWidgetModuleLoader,
logger,
resolveInstalledPackageVersion,
type PrerenderedWidgetStates,
Expand Down Expand Up @@ -58,11 +58,11 @@ export async function prerenderClientRenderedAndroidWidgets(
}

const placeholderEnv = buildPlaceholderEnv(resolveInstalledPackageVersion(projectRoot, '@use-voltra/android-client'))
const loader = createPrerenderWidgetModuleLoader(projectRoot, 'android')

for (const widget of clientWidgets) {
try {
const widgetModule = evaluateWidgetModuleExports(projectRoot, widget.clientSourcePath)
const widgetFn = widgetModule?.default ?? widgetModule
const widgetFn = loader.loadDefaultExport(widget.clientSourcePath)
if (typeof widgetFn !== 'function') {
throw new Error(
`Expected the entry module at ${widget.clientSourcePath} to default-export a function or component.`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export async function generateAndroidInitialStates(options: GenerateInitialState
const prerenderedStates: PrerenderedWidgetStates = await prerenderWidgetState(
serverWidgets,
projectRoot,
renderAndroidWidgetToString
renderAndroidWidgetToString,
'android'
)

// Single-node placeholders for Dynamic Widgets (first paint / offline fallback).
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,15 @@
"license": "MIT",
"homepage": "https://use-voltra.dev",
"dependencies": {
"@babel/core": "^7.27.4",
"@bacons/xcode": "^1.0.0-alpha.33",
"@clack/prompts": "^1.0.0-alpha.5",
"@use-voltra/compiler": "workspace:^",
"commander": "^12.1.0",
"cosmiconfig": "^9.0.0",
"vd-tool": "^4.0.2",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@types/babel__core": "^7.20.5",
"tsx": "^4.19.0"
}
}
Loading
Loading