[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
}