Compare commits

...

2 Commits

Author SHA1 Message Date
Jordan Brown
0357e0abed [lint] Use settings for additional hooks in exhaustive deps
Like in the diff below, we can read from the shared configuration to check exhaustive deps.

I allow the classic additionalHooks configuration to override it so that this change
is backwards compatible.


--
2025-09-30 07:20:29 -04:00
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
5 changed files with 218 additions and 7 deletions

View File

@@ -1485,6 +1485,70 @@ const tests = {
}
`,
},
{
// Test settings-based additionalHooks - should work with settings
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
});
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: 'useCustomEffect',
},
},
},
{
// Test settings-based additionalHooks - should work with dependencies
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, [props.foo]);
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: 'useCustomEffect',
},
},
},
{
// Test that rule-level additionalHooks takes precedence over settings
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, []);
}
`,
options: [{additionalHooks: 'useAnotherEffect'}],
settings: {
'react-hooks': {
additionalEffectHooks: 'useCustomEffect',
},
},
},
{
// Test settings with multiple hooks pattern
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, [props.foo]);
useAnotherEffect(() => {
console.log(props.bar);
}, [props.bar]);
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: '(useCustomEffect|useAnotherEffect)',
},
},
},
],
invalid: [
{
@@ -3714,6 +3778,40 @@ const tests = {
},
],
},
{
// Test settings-based additionalHooks - should detect missing dependency
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, []);
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: 'useCustomEffect',
},
},
errors: [
{
message:
"React Hook useCustomEffect has a missing dependency: 'props.foo'. " +
'Either include it or remove the dependency array.',
suggestions: [
{
desc: 'Update the dependencies array to be: [props.foo]',
output: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, [props.foo]);
}
`,
},
],
},
],
},
{
code: normalizeIndent`
function MyComponent() {

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

@@ -21,6 +21,8 @@ import type {
VariableDeclarator,
} from 'estree';
import { getAdditionalEffectHooksFromSettings } from '../shared/Utils';
type DeclaredDependency = {
key: string;
node: Node;
@@ -69,19 +71,22 @@ const rule = {
},
requireExplicitEffectDeps: {
type: 'boolean',
}
},
},
},
],
},
create(context: Rule.RuleContext) {
const rawOptions = context.options && context.options[0];
const settings = context.settings || {};
// Parse the `additionalHooks` regex.
// Use rule-level additionalHooks if provided, otherwise fall back to settings
const additionalHooks =
rawOptions && rawOptions.additionalHooks
? new RegExp(rawOptions.additionalHooks)
: undefined;
: getAdditionalEffectHooksFromSettings(settings);
const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
(rawOptions &&
@@ -93,7 +98,8 @@ const rule = {
? rawOptions.experimental_autoDependenciesHooks
: [];
const requireExplicitEffectDeps: boolean = rawOptions && rawOptions.requireExplicitEffectDeps || false;
const requireExplicitEffectDeps: boolean =
(rawOptions && rawOptions.requireExplicitEffectDeps) || false;
const options = {
additionalHooks,
@@ -1351,7 +1357,7 @@ const rule = {
node: reactiveHook,
message:
`React Hook ${reactiveHookName} always requires dependencies. ` +
`Please add a dependency array or an explicit \`undefined\``
`Please add a dependency array or an explicit \`undefined\``,
});
}

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