From 29089f3f88a14025649b337b277871b3e23c116d Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:31:16 +0200 Subject: [PATCH] feat: Adapt an inline-plugin for more cases --- .../src/__mocks__/test-helpers.js | 19 +- .../src/__tests__/inline-plugin-test.js | 196 ++++++++++++++++++ .../src/inline-plugin.js | 8 +- .../src/utils/createInlinePlatformChecks.js | 152 +++++++++++--- .../types/inline-plugin.d.ts | 4 +- .../utils/createInlinePlatformChecks.d.ts | 4 +- 6 files changed, 339 insertions(+), 44 deletions(-) diff --git a/packages/metro-transform-plugins/src/__mocks__/test-helpers.js b/packages/metro-transform-plugins/src/__mocks__/test-helpers.js index 785dbdf50d..05e75ab994 100644 --- a/packages/metro-transform-plugins/src/__mocks__/test-helpers.js +++ b/packages/metro-transform-plugins/src/__mocks__/test-helpers.js @@ -20,7 +20,8 @@ const nullthrows = require('nullthrows'); function makeTransformOptions( plugins: ReadonlyArray, - options: OptionsT, + pluginOptions: OptionsT, + babelOptions?: BabelCoreOptions, ): BabelCoreOptions { return { ast: true, @@ -30,9 +31,12 @@ function makeTransformOptions( compact: true, configFile: false, plugins: plugins.length - ? plugins.map(plugin => [plugin, options]) + ? plugins.map(plugin => [plugin, pluginOptions]) : [() => ({visitor: {}})], sourceType: 'module', + filename: + '/Users/test/app/node_modules/react-native/Libraries/Components/Pressable/useAndroidRippleForView.js', + ...babelOptions, }; } @@ -55,10 +59,11 @@ function transformToAst( plugins: ReadonlyArray, code: string, options: T, + babelOptions?: BabelCoreOptions, ): BabelNodeFile { const transformResult = transformSync( code, - makeTransformOptions(plugins, options), + makeTransformOptions(plugins, options, babelOptions), ); const ast = nullthrows(transformResult.ast); validateOutputAst(ast); @@ -69,8 +74,9 @@ function transform( code: string, plugins: ReadonlyArray, options: ?EntryOptions, + babelOptions?: BabelCoreOptions, ) { - return generate(transformToAst(plugins, code, options)).code; + return generate(transformToAst(plugins, code, options, babelOptions)).code; } exports.compare = function ( @@ -78,8 +84,11 @@ exports.compare = function ( code: string, expected: string, options: ?EntryOptions = {}, + babelOptions?: BabelCoreOptions, ) { - expect(transform(code, plugins, options)).toBe(transform(expected, [], {})); + expect(transform(code, plugins, options, babelOptions)).toBe( + transform(expected, [], {}), + ); }; exports.transformToAst = transformToAst; diff --git a/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js index a1b1010354..1fed423188 100644 --- a/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js @@ -934,4 +934,200 @@ describe('inline constants', () => { compare([stripFlow, inlinePlugin], code, expected, {dev: false}); }); + + test('replaces Platform.OS in the code if Platform is a top level relative Node.js require()', () => { + // Source code before `@react-native/babel-preset`: + // ``` + // const Platform = require('../../Utilities/Platform').default; + // ``` + const code = ` + var Platform = require('../../Utilities/Platform').default; + var test = Platform.OS === 'ios' ? 'ios' : 'not-ios'; + `; + + compare([inlinePlugin], code, code.replace('Platform.OS', '"android"'), { + inlinePlatform: true, + platform: 'android', + }); + }); + + test('replaces Platform.OS in the code if Platform is a top level relative ES import', () => { + // Source code before `@react-native/babel-preset`: + // ``` + // import Platform from '../../Utilities/Platform'; + // ``` + const code = ` + var _Platform = _interopRequireDefault(require("../../Utilities/Platform")); + var test = _Platform.default.OS === 'ios' ? 'ios' : 'not-ios'; + `; + + compare( + [inlinePlugin], + code, + code.replace('_Platform.default.OS', '"android"'), + { + inlinePlatform: true, + platform: 'android', + }, + ); + }); + + test('should not replace Platform.OS in the code if Platform was exported from unexpected place', () => { + // Source code before `@react-native/babel-preset`: + // ``` + // import Platform from '../../diff/lib/name.js'; + // ``` + const code = ` + var _Platform = _interopRequireDefault(require("../../diff/lib/name.js")); + var test = _Platform.default.OS === 'ios' ? 'ios' : 'not-ios'; + `; + + compare([inlinePlugin], code, code, { + inlinePlatform: true, + platform: 'android', + }); + }); + + test('replaces Platform.OS in the code if babel helper wrap require call', () => { + // Source code before `@react-native/babel-preset`: + // ``` + // import * as RN from 'react-native'; + // ``` + const code = ` + var RN = _interopRequireWildcard(require('react-native')); + var test = RN.Platform.OS === 'ios' ? 'ios' : 'not-ios'; + `; + + compare([inlinePlugin], code, code.replace('RN.Platform.OS', '"android"'), { + inlinePlatform: 'true', + platform: 'android', + }); + }); + + test('replaces Platform.select in the code if Platform is a top level relative Node.js require()', () => { + // Source code before `@react-native/babel-preset`: + // ``` + // const Platform = require('../../Utilities/Platform').default; + // ``` + const code = ` + var Platform = require('../../Utilities/Platform').default; + + function a() { + Platform.select({ios: 1, android: 2}); + var b = a.Platform.select({}); + } + `; + + compare([inlinePlugin], code, code.replace(/Platform\.select[^;]+/, '2'), { + inlinePlatform: 'true', + platform: 'android', + }); + }); + + test('replaces Platform.select in the code if Platform is a top level relative ES import', () => { + // Source code before `@react-native/babel-preset`: + // ``` + // import Platform from '../../Utilities/Platform'; + // ``` + const code = ` + var _Platform = _interopRequireDefault(require("../../Utilities/Platform")); + + function a() { + _Platform.default.select({ios: 1, android: 2}); + var b = a.Platform.select({}); + } + `; + + compare( + [inlinePlugin], + code, + code.replace(/_Platform\.default\.select[^;]+/, '2'), + { + inlinePlatform: 'true', + platform: 'android', + }, + ); + }); + + test('should not replace Platform.select in the code if Platform was imported from non react-native package files', () => { + // Source code before `@react-native/babel-preset`: + // ``` + // import Platform from '../../Utilities/Platform'; + // ``` + const code = ` + var _Platform = _interopRequireDefault(require("../Platform")); + + function a() { + _Platform.default.select({ios: 1, android: 2}); + var b = a.Platform.select({}); + } + `; + + compare( + [inlinePlugin], + code, + code, + { + inlinePlatform: 'true', + platform: 'android', + }, + { + filename: '/Users/test/app/src/myCode.js', + }, + ); + }); + + test('replaces Platform.select in the code if Platform is a top level relative ES import on Windows', () => { + // Source code before `@react-native/babel-preset`: + // ``` + // import Platform from '../../Utilities/Platform'; + // ``` + const code = ` + var _Platform = _interopRequireDefault(require("../../Utilities/Platform")); + + function a() { + _Platform.default.select({ios: 1, android: 2}); + var b = a.Platform.select({}); + } + `; + + compare( + [inlinePlugin], + code, + code.replace(/_Platform\.default\.select[^;]+/, '2'), + { + inlinePlatform: 'true', + platform: 'android', + }, + { + filename: + 'C:\\Users\\test\\app\\node_modules\\react-native\\Libraries\\Components\\Pressable\\useAndroidRippleForView.js', + }, + ); + }); + + test('replaces Platform.select in the code if babel helper wrap require call', () => { + // Source code before `@react-native/babel-preset`: + // ``` + // import * as RN from 'react-native'; + // ``` + const code = ` + var RN = _interopRequireWildcard(require('react-native')); + + function a() { + RN.Platform.select({ios: 1, android: 2}); + var b = a.Platform.select({}); + } + `; + + compare( + [inlinePlugin], + code, + code.replace(/RN\.Platform\.select[^;]+/, '2'), + { + inlinePlatform: 'true', + platform: 'android', + }, + ); + }); }); diff --git a/packages/metro-transform-plugins/src/inline-plugin.js b/packages/metro-transform-plugins/src/inline-plugin.js index 2cc055d260..a25c3316b2 100644 --- a/packages/metro-transform-plugins/src/inline-plugin.js +++ b/packages/metro-transform-plugins/src/inline-plugin.js @@ -31,7 +31,7 @@ export type Options = Readonly<{ platform: string, }>; -type State = {opts: Options}; +type State = {opts: Options, filename?: string}; const env = {name: 'env'}; const nodeEnv = {name: 'NODE_ENV'}; @@ -137,11 +137,12 @@ export default function inlinePlugin( const node = path.node; const scope = path.scope; const opts = state.opts; + const filename = state.filename; if (!isLeftHandSideOfAssignmentExpression(node, path.parent)) { if ( opts.inlinePlatform && - isPlatformNode(node, scope, !!opts.isWrapped) + isPlatformNode(node, scope, !!opts.isWrapped, filename) ) { path.replaceWith(t.stringLiteral(opts.platform)); } else if (!opts.dev && isProcessEnvNodeEnv(node, scope)) { @@ -156,10 +157,11 @@ export default function inlinePlugin( const scope = path.scope; const arg = node.arguments[0]; const opts = state.opts; + const filename = state.filename; if ( opts.inlinePlatform && - isPlatformSelectNode(node, scope, !!opts.isWrapped) && + isPlatformSelectNode(node, scope, !!opts.isWrapped, filename) && isObjectExpression(arg) ) { if (hasStaticProperties(arg)) { diff --git a/packages/metro-transform-plugins/src/utils/createInlinePlatformChecks.js b/packages/metro-transform-plugins/src/utils/createInlinePlatformChecks.js index 86121f16fa..0b5606c796 100644 --- a/packages/metro-transform-plugins/src/utils/createInlinePlatformChecks.js +++ b/packages/metro-transform-plugins/src/utils/createInlinePlatformChecks.js @@ -15,21 +15,40 @@ import type {CallExpression, MemberExpression} from '@babel/types'; // eslint-disable-next-line import/no-extraneous-dependencies import typeof * as Types from '@babel/types'; -const importMap = new Map([['ReactNative', 'react-native']]); +const PLATFORM_MODULE_RELATIVE_IMPORTS = /^(\.\.?\/).*Platform(\.js)?$/; + +const allowedPlatformImports = [ + // 1. ES imports: `import {Platform} from 'react-native'` + (importSource: string): boolean => importSource === 'react-native', + // 2. Relative imports inside react-native package: `import Platform from '../../Utilities/Platform'` + (importSource: string): boolean => + PLATFORM_MODULE_RELATIVE_IMPORTS.test(importSource), + // 3. Haste modules `require('Platform')` + (importSource: string): boolean => importSource === 'Platform', + // 4. Exotic imports `require('React').Platform` + (importSource: string): boolean => importSource === 'React', +]; type PlatformChecks = { isPlatformNode: ( node: MemberExpression, scope: Scope, isWrappedModule: boolean, + filename?: string, ) => boolean, isPlatformSelectNode: ( node: CallExpression, scope: Scope, isWrappedModule: boolean, + filename?: string, ) => boolean, }; +const REACT_NATIVE_MODULES_REGEX = /[\/\\]node_modules[\/\\]react-native[\/\\]/; + +const isReactNativeFile = (filename?: string): boolean => + filename != null && REACT_NATIVE_MODULES_REGEX.test(filename); + export default function createInlinePlatformChecks( t: Types, requireName: string = 'require', @@ -45,32 +64,43 @@ export default function createInlinePlatformChecks( node: MemberExpression, scope: Scope, isWrappedModule: boolean, + filename?: string, ): boolean => - isPlatformOS(node, scope, isWrappedModule) || - isReactPlatformOS(node, scope, isWrappedModule); + isIdentifier(node.property, {name: 'OS'}) && + (isPlatformOS(node, scope, isWrappedModule) || + isReactPlatformOS(node, scope, isWrappedModule) || + isPlatformDefaultOS(node, scope, isWrappedModule, filename)); const isPlatformSelectNode = ( node: CallExpression, scope: Scope, isWrappedModule: boolean, + filename?: string, ): boolean => - isPlatformSelect(node, scope, isWrappedModule) || - isReactPlatformSelect(node, scope, isWrappedModule); + isMemberExpression(node.callee) && + isIdentifier(node.callee.property, {name: 'select'}) && + (isPlatformSelect(node, scope, isWrappedModule) || + isReactPlatformSelect(node, scope, isWrappedModule) || + isPlatformDefaultSelect(node, scope, isWrappedModule, filename)); + /** + * Platform.OS + */ const isPlatformOS = ( node: MemberExpression, scope: Scope, isWrappedModule: boolean, ): boolean => - isIdentifier(node.property, {name: 'OS'}) && isImportOrGlobal(node.object, scope, [{name: 'Platform'}], isWrappedModule); + /** + * React.Platform.OS + */ const isReactPlatformOS = ( node: MemberExpression, scope: Scope, isWrappedModule: boolean, ): boolean => - isIdentifier(node.property, {name: 'OS'}) && isMemberExpression(node.object) && isIdentifier(node.object.property, {name: 'Platform'}) && isImportOrGlobal( @@ -81,13 +111,35 @@ export default function createInlinePlatformChecks( isWrappedModule, ); + /** + * `_Platform.default.OS` + */ + const isPlatformDefaultOS = ( + node: MemberExpression, + scope: Scope, + isWrappedModule: boolean, + filename?: string, + ): boolean => + isReactNativeFile(filename) && + isMemberExpression(node.object) && + isIdentifier(node.object.property, {name: 'default'}) && + isIdentifier(node.object.object, {name: '_Platform'}) && + isImportOrGlobal( + // $FlowFixMe[incompatible-type] + node.object.object, + scope, + [], + isWrappedModule, + ); + + /** + * Platform.select(...) + */ const isPlatformSelect = ( node: CallExpression, scope: Scope, isWrappedModule: boolean, ): boolean => - isMemberExpression(node.callee) && - isIdentifier(node.callee.property, {name: 'select'}) && isImportOrGlobal( // $FlowFixMe[incompatible-type] node.callee.object, @@ -96,13 +148,14 @@ export default function createInlinePlatformChecks( isWrappedModule, ); + /** + * React.Platform.select(...) + */ const isReactPlatformSelect = ( node: CallExpression, scope: Scope, isWrappedModule: boolean, ): boolean => - isMemberExpression(node.callee) && - isIdentifier(node.callee.property, {name: 'select'}) && isMemberExpression(node.callee.object) && isIdentifier(node.callee.object.property, {name: 'Platform'}) && isImportOrGlobal( @@ -114,24 +167,51 @@ export default function createInlinePlatformChecks( isWrappedModule, ); - const isRequireCall = ( - node: BabelNodeExpression, - dependencyId: string, + /** + * _Platform.default.select(...) + */ + const isPlatformDefaultSelect = ( + node: CallExpression, scope: Scope, + isWrappedModule: boolean, + filename?: string, ): boolean => - isCallExpression(node) && - isIdentifier(node.callee, {name: requireName}) && - checkRequireArgs(node.arguments, dependencyId); + isReactNativeFile(filename) && + isMemberExpression(node.callee.object) && + isIdentifier(node.callee.object.property, {name: 'default'}) && + // $FlowFixMe[incompatible-type] + // $FlowFixMe[incompatible-use] + isIdentifier(node.callee.object.object, {name: '_Platform'}) && + isImportOrGlobal( + // $FlowFixMe[incompatible-type] + // $FlowFixMe[incompatible-use] + node.callee.object.object, + scope, + [], + isWrappedModule, + ); - const isImport = ( - node: BabelNodeExpression, - scope: Scope, - patterns: Array<{name: string}>, - ): boolean => - patterns.some((pattern: {name: string}) => { - const importName = importMap.get(pattern.name) || pattern.name; - return isRequireCall(node, importName, scope); - }); + const isRequireCall = (node: BabelNodeExpression): boolean => + // 1. Simple case: `require('react-native')` + (isCallExpression(node) && + isIdentifier(node.callee, {name: requireName}) && + checkRequireArgs(node.arguments)) || + // 2. Require a babel helpers + // ``` + // // Before + // import Platform from '../Platform'; + // import * as RN from 'react-native'; + // // After + // var _Platform = _interopRequireDefault(require('../Platform')); + // var RN = _interopRequireWildcard(require('react-native')); + // ``` + ((isIdentifier(node.callee, {name: '_interopRequireDefault'}) || + isIdentifier(node.callee, {name: '_interopRequireWildcard'})) && + // $FlowFixMe[incompatible-type] + // $FlowFixMe[incompatible-use] + isRequireCall(node.arguments[0])); + + const isImport = (node: BabelNodeExpression): boolean => isRequireCall(node); const isImportOrGlobal = ( node: BabelNodeExpression, @@ -143,12 +223,12 @@ export default function createInlinePlatformChecks( isIdentifier(node, pattern), ); if ( - !!identifier && + identifier != null && isToplevelBinding(scope.getBinding(identifier.name), isWrappedModule) ) { return true; } - if (isImport(node, scope, patterns)) { + if (isImport(node)) { return true; } if (isIdentifier(node)) { @@ -160,7 +240,7 @@ export default function createInlinePlatformChecks( ) { const init = binding.path.node.init; // $FlowFixMe[incompatible-type] Flow doesn't narrow binding.path.node through isVariableDeclarator() - if (init != null && isImport(init, scope, patterns)) { + if (init != null && isImport(init)) { return true; } } @@ -174,14 +254,20 @@ export default function createInlinePlatformChecks( | BabelNodeSpreadElement | BabelNodeArgumentPlaceholder, >, - dependencyId: string, ): boolean => { - const pattern = t.stringLiteral(dependencyId); return ( - isStringLiteral(args[0], pattern) || + // Basic case: `require('')` + (isStringLiteral(args[0]) && + allowedPlatformImports.some( + check => typeof args[0].value === 'string' && check(args[0].value), + )) || + // Transformed require calls: `require(arbitraryMapName[321], '')` (isMemberExpression(args[0]) && isNumericLiteral(args[0].property) && - isStringLiteral(args[1], pattern)) + isStringLiteral(args[1]) && + allowedPlatformImports.some( + check => typeof args[1].value === 'string' && check(args[1].value), + )) ); }; diff --git a/packages/metro-transform-plugins/types/inline-plugin.d.ts b/packages/metro-transform-plugins/types/inline-plugin.d.ts index 30caa0ae52..87246c97d2 100644 --- a/packages/metro-transform-plugins/types/inline-plugin.d.ts +++ b/packages/metro-transform-plugins/types/inline-plugin.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<0a0f52c4e23d8cd25d04b2d46a09e480>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-transform-plugins/src/inline-plugin.js @@ -26,7 +26,7 @@ export type Options = Readonly<{ requireName?: string; platform: string; }>; -type State = {opts: Options}; +type State = {opts: Options; filename?: string}; declare function inlinePlugin( $$PARAM_0$$: {types: Types}, options: Options, diff --git a/packages/metro-transform-plugins/types/utils/createInlinePlatformChecks.d.ts b/packages/metro-transform-plugins/types/utils/createInlinePlatformChecks.d.ts index 819cc274c5..33ac44b64a 100644 --- a/packages/metro-transform-plugins/types/utils/createInlinePlatformChecks.d.ts +++ b/packages/metro-transform-plugins/types/utils/createInlinePlatformChecks.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<13269e5dcf93e0b31428517812e3bb88>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-transform-plugins/src/utils/createInlinePlatformChecks.js @@ -25,11 +25,13 @@ type PlatformChecks = { node: MemberExpression, scope: Scope, isWrappedModule: boolean, + filename?: string, ) => boolean; isPlatformSelectNode: ( node: CallExpression, scope: Scope, isWrappedModule: boolean, + filename?: string, ) => boolean; }; declare function createInlinePlatformChecks(