one
Version:
One is a React framework for web, iOS, and Android apps.
188 lines (172 loc) • 7.27 kB
text/typescript
import { transformSync } from '@babel/core'
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
import { parseSync } from 'oxc-parser'
import * as vm from 'node:vm'
import { expect, it } from 'vitest'
import oneRouterMetroPlugin from '../babel-plugins/one-router-metro'
const nodeRequire = createRequire(import.meta.url)
const metroRequire = createRequire(nodeRequire.resolve('metro/package.json'))
const inlineRequiresPlugin = metroRequire('metro-transform-plugins').inlineRequiresPlugin
const polyfillFile = new URL('../../dist/esm/polyfills-mobile.native.js', import.meta.url)
function nativeCoreJsImports() {
const code = readFileSync(polyfillFile, 'utf8')
const parsed = parseSync(polyfillFile.pathname, code, { lang: 'js' })
if (parsed.errors.length) throw new Error(parsed.errors[0].message)
return parsed.program.body
.filter((statement) => statement.type === 'ImportDeclaration')
.map((statement) => statement.source.value)
.filter((specifier) => specifier.startsWith('core-js/'))
}
// the transform is pure, so each core-js file is transformed once per run;
// every evaluation still executes the modules in its own fresh context
const transformed = new Map<string, string>()
function metroTransform(filename: string) {
const cached = transformed.get(filename)
if (cached) return cached
const code = transformSync(readFileSync(filename, 'utf8'), {
filename,
babelrc: false,
configFile: false,
plugins: [inlineRequiresPlugin],
})?.code
if (!code) throw new Error(`Metro did not transform ${filename}`)
transformed.set(filename, code)
return code
}
function evaluateCoreJs(imports: string[], entry?: string) {
const context = vm.createContext({
process: { env: { ONE_ROUTER_ROOT_FOLDER_NAME: 'app' } },
})
vm.runInContext(
'for (const name of ["toSorted", "toReversed", "toSpliced", "with"]) delete Array.prototype[name]',
context
)
const cache = new Map<string, { exports: any }>()
function load(specifier: string, from: string): any {
const filename = createRequire(from).resolve(specifier)
const cached = cache.get(filename)
if (cached) return cached.exports
const module = { exports: {} as any }
cache.set(filename, module)
const code = metroTransform(filename)
const factory = vm.runInContext(
`(function(require, module, exports) { ${code}\n })`,
context,
{ filename }
)
factory((name: string) => load(name, filename), module, module.exports)
return module.exports
}
const install = () => {
for (const specifier of imports) load(specifier, polyfillFile.pathname)
}
if (entry) {
const factory = vm.runInContext(`(function(require) { ${entry}\n })`, context)
factory((specifier: string) => {
if (specifier === 'react-native/Libraries/Core/InitializeCore') {
context.nativeInitialized = true
return {}
}
if (specifier === './dist/esm/polyfills-mobile.native.js') {
if (!context.nativeInitialized)
throw new Error('native globals were not initialized')
install()
return {}
}
if (specifier === './native-array-setup.js') {
context.setupArrays = vm.runInContext(
'JSON.stringify([[2,1].toSorted(), [1,2].toReversed(), [1,2,3].toSpliced(1,1,4), [1,2].with(-1,3)])',
context
)
return {}
}
if (specifier === 'one') {
install()
return { createApp() {} }
}
if (specifier === './metro-entry-ctx.js') return { ctx: { keys: () => [] } }
if (specifier.startsWith('@babel/runtime/'))
return load(specifier, polyfillFile.pathname)
throw new Error(`unexpected native entry import: ${specifier}`)
})
} else install()
return (expression = 'Function.prototype.toString.call(function metroProbe() {})') =>
vm.runInContext(expression, context)
}
function compileMetroEntry(source: string) {
const filename = new URL('../../metro-entry.js', import.meta.url).pathname
const compiled = transformSync(source, {
filename,
babelrc: false,
configFile: false,
presets: [nodeRequire('@react-native/babel-preset')],
plugins: [
[
oneRouterMetroPlugin,
{
ONE_ROUTER_APP_ROOT_RELATIVE_TO_ENTRY: './app',
ONE_ROUTER_ROOT_FOLDER_NAME: 'app',
ONE_ROUTER_REQUIRE_CONTEXT_REGEX_STRING: '\\./app/.*',
ONE_SETUP_FILE_NATIVE: './native-array-setup.js',
},
],
],
})?.code
if (!compiled) throw new Error('native Metro entry did not compile')
const entry = transformSync(compiled, {
filename,
babelrc: false,
configFile: false,
plugins: [inlineRequiresPlugin],
})?.code
if (!entry) throw new Error('native Metro entry did not inline requires')
return entry
}
// compiling loads the whole react native babel preset on first use; do it once
// at collection so the test times only what it asserts
const metroEntrySource = readFileSync(new URL('../../metro-entry.js', import.meta.url), 'utf8')
const compiledMetroEntry = compileMetroEntry(metroEntrySource)
const compiledControlEntry = compileMetroEntry(
metroEntrySource
.replace("import 'react-native/Libraries/Core/InitializeCore'", '')
.replace("import './dist/esm/polyfills-mobile.native.js'", '')
)
it('initializes native array methods before configured app setup in the real Metro entry', () => {
const imports = nativeCoreJsImports()
expect(() => evaluateCoreJs(imports, compiledControlEntry)).toThrow('toSorted')
const evaluate = evaluateCoreJs(imports, compiledMetroEntry)
expect(evaluate('setupArrays')).toBe('[[1,2],[2,1],[1,4,3],[1,3]]')
})
it('installs change-array-by-copy methods before native consumers under Metro inline requires', () => {
const evaluate = evaluateCoreJs(nativeCoreJsImports())
expect(evaluate('JSON.stringify([2, 1].toSorted())')).toBe('[1,2]')
expect(evaluate('JSON.stringify([1, 2].toReversed())')).toBe('[2,1]')
expect(evaluate('JSON.stringify([1, 2, 3].toSpliced(1, 1, 4))')).toBe('[1,4,3]')
expect(evaluate('JSON.stringify([1, 2].with(-1, 3))')).toBe('[1,3]')
expect(
evaluate('JSON.stringify(Array.prototype.toSorted.call({0: 2, 1: 1, length: 2}))')
).toBe('[1,2]')
expect(evaluate('JSON.stringify([3, 2, 1].toSorted((a, b) => a - b))')).toBe('[1,2,3]')
expect(evaluate('JSON.stringify([, 2].toSorted())')).toBe('[2,null]')
expect(evaluate('0 in [, 2].toReversed() && 1 in [, 2].toReversed()')).toBe(true)
expect(
evaluate(
'(() => { const a = [2, 1]; a.toSorted(); a.toReversed(); a.toSpliced(0, 1); a.with(0, 3); return JSON.stringify(a) })()'
)
).toBe('[2,1]')
expect(() => evaluate('[1, 2].with(2, 3)')).toThrow()
expect(
evaluate('Object.getOwnPropertyDescriptor(Array.prototype, "toSorted").enumerable')
).toBe(false)
})
it('keeps native core-js polyfills safe under Metro inline requires', () => {
const imports = nativeCoreJsImports()
// the control must still fail, or this probe no longer models Metro's bug.
expect(() =>
evaluateCoreJs(
imports.filter((name) => name !== 'core-js/internals/inspect-source')
)()
).toThrow()
expect(evaluateCoreJs(imports)()).toContain('metroProbe')
})