babel-plugin-transform-react-remove-prop-types
Version:
Remove unnecessary React propTypes from the production build
403 lines (337 loc) • 12.6 kB
JavaScript
/* eslint-disable global-require, import/no-dynamic-require */
// import generate from 'babel-generator';
// console.log(generate(node).code);
import isAnnotatedForRemoval from './isAnnotatedForRemoval'
import isStatelessComponent from './isStatelessComponent'
import remove from './remove'
function isPathReactClass(path, globalOptions) {
const node = path.node
const matchers = globalOptions.classNameMatchers
if (path.matchesPattern('React.Component') || path.matchesPattern('React.PureComponent')) {
return true
}
if (node && (node.name === 'Component' || node.name === 'PureComponent')) {
return true
}
if (node && matchers && matchers.test(node.name)) {
return true
}
return false
}
function isReactClass(superClass, scope, globalOptions) {
if (!superClass.node) {
return false
}
let answer = false
if (isPathReactClass(superClass, globalOptions)) {
answer = true
} else if (superClass.node.name) {
// Check for inheritance
const className = superClass.node.name
const binding = scope.getBinding(className)
if (!binding) {
answer = false
} else {
const bindingSuperClass = binding.path.get('superClass')
if (isPathReactClass(bindingSuperClass, globalOptions)) {
answer = true
}
}
}
return answer
}
function areSetsEqual(set1, set2) {
if (set1 === set2) {
return true
}
if (set1.size !== set2.size) {
return false
}
return !Array.from(set1).some(item => !set2.has(item))
}
function memberExpressionRootIdentifier(path) {
// Traverse up to the parent before the topmost member expression, and then
// traverse back down to find the topmost identifier. It seems like there
// might be a better way to do this.
const parent = path.findParent(p => !p.isMemberExpression())
const { type } = parent.node
let memberExpression
if (type === 'ObjectProperty') {
// The topmost MemberExpression's parent is an object property, so the
// topmost MemberExpression should be the value.
memberExpression = parent.get('value')
}
if (!memberExpression || memberExpression.type !== 'MemberExpression') {
// This case is currently unhandled by this plugin.
return null
}
// We have a topmost MemberExpression now, so we want to traverse down the
// left half untli we no longer see MemberExpressions. This node will give us
// our leftmost identifier.
while (memberExpression.node.object.type === 'MemberExpression') {
memberExpression = memberExpression.get('object')
}
return memberExpression.get('object')
}
export default function(api) {
const { template, types, traverse } = api
const nestedIdentifiers = new Set()
const removedPaths = new WeakSet()
const collectNestedIdentifiers = {
Identifier(path) {
if (path.parent.type === 'MemberExpression') {
// foo.bar
const root = memberExpressionRootIdentifier(path)
if (root) {
nestedIdentifiers.add(root.node.name)
}
return
}
if (
path.parent.type === 'ObjectProperty' &&
(path.parent.key === path.node || path.parent.shorthand)
) {
// { foo: 'bar' }
// { foo }
return
}
nestedIdentifiers.add(path.node.name)
},
}
return {
visitor: {
Program(programPath, state) {
let ignoreFilenames
let classNameMatchers
if (state.opts.ignoreFilenames) {
ignoreFilenames = new RegExp(state.opts.ignoreFilenames.join('|'), 'i')
} else {
ignoreFilenames = undefined
}
if (state.opts.classNameMatchers) {
classNameMatchers = new RegExp(state.opts.classNameMatchers.join('|'))
} else {
classNameMatchers = undefined
}
const globalOptions = {
visitedKey: `transform-react-remove-prop-types${Date.now()}`,
unsafeWrapTemplate: template(
`
if (process.env.NODE_ENV !== "production") {
NODE;
}
`,
{ placeholderPattern: /^NODE$/ }
),
wrapTemplate: ({ LEFT, RIGHT }, options = {}) => {
const { as = 'assignmentExpression' } = options
const right = template.expression(
`
process.env.NODE_ENV !== "production" ? RIGHT : {}
`,
{ placeholderPattern: /^(LEFT|RIGHT)$/ }
)({ RIGHT })
switch (as) {
case 'variableDeclarator':
return types.variableDeclarator(LEFT, right)
case 'assignmentExpression':
return types.assignmentExpression('=', LEFT, right)
default:
throw new Error(`unrecognized template type ${as}`)
}
},
mode: state.opts.mode || 'remove',
ignoreFilenames,
types,
removeImport: state.opts.removeImport || false,
libraries: (state.opts.additionalLibraries || []).concat('prop-types'),
classNameMatchers,
createReactClassName: state.opts.createReactClassName || 'createReactClass',
}
if (state.opts.plugins) {
const pluginsState = state
const pluginsVisitors = state.opts.plugins.map(pluginOpts => {
const pluginName = typeof pluginOpts === 'string' ? pluginOpts : pluginOpts[0]
if (typeof pluginOpts !== 'string') {
pluginsState.opts = {
...pluginsState.opts,
...pluginOpts[1],
}
}
let plugin = require(pluginName)
if (typeof plugin !== 'function') {
plugin = plugin.default
}
return plugin(api).visitor
})
traverse(
programPath.parent,
traverse.visitors.merge(pluginsVisitors),
programPath.scope,
pluginsState,
programPath.parentPath
)
}
// On program start, do an explicit traversal up front for this plugin.
programPath.traverse({
ObjectProperty: {
exit(path) {
const node = path.node
if (node.computed || node.key.name !== 'propTypes') {
return
}
const parent = path.findParent(currentNode => {
if (currentNode.type !== 'CallExpression') {
return false
}
return (
currentNode.get('callee').node.name === globalOptions.createReactClassName ||
(currentNode.get('callee').node.property &&
currentNode.get('callee').node.property.name === 'createClass')
)
})
if (parent) {
path.traverse(collectNestedIdentifiers)
removedPaths.add(path)
remove(path, globalOptions, {
type: 'createClass',
})
}
},
},
// Here to support stage-1 transform-class-properties.
ClassProperty(path) {
const { node, scope } = path
if (node.key.name === 'propTypes') {
const pathClassDeclaration = scope.path
if (isReactClass(pathClassDeclaration.get('superClass'), scope, globalOptions)) {
path.traverse(collectNestedIdentifiers)
removedPaths.add(path)
remove(path, globalOptions, {
type: 'class static',
pathClassDeclaration,
})
}
}
},
AssignmentExpression(path) {
const { node, scope } = path
if (
node.left.computed ||
!node.left.property ||
node.left.property.name !== 'propTypes'
) {
return
}
const forceRemoval = isAnnotatedForRemoval(path.node.left)
if (forceRemoval) {
path.traverse(collectNestedIdentifiers)
removedPaths.add(path)
remove(path, globalOptions, { type: 'assign' })
return
}
const className = node.left.object.name
const binding = scope.getBinding(className)
if (!binding) {
return
}
if (binding.path.isClassDeclaration()) {
const superClass = binding.path.get('superClass')
if (isReactClass(superClass, scope, globalOptions)) {
path.traverse(collectNestedIdentifiers)
removedPaths.add(path)
remove(path, globalOptions, { type: 'assign' })
}
} else if (isStatelessComponent(binding.path)) {
path.traverse(collectNestedIdentifiers)
removedPaths.add(path)
remove(path, globalOptions, { type: 'assign' })
}
},
})
let skippedIdentifiers = 0
const removeNewlyUnusedIdentifiers = {
VariableDeclarator(path) {
// Only consider the top level scope.
if (path.scope.block.type !== 'Program') {
return
}
if (['ObjectPattern', 'ArrayPattern'].includes(path.node.id.type)) {
// Object or Array destructuring, so we will want to capture all
// the names created by the destructuring. This currently doesn't
// work, but would be good to improve. All of the names for
// ObjectPattern can be collected like:
//
// path.node.id.properties.map(prop => prop.value.name);
return
}
const { name } = path.node.id
if (!nestedIdentifiers.has(name)) {
return
}
const { referencePaths } = path.scope.getBinding(name)
// Count the number of referencePaths that are not in the
// removedPaths Set. We need to do this in order to support the wrap
// option, which doesn't actually remove the references.
const hasRemainingReferencePaths = referencePaths.some(referencePath => {
const found = referencePath.find(path2 => removedPaths.has(path2))
return !found
})
if (hasRemainingReferencePaths) {
// There are still references to this identifier, so we need to
// skip over it for now.
skippedIdentifiers += 1
return
}
removedPaths.add(path)
nestedIdentifiers.delete(name)
path.get('init').traverse(collectNestedIdentifiers)
remove(path, globalOptions, { type: 'declarator' })
},
}
let lastNestedIdentifiers = new Set()
while (
!areSetsEqual(nestedIdentifiers, lastNestedIdentifiers) &&
nestedIdentifiers.size > 0 &&
skippedIdentifiers < nestedIdentifiers.size
) {
lastNestedIdentifiers = new Set(nestedIdentifiers)
skippedIdentifiers = 0
programPath.scope.crawl()
programPath.traverse(removeNewlyUnusedIdentifiers)
}
if (globalOptions.removeImport) {
if (globalOptions.mode === 'remove') {
programPath.scope.crawl()
programPath.traverse({
ImportDeclaration(path) {
const { source, specifiers } = path.node
const found = globalOptions.libraries.some(library => {
if (library instanceof RegExp) {
return library.test(source.value)
}
return source.value === library
})
if (!found) {
return
}
const haveUsedSpecifiers = specifiers.some(specifier => {
const importedIdentifierName = specifier.local.name
const { referencePaths } = path.scope.getBinding(importedIdentifierName)
return referencePaths.length > 0
})
if (!haveUsedSpecifiers) {
path.remove()
}
},
})
} else {
throw new Error(
'transform-react-remove-prop-type: removeImport = true and mode != "remove" can not be used at the same time.'
)
}
}
},
},
}
}