@samitouri / QOS-React / commits / 9b7e1d1389

[ESLint] Suggest moving inside a Hook or useCallback when bare function is a dependency (#15026)

* Warn about bare function deps and suggest moving or useCallback * Clearer wording

Dan Abramov committed Mar 6, 2019 at 23:50 UTC 9b7e1d1389e080d19e71680bbbe979ec58fa7389
2 files changed +579 -33
packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js
+448 -31
@@ -313,7 +313,7 @@ const tests = {
313 },
314 {
315 code: `
316 - function MyComponent({ maybeRef2 }) {
316 + function MyComponent({ maybeRef2, foo }) {
317 const definitelyRef1 = useRef();
318 const definitelyRef2 = useRef();
319 const maybeRef1 = useSomeOtherRefyThing();
@@ -323,8 +323,8 @@ const tests = {
323 const [state4, dispatch2] = React.useReducer();
324 const [state5, maybeSetState] = useFunnyState();
325 const [state6, maybeDispatch] = useFunnyReducer();
326 - function mySetState() {}
327 - function myDispatch() {}
326 + const mySetState = useCallback(() => {}, []);
327 + let myDispatch = useCallback(() => {}, []);
328
329 useEffect(() => {
330 // Known to be static
@@ -380,8 +380,8 @@ const tests = {
380 const [state5, maybeSetState] = useFunnyState();
381 const [state6, maybeDispatch] = useFunnyReducer();
382
383 - function mySetState() {}
384 - function myDispatch() {}
383 + const mySetState = useCallback(() => {}, []);
384 + let myDispatch = useCallback(() => {}, []);
385
386 useEffect(() => {
387 // Known to be static
@@ -662,30 +662,6 @@ const tests = {
662 }
663 `,
664 },
665 - {
666 - code: `
667 - function MyComponent(props) {
668 - function handleNext1() {
669 - console.log('hello');
670 - }
671 - const handleNext2 = () => {
672 - console.log('hello');
673 - };
674 - let handleNext3 = function() {
675 - console.log('hello');
676 - };
677 - useEffect(() => {
678 - return Store.subscribe(handleNext1);
679 - }, [handleNext1]);
680 - useLayoutEffect(() => {
681 - return Store.subscribe(handleNext2);
682 - }, [handleNext2]);
683 - useMemo(() => {
684 - return Store.subscribe(handleNext3);
685 - }, [handleNext3]);
686 - }
687 - `,
688 - },
665 {
666 // Declaring handleNext is optional because
667 // it doesn't use anything in the function scope.
@@ -950,8 +926,12 @@ const tests = {
926 }
927 `,
928 errors: [
953 - "React Hook useMemo doesn't serve any purpose without a dependency array as a second argument.",
954 - "React Hook useCallback doesn't serve any purpose without a dependency array as a second argument.",
929 + "React Hook useMemo doesn't serve any purpose without a dependency array. " +
930 + 'To enable this optimization, pass an array of values used by the inner ' +
931 + 'function as the second argument to useMemo.',
932 + "React Hook useCallback doesn't serve any purpose without a dependency array. " +
933 + 'To enable this optimization, pass an array of values used by the inner ' +
934 + 'function as the second argument to useCallback.',
935 ],
936 },
937 {
@@ -3313,6 +3293,443 @@ const tests = {
3293 'Either include it or remove the dependency array.',
3294 ],
3295 },
3296 + {
3297 + // Even if the function only references static values,
3298 + // once you specify it in deps, it will invalidate them.
3299 + code: `
3300 + function MyComponent(props) {
3301 + let [, setState] = useState();
3302 +
3303 + function handleNext(value) {
3304 + setState(value);
3305 + }
3306 +
3307 + useEffect(() => {
3308 + return Store.subscribe(handleNext);
3309 + }, [handleNext]);
3310 + }
3311 + `,
3312 + // Not gonna autofix a function definition
3313 + // because it's not always safe due to hoisting.
3314 + output: `
3315 + function MyComponent(props) {
3316 + let [, setState] = useState();
3317 +
3318 + function handleNext(value) {
3319 + setState(value);
3320 + }
3321 +
3322 + useEffect(() => {
3323 + return Store.subscribe(handleNext);
3324 + }, [handleNext]);
3325 + }
3326 + `,
3327 + errors: [
3328 + `The 'handleNext' function makes the dependencies of ` +
3329 + `useEffect Hook (at line 11) change on every render. ` +
3330 + `To fix this, move the 'handleNext' function ` +
3331 + `inside the useEffect callback. Alternatively, ` +
3332 + `wrap the 'handleNext' definition into its own useCallback() Hook.`,
3333 + ],
3334 + },
3335 + {
3336 + // Even if the function only references static values,
3337 + // once you specify it in deps, it will invalidate them.
3338 + code: `
3339 + function MyComponent(props) {
3340 + let [, setState] = useState();
3341 +
3342 + const handleNext = (value) => {
3343 + setState(value);
3344 + };
3345 +
3346 + useEffect(() => {
3347 + return Store.subscribe(handleNext);
3348 + }, [handleNext]);
3349 + }
3350 + `,
3351 + // We don't autofix moving (too invasive). But that's the suggested fix
3352 + // when only effect uses this function. Otherwise, we'd useCallback.
3353 + output: `
3354 + function MyComponent(props) {
3355 + let [, setState] = useState();
3356 +
3357 + const handleNext = (value) => {
3358 + setState(value);
3359 + };
3360 +
3361 + useEffect(() => {
3362 + return Store.subscribe(handleNext);
3363 + }, [handleNext]);
3364 + }
3365 + `,
3366 + errors: [
3367 + `The 'handleNext' function makes the dependencies of ` +
3368 + `useEffect Hook (at line 11) change on every render. ` +
3369 + `To fix this, move the 'handleNext' function ` +
3370 + `inside the useEffect callback. Alternatively, ` +
3371 + `wrap the 'handleNext' definition into its own useCallback() Hook.`,
3372 + ],
3373 + },
3374 + {
3375 + // Even if the function only references static values,
3376 + // once you specify it in deps, it will invalidate them.
3377 + // However, we can't suggest moving handleNext into the
3378 + // effect because it is *also* used outside of it.
3379 + // So our suggestion is useCallback().
3380 + code: `
3381 + function MyComponent(props) {
3382 + let [, setState] = useState();
3383 +
3384 + const handleNext = (value) => {
3385 + setState(value);
3386 + };
3387 +
3388 + useEffect(() => {
3389 + return Store.subscribe(handleNext);
3390 + }, [handleNext]);
3391 +
3392 + return <div onClick={handleNext} />;
3393 + }
3394 + `,
3395 + // We autofix this one with useCallback since it's
3396 + // the easy fix and you can't just move it into effect.
3397 + output: `
3398 + function MyComponent(props) {
3399 + let [, setState] = useState();
3400 +
3401 + const handleNext = useCallback((value) => {
3402 + setState(value);
3403 + });
3404 +
3405 + useEffect(() => {
3406 + return Store.subscribe(handleNext);
3407 + }, [handleNext]);
3408 +
3409 + return <div onClick={handleNext} />;
3410 + }
3411 + `,
3412 + errors: [
3413 + `The 'handleNext' function makes the dependencies of ` +
3414 + `useEffect Hook (at line 11) change on every render. ` +
3415 + `To fix this, wrap the 'handleNext' definition into its own useCallback() Hook.`,
3416 + ],
3417 + },
3418 + {
3419 + code: `
3420 + function MyComponent(props) {
3421 + function handleNext1() {
3422 + console.log('hello');
3423 + }
3424 + const handleNext2 = () => {
3425 + console.log('hello');
3426 + };
3427 + let handleNext3 = function() {
3428 + console.log('hello');
3429 + };
3430 + useEffect(() => {
3431 + return Store.subscribe(handleNext1);
3432 + }, [handleNext1]);
3433 + useLayoutEffect(() => {
3434 + return Store.subscribe(handleNext2);
3435 + }, [handleNext2]);
3436 + useMemo(() => {
3437 + return Store.subscribe(handleNext3);
3438 + }, [handleNext3]);
3439 + }
3440 + `,
3441 + // Autofix doesn't wrap into useCallback here
3442 + // because they are only referenced by effect itself.
3443 + output: `
3444 + function MyComponent(props) {
3445 + function handleNext1() {
3446 + console.log('hello');
3447 + }
3448 + const handleNext2 = () => {
3449 + console.log('hello');
3450 + };
3451 + let handleNext3 = function() {
3452 + console.log('hello');
3453 + };
3454 + useEffect(() => {
3455 + return Store.subscribe(handleNext1);
3456 + }, [handleNext1]);
3457 + useLayoutEffect(() => {
3458 + return Store.subscribe(handleNext2);
3459 + }, [handleNext2]);
3460 + useMemo(() => {
3461 + return Store.subscribe(handleNext3);
3462 + }, [handleNext3]);
3463 + }
3464 + `,
3465 + errors: [
3466 + "The 'handleNext1' function makes the dependencies of useEffect Hook " +
3467 + "(at line 14) change on every render. To fix this, move the 'handleNext1' " +
3468 + 'function inside the useEffect callback. Alternatively, wrap the ' +
3469 + "'handleNext1' definition into its own useCallback() Hook.",
3470 + "The 'handleNext2' function makes the dependencies of useLayoutEffect Hook " +
3471 + "(at line 17) change on every render. To fix this, move the 'handleNext2' " +
3472 + 'function inside the useLayoutEffect callback. Alternatively, wrap the ' +
3473 + "'handleNext2' definition into its own useCallback() Hook.",
3474 + "The 'handleNext3' function makes the dependencies of useMemo Hook " +
3475 + "(at line 20) change on every render. To fix this, move the 'handleNext3' " +
3476 + 'function inside the useMemo callback. Alternatively, wrap the ' +
3477 + "'handleNext3' definition into its own useCallback() Hook.",
3478 + ],
3479 + },
3480 + {
3481 + code: `
3482 + function MyComponent(props) {
3483 + function handleNext1() {
3484 + console.log('hello');
3485 + }
3486 + const handleNext2 = () => {
3487 + console.log('hello');
3488 + };
3489 + let handleNext3 = function() {
3490 + console.log('hello');
3491 + };
3492 + useEffect(() => {
3493 + handleNext1();
3494 + return Store.subscribe(() => handleNext1());
3495 + }, [handleNext1]);
3496 + useLayoutEffect(() => {
3497 + handleNext2();
3498 + return Store.subscribe(() => handleNext2());
3499 + }, [handleNext2]);
3500 + useMemo(() => {
3501 + handleNext3();
3502 + return Store.subscribe(() => handleNext3());
3503 + }, [handleNext3]);
3504 + }
3505 + `,
3506 + // Autofix doesn't wrap into useCallback here
3507 + // because they are only referenced by effect itself.
3508 + output: `
3509 + function MyComponent(props) {
3510 + function handleNext1() {
3511 + console.log('hello');
3512 + }
3513 + const handleNext2 = () => {
3514 + console.log('hello');
3515 + };
3516 + let handleNext3 = function() {
3517 + console.log('hello');
3518 + };
3519 + useEffect(() => {
3520 + handleNext1();
3521 + return Store.subscribe(() => handleNext1());
3522 + }, [handleNext1]);
3523 + useLayoutEffect(() => {
3524 + handleNext2();
3525 + return Store.subscribe(() => handleNext2());
3526 + }, [handleNext2]);
3527 + useMemo(() => {
3528 + handleNext3();
3529 + return Store.subscribe(() => handleNext3());
3530 + }, [handleNext3]);
3531 + }
3532 + `,
3533 + errors: [
3534 + "The 'handleNext1' function makes the dependencies of useEffect Hook " +
3535 + "(at line 15) change on every render. To fix this, move the 'handleNext1' " +
3536 + 'function inside the useEffect callback. Alternatively, wrap the ' +
3537 + "'handleNext1' definition into its own useCallback() Hook.",
3538 + "The 'handleNext2' function makes the dependencies of useLayoutEffect Hook " +
3539 + "(at line 19) change on every render. To fix this, move the 'handleNext2' " +
3540 + 'function inside the useLayoutEffect callback. Alternatively, wrap the ' +
3541 + "'handleNext2' definition into its own useCallback() Hook.",
3542 + "The 'handleNext3' function makes the dependencies of useMemo Hook " +
3543 + "(at line 23) change on every render. To fix this, move the 'handleNext3' " +
3544 + 'function inside the useMemo callback. Alternatively, wrap the ' +
3545 + "'handleNext3' definition into its own useCallback() Hook.",
3546 + ],
3547 + },
3548 + {
3549 + code: `
3550 + function MyComponent(props) {
3551 + function handleNext1() {
3552 + console.log('hello');
3553 + }
3554 + const handleNext2 = () => {
3555 + console.log('hello');
3556 + };
3557 + let handleNext3 = function() {
3558 + console.log('hello');
3559 + };
3560 + useEffect(() => {
3561 + handleNext1();
3562 + return Store.subscribe(() => handleNext1());
3563 + }, [handleNext1]);
3564 + useLayoutEffect(() => {
3565 + handleNext2();
3566 + return Store.subscribe(() => handleNext2());
3567 + }, [handleNext2]);
3568 + useMemo(() => {
3569 + handleNext3();
3570 + return Store.subscribe(() => handleNext3());
3571 + }, [handleNext3]);
3572 + return (
3573 + <div
3574 + onClick={() => {
3575 + handleNext1();
3576 + setTimeout(handleNext2);
3577 + setTimeout(() => {
3578 + handleNext3();
3579 + });
3580 + }}
3581 + />
3582 + );
3583 + }
3584 + `,
3585 + // Autofix wraps into useCallback where possible (variables only)
3586 + // because they are only referenced outside the effect.
3587 + output: `
3588 + function MyComponent(props) {
3589 + function handleNext1() {
3590 + console.log('hello');
3591 + }
3592 + const handleNext2 = useCallback(() => {
3593 + console.log('hello');
3594 + });
3595 + let handleNext3 = useCallback(function() {
3596 + console.log('hello');
3597 + });
3598 + useEffect(() => {
3599 + handleNext1();
3600 + return Store.subscribe(() => handleNext1());
3601 + }, [handleNext1]);
3602 + useLayoutEffect(() => {
3603 + handleNext2();
3604 + return Store.subscribe(() => handleNext2());
3605 + }, [handleNext2]);
3606 + useMemo(() => {
3607 + handleNext3();
3608 + return Store.subscribe(() => handleNext3());
3609 + }, [handleNext3]);
3610 + return (
3611 + <div
3612 + onClick={() => {
3613 + handleNext1();
3614 + setTimeout(handleNext2);
3615 + setTimeout(() => {
3616 + handleNext3();
3617 + });
3618 + }}
3619 + />
3620 + );
3621 + }
3622 + `,
3623 + errors: [
3624 + "The 'handleNext1' function makes the dependencies of useEffect Hook " +
3625 + '(at line 15) change on every render. To fix this, wrap the ' +
3626 + "'handleNext1' definition into its own useCallback() Hook.",
3627 + "The 'handleNext2' function makes the dependencies of useLayoutEffect Hook " +
3628 + '(at line 19) change on every render. To fix this, wrap the ' +
3629 + "'handleNext2' definition into its own useCallback() Hook.",
3630 + "The 'handleNext3' function makes the dependencies of useMemo Hook " +
3631 + '(at line 23) change on every render. To fix this, wrap the ' +
3632 + "'handleNext3' definition into its own useCallback() Hook.",
3633 + ],
3634 + },
3635 + {
3636 + code: `
3637 + function MyComponent(props) {
3638 + const handleNext1 = () => {
3639 + console.log('hello');
3640 + };
3641 + function handleNext2() {
3642 + console.log('hello');
3643 + }
3644 + useEffect(() => {
3645 + return Store.subscribe(handleNext1);
3646 + return Store.subscribe(handleNext2);
3647 + }, [handleNext1, handleNext2]);
3648 + useEffect(() => {
3649 + return Store.subscribe(handleNext1);
3650 + return Store.subscribe(handleNext2);
3651 + }, [handleNext1, handleNext2]);
3652 + }
3653 + `,
3654 + // Normally we'd suggest moving handleNext inside an
3655 + // effect. But it's used by more than one. So we
3656 + // suggest useCallback() and use it for the autofix
3657 + // where possible (variable but not declaration).
3658 + output: `
3659 + function MyComponent(props) {
3660 + const handleNext1 = useCallback(() => {
3661 + console.log('hello');
3662 + });
3663 + function handleNext2() {
3664 + console.log('hello');
3665 + }
3666 + useEffect(() => {
3667 + return Store.subscribe(handleNext1);
3668 + return Store.subscribe(handleNext2);
3669 + }, [handleNext1, handleNext2]);
3670 + useEffect(() => {
3671 + return Store.subscribe(handleNext1);
3672 + return Store.subscribe(handleNext2);
3673 + }, [handleNext1, handleNext2]);
3674 + }
3675 + `,
3676 + // TODO: we could coalesce messages for the same function if it affects multiple Hooks.
3677 + errors: [
3678 + "The 'handleNext1' function makes the dependencies of useEffect Hook " +
3679 + '(at line 12) change on every render. To fix this, wrap the ' +
3680 + "'handleNext1' definition into its own useCallback() Hook.",
3681 + "The 'handleNext1' function makes the dependencies of useEffect Hook " +
3682 + '(at line 16) change on every render. To fix this, wrap the ' +
3683 + "'handleNext1' definition into its own useCallback() Hook.",
3684 + "The 'handleNext2' function makes the dependencies of useEffect Hook " +
3685 + '(at line 12) change on every render. To fix this, wrap the ' +
3686 + "'handleNext2' definition into its own useCallback() Hook.",
3687 + "The 'handleNext2' function makes the dependencies of useEffect Hook " +
3688 + '(at line 16) change on every render. To fix this, wrap the ' +
3689 + "'handleNext2' definition into its own useCallback() Hook.",
3690 + ],
3691 + },
3692 + {
3693 + code: `
3694 + function MyComponent(props) {
3695 + let [, setState] = useState();
3696 + let taint = props.foo;
3697 +
3698 + function handleNext(value) {
3699 + let value2 = value * taint;
3700 + setState(value2);
3701 + console.log('hello');
3702 + }
3703 +
3704 + useEffect(() => {
3705 + return Store.subscribe(handleNext);
3706 + }, [handleNext]);
3707 + }
3708 + `,
3709 + output: `
3710 + function MyComponent(props) {
3711 + let [, setState] = useState();
3712 + let taint = props.foo;
3713 +
3714 + function handleNext(value) {
3715 + let value2 = value * taint;
3716 + setState(value2);
3717 + console.log('hello');
3718 + }
3719 +
3720 + useEffect(() => {
3721 + return Store.subscribe(handleNext);
3722 + }, [handleNext]);
3723 + }
3724 + `,
3725 + errors: [
3726 + `The 'handleNext' function makes the dependencies of ` +
3727 + `useEffect Hook (at line 14) change on every render. ` +
3728 + `To fix this, move the 'handleNext' function inside ` +
3729 + `the useEffect callback. Alternatively, wrap the ` +
3730 + `'handleNext' definition into its own useCallback() Hook.`,
3731 + ],
3732 + },
3733 {
3734 code: `
3735 function Counter() {
packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js
+131 -2
@@ -93,10 +93,12 @@ export default {
93 reactiveHookName === 'useCallback'
94 ) {
95 context.report({
96 - node: node,
96 + node: node.parent.callee,
97 message:
98 `React Hook ${reactiveHookName} doesn't serve any purpose ` +
99 - `without a dependency array as a second argument.`,
99 + `without a dependency array. To enable ` +
100 + `this optimization, pass an array of values used by the ` +
101 + `inner function as the second argument to ${reactiveHookName}.`,
102 });
103 }
104 return;
@@ -550,7 +552,57 @@ export default {
552 duplicateDependencies.size +
553 missingDependencies.size +
554 unnecessaryDependencies.size;
555 +
556 if (problemCount === 0) {
557 + // If nothing else to report, check if some callbacks
558 + // are bare and would invalidate on every render.
559 + const bareFunctions = scanForDeclaredBareFunctions({
560 + declaredDependencies,
561 + declaredDependenciesNode,
562 + componentScope,
563 + scope,
564 + });
565 + bareFunctions.forEach(({fn, suggestUseCallback}) => {
566 + let message =
567 + `The '${fn.name.name}' function makes the dependencies of ` +
568 + `${reactiveHookName} Hook (at line ${
569 + declaredDependenciesNode.loc.start.line
570 + }) ` +
571 + `change on every render.`;
572 + if (suggestUseCallback) {
573 + message +=
574 + ` To fix this, ` +
575 + `wrap the '${
576 + fn.name.name
577 + }' definition into its own useCallback() Hook.`;
578 + } else {
579 + message +=
580 + ` To fix this, move the '${fn.name.name}' function ` +
581 + `inside the ${reactiveHookName} callback. Alternatively, ` +
582 + `wrap the '${
583 + fn.name.name
584 + }' definition into its own useCallback() Hook.`;
585 + }
586 + context.report({
587 + node: fn.node,
588 + message,
589 + fix(fixer) {
590 + // Only handle the simple case: arrow functions.
591 + // Wrapping function declarations can mess up hoisting.
592 + if (suggestUseCallback && fn.type === 'Variable') {
593 + return [
594 + // TODO: also add an import?
595 + fixer.insertTextBefore(fn.node.init, 'useCallback('),
596 + // TODO: ideally we'd gather deps here but it would
597 + // require restructuring the rule code. For now,
598 + // this is fine. Note we're intentionally not adding
599 + // [] because that changes semantics.
600 + fixer.insertTextAfter(fn.node.init, ')'),
601 + ];
602 + }
603 + },
604 + });
605 + });
606 return;
607 }
608
@@ -858,6 +910,83 @@ function collectRecommendations({
910 };
911 }
912
913 +// Finds functions declared as dependencies
914 +// that would invalidate on every render.
915 +function scanForDeclaredBareFunctions({
916 + declaredDependencies,
917 + declaredDependenciesNode,
918 + componentScope,
919 + scope,
920 +}) {
921 + const bareFunctions = declaredDependencies
922 + .map(({key}) => {
923 + const fnRef = componentScope.set.get(key);
924 + if (fnRef == null) {
925 + return null;
926 + }
927 + let fnNode = fnRef.defs[0];
928 + if (fnNode == null) {
929 + return null;
930 + }
931 + // const handleChange = function () {}
932 + // const handleChange = () => {}
933 + if (
934 + fnNode.type === 'Variable' &&
935 + fnNode.node.type === 'VariableDeclarator' &&
936 + fnNode.node.init != null &&
937 + (fnNode.node.init.type === 'ArrowFunctionExpression' ||
938 + fnNode.node.init.type === 'FunctionExpression')
939 + ) {
940 + return fnRef;
941 + }
942 + // function handleChange() {}
943 + if (
944 + fnNode.type === 'FunctionName' &&
945 + fnNode.node.type === 'FunctionDeclaration'
946 + ) {
947 + return fnRef;
948 + }
949 + return null;
950 + })
951 + .filter(Boolean);
952 +
953 + function isUsedOutsideOfHook(fnRef) {
954 + let foundWriteExpr = false;
955 + for (let i = 0; i < fnRef.references.length; i++) {
956 + const reference = fnRef.references[i];
957 + if (reference.writeExpr) {
958 + if (foundWriteExpr) {
959 + // Two writes to the same function.
960 + return true;
961 + } else {
962 + // Ignore first write as it's not usage.
963 + foundWriteExpr = true;
964 + continue;
965 + }
966 + }
967 + let currentScope = reference.from;
968 + while (currentScope !== scope && currentScope != null) {
969 + currentScope = currentScope.upper;
970 + }
971 + if (currentScope !== scope) {
972 + // This reference is outside the Hook callback.
973 + // It can only be legit if it's the deps array.
974 + if (isAncestorNodeOf(declaredDependenciesNode, reference.identifier)) {
975 + continue;
976 + } else {
977 + return true;
978 + }
979 + }
980 + }
981 + return false;
982 + }
983 +
984 + return bareFunctions.map(fnRef => ({
985 + fn: fnRef.defs[0],
986 + suggestUseCallback: isUsedOutsideOfHook(fnRef),
987 + }));
988 +}
989 +
990 /**
991 * Assuming () means the passed/returned node:
992 * (props) => (props)