From 00df4ef78f2dc27337feff769db8431b6e1f57e9 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Tue, 3 Sep 2024 05:35:53 -0700 Subject: [PATCH] Inline requires: Flow-type plugin and tests Summary: Ahead of making some changes/enhancements to `inline-requires-plugin`, make it type-safe. Changelog: Internal Reviewed By: vzaidman Differential Revision: D61869704 fbshipit-source-id: 8d61d22fcffd66a28d4860dc596d4b22f685f977 --- flow-typed/npm/babel-plugin-tester_v6.x.x.js | 37 ++++ .../__tests__/inline-requires-plugin-test.js | 35 ++- .../src/__tests__/validateOutputAst.js | 17 +- .../src/inline-requires-plugin.js | 208 +++++++++++++----- 4 files changed, 222 insertions(+), 75 deletions(-) create mode 100644 flow-typed/npm/babel-plugin-tester_v6.x.x.js diff --git a/flow-typed/npm/babel-plugin-tester_v6.x.x.js b/flow-typed/npm/babel-plugin-tester_v6.x.x.js new file mode 100644 index 0000000000..b039ec0035 --- /dev/null +++ b/flow-typed/npm/babel-plugin-tester_v6.x.x.js @@ -0,0 +1,37 @@ +/** + * 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 + */ + +// Partial typings for babel-plugin-tester. Add APIs as you need them. + +declare module 'babel-plugin-tester' { + import typeof * as Babel from '@babel/core'; + import type {BabelCoreOptions, PluginObj} from '@babel/core'; + + declare type PluginTesterOptions = { + babelOptions?: BabelCoreOptions, + plugin: (babel: Babel) => PluginObj, + pluginOptions?: TOpts, + tests: $ReadOnly<{ + [title: string]: $ReadOnly<{ + code: string, + output?: string, + error?: string, + snapshot?: boolean, + ... + }>, + }>, + }; + + declare function pluginTester( + opts: PluginTesterOptions, + ): void; + + declare module.exports: typeof pluginTester; +} diff --git a/packages/metro-transform-plugins/src/__tests__/inline-requires-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/inline-requires-plugin-test.js index d3f6541a92..b1cfb244fd 100644 --- a/packages/metro-transform-plugins/src/__tests__/inline-requires-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/inline-requires-plugin-test.js @@ -5,18 +5,20 @@ * LICENSE file in the root directory of this source tree. * * @format + * @flow strict-local */ -/* eslint-disable max-len */ - 'use strict'; +import type {PluginOptions, State} from '../inline-requires-plugin'; + const inlineRequiresPlugin = require('../inline-requires-plugin'); const validateOutputAst = require('./validateOutputAst'); const babel = require('@babel/core'); const pluginTester = require('babel-plugin-tester'); +const nullthrows = require('nullthrows'); -pluginTester({ +pluginTester({ babelOptions: { babelrc: false, configFile: false, @@ -246,17 +248,25 @@ pluginTester({ }); describe('inline-requires', () => { - const transform = (source, options) => - babel.transform(source.join('\n'), { + const transform = ( + source: $ReadOnlyArray, + options?: $ReadOnly<{...}>, + ) => + babel.transformSync(source.join('\n'), { ast: true, compact: true, plugins: [ + // $FlowFixMe[untyped-import] @babel/plugin-transform-modules-commonjs [require('@babel/plugin-transform-modules-commonjs'), {strict: false}], [inlineRequiresPlugin, options], ], }); - const compare = (input, output, options) => { + const compare = ( + input: $ReadOnlyArray, + output: $ReadOnlyArray, + options?: $ReadOnly<{...}>, + ) => { expect(transform(input, options).code).toBe( transform(output, options).code, ); @@ -293,16 +303,19 @@ describe('inline-requires', () => { it('should remove loc information from nodes', function () { const ast = transform(['var x = require("x"); x']).ast; - const expression = ast.program.body[0].expression; + expect(ast).not.toBeNull(); + const expression = nullthrows(ast).program.body[0].expression; - function expectNoLocation(node) { + function expectNodeWithNoLocation(maybeNode: ?BabelNode) { + expect(maybeNode).not.toBeNull(); + const node = nullthrows(maybeNode); expect(node.start).toBeUndefined(); expect(node.end).toBeUndefined(); expect(node.loc).toBeUndefined(); } - expectNoLocation(expression); - expectNoLocation(expression.arguments[0]); + expectNodeWithNoLocation(expression); + expectNodeWithNoLocation(nullthrows(expression?.arguments)[0]); }); it('should not emit duplicate nodes', function () { @@ -311,6 +324,6 @@ describe('inline-requires', () => { 'foo.bar()', 'foo.baz()', ]).ast; - validateOutputAst(ast); + validateOutputAst(nullthrows(ast)); }); }); diff --git a/packages/metro-transform-plugins/src/__tests__/validateOutputAst.js b/packages/metro-transform-plugins/src/__tests__/validateOutputAst.js index 85f6f948d3..ce723f8d30 100644 --- a/packages/metro-transform-plugins/src/__tests__/validateOutputAst.js +++ b/packages/metro-transform-plugins/src/__tests__/validateOutputAst.js @@ -3,19 +3,24 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict */ 'use strict'; const t = require('@babel/types'); -module.exports = function validateOutputAst(ast) { - const seenNodes = new Set(); +module.exports = function validateOutputAst(ast: BabelNode) { + const seenNodes = new Set(); t.traverseFast(ast, function enter(node) { if (seenNodes.has(node)) { - throw new Error('Found a duplicate node in the output, which can cause' - + ' undefined behavior in Babel.'); + throw new Error( + 'Found a duplicate node in the output, which can cause' + + ' undefined behavior in Babel.', + ); } seenNodes.add(node); - }) -} + }); +}; diff --git a/packages/metro-transform-plugins/src/inline-requires-plugin.js b/packages/metro-transform-plugins/src/inline-requires-plugin.js index eef6a06588..97e3ae7821 100644 --- a/packages/metro-transform-plugins/src/inline-requires-plugin.js +++ b/packages/metro-transform-plugins/src/inline-requires-plugin.js @@ -5,10 +5,29 @@ * LICENSE file in the root directory of this source tree. * * @format + * @flow strict */ 'use strict'; +import type {PluginObj} from '@babel/core'; +import typeof * as Babel from '@babel/core'; +import type {NodePath} from '@babel/traverse'; +import type {Program} from '@babel/types'; + +export type PluginOptions = $ReadOnly<{ + ignoredRequires?: $ReadOnlyArray, + inlineableCalls?: $ReadOnlyArray, +}>; + +export type State = { + opts?: PluginOptions, + ignoredRequires: Set, + inlineableCalls: Set, + membersAssigned: Map>, + ... +}; + /** * This transform inlines top-level require(...) aliases with to enable lazy * loading of dependencies. It is able to inline both single references and @@ -36,30 +55,31 @@ * Is also successfully inlined into: * g(require('foo').Baz); */ -module.exports = babel => ({ +module.exports = ({types: t, traverse}: Babel): PluginObj => ({ name: 'inline-requires', visitor: { Program: { - exit(path, state) { - const t = babel.types; - const ignoredRequires = new Set(); + enter() {}, + exit(path: NodePath, state: State): void { + const ignoredRequires = new Set(); const inlineableCalls = new Set(['require']); + const opts = state.opts; - if (state.opts != null) { - if (state.opts.ignoredRequires != null) { - for (const name of state.opts.ignoredRequires) { + if (opts != null) { + if (opts.ignoredRequires != null) { + for (const name of opts.ignoredRequires) { ignoredRequires.add(name); } } - if (state.opts.inlineableCalls != null) { - for (const name of state.opts.inlineableCalls) { + if (opts.inlineableCalls != null) { + for (const name of opts.inlineableCalls) { inlineableCalls.add(name); } } } path.scope.crawl(); - path.traverse( + path.traverse( { CallExpression(path, state) { const parseResult = @@ -76,17 +96,22 @@ module.exports = babel => ({ ? declarationPath.node.id.name : null; - const binding = declarationPath.scope.getBinding(name); - if (binding.constantViolations.length > 0) { + const binding = + name == null ? null : declarationPath.scope.getBinding(name); + if (binding == null || binding.constantViolations.length > 0) { + return; + } + + const initPath = declarationPath.get('init'); + + if (init == null || Array.isArray(initPath)) { return; } - const initLoc = getNearestLocFromPath( - declarationPath.get('init'), - ); + const initLoc = getNearestLocFromPath(initPath); deleteLocation(init); - babel.traverse(init, { + traverse(init, { noScope: true, enter: path => deleteLocation(path.node), }); @@ -97,6 +122,7 @@ module.exports = babel => ({ try { referencePath.scope.rename(requireFnName); const refExpr = t.cloneDeep(init); + // $FlowFixMe[prop-missing] refExpr.METRO_INLINE_REQUIRES_INIT_LOC = initLoc; referencePath.replaceWith(refExpr); } catch (error) { @@ -122,18 +148,23 @@ module.exports = babel => ({ }, }); -function excludeMemberAssignment(moduleName, referencePath, state) { - const assignment = referencePath.parentPath.parent; +function excludeMemberAssignment( + moduleName: string, + referencePath: NodePath<>, + state: State, +) { + const assignment: ?BabelNode = referencePath.parentPath?.parent; - const isValid = - assignment.type === 'AssignmentExpression' && - assignment.left.type === 'MemberExpression' && - assignment.left.object === referencePath.node; - if (!isValid) { + if (assignment?.type !== 'AssignmentExpression') { return; } - const memberPropertyName = getMemberPropertyName(assignment.left); + const left = assignment.left; + if (left.type !== 'MemberExpression' || left.object !== referencePath.node) { + return; + } + + const memberPropertyName = getMemberPropertyName(left); if (memberPropertyName == null) { return; } @@ -146,15 +177,16 @@ function excludeMemberAssignment(moduleName, referencePath, state) { membersAssigned.add(memberPropertyName); } -function isExcludedMemberAssignment(moduleName, memberPropertyName, state) { +function isExcludedMemberAssignment( + moduleName: string, + memberPropertyName: string, + state: State, +) { const excludedAliases = state.membersAssigned.get(moduleName); return excludedAliases != null && excludedAliases.has(memberPropertyName); } -function getMemberPropertyName(node) { - if (node.type !== 'MemberExpression') { - return null; - } +function getMemberPropertyName(node: BabelNodeMemberExpression): ?string { if (node.property.type === 'Identifier') { return node.property.name; } @@ -164,62 +196,113 @@ function getMemberPropertyName(node) { return null; } -function deleteLocation(node) { +function deleteLocation(node: BabelNode) { delete node.start; delete node.end; delete node.loc; } -function parseInlineableAlias(path, state) { +function parseInlineableAlias( + path: NodePath, + state: State, +): ?{ + declarationPath: NodePath, + moduleName: string, + requireFnName: string, +} { const module = getInlineableModule(path, state); if (module == null) { return null; } const {moduleName, requireFnName} = module; + const parentPath = path.parentPath; + if (parentPath == null) { + return null; + } + const grandParentPath = parentPath.parentPath; + if (grandParentPath == null) { + return null; + } + const isValid = path.parent.type === 'VariableDeclarator' && path.parent.id.type === 'Identifier' && - path.parentPath.parent.type === 'VariableDeclaration' && - path.parentPath.parentPath.parent.type === 'Program'; + parentPath.parent.type === 'VariableDeclaration' && + grandParentPath.parent.type === 'Program'; - return !isValid || path.parentPath.node == null + return !isValid || parentPath.node == null ? null : { - declarationPath: path.parentPath, + declarationPath: parentPath, moduleName, requireFnName, }; } -function parseInlineableMemberAlias(path, state) { +function parseInlineableMemberAlias( + path: NodePath, + state: State, +): ?{ + declarationPath: NodePath, + moduleName: string, + requireFnName: string, +} { const module = getInlineableModule(path, state); if (module == null) { return null; } const {moduleName, requireFnName} = module; - const isValid = - path.parent.type === 'MemberExpression' && - path.parentPath.parent.type === 'VariableDeclarator' && - path.parentPath.parent.id.type === 'Identifier' && - path.parentPath.parentPath.parent.type === 'VariableDeclaration' && - path.parentPath.parentPath.parentPath.parent.type === 'Program'; + const parent = path.parent; + const parentPath = path.parentPath; + if (parentPath == null) { + return null; + } + const grandParentPath = parentPath.parentPath; + if (grandParentPath == null) { + return null; + } + + if (parent.type !== 'MemberExpression') { + return null; + } + + const memberExpression: BabelNodeMemberExpression = parent; - const memberPropertyName = getMemberPropertyName(path.parent); + if (parentPath.parent.type !== 'VariableDeclarator') { + return null; + } + const variableDeclarator = parentPath.parent; - return !isValid || - path.parentPath.parentPath.node == null || + if (variableDeclarator.id.type !== 'Identifier') { + return null; + } + + if ( + grandParentPath.parent.type !== 'VariableDeclaration' || + grandParentPath.parentPath?.parent.type !== 'Program' || + grandParentPath.node == null + ) { + return null; + } + + const memberPropertyName = getMemberPropertyName(memberExpression); + + return memberPropertyName == null || isExcludedMemberAssignment(moduleName, memberPropertyName, state) ? null : { - declarationPath: path.parentPath.parentPath, + declarationPath: grandParentPath, moduleName, requireFnName, }; } -function getInlineableModule(path, state) { +function getInlineableModule( + path: NodePath, + state: State, +): ?{moduleName: string, requireFnName: string} { const node = path.node; const isInlineable = node.type === 'CallExpression' && @@ -239,21 +322,30 @@ function getInlineableModule(path, state) { // require(require.resolve('foo')); if (moduleName == null) { - moduleName = - node['arguments'][0].type === 'CallExpression' && - node['arguments'][0].callee.type === 'MemberExpression' && - node['arguments'][0].callee.object.type === 'Identifier' && - state.inlineableCalls.has(node['arguments'][0].callee.object.name) && - node['arguments'][0].callee.property.type === 'Identifier' && - node['arguments'][0].callee.property.name === 'resolve' && - node['arguments'][0]['arguments'].length >= 1 && - node['arguments'][0]['arguments'][0].type === 'StringLiteral' - ? node['arguments'][0]['arguments'][0].value - : null; + const callNode = node['arguments'][0]; + if ( + callNode.type === 'CallExpression' && + callNode.callee.type === 'MemberExpression' && + callNode.callee.object.type === 'Identifier' + ) { + const callee = callNode.callee; + moduleName = + callee.object.type === 'Identifier' && + state.inlineableCalls.has(callee.object.name) && + callee.property.type === 'Identifier' && + callee.property.name === 'resolve' && + callNode['arguments'].length >= 1 && + callNode['arguments'][0].type === 'StringLiteral' + ? callNode['arguments'][0].value + : null; + } } // Check if require is in any parent scope const fnName = node.callee.name; + if (fnName == null) { + return null; + } const isRequireInScope = path.scope.getBinding(fnName) != null; return moduleName == null ||