From 8262fbf90f86c0fe75343d76e37295b26a680ed7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Jun 2026 14:09:55 +0200 Subject: [PATCH 01/36] feat: add Voltra widget Metro PoC Adds an example Metro setup that discovers use-voltra widget components, registers generated widget entries, and serves widget bundles from /voltra with a secondary Metro middleware. --- example/.gitignore | 3 +- example/metro.config.js | 5 + example/metro/createMetroConfig.js | 50 ++++++ example/metro/createVoltraMiddleware.js | 57 +++++++ example/metro/createWidgetMetroConfig.js | 69 ++++++++ example/metro/scanVoltraDirectives.js | 170 +++++++++++++++++++ example/metro/widgetRegistry.js | 207 +++++++++++++++++++++++ example/widgets/ios/IosWeatherWidget.tsx | 2 + 8 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 example/metro.config.js create mode 100644 example/metro/createMetroConfig.js create mode 100644 example/metro/createVoltraMiddleware.js create mode 100644 example/metro/createWidgetMetroConfig.js create mode 100644 example/metro/scanVoltraDirectives.js create mode 100644 example/metro/widgetRegistry.js diff --git a/example/.gitignore b/example/.gitignore index d0e12830..aa83371d 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -20,6 +20,7 @@ expo-env.d.ts # Metro .metro-health-check* +.voltra/ # debug npm-debug.* @@ -37,4 +38,4 @@ yarn-error.* *.tsbuildinfo /ios -/android \ No newline at end of file +/android diff --git a/example/metro.config.js b/example/metro.config.js new file mode 100644 index 00000000..94c48ca3 --- /dev/null +++ b/example/metro.config.js @@ -0,0 +1,5 @@ +const { createMetroConfig } = require('./metro/createMetroConfig') + +module.exports = async function metroConfig() { + return createMetroConfig(__dirname) +} diff --git a/example/metro/createMetroConfig.js b/example/metro/createMetroConfig.js new file mode 100644 index 00000000..9668bb5d --- /dev/null +++ b/example/metro/createMetroConfig.js @@ -0,0 +1,50 @@ +const connect = require('connect') +const Metro = require('metro') +const { getDefaultConfig } = require('expo/metro-config') + +const { createVoltraMiddleware } = require('./createVoltraMiddleware') +const { createWidgetMetroConfig } = require('./createWidgetMetroConfig') +const { createWidgetRegistry } = require('./widgetRegistry') + +async function createMetroConfig(projectRoot) { + const appConfig = getDefaultConfig(projectRoot) + appConfig.resolver.extraNodeModules = { + ...appConfig.resolver.extraNodeModules, + '~': projectRoot, + } + + const registry = createWidgetRegistry({ projectRoot }) + + const widgetConfig = await createWidgetMetroConfig({ + projectRoot, + registry, + appConfig, + }) + const widgetMetro = await Metro.createConnectMiddleware(widgetConfig, { + port: appConfig.server.port, + }) + const voltraMiddleware = createVoltraMiddleware({ + registry, + widgetMetro, + }) + + const previousHook = appConfig.serializer.experimentalSerializerHook + appConfig.serializer.experimentalSerializerHook = (graph, delta) => { + if (previousHook) { + previousHook(graph, delta) + } + + registry.applyMetroDelta(delta) + } + + const previousEnhanceMiddleware = appConfig.server.enhanceMiddleware || ((middleware) => middleware) + appConfig.server.enhanceMiddleware = (metroMiddleware, metroServer) => { + const enhancedAppMetroMiddleware = previousEnhanceMiddleware(metroMiddleware, metroServer) + + return connect().use('/voltra', voltraMiddleware).use(enhancedAppMetroMiddleware) + } + + return appConfig +} + +module.exports = { createMetroConfig } diff --git a/example/metro/createVoltraMiddleware.js b/example/metro/createVoltraMiddleware.js new file mode 100644 index 00000000..8d6043d9 --- /dev/null +++ b/example/metro/createVoltraMiddleware.js @@ -0,0 +1,57 @@ +function sendJson(res, status, value) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + }) + res.end(JSON.stringify(value, null, 2)) +} + +function createBundleRequest(widget, originalSearchParams) { + const query = new URLSearchParams(originalSearchParams) + query.set('bundleEntry', widget.generatedEntryRelativePath) + + if (!query.has('platform')) { + query.set('platform', 'voltra') + } + + return `/voltra-widget.bundle?${query.toString()}` +} + +function createVoltraMiddleware({ registry, widgetMetro }) { + return (req, res, next) => { + const requestUrl = new URL(req.url, 'http://localhost') + const pathname = requestUrl.pathname || '/' + + if (pathname === '/' || pathname === '/widgets') { + sendJson(res, 200, { + ready: registry.isReady(), + widgets: registry.listWidgets(), + }) + return + } + + const widgetBundleMatch = pathname.match(/^\/widgets\/([^/]+)\.bundle$/) + if (widgetBundleMatch) { + const widgetId = decodeURIComponent(widgetBundleMatch[1]) + const widget = registry.getWidget(widgetId) + + if (!widget) { + sendJson(res, registry.isReady() ? 404 : 425, { + error: registry.isReady() + ? `Unknown Voltra widget "${widgetId}".` + : 'Voltra widget registry is not ready yet. Build the app bundle first.', + }) + return + } + + req.url = createBundleRequest(widget, requestUrl.searchParams) + widgetMetro.middleware(req, res, next) + return + } + + sendJson(res, 404, { + error: `Unknown Voltra endpoint "${pathname}".`, + }) + } +} + +module.exports = { createVoltraMiddleware } diff --git a/example/metro/createWidgetMetroConfig.js b/example/metro/createWidgetMetroConfig.js new file mode 100644 index 00000000..70f6d575 --- /dev/null +++ b/example/metro/createWidgetMetroConfig.js @@ -0,0 +1,69 @@ +const path = require('node:path') + +const { getDefaultConfig } = require('metro-config') + +const blockedModules = new Set(['react-native']) + +function createLinkedPackages(repoRoot) { + return { + '@use-voltra/android': path.join(repoRoot, 'packages/android'), + '@use-voltra/android-client': path.join(repoRoot, 'packages/android-client'), + '@use-voltra/core': path.join(repoRoot, 'packages/core'), + '@use-voltra/expo-plugin': path.join(repoRoot, 'packages/expo-plugin'), + '@use-voltra/ios': path.join(repoRoot, 'packages/ios'), + '@use-voltra/ios-client': path.join(repoRoot, 'packages/ios-client'), + '@use-voltra/server': path.join(repoRoot, 'packages/server'), + } +} + +function unique(items) { + return Array.from(new Set(items.filter(Boolean))) +} + +async function createWidgetMetroConfig({ projectRoot, appConfig }) { + const repoRoot = path.resolve(projectRoot, '..') + const appNodeModules = path.join(projectRoot, 'node_modules') + const linkedPackages = createLinkedPackages(repoRoot) + const config = await getDefaultConfig(projectRoot) + const sourceExts = unique([...config.resolver.sourceExts, ...appConfig.resolver.sourceExts]) + + return { + ...config, + projectRoot, + watchFolders: unique([...config.watchFolders, ...appConfig.watchFolders, ...Object.values(linkedPackages)]), + resolver: { + ...config.resolver, + sourceExts, + extraNodeModules: { + ...config.resolver.extraNodeModules, + ...linkedPackages, + '~': projectRoot, + react: path.join(appNodeModules, 'react'), + }, + nodeModulesPaths: unique([appNodeModules, ...config.resolver.nodeModulesPaths]), + resolveRequest(context, moduleName, platform) { + if (blockedModules.has(moduleName) || moduleName.startsWith('react-native/')) { + throw new Error(`Voltra widget bundles cannot import "${moduleName}"`) + } + + return context.resolveRequest(context, moduleName, platform) + }, + }, + serializer: { + ...config.serializer, + getModulesRunBeforeMainModule: () => [], + getPolyfills: () => [], + polyfillModuleNames: [], + }, + transformer: { + ...config.transformer, + babelTransformerPath: appConfig.transformer.babelTransformerPath, + }, + server: { + ...config.server, + enhanceMiddleware: (middleware) => middleware, + }, + } +} + +module.exports = { createWidgetMetroConfig } diff --git a/example/metro/scanVoltraDirectives.js b/example/metro/scanVoltraDirectives.js new file mode 100644 index 00000000..fc8d321a --- /dev/null +++ b/example/metro/scanVoltraDirectives.js @@ -0,0 +1,170 @@ +const parser = require('@babel/parser') + +const supportedExtensions = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']) + +function hasVoltraDirective(functionNode) { + if (!functionNode || !functionNode.body || functionNode.body.type !== 'BlockStatement') { + return false + } + + return functionNode.body.directives.some((item) => item.value && item.value.value === 'use voltra') +} + +function parseSource(source, filePath) { + return parser.parse(source, { + sourceFilename: filePath, + sourceType: 'unambiguous', + plugins: [ + 'classProperties', + 'decorators-legacy', + 'dynamicImport', + 'exportDefaultFrom', + 'importMeta', + 'jsx', + 'topLevelAwait', + 'typescript', + ], + }) +} + +function collectExportedLocals(program) { + const exportedLocals = new Map() + + function add(localName, exportName) { + if (!localName) { + return + } + + const exports = exportedLocals.get(localName) || new Set() + exports.add(exportName) + exportedLocals.set(localName, exports) + } + + for (const statement of program.body) { + if (statement.type === 'ExportNamedDeclaration') { + if (statement.declaration) { + if (statement.declaration.type === 'FunctionDeclaration') { + add(statement.declaration.id && statement.declaration.id.name, statement.declaration.id && statement.declaration.id.name) + } + + if (statement.declaration.type === 'VariableDeclaration') { + for (const declaration of statement.declaration.declarations) { + add(declaration.id && declaration.id.name, declaration.id && declaration.id.name) + } + } + } + + for (const specifier of statement.specifiers) { + add(specifier.local && specifier.local.name, specifier.exported && specifier.exported.name) + } + } + + if (statement.type === 'ExportDefaultDeclaration' && statement.declaration.type === 'Identifier') { + add(statement.declaration.name, 'default') + } + } + + return exportedLocals +} + +function createWidgetRecord({ componentName, exportName, filePath }) { + if (!componentName || componentName === 'default') { + return null + } + + return { + id: componentName, + componentName, + exportName, + sourcePath: filePath, + } +} + +function scanDeclaration({ declaration, exportedLocals, filePath }) { + if (!declaration) { + return [] + } + + if (declaration.type === 'FunctionDeclaration') { + const componentName = declaration.id && declaration.id.name + const exportNames = componentName ? exportedLocals.get(componentName) : null + + if (!hasVoltraDirective(declaration) || !exportNames) { + return [] + } + + return Array.from(exportNames) + .map((exportName) => createWidgetRecord({ componentName, exportName, filePath })) + .filter(Boolean) + } + + if (declaration.type === 'VariableDeclaration') { + const widgets = [] + + for (const variableDeclarator of declaration.declarations) { + const componentName = variableDeclarator.id && variableDeclarator.id.name + const init = variableDeclarator.init + const exportNames = componentName ? exportedLocals.get(componentName) : null + + if (!hasVoltraDirective(init) || !exportNames) { + continue + } + + for (const exportName of exportNames) { + const widget = createWidgetRecord({ componentName, exportName, filePath }) + + if (widget) { + widgets.push(widget) + } + } + } + + return widgets + } + + return [] +} + +function scanVoltraDirectives({ filePath, source }) { + if (!supportedExtensions.has(require('node:path').extname(filePath))) { + return [] + } + + if (!source.includes("'use voltra'") && !source.includes('"use voltra"')) { + return [] + } + + const ast = parseSource(source, filePath) + const exportedLocals = collectExportedLocals(ast.program) + const widgets = [] + + for (const statement of ast.program.body) { + if (statement.type === 'ExportDefaultDeclaration') { + if (hasVoltraDirective(statement.declaration)) { + const componentName = statement.declaration.id ? statement.declaration.id.name : 'default' + const widget = createWidgetRecord({ + componentName, + exportName: 'default', + filePath, + }) + + if (widget) { + widgets.push(widget) + } + } + + continue + } + + if (statement.type === 'ExportNamedDeclaration') { + widgets.push(...scanDeclaration({ declaration: statement.declaration, exportedLocals, filePath })) + continue + } + + widgets.push(...scanDeclaration({ declaration: statement, exportedLocals, filePath })) + } + + return widgets +} + +module.exports = { scanVoltraDirectives } diff --git a/example/metro/widgetRegistry.js b/example/metro/widgetRegistry.js new file mode 100644 index 00000000..52282c81 --- /dev/null +++ b/example/metro/widgetRegistry.js @@ -0,0 +1,207 @@ +const crypto = require('node:crypto') +const fs = require('node:fs') +const path = require('node:path') + +const { scanVoltraDirectives } = require('./scanVoltraDirectives') + +function toPosixPath(value) { + return value.split(path.sep).join('/') +} + +function ensureDirectory(directory) { + fs.mkdirSync(directory, { recursive: true }) +} + +function hash(value) { + return crypto.createHash('sha1').update(value).digest('hex').slice(0, 10) +} + +function safeFileName(value) { + return value.replace(/[^a-zA-Z0-9_.-]+/g, '-') +} + +class DuplicateVoltraWidgetError extends Error { + constructor({ widgetId, firstPath, secondPath, projectRoot }) { + const firstRelativePath = toPosixPath(path.relative(projectRoot, firstPath)) + const secondRelativePath = toPosixPath(path.relative(projectRoot, secondPath)) + + super( + `Duplicate Voltra widget component "${widgetId}" found in both "${firstRelativePath}" and "${secondRelativePath}". ` + + 'Widget IDs are inherited from component names and must be unique.' + ) + + this.name = 'DuplicateVoltraWidgetError' + } +} + +function createWidgetRegistry({ projectRoot }) { + const generatedRoot = path.join(projectRoot, '.voltra', 'metro') + const generatedEntryRoot = path.join(generatedRoot, 'entries') + const widgetsById = new Map() + const widgetIdsBySourcePath = new Map() + let ready = false + + function createGeneratedEntry(widget) { + ensureDirectory(generatedEntryRoot) + + const entryFileName = `${safeFileName(widget.id)}-${hash(`${widget.sourcePath}:${widget.exportName}`)}.js` + const generatedEntryPath = path.join(generatedEntryRoot, entryFileName) + const importPath = toPosixPath(path.relative(generatedEntryRoot, widget.sourcePath)).replace(/\.[cm]?[jt]sx?$/, '') + const normalizedImportPath = importPath.startsWith('.') ? importPath : `./${importPath}` + const exportExpression = + widget.exportName === 'default' ? 'WidgetModule.default' : `WidgetModule[${JSON.stringify(widget.exportName)}]` + + fs.writeFileSync( + generatedEntryPath, + [ + `import * as WidgetModule from ${JSON.stringify(normalizedImportPath)}`, + '', + `const Widget = ${exportExpression}`, + '', + 'if (!Widget) {', + ` throw new Error(${JSON.stringify(`Unable to find Voltra widget export "${widget.exportName}".`)})`, + '}', + '', + 'export default Widget', + 'export { Widget }', + '', + ].join('\n') + ) + + return { + generatedEntryPath, + generatedEntryRelativePath: toPosixPath(path.relative(projectRoot, generatedEntryPath)), + } + } + + function removeSourcePath(sourcePath) { + const widgetIds = widgetIdsBySourcePath.get(sourcePath) || [] + + for (const widgetId of widgetIds) { + widgetsById.delete(widgetId) + } + + widgetIdsBySourcePath.delete(sourcePath) + } + + function registerWidgets(sourcePath, widgets) { + removeSourcePath(sourcePath) + + if (widgets.length === 0) { + return [] + } + + const newWidgetsById = new Map() + for (const widget of widgets) { + const duplicateInSource = newWidgetsById.get(widget.id) + + if (duplicateInSource) { + throw new DuplicateVoltraWidgetError({ + widgetId: widget.id, + firstPath: duplicateInSource.sourcePath, + secondPath: widget.sourcePath, + projectRoot, + }) + } + + newWidgetsById.set(widget.id, widget) + + const existingWidget = widgetsById.get(widget.id) + + if (existingWidget) { + throw new DuplicateVoltraWidgetError({ + widgetId: widget.id, + firstPath: existingWidget.sourcePath, + secondPath: widget.sourcePath, + projectRoot, + }) + } + } + + const registered = widgets.map((widget) => { + const entry = createGeneratedEntry(widget) + const registeredWidget = { + ...widget, + ...entry, + } + + widgetsById.set(widget.id, registeredWidget) + return registeredWidget + }) + + widgetIdsBySourcePath.set( + sourcePath, + registered.map((widget) => widget.id) + ) + + return registered + } + + function scanSource({ filePath, source }) { + const widgets = scanVoltraDirectives({ + filePath, + source, + }) + + return registerWidgets(filePath, widgets) + } + + function scanModule(module) { + try { + return scanSource({ + filePath: module.path, + source: module.getSource().toString('utf8'), + }) + } catch (error) { + if (error instanceof DuplicateVoltraWidgetError) { + throw error + } + + console.warn(`[voltra:metro] Failed to scan ${module.path}: ${error.message}`) + removeSourcePath(module.path) + return [] + } + } + + function scanModuleMap(modules) { + if (!modules) { + return + } + + for (const module of modules.values()) { + scanModule(module) + } + } + + return { + projectRoot, + applyMetroDelta(delta) { + if (delta.deleted) { + for (const deletedPath of delta.deleted) { + removeSourcePath(deletedPath) + } + } + + scanModuleMap(delta.added) + scanModuleMap(delta.modified) + ready = true + }, + getWidget(widgetId) { + return widgetsById.get(widgetId) || null + }, + isReady() { + return ready + }, + listWidgets() { + return Array.from(widgetsById.values()).map((widget) => ({ + id: widget.id, + componentName: widget.componentName, + exportName: widget.exportName, + sourcePath: toPosixPath(path.relative(projectRoot, widget.sourcePath)), + generatedEntryRelativePath: widget.generatedEntryRelativePath, + })) + }, + } +} + +module.exports = { DuplicateVoltraWidgetError, createWidgetRegistry } diff --git a/example/widgets/ios/IosWeatherWidget.tsx b/example/widgets/ios/IosWeatherWidget.tsx index e07a294f..e73538c2 100644 --- a/example/widgets/ios/IosWeatherWidget.tsx +++ b/example/widgets/ios/IosWeatherWidget.tsx @@ -21,6 +21,8 @@ interface WeatherWidgetProps { } export const IosWeatherWidget = ({ weather = DEFAULT_WEATHER }: WeatherWidgetProps) => { + 'use voltra' + const gradient = WEATHER_GRADIENTS[weather.condition] const emoji = WEATHER_EMOJIS[weather.condition] const description = WEATHER_DESCRIPTIONS[weather.condition] From 89f91303f194dd3a785f8f8fc68158aa36fbc8b6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 2 Jun 2026 14:16:07 +0200 Subject: [PATCH 02/36] feat: render generated Voltra widget entries Update generated widget entries to export a render function that accepts props and renders the discovered component through the Voltra renderer. --- example/metro/widgetRegistry.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/example/metro/widgetRegistry.js b/example/metro/widgetRegistry.js index 52282c81..1583052c 100644 --- a/example/metro/widgetRegistry.js +++ b/example/metro/widgetRegistry.js @@ -54,6 +54,8 @@ function createWidgetRegistry({ projectRoot }) { fs.writeFileSync( generatedEntryPath, [ + "import { createElement } from 'react'", + "import { renderVoltraVariantToJson } from '@use-voltra/ios'", `import * as WidgetModule from ${JSON.stringify(normalizedImportPath)}`, '', `const Widget = ${exportExpression}`, @@ -62,7 +64,11 @@ function createWidgetRegistry({ projectRoot }) { ` throw new Error(${JSON.stringify(`Unable to find Voltra widget export "${widget.exportName}".`)})`, '}', '', - 'export default Widget', + 'export function render(props = {}) {', + ' return renderVoltraVariantToJson(createElement(Widget, props))', + '}', + '', + 'export default render', 'export { Widget }', '', ].join('\n') From 49083589e2d5c5fb1e2d3c2fa1f68bb9890912ec Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 3 Jun 2026 07:35:29 +0200 Subject: [PATCH 03/36] feat(track-5): add WidgetEnvironment type for client-rendered widgets (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the env shape consumed by client-rendered widgets in their `(props, env) => JSX` signature. Mirrors expo-widgets' WidgetEnvironment for runtime device fields, with a Voltra-specific env.build.* namespace for dev-mode tooling. - packages/core/src/widget-environment.ts (new): WidgetEnvironment, WidgetBuildEnvironment, MaterialColorScheme. iOS-only fields (widgetRenderingMode, showsWidgetContainerBackground) and Android-only fields (materialColors) are optional — undefined on the wrong platform. - isIosEnv / isAndroidEnv type guards for platform narrowing. - Re-exported from @use-voltra/core, @use-voltra/ios, @use-voltra/android. Phase 1 of the client-rendered widgets implementation; native runtime and generated entry plumbing land in later phases. Pure types — no runtime behavior changed. Build + typecheck + lint clean. --- packages/android/src/index.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/widget-environment.ts | 154 ++++++++++++++++++++++++ packages/ios/src/index.ts | 2 + 4 files changed, 159 insertions(+) create mode 100644 packages/core/src/widget-environment.ts diff --git a/packages/android/src/index.ts b/packages/android/src/index.ts index 4c7b2ffc..315d5936 100644 --- a/packages/android/src/index.ts +++ b/packages/android/src/index.ts @@ -89,3 +89,5 @@ export type { LinearProgressIndicatorProps } from './jsx/LinearProgressIndicator export type { RowProps } from './jsx/Row.js' export type { SpacerProps } from './jsx/Spacer.js' export type { TextProps } from './jsx/Text.js' +export { isAndroidEnv, isIosEnv } from '@use-voltra/core' +export type { MaterialColorScheme, WidgetBuildEnvironment, WidgetEnvironment } from '@use-voltra/core' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cf73c506..442aa610 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,4 @@ export * from './payload.js' export * from './payload/short-names.js' export * from './renderer/index.js' export * from './types.js' +export * from './widget-environment.js' diff --git a/packages/core/src/widget-environment.ts b/packages/core/src/widget-environment.ts new file mode 100644 index 00000000..887a0dbb --- /dev/null +++ b/packages/core/src/widget-environment.ts @@ -0,0 +1,154 @@ +/** + * Track 5 — env shape consumed by client-rendered widgets. + * + * Client-rendered widgets are functions of `(props, env) => JSX`, evaluated inside the + * Voltra JS runtime (JSC on iOS, Hermes on Android) at every render. The `env` second + * argument is populated by the native runtime at draw time and carries: + * + * - **Runtime device state** (`colorScheme`, `widgetFamily`, etc.) captured per render + * - **Platform-specific runtime state** (`widgetRenderingMode` on iOS, `materialColors` on + * Android), present only on the platform that has the concept + * - **AppIntent / user-configured params** under `env.configuration` (TypeScript-typed per + * widget via the generic parameter) + * - **Build env** under `env.build.*` — values that don't change between renders inside a + * process (isDev, Metro URL, app version, Voltra version) + * + * The shape mirrors expo-widgets' `WidgetEnvironment` for the runtime device fields, with + * a Voltra-specific `env.build.*` namespace added for dev-mode tooling. + * + * @typeParam TConfig - Shape of `env.configuration` (AppIntent / user-configured params). + * Defaults to `undefined` for widgets that don't accept user configuration. Widget authors + * can supply a more specific type per widget for typed access. + */ +export type WidgetEnvironment | undefined = undefined> = { + /** Date the widget is being rendered for. Transported as epoch ms over the JS boundary + * and reconstructed as `Date` by the runtime entry. */ + date: Date + + /** Widget size family. iOS values: `systemSmall`, `systemMedium`, `systemLarge`, etc. + * Android values: synthesized from Glance `LocalSize` (e.g. `"200x200"`). */ + widgetFamily: string + + /** Current color scheme of the widget's environment. May be `undefined` if the platform + * doesn't expose it (rare). */ + colorScheme?: 'light' | 'dark' + + /** BCP-47 locale tag — for example `"en-US"` or `"pl-PL"`. */ + locale?: string + + // --------------------------------------------------------------------------- + // iOS-only runtime values + // Present only when rendering on iOS; `undefined` on Android. + // --------------------------------------------------------------------------- + + /** iOS — rendering mode the widget is being drawn in. `fullColor` on home screen, + * `accented` on tinted/Liquid Glass widgets (iOS 18+) and watchOS, `vibrant` on lock + * screen. Maps to SwiftUI `@Environment(\.widgetRenderingMode)`. */ + widgetRenderingMode?: 'fullColor' | 'accented' | 'vibrant' + + /** iOS — whether the system is drawing a container background behind the widget. + * Maps to SwiftUI `@Environment(\.showsWidgetContainerBackground)`. iOS 17+. */ + showsWidgetContainerBackground?: boolean + + // --------------------------------------------------------------------------- + // Android-only runtime values + // Present only when rendering on Android; `undefined` on iOS. + // --------------------------------------------------------------------------- + + /** Android — Material You dynamic color tokens captured from + * `MaterialTheme.colorScheme`. Field-for-field maps onto Compose `ColorScheme` + * (primary, onPrimary, surface, onSurface, etc.). */ + materialColors?: MaterialColorScheme + + // --------------------------------------------------------------------------- + // System-managed configuration + // --------------------------------------------------------------------------- + + /** AppIntent / user-configured parameters for this widget. `undefined` for widgets that + * don't accept user configuration. Typed per widget via the [TConfig] generic. */ + configuration: TConfig + + // --------------------------------------------------------------------------- + // Build env — static for the process lifetime, supplied by the runtime + // --------------------------------------------------------------------------- + + /** Build / process-level metadata, populated by the runtime once per process. Static for + * the JS runtime's lifetime; does not change between renders. */ + build: WidgetBuildEnvironment +} + +/** + * Build / process metadata available inside the widget render function. Populated by the + * native runtime; identical across every render in a process. + */ +export type WidgetBuildEnvironment = { + /** True when running against a development build (DEBUG / `__DEV__`). Used to gate + * dev-mode behaviour like fetching bundles from Metro. */ + isDev: boolean + + /** URL of the Metro dev server when `isDev` is true. Used by the runtime to fetch widget + * bundles for hot-reload. `undefined` in release builds. */ + metroUrl?: string + + /** App version string (`CFBundleShortVersionString` on iOS, `versionName` on Android). */ + appVersion: string + + /** Voltra package version (`@use-voltra/core`). Surfaces in error reports and lets + * widgets gate behaviour by compatibility level if needed. */ + voltraVersion: string +} + +/** + * Material You dynamic color tokens captured from Android `MaterialTheme.colorScheme`. + * Field names map 1:1 onto Compose's `ColorScheme` properties. Each value is an RGBA + * hex string (`#RRGGBBAA` or `#RRGGBB`). Available on Android only. + */ +export type MaterialColorScheme = { + primary: string + onPrimary: string + primaryContainer: string + onPrimaryContainer: string + secondary: string + onSecondary: string + secondaryContainer: string + onSecondaryContainer: string + tertiary: string + onTertiary: string + tertiaryContainer: string + onTertiaryContainer: string + background: string + onBackground: string + surface: string + onSurface: string + surfaceVariant: string + onSurfaceVariant: string + outline: string + outlineVariant: string + error: string + onError: string + errorContainer: string + onErrorContainer: string +} + +/** + * Type guard — returns true when the runtime env is an iOS-platform env. + * + * @example + * if (isIosEnv(env)) { + * // env.widgetRenderingMode is narrowed to the concrete value (not undefined) + * } + */ +export function isIosEnv( + env: WidgetEnvironment +): env is WidgetEnvironment & { widgetRenderingMode: NonNullable } { + return env.widgetRenderingMode !== undefined +} + +/** + * Type guard — returns true when the runtime env is an Android-platform env. + */ +export function isAndroidEnv( + env: WidgetEnvironment +): env is WidgetEnvironment & { materialColors: NonNullable } { + return env.materialColors !== undefined +} diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index 98b89a67..93c42502 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -30,3 +30,5 @@ export type { } from './types.js' export { renderWidgetToJson, renderWidgetToString } from './widgets/renderer.js' export type { ScheduledWidgetEntry, WidgetFamily, WidgetInfo, WidgetVariants } from './widgets/types.js' +export { isAndroidEnv, isIosEnv } from '@use-voltra/core' +export type { MaterialColorScheme, WidgetBuildEnvironment, WidgetEnvironment } from '@use-voltra/core' From b2b4f998b54c64a1772eb7fb220f9be371343ad5 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 3 Jun 2026 07:54:01 +0200 Subject: [PATCH 04/36] feat(track-5): thread env into the generated render entry (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - example/metro/widgetRegistry.js: generated entry now emits `render(props = {}, env = {})`. Env is passed to the widget via a WidgetWithEnv closure wrapper because createElement does not accept extra positional args. - example/widgets/ios/IosWeatherWidget.tsx: accepts `env: WidgetEnvironment` as the second arg and appends the current color scheme to the description as a visible test marker. Verified by curling /voltra/widgets/IosWeatherWidget.bundle and grepping for WidgetWithEnv (×2), forwardedProps (×2), env.colorScheme (×1). Bundle size +2.5 KB. No native side yet — that's Phase 3. --- example/metro/widgetRegistry.js | 11 ++++++++--- example/widgets/ios/IosWeatherWidget.tsx | 12 +++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/example/metro/widgetRegistry.js b/example/metro/widgetRegistry.js index 1583052c..aba6d436 100644 --- a/example/metro/widgetRegistry.js +++ b/example/metro/widgetRegistry.js @@ -64,14 +64,19 @@ function createWidgetRegistry({ projectRoot }) { ` throw new Error(${JSON.stringify(`Unable to find Voltra widget export "${widget.exportName}".`)})`, '}', '', - 'export function render(props = {}) {', - ' return renderVoltraVariantToJson(createElement(Widget, props))', + '// Voltra client-rendered widget entry — invoked by the native JS runtime on every render.', + '// `env` is captured at draw time by Swift / Kotlin and passed across the JSC / Hermes', + '// boundary; closure-passed into the widget function because createElement does not', + '// accept extra positional args.', + 'export function render(props = {}, env = {}) {', + ' const WidgetWithEnv = (forwardedProps) => Widget(forwardedProps, env)', + ' return renderVoltraVariantToJson(createElement(WidgetWithEnv, props))', '}', '', 'export default render', 'export { Widget }', '', - ].join('\n') + ].join('\n'), ) return { diff --git a/example/widgets/ios/IosWeatherWidget.tsx b/example/widgets/ios/IosWeatherWidget.tsx index e73538c2..f024d8d8 100644 --- a/example/widgets/ios/IosWeatherWidget.tsx +++ b/example/widgets/ios/IosWeatherWidget.tsx @@ -1,4 +1,4 @@ -import { Voltra } from '@use-voltra/ios' +import { Voltra, type WidgetEnvironment } from '@use-voltra/ios' import { DEFAULT_WEATHER, @@ -20,12 +20,18 @@ interface WeatherWidgetProps { weather?: WeatherData } -export const IosWeatherWidget = ({ weather = DEFAULT_WEATHER }: WeatherWidgetProps) => { +export const IosWeatherWidget = ( + { weather = DEFAULT_WEATHER }: WeatherWidgetProps, + env: WidgetEnvironment = {} as WidgetEnvironment +) => { 'use voltra' const gradient = WEATHER_GRADIENTS[weather.condition] const emoji = WEATHER_EMOJIS[weather.condition] - const description = WEATHER_DESCRIPTIONS[weather.condition] + const baseDescription = WEATHER_DESCRIPTIONS[weather.condition] + // Phase 2 verification — append the current color scheme so it's visible the env + // arg is actually reaching the widget. Replace with real env-driven styling later. + const description = env.colorScheme ? `${baseDescription} · ${env.colorScheme}` : baseDescription return ( From b01a9fc413c1cc722a377537e1a9d88114c25a61 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 3 Jun 2026 08:38:10 +0200 Subject: [PATCH 05/36] feat(track-5): runtime smoke test for client-rendered widgets (Phase 3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleared the critical risk gate — proven end-to-end on iOS simulator that the shared JSContext + per-widget Metro bundle + namespaced-global pattern works: JS fetches /voltra/widgets/IosWeatherWidget.bundle (~219 KB) → Swift evaluateBundle wraps source with a small bootstrap that captures __r(0) into globalThis.__voltraWidgets[] → Swift render(widgetId, propsJSON, envJSON) calls the captured render fn → bundle's JSX runs with closure-passed env, renderVoltraVariantToJson produces the compact JSON, JSON.stringify returns it across the boundary - packages/ios-client/ios/shared/VoltraJSRenderer.swift (new): shared JSContext singleton with NSLock; evaluateBundle / render API - packages/ios-client/src/native/NativeVoltra.ts + ios/app/VoltraModule.swift + ios/app/NativeVoltra.mm: two temporary TurboModule methods bridging the runtime to JS — voltraWidgetEvalBundle / voltraWidgetRender. Removed in Phase 3b once the widget extension calls the runtime directly. - packages/ios-client/src/widgets/client-rendered-smoke.ts: JS wrapper exported from @use-voltra/ios-client for the smoke screen. - example/metro/widgetRegistry.js: generated entry now JSON.parses incoming props/env (they cross the boundary as strings) and JSON.stringifies the resolved tree before returning. - example/screens/ios/IosClientRenderedSmokeScreen.tsx + route: in-app screen that fetches the Metro bundle, evaluates, calls render, displays the JSON. No WidgetKit involvement yet. Phase 3b adds env capture from @Environment, dual dev/prod bundle source, and the actual widget extension hookup. --- .../testing-grounds/client-rendered-smoke.tsx | 5 + example/metro/widgetRegistry.js | 16 +- .../ios/IosClientRenderedSmokeScreen.tsx | 174 ++++++++++++++++++ example/screens/ios/tabs/sections.ts | 7 + packages/ios-client/ios/app/NativeVoltra.mm | 36 ++++ .../ios-client/ios/app/VoltraModule.swift | 49 +++++ .../ios/shared/VoltraJSRenderer.swift | 159 ++++++++++++++++ packages/ios-client/src/index.ts | 2 + .../ios-client/src/native/NativeVoltra.ts | 17 ++ .../src/widgets/client-rendered-smoke.ts | 21 +++ 10 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 example/app/testing-grounds/client-rendered-smoke.tsx create mode 100644 example/screens/ios/IosClientRenderedSmokeScreen.tsx create mode 100644 packages/ios-client/ios/shared/VoltraJSRenderer.swift create mode 100644 packages/ios-client/src/widgets/client-rendered-smoke.ts diff --git a/example/app/testing-grounds/client-rendered-smoke.tsx b/example/app/testing-grounds/client-rendered-smoke.tsx new file mode 100644 index 00000000..7c11fed4 --- /dev/null +++ b/example/app/testing-grounds/client-rendered-smoke.tsx @@ -0,0 +1,5 @@ +import IosClientRenderedSmokeScreen from '~/screens/ios/IosClientRenderedSmokeScreen' + +export default function ClientRenderedSmokeIndex() { + return +} diff --git a/example/metro/widgetRegistry.js b/example/metro/widgetRegistry.js index aba6d436..808a163f 100644 --- a/example/metro/widgetRegistry.js +++ b/example/metro/widgetRegistry.js @@ -65,12 +65,18 @@ function createWidgetRegistry({ projectRoot }) { '}', '', '// Voltra client-rendered widget entry — invoked by the native JS runtime on every render.', - '// `env` is captured at draw time by Swift / Kotlin and passed across the JSC / Hermes', - '// boundary; closure-passed into the widget function because createElement does not', - '// accept extra positional args.', - 'export function render(props = {}, env = {}) {', + '//', + '// Props / env cross the JSC / Hermes boundary as JSON strings (cheapest marshaling),', + '// so the entry parses them before calling the widget. Env is closure-passed because', + '// createElement does not accept extra positional args. The resolved Voltra node tree', + "// is stringified before returning so the native side gets a plain String it can hand", + '// to the existing payload parser.', + 'export function render(propsJSON, envJSON) {', + " const props = typeof propsJSON === 'string' ? (propsJSON ? JSON.parse(propsJSON) : {}) : (propsJSON || {})", + " const env = typeof envJSON === 'string' ? (envJSON ? JSON.parse(envJSON) : {}) : (envJSON || {})", ' const WidgetWithEnv = (forwardedProps) => Widget(forwardedProps, env)', - ' return renderVoltraVariantToJson(createElement(WidgetWithEnv, props))', + ' const resolved = renderVoltraVariantToJson(createElement(WidgetWithEnv, props))', + ' return JSON.stringify(resolved)', '}', '', 'export default render', diff --git a/example/screens/ios/IosClientRenderedSmokeScreen.tsx b/example/screens/ios/IosClientRenderedSmokeScreen.tsx new file mode 100644 index 00000000..b5152907 --- /dev/null +++ b/example/screens/ios/IosClientRenderedSmokeScreen.tsx @@ -0,0 +1,174 @@ +import { useRouter } from 'expo-router' +import React, { useState } from 'react' +import { Alert, Platform, ScrollView, StyleSheet, Text, View } from 'react-native' +import { voltraWidgetEvalBundle, voltraWidgetRender } from '@use-voltra/ios-client' + +import { Button } from '~/components/Button' +import { ScreenLayout } from '~/components/ScreenLayout' + +const WIDGET_ID = 'IosWeatherWidget' +const METRO_BASE_URL = 'http://localhost:8081' + +/** + * Track 5 / Phase 3a — runtime smoke test. + * + * Tap the button: + * 1. Fetch the widget bundle from Metro (the maintainer's /voltra/widgets/.bundle endpoint) + * 2. Hand the raw source to native, which evals it in the shared JSContext + * 3. Call render(props, env) with hardcoded values + * 4. Display the resolved JSON string returned by the bundle + * + * Verifies the JSC runtime, the bundle's render export, the props/env round-trip, and that + * the renderer's compact-JSON output matches Voltra's wire format — all without involving + * WidgetKit. WidgetKit hook-up arrives in Phase 3b. + */ +export default function IosClientRenderedSmokeScreen() { + const router = useRouter() + const [busy, setBusy] = useState(false) + const [bundleSize, setBundleSize] = useState(null) + const [resolved, setResolved] = useState(null) + const [error, setError] = useState(null) + + const runSmokeTest = async () => { + if (Platform.OS !== 'ios') { + Alert.alert('Not available', 'This smoke test is iOS-only for now.') + return + } + setBusy(true) + setResolved(null) + setError(null) + try { + const url = `${METRO_BASE_URL}/voltra/widgets/${WIDGET_ID}.bundle?platform=ios&dev=true` + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Metro returned ${response.status}: ${await response.text()}`) + } + const source = await response.text() + setBundleSize(source.length) + + await voltraWidgetEvalBundle(WIDGET_ID, source) + + const props = { + weather: { + condition: 'sunny', + temperature: 22, + location: 'Warsaw', + highTemp: 24, + lowTemp: 15, + }, + } + const env = { + date: Date.now(), + widgetFamily: 'systemMedium', + colorScheme: 'dark' as const, + widgetRenderingMode: 'fullColor' as const, + configuration: undefined, + build: { + isDev: true, + metroUrl: METRO_BASE_URL, + appVersion: '1.0.0', + voltraVersion: '1.4.1', + }, + } + const result = await voltraWidgetRender(WIDGET_ID, JSON.stringify(props), JSON.stringify(env)) + setResolved(result) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + setError(message) + Alert.alert('Smoke test failed', message) + } finally { + setBusy(false) + } + } + + return ( + + +