packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts TYPESCRIPT 2,139 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 2,139.
1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 */7/* eslint-disable no-for-of-loops/no-for-of-loops */8import type {Rule, Scope} from 'eslint';9import 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';2324import {getAdditionalEffectHooksFromSettings} from '../shared/Utils';2526type DeclaredDependency = {27  key: string;28  node: Node;29};3031type Dependency = {32  isStable: boolean;33  references: Array<Scope.Reference>;34};3536type DependencyTreeNode = {37  isUsed: boolean; // True if used in code38  isSatisfiedRecursively: boolean; // True if specified in deps39  isSubtreeUsed: boolean; // True if something deeper is used by code40  children: Map<string, DependencyTreeNode>; // Nodes for properties41};4243const 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 || {};8283    // Parse the `additionalHooks` regex.84    // Use rule-level additionalHooks if provided, otherwise fall back to settings85    const additionalHooks =86      rawOptions && rawOptions.additionalHooks87        ? new RegExp(rawOptions.additionalHooks)88        : getAdditionalEffectHooksFromSettings(settings);8990    const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =91      (rawOptions &&92        rawOptions.enableDangerousAutofixThisMayCauseInfiniteLoops) ||93      false;9495    const experimental_autoDependenciesHooks: ReadonlyArray<string> =96      rawOptions && Array.isArray(rawOptions.experimental_autoDependenciesHooks)97        ? rawOptions.experimental_autoDependenciesHooks98        : [];99100    const requireExplicitEffectDeps: boolean =101      (rawOptions && rawOptions.requireExplicitEffectDeps) || false;102103    const options = {104      additionalHooks,105      experimental_autoDependenciesHooks,106      enableDangerousAutofixThisMayCauseInfiniteLoops,107      requireExplicitEffectDeps,108    };109110    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    }124125    /**126     * SourceCode that also works down to ESLint 3.0.0127     */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.0138     */139    const getScope =140      typeof context.getScope === 'function'141        ? () => {142            return context.getScope();143          }144        : (node: Node) => {145            return context.sourceCode.getScope(node);146          };147148    const scopeManager = getSourceCode().scopeManager;149150    // Should be shared between visitors.151    const setStateCallSites = new WeakMap<152      Expression | Super,153      Pattern | null | undefined154    >();155    const stateVariables = new WeakSet<Identifier>();156    const stableKnownValueCache = new WeakMap<Scope.Variable, boolean>();157    const functionWithoutCapturedValueCache = new WeakMap<158      Scope.Variable,159      boolean160    >();161    const useEffectEventVariables = new WeakSet<Expression>();162163    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      }206207      // 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      }214215      // Find all our "pure scopes". On every re-render of a component these216      // pure scopes may have changes to the variables declared within. So all217      // variables used in our reactive hook callback but declared in a pure218      // 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 pure221      // scope. We can't enforce this in a lint so we trust that all variables222      // 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 types232            currentScope.type === 'hook' ||233            // @ts-expect-error incorrect TS types234            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 with242        // the lint.243        if (!currentScope) {244          return;245        }246        componentScope = currentScope;247      }248249      const isArray = Array.isArray;250251      // Next we'll define a few helpers that helps us252      // tell if some values don't have to be declared as deps.253254      // Some are known to be stable based on Hook calls.255      // const [state, setState] = useState() / React.useState()256      //               ^^^ true for this reference257      // const [state, dispatch] = useReducer() / React.useReducer()258      //               ^^^ true for this reference259      // const [state, dispatch] = useActionState() / React.useActionState()260      //               ^^^ true for this reference261      // const ref = useRef()262      //       ^^^ true for this reference263      // const onStuff = useEffectEvent(() => {})264      //       ^^^ true for this reference265      // 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 constants287        // const foo = 42288        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 stable309          return true;310        }311        // Detect known Hook calls312        // 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.computed324        ) {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      }404405      // 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 for420        // 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 values444        // 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 anything459        // from render--or everything it captures is known stable.460        return true;461      }462463      // 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      );472473      // These are usually mistaken. Collect them.474      const currentRefsInEffectCleanup = new Map<475        string,476        {477          reference: Scope.Reference;478          dependencyNode: Identifier;479        }480      >();481482      // Is this reference inside a cleanup function for this effect node?483      // We can check by traversing scopes upwards from the reference, and checking484      // 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      }498499      // 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);504505      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 pure508          // 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          }515516          // 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          );530531          // 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          }550551          if (552            dependencyNode.parent?.type === 'TSTypeQuery' ||553            dependencyNode.parent?.type === 'TSTypeReference'554          ) {555            continue;556          }557558          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 parameters567          if (568            // @ts-expect-error We don't have flow types569            def.type === 'TypeParameter' ||570            // @ts-expect-error Flow-specific AST node type571            dependencyNode.parent?.type === 'GenericTypeAnnotation'572          ) {573            continue;574          }575576          // Add the dependency to a map so we can make sure it is referenced577          // 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        }591592        for (const childScope of currentScope.childScopes) {593          gatherDependenciesRecursively(childScope);594        }595      }596597      // 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.current610              // 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 === parent618            ) {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      );639640      // 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      }659660      // 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      });672673      if (staleAssignments.size > 0) {674        // The intent isn't clear so we'll wait until you fix those first.675        return;676      }677678      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            }693694            const id = reference.identifier;695            const isSetState = setStateCallSites.has(id);696            if (!isSetState) {697              return;698            }699700            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 === null748      ) {749        return;750      }751752      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';759760      if (!isArrayExpression && !isTSAsArrayExpression) {761        // If the declared dependencies are not an array expression then we762        // can't verify that the user provided the correct dependencies. Tell763        // 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 = isTSAsArrayExpression774          ? declaredDependenciesNode.expression775          : declaredDependenciesNode;776777        (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 error816            // 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                }857858                return;859              } else {860                throw error;861              }862            }863864            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 better871              maybeID = maybeID.object || maybeID.expression.object;872            }873            const isDeclaredInComponent = !componentScope.through.some(874              ref => ref.identifier === maybeID,875            );876877            // Add the dependency to our declared dependency map.878            declaredDependencies.push({879              key: declaredDependency,880              node: declaredDependencyNode,881            });882883            if (!isDeclaredInComponent) {884              externalDependencies.add(declaredDependency);885            }886          },887        );888      }889890      const {891        suggestedDependencies,892        unnecessaryDependencies,893        missingDependencies,894        duplicateDependencies,895      } = collectRecommendations({896        dependencies,897        declaredDependencies,898        stableDependencies,899        externalDependencies,900        isEffect,901      });902903      let suggestedDeps = suggestedDependencies;904905      const problemCount =906        duplicateDependencies.size +907        missingDependencies.size +908        unnecessaryDependencies.size;909910      if (problemCount === 0) {911        // If nothing else to report, check if some dependencies would912        // 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';923924            const constructionType =925              depType === 'function' ? 'definition' : 'initialization';926927            const defaultAdvice = `wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`;928929            const advice = isUsedOutsideOfHook930              ? `To fix this, ${defaultAdvice}`931              : `Move it inside the ${reactiveHookName} callback. Alternatively, ${defaultAdvice}`;932933            const causation =934              depType === 'conditional' || depType === 'logical expression'935                ? 'could make'936                : 'makes';937938            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}`;942943            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 this950              // fix unsafe. Functions _probably_ won't be mutated, so we'll951              // 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 require966                      // restructuring the rule code. This will cause a new lint967                      // error to appear immediately for useCallback. Note we're968                      // 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      }987988      // If we're going to report a missing dependency,989      // we might as well recalculate the list ignoring990      // the currently specified deps. This can result991      // in some extra deduplication. We can't do this992      // for effects though because those have legit993      // use cases for over-specifying deps.994      if (!isEffect && missingDependencies.size > 0) {995        suggestedDeps = collectRecommendations({996          dependencies,997          declaredDependencies: [], // Pretend we don't know998          stableDependencies,999          externalDependencies,1000          isEffect,1001        }).suggestedDependencies;1002      }10031004      // 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      }10161017      // 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 to1019      // 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      }10341035      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      }10601061      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 callback1079          // 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      }10871088      // `props.foo()` marks `props` as a dependency because it has1089      // 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      }11311132      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 !== topScopeRef1146          ) {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 === id1165            ) {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 parameter1174          // 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      }11851186      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 === missingDep1213                  ) {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.callee1241                              ? maybeCall.callee.name1242                              : '',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.setter1277                }(${setStateRecommendation.missingDep.slice(1278                  0,1279                  1,1280                )} => ...)' if you only need '${1281                  setStateRecommendation.missingDep1282                }'` + ` in the '${setStateRecommendation.setter}' call.`;1283              break;1284            default:1285              throw new Error('Unknown case.');1286          }1287        }1288      }12891290      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: [${suggestedDeps1312              .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    }13251326    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          ? maybeNode1342          : undefined;1343      const isEffect = /Effect($|[^a-z])/g.test(reactiveHookName);13441345      // Check whether a callback is supplied. If there is no callback supplied1346      // 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      }13571358      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      }13661367      const isAutoDepsHook =1368        options.experimental_autoDependenciesHooks.includes(reactiveHookName);13691370      // Check the declared dependencies for this reactive hook. If there is no1371      // 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        !isEffect1379      ) {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      }13961397      while (1398        callback.type === 'TSAsExpression' ||1399        callback.type === 'AsExpression'1400      ) {1401        callback = callback.expression;1402      }14031404      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; // Handled1416        case 'Identifier':1417          if (1418            !declaredDependenciesNode ||1419            (isAutoDepsHook &&1420              declaredDependenciesNode.type === 'Literal' &&1421              declaredDependenciesNode.value === null)1422          ) {1423            // Always runs, no problems.1424            return; // Handled1425          }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 because1436            // this is valid regardless.1437            return; // Handled1438          }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; // Handled1444          }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; // Unhandled1451          }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; // Unhandled1462          }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; // Handled1475            case 'VariableDeclarator':1476              const init = def.node.init;1477              if (!init) {1478                break; // Unhandled1479              }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; // Handled1495              }1496              break; // Unhandled1497          }1498          break; // Unhandled1499        default:1500          // useEffect(generateEffectBody(), []);1501          reportProblem({1502            node: reactiveHook,1503            message: getUnknownDependenciesMessage(reactiveHookName),1504          });1505          return; // Handled1506      }15071508      // 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    }15271528    return {1529      CallExpression: visitCallExpression,1530    };1531  },1532} satisfies Rule.RuleModule;15331534// The meat of the logic.1535function 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 will1556  // 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 code1561      isSatisfiedRecursively: false, // True if specified in deps1562      isSubtreeUsed: false, // True if something deeper is used by code1563      children: new Map(), // Nodes for properties1564    };1565  }15661567  // 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  });15761577  // 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  });15871588  // 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  }16211622  // 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  }16641665  // 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  });16981699  // Then add the missing ones at the end.1700  missingDependencies.forEach(key => {1701    suggestedDependencies.push(key);1702  });17031704  return {1705    suggestedDependencies,1706    unnecessaryDependencies,1707    duplicateDependencies,1708    missingDependencies,1709  };1710}17111712// If the node will result in constructing a referentially unique value, return1713// its human readable type name, else return null.1714function 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) != null1729      ) {1730        return 'conditional';1731      }1732      return null;1733    case 'LogicalExpression':1734      if (1735        getConstructionExpressionType(node.left) != null ||1736        getConstructionExpressionType(node.right) != null1737      ) {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}17641765// Finds variables declared as dependencies1766// that would invalidate on every render.1767function 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 = declaredDependencies1779    .map(({key}) => {1780      const ref = componentScope.variables.find(v => v.name === key);1781      if (ref == null) {1782        return null;1783      }17841785      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 assignment1798        node.node.init != null1799      ) {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      }18141815      // 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]>;18221823  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  }18501851  return constructions.map(([ref, depType]) => ({1852    construction: ref.defs[0] as Scope.Definition,1853    depType,1854    isUsedOutsideOfHook: isUsedOutsideOfHook(ref),1855  }));1856}18571858/**1859 * Assuming () means the passed/returned node:1860 * (props) => (props)1861 * props.(foo) => (props.foo)1862 * props.foo.(bar) => (props).foo.bar1863 * props.foo.bar.(baz) => (props).foo.bar.baz1864 */1865function 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.parent1879    )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 === node1888  ) {1889    return node.object;1890  } else {1891    return node;1892  }1893}18941895/**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 */1901function 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}19191920/**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 */1927function 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;19551956    if (expression.type === 'CallExpression') {1957      throw new Error(`Unsupported node type: ${expression.type}`);1958    }19591960    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}19691970function 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.computed1979  ) {1980    return node.property;1981  }1982  return node;1983}19841985// 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).1990function 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  }

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.