Compare commits

...

1 Commits

Author SHA1 Message Date
Jordan Brown
ac4ac1f5a6 [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-eslint` 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-eslint': {
      additionalEffectHooks: string,
    },
  },
}
```

The next diff allows useEffect to read from the same configuration.


----
2025-09-30 07:13:44 -04:00
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: [
{
@@ -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)],
},
],
};

View File

@@ -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
) {

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