Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<div class="label deprecated">Deprecated</div> 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).
Expand Down
67 changes: 67 additions & 0 deletions packages/metro-babel-transformer/src/__tests__/transform-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
11 changes: 11 additions & 0 deletions packages/metro-babel-transformer/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions packages/metro-transform-worker/src/__tests__/index-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -280,6 +284,57 @@ test('does not add "use strict" on non-modules', async () => {
);
});

function mockBabelTransformer(): JestMockFn<
[BabelTransformerArgs],
ReturnType<BabelTransformer['transform']>,
> {
const actual = jest.requireActual<BabelTransformer>(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');

Expand Down
19 changes: 19 additions & 0 deletions packages/metro-transform-worker/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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});
});
Original file line number Diff line number Diff line change
@@ -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},
],
]
: []),
],
},
],
};
};
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading