Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/rn-087-native-runtime.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/repack/src/loaders/assetsLoader/extractAssets.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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)},
Expand Down
31 changes: 29 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,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 {
/**
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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(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();
});
});
Loading
Loading