[lint] Enable custom hooks configuration for useEffectEvent calling rules (#34497)

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
This commit is contained in:
Jordan Brown 2025-09-30 16:44:22 -04:00 committed by GitHub
parent a55e98f738
commit 92cfdc3a4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 110 additions and 3 deletions

View File

@ -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: [ invalid: [
{ {
@ -1353,6 +1374,39 @@ const allTests = {
`, `,
errors: [tryCatchUseError('use')], 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)],
},
], ],
}; };

View File

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

View File

@ -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;
}