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.