From 984ccbac71853c9ef86ea5a925cd8508bd1356fe Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Mon, 15 Sep 2025 12:39:33 -0400 Subject: [PATCH] [lint] Enable custom hooks configuration for useEffectEvent calling rules --- .../__tests__/ESLintRulesOfHooks-test.js | 87 +++++++++++++++++++ .../src/rules/RulesOfHooks.ts | 52 ++++++++++- 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 2f3e14c5f9..e15a8d9e19 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -581,6 +581,60 @@ const allTests = { }; `, }, + { + code: normalizeIndent` + // Valid: useEffectEvent can be called in custom effect hooks configured via rule options + function MyComponent({ theme }) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useCustomEffect(() => { + onClick(); + }); + } + `, + options: [{additionalHooks: 'useCustomEffect'}], + }, + { + 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': { + additionalHooks: '(useMyEffect|useServerEffect)', + }, + }, + }, + { + code: normalizeIndent` + // Valid: Rule options take precedence over settings + function MyComponent({ theme }) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useOptionEffect(() => { + onClick(); // This should work since rule options take precedence + }); + } + `, + options: [{additionalHooks: 'useOptionEffect'}], + settings: { + 'react-hooks': { + additionalHooks: 'useSettingsEffect', + }, + }, + }, ], invalid: [ { @@ -1353,6 +1407,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': { + additionalHooks: '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..67b22608b8 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,39 @@ 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 rawOptions = context.options && context.options[0]; + const settings = context.settings || {}; + + // Parse the `additionalHooks` regex from either rule options or ESLint settings + const additionalHooksFromOption = + rawOptions && rawOptions.additionalHooks + ? rawOptions.additionalHooks + : undefined; + const additionalHooksFromSettings = + settings['react-hooks'] && settings['react-hooks'].additionalHooks + ? settings['react-hooks'].additionalHooks + : undefined; + + // Prefer rule option over settings, but allow either + const additionalHooksRegex = + additionalHooksFromOption || additionalHooksFromSettings; + const additionalHooks = additionalHooksRegex + ? new RegExp(additionalHooksRegex) + : undefined; + let lastEffect: CallExpression | null = null; const codePathReactHooksMapStack: Array< Map> @@ -726,7 +772,7 @@ const rule = { // Check all `useEffect` and `React.useEffect`, `useEffectEvent`, and `React.useEffectEvent` const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee); if ( - (isEffectIdentifier(nodeWithoutNamespace) || + (isEffectIdentifier(nodeWithoutNamespace, additionalHooks) || isUseEffectEventIdentifier(nodeWithoutNamespace)) && node.arguments.length > 0 ) {