@samitouri / QOS-React-2 / commits / dab2fdbbbd

Add eslint-plugin-react-hooks/exhaustive-deps rule to check stale closure dependencies (#14636)

* Add ESLint rule for useEffect/useCallback/useMemo Hook dependencies * Fix ReactiveDependencies rule * fix lint errors * Support useLayoutEffect * Add some failing tests and comments * Gather dependencies in child scopes too * If we don't find foo.bar.baz in deps, try foo.bar, then foo * foo is enough for both foo.bar and foo.baz * Shorter rule name * Add fixable meta * Remove a bunch of code and start from scratch * [WIP] Only report errors from dependency array This results in nicer editing experience. Also has autofix. * Fix typo * [Temp] Skip all tests * Fix the first test * Revamp the test suite * Fix [foo] to include foo.bar * Don't suggest call expressions * Special case 'current' for refs * Don't complain about known static deps * Support useImperativeHandle * Better punctuation and formatting * More uniform message format * Treat React.useRef/useState/useReducer as static too * Add special message for ref.current * Add a TODO case * Alphabetize the autofix * Only alphabetize if it already was * Don't add static deps by default * Add an undefined variable case * Tweak wording * Rename to exhaustive-deps * Clean up / refactor a little bit

Dan Abramov committed Feb 20, 2019 at 18:18 UTC dab2fdbbbd6eb560523fe2146c153f6a24e2f8dc
5 files changed +2235 -5
fixtures/eslint/.eslintrc.json
+2 -1
@@ -9,6 +9,7 @@
9 },
10 "plugins": ["react-hooks"],
11 "rules": {
12 - "react-hooks/rules-of-hooks": 2
12 + "react-hooks/rules-of-hooks": 2,
13 + "react-hooks/exhaustive-deps": 2
14 }
15 }
fixtures/eslint/index.js
+22 -4
@@ -4,8 +4,26 @@
4 // 2. "File > Add Folder to Workspace" this specific folder in VSCode with ESLint extension
5 // 3. Changes to the rule source should get picked up without restarting ESLint server
6
7 -function Foo() {
8 - if (condition) {
9 - useEffect(() => {});
10 - }
7 +function Comment({comment, commentSource}) {
8 + const currentUserID = comment.viewer.id;
9 + const environment = RelayEnvironment.forUser(currentUserID);
10 + const commentID = nullthrows(comment.id);
11 + useEffect(
12 + () => {
13 + const subscription = SubscriptionCounter.subscribeOnce(
14 + `StoreSubscription_${commentID}`,
15 + () =>
16 + StoreSubscription.subscribe(
17 + environment,
18 + {
19 + comment_id: commentID,
20 + },
21 + currentUserID,
22 + commentSource
23 + )
24 + );
25 + return () => subscription.dispose();
26 + },
27 + [commentID, commentSource, currentUserID, environment]
28 + );
29 }
packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js new
+1642
@@ -0,0 +1,1642 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +
8 +'use strict';
9 +
10 +const ESLintTester = require('eslint').RuleTester;
11 +const ReactHooksESLintPlugin = require('eslint-plugin-react-hooks');
12 +const ReactHooksESLintRule = ReactHooksESLintPlugin.rules['exhaustive-deps'];
13 +
14 +ESLintTester.setDefaultConfig({
15 + parser: 'babel-eslint',
16 + parserOptions: {
17 + ecmaVersion: 6,
18 + sourceType: 'module',
19 + },
20 +});
21 +
22 +// ***************************************************
23 +// For easier local testing, you can add to any case:
24 +// {
25 +// skip: true,
26 +// --or--
27 +// only: true,
28 +// ...
29 +// }
30 +// ***************************************************
31 +
32 +const tests = {
33 + valid: [
34 + {
35 + code: `
36 + function MyComponent() {
37 + const local = 42;
38 + useEffect(() => {
39 + console.log(local);
40 + });
41 + }
42 + `,
43 + },
44 + {
45 + code: `
46 + function MyComponent() {
47 + useEffect(() => {
48 + const local = 42;
49 + console.log(local);
50 + }, []);
51 + }
52 + `,
53 + },
54 + {
55 + code: `
56 + function MyComponent() {
57 + const local = 42;
58 + useEffect(() => {
59 + console.log(local);
60 + }, [local]);
61 + }
62 + `,
63 + },
64 + {
65 + // OK because `props` wasn't defined.
66 + // We don't technically know if `props` is supposed
67 + // to be an import that hasn't been added yet, or
68 + // a component-level variable. Ignore it until it
69 + // gets defined (a different rule would flag it anyway).
70 + code: `
71 + function MyComponent() {
72 + useEffect(() => {
73 + console.log(props.foo);
74 + }, []);
75 + }
76 + `,
77 + },
78 + {
79 + code: `
80 + function MyComponent() {
81 + const local1 = 42;
82 + {
83 + const local2 = 42;
84 + useEffect(() => {
85 + console.log(local1);
86 + console.log(local2);
87 + });
88 + }
89 + }
90 + `,
91 + },
92 + {
93 + code: `
94 + function MyComponent() {
95 + const local1 = 42;
96 + {
97 + const local2 = 42;
98 + useEffect(() => {
99 + console.log(local1);
100 + console.log(local2);
101 + }, [local1, local2]);
102 + }
103 + }
104 + `,
105 + },
106 + {
107 + code: `
108 + function MyComponent() {
109 + const local1 = 42;
110 + function MyNestedComponent() {
111 + const local2 = 42;
112 + useEffect(() => {
113 + console.log(local1);
114 + console.log(local2);
115 + }, [local2]);
116 + }
117 + }
118 + `,
119 + },
120 + {
121 + code: `
122 + function MyComponent() {
123 + const local = 42;
124 + useEffect(() => {
125 + console.log(local);
126 + console.log(local);
127 + }, [local]);
128 + }
129 + `,
130 + },
131 + {
132 + code: `
133 + function MyComponent() {
134 + useEffect(() => {
135 + console.log(unresolved);
136 + }, []);
137 + }
138 + `,
139 + },
140 + {
141 + code: `
142 + function MyComponent() {
143 + const local = 42;
144 + useEffect(() => {
145 + console.log(local);
146 + }, [,,,local,,,]);
147 + }
148 + `,
149 + },
150 + {
151 + code: `
152 + // Regression test
153 + function MyComponent({ foo }) {
154 + useEffect(() => {
155 + console.log(foo.length);
156 + }, [foo]);
157 + }
158 + `,
159 + },
160 + {
161 + code: `
162 + // Regression test
163 + function MyComponent({ foo }) {
164 + useEffect(() => {
165 + console.log(foo.length);
166 + console.log(foo.slice(0));
167 + }, [foo]);
168 + }
169 + `,
170 + },
171 + {
172 + code: `
173 + // Regression test
174 + function MyComponent({ history }) {
175 + useEffect(() => {
176 + return history.listen();
177 + }, [history]);
178 + }
179 + `,
180 + },
181 + {
182 + // TODO: we might want to forbid dot-access in deps.
183 + code: `
184 + function MyComponent(props) {
185 + useEffect(() => {
186 + console.log(props.foo);
187 + }, [props.foo]);
188 + }
189 + `,
190 + },
191 + {
192 + // TODO: we might want to forbid dot-access in deps.
193 + code: `
194 + function MyComponent(props) {
195 + useEffect(() => {
196 + console.log(props.foo);
197 + console.log(props.bar);
198 + }, [props.bar, props.foo]);
199 + }
200 + `,
201 + },
202 + {
203 + // TODO: we might want to forbid dot-access in deps.
204 + code: `
205 + function MyComponent(props) {
206 + useEffect(() => {
207 + console.log(props.foo);
208 + console.log(props.bar);
209 + }, [props.foo, props.bar]);
210 + }
211 + `,
212 + },
213 + {
214 + // TODO: we might want to forbid dot-access in deps.
215 + code: `
216 + function MyComponent(props) {
217 + const local = 42;
218 + useEffect(() => {
219 + console.log(props.foo);
220 + console.log(props.bar);
221 + console.log(local);
222 + }, [props.foo, props.bar, local]);
223 + }
224 + `,
225 + },
226 + {
227 + // TODO: we might want to warn "props.foo"
228 + // is extraneous because we already have "props".
229 + code: `
230 + function MyComponent(props) {
231 + const local = 42;
232 + useEffect(() => {
233 + console.log(props.foo);
234 + console.log(props.bar);
235 + }, [props, props.foo]);
236 + }
237 + `,
238 + },
239 + {
240 + code: `
241 + function MyComponent(props) {
242 + useCustomEffect(() => {
243 + console.log(props.foo);
244 + });
245 + }
246 + `,
247 + options: [{additionalHooks: 'useCustomEffect'}],
248 + },
249 + {
250 + // TODO: we might want to forbid dot-access in deps.
251 + code: `
252 + function MyComponent(props) {
253 + useCustomEffect(() => {
254 + console.log(props.foo);
255 + }, [props.foo]);
256 + }
257 + `,
258 + options: [{additionalHooks: 'useCustomEffect'}],
259 + },
260 + {
261 + code: `
262 + // Valid because we don't care about hooks outside of components.
263 + const local = 42;
264 + useEffect(() => {
265 + console.log(local);
266 + }, []);
267 + `,
268 + },
269 + {
270 + code: `
271 + // Valid because we don't care about hooks outside of components.
272 + const local1 = 42;
273 + {
274 + const local2 = 42;
275 + useEffect(() => {
276 + console.log(local1);
277 + console.log(local2);
278 + }, []);
279 + }
280 + `,
281 + },
282 + {
283 + code: `
284 + function MyComponent() {
285 + const ref = useRef();
286 + useEffect(() => {
287 + console.log(ref.current);
288 + }, [ref]);
289 + }
290 + `,
291 + },
292 + {
293 + code: `
294 + function MyComponent() {
295 + const ref = useRef();
296 + useEffect(() => {
297 + console.log(ref.current);
298 + }, []);
299 + }
300 + `,
301 + },
302 + {
303 + code: `
304 + function MyComponent({ maybeRef2 }) {
305 + const definitelyRef1 = useRef();
306 + const definitelyRef2 = useRef();
307 + const maybeRef1 = useSomeOtherRefyThing();
308 + const [state1, setState1] = useState();
309 + const [state2, setState2] = React.useState();
310 + const [state3, dispatch1] = useReducer();
311 + const [state4, dispatch2] = React.useReducer();
312 + const [state5, maybeSetState] = useFunnyState();
313 + const [state6, maybeDispatch] = useFunnyReducer();
314 + function mySetState() {}
315 + function myDispatch() {}
316 +
317 + useEffect(() => {
318 + // Known to be static
319 + console.log(definitelyRef1.current);
320 + console.log(definitelyRef2.current);
321 + console.log(maybeRef1.current);
322 + console.log(maybeRef2.current);
323 + setState1();
324 + setState2();
325 + dispatch1();
326 + dispatch2();
327 +
328 + // Dynamic
329 + console.log(state1);
330 + console.log(state2);
331 + console.log(state3);
332 + console.log(state4);
333 + console.log(state5);
334 + console.log(state6);
335 + mySetState();
336 + myDispatch();
337 +
338 + // Not sure; assume dynamic
339 + maybeSetState();
340 + maybeDispatch();
341 + }, [
342 + // Dynamic
343 + state1, state2, state3, state4, state5, state6,
344 + maybeRef1, maybeRef2,
345 +
346 + // Not sure; assume dynamic
347 + mySetState, myDispatch,
348 + maybeSetState, maybeDispatch
349 +
350 + // In this test, we don't specify static deps.
351 + // That should be okay.
352 + ]);
353 + }
354 + `,
355 + },
356 + {
357 + code: `
358 + function MyComponent({ maybeRef2 }) {
359 + const definitelyRef1 = useRef();
360 + const definitelyRef2 = useRef();
361 + const maybeRef1 = useSomeOtherRefyThing();
362 +
363 + const [state1, setState1] = useState();
364 + const [state2, setState2] = React.useState();
365 + const [state3, dispatch1] = useReducer();
366 + const [state4, dispatch2] = React.useReducer();
367 +
368 + const [state5, maybeSetState] = useFunnyState();
369 + const [state6, maybeDispatch] = useFunnyReducer();
370 +
371 + function mySetState() {}
372 + function myDispatch() {}
373 +
374 + useEffect(() => {
375 + // Known to be static
376 + console.log(definitelyRef1.current);
377 + console.log(definitelyRef2.current);
378 + console.log(maybeRef1.current);
379 + console.log(maybeRef2.current);
380 + setState1();
381 + setState2();
382 + dispatch1();
383 + dispatch2();
384 +
385 + // Dynamic
386 + console.log(state1);
387 + console.log(state2);
388 + console.log(state3);
389 + console.log(state4);
390 + console.log(state5);
391 + console.log(state6);
392 + mySetState();
393 + myDispatch();
394 +
395 + // Not sure; assume dynamic
396 + maybeSetState();
397 + maybeDispatch();
398 + }, [
399 + // Dynamic
400 + state1, state2, state3, state4, state5, state6,
401 + maybeRef1, maybeRef2,
402 +
403 + // Not sure; assume dynamic
404 + mySetState, myDispatch,
405 + maybeSetState, maybeDispatch,
406 +
407 + // In this test, we specify static deps.
408 + // That should be okay too!
409 + definitelyRef1, definitelyRef2, setState1, setState2, dispatch1, dispatch2
410 + ]);
411 + }
412 + `,
413 + },
414 + {
415 + code: `
416 + const MyComponent = forwardRef((props, ref) => {
417 + useImperativeHandle(ref, () => ({
418 + focus() {
419 + alert(props.hello);
420 + }
421 + }))
422 + });
423 + `,
424 + },
425 + {
426 + code: `
427 + const MyComponent = forwardRef((props, ref) => {
428 + useImperativeHandle(ref, () => ({
429 + focus() {
430 + alert(props.hello);
431 + }
432 + }), [props.hello])
433 + });
434 + `,
435 + },
436 + ],
437 + invalid: [
438 + {
439 + code: `
440 + function MyComponent() {
441 + const local = 42;
442 + useEffect(() => {
443 + console.log(local);
444 + }, []);
445 + }
446 + `,
447 + output: `
448 + function MyComponent() {
449 + const local = 42;
450 + useEffect(() => {
451 + console.log(local);
452 + }, [local]);
453 + }
454 + `,
455 + errors: [
456 + "React Hook useEffect has a missing dependency: 'local'. " +
457 + 'Either include it or remove the dependency array.',
458 + ],
459 + },
460 + {
461 + code: `
462 + // Regression test
463 + function MyComponent() {
464 + const local = 42;
465 + useEffect(() => {
466 + if (true) {
467 + console.log(local);
468 + }
469 + }, []);
470 + }
471 + `,
472 + output: `
473 + // Regression test
474 + function MyComponent() {
475 + const local = 42;
476 + useEffect(() => {
477 + if (true) {
478 + console.log(local);
479 + }
480 + }, [local]);
481 + }
482 + `,
483 + errors: [
484 + "React Hook useEffect has a missing dependency: 'local'. " +
485 + 'Either include it or remove the dependency array.',
486 + ],
487 + },
488 + {
489 + code: `
490 + // Regression test
491 + function MyComponent() {
492 + const local = 42;
493 + useEffect(() => {
494 + try {
495 + console.log(local);
496 + } finally {}
497 + }, []);
498 + }
499 + `,
500 + output: `
501 + // Regression test
502 + function MyComponent() {
503 + const local = 42;
504 + useEffect(() => {
505 + try {
506 + console.log(local);
507 + } finally {}
508 + }, [local]);
509 + }
510 + `,
511 + errors: [
512 + "React Hook useEffect has a missing dependency: 'local'. " +
513 + 'Either include it or remove the dependency array.',
514 + ],
515 + },
516 + {
517 + code: `
518 + // Regression test
519 + function MyComponent() {
520 + const local = 42;
521 + useEffect(() => {
522 + function inner() {
523 + console.log(local);
524 + }
525 + inner();
526 + }, []);
527 + }
528 + `,
529 + output: `
530 + // Regression test
531 + function MyComponent() {
532 + const local = 42;
533 + useEffect(() => {
534 + function inner() {
535 + console.log(local);
536 + }
537 + inner();
538 + }, [local]);
539 + }
540 + `,
541 + errors: [
542 + "React Hook useEffect has a missing dependency: 'local'. " +
543 + 'Either include it or remove the dependency array.',
544 + ],
545 + },
546 + {
547 + code: `
548 + function MyComponent() {
549 + const local1 = 42;
550 + {
551 + const local2 = 42;
552 + useEffect(() => {
553 + console.log(local1);
554 + console.log(local2);
555 + }, []);
556 + }
557 + }
558 + `,
559 + output: `
560 + function MyComponent() {
561 + const local1 = 42;
562 + {
563 + const local2 = 42;
564 + useEffect(() => {
565 + console.log(local1);
566 + console.log(local2);
567 + }, [local1, local2]);
568 + }
569 + }
570 + `,
571 + errors: [
572 + "React Hook useEffect has missing dependencies: 'local1' and 'local2'. " +
573 + 'Either include them or remove the dependency array.',
574 + ],
575 + },
576 + {
577 + code: `
578 + function MyComponent() {
579 + const local1 = 42;
580 + const local2 = 42;
581 + useEffect(() => {
582 + console.log(local1);
583 + console.log(local2);
584 + }, [local1]);
585 + }
586 + `,
587 + output: `
588 + function MyComponent() {
589 + const local1 = 42;
590 + const local2 = 42;
591 + useEffect(() => {
592 + console.log(local1);
593 + console.log(local2);
594 + }, [local1, local2]);
595 + }
596 + `,
597 + errors: [
598 + "React Hook useEffect has a missing dependency: 'local2'. " +
599 + 'Either include it or remove the dependency array.',
600 + ],
601 + },
602 + {
603 + code: `
604 + function MyComponent() {
605 + const local1 = 42;
606 + const local2 = 42;
607 + useEffect(() => {
608 + console.log(local1);
609 + }, [local1, local2]);
610 + }
611 + `,
612 + output: `
613 + function MyComponent() {
614 + const local1 = 42;
615 + const local2 = 42;
616 + useEffect(() => {
617 + console.log(local1);
618 + }, [local1]);
619 + }
620 + `,
621 + errors: [
622 + "React Hook useEffect has an unnecessary dependency: 'local2'. " +
623 + 'Either exclude it or remove the dependency array.',
624 + ],
625 + },
626 + {
627 + // TODO: this case is weird.
628 + // Maybe it should not consider local1 unused despite component nesting?
629 + code: `
630 + function MyComponent() {
631 + const local1 = 42;
632 + function MyNestedComponent() {
633 + const local2 = 42;
634 + useEffect(() => {
635 + console.log(local1);
636 + console.log(local2);
637 + }, [local1]);
638 + }
639 + }
640 + `,
641 + output: `
642 + function MyComponent() {
643 + const local1 = 42;
644 + function MyNestedComponent() {
645 + const local2 = 42;
646 + useEffect(() => {
647 + console.log(local1);
648 + console.log(local2);
649 + }, [local2]);
650 + }
651 + }
652 + `,
653 + errors: [
654 + // Focus on the more important part first (missing dep)
655 + "React Hook useEffect has a missing dependency: 'local2'. " +
656 + 'Either include it or remove the dependency array.',
657 + ],
658 + },
659 + {
660 + code: `
661 + function MyComponent() {
662 + const local = 42;
663 + useEffect(() => {
664 + console.log(local);
665 + console.log(local);
666 + }, []);
667 + }
668 + `,
669 + output: `
670 + function MyComponent() {
671 + const local = 42;
672 + useEffect(() => {
673 + console.log(local);
674 + console.log(local);
675 + }, [local]);
676 + }
677 + `,
678 + errors: [
679 + "React Hook useEffect has a missing dependency: 'local'. " +
680 + 'Either include it or remove the dependency array.',
681 + ],
682 + },
683 + {
684 + code: `
685 + function MyComponent() {
686 + const local = 42;
687 + useEffect(() => {
688 + console.log(local);
689 + console.log(local);
690 + }, [local, local]);
691 + }
692 + `,
693 + output: `
694 + function MyComponent() {
695 + const local = 42;
696 + useEffect(() => {
697 + console.log(local);
698 + console.log(local);
699 + }, [local]);
700 + }
701 + `,
702 + errors: [
703 + "React Hook useEffect has a duplicate dependency: 'local'. " +
704 + 'Either omit it or remove the dependency array.',
705 + ],
706 + },
707 + {
708 + code: `
709 + function MyComponent() {
710 + useEffect(() => {}, [local]);
711 + }
712 + `,
713 + output: `
714 + function MyComponent() {
715 + useEffect(() => {}, []);
716 + }
717 + `,
718 + errors: [
719 + "React Hook useEffect has an unnecessary dependency: 'local'. " +
720 + 'Either exclude it or remove the dependency array.',
721 + ],
722 + },
723 + {
724 + code: `
725 + function MyComponent({ history }) {
726 + useEffect(() => {
727 + return history.listen();
728 + }, []);
729 + }
730 + `,
731 + output: `
732 + function MyComponent({ history }) {
733 + useEffect(() => {
734 + return history.listen();
735 + }, [history]);
736 + }
737 + `,
738 + errors: [
739 + "React Hook useEffect has a missing dependency: 'history'. " +
740 + 'Either include it or remove the dependency array.',
741 + ],
742 + },
743 + {
744 + code: `
745 + function MyComponent({ history }) {
746 + useEffect(() => {
747 + return [
748 + history.foo.bar[2].dobedo.listen(),
749 + history.foo.bar().dobedo.listen[2]
750 + ];
751 + }, []);
752 + }
753 + `,
754 + output: `
755 + function MyComponent({ history }) {
756 + useEffect(() => {
757 + return [
758 + history.foo.bar[2].dobedo.listen(),
759 + history.foo.bar().dobedo.listen[2]
760 + ];
761 + }, [history.foo]);
762 + }
763 + `,
764 + errors: [
765 + "React Hook useEffect has a missing dependency: 'history.foo'. " +
766 + 'Either include it or remove the dependency array.',
767 + ],
768 + },
769 + {
770 + code: `
771 + function MyComponent() {
772 + const dependencies = [];
773 + useEffect(() => {}, dependencies);
774 + }
775 + `,
776 + output: `
777 + function MyComponent() {
778 + const dependencies = [];
779 + useEffect(() => {}, dependencies);
780 + }
781 + `,
782 + errors: [
783 + 'React Hook useEffect has a second argument which is not an array ' +
784 + "literal. This means we can't statically verify whether you've " +
785 + 'passed the correct dependencies.',
786 + ],
787 + },
788 + {
789 + code: `
790 + function MyComponent() {
791 + const local = 42;
792 + const dependencies = [local];
793 + useEffect(() => {
794 + console.log(local);
795 + }, dependencies);
796 + }
797 + `,
798 + // TODO: should this autofix or bail out?
799 + output: `
800 + function MyComponent() {
801 + const local = 42;
802 + const dependencies = [local];
803 + useEffect(() => {
804 + console.log(local);
805 + }, [local]);
806 + }
807 + `,
808 + errors: [
809 + 'React Hook useEffect has a second argument which is not an array ' +
810 + "literal. This means we can't statically verify whether you've " +
811 + 'passed the correct dependencies.',
812 + "React Hook useEffect has a missing dependency: 'local'. " +
813 + 'Either include it or remove the dependency array.',
814 + ],
815 + },
816 + {
817 + code: `
818 + function MyComponent() {
819 + const local = 42;
820 + const dependencies = [local];
821 + useEffect(() => {
822 + console.log(local);
823 + }, [...dependencies]);
824 + }
825 + `,
826 + // TODO: should this autofix or bail out?
827 + output: `
828 + function MyComponent() {
829 + const local = 42;
830 + const dependencies = [local];
831 + useEffect(() => {
832 + console.log(local);
833 + }, [local]);
834 + }
835 + `,
836 + errors: [
837 + "React Hook useEffect has a missing dependency: 'local'. " +
838 + 'Either include it or remove the dependency array.',
839 + 'React Hook useEffect has a spread element in its dependency array. ' +
840 + "This means we can't statically verify whether you've passed the " +
841 + 'correct dependencies.',
842 + ],
843 + },
844 + {
845 + code: `
846 + function MyComponent() {
847 + const local = 42;
848 + useEffect(() => {
849 + console.log(local);
850 + }, [local, ...dependencies]);
851 + }
852 + `,
853 + output: `
854 + function MyComponent() {
855 + const local = 42;
856 + useEffect(() => {
857 + console.log(local);
858 + }, [local, ...dependencies]);
859 + }
860 + `,
861 + errors: [
862 + 'React Hook useEffect has a spread element in its dependency array. ' +
863 + "This means we can't statically verify whether you've passed the " +
864 + 'correct dependencies.',
865 + ],
866 + },
867 + {
868 + code: `
869 + function MyComponent() {
870 + const local = 42;
871 + useEffect(() => {
872 + console.log(local);
873 + }, [computeCacheKey(local)]);
874 + }
875 + `,
876 + // TODO: I'm not sure this is a good idea.
877 + // Maybe bail out?
878 + output: `
879 + function MyComponent() {
880 + const local = 42;
881 + useEffect(() => {
882 + console.log(local);
883 + }, [local]);
884 + }
885 + `,
886 + errors: [
887 + "React Hook useEffect has a missing dependency: 'local'. " +
888 + 'Either include it or remove the dependency array.',
889 + 'React Hook useEffect has a complex expression in the dependency array. ' +
890 + 'Extract it to a separate variable so it can be statically checked.',
891 + ],
892 + },
893 + {
894 + // only: true,
895 + code: `
896 + function MyComponent(props) {
897 + useEffect(() => {
898 + console.log(props.items[0]);
899 + }, [props.items[0]]);
900 + }
901 + `,
902 + output: `
903 + function MyComponent(props) {
904 + useEffect(() => {
905 + console.log(props.items[0]);
906 + }, [props.items]);
907 + }
908 + `,
909 + errors: [
910 + "React Hook useEffect has a missing dependency: 'props.items'. " +
911 + 'Either include it or remove the dependency array.',
912 + 'React Hook useEffect has a complex expression in the dependency array. ' +
913 + 'Extract it to a separate variable so it can be statically checked.',
914 + ],
915 + },
916 + {
917 + code: `
918 + function MyComponent(props) {
919 + useEffect(() => {
920 + console.log(props.items[0]);
921 + }, [props.items, props.items[0]]);
922 + }
923 + `,
924 + // TODO: ideally autofix would remove the bad expression?
925 + output: `
926 + function MyComponent(props) {
927 + useEffect(() => {
928 + console.log(props.items[0]);
929 + }, [props.items, props.items[0]]);
930 + }
931 + `,
932 + errors: [
933 + 'React Hook useEffect has a complex expression in the dependency array. ' +
934 + 'Extract it to a separate variable so it can be statically checked.',
935 + ],
936 + },
937 + {
938 + code: `
939 + function MyComponent({ items }) {
940 + useEffect(() => {
941 + console.log(items[0]);
942 + }, [items[0]]);
943 + }
944 + `,
945 + output: `
946 + function MyComponent({ items }) {
947 + useEffect(() => {
948 + console.log(items[0]);
949 + }, [items]);
950 + }
951 + `,
952 + errors: [
953 + "React Hook useEffect has a missing dependency: 'items'. " +
954 + 'Either include it or remove the dependency array.',
955 + 'React Hook useEffect has a complex expression in the dependency array. ' +
956 + 'Extract it to a separate variable so it can be statically checked.',
957 + ],
958 + },
959 + {
960 + code: `
961 + function MyComponent({ items }) {
962 + useEffect(() => {
963 + console.log(items[0]);
964 + }, [items, items[0]]);
965 + }
966 + `,
967 + // TODO: ideally autofix would remove the bad expression?
968 + output: `
969 + function MyComponent({ items }) {
970 + useEffect(() => {
971 + console.log(items[0]);
972 + }, [items, items[0]]);
973 + }
974 + `,
975 + errors: [
976 + 'React Hook useEffect has a complex expression in the dependency array. ' +
977 + 'Extract it to a separate variable so it can be statically checked.',
978 + ],
979 + },
980 + {
981 + // TODO: need to think more about this case.
982 + code: `
983 + function MyComponent() {
984 + const local = {id: 42};
985 + useEffect(() => {
986 + console.log(local);
987 + }, [local.id]);
988 + }
989 + `,
990 + // TODO: this may not be a good idea.
991 + output: `
992 + function MyComponent() {
993 + const local = {id: 42};
994 + useEffect(() => {
995 + console.log(local);
996 + }, [local]);
997 + }
998 + `,
999 + errors: [
1000 + "React Hook useEffect has a missing dependency: 'local'. " +
1001 + 'Either include it or remove the dependency array.',
1002 + ],
1003 + },
1004 + {
1005 + code: `
1006 + function MyComponent() {
1007 + const local = 42;
1008 + useEffect(() => {
1009 + console.log(local);
1010 + }, [local, local]);
1011 + }
1012 + `,
1013 + output: `
1014 + function MyComponent() {
1015 + const local = 42;
1016 + useEffect(() => {
1017 + console.log(local);
1018 + }, [local]);
1019 + }
1020 + `,
1021 + errors: [
1022 + "React Hook useEffect has a duplicate dependency: 'local'. " +
1023 + 'Either omit it or remove the dependency array.',
1024 + ],
1025 + },
1026 + {
1027 + code: `
1028 + function MyComponent() {
1029 + const local1 = 42;
1030 + useEffect(() => {
1031 + const local1 = 42;
1032 + console.log(local1);
1033 + }, [local1]);
1034 + }
1035 + `,
1036 + output: `
1037 + function MyComponent() {
1038 + const local1 = 42;
1039 + useEffect(() => {
1040 + const local1 = 42;
1041 + console.log(local1);
1042 + }, []);
1043 + }
1044 + `,
1045 + errors: [
1046 + "React Hook useEffect has an unnecessary dependency: 'local1'. " +
1047 + 'Either exclude it or remove the dependency array.',
1048 + ],
1049 + },
1050 + {
1051 + code: `
1052 + function MyComponent() {
1053 + const local1 = 42;
1054 + useEffect(() => {}, [local1]);
1055 + }
1056 + `,
1057 + output: `
1058 + function MyComponent() {
1059 + const local1 = 42;
1060 + useEffect(() => {}, []);
1061 + }
1062 + `,
1063 + errors: [
1064 + "React Hook useEffect has an unnecessary dependency: 'local1'. " +
1065 + 'Either exclude it or remove the dependency array.',
1066 + ],
1067 + },
1068 + {
1069 + code: `
1070 + function MyComponent(props) {
1071 + useEffect(() => {
1072 + console.log(props.foo);
1073 + }, []);
1074 + }
1075 + `,
1076 + // TODO: we need to think about ideal output here.
1077 + // Should it capture by default?
1078 + output: `
1079 + function MyComponent(props) {
1080 + useEffect(() => {
1081 + console.log(props.foo);
1082 + }, [props.foo]);
1083 + }
1084 + `,
1085 + errors: [
1086 + "React Hook useEffect has a missing dependency: 'props.foo'. " +
1087 + 'Either include it or remove the dependency array.',
1088 + ],
1089 + },
1090 + {
1091 + code: `
1092 + function MyComponent(props) {
1093 + useEffect(() => {
1094 + console.log(props.foo);
1095 + console.log(props.bar);
1096 + }, []);
1097 + }
1098 + `,
1099 + // TODO: we need to think about ideal output here.
1100 + // Should it capture by default?
1101 + output: `
1102 + function MyComponent(props) {
1103 + useEffect(() => {
1104 + console.log(props.foo);
1105 + console.log(props.bar);
1106 + }, [props.bar, props.foo]);
1107 + }
1108 + `,
1109 + errors: [
1110 + "React Hook useEffect has missing dependencies: 'props.bar' and 'props.foo'. " +
1111 + 'Either include them or remove the dependency array.',
1112 + ],
1113 + },
1114 + {
1115 + code: `
1116 + function MyComponent(props) {
1117 + let a, b, c, d, e, f, g;
1118 + useEffect(() => {
1119 + console.log(b, e, d, c, a, g, f);
1120 + }, [c, a, g]);
1121 + }
1122 + `,
1123 + // Don't alphabetize if it wasn't alphabetized in the first place.
1124 + output: `
1125 + function MyComponent(props) {
1126 + let a, b, c, d, e, f, g;
1127 + useEffect(() => {
1128 + console.log(b, e, d, c, a, g, f);
1129 + }, [c, a, g, b, e, d, f]);
1130 + }
1131 + `,
1132 + errors: [
1133 + "React Hook useEffect has missing dependencies: 'b', 'd', 'e', and 'f'. " +
1134 + 'Either include them or remove the dependency array.',
1135 + ],
1136 + },
1137 + {
1138 + code: `
1139 + function MyComponent(props) {
1140 + let a, b, c, d, e, f, g;
1141 + useEffect(() => {
1142 + console.log(b, e, d, c, a, g, f);
1143 + }, [a, c, g]);
1144 + }
1145 + `,
1146 + // Alphabetize if it was alphabetized.
1147 + output: `
1148 + function MyComponent(props) {
1149 + let a, b, c, d, e, f, g;
1150 + useEffect(() => {
1151 + console.log(b, e, d, c, a, g, f);
1152 + }, [a, b, c, d, e, f, g]);
1153 + }
1154 + `,
1155 + errors: [
1156 + "React Hook useEffect has missing dependencies: 'b', 'd', 'e', and 'f'. " +
1157 + 'Either include them or remove the dependency array.',
1158 + ],
1159 + },
1160 + {
1161 + code: `
1162 + function MyComponent(props) {
1163 + let a, b, c, d, e, f, g;
1164 + useEffect(() => {
1165 + console.log(b, e, d, c, a, g, f);
1166 + }, []);
1167 + }
1168 + `,
1169 + // Alphabetize if it was empty.
1170 + output: `
1171 + function MyComponent(props) {
1172 + let a, b, c, d, e, f, g;
1173 + useEffect(() => {
1174 + console.log(b, e, d, c, a, g, f);
1175 + }, [a, b, c, d, e, f, g]);
1176 + }
1177 + `,
1178 + errors: [
1179 + "React Hook useEffect has missing dependencies: 'a', 'b', 'c', 'd', 'e', 'f', and 'g'. " +
1180 + 'Either include them or remove the dependency array.',
1181 + ],
1182 + },
1183 + {
1184 + code: `
1185 + function MyComponent(props) {
1186 + const local = 42;
1187 + useEffect(() => {
1188 + console.log(props.foo);
1189 + console.log(props.bar);
1190 + console.log(local);
1191 + }, []);
1192 + }
1193 + `,
1194 + // TODO: we need to think about ideal output here.
1195 + // Should it capture by default?
1196 + output: `
1197 + function MyComponent(props) {
1198 + const local = 42;
1199 + useEffect(() => {
1200 + console.log(props.foo);
1201 + console.log(props.bar);
1202 + console.log(local);
1203 + }, [local, props.bar, props.foo]);
1204 + }
1205 + `,
1206 + errors: [
1207 + "React Hook useEffect has missing dependencies: 'local', 'props.bar', and 'props.foo'. " +
1208 + 'Either include them or remove the dependency array.',
1209 + ],
1210 + },
1211 + {
1212 + code: `
1213 + function MyComponent(props) {
1214 + const local = 42;
1215 + useEffect(() => {
1216 + console.log(props.foo);
1217 + console.log(props.bar);
1218 + console.log(local);
1219 + }, [props]);
1220 + }
1221 + `,
1222 + output: `
1223 + function MyComponent(props) {
1224 + const local = 42;
1225 + useEffect(() => {
1226 + console.log(props.foo);
1227 + console.log(props.bar);
1228 + console.log(local);
1229 + }, [local, props]);
1230 + }
1231 + `,
1232 + errors: [
1233 + "React Hook useEffect has a missing dependency: 'local'. " +
1234 + 'Either include it or remove the dependency array.',
1235 + ],
1236 + },
1237 + {
1238 + code: `
1239 + function MyComponent(props) {
1240 + useEffect(() => {
1241 + console.log(props.foo);
1242 + }, []);
1243 + useCallback(() => {
1244 + console.log(props.foo);
1245 + }, []);
1246 + useMemo(() => {
1247 + console.log(props.foo);
1248 + }, []);
1249 + React.useEffect(() => {
1250 + console.log(props.foo);
1251 + }, []);
1252 + React.useCallback(() => {
1253 + console.log(props.foo);
1254 + }, []);
1255 + React.useMemo(() => {
1256 + console.log(props.foo);
1257 + }, []);
1258 + React.notReactiveHook(() => {
1259 + console.log(props.foo);
1260 + }, []);
1261 + }
1262 + `,
1263 + output: `
1264 + function MyComponent(props) {
1265 + useEffect(() => {
1266 + console.log(props.foo);
1267 + }, [props.foo]);
1268 + useCallback(() => {
1269 + console.log(props.foo);
1270 + }, [props.foo]);
1271 + useMemo(() => {
1272 + console.log(props.foo);
1273 + }, [props.foo]);
1274 + React.useEffect(() => {
1275 + console.log(props.foo);
1276 + }, [props.foo]);
1277 + React.useCallback(() => {
1278 + console.log(props.foo);
1279 + }, [props.foo]);
1280 + React.useMemo(() => {
1281 + console.log(props.foo);
1282 + }, [props.foo]);
1283 + React.notReactiveHook(() => {
1284 + console.log(props.foo);
1285 + }, []);
1286 + }
1287 + `,
1288 + errors: [
1289 + "React Hook useEffect has a missing dependency: 'props.foo'. " +
1290 + 'Either include it or remove the dependency array.',
1291 + "React Hook useCallback has a missing dependency: 'props.foo'. " +
1292 + 'Either include it or remove the dependency array.',
1293 + "React Hook useMemo has a missing dependency: 'props.foo'. " +
1294 + 'Either include it or remove the dependency array.',
1295 + "React Hook React.useEffect has a missing dependency: 'props.foo'. " +
1296 + 'Either include it or remove the dependency array.',
1297 + "React Hook React.useCallback has a missing dependency: 'props.foo'. " +
1298 + 'Either include it or remove the dependency array.',
1299 + "React Hook React.useMemo has a missing dependency: 'props.foo'. " +
1300 + 'Either include it or remove the dependency array.',
1301 + ],
1302 + },
1303 + {
1304 + code: `
1305 + function MyComponent(props) {
1306 + useCustomEffect(() => {
1307 + console.log(props.foo);
1308 + }, []);
1309 + useEffect(() => {
1310 + console.log(props.foo);
1311 + }, []);
1312 + React.useEffect(() => {
1313 + console.log(props.foo);
1314 + }, []);
1315 + React.useCustomEffect(() => {
1316 + console.log(props.foo);
1317 + }, []);
1318 + }
1319 + `,
1320 + output: `
1321 + function MyComponent(props) {
1322 + useCustomEffect(() => {
1323 + console.log(props.foo);
1324 + }, [props.foo]);
1325 + useEffect(() => {
1326 + console.log(props.foo);
1327 + }, [props.foo]);
1328 + React.useEffect(() => {
1329 + console.log(props.foo);
1330 + }, [props.foo]);
1331 + React.useCustomEffect(() => {
1332 + console.log(props.foo);
1333 + }, []);
1334 + }
1335 + `,
1336 + options: [{additionalHooks: 'useCustomEffect'}],
1337 + errors: [
1338 + "React Hook useCustomEffect has a missing dependency: 'props.foo'. " +
1339 + 'Either include it or remove the dependency array.',
1340 + "React Hook useEffect has a missing dependency: 'props.foo'. " +
1341 + 'Either include it or remove the dependency array.',
1342 + "React Hook React.useEffect has a missing dependency: 'props.foo'. " +
1343 + 'Either include it or remove the dependency array.',
1344 + ],
1345 + },
1346 + {
1347 + code: `
1348 + function MyComponent() {
1349 + const local = 42;
1350 + useEffect(() => {
1351 + console.log(local);
1352 + }, [a ? local : b]);
1353 + }
1354 + `,
1355 + // TODO: should we bail out instead?
1356 + output: `
1357 + function MyComponent() {
1358 + const local = 42;
1359 + useEffect(() => {
1360 + console.log(local);
1361 + }, [local]);
1362 + }
1363 + `,
1364 + errors: [
1365 + "React Hook useEffect has a missing dependency: 'local'. " +
1366 + 'Either include it or remove the dependency array.',
1367 + 'React Hook useEffect has a complex expression in the dependency array. ' +
1368 + 'Extract it to a separate variable so it can be statically checked.',
1369 + ],
1370 + },
1371 + {
1372 + code: `
1373 + function MyComponent() {
1374 + const local = 42;
1375 + useEffect(() => {
1376 + console.log(local);
1377 + }, [a && local]);
1378 + }
1379 + `,
1380 + // TODO: should we bail out instead?
1381 + output: `
1382 + function MyComponent() {
1383 + const local = 42;
1384 + useEffect(() => {
1385 + console.log(local);
1386 + }, [local]);
1387 + }
1388 + `,
1389 + errors: [
1390 + "React Hook useEffect has a missing dependency: 'local'. " +
1391 + 'Either include it or remove the dependency array.',
1392 + 'React Hook useEffect has a complex expression in the dependency array. ' +
1393 + 'Extract it to a separate variable so it can be statically checked.',
1394 + ],
1395 + },
1396 + {
1397 + code: `
1398 + function MyComponent() {
1399 + const ref = useRef();
1400 + const [state, setState] = useState();
1401 + useEffect(() => {
1402 + ref.current = 42;
1403 + setState(state + 1);
1404 + }, []);
1405 + }
1406 + `,
1407 + output: `
1408 + function MyComponent() {
1409 + const ref = useRef();
1410 + const [state, setState] = useState();
1411 + useEffect(() => {
1412 + ref.current = 42;
1413 + setState(state + 1);
1414 + }, [state]);
1415 + }
1416 + `,
1417 + errors: [
1418 + "React Hook useEffect has a missing dependency: 'state'. " +
1419 + 'Either include it or remove the dependency array.',
1420 + ],
1421 + },
1422 + {
1423 + code: `
1424 + function MyComponent() {
1425 + const ref = useRef();
1426 + const [state, setState] = useState();
1427 + useEffect(() => {
1428 + ref.current = 42;
1429 + setState(state + 1);
1430 + }, [ref]);
1431 + }
1432 + `,
1433 + // We don't ask to remove static deps but don't add them either.
1434 + // Don't suggest removing "ref" (it's fine either way)
1435 + // but *do* add "state". *Don't* add "setState" ourselves.
1436 + output: `
1437 + function MyComponent() {
1438 + const ref = useRef();
1439 + const [state, setState] = useState();
1440 + useEffect(() => {
1441 + ref.current = 42;
1442 + setState(state + 1);
1443 + }, [ref, state]);
1444 + }
1445 + `,
1446 + errors: [
1447 + "React Hook useEffect has a missing dependency: 'state'. " +
1448 + 'Either include it or remove the dependency array.',
1449 + ],
1450 + },
1451 + {
1452 + code: `
1453 + function MyComponent(props) {
1454 + const ref1 = useRef();
1455 + const ref2 = useRef();
1456 + useEffect(() => {
1457 + ref1.current.focus();
1458 + console.log(ref2.current.textContent);
1459 + alert(props.someOtherRefs.current.innerHTML);
1460 + fetch(props.color);
1461 + }, []);
1462 + }
1463 + `,
1464 + output: `
1465 + function MyComponent(props) {
1466 + const ref1 = useRef();
1467 + const ref2 = useRef();
1468 + useEffect(() => {
1469 + ref1.current.focus();
1470 + console.log(ref2.current.textContent);
1471 + alert(props.someOtherRefs.current.innerHTML);
1472 + fetch(props.color);
1473 + }, [props.color, props.someOtherRefs]);
1474 + }
1475 + `,
1476 + errors: [
1477 + "React Hook useEffect has missing dependencies: 'props.color' and 'props.someOtherRefs'. " +
1478 + 'Either include them or remove the dependency array.',
1479 + ],
1480 + },
1481 + {
1482 + code: `
1483 + function MyComponent(props) {
1484 + const ref1 = useRef();
1485 + const ref2 = useRef();
1486 + useEffect(() => {
1487 + ref1.current.focus();
1488 + console.log(ref2.current.textContent);
1489 + alert(props.someOtherRefs.current.innerHTML);
1490 + fetch(props.color);
1491 + }, [ref1.current, ref2.current, props.someOtherRefs, props.color]);
1492 + }
1493 + `,
1494 + output: `
1495 + function MyComponent(props) {
1496 + const ref1 = useRef();
1497 + const ref2 = useRef();
1498 + useEffect(() => {
1499 + ref1.current.focus();
1500 + console.log(ref2.current.textContent);
1501 + alert(props.someOtherRefs.current.innerHTML);
1502 + fetch(props.color);
1503 + }, [props.someOtherRefs, props.color]);
1504 + }
1505 + `,
1506 + errors: [
1507 + "React Hook useEffect has unnecessary dependencies: 'ref1.current' and 'ref2.current'. " +
1508 + 'Either exclude them or remove the dependency array. ' +
1509 + "Mutable values like 'ref1.current' aren't valid dependencies " +
1510 + "because their mutation doesn't re-render the component.",
1511 + ],
1512 + },
1513 + {
1514 + code: `
1515 + function MyComponent() {
1516 + const ref = useRef();
1517 + useEffect(() => {
1518 + console.log(ref.current);
1519 + }, [ref.current]);
1520 + }
1521 + `,
1522 + output: `
1523 + function MyComponent() {
1524 + const ref = useRef();
1525 + useEffect(() => {
1526 + console.log(ref.current);
1527 + }, []);
1528 + }
1529 + `,
1530 + errors: [
1531 + "React Hook useEffect has an unnecessary dependency: 'ref.current'. " +
1532 + 'Either exclude it or remove the dependency array. ' +
1533 + "Mutable values like 'ref.current' aren't valid dependencies " +
1534 + "because their mutation doesn't re-render the component.",
1535 + ],
1536 + },
1537 + {
1538 + code: `
1539 + function MyComponent() {
1540 + const ref = useRef();
1541 + useEffect(() => {
1542 + console.log(ref.current);
1543 + }, [ref.current, ref]);
1544 + }
1545 + `,
1546 + output: `
1547 + function MyComponent() {
1548 + const ref = useRef();
1549 + useEffect(() => {
1550 + console.log(ref.current);
1551 + }, [ref]);
1552 + }
1553 + `,
1554 + errors: [
1555 + "React Hook useEffect has an unnecessary dependency: 'ref.current'. " +
1556 + 'Either exclude it or remove the dependency array. ' +
1557 + "Mutable values like 'ref.current' aren't valid dependencies " +
1558 + "because their mutation doesn't re-render the component.",
1559 + ],
1560 + },
1561 + {
1562 + code: `
1563 + const MyComponent = forwardRef((props, ref) => {
1564 + useImperativeHandle(ref, () => ({
1565 + focus() {
1566 + alert(props.hello);
1567 + }
1568 + }), [])
1569 + });
1570 + `,
1571 + output: `
1572 + const MyComponent = forwardRef((props, ref) => {
1573 + useImperativeHandle(ref, () => ({
1574 + focus() {
1575 + alert(props.hello);
1576 + }
1577 + }), [props.hello])
1578 + });
1579 + `,
1580 + errors: [
1581 + "React Hook useImperativeHandle has a missing dependency: 'props.hello'. " +
1582 + 'Either include it or remove the dependency array.',
1583 + ],
1584 + },
1585 + {
1586 + code: `
1587 + function MyComponent(props) {
1588 + useEffect(() => {
1589 + if (props.onChange) {
1590 + props.onChange();
1591 + }
1592 + }, []);
1593 + }
1594 + `,
1595 + // TODO: [props.onChange] is superfluous. Fix to just [props.onChange].
1596 + output: `
1597 + function MyComponent(props) {
1598 + useEffect(() => {
1599 + if (props.onChange) {
1600 + props.onChange();
1601 + }
1602 + }, [props, props.onChange]);
1603 + }
1604 + `,
1605 + errors: [
1606 + // TODO: reporting props separately is superfluous. Fix to just props.onChange.
1607 + "React Hook useEffect has missing dependencies: 'props' and 'props.onChange'. " +
1608 + 'Either include them or remove the dependency array.',
1609 + ],
1610 + },
1611 + ],
1612 +};
1613 +
1614 +// For easier local testing
1615 +if (!process.env.CI) {
1616 + let only = [];
1617 + let skipped = [];
1618 + [...tests.valid, ...tests.invalid].forEach(t => {
1619 + if (t.skip) {
1620 + delete t.skip;
1621 + skipped.push(t);
1622 + }
1623 + if (t.only) {
1624 + delete t.only;
1625 + only.push(t);
1626 + }
1627 + });
1628 + const predicate = t => {
1629 + if (only.length > 0) {
1630 + return only.indexOf(t) !== -1;
1631 + }
1632 + if (skipped.length > 0) {
1633 + return skipped.indexOf(t) === -1;
1634 + }
1635 + return true;
1636 + };
1637 + tests.valid = tests.valid.filter(predicate);
1638 + tests.invalid = tests.invalid.filter(predicate);
1639 +}
1640 +
1641 +const eslintTester = new ESLintTester();
1642 +eslintTester.run('react-hooks', ReactHooksESLintRule, tests);
packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js new
+567
@@ -0,0 +1,567 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +
8 +/* eslint-disable no-for-of-loops/no-for-of-loops */
9 +
10 +'use strict';
11 +
12 +// const [state, setState] = useState() / React.useState()
13 +// ^^^ true for this reference
14 +// const [state, dispatch] = useReducer() / React.useReducer()
15 +// ^^^ true for this reference
16 +// const ref = useRef()
17 +// ^^ true for this reference
18 +// False for everything else.
19 +function isDefinitelyStaticDependency(reference) {
20 + // This function is written defensively because I'm not sure about corner cases.
21 + // TODO: we can strengthen this if we're sure about the types.
22 + const resolved = reference.resolved;
23 + if (resolved == null || !Array.isArray(resolved.defs)) {
24 + return false;
25 + }
26 + const def = resolved.defs[0];
27 + if (def == null || def.node.init == null) {
28 + return false;
29 + }
30 + // Look for `let stuff = SomeHook();`
31 + const init = def.node.init;
32 + if (init.callee == null) {
33 + return false;
34 + }
35 + let callee = init.callee;
36 + // Step into `= React.something` initializer.
37 + if (
38 + callee.type === 'MemberExpression' &&
39 + callee.object.name === 'React' &&
40 + callee.property != null &&
41 + !callee.computed
42 + ) {
43 + callee = callee.property;
44 + }
45 + if (callee.type !== 'Identifier') {
46 + return;
47 + }
48 + const id = def.node.id;
49 + if (callee.name === 'useRef' && id.type === 'Identifier') {
50 + // useRef() return value is static.
51 + return true;
52 + } else if (callee.name === 'useState' || callee.name === 'useReducer') {
53 + // Only consider second value in initializing tuple static.
54 + if (
55 + id.type === 'ArrayPattern' &&
56 + id.elements.length === 2 &&
57 + Array.isArray(reference.resolved.identifiers) &&
58 + // Is second tuple value the same reference we're checking?
59 + id.elements[1] === reference.resolved.identifiers[0]
60 + ) {
61 + return true;
62 + }
63 + }
64 + // By default assume it's dynamic.
65 + return false;
66 +}
67 +
68 +export default {
69 + meta: {
70 + fixable: 'code',
71 + schema: [
72 + {
73 + type: 'object',
74 + additionalProperties: false,
75 + properties: {
76 + additionalHooks: {
77 + type: 'string',
78 + },
79 + },
80 + },
81 + ],
82 + },
83 + create(context) {
84 + // Parse the `additionalHooks` regex.
85 + const additionalHooks =
86 + context.options &&
87 + context.options[0] &&
88 + context.options[0].additionalHooks
89 + ? new RegExp(context.options[0].additionalHooks)
90 + : undefined;
91 + const options = {additionalHooks};
92 +
93 + return {
94 + FunctionExpression: visitFunctionExpression,
95 + ArrowFunctionExpression: visitFunctionExpression,
96 + };
97 +
98 + /**
99 + * Visitor for both function expressions and arrow function expressions.
100 + */
101 + function visitFunctionExpression(node) {
102 + // We only want to lint nodes which are reactive hook callbacks.
103 + if (
104 + (node.type !== 'FunctionExpression' &&
105 + node.type !== 'ArrowFunctionExpression') ||
106 + node.parent.type !== 'CallExpression'
107 + ) {
108 + return;
109 + }
110 +
111 + const callbackIndex = getReactiveHookCallbackIndex(
112 + node.parent.callee,
113 + options,
114 + );
115 + if (node.parent.arguments[callbackIndex] !== node) {
116 + return;
117 + }
118 +
119 + // Get the reactive hook node.
120 + const reactiveHook = node.parent.callee;
121 +
122 + // Get the declared dependencies for this reactive hook. If there is no
123 + // second argument then the reactive callback will re-run on every render.
124 + // So no need to check for dependency inclusion.
125 + const depsIndex = callbackIndex + 1;
126 + const declaredDependenciesNode = node.parent.arguments[depsIndex];
127 + if (!declaredDependenciesNode) {
128 + return;
129 + }
130 +
131 + // Get the current scope.
132 + const scope = context.getScope();
133 +
134 + // Find all our "pure scopes". On every re-render of a component these
135 + // pure scopes may have changes to the variables declared within. So all
136 + // variables used in our reactive hook callback but declared in a pure
137 + // scope need to be listed as dependencies of our reactive hook callback.
138 + //
139 + // According to the rules of React you can't read a mutable value in pure
140 + // scope. We can't enforce this in a lint so we trust that all variables
141 + // declared outside of pure scope are indeed frozen.
142 + const pureScopes = new Set();
143 + {
144 + let currentScope = scope.upper;
145 + while (currentScope) {
146 + pureScopes.add(currentScope);
147 + if (currentScope.type === 'function') {
148 + break;
149 + }
150 + currentScope = currentScope.upper;
151 + }
152 + // If there is no parent function scope then there are no pure scopes.
153 + // The ones we've collected so far are incorrect. So don't continue with
154 + // the lint.
155 + if (!currentScope) {
156 + return;
157 + }
158 + }
159 +
160 + // Get dependencies from all our resolved references in pure scopes.
161 + // Key is dependency string, value is whether it's static.
162 + const dependencies = new Map();
163 + gatherDependenciesRecursively(scope);
164 +
165 + function gatherDependenciesRecursively(currentScope) {
166 + for (const reference of currentScope.references) {
167 + // If this reference is not resolved or it is not declared in a pure
168 + // scope then we don't care about this reference.
169 + if (!reference.resolved) {
170 + continue;
171 + }
172 + if (!pureScopes.has(reference.resolved.scope)) {
173 + continue;
174 + }
175 + // Narrow the scope of a dependency if it is, say, a member expression.
176 + // Then normalize the narrowed dependency.
177 +
178 + const referenceNode = fastFindReferenceWithParent(
179 + node,
180 + reference.identifier,
181 + );
182 + const dependencyNode = getDependency(referenceNode);
183 + const dependency = toPropertyAccessString(dependencyNode);
184 +
185 + // Add the dependency to a map so we can make sure it is referenced
186 + // again in our dependencies array. Remember whether it's static.
187 + if (!dependencies.has(dependency)) {
188 + const isStatic = isDefinitelyStaticDependency(reference);
189 + dependencies.set(dependency, isStatic);
190 + }
191 + }
192 + for (const childScope of currentScope.childScopes) {
193 + gatherDependenciesRecursively(childScope);
194 + }
195 + }
196 +
197 + const declaredDependencies = [];
198 + if (declaredDependenciesNode.type !== 'ArrayExpression') {
199 + // If the declared dependencies are not an array expression then we
200 + // can't verify that the user provided the correct dependencies. Tell
201 + // the user this in an error.
202 + context.report({
203 + node: declaredDependenciesNode,
204 + message:
205 + `React Hook ${context.getSource(reactiveHook)} has a second ` +
206 + "argument which is not an array literal. This means we can't " +
207 + "statically verify whether you've passed the correct dependencies.",
208 + });
209 + } else {
210 + declaredDependenciesNode.elements.forEach(declaredDependencyNode => {
211 + // Skip elided elements.
212 + if (declaredDependencyNode === null) {
213 + return;
214 + }
215 + // If we see a spread element then add a special warning.
216 + if (declaredDependencyNode.type === 'SpreadElement') {
217 + context.report({
218 + node: declaredDependencyNode,
219 + message:
220 + `React Hook ${context.getSource(reactiveHook)} has a spread ` +
221 + "element in its dependency array. This means we can't " +
222 + "statically verify whether you've passed the " +
223 + 'correct dependencies.',
224 + });
225 + return;
226 + }
227 + // Try to normalize the declared dependency. If we can't then an error
228 + // will be thrown. We will catch that error and report an error.
229 + let declaredDependency;
230 + try {
231 + declaredDependency = toPropertyAccessString(declaredDependencyNode);
232 + } catch (error) {
233 + if (/Unsupported node type/.test(error.message)) {
234 + context.report({
235 + node: declaredDependencyNode,
236 + message:
237 + `React Hook ${context.getSource(reactiveHook)} has a ` +
238 + `complex expression in the dependency array. ` +
239 + 'Extract it to a separate variable so it can be statically checked.',
240 + });
241 + return;
242 + } else {
243 + throw error;
244 + }
245 + }
246 + // Add the dependency to our declared dependency map.
247 + declaredDependencies.push({
248 + key: declaredDependency,
249 + node: declaredDependencyNode,
250 + });
251 + });
252 + }
253 +
254 + // TODO: we can do a pass at this code and pick more appropriate
255 + // data structures to avoid nested loops if we can.
256 + let suggestedDependencies = [];
257 + let duplicateDependencies = new Set();
258 + let unnecessaryDependencies = new Set();
259 + let missingDependencies = new Set();
260 + let actualDependencies = Array.from(dependencies.keys());
261 +
262 + function satisfies(actualDep, dep) {
263 + return actualDep === dep || actualDep.startsWith(dep + '.');
264 + }
265 +
266 + // First, ensure what user specified makes sense.
267 + declaredDependencies.forEach(({key}) => {
268 + if (actualDependencies.some(actualDep => satisfies(actualDep, key))) {
269 + // Legit dependency.
270 + if (suggestedDependencies.indexOf(key) === -1) {
271 + suggestedDependencies.push(key);
272 + } else {
273 + // Duplicate. Do nothing.
274 + duplicateDependencies.add(key);
275 + }
276 + } else {
277 + // Unnecessary dependency. Do nothing.
278 + unnecessaryDependencies.add(key);
279 + }
280 + });
281 +
282 + // Then fill in the missing ones.
283 + dependencies.forEach((isStatic, key) => {
284 + if (
285 + !suggestedDependencies.some(suggestedDep =>
286 + satisfies(key, suggestedDep),
287 + )
288 + ) {
289 + if (!isStatic) {
290 + // Legit missing.
291 + suggestedDependencies.push(key);
292 + missingDependencies.add(key);
293 + }
294 + } else {
295 + // Already did that. Do nothing.
296 + }
297 + });
298 +
299 + function areDeclaredDepsAlphabetized() {
300 + if (declaredDependencies.length === 0) {
301 + return true;
302 + }
303 + const declaredDepKeys = declaredDependencies.map(dep => dep.key);
304 + const sortedDeclaredDepKeys = declaredDepKeys.slice().sort();
305 + return declaredDepKeys.join(',') === sortedDeclaredDepKeys.join(',');
306 + }
307 +
308 + if (areDeclaredDepsAlphabetized()) {
309 + // Alphabetize the autofix, but only if deps were already alphabetized.
310 + suggestedDependencies.sort();
311 + }
312 +
313 + const problemCount =
314 + duplicateDependencies.size +
315 + missingDependencies.size +
316 + unnecessaryDependencies.size;
317 +
318 + if (problemCount === 0) {
319 + return;
320 + }
321 +
322 + function getWarningMessage(deps, singlePrefix, label, fixVerb) {
323 + if (deps.size === 0) {
324 + return null;
325 + }
326 + return (
327 + (deps.size > 1 ? '' : singlePrefix + ' ') +
328 + label +
329 + ' ' +
330 + (deps.size > 1 ? 'dependencies' : 'dependency') +
331 + ': ' +
332 + joinEnglish(
333 + Array.from(deps)
334 + .sort()
335 + .map(name => "'" + name + "'"),
336 + ) +
337 + `. Either ${fixVerb} ${
338 + deps.size > 1 ? 'them' : 'it'
339 + } or remove the dependency array.`
340 + );
341 + }
342 +
343 + let extraWarning = '';
344 + if (unnecessaryDependencies.size > 0) {
345 + let badRef = null;
346 + Array.from(unnecessaryDependencies.keys()).forEach(key => {
347 + if (badRef !== null) {
348 + return;
349 + }
350 + if (key.endsWith('.current')) {
351 + badRef = key;
352 + }
353 + });
354 + if (badRef !== null) {
355 + extraWarning =
356 + ` Mutable values like '${badRef}' aren't valid dependencies ` +
357 + "because their mutation doesn't re-render the component.";
358 + }
359 + }
360 +
361 + context.report({
362 + node: declaredDependenciesNode,
363 + message:
364 + `React Hook ${context.getSource(reactiveHook)} has ` +
365 + // To avoid a long message, show the next actionable item.
366 + (getWarningMessage(missingDependencies, 'a', 'missing', 'include') ||
367 + getWarningMessage(
368 + unnecessaryDependencies,
369 + 'an',
370 + 'unnecessary',
371 + 'exclude',
372 + ) ||
373 + getWarningMessage(
374 + duplicateDependencies,
375 + 'a',
376 + 'duplicate',
377 + 'omit',
378 + )) +
379 + extraWarning,
380 + fix(fixer) {
381 + // TODO: consider preserving the comments or formatting?
382 + return fixer.replaceText(
383 + declaredDependenciesNode,
384 + `[${suggestedDependencies.join(', ')}]`,
385 + );
386 + },
387 + });
388 + }
389 + },
390 +};
391 +
392 +/**
393 + * Assuming () means the passed/returned node:
394 + * (props) => (props)
395 + * props.(foo) => (props.foo)
396 + * props.foo.(bar) => (props.foo).bar
397 + */
398 +function getDependency(node) {
399 + if (
400 + node.parent.type === 'MemberExpression' &&
401 + node.parent.object === node &&
402 + node.parent.property.name !== 'current' &&
403 + !node.parent.computed &&
404 + !(
405 + node.parent.parent != null &&
406 + node.parent.parent.type === 'CallExpression' &&
407 + node.parent.parent.callee === node.parent
408 + )
409 + ) {
410 + return node.parent;
411 + } else {
412 + return node;
413 + }
414 +}
415 +
416 +/**
417 + * Assuming () means the passed node.
418 + * (foo) -> 'foo'
419 + * foo.(bar) -> 'foo.bar'
420 + * foo.bar.(baz) -> 'foo.bar.baz'
421 + * Otherwise throw.
422 + */
423 +function toPropertyAccessString(node) {
424 + if (node.type === 'Identifier') {
425 + return node.name;
426 + } else if (node.type === 'MemberExpression' && !node.computed) {
427 + const object = toPropertyAccessString(node.object);
428 + const property = toPropertyAccessString(node.property);
429 + return `${object}.${property}`;
430 + } else {
431 + throw new Error(`Unsupported node type: ${node.type}`);
432 + }
433 +}
434 +
435 +// What's the index of callback that needs to be analyzed for a given Hook?
436 +// -1 if it's not a Hook we care about (e.g. useState).
437 +// 0 for useEffect/useMemo/useCallback(fn).
438 +// 1 for useImperativeHandle(ref, fn).
439 +// For additionally configured Hooks, assume that they're like useEffect (0).
440 +function getReactiveHookCallbackIndex(node, options) {
441 + let isOnReactObject = false;
442 + if (
443 + node.type === 'MemberExpression' &&
444 + node.object.type === 'Identifier' &&
445 + node.object.name === 'React' &&
446 + node.property.type === 'Identifier' &&
447 + !node.computed
448 + ) {
449 + node = node.property;
450 + isOnReactObject = true;
451 + }
452 + if (node.type !== 'Identifier') {
453 + return;
454 + }
455 + switch (node.name) {
456 + case 'useEffect':
457 + case 'useLayoutEffect':
458 + case 'useCallback':
459 + case 'useMemo':
460 + // useEffect(fn)
461 + return 0;
462 + case 'useImperativeHandle':
463 + // useImperativeHandle(ref, fn)
464 + return 1;
465 + default:
466 + if (!isOnReactObject && options && options.additionalHooks) {
467 + // Allow the user to provide a regular expression which enables the lint to
468 + // target custom reactive hooks.
469 + let name;
470 + try {
471 + name = toPropertyAccessString(node);
472 + } catch (error) {
473 + if (/Unsupported node type/.test(error.message)) {
474 + return 0;
475 + } else {
476 + throw error;
477 + }
478 + }
479 + return options.additionalHooks.test(name) ? 0 : -1;
480 + } else {
481 + return -1;
482 + }
483 + }
484 +}
485 +
486 +/**
487 + * ESLint won't assign node.parent to references from context.getScope()
488 + *
489 + * So instead we search for the node from an ancestor assigning node.parent
490 + * as we go. This mutates the AST.
491 + *
492 + * This traversal is:
493 + * - optimized by only searching nodes with a range surrounding our target node
494 + * - agnostic to AST node types, it looks for `{ type: string, ... }`
495 + */
496 +function fastFindReferenceWithParent(start, target) {
497 + let queue = [start];
498 + let item = null;
499 +
500 + while (queue.length) {
501 + item = queue.shift();
502 +
503 + if (isSameIdentifier(item, target)) {
504 + return item;
505 + }
506 +
507 + if (!isAncestorNodeOf(item, target)) {
508 + continue;
509 + }
510 +
511 + for (let [key, value] of Object.entries(item)) {
512 + if (key === 'parent') {
513 + continue;
514 + }
515 + if (isNodeLike(value)) {
516 + value.parent = item;
517 + queue.push(value);
518 + } else if (Array.isArray(value)) {
519 + value.forEach(val => {
520 + if (isNodeLike(val)) {
521 + val.parent = item;
522 + queue.push(val);
523 + }
524 + });
525 + }
526 + }
527 + }
528 +
529 + return null;
530 +}
531 +
532 +function joinEnglish(arr) {
533 + let s = '';
534 + for (let i = 0; i < arr.length; i++) {
535 + s += arr[i];
536 + if (i === 0 && arr.length === 2) {
537 + s += ' and ';
538 + } else if (i === arr.length - 2 && arr.length > 2) {
539 + s += ', and ';
540 + } else if (i < arr.length - 1) {
541 + s += ', ';
542 + }
543 + }
544 + return s;
545 +}
546 +
547 +function isNodeLike(val) {
548 + return (
549 + typeof val === 'object' &&
550 + val !== null &&
551 + !Array.isArray(val) &&
552 + typeof val.type === 'string'
553 + );
554 +}
555 +
556 +function isSameIdentifier(a, b) {
557 + return (
558 + a.type === 'Identifier' &&
559 + a.name === b.name &&
560 + a.range[0] === b.range[0] &&
561 + a.range[1] === b.range[1]
562 + );
563 +}
564 +
565 +function isAncestorNodeOf(a, b) {
566 + return a.range[0] <= b.range[0] && a.range[1] >= b.range[1];
567 +}
packages/eslint-plugin-react-hooks/src/index.js
+2
@@ -8,7 +8,9 @@
8 'use strict';
9
10 import RuleOfHooks from './RulesOfHooks';
11 +import ExhaustiveDeps from './ExhaustiveDeps';
12
13 export const rules = {
14 'rules-of-hooks': RuleOfHooks,
15 + 'exhaustive-deps': ExhaustiveDeps,
16 };