diff --git a/.changeset/widget-react-native-imports.md b/.changeset/widget-react-native-imports.md
new file mode 100644
index 00000000..a1d27f8f
--- /dev/null
+++ b/.changeset/widget-react-native-imports.md
@@ -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.
diff --git a/docs/adr/0003-widget-module-resolution.md b/docs/adr/0003-widget-module-resolution.md
new file mode 100644
index 00000000..1f8d249d
--- /dev/null
+++ b/docs/adr/0003-widget-module-resolution.md
@@ -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.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index cac86405..aee4df2f 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -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 |
diff --git a/example/widgets/android/AndroidClientDemoWidget.tsx b/example/widgets/android/AndroidClientDemoWidget.tsx
index fd52978c..40d1c83f 100644
--- a/example/widgets/android/AndroidClientDemoWidget.tsx
+++ b/example/widgets/android/AndroidClientDemoWidget.tsx
@@ -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
@@ -44,16 +55,20 @@ export default function AndroidClientDemoWidget(
)
const swatch = (color: string) => (
-
+
)
return (
- Dynamic Widget demo
- {hotReloadMarker}
+
+ Dynamic Widget demo
+
+
+ {hotReloadMarker}
+
{row('size:', env.widgetFamily ?? '?')}
{row('scheme:', env.colorScheme ?? '?')}
diff --git a/packages/android-client/expo-plugin/src/android/clientRendered.ts b/packages/android-client/expo-plugin/src/android/clientRendered.ts
index 9f4a3d9a..15f425b4 100644
--- a/packages/android-client/expo-plugin/src/android/clientRendered.ts
+++ b/packages/android-client/expo-plugin/src/android/clientRendered.ts
@@ -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'
@@ -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)
@@ -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,
@@ -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(
diff --git a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts
index f8fc0fb9..6ffa9d3e 100644
--- a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts
+++ b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts
@@ -1,5 +1,5 @@
import {
- evaluateWidgetModuleExports,
+ createPrerenderWidgetModuleLoader,
logger,
resolveInstalledPackageVersion,
type PrerenderedWidgetStates,
@@ -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.`
diff --git a/packages/android-client/expo-plugin/src/android/files/initialStates.ts b/packages/android-client/expo-plugin/src/android/files/initialStates.ts
index 7a6c174d..618256e4 100644
--- a/packages/android-client/expo-plugin/src/android/files/initialStates.ts
+++ b/packages/android-client/expo-plugin/src/android/files/initialStates.ts
@@ -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).
diff --git a/packages/cli/package.json b/packages/cli/package.json
index ef46e0ad..8f147eb4 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -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"
}
}
diff --git a/packages/cli/src/platforms/android/generated.ts b/packages/cli/src/platforms/android/generated.ts
index b73bac36..c021e887 100644
--- a/packages/cli/src/platforms/android/generated.ts
+++ b/packages/cli/src/platforms/android/generated.ts
@@ -8,9 +8,11 @@ import { getPlatformClientPackageVersion, requirePlatformPackage } from '../../d
import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite'
import { normalizeRelativePath, toRelativePath } from '../../fs/path'
import { VoltraCliError } from '../../reporting/summary'
-import { createDynamicWidgetBuildInfo, evaluateWidgetModuleExports } from '../shared/widgetModule'
+import { createDynamicWidgetBuildInfo, createGeneratedWidgetModuleLoader } from '../shared/widgetModule'
import { androidWidgetSizingAttributes, androidWidgetSizingWarnings } from './widgetSizing'
+import type { WidgetModuleLoader } from '@use-voltra/compiler'
+
import type { AndroidProjectDiscovery } from '../../discovery/android'
import type {
AndroidWidgetAppIntentParameter,
@@ -80,10 +82,16 @@ export async function generateAndroidFiles(options: GenerateAndroidFilesOptions)
const { projectRoot, android, discovery } = options
const voltraVersion = getPlatformClientPackageVersion(projectRoot, 'android')
const resourceRoot = path.join(discovery.appModuleRoot, 'src', 'main')
- const detectedWidgets = detectClientRenderedWidgets(projectRoot, android.widgets)
const changes: ReportedChange[] = []
const warnings: string[] = []
const generatedFiles = new Set()
+ const widgetModuleLoader = createGeneratedWidgetModuleLoader(
+ projectRoot,
+ 'android',
+ createAndroidGeneratedFilesError,
+ (message) => warnings.push(message)
+ )
+ const detectedWidgets = detectClientRenderedWidgets(projectRoot, widgetModuleLoader, android.widgets)
mergeSingleResult(
await writeGeneratedTextFile(
@@ -109,6 +117,7 @@ export async function generateAndroidFiles(options: GenerateAndroidFilesOptions)
const initialStateFiles = await generateAndroidInitialStates(
projectRoot,
+ widgetModuleLoader,
resourceRoot,
detectedWidgets,
voltraVersion
@@ -422,6 +431,7 @@ async function copyAndroidFonts(
async function generateAndroidInitialStates(
projectRoot: string,
+ loader: WidgetModuleLoader,
resourceRoot: string,
widgets: DetectedAndroidWidget[],
voltraVersion: string
@@ -431,11 +441,11 @@ async function generateAndroidInitialStates(
)
const prerenderableServerWidgets = serverWidgets.filter((widget) => widget.initialStatePath)
const serverStates = await prerenderWidgetStates(
- projectRoot,
+ loader,
prerenderableServerWidgets,
loadAndroidWidgetRenderer(projectRoot)
)
- const clientStates = await prerenderClientRenderedAndroidWidgets(projectRoot, widgets, voltraVersion)
+ const clientStates = await prerenderClientRenderedAndroidWidgets(projectRoot, loader, widgets, voltraVersion)
const prerenderedStates = new Map([...serverStates, ...clientStates])
if (prerenderedStates.size === 0) {
@@ -458,7 +468,7 @@ async function generateAndroidInitialStates(
}
async function prerenderWidgetStates(
- projectRoot: string,
+ loader: WidgetModuleLoader,
widgets: NormalizedAndroidWidgetConfig[],
renderer: AndroidWidgetRenderer
): Promise {
@@ -483,7 +493,7 @@ async function prerenderWidgetStates(
throw new AndroidGeneratedFilesError(`Initial state file not found for widget '${widget.id}' at ${modulePath}`)
}
- const widgetVariants = evaluateLegacyWidgetModule(projectRoot, modulePath)
+ const widgetVariants = evaluateLegacyWidgetModule(loader, modulePath)
localeStates.set(localeKey, renderer(widgetVariants))
}
@@ -493,11 +503,8 @@ async function prerenderWidgetStates(
return prerenderedStates
}
-function evaluateLegacyWidgetModule(projectRoot: string, filePath: string): AndroidWidgetVariants {
- const exports = evaluateWidgetModuleExports(projectRoot, filePath, createAndroidGeneratedFilesError) as {
- default?: unknown
- }
- const widgetVariants = exports.default ?? exports
+function evaluateLegacyWidgetModule(loader: WidgetModuleLoader, filePath: string): AndroidWidgetVariants {
+ const widgetVariants = loader.loadDefaultExport(filePath)
if (!widgetVariants || typeof widgetVariants !== 'object') {
throw new AndroidGeneratedFilesError(`Widget file must export widget variants: ${filePath}`)
@@ -545,12 +552,17 @@ function createDynamicWidgetsManifest(widgets: DetectedAndroidWidget[]): Dynamic
function detectClientRenderedWidgets(
projectRoot: string,
+ loader: WidgetModuleLoader,
widgets: NormalizedAndroidWidgetConfig[]
): DetectedAndroidWidget[] {
- return widgets.map((widget) => detectSingleWidget(projectRoot, widget))
+ return widgets.map((widget) => detectSingleWidget(projectRoot, loader, widget))
}
-function detectSingleWidget(projectRoot: string, widget: NormalizedAndroidWidgetConfig): DetectedAndroidWidget {
+function detectSingleWidget(
+ projectRoot: string,
+ loader: WidgetModuleLoader,
+ widget: NormalizedAndroidWidgetConfig
+): DetectedAndroidWidget {
if (!widget.entry) {
return {
...widget,
@@ -564,7 +576,7 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedAndroidWidget
throw createAndroidGeneratedFilesError(`[voltra] Dynamic Widget "${widget.id}" entry not found at ${widget.entry}`)
}
- const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, clientSourcePath)
+ const widgetModule = safelyEvaluateDynamicWidget(loader, widget.id, widget.entry, clientSourcePath)
const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
if (typeof widgetFn !== 'function') {
@@ -583,6 +595,7 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedAndroidWidget
async function prerenderClientRenderedAndroidWidgets(
projectRoot: string,
+ loader: WidgetModuleLoader,
widgets: DetectedAndroidWidget[],
voltraVersion: string
): Promise {
@@ -607,7 +620,7 @@ async function prerenderClientRenderedAndroidWidgets(
for (const widget of clientWidgets) {
try {
- const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, widget.clientSourcePath)
+ const widgetModule = safelyEvaluateDynamicWidget(loader, widget.id, widget.entry, widget.clientSourcePath)
const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
const element = widgetFn({}, placeholderEnv)
prerenderedStates.set(widget.id, new Map([[DEFAULT_INITIAL_STATE_LOCALE, JSON.stringify(renderer(element))]]))
@@ -628,13 +641,13 @@ async function prerenderClientRenderedAndroidWidgets(
}
function safelyEvaluateDynamicWidget(
- projectRoot: string,
+ loader: WidgetModuleLoader,
widgetId: string,
widgetEntry: string,
clientSourcePath: string
): unknown {
try {
- return evaluateWidgetModuleExports(projectRoot, clientSourcePath, createAndroidGeneratedFilesError)
+ return loader.load(clientSourcePath)
} catch (error) {
if (error instanceof AndroidGeneratedFilesError) {
throw error
diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts
index c090e850..220e32c9 100644
--- a/packages/cli/src/platforms/ios/generated.ts
+++ b/packages/cli/src/platforms/ios/generated.ts
@@ -7,10 +7,12 @@ import { getPlatformClientPackageVersion, requirePlatformPackage } from '../../d
import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite'
import { normalizeRelativePath, toRelativePath } from '../../fs/path'
import { VoltraCliError } from '../../reporting/summary'
-import { createDynamicWidgetBuildInfo, evaluateWidgetModuleExports } from '../shared/widgetModule'
+import { createDynamicWidgetBuildInfo, createGeneratedWidgetModuleLoader } from '../shared/widgetModule'
import { buildPlistXml, parsePlistFile } from './plist'
import { resolveIOSWidgetTargetName } from './targetName'
+import type { WidgetModuleLoader } from '@use-voltra/compiler'
+
import type { IOSProjectDiscovery } from '../../discovery/ios'
import type {
IOSWidgetAppIntentParameter,
@@ -155,11 +157,17 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis
const voltraVersion = getPlatformClientPackageVersion(projectRoot, 'ios')
const targetName = resolveIOSWidgetTargetName(ios, discovery)
const targetPath = path.join(discovery.iosRoot, targetName)
- const detectedWidgets = detectClientRenderedWidgets(projectRoot, ios.widgets)
const mainAppMetadata = await readMainAppMetadata(discovery.infoPlistPath)
const changes: ReportedChange[] = []
const warnings: string[] = [...(await getDivergentMainAppMetadataWarnings(discovery, mainAppMetadata))]
const generatedFiles = new Set()
+ const widgetModuleLoader = createGeneratedWidgetModuleLoader(
+ projectRoot,
+ 'ios',
+ createGeneratedFilesError,
+ (message) => warnings.push(message)
+ )
+ const detectedWidgets = detectClientRenderedWidgets(projectRoot, widgetModuleLoader, ios.widgets)
mergeSingleResult(
await writeGeneratedTextFile(
@@ -191,7 +199,12 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis
const fontsResult = await copyIOSFonts(projectRoot, targetPath, ios.fonts)
mergeResult(fontsResult, changes, warnings, generatedFiles)
- const initialStatesResult = await generateInitialStatesSwift(projectRoot, detectedWidgets, voltraVersion)
+ const initialStatesResult = await generateInitialStatesSwift(
+ projectRoot,
+ widgetModuleLoader,
+ detectedWidgets,
+ voltraVersion
+ )
mergeSingleResult(
await writeGeneratedTextFile(
projectRoot,
@@ -482,6 +495,7 @@ async function readMainAppMetadata(infoPlistPath: string): Promise {
@@ -490,11 +504,11 @@ async function generateInitialStatesSwift(
)
const prerenderableServerWidgets = serverWidgets.filter((widget) => widget.initialStatePath)
const serverStates = await prerenderWidgetStates(
- projectRoot,
+ loader,
prerenderableServerWidgets,
loadIOSWidgetRenderer(projectRoot)
)
- const clientStates = await prerenderClientRenderedWidgets(projectRoot, widgets, voltraVersion)
+ const clientStates = await prerenderClientRenderedWidgets(projectRoot, loader, widgets, voltraVersion)
const prerenderedStates = new Map([...serverStates, ...clientStates])
if (prerenderedStates.size === 0) {
@@ -819,7 +833,7 @@ function createSwiftDictionaryLiteral(entries: string[]): string {
}
async function prerenderWidgetStates(
- projectRoot: string,
+ loader: WidgetModuleLoader,
widgets: NormalizedIOSWidgetConfig[],
renderer: IOSWidgetRenderer
): Promise {
@@ -841,7 +855,7 @@ async function prerenderWidgetStates(
throw new IOSGeneratedFilesError(`Initial state file not found for widget '${widget.id}' at ${modulePath}`)
}
- const widgetVariants = evaluateLegacyWidgetModule(projectRoot, modulePath)
+ const widgetVariants = evaluateLegacyWidgetModule(loader, modulePath)
localeStates.set(localeKey, renderer(widgetVariants))
}
@@ -851,9 +865,8 @@ async function prerenderWidgetStates(
return prerenderedStates
}
-function evaluateLegacyWidgetModule(projectRoot: string, filePath: string): WidgetVariants {
- const exports = evaluateWidgetModuleExports(projectRoot, filePath, createGeneratedFilesError) as { default?: unknown }
- const widgetVariants = exports.default ?? exports
+function evaluateLegacyWidgetModule(loader: WidgetModuleLoader, filePath: string): WidgetVariants {
+ const widgetVariants = loader.loadDefaultExport(filePath)
if (!widgetVariants || typeof widgetVariants !== 'object') {
throw new IOSGeneratedFilesError(`Widget file must export widget variants: ${filePath}`)
@@ -895,11 +908,19 @@ function createDynamicWidgetsManifest(widgets: DetectedIOSWidget[]): DynamicWidg
}
}
-function detectClientRenderedWidgets(projectRoot: string, widgets: NormalizedIOSWidgetConfig[]): DetectedIOSWidget[] {
- return widgets.map((widget) => detectSingleWidget(projectRoot, widget))
+function detectClientRenderedWidgets(
+ projectRoot: string,
+ loader: WidgetModuleLoader,
+ widgets: NormalizedIOSWidgetConfig[]
+): DetectedIOSWidget[] {
+ return widgets.map((widget) => detectSingleWidget(projectRoot, loader, widget))
}
-function detectSingleWidget(projectRoot: string, widget: NormalizedIOSWidgetConfig): DetectedIOSWidget {
+function detectSingleWidget(
+ projectRoot: string,
+ loader: WidgetModuleLoader,
+ widget: NormalizedIOSWidgetConfig
+): DetectedIOSWidget {
if (!widget.entry) {
return {
...widget,
@@ -913,7 +934,7 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedIOSWidgetConf
throw createGeneratedFilesError(`[voltra] Dynamic Widget "${widget.id}" entry not found at ${widget.entry}`)
}
- const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, clientSourcePath)
+ const widgetModule = safelyEvaluateDynamicWidget(loader, widget.id, widget.entry, clientSourcePath)
const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
if (typeof widgetFn !== 'function') {
@@ -932,6 +953,7 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedIOSWidgetConf
async function prerenderClientRenderedWidgets(
projectRoot: string,
+ loader: WidgetModuleLoader,
widgets: DetectedIOSWidget[],
voltraVersion: string
): Promise {
@@ -958,7 +980,7 @@ async function prerenderClientRenderedWidgets(
for (const widget of clientWidgets) {
try {
- const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, widget.clientSourcePath)
+ const widgetModule = safelyEvaluateDynamicWidget(loader, widget.id, widget.entry, widget.clientSourcePath)
const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
const element = widgetFn({}, placeholderEnv)
prerenderedStates.set(widget.id, new Map([[DEFAULT_INITIAL_STATE_LOCALE, JSON.stringify(renderer(element))]]))
@@ -979,13 +1001,13 @@ async function prerenderClientRenderedWidgets(
}
function safelyEvaluateDynamicWidget(
- projectRoot: string,
+ loader: WidgetModuleLoader,
widgetId: string,
widgetEntry: string,
clientSourcePath: string
): unknown {
try {
- return evaluateWidgetModuleExports(projectRoot, clientSourcePath, createGeneratedFilesError)
+ return loader.load(clientSourcePath)
} catch (error) {
if (error instanceof IOSGeneratedFilesError) {
throw error
diff --git a/packages/cli/src/platforms/shared/widgetModule.ts b/packages/cli/src/platforms/shared/widgetModule.ts
index 2402377b..e08f3d56 100644
--- a/packages/cli/src/platforms/shared/widgetModule.ts
+++ b/packages/cli/src/platforms/shared/widgetModule.ts
@@ -1,11 +1,6 @@
-import fs from 'node:fs'
-import path from 'node:path'
-import vm from 'node:vm'
-import { createRequire } from 'node:module'
+import { createWidgetModuleLoader, type WidgetModuleLoader } from '@use-voltra/compiler'
-import * as babel from '@babel/core'
-
-const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '']
+import type { VoltraPlatform } from '../../config/types'
export interface WidgetModuleBuildInfo {
isDev: false
@@ -23,133 +18,18 @@ export function createDynamicWidgetBuildInfo(voltraVersion: string): WidgetModul
}
}
-export function evaluateWidgetModuleExports(
+/**
+ * Loader used by the apply pipeline to evaluate widget source at build time.
+ *
+ * The evaluation rules — which imports are allowed and what they resolve to — live in
+ * `@use-voltra/compiler` so that `voltra apply`, the Expo config plugins, and the Metro
+ * widget bundler all apply the same contract.
+ */
+export function createGeneratedWidgetModuleLoader(
projectRoot: string,
- filePath: string,
- createError: (message: string) => Error
-): unknown {
- const projectRequire = createProjectRequire(projectRoot)
- const moduleCache = new Map()
-
- const customRequire = (moduleSpecifier: string, currentDir: string): unknown => {
- if (!isLocalModule(moduleSpecifier)) {
- return projectRequire(moduleSpecifier)
- }
-
- const resolvedModulePath = resolveModulePath(moduleSpecifier, currentDir)
-
- if (!resolvedModulePath) {
- throw createError(`Cannot resolve module '${moduleSpecifier}' from '${currentDir}'`)
- }
-
- const cachedModule = moduleCache.get(resolvedModulePath)
- if (cachedModule !== undefined) {
- return cachedModule
- }
-
- const transpiledCode = transpileWidgetModule(projectRoot, resolvedModulePath, projectRequire, createError)
- const moduleDir = path.dirname(resolvedModulePath)
- const moduleRecord = { exports: {} as Record }
- moduleCache.set(resolvedModulePath, moduleRecord.exports)
-
- const context = vm.createContext({
- __dirname: moduleDir,
- __filename: resolvedModulePath,
- console,
- exports: moduleRecord.exports,
- module: moduleRecord,
- process,
- require: (specifier: string) => customRequire(specifier, moduleDir),
- })
-
- const script = new vm.Script(transpiledCode, { filename: resolvedModulePath })
- script.runInContext(context)
-
- moduleCache.set(resolvedModulePath, moduleRecord.exports)
- return moduleRecord.exports
- }
-
- return customRequire(filePath, path.dirname(filePath))
-}
-
-function transpileWidgetModule(
- projectRoot: string,
- filePath: string,
- projectRequire: NodeRequire,
- createError: (message: string) => Error
-): string {
- const source = fs.readFileSync(filePath, 'utf8')
- const projectBabelConfigPath = resolveProjectBabelConfig(projectRoot)
- const result = babel.transformSync(source, {
- babelrc: false,
- configFile: projectBabelConfigPath,
- cwd: projectRoot,
- filename: filePath,
- presets: projectBabelConfigPath ? undefined : [resolveFallbackBabelPreset(projectRequire, createError)],
- })
-
- if (!result?.code) {
- throw createError(`Babel transpilation failed for ${filePath}`)
- }
-
- return result.code
-}
-
-function resolveProjectBabelConfig(projectRoot: string): string | undefined {
- const candidates = ['babel.config.js', 'babel.config.cjs', 'babel.config.mjs']
-
- for (const candidate of candidates) {
- const candidatePath = path.join(projectRoot, candidate)
- if (fs.existsSync(candidatePath)) {
- return candidatePath
- }
- }
-
- return undefined
-}
-
-function resolveFallbackBabelPreset(projectRequire: NodeRequire, createError: (message: string) => Error): string {
- try {
- return projectRequire.resolve('@react-native/babel-preset')
- } catch {
- try {
- return projectRequire.resolve('babel-preset-expo')
- } catch {
- throw createError(
- 'Could not resolve a Babel preset for widget evaluation. Add a project babel.config.js or install @react-native/babel-preset.'
- )
- }
- }
-}
-
-function createProjectRequire(projectRoot: string): NodeRequire {
- return createRequire(path.join(projectRoot, 'package.json'))
-}
-
-function isLocalModule(moduleSpecifier: string): boolean {
- return moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/')
-}
-
-function resolveModulePath(moduleSpecifier: string, fromDir: string): string | null {
- const basePath = path.resolve(fromDir, moduleSpecifier)
-
- for (const extension of MODULE_EXTENSIONS) {
- const candidate = `${basePath}${extension}`
- if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
- return candidate
- }
- }
-
- if (!fs.existsSync(basePath) || !fs.statSync(basePath).isDirectory()) {
- return null
- }
-
- for (const extension of MODULE_EXTENSIONS) {
- const indexCandidate = path.join(basePath, `index${extension}`)
- if (fs.existsSync(indexCandidate) && fs.statSync(indexCandidate).isFile()) {
- return indexCandidate
- }
- }
-
- return null
+ platform: VoltraPlatform,
+ createError: (message: string) => Error,
+ onWarning?: (message: string) => void
+): WidgetModuleLoader {
+ return createWidgetModuleLoader({ projectRoot, platform, createError, onWarning })
}
diff --git a/packages/cli/tsconfig.typecheck.json b/packages/cli/tsconfig.typecheck.json
index 4979fccb..ea5b3db8 100644
--- a/packages/cli/tsconfig.typecheck.json
+++ b/packages/cli/tsconfig.typecheck.json
@@ -1,6 +1,11 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
- "noEmit": true
+ "noEmit": true,
+ "rootDir": "../..",
+ "baseUrl": "../..",
+ "paths": {
+ "@use-voltra/compiler": ["packages/compiler/src/index.ts"]
+ }
}
}
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index aa175f66..11c11465 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -12,6 +12,18 @@
"import": "./build/esm/index.js",
"default": "./build/esm/index.js"
},
+ "./react-native/ios": {
+ "types": "./build/types/react-native/ios.d.ts",
+ "require": "./build/cjs/react-native/ios.js",
+ "import": "./build/esm/react-native/ios.js",
+ "default": "./build/esm/react-native/ios.js"
+ },
+ "./react-native/android": {
+ "types": "./build/types/react-native/android.d.ts",
+ "require": "./build/cjs/react-native/android.js",
+ "import": "./build/esm/react-native/android.js",
+ "default": "./build/esm/react-native/android.js"
+ },
"./package.json": "./package.json"
},
"files": [
@@ -23,9 +35,10 @@
"clean": "rm -rf build",
"lint": "oxlint src",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
- "test": "node --test"
+ "test": "node --import tsx --test"
},
"dependencies": {
+ "@babel/core": "^7.27.4",
"@babel/parser": "^7.27.4",
"@babel/types": "^7.27.4"
},
@@ -44,5 +57,11 @@
"url": "https://github.com/callstackincubator/voltra/issues"
},
"license": "MIT",
- "homepage": "https://use-voltra.dev"
+ "homepage": "https://use-voltra.dev",
+ "devDependencies": {
+ "@babel/runtime": "^7.27.4",
+ "@react-native/babel-preset": "0.83.2",
+ "@types/babel__core": "^7.20.5",
+ "tsx": "^4.19.0"
+ }
}
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index e9f7b266..48491c20 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -239,3 +239,16 @@ export function scanVoltraDirectives({
return widgets
}
+
+export { createWidgetModuleLoader } from './widget-module/loader.js'
+export type { CreateWidgetModuleLoaderOptions, WidgetModuleLoader } from './widget-module/loader.js'
+export {
+ describeBlockedWidgetImport,
+ describeUnsupportedReactNativeExport,
+ getWidgetReactNativeShimSpecifier,
+ isReactNativeImport,
+ resolveWidgetImport,
+ SUPPORTED_REACT_NATIVE_EXPORTS,
+ WIDGET_REACT_NATIVE_SHIM_PACKAGE,
+} from './widget-module/policy.js'
+export type { WidgetImportResolution, WidgetModulePlatform } from './widget-module/policy.js'
diff --git a/packages/compiler/src/react-native/android.ts b/packages/compiler/src/react-native/android.ts
new file mode 100644
index 00000000..35eeaa4d
--- /dev/null
+++ b/packages/compiler/src/react-native/android.ts
@@ -0,0 +1,5 @@
+/** The `react-native` surface served to Android widget code. */
+import { createPlatform } from './platform.js'
+
+export const Platform = createPlatform('android')
+export * from './shim.js'
diff --git a/packages/compiler/src/react-native/ios.ts b/packages/compiler/src/react-native/ios.ts
new file mode 100644
index 00000000..3fc3ca86
--- /dev/null
+++ b/packages/compiler/src/react-native/ios.ts
@@ -0,0 +1,5 @@
+/** The `react-native` surface served to iOS widget code. */
+import { createPlatform } from './platform.js'
+
+export const Platform = createPlatform('ios')
+export * from './shim.js'
diff --git a/packages/compiler/src/react-native/platform.ts b/packages/compiler/src/react-native/platform.ts
new file mode 100644
index 00000000..60e45b61
--- /dev/null
+++ b/packages/compiler/src/react-native/platform.ts
@@ -0,0 +1,53 @@
+/**
+ * `Platform` as widget code sees it.
+ *
+ * Kept out of `shim.ts` so that module holds only the widget-visible surface and the
+ * per-platform entry points can re-export it wholesale.
+ */
+
+import { describeUnsupportedReactNativeExport, type WidgetModulePlatform } from '../widget-module/policy.js'
+
+function unsupported(symbol: string): never {
+ throw new Error(describeUnsupportedReactNativeExport(symbol))
+}
+
+export type WidgetPlatform = {
+ readonly OS: WidgetModulePlatform
+ select(specifics: PlatformSelectSpec): T | undefined
+}
+
+type PlatformSelectSpec = {
+ ios?: T
+ android?: T
+ native?: T
+ default?: T
+}
+
+export function createPlatform(platform: WidgetModulePlatform): WidgetPlatform {
+ return Object.freeze({
+ OS: platform,
+ select(specifics: PlatformSelectSpec): T | undefined {
+ if (specifics && platform in specifics) {
+ return specifics[platform]
+ }
+
+ if (specifics && 'native' in specifics) {
+ return specifics.native
+ }
+
+ return specifics?.default
+ },
+ get Version(): never {
+ return unsupported('Platform.Version')
+ },
+ get constants(): never {
+ return unsupported('Platform.constants')
+ },
+ get isTV(): never {
+ return unsupported('Platform.isTV')
+ },
+ get isTesting(): never {
+ return unsupported('Platform.isTesting')
+ },
+ }) as WidgetPlatform
+}
diff --git a/packages/compiler/src/react-native/shim.node.test.ts b/packages/compiler/src/react-native/shim.node.test.ts
new file mode 100644
index 00000000..8c1c5853
--- /dev/null
+++ b/packages/compiler/src/react-native/shim.node.test.ts
@@ -0,0 +1,34 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import * as android from './android.js'
+import * as ios from './ios.js'
+
+/**
+ * Metro resolves these entry points directly, with no loader in between, so whatever they
+ * export is exactly what a Dynamic Widget sees on device.
+ */
+describe('react-native shim entry points', () => {
+ it('reports the platform it was resolved for', () => {
+ assert.equal(ios.Platform.OS, 'ios')
+ assert.equal(android.Platform.OS, 'android')
+ assert.equal(ios.Platform.select({ ios: 'a', android: 'b' }), 'a')
+ assert.equal(android.Platform.select({ native: 'n', default: 'd' }), 'n')
+ assert.equal(android.Platform.select({ default: 'd' }), 'd')
+ })
+
+ it('passes styles through unchanged', () => {
+ const styles = ios.StyleSheet.create({ title: { fontSize: 12 } })
+ assert.deepEqual(styles.title, { fontSize: 12 })
+ assert.deepEqual(ios.StyleSheet.flatten([{ a: 1 }, false, [{ b: 2 }]]), { a: 1, b: 2 })
+ assert.deepEqual(ios.StyleSheet.compose({ a: 1 }, null), { a: 1 })
+ })
+
+ it('rejects unsupported exports on use rather than reading as undefined', () => {
+ assert.notEqual(ios.View, undefined)
+ assert.throws(() => (ios.View as () => void)(), /'View' is not available to Voltra widget code/)
+ assert.throws(() => ios.Animated.timing, /'Animated' is not available to Voltra widget code/)
+ assert.throws(() => new (android.NativeEventEmitter as new () => void)(), /'NativeEventEmitter' is not available/)
+ assert.throws(() => ios.Platform.Version, /'Platform.Version' is not available/)
+ })
+})
diff --git a/packages/compiler/src/react-native/shim.ts b/packages/compiler/src/react-native/shim.ts
new file mode 100644
index 00000000..f67ea57d
--- /dev/null
+++ b/packages/compiler/src/react-native/shim.ts
@@ -0,0 +1,149 @@
+/**
+ * The `react-native` surface available to Voltra widget code.
+ *
+ * Widget code does not run against the React Native runtime: at build time it is
+ * evaluated in a Node VM, and on device it runs in a separate JS engine with no
+ * bridge and no native modules. Only the parts of `react-native` that are pure
+ * data manipulation can be honoured, so this shim implements `StyleSheet` and
+ * `Platform` and rejects everything else with an actionable message instead of
+ * silently handing back `undefined`.
+ */
+
+import { describeUnsupportedReactNativeExport } from '../widget-module/policy.js'
+
+type Style = Record
+type StyleInput = Style | false | null | undefined | ReadonlyArray
+
+function unsupported(symbol: string): never {
+ throw new Error(describeUnsupportedReactNativeExport(symbol))
+}
+
+/**
+ * Stand-in for a React Native export widget code cannot use.
+ *
+ * Importing it is harmless; rendering it, calling it, or reading anything off it throws.
+ * A function proxy covers all three, so ``, `Animated.timing(...)`, and
+ * `new NativeEventEmitter()` all fail with the same message.
+ */
+function createRejectedExport(symbol: string): any {
+ const reject = (): never => unsupported(symbol)
+
+ return new Proxy(
+ function rejectedReactNativeExport(): never {
+ return reject()
+ },
+ {
+ apply: reject,
+ construct: reject,
+ get(target, property) {
+ // Symbols are read by tooling (React's element check, `util.inspect`) before anything
+ // renders, so answering those keeps the error at the point of actual use.
+ return typeof property === 'symbol' ? Reflect.get(target, property) : reject()
+ },
+ }
+ )
+}
+
+const absoluteFillObject: Style = Object.freeze({
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+})
+
+function flatten(style: StyleInput): Style {
+ if (!style) {
+ return {}
+ }
+
+ if (!Array.isArray(style)) {
+ return style as Style
+ }
+
+ const flattened: Style = {}
+
+ for (const entry of style) {
+ const resolved = flatten(entry as StyleInput)
+
+ for (const key of Object.keys(resolved)) {
+ flattened[key] = resolved[key]
+ }
+ }
+
+ return flattened
+}
+
+/**
+ * `StyleSheet` is an identity mapping in Voltra: widget styles are plain objects that
+ * are serialized into the widget payload, so there is no registry to allocate ids in.
+ */
+export const StyleSheet = Object.freeze({
+ create>(styles: T): T {
+ return styles
+ },
+ flatten,
+ compose(first: StyleInput, second: StyleInput): StyleInput {
+ if (first === null || first === undefined) {
+ return second
+ }
+
+ return second === null || second === undefined ? first : [first, second]
+ },
+ absoluteFill: absoluteFillObject,
+ absoluteFillObject,
+ /**
+ * Widgets are rendered by the host OS, which does not expose a screen scale to the
+ * widget process, so the thinnest expressible line is a single point.
+ */
+ hairlineWidth: 1,
+})
+
+export const ActivityIndicator = createRejectedExport('ActivityIndicator')
+export const Alert = createRejectedExport('Alert')
+export const Animated = createRejectedExport('Animated')
+export const AppRegistry = createRejectedExport('AppRegistry')
+export const AppState = createRejectedExport('AppState')
+export const Appearance = createRejectedExport('Appearance')
+export const Button = createRejectedExport('Button')
+export const DeviceEventEmitter = createRejectedExport('DeviceEventEmitter')
+export const Dimensions = createRejectedExport('Dimensions')
+export const Easing = createRejectedExport('Easing')
+export const FlatList = createRejectedExport('FlatList')
+export const I18nManager = createRejectedExport('I18nManager')
+export const Image = createRejectedExport('Image')
+export const ImageBackground = createRejectedExport('ImageBackground')
+export const InteractionManager = createRejectedExport('InteractionManager')
+export const Keyboard = createRejectedExport('Keyboard')
+export const KeyboardAvoidingView = createRejectedExport('KeyboardAvoidingView')
+export const LayoutAnimation = createRejectedExport('LayoutAnimation')
+export const Linking = createRejectedExport('Linking')
+export const Modal = createRejectedExport('Modal')
+export const NativeEventEmitter = createRejectedExport('NativeEventEmitter')
+export const NativeModules = createRejectedExport('NativeModules')
+export const PanResponder = createRejectedExport('PanResponder')
+export const PermissionsAndroid = createRejectedExport('PermissionsAndroid')
+export const PixelRatio = createRejectedExport('PixelRatio')
+export const Pressable = createRejectedExport('Pressable')
+export const SafeAreaView = createRejectedExport('SafeAreaView')
+export const ScrollView = createRejectedExport('ScrollView')
+export const SectionList = createRejectedExport('SectionList')
+export const Share = createRejectedExport('Share')
+export const StatusBar = createRejectedExport('StatusBar')
+export const Switch = createRejectedExport('Switch')
+export const Text = createRejectedExport('Text')
+export const TextInput = createRejectedExport('TextInput')
+export const ToastAndroid = createRejectedExport('ToastAndroid')
+export const TouchableHighlight = createRejectedExport('TouchableHighlight')
+export const TouchableOpacity = createRejectedExport('TouchableOpacity')
+export const TouchableWithoutFeedback = createRejectedExport('TouchableWithoutFeedback')
+export const TurboModuleRegistry = createRejectedExport('TurboModuleRegistry')
+export const UIManager = createRejectedExport('UIManager')
+export const Vibration = createRejectedExport('Vibration')
+export const View = createRejectedExport('View')
+export const VirtualizedList = createRejectedExport('VirtualizedList')
+export const findNodeHandle = createRejectedExport('findNodeHandle')
+export const processColor = createRejectedExport('processColor')
+export const requireNativeComponent = createRejectedExport('requireNativeComponent')
+export const useColorScheme = createRejectedExport('useColorScheme')
+export const useWindowDimensions = createRejectedExport('useWindowDimensions')
diff --git a/packages/compiler/src/widget-module/loader.node.test.ts b/packages/compiler/src/widget-module/loader.node.test.ts
new file mode 100644
index 00000000..7b5dec5d
--- /dev/null
+++ b/packages/compiler/src/widget-module/loader.node.test.ts
@@ -0,0 +1,195 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { after, describe, it } from 'node:test'
+
+import { createWidgetModuleLoader } from './loader.js'
+import type { WidgetModulePlatform } from './policy.js'
+
+const temporaryRoots: string[] = []
+
+after(() => {
+ for (const root of temporaryRoots) {
+ fs.rmSync(root, { force: true, recursive: true })
+ }
+})
+
+/**
+ * Create a throwaway project whose Babel setup mirrors what an app provides, so the
+ * loader exercises the same transpile path it takes in a real project.
+ */
+function createProject(files: Record, babelConfigFilename: string | null = 'babel.config.js'): string {
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-widget-module-'))
+ temporaryRoots.push(projectRoot)
+
+ fs.writeFileSync(path.join(projectRoot, 'package.json'), JSON.stringify({ name: 'fixture-project' }))
+
+ if (babelConfigFilename) {
+ const presets = [require.resolve('@react-native/babel-preset')]
+ fs.writeFileSync(
+ path.join(projectRoot, babelConfigFilename),
+ babelConfigFilename.endsWith('.json')
+ ? JSON.stringify({ presets })
+ : `module.exports = { presets: ${JSON.stringify(presets)} }\n`
+ )
+ }
+
+ // Babel's runtime helpers are injected into the transpiled output, so the fixture has to
+ // resolve them the way a real app does.
+ const runtimeDir = path.dirname(require.resolve('@babel/runtime/package.json'))
+ const runtimeLink = path.join(projectRoot, 'node_modules', '@babel', 'runtime')
+ fs.mkdirSync(path.dirname(runtimeLink), { recursive: true })
+ fs.mkdirSync(path.join(projectRoot, 'node_modules', '@react-native'), { recursive: true })
+ fs.symlinkSync(runtimeDir, runtimeLink, 'dir')
+
+ for (const [relativePath, contents] of Object.entries(files)) {
+ const filePath = path.join(projectRoot, relativePath)
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
+ fs.writeFileSync(filePath, contents)
+ }
+
+ return projectRoot
+}
+
+function writePackage(projectRoot: string, packageName: string, source: string): void {
+ const packageDir = path.join(projectRoot, 'node_modules', ...packageName.split('/'))
+ fs.mkdirSync(packageDir, { recursive: true })
+ fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({ name: packageName, main: 'index.js' }))
+ fs.writeFileSync(path.join(packageDir, 'index.js'), source)
+}
+
+function load(projectRoot: string, entry: string, platform: WidgetModulePlatform = 'ios'): any {
+ const warnings: string[] = []
+ const loader = createWidgetModuleLoader({ projectRoot, platform, onWarning: (message) => warnings.push(message) })
+ return { exports: loader.load(path.join(projectRoot, entry)), loader, warnings }
+}
+
+describe('createWidgetModuleLoader', () => {
+ it("serves StyleSheet and Platform for imports from 'react-native'", () => {
+ const projectRoot = createProject({
+ 'widget.ts': [
+ "import { Platform, StyleSheet } from 'react-native'",
+ 'const styles = StyleSheet.create({ title: { fontSize: 12 } })',
+ 'export const style = styles.title',
+ 'export const os = Platform.OS',
+ "export const selected = Platform.select({ ios: 'apple', android: 'robot' })",
+ '',
+ ].join('\n'),
+ })
+
+ const ios = load(projectRoot, 'widget.ts', 'ios').exports
+ assert.deepEqual({ ...ios.style }, { fontSize: 12 })
+ assert.equal(ios.os, 'ios')
+ assert.equal(ios.selected, 'apple')
+
+ const android = load(projectRoot, 'widget.ts', 'android').exports
+ assert.equal(android.os, 'android')
+ assert.equal(android.selected, 'robot')
+ })
+
+ it('flattens nested styles the way StyleSheet.flatten does', () => {
+ const projectRoot = createProject({
+ 'widget.ts': [
+ "import { StyleSheet } from 'react-native'",
+ 'export const flat = StyleSheet.flatten([{ a: 1 }, null, [{ b: 2 }, { a: 3 }]])',
+ '',
+ ].join('\n'),
+ })
+
+ assert.deepEqual({ ...load(projectRoot, 'widget.ts').exports.flat }, { a: 3, b: 2 })
+ })
+
+ it('rejects a react-native symbol the shim does not name at all', () => {
+ const projectRoot = createProject({
+ 'widget.ts': ["import { YellowBox } from 'react-native'", 'export default YellowBox', ''].join('\n'),
+ })
+
+ assert.throws(() => load(projectRoot, 'widget.ts'), /'YellowBox' is not available to Voltra widget code/)
+ })
+
+ it('lets a known-unsupported symbol import, then rejects it on use', () => {
+ // The shim names these so a Metro bundle — which has no loader to intercept reads —
+ // fails loudly too. Importing stays harmless; using it does not.
+ const projectRoot = createProject({
+ 'widget.ts': ["import { Animated } from 'react-native'", 'export default () => Animated.timing', ''].join('\n'),
+ })
+
+ const widget = load(projectRoot, 'widget.ts').exports.default as () => unknown
+ assert.throws(() => widget(), /'Animated' is not available to Voltra widget code/)
+ })
+
+ it('rejects a deep react-native import', () => {
+ const projectRoot = createProject({
+ 'widget.ts': ["import x from 'react-native/Libraries/Text/Text'", 'export default x', ''].join('\n'),
+ })
+
+ assert.throws(() => load(projectRoot, 'widget.ts'), /cannot import 'react-native\/Libraries\/Text\/Text'/)
+ })
+
+ it('redirects client packages to their rendering package and warns once', () => {
+ const projectRoot = createProject({
+ 'widget.ts': ["export { label } from '@use-voltra/ios-client'", ''].join('\n'),
+ 'other.ts': ["export { label } from '@use-voltra/ios-client'", ''].join('\n'),
+ })
+ writePackage(projectRoot, '@use-voltra/ios', "exports.label = 'ios'\n")
+ writePackage(projectRoot, '@use-voltra/ios-client', "throw new Error('client package should not load')\n")
+
+ const warnings: string[] = []
+ const loader = createWidgetModuleLoader({
+ projectRoot,
+ platform: 'ios',
+ onWarning: (message) => warnings.push(message),
+ })
+
+ assert.equal(loader.load(path.join(projectRoot, 'widget.ts')).label, 'ios')
+ assert.equal(loader.load(path.join(projectRoot, 'other.ts')).label, 'ios')
+ assert.deepEqual(warnings, ["Widget code imported '@use-voltra/ios-client'. Using '@use-voltra/ios' instead."])
+ })
+
+ it('resolves relative imports, directory indexes, and circular graphs', () => {
+ const projectRoot = createProject({
+ 'widget.ts': ["import { name } from './shared'", 'export const label = `hello ${name}`', ''].join('\n'),
+ 'shared/index.ts': ["export const name = 'widget'", ''].join('\n'),
+ })
+
+ assert.equal(load(projectRoot, 'widget.ts').exports.label, 'hello widget')
+ })
+
+ it('reads the default export, falling back to the exports object', () => {
+ const projectRoot = createProject({
+ 'default.ts': 'export default { variants: 1 }\n',
+ 'named.ts': 'export const variants = 2\n',
+ })
+ const loader = createWidgetModuleLoader({ projectRoot, platform: 'android' })
+
+ assert.deepEqual({ ...(loader.loadDefaultExport(path.join(projectRoot, 'default.ts')) as any) }, { variants: 1 })
+ assert.equal((loader.loadDefaultExport(path.join(projectRoot, 'named.ts')) as any).variants, 2)
+ })
+
+ it("honours a project's babel.config.json, which Babel discovers on its own", () => {
+ const projectRoot = createProject({ 'widget.ts': 'export const answer: number = 42\n' }, 'babel.config.json')
+
+ assert.equal(load(projectRoot, 'widget.ts').exports.answer, 42)
+ })
+
+ it('falls back to an installed preset when the project defines no Babel configuration', () => {
+ const projectRoot = createProject({ 'widget.ts': 'export const answer: number = 42\n' }, null)
+ const runtimeDir = path.dirname(require.resolve('@react-native/babel-preset/package.json'))
+ fs.symlinkSync(runtimeDir, path.join(projectRoot, 'node_modules', '@react-native', 'babel-preset'), 'dir')
+
+ assert.equal(load(projectRoot, 'widget.ts').exports.answer, 42)
+ })
+
+ it('wraps failures with the caller-supplied error factory', () => {
+ const projectRoot = createProject({ 'widget.ts': "import './missing'\n" })
+ class CustomError extends Error {}
+ const loader = createWidgetModuleLoader({
+ projectRoot,
+ platform: 'ios',
+ createError: (message) => new CustomError(message),
+ })
+
+ assert.throws(() => loader.load(path.join(projectRoot, 'widget.ts')), CustomError)
+ })
+})
diff --git a/packages/compiler/src/widget-module/loader.ts b/packages/compiler/src/widget-module/loader.ts
new file mode 100644
index 00000000..1e029704
--- /dev/null
+++ b/packages/compiler/src/widget-module/loader.ts
@@ -0,0 +1,263 @@
+import fs from 'node:fs'
+import { createRequire } from 'node:module'
+import path from 'node:path'
+import vm from 'node:vm'
+
+import * as babel from '@babel/core'
+
+import * as reactNativeAndroid from '../react-native/android.js'
+import * as reactNativeIos from '../react-native/ios.js'
+import {
+ describeUnsupportedReactNativeExport,
+ getWidgetReactNativeShimSpecifier,
+ resolveWidgetImport,
+ type WidgetModulePlatform,
+} from './policy.js'
+
+/** Extensions tried when resolving a relative import from widget code. */
+const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '']
+
+/**
+ * Presets used when the project defines no Babel configuration at all.
+ *
+ * `babel-preset-expo` comes first because it wraps `@react-native/babel-preset` and adds the
+ * transforms an Expo app needs; a bare React Native project resolves only the latter.
+ */
+const FALLBACK_BABEL_PRESETS = ['babel-preset-expo', '@react-native/babel-preset']
+
+export interface CreateWidgetModuleLoaderOptions {
+ /** Root of the app project. Bare imports and Babel configuration resolve from here. */
+ projectRoot: string
+ /** Platform the widget is being loaded for. Drives `Platform.OS` in widget code. */
+ platform: WidgetModulePlatform
+ /** Called once per distinct message when an import is served from somewhere else. */
+ onWarning?: (message: string) => void
+ /** Wraps loader failures so callers can surface them as their own error type. */
+ createError?: (message: string) => Error
+}
+
+export interface WidgetModuleLoader {
+ /** Evaluate `entryPath` and return its exports object. */
+ load(entryPath: string): TExports
+ /** Evaluate `entryPath` and return its default export, falling back to the exports object. */
+ loadDefaultExport(entryPath: string): TExport
+}
+
+/**
+ * Create a loader that evaluates widget source the way Voltra's build steps need it.
+ *
+ * Relative imports are transpiled with the project's Babel setup and evaluated in a
+ * Node VM; bare imports go through {@link resolveWidgetImport} so that build-time
+ * evaluation and the on-device Metro bundle agree on what widget code may import.
+ *
+ * Each `load` call gets a fresh module cache, matching Node's behaviour of evaluating a
+ * dependency graph once per entry point. Warnings are deduplicated across the loader's
+ * lifetime so a project-wide message is reported once, not once per widget.
+ */
+export function createWidgetModuleLoader({
+ projectRoot,
+ platform,
+ onWarning,
+ createError = (message) => new Error(message),
+}: CreateWidgetModuleLoaderOptions): WidgetModuleLoader {
+ const projectRequire = createRequire(path.join(projectRoot, 'package.json'))
+ const warnedMessages = new Set()
+
+ function warnOnce(message: string): void {
+ if (warnedMessages.has(message)) {
+ return
+ }
+
+ warnedMessages.add(message)
+ onWarning?.(message)
+ }
+
+ function requireBareModule(specifier: string): unknown {
+ const resolution = resolveWidgetImport(specifier, platform)
+
+ if (resolution.kind === 'blocked') {
+ throw createError(resolution.reason)
+ }
+
+ if (resolution.kind === 'passthrough') {
+ return projectRequire(specifier)
+ }
+
+ if (resolution.warning) {
+ warnOnce(resolution.warning)
+ }
+
+ if (resolution.specifier === getWidgetReactNativeShimSpecifier(platform)) {
+ return createReactNativeShimExports(platform)
+ }
+
+ return projectRequire(resolution.specifier)
+ }
+
+ function load(entryPath: string): TExports {
+ const moduleCache = new Map()
+
+ function customRequire(specifier: string, currentDir: string): unknown {
+ if (!isRelativeModule(specifier)) {
+ return requireBareModule(specifier)
+ }
+
+ const resolvedPath = resolveModulePath(specifier, currentDir)
+
+ if (!resolvedPath) {
+ throw createError(`Cannot resolve module '${specifier}' from '${currentDir}'`)
+ }
+
+ if (moduleCache.has(resolvedPath)) {
+ return moduleCache.get(resolvedPath)
+ }
+
+ const transpiledCode = transpile(resolvedPath)
+ const moduleDir = path.dirname(resolvedPath)
+ const moduleRecord = { exports: {} as Record }
+
+ // Cache before evaluating so circular imports see a partial exports object
+ // instead of re-entering evaluation.
+ moduleCache.set(resolvedPath, moduleRecord.exports)
+
+ const context = vm.createContext({
+ __dirname: moduleDir,
+ __filename: resolvedPath,
+ console,
+ exports: moduleRecord.exports,
+ module: moduleRecord,
+ process,
+ require: (nestedSpecifier: string) => customRequire(nestedSpecifier, moduleDir),
+ })
+
+ new vm.Script(transpiledCode, { filename: resolvedPath }).runInContext(context)
+
+ // `module.exports` may have been reassigned wholesale.
+ moduleCache.set(resolvedPath, moduleRecord.exports)
+
+ return moduleRecord.exports
+ }
+
+ return customRequire(entryPath, path.dirname(entryPath)) as TExports
+ }
+
+ function transpile(filePath: string): string {
+ const source = fs.readFileSync(filePath, 'utf8')
+ // Let Babel find the project's root configuration itself rather than guessing at
+ // filenames: it knows about babel.config.json, .cjs, .mjs and .ts, and a project that
+ // uses one expects its plugins to apply to widget code too.
+ const baseOptions: babel.TransformOptions = {
+ babelrc: false,
+ cwd: projectRoot,
+ filename: filePath,
+ root: projectRoot,
+ }
+ const hasProjectConfig = Boolean(babel.loadPartialConfig(baseOptions)?.config)
+ const result = babel.transformSync(source, {
+ ...baseOptions,
+ ...(hasProjectConfig
+ ? {}
+ : { configFile: false, presets: [resolveFallbackBabelPreset(projectRequire, createError)] }),
+ })
+
+ if (!result?.code) {
+ throw createError(`Babel transpilation failed for ${filePath}`)
+ }
+
+ return result.code
+ }
+
+ return {
+ load,
+ loadDefaultExport(entryPath: string): TExport {
+ const exports = load | undefined>(entryPath)
+
+ if (exports && typeof exports === 'object' && 'default' in exports && exports.default !== undefined) {
+ return exports.default as TExport
+ }
+
+ return exports as TExport
+ },
+ }
+}
+
+/**
+ * Serve the shim behind a proxy so a named import the shim does not implement fails with
+ * an actionable message instead of becoming `undefined` at render time.
+ *
+ * The shim is imported from this package rather than resolved against the app project,
+ * which need not depend on it directly.
+ */
+function createReactNativeShimExports(platform: WidgetModulePlatform): unknown {
+ const shim = platform === 'ios' ? reactNativeIos : reactNativeAndroid
+ // Copy descriptors rather than values so the shim's rejecting stubs are carried over
+ // without being invoked, and so the proxy target is an ordinary extensible object.
+ const shimExports = Object.defineProperties(
+ {},
+ { ...Object.getOwnPropertyDescriptors(shim), __esModule: { value: true } }
+ ) as Record
+
+ return new Proxy(shimExports, {
+ get(target, property) {
+ if (typeof property === 'symbol' || property in target) {
+ return Reflect.get(target, property)
+ }
+
+ throw new Error(describeUnsupportedReactNativeExport(String(property)))
+ },
+ })
+}
+
+function isRelativeModule(specifier: string): boolean {
+ return specifier.startsWith('.') || specifier.startsWith('/')
+}
+
+function resolveModulePath(specifier: string, fromDir: string): string | null {
+ const basePath = path.resolve(fromDir, specifier)
+
+ for (const extension of MODULE_EXTENSIONS) {
+ const candidate = `${basePath}${extension}`
+
+ if (isFile(candidate)) {
+ return candidate
+ }
+ }
+
+ if (!isDirectory(basePath)) {
+ return null
+ }
+
+ for (const extension of MODULE_EXTENSIONS) {
+ const candidate = path.join(basePath, `index${extension}`)
+
+ if (isFile(candidate)) {
+ return candidate
+ }
+ }
+
+ return null
+}
+
+function isFile(candidate: string): boolean {
+ return fs.existsSync(candidate) && fs.statSync(candidate).isFile()
+}
+
+function isDirectory(candidate: string): boolean {
+ return fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()
+}
+
+function resolveFallbackBabelPreset(projectRequire: NodeRequire, createError: (message: string) => Error): string {
+ for (const preset of FALLBACK_BABEL_PRESETS) {
+ try {
+ return projectRequire.resolve(preset)
+ } catch {
+ continue
+ }
+ }
+
+ throw createError(
+ `Could not resolve a Babel preset for widget evaluation. Add a project Babel configuration or install one of ${FALLBACK_BABEL_PRESETS.join(
+ ', '
+ )}.`
+ )
+}
diff --git a/packages/compiler/src/widget-module/policy.node.test.ts b/packages/compiler/src/widget-module/policy.node.test.ts
new file mode 100644
index 00000000..0aaddfbf
--- /dev/null
+++ b/packages/compiler/src/widget-module/policy.node.test.ts
@@ -0,0 +1,37 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import { getWidgetReactNativeShimSpecifier, isReactNativeImport, resolveWidgetImport } from './policy'
+
+describe('resolveWidgetImport', () => {
+ it('aliases react-native to the platform shim', () => {
+ assert.deepEqual(resolveWidgetImport('react-native', 'android'), {
+ kind: 'alias',
+ specifier: getWidgetReactNativeShimSpecifier('android'),
+ })
+ })
+
+ it('blocks react-native deep imports', () => {
+ const resolution = resolveWidgetImport('react-native/Libraries/Image/Image', 'ios')
+ assert.equal(resolution.kind, 'blocked')
+ })
+
+ it('aliases client packages to their rendering package with a warning', () => {
+ assert.deepEqual(resolveWidgetImport('@use-voltra/android-client', 'android'), {
+ kind: 'alias',
+ specifier: '@use-voltra/android',
+ warning: "Widget code imported '@use-voltra/android-client'. Using '@use-voltra/android' instead.",
+ })
+ })
+
+ it('passes everything else through', () => {
+ assert.deepEqual(resolveWidgetImport('@use-voltra/ios', 'ios'), { kind: 'passthrough' })
+ assert.deepEqual(resolveWidgetImport('react', 'ios'), { kind: 'passthrough' })
+ })
+
+ it('recognises react-native specifiers', () => {
+ assert.equal(isReactNativeImport('react-native'), true)
+ assert.equal(isReactNativeImport('react-native/Libraries/Text/Text'), true)
+ assert.equal(isReactNativeImport('react-native-svg'), false)
+ })
+})
diff --git a/packages/compiler/src/widget-module/policy.ts b/packages/compiler/src/widget-module/policy.ts
new file mode 100644
index 00000000..dca6856f
--- /dev/null
+++ b/packages/compiler/src/widget-module/policy.ts
@@ -0,0 +1,160 @@
+/**
+ * Widget module import policy.
+ *
+ * Widget source is evaluated in three different environments:
+ *
+ * - the Voltra CLI's `apply` pipeline (Node VM),
+ * - the Expo config plugins' prebuild prerender (Node VM),
+ * - Metro, when bundling a Dynamic Widget for the device.
+ *
+ * All three must agree on what a widget file is allowed to import and what each
+ * specifier resolves to, otherwise a widget that prerenders successfully can still
+ * fail to bundle — or worse, render differently on device than it did at build time.
+ * This module is the single source of truth for that contract.
+ */
+
+/** Platform a widget module is being loaded or bundled for. */
+export type WidgetModulePlatform = 'ios' | 'android'
+
+/**
+ * Client packages carry the native bridge and app-side runtime. They cannot be
+ * evaluated outside the app, so widget code that imports them is served the
+ * matching rendering package instead.
+ */
+const PACKAGE_ALIASES: Readonly> = {
+ '@use-voltra/ios-client': '@use-voltra/ios',
+ '@use-voltra/android-client': '@use-voltra/android',
+}
+
+/** Package that hosts the `react-native` shim served to widget code. */
+export const WIDGET_REACT_NATIVE_SHIM_PACKAGE = '@use-voltra/compiler'
+
+/** Specifier of the `react-native` shim for a given platform. */
+export function getWidgetReactNativeShimSpecifier(platform: WidgetModulePlatform): string {
+ return `${WIDGET_REACT_NATIVE_SHIM_PACKAGE}/react-native/${platform}`
+}
+
+/** Symbols the `react-native` shim implements. Anything else is rejected. */
+export const SUPPORTED_REACT_NATIVE_EXPORTS = ['Platform', 'StyleSheet'] as const
+
+/**
+ * React Native exports widget code is most likely to reach for by mistake.
+ *
+ * The Node loader rejects *any* unimplemented symbol, but a Metro bundle resolves the shim
+ * file directly and has no such interception point. Naming these explicitly means a widget
+ * that slips one past build-time evaluation still fails loudly on device with the same
+ * message, rather than silently reading `undefined`.
+ */
+export const REJECTED_REACT_NATIVE_EXPORTS = [
+ 'ActivityIndicator',
+ 'Alert',
+ 'Animated',
+ 'AppRegistry',
+ 'AppState',
+ 'Appearance',
+ 'Button',
+ 'DeviceEventEmitter',
+ 'Dimensions',
+ 'Easing',
+ 'FlatList',
+ 'I18nManager',
+ 'Image',
+ 'ImageBackground',
+ 'InteractionManager',
+ 'Keyboard',
+ 'KeyboardAvoidingView',
+ 'LayoutAnimation',
+ 'Linking',
+ 'Modal',
+ 'NativeEventEmitter',
+ 'NativeModules',
+ 'PanResponder',
+ 'PermissionsAndroid',
+ 'PixelRatio',
+ 'Pressable',
+ 'SafeAreaView',
+ 'ScrollView',
+ 'SectionList',
+ 'Share',
+ 'StatusBar',
+ 'Switch',
+ 'Text',
+ 'TextInput',
+ 'ToastAndroid',
+ 'TouchableHighlight',
+ 'TouchableOpacity',
+ 'TouchableWithoutFeedback',
+ 'TurboModuleRegistry',
+ 'UIManager',
+ 'Vibration',
+ 'View',
+ 'VirtualizedList',
+ 'findNodeHandle',
+ 'processColor',
+ 'requireNativeComponent',
+ 'useColorScheme',
+ 'useWindowDimensions',
+] as const
+
+/** How an import from widget code should be handled. */
+export type WidgetImportResolution =
+ /** Resolve the specifier as written. */
+ | { kind: 'passthrough' }
+ /** Resolve `specifier` instead of the requested one. */
+ | { kind: 'alias'; specifier: string; warning?: string }
+ /** Reject the import with `reason`. */
+ | { kind: 'blocked'; reason: string }
+
+function isReactNativeRoot(specifier: string): boolean {
+ return specifier === 'react-native'
+}
+
+function isReactNativeDeepImport(specifier: string): boolean {
+ return specifier.startsWith('react-native/')
+}
+
+/** Whether a specifier reaches for React Native, either its entry point or a deep path. */
+export function isReactNativeImport(specifier: string): boolean {
+ return isReactNativeRoot(specifier) || isReactNativeDeepImport(specifier)
+}
+
+/** Decide how a bare import from widget code should be resolved. */
+export function resolveWidgetImport(specifier: string, platform: WidgetModulePlatform): WidgetImportResolution {
+ if (isReactNativeRoot(specifier)) {
+ return { kind: 'alias', specifier: getWidgetReactNativeShimSpecifier(platform) }
+ }
+
+ if (isReactNativeDeepImport(specifier)) {
+ return { kind: 'blocked', reason: describeBlockedWidgetImport(specifier) }
+ }
+
+ const aliasedPackage = PACKAGE_ALIASES[specifier]
+
+ if (aliasedPackage) {
+ return {
+ kind: 'alias',
+ specifier: aliasedPackage,
+ warning: `Widget code imported '${specifier}'. Using '${aliasedPackage}' instead.`,
+ }
+ }
+
+ return { kind: 'passthrough' }
+}
+
+/** Message shown when widget code imports something it cannot use. */
+export function describeBlockedWidgetImport(specifier: string): string {
+ return (
+ `Voltra widget code cannot import '${specifier}'. ` +
+ `Only ${SUPPORTED_REACT_NATIVE_EXPORTS.join(' and ')} are available from 'react-native'; ` +
+ "use the components exported by '@use-voltra/ios' or '@use-voltra/android' for everything else."
+ )
+}
+
+/** Message shown when widget code reaches for a `react-native` symbol the shim does not implement. */
+export function describeUnsupportedReactNativeExport(symbol: string): string {
+ return (
+ `'${symbol}' is not available to Voltra widget code. ` +
+ `Only ${SUPPORTED_REACT_NATIVE_EXPORTS.join(' and ')} are available from 'react-native'; ` +
+ "use the components exported by '@use-voltra/ios' or '@use-voltra/android' for everything else."
+ )
+}
diff --git a/packages/compiler/tsconfig.base.json b/packages/compiler/tsconfig.base.json
index 452d729b..f7d3806a 100644
--- a/packages/compiler/tsconfig.base.json
+++ b/packages/compiler/tsconfig.base.json
@@ -13,5 +13,5 @@
"types": ["node"]
},
"include": ["./src"],
- "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
+ "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*", "**/*.node.test.ts"]
}
diff --git a/packages/expo-plugin/package.json b/packages/expo-plugin/package.json
index a85a2a71..6663e5ec 100644
--- a/packages/expo-plugin/package.json
+++ b/packages/expo-plugin/package.json
@@ -27,7 +27,7 @@
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
},
"dependencies": {
- "@babel/core": "^7.27.4",
+ "@use-voltra/compiler": "workspace:^",
"@use-voltra/core": "workspace:^"
},
"keywords": [
@@ -52,7 +52,6 @@
"react-native": "*"
},
"devDependencies": {
- "@types/babel__core": "^7.20.5",
"@types/jest": "^29.5.14",
"@types/node": "^20.19.25",
"jest": "^29.7.0",
diff --git a/packages/expo-plugin/src/constants.ts b/packages/expo-plugin/src/constants.ts
index 1e87c0d4..f3306aa9 100644
--- a/packages/expo-plugin/src/constants.ts
+++ b/packages/expo-plugin/src/constants.ts
@@ -1,5 +1,2 @@
-/** Extensions to try when resolving module paths for pre-rendering */
-export const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '']
-
/** Maximum image size in bytes for widget / Live Activity assets (4KB limit) */
export const MAX_IMAGE_SIZE_BYTES = 4096
diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts
index b76b1ebf..1ddf716d 100644
--- a/packages/expo-plugin/src/index.ts
+++ b/packages/expo-plugin/src/index.ts
@@ -1,4 +1,4 @@
-export { MAX_IMAGE_SIZE_BYTES, MODULE_EXTENSIONS } from './constants'
+export { MAX_IMAGE_SIZE_BYTES } from './constants'
export { getDynamicLiveActivityAttributesType } from './dynamic-live-activity'
export type {
DynamicWidgetEntryConfig,
@@ -39,6 +39,17 @@ export { resolveFontPaths } from './utils/fonts'
export { normalizeLocaleTag, pickLocalizedValue } from './utils/localePick'
export { logger } from './utils/logger'
export { resolveInstalledPackageVersion } from './utils/packageVersion'
-export type { PrerenderableWidget, PrerenderedWidgetStates, WidgetRenderer } from './utils/prerender'
-export { evaluateWidgetModule, evaluateWidgetModuleExports, prerenderWidgetState } from './utils/prerender'
+export type {
+ PrerenderableWidget,
+ PrerenderedWidgetStates,
+ WidgetModuleEvaluationOptions,
+ WidgetRenderer,
+} from './utils/prerender'
+export {
+ createPrerenderWidgetModuleLoader,
+ evaluateWidgetModule,
+ evaluateWidgetModuleExports,
+ prerenderWidgetState,
+} from './utils/prerender'
+export type { WidgetModuleLoader, WidgetModulePlatform } from '@use-voltra/compiler'
export { isWidgetLocalizedMap, widgetLabelEnglish } from './utils/widgetLabel'
diff --git a/packages/expo-plugin/src/utils/prerender.node.test.ts b/packages/expo-plugin/src/utils/prerender.node.test.ts
index e6667e61..758bb5f5 100644
--- a/packages/expo-plugin/src/utils/prerender.node.test.ts
+++ b/packages/expo-plugin/src/utils/prerender.node.test.ts
@@ -54,7 +54,8 @@ describe('prerenderWidgetState', () => {
const states = await prerenderWidgetState(
[{ id: 'demo', initialStatePath: './widgets/state.ts' }],
tempRoot,
- (variants) => JSON.stringify(variants)
+ (variants) => JSON.stringify(variants),
+ targetLabel
)
expect(states.get('demo')?.get('__default')).toBe(JSON.stringify({ label: targetLabel }))
diff --git a/packages/expo-plugin/src/utils/prerender.ts b/packages/expo-plugin/src/utils/prerender.ts
index 41b3d06e..b22c427e 100644
--- a/packages/expo-plugin/src/utils/prerender.ts
+++ b/packages/expo-plugin/src/utils/prerender.ts
@@ -1,11 +1,7 @@
-import fs from 'node:fs'
import path from 'node:path'
-import { createRequire } from 'node:module'
-import vm from 'node:vm'
-import * as babel from '@babel/core'
+import { createWidgetModuleLoader, type WidgetModuleLoader, type WidgetModulePlatform } from '@use-voltra/compiler'
-import { MODULE_EXTENSIONS } from '../constants'
import type { WidgetInitialStatePath, WidgetLabel } from '../types'
import { logger } from './logger'
import { isWidgetLocalizedMap } from './widgetLabel'
@@ -26,170 +22,51 @@ export interface PrerenderableWidget {
/** widgetId -> locale key -> prerendered JSON string (single-file widgets use `__default`) */
export type PrerenderedWidgetStates = Map>
-const PRERENDER_PACKAGE_REDIRECTS: Record = {
- '@use-voltra/ios-client': '@use-voltra/ios',
- '@use-voltra/android-client': '@use-voltra/android',
+export interface WidgetModuleEvaluationOptions {
+ projectRoot: string
+ /** Platform being prebuilt. Determines `Platform.OS` inside widget code. */
+ platform: WidgetModulePlatform
+ /** Reuse a loader across several widgets so warnings are reported once per prebuild. */
+ loader?: WidgetModuleLoader
}
/**
- * Check if a module specifier is a relative or absolute path (local file)
- */
-function isLocalModule(moduleSpecifier: string): boolean {
- return moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/')
-}
-
-/**
- * Resolve a module path, trying different extensions
- */
-function resolveModulePath(moduleSpecifier: string, fromDir: string): string | null {
- const basePath = path.resolve(fromDir, moduleSpecifier)
-
- for (const ext of MODULE_EXTENSIONS) {
- const fullPath = basePath + ext
- if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
- return fullPath
- }
- }
-
- // Try index files if it's a directory
- if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) {
- for (const ext of MODULE_EXTENSIONS) {
- const indexPath = path.join(basePath, 'index' + ext)
- if (fs.existsSync(indexPath)) {
- return indexPath
- }
- }
- }
-
- return null
-}
-
-function getProjectBabelConfigPath(projectRoot: string): string | null {
- const configPath = path.join(projectRoot, 'babel.config.js')
- return fs.existsSync(configPath) ? configPath : null
-}
-
-function getFallbackExpoPreset(projectRoot: string): string {
- return require.resolve('babel-preset-expo', { paths: [projectRoot] })
-}
-
-/**
- * Transpile a file with Babel
+ * Create the loader used to evaluate widget source during prebuild.
+ *
+ * Evaluation rules live in `@use-voltra/compiler` so that prebuild, `voltra apply`, and
+ * the Metro widget bundler agree on what widget code may import.
*/
-function transpileFile(filePath: string, projectRoot: string): string {
- const code = fs.readFileSync(filePath, 'utf8')
- const projectBabelConfigPath = getProjectBabelConfigPath(projectRoot)
-
- const result = babel.transformSync(code, {
- cwd: projectRoot,
- filename: filePath,
- ...(projectBabelConfigPath
- ? { configFile: projectBabelConfigPath }
- : {
- babelrc: false,
- configFile: false,
- presets: [getFallbackExpoPreset(projectRoot)],
- }),
+export function createPrerenderWidgetModuleLoader(
+ projectRoot: string,
+ platform: WidgetModulePlatform
+): WidgetModuleLoader {
+ return createWidgetModuleLoader({
+ projectRoot,
+ platform,
+ onWarning: (message) => logger.warn(message),
})
+}
- if (!result || !result.code) {
- throw new Error(`Babel transpilation failed for ${filePath}`)
- }
-
- return result.code
+function resolveLoader({ projectRoot, platform, loader }: WidgetModuleEvaluationOptions): WidgetModuleLoader {
+ return loader ?? createPrerenderWidgetModuleLoader(projectRoot, platform)
}
/**
- * Evaluate a widget module using Babel transpilation and Node.js VM.
- * This allows executing widget code that uses JSX and React components.
- * Local module dependencies are also transpiled with the same Babel settings.
+ * Evaluate a widget module and return its exports object.
*
* Exported so platform-specific prerender flows can reuse the same module loader rather
- * than duplicating the Babel + VM scaffolding. The returned value is the module's exports
- * object — callers decide whether to read `.default`, a named export, etc.
+ * than duplicating the Babel + VM scaffolding. Callers decide whether to read `.default`,
+ * a named export, etc.
*/
-export function evaluateWidgetModuleExports(
- projectRoot: string,
- filePath: string,
- warnedRedirects = new Set()
-): any {
- // Cache for already-evaluated modules to handle circular dependencies
- const moduleCache = new Map()
- const projectRequire = createRequire(path.join(projectRoot, 'package.json'))
-
- /**
- * Custom require that transpiles local modules with Babel
- */
- function customRequire(moduleSpecifier: string, currentDir: string): any {
- // For non-local modules (npm packages), use native require
- if (!isLocalModule(moduleSpecifier)) {
- const redirectedSpecifier = PRERENDER_PACKAGE_REDIRECTS[moduleSpecifier]
-
- if (redirectedSpecifier) {
- if (!warnedRedirects.has(moduleSpecifier)) {
- warnedRedirects.add(moduleSpecifier)
- logger.warn(
- `Prerendering initial state imported '${moduleSpecifier}'. Using '${redirectedSpecifier}' instead.`
- )
- }
-
- return projectRequire(redirectedSpecifier)
- }
-
- return projectRequire(moduleSpecifier)
- }
-
- // Resolve the local module path
- const resolvedPath = resolveModulePath(moduleSpecifier, currentDir)
- if (!resolvedPath) {
- throw new Error(`Cannot resolve module '${moduleSpecifier}' from '${currentDir}'`)
- }
-
- // Return cached module if already evaluated
- if (moduleCache.has(resolvedPath)) {
- return moduleCache.get(resolvedPath)
- }
-
- // Transpile and evaluate the module
- const transpiledCode = transpileFile(resolvedPath, projectRoot)
- const moduleDir = path.dirname(resolvedPath)
-
- const mockModule = { exports: {} as any }
-
- // Create require function bound to the module's directory
- const boundRequire = (spec: string) => customRequire(spec, moduleDir)
-
- const context = vm.createContext({
- exports: mockModule.exports,
- module: mockModule,
- require: boundRequire,
- __filename: resolvedPath,
- __dirname: moduleDir,
- console: console,
- process: process,
- })
-
- // Cache before evaluation to handle circular dependencies
- moduleCache.set(resolvedPath, mockModule.exports)
-
- const script = new vm.Script(transpiledCode, { filename: resolvedPath })
- script.runInContext(context)
-
- // Update cache with final exports (in case module.exports was reassigned)
- moduleCache.set(resolvedPath, mockModule.exports)
-
- return mockModule.exports
- }
-
- return customRequire(filePath, path.dirname(filePath))
+export function evaluateWidgetModuleExports(filePath: string, options: WidgetModuleEvaluationOptions): any {
+ return resolveLoader(options).load(filePath)
}
/**
* Evaluate a widget file as a server-style WidgetVariants module and return its object export.
*/
-export function evaluateWidgetModule(projectRoot: string, filePath: string, warnedRedirects = new Set()): any {
- const exports = evaluateWidgetModuleExports(projectRoot, filePath, warnedRedirects)
- const widgetVariants: any = exports.default || exports
+export function evaluateWidgetModule(filePath: string, options: WidgetModuleEvaluationOptions): any {
+ const widgetVariants = resolveLoader(options).loadDefaultExport(filePath)
if (!widgetVariants || typeof widgetVariants !== 'object') {
throw new Error('Widget file must export a WidgetVariants object or have a default export of WidgetVariants')
@@ -208,15 +85,17 @@ export function evaluateWidgetModule(projectRoot: string, filePath: string, warn
* @param widgets - Array of widget configurations
* @param projectRoot - Root directory of the Expo project
* @param renderer - The renderer function to use (voltra/server or voltra/android/server)
+ * @param platform - Platform being prebuilt
* @returns Map of widgetId -> (locale key -> prerendered JSON string)
*/
export async function prerenderWidgetState(
widgets: PrerenderableWidget[],
projectRoot: string,
- renderer: WidgetRenderer
+ renderer: WidgetRenderer,
+ platform: WidgetModulePlatform
): Promise {
const prerenderedStates: PrerenderedWidgetStates = new Map()
- const warnedRedirects = new Set()
+ const loader = createPrerenderWidgetModuleLoader(projectRoot, platform)
for (const widget of widgets) {
if (!widget.initialStatePath) {
@@ -233,7 +112,7 @@ export async function prerenderWidgetState(
try {
for (const [localeKey, relativePath] of Object.entries(perLocalePaths)) {
const absoluteWidgetPath = path.resolve(projectRoot, relativePath)
- const widgetVariants = evaluateWidgetModule(projectRoot, absoluteWidgetPath, warnedRedirects)
+ const widgetVariants = evaluateWidgetModule(absoluteWidgetPath, { projectRoot, platform, loader })
const prerenderedState = renderer(widgetVariants)
inner.set(localeKey, prerenderedState)
}
diff --git a/packages/expo-plugin/tsconfig.typecheck.json b/packages/expo-plugin/tsconfig.typecheck.json
index 6ad020ad..07ebb3a4 100644
--- a/packages/expo-plugin/tsconfig.typecheck.json
+++ b/packages/expo-plugin/tsconfig.typecheck.json
@@ -5,6 +5,7 @@
"rootDir": "../..",
"baseUrl": "../..",
"paths": {
+ "@use-voltra/compiler": ["packages/compiler/src/index.ts"],
"@use-voltra/core/dynamic-live-activity": ["packages/core/src/dynamic-live-activity.ts"]
}
}
diff --git a/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.ts b/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.ts
index fa47447d..939ca6ce 100644
--- a/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.ts
+++ b/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.ts
@@ -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 { IOSWidgetConfig } from '../types'
@@ -30,7 +30,8 @@ let hasWarnedExperimental = false
/** Inspect every widget once and tag entry-backed widgets as Dynamic Widgets. */
export function detectClientRenderedWidgets(widgets: IOSWidgetConfig[], projectRoot: string): DetectedIOSWidget[] {
- const detected = widgets.map((widget) => detectSingleWidget(widget, projectRoot))
+ const loader = createPrerenderWidgetModuleLoader(projectRoot, 'ios')
+ const detected = widgets.map((widget) => detectSingleWidget(widget, projectRoot, loader))
if (!hasWarnedExperimental) {
const clientWidgetIds = detected.filter((widget) => widget.clientRendered).map((widget) => widget.id)
@@ -47,7 +48,11 @@ export function detectClientRenderedWidgets(widgets: IOSWidgetConfig[], projectR
return detected
}
-function detectSingleWidget(widget: IOSWidgetConfig, projectRoot: string): DetectedIOSWidget {
+function detectSingleWidget(
+ widget: IOSWidgetConfig,
+ projectRoot: string,
+ loader: WidgetModuleLoader
+): DetectedIOSWidget {
if (widget.entry === undefined) {
return {
...widget,
@@ -65,8 +70,7 @@ function detectSingleWidget(widget: IOSWidgetConfig, projectRoot: string): Detec
)
}
- 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(
diff --git a/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts b/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts
index 0a3db79e..1f2a3b91 100644
--- a/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts
+++ b/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts
@@ -1,5 +1,5 @@
import {
- evaluateWidgetModuleExports,
+ createPrerenderWidgetModuleLoader,
logger,
resolveInstalledPackageVersion,
type PrerenderedWidgetStates,
@@ -81,11 +81,11 @@ export async function prerenderClientRenderedWidgets(
}
const placeholderEnv = buildPlaceholderEnv(resolveInstalledPackageVersion(projectRoot, '@use-voltra/ios-client'))
+ const loader = createPrerenderWidgetModuleLoader(projectRoot, 'ios')
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.`
diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts
index ce9e47f2..a84ddd55 100644
--- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts
+++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts
@@ -67,7 +67,7 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr
// stringify). Both produce entries in the same map shape so
// VoltraWidgetInitialStates.swift can read either via the same lookup at runtime.
const serverWidgets = detectedWidgets.filter((w) => !w.clientRendered)
- const serverStates = await prerenderWidgetState(serverWidgets, projectRoot, renderWidgetToString)
+ const serverStates = await prerenderWidgetState(serverWidgets, projectRoot, renderWidgetToString, 'ios')
const clientStates = await prerenderClientRenderedWidgets(detectedWidgets, projectRoot)
const prerenderedStates = new Map([...serverStates, ...clientStates])
diff --git a/packages/metro/src/createWidgetMetroConfig.ts b/packages/metro/src/createWidgetMetroConfig.ts
index d256b1be..e1ce158c 100644
--- a/packages/metro/src/createWidgetMetroConfig.ts
+++ b/packages/metro/src/createWidgetMetroConfig.ts
@@ -1,10 +1,17 @@
+import { createRequire } from 'node:module'
import path from 'node:path'
+import {
+ describeBlockedWidgetImport,
+ isReactNativeImport,
+ resolveWidgetImport,
+ WIDGET_REACT_NATIVE_SHIM_PACKAGE,
+ type WidgetModulePlatform,
+} from '@use-voltra/compiler'
+
import { requireProjectModule, resolveProjectModulePath } from './resolveProjectModule'
import { createErrorOnlyMetroReporter } from './createErrorOnlyMetroReporter'
-const blockedModules = new Set(['react-native'])
-
function unique(items: Array): T[] {
return Array.from(new Set(items.filter((item): item is T => item !== null && item !== undefined)))
}
@@ -17,6 +24,36 @@ function resolvePnpmTransitive(name: string, projectRoot: string): string | null
}
}
+/**
+ * Locate a `@use-voltra/compiler` entry point from this package's own installation.
+ *
+ * The shim it hosts is a transitive dependency of the app, so it cannot be resolved from
+ * the project root; going through `@use-voltra/metro` — which the app does depend on —
+ * finds the copy that pairs with this bundler.
+ */
+function resolveWidgetShim(specifier: string, projectRoot: string): string {
+ const metroPackagePath = resolveProjectModulePath('@use-voltra/metro/package.json', projectRoot)
+ return createRequire(metroPackagePath).resolve(specifier)
+}
+
+function asWidgetModulePlatform(platform: string | null): WidgetModulePlatform | null {
+ return platform === 'ios' || platform === 'android' ? platform : null
+}
+
+/** Report each redirect once per bundler config, matching what the build-time loaders log. */
+function createWarnOnce(): (message: string) => void {
+ const seen = new Set()
+
+ return (message: string) => {
+ if (seen.has(message)) {
+ return
+ }
+
+ seen.add(message)
+ console.warn(`[voltra] ${message}`)
+ }
+}
+
export async function createWidgetMetroConfig({
projectRoot,
appConfig,
@@ -38,6 +75,7 @@ export async function createWidgetMetroConfig({
const pnpmTransitiveModules = Object.fromEntries(
Object.entries(pnpmTransitives).filter((entry): entry is [string, string] => entry[1] !== null)
)
+ const warnOnce = createWarnOnce()
return {
...config,
@@ -57,12 +95,39 @@ export async function createWidgetMetroConfig({
...(config.resolver?.nodeModulesPaths ?? []),
...(appConfig.resolver?.nodeModulesPaths ?? []),
]),
- resolveRequest(context: any, moduleName: string, platform: string | null) {
- if (blockedModules.has(moduleName) || moduleName.startsWith('react-native/')) {
- throw new Error(`Voltra widget bundles cannot import "${moduleName}"`)
+ resolveRequest(context: any, moduleName: string, requestedPlatform: string | null) {
+ // The same import policy the CLI and the Expo plugins apply when they evaluate
+ // widget source at build time, so a widget that prerenders also bundles.
+ const widgetPlatform = asWidgetModulePlatform(requestedPlatform)
+
+ if (!widgetPlatform) {
+ // Without a target platform there is no honest `Platform.OS` to serve.
+ if (isReactNativeImport(moduleName)) {
+ throw new Error(describeBlockedWidgetImport(moduleName))
+ }
+
+ return context.resolveRequest(context, moduleName, requestedPlatform)
+ }
+
+ const resolution = resolveWidgetImport(moduleName, widgetPlatform)
+
+ if (resolution.kind === 'blocked') {
+ throw new Error(resolution.reason)
+ }
+
+ if (resolution.kind === 'passthrough') {
+ return context.resolveRequest(context, moduleName, requestedPlatform)
+ }
+
+ if (resolution.warning) {
+ warnOnce(resolution.warning)
+ }
+
+ if (resolution.specifier.startsWith(`${WIDGET_REACT_NATIVE_SHIM_PACKAGE}/`)) {
+ return { type: 'sourceFile', filePath: resolveWidgetShim(resolution.specifier, projectRoot) }
}
- return context.resolveRequest(context, moduleName, platform)
+ return context.resolveRequest(context, resolution.specifier, requestedPlatform)
},
},
serializer: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 403fefc9..1ccb4f4a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -293,15 +293,15 @@ importers:
packages/cli:
dependencies:
- '@babel/core':
- specifier: ^7.27.4
- version: 7.29.0
'@bacons/xcode':
specifier: ^1.0.0-alpha.33
version: 1.0.0-alpha.33
'@clack/prompts':
specifier: ^1.0.0-alpha.5
version: 1.4.0
+ '@use-voltra/compiler':
+ specifier: workspace:^
+ version: link:../compiler
commander:
specifier: ^12.1.0
version: 12.1.0
@@ -315,21 +315,34 @@ importers:
specifier: ^0.6.2
version: 0.6.2
devDependencies:
- '@types/babel__core':
- specifier: ^7.20.5
- version: 7.20.5
tsx:
specifier: ^4.19.0
version: 4.22.3
packages/compiler:
dependencies:
+ '@babel/core':
+ specifier: ^7.27.4
+ version: 7.29.0
'@babel/parser':
specifier: ^7.27.4
version: 7.29.3
'@babel/types':
specifier: ^7.27.4
version: 7.29.0
+ devDependencies:
+ '@babel/runtime':
+ specifier: ^7.27.4
+ version: 7.29.2
+ '@react-native/babel-preset':
+ specifier: 0.83.2
+ version: 0.83.2(@babel/core@7.29.0)
+ '@types/babel__core':
+ specifier: ^7.20.5
+ version: 7.20.5
+ tsx:
+ specifier: ^4.19.0
+ version: 4.22.3
packages/core:
dependencies:
@@ -342,9 +355,9 @@ importers:
packages/expo-plugin:
dependencies:
- '@babel/core':
- specifier: ^7.27.4
- version: 7.29.0
+ '@use-voltra/compiler':
+ specifier: workspace:^
+ version: link:../compiler
'@use-voltra/core':
specifier: workspace:^
version: link:../core
@@ -358,9 +371,6 @@ importers:
specifier: '*'
version: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.15)(react@19.2.4)
devDependencies:
- '@types/babel__core':
- specifier: ^7.20.5
- version: 7.20.5
'@types/jest':
specifier: ^29.5.14
version: 29.5.14
diff --git a/website/docs/v2/android/development/styling.md b/website/docs/v2/android/development/styling.md
index 2db9e6be..987eeb91 100644
--- a/website/docs/v2/android/development/styling.md
+++ b/website/docs/v2/android/development/styling.md
@@ -124,3 +124,47 @@ const element = (
)
```
+
+## Sharing styles with `StyleSheet`
+
+Widget files can import `StyleSheet` and `Platform` from `react-native`, so styles can live
+outside the element tree exactly as they do in the rest of your app:
+
+```tsx
+import { Platform, StyleSheet } from 'react-native'
+import { VoltraAndroid } from '@use-voltra/android'
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ backgroundColor: '#101828',
+ },
+ title: {
+ color: '#F8FAFC',
+ fontSize: 18,
+ fontWeight: 'bold',
+ },
+})
+
+const element = (
+
+ {Platform.OS}
+
+)
+```
+
+Widget code does not run against the React Native runtime — at build time it is evaluated in a
+Node sandbox, and Dynamic Widgets run on device in a separate JS engine with no bridge. Only the
+parts of `react-native` that are pure data manipulation are therefore available:
+
+- `StyleSheet.create`, `StyleSheet.flatten`, `StyleSheet.compose`, `StyleSheet.absoluteFill`,
+ `StyleSheet.absoluteFillObject`, and `StyleSheet.hairlineWidth`.
+- `Platform.OS` and `Platform.select`. Inside a widget, `Platform.OS` is the platform the widget is
+ being built for, so `Platform.select` picks the same branch at build time and on device.
+
+Anything else imported from `react-native` — components, `Dimensions`, `Animated`, `PixelRatio` —
+is rejected with a message naming the symbol. Build steps that evaluate your widget
+(`voltra apply` and `expo prebuild`) fail outright; a symbol that only appears on a branch those
+steps never reach throws the same message when the widget renders, rather than reading as
+`undefined`. Deep imports such as `react-native/Libraries/...` always fail the build. Use the
+`VoltraAndroid` components for everything visual.
diff --git a/website/docs/v2/ios/development/styling.md b/website/docs/v2/ios/development/styling.md
index 6cd6e30f..aaa607ec 100644
--- a/website/docs/v2/ios/development/styling.md
+++ b/website/docs/v2/ios/development/styling.md
@@ -132,3 +132,48 @@ const element = (
```
For gradients and custom fonts, see the dedicated [Gradients](./gradients) and [Custom Fonts](./custom-fonts) pages.
+
+## Sharing styles with `StyleSheet`
+
+Widget files can import `StyleSheet` and `Platform` from `react-native`, so styles can live
+outside the element tree exactly as they do in the rest of your app:
+
+```tsx
+import { Platform, StyleSheet } from 'react-native'
+import { Voltra } from '@use-voltra/ios'
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ borderRadius: 18,
+ backgroundColor: '#101828',
+ },
+ title: {
+ color: '#F8FAFC',
+ fontSize: 18,
+ fontWeight: '600',
+ },
+})
+
+const element = (
+
+ {Platform.OS}
+
+)
+```
+
+Widget code does not run against the React Native runtime — at build time it is evaluated in a
+Node sandbox, and Dynamic Widgets run on device in a separate JS engine with no bridge. Only the
+parts of `react-native` that are pure data manipulation are therefore available:
+
+- `StyleSheet.create`, `StyleSheet.flatten`, `StyleSheet.compose`, `StyleSheet.absoluteFill`,
+ `StyleSheet.absoluteFillObject`, and `StyleSheet.hairlineWidth`.
+- `Platform.OS` and `Platform.select`. Inside a widget, `Platform.OS` is the platform the widget is
+ being built for, so `Platform.select` picks the same branch at build time and on device.
+
+Anything else imported from `react-native` — components, `Dimensions`, `Animated`, `PixelRatio` —
+is rejected with a message naming the symbol. Build steps that evaluate your widget
+(`voltra apply` and `expo prebuild`) fail outright; a symbol that only appears on a branch those
+steps never reach throws the same message when the widget renders, rather than reading as
+`undefined`. Deep imports such as `react-native/Libraries/...` always fail the build. Use the
+`Voltra` components for everything visual.