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

This commit is contained in:
Jan Kassens 2025-05-19 15:08:30 -04:00 committed by GitHub
parent 462d08f9ba
commit a3abf5f2f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 157 additions and 10 deletions

View File

@ -515,6 +515,22 @@ const tests = {
`, `,
options: [{additionalHooks: 'useCustomEffect'}], options: [{additionalHooks: 'useCustomEffect'}],
}, },
{
// behaves like no deps
code: normalizeIndent`
function MyComponent(props) {
useSpecialEffect(() => {
console.log(props.foo);
}, null);
}
`,
options: [
{
additionalHooks: 'useSpecialEffect',
experimental_autoDependenciesHooks: ['useSpecialEffect'],
},
],
},
{ {
code: normalizeIndent` code: normalizeIndent`
function MyComponent(props) { function MyComponent(props) {
@ -1470,6 +1486,38 @@ const tests = {
}, },
], ],
invalid: [ invalid: [
{
code: normalizeIndent`
function MyComponent(props) {
useSpecialEffect(() => {
console.log(props.foo);
}, null);
}
`,
options: [{additionalHooks: 'useSpecialEffect'}],
errors: [
{
message:
"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.",
},
{
message:
"React Hook useSpecialEffect has a missing dependency: 'props.foo'. Either include it or remove the dependency array.",
suggestions: [
{
desc: 'Update the dependencies array to be: [props.foo]',
output: normalizeIndent`
function MyComponent(props) {
useSpecialEffect(() => {
console.log(props.foo);
}, [props.foo]);
}
`,
},
],
},
],
},
{ {
code: normalizeIndent` code: normalizeIndent`
function MyComponent(props) { function MyComponent(props) {
@ -7821,6 +7869,24 @@ const testsTypescript = {
} }
`, `,
}, },
{
code: normalizeIndent`
function MyComponent() {
const [state, setState] = React.useState<number>(0);
useSpecialEffect(() => {
const someNumber: typeof state = 2;
setState(prevState => prevState + someNumber);
})
}
`,
options: [
{
additionalHooks: 'useSpecialEffect',
experimental_autoDependenciesHooks: ['useSpecialEffect'],
},
],
},
{ {
code: normalizeIndent` code: normalizeIndent`
function App() { function App() {
@ -8176,6 +8242,48 @@ const testsTypescript = {
function MyComponent() { function MyComponent() {
const [state, setState] = React.useState<number>(0); const [state, setState] = React.useState<number>(0);
useSpecialEffect(() => {
const someNumber: typeof state = 2;
setState(prevState => prevState + someNumber + state);
}, [])
}
`,
options: [
{
additionalHooks: 'useSpecialEffect',
experimental_autoDependenciesHooks: ['useSpecialEffect'],
},
],
errors: [
{
message:
"React Hook useSpecialEffect has a missing dependency: 'state'. " +
'Either include it or remove the dependency array. ' +
`You can also do a functional update 'setState(s => ...)' ` +
`if you only need 'state' in the 'setState' call.`,
suggestions: [
{
desc: 'Update the dependencies array to be: [state]',
output: normalizeIndent`
function MyComponent() {
const [state, setState] = React.useState<number>(0);
useSpecialEffect(() => {
const someNumber: typeof state = 2;
setState(prevState => prevState + someNumber + state);
}, [state])
}
`,
},
],
},
],
},
{
code: normalizeIndent`
function MyComponent() {
const [state, setState] = React.useState<number>(0);
useMemo(() => { useMemo(() => {
const someNumber: typeof state = 2; const someNumber: typeof state = 2;
console.log(someNumber); console.log(someNumber);

View File

@ -61,27 +61,38 @@ const rule = {
enableDangerousAutofixThisMayCauseInfiniteLoops: { enableDangerousAutofixThisMayCauseInfiniteLoops: {
type: 'boolean', type: 'boolean',
}, },
experimental_autoDependenciesHooks: {
type: 'array',
items: {
type: 'string',
},
},
}, },
}, },
], ],
}, },
create(context: Rule.RuleContext) { create(context: Rule.RuleContext) {
const rawOptions = context.options && context.options[0];
// Parse the `additionalHooks` regex. // Parse the `additionalHooks` regex.
const additionalHooks = const additionalHooks =
context.options && rawOptions && rawOptions.additionalHooks
context.options[0] && ? new RegExp(rawOptions.additionalHooks)
context.options[0].additionalHooks
? new RegExp(context.options[0].additionalHooks)
: undefined; : undefined;
const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean = const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
(context.options && (rawOptions &&
context.options[0] && rawOptions.enableDangerousAutofixThisMayCauseInfiniteLoops) ||
context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) ||
false; false;
const experimental_autoDependenciesHooks: ReadonlyArray<string> =
rawOptions && Array.isArray(rawOptions.experimental_autoDependenciesHooks)
? rawOptions.experimental_autoDependenciesHooks
: [];
const options = { const options = {
additionalHooks, additionalHooks,
experimental_autoDependenciesHooks,
enableDangerousAutofixThisMayCauseInfiniteLoops, enableDangerousAutofixThisMayCauseInfiniteLoops,
}; };
@ -162,6 +173,7 @@ const rule = {
reactiveHook: Node, reactiveHook: Node,
reactiveHookName: string, reactiveHookName: string,
isEffect: boolean, isEffect: boolean,
isAutoDepsHook: boolean,
): void { ): void {
if (isEffect && node.async) { if (isEffect && node.async) {
reportProblem({ reportProblem({
@ -649,6 +661,9 @@ const rule = {
} }
if (!declaredDependenciesNode) { if (!declaredDependenciesNode) {
if (isAutoDepsHook) {
return;
}
// Check if there are any top-level setState() calls. // Check if there are any top-level setState() calls.
// Those tend to lead to infinite loops. // Those tend to lead to infinite loops.
let setStateInsideEffectWithoutDeps: string | null = null; let setStateInsideEffectWithoutDeps: string | null = null;
@ -711,6 +726,13 @@ const rule = {
} }
return; return;
} }
if (
isAutoDepsHook &&
declaredDependenciesNode.type === 'Literal' &&
declaredDependenciesNode.value === null
) {
return;
}
const declaredDependencies: Array<DeclaredDependency> = []; const declaredDependencies: Array<DeclaredDependency> = [];
const externalDependencies = new Set<string>(); const externalDependencies = new Set<string>();
@ -1318,10 +1340,19 @@ const rule = {
return; return;
} }
const isAutoDepsHook =
options.experimental_autoDependenciesHooks.includes(reactiveHookName);
// Check the declared dependencies for this reactive hook. If there is no // Check the declared dependencies for this reactive hook. If there is no
// second argument then the reactive callback will re-run on every render. // second argument then the reactive callback will re-run on every render.
// So no need to check for dependency inclusion. // So no need to check for dependency inclusion.
if (!declaredDependenciesNode && !isEffect) { if (
(!declaredDependenciesNode ||
(isAutoDepsHook &&
declaredDependenciesNode.type === 'Literal' &&
declaredDependenciesNode.value === null)) &&
!isEffect
) {
// These are only used for optimization. // These are only used for optimization.
if ( if (
reactiveHookName === 'useMemo' || reactiveHookName === 'useMemo' ||
@ -1355,11 +1386,17 @@ const rule = {
reactiveHook, reactiveHook,
reactiveHookName, reactiveHookName,
isEffect, isEffect,
isAutoDepsHook,
); );
return; // Handled return; // Handled
case 'Identifier': case 'Identifier':
if (!declaredDependenciesNode) { if (
// No deps, no problems. !declaredDependenciesNode ||
(isAutoDepsHook &&
declaredDependenciesNode.type === 'Literal' &&
declaredDependenciesNode.value === null)
) {
// Always runs, no problems.
return; // Handled return; // Handled
} }
// The function passed as a callback is not written inline. // The function passed as a callback is not written inline.
@ -1408,6 +1445,7 @@ const rule = {
reactiveHook, reactiveHook,
reactiveHookName, reactiveHookName,
isEffect, isEffect,
isAutoDepsHook,
); );
return; // Handled return; // Handled
case 'VariableDeclarator': case 'VariableDeclarator':
@ -1427,6 +1465,7 @@ const rule = {
reactiveHook, reactiveHook,
reactiveHookName, reactiveHookName,
isEffect, isEffect,
isAutoDepsHook,
); );
return; // Handled return; // Handled
} }