@samitouri / QOS-React / commits / df7b4768c7

[ESLint] Deduplicate suggested dependencies (#14982)

* Deduplicate suggested dependencies * Tweak test cases

Dan Abramov committed Mar 1, 2019 at 16:10 UTC df7b4768c7931504338ee1093ebcfda3d5974650
2 files changed +765 -316
packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js
+547 -236
@@ -33,33 +33,33 @@ const tests = {
33 valid: [
34 {
35 code: `
36 - function MyComponent() {
37 - const local = {};
38 - useEffect(() => {
39 - console.log(local);
40 - });
41 - }
42 - `,
36 + function MyComponent() {
37 + const local = {};
38 + useEffect(() => {
39 + console.log(local);
40 + });
41 + }
42 + `,
43 },
44 {
45 code: `
46 - function MyComponent() {
47 - useEffect(() => {
48 - const local = {};
49 - console.log(local);
50 - }, []);
51 - }
52 - `,
46 + function MyComponent() {
47 + useEffect(() => {
48 + const local = {};
49 + console.log(local);
50 + }, []);
51 + }
52 + `,
53 },
54 {
55 code: `
56 - function MyComponent() {
57 - const local = {};
58 - useEffect(() => {
59 - console.log(local);
60 - }, [local]);
61 - }
62 - `,
56 + function MyComponent() {
57 + const local = {};
58 + useEffect(() => {
59 + console.log(local);
60 + }, [local]);
61 + }
62 + `,
63 },
64 {
65 // OK because `props` wasn't defined.
@@ -68,164 +68,161 @@ const tests = {
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 - `,
71 + function MyComponent() {
72 + useEffect(() => {
73 + console.log(props.foo);
74 + }, []);
75 + }
76 + `,
77 },
78 {
79 code: `
80 - function MyComponent() {
81 - const local1 = {};
82 - {
83 - const local2 = {};
84 - useEffect(() => {
85 - console.log(local1);
86 - console.log(local2);
87 - });
80 + function MyComponent() {
81 + const local1 = {};
82 + {
83 + const local2 = {};
84 + useEffect(() => {
85 + console.log(local1);
86 + console.log(local2);
87 + });
88 + }
89 }
89 - }
90 - `,
90 + `,
91 },
92 {
93 code: `
94 - function MyComponent() {
95 - const local1 = {};
96 - {
97 - const local2 = {};
98 - useCallback(() => {
99 - console.log(local1);
100 - console.log(local2);
101 - }, [local1, local2]);
94 + function MyComponent() {
95 + const local1 = {};
96 + {
97 + const local2 = {};
98 + useCallback(() => {
99 + console.log(local1);
100 + console.log(local2);
101 + }, [local1, local2]);
102 + }
103 }
103 - }
104 - `,
104 + `,
105 },
106 {
107 code: `
108 - function MyComponent() {
109 - const local1 = {};
110 - function MyNestedComponent() {
111 - const local2 = {};
112 - useCallback(() => {
113 - console.log(local1);
114 - console.log(local2);
115 - }, [local2]);
108 + function MyComponent() {
109 + const local1 = {};
110 + function MyNestedComponent() {
111 + const local2 = {};
112 + useCallback(() => {
113 + console.log(local1);
114 + console.log(local2);
115 + }, [local2]);
116 + }
117 }
117 - }
118 - `,
118 + `,
119 },
120 {
121 code: `
122 - function MyComponent() {
123 - const local = {};
124 - useEffect(() => {
125 - console.log(local);
126 - console.log(local);
127 - }, [local]);
128 - }
129 - `,
122 + function MyComponent() {
123 + const local = {};
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 - `,
133 + function MyComponent() {
134 + useEffect(() => {
135 + console.log(unresolved);
136 + }, []);
137 + }
138 + `,
139 },
140 {
141 code: `
142 - function MyComponent() {
143 - const local = {};
144 - useEffect(() => {
145 - console.log(local);
146 - }, [,,,local,,,]);
147 - }
148 - `,
142 + function MyComponent() {
143 + const local = {};
144 + useEffect(() => {
145 + console.log(local);
146 + }, [,,,local,,,]);
147 + }
148 + `,
149 },
150 {
151 - code: `
151 // Regression test
153 - function MyComponent({ foo }) {
154 - useEffect(() => {
155 - console.log(foo.length);
156 - }, [foo]);
157 - }
158 - `,
152 + code: `
153 + function MyComponent({ foo }) {
154 + useEffect(() => {
155 + console.log(foo.length);
156 + }, [foo]);
157 + }
158 + `,
159 },
160 {
161 - code: `
161 // Regression test
163 - function MyComponent({ foo }) {
164 - useEffect(() => {
165 - console.log(foo.length);
166 - console.log(foo.slice(0));
167 - }, [foo]);
168 - }
169 - `,
162 + code: `
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: `
172 // Regression test
174 - function MyComponent({ history }) {
175 - useEffect(() => {
176 - return history.listen();
177 - }, [history]);
178 - }
179 - `,
173 + code: `
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.
182 code: `
184 - function MyComponent(props) {
185 - useEffect(() => {
186 - console.log(props.foo);
187 - }, [props.foo]);
188 - }
189 - `,
183 + function MyComponent(props) {
184 + useEffect(() => {
185 + console.log(props.foo);
186 + }, [props.foo]);
187 + }
188 + `,
189 },
190 {
192 - // TODO: we might want to forbid dot-access in deps.
191 code: `
194 - function MyComponent(props) {
195 - useEffect(() => {
196 - console.log(props.foo);
197 - console.log(props.bar);
198 - }, [props.bar, props.foo]);
199 - }
200 - `,
192 + function MyComponent(props) {
193 + useEffect(() => {
194 + console.log(props.foo);
195 + console.log(props.bar);
196 + }, [props.bar, props.foo]);
197 + }
198 + `,
199 },
200 {
203 - // TODO: we might want to forbid dot-access in deps.
201 code: `
205 - function MyComponent(props) {
206 - useEffect(() => {
207 - console.log(props.foo);
208 - console.log(props.bar);
209 - }, [props.foo, props.bar]);
210 - }
211 - `,
202 + function MyComponent(props) {
203 + useEffect(() => {
204 + console.log(props.foo);
205 + console.log(props.bar);
206 + }, [props.foo, props.bar]);
207 + }
208 + `,
209 },
210 {
214 - // TODO: we might want to forbid dot-access in deps.
211 code: `
216 - function MyComponent(props) {
217 - const local = {};
218 - useEffect(() => {
219 - console.log(props.foo);
220 - console.log(props.bar);
221 - console.log(local);
222 - }, [props.foo, props.bar, local]);
223 - }
224 - `,
212 + function MyComponent(props) {
213 + const local = {};
214 + useEffect(() => {
215 + console.log(props.foo);
216 + console.log(props.bar);
217 + console.log(local);
218 + }, [props.foo, props.bar, local]);
219 + }
220 + `,
221 },
222 {
227 - // TODO: we might want to warn "props.foo"
228 - // is extraneous because we already have "props".
223 + // [props, props.foo] is technically unnecessary ('props' covers 'props.foo').
224 + // However, it's valid for effects to over-specify their deps.
225 + // So we don't warn about this. We *would* warn about useMemo/useCallback.
226 code: `
227 function MyComponent(props) {
228 const local = {};
@@ -233,6 +230,12 @@ const tests = {
230 console.log(props.foo);
231 console.log(props.bar);
232 }, [props, props.foo]);
233 +
234 + let color = {}
235 + useEffect(() => {
236 + console.log(props.foo.bar.baz);
237 + console.log(color);
238 + }, [props.foo, props.foo.bar.baz, color]);
239 }
240 `,
241 },
@@ -247,7 +250,6 @@ const tests = {
250 options: [{additionalHooks: 'useCustomEffect'}],
251 },
252 {
250 - // TODO: we might want to forbid dot-access in deps.
253 code: `
254 function MyComponent(props) {
255 useCustomEffect(() => {
@@ -258,46 +260,46 @@ const tests = {
260 options: [{additionalHooks: 'useCustomEffect'}],
261 },
262 {
261 - code: `
263 // Valid because we don't care about hooks outside of components.
263 - const local = {};
264 - useEffect(() => {
265 - console.log(local);
266 - }, []);
267 - `,
268 - },
269 - {
264 code: `
271 - // Valid because we don't care about hooks outside of components.
272 - const local1 = {};
273 - {
274 - const local2 = {};
265 + const local = {};
266 useEffect(() => {
276 - console.log(local1);
277 - console.log(local2);
267 + console.log(local);
268 }, []);
279 - }
280 - `,
269 + `,
270 },
271 {
272 + // Valid because we don't care about hooks outside of components.
273 code: `
284 - function MyComponent() {
285 - const ref = useRef();
286 - useEffect(() => {
287 - console.log(ref.current);
288 - }, [ref]);
289 - }
290 - `,
274 + const local1 = {};
275 + {
276 + const local2 = {};
277 + useEffect(() => {
278 + console.log(local1);
279 + console.log(local2);
280 + }, []);
281 + }
282 + `,
283 },
284 {
285 code: `
294 - function MyComponent() {
295 - const ref = useRef();
296 - useEffect(() => {
297 - console.log(ref.current);
298 - }, []);
299 - }
300 - `,
286 + function MyComponent() {
287 + const ref = useRef();
288 + useEffect(() => {
289 + console.log(ref.current);
290 + }, [ref]);
291 + }
292 + `,
293 + },
294 + {
295 + code: `
296 + function MyComponent() {
297 + const ref = useRef();
298 + useEffect(() => {
299 + console.log(ref.current);
300 + }, []);
301 + }
302 + `,
303 },
304 {
305 code: `
@@ -561,38 +563,56 @@ const tests = {
563 `,
564 },
565 {
564 - // Valid because it's a primitive constant
566 + // Valid because it's a primitive constant.
567 code: `
566 - function MyComponent() {
567 - const local1 = 42;
568 - const local2 = '42';
569 - const local3 = null;
570 - useEffect(() => {
571 - console.log(local1);
572 - console.log(local2);
573 - console.log(local3);
574 - }, []);
575 - }
576 - `,
568 + function MyComponent() {
569 + const local1 = 42;
570 + const local2 = '42';
571 + const local3 = null;
572 + useEffect(() => {
573 + console.log(local1);
574 + console.log(local2);
575 + console.log(local3);
576 + }, []);
577 + }
578 + `,
579 },
580 {
581 // It's not a mistake to specify constant values though.
582 code: `
581 - function MyComponent() {
582 - const local1 = 42;
583 - const local2 = '42';
584 - const local3 = null;
585 - useEffect(() => {
586 - console.log(local1);
587 - console.log(local2);
588 - console.log(local3);
589 - }, [local1, local2, local3]);
590 - }
591 - `,
583 + function MyComponent() {
584 + const local1 = 42;
585 + const local2 = '42';
586 + const local3 = null;
587 + useEffect(() => {
588 + console.log(local1);
589 + console.log(local2);
590 + console.log(local3);
591 + }, [local1, local2, local3]);
592 + }
593 + `,
594 + },
595 + {
596 + // It is valid for effects to over-specify their deps.
597 + // TODO: maybe only allow local ones, and disallow this example.
598 + code: `
599 + function MyComponent() {
600 + useEffect(() => {}, [window]);
601 + }
602 + `,
603 + },
604 + {
605 + // It is valid for effects to over-specify their deps.
606 + code: `
607 + function MyComponent(props) {
608 + const local = props.local;
609 + useEffect(() => {}, [local]);
610 + }
611 + `,
612 },
613 {
614 // Valid even though activeTab is "unused".
595 - // We allow that in effects, but not callbacks or memo.
615 + // We allow over-specifying deps for effects, but not callbacks or memo.
616 code: `
617 function Foo({ activeTab }) {
618 useEffect(() => {
@@ -601,6 +621,46 @@ const tests = {
621 }
622 `,
623 },
624 + {
625 + // It is valid to specify broader effect deps than strictly necessary.
626 + // Don't warn for this.
627 + code: `
628 + function MyComponent(props) {
629 + useEffect(() => {
630 + console.log(props.foo.bar.baz);
631 + }, [props]);
632 + useEffect(() => {
633 + console.log(props.foo.bar.baz);
634 + }, [props.foo]);
635 + useEffect(() => {
636 + console.log(props.foo.bar.baz);
637 + }, [props.foo.bar]);
638 + useEffect(() => {
639 + console.log(props.foo.bar.baz);
640 + }, [props.foo.bar.baz]);
641 + }
642 + `,
643 + },
644 + {
645 + // It is *also* valid to specify broader memo/callback deps than strictly necessary.
646 + // Don't warn for this either.
647 + code: `
648 + function MyComponent(props) {
649 + const fn = useCallback(() => {
650 + console.log(props.foo.bar.baz);
651 + }, [props]);
652 + const fn2 = useCallback(() => {
653 + console.log(props.foo.bar.baz);
654 + }, [props.foo]);
655 + const fn3 = useMemo(() => {
656 + console.log(props.foo.bar.baz);
657 + }, [props.foo.bar]);
658 + const fn4 = useMemo(() => {
659 + console.log(props.foo.bar.baz);
660 + }, [props.foo.bar.baz]);
661 + }
662 + `,
663 + },
664 ],
665 invalid: [
666 {
@@ -674,8 +734,8 @@ const tests = {
734 ],
735 },
736 {
737 + // Regression test
738 code: `
678 - // Regression test
739 function MyComponent() {
740 const local = {};
741 useEffect(() => {
@@ -686,7 +746,6 @@ const tests = {
746 }
747 `,
748 output: `
689 - // Regression test
749 function MyComponent() {
750 const local = {};
751 useEffect(() => {
@@ -702,8 +761,8 @@ const tests = {
761 ],
762 },
763 {
764 + // Regression test
765 code: `
706 - // Regression test
766 function MyComponent() {
767 const local = {};
768 useEffect(() => {
@@ -714,7 +773,6 @@ const tests = {
773 }
774 `,
775 output: `
717 - // Regression test
776 function MyComponent() {
777 const local = {};
778 useEffect(() => {
@@ -730,8 +788,8 @@ const tests = {
788 ],
789 },
790 {
791 + // Regression test
792 code: `
734 - // Regression test
793 function MyComponent() {
794 const local = {};
795 useEffect(() => {
@@ -743,7 +801,6 @@ const tests = {
801 }
802 `,
803 output: `
746 - // Regression test
804 function MyComponent() {
805 const local = {};
806 useEffect(() => {
@@ -923,7 +980,7 @@ const tests = {
980 {
981 code: `
982 function MyComponent() {
926 - useCallback(() => {}, [local]);
983 + useCallback(() => {}, [window]);
984 }
985 `,
986 output: `
@@ -931,6 +988,26 @@ const tests = {
988 useCallback(() => {}, []);
989 }
990 `,
991 + errors: [
992 + "React Hook useCallback has an unnecessary dependency: 'window'. " +
993 + 'Either exclude it or remove the dependency array.',
994 + ],
995 + },
996 + {
997 + // It is not valid for useCallback to specify extraneous deps
998 + // because it doesn't serve as a side effect trigger unlike useEffect.
999 + code: `
1000 + function MyComponent(props) {
1001 + let local = props.foo;
1002 + useCallback(() => {}, [local]);
1003 + }
1004 + `,
1005 + output: `
1006 + function MyComponent(props) {
1007 + let local = props.foo;
1008 + useCallback(() => {}, []);
1009 + }
1010 + `,
1011 errors: [
1012 "React Hook useCallback has an unnecessary dependency: 'local'. " +
1013 'Either exclude it or remove the dependency array.',
@@ -938,18 +1015,18 @@ const tests = {
1015 },
1016 {
1017 code: `
941 - function MyComponent({ history }) {
942 - useEffect(() => {
943 - return history.listen();
944 - }, []);
945 - }
1018 + function MyComponent({ history }) {
1019 + useEffect(() => {
1020 + return history.listen();
1021 + }, []);
1022 + }
1023 `,
1024 output: `
948 - function MyComponent({ history }) {
949 - useEffect(() => {
950 - return history.listen();
951 - }, [history]);
952 - }
1025 + function MyComponent({ history }) {
1026 + useEffect(() => {
1027 + return history.listen();
1028 + }, [history]);
1029 + }
1030 `,
1031 errors: [
1032 "React Hook useEffect has a missing dependency: 'history'. " +
@@ -958,24 +1035,24 @@ const tests = {
1035 },
1036 {
1037 code: `
961 - function MyComponent({ history }) {
962 - useEffect(() => {
963 - return [
964 - history.foo.bar[2].dobedo.listen(),
965 - history.foo.bar().dobedo.listen[2]
966 - ];
967 - }, []);
968 - }
1038 + function MyComponent({ history }) {
1039 + useEffect(() => {
1040 + return [
1041 + history.foo.bar[2].dobedo.listen(),
1042 + history.foo.bar().dobedo.listen[2]
1043 + ];
1044 + }, []);
1045 + }
1046 `,
1047 output: `
971 - function MyComponent({ history }) {
972 - useEffect(() => {
973 - return [
974 - history.foo.bar[2].dobedo.listen(),
975 - history.foo.bar().dobedo.listen[2]
976 - ];
977 - }, [history.foo]);
978 - }
1048 + function MyComponent({ history }) {
1049 + useEffect(() => {
1050 + return [
1051 + history.foo.bar[2].dobedo.listen(),
1052 + history.foo.bar().dobedo.listen[2]
1053 + ];
1054 + }, [history.foo]);
1055 + }
1056 `,
1057 errors: [
1058 "React Hook useEffect has a missing dependency: 'history.foo'. " +
@@ -1240,6 +1317,62 @@ const tests = {
1317 ],
1318 },
1319 {
1320 + // It is not valid for useCallback to specify extraneous deps
1321 + // because it doesn't serve as a side effect trigger unlike useEffect.
1322 + // However, we generally allow specifying *broader* deps as escape hatch.
1323 + // So while [props, props.foo] is unnecessary, 'props' wins here as the
1324 + // broader one, and this is why 'props.foo' is reported as unnecessary.
1325 + code: `
1326 + function MyComponent(props) {
1327 + const local = {};
1328 + useCallback(() => {
1329 + console.log(props.foo);
1330 + console.log(props.bar);
1331 + }, [props, props.foo]);
1332 + }
1333 + `,
1334 + output: `
1335 + function MyComponent(props) {
1336 + const local = {};
1337 + useCallback(() => {
1338 + console.log(props.foo);
1339 + console.log(props.bar);
1340 + }, [props]);
1341 + }
1342 + `,
1343 + errors: [
1344 + "React Hook useCallback has an unnecessary dependency: 'props.foo'. " +
1345 + 'Either exclude it or remove the dependency array.',
1346 + ],
1347 + },
1348 + {
1349 + // Since we don't have 'props' in the list, we'll suggest narrow dependencies.
1350 + code: `
1351 + function MyComponent(props) {
1352 + const local = {};
1353 + useCallback(() => {
1354 + console.log(props.foo);
1355 + console.log(props.bar);
1356 + }, []);
1357 + }
1358 + `,
1359 + output: `
1360 + function MyComponent(props) {
1361 + const local = {};
1362 + useCallback(() => {
1363 + console.log(props.foo);
1364 + console.log(props.bar);
1365 + }, [props.bar, props.foo]);
1366 + }
1367 + `,
1368 + errors: [
1369 + "React Hook useCallback has missing dependencies: 'props.bar' and 'props.foo'. " +
1370 + 'Either include them or remove the dependency array.',
1371 + ],
1372 + },
1373 + {
1374 + // Effects are allowed to over-specify deps. We'll complain about missing
1375 + // 'local', but we won't remove the already-specified 'local.id' from your list.
1376 code: `
1377 function MyComponent() {
1378 const local = {id: 42};
@@ -1248,8 +1381,6 @@ const tests = {
1381 }, [local.id]);
1382 }
1383 `,
1251 - // TODO: autofix should be smart enough
1252 - // to remove local.id from the list.
1384 output: `
1385 function MyComponent() {
1386 const local = {id: 42};
@@ -1263,6 +1394,193 @@ const tests = {
1394 'Either include it or remove the dependency array.',
1395 ],
1396 },
1397 + {
1398 + // Callbacks are not allowed to over-specify deps. So we'll complain about missing
1399 + // 'local' and we will also *remove* 'local.id' from your list.
1400 + code: `
1401 + function MyComponent() {
1402 + const local = {id: 42};
1403 + const fn = useCallback(() => {
1404 + console.log(local);
1405 + }, [local.id]);
1406 + }
1407 + `,
1408 + output: `
1409 + function MyComponent() {
1410 + const local = {id: 42};
1411 + const fn = useCallback(() => {
1412 + console.log(local);
1413 + }, [local]);
1414 + }
1415 + `,
1416 + errors: [
1417 + "React Hook useCallback has a missing dependency: 'local'. " +
1418 + 'Either include it or remove the dependency array.',
1419 + ],
1420 + },
1421 + {
1422 + // Callbacks are not allowed to over-specify deps. So we'll complain about
1423 + // the unnecessary 'local.id'.
1424 + code: `
1425 + function MyComponent() {
1426 + const local = {id: 42};
1427 + const fn = useCallback(() => {
1428 + console.log(local);
1429 + }, [local.id, local]);
1430 + }
1431 + `,
1432 + output: `
1433 + function MyComponent() {
1434 + const local = {id: 42};
1435 + const fn = useCallback(() => {
1436 + console.log(local);
1437 + }, [local]);
1438 + }
1439 + `,
1440 + errors: [
1441 + "React Hook useCallback has an unnecessary dependency: 'local.id'. " +
1442 + 'Either exclude it or remove the dependency array.',
1443 + ],
1444 + },
1445 + {
1446 + code: `
1447 + function MyComponent(props) {
1448 + const fn = useCallback(() => {
1449 + console.log(props.foo.bar.baz);
1450 + }, []);
1451 + }
1452 + `,
1453 + output: `
1454 + function MyComponent(props) {
1455 + const fn = useCallback(() => {
1456 + console.log(props.foo.bar.baz);
1457 + }, [props.foo.bar.baz]);
1458 + }
1459 + `,
1460 + errors: [
1461 + "React Hook useCallback has a missing dependency: 'props.foo.bar.baz'. " +
1462 + 'Either include it or remove the dependency array.',
1463 + ],
1464 + },
1465 + {
1466 + code: `
1467 + function MyComponent(props) {
1468 + let color = {}
1469 + const fn = useCallback(() => {
1470 + console.log(props.foo.bar.baz);
1471 + console.log(color);
1472 + }, [props.foo, props.foo.bar.baz]);
1473 + }
1474 + `,
1475 + output: `
1476 + function MyComponent(props) {
1477 + let color = {}
1478 + const fn = useCallback(() => {
1479 + console.log(props.foo.bar.baz);
1480 + console.log(color);
1481 + }, [color, props.foo.bar.baz]);
1482 + }
1483 + `,
1484 + errors: [
1485 + "React Hook useCallback has a missing dependency: 'color'. " +
1486 + 'Either include it or remove the dependency array.',
1487 + ],
1488 + },
1489 + {
1490 + // Callbacks are not allowed to over-specify deps. So one of these is extra.
1491 + // However, it *is* allowed to specify broader deps then strictly necessary.
1492 + // So in this case we ask you to remove 'props.foo.bar.baz' because 'props.foo'
1493 + // already covers it, and having both is unnecessary.
1494 + // TODO: maybe consider suggesting a narrower one by default in these cases.
1495 + code: `
1496 + function MyComponent(props) {
1497 + const fn = useCallback(() => {
1498 + console.log(props.foo.bar.baz);
1499 + }, [props.foo.bar.baz, props.foo]);
1500 + }
1501 + `,
1502 + output: `
1503 + function MyComponent(props) {
1504 + const fn = useCallback(() => {
1505 + console.log(props.foo.bar.baz);
1506 + }, [props.foo]);
1507 + }
1508 + `,
1509 + errors: [
1510 + "React Hook useCallback has an unnecessary dependency: 'props.foo.bar.baz'. " +
1511 + 'Either exclude it or remove the dependency array.',
1512 + ],
1513 + },
1514 + {
1515 + code: `
1516 + function MyComponent(props) {
1517 + const fn = useCallback(() => {
1518 + console.log(props.foo.bar.baz);
1519 + console.log(props.foo.fizz.bizz);
1520 + }, []);
1521 + }
1522 + `,
1523 + output: `
1524 + function MyComponent(props) {
1525 + const fn = useCallback(() => {
1526 + console.log(props.foo.bar.baz);
1527 + console.log(props.foo.fizz.bizz);
1528 + }, [props.foo.bar.baz, props.foo.fizz.bizz]);
1529 + }
1530 + `,
1531 + errors: [
1532 + "React Hook useCallback has missing dependencies: 'props.foo.bar.baz' and 'props.foo.fizz.bizz'. " +
1533 + 'Either include them or remove the dependency array.',
1534 + ],
1535 + },
1536 + {
1537 + // Normally we allow specifying deps too broadly.
1538 + // So we'd be okay if 'props.foo.bar' was there rather than 'props.foo.bar.baz'.
1539 + // However, 'props.foo.bar.baz' is missing. So we know there is a mistake.
1540 + // When we're sure there is a mistake, for callbacks we will rebuild the list
1541 + // from scratch. This will set the user on a better path by default.
1542 + // This is why we end up with just 'props.foo.bar', and not them both.
1543 + code: `
1544 + function MyComponent(props) {
1545 + const fn = useCallback(() => {
1546 + console.log(props.foo.bar);
1547 + }, [props.foo.bar.baz]);
1548 + }
1549 + `,
1550 + output: `
1551 + function MyComponent(props) {
1552 + const fn = useCallback(() => {
1553 + console.log(props.foo.bar);
1554 + }, [props.foo.bar]);
1555 + }
1556 + `,
1557 + errors: [
1558 + "React Hook useCallback has a missing dependency: 'props.foo.bar'. " +
1559 + 'Either include it or remove the dependency array.',
1560 + ],
1561 + },
1562 + {
1563 + code: `
1564 + function MyComponent(props) {
1565 + const fn = useCallback(() => {
1566 + console.log(props);
1567 + console.log(props.hello);
1568 + }, [props.foo.bar.baz]);
1569 + }
1570 + `,
1571 + output: `
1572 + function MyComponent(props) {
1573 + const fn = useCallback(() => {
1574 + console.log(props);
1575 + console.log(props.hello);
1576 + }, [props]);
1577 + }
1578 + `,
1579 + errors: [
1580 + "React Hook useCallback has a missing dependency: 'props'. " +
1581 + 'Either include it or remove the dependency array.',
1582 + ],
1583 + },
1584 {
1585 code: `
1586 function MyComponent() {
@@ -1335,8 +1653,6 @@ const tests = {
1653 }, []);
1654 }
1655 `,
1338 - // TODO: we need to think about ideal output here.
1339 - // Should it capture by default?
1656 output: `
1657 function MyComponent(props) {
1658 useEffect(() => {
@@ -1358,8 +1674,6 @@ const tests = {
1674 }, []);
1675 }
1676 `,
1361 - // TODO: we need to think about ideal output here.
1362 - // Should it capture by default?
1677 output: `
1678 function MyComponent(props) {
1679 useEffect(() => {
@@ -1453,8 +1767,6 @@ const tests = {
1767 }, []);
1768 }
1769 `,
1456 - // TODO: we need to think about ideal output here.
1457 - // Should it capture by default?
1770 output: `
1771 function MyComponent(props) {
1772 const local = {};
@@ -1910,20 +2222,19 @@ const tests = {
2222 }, []);
2223 }
2224 `,
1913 - // TODO: [props.onChange] is superfluous. Fix to just [props.onChange].
2225 output: `
2226 function MyComponent(props) {
2227 useEffect(() => {
2228 if (props.onChange) {
2229 props.onChange();
2230 }
1920 - }, [props, props.onChange]);
2231 + }, [props]);
2232 }
2233 `,
2234 errors: [
1924 - // TODO: reporting props separately is superfluous. Fix to just props.onChange.
1925 - "React Hook useEffect has missing dependencies: 'props' and 'props.onChange'. " +
1926 - 'Either include them or remove the dependency array.',
2235 + // TODO: make this message clearer since it's not obvious why.
2236 + "React Hook useEffect has a missing dependency: 'props'. " +
2237 + 'Either include it or remove the dependency array.',
2238 ],
2239 },
2240 {
packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js
+218 -80
@@ -306,77 +306,75 @@ export default {
306 });
307 }
308
309 - // TODO: we can do a pass at this code and pick more appropriate
310 - // data structures to avoid nested loops if we can.
311 - let suggestedDependencies = [];
312 - let duplicateDependencies = new Set();
313 - let unnecessaryDependencies = new Set();
314 - let missingDependencies = new Set();
315 - let actualDependencies = Array.from(dependencies.keys());
309 + // Warn about assigning to variables in the outer scope.
310 + // Those are usually bugs.
311 let foundStaleAssignments = false;
317 -
318 - function satisfies(actualDep, dep) {
319 - return actualDep === dep || actualDep.startsWith(dep + '.');
312 + function reportStaleAssignment(writeExpr, key) {
313 + foundStaleAssignments = true;
314 + context.report({
315 + node: writeExpr,
316 + message:
317 + `Assignments to the '${key}' variable from inside a React ${context.getSource(
318 + reactiveHook,
319 + )} Hook ` +
320 + `will not persist between re-renders. ` +
321 + `If it's only needed by this Hook, move the variable inside it. ` +
322 + `Alternatively, declare a ref with the useRef Hook, ` +
323 + `and keep the mutable value in its 'current' property.`,
324 + });
325 }
326
322 - // First, ensure what user specified makes sense.
323 - declaredDependencies.forEach(({key}) => {
324 - if (actualDependencies.some(actualDep => satisfies(actualDep, key))) {
325 - // Legit dependency.
326 - if (suggestedDependencies.indexOf(key) === -1) {
327 - suggestedDependencies.push(key);
328 - } else {
329 - // Duplicate. Do nothing.
330 - duplicateDependencies.add(key);
331 - }
332 - } else {
333 - if (isEffect && !key.endsWith('.current')) {
334 - // Effects may have extra "unnecessary" deps.
335 - // Such as resetting scroll when ID changes.
336 - // The exception is ref.current which is always wrong.
337 - // Consider them legit.
338 - if (suggestedDependencies.indexOf(key) === -1) {
339 - suggestedDependencies.push(key);
340 - }
341 - } else {
342 - // Unnecessary dependency. Remember to report it.
343 - unnecessaryDependencies.add(key);
344 - }
345 - }
346 - });
347 -
348 - // Then fill in the missing ones.
327 + // Remember which deps are optional and report bad usage first.
328 + const optionalDependencies = new Set();
329 dependencies.forEach(({isStatic, reference}, key) => {
330 + if (isStatic) {
331 + optionalDependencies.add(key);
332 + }
333 if (reference.writeExpr) {
351 - foundStaleAssignments = true;
352 - context.report({
353 - node: reference.writeExpr,
354 - message:
355 - `Assignments to the '${key}' variable from inside a React ${context.getSource(
356 - reactiveHook,
357 - )} Hook ` +
358 - `will not persist between re-renders. ` +
359 - `If it's only needed by this Hook, move the variable inside it. ` +
360 - `Alternatively, declare a ref with the useRef Hook, ` +
361 - `and keep the mutable value in its 'current' property.`,
362 - });
334 + reportStaleAssignment(reference.writeExpr, key);
335 }
336 + });
337 + if (foundStaleAssignments) {
338 + // The intent isn't clear so we'll wait until you fix those first.
339 + return;
340 + }
341
365 - if (
366 - !suggestedDependencies.some(suggestedDep =>
367 - satisfies(key, suggestedDep),
368 - )
369 - ) {
370 - if (!isStatic) {
371 - // Legit missing.
372 - suggestedDependencies.push(key);
373 - missingDependencies.add(key);
374 - }
375 - } else {
376 - // Already did that. Do nothing.
377 - }
342 + let {
343 + suggestedDependencies,
344 + unnecessaryDependencies,
345 + missingDependencies,
346 + duplicateDependencies,
347 + } = collectRecommendations({
348 + dependencies,
349 + declaredDependencies,
350 + optionalDependencies,
351 + isEffect,
352 });
353
354 + const problemCount =
355 + duplicateDependencies.size +
356 + missingDependencies.size +
357 + unnecessaryDependencies.size;
358 + if (problemCount === 0) {
359 + return;
360 + }
361 +
362 + // If we're going to report a missing dependency,
363 + // we might as well recalculate the list ignoring
364 + // the currently specified deps. This can result
365 + // in some extra deduplication. We can't do this
366 + // for effects though because those have legit
367 + // use cases for over-specifying deps.
368 + if (!isEffect && missingDependencies.size > 0) {
369 + suggestedDependencies = collectRecommendations({
370 + dependencies,
371 + declaredDependencies: [], // Pretend we don't know
372 + optionalDependencies,
373 + isEffect,
374 + }).suggestedDependencies;
375 + }
376 +
377 + // Alphabetize the suggestions, but only if deps were already alphabetized.
378 function areDeclaredDepsAlphabetized() {
379 if (declaredDependencies.length === 0) {
380 return true;
@@ -385,26 +383,10 @@ export default {
383 const sortedDeclaredDepKeys = declaredDepKeys.slice().sort();
384 return declaredDepKeys.join(',') === sortedDeclaredDepKeys.join(',');
385 }
388 -
386 if (areDeclaredDepsAlphabetized()) {
390 - // Alphabetize the autofix, but only if deps were already alphabetized.
387 suggestedDependencies.sort();
388 }
389
394 - const problemCount =
395 - duplicateDependencies.size +
396 - missingDependencies.size +
397 - unnecessaryDependencies.size;
398 -
399 - if (problemCount === 0) {
400 - return;
401 - }
402 -
403 - if (foundStaleAssignments) {
404 - // The intent isn't clear so we'll wait until you fix those first.
405 - return;
406 - }
407 -
390 function getWarningMessage(deps, singlePrefix, label, fixVerb) {
391 if (deps.size === 0) {
392 return null;
@@ -475,11 +457,167 @@ export default {
457 },
458 };
459
460 +// The meat of the logic.
461 +function collectRecommendations({
462 + dependencies,
463 + declaredDependencies,
464 + optionalDependencies,
465 + isEffect,
466 +}) {
467 + // Our primary data structure.
468 + // It is a logical representation of property chains:
469 + // `props` -> `props.foo` -> `props.foo.bar` -> `props.foo.bar.baz`
470 + // -> `props.lol`
471 + // -> `props.huh` -> `props.huh.okay`
472 + // -> `props.wow`
473 + // We'll use it to mark nodes that are *used* by the programmer,
474 + // and the nodes that were *declared* as deps. Then we will
475 + // traverse it to learn which deps are missing or unnecessary.
476 + const depTree = createDepTree();
477 + function createDepTree() {
478 + return {
479 + isRequired: false, // True if used in code
480 + isSatisfiedRecursively: false, // True if specified in deps
481 + hasRequiredNodesBelow: false, // True if something deeper is used by code
482 + children: new Map(), // Nodes for properties
483 + };
484 + }
485 +
486 + // Mark all required nodes first.
487 + // Imagine exclamation marks next to each used deep property.
488 + dependencies.forEach((_, key) => {
489 + const node = getOrCreateNodeByPath(depTree, key);
490 + node.isRequired = true;
491 + markAllParentsByPath(depTree, key, parent => {
492 + parent.hasRequiredNodesBelow = true;
493 + });
494 + });
495 +
496 + // Mark all satisfied nodes.
497 + // Imagine checkmarks next to each declared dependency.
498 + declaredDependencies.forEach(({key}) => {
499 + const node = getOrCreateNodeByPath(depTree, key);
500 + node.isSatisfiedRecursively = true;
501 + });
502 + optionalDependencies.forEach(key => {
503 + const node = getOrCreateNodeByPath(depTree, key);
504 + node.isSatisfiedRecursively = true;
505 + });
506 +
507 + // Tree manipulation helpers.
508 + function getOrCreateNodeByPath(rootNode, path) {
509 + let keys = path.split('.');
510 + let node = rootNode;
511 + for (let key of keys) {
512 + let child = node.children.get(key);
513 + if (!child) {
514 + child = createDepTree();
515 + node.children.set(key, child);
516 + }
517 + node = child;
518 + }
519 + return node;
520 + }
521 + function markAllParentsByPath(rootNode, path, fn) {
522 + let keys = path.split('.');
523 + let node = rootNode;
524 + for (let key of keys) {
525 + let child = node.children.get(key);
526 + if (!child) {
527 + return;
528 + }
529 + fn(child);
530 + node = child;
531 + }
532 + }
533 +
534 + // Now we can learn which dependencies are missing or necessary.
535 + let missingDependencies = new Set();
536 + let satisfyingDependencies = new Set();
537 + scanTreeRecursively(
538 + depTree,
539 + missingDependencies,
540 + satisfyingDependencies,
541 + key => key,
542 + );
543 + function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) {
544 + node.children.forEach((child, key) => {
545 + const path = keyToPath(key);
546 + if (child.isSatisfiedRecursively) {
547 + if (child.hasRequiredNodesBelow) {
548 + // Remember this dep actually satisfied something.
549 + satisfyingPaths.add(path);
550 + }
551 + // It doesn't matter if there's something deeper.
552 + // It would be transitively satisfied since we assume immutability.
553 + // `props.foo` is enough if you read `props.foo.id`.
554 + return;
555 + }
556 + if (child.isRequired) {
557 + // Remember that no declared deps satisfied this node.
558 + missingPaths.add(path);
559 + // If we got here, nothing in its subtree was satisfied.
560 + // No need to search further.
561 + return;
562 + }
563 + scanTreeRecursively(
564 + child,
565 + missingPaths,
566 + satisfyingPaths,
567 + childKey => path + '.' + childKey,
568 + );
569 + });
570 + }
571 +
572 + // Collect suggestions in the order they were originally specified.
573 + let suggestedDependencies = [];
574 + let unnecessaryDependencies = new Set();
575 + let duplicateDependencies = new Set();
576 + declaredDependencies.forEach(({key}) => {
577 + // Does this declared dep satsify a real need?
578 + if (satisfyingDependencies.has(key)) {
579 + if (suggestedDependencies.indexOf(key) === -1) {
580 + // Good one.
581 + suggestedDependencies.push(key);
582 + } else {
583 + // Duplicate.
584 + duplicateDependencies.add(key);
585 + }
586 + } else {
587 + if (isEffect && !key.endsWith('.current')) {
588 + // Effects are allowed extra "unnecessary" deps.
589 + // Such as resetting scroll when ID changes.
590 + // Consider them legit.
591 + // The exception is ref.current which is always wrong.
592 + if (suggestedDependencies.indexOf(key) === -1) {
593 + suggestedDependencies.push(key);
594 + }
595 + } else {
596 + // It's definitely not needed.
597 + unnecessaryDependencies.add(key);
598 + }
599 + }
600 + });
601 +
602 + // Then add the missing ones at the end.
603 + missingDependencies.forEach(key => {
604 + suggestedDependencies.push(key);
605 + });
606 +
607 + return {
608 + suggestedDependencies,
609 + unnecessaryDependencies,
610 + duplicateDependencies,
611 + missingDependencies,
612 + };
613 +}
614 +
615 /**
616 * Assuming () means the passed/returned node:
617 * (props) => (props)
618 * props.(foo) => (props.foo)
482 - * props.foo.(bar) => (props.foo).bar
619 + * props.foo.(bar) => (props).foo.bar
620 + * props.foo.bar.(baz) => (props).foo.bar.baz
621 */
622 function getDependency(node) {
623 if (
@@ -493,7 +631,7 @@ function getDependency(node) {
631 node.parent.parent.callee === node.parent
632 )
633 ) {
496 - return node.parent;
634 + return getDependency(node.parent);
635 } else {
636 return node;
637 }