diff --git a/packages/react-native-babel-preset/src/__tests__/inline-platform-opt-in-test.js b/packages/react-native-babel-preset/src/__tests__/inline-platform-opt-in-test.js new file mode 100644 index 000000000000..f47ee84146a0 --- /dev/null +++ b/packages/react-native-babel-preset/src/__tests__/inline-platform-opt-in-test.js @@ -0,0 +1,103 @@ +/** + * 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 {Platform} from 'react-native';\nconst os = Platform.OS;"; + +type PresetOptions = { + platform?: ?string, + inlinePlatform?: boolean, +}; + +type CallerOptions = { + platform?: ?string, + inlinePlatform?: 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 isInlined(code: string): boolean { + return code.includes('"ios"') && !/\.OS\b/.test(code); +} + +describe('Platform inlining is opt-in', () => { + test('does not inline when only a platform is given', () => { + // A platform on its own says which platform we are compiling *for*. It is + // also set by consumers that need platform-correct module resolution but + // must keep `Platform` observable at runtime - Jest mocks it. + expect(isInlined(transform({presetOptions: {platform: 'ios'}}))).toBe( + false, + ); + }); + + test('does not inline when only the Babel caller gives a platform', () => { + expect(isInlined(transform({caller: {platform: 'ios'}}))).toBe(false); + }); + + test('inlines when opted in via preset options', () => { + expect( + isInlined( + transform({presetOptions: {platform: 'ios', inlinePlatform: true}}), + ), + ).toBe(true); + }); + + test('inlines when opted in via the Babel caller', () => { + // The only channel available when the preset is named in a babel.config.js, + // where Babel supplies no preset options. + expect( + isInlined(transform({caller: {platform: 'ios', inlinePlatform: true}})), + ).toBe(true); + }); + + test('preset options take precedence over the caller', () => { + expect( + isInlined( + transform({ + presetOptions: {inlinePlatform: false}, + caller: {platform: 'ios', inlinePlatform: true}, + }), + ), + ).toBe(false); + }); + + test('opting in without a platform is still a no-op', () => { + expect(isInlined(transform({presetOptions: {inlinePlatform: true}}))).toBe( + false, + ); + }); +}); diff --git a/packages/react-native-babel-preset/src/configs/main.js b/packages/react-native-babel-preset/src/configs/main.js index d1f9491d142f..a48b126ca8b8 100644 --- a/packages/react-native-babel-preset/src/configs/main.js +++ b/packages/react-native-babel-preset/src/configs/main.js @@ -48,16 +48,18 @@ function getTransformProfile(caller) { return caller?.unstable_transformProfile ?? 'hermes-stable'; } -// The target platform for `Platform.OS` / `Platform.select` inlining. Metro -// passes this in transform options; when the preset is consumed directly as a -// Babel preset (no `options.platform`), fall back to the Babel caller so any -// Metro-driven consumer (bare Metro, Expo, @fb-tools/transformer) works without -// extra wiring. Reading it via `babel.caller` also makes Babel re-evaluate the -// preset when the platform changes between transform calls. +// The target platform, currently only used for platform inlining. function getPlatform(caller) { return caller?.platform ?? null; } +// Boolean, whether to inline `Platform`. Separate from `platform` (string) +// because a platform already reaches the preset and may be used for other +// purposes. +function getInlinePlatform(caller) { + return caller?.inlinePlatform ?? false; +} + // use `this.foo = bar` instead of `this.defineProperty('foo', ...)` const loose = true; @@ -69,6 +71,9 @@ const getPreset = (src, options, babel) => { const platform = options?.platform ?? babel?.caller(getPlatform); + const inlinePlatform = + options?.inlinePlatform ?? babel?.caller(getInlinePlatform) ?? 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. @@ -121,7 +126,9 @@ const getPreset = (src, options, babel) => { // `disableImportExportTransform` is set), while the source-level import that // proves provenance is still intact. It is a no-op when `platform` is null or // the empty string. - extraPlugins.push([require('../inline-platform-plugin'), {platform}]); + if (inlinePlatform) { + extraPlugins.push([require('../inline-platform-plugin'), {platform}]); + } if (!options.useTransformReactJSXExperimental) { extraPlugins.push([ diff --git a/packages/react-native-babel-transformer/src/__tests__/inline-platform-integration-test.js b/packages/react-native-babel-transformer/src/__tests__/inline-platform-integration-test.js index 22ce30319d52..dfbf52083153 100644 --- a/packages/react-native-babel-transformer/src/__tests__/inline-platform-integration-test.js +++ b/packages/react-native-babel-transformer/src/__tests__/inline-platform-integration-test.js @@ -30,10 +30,12 @@ function transformToCode( filename = path.join(PROJECT_ROOT, 'App.js'), platform = 'ios', experimentalImportSupport = false, + inlinePlatform = true, }: { filename?: string, platform?: ?string, experimentalImportSupport?: boolean, + inlinePlatform?: boolean, } = {}, ): string { const {transform} = require('../index.js'); @@ -48,6 +50,7 @@ function transformToCode( experimentalImportSupport, globalPrefix: '__metro__', hot: false, + inlinePlatform, minify: false, platform, publicPath: 'test', @@ -151,6 +154,19 @@ describe.each([false, true])( expect(code).toMatch(/\.OS\b/); }); + + test('does not inline without the inlinePlatform opt-in', () => { + // Metro sets this per build; consumers that only need platform-correct + // resolution (Jest) pass a platform without it and must keep `Platform` + // observable so it can be mocked. + const code = transformToCode( + "import {Platform} from 'react-native';\nconst os = Platform.OS;", + {inlinePlatform: false, experimentalImportSupport}, + ); + + expect(code).toMatch(/\.OS\b/); + expect(code).not.toContain('"ios"'); + }); }, ); diff --git a/packages/react-native-babel-transformer/src/index.js b/packages/react-native-babel-transformer/src/index.js index a48f3a802736..a830c9cd1072 100644 --- a/packages/react-native-babel-transformer/src/index.js +++ b/packages/react-native-babel-transformer/src/index.js @@ -197,6 +197,8 @@ const transform /*: BabelTransformer['transform'] */ = ({ name: 'metro', bundler: 'metro', platform: options.platform, + // $FlowFixMe[prop-missing] Remove suppression after next Metro release + inlinePlatform: options.inlinePlatform, unstable_transformProfile: options.unstable_transformProfile, }, ast: true,