From ac4ac1f5a69d45545b0dc17c3304acba00cd374a Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Sun, 28 Sep 2025 14:33:12 -0400 Subject: [PATCH 1/2] [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. ---- --- .../__tests__/ESLintRulesOfHooks-test.js | 54 +++++++++++++++++++ .../src/rules/RulesOfHooks.ts | 37 +++++++++++-- .../src/shared/Utils.ts | 22 ++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 packages/eslint-plugin-react-hooks/src/shared/Utils.ts diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 2f3e14c5f95e4..83455f0b8d43f 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -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)], + }, ], }; diff --git a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts index 4c7618d8e084c..97909d6b0f80c 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts @@ -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> @@ -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 ) { diff --git a/packages/eslint-plugin-react-hooks/src/shared/Utils.ts b/packages/eslint-plugin-react-hooks/src/shared/Utils.ts new file mode 100644 index 0000000000000..54bc21011972d --- /dev/null +++ b/packages/eslint-plugin-react-hooks/src/shared/Utils.ts @@ -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; +} From 0357e0abed9cea4833f25d949a01ccf1b13670be Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Tue, 30 Sep 2025 07:20:28 -0400 Subject: [PATCH 2/2] [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. -- --- .../ESLintRuleExhaustiveDeps-test.js | 98 +++++++++++++++++++ .../src/rules/ExhaustiveDeps.ts | 14 ++- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js index 812c2010a042d..dca94c516c296 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js @@ -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() { diff --git a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts index d59a1ff79202c..8523c3cc2eca8 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts @@ -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\``, }); }