diff --git a/docs/Configuration.md b/docs/Configuration.md
index 8f057477f8..13bd6a0c63 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -605,7 +605,9 @@ Type: `boolean | string`
Whether the transformer should use the `@babel/transform/runtime` plugin. Defaults to `true`.
-If the value is a string, it is treated as a runtime version number and passed as `version` to the `@babel/plugin-transform-runtime` configuration. This allows you to optimize the generated Babel runtime calls based on the version installed in your project.
+When `true`, Metro also passes Babel presets that support it (via [caller data](https://babeljs.io/docs/options#caller)) its own copy of `@babel/runtime` to import helpers from - as `babelRuntimeModuleName: 'metro:babel-runtime'` - along with that copy's installed version as `enableBabelRuntime`. Helpers then resolve to a `@babel/runtime` guaranteed to exist at a known version, regardless of whether or where your project installs one.
+
+
Deprecated
If the value is a string, it is treated as a runtime version number and passed as `version` to the `@babel/plugin-transform-runtime` configuration, and helpers are imported from your project's own `@babel/runtime`. Use `true` instead, which targets Metro's own `@babel/runtime` at its installed version.
:::note
This option only works under the default settings for React Native. It may have no effect in a project that uses custom [`transformerPath`](#transformerpath), a custom [`babelTransformerPath`](#babeltransformerpath) or a custom [Babel config file](https://babeljs.io/docs/en/config-files).
diff --git a/packages/metro-babel-transformer/src/__tests__/transform-test.js b/packages/metro-babel-transformer/src/__tests__/transform-test.js
index 8e1170d88c..cc0a1c089d 100644
--- a/packages/metro-babel-transformer/src/__tests__/transform-test.js
+++ b/packages/metro-babel-transformer/src/__tests__/transform-test.js
@@ -49,3 +49,70 @@ test('exposes the correct absolute path to a source file to plugins', () => {
expect(pluginCwd).toEqual(PROJECT_ROOT);
expect(visitorFilename).toEqual(path.resolve(PROJECT_ROOT, 'foo.js'));
});
+
+test('exposes the Babel runtime module name and version to presets via the caller', () => {
+ let callerRuntime;
+ transform({
+ filename: 'foo.js',
+ src: 'console.log("foo");',
+ plugins: [
+ babel => {
+ callerRuntime = {
+ babelRuntimeModuleName: babel.caller(
+ caller => caller?.babelRuntimeModuleName,
+ ),
+ enableBabelRuntime: babel.caller(
+ caller => caller?.enableBabelRuntime,
+ ),
+ };
+ return {visitor: {}};
+ },
+ ],
+ options: {
+ babelRuntimeModuleName: 'metro:babel-runtime',
+ babelRuntimeVersion: '7.29.7',
+ dev: true,
+ enableBabelRuntime: true,
+ enableBabelRCLookup: false,
+ globalPrefix: '__metro__',
+ minify: false,
+ platform: null,
+ publicPath: 'test',
+ projectRoot: PROJECT_ROOT,
+ },
+ });
+ expect(callerRuntime).toEqual({
+ babelRuntimeModuleName: 'metro:babel-runtime',
+ enableBabelRuntime: '7.29.7',
+ });
+});
+
+test('omits the Babel runtime from the caller when not provided', () => {
+ let callerKeys;
+ transform({
+ filename: 'foo.js',
+ src: 'console.log("foo");',
+ plugins: [
+ babel => {
+ callerKeys = ['babelRuntimeModuleName', 'enableBabelRuntime'].filter(
+ key =>
+ babel.caller(
+ caller => caller != null && Object.hasOwn(caller, key),
+ ),
+ );
+ return {visitor: {}};
+ },
+ ],
+ options: {
+ dev: true,
+ enableBabelRuntime: false,
+ enableBabelRCLookup: false,
+ globalPrefix: '__metro__',
+ minify: false,
+ platform: null,
+ publicPath: 'test',
+ projectRoot: PROJECT_ROOT,
+ },
+ });
+ expect(callerKeys).toEqual([]);
+});
diff --git a/packages/metro-babel-transformer/src/index.js b/packages/metro-babel-transformer/src/index.js
index 91a3c2a15e..b9dfe7c5bf 100644
--- a/packages/metro-babel-transformer/src/index.js
+++ b/packages/metro-babel-transformer/src/index.js
@@ -33,6 +33,8 @@ export type CustomTransformOptions = {
export type TransformProfile = 'default' | 'hermes-stable' | 'hermes-canary';
type BabelTransformerOptions = Readonly<{
+ babelRuntimeModuleName?: string,
+ babelRuntimeVersion?: string,
customTransformOptions?: CustomTransformOptions,
dev: boolean,
enableBabelRCLookup?: boolean,
@@ -111,6 +113,15 @@ function transform(
name: 'metro',
platform: options.platform,
inlinePlatform: options.inlinePlatform,
+ // A string `enableBabelRuntime` is the `@babel/runtime` version that
+ // presets such as `@react-native/babel-preset` may target.
+ ...(options.babelRuntimeModuleName != null &&
+ options.babelRuntimeVersion != null
+ ? {
+ babelRuntimeModuleName: options.babelRuntimeModuleName,
+ enableBabelRuntime: options.babelRuntimeVersion,
+ }
+ : null),
},
// NOTE(EvanBacon): We split the parse/transform steps up to accommodate
// Hermes parsing, but this defaults to cloning the AST which increases
diff --git a/packages/metro-transform-worker/src/__tests__/index-test.js b/packages/metro-transform-worker/src/__tests__/index-test.js
index 2387b786ba..a9257f36e2 100644
--- a/packages/metro-transform-worker/src/__tests__/index-test.js
+++ b/packages/metro-transform-worker/src/__tests__/index-test.js
@@ -30,6 +30,10 @@ jest
import type {JsTransformerConfig, JsTransformOptions} from '../index';
import typeof * as TransformerType from '../index';
+import type {
+ BabelTransformer,
+ BabelTransformerArgs,
+} from 'metro-babel-transformer';
import typeof FSType from 'node:fs';
const {Buffer} = require('node:buffer');
@@ -280,6 +284,57 @@ test('does not add "use strict" on non-modules', async () => {
);
});
+function mockBabelTransformer(): JestMockFn<
+ [BabelTransformerArgs],
+ ReturnType,
+> {
+ const actual = jest.requireActual(babelTransformerPath);
+ const transform = jest.fn(actual.transform);
+ jest.doMock(babelTransformerPath, () => ({...actual, transform}));
+ return transform;
+}
+
+test("passes Metro's own Babel runtime when enableBabelRuntime is true", async () => {
+ const babelTransform = mockBabelTransformer();
+ const {version} = jest.requireActual<{version: string, ...}>(
+ require.resolve('@babel/runtime/package.json', {
+ paths: [path.dirname(require.resolve('metro-runtime/package.json'))],
+ }),
+ );
+
+ await Transformer.transform(
+ baseConfig,
+ '/root',
+ 'local/file.js',
+ Buffer.from('arbitrary(code)', 'utf8'),
+ baseTransformOptions,
+ );
+
+ expect(babelTransform.mock.calls[0][0].options).toMatchObject({
+ babelRuntimeModuleName: 'metro:babel-runtime',
+ babelRuntimeVersion: version,
+ });
+});
+
+test.each([false, '7.25.0'])(
+ "does not pass Metro's own Babel runtime when enableBabelRuntime is %p",
+ async enableBabelRuntime => {
+ const babelTransform = mockBabelTransformer();
+
+ await Transformer.transform(
+ {...baseConfig, enableBabelRuntime},
+ '/root',
+ 'local/file.js',
+ Buffer.from('arbitrary(code)', 'utf8'),
+ baseTransformOptions,
+ );
+
+ const {options} = babelTransform.mock.calls[0][0];
+ expect(options).not.toHaveProperty('babelRuntimeModuleName');
+ expect(options).not.toHaveProperty('babelRuntimeVersion');
+ },
+);
+
test('preserves require() calls when module wrapping is disabled', async () => {
const contents = ['require("./c");'].join('\n');
diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js
index 0758f034d7..185c708809 100644
--- a/packages/metro-transform-worker/src/index.js
+++ b/packages/metro-transform-worker/src/index.js
@@ -53,6 +53,10 @@ import {
vlqMapFromTuples,
} from 'metro-source-map';
import metroTransformPlugins from 'metro-transform-plugins';
+import {
+ getMetroBabelRuntimePackageJsonPath,
+ getMetroBabelRuntimeVersion,
+} from 'metro/private/lib/metroBabelRuntime';
import collectDependencies from 'metro/private/ModuleGraph/worker/collectDependencies';
import generateImportNames from 'metro/private/ModuleGraph/worker/generateImportNames';
import {
@@ -65,6 +69,10 @@ import nullthrows from 'nullthrows';
const InternalInvalidRequireCallError =
collectDependencies.InvalidRequireCallError;
+// Resolved by Metro's `metro:` scheme resolver to the `@babel/runtime`
+// guaranteed to exist at a known version.
+const METRO_BABEL_RUNTIME_MODULE_NAME = 'metro:babel-runtime';
+
type MinifierConfig = Readonly<{[key: string]: unknown, ...}>;
export type MinifierOptions = {
@@ -660,6 +668,12 @@ function getBabelTransformArgs(
filename: file.filename, // System-separated, project-root-relative
options: {
...babelTransformerOptions,
+ ...(config.enableBabelRuntime === true
+ ? {
+ babelRuntimeModuleName: METRO_BABEL_RUNTIME_MODULE_NAME,
+ babelRuntimeVersion: getMetroBabelRuntimeVersion(),
+ }
+ : null),
enableBabelRCLookup: config.enableBabelRCLookup,
enableBabelRuntime: config.enableBabelRuntime,
globalPrefix: config.globalPrefix,
@@ -756,6 +770,11 @@ export const getCacheKey = (
require.resolve('metro/private/ModuleGraph/worker/generateImportNames'),
require.resolve('metro/private/ModuleGraph/worker/JsFileWrapping'),
...metroTransformPlugins.getTransformPluginCacheKeyFiles(),
+ // Transform output depends on the installed version of Metro's own
+ // `@babel/runtime`, which is read from its `package.json`.
+ ...(config.enableBabelRuntime === true
+ ? [getMetroBabelRuntimePackageJsonPath()]
+ : []),
]);
// $FlowFixMe[unsupported-syntax]
diff --git a/packages/metro/src/integration_tests/__tests__/babel-runtime-test.js b/packages/metro/src/integration_tests/__tests__/babel-runtime-test.js
new file mode 100644
index 0000000000..b42f185f83
--- /dev/null
+++ b/packages/metro/src/integration_tests/__tests__/babel-runtime-test.js
@@ -0,0 +1,88 @@
+/**
+ * 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.
+ *
+ * @format
+ * @oncall react_native
+ */
+
+'use strict';
+
+const Metro = require('../../..');
+const execBundle = require('../execBundle');
+const path = require('node:path');
+
+jest.setTimeout(30 * 1000);
+
+const PROJECT_ROOT = path.resolve(__dirname, '../basic_bundle/babel-runtime');
+
+// The `interopRequireWildcard` helper of the `@babel/runtime` that
+// metro-runtime depends on.
+function getBabelRuntimeHelperPath() {
+ return require.resolve('@babel/runtime/helpers/interopRequireWildcard', {
+ paths: [path.dirname(require.resolve('metro-runtime/package.json'))],
+ });
+}
+
+async function build(enableBabelRuntime) {
+ const baseConfig = await Metro.loadConfig({
+ config: require.resolve('../metro.config.js'),
+ });
+ // Build with Metro's own Babel transformer, which leaves Babel to discover
+ // the fixture's `babel.config.js` from the project root, and have Babel
+ // (rather than Metro) compile ESM so its helpers are exercised.
+ const config = {
+ ...baseConfig,
+ projectRoot: PROJECT_ROOT,
+ // Metro's own `@babel/runtime` is hoisted to the repo root, outside the
+ // shared config's watch folders.
+ watchFolders: [
+ ...baseConfig.watchFolders,
+ path.dirname(path.dirname(getBabelRuntimeHelperPath())),
+ ],
+ transformer: {
+ ...baseConfig.transformer,
+ babelTransformerPath: require.resolve('metro-babel-transformer'),
+ enableBabelRuntime,
+ getTransformOptions: async () => ({
+ transform: {experimentalImportSupport: false, inlineRequires: false},
+ }),
+ },
+ };
+ const result = await Metro.runBuild(config, {
+ entry: 'index.js',
+ dev: true,
+ minify: false,
+ });
+ return result.code;
+}
+
+test("imports helpers from Metro's own @babel/runtime when enableBabelRuntime is true", async () => {
+ const code = await build(true);
+
+ expect(code).toContain(
+ '"metro:babel-runtime/helpers/interopRequireWildcard"',
+ );
+
+ // The helper is bundled from the `@babel/runtime` that metro-runtime depends
+ // on.
+ const helperPath = getBabelRuntimeHelperPath();
+ expect(code.replaceAll('\\\\', '/')).toContain(
+ JSON.stringify(
+ path.relative(PROJECT_ROOT, helperPath).replaceAll('\\', '/'),
+ ),
+ );
+
+ expect(execBundle(code)).toMatchObject({answer: 42});
+});
+
+test('inlines helpers when enableBabelRuntime is false', async () => {
+ const code = await build(false);
+
+ expect(code).not.toContain('metro:babel-runtime');
+ expect(code).toContain('function _interopRequireWildcard(');
+
+ expect(execBundle(code)).toMatchObject({answer: 42});
+});
diff --git a/packages/metro/src/integration_tests/basic_bundle/babel-runtime/babel.config.js b/packages/metro/src/integration_tests/basic_bundle/babel-runtime/babel.config.js
new file mode 100644
index 0000000000..2e4c40b546
--- /dev/null
+++ b/packages/metro/src/integration_tests/basic_bundle/babel-runtime/babel.config.js
@@ -0,0 +1,42 @@
+/**
+ * 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.
+ *
+ * @format
+ * @oncall react_native
+ */
+
+'use strict';
+
+// A minimal stand-in for a preset such as `@react-native/babel-preset`, which
+// reads `@babel/runtime` configuration from Babel caller data. As a root config
+// it applies to every file in the bundle, so beyond stripping Flow (from
+// Metro's own polyfills) it only transforms the fixture's modules.
+module.exports = api => {
+ const moduleName = api.caller(caller => caller?.babelRuntimeModuleName);
+ const version = api.caller(caller => caller?.enableBabelRuntime);
+ return {
+ plugins: [
+ require.resolve('flow-parser/babel-plugin'),
+ require.resolve('@babel/plugin-transform-flow-strip-types'),
+ ],
+ overrides: [
+ {
+ test: __dirname,
+ plugins: [
+ require.resolve('@babel/plugin-transform-modules-commonjs'),
+ ...(typeof moduleName === 'string' && typeof version === 'string'
+ ? [
+ [
+ require.resolve('@babel/plugin-transform-runtime'),
+ {helpers: true, regenerator: false, moduleName, version},
+ ],
+ ]
+ : []),
+ ],
+ },
+ ],
+ };
+};
diff --git a/packages/metro/src/integration_tests/basic_bundle/babel-runtime/index.js b/packages/metro/src/integration_tests/basic_bundle/babel-runtime/index.js
new file mode 100644
index 0000000000..fff6e1482f
--- /dev/null
+++ b/packages/metro/src/integration_tests/basic_bundle/babel-runtime/index.js
@@ -0,0 +1,16 @@
+/**
+ * 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.
+ *
+ * @format
+ * @oncall react_native
+ */
+
+// `import * as` uses Babel's `interopRequireWildcard` helper, which
+// `@babel/runtime` only provides from 7.14.0. It is imported from the runtime
+// only when the preset is told a recent enough version, and inlined otherwise.
+import * as values from './values';
+
+export const answer = values.answer;
diff --git a/packages/metro/src/integration_tests/basic_bundle/babel-runtime/values.js b/packages/metro/src/integration_tests/basic_bundle/babel-runtime/values.js
new file mode 100644
index 0000000000..850632777d
--- /dev/null
+++ b/packages/metro/src/integration_tests/basic_bundle/babel-runtime/values.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.
+ *
+ * @format
+ * @oncall react_native
+ */
+
+export const answer = 42;
diff --git a/packages/metro/src/lib/metroBabelRuntime.js b/packages/metro/src/lib/metroBabelRuntime.js
new file mode 100644
index 0000000000..791eb3f8a9
--- /dev/null
+++ b/packages/metro/src/lib/metroBabelRuntime.js
@@ -0,0 +1,50 @@
+/**
+ * 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
+ */
+
+import * as path from 'node:path';
+
+// Resolved on first use rather than at module load, so that importing this
+// module (e.g. transitively from a resolution context) cannot fail in projects
+// that never use Metro's own `@babel/runtime`.
+let packageJsonPath: ?string = null;
+let version: ?string = null;
+
+/**
+ * The `package.json` path of the `@babel/runtime` that metro-runtime depends
+ * on, which `metro:babel-runtime` resolves to.
+ */
+export function getMetroBabelRuntimePackageJsonPath(): string {
+ if (packageJsonPath == null) {
+ const metroRuntimeDir = path.dirname(
+ require.resolve('metro-runtime/package.json'),
+ );
+ packageJsonPath = require.resolve('@babel/runtime/package.json', {
+ paths: [metroRuntimeDir],
+ });
+ }
+ return packageJsonPath;
+}
+
+/**
+ * The installed version of the `@babel/runtime` that `metro:babel-runtime`
+ * resolves to.
+ */
+export function getMetroBabelRuntimeVersion(): string {
+ if (version == null) {
+ // $FlowFixMe[unsupported-syntax] Dynamic require of a resolved JSON path
+ const packageJson = require(getMetroBabelRuntimePackageJsonPath()) as {
+ version: string,
+ ...
+ };
+ version = packageJson.version;
+ }
+ return version;
+}
diff --git a/packages/metro/src/lib/metroSchemeResolver.js b/packages/metro/src/lib/metroSchemeResolver.js
index 590256bd5e..55d55acd5b 100644
--- a/packages/metro/src/lib/metroSchemeResolver.js
+++ b/packages/metro/src/lib/metroSchemeResolver.js
@@ -11,29 +11,11 @@
import type {CustomResolver} from 'metro-resolver';
-import * as path from 'node:path';
+import {getMetroBabelRuntimePackageJsonPath} from './metroBabelRuntime';
const BABEL_RUNTIME_SPECIFIER = 'babel-runtime';
const BABEL_RUNTIME_PACKAGE = '@babel/runtime';
-// Resolved on first use rather than at module load, so that importing this
-// module (e.g. transitively from a resolution context) cannot fail in projects
-// that never emit a `metro:` specifier.
-let babelRuntimePackageJsonPath: ?string = null;
-
-function getBabelRuntimePackageJsonPath(): string {
- if (babelRuntimePackageJsonPath == null) {
- const metroRuntimeDir = path.dirname(
- require.resolve('metro-runtime/package.json'),
- );
- babelRuntimePackageJsonPath = require.resolve(
- '@babel/runtime/package.json',
- {paths: [metroRuntimeDir]},
- );
- }
- return babelRuntimePackageJsonPath;
-}
-
/**
* Resolver used for Metro's own `metro:` URI scheme, currently handling only
* metro:babel-runtime and subpaths.
@@ -45,7 +27,7 @@ export default ((context, specifier, platform) => {
// `metro:babel-runtime/helpers/interopRequireDefault`) to metro-runtime's
// `@babel/runtime` dependency, so injected Babel helpers resolve
// deterministically regardless of where `@babel/runtime` is hoisted. The
- // `@babel/runtime` root is resolved via Node (above), the subpath is then
+ // `@babel/runtime` root is resolved via Node (see `metroBabelRuntime`), the subpath is then
// resolved by Metro as a package self-reference, with the origin inside
// `@babel/runtime` so its `exports` map is applied.
if (
@@ -54,7 +36,7 @@ export default ((context, specifier, platform) => {
) {
const subpath = pathname.slice(BABEL_RUNTIME_SPECIFIER.length);
return context.resolveRequest(
- {...context, originModulePath: getBabelRuntimePackageJsonPath()},
+ {...context, originModulePath: getMetroBabelRuntimePackageJsonPath()},
BABEL_RUNTIME_PACKAGE + subpath,
platform,
);