blob: 05bc08b29a10f62c86d4fe193ad5f4dda29dcfad [file] [log] [blame]
/**
* @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');
const ast = require('../util/ast');
// 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'
);
}
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) {
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)) {
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'(node) {
if (!classInfo) {
return;
}
if (utils.isES5Component(node)) {
reportUnusedFields();
classInfo = null;
}
},
'ClassDeclaration:exit'() {
if (!classInfo) {
return;
}
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(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);
if (
getName(node.key) === '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;
}
},
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 (!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;
}
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(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;
}
}
};
})
};