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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/expo-plugin/src/utils/prerender.node.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 })
}
})
})
28 changes: 25 additions & 3 deletions packages/expo-plugin/src/utils/prerender.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -24,6 +26,11 @@ export interface PrerenderableWidget {
/** widgetId -> locale key -> prerendered JSON string (single-file widgets use `__default`) */
export type PrerenderedWidgetStates = Map<string, Map<string, string>>

const PRERENDER_PACKAGE_REDIRECTS: Record<string, string> = {
'@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)
*/
Expand Down Expand Up @@ -97,17 +104,31 @@ 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<string>): any {
// Cache for already-evaluated modules to handle circular dependencies
const moduleCache = new Map<string, any>()
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)) {
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
Expand Down Expand Up @@ -181,6 +202,7 @@ export async function prerenderWidgetState(
renderer: WidgetRenderer
): Promise<PrerenderedWidgetStates> {
const prerenderedStates: PrerenderedWidgetStates = new Map()
const warnedRedirects = new Set<string>()

for (const widget of widgets) {
if (!widget.initialStatePath) {
Expand All @@ -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)
}
Expand Down
Loading