@samitouri / QOS-React / commits / 92cfdc3a4e

[lint] Enable custom hooks configuration for useEffectEvent calling rules (#34497)

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-hooks` 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-hooks': { additionalEffectHooks: string, }, }, } ``` The next diff allows useEffect to read from the same configuration. ---- --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34497). * #34637 * __->__ #34497

Jordan Brown committed Sep 30, 2025 at 16:44 UTC 92cfdc3a4ed8f3350b746bd79d87f6549db8003c
3 files changed +110 -3
packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js
+54
@@ -581,6 +581,27 @@ const allTests = {
581 };
582 `,
583 },
584 + {
585 + code: normalizeIndent`
586 + // Valid: useEffectEvent can be called in custom effect hooks configured via ESLint settings
587 + function MyComponent({ theme }) {
588 + const onClick = useEffectEvent(() => {
589 + showNotification(theme);
590 + });
591 + useMyEffect(() => {
592 + onClick();
593 + });
594 + useServerEffect(() => {
595 + onClick();
596 + });
597 + }
598 + `,
599 + settings: {
600 + 'react-hooks': {
601 + additionalEffectHooks: '(useMyEffect|useServerEffect)',
602 + },
603 + },
604 + },
605 ],
606 invalid: [
607 {
@@ -1353,6 +1374,39 @@ const allTests = {
1374 `,
1375 errors: [tryCatchUseError('use')],
1376 },
1377 + {
1378 + code: normalizeIndent`
1379 + // Invalid: useEffectEvent should not be callable in regular custom hooks without additional configuration
1380 + function MyComponent({ theme }) {
1381 + const onClick = useEffectEvent(() => {
1382 + showNotification(theme);
1383 + });
1384 + useCustomHook(() => {
1385 + onClick();
1386 + });
1387 + }
1388 + `,
1389 + errors: [useEffectEventError('onClick', true)],
1390 + },
1391 + {
1392 + code: normalizeIndent`
1393 + // Invalid: useEffectEvent should not be callable in hooks not matching the settings regex
1394 + function MyComponent({ theme }) {
1395 + const onClick = useEffectEvent(() => {
1396 + showNotification(theme);
1397 + });
1398 + useWrongHook(() => {
1399 + onClick();
1400 + });
1401 + }
1402 + `,
1403 + settings: {
1404 + 'react-hooks': {
1405 + additionalEffectHooks: 'useMyEffect',
1406 + },
1407 + },
1408 + errors: [useEffectEventError('onClick', true)],
1409 + },
1410 ],
1411 };
1412
packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts
+34 -3
@@ -20,6 +20,7 @@ import type {
20
21 // @ts-expect-error untyped module
22 import CodePathAnalyzer from '../code-path-analysis/code-path-analyzer';
23 +import { getAdditionalEffectHooksFromSettings } from '../shared/Utils';
24
25 /**
26 * Catch all identifiers that begin with "use" followed by an uppercase Latin
@@ -147,8 +148,23 @@ function getNodeWithoutReactNamespace(
148 return node;
149 }
150
150 -function isEffectIdentifier(node: Node): boolean {
151 - return node.type === 'Identifier' && (node.name === 'useEffect' || node.name === 'useLayoutEffect' || node.name === 'useInsertionEffect');
151 +function isEffectIdentifier(node: Node, additionalHooks?: RegExp): boolean {
152 + const isBuiltInEffect =
153 + node.type === 'Identifier' &&
154 + (node.name === 'useEffect' ||
155 + node.name === 'useLayoutEffect' ||
156 + node.name === 'useInsertionEffect');
157 +
158 + if (isBuiltInEffect) {
159 + return true;
160 + }
161 +
162 + // Check if this matches additional hooks configured by the user
163 + if (additionalHooks && node.type === 'Identifier') {
164 + return additionalHooks.test(node.name);
165 + }
166 +
167 + return false;
168 }
169 function isUseEffectEventIdentifier(node: Node): boolean {
170 if (__EXPERIMENTAL__) {
@@ -169,8 +185,23 @@ const rule = {
185 recommended: true,
186 url: 'https://react.dev/reference/rules/rules-of-hooks',
187 },
188 + schema: [
189 + {
190 + type: 'object',
191 + additionalProperties: false,
192 + properties: {
193 + additionalHooks: {
194 + type: 'string',
195 + },
196 + },
197 + },
198 + ],
199 },
200 create(context: Rule.RuleContext) {
201 + const settings = context.settings || {};
202 +
203 + const additionalEffectHooks = getAdditionalEffectHooksFromSettings(settings);
204 +
205 let lastEffect: CallExpression | null = null;
206 const codePathReactHooksMapStack: Array<
207 Map<Rule.CodePathSegment, Array<Node>>
@@ -726,7 +757,7 @@ const rule = {
757 // Check all `useEffect` and `React.useEffect`, `useEffectEvent`, and `React.useEffectEvent`
758 const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee);
759 if (
729 - (isEffectIdentifier(nodeWithoutNamespace) ||
760 + (isEffectIdentifier(nodeWithoutNamespace, additionalEffectHooks) ||
761 isUseEffectEventIdentifier(nodeWithoutNamespace)) &&
762 node.arguments.length > 0
763 ) {
packages/eslint-plugin-react-hooks/src/shared/Utils.ts new
+22
@@ -0,0 +1,22 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +
8 +import { Rule } from 'eslint';
9 +
10 +const SETTINGS_KEY = 'react-hooks';
11 +const SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY = 'additionalEffectHooks';
12 +
13 +export function getAdditionalEffectHooksFromSettings(
14 + settings: Rule.RuleContext['settings'],
15 +): RegExp | undefined {
16 + const additionalHooks = settings[SETTINGS_KEY]?.[SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY];
17 + if (additionalHooks != null && typeof additionalHooks === 'string') {
18 + return new RegExp(additionalHooks);
19 + }
20 +
21 + return undefined;
22 +}