diff --git a/packages/expo-plugin/src/utils/prerender.node.test.ts b/packages/expo-plugin/src/utils/prerender.node.test.ts new file mode 100644 index 00000000..e6667e61 --- /dev/null +++ b/packages/expo-plugin/src/utils/prerender.node.test.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { logger } from './logger' +import { prerenderWidgetState } from './prerender' + +async function writePackage(root: string, packageName: string, source: string): Promise { + const packageDir = path.join(root, 'node_modules', ...packageName.split('/')) + await fs.mkdir(packageDir, { recursive: true }) + await fs.writeFile( + path.join(packageDir, 'package.json'), + JSON.stringify( + { + name: packageName, + main: 'index.js', + }, + null, + 2 + ) + ) + await fs.writeFile(path.join(packageDir, 'index.js'), source) +} + +describe('prerenderWidgetState', () => { + it.each([ + ['@use-voltra/ios-client', '@use-voltra/ios', 'ios'], + ['@use-voltra/android-client', '@use-voltra/android', 'android'], + ] as const)('redirects %s to %s and warns once', async (clientPackage, targetPackage, targetLabel) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'voltra-prerender-')) + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined) + + try { + await fs.writeFile( + path.join(tempRoot, 'package.json'), + JSON.stringify( + { + name: 'fixture-project', + }, + null, + 2 + ) + ) + + await fs.mkdir(path.join(tempRoot, 'widgets'), { recursive: true }) + await writePackage(tempRoot, targetPackage, `exports.label = ${JSON.stringify(targetLabel)}\n`) + await writePackage(tempRoot, clientPackage, `throw new Error('client package should not load')\n`) + + await fs.writeFile( + path.join(tempRoot, 'widgets', 'state.ts'), + [`import { label } from '${clientPackage}'`, 'export default { label }', ''].join('\n') + ) + + const states = await prerenderWidgetState( + [{ id: 'demo', initialStatePath: './widgets/state.ts' }], + tempRoot, + (variants) => JSON.stringify(variants) + ) + + expect(states.get('demo')?.get('__default')).toBe(JSON.stringify({ label: targetLabel })) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(clientPackage)) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(targetPackage)) + } finally { + warnSpy.mockRestore() + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/expo-plugin/src/utils/prerender.ts b/packages/expo-plugin/src/utils/prerender.ts index 828e0c01..4518b9fc 100644 --- a/packages/expo-plugin/src/utils/prerender.ts +++ b/packages/expo-plugin/src/utils/prerender.ts @@ -1,11 +1,13 @@ 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 { MODULE_EXTENSIONS } from '../constants' import type { WidgetInitialStatePath, WidgetLabel } from '../types' +import { logger } from './logger' import { isWidgetLocalizedMap } from './widgetLabel' /** @@ -24,6 +26,11 @@ 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', +} + /** * Check if a module specifier is a relative or absolute path (local file) */ @@ -97,9 +104,10 @@ function transpileFile(filePath: string, projectRoot: string): string { * This allows executing widget code that uses JSX and React components. * Local module dependencies are also transpiled with the same Babel settings. */ -function evaluateWidgetModule(projectRoot: string, filePath: string): any { +function evaluateWidgetModule(projectRoot: string, filePath: string, warnedRedirects: 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 @@ -107,7 +115,20 @@ function evaluateWidgetModule(projectRoot: string, filePath: string): any { function customRequire(moduleSpecifier: string, currentDir: string): any { // For non-local modules (npm packages), use native require if (!isLocalModule(moduleSpecifier)) { - return require(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 @@ -181,6 +202,7 @@ export async function prerenderWidgetState( renderer: WidgetRenderer ): Promise { const prerenderedStates: PrerenderedWidgetStates = new Map() + const warnedRedirects = new Set() for (const widget of widgets) { if (!widget.initialStatePath) { @@ -197,7 +219,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) + const widgetVariants = evaluateWidgetModule(projectRoot, absoluteWidgetPath, warnedRedirects) const prerenderedState = renderer(widgetVariants) inner.set(localeKey, prerenderedState) }