@samitouri / QOS-React-2 / commits / 7ca1d861e8

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