From c306f3fc7be876ea9759c583079bdde8fffce53f Mon Sep 17 00:00:00 2001 From: Shubh Porwal Date: Thu, 10 Sep 2026 22:13:47 +0530 Subject: [PATCH] Honour Metro's `experimentalImportSupport` in `react-native/babel-preset` via the Babel caller `experimentalImportSupport: true` in a Metro config asks Metro to lower `import`/`export` itself, so `react-native/babel-preset` must leave ESM intact. `react-native/metro-babel-transformer` only passes the matching `disableImportExportTransform` preset option when the project has no Babel config of its own. Apps from the template have a `babel.config.js` naming the preset, so the preset lowers to CommonJS regardless and the Metro option is silently a no-op (https://github.com/facebook/metro/issues/909). It also keeps default imports eager under `inlineRequires`, which only inlines bare `require` calls. Pass `experimentalImportSupport` through the Babel caller and resolve it in the preset as `options.disableImportExportTransform ?? babel.caller(...) ?? false`, the channel #57973 added for `inlinePlatform`. Preset options still take precedence and the default is unchanged. Changelog: [General][Fixed] - `experimentalImportSupport` in a Metro config now takes effect when `react-native/babel-preset` is named in a project Babel config Co-Authored-By: Claude Fable 5.1 --- .../import-export-transform-opt-in-test.js | 93 +++++++++++++++++++ .../src/configs/main.js | 13 ++- .../__tests__/__fixtures__/babel.config.js | 17 ++++ .../experimental-import-support-test.js | 87 +++++++++++++++++ .../src/index.js | 1 + 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-babel-preset/src/__tests__/import-export-transform-opt-in-test.js create mode 100644 packages/react-native-babel-transformer/src/__tests__/__fixtures__/babel.config.js create mode 100644 packages/react-native-babel-transformer/src/__tests__/experimental-import-support-test.js diff --git a/packages/react-native-babel-preset/src/__tests__/import-export-transform-opt-in-test.js b/packages/react-native-babel-preset/src/__tests__/import-export-transform-opt-in-test.js new file mode 100644 index 000000000000..acb6c17ee76f --- /dev/null +++ b/packages/react-native-babel-preset/src/__tests__/import-export-transform-opt-in-test.js @@ -0,0 +1,93 @@ +/** + * 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 + */ + +'use strict'; + +// $FlowExpectedError[untyped-import] - Preset is untyped +const preset = require('../index'); +const babel = require('@babel/core'); + +const FILENAME = '/app/src/App.js'; +const SRC = "import foo from './foo';\nexport const bar = foo;"; + +type PresetOptions = { + disableImportExportTransform?: boolean, +}; + +type CallerOptions = { + experimentalImportSupport?: boolean, +}; + +function transform({ + presetOptions = {}, + caller = {}, +}: { + presetOptions?: PresetOptions, + caller?: CallerOptions, +} = {}): string { + const result = babel.transformSync(SRC, { + babelrc: false, + caller: {name: 'test', ...caller}, + compact: false, + configFile: false, + filename: FILENAME, + presets: [[preset, {dev: false, ...presetOptions}]], + sourceMaps: false, + }); + const code = result?.code; + if (code == null) { + throw new Error('Expected the transform to produce code'); + } + return code; +} + +function isLowered(code: string): boolean { + return code.includes('require(') && !/^import\b/m.test(code); +} + +describe('import/export lowering is skipped when the caller lowers it', () => { + test('lowers by default', () => { + expect(isLowered(transform())).toBe(true); + }); + + test('keeps ESM when opted out via preset options', () => { + expect( + isLowered( + transform({presetOptions: {disableImportExportTransform: true}}), + ), + ).toBe(false); + }); + + test('keeps ESM when the Babel caller lowers imports itself', () => { + // The only channel available when the preset is named in a babel.config.js, + // where Babel supplies no preset options. Metro's transformer sets this from + // its `experimentalImportSupport` transform option. + expect( + isLowered(transform({caller: {experimentalImportSupport: true}})), + ).toBe(false); + }); + + test('lowers when the Babel caller does not lower imports', () => { + expect( + isLowered(transform({caller: {experimentalImportSupport: false}})), + ).toBe(true); + }); + + test('preset options take precedence over the caller', () => { + expect( + isLowered( + transform({ + presetOptions: {disableImportExportTransform: false}, + caller: {experimentalImportSupport: true}, + }), + ), + ).toBe(true); + }); +}); diff --git a/packages/react-native-babel-preset/src/configs/main.js b/packages/react-native-babel-preset/src/configs/main.js index 45fc88e21c90..b9e3802336b9 100644 --- a/packages/react-native-babel-preset/src/configs/main.js +++ b/packages/react-native-babel-preset/src/configs/main.js @@ -60,6 +60,12 @@ function getInlinePlatform(caller) { return caller?.inlinePlatform ?? false; } +// Boolean, whether the caller lowers `import`/`export` itself (Metro's +// `experimentalImportSupport`). When it does, the preset must leave ESM intact. +function getExperimentalImportSupport(caller) { + return caller?.experimentalImportSupport ?? false; +} + // use `this.foo = bar` instead of `this.defineProperty('foo', ...)` const loose = true; @@ -78,6 +84,11 @@ const getPreset = (src, options, babel) => { const inlinePlatform = options.inlinePlatform ?? babel?.caller(getInlinePlatform) ?? false; + const disableImportExportTransform = + options.disableImportExportTransform ?? + babel?.caller(getExperimentalImportSupport) ?? + false; + // Hermes V1 uses more optimised transform profiles. There is currently no // difference between stable and canary, but canary may in future be used to // test features in pre-prod Hermes V1 versions. @@ -151,7 +162,7 @@ const getPreset = (src, options, babel) => { extraPlugins.push([require('@react-native/babel-plugin-codegen')]); } - if (!options.disableImportExportTransform) { + if (!disableImportExportTransform) { extraPlugins.push( [require('@babel/plugin-proposal-export-default-from')], [ diff --git a/packages/react-native-babel-transformer/src/__tests__/__fixtures__/babel.config.js b/packages/react-native-babel-transformer/src/__tests__/__fixtures__/babel.config.js new file mode 100644 index 000000000000..edea543eb015 --- /dev/null +++ b/packages/react-native-babel-transformer/src/__tests__/__fixtures__/babel.config.js @@ -0,0 +1,17 @@ +/** + * 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 + */ + +'use strict'; + +// A project Babel config that names the preset with no options - the shape of +// the app template. The preset then only learns about Metro's transform +// options through the Babel caller. +module.exports = { + presets: [require.resolve('@react-native/babel-preset')], +}; diff --git a/packages/react-native-babel-transformer/src/__tests__/experimental-import-support-test.js b/packages/react-native-babel-transformer/src/__tests__/experimental-import-support-test.js new file mode 100644 index 000000000000..701bbcf1fcea --- /dev/null +++ b/packages/react-native-babel-transformer/src/__tests__/experimental-import-support-test.js @@ -0,0 +1,87 @@ +/** + * 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 + */ + +'use strict'; + +const generate = require('@babel/generator').default; +const path = require('node:path'); + +const PROJECT_ROOT = path.sep === '/' ? '/my/project' : 'C:\\my\\project'; +const PROJECT_BABEL_CONFIG = path.join( + __dirname, + '__fixtures__', + 'babel.config.js', +); +const SRC = "import foo from './foo';\nexport const bar = foo;"; + +// The transformer memoizes its resolved Babel config in a module-level +// closure, so a fresh module instance is required for every distinct config. +beforeEach(() => { + jest.resetModules(); +}); + +function transformToCode({ + experimentalImportSupport, + extendsBabelConfigPath, +}: { + experimentalImportSupport: boolean, + extendsBabelConfigPath?: string, +}): string { + const {transform} = require('../index.js'); + const {ast} = transform({ + filename: path.join(PROJECT_ROOT, 'App.js'), + src: SRC, + plugins: [], + options: { + dev: true, + enableBabelRuntime: false, + enableBabelRCLookup: false, + experimentalImportSupport, + extendsBabelConfigPath, + globalPrefix: '__metro__', + hot: false, + minify: false, + platform: 'ios', + publicPath: 'test', + projectRoot: PROJECT_ROOT, + }, + }); + return generate(ast).code; +} + +function isLowered(code: string): boolean { + return code.includes('require(') && !/^import\b/m.test(code); +} + +describe.each([ + ['no project Babel config', undefined], + ['a project Babel config naming the preset', PROJECT_BABEL_CONFIG], +])('with %s', (_name, extendsBabelConfigPath) => { + test('lowers import/export when experimentalImportSupport is off', () => { + const code = transformToCode({ + experimentalImportSupport: false, + extendsBabelConfigPath, + }); + + expect(isLowered(code)).toBe(true); + }); + + test('keeps import/export when experimentalImportSupport is on', () => { + // Metro lowers ESM itself in this mode. If the preset lowered it first, + // Metro's plugin would find nothing to do and the option would be a no-op. + const code = transformToCode({ + experimentalImportSupport: true, + extendsBabelConfigPath, + }); + + expect(isLowered(code)).toBe(false); + expect(code).toContain("import foo from './foo'"); + }); +}); diff --git a/packages/react-native-babel-transformer/src/index.js b/packages/react-native-babel-transformer/src/index.js index a830c9cd1072..a8a7e8803dd2 100644 --- a/packages/react-native-babel-transformer/src/index.js +++ b/packages/react-native-babel-transformer/src/index.js @@ -199,6 +199,7 @@ const transform /*: BabelTransformer['transform'] */ = ({ platform: options.platform, // $FlowFixMe[prop-missing] Remove suppression after next Metro release inlinePlatform: options.inlinePlatform, + experimentalImportSupport: options.experimentalImportSupport, unstable_transformProfile: options.unstable_transformProfile, }, ast: true,