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.