[ESLint] Consistently treat optional chaining as regular chaining (#19273)
* Revert "Fix ExhaustiveDeps ESLint rule throwing with optional chaining (#19260)" This reverts commit 0f84b0f02b5579d780a9f54497007c4c84aaebb7. * Re-add a test from #19260 * Remove all code for optional chaining support * Consistently treat optional chaining as regular chaining This is not ideal because our suggestions use normal chaining. But it gets rid of all current edge cases. * Add more tests * More consistency in treating normal and optional expressions * Add regression tests for every occurrence of Optional*
Dan Abramov committed
Jul 7, 2020 at 17:38 UTC
7ca1d861e85868dfb4e5c1fc9db4db3d94cdefc1
2 files changed
+396
-43
packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js
+364
-7
@@ -270,6 +270,44 @@ const tests = {
270
}
271
`,
272
},
273
+ {
274
+ code: normalizeIndent`
275
+ function MyComponent(props) {
276
+ useEffect(() => {
277
+ console.log(props.foo?.bar);
278
+ }, [props.foo.bar]);
279
+ }
280
+ `,
281
+ },
282
+ {
283
+ code: normalizeIndent`
284
+ function MyComponent(props) {
285
+ useEffect(() => {
286
+ console.log(props.foo.bar);
287
+ }, [props.foo?.bar]);
288
+ }
289
+ `,
290
+ },
291
+ {
292
+ code: normalizeIndent`
293
+ function MyComponent(props) {
294
+ useEffect(() => {
295
+ console.log(props.foo.bar);
296
+ console.log(props.foo?.bar);
297
+ }, [props.foo?.bar]);
298
+ }
299
+ `,
300
+ },
301
+ {
302
+ code: normalizeIndent`
303
+ function MyComponent(props) {
304
+ useEffect(() => {
305
+ console.log(props.foo.bar);
306
+ console.log(props.foo?.bar);
307
+ }, [props.foo.bar]);
308
+ }
309
+ `,
310
+ },
311
{
312
code: normalizeIndent`
313
function MyComponent(props) {
@@ -307,6 +345,42 @@ const tests = {
345
}
346
`,
347
},
348
+ {
349
+ code: normalizeIndent`
350
+ function MyComponent(props) {
351
+ useCallback(() => {
352
+ console.log(props.foo.bar?.toString());
353
+ }, [props.foo.bar]);
354
+ }
355
+ `,
356
+ },
357
+ {
358
+ code: normalizeIndent`
359
+ function MyComponent(props) {
360
+ useCallback(() => {
361
+ console.log(props.foo?.bar?.toString());
362
+ }, [props.foo.bar]);
363
+ }
364
+ `,
365
+ },
366
+ {
367
+ code: normalizeIndent`
368
+ function MyComponent(props) {
369
+ useCallback(() => {
370
+ console.log(props.foo.bar.toString());
371
+ }, [props?.foo?.bar]);
372
+ }
373
+ `,
374
+ },
375
+ {
376
+ code: normalizeIndent`
377
+ function MyComponent(props) {
378
+ useCallback(() => {
379
+ console.log(props.foo?.bar?.baz);
380
+ }, [props?.foo.bar?.baz]);
381
+ }
382
+ `,
383
+ },
384
{
385
code: normalizeIndent`
386
function MyComponent() {
@@ -692,6 +766,24 @@ const tests = {
766
}
767
`,
768
},
769
+ {
770
+ // Valid because we assign ref.current
771
+ // ourselves. Therefore it's likely not
772
+ // a ref managed by React.
773
+ code: normalizeIndent`
774
+ function MyComponent() {
775
+ const myRef = useRef();
776
+ useEffect(() => {
777
+ const handleMove = () => {};
778
+ myRef.current = {};
779
+ return () => {
780
+ console.log(myRef?.current?.toString())
781
+ };
782
+ }, []);
783
+ return <div />;
784
+ }
785
+ `,
786
+ },
787
{
788
// Valid because we assign ref.current
789
// ourselves. Therefore it's likely not
@@ -1264,17 +1356,103 @@ const tests = {
1356
errors: [
1357
{
1358
message:
1267
- "React Hook useCallback has a missing dependency: 'props.foo?.toString'. " +
1359
+ "React Hook useCallback has a missing dependency: 'props.foo'. " +
1360
'Either include it or remove the dependency array.',
1361
suggestions: [
1362
{
1271
- desc:
1272
- 'Update the dependencies array to be: [props.foo?.toString]',
1363
+ desc: 'Update the dependencies array to be: [props.foo]',
1364
output: normalizeIndent`
1365
function MyComponent(props) {
1366
useCallback(() => {
1367
console.log(props.foo?.toString());
1277
- }, [props.foo?.toString]);
1368
+ }, [props.foo]);
1369
+ }
1370
+ `,
1371
+ },
1372
+ ],
1373
+ },
1374
+ ],
1375
+ },
1376
+ {
1377
+ code: normalizeIndent`
1378
+ function MyComponent(props) {
1379
+ useCallback(() => {
1380
+ console.log(props.foo?.bar.baz);
1381
+ }, []);
1382
+ }
1383
+ `,
1384
+ errors: [
1385
+ {
1386
+ message:
1387
+ // TODO: Ideally this would suggest props.foo?.bar.baz instead.
1388
+ "React Hook useCallback has a missing dependency: 'props.foo.bar.baz'. " +
1389
+ 'Either include it or remove the dependency array.',
1390
+ suggestions: [
1391
+ {
1392
+ desc: 'Update the dependencies array to be: [props.foo.bar.baz]',
1393
+ output: normalizeIndent`
1394
+ function MyComponent(props) {
1395
+ useCallback(() => {
1396
+ console.log(props.foo?.bar.baz);
1397
+ }, [props.foo.bar.baz]);
1398
+ }
1399
+ `,
1400
+ },
1401
+ ],
1402
+ },
1403
+ ],
1404
+ },
1405
+ {
1406
+ code: normalizeIndent`
1407
+ function MyComponent(props) {
1408
+ useCallback(() => {
1409
+ console.log(props.foo?.bar?.baz);
1410
+ }, []);
1411
+ }
1412
+ `,
1413
+ errors: [
1414
+ {
1415
+ message:
1416
+ // TODO: Ideally this would suggest props.foo?.bar?.baz instead.
1417
+ "React Hook useCallback has a missing dependency: 'props.foo.bar.baz'. " +
1418
+ 'Either include it or remove the dependency array.',
1419
+ suggestions: [
1420
+ {
1421
+ desc: 'Update the dependencies array to be: [props.foo.bar.baz]',
1422
+ output: normalizeIndent`
1423
+ function MyComponent(props) {
1424
+ useCallback(() => {
1425
+ console.log(props.foo?.bar?.baz);
1426
+ }, [props.foo.bar.baz]);
1427
+ }
1428
+ `,
1429
+ },
1430
+ ],
1431
+ },
1432
+ ],
1433
+ },
1434
+ {
1435
+ code: normalizeIndent`
1436
+ function MyComponent(props) {
1437
+ useCallback(() => {
1438
+ console.log(props.foo?.bar.toString());
1439
+ }, []);
1440
+ }
1441
+ `,
1442
+ errors: [
1443
+ {
1444
+ message:
1445
+ // TODO: Ideally this would suggest props.foo?.bar instead.
1446
+ "React Hook useCallback has a missing dependency: 'props.foo.bar'. " +
1447
+ 'Either include it or remove the dependency array.',
1448
+ suggestions: [
1449
+ {
1450
+ desc: 'Update the dependencies array to be: [props.foo.bar]',
1451
+ output: normalizeIndent`
1452
+ function MyComponent(props) {
1453
+ useCallback(() => {
1454
+ console.log(props.foo?.bar.toString());
1455
+ }, [props.foo.bar]);
1456
}
1457
`,
1458
},
@@ -1867,18 +2045,18 @@ const tests = {
2045
errors: [
2046
{
2047
message:
1870
- "React Hook useEffect has a missing dependency: 'history?.foo'. " +
2048
+ "React Hook useEffect has a missing dependency: 'history.foo'. " +
2049
'Either include it or remove the dependency array.',
2050
suggestions: [
2051
{
1874
- desc: 'Update the dependencies array to be: [history?.foo]',
2052
+ desc: 'Update the dependencies array to be: [history.foo]',
2053
output: normalizeIndent`
2054
function MyComponent({ history }) {
2055
useEffect(() => {
2056
return [
2057
history?.foo
2058
];
1881
- }, [history?.foo]);
2059
+ }, [history.foo]);
2060
}
2061
`,
2062
},
@@ -3479,6 +3657,47 @@ const tests = {
3657
},
3658
],
3659
},
3660
+ {
3661
+ code: normalizeIndent`
3662
+ function MyComponent(props) {
3663
+ const ref1 = useRef();
3664
+ const ref2 = useRef();
3665
+ useEffect(() => {
3666
+ ref1?.current?.focus();
3667
+ console.log(ref2?.current?.textContent);
3668
+ alert(props.someOtherRefs.current.innerHTML);
3669
+ fetch(props.color);
3670
+ }, [ref1?.current, ref2?.current, props.someOtherRefs, props.color]);
3671
+ }
3672
+ `,
3673
+ errors: [
3674
+ {
3675
+ message:
3676
+ "React Hook useEffect has unnecessary dependencies: 'ref1.current' and 'ref2.current'. " +
3677
+ 'Either exclude them or remove the dependency array. ' +
3678
+ "Mutable values like 'ref1.current' aren't valid dependencies " +
3679
+ "because mutating them doesn't re-render the component.",
3680
+ suggestions: [
3681
+ {
3682
+ desc:
3683
+ 'Update the dependencies array to be: [props.someOtherRefs, props.color]',
3684
+ output: normalizeIndent`
3685
+ function MyComponent(props) {
3686
+ const ref1 = useRef();
3687
+ const ref2 = useRef();
3688
+ useEffect(() => {
3689
+ ref1?.current?.focus();
3690
+ console.log(ref2?.current?.textContent);
3691
+ alert(props.someOtherRefs.current.innerHTML);
3692
+ fetch(props.color);
3693
+ }, [props.someOtherRefs, props.color]);
3694
+ }
3695
+ `,
3696
+ },
3697
+ ],
3698
+ },
3699
+ ],
3700
+ },
3701
{
3702
code: normalizeIndent`
3703
function MyComponent() {
@@ -3683,6 +3902,42 @@ const tests = {
3902
},
3903
],
3904
},
3905
+ {
3906
+ code: normalizeIndent`
3907
+ function MyComponent(props) {
3908
+ useEffect(() => {
3909
+ if (props?.onChange) {
3910
+ props?.onChange();
3911
+ }
3912
+ }, []);
3913
+ }
3914
+ `,
3915
+ errors: [
3916
+ {
3917
+ message:
3918
+ "React Hook useEffect has a missing dependency: 'props'. " +
3919
+ 'Either include it or remove the dependency array. ' +
3920
+ `However, 'props' will change when *any* prop changes, so the ` +
3921
+ `preferred fix is to destructure the 'props' object outside ` +
3922
+ `of the useEffect call and refer to those specific ` +
3923
+ `props inside useEffect.`,
3924
+ suggestions: [
3925
+ {
3926
+ desc: 'Update the dependencies array to be: [props]',
3927
+ output: normalizeIndent`
3928
+ function MyComponent(props) {
3929
+ useEffect(() => {
3930
+ if (props?.onChange) {
3931
+ props?.onChange();
3932
+ }
3933
+ }, [props]);
3934
+ }
3935
+ `,
3936
+ },
3937
+ ],
3938
+ },
3939
+ ],
3940
+ },
3941
{
3942
code: normalizeIndent`
3943
function MyComponent(props) {
@@ -4074,6 +4329,29 @@ const tests = {
4329
},
4330
],
4331
},
4332
+ {
4333
+ code: normalizeIndent`
4334
+ function MyComponent() {
4335
+ const myRef = useRef();
4336
+ useEffect(() => {
4337
+ const handleMove = () => {};
4338
+ myRef?.current?.addEventListener('mousemove', handleMove);
4339
+ return () => myRef?.current?.removeEventListener('mousemove', handleMove);
4340
+ }, []);
4341
+ return <div ref={myRef} />;
4342
+ }
4343
+ `,
4344
+ errors: [
4345
+ {
4346
+ message:
4347
+ `The ref value 'myRef.current' will likely have changed by the time ` +
4348
+ `this effect cleanup function runs. If this ref points to a node ` +
4349
+ `rendered by React, copy 'myRef.current' to a variable inside the effect, ` +
4350
+ `and use that variable in the cleanup function.`,
4351
+ suggestions: undefined,
4352
+ },
4353
+ ],
4354
+ },
4355
{
4356
code: normalizeIndent`
4357
function MyComponent() {
@@ -4448,6 +4726,51 @@ const tests = {
4726
},
4727
],
4728
},
4729
+ {
4730
+ code: normalizeIndent`
4731
+ import MutableStore from 'store';
4732
+ let z = {};
4733
+
4734
+ function MyComponent(props) {
4735
+ let x = props.foo;
4736
+ {
4737
+ let y = props.bar;
4738
+ const fn = useCallback(() => {
4739
+ // nothing
4740
+ }, [MutableStore?.hello?.world, props.foo, x, y, z, global?.stuff]);
4741
+ }
4742
+ }
4743
+ `,
4744
+ errors: [
4745
+ {
4746
+ message:
4747
+ 'React Hook useCallback has unnecessary dependencies: ' +
4748
+ "'MutableStore.hello.world', 'global.stuff', 'props.foo', 'x', 'y', and 'z'. " +
4749
+ 'Either exclude them or remove the dependency array. ' +
4750
+ "Outer scope values like 'MutableStore.hello.world' aren't valid dependencies " +
4751
+ "because mutating them doesn't re-render the component.",
4752
+ suggestions: [
4753
+ {
4754
+ desc: 'Update the dependencies array to be: []',
4755
+ output: normalizeIndent`
4756
+ import MutableStore from 'store';
4757
+ let z = {};
4758
+
4759
+ function MyComponent(props) {
4760
+ let x = props.foo;
4761
+ {
4762
+ let y = props.bar;
4763
+ const fn = useCallback(() => {
4764
+ // nothing
4765
+ }, []);
4766
+ }
4767
+ }
4768
+ `,
4769
+ },
4770
+ ],
4771
+ },
4772
+ ],
4773
+ },
4774
{
4775
// Every almost-static function is tainted by a dynamic value.
4776
code: normalizeIndent`
@@ -6008,6 +6331,40 @@ const tests = {
6331
},
6332
],
6333
},
6334
+ {
6335
+ code: normalizeIndent`
6336
+ function Podcasts({ fetchPodcasts, id }) {
6337
+ let [podcasts, setPodcasts] = useState(null);
6338
+ useEffect(() => {
6339
+ console.log(fetchPodcasts);
6340
+ fetchPodcasts?.(id).then(setPodcasts);
6341
+ }, [id]);
6342
+ }
6343
+ `,
6344
+ errors: [
6345
+ {
6346
+ message:
6347
+ `React Hook useEffect has a missing dependency: 'fetchPodcasts'. ` +
6348
+ `Either include it or remove the dependency array. ` +
6349
+ `If 'fetchPodcasts' changes too often, ` +
6350
+ `find the parent component that defines it and wrap that definition in useCallback.`,
6351
+ suggestions: [
6352
+ {
6353
+ desc: 'Update the dependencies array to be: [fetchPodcasts, id]',
6354
+ output: normalizeIndent`
6355
+ function Podcasts({ fetchPodcasts, id }) {
6356
+ let [podcasts, setPodcasts] = useState(null);
6357
+ useEffect(() => {
6358
+ console.log(fetchPodcasts);
6359
+ fetchPodcasts?.(id).then(setPodcasts);
6360
+ }, [fetchPodcasts, id]);
6361
+ }
6362
+ `,
6363
+ },
6364
+ ],
6365
+ },
6366
+ ],
6367
+ },
6368
{
6369
// The mistake here is that it was moved inside the effect
6370
// so it can't be referenced in the deps array.
packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js
+32
-36
@@ -525,7 +525,8 @@ export default {
525
isEffect &&
526
// ... and this look like accessing .current...
527
dependencyNode.type === 'Identifier' &&
528
- dependencyNode.parent.type === 'MemberExpression' &&
528
+ (dependencyNode.parent.type === 'MemberExpression' ||
529
+ dependencyNode.parent.type === 'OptionalMemberExpression') &&
530
!dependencyNode.parent.computed &&
531
dependencyNode.parent.property.type === 'Identifier' &&
532
dependencyNode.parent.property.name === 'current' &&
@@ -587,6 +588,7 @@ export default {
588
if (
589
parent != null &&
590
// ref.current
591
+ // Note: no need to handle OptionalMemberExpression because it can't be LHS.
592
parent.type === 'MemberExpression' &&
593
!parent.computed &&
594
parent.property.type === 'Identifier' &&
@@ -790,7 +792,10 @@ export default {
792
}
793
794
let maybeID = declaredDependencyNode;
793
- while (maybeID.type === 'MemberExpression') {
795
+ while (
796
+ maybeID.type === 'MemberExpression' ||
797
+ maybeID.type === 'OptionalMemberExpression'
798
+ ) {
799
maybeID = maybeID.object;
800
}
801
const isDeclaredInComponent = !componentScope.through.some(
@@ -991,7 +996,10 @@ export default {
996
isPropsOnlyUsedInMembers = false;
997
break;
998
}
994
- if (parent.type !== 'MemberExpression') {
999
+ if (
1000
+ parent.type !== 'MemberExpression' &&
1001
+ parent.type !== 'OptionalMemberExpression'
1002
+ ) {
1003
isPropsOnlyUsedInMembers = false;
1004
break;
1005
}
@@ -1016,11 +1024,11 @@ export default {
1024
// Is this a variable from top scope?
1025
const topScopeRef = componentScope.set.get(missingDep);
1026
const usedDep = dependencies.get(missingDep);
1019
- if (usedDep && usedDep.references[0].resolved !== topScopeRef) {
1027
+ if (usedDep.references[0].resolved !== topScopeRef) {
1028
return;
1029
}
1030
// Is this a destructured prop?
1023
- const def = topScopeRef && topScopeRef.defs[0];
1031
+ const def = topScopeRef.defs[0];
1032
if (def == null || def.name == null || def.type !== 'Parameter') {
1033
return;
1034
}
@@ -1032,7 +1040,8 @@ export default {
1040
if (
1041
id != null &&
1042
id.parent != null &&
1035
- id.parent.type === 'CallExpression' &&
1043
+ (id.parent.type === 'CallExpression' ||
1044
+ id.parent.type === 'OptionalCallExpression') &&
1045
id.parent.callee === id
1046
) {
1047
isFunctionCall = true;
@@ -1062,7 +1071,7 @@ export default {
1071
return;
1072
}
1073
const usedDep = dependencies.get(missingDep);
1065
- const references = usedDep ? usedDep.references : [];
1074
+ const references = usedDep.references;
1075
let id;
1076
let maybeCall;
1077
for (let i = 0; i < references.length; i++) {
@@ -1238,7 +1247,7 @@ function collectRecommendations({
1247
const keys = path.split('.');
1248
let node = rootNode;
1249
for (const key of keys) {
1241
- let child = getChildByKey(node, key);
1250
+ let child = node.children.get(key);
1251
if (!child) {
1252
child = createDepTree();
1253
node.children.set(key, child);
@@ -1251,7 +1260,7 @@ function collectRecommendations({
1260
const keys = path.split('.');
1261
let node = rootNode;
1262
for (const key of keys) {
1254
- const child = getChildByKey(node, key);
1263
+ const child = node.children.get(key);
1264
if (!child) {
1265
return;
1266
}
@@ -1260,21 +1269,6 @@ function collectRecommendations({
1269
}
1270
}
1271
1263
- /**
1264
- * Match key with optional chaining
1265
- * key -> key
1266
- * key? -> key
1267
- * key -> key?
1268
- * Otherwise undefined.
1269
- */
1270
- function getChildByKey(node, key) {
1271
- return (
1272
- node.children.get(key) ||
1273
- node.children.get(key.split('?')[0]) ||
1274
- node.children.get(key + '?')
1275
- );
1276
- }
1277
-
1272
// Now we can learn which dependencies are missing or necessary.
1273
const missingDependencies = new Set();
1274
const satisfyingDependencies = new Set();
@@ -1287,13 +1281,10 @@ function collectRecommendations({
1281
function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) {
1282
node.children.forEach((child, key) => {
1283
const path = keyToPath(key);
1290
- // For analyzing dependencies, we want the "normalized" path, without any optional chaining ("?.") operator
1291
- // foo?.bar -> foo.bar
1292
- const normalizedPath = path.replace(/\?$/, '');
1284
if (child.isSatisfiedRecursively) {
1285
if (child.hasRequiredNodesBelow) {
1286
// Remember this dep actually satisfied something.
1296
- satisfyingPaths.add(normalizedPath);
1287
+ satisfyingPaths.add(path);
1288
}
1289
// It doesn't matter if there's something deeper.
1290
// It would be transitively satisfied since we assume immutability.
@@ -1302,7 +1293,7 @@ function collectRecommendations({
1293
}
1294
if (child.isRequired) {
1295
// Remember that no declared deps satisfied this node.
1305
- missingPaths.add(normalizedPath);
1296
+ missingPaths.add(path);
1297
// If we got here, nothing in its subtree was satisfied.
1298
// No need to search further.
1299
return;
@@ -1454,12 +1445,14 @@ function getDependency(node) {
1445
!node.parent.computed &&
1446
!(
1447
node.parent.parent != null &&
1457
- node.parent.parent.type === 'CallExpression' &&
1448
+ (node.parent.parent.type === 'CallExpression' ||
1449
+ node.parent.parent.type === 'OptionalCallExpression') &&
1450
node.parent.parent.callee === node.parent
1451
)
1452
) {
1453
return getDependency(node.parent);
1454
} else if (
1455
+ // Note: we don't check OptionalMemberExpression because it can't be LHS.
1456
node.type === 'MemberExpression' &&
1457
node.parent &&
1458
node.parent.type === 'AssignmentExpression'
@@ -1475,20 +1468,23 @@ function getDependency(node) {
1468
* (foo) -> 'foo'
1469
* foo.(bar) -> 'foo.bar'
1470
* foo.bar.(baz) -> 'foo.bar.baz'
1478
- * foo?.(bar) -> 'foo?.bar'
1471
* Otherwise throw.
1472
*/
1473
function toPropertyAccessString(node) {
1474
if (node.type === 'Identifier') {
1475
return node.name;
1484
- } else if (node.type === 'MemberExpression' && !node.computed) {
1476
+ } else if (
1477
+ (node.type === 'MemberExpression' ||
1478
+ node.type === 'OptionalMemberExpression') &&
1479
+ !node.computed
1480
+ ) {
1481
const object = toPropertyAccessString(node.object);
1482
const property = toPropertyAccessString(node.property);
1483
+ // Note: we intentionally omit ? even for optional chaining
1484
+ // because the returned string represents a path to the node, and
1485
+ // is used as a key in Maps where being optional doesn't matter.
1486
+ // The result string is not being interpolated in the code output.
1487
return `${object}.${property}`;
1488
- } else if (node.type === 'OptionalMemberExpression' && !node.computed) {
1489
- const object = toPropertyAccessString(node.object);
1490
- const property = toPropertyAccessString(node.property);
1491
- return `${object}?.${property}`;
1488
} else {
1489
throw new Error(`Unsupported node type: ${node.type}`);
1490
}