You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
520 lines
16 KiB
520 lines
16 KiB
/** |
|
* @fileoverview Attempts to discover all state fields in a React component and |
|
* warn if any of them are never read. |
|
* |
|
* State field definitions are collected from `this.state = {}` assignments in |
|
* the constructor, objects passed to `this.setState()`, and `state = {}` class |
|
* property assignments. |
|
*/ |
|
|
|
'use strict'; |
|
|
|
const docsUrl = require('../util/docsUrl'); |
|
const ast = require('../util/ast'); |
|
const componentUtil = require('../util/componentUtil'); |
|
const report = require('../util/report'); |
|
|
|
// Descend through all wrapping TypeCastExpressions and return the expression |
|
// that was cast. |
|
function uncast(node) { |
|
while (node.type === 'TypeCastExpression') { |
|
node = node.expression; |
|
} |
|
return node; |
|
} |
|
|
|
// Return the name of an identifier or the string value of a literal. Useful |
|
// anywhere that a literal may be used as a key (e.g., member expressions, |
|
// method definitions, ObjectExpression property keys). |
|
function getName(node) { |
|
node = uncast(node); |
|
const type = node.type; |
|
|
|
if (type === 'Identifier') { |
|
return node.name; |
|
} |
|
if (type === 'Literal') { |
|
return String(node.value); |
|
} |
|
if (type === 'TemplateLiteral' && node.expressions.length === 0) { |
|
return node.quasis[0].value.raw; |
|
} |
|
return null; |
|
} |
|
|
|
function isThisExpression(node) { |
|
return ast.unwrapTSAsExpression(uncast(node)).type === 'ThisExpression'; |
|
} |
|
|
|
function getInitialClassInfo() { |
|
return { |
|
// Set of nodes where state fields were defined. |
|
stateFields: new Set(), |
|
|
|
// Set of names of state fields that we've seen used. |
|
usedStateFields: new Set(), |
|
|
|
// Names of local variables that may be pointing to this.state. To |
|
// track this properly, we would need to keep track of all locals, |
|
// shadowing, assignments, etc. To keep things simple, we only |
|
// maintain one set of aliases per method and accept that it will |
|
// produce some false negatives. |
|
aliases: null, |
|
}; |
|
} |
|
|
|
function isSetStateCall(node) { |
|
const unwrappedCalleeNode = ast.unwrapTSAsExpression(node.callee); |
|
|
|
return ( |
|
unwrappedCalleeNode.type === 'MemberExpression' |
|
&& isThisExpression(unwrappedCalleeNode.object) |
|
&& getName(unwrappedCalleeNode.property) === 'setState' |
|
); |
|
} |
|
|
|
const messages = { |
|
unusedStateField: 'Unused state field: \'{{name}}\'', |
|
}; |
|
|
|
module.exports = { |
|
meta: { |
|
docs: { |
|
description: 'Prevent definition of unused state fields', |
|
category: 'Best Practices', |
|
recommended: false, |
|
url: docsUrl('no-unused-state'), |
|
}, |
|
|
|
messages, |
|
|
|
schema: [], |
|
}, |
|
|
|
create(context) { |
|
// Non-null when we are inside a React component ClassDeclaration and we have |
|
// not yet encountered any use of this.state which we have chosen not to |
|
// analyze. If we encounter any such usage (like this.state being spread as |
|
// JSX attributes), then this is again set to null. |
|
let classInfo = null; |
|
|
|
function isStateParameterReference(node) { |
|
const classMethods = [ |
|
'shouldComponentUpdate', |
|
'componentWillUpdate', |
|
'UNSAFE_componentWillUpdate', |
|
'getSnapshotBeforeUpdate', |
|
'componentDidUpdate', |
|
]; |
|
|
|
let scope = context.getScope(); |
|
while (scope) { |
|
const parent = scope.block && scope.block.parent; |
|
if ( |
|
parent |
|
&& parent.type === 'MethodDefinition' && ( |
|
(parent.static && parent.key.name === 'getDerivedStateFromProps') |
|
|| classMethods.indexOf(parent.key.name) !== -1 |
|
) |
|
&& parent.value.type === 'FunctionExpression' |
|
&& parent.value.params[1] |
|
&& parent.value.params[1].name === node.name |
|
) { |
|
return true; |
|
} |
|
scope = scope.upper; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
// Returns true if the given node is possibly a reference to `this.state` or the state parameter of |
|
// a lifecycle method. |
|
function isStateReference(node) { |
|
node = uncast(node); |
|
|
|
const isDirectStateReference = node.type === 'MemberExpression' |
|
&& isThisExpression(node.object) |
|
&& node.property.name === 'state'; |
|
|
|
const isAliasedStateReference = node.type === 'Identifier' |
|
&& classInfo.aliases |
|
&& classInfo.aliases.has(node.name); |
|
|
|
return isDirectStateReference || isAliasedStateReference || isStateParameterReference(node); |
|
} |
|
|
|
// Takes an ObjectExpression node and adds all named Property nodes to the |
|
// current set of state fields. |
|
function addStateFields(node) { |
|
node.properties.filter((prop) => ( |
|
prop.type === 'Property' |
|
&& (prop.key.type === 'Literal' |
|
|| (prop.key.type === 'TemplateLiteral' && prop.key.expressions.length === 0) |
|
|| (prop.computed === false && prop.key.type === 'Identifier')) |
|
&& getName(prop.key) !== null |
|
)).forEach((prop) => { |
|
classInfo.stateFields.add(prop); |
|
}); |
|
} |
|
|
|
// Adds the name of the given node as a used state field if the node is an |
|
// Identifier or a Literal. Other node types are ignored. |
|
function addUsedStateField(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
const name = getName(node); |
|
if (name) { |
|
classInfo.usedStateFields.add(name); |
|
} |
|
} |
|
|
|
// Records used state fields and new aliases for an ObjectPattern which |
|
// destructures `this.state`. |
|
function handleStateDestructuring(node) { |
|
for (const prop of node.properties) { |
|
if (prop.type === 'Property') { |
|
addUsedStateField(prop.key); |
|
} else if ( |
|
(prop.type === 'ExperimentalRestProperty' || prop.type === 'RestElement') |
|
&& classInfo.aliases |
|
) { |
|
classInfo.aliases.add(getName(prop.argument)); |
|
} |
|
} |
|
} |
|
|
|
// Used to record used state fields and new aliases for both |
|
// AssignmentExpressions and VariableDeclarators. |
|
function handleAssignment(left, right) { |
|
const unwrappedRight = ast.unwrapTSAsExpression(right); |
|
|
|
switch (left.type) { |
|
case 'Identifier': |
|
if (isStateReference(unwrappedRight) && classInfo.aliases) { |
|
classInfo.aliases.add(left.name); |
|
} |
|
break; |
|
case 'ObjectPattern': |
|
if (isStateReference(unwrappedRight)) { |
|
handleStateDestructuring(left); |
|
} else if (isThisExpression(unwrappedRight) && classInfo.aliases) { |
|
for (const prop of left.properties) { |
|
if (prop.type === 'Property' && getName(prop.key) === 'state') { |
|
const name = getName(prop.value); |
|
if (name) { |
|
classInfo.aliases.add(name); |
|
} else if (prop.value.type === 'ObjectPattern') { |
|
handleStateDestructuring(prop.value); |
|
} |
|
} |
|
} |
|
} |
|
break; |
|
default: |
|
// pass |
|
} |
|
} |
|
|
|
function reportUnusedFields() { |
|
// Report all unused state fields. |
|
for (const node of classInfo.stateFields) { |
|
const name = getName(node.key); |
|
if (!classInfo.usedStateFields.has(name)) { |
|
report(context, messages.unusedStateField, 'unusedStateField', { |
|
node, |
|
data: { |
|
name, |
|
}, |
|
}); |
|
} |
|
} |
|
} |
|
|
|
function handleES6ComponentEnter(node) { |
|
if (componentUtil.isES6Component(node, context)) { |
|
classInfo = getInitialClassInfo(); |
|
} |
|
} |
|
|
|
function handleES6ComponentExit() { |
|
if (!classInfo) { |
|
return; |
|
} |
|
reportUnusedFields(); |
|
classInfo = null; |
|
} |
|
|
|
function isGDSFP(node) { |
|
const name = getName(node.key); |
|
if ( |
|
!node.static |
|
|| name !== 'getDerivedStateFromProps' |
|
|| !node.value |
|
|| !node.value.params |
|
|| node.value.params.length < 2 // no `state` argument |
|
) { |
|
return false; |
|
} |
|
return true; |
|
} |
|
|
|
return { |
|
ClassDeclaration: handleES6ComponentEnter, |
|
|
|
'ClassDeclaration:exit': handleES6ComponentExit, |
|
|
|
ClassExpression: handleES6ComponentEnter, |
|
|
|
'ClassExpression:exit': handleES6ComponentExit, |
|
|
|
ObjectExpression(node) { |
|
if (componentUtil.isES5Component(node, context)) { |
|
classInfo = getInitialClassInfo(); |
|
} |
|
}, |
|
|
|
'ObjectExpression:exit'(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
|
|
if (componentUtil.isES5Component(node, context)) { |
|
reportUnusedFields(); |
|
classInfo = null; |
|
} |
|
}, |
|
|
|
CallExpression(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
|
|
const unwrappedNode = ast.unwrapTSAsExpression(node); |
|
const unwrappedArgumentNode = ast.unwrapTSAsExpression(unwrappedNode.arguments[0]); |
|
|
|
// If we're looking at a `this.setState({})` invocation, record all the |
|
// properties as state fields. |
|
if ( |
|
isSetStateCall(unwrappedNode) |
|
&& unwrappedNode.arguments.length > 0 |
|
&& unwrappedArgumentNode.type === 'ObjectExpression' |
|
) { |
|
addStateFields(unwrappedArgumentNode); |
|
} else if ( |
|
isSetStateCall(unwrappedNode) |
|
&& unwrappedNode.arguments.length > 0 |
|
&& unwrappedArgumentNode.type === 'ArrowFunctionExpression' |
|
) { |
|
const unwrappedBodyNode = ast.unwrapTSAsExpression(unwrappedArgumentNode.body); |
|
|
|
if (unwrappedBodyNode.type === 'ObjectExpression') { |
|
addStateFields(unwrappedBodyNode); |
|
} |
|
if (unwrappedArgumentNode.params.length > 0 && classInfo.aliases) { |
|
const firstParam = unwrappedArgumentNode.params[0]; |
|
if (firstParam.type === 'ObjectPattern') { |
|
handleStateDestructuring(firstParam); |
|
} else { |
|
classInfo.aliases.add(getName(firstParam)); |
|
} |
|
} |
|
} |
|
}, |
|
|
|
'ClassProperty, PropertyDefinition'(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
// If we see state being assigned as a class property using an object |
|
// expression, record all the fields of that object as state fields. |
|
const unwrappedValueNode = ast.unwrapTSAsExpression(node.value); |
|
|
|
const name = getName(node.key); |
|
if ( |
|
name === 'state' |
|
&& !node.static |
|
&& unwrappedValueNode |
|
&& unwrappedValueNode.type === 'ObjectExpression' |
|
) { |
|
addStateFields(unwrappedValueNode); |
|
} |
|
|
|
if ( |
|
!node.static |
|
&& unwrappedValueNode |
|
&& unwrappedValueNode.type === 'ArrowFunctionExpression' |
|
) { |
|
// Create a new set for this.state aliases local to this method. |
|
classInfo.aliases = new Set(); |
|
} |
|
}, |
|
|
|
'ClassProperty:exit'(node) { |
|
if ( |
|
classInfo |
|
&& !node.static |
|
&& node.value |
|
&& node.value.type === 'ArrowFunctionExpression' |
|
) { |
|
// Forget our set of local aliases. |
|
classInfo.aliases = null; |
|
} |
|
}, |
|
|
|
'PropertyDefinition, ClassProperty'(node) { |
|
if (!isGDSFP(node)) { |
|
return; |
|
} |
|
|
|
const childScope = context.getScope().childScopes.find((x) => x.block === node.value); |
|
if (!childScope) { |
|
return; |
|
} |
|
const scope = childScope.variableScope.childScopes.find((x) => x.block === node.value); |
|
const stateArg = node.value.params[1]; // probably "state" |
|
if (!scope || !scope.variables) { |
|
return; |
|
} |
|
const argVar = scope.variables.find((x) => x.name === stateArg.name); |
|
|
|
const stateRefs = argVar.references; |
|
|
|
stateRefs.forEach((ref) => { |
|
const identifier = ref.identifier; |
|
if (identifier && identifier.parent && identifier.parent.type === 'MemberExpression') { |
|
addUsedStateField(identifier.parent.property); |
|
} |
|
}); |
|
}, |
|
|
|
'PropertyDefinition:exit'(node) { |
|
if ( |
|
classInfo |
|
&& !node.static |
|
&& node.value |
|
&& node.value.type === 'ArrowFunctionExpression' |
|
&& !isGDSFP(node) |
|
) { |
|
// Forget our set of local aliases. |
|
classInfo.aliases = null; |
|
} |
|
}, |
|
|
|
MethodDefinition() { |
|
if (!classInfo) { |
|
return; |
|
} |
|
// Create a new set for this.state aliases local to this method. |
|
classInfo.aliases = new Set(); |
|
}, |
|
|
|
'MethodDefinition:exit'() { |
|
if (!classInfo) { |
|
return; |
|
} |
|
// Forget our set of local aliases. |
|
classInfo.aliases = null; |
|
}, |
|
|
|
FunctionExpression(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
|
|
const parent = node.parent; |
|
if (!componentUtil.isES5Component(parent.parent, context)) { |
|
return; |
|
} |
|
|
|
if (parent.key.name === 'getInitialState') { |
|
const body = node.body.body; |
|
const lastBodyNode = body[body.length - 1]; |
|
|
|
if ( |
|
lastBodyNode.type === 'ReturnStatement' |
|
&& lastBodyNode.argument.type === 'ObjectExpression' |
|
) { |
|
addStateFields(lastBodyNode.argument); |
|
} |
|
} else { |
|
// Create a new set for this.state aliases local to this method. |
|
classInfo.aliases = new Set(); |
|
} |
|
}, |
|
|
|
AssignmentExpression(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
|
|
const unwrappedLeft = ast.unwrapTSAsExpression(node.left); |
|
const unwrappedRight = ast.unwrapTSAsExpression(node.right); |
|
|
|
// Check for assignments like `this.state = {}` |
|
if ( |
|
unwrappedLeft.type === 'MemberExpression' |
|
&& isThisExpression(unwrappedLeft.object) |
|
&& getName(unwrappedLeft.property) === 'state' |
|
&& unwrappedRight.type === 'ObjectExpression' |
|
) { |
|
// Find the nearest function expression containing this assignment. |
|
let fn = node; |
|
while (fn.type !== 'FunctionExpression' && fn.parent) { |
|
fn = fn.parent; |
|
} |
|
// If the nearest containing function is the constructor, then we want |
|
// to record all the assigned properties as state fields. |
|
if ( |
|
fn.parent |
|
&& fn.parent.type === 'MethodDefinition' |
|
&& fn.parent.kind === 'constructor' |
|
) { |
|
addStateFields(unwrappedRight); |
|
} |
|
} else { |
|
// Check for assignments like `alias = this.state` and record the alias. |
|
handleAssignment(unwrappedLeft, unwrappedRight); |
|
} |
|
}, |
|
|
|
VariableDeclarator(node) { |
|
if (!classInfo || !node.init) { |
|
return; |
|
} |
|
handleAssignment(node.id, node.init); |
|
}, |
|
|
|
'MemberExpression, OptionalMemberExpression'(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
if (isStateReference(ast.unwrapTSAsExpression(node.object))) { |
|
// If we see this.state[foo] access, give up. |
|
if (node.computed && node.property.type !== 'Literal') { |
|
classInfo = null; |
|
return; |
|
} |
|
// Otherwise, record that we saw this property being accessed. |
|
addUsedStateField(node.property); |
|
// If we see a `this.state` access in a CallExpression, give up. |
|
} else if (isStateReference(node) && node.parent.type === 'CallExpression') { |
|
classInfo = null; |
|
} |
|
}, |
|
|
|
JSXSpreadAttribute(node) { |
|
if (classInfo && isStateReference(node.argument)) { |
|
classInfo = null; |
|
} |
|
}, |
|
|
|
'ExperimentalSpreadProperty, SpreadElement'(node) { |
|
if (classInfo && isStateReference(node.argument)) { |
|
classInfo = null; |
|
} |
|
}, |
|
}; |
|
}, |
|
};
|
|
|