main
ts 2,138 lines 72.1 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7 /* eslint-disable no-for-of-loops/no-for-of-loops */
8 import type {Rule, Scope} from 'eslint';
9 import type {
10 ArrayExpression,
11 ArrowFunctionExpression,
12 CallExpression,
13 Expression,
14 FunctionDeclaration,
15 FunctionExpression,
16 Identifier,
17 Node,
18 Pattern,
19 PrivateIdentifier,
20 Super,
21 VariableDeclarator,
22 } from 'estree';
23
24 import {getAdditionalEffectHooksFromSettings} from '../shared/Utils';
25
26 type DeclaredDependency = {
27 key: string;
28 node: Node;
29 };
30
31 type Dependency = {
32 isStable: boolean;
33 references: Array<Scope.Reference>;
34 };
35
36 type DependencyTreeNode = {
37 isUsed: boolean; // True if used in code
38 isSatisfiedRecursively: boolean; // True if specified in deps
39 isSubtreeUsed: boolean; // True if something deeper is used by code
40 children: Map<string, DependencyTreeNode>; // Nodes for properties
41 };
42
43 const rule = {
44 meta: {
45 type: 'suggestion',
46 docs: {
47 description:
48 'verifies the list of dependencies for Hooks like useEffect and similar',
49 recommended: true,
50 url: 'https://github.com/facebook/react/issues/14920',
51 },
52 fixable: 'code',
53 hasSuggestions: true,
54 schema: [
55 {
56 type: 'object',
57 additionalProperties: false,
58 enableDangerousAutofixThisMayCauseInfiniteLoops: false,
59 properties: {
60 additionalHooks: {
61 type: 'string',
62 },
63 enableDangerousAutofixThisMayCauseInfiniteLoops: {
64 type: 'boolean',
65 },
66 experimental_autoDependenciesHooks: {
67 type: 'array',
68 items: {
69 type: 'string',
70 },
71 },
72 requireExplicitEffectDeps: {
73 type: 'boolean',
74 },
75 },
76 },
77 ],
78 },
79 create(context: Rule.RuleContext) {
80 const rawOptions = context.options && context.options[0];
81 const settings = context.settings || {};
82
83 // Parse the `additionalHooks` regex.
84 // Use rule-level additionalHooks if provided, otherwise fall back to settings
85 const additionalHooks =
86 rawOptions && rawOptions.additionalHooks
87 ? new RegExp(rawOptions.additionalHooks)
88 : getAdditionalEffectHooksFromSettings(settings);
89
90 const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
91 (rawOptions &&
92 rawOptions.enableDangerousAutofixThisMayCauseInfiniteLoops) ||
93 false;
94
95 const experimental_autoDependenciesHooks: ReadonlyArray<string> =
96 rawOptions && Array.isArray(rawOptions.experimental_autoDependenciesHooks)
97 ? rawOptions.experimental_autoDependenciesHooks
98 : [];
99
100 const requireExplicitEffectDeps: boolean =
101 (rawOptions && rawOptions.requireExplicitEffectDeps) || false;
102
103 const options = {
104 additionalHooks,
105 experimental_autoDependenciesHooks,
106 enableDangerousAutofixThisMayCauseInfiniteLoops,
107 requireExplicitEffectDeps,
108 };
109
110 function reportProblem(problem: Rule.ReportDescriptor) {
111 if (enableDangerousAutofixThisMayCauseInfiniteLoops) {
112 // Used to enable legacy behavior. Dangerous.
113 // Keep this as an option until major IDEs upgrade (including VSCode FB ESLint extension).
114 if (
115 Array.isArray(problem.suggest) &&
116 problem.suggest.length > 0 &&
117 problem.suggest[0]
118 ) {
119 problem.fix = problem.suggest[0].fix;
120 }
121 }
122 context.report(problem);
123 }
124
125 /**
126 * SourceCode that also works down to ESLint 3.0.0
127 */
128 const getSourceCode =
129 typeof context.getSourceCode === 'function'
130 ? () => {
131 return context.getSourceCode();
132 }
133 : () => {
134 return context.sourceCode;
135 };
136 /**
137 * SourceCode#getScope that also works down to ESLint 3.0.0
138 */
139 const getScope =
140 typeof context.getScope === 'function'
141 ? () => {
142 return context.getScope();
143 }
144 : (node: Node) => {
145 return context.sourceCode.getScope(node);
146 };
147
148 const scopeManager = getSourceCode().scopeManager;
149
150 // Should be shared between visitors.
151 const setStateCallSites = new WeakMap<
152 Expression | Super,
153 Pattern | null | undefined
154 >();
155 const stateVariables = new WeakSet<Identifier>();
156 const stableKnownValueCache = new WeakMap<Scope.Variable, boolean>();
157 const functionWithoutCapturedValueCache = new WeakMap<
158 Scope.Variable,
159 boolean
160 >();
161 const useEffectEventVariables = new WeakSet<Expression>();
162
163 function memoizeWithWeakMap(
164 fn: (resolved: Scope.Variable) => boolean,
165 map: WeakMap<Scope.Variable, boolean>,
166 ) {
167 return function (arg: Scope.Variable): boolean {
168 if (map.has(arg)) {
169 // to verify cache hits:
170 // console.log(arg.name)
171 return map.get(arg)!;
172 }
173 const result = fn(arg);
174 map.set(arg, result);
175 return result;
176 };
177 }
178 /**
179 * Visitor for both function expressions and arrow function expressions.
180 */
181 function visitFunctionWithDependencies(
182 node: ArrowFunctionExpression | FunctionDeclaration | FunctionExpression,
183 declaredDependenciesNode: Node | undefined,
184 reactiveHook: Node,
185 reactiveHookName: string,
186 isEffect: boolean,
187 isAutoDepsHook: boolean,
188 ): void {
189 if (isEffect && node.async) {
190 reportProblem({
191 node: node,
192 message:
193 `Effect callbacks are synchronous to prevent race conditions. ` +
194 `Put the async function inside:\n\n` +
195 'useEffect(() => {\n' +
196 ' async function fetchData() {\n' +
197 ' // You can await here\n' +
198 ' const response = await MyAPI.getData(someId);\n' +
199 ' // ...\n' +
200 ' }\n' +
201 ' fetchData();\n' +
202 `}, [someId]); // Or [] if effect doesn't need props or state\n\n` +
203 'Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching',
204 });
205 }
206
207 // Get the current scope.
208 const scope = scopeManager.acquire(node);
209 if (!scope) {
210 throw new Error(
211 'Unable to acquire scope for the current node. This is a bug in eslint-plugin-react-hooks, please file an issue.',
212 );
213 }
214
215 // Find all our "pure scopes". On every re-render of a component these
216 // pure scopes may have changes to the variables declared within. So all
217 // variables used in our reactive hook callback but declared in a pure
218 // scope need to be listed as dependencies of our reactive hook callback.
219 //
220 // According to the rules of React you can't read a mutable value in pure
221 // scope. We can't enforce this in a lint so we trust that all variables
222 // declared outside of pure scope are indeed frozen.
223 const pureScopes = new Set();
224 let componentScope: Scope.Scope | null = null;
225 {
226 let currentScope = scope.upper;
227 while (currentScope) {
228 pureScopes.add(currentScope);
229 if (
230 currentScope.type === 'function' ||
231 // @ts-expect-error incorrect TS types
232 currentScope.type === 'hook' ||
233 // @ts-expect-error incorrect TS types
234 currentScope.type === 'component'
235 ) {
236 break;
237 }
238 currentScope = currentScope.upper;
239 }
240 // If there is no parent function scope then there are no pure scopes.
241 // The ones we've collected so far are incorrect. So don't continue with
242 // the lint.
243 if (!currentScope) {
244 return;
245 }
246 componentScope = currentScope;
247 }
248
249 const isArray = Array.isArray;
250
251 // Next we'll define a few helpers that helps us
252 // tell if some values don't have to be declared as deps.
253
254 // Some are known to be stable based on Hook calls.
255 // const [state, setState] = useState() / React.useState()
256 // ^^^ true for this reference
257 // const [state, dispatch] = useReducer() / React.useReducer()
258 // ^^^ true for this reference
259 // const [state, dispatch] = useActionState() / React.useActionState()
260 // ^^^ true for this reference
261 // const ref = useRef()
262 // ^^^ true for this reference
263 // const onStuff = useEffectEvent(() => {})
264 // ^^^ true for this reference
265 // False for everything else.
266 function isStableKnownHookValue(resolved: Scope.Variable): boolean {
267 if (!isArray(resolved.defs)) {
268 return false;
269 }
270 const def = resolved.defs[0];
271 if (def == null) {
272 return false;
273 }
274 // Look for `let stuff = ...`
275 const defNode: VariableDeclarator = def.node;
276 if (defNode.type !== 'VariableDeclarator') {
277 return false;
278 }
279 let init = defNode.init;
280 if (init == null) {
281 return false;
282 }
283 while (init.type === 'TSAsExpression' || init.type === 'AsExpression') {
284 init = init.expression;
285 }
286 // Detect primitive constants
287 // const foo = 42
288 let declaration = defNode.parent;
289 if (declaration == null && componentScope != null) {
290 // This might happen if variable is declared after the callback.
291 // In that case ESLint won't set up .parent refs.
292 // So we'll set them up manually.
293 fastFindReferenceWithParent(componentScope.block, def.node.id);
294 declaration = def.node.parent;
295 if (declaration == null) {
296 return false;
297 }
298 }
299 if (
300 declaration != null &&
301 'kind' in declaration &&
302 declaration.kind === 'const' &&
303 init.type === 'Literal' &&
304 (typeof init.value === 'string' ||
305 typeof init.value === 'number' ||
306 init.value === null)
307 ) {
308 // Definitely stable
309 return true;
310 }
311 // Detect known Hook calls
312 // const [_, setState] = useState()
313 if (init.type !== 'CallExpression') {
314 return false;
315 }
316 let callee: Expression | PrivateIdentifier | Super = init.callee;
317 // Step into `= React.something` initializer.
318 if (
319 callee.type === 'MemberExpression' &&
320 'name' in callee.object &&
321 callee.object.name === 'React' &&
322 callee.property != null &&
323 !callee.computed
324 ) {
325 callee = callee.property;
326 }
327 if (callee.type !== 'Identifier') {
328 return false;
329 }
330 const definitionNode: VariableDeclarator = def.node;
331 const id = definitionNode.id;
332 const {name} = callee;
333 if (name === 'useRef' && id.type === 'Identifier') {
334 // useRef() return value is stable.
335 return true;
336 } else if (
337 isUseEffectEventIdentifier(callee) &&
338 id.type === 'Identifier'
339 ) {
340 for (const ref of resolved.references) {
341 // @ts-expect-error These types are not compatible (Reference and Identifier)
342 if (ref !== id) {
343 useEffectEventVariables.add(ref.identifier);
344 }
345 }
346 // useEffectEvent() return value is always unstable.
347 return true;
348 } else if (
349 name === 'useState' ||
350 name === 'useReducer' ||
351 name === 'useActionState'
352 ) {
353 // Only consider second value in initializing tuple stable.
354 if (
355 id.type === 'ArrayPattern' &&
356 id.elements.length === 2 &&
357 isArray(resolved.identifiers)
358 ) {
359 // Is second tuple value the same reference we're checking?
360 if (id.elements[1] === resolved.identifiers[0]) {
361 if (name === 'useState') {
362 const references = resolved.references;
363 let writeCount = 0;
364 for (const reference of references) {
365 if (reference.isWrite()) {
366 writeCount++;
367 }
368 if (writeCount > 1) {
369 return false;
370 }
371 setStateCallSites.set(reference.identifier, id.elements[0]);
372 }
373 }
374 // Setter is stable.
375 return true;
376 } else if (id.elements[0] === resolved.identifiers[0]) {
377 if (name === 'useState') {
378 const references = resolved.references;
379 for (const reference of references) {
380 stateVariables.add(reference.identifier);
381 }
382 }
383 // State variable itself is dynamic.
384 return false;
385 }
386 }
387 } else if (name === 'useTransition') {
388 // Only consider second value in initializing tuple stable.
389 if (
390 id.type === 'ArrayPattern' &&
391 id.elements.length === 2 &&
392 Array.isArray(resolved.identifiers)
393 ) {
394 // Is second tuple value the same reference we're checking?
395 if (id.elements[1] === resolved.identifiers[0]) {
396 // Setter is stable.
397 return true;
398 }
399 }
400 }
401 // By default assume it's dynamic.
402 return false;
403 }
404
405 // Some are just functions that don't reference anything dynamic.
406 function isFunctionWithoutCapturedValues(
407 resolved: Scope.Variable,
408 ): boolean {
409 if (!isArray(resolved.defs)) {
410 return false;
411 }
412 const def = resolved.defs[0];
413 if (def == null) {
414 return false;
415 }
416 if (def.node == null || def.node.id == null) {
417 return false;
418 }
419 // Search the direct component subscopes for
420 // top-level function definitions matching this reference.
421 const fnNode: Node = def.node;
422 const childScopes = componentScope?.childScopes || [];
423 let fnScope = null;
424 for (const childScope of childScopes) {
425 const childScopeBlock = childScope.block;
426 if (
427 // function handleChange() {}
428 (fnNode.type === 'FunctionDeclaration' &&
429 childScopeBlock === fnNode) ||
430 // const handleChange = () => {}
431 // const handleChange = function() {}
432 (fnNode.type === 'VariableDeclarator' &&
433 childScopeBlock.parent === fnNode)
434 ) {
435 // Found it!
436 fnScope = childScope;
437 break;
438 }
439 }
440 if (fnScope == null) {
441 return false;
442 }
443 // Does this function capture any values
444 // that are in pure scopes (aka render)?
445 for (const ref of fnScope.through) {
446 if (ref.resolved == null) {
447 continue;
448 }
449 if (
450 pureScopes.has(ref.resolved.scope) &&
451 // Stable values are fine though,
452 // although we won't check functions deeper.
453 !memoizedIsStableKnownHookValue(ref.resolved)
454 ) {
455 return false;
456 }
457 }
458 // If we got here, this function doesn't capture anything
459 // from render--or everything it captures is known stable.
460 return true;
461 }
462
463 // Remember such values. Avoid re-running extra checks on them.
464 const memoizedIsStableKnownHookValue = memoizeWithWeakMap(
465 isStableKnownHookValue,
466 stableKnownValueCache,
467 );
468 const memoizedIsFunctionWithoutCapturedValues = memoizeWithWeakMap(
469 isFunctionWithoutCapturedValues,
470 functionWithoutCapturedValueCache,
471 );
472
473 // These are usually mistaken. Collect them.
474 const currentRefsInEffectCleanup = new Map<
475 string,
476 {
477 reference: Scope.Reference;
478 dependencyNode: Identifier;
479 }
480 >();
481
482 // Is this reference inside a cleanup function for this effect node?
483 // We can check by traversing scopes upwards from the reference, and checking
484 // if the last "return () => " we encounter is located directly inside the effect.
485 function isInsideEffectCleanup(reference: Scope.Reference): boolean {
486 let curScope: Scope.Scope | null = reference.from;
487 let isInReturnedFunction = false;
488 while (curScope != null && curScope.block !== node) {
489 if (curScope.type === 'function') {
490 isInReturnedFunction =
491 curScope.block.parent != null &&
492 curScope.block.parent.type === 'ReturnStatement';
493 }
494 curScope = curScope.upper;
495 }
496 return isInReturnedFunction;
497 }
498
499 // Get dependencies from all our resolved references in pure scopes.
500 // Key is dependency string, value is whether it's stable.
501 const dependencies = new Map<string, Dependency>();
502 const optionalChains = new Map<string, boolean>();
503 gatherDependenciesRecursively(scope);
504
505 function gatherDependenciesRecursively(currentScope: Scope.Scope): void {
506 for (const reference of currentScope.references) {
507 // If this reference is not resolved or it is not declared in a pure
508 // scope then we don't care about this reference.
509 if (!reference.resolved) {
510 continue;
511 }
512 if (!pureScopes.has(reference.resolved.scope)) {
513 continue;
514 }
515
516 // Narrow the scope of a dependency if it is, say, a member expression.
517 // Then normalize the narrowed dependency.
518 const referenceNode = fastFindReferenceWithParent(
519 node,
520 reference.identifier,
521 );
522 if (referenceNode == null) {
523 continue;
524 }
525 const dependencyNode = getDependency(referenceNode);
526 const dependency = analyzePropertyChain(
527 dependencyNode,
528 optionalChains,
529 );
530
531 // Accessing ref.current inside effect cleanup is bad.
532 if (
533 // We're in an effect...
534 isEffect &&
535 // ... and this look like accessing .current...
536 dependencyNode.type === 'Identifier' &&
537 (dependencyNode.parent?.type === 'MemberExpression' ||
538 dependencyNode.parent?.type === 'OptionalMemberExpression') &&
539 !dependencyNode.parent.computed &&
540 dependencyNode.parent.property.type === 'Identifier' &&
541 dependencyNode.parent.property.name === 'current' &&
542 // ...in a cleanup function or below...
543 isInsideEffectCleanup(reference)
544 ) {
545 currentRefsInEffectCleanup.set(dependency, {
546 reference,
547 dependencyNode,
548 });
549 }
550
551 if (
552 dependencyNode.parent?.type === 'TSTypeQuery' ||
553 dependencyNode.parent?.type === 'TSTypeReference'
554 ) {
555 continue;
556 }
557
558 const def = reference.resolved.defs[0];
559 if (def == null) {
560 continue;
561 }
562 // Ignore references to the function itself as it's not defined yet.
563 if (def.node != null && def.node.init === node.parent) {
564 continue;
565 }
566 // Ignore Flow type parameters
567 if (
568 // @ts-expect-error We don't have flow types
569 def.type === 'TypeParameter' ||
570 // @ts-expect-error Flow-specific AST node type
571 dependencyNode.parent?.type === 'GenericTypeAnnotation'
572 ) {
573 continue;
574 }
575
576 // Add the dependency to a map so we can make sure it is referenced
577 // again in our dependencies array. Remember whether it's stable.
578 if (!dependencies.has(dependency)) {
579 const resolved = reference.resolved;
580 const isStable =
581 memoizedIsStableKnownHookValue(resolved) ||
582 memoizedIsFunctionWithoutCapturedValues(resolved);
583 dependencies.set(dependency, {
584 isStable,
585 references: [reference],
586 });
587 } else {
588 dependencies.get(dependency)?.references.push(reference);
589 }
590 }
591
592 for (const childScope of currentScope.childScopes) {
593 gatherDependenciesRecursively(childScope);
594 }
595 }
596
597 // Warn about accessing .current in cleanup effects.
598 currentRefsInEffectCleanup.forEach(
599 ({reference, dependencyNode}, dependency) => {
600 const references = reference.resolved?.references || [];
601 // Is React managing this ref or us?
602 // Let's see if we can find a .current assignment.
603 let foundCurrentAssignment = false;
604 for (const ref of references) {
605 const {identifier} = ref;
606 const {parent} = identifier;
607 if (
608 parent != null &&
609 // ref.current
610 // Note: no need to handle OptionalMemberExpression because it can't be LHS.
611 parent.type === 'MemberExpression' &&
612 !parent.computed &&
613 parent.property.type === 'Identifier' &&
614 parent.property.name === 'current' &&
615 // ref.current = <something>
616 parent.parent?.type === 'AssignmentExpression' &&
617 parent.parent.left === parent
618 ) {
619 foundCurrentAssignment = true;
620 break;
621 }
622 }
623 // We only want to warn about React-managed refs.
624 if (foundCurrentAssignment) {
625 return;
626 }
627 reportProblem({
628 // @ts-expect-error We can do better here (dependencyNode.parent has not been type narrowed)
629 node: dependencyNode.parent.property,
630 message:
631 `The ref value '${dependency}.current' will likely have ` +
632 `changed by the time this effect cleanup function runs. If ` +
633 `this ref points to a node rendered by React, copy ` +
634 `'${dependency}.current' to a variable inside the effect, and ` +
635 `use that variable in the cleanup function.`,
636 });
637 },
638 );
639
640 // Warn about assigning to variables in the outer scope.
641 // Those are usually bugs.
642 const staleAssignments = new Set<string>();
643 function reportStaleAssignment(writeExpr: Node, key: string): void {
644 if (staleAssignments.has(key)) {
645 return;
646 }
647 staleAssignments.add(key);
648 reportProblem({
649 node: writeExpr,
650 message:
651 `Assignments to the '${key}' variable from inside React Hook ` +
652 `${getSourceCode().getText(reactiveHook)} will be lost after each ` +
653 `render. To preserve the value over time, store it in a useRef ` +
654 `Hook and keep the mutable value in the '.current' property. ` +
655 `Otherwise, you can move this variable directly inside ` +
656 `${getSourceCode().getText(reactiveHook)}.`,
657 });
658 }
659
660 // Remember which deps are stable and report bad usage first.
661 const stableDependencies = new Set<string>();
662 dependencies.forEach(({isStable, references}, key) => {
663 if (isStable) {
664 stableDependencies.add(key);
665 }
666 references.forEach(reference => {
667 if (reference.writeExpr) {
668 reportStaleAssignment(reference.writeExpr, key);
669 }
670 });
671 });
672
673 if (staleAssignments.size > 0) {
674 // The intent isn't clear so we'll wait until you fix those first.
675 return;
676 }
677
678 if (!declaredDependenciesNode) {
679 if (isAutoDepsHook) {
680 return;
681 }
682 // Check if there are any top-level setState() calls.
683 // Those tend to lead to infinite loops.
684 let setStateInsideEffectWithoutDeps: string | null = null;
685 dependencies.forEach(({references}, key) => {
686 if (setStateInsideEffectWithoutDeps) {
687 return;
688 }
689 references.forEach(reference => {
690 if (setStateInsideEffectWithoutDeps) {
691 return;
692 }
693
694 const id = reference.identifier;
695 const isSetState = setStateCallSites.has(id);
696 if (!isSetState) {
697 return;
698 }
699
700 let fnScope: Scope.Scope | null = reference.from;
701 while (fnScope != null && fnScope.type !== 'function') {
702 fnScope = fnScope.upper;
703 }
704 const isDirectlyInsideEffect = fnScope?.block === node;
705 if (isDirectlyInsideEffect) {
706 // TODO: we could potentially ignore early returns.
707 setStateInsideEffectWithoutDeps = key;
708 }
709 });
710 });
711 if (setStateInsideEffectWithoutDeps) {
712 const {suggestedDependencies} = collectRecommendations({
713 dependencies,
714 declaredDependencies: [],
715 stableDependencies,
716 externalDependencies: new Set<string>(),
717 isEffect: true,
718 });
719 reportProblem({
720 node: reactiveHook,
721 message:
722 `React Hook ${reactiveHookName} contains a call to '${setStateInsideEffectWithoutDeps}'. ` +
723 `Without a list of dependencies, this can lead to an infinite chain of updates. ` +
724 `To fix this, pass [` +
725 suggestedDependencies.join(', ') +
726 `] as a second argument to the ${reactiveHookName} Hook.`,
727 suggest: [
728 {
729 desc: `Add dependencies array: [${suggestedDependencies.join(
730 ', ',
731 )}]`,
732 fix(fixer) {
733 return fixer.insertTextAfter(
734 node,
735 `, [${suggestedDependencies.join(', ')}]`,
736 );
737 },
738 },
739 ],
740 });
741 }
742 return;
743 }
744 if (
745 isAutoDepsHook &&
746 declaredDependenciesNode.type === 'Literal' &&
747 declaredDependenciesNode.value === null
748 ) {
749 return;
750 }
751
752 const declaredDependencies: Array<DeclaredDependency> = [];
753 const externalDependencies = new Set<string>();
754 const isArrayExpression =
755 declaredDependenciesNode.type === 'ArrayExpression';
756 const isTSAsArrayExpression =
757 declaredDependenciesNode.type === 'TSAsExpression' &&
758 declaredDependenciesNode.expression.type === 'ArrayExpression';
759
760 if (!isArrayExpression && !isTSAsArrayExpression) {
761 // If the declared dependencies are not an array expression then we
762 // can't verify that the user provided the correct dependencies. Tell
763 // the user this in an error.
764 reportProblem({
765 node: declaredDependenciesNode,
766 message:
767 `React Hook ${getSourceCode().getText(reactiveHook)} was passed a ` +
768 'dependency list that is not an array literal. This means we ' +
769 "can't statically verify whether you've passed the correct " +
770 'dependencies.',
771 });
772 } else {
773 const arrayExpression = isTSAsArrayExpression
774 ? declaredDependenciesNode.expression
775 : declaredDependenciesNode;
776
777 (arrayExpression as ArrayExpression).elements.forEach(
778 declaredDependencyNode => {
779 // Skip elided elements.
780 if (declaredDependencyNode === null) {
781 return;
782 }
783 // If we see a spread element then add a special warning.
784 if (declaredDependencyNode.type === 'SpreadElement') {
785 reportProblem({
786 node: declaredDependencyNode,
787 message:
788 `React Hook ${getSourceCode().getText(reactiveHook)} has a spread ` +
789 "element in its dependency array. This means we can't " +
790 "statically verify whether you've passed the " +
791 'correct dependencies.',
792 });
793 return;
794 }
795 if (useEffectEventVariables.has(declaredDependencyNode)) {
796 reportProblem({
797 node: declaredDependencyNode,
798 message:
799 'Functions returned from `useEffectEvent` must not be included in the dependency array. ' +
800 `Remove \`${getSourceCode().getText(
801 declaredDependencyNode,
802 )}\` from the list.`,
803 suggest: [
804 {
805 desc: `Remove the dependency \`${getSourceCode().getText(
806 declaredDependencyNode,
807 )}\``,
808 fix(fixer) {
809 return fixer.removeRange(declaredDependencyNode.range!);
810 },
811 },
812 ],
813 });
814 }
815 // Try to normalize the declared dependency. If we can't then an error
816 // will be thrown. We will catch that error and report an error.
817 let declaredDependency;
818 try {
819 declaredDependency = analyzePropertyChain(
820 declaredDependencyNode,
821 null,
822 );
823 } catch (error: unknown) {
824 if (
825 error instanceof Error &&
826 /Unsupported node type/.test(error.message)
827 ) {
828 if (declaredDependencyNode.type === 'Literal') {
829 if (
830 declaredDependencyNode.value &&
831 dependencies.has(declaredDependencyNode.value as string)
832 ) {
833 reportProblem({
834 node: declaredDependencyNode,
835 message:
836 `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
837 `because it never changes. ` +
838 `Did you mean to include ${declaredDependencyNode.value} in the array instead?`,
839 });
840 } else {
841 reportProblem({
842 node: declaredDependencyNode,
843 message:
844 `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
845 'because it never changes. You can safely remove it.',
846 });
847 }
848 } else {
849 reportProblem({
850 node: declaredDependencyNode,
851 message:
852 `React Hook ${getSourceCode().getText(reactiveHook)} has a ` +
853 `complex expression in the dependency array. ` +
854 'Extract it to a separate variable so it can be statically checked.',
855 });
856 }
857
858 return;
859 } else {
860 throw error;
861 }
862 }
863
864 let maybeID = declaredDependencyNode;
865 while (
866 maybeID.type === 'MemberExpression' ||
867 maybeID.type === 'OptionalMemberExpression' ||
868 maybeID.type === 'ChainExpression'
869 ) {
870 // @ts-expect-error This can be done better
871 maybeID = maybeID.object || maybeID.expression.object;
872 }
873 const isDeclaredInComponent = !componentScope.through.some(
874 ref => ref.identifier === maybeID,
875 );
876
877 // Add the dependency to our declared dependency map.
878 declaredDependencies.push({
879 key: declaredDependency,
880 node: declaredDependencyNode,
881 });
882
883 if (!isDeclaredInComponent) {
884 externalDependencies.add(declaredDependency);
885 }
886 },
887 );
888 }
889
890 const {
891 suggestedDependencies,
892 unnecessaryDependencies,
893 missingDependencies,
894 duplicateDependencies,
895 } = collectRecommendations({
896 dependencies,
897 declaredDependencies,
898 stableDependencies,
899 externalDependencies,
900 isEffect,
901 });
902
903 let suggestedDeps = suggestedDependencies;
904
905 const problemCount =
906 duplicateDependencies.size +
907 missingDependencies.size +
908 unnecessaryDependencies.size;
909
910 if (problemCount === 0) {
911 // If nothing else to report, check if some dependencies would
912 // invalidate on every render.
913 const constructions = scanForConstructions({
914 declaredDependencies,
915 declaredDependenciesNode,
916 componentScope,
917 scope,
918 });
919 constructions.forEach(
920 ({construction, isUsedOutsideOfHook, depType}) => {
921 const wrapperHook =
922 depType === 'function' ? 'useCallback' : 'useMemo';
923
924 const constructionType =
925 depType === 'function' ? 'definition' : 'initialization';
926
927 const defaultAdvice = `wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`;
928
929 const advice = isUsedOutsideOfHook
930 ? `To fix this, ${defaultAdvice}`
931 : `Move it inside the ${reactiveHookName} callback. Alternatively, ${defaultAdvice}`;
932
933 const causation =
934 depType === 'conditional' || depType === 'logical expression'
935 ? 'could make'
936 : 'makes';
937
938 const message =
939 `The '${construction.name.name}' ${depType} ${causation} the dependencies of ` +
940 `${reactiveHookName} Hook (at line ${declaredDependenciesNode.loc?.start.line}) ` +
941 `change on every render. ${advice}`;
942
943 let suggest: Rule.ReportDescriptor['suggest'];
944 // Only handle the simple case of variable assignments.
945 // Wrapping function declarations can mess up hoisting.
946 if (
947 isUsedOutsideOfHook &&
948 construction.type === 'Variable' &&
949 // Objects may be mutated after construction, which would make this
950 // fix unsafe. Functions _probably_ won't be mutated, so we'll
951 // allow this fix for them.
952 depType === 'function'
953 ) {
954 suggest = [
955 {
956 desc: `Wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`,
957 fix(fixer) {
958 const [before, after] =
959 wrapperHook === 'useMemo'
960 ? [`useMemo(() => { return `, '; })']
961 : ['useCallback(', ')'];
962 return [
963 // TODO: also add an import?
964 fixer.insertTextBefore(construction.node.init!, before),
965 // TODO: ideally we'd gather deps here but it would require
966 // restructuring the rule code. This will cause a new lint
967 // error to appear immediately for useCallback. Note we're
968 // not adding [] because would that changes semantics.
969 fixer.insertTextAfter(construction.node.init!, after),
970 ];
971 },
972 },
973 ];
974 }
975 // TODO: What if the function needs to change on every render anyway?
976 // Should we suggest removing effect deps as an appropriate fix too?
977 reportProblem({
978 // TODO: Why not report this at the dependency site?
979 node: construction.node,
980 message,
981 suggest,
982 });
983 },
984 );
985 return;
986 }
987
988 // If we're going to report a missing dependency,
989 // we might as well recalculate the list ignoring
990 // the currently specified deps. This can result
991 // in some extra deduplication. We can't do this
992 // for effects though because those have legit
993 // use cases for over-specifying deps.
994 if (!isEffect && missingDependencies.size > 0) {
995 suggestedDeps = collectRecommendations({
996 dependencies,
997 declaredDependencies: [], // Pretend we don't know
998 stableDependencies,
999 externalDependencies,
1000 isEffect,
1001 }).suggestedDependencies;
1002 }
1003
1004 // Alphabetize the suggestions, but only if deps were already alphabetized.
1005 function areDeclaredDepsAlphabetized(): boolean {
1006 if (declaredDependencies.length === 0) {
1007 return true;
1008 }
1009 const declaredDepKeys = declaredDependencies.map(dep => dep.key);
1010 const sortedDeclaredDepKeys = declaredDepKeys.slice().sort();
1011 return declaredDepKeys.join(',') === sortedDeclaredDepKeys.join(',');
1012 }
1013 if (areDeclaredDepsAlphabetized()) {
1014 suggestedDeps.sort();
1015 }
1016
1017 // Most of our algorithm deals with dependency paths with optional chaining stripped.
1018 // This function is the last step before printing a dependency, so now is a good time to
1019 // check whether any members in our path are always used as optional-only. In that case,
1020 // we will use ?. instead of . to concatenate those parts of the path.
1021 function formatDependency(path: string): string {
1022 const members = path.split('.');
1023 let finalPath = '';
1024 for (let i = 0; i < members.length; i++) {
1025 if (i !== 0) {
1026 const pathSoFar = members.slice(0, i + 1).join('.');
1027 const isOptional = optionalChains.get(pathSoFar) === true;
1028 finalPath += isOptional ? '?.' : '.';
1029 }
1030 finalPath += members[i];
1031 }
1032 return finalPath;
1033 }
1034
1035 function getWarningMessage(
1036 deps: Set<string>,
1037 singlePrefix: string,
1038 label: string,
1039 fixVerb: string,
1040 ): string | null {
1041 if (deps.size === 0) {
1042 return null;
1043 }
1044 return (
1045 (deps.size > 1 ? '' : singlePrefix + ' ') +
1046 label +
1047 ' ' +
1048 (deps.size > 1 ? 'dependencies' : 'dependency') +
1049 ': ' +
1050 joinEnglish(
1051 Array.from(deps)
1052 .sort()
1053 .map(name => "'" + formatDependency(name) + "'"),
1054 ) +
1055 `. Either ${fixVerb} ${
1056 deps.size > 1 ? 'them' : 'it'
1057 } or remove the dependency array.`
1058 );
1059 }
1060
1061 let extraWarning = '';
1062 if (unnecessaryDependencies.size > 0) {
1063 let badRef: string | null = null;
1064 Array.from(unnecessaryDependencies.keys()).forEach(key => {
1065 if (badRef !== null) {
1066 return;
1067 }
1068 if (key.endsWith('.current')) {
1069 badRef = key;
1070 }
1071 });
1072 if (badRef !== null) {
1073 extraWarning =
1074 ` Mutable values like '${badRef}' aren't valid dependencies ` +
1075 "because mutating them doesn't re-render the component.";
1076 } else if (externalDependencies.size > 0) {
1077 const dep = Array.from(externalDependencies)[0]!;
1078 // Don't show this warning for things that likely just got moved *inside* the callback
1079 // because in that case they're clearly not referring to globals.
1080 if (!scope.set.has(dep)) {
1081 extraWarning =
1082 ` Outer scope values like '${dep}' aren't valid dependencies ` +
1083 `because mutating them doesn't re-render the component.`;
1084 }
1085 }
1086 }
1087
1088 // `props.foo()` marks `props` as a dependency because it has
1089 // a `this` value. This warning can be confusing.
1090 // So if we're going to show it, append a clarification.
1091 if (!extraWarning && missingDependencies.has('props')) {
1092 const propDep = dependencies.get('props');
1093 if (propDep == null) {
1094 return;
1095 }
1096 const refs = propDep.references;
1097 if (!Array.isArray(refs)) {
1098 return;
1099 }
1100 let isPropsOnlyUsedInMembers = true;
1101 for (const ref of refs) {
1102 const id = fastFindReferenceWithParent(
1103 componentScope.block,
1104 ref.identifier,
1105 );
1106 if (!id) {
1107 isPropsOnlyUsedInMembers = false;
1108 break;
1109 }
1110 const parent = id.parent;
1111 if (parent == null) {
1112 isPropsOnlyUsedInMembers = false;
1113 break;
1114 }
1115 if (
1116 parent.type !== 'MemberExpression' &&
1117 parent.type !== 'OptionalMemberExpression'
1118 ) {
1119 isPropsOnlyUsedInMembers = false;
1120 break;
1121 }
1122 }
1123 if (isPropsOnlyUsedInMembers) {
1124 extraWarning =
1125 ` However, 'props' will change when *any* prop changes, so the ` +
1126 `preferred fix is to destructure the 'props' object outside of ` +
1127 `the ${reactiveHookName} call and refer to those specific props ` +
1128 `inside ${getSourceCode().getText(reactiveHook)}.`;
1129 }
1130 }
1131
1132 if (!extraWarning && missingDependencies.size > 0) {
1133 // See if the user is trying to avoid specifying a callable prop.
1134 // This usually means they're unaware of useCallback.
1135 let missingCallbackDep: string | null = null;
1136 missingDependencies.forEach(missingDep => {
1137 if (missingCallbackDep) {
1138 return;
1139 }
1140 // Is this a variable from top scope?
1141 const topScopeRef = componentScope.set.get(missingDep);
1142 const usedDep = dependencies.get(missingDep);
1143 if (
1144 !usedDep?.references ||
1145 usedDep?.references[0]?.resolved !== topScopeRef
1146 ) {
1147 return;
1148 }
1149 // Is this a destructured prop?
1150 const def = topScopeRef?.defs[0];
1151 if (def == null || def.name == null || def.type !== 'Parameter') {
1152 return;
1153 }
1154 // Was it called in at least one case? Then it's a function.
1155 let isFunctionCall = false;
1156 let id: Identifier | undefined;
1157 for (const reference of usedDep.references) {
1158 id = reference.identifier;
1159 if (
1160 id != null &&
1161 id.parent != null &&
1162 (id.parent.type === 'CallExpression' ||
1163 id.parent.type === 'OptionalCallExpression') &&
1164 id.parent.callee === id
1165 ) {
1166 isFunctionCall = true;
1167 break;
1168 }
1169 }
1170 if (!isFunctionCall) {
1171 return;
1172 }
1173 // If it's missing (i.e. in component scope) *and* it's a parameter
1174 // then it is definitely coming from props destructuring.
1175 // (It could also be props itself but we wouldn't be calling it then.)
1176 missingCallbackDep = missingDep;
1177 });
1178 if (missingCallbackDep !== null) {
1179 extraWarning =
1180 ` If '${missingCallbackDep}' changes too often, ` +
1181 `find the parent component that defines it ` +
1182 `and wrap that definition in useCallback.`;
1183 }
1184 }
1185
1186 if (!extraWarning && missingDependencies.size > 0) {
1187 let setStateRecommendation: {
1188 missingDep: string;
1189 setter: string;
1190 form: 'reducer' | 'updater' | 'inlineReducer';
1191 } | null = null;
1192 for (const missingDep of missingDependencies) {
1193 if (setStateRecommendation !== null) {
1194 break;
1195 }
1196 const usedDep = dependencies.get(missingDep)!;
1197 const references = usedDep.references;
1198 let id;
1199 let maybeCall;
1200 for (const reference of references) {
1201 id = reference.identifier;
1202 maybeCall = id.parent;
1203 // Try to see if we have setState(someExpr(missingDep)).
1204 while (maybeCall != null && maybeCall !== componentScope.block) {
1205 if (maybeCall.type === 'CallExpression') {
1206 const correspondingStateVariable = setStateCallSites.get(
1207 maybeCall.callee,
1208 );
1209 if (correspondingStateVariable != null) {
1210 if (
1211 'name' in correspondingStateVariable &&
1212 correspondingStateVariable.name === missingDep
1213 ) {
1214 // setCount(count + 1)
1215 setStateRecommendation = {
1216 missingDep,
1217 setter:
1218 'name' in maybeCall.callee ? maybeCall.callee.name : '',
1219 form: 'updater',
1220 };
1221 } else if (stateVariables.has(id)) {
1222 // setCount(count + increment)
1223 setStateRecommendation = {
1224 missingDep,
1225 setter:
1226 'name' in maybeCall.callee ? maybeCall.callee.name : '',
1227 form: 'reducer',
1228 };
1229 } else {
1230 const resolved = reference.resolved;
1231 if (resolved != null) {
1232 // If it's a parameter *and* a missing dep,
1233 // it must be a prop or something inside a prop.
1234 // Therefore, recommend an inline reducer.
1235 const def = resolved.defs[0];
1236 if (def != null && def.type === 'Parameter') {
1237 setStateRecommendation = {
1238 missingDep,
1239 setter:
1240 'name' in maybeCall.callee
1241 ? maybeCall.callee.name
1242 : '',
1243 form: 'inlineReducer',
1244 };
1245 }
1246 }
1247 }
1248 break;
1249 }
1250 }
1251 maybeCall = maybeCall.parent;
1252 }
1253 if (setStateRecommendation !== null) {
1254 break;
1255 }
1256 }
1257 }
1258 if (setStateRecommendation !== null) {
1259 switch (setStateRecommendation.form) {
1260 case 'reducer':
1261 extraWarning =
1262 ` You can also replace multiple useState variables with useReducer ` +
1263 `if '${setStateRecommendation.setter}' needs the ` +
1264 `current value of '${setStateRecommendation.missingDep}'.`;
1265 break;
1266 case 'inlineReducer':
1267 extraWarning =
1268 ` If '${setStateRecommendation.setter}' needs the ` +
1269 `current value of '${setStateRecommendation.missingDep}', ` +
1270 `you can also switch to useReducer instead of useState and ` +
1271 `read '${setStateRecommendation.missingDep}' in the reducer.`;
1272 break;
1273 case 'updater':
1274 extraWarning =
1275 ` You can also do a functional update '${
1276 setStateRecommendation.setter
1277 }(${setStateRecommendation.missingDep.slice(
1278 0,
1279 1,
1280 )} => ...)' if you only need '${
1281 setStateRecommendation.missingDep
1282 }'` + ` in the '${setStateRecommendation.setter}' call.`;
1283 break;
1284 default:
1285 throw new Error('Unknown case.');
1286 }
1287 }
1288 }
1289
1290 reportProblem({
1291 node: declaredDependenciesNode,
1292 message:
1293 `React Hook ${getSourceCode().getText(reactiveHook)} has ` +
1294 // To avoid a long message, show the next actionable item.
1295 (getWarningMessage(missingDependencies, 'a', 'missing', 'include') ||
1296 getWarningMessage(
1297 unnecessaryDependencies,
1298 'an',
1299 'unnecessary',
1300 'exclude',
1301 ) ||
1302 getWarningMessage(
1303 duplicateDependencies,
1304 'a',
1305 'duplicate',
1306 'omit',
1307 )) +
1308 extraWarning,
1309 suggest: [
1310 {
1311 desc: `Update the dependencies array to be: [${suggestedDeps
1312 .map(formatDependency)
1313 .join(', ')}]`,
1314 fix(fixer) {
1315 // TODO: consider preserving the comments or formatting?
1316 return fixer.replaceText(
1317 declaredDependenciesNode,
1318 `[${suggestedDeps.map(formatDependency).join(', ')}]`,
1319 );
1320 },
1321 },
1322 ],
1323 });
1324 }
1325
1326 function visitCallExpression(node: CallExpression): void {
1327 const callbackIndex = getReactiveHookCallbackIndex(node.callee, options);
1328 if (callbackIndex === -1) {
1329 // Not a React Hook call that needs deps.
1330 return;
1331 }
1332 let callback = node.arguments[callbackIndex];
1333 const reactiveHook = node.callee;
1334 const nodeWithoutNamespace = getNodeWithoutReactNamespace(reactiveHook);
1335 const reactiveHookName =
1336 'name' in nodeWithoutNamespace ? nodeWithoutNamespace.name : '';
1337 const maybeNode = node.arguments[callbackIndex + 1];
1338 const declaredDependenciesNode =
1339 maybeNode &&
1340 !(maybeNode.type === 'Identifier' && maybeNode.name === 'undefined')
1341 ? maybeNode
1342 : undefined;
1343 const isEffect = /Effect($|[^a-z])/g.test(reactiveHookName);
1344
1345 // Check whether a callback is supplied. If there is no callback supplied
1346 // then the hook will not work and React will throw a TypeError.
1347 // So no need to check for dependency inclusion.
1348 if (!callback) {
1349 reportProblem({
1350 node: reactiveHook,
1351 message:
1352 `React Hook ${reactiveHookName} requires an effect callback. ` +
1353 `Did you forget to pass a callback to the hook?`,
1354 });
1355 return;
1356 }
1357
1358 if (!maybeNode && isEffect && options.requireExplicitEffectDeps) {
1359 reportProblem({
1360 node: reactiveHook,
1361 message:
1362 `React Hook ${reactiveHookName} always requires dependencies. ` +
1363 `Please add a dependency array or an explicit \`undefined\``,
1364 });
1365 }
1366
1367 const isAutoDepsHook =
1368 options.experimental_autoDependenciesHooks.includes(reactiveHookName);
1369
1370 // Check the declared dependencies for this reactive hook. If there is no
1371 // second argument then the reactive callback will re-run on every render.
1372 // So no need to check for dependency inclusion.
1373 if (
1374 (!declaredDependenciesNode ||
1375 (isAutoDepsHook &&
1376 declaredDependenciesNode.type === 'Literal' &&
1377 declaredDependenciesNode.value === null)) &&
1378 !isEffect
1379 ) {
1380 // These are only used for optimization.
1381 if (
1382 reactiveHookName === 'useMemo' ||
1383 reactiveHookName === 'useCallback'
1384 ) {
1385 // TODO: Can this have a suggestion?
1386 reportProblem({
1387 node: reactiveHook,
1388 message:
1389 `React Hook ${reactiveHookName} does nothing when called with ` +
1390 `only one argument. Did you forget to pass an array of ` +
1391 `dependencies?`,
1392 });
1393 }
1394 return;
1395 }
1396
1397 while (
1398 callback.type === 'TSAsExpression' ||
1399 callback.type === 'AsExpression'
1400 ) {
1401 callback = callback.expression;
1402 }
1403
1404 switch (callback.type) {
1405 case 'FunctionExpression':
1406 case 'ArrowFunctionExpression':
1407 visitFunctionWithDependencies(
1408 callback,
1409 declaredDependenciesNode,
1410 reactiveHook,
1411 reactiveHookName,
1412 isEffect,
1413 isAutoDepsHook,
1414 );
1415 return; // Handled
1416 case 'Identifier':
1417 if (
1418 !declaredDependenciesNode ||
1419 (isAutoDepsHook &&
1420 declaredDependenciesNode.type === 'Literal' &&
1421 declaredDependenciesNode.value === null)
1422 ) {
1423 // Always runs, no problems.
1424 return; // Handled
1425 }
1426 // The function passed as a callback is not written inline.
1427 // But perhaps it's in the dependencies array?
1428 if (
1429 'elements' in declaredDependenciesNode &&
1430 declaredDependenciesNode.elements &&
1431 declaredDependenciesNode.elements.some(
1432 el => el && el.type === 'Identifier' && el.name === callback.name,
1433 )
1434 ) {
1435 // If it's already in the list of deps, we don't care because
1436 // this is valid regardless.
1437 return; // Handled
1438 }
1439 // We'll do our best effort to find it, complain otherwise.
1440 const variable = getScope(callback).set.get(callback.name);
1441 if (variable == null || variable.defs == null) {
1442 // If it's not in scope, we don't care.
1443 return; // Handled
1444 }
1445 // The function passed as a callback is not written inline.
1446 // But it's defined somewhere in the render scope.
1447 // We'll do our best effort to find and check it, complain otherwise.
1448 const def = variable.defs[0];
1449 if (!def || !def.node) {
1450 break; // Unhandled
1451 }
1452 if (def.type === 'Parameter') {
1453 reportProblem({
1454 node: reactiveHook,
1455 message: getUnknownDependenciesMessage(reactiveHookName),
1456 });
1457 return;
1458 }
1459 if (def.type !== 'Variable' && def.type !== 'FunctionName') {
1460 // Parameter or an unusual pattern. Bail out.
1461 break; // Unhandled
1462 }
1463 switch (def.node.type) {
1464 case 'FunctionDeclaration':
1465 // useEffect(() => { ... }, []);
1466 visitFunctionWithDependencies(
1467 def.node,
1468 declaredDependenciesNode,
1469 reactiveHook,
1470 reactiveHookName,
1471 isEffect,
1472 isAutoDepsHook,
1473 );
1474 return; // Handled
1475 case 'VariableDeclarator':
1476 const init = def.node.init;
1477 if (!init) {
1478 break; // Unhandled
1479 }
1480 switch (init.type) {
1481 // const effectBody = () => {...};
1482 // useEffect(effectBody, []);
1483 case 'ArrowFunctionExpression':
1484 case 'FunctionExpression':
1485 // We can inspect this function as if it were inline.
1486 visitFunctionWithDependencies(
1487 init,
1488 declaredDependenciesNode,
1489 reactiveHook,
1490 reactiveHookName,
1491 isEffect,
1492 isAutoDepsHook,
1493 );
1494 return; // Handled
1495 }
1496 break; // Unhandled
1497 }
1498 break; // Unhandled
1499 default:
1500 // useEffect(generateEffectBody(), []);
1501 reportProblem({
1502 node: reactiveHook,
1503 message: getUnknownDependenciesMessage(reactiveHookName),
1504 });
1505 return; // Handled
1506 }
1507
1508 // Something unusual. Fall back to suggesting to add the body itself as a dep.
1509 reportProblem({
1510 node: reactiveHook,
1511 message:
1512 `React Hook ${reactiveHookName} has a missing dependency: '${callback.name}'. ` +
1513 `Either include it or remove the dependency array.`,
1514 suggest: [
1515 {
1516 desc: `Update the dependencies array to be: [${callback.name}]`,
1517 fix(fixer) {
1518 return fixer.replaceText(
1519 declaredDependenciesNode,
1520 `[${callback.name}]`,
1521 );
1522 },
1523 },
1524 ],
1525 });
1526 }
1527
1528 return {
1529 CallExpression: visitCallExpression,
1530 };
1531 },
1532 } satisfies Rule.RuleModule;
1533
1534 // The meat of the logic.
1535 function collectRecommendations({
1536 dependencies,
1537 declaredDependencies,
1538 stableDependencies,
1539 externalDependencies,
1540 isEffect,
1541 }: {
1542 dependencies: Map<string, Dependency>;
1543 declaredDependencies: Array<DeclaredDependency>;
1544 stableDependencies: Set<string>;
1545 externalDependencies: Set<string>;
1546 isEffect: boolean;
1547 }) {
1548 // Our primary data structure.
1549 // It is a logical representation of property chains:
1550 // `props` -> `props.foo` -> `props.foo.bar` -> `props.foo.bar.baz`
1551 // -> `props.lol`
1552 // -> `props.huh` -> `props.huh.okay`
1553 // -> `props.wow`
1554 // We'll use it to mark nodes that are *used* by the programmer,
1555 // and the nodes that were *declared* as deps. Then we will
1556 // traverse it to learn which deps are missing or unnecessary.
1557 const depTree = createDepTree();
1558 function createDepTree(): DependencyTreeNode {
1559 return {
1560 isUsed: false, // True if used in code
1561 isSatisfiedRecursively: false, // True if specified in deps
1562 isSubtreeUsed: false, // True if something deeper is used by code
1563 children: new Map(), // Nodes for properties
1564 };
1565 }
1566
1567 // Mark all required nodes first.
1568 // Imagine exclamation marks next to each used deep property.
1569 dependencies.forEach((_, key) => {
1570 const node = getOrCreateNodeByPath(depTree, key);
1571 node.isUsed = true;
1572 markAllParentsByPath(depTree, key, parent => {
1573 parent.isSubtreeUsed = true;
1574 });
1575 });
1576
1577 // Mark all satisfied nodes.
1578 // Imagine checkmarks next to each declared dependency.
1579 declaredDependencies.forEach(({key}) => {
1580 const node = getOrCreateNodeByPath(depTree, key);
1581 node.isSatisfiedRecursively = true;
1582 });
1583 stableDependencies.forEach(key => {
1584 const node = getOrCreateNodeByPath(depTree, key);
1585 node.isSatisfiedRecursively = true;
1586 });
1587
1588 // Tree manipulation helpers.
1589 function getOrCreateNodeByPath(
1590 rootNode: DependencyTreeNode,
1591 path: string,
1592 ): DependencyTreeNode {
1593 const keys = path.split('.');
1594 let node = rootNode;
1595 for (const key of keys) {
1596 let child = node.children.get(key);
1597 if (!child) {
1598 child = createDepTree();
1599 node.children.set(key, child);
1600 }
1601 node = child;
1602 }
1603 return node;
1604 }
1605 function markAllParentsByPath(
1606 rootNode: DependencyTreeNode,
1607 path: string,
1608 fn: (node: DependencyTreeNode) => void,
1609 ): void {
1610 const keys = path.split('.');
1611 let node = rootNode;
1612 for (const key of keys) {
1613 const child = node.children.get(key);
1614 if (!child) {
1615 return;
1616 }
1617 fn(child);
1618 node = child;
1619 }
1620 }
1621
1622 // Now we can learn which dependencies are missing or necessary.
1623 const missingDependencies = new Set<string>();
1624 const satisfyingDependencies = new Set<string>();
1625 scanTreeRecursively(
1626 depTree,
1627 missingDependencies,
1628 satisfyingDependencies,
1629 key => key,
1630 );
1631 function scanTreeRecursively(
1632 node: DependencyTreeNode,
1633 missingPaths: Set<string>,
1634 satisfyingPaths: Set<string>,
1635 keyToPath: (key: string) => string,
1636 ): void {
1637 node.children.forEach((child, key) => {
1638 const path = keyToPath(key);
1639 if (child.isSatisfiedRecursively) {
1640 if (child.isSubtreeUsed) {
1641 // Remember this dep actually satisfied something.
1642 satisfyingPaths.add(path);
1643 }
1644 // It doesn't matter if there's something deeper.
1645 // It would be transitively satisfied since we assume immutability.
1646 // `props.foo` is enough if you read `props.foo.id`.
1647 return;
1648 }
1649 if (child.isUsed) {
1650 // Remember that no declared deps satisfied this node.
1651 missingPaths.add(path);
1652 // If we got here, nothing in its subtree was satisfied.
1653 // No need to search further.
1654 return;
1655 }
1656 scanTreeRecursively(
1657 child,
1658 missingPaths,
1659 satisfyingPaths,
1660 childKey => path + '.' + childKey,
1661 );
1662 });
1663 }
1664
1665 // Collect suggestions in the order they were originally specified.
1666 const suggestedDependencies: Array<string> = [];
1667 const unnecessaryDependencies = new Set<string>();
1668 const duplicateDependencies = new Set<string>();
1669 declaredDependencies.forEach(({key}) => {
1670 // Does this declared dep satisfy a real need?
1671 if (satisfyingDependencies.has(key)) {
1672 if (suggestedDependencies.indexOf(key) === -1) {
1673 // Good one.
1674 suggestedDependencies.push(key);
1675 } else {
1676 // Duplicate.
1677 duplicateDependencies.add(key);
1678 }
1679 } else {
1680 if (
1681 isEffect &&
1682 !key.endsWith('.current') &&
1683 !externalDependencies.has(key)
1684 ) {
1685 // Effects are allowed extra "unnecessary" deps.
1686 // Such as resetting scroll when ID changes.
1687 // Consider them legit.
1688 // The exception is ref.current which is always wrong.
1689 if (suggestedDependencies.indexOf(key) === -1) {
1690 suggestedDependencies.push(key);
1691 }
1692 } else {
1693 // It's definitely not needed.
1694 unnecessaryDependencies.add(key);
1695 }
1696 }
1697 });
1698
1699 // Then add the missing ones at the end.
1700 missingDependencies.forEach(key => {
1701 suggestedDependencies.push(key);
1702 });
1703
1704 return {
1705 suggestedDependencies,
1706 unnecessaryDependencies,
1707 duplicateDependencies,
1708 missingDependencies,
1709 };
1710 }
1711
1712 // If the node will result in constructing a referentially unique value, return
1713 // its human readable type name, else return null.
1714 function getConstructionExpressionType(node: Node): string | null {
1715 switch (node.type) {
1716 case 'ObjectExpression':
1717 return 'object';
1718 case 'ArrayExpression':
1719 return 'array';
1720 case 'ArrowFunctionExpression':
1721 case 'FunctionExpression':
1722 return 'function';
1723 case 'ClassExpression':
1724 return 'class';
1725 case 'ConditionalExpression':
1726 if (
1727 getConstructionExpressionType(node.consequent) != null ||
1728 getConstructionExpressionType(node.alternate) != null
1729 ) {
1730 return 'conditional';
1731 }
1732 return null;
1733 case 'LogicalExpression':
1734 if (
1735 getConstructionExpressionType(node.left) != null ||
1736 getConstructionExpressionType(node.right) != null
1737 ) {
1738 return 'logical expression';
1739 }
1740 return null;
1741 case 'JSXFragment':
1742 return 'JSX fragment';
1743 case 'JSXElement':
1744 return 'JSX element';
1745 case 'AssignmentExpression':
1746 if (getConstructionExpressionType(node.right) != null) {
1747 return 'assignment expression';
1748 }
1749 return null;
1750 case 'NewExpression':
1751 return 'object construction';
1752 case 'Literal':
1753 if (node.value instanceof RegExp) {
1754 return 'regular expression';
1755 }
1756 return null;
1757 case 'TypeCastExpression':
1758 case 'AsExpression':
1759 case 'TSAsExpression':
1760 return getConstructionExpressionType(node.expression);
1761 }
1762 return null;
1763 }
1764
1765 // Finds variables declared as dependencies
1766 // that would invalidate on every render.
1767 function scanForConstructions({
1768 declaredDependencies,
1769 declaredDependenciesNode,
1770 componentScope,
1771 scope,
1772 }: {
1773 declaredDependencies: Array<DeclaredDependency>;
1774 declaredDependenciesNode: Node;
1775 componentScope: Scope.Scope;
1776 scope: Scope.Scope;
1777 }) {
1778 const constructions = declaredDependencies
1779 .map(({key}) => {
1780 const ref = componentScope.variables.find(v => v.name === key);
1781 if (ref == null) {
1782 return null;
1783 }
1784
1785 const node = ref.defs[0];
1786 if (node == null) {
1787 return null;
1788 }
1789 // const handleChange = function () {}
1790 // const handleChange = () => {}
1791 // const foo = {}
1792 // const foo = []
1793 // etc.
1794 if (
1795 node.type === 'Variable' &&
1796 node.node.type === 'VariableDeclarator' &&
1797 node.node.id.type === 'Identifier' && // Ensure this is not destructed assignment
1798 node.node.init != null
1799 ) {
1800 const constantExpressionType = getConstructionExpressionType(
1801 node.node.init,
1802 );
1803 if (constantExpressionType) {
1804 return [ref, constantExpressionType];
1805 }
1806 }
1807 // function handleChange() {}
1808 if (
1809 node.type === 'FunctionName' &&
1810 node.node.type === 'FunctionDeclaration'
1811 ) {
1812 return [ref, 'function'];
1813 }
1814
1815 // class Foo {}
1816 if (node.type === 'ClassName' && node.node.type === 'ClassDeclaration') {
1817 return [ref, 'class'];
1818 }
1819 return null;
1820 })
1821 .filter(Boolean) as Array<[Scope.Variable, string]>;
1822
1823 function isUsedOutsideOfHook(ref: Scope.Variable): boolean {
1824 let foundWriteExpr = false;
1825 for (const reference of ref.references) {
1826 if (reference.writeExpr) {
1827 if (foundWriteExpr) {
1828 // Two writes to the same function.
1829 return true;
1830 } else {
1831 // Ignore first write as it's not usage.
1832 foundWriteExpr = true;
1833 continue;
1834 }
1835 }
1836 let currentScope: Scope.Scope | null = reference.from;
1837 while (currentScope !== scope && currentScope != null) {
1838 currentScope = currentScope.upper;
1839 }
1840 if (currentScope !== scope) {
1841 // This reference is outside the Hook callback.
1842 // It can only be legit if it's the deps array.
1843 if (!isAncestorNodeOf(declaredDependenciesNode, reference.identifier)) {
1844 return true;
1845 }
1846 }
1847 }
1848 return false;
1849 }
1850
1851 return constructions.map(([ref, depType]) => ({
1852 construction: ref.defs[0] as Scope.Definition,
1853 depType,
1854 isUsedOutsideOfHook: isUsedOutsideOfHook(ref),
1855 }));
1856 }
1857
1858 /**
1859 * Assuming () means the passed/returned node:
1860 * (props) => (props)
1861 * props.(foo) => (props.foo)
1862 * props.foo.(bar) => (props).foo.bar
1863 * props.foo.bar.(baz) => (props).foo.bar.baz
1864 */
1865 function getDependency(node: Node): Node {
1866 if (
1867 node.parent &&
1868 (node.parent.type === 'MemberExpression' ||
1869 node.parent.type === 'OptionalMemberExpression') &&
1870 node.parent.object === node &&
1871 'name' in node.parent.property &&
1872 node.parent.property.name !== 'current' &&
1873 !node.parent.computed &&
1874 !(
1875 node.parent.parent != null &&
1876 (node.parent.parent.type === 'CallExpression' ||
1877 node.parent.parent.type === 'OptionalCallExpression') &&
1878 node.parent.parent.callee === node.parent
1879 )
1880 ) {
1881 return getDependency(node.parent);
1882 } else if (
1883 // Note: we don't check OptionalMemberExpression because it can't be LHS.
1884 node.type === 'MemberExpression' &&
1885 node.parent &&
1886 node.parent.type === 'AssignmentExpression' &&
1887 node.parent.left === node
1888 ) {
1889 return node.object;
1890 } else {
1891 return node;
1892 }
1893 }
1894
1895 /**
1896 * Mark a node as either optional or required.
1897 * Note: If the node argument is an OptionalMemberExpression, it doesn't necessarily mean it is optional.
1898 * It just means there is an optional member somewhere inside.
1899 * This particular node might still represent a required member, so check .optional field.
1900 */
1901 function markNode(
1902 node: Node,
1903 optionalChains: Map<string, boolean> | null,
1904 result: string,
1905 ): void {
1906 if (optionalChains) {
1907 if ('optional' in node && node.optional) {
1908 // We only want to consider it optional if *all* usages were optional.
1909 if (!optionalChains.has(result)) {
1910 // Mark as (maybe) optional. If there's a required usage, this will be overridden.
1911 optionalChains.set(result, true);
1912 }
1913 } else {
1914 // Mark as required.
1915 optionalChains.set(result, false);
1916 }
1917 }
1918 }
1919
1920 /**
1921 * Assuming () means the passed node.
1922 * (foo) -> 'foo'
1923 * foo(.)bar -> 'foo.bar'
1924 * foo.bar(.)baz -> 'foo.bar.baz'
1925 * Otherwise throw.
1926 */
1927 function analyzePropertyChain(
1928 node: Node,
1929 optionalChains: Map<string, boolean> | null,
1930 ): string {
1931 if (node.type === 'Identifier' || node.type === 'JSXIdentifier') {
1932 const result = node.name;
1933 if (optionalChains) {
1934 // Mark as required.
1935 optionalChains.set(result, false);
1936 }
1937 return result;
1938 } else if (node.type === 'MemberExpression' && !node.computed) {
1939 const object = analyzePropertyChain(node.object, optionalChains);
1940 const property = analyzePropertyChain(node.property, null);
1941 const result = `${object}.${property}`;
1942 markNode(node, optionalChains, result);
1943 return result;
1944 } else if (node.type === 'OptionalMemberExpression' && !node.computed) {
1945 const object = analyzePropertyChain(node.object, optionalChains);
1946 const property = analyzePropertyChain(node.property, null);
1947 const result = `${object}.${property}`;
1948 markNode(node, optionalChains, result);
1949 return result;
1950 } else if (
1951 node.type === 'ChainExpression' &&
1952 (!('computed' in node) || !node.computed)
1953 ) {
1954 const expression = node.expression;
1955
1956 if (expression.type === 'CallExpression') {
1957 throw new Error(`Unsupported node type: ${expression.type}`);
1958 }
1959
1960 const object = analyzePropertyChain(expression.object, optionalChains);
1961 const property = analyzePropertyChain(expression.property, null);
1962 const result = `${object}.${property}`;
1963 markNode(expression, optionalChains, result);
1964 return result;
1965 } else {
1966 throw new Error(`Unsupported node type: ${node.type}`);
1967 }
1968 }
1969
1970 function getNodeWithoutReactNamespace(
1971 node: Expression | Super,
1972 ): Expression | Identifier | Super {
1973 if (
1974 node.type === 'MemberExpression' &&
1975 node.object.type === 'Identifier' &&
1976 node.object.name === 'React' &&
1977 node.property.type === 'Identifier' &&
1978 !node.computed
1979 ) {
1980 return node.property;
1981 }
1982 return node;
1983 }
1984
1985 // What's the index of callback that needs to be analyzed for a given Hook?
1986 // -1 if it's not a Hook we care about (e.g. useState).
1987 // 0 for useEffect/useMemo/useCallback(fn).
1988 // 1 for useImperativeHandle(ref, fn).
1989 // For additionally configured Hooks, assume that they're like useEffect (0).
1990 function getReactiveHookCallbackIndex(
1991 calleeNode: Expression | Super,
1992 options?: {
1993 additionalHooks: RegExp | undefined;
1994 enableDangerousAutofixThisMayCauseInfiniteLoops?: boolean;
1995 },
1996 ): 0 | -1 | 1 {
1997 const node = getNodeWithoutReactNamespace(calleeNode);
1998 if (node.type !== 'Identifier') {
1999 return -1;
2000 }
2001 switch (node.name) {
2002 case 'useEffect':
2003 case 'useLayoutEffect':
2004 case 'useCallback':
2005 case 'useMemo':
2006 // useEffect(fn)
2007 return 0;
2008 case 'useImperativeHandle':
2009 // useImperativeHandle(ref, fn)
2010 return 1;
2011 default:
2012 if (node === calleeNode && options && options.additionalHooks) {
2013 // Allow the user to provide a regular expression which enables the lint to
2014 // target custom reactive hooks.
2015 let name;
2016 try {
2017 name = analyzePropertyChain(node, null);
2018 } catch (error: unknown) {
2019 if (
2020 error instanceof Error &&
2021 /Unsupported node type/.test(error.message)
2022 ) {
2023 return 0;
2024 } else {
2025 throw error;
2026 }
2027 }
2028 return options.additionalHooks.test(name) ? 0 : -1;
2029 } else {
2030 return -1;
2031 }
2032 }
2033 }
2034
2035 /**
2036 * ESLint won't assign node.parent to references from context.getScope()
2037 *
2038 * So instead we search for the node from an ancestor assigning node.parent
2039 * as we go. This mutates the AST.
2040 *
2041 * This traversal is:
2042 * - optimized by only searching nodes with a range surrounding our target node
2043 * - agnostic to AST node types, it looks for `{ type: string, ... }`
2044 */
2045 function fastFindReferenceWithParent(start: Node, target: Node): Node | null {
2046 const queue = [start];
2047 let item: Node;
2048
2049 while (queue.length) {
2050 item = queue.shift() as Node;
2051
2052 if (isSameIdentifier(item, target)) {
2053 return item;
2054 }
2055
2056 if (!isAncestorNodeOf(item, target)) {
2057 continue;
2058 }
2059
2060 for (const [key, value] of Object.entries(item)) {
2061 if (key === 'parent') {
2062 continue;
2063 }
2064 if (isNodeLike(value)) {
2065 value.parent = item;
2066 queue.push(value);
2067 } else if (Array.isArray(value)) {
2068 value.forEach(val => {
2069 if (isNodeLike(val)) {
2070 val.parent = item;
2071 queue.push(val);
2072 }
2073 });
2074 }
2075 }
2076 }
2077
2078 return null;
2079 }
2080
2081 function joinEnglish(arr: Array<string>): string {
2082 let s = '';
2083 for (let i = 0; i < arr.length; i++) {
2084 s += arr[i];
2085 if (i === 0 && arr.length === 2) {
2086 s += ' and ';
2087 } else if (i === arr.length - 2 && arr.length > 2) {
2088 s += ', and ';
2089 } else if (i < arr.length - 1) {
2090 s += ', ';
2091 }
2092 }
2093 return s;
2094 }
2095
2096 function isNodeLike(val: unknown): boolean {
2097 return (
2098 typeof val === 'object' &&
2099 val !== null &&
2100 !Array.isArray(val) &&
2101 'type' in val &&
2102 typeof val.type === 'string'
2103 );
2104 }
2105
2106 function isSameIdentifier(a: Node, b: Node): boolean {
2107 return (
2108 (a.type === 'Identifier' || a.type === 'JSXIdentifier') &&
2109 a.type === b.type &&
2110 a.name === b.name &&
2111 !!a.range &&
2112 !!b.range &&
2113 a.range[0] === b.range[0] &&
2114 a.range[1] === b.range[1]
2115 );
2116 }
2117
2118 function isAncestorNodeOf(a: Node, b: Node): boolean {
2119 return (
2120 !!a.range &&
2121 !!b.range &&
2122 a.range[0] <= b.range[0] &&
2123 a.range[1] >= b.range[1]
2124 );
2125 }
2126
2127 function isUseEffectEventIdentifier(node: Node): boolean {
2128 return node.type === 'Identifier' && node.name === 'useEffectEvent';
2129 }
2130
2131 function getUnknownDependenciesMessage(reactiveHookName: string): string {
2132 return (
2133 `React Hook ${reactiveHookName} received a function whose dependencies ` +
2134 `are unknown. Pass an inline function instead.`
2135 );
2136 }
2137
2138 export default rule;