Skip to content

Commit 3de6a03

Browse files
authored
Add metro:runtime for resolving asyncRequireModulePath from any importer (#2083)
1 parent 1433031 commit 3de6a03

11 files changed

Lines changed: 189 additions & 22 deletions

File tree

‎docs/Configuration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ Type: `string`
475475
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).
476476
477477
:::note
478-
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`.
478+
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.
479479
:::
480480
481481
:::info

‎packages/metro/src/DeltaBundler/__tests__/resolver-test.js‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,12 @@ function dep(name: string): TransformResultDependency {
174174

175175
jest.requireMock('node:os').tmpdir = () => p('/tmp');
176176

177-
// Node resolves Metro's own `@babel/runtime` to a path on the host,
178-
// which is not valid for the platform under test.
177+
// Node resolves Metro's own metro-runtime and `@babel/runtime` to paths
178+
// on the host, which are not valid for the platform under test.
179+
jest.doMock('../../lib/metroRuntime', () => ({
180+
getMetroRuntimePackageJsonPath: () =>
181+
p('/root/node_modules/metro-runtime/package.json'),
182+
}));
179183
jest.doMock('../../lib/metroBabelRuntime', () => ({
180184
getMetroBabelRuntimePackageJsonPath: () =>
181185
p('/root/node_modules/@babel/runtime/package.json'),

‎packages/metro/src/integration_tests/__tests__/dynamic-roots-test.js‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const {getDynamicRootId} = require('../../lib/dynamicRoots');
1515
const {
1616
getMetroBabelRuntimePackageJsonPath,
1717
} = require('../../lib/metroBabelRuntime');
18+
const {getMetroRuntimePackageJsonPath} = require('../../lib/metroRuntime');
1819
const execBundle = require('../execBundle');
1920
const fs = require('node:fs');
2021
const path = require('node:path');
@@ -122,3 +123,42 @@ describe("Metro's own @babel/runtime outside every configured root", () => {
122123
expect(sourceResponse.status).toBe(200);
123124
});
124125
});
126+
127+
describe("Metro's own metro-runtime outside every configured root", () => {
128+
const metroRuntimeDir = path.dirname(getMetroRuntimePackageJsonPath());
129+
let config;
130+
131+
beforeEach(async () => {
132+
const baseConfig = await Metro.loadConfig({
133+
config: require.resolve('../metro.config.js'),
134+
});
135+
config = {
136+
...baseConfig,
137+
resolver: {
138+
...baseConfig.resolver,
139+
// metro-runtime is not resolvable from the importer as a bare
140+
// specifier, as for library code under an isolated layout.
141+
disableHierarchicalLookup: true,
142+
nodeModulesPaths: [],
143+
},
144+
transformer: {
145+
...baseConfig.transformer,
146+
asyncRequireModulePath: 'metro:runtime/modules/asyncRequire',
147+
},
148+
watchFolders: [baseConfig.projectRoot],
149+
};
150+
});
151+
152+
test('the fixture has metro-runtime outside projectRoot and watchFolders', () => {
153+
for (const root of config.watchFolders) {
154+
expect(metroRuntimeDir.startsWith(root + path.sep)).toBe(false);
155+
}
156+
});
157+
158+
test('builds and runs a bundle that uses import() with asyncRequireModulePath from metro:runtime', async () => {
159+
const result = await Metro.runBuild(config, {
160+
entry: 'metro-runtime/index.js',
161+
});
162+
await expect(execBundle(result.code).lazyValue).resolves.toBe('lazy');
163+
});
164+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict
8+
* @format
9+
*/
10+
11+
export const lazyValue: Promise<string> = import('./lazy').then(
12+
module => module.default,
13+
);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict
8+
* @format
9+
*/
10+
11+
export default 'lazy';

‎packages/metro/src/integration_tests/metro.config.js‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ module.exports = {
2727
},
2828
transformer: {
2929
assetRegistryPath: path.join(ROOT_PATH, 'AssetRegistry'),
30-
asyncRequireModulePath:
31-
require.resolve('metro-runtime/src/modules/asyncRequire'),
30+
asyncRequireModulePath: 'metro:runtime/modules/asyncRequire',
3231
babelTransformerPath:
3332
require.resolve('@react-native/metro-babel-transformer'),
3433
enableBabelRCLookup: false,

‎packages/metro/src/lib/__tests__/metroSchemeResolver-test.js‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,44 @@ test('throws for unsupported metro: specifiers', () => {
9494
).toThrow("Unsupported 'metro:' pathname: something-else");
9595
expect(calls).toHaveLength(0);
9696
});
97+
98+
test.each([
99+
['metro:runtime', 'metro-runtime'],
100+
['metro:runtime/modules/asyncRequire', 'metro-runtime/modules/asyncRequire'],
101+
])(
102+
'resolves %s to metro-runtime via package self resolution',
103+
(specifier, expected) => {
104+
const {resolveRequest, calls} = makeCapturingResolveRequest();
105+
const context = {
106+
...makeContext(resolveRequest),
107+
unstable_enablePackageExports: false,
108+
};
109+
110+
expect(metroSchemeResolver(context, specifier, 'ios')).toEqual({
111+
type: 'sourceFile',
112+
filePath: p('/resolved.js'),
113+
});
114+
expect(calls).toEqual([
115+
{
116+
originModulePath: require.resolve('metro-runtime/package.json'),
117+
specifier: expected,
118+
platform: 'ios',
119+
},
120+
]);
121+
},
122+
);
123+
124+
test('applies package exports for metro:runtime regardless of the project setting', () => {
125+
const contexts: Array<CustomResolutionContext> = [];
126+
const context = {
127+
...makeContext(ctx => {
128+
contexts.push(ctx);
129+
return {type: 'sourceFile', filePath: p('/resolved.js')};
130+
}),
131+
unstable_enablePackageExports: false,
132+
};
133+
134+
metroSchemeResolver(context, 'metro:runtime/modules/asyncRequire', null);
135+
expect(contexts).toHaveLength(1);
136+
expect(contexts[0].unstable_enablePackageExports).toBe(true);
137+
});

‎packages/metro/src/lib/metroBabelRuntime.js‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* @oncall react_native
1010
*/
1111

12+
import {getMetroRuntimePackageJsonPath} from './metroRuntime';
1213
import * as path from 'node:path';
1314

1415
/**
@@ -29,9 +30,7 @@ let version: ?string = null;
2930
*/
3031
export function getMetroBabelRuntimePackageJsonPath(): string {
3132
if (packageJsonPath == null) {
32-
const metroRuntimeDir = path.dirname(
33-
require.resolve('metro-runtime/package.json'),
34-
);
33+
const metroRuntimeDir = path.dirname(getMetroRuntimePackageJsonPath());
3534
packageJsonPath = require.resolve('@babel/runtime/package.json', {
3635
paths: [metroRuntimeDir],
3736
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
* @oncall react_native
10+
*/
11+
12+
// Resolved on first use rather than at module load, for consistency with
13+
// `metroBabelRuntime`.
14+
let packageJsonPath: ?string = null;
15+
16+
/**
17+
* The `package.json` path of the metro-runtime that Metro depends on, which
18+
* `metro:runtime` resolves into.
19+
*/
20+
export function getMetroRuntimePackageJsonPath(): string {
21+
if (packageJsonPath == null) {
22+
packageJsonPath = require.resolve('metro-runtime/package.json');
23+
}
24+
return packageJsonPath;
25+
}

‎packages/metro/src/lib/metroSchemeResolver.js‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212
import type {CustomResolver} from 'metro-resolver';
1313

1414
import {getMetroBabelRuntimePackageJsonPath} from './metroBabelRuntime';
15+
import {getMetroRuntimePackageJsonPath} from './metroRuntime';
1516

1617
const BABEL_RUNTIME_SPECIFIER = 'babel-runtime';
1718
const BABEL_RUNTIME_PACKAGE = '@babel/runtime';
19+
const RUNTIME_SPECIFIER = 'runtime';
20+
const RUNTIME_PACKAGE = 'metro-runtime';
1821

1922
/**
20-
* Resolver used for Metro's own `metro:` URI scheme, currently handling only
21-
* metro:babel-runtime and subpaths.
23+
* Resolver used for Metro's own `metro:` URI scheme, handling
24+
* metro:babel-runtime, metro:runtime and their subpaths.
2225
*/
2326
export default ((context, specifier, platform) => {
2427
const {protocol, pathname} = new URL(specifier);
@@ -42,5 +45,29 @@ export default ((context, specifier, platform) => {
4245
);
4346
}
4447

48+
// Maps `metro:runtime/<subpath>` (e.g. `metro:runtime/modules/asyncRequire`)
49+
// to the metro-runtime that Metro depends on, so that modules Metro injects
50+
// into transformed code resolve from any importer, including library code
51+
// that cannot see metro-runtime under an isolated `node_modules` layout.
52+
// The subpath is resolved as a package self-reference, like
53+
// `metro:babel-runtime`. Subpaths such as `modules/asyncRequire` exist only
54+
// in metro-runtime's `exports`, so package exports are enabled here
55+
// regardless of the project's `unstable_enablePackageExports` setting.
56+
if (
57+
pathname === RUNTIME_SPECIFIER ||
58+
pathname.startsWith(RUNTIME_SPECIFIER + '/')
59+
) {
60+
const subpath = pathname.slice(RUNTIME_SPECIFIER.length);
61+
return context.resolveRequest(
62+
{
63+
...context,
64+
originModulePath: getMetroRuntimePackageJsonPath(),
65+
unstable_enablePackageExports: true,
66+
},
67+
RUNTIME_PACKAGE + subpath,
68+
platform,
69+
);
70+
}
71+
4572
throw new Error(`Unsupported '${protocol}' pathname: ${pathname}`);
4673
}) as CustomResolver;

0 commit comments

Comments
 (0)