'use strict';
export default {
meta: {
type: 'suggestion',
docs: {
description:
'verifies the list of dependencies for Hooks like useEffect and similar',
recommended: true,
url: 'https://github.com/facebook/react/issues/14920',
},
fixable: 'code',
hasSuggestions: true,
schema: [
{
type: 'object',
additionalProperties: false,
enableDangerousAutofixThisMayCauseInfiniteLoops: false,
properties: {
additionalHooks: {
type: 'string',
},
enableDangerousAutofixThisMayCauseInfiniteLoops: {
type: 'boolean',
},
},
},
],
},
create(context) {
const additionalHooks =
context.options &&
context.options[0] &&
context.options[0].additionalHooks
? new RegExp(context.options[0].additionalHooks)
: undefined;
const enableDangerousAutofixThisMayCauseInfiniteLoops =
(context.options &&
context.options[0] &&
context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) ||
false;
const options = {
additionalHooks,
enableDangerousAutofixThisMayCauseInfiniteLoops,
};
function reportProblem(problem) {
if (enableDangerousAutofixThisMayCauseInfiniteLoops) {
if (Array.isArray(problem.suggest) && problem.suggest.length > 0) {
problem.fix = problem.suggest[0].fix;
}
}
context.report(problem);
}
const getSource =
typeof context.getSource === 'function'
? node => {
return context.getSource(node);
}
: node => {
return context.sourceCode.getText(node);
};
const getScope =
typeof context.getScope === 'function'
? () => {
return context.getScope();
}
: node => {
return context.sourceCode.getScope(node);
};
const scopeManager = context.getSourceCode().scopeManager;
const setStateCallSites = new WeakMap();
const stateVariables = new WeakSet();
const stableKnownValueCache = new WeakMap();
const functionWithoutCapturedValueCache = new WeakMap();
const useEffectEventVariables = new WeakSet();
function memoizeWithWeakMap(fn, map) {
return function (arg) {
if (map.has(arg)) {
return map.get(arg);
}
const result = fn(arg);
map.set(arg, result);
return result;
};
}
function visitFunctionWithDependencies(
node,
declaredDependenciesNode,
reactiveHook,
reactiveHookName,
isEffect,
) {
if (isEffect && node.async) {
reportProblem({
node: node,
message:
`Effect callbacks are synchronous to prevent race conditions. ` +
`Put the async function inside:\n\n` +
'useEffect(() => {\n' +
' async function fetchData() {\n' +
' // You can await here\n' +
' const response = await MyAPI.getData(someId);\n' +
' // ...\n' +
' }\n' +
' fetchData();\n' +
`}, [someId]); // Or [] if effect doesn't need props or state\n\n` +
'Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching',
});
}
const scope = scopeManager.acquire(node);
const pureScopes = new Set();
let componentScope = null;
{
let currentScope = scope.upper;
while (currentScope) {
pureScopes.add(currentScope);
if (currentScope.type === 'function') {
break;
}
currentScope = currentScope.upper;
}
if (!currentScope) {
return;
}
componentScope = currentScope;
}
const isArray = Array.isArray;
function isStableKnownHookValue(resolved) {
if (!isArray(resolved.defs)) {
return false;
}
const def = resolved.defs[0];
if (def == null) {
return false;
}
if (def.node.type !== 'VariableDeclarator') {
return false;
}
let init = def.node.init;
if (init == null) {
return false;
}
while (init.type === 'TSAsExpression' || init.type === 'AsExpression') {
init = init.expression;
}
let declaration = def.node.parent;
if (declaration == null) {
fastFindReferenceWithParent(componentScope.block, def.node.id);
declaration = def.node.parent;
if (declaration == null) {
return false;
}
}
if (
declaration.kind === 'const' &&
init.type === 'Literal' &&
(typeof init.value === 'string' ||
typeof init.value === 'number' ||
init.value === null)
) {
return true;
}
if (init.type !== 'CallExpression') {
return false;
}
let callee = init.callee;
if (
callee.type === 'MemberExpression' &&
callee.object.name === 'React' &&
callee.property != null &&
!callee.computed
) {
callee = callee.property;
}
if (callee.type !== 'Identifier') {
return false;
}
const id = def.node.id;
const {name} = callee;
if (name === 'useRef' && id.type === 'Identifier') {
return true;
} else if (
isUseEffectEventIdentifier(callee) &&
id.type === 'Identifier'
) {
for (const ref of resolved.references) {
if (ref !== id) {
useEffectEventVariables.add(ref.identifier);
}
}
return true;
} else if (
name === 'useState' ||
name === 'useReducer' ||
name === 'useActionState'
) {
if (
id.type === 'ArrayPattern' &&
id.elements.length === 2 &&
isArray(resolved.identifiers)
) {
if (id.elements[1] === resolved.identifiers[0]) {
if (name === 'useState') {
const references = resolved.references;
let writeCount = 0;
for (let i = 0; i < references.length; i++) {
if (references[i].isWrite()) {
writeCount++;
}
if (writeCount > 1) {
return false;
}
setStateCallSites.set(
references[i].identifier,
id.elements[0],
);
}
}
return true;
} else if (id.elements[0] === resolved.identifiers[0]) {
if (name === 'useState') {
const references = resolved.references;
for (let i = 0; i < references.length; i++) {
stateVariables.add(references[i].identifier);
}
}
return false;
}
}
} else if (name === 'useTransition') {
if (
id.type === 'ArrayPattern' &&
id.elements.length === 2 &&
Array.isArray(resolved.identifiers)
) {
if (id.elements[1] === resolved.identifiers[0]) {
return true;
}
}
}
return false;
}
function isFunctionWithoutCapturedValues(resolved) {
if (!isArray(resolved.defs)) {
return false;
}
const def = resolved.defs[0];
if (def == null) {
return false;
}
if (def.node == null || def.node.id == null) {
return false;
}
const fnNode = def.node;
const childScopes = componentScope.childScopes;
let fnScope = null;
let i;
for (i = 0; i < childScopes.length; i++) {
const childScope = childScopes[i];
const childScopeBlock = childScope.block;
if (
(fnNode.type === 'FunctionDeclaration' &&
childScopeBlock === fnNode) ||
(fnNode.type === 'VariableDeclarator' &&
childScopeBlock.parent === fnNode)
) {
fnScope = childScope;
break;
}
}
if (fnScope == null) {
return false;
}
for (i = 0; i < fnScope.through.length; i++) {
const ref = fnScope.through[i];
if (ref.resolved == null) {
continue;
}
if (
pureScopes.has(ref.resolved.scope) &&
!memoizedIsStableKnownHookValue(ref.resolved)
) {
return false;
}
}
return true;
}
const memoizedIsStableKnownHookValue = memoizeWithWeakMap(
isStableKnownHookValue,
stableKnownValueCache,
);
const memoizedIsFunctionWithoutCapturedValues = memoizeWithWeakMap(
isFunctionWithoutCapturedValues,
functionWithoutCapturedValueCache,
);
const currentRefsInEffectCleanup = new Map();
function isInsideEffectCleanup(reference) {
let curScope = reference.from;
let isInReturnedFunction = false;
while (curScope.block !== node) {
if (curScope.type === 'function') {
isInReturnedFunction =
curScope.block.parent != null &&
curScope.block.parent.type === 'ReturnStatement';
}
curScope = curScope.upper;
}
return isInReturnedFunction;
}
const dependencies = new Map();
const optionalChains = new Map();
gatherDependenciesRecursively(scope);
function gatherDependenciesRecursively(currentScope) {
for (const reference of currentScope.references) {
if (!reference.resolved) {
continue;
}
if (!pureScopes.has(reference.resolved.scope)) {
continue;
}
const referenceNode = fastFindReferenceWithParent(
node,
reference.identifier,
);
const dependencyNode = getDependency(referenceNode);
const dependency = analyzePropertyChain(
dependencyNode,
optionalChains,
);
if (
isEffect &&
dependencyNode.type === 'Identifier' &&
(dependencyNode.parent.type === 'MemberExpression' ||
dependencyNode.parent.type === 'OptionalMemberExpression') &&
!dependencyNode.parent.computed &&
dependencyNode.parent.property.type === 'Identifier' &&
dependencyNode.parent.property.name === 'current' &&
isInsideEffectCleanup(reference)
) {
currentRefsInEffectCleanup.set(dependency, {
reference,
dependencyNode,
});
}
if (
dependencyNode.parent.type === 'TSTypeQuery' ||
dependencyNode.parent.type === 'TSTypeReference'
) {
continue;
}
const def = reference.resolved.defs[0];
if (def == null) {
continue;
}
if (def.node != null && def.node.init === node.parent) {
continue;
}
if (def.type === 'TypeParameter') {
continue;
}
if (!dependencies.has(dependency)) {
const resolved = reference.resolved;
const isStable =
memoizedIsStableKnownHookValue(resolved) ||
memoizedIsFunctionWithoutCapturedValues(resolved);
dependencies.set(dependency, {
isStable,
references: [reference],
});
} else {
dependencies.get(dependency).references.push(reference);
}
}
for (const childScope of currentScope.childScopes) {
gatherDependenciesRecursively(childScope);
}
}
currentRefsInEffectCleanup.forEach(
({reference, dependencyNode}, dependency) => {
const references = reference.resolved.references;
let foundCurrentAssignment = false;
for (let i = 0; i < references.length; i++) {
const {identifier} = references[i];
const {parent} = identifier;
if (
parent != null &&
parent.type === 'MemberExpression' &&
!parent.computed &&
parent.property.type === 'Identifier' &&
parent.property.name === 'current' &&
parent.parent.type === 'AssignmentExpression' &&
parent.parent.left === parent
) {
foundCurrentAssignment = true;
break;
}
}
if (foundCurrentAssignment) {
return;
}
reportProblem({
node: dependencyNode.parent.property,
message:
`The ref value '${dependency}.current' will likely have ` +
`changed by the time this effect cleanup function runs. If ` +
`this ref points to a node rendered by React, copy ` +
`'${dependency}.current' to a variable inside the effect, and ` +
`use that variable in the cleanup function.`,
});
},
);
const staleAssignments = new Set();
function reportStaleAssignment(writeExpr, key) {
if (staleAssignments.has(key)) {
return;
}
staleAssignments.add(key);
reportProblem({
node: writeExpr,
message:
`Assignments to the '${key}' variable from inside React Hook ` +
`${getSource(reactiveHook)} will be lost after each ` +
`render. To preserve the value over time, store it in a useRef ` +
`Hook and keep the mutable value in the '.current' property. ` +
`Otherwise, you can move this variable directly inside ` +
`${getSource(reactiveHook)}.`,
});
}
const stableDependencies = new Set();
dependencies.forEach(({isStable, references}, key) => {
if (isStable) {
stableDependencies.add(key);
}
references.forEach(reference => {
if (reference.writeExpr) {
reportStaleAssignment(reference.writeExpr, key);
}
});
});
if (staleAssignments.size > 0) {
return;
}
if (!declaredDependenciesNode) {
let setStateInsideEffectWithoutDeps = null;
dependencies.forEach(({isStable, references}, key) => {
if (setStateInsideEffectWithoutDeps) {
return;
}
references.forEach(reference => {
if (setStateInsideEffectWithoutDeps) {
return;
}
const id = reference.identifier;
const isSetState = setStateCallSites.has(id);
if (!isSetState) {
return;
}
let fnScope = reference.from;
while (fnScope.type !== 'function') {
fnScope = fnScope.upper;
}
const isDirectlyInsideEffect = fnScope.block === node;
if (isDirectlyInsideEffect) {
setStateInsideEffectWithoutDeps = key;
}
});
});
if (setStateInsideEffectWithoutDeps) {
const {suggestedDependencies} = collectRecommendations({
dependencies,
declaredDependencies: [],
stableDependencies,
externalDependencies: new Set(),
isEffect: true,
});
reportProblem({
node: reactiveHook,
message:
`React Hook ${reactiveHookName} contains a call to '${setStateInsideEffectWithoutDeps}'. ` +
`Without a list of dependencies, this can lead to an infinite chain of updates. ` +
`To fix this, pass [` +
suggestedDependencies.join(', ') +
`] as a second argument to the ${reactiveHookName} Hook.`,
suggest: [
{
desc: `Add dependencies array: [${suggestedDependencies.join(
', ',
)}]`,
fix(fixer) {
return fixer.insertTextAfter(
node,
`, [${suggestedDependencies.join(', ')}]`,
);
},
},
],
});
}
return;
}
const declaredDependencies = [];
const externalDependencies = new Set();
const isArrayExpression =
declaredDependenciesNode.type === 'ArrayExpression';
const isTSAsArrayExpression =
declaredDependenciesNode.type === 'TSAsExpression' &&
declaredDependenciesNode.expression.type === 'ArrayExpression';
if (!isArrayExpression && !isTSAsArrayExpression) {
reportProblem({
node: declaredDependenciesNode,
message:
`React Hook ${getSource(reactiveHook)} was passed a ` +
'dependency list that is not an array literal. This means we ' +
"can't statically verify whether you've passed the correct " +
'dependencies.',
});
} else {
const arrayExpression = isTSAsArrayExpression
? declaredDependenciesNode.expression
: declaredDependenciesNode;
arrayExpression.elements.forEach(declaredDependencyNode => {
if (declaredDependencyNode === null) {
return;
}
if (declaredDependencyNode.type === 'SpreadElement') {
reportProblem({
node: declaredDependencyNode,
message:
`React Hook ${getSource(reactiveHook)} has a spread ` +
"element in its dependency array. This means we can't " +
"statically verify whether you've passed the " +
'correct dependencies.',
});
return;
}
if (useEffectEventVariables.has(declaredDependencyNode)) {
reportProblem({
node: declaredDependencyNode,
message:
'Functions returned from `useEffectEvent` must not be included in the dependency array. ' +
`Remove \`${getSource(
declaredDependencyNode,
)}\` from the list.`,
suggest: [
{
desc: `Remove the dependency \`${getSource(
declaredDependencyNode,
)}\``,
fix(fixer) {
return fixer.removeRange(declaredDependencyNode.range);
},
},
],
});
}
let declaredDependency;
try {
declaredDependency = analyzePropertyChain(
declaredDependencyNode,
null,
);
} catch (error) {
if (/Unsupported node type/.test(error.message)) {
if (declaredDependencyNode.type === 'Literal') {
if (dependencies.has(declaredDependencyNode.value)) {
reportProblem({
node: declaredDependencyNode,
message:
`The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
`because it never changes. ` +
`Did you mean to include ${declaredDependencyNode.value} in the array instead?`,
});
} else {
reportProblem({
node: declaredDependencyNode,
message:
`The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
'because it never changes. You can safely remove it.',
});
}
} else {
reportProblem({
node: declaredDependencyNode,
message:
`React Hook ${getSource(reactiveHook)} has a ` +
`complex expression in the dependency array. ` +
'Extract it to a separate variable so it can be statically checked.',
});
}
return;
} else {
throw error;
}
}
let maybeID = declaredDependencyNode;
while (
maybeID.type === 'MemberExpression' ||
maybeID.type === 'OptionalMemberExpression' ||
maybeID.type === 'ChainExpression'
) {
maybeID = maybeID.object || maybeID.expression.object;
}
const isDeclaredInComponent = !componentScope.through.some(
ref => ref.identifier === maybeID,
);
declaredDependencies.push({
key: declaredDependency,
node: declaredDependencyNode,
});
if (!isDeclaredInComponent) {
externalDependencies.add(declaredDependency);
}
});
}
const {
suggestedDependencies,
unnecessaryDependencies,
missingDependencies,
duplicateDependencies,
} = collectRecommendations({
dependencies,
declaredDependencies,
stableDependencies,
externalDependencies,
isEffect,
});
let suggestedDeps = suggestedDependencies;
const problemCount =
duplicateDependencies.size +
missingDependencies.size +
unnecessaryDependencies.size;
if (problemCount === 0) {
const constructions = scanForConstructions({
declaredDependencies,
declaredDependenciesNode,
componentScope,
scope,
});
constructions.forEach(
({construction, isUsedOutsideOfHook, depType}) => {
const wrapperHook =
depType === 'function' ? 'useCallback' : 'useMemo';
const constructionType =
depType === 'function' ? 'definition' : 'initialization';
const defaultAdvice = `wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`;
const advice = isUsedOutsideOfHook
? `To fix this, ${defaultAdvice}`
: `Move it inside the ${reactiveHookName} callback. Alternatively, ${defaultAdvice}`;
const causation =
depType === 'conditional' || depType === 'logical expression'
? 'could make'
: 'makes';
const message =
`The '${construction.name.name}' ${depType} ${causation} the dependencies of ` +
`${reactiveHookName} Hook (at line ${declaredDependenciesNode.loc.start.line}) ` +
`change on every render. ${advice}`;
let suggest;
if (
isUsedOutsideOfHook &&
construction.type === 'Variable' &&
depType === 'function'
) {
suggest = [
{
desc: `Wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`,
fix(fixer) {
const [before, after] =
wrapperHook === 'useMemo'
? [`useMemo(() => { return `, '; })']
: ['useCallback(', ')'];
return [
fixer.insertTextBefore(construction.node.init, before),
fixer.insertTextAfter(construction.node.init, after),
];
},
},
];
}
reportProblem({
node: construction.node,
message,
suggest,
});
},
);
return;
}
if (!isEffect && missingDependencies.size > 0) {
suggestedDeps = collectRecommendations({
dependencies,
declaredDependencies: [],
stableDependencies,
externalDependencies,
isEffect,
}).suggestedDependencies;
}
function areDeclaredDepsAlphabetized() {
if (declaredDependencies.length === 0) {
return true;
}
const declaredDepKeys = declaredDependencies.map(dep => dep.key);
const sortedDeclaredDepKeys = declaredDepKeys.slice().sort();
return declaredDepKeys.join(',') === sortedDeclaredDepKeys.join(',');
}
if (areDeclaredDepsAlphabetized()) {
suggestedDeps.sort();
}
function formatDependency(path) {
const members = path.split('.');
let finalPath = '';
for (let i = 0; i < members.length; i++) {
if (i !== 0) {
const pathSoFar = members.slice(0, i + 1).join('.');
const isOptional = optionalChains.get(pathSoFar) === true;
finalPath += isOptional ? '?.' : '.';
}
finalPath += members[i];
}
return finalPath;
}
function getWarningMessage(deps, singlePrefix, label, fixVerb) {
if (deps.size === 0) {
return null;
}
return (
(deps.size > 1 ? '' : singlePrefix + ' ') +
label +
' ' +
(deps.size > 1 ? 'dependencies' : 'dependency') +
': ' +
joinEnglish(
Array.from(deps)
.sort()
.map(name => "'" + formatDependency(name) + "'"),
) +
`. Either ${fixVerb} ${
deps.size > 1 ? 'them' : 'it'
} or remove the dependency array.`
);
}
let extraWarning = '';
if (unnecessaryDependencies.size > 0) {
let badRef = null;
Array.from(unnecessaryDependencies.keys()).forEach(key => {
if (badRef !== null) {
return;
}
if (key.endsWith('.current')) {
badRef = key;
}
});
if (badRef !== null) {
extraWarning =
` Mutable values like '${badRef}' aren't valid dependencies ` +
"because mutating them doesn't re-render the component.";
} else if (externalDependencies.size > 0) {
const dep = Array.from(externalDependencies)[0];
if (!scope.set.has(dep)) {
extraWarning =
` Outer scope values like '${dep}' aren't valid dependencies ` +
`because mutating them doesn't re-render the component.`;
}
}
}
if (!extraWarning && missingDependencies.has('props')) {
const propDep = dependencies.get('props');
if (propDep == null) {
return;
}
const refs = propDep.references;
if (!Array.isArray(refs)) {
return;
}
let isPropsOnlyUsedInMembers = true;
for (let i = 0; i < refs.length; i++) {
const ref = refs[i];
const id = fastFindReferenceWithParent(
componentScope.block,
ref.identifier,
);
if (!id) {
isPropsOnlyUsedInMembers = false;
break;
}
const parent = id.parent;
if (parent == null) {
isPropsOnlyUsedInMembers = false;
break;
}
if (
parent.type !== 'MemberExpression' &&
parent.type !== 'OptionalMemberExpression'
) {
isPropsOnlyUsedInMembers = false;
break;
}
}
if (isPropsOnlyUsedInMembers) {
extraWarning =
` However, 'props' will change when *any* prop changes, so the ` +
`preferred fix is to destructure the 'props' object outside of ` +
`the ${reactiveHookName} call and refer to those specific props ` +
`inside ${getSource(reactiveHook)}.`;
}
}
if (!extraWarning && missingDependencies.size > 0) {
let missingCallbackDep = null;
missingDependencies.forEach(missingDep => {
if (missingCallbackDep) {
return;
}
const topScopeRef = componentScope.set.get(missingDep);
const usedDep = dependencies.get(missingDep);
if (usedDep.references[0].resolved !== topScopeRef) {
return;
}
const def = topScopeRef.defs[0];
if (def == null || def.name == null || def.type !== 'Parameter') {
return;
}
let isFunctionCall = false;
let id;
for (let i = 0; i < usedDep.references.length; i++) {
id = usedDep.references[i].identifier;
if (
id != null &&
id.parent != null &&
(id.parent.type === 'CallExpression' ||
id.parent.type === 'OptionalCallExpression') &&
id.parent.callee === id
) {
isFunctionCall = true;
break;
}
}
if (!isFunctionCall) {
return;
}
missingCallbackDep = missingDep;
});
if (missingCallbackDep !== null) {
extraWarning =
` If '${missingCallbackDep}' changes too often, ` +
`find the parent component that defines it ` +
`and wrap that definition in useCallback.`;
}
}
if (!extraWarning && missingDependencies.size > 0) {
let setStateRecommendation = null;
missingDependencies.forEach(missingDep => {
if (setStateRecommendation !== null) {
return;
}
const usedDep = dependencies.get(missingDep);
const references = usedDep.references;
let id;
let maybeCall;
for (let i = 0; i < references.length; i++) {
id = references[i].identifier;
maybeCall = id.parent;
while (maybeCall != null && maybeCall !== componentScope.block) {
if (maybeCall.type === 'CallExpression') {
const correspondingStateVariable = setStateCallSites.get(
maybeCall.callee,
);
if (correspondingStateVariable != null) {
if (correspondingStateVariable.name === missingDep) {
setStateRecommendation = {
missingDep,
setter: maybeCall.callee.name,
form: 'updater',
};
} else if (stateVariables.has(id)) {
setStateRecommendation = {
missingDep,
setter: maybeCall.callee.name,
form: 'reducer',
};
} else {
const resolved = references[i].resolved;
if (resolved != null) {
const def = resolved.defs[0];
if (def != null && def.type === 'Parameter') {
setStateRecommendation = {
missingDep,
setter: maybeCall.callee.name,
form: 'inlineReducer',
};
}
}
}
break;
}
}
maybeCall = maybeCall.parent;
}
if (setStateRecommendation !== null) {
break;
}
}
});
if (setStateRecommendation !== null) {
switch (setStateRecommendation.form) {
case 'reducer':
extraWarning =
` You can also replace multiple useState variables with useReducer ` +
`if '${setStateRecommendation.setter}' needs the ` +
`current value of '${setStateRecommendation.missingDep}'.`;
break;
case 'inlineReducer':
extraWarning =
` If '${setStateRecommendation.setter}' needs the ` +
`current value of '${setStateRecommendation.missingDep}', ` +
`you can also switch to useReducer instead of useState and ` +
`read '${setStateRecommendation.missingDep}' in the reducer.`;
break;
case 'updater':
extraWarning =
` You can also do a functional update '${
setStateRecommendation.setter
}(${setStateRecommendation.missingDep.slice(
0,
1,
)} => ...)' if you only need '${
setStateRecommendation.missingDep
}'` + ` in the '${setStateRecommendation.setter}' call.`;
break;
default:
throw new Error('Unknown case.');
}
}
}
reportProblem({
node: declaredDependenciesNode,
message:
`React Hook ${getSource(reactiveHook)} has ` +
(getWarningMessage(missingDependencies, 'a', 'missing', 'include') ||
getWarningMessage(
unnecessaryDependencies,
'an',
'unnecessary',
'exclude',
) ||
getWarningMessage(
duplicateDependencies,
'a',
'duplicate',
'omit',
)) +
extraWarning,
suggest: [
{
desc: `Update the dependencies array to be: [${suggestedDeps
.map(formatDependency)
.join(', ')}]`,
fix(fixer) {
return fixer.replaceText(
declaredDependenciesNode,
`[${suggestedDeps.map(formatDependency).join(', ')}]`,
);
},
},
],
});
}
function visitCallExpression(node) {
const callbackIndex = getReactiveHookCallbackIndex(node.callee, options);
if (callbackIndex === -1) {
return;
}
const callback = node.arguments[callbackIndex];
const reactiveHook = node.callee;
const reactiveHookName = getNodeWithoutReactNamespace(reactiveHook).name;
const maybeNode = node.arguments[callbackIndex + 1];
const declaredDependenciesNode =
maybeNode &&
!(maybeNode.type === 'Identifier' && maybeNode.name === 'undefined')
? maybeNode
: undefined;
const isEffect = /Effect($|[^a-z])/g.test(reactiveHookName);
if (!callback) {
reportProblem({
node: reactiveHook,
message:
`React Hook ${reactiveHookName} requires an effect callback. ` +
`Did you forget to pass a callback to the hook?`,
});
return;
}
if (!declaredDependenciesNode && !isEffect) {
if (
reactiveHookName === 'useMemo' ||
reactiveHookName === 'useCallback'
) {
reportProblem({
node: reactiveHook,
message:
`React Hook ${reactiveHookName} does nothing when called with ` +
`only one argument. Did you forget to pass an array of ` +
`dependencies?`,
});
}
return;
}
switch (callback.type) {
case 'FunctionExpression':
case 'ArrowFunctionExpression':
visitFunctionWithDependencies(
callback,
declaredDependenciesNode,
reactiveHook,
reactiveHookName,
isEffect,
);
return;
case 'TSAsExpression':
visitFunctionWithDependencies(
callback.expression,
declaredDependenciesNode,
reactiveHook,
reactiveHookName,
isEffect,
);
return;
case 'Identifier':
if (!declaredDependenciesNode) {
return;
}
if (
declaredDependenciesNode.elements &&
declaredDependenciesNode.elements.some(
el => el && el.type === 'Identifier' && el.name === callback.name,
)
) {
return;
}
const variable = getScope(callback).set.get(callback.name);
if (variable == null || variable.defs == null) {
return;
}
const def = variable.defs[0];
if (!def || !def.node) {
break;
}
if (def.type !== 'Variable' && def.type !== 'FunctionName') {
break;
}
switch (def.node.type) {
case 'FunctionDeclaration':
visitFunctionWithDependencies(
def.node,
declaredDependenciesNode,
reactiveHook,
reactiveHookName,
isEffect,
);
return;
case 'VariableDeclarator':
const init = def.node.init;
if (!init) {
break;
}
switch (init.type) {
case 'ArrowFunctionExpression':
case 'FunctionExpression':
visitFunctionWithDependencies(
init,
declaredDependenciesNode,
reactiveHook,
reactiveHookName,
isEffect,
);
return;
}
break;
}
break;
default:
reportProblem({
node: reactiveHook,
message:
`React Hook ${reactiveHookName} received a function whose dependencies ` +
`are unknown. Pass an inline function instead.`,
});
return;
}
reportProblem({
node: reactiveHook,
message:
`React Hook ${reactiveHookName} has a missing dependency: '${callback.name}'. ` +
`Either include it or remove the dependency array.`,
suggest: [
{
desc: `Update the dependencies array to be: [${callback.name}]`,
fix(fixer) {
return fixer.replaceText(
declaredDependenciesNode,
`[${callback.name}]`,
);
},
},
],
});
}
return {
CallExpression: visitCallExpression,
};
},
};
function collectRecommendations({
dependencies,
declaredDependencies,
stableDependencies,
externalDependencies,
isEffect,
}) {
const depTree = createDepTree();
function createDepTree() {
return {
isUsed: false,
isSatisfiedRecursively: false,
isSubtreeUsed: false,
children: new Map(),
};
}
dependencies.forEach((_, key) => {
const node = getOrCreateNodeByPath(depTree, key);
node.isUsed = true;
markAllParentsByPath(depTree, key, parent => {
parent.isSubtreeUsed = true;
});
});
declaredDependencies.forEach(({key}) => {
const node = getOrCreateNodeByPath(depTree, key);
node.isSatisfiedRecursively = true;
});
stableDependencies.forEach(key => {
const node = getOrCreateNodeByPath(depTree, key);
node.isSatisfiedRecursively = true;
});
function getOrCreateNodeByPath(rootNode, path) {
const keys = path.split('.');
let node = rootNode;
for (const key of keys) {
let child = node.children.get(key);
if (!child) {
child = createDepTree();
node.children.set(key, child);
}
node = child;
}
return node;
}
function markAllParentsByPath(rootNode, path, fn) {
const keys = path.split('.');
let node = rootNode;
for (const key of keys) {
const child = node.children.get(key);
if (!child) {
return;
}
fn(child);
node = child;
}
}
const missingDependencies = new Set();
const satisfyingDependencies = new Set();
scanTreeRecursively(
depTree,
missingDependencies,
satisfyingDependencies,
key => key,
);
function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) {
node.children.forEach((child, key) => {
const path = keyToPath(key);
if (child.isSatisfiedRecursively) {
if (child.isSubtreeUsed) {
satisfyingPaths.add(path);
}
return;
}
if (child.isUsed) {
missingPaths.add(path);
return;
}
scanTreeRecursively(
child,
missingPaths,
satisfyingPaths,
childKey => path + '.' + childKey,
);
});
}
const suggestedDependencies = [];
const unnecessaryDependencies = new Set();
const duplicateDependencies = new Set();
declaredDependencies.forEach(({key}) => {
if (satisfyingDependencies.has(key)) {
if (suggestedDependencies.indexOf(key) === -1) {
suggestedDependencies.push(key);
} else {
duplicateDependencies.add(key);
}
} else {
if (
isEffect &&
!key.endsWith('.current') &&
!externalDependencies.has(key)
) {
if (suggestedDependencies.indexOf(key) === -1) {
suggestedDependencies.push(key);
}
} else {
unnecessaryDependencies.add(key);
}
}
});
missingDependencies.forEach(key => {
suggestedDependencies.push(key);
});
return {
suggestedDependencies,
unnecessaryDependencies,
duplicateDependencies,
missingDependencies,
};
}
function getConstructionExpressionType(node) {
switch (node.type) {
case 'ObjectExpression':
return 'object';
case 'ArrayExpression':
return 'array';
case 'ArrowFunctionExpression':
case 'FunctionExpression':
return 'function';
case 'ClassExpression':
return 'class';
case 'ConditionalExpression':
if (
getConstructionExpressionType(node.consequent) != null ||
getConstructionExpressionType(node.alternate) != null
) {
return 'conditional';
}
return null;
case 'LogicalExpression':
if (
getConstructionExpressionType(node.left) != null ||
getConstructionExpressionType(node.right) != null
) {
return 'logical expression';
}
return null;
case 'JSXFragment':
return 'JSX fragment';
case 'JSXElement':
return 'JSX element';
case 'AssignmentExpression':
if (getConstructionExpressionType(node.right) != null) {
return 'assignment expression';
}
return null;
case 'NewExpression':
return 'object construction';
case 'Literal':
if (node.value instanceof RegExp) {
return 'regular expression';
}
return null;
case 'TypeCastExpression':
case 'AsExpression':
case 'TSAsExpression':
return getConstructionExpressionType(node.expression);
}
return null;
}
function scanForConstructions({
declaredDependencies,
declaredDependenciesNode,
componentScope,
scope,
}) {
const constructions = declaredDependencies
.map(({key}) => {
const ref = componentScope.variables.find(v => v.name === key);
if (ref == null) {
return null;
}
const node = ref.defs[0];
if (node == null) {
return null;
}
if (
node.type === 'Variable' &&
node.node.type === 'VariableDeclarator' &&
node.node.id.type === 'Identifier' &&
node.node.init != null
) {
const constantExpressionType = getConstructionExpressionType(
node.node.init,
);
if (constantExpressionType != null) {
return [ref, constantExpressionType];
}
}
if (
node.type === 'FunctionName' &&
node.node.type === 'FunctionDeclaration'
) {
return [ref, 'function'];
}
if (node.type === 'ClassName' && node.node.type === 'ClassDeclaration') {
return [ref, 'class'];
}
return null;
})
.filter(Boolean);
function isUsedOutsideOfHook(ref) {
let foundWriteExpr = false;
for (let i = 0; i < ref.references.length; i++) {
const reference = ref.references[i];
if (reference.writeExpr) {
if (foundWriteExpr) {
return true;
} else {
foundWriteExpr = true;
continue;
}
}
let currentScope = reference.from;
while (currentScope !== scope && currentScope != null) {
currentScope = currentScope.upper;
}
if (currentScope !== scope) {
if (!isAncestorNodeOf(declaredDependenciesNode, reference.identifier)) {
return true;
}
}
}
return false;
}
return constructions.map(([ref, depType]) => ({
construction: ref.defs[0],
depType,
isUsedOutsideOfHook: isUsedOutsideOfHook(ref),
}));
}
function getDependency(node) {
if (
(node.parent.type === 'MemberExpression' ||
node.parent.type === 'OptionalMemberExpression') &&
node.parent.object === node &&
node.parent.property.name !== 'current' &&
!node.parent.computed &&
!(
node.parent.parent != null &&
(node.parent.parent.type === 'CallExpression' ||
node.parent.parent.type === 'OptionalCallExpression') &&
node.parent.parent.callee === node.parent
)
) {
return getDependency(node.parent);
} else if (
node.type === 'MemberExpression' &&
node.parent &&
node.parent.type === 'AssignmentExpression' &&
node.parent.left === node
) {
return node.object;
} else {
return node;
}
}
function markNode(node, optionalChains, result) {
if (optionalChains) {
if (node.optional) {
if (!optionalChains.has(result)) {
optionalChains.set(result, true);
}
} else {
optionalChains.set(result, false);
}
}
}
function analyzePropertyChain(node, optionalChains) {
if (node.type === 'Identifier' || node.type === 'JSXIdentifier') {
const result = node.name;
if (optionalChains) {
optionalChains.set(result, false);
}
return result;
} else if (node.type === 'MemberExpression' && !node.computed) {
const object = analyzePropertyChain(node.object, optionalChains);
const property = analyzePropertyChain(node.property, null);
const result = `${object}.${property}`;
markNode(node, optionalChains, result);
return result;
} else if (node.type === 'OptionalMemberExpression' && !node.computed) {
const object = analyzePropertyChain(node.object, optionalChains);
const property = analyzePropertyChain(node.property, null);
const result = `${object}.${property}`;
markNode(node, optionalChains, result);
return result;
} else if (node.type === 'ChainExpression' && !node.computed) {
const expression = node.expression;
if (expression.type === 'CallExpression') {
throw new Error(`Unsupported node type: ${expression.type}`);
}
const object = analyzePropertyChain(expression.object, optionalChains);
const property = analyzePropertyChain(expression.property, null);
const result = `${object}.${property}`;
markNode(expression, optionalChains, result);
return result;
} else {
throw new Error(`Unsupported node type: ${node.type}`);
}
}
function getNodeWithoutReactNamespace(node, options) {
if (
node.type === 'MemberExpression' &&
node.object.type === 'Identifier' &&
node.object.name === 'React' &&
node.property.type === 'Identifier' &&
!node.computed
) {
return node.property;
}
return node;
}
function getReactiveHookCallbackIndex(calleeNode, options) {
const node = getNodeWithoutReactNamespace(calleeNode);
if (node.type !== 'Identifier') {
return -1;
}
switch (node.name) {
case 'useEffect':
case 'useLayoutEffect':
case 'useCallback':
case 'useMemo':
return 0;
case 'useImperativeHandle':
return 1;
default:
if (node === calleeNode && options && options.additionalHooks) {
let name;
try {
name = analyzePropertyChain(node, null);
} catch (error) {
if (/Unsupported node type/.test(error.message)) {
return 0;
} else {
throw error;
}
}
return options.additionalHooks.test(name) ? 0 : -1;
} else {
return -1;
}
}
}
function fastFindReferenceWithParent(start, target) {
const queue = [start];
let item = null;
while (queue.length) {
item = queue.shift();
if (isSameIdentifier(item, target)) {
return item;
}
if (!isAncestorNodeOf(item, target)) {
continue;
}
for (const [key, value] of Object.entries(item)) {
if (key === 'parent') {
continue;
}
if (isNodeLike(value)) {
value.parent = item;
queue.push(value);
} else if (Array.isArray(value)) {
value.forEach(val => {
if (isNodeLike(val)) {
val.parent = item;
queue.push(val);
}
});
}
}
}
return null;
}
function joinEnglish(arr) {
let s = '';
for (let i = 0; i < arr.length; i++) {
s += arr[i];
if (i === 0 && arr.length === 2) {
s += ' and ';
} else if (i === arr.length - 2 && arr.length > 2) {
s += ', and ';
} else if (i < arr.length - 1) {
s += ', ';
}
}
return s;
}
function isNodeLike(val) {
return (
typeof val === 'object' &&
val !== null &&
!Array.isArray(val) &&
typeof val.type === 'string'
);
}
function isSameIdentifier(a, b) {
return (
(a.type === 'Identifier' || a.type === 'JSXIdentifier') &&
a.type === b.type &&
a.name === b.name &&
a.range[0] === b.range[0] &&
a.range[1] === b.range[1]
);
}
function isAncestorNodeOf(a, b) {
return a.range[0] <= b.range[0] && a.range[1] >= b.range[1];
}
function isUseEffectEventIdentifier(node) {
if (__EXPERIMENTAL__) {
return node.type === 'Identifier' && node.name === 'useEffectEvent';
}
return false;
}