diff --git a/.changeset/rn-087-native-runtime.md b/.changeset/rn-087-native-runtime.md new file mode 100644 index 000000000..a326272cb --- /dev/null +++ b/.changeset/rn-087-native-runtime.md @@ -0,0 +1,12 @@ +--- +"@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, and `react-native/src/private` is +aliased to disk so first-party packages' deep imports keep resolving once package +exports are enabled (0.87 dropped the `./src/*` export wildcard). On 0.86 and +earlier behaviour is unchanged. diff --git a/packages/repack/src/loaders/assetsLoader/extractAssets.ts b/packages/repack/src/loaders/assetsLoader/extractAssets.ts index f50d91e80..482b7c551 100644 --- a/packages/repack/src/loaders/assetsLoader/extractAssets.ts +++ b/packages/repack/src/loaders/assetsLoader/extractAssets.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import path from 'node:path'; import dedent from 'dedent'; +import { ASSET_REGISTRY_REQUEST } from '../../plugins/NativeEntryPlugin/reactNativeRuntime.js'; import type { Asset } from './types.js'; import { getAssetSize } from './utils.js'; @@ -56,7 +57,7 @@ export function extractAssets( ); return dedent` - var AssetRegistry = require('react-native/Libraries/Image/AssetRegistry'); + var AssetRegistry = require(${JSON.stringify(ASSET_REGISTRY_REQUEST)}); module.exports = AssetRegistry.registerAsset({ __packager_asset: true, scales: ${JSON.stringify(scales)}, diff --git a/packages/repack/src/plugins/NativeEntryPlugin/NativeEntryPlugin.ts b/packages/repack/src/plugins/NativeEntryPlugin/NativeEntryPlugin.ts index 433228f33..6f7c481e2 100644 --- a/packages/repack/src/plugins/NativeEntryPlugin/NativeEntryPlugin.ts +++ b/packages/repack/src/plugins/NativeEntryPlugin/NativeEntryPlugin.ts @@ -3,6 +3,11 @@ 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, + getReactNativeDeepImportAliases, + resolveReactNativePolyfills, +} from './reactNativeRuntime.js'; export interface NativeEntryPluginConfig { /** @@ -47,10 +52,32 @@ 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. + // `getReactNativeDeepImportAliases` likewise remaps `react-native/src/private` + // to disk so first-party packages' deep imports keep resolving once package + // exports are enabled (RN 0.87 dropped the `./src/*` export wildcard). + const reactNativeAliases = { + ...getReactNativeAssetRegistryAlias(reactNativePath), + ...getReactNativeDeepImportAliases(reactNativePath), + }; + if (Object.keys(reactNativeAliases).length > 0) { + compiler.options.resolve.alias = { + ...reactNativeAliases, + ...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..915bc4270 --- /dev/null +++ b/packages/repack/src/plugins/NativeEntryPlugin/__tests__/reactNativeRuntime.test.ts @@ -0,0 +1,196 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + ASSET_REGISTRY_REQUEST, + getReactNativeAssetRegistryAlias, + getReactNativeDeepImportAliases, + 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(projectRoot, rn, () => { + 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 no registry file exists', () => { + const rn = makeTmp({ 'index.js': '' }); + expect(getReactNativeAssetRegistryAlias(rn)).toBeNull(); + }); +}); + +describe('getReactNativeDeepImportAliases', () => { + it('remaps react-native/src/private when the exports map drops the wildcards (React Native >= 0.87)', () => { + const rn = makeTmp({ + 'package.json': JSON.stringify({ + exports: { + '.': './index.js', + './asset-registry': './src/asset-registry.js', + }, + }), + 'src/private/featureflags/ReactNativeFeatureFlags.js': '', + }); + expect(getReactNativeDeepImportAliases(rn)).toEqual({ + 'react-native/src/private': path.join(rn, 'src', 'private'), + }); + }); + + it('returns null when the exports map exposes ./src/* (React Native <= 0.86)', () => { + const rn = makeTmp({ + 'package.json': JSON.stringify({ + exports: { './*': './*', './src/*': './src/*' }, + }), + 'src/private/index.js': '', + }); + expect(getReactNativeDeepImportAliases(rn)).toBeNull(); + }); + + it('returns null when the exports map exposes a ./* wildcard', () => { + const rn = makeTmp({ + 'package.json': JSON.stringify({ exports: { './*': './*' } }), + 'src/private/index.js': '', + }); + expect(getReactNativeDeepImportAliases(rn)).toBeNull(); + }); + + it('returns null when React Native declares no exports map', () => { + const rn = makeTmp({ + 'package.json': JSON.stringify({ name: 'react-native' }), + 'src/private/index.js': '', + }); + expect(getReactNativeDeepImportAliases(rn)).toBeNull(); + }); + + it('returns null when src/private does not exist', () => { + const rn = makeTmp({ + 'package.json': JSON.stringify({ exports: { '.': './index.js' } }), + 'index.js': '', + }); + expect(getReactNativeDeepImportAliases(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..8f31844ac --- /dev/null +++ b/packages/repack/src/plugins/NativeEntryPlugin/reactNativeRuntime.ts @@ -0,0 +1,185 @@ +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)) { + const getPolyfills: () => string[] = require(rnGetPolyfillsPath); + return getPolyfills; + } + + // 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.' + ); + } + + const getPolyfills: () => string[] = require(jsPolyfillsPath); + return getPolyfills; +} + +/** + * 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' + ), + }; +} + +/** + * Builds the `resolve.alias` entries that keep React Native's internal deep + * imports working when package exports are enabled (`enablePackageExports`). + * + * React Native <= 0.86 exposed its `exports` map with a `./*` / `./src/*` + * wildcard, so deep requests such as + * `react-native/src/private/featureflags/ReactNativeFeatureFlags` (imported by + * `@react-native/virtualized-lists` and other first-party packages) resolved + * through the exports map. React Native 0.87 narrowed the map to a small set of + * explicit subpaths and dropped those wildcards, so the same deep requests no + * longer match any export condition and resolution fails once package exports + * are on. React Native still ships `src/private/**` on disk - it is simply not + * exported - and no resolve condition can un-hide it, so the only way to keep + * bundling with package exports enabled is to remap the prefix to the on-disk + * directory. Aliasing to an absolute path bypasses the exports map entirely. + * + * Returns `null` (no alias) when `src/private` is already reachable through the + * exports map (<= 0.86), when React Native declares no exports map at all, or + * when the directory is absent - so this is a no-op on the legacy layout. + */ +export function getReactNativeDeepImportAliases( + reactNativePath: string +): Record | null { + const privateDir = path.join(reactNativePath, 'src', 'private'); + if (!fs.existsSync(privateDir)) { + return null; + } + + let exportsMap: Record | undefined; + try { + const pkg: { exports?: unknown } = JSON.parse( + fs.readFileSync(path.join(reactNativePath, 'package.json'), 'utf8') + ); + if (pkg.exports && typeof pkg.exports === 'object') { + exportsMap = { ...pkg.exports }; + } + } catch { + return null; + } + + // No exports map: resolution is file-path based, nothing to work around. + if (!exportsMap) { + return null; + } + + // Already reachable through the exports map (React Native <= 0.86 wildcards). + if ( + exportsMap['./*'] || + exportsMap['./src/*'] || + exportsMap['./src/private/*'] + ) { + return null; + } + + return { + 'react-native/src/private': privateDir, + }; +} 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..cc0a5aea3 --- /dev/null +++ b/tests/integration/src/plugins/NativeEntryPlugin.srcLayout.test.ts @@ -0,0 +1,177 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getResolveOptions, 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$'; +const SRC_PRIVATE_ALIAS_KEY = 'react-native/src/private'; + +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; +} + +type Compiler = Awaited>; + +/** + * Runs the compiler and returns every compilation error plus the emitted main + * chunk. 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. Callers filter the messages for the specific + * resolution failures they care about instead of asserting on a clean build. + */ +function compileCollectingErrors(compiler: Compiler) { + const volume = new Volume(); + // @ts-expect-error memfs is compatible enough with the output filesystem + compiler.outputFileSystem = createFsFromVolume(volume); + return new Promise<{ errorMessages: string[]; code: string }>( + (resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + return; + } + const errors = stats?.toJson({ errors: true }).errors ?? []; + const errorMessages = errors.map( + (e) => `${e.message ?? ''}\n${e.details ?? ''}` + ); + const code = volume.readFileSync('/out/main.js', 'utf-8').toString(); + resolve({ errorMessages, code }); + }); + } + ); +} + +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; + if (!alias || Array.isArray(alias)) { + throw new Error('expected resolve.alias to be an object'); + } + 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 { errorMessages, code } = await compileCollectingErrors(compiler); + const offenders = errorMessages.filter((m) => + /AssetRegistry|asset-registry|IncludeModules|polyfill/i.test(m) + ); + expect(offenders).toEqual([]); + expect(code).toContain('__SRC_LAYOUT_POLYFILL__'); + expect(code).toContain('__SRC_LAYOUT_INITIALIZE_CORE__'); + }); + + it('aliases react-native/src/private when package exports are enabled', async () => { + projectRoot = makeProjectRoot(); + // Mirrors @react-native/virtualized-lists, which deep-imports a path that + // React Native 0.87 no longer lists in its exports map. + const virtualPlugin = await createVirtualModulePlugin({ + './index.js': + "require('react-native/src/private/featureflags/ReactNativeFeatureFlags');" + + 'globalThis.__APP_ENTRY__ = true;', + }); + + const compiler = await createCompiler({ + context: projectRoot, + mode: 'development', + devtool: false, + entry: './index.js', + resolve: { + ...getResolveOptions({ enablePackageExports: true }), + // Point at the entry file (supported by NativeEntryPlugin) so the generic + // alias alone cannot satisfy the deep request: `/index.js/src/...` + // does not exist. Only the injected `react-native/src/private` alias, + // ordered ahead of it, makes the request resolve. + alias: { 'react-native': path.join(FIXTURE, 'index.js') }, + }, + output: { path: '/out' }, + plugins: [new plugins.NativeEntryPlugin({}), virtualPlugin], + }); + + const alias = compiler.options.resolve.alias; + if (!alias || Array.isArray(alias)) { + throw new Error('expected resolve.alias to be an object'); + } + const aliasKeys = Object.keys(alias); + expect(alias[SRC_PRIVATE_ALIAS_KEY]).toBe( + path.join(FIXTURE, 'src', 'private') + ); + expect(aliasKeys.indexOf(SRC_PRIVATE_ALIAS_KEY)).toBeLessThan( + aliasKeys.indexOf('react-native') + ); + + const { errorMessages, code } = await compileCollectingErrors(compiler); + expect( + errorMessages.filter((m) => + /src\/private|ReactNativeFeatureFlags/i.test(m) + ) + ).toEqual([]); + expect(code).toContain('__SRC_LAYOUT_FEATURE_FLAGS__'); + }); +}); 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..a634435ab --- /dev/null +++ b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/package.json @@ -0,0 +1,13 @@ +{ + "type": "commonjs", + "name": "react-native", + "version": "0.87.1", + "main": "index.js", + "exports": { + ".": "./index.js", + "./Libraries/*": "./Libraries/*.js", + "./Libraries/*.js": "./Libraries/*.js", + "./asset-registry": "./src/asset-registry.js", + "./package.json": "./package.json" + } +} 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 }; diff --git a/tests/integration/src/plugins/__fixtures__/react-native-src-layout/src/private/featureflags/ReactNativeFeatureFlags.js b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/src/private/featureflags/ReactNativeFeatureFlags.js new file mode 100644 index 000000000..40665b1b3 --- /dev/null +++ b/tests/integration/src/plugins/__fixtures__/react-native-src-layout/src/private/featureflags/ReactNativeFeatureFlags.js @@ -0,0 +1,2 @@ +globalThis.__SRC_LAYOUT_FEATURE_FLAGS__ = true; +module.exports = {}; diff --git a/website/src/latest/api/plugins/internal.md b/website/src/latest/api/plugins/internal.md index a03439a9d..984f4cbab 100644 --- a/website/src/latest/api/plugins/internal.md +++ b/website/src/latest/api/plugins/internal.md @@ -8,6 +8,10 @@ Plugin that sets up the React Native entry point for each compilation entry. It adds React Native polyfills, `InitializeCore`, `InitializeScriptManager`, and `IncludeModules` as entry modules processed through the standard loader pipeline. A companion runtime module (`PolyfillsRuntimeModule`) ensures polyfills execute before Module Federation's startup wrapper, regardless of the federation version or bundler used. +Polyfills are read from React Native's `rn-get-polyfills.js` when present (React Native 0.86 and earlier). React Native 0.87 removed that file together with its dependency on `@react-native/js-polyfills`, so the plugin resolves `@react-native/js-polyfills` from the project instead, falling back through `@react-native/metro-config` (which still depends on it and is a devDependency of the default template). If neither can be found, the build fails with an error asking you to add a version-matched `@react-native/js-polyfills` or `@react-native/metro-config` to your project. + +On the React Native 0.87 layout the plugin also prepends two resolve aliases: `react-native/Libraries/Image/AssetRegistry` maps to the relocated `src/asset-registry.js`, and `react-native/src/private` maps to the on-disk directory so first-party deep imports keep resolving when package exports are enabled. Neither alias is added on 0.86 and earlier. + ## DevelopmentPlugin ## RepackTargetPlugin