main
ts 934 lines 32 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7 /* eslint-disable no-for-of-loops/no-for-of-loops */
8
9 import type {Rule, Scope} from 'eslint';
10 import type {
11 CallExpression,
12 CatchClause,
13 DoWhileStatement,
14 Expression,
15 Identifier,
16 Node,
17 Super,
18 TryStatement,
19 } from 'estree';
20
21 // @ts-expect-error untyped module
22 import CodePathAnalyzer from '../code-path-analysis/code-path-analyzer';
23 import {getAdditionalEffectHooksFromSettings} from '../shared/Utils';
24
25 /**
26 * Catch all identifiers that begin with "use" followed by an uppercase Latin
27 * character to exclude identifiers like "user".
28 */
29 function isHookName(s: string): boolean {
30 return s === 'use' || /^use[A-Z0-9]/.test(s);
31 }
32
33 /**
34 * We consider hooks to be a hook name identifier or a member expression
35 * containing a hook name.
36 */
37 function isHook(node: Node): boolean {
38 if (node.type === 'Identifier') {
39 return isHookName(node.name);
40 } else if (
41 node.type === 'MemberExpression' &&
42 !node.computed &&
43 isHook(node.property)
44 ) {
45 const obj = node.object;
46 const isPascalCaseNameSpace = /^[A-Z].*/;
47 return obj.type === 'Identifier' && isPascalCaseNameSpace.test(obj.name);
48 } else {
49 return false;
50 }
51 }
52
53 /**
54 * Checks if the node is a React component name. React component names must
55 * always start with an uppercase letter.
56 */
57 function isComponentName(node: Node): boolean {
58 return node.type === 'Identifier' && /^[A-Z]/.test(node.name);
59 }
60
61 function isReactFunction(node: Node, functionName: string): boolean {
62 return (
63 ('name' in node && node.name === functionName) ||
64 (node.type === 'MemberExpression' &&
65 'name' in node.object &&
66 node.object.name === 'React' &&
67 'name' in node.property &&
68 node.property.name === functionName)
69 );
70 }
71
72 /**
73 * Checks if the node is a callback argument of forwardRef. This render function
74 * should follow the rules of hooks.
75 */
76 function isForwardRefCallback(node: Node): boolean {
77 return !!(
78 node.parent &&
79 'callee' in node.parent &&
80 node.parent.callee &&
81 isReactFunction(node.parent.callee, 'forwardRef')
82 );
83 }
84
85 /**
86 * Checks if the node is a callback argument of React.memo. This anonymous
87 * functional component should follow the rules of hooks.
88 */
89 function isMemoCallback(node: Node): boolean {
90 return !!(
91 node.parent &&
92 'callee' in node.parent &&
93 node.parent.callee &&
94 isReactFunction(node.parent.callee, 'memo')
95 );
96 }
97
98 function isInsideComponentOrHook(node: Node | undefined): boolean {
99 while (node) {
100 const functionName = getFunctionName(node);
101 if (functionName) {
102 if (isComponentName(functionName) || isHook(functionName)) {
103 return true;
104 }
105 }
106 if (isForwardRefCallback(node) || isMemoCallback(node)) {
107 return true;
108 }
109 node = node.parent;
110 }
111 return false;
112 }
113
114 function isInsideDoWhileLoop(node: Node | undefined): node is DoWhileStatement {
115 while (node) {
116 if (node.type === 'DoWhileStatement') {
117 return true;
118 }
119 node = node.parent;
120 }
121 return false;
122 }
123
124 function isInsideTryCatch(
125 node: Node | undefined,
126 ): node is TryStatement | CatchClause {
127 while (node) {
128 if (node.type === 'TryStatement' || node.type === 'CatchClause') {
129 return true;
130 }
131 node = node.parent;
132 }
133 return false;
134 }
135
136 function getNodeWithoutReactNamespace(
137 node: Expression | Super,
138 ): Expression | Identifier | Super {
139 if (
140 node.type === 'MemberExpression' &&
141 node.object.type === 'Identifier' &&
142 node.object.name === 'React' &&
143 node.property.type === 'Identifier' &&
144 !node.computed
145 ) {
146 return node.property;
147 }
148 return node;
149 }
150
151 function isEffectIdentifier(node: Node, additionalHooks?: RegExp): boolean {
152 const isBuiltInEffect =
153 node.type === 'Identifier' &&
154 (node.name === 'useEffect' ||
155 node.name === 'useLayoutEffect' ||
156 node.name === 'useInsertionEffect');
157
158 if (isBuiltInEffect) {
159 return true;
160 }
161
162 // Check if this matches additional hooks configured by the user
163 if (additionalHooks && node.type === 'Identifier') {
164 return additionalHooks.test(node.name);
165 }
166
167 return false;
168 }
169
170 function isUseEffectEventIdentifier(node: Node): boolean {
171 return node.type === 'Identifier' && node.name === 'useEffectEvent';
172 }
173
174 function useEffectEventError(fn: string | null, called: boolean): string {
175 // no function identifier, i.e. it is not assigned to a variable
176 if (fn === null) {
177 return (
178 `React Hook "useEffectEvent" can only be called at the top level of your component.` +
179 ` It cannot be passed down.`
180 );
181 }
182
183 return (
184 `\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
185 'Effects and Effect Events in the same component.' +
186 (called ? '' : ' It cannot be assigned to a variable or passed down.')
187 );
188 }
189
190 function isUseIdentifier(node: Node): boolean {
191 return isReactFunction(node, 'use');
192 }
193
194 const rule = {
195 meta: {
196 type: 'problem',
197 docs: {
198 description: 'enforces the Rules of Hooks',
199 recommended: true,
200 url: 'https://react.dev/reference/rules/rules-of-hooks',
201 },
202 schema: [
203 {
204 type: 'object',
205 additionalProperties: false,
206 properties: {
207 additionalHooks: {
208 type: 'string',
209 },
210 },
211 },
212 ],
213 },
214 create(context: Rule.RuleContext) {
215 const settings = context.settings || {};
216
217 const additionalEffectHooks =
218 getAdditionalEffectHooksFromSettings(settings);
219
220 let lastEffect: CallExpression | null = null;
221 const codePathReactHooksMapStack: Array<
222 Map<Rule.CodePathSegment, Array<Node>>
223 > = [];
224 const codePathSegmentStack: Array<Rule.CodePathSegment> = [];
225 const useEffectEventFunctions = new WeakSet();
226
227 // For a given scope, iterate through the references and add all useEffectEvent definitions. We can
228 // do this in non-Program nodes because we can rely on the assumption that useEffectEvent functions
229 // can only be declared within a component or hook at its top level.
230 function recordAllUseEffectEventFunctions(scope: Scope.Scope): void {
231 for (const reference of scope.references) {
232 const parent = reference.identifier.parent;
233 if (
234 parent?.type === 'VariableDeclarator' &&
235 parent.init &&
236 parent.init.type === 'CallExpression' &&
237 parent.init.callee &&
238 isUseEffectEventIdentifier(parent.init.callee)
239 ) {
240 if (reference.resolved === null) {
241 throw new Error('Unexpected null reference.resolved');
242 }
243 for (const ref of reference.resolved.references) {
244 if (ref !== reference) {
245 useEffectEventFunctions.add(ref.identifier);
246 }
247 }
248 }
249 }
250 }
251
252 /**
253 * SourceCode that also works down to ESLint 3.0.0
254 */
255 const getSourceCode =
256 typeof context.getSourceCode === 'function'
257 ? () => {
258 return context.getSourceCode();
259 }
260 : () => {
261 return context.sourceCode;
262 };
263 /**
264 * SourceCode#getScope that also works down to ESLint 3.0.0
265 */
266 const getScope =
267 typeof context.getScope === 'function'
268 ? (): Scope.Scope => {
269 return context.getScope();
270 }
271 : (node: Node): Scope.Scope => {
272 return getSourceCode().getScope(node);
273 };
274
275 function hasFlowSuppression(node: Node, suppression: string) {
276 const sourceCode = getSourceCode();
277 const comments = sourceCode.getAllComments();
278 const flowSuppressionRegex = new RegExp(
279 '\\$FlowFixMe\\[' + suppression + '\\]',
280 );
281 return comments.some(
282 commentNode =>
283 flowSuppressionRegex.test(commentNode.value) &&
284 commentNode.loc != null &&
285 node.loc != null &&
286 commentNode.loc.end.line === node.loc.start.line - 1,
287 );
288 }
289
290 const analyzer = new CodePathAnalyzer({
291 // Maintain code segment path stack as we traverse.
292 onCodePathSegmentStart: (segment: Rule.CodePathSegment) =>
293 codePathSegmentStack.push(segment),
294 onCodePathSegmentEnd: () => codePathSegmentStack.pop(),
295
296 // Maintain code path stack as we traverse.
297 onCodePathStart: () =>
298 codePathReactHooksMapStack.push(
299 new Map<Rule.CodePathSegment, Array<Node>>(),
300 ),
301
302 // Process our code path.
303 //
304 // Everything is ok if all React Hooks are both reachable from the initial
305 // segment and reachable from every final segment.
306 onCodePathEnd(codePath: any, codePathNode: Node) {
307 const reactHooksMap = codePathReactHooksMapStack.pop();
308 if (reactHooksMap?.size === 0) {
309 return;
310 } else if (typeof reactHooksMap === 'undefined') {
311 throw new Error('Unexpected undefined reactHooksMap');
312 }
313
314 // All of the segments which are cyclic are recorded in this set.
315 const cyclic = new Set();
316
317 /**
318 * Count the number of code paths from the start of the function to this
319 * segment. For example:
320 *
321 * ```js
322 * function MyComponent() {
323 * if (condition) {
324 * // Segment 1
325 * } else {
326 * // Segment 2
327 * }
328 * // Segment 3
329 * }
330 * ```
331 *
332 * Segments 1 and 2 have one path to the beginning of `MyComponent` and
333 * segment 3 has two paths to the beginning of `MyComponent` since we
334 * could have either taken the path of segment 1 or segment 2.
335 *
336 * Populates `cyclic` with cyclic segments.
337 */
338 function countPathsFromStart(
339 segment: Rule.CodePathSegment,
340 pathHistory?: Set<string>,
341 ): bigint {
342 const {cache} = countPathsFromStart;
343 let paths = cache.get(segment.id);
344 const pathList = new Set<string>(pathHistory);
345
346 // If `pathList` includes the current segment then we've found a cycle!
347 // We need to fill `cyclic` with all segments inside cycle
348 if (pathList.has(segment.id)) {
349 const pathArray = [...pathList];
350 const cyclicSegments = pathArray.slice(
351 pathArray.indexOf(segment.id) + 1,
352 );
353 for (const cyclicSegment of cyclicSegments) {
354 cyclic.add(cyclicSegment);
355 }
356
357 return BigInt('0');
358 }
359
360 // add the current segment to pathList
361 pathList.add(segment.id);
362
363 // We have a cached `paths`. Return it.
364 if (paths !== undefined) {
365 return paths;
366 }
367
368 if (codePath.thrownSegments.includes(segment)) {
369 paths = BigInt('0');
370 } else if (segment.prevSegments.length === 0) {
371 paths = BigInt('1');
372 } else {
373 paths = BigInt('0');
374 for (const prevSegment of segment.prevSegments) {
375 paths += countPathsFromStart(prevSegment, pathList);
376 }
377 }
378
379 // If our segment is reachable then there should be at least one path
380 // to it from the start of our code path.
381 if (segment.reachable && paths === BigInt('0')) {
382 cache.delete(segment.id);
383 } else {
384 cache.set(segment.id, paths);
385 }
386
387 return paths;
388 }
389
390 /**
391 * Count the number of code paths from this segment to the end of the
392 * function. For example:
393 *
394 * ```js
395 * function MyComponent() {
396 * // Segment 1
397 * if (condition) {
398 * // Segment 2
399 * } else {
400 * // Segment 3
401 * }
402 * }
403 * ```
404 *
405 * Segments 2 and 3 have one path to the end of `MyComponent` and
406 * segment 1 has two paths to the end of `MyComponent` since we could
407 * either take the path of segment 1 or segment 2.
408 *
409 * Populates `cyclic` with cyclic segments.
410 */
411
412 function countPathsToEnd(
413 segment: Rule.CodePathSegment,
414 pathHistory?: Set<string>,
415 ): bigint {
416 const {cache} = countPathsToEnd;
417 let paths = cache.get(segment.id);
418 const pathList = new Set(pathHistory);
419
420 // If `pathList` includes the current segment then we've found a cycle!
421 // We need to fill `cyclic` with all segments inside cycle
422 if (pathList.has(segment.id)) {
423 const pathArray = Array.from(pathList);
424 const cyclicSegments = pathArray.slice(
425 pathArray.indexOf(segment.id) + 1,
426 );
427 for (const cyclicSegment of cyclicSegments) {
428 cyclic.add(cyclicSegment);
429 }
430
431 return BigInt('0');
432 }
433
434 // add the current segment to pathList
435 pathList.add(segment.id);
436
437 // We have a cached `paths`. Return it.
438 if (paths !== undefined) {
439 return paths;
440 }
441
442 if (codePath.thrownSegments.includes(segment)) {
443 paths = BigInt('0');
444 } else if (segment.nextSegments.length === 0) {
445 paths = BigInt('1');
446 } else {
447 paths = BigInt('0');
448 for (const nextSegment of segment.nextSegments) {
449 paths += countPathsToEnd(nextSegment, pathList);
450 }
451 }
452
453 cache.set(segment.id, paths);
454 return paths;
455 }
456
457 /**
458 * Gets the shortest path length to the start of a code path.
459 * For example:
460 *
461 * ```js
462 * function MyComponent() {
463 * if (condition) {
464 * // Segment 1
465 * }
466 * // Segment 2
467 * }
468 * ```
469 *
470 * There is only one path from segment 1 to the code path start. Its
471 * length is one so that is the shortest path.
472 *
473 * There are two paths from segment 2 to the code path start. One
474 * through segment 1 with a length of two and another directly to the
475 * start with a length of one. The shortest path has a length of one
476 * so we would return that.
477 */
478
479 function shortestPathLengthToStart(
480 segment: Rule.CodePathSegment,
481 ): number {
482 const {cache} = shortestPathLengthToStart;
483 let length = cache.get(segment.id);
484
485 // If `length` is null then we found a cycle! Return infinity since
486 // the shortest path is definitely not the one where we looped.
487 if (length === null) {
488 return Infinity;
489 }
490
491 // We have a cached `length`. Return it.
492 if (length !== undefined) {
493 return length;
494 }
495
496 // Compute `length` and cache it. Guarding against cycles.
497 cache.set(segment.id, null);
498 if (segment.prevSegments.length === 0) {
499 length = 1;
500 } else {
501 length = Infinity;
502 for (const prevSegment of segment.prevSegments) {
503 const prevLength = shortestPathLengthToStart(prevSegment);
504 if (prevLength < length) {
505 length = prevLength;
506 }
507 }
508 length += 1;
509 }
510 cache.set(segment.id, length);
511 return length;
512 }
513
514 countPathsFromStart.cache = new Map<string, bigint>();
515 countPathsToEnd.cache = new Map<string, bigint>();
516 shortestPathLengthToStart.cache = new Map<string, number | null>();
517
518 // Count all code paths to the end of our component/hook. Also primes
519 // the `countPathsToEnd` cache.
520 const allPathsFromStartToEnd = countPathsToEnd(codePath.initialSegment);
521
522 // Gets the function name for our code path. If the function name is
523 // `undefined` then we know either that we have an anonymous function
524 // expression or our code path is not in a function. In both cases we
525 // will want to error since neither are React function components or
526 // hook functions - unless it is an anonymous function argument to
527 // forwardRef or memo.
528 const codePathFunctionName = getFunctionName(codePathNode);
529
530 // This is a valid code path for React hooks if we are directly in a React
531 // function component or we are in a hook function.
532 const isSomewhereInsideComponentOrHook =
533 isInsideComponentOrHook(codePathNode);
534 const isDirectlyInsideComponentOrHook = codePathFunctionName
535 ? isComponentName(codePathFunctionName) ||
536 isHook(codePathFunctionName)
537 : isForwardRefCallback(codePathNode) || isMemoCallback(codePathNode);
538
539 // Compute the earliest finalizer level using information from the
540 // cache. We expect all reachable final segments to have a cache entry
541 // after calling `visitSegment()`.
542 let shortestFinalPathLength = Infinity;
543 for (const finalSegment of codePath.finalSegments) {
544 if (!finalSegment.reachable) {
545 continue;
546 }
547 const length = shortestPathLengthToStart(finalSegment);
548 if (length < shortestFinalPathLength) {
549 shortestFinalPathLength = length;
550 }
551 }
552
553 // Make sure all React Hooks pass our lint invariants. Log warnings
554 // if not.
555 for (const [segment, reactHooks] of reactHooksMap) {
556 // NOTE: We could report here that the hook is not reachable, but
557 // that would be redundant with more general "no unreachable"
558 // lint rules.
559 if (!segment.reachable) {
560 continue;
561 }
562
563 // If there are any final segments with a shorter path to start then
564 // we possibly have an early return.
565 //
566 // If our segment is a final segment itself then siblings could
567 // possibly be early returns.
568 const possiblyHasEarlyReturn =
569 segment.nextSegments.length === 0
570 ? shortestFinalPathLength <= shortestPathLengthToStart(segment)
571 : shortestFinalPathLength < shortestPathLengthToStart(segment);
572
573 // Count all the paths from the start of our code path to the end of
574 // our code path that go _through_ this segment. The critical piece
575 // of this is _through_. If we just call `countPathsToEnd(segment)`
576 // then we neglect that we may have gone through multiple paths to get
577 // to this point! Consider:
578 //
579 // ```js
580 // function MyComponent() {
581 // if (a) {
582 // // Segment 1
583 // } else {
584 // // Segment 2
585 // }
586 // // Segment 3
587 // if (b) {
588 // // Segment 4
589 // } else {
590 // // Segment 5
591 // }
592 // }
593 // ```
594 //
595 // In this component we have four code paths:
596 //
597 // 1. `a = true; b = true`
598 // 2. `a = true; b = false`
599 // 3. `a = false; b = true`
600 // 4. `a = false; b = false`
601 //
602 // From segment 3 there are two code paths to the end through segment
603 // 4 and segment 5. However, we took two paths to get here through
604 // segment 1 and segment 2.
605 //
606 // If we multiply the paths from start (two) by the paths to end (two)
607 // for segment 3 we get four. Which is our desired count.
608 const pathsFromStartToEnd =
609 countPathsFromStart(segment) * countPathsToEnd(segment);
610
611 // Is this hook a part of a cyclic segment?
612 const cycled = cyclic.has(segment.id);
613
614 for (const hook of reactHooks) {
615 // Skip reporting if this hook already has a relevant flow suppression.
616 if (hasFlowSuppression(hook, 'react-rule-hook')) {
617 continue;
618 }
619
620 // Report an error if use() is called inside try/catch.
621 if (isUseIdentifier(hook) && isInsideTryCatch(hook)) {
622 context.report({
623 node: hook,
624 message: `React Hook "${getSourceCode().getText(
625 hook,
626 )}" cannot be called in a try/catch block.`,
627 });
628 }
629
630 // Report an error if a hook may be called more then once.
631 // `use(...)` can be called in loops.
632 if (
633 (cycled || isInsideDoWhileLoop(hook)) &&
634 !isUseIdentifier(hook)
635 ) {
636 context.report({
637 node: hook,
638 message:
639 `React Hook "${getSourceCode().getText(
640 hook,
641 )}" may be executed ` +
642 'more than once. Possibly because it is called in a loop. ' +
643 'React Hooks must be called in the exact same order in ' +
644 'every component render.',
645 });
646 }
647
648 // If this is not a valid code path for React hooks then we need to
649 // log a warning for every hook in this code path.
650 //
651 // Pick a special message depending on the scope this hook was
652 // called in.
653 if (isDirectlyInsideComponentOrHook) {
654 // Report an error if the hook is called inside an async function.
655 // @ts-expect-error the above check hasn't properly type-narrowed `codePathNode` (async doesn't exist on Node)
656 const isAsyncFunction = codePathNode.async;
657 if (isAsyncFunction) {
658 context.report({
659 node: hook,
660 message:
661 `React Hook "${getSourceCode().getText(hook)}" cannot be ` +
662 'called in an async function.',
663 });
664 }
665
666 // Report an error if a hook does not reach all finalizing code
667 // path segments.
668 //
669 // Special case when we think there might be an early return.
670 if (
671 !cycled &&
672 pathsFromStartToEnd !== allPathsFromStartToEnd &&
673 !isUseIdentifier(hook) && // `use(...)` can be called conditionally.
674 !isInsideDoWhileLoop(hook) // wrapping do/while loops are checked separately.
675 ) {
676 const message =
677 `React Hook "${getSourceCode().getText(hook)}" is called ` +
678 'conditionally. React Hooks must be called in the exact ' +
679 'same order in every component render.' +
680 (possiblyHasEarlyReturn
681 ? ' Did you accidentally call a React Hook after an' +
682 ' early return?'
683 : '');
684 context.report({node: hook, message});
685 }
686 } else if (
687 codePathNode.parent != null &&
688 (codePathNode.parent.type === 'MethodDefinition' ||
689 // @ts-expect-error `ClassProperty` was removed from typescript-estree in https://github.com/typescript-eslint/typescript-eslint/pull/3806
690 codePathNode.parent.type === 'ClassProperty' ||
691 codePathNode.parent.type === 'PropertyDefinition') &&
692 codePathNode.parent.value === codePathNode
693 ) {
694 // Custom message for hooks inside a class
695 const message =
696 `React Hook "${getSourceCode().getText(
697 hook,
698 )}" cannot be called ` +
699 'in a class component. React Hooks must be called in a ' +
700 'React function component or a custom React Hook function.';
701 context.report({node: hook, message});
702 } else if (codePathFunctionName) {
703 // Custom message if we found an invalid function name.
704 const message =
705 `React Hook "${getSourceCode().getText(hook)}" is called in ` +
706 `function "${getSourceCode().getText(codePathFunctionName)}" ` +
707 'that is neither a React function component nor a custom ' +
708 'React Hook function.' +
709 ' React component names must start with an uppercase letter.' +
710 ' React Hook names must start with the word "use".';
711 context.report({node: hook, message});
712 } else if (codePathNode.type === 'Program') {
713 // These are dangerous if you have inline requires enabled.
714 const message =
715 `React Hook "${getSourceCode().getText(
716 hook,
717 )}" cannot be called ` +
718 'at the top level. React Hooks must be called in a ' +
719 'React function component or a custom React Hook function.';
720 context.report({node: hook, message});
721 } else {
722 // Assume in all other cases the user called a hook in some
723 // random function callback. This should usually be true for
724 // anonymous function expressions. Hopefully this is clarifying
725 // enough in the common case that the incorrect message in
726 // uncommon cases doesn't matter.
727 // `use(...)` can be called in callbacks.
728 if (isSomewhereInsideComponentOrHook && !isUseIdentifier(hook)) {
729 const message =
730 `React Hook "${getSourceCode().getText(
731 hook,
732 )}" cannot be called ` +
733 'inside a callback. React Hooks must be called in a ' +
734 'React function component or a custom React Hook function.';
735 context.report({node: hook, message});
736 }
737 }
738 }
739 }
740 },
741 });
742
743 return {
744 '*'(node: any) {
745 analyzer.enterNode(node);
746 },
747
748 '*:exit'(node: any) {
749 analyzer.leaveNode(node);
750 },
751
752 // Missed opportunity...We could visit all `Identifier`s instead of all
753 // `CallExpression`s and check that _every use_ of a hook name is valid.
754 // But that gets complicated and enters type-system territory, so we're
755 // only being strict about hook calls for now.
756 CallExpression(node) {
757 if (isHook(node.callee)) {
758 // Add the hook node to a map keyed by the code path segment. We will
759 // do full code path analysis at the end of our code path.
760 const reactHooksMap = last(codePathReactHooksMapStack);
761 const codePathSegment = last(codePathSegmentStack);
762 let reactHooks = reactHooksMap.get(codePathSegment);
763 if (!reactHooks) {
764 reactHooks = [];
765 reactHooksMap.set(codePathSegment, reactHooks);
766 }
767 reactHooks.push(node.callee);
768 }
769
770 // useEffectEvent: useEffectEvent functions can be passed by reference within useEffect as well as in
771 // another useEffectEvent
772 // Check all `useEffect` and `React.useEffect`, `useEffectEvent`, and `React.useEffectEvent`
773 const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee);
774 if (
775 (isEffectIdentifier(nodeWithoutNamespace, additionalEffectHooks) ||
776 isUseEffectEventIdentifier(nodeWithoutNamespace)) &&
777 node.arguments.length > 0
778 ) {
779 // Denote that we have traversed into a useEffect call, and stash the CallExpr for
780 // comparison later when we exit
781 lastEffect = node;
782 }
783
784 // Specifically disallow <Child onClick={useEffectEvent(...)} /> because this
785 // case can't be caught by `recordAllUseEffectEventFunctions` as it isn't assigned to a variable
786 if (
787 isUseEffectEventIdentifier(nodeWithoutNamespace) &&
788 node.parent?.type !== 'VariableDeclarator' &&
789 // like in other hooks, calling useEffectEvent at component's top level without assignment is valid
790 node.parent?.type !== 'ExpressionStatement'
791 ) {
792 const message = useEffectEventError(null, false);
793
794 context.report({
795 node,
796 message,
797 });
798 }
799 },
800
801 Identifier(node) {
802 // This identifier resolves to a useEffectEvent function, but isn't being referenced in an
803 // effect or another event function. It isn't being called either.
804 if (lastEffect == null && useEffectEventFunctions.has(node)) {
805 const message = useEffectEventError(
806 getSourceCode().getText(node),
807 node.parent.type === 'CallExpression',
808 );
809
810 context.report({
811 node,
812 message,
813 });
814 }
815 },
816
817 'CallExpression:exit'(node) {
818 if (node === lastEffect) {
819 lastEffect = null;
820 }
821 },
822
823 FunctionDeclaration(node) {
824 // function MyComponent() { const onClick = useEffectEvent(...) }
825 if (isInsideComponentOrHook(node)) {
826 recordAllUseEffectEventFunctions(getScope(node));
827 }
828 },
829
830 ArrowFunctionExpression(node) {
831 // const MyComponent = () => { const onClick = useEffectEvent(...) }
832 if (isInsideComponentOrHook(node)) {
833 recordAllUseEffectEventFunctions(getScope(node));
834 }
835 },
836
837 // @ts-expect-error parser-hermes produces these node types
838 ComponentDeclaration(node) {
839 // component MyComponent() { const onClick = useEffectEvent(...) }
840 recordAllUseEffectEventFunctions(getScope(node));
841 },
842
843 // @ts-expect-error parser-hermes produces these node types
844 HookDeclaration(node) {
845 // hook useMyHook() { const onClick = useEffectEvent(...) }
846 recordAllUseEffectEventFunctions(getScope(node));
847 },
848 };
849 },
850 } satisfies Rule.RuleModule;
851
852 /**
853 * Gets the static name of a function AST node. For function declarations it is
854 * easy. For anonymous function expressions it is much harder. If you search for
855 * `IsAnonymousFunctionDefinition()` in the ECMAScript spec you'll find places
856 * where JS gives anonymous function expressions names. We roughly detect the
857 * same AST nodes with some exceptions to better fit our use case.
858 */
859
860 function getFunctionName(node: Node) {
861 if (
862 // @ts-expect-error parser-hermes produces these node types
863 node.type === 'ComponentDeclaration' ||
864 // @ts-expect-error parser-hermes produces these node types
865 node.type === 'HookDeclaration' ||
866 node.type === 'FunctionDeclaration' ||
867 (node.type === 'FunctionExpression' && node.id)
868 ) {
869 // function useHook() {}
870 // const whatever = function useHook() {};
871 //
872 // Function declaration or function expression names win over any
873 // assignment statements or other renames.
874 return node.id;
875 } else if (
876 node.type === 'FunctionExpression' ||
877 node.type === 'ArrowFunctionExpression'
878 ) {
879 if (
880 node.parent?.type === 'VariableDeclarator' &&
881 node.parent.init === node
882 ) {
883 // const useHook = () => {};
884 return node.parent.id;
885 } else if (
886 node.parent?.type === 'AssignmentExpression' &&
887 node.parent.right === node &&
888 node.parent.operator === '='
889 ) {
890 // useHook = () => {};
891 return node.parent.left;
892 } else if (
893 node.parent?.type === 'Property' &&
894 node.parent.value === node &&
895 !node.parent.computed
896 ) {
897 // {useHook: () => {}}
898 // {useHook() {}}
899 return node.parent.key;
900
901 // NOTE: We could also support `ClassProperty` and `MethodDefinition`
902 // here to be pedantic. However, hooks in a class are an anti-pattern. So
903 // we don't allow it to error early.
904 //
905 // class {useHook = () => {}}
906 // class {useHook() {}}
907 } else if (
908 node.parent?.type === 'AssignmentPattern' &&
909 node.parent.right === node &&
910 // @ts-expect-error Property computed does not exist on type `AssignmentPattern`.
911 !node.parent.computed
912 ) {
913 // const {useHook = () => {}} = {};
914 // ({useHook = () => {}} = {});
915 //
916 // Kinda clowny, but we'd said we'd follow spec convention for
917 // `IsAnonymousFunctionDefinition()` usage.
918 return node.parent.left;
919 } else {
920 return undefined;
921 }
922 } else {
923 return undefined;
924 }
925 }
926
927 /**
928 * Convenience function for peeking the last item in a stack.
929 */
930 function last<T>(array: Array<T>): T {
931 return array[array.length - 1] as T;
932 }
933
934 export default rule;