Repository: facebook/react
Pull request: [eslint] Fix useEffectEvent checks in component syntax

We were not recording uEE calls in component/hook syntax. Easy fix.

Added tests matching function component syntax for component syntax + added one for hooks

Changed files:
filename: packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js
patch: @@ -585,6 +585,29 @@ const allTests = {
       code: normalizeIndent`
         // Valid: useEffectEvent can be called in custom effect hooks configured via ESLint settings
         function MyComponent({ theme }) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          useMyEffect(() => {
+            onClick();
+          });
+          useServerEffect(() => {
+            onClick();
+          });
+        }
+      `,
+      settings: {
+        'react-hooks': {
+          additionalEffectHooks: '(useMyEffect|useServerEffect)',
+        },
+      },
+    },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Valid: useEffectEvent can be called in custom effect hooks configured via ESLint settings
+        component MyComponent(theme: any) {
           const onClick = useEffectEvent(() => {
             showNotification(theme);
           });
@@ -618,6 +641,24 @@ const allTests = {
         }
       `,
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Valid because functions created with useEffectEvent can be called in a useEffect.
+        component MyComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          useEffect(() => {
+            onClick();
+          });
+          React.useEffect(() => {
+            onClick();
+          });
+        }
+      `,
+    },
     {
       code: normalizeIndent`
         // Valid because functions created with useEffectEvent can be passed by reference in useEffect
@@ -644,6 +685,34 @@ const allTests = {
         }
       `,
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Valid because functions created with useEffectEvent can be passed by reference in useEffect
+        // and useEffectEvent.
+        component MyComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          const onClick2 = useEffectEvent(() => {
+            debounce(onClick);
+            debounce(() => onClick());
+            debounce(() => { onClick() });
+            deboucne(() => debounce(onClick));
+          });
+          useEffect(() => {
+            let id = setInterval(() => onClick(), 100);
+            return () => clearInterval(onClick);
+          }, []);
+          React.useEffect(() => {
+            let id = setInterval(() => onClick(), 100);
+            return () => clearInterval(onClick);
+          }, []);
+          return null;
+        }
+      `,
+    },
     {
       code: normalizeIndent`
         function MyComponent({ theme }) {
@@ -656,6 +725,20 @@ const allTests = {
         }
       `,
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        component MyComponent(theme: any) {
+          useEffect(() => {
+            onClick();
+          });
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+        }
+      `,
+    },
     {
       code: normalizeIndent`
         function MyComponent({ theme }) {
@@ -673,6 +756,25 @@ const allTests = {
         }
       `,
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        component MyComponent(theme: any) {
+          // Can receive arguments
+          const onEvent = useEffectEvent((text) => {
+            console.log(text);
+          });
+
+          useEffect(() => {
+            onEvent('Hello world');
+          });
+          React.useEffect(() => {
+            onEvent('Hello world');
+          });
+        }
+      `,
+    },
     {
       code: normalizeIndent`
         // Valid because functions created with useEffectEvent can be called in useLayoutEffect.
@@ -689,6 +791,24 @@ const allTests = {
         }
       `,
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Valid because functions created with useEffectEvent can be called in useLayoutEffect.
+        component MyComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          useLayoutEffect(() => {
+            onClick();
+          });
+          React.useLayoutEffect(() => {
+            onClick();
+          });
+        }
+      `,
+    },
     {
       code: normalizeIndent`
         // Valid because functions created with useEffectEvent can be called in useInsertionEffect.
@@ -705,6 +825,24 @@ const allTests = {
         }
       `,
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Valid because functions created with useEffectEvent can be called in useInsertionEffect.
+        component MyComponent(theme) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          useInsertionEffect(() => {
+            onClick();
+          });
+          React.useInsertionEffect(() => {
+            onClick();
+          });
+        }
+      `,
+    },
     {
       code: normalizeIndent`
         // Valid because functions created with useEffectEvent can be passed by reference in useLayoutEffect 
