From fea257f5d968576a9cdaf94bd909824fab64d457 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 21 Sep 2026 15:34:38 +0100 Subject: [PATCH] Pass Metro's own @babel/runtime and its version to Babel presets via caller Summary: Follow-up to 8388f719f9 (the default `metro:` scheme resolver), which maps `metro:babel-runtime/` to metro-runtime's own `@babel/runtime` dependency. This diff is the Metro half of pointing `@babel/plugin-transform-runtime` at it. When `transformer.enableBabelRuntime` is literally `true` (the default), the transform worker passes the Babel transformer `babelRuntimeModuleName: 'metro:babel-runtime'` and `babelRuntimeVersion` - the installed version of that `@babel/runtime`. `metro-babel-transformer` forwards them to presets as Babel caller data, as `babelRuntimeModuleName` and `enableBabelRuntime` respectively, matching what `@react-native/babel-preset` reads from react/react-native#57974. Caller data reaches a preset however it's configured - including via a project `babel.config.js`, where the preset's options come from the user. The version is read from the installed `package.json` rather than pinned to metro-runtime's range floor, so output benefits from runtime updates without a manual sync. Because that makes transform output depend on it, the `package.json` is added to the transform worker's cache key when `enableBabelRuntime` is `true`. A string `enableBabelRuntime` keeps its current meaning (a version for the project's own `@babel/runtime`) and passes neither value. It's now documented as deprecated in favour of `true`, since a version describing the project's copy is meaningless once helpers come from Metro's. The lookup of metro-runtime's `@babel/runtime` moves from `metroSchemeResolver` into a shared `metro/private/lib/metroBabelRuntime`, so the resolver and the worker agree on the same copy. This is a no-op until a preset reads it. Changelog: ``` - **[Feature]**: When `transformer.enableBabelRuntime` is `true`, pass Metro's own `@babel/runtime` (`metro:babel-runtime`) and its installed version to Babel presets via caller data - **[Deprecated]**: String values of `transformer.enableBabelRuntime` (use `true`) ``` Test plan: New integration test (`integration_tests/__tests__/babel-runtime-test.js`) builds and executes a bundle through the full Metro + Babel pipeline with Metro's own `metro-babel-transformer`, and a fixture `babel.config.js` whose preset reads `@babel/runtime` configuration from caller data, as `@react-native/babel-preset` does. The fixture uses `import * as`, whose `interopRequireWildcard` helper is only available from `@babel/runtime` 7.14.0, so it's imported rather than inlined only if the installed version reaches the preset: - With `enableBabelRuntime: true`, the bundle imports `metro:babel-runtime/helpers/interopRequireWildcard`, which resolves to metro-runtime's own `@babel/runtime`, and executes correctly. - With `enableBabelRuntime: false`, the helper is inlined, and the bundle executes correctly. ``` yarn jest packages/metro/src/integration_tests packages/metro-transform-worker packages/metro-babel-transformer packages/metro/src/lib yarn flow check yarn build-ts-defs && yarn verify-api-snapshots yarn typecheck-ts ``` --- docs/Configuration.md | 4 +- .../src/__tests__/transform-test.js | 67 ++++++++++++++ packages/metro-babel-transformer/src/index.js | 11 +++ .../src/__tests__/index-test.js | 55 ++++++++++++ packages/metro-transform-worker/src/index.js | 19 ++++ .../__tests__/babel-runtime-test.js | 88 +++++++++++++++++++ .../babel-runtime/babel.config.js | 42 +++++++++ .../basic_bundle/babel-runtime/index.js | 16 ++++ .../basic_bundle/babel-runtime/values.js | 11 +++ packages/metro/src/lib/metroBabelRuntime.js | 50 +++++++++++ packages/metro/src/lib/metroSchemeResolver.js | 24 +---- 11 files changed, 365 insertions(+), 22 deletions(-) create mode 100644 packages/metro/src/integration_tests/__tests__/babel-runtime-test.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/babel-runtime/babel.config.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/babel-runtime/index.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/babel-runtime/values.js create mode 100644 packages/metro/src/lib/metroBabelRuntime.js 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, );