From 3215a0a24b375352926cb0a5c672e065a41be9d8 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:30:09 +0100 Subject: [PATCH 1/2] Add metro:runtime and use it for the default asyncRequireModulePath `transformer.asyncRequireModulePath` is injected as a `require()` into every module that uses `import()`, and resolved from that module. The default, `metro-runtime/src/modules/asyncRequire`, therefore fails wherever `metro-runtime` isn't resolvable from an importer, eg library code under isolated `node_modules`. React Native works around this with an absolute `require.resolve()` path, which is hashed into the transform cache key and so makes caches non-portable between checkouts. This adds `metro:runtime/` to the `metro:` scheme, resolved through the `exports` of the `metro-runtime` that Metro itself depends on (with package exports enabled, since subpaths like `modules/asyncRequire` exist only there), and makes `metro:runtime/modules/asyncRequire` the default. As for `@babel/runtime`, Metro adds that `metro-runtime` to the file map as a dynamic root, in case it's outside every configured root. The integration tests' config no longer overrides `asyncRequireModulePath`, so they exercise the default. Changelog: - **[Fix]**: Resolve the default `transformer.asyncRequireModulePath` from any importer, via a new built-in `metro:runtime/*` specifier --- docs/Configuration.md | 4 +- packages/metro-config/src/defaults/index.js | 2 +- .../DeltaBundler/__tests__/resolver-test.js | 8 +++- .../__tests__/dynamic-roots-test.js | 36 ++++++++++++++++ .../basic_bundle/metro-runtime/index.js | 13 ++++++ .../basic_bundle/metro-runtime/lazy.js | 11 +++++ .../src/integration_tests/metro.config.js | 2 - .../lib/__tests__/metroSchemeResolver-test.js | 41 +++++++++++++++++++ packages/metro/src/lib/metroBabelRuntime.js | 5 +-- packages/metro/src/lib/metroRuntime.js | 25 +++++++++++ packages/metro/src/lib/metroSchemeResolver.js | 31 +++++++++++++- .../metro/src/node-haste/DependencyGraph.js | 32 +++++++++------ 12 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 packages/metro/src/integration_tests/basic_bundle/metro-runtime/index.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/metro-runtime/lazy.js create mode 100644 packages/metro/src/lib/metroRuntime.js diff --git a/docs/Configuration.md b/docs/Configuration.md index e3c2fbbdbb..27a0064601 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -472,10 +472,10 @@ In a future release of Metro, this option will be removed. Type: `string` -The name of a module that provides the `asyncRequire` function, which is used to implement [dynamic `import()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) at runtime. Defaults to [`metro-runtime/src/modules/asyncRequire`](https://github.com/react/metro/blob/main/packages/metro-runtime/src/modules/asyncRequire.js). +The name of a module that provides the `asyncRequire` function, which is used to implement [dynamic `import()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) at runtime. Defaults to `metro:runtime/modules/asyncRequire`, which Metro's built-in [`metro:` scheme resolver](#schemeresolvers) resolves to [`asyncRequire`](https://github.com/react/metro/blob/main/packages/metro-runtime/src/modules/asyncRequire.js) in the `metro-runtime` that Metro itself depends on. :::note -The module named by `asyncRequireModulePath` is [resolved](./Resolution.md) relative to the module containing the original `import()` call. In particular, assuming the default value of `asyncRequireModulePath` is in use, the project must have a compatible version of `metro-runtime` installed in `node_modules`. +The module named by `asyncRequireModulePath` is [resolved](./Resolution.md) relative to each module containing an `import()` call, including modules in `node_modules`. A custom value must be resolvable from all of them. The default always is, because a `metro:` specifier resolves to the same file from any importer. ::: :::info diff --git a/packages/metro-config/src/defaults/index.js b/packages/metro-config/src/defaults/index.js index 8c4a51a945..f1cbc86b5d 100644 --- a/packages/metro-config/src/defaults/index.js +++ b/packages/metro-config/src/defaults/index.js @@ -99,7 +99,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({ transformer: { assetPlugins: [], - asyncRequireModulePath: 'metro-runtime/src/modules/asyncRequire', + asyncRequireModulePath: 'metro:runtime/modules/asyncRequire', assetRegistryPath: 'missing-asset-registry-path', babelTransformerPath: 'metro-babel-transformer', dynamicDepsInPackages: 'throwAtRuntime', diff --git a/packages/metro/src/DeltaBundler/__tests__/resolver-test.js b/packages/metro/src/DeltaBundler/__tests__/resolver-test.js index f0b1fdb14d..2dee55f56c 100644 --- a/packages/metro/src/DeltaBundler/__tests__/resolver-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/resolver-test.js @@ -174,8 +174,12 @@ function dep(name: string): TransformResultDependency { jest.requireMock('node:os').tmpdir = () => p('/tmp'); - // Node resolves Metro's own `@babel/runtime` to a path on the host, - // which is not valid for the platform under test. + // Node resolves Metro's own metro-runtime and `@babel/runtime` to paths + // on the host, which are not valid for the platform under test. + jest.doMock('../../lib/metroRuntime', () => ({ + getMetroRuntimePackageJsonPath: () => + p('/root/node_modules/metro-runtime/package.json'), + })); jest.doMock('../../lib/metroBabelRuntime', () => ({ getMetroBabelRuntimePackageJsonPath: () => p('/root/node_modules/@babel/runtime/package.json'), diff --git a/packages/metro/src/integration_tests/__tests__/dynamic-roots-test.js b/packages/metro/src/integration_tests/__tests__/dynamic-roots-test.js index 1a34953c53..58f3334336 100644 --- a/packages/metro/src/integration_tests/__tests__/dynamic-roots-test.js +++ b/packages/metro/src/integration_tests/__tests__/dynamic-roots-test.js @@ -15,6 +15,7 @@ const {getDynamicRootId} = require('../../lib/dynamicRoots'); const { getMetroBabelRuntimePackageJsonPath, } = require('../../lib/metroBabelRuntime'); +const {getMetroRuntimePackageJsonPath} = require('../../lib/metroRuntime'); const execBundle = require('../execBundle'); const fs = require('node:fs'); const path = require('node:path'); @@ -122,3 +123,38 @@ describe("Metro's own @babel/runtime outside every configured root", () => { expect(sourceResponse.status).toBe(200); }); }); + +describe("Metro's own metro-runtime outside every configured root", () => { + const metroRuntimeDir = path.dirname(getMetroRuntimePackageJsonPath()); + let config; + + beforeEach(async () => { + const baseConfig = await Metro.loadConfig({ + config: require.resolve('../metro.config.js'), + }); + config = { + ...baseConfig, + resolver: { + ...baseConfig.resolver, + // metro-runtime is not resolvable from the importer as a bare + // specifier, as for library code under an isolated layout. + disableHierarchicalLookup: true, + nodeModulesPaths: [], + }, + watchFolders: [baseConfig.projectRoot], + }; + }); + + test('the fixture has metro-runtime outside projectRoot and watchFolders', () => { + for (const root of config.watchFolders) { + expect(metroRuntimeDir.startsWith(root + path.sep)).toBe(false); + } + }); + + test('builds and runs a bundle that uses import() with the default asyncRequireModulePath', async () => { + const result = await Metro.runBuild(config, { + entry: 'metro-runtime/index.js', + }); + await expect(execBundle(result.code).lazyValue).resolves.toBe('lazy'); + }); +}); diff --git a/packages/metro/src/integration_tests/basic_bundle/metro-runtime/index.js b/packages/metro/src/integration_tests/basic_bundle/metro-runtime/index.js new file mode 100644 index 0000000000..9f18fcee1f --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/metro-runtime/index.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +export const lazyValue: Promise = import('./lazy').then( + module => module.default, +); diff --git a/packages/metro/src/integration_tests/basic_bundle/metro-runtime/lazy.js b/packages/metro/src/integration_tests/basic_bundle/metro-runtime/lazy.js new file mode 100644 index 0000000000..77937309b6 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/metro-runtime/lazy.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +export default 'lazy'; diff --git a/packages/metro/src/integration_tests/metro.config.js b/packages/metro/src/integration_tests/metro.config.js index 0a6c003319..5b8bf14e5d 100644 --- a/packages/metro/src/integration_tests/metro.config.js +++ b/packages/metro/src/integration_tests/metro.config.js @@ -27,8 +27,6 @@ module.exports = { }, transformer: { assetRegistryPath: path.join(ROOT_PATH, 'AssetRegistry'), - asyncRequireModulePath: - require.resolve('metro-runtime/src/modules/asyncRequire'), babelTransformerPath: require.resolve('@react-native/metro-babel-transformer'), enableBabelRCLookup: false, diff --git a/packages/metro/src/lib/__tests__/metroSchemeResolver-test.js b/packages/metro/src/lib/__tests__/metroSchemeResolver-test.js index 3cad290c8f..31bb836998 100644 --- a/packages/metro/src/lib/__tests__/metroSchemeResolver-test.js +++ b/packages/metro/src/lib/__tests__/metroSchemeResolver-test.js @@ -94,3 +94,44 @@ test('throws for unsupported metro: specifiers', () => { ).toThrow("Unsupported 'metro:' pathname: something-else"); expect(calls).toHaveLength(0); }); + +test.each([ + ['metro:runtime', 'metro-runtime'], + ['metro:runtime/modules/asyncRequire', 'metro-runtime/modules/asyncRequire'], +])( + 'resolves %s to metro-runtime via package self resolution', + (specifier, expected) => { + const {resolveRequest, calls} = makeCapturingResolveRequest(); + const context = { + ...makeContext(resolveRequest), + unstable_enablePackageExports: false, + }; + + expect(metroSchemeResolver(context, specifier, 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/resolved.js'), + }); + expect(calls).toEqual([ + { + originModulePath: require.resolve('metro-runtime/package.json'), + specifier: expected, + platform: 'ios', + }, + ]); + }, +); + +test('applies package exports for metro:runtime regardless of the project setting', () => { + const contexts: Array = []; + const context = { + ...makeContext(ctx => { + contexts.push(ctx); + return {type: 'sourceFile', filePath: p('/resolved.js')}; + }), + unstable_enablePackageExports: false, + }; + + metroSchemeResolver(context, 'metro:runtime/modules/asyncRequire', null); + expect(contexts).toHaveLength(1); + expect(contexts[0].unstable_enablePackageExports).toBe(true); +}); diff --git a/packages/metro/src/lib/metroBabelRuntime.js b/packages/metro/src/lib/metroBabelRuntime.js index cc00b105c0..06eef74145 100644 --- a/packages/metro/src/lib/metroBabelRuntime.js +++ b/packages/metro/src/lib/metroBabelRuntime.js @@ -9,6 +9,7 @@ * @oncall react_native */ +import {getMetroRuntimePackageJsonPath} from './metroRuntime'; import * as path from 'node:path'; /** @@ -29,9 +30,7 @@ let version: ?string = null; */ export function getMetroBabelRuntimePackageJsonPath(): string { if (packageJsonPath == null) { - const metroRuntimeDir = path.dirname( - require.resolve('metro-runtime/package.json'), - ); + const metroRuntimeDir = path.dirname(getMetroRuntimePackageJsonPath()); packageJsonPath = require.resolve('@babel/runtime/package.json', { paths: [metroRuntimeDir], }); diff --git a/packages/metro/src/lib/metroRuntime.js b/packages/metro/src/lib/metroRuntime.js new file mode 100644 index 0000000000..a71e11068a --- /dev/null +++ b/packages/metro/src/lib/metroRuntime.js @@ -0,0 +1,25 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +// Resolved on first use rather than at module load, for consistency with +// `metroBabelRuntime`. +let packageJsonPath: ?string = null; + +/** + * The `package.json` path of the metro-runtime that Metro depends on, which + * `metro:runtime` resolves into. + */ +export function getMetroRuntimePackageJsonPath(): string { + if (packageJsonPath == null) { + packageJsonPath = require.resolve('metro-runtime/package.json'); + } + return packageJsonPath; +} diff --git a/packages/metro/src/lib/metroSchemeResolver.js b/packages/metro/src/lib/metroSchemeResolver.js index 6fa9f40084..3a343277ea 100644 --- a/packages/metro/src/lib/metroSchemeResolver.js +++ b/packages/metro/src/lib/metroSchemeResolver.js @@ -12,13 +12,16 @@ import type {CustomResolver} from 'metro-resolver'; import {getMetroBabelRuntimePackageJsonPath} from './metroBabelRuntime'; +import {getMetroRuntimePackageJsonPath} from './metroRuntime'; const BABEL_RUNTIME_SPECIFIER = 'babel-runtime'; const BABEL_RUNTIME_PACKAGE = '@babel/runtime'; +const RUNTIME_SPECIFIER = 'runtime'; +const RUNTIME_PACKAGE = 'metro-runtime'; /** - * Resolver used for Metro's own `metro:` URI scheme, currently handling only - * metro:babel-runtime and subpaths. + * Resolver used for Metro's own `metro:` URI scheme, handling + * metro:babel-runtime, metro:runtime and their subpaths. */ export default ((context, specifier, platform) => { const {protocol, pathname} = new URL(specifier); @@ -42,5 +45,29 @@ export default ((context, specifier, platform) => { ); } + // Maps `metro:runtime/` (e.g. `metro:runtime/modules/asyncRequire`) + // to the metro-runtime that Metro depends on, so that modules Metro injects + // into transformed code resolve from any importer, including library code + // that cannot see metro-runtime under an isolated `node_modules` layout. + // The subpath is resolved as a package self-reference, like + // `metro:babel-runtime`. Subpaths such as `modules/asyncRequire` exist only + // in metro-runtime's `exports`, so package exports are enabled here + // regardless of the project's `unstable_enablePackageExports` setting. + if ( + pathname === RUNTIME_SPECIFIER || + pathname.startsWith(RUNTIME_SPECIFIER + '/') + ) { + const subpath = pathname.slice(RUNTIME_SPECIFIER.length); + return context.resolveRequest( + { + ...context, + originModulePath: getMetroRuntimePackageJsonPath(), + unstable_enablePackageExports: true, + }, + RUNTIME_PACKAGE + subpath, + platform, + ); + } + throw new Error(`Unsupported '${protocol}' pathname: ${pathname}`); }) as CustomResolver; diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index d3b7348c92..18e57e2b8c 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -30,6 +30,7 @@ import type { import {getDynamicRoots} from '../lib/dynamicRoots'; import {getMetroBabelRuntimePackageJsonPath} from '../lib/metroBabelRuntime'; +import {getMetroRuntimePackageJsonPath} from '../lib/metroRuntime'; import createFileMap from './DependencyGraph/createFileMap'; import createModuleResolver from './DependencyGraph/createModuleResolver'; import {PackageCache} from './PackageCache'; @@ -123,7 +124,7 @@ export default class DependencyGraph extends EventEmitter { this._haste.on('status', status => this._onWatcherStatus(status)); this._initializedPromise = fileMap.build().then(async ({fileSystem}) => { - await this._addMetroBabelRuntimeRoot(); + await this._addMetroRuntimeRoots(); log(createActionEndEntry(initializingMetroLogEntry)); config.reporter.update({type: 'dep_graph_loaded'}); @@ -145,19 +146,26 @@ export default class DependencyGraph extends EventEmitter { } /** - * `metro:babel-runtime` resolves into the `@babel/runtime` that - * metro-runtime depends on, wherever it is installed, so its files must be - * in the file map even when no configured root contains them. + * `metro:runtime` and `metro:babel-runtime` resolve into the metro-runtime + * that Metro depends on and its `@babel/runtime`, wherever they are + * installed, so their files must be in the file map even when no configured + * root contains them. metro-runtime is added first, so that an + * `@babel/runtime` nested within it needs no root of its own. */ - async _addMetroBabelRuntimeRoot(): Promise { - let packageJsonPath; - try { - packageJsonPath = getMetroBabelRuntimePackageJsonPath(); - } catch { - // Reported by the `metro:` scheme resolver, if it is ever used. - return; + async _addMetroRuntimeRoots(): Promise { + for (const getPackageJsonPath of [ + getMetroRuntimePackageJsonPath, + getMetroBabelRuntimePackageJsonPath, + ]) { + let packageJsonPath; + try { + packageJsonPath = getPackageJsonPath(); + } catch { + // Reported by the `metro:` scheme resolver, if it is ever used. + continue; + } + await this._haste.addRoot(path.dirname(packageJsonPath)); } - await this._haste.addRoot(path.dirname(packageJsonPath)); } /** From f4bdf1c3633639db144228915ddce6a1f2d630b9 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:47:57 +0100 Subject: [PATCH 2/2] Keep the existing asyncRequireModulePath default --- docs/Configuration.md | 4 ++-- packages/metro-config/src/defaults/index.js | 2 +- .../src/integration_tests/__tests__/dynamic-roots-test.js | 6 +++++- packages/metro/src/integration_tests/metro.config.js | 1 + 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index 27a0064601..40c4faab63 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -472,10 +472,10 @@ In a future release of Metro, this option will be removed. Type: `string` -The name of a module that provides the `asyncRequire` function, which is used to implement [dynamic `import()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) at runtime. Defaults to `metro:runtime/modules/asyncRequire`, which Metro's built-in [`metro:` scheme resolver](#schemeresolvers) resolves to [`asyncRequire`](https://github.com/react/metro/blob/main/packages/metro-runtime/src/modules/asyncRequire.js) in the `metro-runtime` that Metro itself depends on. +The name of a module that provides the `asyncRequire` function, which is used to implement [dynamic `import()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) at runtime. Defaults to [`metro-runtime/src/modules/asyncRequire`](https://github.com/react/metro/blob/main/packages/metro-runtime/src/modules/asyncRequire.js). :::note -The module named by `asyncRequireModulePath` is [resolved](./Resolution.md) relative to each module containing an `import()` call, including modules in `node_modules`. A custom value must be resolvable from all of them. The default always is, because a `metro:` specifier resolves to the same file from any importer. +The module named by `asyncRequireModulePath` is [resolved](./Resolution.md) relative to each module containing an `import()` call, including modules in `node_modules`, so it must be resolvable from all of them. In particular, assuming the default value of `asyncRequireModulePath` is in use, the project must have a compatible version of `metro-runtime` installed in `node_modules`. Use `metro:runtime/modules/asyncRequire` to resolve the same module from any importer, through Metro's built-in [`metro:` scheme resolver](#schemeresolvers), in the `metro-runtime` that Metro itself depends on. ::: :::info diff --git a/packages/metro-config/src/defaults/index.js b/packages/metro-config/src/defaults/index.js index f1cbc86b5d..8c4a51a945 100644 --- a/packages/metro-config/src/defaults/index.js +++ b/packages/metro-config/src/defaults/index.js @@ -99,7 +99,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({ transformer: { assetPlugins: [], - asyncRequireModulePath: 'metro:runtime/modules/asyncRequire', + asyncRequireModulePath: 'metro-runtime/src/modules/asyncRequire', assetRegistryPath: 'missing-asset-registry-path', babelTransformerPath: 'metro-babel-transformer', dynamicDepsInPackages: 'throwAtRuntime', diff --git a/packages/metro/src/integration_tests/__tests__/dynamic-roots-test.js b/packages/metro/src/integration_tests/__tests__/dynamic-roots-test.js index 58f3334336..aedad166b0 100644 --- a/packages/metro/src/integration_tests/__tests__/dynamic-roots-test.js +++ b/packages/metro/src/integration_tests/__tests__/dynamic-roots-test.js @@ -141,6 +141,10 @@ describe("Metro's own metro-runtime outside every configured root", () => { disableHierarchicalLookup: true, nodeModulesPaths: [], }, + transformer: { + ...baseConfig.transformer, + asyncRequireModulePath: 'metro:runtime/modules/asyncRequire', + }, watchFolders: [baseConfig.projectRoot], }; }); @@ -151,7 +155,7 @@ describe("Metro's own metro-runtime outside every configured root", () => { } }); - test('builds and runs a bundle that uses import() with the default asyncRequireModulePath', async () => { + test('builds and runs a bundle that uses import() with asyncRequireModulePath from metro:runtime', async () => { const result = await Metro.runBuild(config, { entry: 'metro-runtime/index.js', }); diff --git a/packages/metro/src/integration_tests/metro.config.js b/packages/metro/src/integration_tests/metro.config.js index 5b8bf14e5d..099e0da3bc 100644 --- a/packages/metro/src/integration_tests/metro.config.js +++ b/packages/metro/src/integration_tests/metro.config.js @@ -27,6 +27,7 @@ module.exports = { }, transformer: { assetRegistryPath: path.join(ROOT_PATH, 'AssetRegistry'), + asyncRequireModulePath: 'metro:runtime/modules/asyncRequire', babelTransformerPath: require.resolve('@react-native/metro-babel-transformer'), enableBabelRCLookup: false,