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