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
10 changes: 10 additions & 0 deletions .changeset/rn-087-native-runtime.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 23 additions & 2 deletions packages/repack/src/plugins/NativeEntryPlugin/NativeEntryPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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();
});
});
124 changes: 124 additions & 0 deletions packages/repack/src/plugins/NativeEntryPlugin/reactNativeRuntime.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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'
),
};
}
Loading
Loading