diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 2f3e14c5f9..338482499f 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-eslint': { + 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-eslint': { + 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 4c7618d8e0..35bd62a78c 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts @@ -147,8 +147,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 +184,26 @@ 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 = + settings['react-eslint'] && settings['react-eslint'].additionalEffectHooks + ? new RegExp(settings['react-eslint'].additionalEffectHooks) + : undefined; + let lastEffect: CallExpression | null = null; const codePathReactHooksMapStack: Array< Map> @@ -726,7 +759,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 ) {