@samitouri / QOS-React / commits / a3abf5f2f8

[eslint-plugin-react-hooks] add experimental_autoDependenciesHooks option (#33294)

Jan Kassens committed May 19, 2025 at 15:08 UTC a3abf5f2f835ad0c61e2325f5cbac2d1d9045517
2 files changed +157 -10
packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js
+108
@@ -515,6 +515,22 @@ const tests = {
515 `,
516 options: [{additionalHooks: 'useCustomEffect'}],
517 },
518 + {
519 + // behaves like no deps
520 + code: normalizeIndent`
521 + function MyComponent(props) {
522 + useSpecialEffect(() => {
523 + console.log(props.foo);
524 + }, null);
525 + }
526 + `,
527 + options: [
528 + {
529 + additionalHooks: 'useSpecialEffect',
530 + experimental_autoDependenciesHooks: ['useSpecialEffect'],
531 + },
532 + ],
533 + },
534 {
535 code: normalizeIndent`
536 function MyComponent(props) {
@@ -1470,6 +1486,38 @@ const tests = {
1486 },
1487 ],
1488 invalid: [
1489 + {
1490 + code: normalizeIndent`
1491 + function MyComponent(props) {
1492 + useSpecialEffect(() => {
1493 + console.log(props.foo);
1494 + }, null);
1495 + }
1496 + `,
1497 + options: [{additionalHooks: 'useSpecialEffect'}],
1498 + errors: [
1499 + {
1500 + message:
1501 + "React Hook useSpecialEffect was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies.",
1502 + },
1503 + {
1504 + message:
1505 + "React Hook useSpecialEffect has a missing dependency: 'props.foo'. Either include it or remove the dependency array.",
1506 + suggestions: [
1507 + {
1508 + desc: 'Update the dependencies array to be: [props.foo]',
1509 + output: normalizeIndent`
1510 + function MyComponent(props) {
1511 + useSpecialEffect(() => {
1512 + console.log(props.foo);
1513 + }, [props.foo]);
1514 + }
1515 + `,
1516 + },
1517 + ],
1518 + },
1519 + ],
1520 + },
1521 {
1522 code: normalizeIndent`
1523 function MyComponent(props) {
@@ -7821,6 +7869,24 @@ const testsTypescript = {
7869 }
7870 `,
7871 },
7872 + {
7873 + code: normalizeIndent`
7874 + function MyComponent() {
7875 + const [state, setState] = React.useState<number>(0);
7876 +
7877 + useSpecialEffect(() => {
7878 + const someNumber: typeof state = 2;
7879 + setState(prevState => prevState + someNumber);
7880 + })
7881 + }
7882 + `,
7883 + options: [
7884 + {
7885 + additionalHooks: 'useSpecialEffect',
7886 + experimental_autoDependenciesHooks: ['useSpecialEffect'],
7887 + },
7888 + ],
7889 + },
7890 {
7891 code: normalizeIndent`
7892 function App() {
@@ -8176,6 +8242,48 @@ const testsTypescript = {
8242 function MyComponent() {
8243 const [state, setState] = React.useState<number>(0);
8244
8245 + useSpecialEffect(() => {
8246 + const someNumber: typeof state = 2;
8247 + setState(prevState => prevState + someNumber + state);
8248 + }, [])
8249 + }
8250 + `,
8251 + options: [
8252 + {
8253 + additionalHooks: 'useSpecialEffect',
8254 + experimental_autoDependenciesHooks: ['useSpecialEffect'],
8255 + },
8256 + ],
8257 + errors: [
8258 + {
8259 + message:
8260 + "React Hook useSpecialEffect has a missing dependency: 'state'. " +
8261 + 'Either include it or remove the dependency array. ' +
8262 + `You can also do a functional update 'setState(s => ...)' ` +
8263 + `if you only need 'state' in the 'setState' call.`,
8264 + suggestions: [
8265 + {
8266 + desc: 'Update the dependencies array to be: [state]',
8267 + output: normalizeIndent`
8268 + function MyComponent() {
8269 + const [state, setState] = React.useState<number>(0);
8270 +
8271 + useSpecialEffect(() => {
8272 + const someNumber: typeof state = 2;
8273 + setState(prevState => prevState + someNumber + state);
8274 + }, [state])
8275 + }
8276 + `,
8277 + },
8278 + ],
8279 + },
8280 + ],
8281 + },
8282 + {
8283 + code: normalizeIndent`
8284 + function MyComponent() {
8285 + const [state, setState] = React.useState<number>(0);
8286 +
8287 useMemo(() => {
8288 const someNumber: typeof state = 2;
8289 console.log(someNumber);
packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts
+49 -10
@@ -61,27 +61,38 @@ const rule = {
61 enableDangerousAutofixThisMayCauseInfiniteLoops: {
62 type: 'boolean',
63 },
64 + experimental_autoDependenciesHooks: {
65 + type: 'array',
66 + items: {
67 + type: 'string',
68 + },
69 + },
70 },
71 },
72 ],
73 },
74 create(context: Rule.RuleContext) {
75 + const rawOptions = context.options && context.options[0];
76 +
77 // Parse the `additionalHooks` regex.
78 const additionalHooks =
71 - context.options &&
72 - context.options[0] &&
73 - context.options[0].additionalHooks
74 - ? new RegExp(context.options[0].additionalHooks)
79 + rawOptions && rawOptions.additionalHooks
80 + ? new RegExp(rawOptions.additionalHooks)
81 : undefined;
82
83 const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
78 - (context.options &&
79 - context.options[0] &&
80 - context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) ||
84 + (rawOptions &&
85 + rawOptions.enableDangerousAutofixThisMayCauseInfiniteLoops) ||
86 false;
87
88 + const experimental_autoDependenciesHooks: ReadonlyArray<string> =
89 + rawOptions && Array.isArray(rawOptions.experimental_autoDependenciesHooks)
90 + ? rawOptions.experimental_autoDependenciesHooks
91 + : [];
92 +
93 const options = {
94 additionalHooks,
95 + experimental_autoDependenciesHooks,
96 enableDangerousAutofixThisMayCauseInfiniteLoops,
97 };
98
@@ -162,6 +173,7 @@ const rule = {
173 reactiveHook: Node,
174 reactiveHookName: string,
175 isEffect: boolean,
176 + isAutoDepsHook: boolean,
177 ): void {
178 if (isEffect && node.async) {
179 reportProblem({
@@ -649,6 +661,9 @@ const rule = {
661 }
662
663 if (!declaredDependenciesNode) {
664 + if (isAutoDepsHook) {
665 + return;
666 + }
667 // Check if there are any top-level setState() calls.
668 // Those tend to lead to infinite loops.
669 let setStateInsideEffectWithoutDeps: string | null = null;
@@ -711,6 +726,13 @@ const rule = {
726 }
727 return;
728 }
729 + if (
730 + isAutoDepsHook &&
731 + declaredDependenciesNode.type === 'Literal' &&
732 + declaredDependenciesNode.value === null
733 + ) {
734 + return;
735 + }
736
737 const declaredDependencies: Array<DeclaredDependency> = [];
738 const externalDependencies = new Set<string>();
@@ -1318,10 +1340,19 @@ const rule = {
1340 return;
1341 }
1342
1343 + const isAutoDepsHook =
1344 + options.experimental_autoDependenciesHooks.includes(reactiveHookName);
1345 +
1346 // Check the declared dependencies for this reactive hook. If there is no
1347 // second argument then the reactive callback will re-run on every render.
1348 // So no need to check for dependency inclusion.
1324 - if (!declaredDependenciesNode && !isEffect) {
1349 + if (
1350 + (!declaredDependenciesNode ||
1351 + (isAutoDepsHook &&
1352 + declaredDependenciesNode.type === 'Literal' &&
1353 + declaredDependenciesNode.value === null)) &&
1354 + !isEffect
1355 + ) {
1356 // These are only used for optimization.
1357 if (
1358 reactiveHookName === 'useMemo' ||
@@ -1355,11 +1386,17 @@ const rule = {
1386 reactiveHook,
1387 reactiveHookName,
1388 isEffect,
1389 + isAutoDepsHook,
1390 );
1391 return; // Handled
1392 case 'Identifier':
1361 - if (!declaredDependenciesNode) {
1362 - // No deps, no problems.
1393 + if (
1394 + !declaredDependenciesNode ||
1395 + (isAutoDepsHook &&
1396 + declaredDependenciesNode.type === 'Literal' &&
1397 + declaredDependenciesNode.value === null)
1398 + ) {
1399 + // Always runs, no problems.
1400 return; // Handled
1401 }
1402 // The function passed as a callback is not written inline.
@@ -1408,6 +1445,7 @@ const rule = {
1445 reactiveHook,
1446 reactiveHookName,
1447 isEffect,
1448 + isAutoDepsHook,
1449 );
1450 return; // Handled
1451 case 'VariableDeclarator':
@@ -1427,6 +1465,7 @@ const rule = {
1465 reactiveHook,
1466 reactiveHookName,
1467 isEffect,
1468 + isAutoDepsHook,
1469 );
1470 return; // Handled
1471 }