diff --git a/packages/react-native-babel-preset/src/__tests__/inline-platform-plugin-test.js b/packages/react-native-babel-preset/src/__tests__/inline-platform-plugin-test.js new file mode 100644 index 000000000000..92bb4aac0739 --- /dev/null +++ b/packages/react-native-babel-preset/src/__tests__/inline-platform-plugin-test.js @@ -0,0 +1,525 @@ +/** + * 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 inlinePlatformPlugin = require('../inline-platform-plugin.js'); +const {transformSync} = require('@babel/core'); +const path = require('node:path'); + +const RN_ROOT = '/app/node_modules/react-native'; +const APP_FILE = '/app/src/App.js'; + +function transform( + code: string, + { + filename = APP_FILE, + platform = 'ios', + }: {filename?: string, platform?: ?string} = {}, +): string { + const result = transformSync(code, { + babelrc: false, + configFile: false, + compact: true, + filename, + plugins: [ + // $FlowFixMe[untyped-import] + require('@babel/plugin-syntax-flow'), + [inlinePlatformPlugin, {platform}], + ], + sourceType: 'module', + }); + return result.code; +} + +// Asserts the code is unchanged by the plugin, modulo formatting. +function expectUnchanged( + code: string, + options?: {filename?: string, platform?: ?string}, +) { + expect(transform(code, options)).toBe( + transform(code, {...options, platform: null}), + ); +} + +describe('Platform.OS from an ESM import', () => { + test('inlines a named import from react-native', () => { + expect( + transform(` + import {Platform} from 'react-native'; + const os = Platform.OS; + `), + ).toMatchInlineSnapshot( + `"import{Platform}from'react-native';const os=\\"ios\\";"`, + ); + }); + + test('inlines an aliased named import', () => { + expect( + transform(` + import {Platform as P} from 'react-native'; + const os = P.OS; + `), + ).toMatchInlineSnapshot( + `"import{Platform as P}from'react-native';const os=\\"ios\\";"`, + ); + }); + + test('inlines through a namespace import', () => { + expect( + transform(` + import * as RN from 'react-native'; + const os = RN.Platform.OS; + `), + ).toMatchInlineSnapshot( + `"import*as RN from'react-native';const os=\\"ios\\";"`, + ); + }); + + test('inlines a public deep default import', () => { + expect( + transform(` + import P from 'react-native/Libraries/Utilities/Platform'; + const os = P.OS; + `), + ).toMatchInlineSnapshot( + `"import P from'react-native/Libraries/Utilities/Platform';const os=\\"ios\\";"`, + ); + }); + + test('inlines a default import from the react-native barrel', () => { + // `react-native` is CommonJS, so the default import is the barrel object. + expect( + transform(` + import ReactNative from 'react-native'; + const os = ReactNative.Platform.OS; + `), + ).toMatchInlineSnapshot( + `"import ReactNative from'react-native';const os=\\"ios\\";"`, + ); + }); + + test('respects the requested platform', () => { + expect( + transform( + ` + import {Platform} from 'react-native'; + const os = Platform.OS; + `, + {platform: 'android'}, + ), + ).toContain('"android"'); + }); + + test('does not inline a type-only import', () => { + expectUnchanged(` + import type {Platform} from 'react-native'; + const os = Platform.OS; + `); + }); +}); + +describe('Platform.OS from CommonJS', () => { + test('inlines a direct require of the barrel', () => { + expect( + transform(` + const os = require('react-native').Platform.OS; + `), + ).toMatchInlineSnapshot(`"const os=\\"ios\\";"`); + }); + + test('inlines a bound require of the barrel', () => { + expect( + transform(` + const RN = require('react-native'); + const os = RN.Platform.OS; + `), + ).toMatchInlineSnapshot( + `"const RN=require('react-native');const os=\\"ios\\";"`, + ); + }); + + test('inlines a deep require with .default', () => { + expect( + transform(` + const P = require('react-native/Libraries/Utilities/Platform').default; + const os = P.OS; + `), + ).toMatchInlineSnapshot( + `"const P=require('react-native/Libraries/Utilities/Platform').default;const os=\\"ios\\";"`, + ); + }); + + test('inlines a destructured require', () => { + expect( + transform(` + const {Platform} = require('react-native'); + const os = Platform.OS; + `), + ).toMatchInlineSnapshot( + `"const{Platform}=require('react-native');const os=\\"ios\\";"`, + ); + }); + + test('inlines a renamed destructured require', () => { + expect( + transform(` + const {Platform: P} = require('react-native'); + const os = P.OS; + `), + ).toMatchInlineSnapshot( + `"const{Platform:P}=require('react-native');const os=\\"ios\\";"`, + ); + }); + + test('follows an immutable alias', () => { + expect( + transform(` + const {Platform} = require('react-native'); + const P = Platform; + const os = P.OS; + `), + ).toMatchInlineSnapshot( + `"const{Platform}=require('react-native');const P=Platform;const os=\\"ios\\";"`, + ); + }); + + test('does not inline when require is shadowed', () => { + expectUnchanged(` + function f(require) { + const P = require('react-native').Platform; + return P.OS; + } + `); + }); + + test('does not follow a reassigned binding', () => { + expectUnchanged(` + let P = require('react-native').Platform; + P = somethingElse; + const os = P.OS; + `); + }); + + test('does not inline a require with a non-literal specifier', () => { + expectUnchanged(` + const os = require(dynamicName).Platform.OS; + `); + }); +}); + +describe('provenance is required', () => { + test('does not inline a bare global Platform', () => { + // Metro's late pass still handles this historical form. + expectUnchanged('const os = Platform.OS;'); + }); + + test('does not inline React.Platform.OS', () => { + expectUnchanged('const os = React.Platform.OS;'); + }); + + test('does not inline a same-named import from another package', () => { + expectUnchanged(` + import Platform from 'other-package'; + const os = Platform.OS; + `); + expectUnchanged(` + import {Platform} from 'other-package'; + const os = Platform.OS; + `); + }); + + test('does not inline a locally declared Platform', () => { + expectUnchanged(` + const Platform = {OS: 'web'}; + const os = Platform.OS; + `); + }); + + test('does not inline a parameter named Platform', () => { + expectUnchanged(` + function f(Platform) { + return Platform.OS; + } + `); + }); + + test('does not inline a shadowing local inside a function', () => { + const code = ` + import {Platform} from 'react-native'; + function f() { + const Platform = {OS: 'web'}; + return Platform.OS; + } + const outer = Platform.OS; + `; + const output = transform(code); + expect(output).toContain('Platform.OS'); + expect(output).toContain('const outer="ios"'); + }); + + test('does not inline a non-Platform member of the barrel', () => { + expectUnchanged(` + import {View} from 'react-native'; + const os = View.OS; + `); + }); + + test('does not inline a different module named Platform', () => { + expectUnchanged( + ` + import P from '../Utilities/Platform'; + const os = P.OS; + `, + {filename: '/app/src/components/Thing.js'}, + ); + }); +}); + +describe('unsafe positions', () => { + test('does not replace an assignment target', () => { + const output = transform(` + import {Platform} from 'react-native'; + Platform.OS = 'web'; + `); + expect(output).toContain("Platform.OS='web'"); + }); + + test('does not replace an update target', () => { + const output = transform(` + import {Platform} from 'react-native'; + Platform.OS++; + `); + expect(output).toContain('Platform.OS++'); + }); + + test('does not replace a delete target', () => { + const output = transform(` + import {Platform} from 'react-native'; + delete Platform.OS; + `); + expect(output).toContain('delete Platform.OS'); + }); + + test('does not inline computed access', () => { + const output = transform(` + import {Platform} from 'react-native'; + const os = Platform['OS']; + `); + expect(output).toContain("Platform['OS']"); + }); +}); + +describe('React Native internal relative imports', () => { + test('inlines from Libraries/', () => { + expect( + transform( + ` + import Platform from '../../Utilities/Platform'; + const os = Platform.OS; + `, + { + filename: `${RN_ROOT}/Libraries/Components/ScrollView/ScrollView.js`, + }, + ), + ).toContain('"ios"'); + }); + + test('inlines from src/private/', () => { + expect( + transform( + ` + import Platform from '../../../Libraries/Utilities/Platform'; + const os = Platform.OS; + `, + { + filename: `/app/packages/react-native/src/private/animated/NativeAnimatedHelper.js`, + }, + ), + ).toContain('"ios"'); + }); + + test('inlines a relative CommonJS require with .default', () => { + expect( + transform( + ` + const P = require('../../Utilities/Platform').default; + const os = P.OS; + `, + {filename: `${RN_ROOT}/Libraries/Components/View/View.js`}, + ), + ).toContain('"ios"'); + }); + + test('inlines a relative import with an explicit extension', () => { + expect( + transform( + ` + import Platform from '../../Utilities/Platform.js'; + const os = Platform.OS; + `, + {filename: `${RN_ROOT}/Libraries/Components/View/View.js`}, + ), + ).toContain('"ios"'); + }); + + test('inlines under a pnpm-style layout', () => { + expect( + transform( + ` + import Platform from '../../Utilities/Platform'; + const os = Platform.OS; + `, + { + filename: + '/app/node_modules/.pnpm/react-native@0.87.0/node_modules/react-native/Libraries/Components/View/View.js', + }, + ), + ).toContain('"ios"'); + }); + + test('does not inline when the package root is react-native-something', () => { + expectUnchanged( + ` + import Platform from '../../Utilities/Platform'; + const os = Platform.OS; + `, + { + filename: + '/app/node_modules/react-native-web/Libraries/Components/View/View.js', + }, + ); + }); + + test('does not inline when the importer is outside the resolved RN root', () => { + // Resolves into react-native, but the importer is not part of it. + expectUnchanged( + ` + import Platform from '../node_modules/react-native/Libraries/Utilities/Platform'; + const os = Platform.OS; + `, + {filename: '/app/src/App.js'}, + ); + }); + + test('does not inline a relative path that escapes into another package', () => { + expectUnchanged( + ` + import Platform from '../../../other-package/Libraries/Utilities/Platform'; + const os = Platform.OS; + `, + {filename: `${RN_ROOT}/Libraries/Components/View/View.js`}, + ); + }); + + if (path.sep === '\\') { + test('normalizes Windows separators', () => { + expect( + transform( + ` + import Platform from '../../Utilities/Platform'; + const os = Platform.OS; + `, + { + filename: + 'C:\\app\\node_modules\\react-native\\Libraries\\Components\\View\\View.js', + }, + ), + ).toContain('"ios"'); + }); + } +}); + +describe('Platform.select', () => { + const select = (spec: string, platform: string = 'ios') => + transform( + ` + import {Platform} from 'react-native'; + const value = Platform.select(${spec}); + `, + {platform}, + ); + + test('picks the exact platform', () => { + expect(select('{ios: 1, android: 2}')).toContain('const value=1'); + }); + + test('falls back to native', () => { + expect(select('{ios: 1, native: 2}', 'android')).toContain('const value=2'); + }); + + test('falls back to default', () => { + expect(select('{ios: 1, default: 3}', 'android')).toContain( + 'const value=3', + ); + }); + + test('prefers native over default', () => { + expect(select('{ios: 1, native: 2, default: 3}', 'android')).toContain( + 'const value=2', + ); + }); + + test('yields undefined when nothing matches', () => { + expect(select('{ios: 1}', 'android')).toContain('const value=undefined'); + }); + + test('accepts string keys', () => { + expect(select("{'ios': 1, 'android': 2}")).toContain('const value=1'); + }); + + test('accepts object methods', () => { + expect(select('{ios() { return 1; }}')).toContain('function'); + }); + + test('does not inline computed keys', () => { + expect(select('{[key]: 1, default: 2}')).toContain('Platform.select'); + }); + + test('does not inline spreads', () => { + expect(select('{...rest, default: 2}')).toContain('Platform.select'); + }); + + test('does not inline getters', () => { + expect(select('{get ios() { return 1; }}')).toContain('Platform.select'); + }); + + test('does not inline a non-object argument', () => { + expect(select('spec')).toContain('Platform.select'); + }); + + test('does not inline an unproven Platform.select', () => { + expectUnchanged('const value = Platform.select({ios: 1});'); + }); +}); + +describe('platform option', () => { + const code = ` + import {Platform} from 'react-native'; + const os = Platform.OS; + const value = Platform.select({ios: 1}); + `; + + test('is a no-op when platform is null', () => { + const output = transform(code, {platform: null}); + expect(output).toContain('Platform.OS'); + expect(output).toContain('Platform.select'); + }); + + test('is a no-op when platform is the empty string', () => { + // React Native's Jest preprocessor passes `platform: ''` for every file it + // transforms; inlining `Platform.OS` to `""` there would break the RN test + // suite wholesale. + const output = transform(code, {platform: ''}); + expect(output).toContain('Platform.OS'); + expect(output).toContain('Platform.select'); + expect(output).not.toContain('""'); + }); +}); diff --git a/packages/react-native-babel-preset/src/configs/main.js b/packages/react-native-babel-preset/src/configs/main.js index e1350e6ecd91..d1f9491d142f 100644 --- a/packages/react-native-babel-preset/src/configs/main.js +++ b/packages/react-native-babel-preset/src/configs/main.js @@ -48,6 +48,16 @@ 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. +function getPlatform(caller) { + return caller?.platform ?? null; +} + // use `this.foo = bar` instead of `this.defineProperty('foo', ...)` const loose = true; @@ -57,6 +67,8 @@ const getPreset = (src, options, babel) => { const dev = options?.dev ?? babel?.env('development') ?? false; + const platform = options?.platform ?? babel?.caller(getPlatform); + // 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. @@ -103,6 +115,14 @@ const getPreset = (src, options, babel) => { const extraPlugins = []; const firstPartyPlugins = []; + // Inline `Platform.OS` and `Platform.select(...)` for provably React + // Native-owned `Platform` imports. This must run before the CommonJS module + // transform below (and before Metro's own import lowering when + // `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 (!options.useTransformReactJSXExperimental) { extraPlugins.push([ require('@babel/plugin-transform-react-jsx'), diff --git a/packages/react-native-babel-preset/src/index.js b/packages/react-native-babel-preset/src/index.js index 28e4af4f5cf8..70b5137ca47d 100644 --- a/packages/react-native-babel-preset/src/index.js +++ b/packages/react-native-babel-preset/src/index.js @@ -41,6 +41,7 @@ module.exports.getCacheKey = () => { readFileSync(require.resolve('./configs/lazy-imports.js')), readFileSync(require.resolve('./passthrough-syntax-plugins.js')), readFileSync(require.resolve('./plugin-warn-on-deep-imports.js')), + readFileSync(require.resolve('./inline-platform-plugin.js')), ].forEach(part => key.update(part)); cacheKey = key.digest('hex'); return cacheKey; diff --git a/packages/react-native-babel-preset/src/inline-platform-plugin.js b/packages/react-native-babel-preset/src/inline-platform-plugin.js new file mode 100644 index 000000000000..c818ff3331d5 --- /dev/null +++ b/packages/react-native-babel-preset/src/inline-platform-plugin.js @@ -0,0 +1,531 @@ +/** + * 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 + * @format + */ + +// This file uses Flow comment syntax so that it may be used from source as part +// of a transformer without itself requiring transformation, matching +// ./index.js. + +'use strict'; + +/*:: +import type {PluginObj} from '@babel/core'; +import type {Binding, NodePath} from '@babel/traverse'; +import type { + CallExpression, + MemberExpression, + Node, + ObjectExpression, + ObjectPattern, +} from '@babel/types'; +// Type-only import. No runtime dependency. +// eslint-disable-next-line import/no-extraneous-dependencies +import typeof * as Types from '@babel/types'; + +export type Options = { + platform: ?string, +}; + +// What a proven expression refers to. +// +// PLATFORM the Platform object itself +// RN_BARREL the `react-native` module's exports object +// PLATFORM_MODULE the exports object of Libraries/Utilities/Platform +type Provenance = 'platform' | 'rn-barrel' | 'platform-module'; + +type State = { + opts: Options, + filename?: ?string, + ... +}; +*/ + +const nodePath = require('node:path'); + +const RN_PACKAGE_NAME = 'react-native'; +const PLATFORM_MODULE_PATH = 'Libraries/Utilities/Platform'; +const RN_PLATFORM_SPECIFIER = RN_PACKAGE_NAME + '/' + PLATFORM_MODULE_PATH; +const SOURCE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx']; + +const PLATFORM /*: Provenance */ = 'platform'; +const RN_BARREL /*: Provenance */ = 'rn-barrel'; +const PLATFORM_MODULE /*: Provenance */ = 'platform-module'; + +// Sentinel stored while a binding is being resolved, to break alias cycles. +const RESOLVING = 'resolving'; + +function toPosix(filePath /*: string */) /*: string */ { + return filePath.split(nodePath.sep).join('/').split('\\').join('/'); +} + +function stripSourceExtension(filePath /*: string */) /*: string */ { + for (const extension of SOURCE_EXTENSIONS) { + if (filePath.endsWith(extension)) { + return filePath.slice(0, -extension.length); + } + } + return filePath; +} + +/** + * Whether a relative specifier in `filename` logically refers to React + * Native's own Platform module. + * + * This is a purely lexical judgement - we never touch the filesystem, and we + * never resolve the platform-specific implementation (Platform.ios.js, + * Platform.android.js). Metro does that later; the identity we care about here + * is the extension-less module `/Libraries/Utilities/Platform`. + * + * `filename` may be absolute or project-root-relative: Metro passes + * `path.relative(projectRoot, filePath)`, while Babel callers generally pass an + * absolute path. Both work, because everything below is relative arithmetic on + * the importer's own path. It does mean a relative importer path is interpreted + * as being rooted at the project root, so this assumes the project root is not + * itself inside the react-native package - true for any real app, and for RN's + * own repo, where the project root is the monorepo root. + * + * The React Native package root is identified by its directory name, which + * holds for every layout we need to support: + * + * node_modules/react-native/... + * packages/react-native/... + * node_modules/.pnpm/react-native@x.y.z/node_modules/react-native/... + * + * We deliberately do not accept a path that merely ends in + * `Libraries/Utilities/Platform`: an app with its own module of that name must + * not be inlined. + */ +function isRelativeReactNativePlatformImport( + specifier /*: string */, + filename /*: ?string */, +) /*: boolean */ { + if (filename == null || filename === '') { + return false; + } + + const importer = toPosix(filename); + // Resolve with posix semantics against the importer's directory, so that a + // relative importer path stays relative rather than being resolved against + // the process cwd (which has nothing to do with the bundle). + const target = stripSourceExtension( + nodePath.posix.join(nodePath.posix.dirname(importer), specifier), + ); + const suffix = '/' + PLATFORM_MODULE_PATH; + + if (!target.endsWith(suffix)) { + return false; + } + + const reactNativeRoot = target.slice(0, -suffix.length); + + // A specifier that climbs above the root it was resolved against cannot be + // trusted; `join` leaves the leading `..` segments in place. + if (reactNativeRoot.startsWith('..')) { + return false; + } + + // The importer must live inside the same React Native package. + if (!importer.startsWith(reactNativeRoot + '/')) { + return false; + } + + return nodePath.posix.basename(reactNativeRoot) === RN_PACKAGE_NAME; +} + +function isRelativeSpecifier(specifier /*: string */) /*: boolean */ { + return specifier.startsWith('./') || specifier.startsWith('../'); +} + +/** + * Non-computed, identifier-keyed property name, or null. + */ +function getStaticPropertyName( + node /*: MemberExpression */, +) /*: string | null */ { + if (node.computed === true) { + return null; + } + if (node.property.type === 'Identifier') { + return node.property.name; + } + return null; +} + +module.exports = function inlinePlatformPlugin( + {types: t} /*: {types: Types} */, +) /*: PluginObj */ { + // Per-file cache of resolved binding provenance, reset in `pre()`. Held in a + // closure rather than on the plugin pass so the visitor need not reference + // `this`. + let rnBindingCache /*: WeakMap */ = + new WeakMap(); + + /** + * What module a specifier resolves to, from React Native's point of view. + */ + function getModuleProvenance( + specifier /*: string */, + state /*: State */, + ) /*: Provenance | null */ { + if (specifier === RN_PACKAGE_NAME) { + return RN_BARREL; + } + if (specifier === RN_PLATFORM_SPECIFIER) { + return PLATFORM_MODULE; + } + if (!isRelativeSpecifier(specifier)) { + return null; + } + return isRelativeReactNativePlatformImport(specifier, state.filename) + ? PLATFORM_MODULE + : null; + } + + /** + * Reading `propertyName` off an expression with `objectProvenance`. + */ + function getMemberProvenance( + objectProvenance /*: Provenance | null */, + propertyName /*: string */, + ) /*: Provenance | null */ { + if (objectProvenance === RN_BARREL && propertyName === 'Platform') { + return PLATFORM; + } + if (objectProvenance === PLATFORM_MODULE && propertyName === 'default') { + return PLATFORM; + } + return null; + } + + function getRequireCallProvenance( + path /*: NodePath */, + state /*: State */, + ) /*: Provenance | null */ { + if (!path.get('callee').isIdentifier({name: 'require'})) { + return null; + } + // Only a free `require` is a module import. A local binding named + // `require` may be anything at all. + if (path.scope.getBinding('require') != null) { + return null; + } + const args = path.node.arguments; + if (args.length !== 1 || args[0].type !== 'StringLiteral') { + return null; + } + return getModuleProvenance(args[0].value, state); + } + + function getExpressionProvenance( + path /*: NodePath<$FlowFixMe> */, + state /*: State */, + ) /*: Provenance | null */ { + if (path.isIdentifier()) { + const binding = path.scope.getBinding(path.node.name); + return binding == null ? null : getBindingProvenance(binding, state); + } + if (path.isMemberExpression()) { + const propertyName = getStaticPropertyName(path.node); + if (propertyName == null) { + return null; + } + return getMemberProvenance( + getExpressionProvenance(path.get('object'), state), + propertyName, + ); + } + if (path.isCallExpression()) { + return getRequireCallProvenance(path, state); + } + return null; + } + + function getImportBindingProvenance( + binding /*: Binding */, + state /*: State */, + ) /*: Provenance | null */ { + const specifierPath = binding.path; + const declaration = specifierPath.parent; + + if (declaration.type !== 'ImportDeclaration') { + return null; + } + // `import type {Platform} from ...` binds nothing at runtime. + if ( + declaration.importKind === 'type' || + declaration.importKind === 'typeof' + ) { + return null; + } + + const moduleProvenance = getModuleProvenance( + declaration.source.value, + state, + ); + if (moduleProvenance == null) { + return null; + } + + switch (specifierPath.node.type) { + case 'ImportNamespaceSpecifier': + // A namespace object stands in for the module's exports. + return moduleProvenance; + case 'ImportDefaultSpecifier': + // `react-native` is CommonJS, so interop hands back the barrel itself. + return moduleProvenance === RN_BARREL + ? RN_BARREL + : getMemberProvenance(moduleProvenance, 'default'); + case 'ImportSpecifier': { + if (specifierPath.node.importKind === 'type') { + return null; + } + const imported = specifierPath.node.imported; + const importedName = + imported.type === 'Identifier' ? imported.name : imported.value; + return getMemberProvenance(moduleProvenance, importedName); + } + default: + return null; + } + } + + /** + * Provenance of a `const {Platform} = require('react-native')` style + * binding. + */ + function getDestructuredProvenance( + binding /*: Binding */, + pattern /*: ObjectPattern */, + initProvenance /*: Provenance | null */, + ) /*: Provenance | null */ { + if (initProvenance == null) { + return null; + } + for (const property of pattern.properties) { + if (property.type !== 'ObjectProperty' || property.computed === true) { + continue; + } + // Identity, not name: `const {Platform: P}` binds `P`. + if (property.value !== binding.identifier) { + continue; + } + const key = property.key; + const keyName = + key.type === 'Identifier' + ? key.name + : key.type === 'StringLiteral' + ? key.value + : null; + return keyName == null + ? null + : getMemberProvenance(initProvenance, keyName); + } + return null; + } + + function getVariableBindingProvenance( + binding /*: Binding */, + state /*: State */, + ) /*: Provenance | null */ { + const declaratorPath = binding.path; + const declarator = declaratorPath.node; + if (declarator == null || declarator.type !== 'VariableDeclarator') { + return null; + } + const initPath = declaratorPath.get('init'); + if (Array.isArray(initPath) || initPath.node == null) { + return null; + } + + const id = declarator.id; + if (id.type === 'ObjectPattern') { + return getDestructuredProvenance( + binding, + id, + getExpressionProvenance(initPath, state), + ); + } + if (id.type !== 'Identifier') { + return null; + } + return getExpressionProvenance(initPath, state); + } + + function getBindingProvenance( + binding /*: Binding */, + state /*: State */, + ) /*: Provenance | null */ { + const cache = rnBindingCache; + const cached = cache.get(binding); + if (cached !== undefined) { + // An alias cycle is not resolvable. + return cached === RESOLVING ? null : cached; + } + cache.set(binding, RESOLVING); + + let provenance = null; + if (binding.kind === 'module') { + provenance = getImportBindingProvenance(binding, state); + } else if (binding.constant && binding.constantViolations.length === 0) { + // Only immutable bindings can be followed - a reassignable one may hold + // something else by the time it is read. + provenance = getVariableBindingProvenance(binding, state); + } + + cache.set(binding, provenance); + return provenance; + } + + function isPlatform( + path /*: NodePath<$FlowFixMe> */, + state /*: State */, + ) /*: boolean */ { + return getExpressionProvenance(path, state) === PLATFORM; + } + + /** + * Contexts in which replacing an expression with a literal is invalid. + */ + function isWriteTarget( + path /*: NodePath */, + ) /*: boolean */ { + const {parent, node} = path; + if (parent.type === 'AssignmentExpression' && parent.left === node) { + return true; + } + if (parent.type === 'UpdateExpression' && parent.argument === node) { + return true; + } + if (parent.type === 'UnaryExpression' && parent.operator === 'delete') { + return true; + } + return false; + } + + // The following two helpers intentionally mirror Metro's inline-plugin so + // that a Platform.select call inlines identically whichever pass reaches it + // first. + function hasStaticProperties( + objectExpression /*: ObjectExpression */, + ) /*: boolean */ { + return objectExpression.properties.every(property => { + if (property.computed === true || t.isSpreadElement(property)) { + return false; + } + if (t.isObjectMethod(property) && property.kind !== 'method') { + return false; + } + return t.isIdentifier(property.key) || t.isStringLiteral(property.key); + }); + } + + function findProperty( + objectExpression /*: ObjectExpression */, + key /*: string */, + fallback /*: () => Node */, + ) /*: Node */ { + for (const property of objectExpression.properties) { + if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) { + continue; + } + if ( + (t.isIdentifier(property.key) && property.key.name === key) || + (t.isStringLiteral(property.key) && property.key.value === key) + ) { + if (t.isObjectProperty(property)) { + return property.value; + } + return t.toExpression(property); + } + } + return fallback(); + } + + /** + * The target platform, or null if there is nothing safe to inline to. + * + * Callers without a concrete platform are not consistent about how they say + * so: Metro passes `null` for platform-agnostic builds, while React Native's + * own Jest preprocessor passes the empty string. Inlining to `""` in either + * case would be actively wrong, so treat both as "no platform". + */ + function getTargetPlatform(state /*: State */) /*: string | null */ { + const platform = state.opts.platform; + return platform == null || platform === '' ? null : platform; + } + + return { + name: 'inline-platform', + pre() /*: void */ { + rnBindingCache = new WeakMap(); + }, + visitor: { + MemberExpression( + path /*: NodePath */, + state /*: State */, + ) /*: void */ { + const platform = getTargetPlatform(state); + if (platform == null) { + return; + } + if (getStaticPropertyName(path.node) !== 'OS') { + return; + } + if (isWriteTarget(path)) { + return; + } + if (!isPlatform(path.get('object'), state)) { + return; + } + path.replaceWith(t.stringLiteral(platform)); + }, + CallExpression( + path /*: NodePath */, + state /*: State */, + ) /*: void */ { + const platform = getTargetPlatform(state); + if (platform == null) { + return; + } + const callee = path.get('callee'); + const calleeNode = callee.node; + if (calleeNode.type !== 'MemberExpression') { + return; + } + if (getStaticPropertyName(calleeNode) !== 'select') { + return; + } + const args = path.node.arguments; + const spec = args[0]; + if ( + args.length !== 1 || + spec == null || + spec.type !== 'ObjectExpression' + ) { + return; + } + if (!hasStaticProperties(spec)) { + return; + } + const calleeObject = callee.get('object'); + if (Array.isArray(calleeObject) || !isPlatform(calleeObject, state)) { + return; + } + + path.replaceWith( + findProperty(spec, platform, () => + findProperty(spec, 'native', () => + findProperty(spec, 'default', () => t.identifier('undefined')), + ), + ), + ); + }, + }, + }; +}; 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 new file mode 100644 index 000000000000..22ce30319d52 --- /dev/null +++ b/packages/react-native-babel-transformer/src/__tests__/inline-platform-integration-test.js @@ -0,0 +1,184 @@ +/** + * 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 RN_ROOT = path.join(PROJECT_ROOT, 'node_modules', 'react-native'); + +// The transformer memoizes its resolved Babel config in a module-level +// closure, keyed on nothing - so a fresh module instance is required for every +// distinct `options` shape, or later variants silently reuse the first +// variant's config. +beforeEach(() => { + jest.resetModules(); +}); + +function transformToCode( + src: string, + { + filename = path.join(PROJECT_ROOT, 'App.js'), + platform = 'ios', + experimentalImportSupport = false, + }: { + filename?: string, + platform?: ?string, + experimentalImportSupport?: boolean, + } = {}, +): string { + const {transform} = require('../index.js'); + const {ast} = transform({ + filename, + src, + plugins: [], + options: { + dev: true, + enableBabelRuntime: false, + enableBabelRCLookup: false, + experimentalImportSupport, + globalPrefix: '__metro__', + hot: false, + minify: false, + platform, + publicPath: 'test', + projectRoot: PROJECT_ROOT, + }, + }); + return generate(ast).code; +} + +// Each of these must inline during RN's own Babel pass, before ESM lowering +// destroys the evidence that the value came from React Native. +const IMPORT_FORMS = [ + { + name: 'named import from react-native', + src: "import {Platform} from 'react-native';\nconst os = Platform.OS;", + }, + { + name: 'aliased named import from react-native', + src: "import {Platform as P} from 'react-native';\nconst os = P.OS;", + }, + { + name: 'namespace import from react-native', + src: "import * as RN from 'react-native';\nconst os = RN.Platform.OS;", + }, + { + name: 'public deep default import', + src: "import P from 'react-native/Libraries/Utilities/Platform';\nconst os = P.OS;", + }, + { + name: 'destructured require of react-native', + src: "const {Platform} = require('react-native');\nconst os = Platform.OS;", + }, +]; + +describe.each([false, true])( + 'with experimentalImportSupport=%s', + experimentalImportSupport => { + test.each(IMPORT_FORMS)('inlines Platform.OS for a $name', ({src}) => { + const code = transformToCode(src, {experimentalImportSupport}); + + expect(code).toContain('"ios"'); + expect(code).not.toMatch(/\.OS\b/); + }); + + test('inlines Platform.select', () => { + const code = transformToCode( + "import {Platform} from 'react-native';\n" + + 'const value = Platform.select({ios: 1, android: 2});', + {experimentalImportSupport}, + ); + + expect(code).not.toContain('select'); + expect(code).toMatch(/[=]\s*1/); + }); + + test('inlines an RN-internal relative import', () => { + const code = transformToCode( + "import Platform from '../../Utilities/Platform';\nconst os = Platform.OS;", + { + filename: path.join( + RN_ROOT, + 'Libraries', + 'Components', + 'ScrollView', + 'ScrollView.js', + ), + experimentalImportSupport, + }, + ); + + expect(code).toContain('"ios"'); + expect(code).not.toMatch(/\.OS\b/); + }); + + test('leaves the import in place after inlining', () => { + // Removing it would change dependency collection; that is out of scope + // here and handled by a separate opt-in pass. + const code = transformToCode( + "import {Platform} from 'react-native';\nconst os = Platform.OS;", + {experimentalImportSupport}, + ); + + expect(code).toContain('react-native'); + }); + + test('does not inline a same-named import from another package', () => { + const code = transformToCode( + "import Platform from 'other-package';\nconst os = Platform.OS;", + {experimentalImportSupport}, + ); + + expect(code).toMatch(/\.OS\b/); + expect(code).not.toContain('"ios"'); + }); + + test('does not inline when no platform is given', () => { + const code = transformToCode( + "import {Platform} from 'react-native';\nconst os = Platform.OS;", + {platform: null, experimentalImportSupport}, + ); + + expect(code).toMatch(/\.OS\b/); + }); + }, +); + +test('the two import-support modes really do produce different output', () => { + // Guards the test setup itself: without a module reset between variants the + // memoized config leaks and the parameterized suite above would silently run + // the same configuration twice. + const src = "import {Platform} from 'react-native';\nconst x = Other.thing;"; + + const lowered = transformToCode(src, {experimentalImportSupport: false}); + jest.resetModules(); + const preserved = transformToCode(src, {experimentalImportSupport: true}); + + expect(lowered).toContain('require'); + expect(preserved).toContain('import'); + expect(preserved).not.toContain('require'); +}); + +test('inlines before the preset lowers ESM to CommonJS interop', () => { + // Guards the ordering contract: if the plugin ran after the RN preset's + // import transform, it would see `_reactNative.Platform.OS` and the + // specifier proving RN provenance would be gone. + const code = transformToCode( + "import {Platform} from 'react-native';\nconst os = Platform.OS;", + {experimentalImportSupport: false}, + ); + + expect(code).toContain('require'); + expect(code).toContain('"ios"'); + expect(code).not.toMatch(/_reactNative\.Platform/); +});