[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
}