diff --git a/docs/Configuration.md b/docs/Configuration.md index e3c2fbbdbb..40c4faab63 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -475,7 +475,7 @@ 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). :::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`, 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/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..aedad166b0 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,42 @@ 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: [], + }, + transformer: { + ...baseConfig.transformer, + asyncRequireModulePath: 'metro:runtime/modules/asyncRequire', + }, + 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 asyncRequireModulePath from metro:runtime', 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..099e0da3bc 100644 --- a/packages/metro/src/integration_tests/metro.config.js +++ b/packages/metro/src/integration_tests/metro.config.js @@ -27,8 +27,7 @@ module.exports = { }, transformer: { assetRegistryPath: path.join(ROOT_PATH, 'AssetRegistry'), - asyncRequireModulePath: - require.resolve('metro-runtime/src/modules/asyncRequire'), + asyncRequireModulePath: 'metro:runtime/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)); } /**