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