Skip to content
Open
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
19 changes: 14 additions & 5 deletions packages/metro-transform-plugins/src/__mocks__/test-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const nullthrows = require('nullthrows');

function makeTransformOptions<OptionsT extends ?EntryOptions>(
plugins: ReadonlyArray<PluginEntry>,
options: OptionsT,
pluginOptions: OptionsT,
babelOptions?: BabelCoreOptions,
): BabelCoreOptions {
return {
ast: true,
Expand All @@ -30,9 +31,12 @@ function makeTransformOptions<OptionsT extends ?EntryOptions>(
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,
};
}

Expand All @@ -55,10 +59,11 @@ function transformToAst<T extends ?EntryOptions>(
plugins: ReadonlyArray<PluginEntry>,
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);
Expand All @@ -69,17 +74,21 @@ function transform(
code: string,
plugins: ReadonlyArray<PluginEntry>,
options: ?EntryOptions,
babelOptions?: BabelCoreOptions,
) {
return generate(transformToAst(plugins, code, options)).code;
return generate(transformToAst(plugins, code, options, babelOptions)).code;
}

exports.compare = function (
plugins: ReadonlyArray<PluginEntry>,
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;
101 changes: 101 additions & 0 deletions packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -934,4 +934,105 @@ 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('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('replaces Platform.select in the code if Platform is a top level relative ES import on Window', () => {
// 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',
},
);
});
});
8 changes: 5 additions & 3 deletions packages/metro-transform-plugins/src/inline-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'};
Expand Down Expand Up @@ -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)) {
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,21 @@ type PlatformChecks = {
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',
Expand All @@ -45,30 +52,40 @@ export default function createInlinePlatformChecks(
node: MemberExpression,
scope: Scope,
isWrappedModule: boolean,
filename?: string,
): boolean =>
isPlatformOS(node, scope, isWrappedModule) ||
isReactPlatformOS(node, scope, isWrappedModule);
isPlatformOS(node, scope, isWrappedModule, filename) ||
isReactPlatformOS(node, scope, isWrappedModule, filename);

const isPlatformSelectNode = (
node: CallExpression,
scope: Scope,
isWrappedModule: boolean,
filename?: string,
): boolean =>
isPlatformSelect(node, scope, isWrappedModule) ||
isReactPlatformSelect(node, scope, isWrappedModule);
isPlatformSelect(node, scope, isWrappedModule, filename) ||
isReactPlatformSelect(node, scope, isWrappedModule, filename);

const isPlatformOS = (
node: MemberExpression,
scope: Scope,
isWrappedModule: boolean,
filename?: string,
): boolean =>
isIdentifier(node.property, {name: 'OS'}) &&
isImportOrGlobal(node.object, scope, [{name: 'Platform'}], isWrappedModule);
isImportOrGlobal(
node.object,
scope,
[{name: 'Platform'}],
isWrappedModule,
filename,
);

const isReactPlatformOS = (
node: MemberExpression,
scope: Scope,
isWrappedModule: boolean,
filename?: string,
): boolean =>
isIdentifier(node.property, {name: 'OS'}) &&
isMemberExpression(node.object) &&
Expand All @@ -79,12 +96,14 @@ export default function createInlinePlatformChecks(
scope,
[{name: 'React'}, {name: 'ReactNative'}],
isWrappedModule,
filename,
);

const isPlatformSelect = (
node: CallExpression,
scope: Scope,
isWrappedModule: boolean,
filename?: string,
): boolean =>
isMemberExpression(node.callee) &&
isIdentifier(node.callee.property, {name: 'select'}) &&
Expand All @@ -94,12 +113,14 @@ export default function createInlinePlatformChecks(
scope,
[{name: 'Platform'}],
isWrappedModule,
filename,
);

const isReactPlatformSelect = (
node: CallExpression,
scope: Scope,
isWrappedModule: boolean,
filename?: string,
): boolean =>
isMemberExpression(node.callee) &&
isIdentifier(node.callee.property, {name: 'select'}) &&
Expand All @@ -112,6 +133,7 @@ export default function createInlinePlatformChecks(
scope,
[{name: 'React'}, {name: 'ReactNative'}],
isWrappedModule,
filename,
);

const isRequireCall = (
Expand All @@ -138,6 +160,7 @@ export default function createInlinePlatformChecks(
scope: Scope,
patterns: Array<{name: string}>,
isWrappedModule: boolean,
filename?: string,
): boolean => {
const identifier = patterns.find((pattern: {name: string}) =>
isIdentifier(node, pattern),
Expand All @@ -148,6 +171,38 @@ export default function createInlinePlatformChecks(
) {
return true;
}
// Special case for handling transformed relative ES imports:
// Works only for RN files: `*/node_modules/react-native/**/*`
//
// ```tsx
// 1. Source code
// import Platform from '../../Utilities/Platform';
// const test = Platform.OS === 'ios' ? 1 : 2;
//
// 2. After `@react-native/babel-preset`
// var _Platform = _interopRequireDefault(require("../../Utilities/Platform"));
// var test = _Platform.default.OS === 'ios' ? 1 : 2;
// ```
if (
isReactNativeFile(filename) &&
!identifier &&
isMemberExpression(node)
) {
const objIdentifier = patterns.find((pattern: {name: string}) =>
isIdentifier(node.object, {name: `_${pattern.name}`}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't assume the _ prefix. Instead, can we trace whether the variable came from an _interopRequireDefault expression?

@retyui retyui Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to add prefix as isImportOrGlobal is a common util that uses provided patterns [{name: 'Platform'}] to find Platform identifiers that were renamed

var test = _Platform.default.OS === 'ios' ? 'ios' : 'not-ios';
                // ^^^ isImportOrGlobal(..., [{name: 'Platform'}]) // won't works 

stacktrace:

<MemberExpression|CallExpression>(path: NodePath, state: State)
  isPlatformNode(node, scope, !!opts.isWrapped)
    isPlatformOS(node, scope, isWrappedModule)
      isImportOrGlobal(node.object, scope, [{name: 'Platform'}], isWrappedModule);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can't just look at the variable name, you need to trace where it was bound, what import it resolved, and whether that matches the known path for the react-native Platform module.

Don't really need to know whether you're inside node_modules/react-native or not, you just need to evaluate the relative path.

@retyui retyui Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you right, but current implementation doesn't do it too, it's just search for Platform.OS or Platform.select() expressions and that a Platform identifier has a top level binding

var MyPlatform = require('react-native').Platform;
MyPlatform.select({ios: 1, android: 2});
// ^^^ if you change the variable name, plugin won'r work

);

if (
objIdentifier &&
isToplevelBinding(
scope.getBinding(`_${objIdentifier.name}`),
isWrappedModule,
) &&
isIdentifier(node.property, {name: 'default'})
) {
return true;
}
}
if (isImport(node, scope, patterns)) {
return true;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/metro-transform-plugins/types/inline-plugin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*
* @noformat
* @oncall react_native
* @generated SignedSource<<0a0f52c4e23d8cd25d04b2d46a09e480>>
* @generated SignedSource<<b359d860825de0cb738f9042eb9a0086>>
*
* This file was translated from Flow by scripts/generateTypeScriptDefinitions.js
* Original file: packages/metro-transform-plugins/src/inline-plugin.js
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*
* @noformat
* @oncall react_native
* @generated SignedSource<<13269e5dcf93e0b31428517812e3bb88>>
* @generated SignedSource<<ecf4ea083ce738b6e360a87e0bfccc20>>
*
* This file was translated from Flow by scripts/generateTypeScriptDefinitions.js
* Original file: packages/metro-transform-plugins/src/utils/createInlinePlatformChecks.js
Expand All @@ -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(
Expand Down
Loading