@@ -739,6 +877,42 @@ const allTests = {
         }
       `,
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Valid because functions created with useEffectEvent can be passed by reference in useLayoutEffect.
+        // and useInsertionEffect.
+        component MyComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          const onClick2 = useEffectEvent(() => {
+            debounce(onClick);
+            debounce(() => onClick());
+            debounce(() => { onClick() });
+            deboucne(() => debounce(onClick));
+          });
+          useLayoutEffect(() => {
+            let id = setInterval(() => onClick(), 100);
+            return () => clearInterval(onClick);
+          }, []);
+          React.useLayoutEffect(() => {
+            let id = setInterval(() => onClick(), 100);
+            return () => clearInterval(onClick);
+          }, []);
+          useInsertionEffect(() => {
+            let id = setInterval(() => onClick(), 100);
+            return () => clearInterval(onClick);
+          }, []);
+          React.useInsertionEffect(() => {
+            let id = setInterval(() => onClick(), 100);
+            return () => clearInterval(onClick);
+          }, []);
+          return null;
+        }
+      `,
+    },
   ],
   invalid: [
     {
@@ -1525,6 +1699,22 @@ const allTests = {
       `,
       errors: [useEffectEventError('onClick', true)],
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+      // Component syntax version
+      // Invalid: useEffectEvent should not be callable in regular custom hooks without additional configuration
+      component MyComponent() {
+        const onClick = useEffectEvent(() => {
+          showNotification(theme);
+        });
+        useCustomHook(() => {
+          onClick();
+        });
+      }
+      `,
+      errors: [useEffectEventError('onClick', true)],
+    },
     {
       code: normalizeIndent`
         // Invalid: useEffectEvent should not be callable in hooks not matching the settings regex
@@ -1544,6 +1734,27 @@ const allTests = {
       },
       errors: [useEffectEventError('onClick', true)],
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Invalid: useEffectEvent should not be callable in hooks not matching the settings regex
+        component MyComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          useWrongHook(() => {
+            onClick();
+          });
+        }
+      `,
+      settings: {
+        'react-hooks': {
+          additionalEffectHooks: 'useMyEffect',
+        },
+      },
+      errors: [useEffectEventError('onClick', true)],
+    },
     {
       code: normalizeIndent`
         function MyComponent({ theme }) {
@@ -1555,6 +1766,19 @@ const allTests = {
       `,
       errors: [useEffectEventError('onClick', false)],
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        component MyComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          return <Child onClick={onClick}></Child>;
+        }
+      `,
+      errors: [useEffectEventError('onClick', false)],
+    },
     {
       code: normalizeIndent`
         // Invalid because useEffectEvent is being passed down
@@ -1566,6 +1790,19 @@ const allTests = {
       `,
       errors: [{...useEffectEventError(null, false), line: 4}],
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Invalid because useEffectEvent is being passed down
+        component MyComponent(theme: any) {
+          return <Child onClick={useEffectEvent(() => {
+            showNotification(theme);
+          })} />;
+        }
+      `,
+      errors: [{...useEffectEventError(null, false), line: 5}],
+    },
     {
       code: normalizeIndent`
         // This should error even though it shares an identifier name with the below
@@ -1601,6 +1838,43 @@ const allTests = {
         {...useEffectEventError('onClick', true), line: 15},
       ],
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // This should error even though it shares an identifier name with the below
+        component MyComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme)
+          });
+          return <Child onClick={onClick} />
+        }
+
+        // The useEffectEvent function shares an identifier name with the above
+        component MyOtherComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme)
+          });
+          return <Child onClick={() => onClick()} />
+        }
+
+        // The useEffectEvent function shares an identifier name with the above
+        component MyLastComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme)
+          });
+          useEffect(() => {
+            onClick(); // No error here, errors on all other uses
+            onClick;
+          })
+          return <Child />
+        }
+      `,
+      errors: [
+        {...useEffectEventError('onClick', false), line: 8},
+        {...useEffectEventError('onClick', true), line: 16},
+      ],
+    },
     {
       code: normalizeIndent`
         const MyComponent = ({ theme }) => {
@@ -1625,6 +1899,21 @@ const allTests = {
       `,
       errors: [{...useEffectEventError('onClick', false), line: 7}],
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Invalid because onClick is being aliased to foo but not invoked
+        component MyComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          let foo = onClick;
+          return <Bar onClick={foo} />
+        }
+      `,
+      errors: [{...useEffectEventError('onClick', false), line: 8}],
+    },
     {
       code: normalizeIndent`
         // Should error because it's being passed down to JSX, although it's been referenced once
@@ -1641,6 +1930,24 @@ const allTests = {
       `,
       errors: [useEffectEventError('onClick', false)],
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Component syntax version
+        // Should error because it's being passed down to JSX, although it's been referenced once
+        // in an effect
+        component MyComponent(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(them);
+          });
+          useEffect(() => {
+            setTimeout(onClick, 100);
+          });
+          return <Child onClick={onClick} />
+        }
+      `,
+      errors: [useEffectEventError('onClick', false)],
+    },
     {
       code: normalizeIndent`
         // Invalid because functions created with useEffectEvent cannot be called in arbitrary closures.
@@ -1676,6 +1983,43 @@ const allTests = {
           `It cannot be assigned to a variable or passed down.`,
       ],
     },
+    {
+      syntax: 'flow',
+      code: normalizeIndent`
+        // Hook syntax version
+        // Invalid because functions created with useEffectEvent cannot be called in arbitrary closures.
+        hook useMyHook(theme: any) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          // error message 1
+          const onClick2 = () => { onClick() };
+          // error message 2
+          const onClick3 = useCallback(() => onClick(), []);
+          // error message 3
+          const onClick4 = onClick;
+          return <>
+            {/** error message 4 */}
+            <Child onClick={onClick}></Child>
+            <Child onClick={onClick2}></Child>
+            <Child onClick={onClick3}></Child>
+          </>;
+        }
+      `,
+      // Explicitly test error messages here for various cases
+      errors: [
+        `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
+          'Effects and Effect Events in the same component.',
+        `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
+          'Effects and Effect Events in the same component.',
+        `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
+          `Effects and Effect Events in the same component. ` +
+          `It cannot be assigned to a variable or passed down.`,
+        `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
+          `Effects and Effect Events in the same component. ` +
+          `It cannot be assigned to a variable or passed down.`,
+      ],
+    },
   ],
 };
 

filename: packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts
patch: @@ -833,6 +833,18 @@ const rule = {
           recordAllUseEffectEventFunctions(getScope(node));
         }
       },
+
+      // @ts-expect-error parser-hermes produces these node types
+      ComponentDeclaration(node) {
+        // component MyComponent() { const onClick = useEffectEvent(...) }
+        recordAllUseEffectEventFunctions(getScope(node));
+      },
+
+      // @ts-expect-error parser-hermes produces these node types
+      HookDeclaration(node) {
+        // hook useMyHook() { const onClick = useEffectEvent(...) }
+        recordAllUseEffectEventFunctions(getScope(node));
+      },
     };
   },
 } satisfies Rule.RuleModule;