diff --git a/.changeset/rn-087-native-runtime.md b/.changeset/rn-087-native-runtime.md new file mode 100644 index 000000000..7d8baf2fe --- /dev/null +++ b/.changeset/rn-087-native-runtime.md @@ -0,0 +1,10 @@ +--- +"@callstack/repack": patch +--- + +Support React Native 0.87. Polyfills are read from `rn-get-polyfills.js` when +present, otherwise from `@react-native/js-polyfills` (resolved from the project, +falling back through `@react-native/metro-config`), with an actionable error when +neither can be found. The asset registry request is aliased to +`src/asset-registry.js` on the 0.87 layout; on 0.86 and earlier behaviour is +unchanged. diff --git a/packages/repack/src/plugins/NativeEntryPlugin/NativeEntryPlugin.ts b/packages/repack/src/plugins/NativeEntryPlugin/NativeEntryPlugin.ts index 433228f33..91f3ea520 100644 --- a/packages/repack/src/plugins/NativeEntryPlugin/NativeEntryPlugin.ts +++ b/packages/repack/src/plugins/NativeEntryPlugin/NativeEntryPlugin.ts @@ -3,6 +3,10 @@ import type { ResolveAlias, Compiler as RspackCompiler } from '@rspack/core'; import type { Compiler as WebpackCompiler } from 'webpack'; import { isRspackCompiler, moveElementBefore } from '../../helpers/index.js'; import { makePolyfillsRuntimeModule } from './PolyfillsRuntimeModule.js'; +import { + getReactNativeAssetRegistryAlias, + resolveReactNativePolyfills, +} from './reactNativeRuntime.js'; export interface NativeEntryPluginConfig { /** @@ -47,10 +51,27 @@ export class NativeEntryPlugin { : undefined ); - const getReactNativePolyfills: () => string[] = require( - path.join(reactNativePath, 'rn-get-polyfills.js') + const getReactNativePolyfills = resolveReactNativePolyfills( + compiler.context, + reactNativePath ); + // Map `react-native/Libraries/Image/AssetRegistry` to the relocated + // `src/asset-registry.js` on the React Native >= 0.87 layout (no-op on <= 0.86). + // Done here because Repack's default resolver ignores `package.json` exports. + // The exact-match alias must be prepended: enhanced-resolve and Rspack match + // aliases in insertion order, so a user's generic `react-native` alias would + // otherwise win and rewrite the request to a non-existent path before the + // specific key is consulted. + const assetRegistryAlias = + getReactNativeAssetRegistryAlias(reactNativePath); + if (assetRegistryAlias) { + compiler.options.resolve.alias = { + ...assetRegistryAlias, + ...compiler.options.resolve.alias, + }; + } + const initializeCorePath = this.config?.initializeCoreLocation ?? path.join(reactNativePath, 'Libraries/Core/InitializeCore.js'); diff --git a/packages/repack/src/plugins/NativeEntryPlugin/__tests__/reactNativeRuntime.test.ts b/packages/repack/src/plugins/NativeEntryPlugin/__tests__/reactNativeRuntime.test.ts new file mode 100644 index 000000000..274eb619d --- /dev/null +++ b/packages/repack/src/plugins/NativeEntryPlugin/__tests__/reactNativeRuntime.test.ts @@ -0,0 +1,152 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + ASSET_REGISTRY_REQUEST, + getReactNativeAssetRegistryAlias, + resolveReactNativePolyfills, +} from '../reactNativeRuntime.js'; + +const tmpDirs: string[] = []; + +function makeTmp(files: Record) { + const dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'rn-layout-')) + ); + tmpDirs.push(dir); + for (const [rel, contents] of Object.entries(files)) { + const target = path.join(dir, rel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + } + return dir; +} + +afterEach(() => { + while (tmpDirs.length) { + const dir = tmpDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('resolveReactNativePolyfills', () => { + it('uses rn-get-polyfills.js when present (React Native <= 0.86)', () => { + const rn = makeTmp({ + 'rn-get-polyfills.js': + "module.exports = () => [require.resolve('./console.js')];", + 'console.js': '// polyfill', + }); + // Resolver would fail if consulted; reaching the result proves the shim was used. + const getPolyfills = resolveReactNativePolyfills(rn, rn, () => { + throw new Error('resolver should not be called when the shim exists'); + }); + const paths = getPolyfills(); + expect(paths).toHaveLength(1); + expect(path.basename(paths[0])).toBe('console.js'); + }); + + it('resolves @react-native/js-polyfills from the project root', () => { + const rn = makeTmp({ 'index.js': '' }); + const projectRoot = makeTmp({ 'package.json': '{}' }); + const polyfillsModule = path.join(projectRoot, 'polyfills.js'); + fs.writeFileSync( + polyfillsModule, + "module.exports = () => ['/abs/console.js'];" + ); + + const getPolyfills = resolveReactNativePolyfills( + projectRoot, + rn, + (req, paths) => { + if (req.includes('metro-config')) throw new Error('no metro-config'); + if (req.includes('js-polyfills') && paths.includes(projectRoot)) { + return polyfillsModule; + } + throw new Error('unexpected ' + req); + } + ); + + expect(getPolyfills()).toEqual(['/abs/console.js']); + }); + + it('falls back through @react-native/metro-config when the project root has no polyfills', () => { + const rn = makeTmp({ 'index.js': '' }); + const projectRoot = makeTmp({ 'package.json': '{}' }); + + const metroPkg = path.join( + projectRoot, + 'node_modules', + '@react-native', + 'metro-config', + 'package.json' + ); + fs.mkdirSync(path.dirname(metroPkg), { recursive: true }); + fs.writeFileSync(metroPkg, '{"name":"@react-native/metro-config"}'); + const metroDir = path.dirname(metroPkg); + const polyfillsModule = path.join(projectRoot, 'polyfills.js'); + fs.writeFileSync( + polyfillsModule, + "module.exports = () => ['/abs/error-guard.js'];" + ); + + let consultedMetro = false; + const getPolyfills = resolveReactNativePolyfills( + projectRoot, + rn, + (req, paths) => { + if (req.includes('metro-config')) { + consultedMetro = true; + return metroPkg; + } + if (req.includes('js-polyfills')) { + // Resolvable only from metro-config's directory, not the project root. + if (paths.includes(metroDir)) return polyfillsModule; + throw new Error('not resolvable from ' + paths.join(',')); + } + throw new Error('unexpected ' + req); + } + ); + + expect(consultedMetro).toBe(true); + expect(getPolyfills()).toEqual(['/abs/error-guard.js']); + }); + + it('throws a descriptive error when nothing resolves', () => { + const rn = makeTmp({ 'index.js': '' }); + const projectRoot = makeTmp({ 'package.json': '{}' }); + expect(() => + resolveReactNativePolyfills(rn, projectRoot, () => { + throw new Error('cannot resolve'); + }) + ).toThrow(/Unable to locate React Native polyfills/); + }); +}); + +describe('getReactNativeAssetRegistryAlias', () => { + it('maps to src/asset-registry on the React Native >= 0.87 layout', () => { + const rn = makeTmp({ 'src/asset-registry.js': 'module.exports = {};' }); + expect(getReactNativeAssetRegistryAlias(rn)).toEqual({ + [`${ASSET_REGISTRY_REQUEST}$`]: path.join(rn, 'src', 'asset-registry'), + }); + }); + + it('returns null on the React Native <= 0.86 layout (legacy file present)', () => { + const rn = makeTmp({ + 'Libraries/Image/AssetRegistry.js': 'module.exports = {};', + }); + expect(getReactNativeAssetRegistryAlias(rn)).toBeNull(); + }); + + it('returns null when the legacy file also exists (no remap)', () => { + const rn = makeTmp({ + 'src/asset-registry.js': 'module.exports = {};', + 'Libraries/Image/AssetRegistry.js': 'module.exports = {};', + }); + expect(getReactNativeAssetRegistryAlias(rn)).toBeNull(); + }); + + it('returns null when no registry file exists', () => { + const rn = makeTmp({ 'index.js': '' }); + expect(getReactNativeAssetRegistryAlias(rn)).toBeNull(); + }); +}); diff --git a/packages/repack/src/plugins/NativeEntryPlugin/reactNativeRuntime.ts b/packages/repack/src/plugins/NativeEntryPlugin/reactNativeRuntime.ts new file mode 100644 index 000000000..34608f773 --- /dev/null +++ b/packages/repack/src/plugins/NativeEntryPlugin/reactNativeRuntime.ts @@ -0,0 +1,124 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Request the assets loader and IncludeModules emit for the asset registry. + * + * React Native <= 0.86 ships this file and, on 0.86, resolves it directly (its + * `exports` map has a `./Libraries/*` wildcard, so it works with package exports + * on or off). React Native 0.87 removes the file and drops the `./*` wildcard, so + * the request is remapped to `src/asset-registry.js` with a resolve alias (see + * {@link getReactNativeAssetRegistryAlias}). + * + * Keeping this request unchanged across versions preserves the Module Federation + * deep-import share key (`shared['react-native/']`) so hosts and remotes built + * with different Re.Pack versions still share a single registry instance. + */ +export const ASSET_REGISTRY_REQUEST = + 'react-native/Libraries/Image/AssetRegistry'; + +type Resolver = (request: string, paths: string[]) => string; + +const defaultResolver: Resolver = (request, paths) => + require.resolve(request, { paths }); + +/** + * Resolves React Native's polyfill list, returning the same + * `() => string[]` contract as the historic `rn-get-polyfills.js`. + * + * React Native <= 0.86 shipped `rn-get-polyfills.js` at the package root, which + * re-exported `@react-native/js-polyfills` (a direct dependency of react-native). + * 0.87 removed that file and dropped the dependency entirely, so the polyfills + * are now only reachable through packages that still pull them in - in practice + * `@react-native/metro-config`, itself an optional peer of the CLI plugin and a + * template devDependency. + * + * The polyfills are inlined into the emitted bundle, so they must be resolvable + * for production bundles too - they cannot be treated as dev-only. `resolveFrom` + * is injectable so the lookup chain can be exercised hermetically (the real + * resolver leaks the surrounding install layout, e.g. pnpm's virtual store). + */ +export function resolveReactNativePolyfills( + projectRoot: string, + reactNativePath: string, + resolveFrom: Resolver = defaultResolver +): () => string[] { + const rnGetPolyfillsPath = path.join(reactNativePath, 'rn-get-polyfills.js'); + if (fs.existsSync(rnGetPolyfillsPath)) { + return require(rnGetPolyfillsPath) as () => string[]; + } + + // React Native >= 0.87: resolve the polyfills from the project, then chain + // through `@react-native/metro-config`, which owns the dependency. Each + // location is tried in turn (same "resolve the owner, then chain" pattern used + // for the hermes parser). + const lookupDirs: string[] = [projectRoot]; + try { + const metroConfigPackageJson = resolveFrom( + '@react-native/metro-config/package.json', + [projectRoot] + ); + lookupDirs.push(path.dirname(metroConfigPackageJson)); + } catch { + // metro-config is an optional peer; a missing entry is handled below. + } + + let jsPolyfillsPath: string | undefined; + for (const dir of lookupDirs) { + try { + jsPolyfillsPath = resolveFrom('@react-native/js-polyfills', [dir]); + break; + } catch { + // try the next location + } + } + + if (!jsPolyfillsPath) { + throw new Error( + '[RepackNativeEntryPlugin] Unable to locate React Native polyfills. ' + + "React Native >= 0.87 no longer depends on '@react-native/js-polyfills', " + + 'so Repack cannot resolve the polyfills that must be present in the ' + + 'bundle. Add a version-matched `@react-native/js-polyfills` (or ' + + '`@react-native/metro-config`) to your project so it is available while bundling.' + ); + } + + return require(jsPolyfillsPath) as () => string[]; +} + +/** + * Builds the `resolve.alias` entry that maps {@link ASSET_REGISTRY_REQUEST} to + * the relocated registry file on the React Native >= 0.87 layout. + * + * Returns `null` (no alias) whenever the legacy file already exists, or no + * registry can be found at all. On <= 0.86 the legacy request resolves natively, + * so injecting an alias there would mutate resolution for no benefit - and would + * break the 0.86 `exports` wildcard path. The alias target is extensionless so + * platform extensions (`.native.js`, `.ios.js`, ...) still apply. + */ +export function getReactNativeAssetRegistryAlias( + reactNativePath: string +): Record | null { + const legacyFile = path.join( + reactNativePath, + 'Libraries', + 'Image', + 'AssetRegistry.js' + ); + const modernFile = path.join(reactNativePath, 'src', 'asset-registry.js'); + + // Only remap on the new layout: legacy file gone, relocated file present. + if (fs.existsSync(legacyFile) || !fs.existsSync(modernFile)) { + return null; + } + + // Exact-match alias (`$`) so only the exact request is remapped, and the + // request keeps its `react-native/` prefix for Module Federation sharing. + return { + [`${ASSET_REGISTRY_REQUEST}$`]: path.join( + reactNativePath, + 'src', + 'asset-registry' + ), + }; +} diff --git a/tests/integration/src/plugins/NativeEntryPlugin.srcLayout.test.ts b/tests/integration/src/plugins/NativeEntryPlugin.srcLayout.test.ts new file mode 100644 index 000000000..16877c64a --- /dev/null +++ b/tests/integration/src/plugins/NativeEntryPlugin.srcLayout.test.ts @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { plugins } from '@callstack/repack'; +import { createFsFromVolume, Volume } from 'memfs'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createCompiler, createVirtualModulePlugin } from '../helpers.js'; + +const _dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = path.join(_dirname, '__fixtures__', 'react-native-src-layout'); +const ASSET_REGISTRY_ALIAS_KEY = 'react-native/Libraries/Image/AssetRegistry$'; + +let projectRoot: string | undefined; + +/** + * Creates a temporary project root that carries a real `@react-native/js-polyfills` + * package, mirroring how RN >= 0.87 exposes polyfills only through a package that + * still depends on js-polyfills (rather than through react-native itself). + */ +function makeProjectRoot() { + const dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'rn87-project-')) + ); + const pkg = path.join(dir, 'node_modules', '@react-native', 'js-polyfills'); + fs.mkdirSync(pkg, { recursive: true }); + fs.writeFileSync( + path.join(pkg, 'package.json'), + JSON.stringify({ name: '@react-native/js-polyfills', main: 'index.js' }) + ); + fs.writeFileSync( + path.join(pkg, 'index.js'), + "module.exports = () => [require.resolve('./error-guard.js')];" + ); + fs.writeFileSync( + path.join(pkg, 'error-guard.js'), + 'globalThis.__SRC_LAYOUT_POLYFILL__ = true;' + ); + return dir; +} + +afterEach(() => { + if (projectRoot) { + fs.rmSync(projectRoot, { recursive: true, force: true }); + projectRoot = undefined; + } +}); + +describe('NativeEntryPlugin - React Native 0.87 src layout', () => { + it('aliases the legacy asset registry request, ahead of a user react-native alias', async () => { + projectRoot = makeProjectRoot(); + const virtualPlugin = await createVirtualModulePlugin({ + './index.js': + "var A = require('react-native/Libraries/Image/AssetRegistry');" + + "globalThis.__APP_REGISTERED__ = A.registerAsset({ name: 'logo' });", + }); + + const compiler = await createCompiler({ + context: projectRoot, + mode: 'development', + devtool: false, + entry: './index.js', + resolve: { + alias: { 'react-native': FIXTURE }, + }, + output: { path: '/out' }, + plugins: [new plugins.NativeEntryPlugin({}), virtualPlugin], + }); + + // The specific alias must be injected and ordered before the generic key, + // otherwise a user `react-native` alias rewrites the request to a path that + // does not exist on the 0.87 layout before the specific key is consulted. + const alias = compiler.options.resolve.alias as Record; + const aliasKeys = Object.keys(alias); + expect(alias[ASSET_REGISTRY_ALIAS_KEY]).toBe( + path.join(FIXTURE, 'src', 'asset-registry') + ); + expect(aliasKeys.indexOf(ASSET_REGISTRY_ALIAS_KEY)).toBeLessThan( + aliasKeys.indexOf('react-native') + ); + + // Run manually: the harness configures no JS loaders, so repack's own runtime + // entries (InitializeScriptManager/ScriptManager) emit unrelated ESM parse + // errors, exactly as in NativeEntryPlugin.test.ts. We assert only that nothing + // related to the asset registry / IncludeModules / polyfills failed to resolve. + const volume = new Volume(); + // @ts-expect-error memfs is compatible enough with the output filesystem + compiler.outputFileSystem = createFsFromVolume(volume); + const stats = await new Promise((resolve, reject) => { + compiler.run((error, s) => (error ? reject(error) : resolve(s))); + }); + + const messages: string[] = ( + stats.toJson({ errors: true }).errors ?? [] + ).map((e: any) => `${e.message ?? ''}\n${e.details ?? ''}`); + const offenders = messages.filter((m) => + /AssetRegistry|asset-registry|IncludeModules|polyfill/i.test(m) + ); + expect(offenders).toEqual([]); + + const code = volume.readFileSync('/out/main.js', 'utf-8') as string; + expect(code).toContain('__SRC_LAYOUT_POLYFILL__'); + expect(code).toContain('__SRC_LAYOUT_INITIALIZE_CORE__'); + }); +}); diff --git a/tests/integration/src/plugins/__fixtures__/react-native-src-layout/Libraries/Core/InitializeCore.js b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/Libraries/Core/InitializeCore.js new file mode 100644 index 000000000..b2223fe27 --- /dev/null +++ b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/Libraries/Core/InitializeCore.js @@ -0,0 +1 @@ +globalThis.__SRC_LAYOUT_INITIALIZE_CORE__ = true; diff --git a/tests/integration/src/plugins/__fixtures__/react-native-src-layout/Libraries/Image/AssetSourceResolver.js b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/Libraries/Image/AssetSourceResolver.js new file mode 100644 index 000000000..9f12ec1f8 --- /dev/null +++ b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/Libraries/Image/AssetSourceResolver.js @@ -0,0 +1 @@ +module.exports = class AssetSourceResolver {}; diff --git a/tests/integration/src/plugins/__fixtures__/react-native-src-layout/index.js b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/index.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/tests/integration/src/plugins/__fixtures__/react-native-src-layout/package.json b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/package.json new file mode 100644 index 000000000..58629e999 --- /dev/null +++ b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/package.json @@ -0,0 +1,6 @@ +{ + "type": "commonjs", + "name": "react-native", + "version": "0.87.1", + "main": "index.js" +} diff --git a/tests/integration/src/plugins/__fixtures__/react-native-src-layout/src/asset-registry.js b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/src/asset-registry.js new file mode 100644 index 000000000..819ff02bf --- /dev/null +++ b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/src/asset-registry.js @@ -0,0 +1 @@ +module.exports = { registerAsset: (spec) => spec };