Repository: facebook/react
Pull request: [lint] Enable custom hooks configuration for useEffectEvent calling rules

We need to be able to specify additional effect hooks for the RulesOfHooks lint rule
in order to allow useEffectEvent to be called by custom effects. ExhaustiveDeps
does this with a regex suppplied to the rule, but that regex is not accessible from
other rules.

This diff introduces a `react-hooks` entry you can put in the eslint settings that
allows you to specify custom effect hooks and share them across all rules.

This works like:
```
{
  settings: {
    'react-hooks': {
      additionalEffectHooks: string,
    },
  },
}
```

The next diff allows useEffect to read from the same configuration.


----

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34497).
* #34637
* __->__ #34497

Changed files:
filename: packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js
patch: @@ -581,6 +581,27 @@ 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)',
+        },
+      },
+    },
   ],
   invalid: [
     {
@@ -1353,6 +1374,39 @@ const allTests = {
       `,
       errors: [tryCatchUseError('use')],
     },
+    {
+      code: normalizeIndent`
+        // Invalid: useEffectEvent should not be callable in regular custom hooks without additional configuration
+        function MyComponent({ theme }) {
+          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
+        function MyComponent({ theme }) {
+          const onClick = useEffectEvent(() => {
+            showNotification(theme);
+          });
+          useWrongHook(() => {
+            onClick();
+          });
+        }
+      `,
+      settings: {
+        'react-hooks': {
+          additionalEffectHooks: 'useMyEffect',
+        },
+      },
+      errors: [useEffectEventError('onClick', true)],
+    },
   ],
 };
 

filename: packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts
patch: @@ -20,6 +20,7 @@ import type {
 
 // @ts-expect-error untyped module
 import CodePathAnalyzer from '../code-path-analysis/code-path-analyzer';
+import { getAdditionalEffectHooksFromSettings } from '../shared/Utils';
 
 /**
  * Catch all identifiers that begin with "use" followed by an uppercase Latin
@@ -147,8 +148,23 @@ function getNodeWithoutReactNamespace(
   return node;
 }
 
-function isEffectIdentifier(node: Node): boolean {
-  return node.type === 'Identifier' && (node.name === 'useEffect' || node.name === 'useLayoutEffect' || node.name === 'useInsertionEffect');
+function isEffectIdentifier(node: Node, additionalHooks?: RegExp): boolean {
+  const isBuiltInEffect =
+    node.type === 'Identifier' &&
+    (node.name === 'useEffect' ||
+      node.name === 'useLayoutEffect' ||
+      node.name === 'useInsertionEffect');
+
+  if (isBuiltInEffect) {
+    return true;
+  }
+
+  // Check if this matches additional hooks configured by the user
+  if (additionalHooks && node.type === 'Identifier') {
+    return additionalHooks.test(node.name);
+  }
+
+  return false;
 }
 function isUseEffectEventIdentifier(node: Node): boolean {
   if (__EXPERIMENTAL__) {
@@ -169,8 +185,23 @@ const rule = {
       recommended: true,
       url: 'https://react.dev/reference/rules/rules-of-hooks',
     },
+    schema: [
+      {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          additionalHooks: {
+            type: 'string',
+          },
+        },
+      },
+    ],
   },
   create(context: Rule.RuleContext) {
+    const settings = context.settings || {};
+
+    const additionalEffectHooks = getAdditionalEffectHooksFromSettings(settings);
+
     let lastEffect: CallExpression | null = null;
     const codePathReactHooksMapStack: Array<
       Map<Rule.CodePathSegment, Array<Node>>
@@ -726,7 +757,7 @@ const rule = {
         // Check all `useEffect` and `React.useEffect`, `useEffectEvent`, and `React.useEffectEvent`
         const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee);
         if (
-          (isEffectIdentifier(nodeWithoutNamespace) ||
+          (isEffectIdentifier(nodeWithoutNamespace, additionalEffectHooks) ||
             isUseEffectEventIdentifier(nodeWithoutNamespace)) &&
           node.arguments.length > 0
         ) {

filename: packages/eslint-plugin-react-hooks/src/shared/Utils.ts
patch: @@ -0,0 +1,22 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { Rule } from 'eslint';
+
+const SETTINGS_KEY = 'react-hooks';
+const SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY = 'additionalEffectHooks';
+
+export function getAdditionalEffectHooksFromSettings(
+  settings: Rule.RuleContext['settings'],
+): RegExp | undefined {
+  const additionalHooks = settings[SETTINGS_KEY]?.[SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY];
+  if (additionalHooks != null && typeof additionalHooks === 'string') {
+    return new RegExp(additionalHooks);
+  }
+
+  return undefined;
+}