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.
432 lines
13 KiB
432 lines
13 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 Components = require('../util/Components'); |
|
const docsUrl = require('../util/docsUrl'); |
|
|
|
// 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 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) { |
|
return ( |
|
node.callee.type === 'MemberExpression' && |
|
isThisExpression(node.callee.object) && |
|
getName(node.callee.property) === 'setState' |
|
); |
|
} |
|
|
|
module.exports = { |
|
meta: { |
|
docs: { |
|
description: 'Prevent definition of unused state fields', |
|
category: 'Best Practices', |
|
recommended: false, |
|
url: docsUrl('no-unused-state') |
|
}, |
|
schema: [] |
|
}, |
|
|
|
create: Components.detect((context, components, utils) => { |
|
// 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) { |
|
for (const prop of node.properties) { |
|
const key = prop.key; |
|
|
|
if ( |
|
prop.type === 'Property' && |
|
(key.type === 'Literal' || |
|
(key.type === 'TemplateLiteral' && key.expressions.length === 0) || |
|
(prop.computed === false && key.type === 'Identifier')) && |
|
getName(prop.key) !== null |
|
) { |
|
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) { |
|
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) { |
|
switch (left.type) { |
|
case 'Identifier': |
|
if (isStateReference(right) && classInfo.aliases) { |
|
classInfo.aliases.add(left.name); |
|
} |
|
break; |
|
case 'ObjectPattern': |
|
if (isStateReference(right)) { |
|
handleStateDestructuring(left); |
|
} else if (isThisExpression(right) && 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)) { |
|
context.report({ |
|
node, |
|
message: `Unused state field: '${name}'` |
|
}); |
|
} |
|
} |
|
} |
|
|
|
return { |
|
ClassDeclaration(node) { |
|
if (utils.isES6Component(node)) { |
|
classInfo = getInitialClassInfo(); |
|
} |
|
}, |
|
|
|
ObjectExpression(node) { |
|
if (utils.isES5Component(node)) { |
|
classInfo = getInitialClassInfo(); |
|
} |
|
}, |
|
|
|
'ObjectExpression:exit': function (node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
|
|
if (utils.isES5Component(node)) { |
|
reportUnusedFields(); |
|
classInfo = null; |
|
} |
|
}, |
|
|
|
'ClassDeclaration:exit': function () { |
|
if (!classInfo) { |
|
return; |
|
} |
|
reportUnusedFields(); |
|
classInfo = null; |
|
}, |
|
|
|
CallExpression(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
// If we're looking at a `this.setState({})` invocation, record all the |
|
// properties as state fields. |
|
if ( |
|
isSetStateCall(node) && |
|
node.arguments.length > 0 && |
|
node.arguments[0].type === 'ObjectExpression' |
|
) { |
|
addStateFields(node.arguments[0]); |
|
} else if ( |
|
isSetStateCall(node) && |
|
node.arguments.length > 0 && |
|
node.arguments[0].type === 'ArrowFunctionExpression' |
|
) { |
|
if (node.arguments[0].body.type === 'ObjectExpression') { |
|
addStateFields(node.arguments[0].body); |
|
} |
|
if (node.arguments[0].params.length > 0 && classInfo.aliases) { |
|
const firstParam = node.arguments[0].params[0]; |
|
if (firstParam.type === 'ObjectPattern') { |
|
handleStateDestructuring(firstParam); |
|
} else { |
|
classInfo.aliases.add(getName(firstParam)); |
|
} |
|
} |
|
} |
|
}, |
|
|
|
ClassProperty(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. |
|
if ( |
|
getName(node.key) === 'state' && |
|
!node.static && |
|
node.value && |
|
node.value.type === 'ObjectExpression' |
|
) { |
|
addStateFields(node.value); |
|
} |
|
|
|
if ( |
|
!node.static && |
|
node.value && |
|
node.value.type === 'ArrowFunctionExpression' |
|
) { |
|
// Create a new set for this.state aliases local to this method. |
|
classInfo.aliases = new Set(); |
|
} |
|
}, |
|
|
|
'ClassProperty:exit': function (node) { |
|
if ( |
|
classInfo && |
|
!node.static && |
|
node.value && |
|
node.value.type === 'ArrowFunctionExpression' |
|
) { |
|
// 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': function () { |
|
if (!classInfo) { |
|
return; |
|
} |
|
// Forget our set of local aliases. |
|
classInfo.aliases = null; |
|
}, |
|
|
|
FunctionExpression(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
|
|
const parent = node.parent; |
|
if (!utils.isES5Component(parent.parent)) { |
|
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; |
|
} |
|
// Check for assignments like `this.state = {}` |
|
if ( |
|
node.left.type === 'MemberExpression' && |
|
isThisExpression(node.left.object) && |
|
getName(node.left.property) === 'state' && |
|
node.right.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(node.right); |
|
} |
|
} else { |
|
// Check for assignments like `alias = this.state` and record the alias. |
|
handleAssignment(node.left, node.right); |
|
} |
|
}, |
|
|
|
VariableDeclarator(node) { |
|
if (!classInfo || !node.init) { |
|
return; |
|
} |
|
handleAssignment(node.id, node.init); |
|
}, |
|
|
|
MemberExpression(node) { |
|
if (!classInfo) { |
|
return; |
|
} |
|
if (isStateReference(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': function (node) { |
|
if (classInfo && isStateReference(node.argument)) { |
|
classInfo = null; |
|
} |
|
} |
|
}; |
|
}) |
|
};
|
|
|