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
-
7
/* eslint-disable no-for-of-loops/no-for-of-loops */
8
+import type {Rule, Scope} from 'eslint';
9
+import type {
10
+ ArrayExpression,
11
+ ArrowFunctionExpression,
12
+ CallExpression,
13
+ Expression,
14
+ FunctionDeclaration,
15
+ FunctionExpression,
16
+ Identifier,
17
+ Node,
18
+ Pattern,
19
+ PrivateIdentifier,
20
+ Super,
21
+ VariableDeclarator,
22
+} from 'estree';
23
+
24
+type DeclaredDependency = {
25
+ key: string;
26
+ node: Node;
27
+};
28
10
-'use strict';
29
+type Dependency = {
30
+ isStable: boolean;
31
+ references: Scope.Reference[];
32
+};
33
+
34
+type DependencyTreeNode = {
35
+ isUsed: boolean; // True if used in code
36
+ isSatisfiedRecursively: boolean; // True if specified in deps
37
+ isSubtreeUsed: boolean; // True if something deeper is used by code
38
+ children: Map<string, DependencyTreeNode>; // Nodes for properties
39
+};
40
12
-export default {
41
+const rule = {
42
meta: {
43
type: 'suggestion',
44
docs: {
65
},
66
],
67
},
39
- create(context) {
68
+ create(context: Rule.RuleContext) {
69
// Parse the `additionalHooks` regex.
70
const additionalHooks =
71
context.options &&
74
? new RegExp(context.options[0].additionalHooks)
75
: undefined;
76
48
- const enableDangerousAutofixThisMayCauseInfiniteLoops =
77
+ const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
78
(context.options &&
79
context.options[0] &&
80
context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) ||
85
enableDangerousAutofixThisMayCauseInfiniteLoops,
86
};
87
59
- function reportProblem(problem) {
88
+ function reportProblem(problem: Rule.ReportDescriptor) {
89
if (enableDangerousAutofixThisMayCauseInfiniteLoops) {
90
// Used to enable legacy behavior. Dangerous.
91
// Keep this as an option until major IDEs upgrade (including VSCode FB ESLint extension).
63
- if (Array.isArray(problem.suggest) && problem.suggest.length > 0) {
92
+ if (
93
+ Array.isArray(problem.suggest) &&
94
+ problem.suggest.length > 0 &&
95
+ problem.suggest[0]
96
+ ) {
97
problem.fix = problem.suggest[0].fix;
98
}
99
}
101
}
102
103
/**
71
- * SourceCode#getText that also works down to ESLint 3.0.0
104
+ * SourceCode that also works down to ESLint 3.0.0
105
*/
73
- const getSource =
74
- typeof context.getSource === 'function'
75
- ? node => {
76
- return context.getSource(node);
106
+ const getSourceCode =
107
+ typeof context.getSourceCode === 'function'
108
+ ? () => {
109
+ return context.getSourceCode();
110
}
78
- : node => {
79
- return context.sourceCode.getText(node);
111
+ : () => {
112
+ return context.sourceCode;
113
};
114
/**
115
* SourceCode#getScope that also works down to ESLint 3.0.0
119
? () => {
120
return context.getScope();
121
}
89
- : node => {
122
+ : (node: Node) => {
123
return context.sourceCode.getScope(node);
124
};
125
93
- const scopeManager = context.getSourceCode().scopeManager;
126
+ const scopeManager = getSourceCode().scopeManager;
127
128
// Should be shared between visitors.
96
- const setStateCallSites = new WeakMap();
97
- const stateVariables = new WeakSet();
98
- const stableKnownValueCache = new WeakMap();
99
- const functionWithoutCapturedValueCache = new WeakMap();
100
- const useEffectEventVariables = new WeakSet();
101
- function memoizeWithWeakMap(fn, map) {
102
- return function (arg) {
129
+ const setStateCallSites = new WeakMap<
130
+ Expression | Super,
131
+ Pattern | null | undefined
132
+ >();
133
+ const stateVariables = new WeakSet<Identifier>();
134
+ const stableKnownValueCache = new WeakMap<Scope.Variable, boolean>();
135
+ const functionWithoutCapturedValueCache = new WeakMap<
136
+ Scope.Variable,
137
+ boolean
138
+ >();
139
+ const useEffectEventVariables = new WeakSet<Expression>();
140
+
141
+ function memoizeWithWeakMap(
142
+ fn: (resolved: Scope.Variable) => boolean,
143
+ map: WeakMap<Scope.Variable, boolean>,
144
+ ) {
145
+ return function (arg: Scope.Variable): boolean {
146
if (map.has(arg)) {
147
// to verify cache hits:
148
// console.log(arg.name)
106
- return map.get(arg);
149
+ return map.get(arg)!;
150
}
151
const result = fn(arg);
152
map.set(arg, result);
157
* Visitor for both function expressions and arrow function expressions.
158
*/
159
function visitFunctionWithDependencies(
117
- node,
118
- declaredDependenciesNode,
119
- reactiveHook,
120
- reactiveHookName,
121
- isEffect,
122
- ) {
160
+ node: ArrowFunctionExpression | FunctionDeclaration | FunctionExpression,
161
+ declaredDependenciesNode: Node | undefined,
162
+ reactiveHook: Node,
163
+ reactiveHookName: string,
164
+ isEffect: boolean,
165
+ ): void {
166
if (isEffect && node.async) {
167
reportProblem({
168
node: node,
183
184
// Get the current scope.
185
const scope = scopeManager.acquire(node);
186
+ if (!scope) {
187
+ return;
188
+ }
189
190
// Find all our "pure scopes". On every re-render of a component these
191
// pure scopes may have changes to the variables declared within. So all
196
// scope. We can't enforce this in a lint so we trust that all variables
197
// declared outside of pure scope are indeed frozen.
198
const pureScopes = new Set();
153
- let componentScope = null;
199
+ let componentScope: Scope.Scope | null = null;
200
{
201
let currentScope = scope.upper;
202
while (currentScope) {
232
// const onStuff = useEffectEvent(() => {})
233
// ^^^ true for this reference
234
// False for everything else.
189
- function isStableKnownHookValue(resolved) {
235
+ function isStableKnownHookValue(resolved: Scope.Variable): boolean {
236
if (!isArray(resolved.defs)) {
237
return false;
238
}
241
return false;
242
}
243
// Look for `let stuff = ...`
198
- if (def.node.type !== 'VariableDeclarator') {
244
+ const defNode: VariableDeclarator = def.node;
245
+ if (defNode.type !== 'VariableDeclarator') {
246
return false;
247
}
201
- let init = def.node.init;
248
+ let init = defNode.init;
249
if (init == null) {
250
return false;
251
}
254
}
255
// Detect primitive constants
256
// const foo = 42
210
- let declaration = def.node.parent;
211
- if (declaration == null) {
257
+ let declaration = defNode.parent;
258
+ if (declaration == null && componentScope) {
259
// This might happen if variable is declared after the callback.
260
// In that case ESLint won't set up .parent refs.
261
// So we'll set them up manually.
266
}
267
}
268
if (
269
+ declaration &&
270
+ 'kind' in declaration &&
271
declaration.kind === 'const' &&
272
init.type === 'Literal' &&
273
(typeof init.value === 'string' ||
282
if (init.type !== 'CallExpression') {
283
return false;
284
}
236
- let callee = init.callee;
285
+ let callee: Expression | PrivateIdentifier | Super = init.callee;
286
// Step into `= React.something` initializer.
287
if (
288
callee.type === 'MemberExpression' &&
289
+ 'name' in callee.object &&
290
callee.object.name === 'React' &&
291
callee.property != null &&
292
!callee.computed
296
if (callee.type !== 'Identifier') {
297
return false;
298
}
249
- const id = def.node.id;
299
+ const definitionNode: VariableDeclarator = def.node;
300
+ const id = definitionNode.id;
301
const {name} = callee;
302
if (name === 'useRef' && id.type === 'Identifier') {
303
// useRef() return value is stable.
307
id.type === 'Identifier'
308
) {
309
for (const ref of resolved.references) {
310
+ // @ts-expect-error These types are not compatible (Reference and Identifier)
311
if (ref !== id) {
312
useEffectEventVariables.add(ref.identifier);
313
}
330
if (name === 'useState') {
331
const references = resolved.references;
332
let writeCount = 0;
281
- for (let i = 0; i < references.length; i++) {
282
- if (references[i].isWrite()) {
333
+ for (const reference of references) {
334
+ if (reference.isWrite()) {
335
writeCount++;
336
}
337
if (writeCount > 1) {
338
return false;
339
}
288
- setStateCallSites.set(
289
- references[i].identifier,
290
- id.elements[0],
291
- );
340
+ setStateCallSites.set(reference.identifier, id.elements[0]);
341
}
342
}
343
// Setter is stable.
345
} else if (id.elements[0] === resolved.identifiers[0]) {
346
if (name === 'useState') {
347
const references = resolved.references;
299
- for (let i = 0; i < references.length; i++) {
300
- stateVariables.add(references[i].identifier);
348
+ for (const reference of references) {
349
+ stateVariables.add(reference.identifier);
350
}
351
}
352
// State variable itself is dynamic.
372
}
373
374
// Some are just functions that don't reference anything dynamic.
326
- function isFunctionWithoutCapturedValues(resolved) {
375
+ function isFunctionWithoutCapturedValues(
376
+ resolved: Scope.Variable,
377
+ ): boolean {
378
if (!isArray(resolved.defs)) {
379
return false;
380
}
387
}
388
// Search the direct component subscopes for
389
// top-level function definitions matching this reference.
339
- const fnNode = def.node;
340
- const childScopes = componentScope.childScopes;
390
+ const fnNode: Node = def.node;
391
+ const childScopes = componentScope?.childScopes || [];
392
let fnScope = null;
342
- let i;
343
- for (i = 0; i < childScopes.length; i++) {
344
- const childScope = childScopes[i];
393
+ for (const childScope of childScopes) {
394
const childScopeBlock = childScope.block;
395
if (
396
// function handleChange() {}
411
}
412
// Does this function capture any values
413
// that are in pure scopes (aka render)?
365
- for (i = 0; i < fnScope.through.length; i++) {
366
- const ref = fnScope.through[i];
414
+ for (const ref of fnScope.through) {
415
if (ref.resolved == null) {
416
continue;
417
}
440
);
441
442
// These are usually mistaken. Collect them.
395
- const currentRefsInEffectCleanup = new Map();
443
+ const currentRefsInEffectCleanup = new Map<
444
+ string,
445
+ {
446
+ reference: Scope.Reference;
447
+ dependencyNode: Identifier;
448
+ }
449
+ >();
450
451
// Is this reference inside a cleanup function for this effect node?
452
// We can check by traversing scopes upwards from the reference, and checking
453
// if the last "return () => " we encounter is located directly inside the effect.
400
- function isInsideEffectCleanup(reference) {
401
- let curScope = reference.from;
454
+ function isInsideEffectCleanup(reference: Scope.Reference): boolean {
455
+ let curScope: Scope.Scope | null = reference.from;
456
let isInReturnedFunction = false;
403
- while (curScope.block !== node) {
457
+ while (curScope && curScope.block !== node) {
458
if (curScope.type === 'function') {
459
isInReturnedFunction =
460
curScope.block.parent != null &&
467
468
// Get dependencies from all our resolved references in pure scopes.
469
// Key is dependency string, value is whether it's stable.
416
- const dependencies = new Map();
417
- const optionalChains = new Map();
470
+ const dependencies = new Map<string, Dependency>();
471
+ const optionalChains = new Map<string, boolean>();
472
gatherDependenciesRecursively(scope);
473
420
- function gatherDependenciesRecursively(currentScope) {
474
+ function gatherDependenciesRecursively(currentScope: Scope.Scope): void {
475
for (const reference of currentScope.references) {
476
// If this reference is not resolved or it is not declared in a pure
477
// scope then we don't care about this reference.
488
node,
489
reference.identifier,
490
);
491
+ if (referenceNode == null) {
492
+ continue;
493
+ }
494
const dependencyNode = getDependency(referenceNode);
495
const dependency = analyzePropertyChain(
496
dependencyNode,
503
isEffect &&
504
// ... and this look like accessing .current...
505
dependencyNode.type === 'Identifier' &&
449
- (dependencyNode.parent.type === 'MemberExpression' ||
450
- dependencyNode.parent.type === 'OptionalMemberExpression') &&
506
+ (dependencyNode.parent?.type === 'MemberExpression' ||
507
+ dependencyNode.parent?.type === 'OptionalMemberExpression') &&
508
!dependencyNode.parent.computed &&
509
dependencyNode.parent.property.type === 'Identifier' &&
510
dependencyNode.parent.property.name === 'current' &&
518
}
519
520
if (
464
- dependencyNode.parent.type === 'TSTypeQuery' ||
465
- dependencyNode.parent.type === 'TSTypeReference'
521
+ dependencyNode.parent?.type === 'TSTypeQuery' ||
522
+ dependencyNode.parent?.type === 'TSTypeReference'
523
) {
524
continue;
525
}
529
continue;
530
}
531
// Ignore references to the function itself as it's not defined yet.
475
- if (def.node != null && def.node.init === node.parent) {
532
+ if (def.node && def.node.init === node.parent) {
533
continue;
534
}
535
// Ignore Flow type parameters
536
+ // @ts-expect-error We don't have flow types
537
if (def.type === 'TypeParameter') {
538
continue;
539
}
550
references: [reference],
551
});
552
} else {
495
- dependencies.get(dependency).references.push(reference);
553
+ dependencies.get(dependency)?.references.push(reference);
554
}
555
}
556
562
// Warn about accessing .current in cleanup effects.
563
currentRefsInEffectCleanup.forEach(
564
({reference, dependencyNode}, dependency) => {
507
- const references = reference.resolved.references;
565
+ const references = reference.resolved?.references || [];
566
// Is React managing this ref or us?
567
// Let's see if we can find a .current assignment.
568
let foundCurrentAssignment = false;
511
- for (let i = 0; i < references.length; i++) {
512
- const {identifier} = references[i];
569
+ for (const reference of references) {
570
+ const {identifier} = reference;
571
const {parent} = identifier;
572
if (
573
parent != null &&
578
parent.property.type === 'Identifier' &&
579
parent.property.name === 'current' &&
580
// ref.current = <something>
523
- parent.parent.type === 'AssignmentExpression' &&
581
+ parent.parent?.type === 'AssignmentExpression' &&
582
parent.parent.left === parent
583
) {
584
foundCurrentAssignment = true;
590
return;
591
}
592
reportProblem({
593
+ // @ts-expect-error We can do better here (dependencyNode.parent has not been type narrowed)
594
node: dependencyNode.parent.property,
595
message:
596
`The ref value '${dependency}.current' will likely have ` +
604
605
// Warn about assigning to variables in the outer scope.
606
// Those are usually bugs.
548
- const staleAssignments = new Set();
549
- function reportStaleAssignment(writeExpr, key) {
607
+ const staleAssignments = new Set<string>();
608
+ function reportStaleAssignment(writeExpr: Node, key: string): void {
609
if (staleAssignments.has(key)) {
610
return;
611
}
614
node: writeExpr,
615
message:
616
`Assignments to the '${key}' variable from inside React Hook ` +
558
- `${getSource(reactiveHook)} will be lost after each ` +
617
+ `${getSourceCode().getText(reactiveHook)} will be lost after each ` +
618
`render. To preserve the value over time, store it in a useRef ` +
619
`Hook and keep the mutable value in the '.current' property. ` +
620
`Otherwise, you can move this variable directly inside ` +
562
- `${getSource(reactiveHook)}.`,
621
+ `${getSourceCode().getText(reactiveHook)}.`,
622
});
623
}
624
625
// Remember which deps are stable and report bad usage first.
567
- const stableDependencies = new Set();
626
+ const stableDependencies = new Set<string>();
627
dependencies.forEach(({isStable, references}, key) => {
628
if (isStable) {
629
stableDependencies.add(key);
643
if (!declaredDependenciesNode) {
644
// Check if there are any top-level setState() calls.
645
// Those tend to lead to infinite loops.
587
- let setStateInsideEffectWithoutDeps = null;
588
- dependencies.forEach(({isStable, references}, key) => {
646
+ let setStateInsideEffectWithoutDeps: string | null = null;
647
+ dependencies.forEach(({references}, key) => {
648
if (setStateInsideEffectWithoutDeps) {
649
return;
650
}
659
return;
660
}
661
603
- let fnScope = reference.from;
604
- while (fnScope.type !== 'function') {
662
+ let fnScope: Scope.Scope | null = reference.from;
663
+ while (fnScope && fnScope.type !== 'function') {
664
fnScope = fnScope.upper;
665
}
607
- const isDirectlyInsideEffect = fnScope.block === node;
666
+ const isDirectlyInsideEffect = fnScope?.block === node;
667
if (isDirectlyInsideEffect) {
668
// TODO: we could potentially ignore early returns.
669
setStateInsideEffectWithoutDeps = key;
675
dependencies,
676
declaredDependencies: [],
677
stableDependencies,
619
- externalDependencies: new Set(),
678
+ externalDependencies: new Set<string>(),
679
isEffect: true,
680
});
681
reportProblem({
704
return;
705
}
706
648
- const declaredDependencies = [];
649
- const externalDependencies = new Set();
707
+ const declaredDependencies: DeclaredDependency[] = [];
708
+ const externalDependencies = new Set<string>();
709
const isArrayExpression =
710
declaredDependenciesNode.type === 'ArrayExpression';
711
const isTSAsArrayExpression =
719
reportProblem({
720
node: declaredDependenciesNode,
721
message:
663
- `React Hook ${getSource(reactiveHook)} was passed a ` +
722
+ `React Hook ${getSourceCode().getText(reactiveHook)} was passed a ` +
723
'dependency list that is not an array literal. This means we ' +
724
"can't statically verify whether you've passed the correct " +
725
'dependencies.',
729
? declaredDependenciesNode.expression
730
: declaredDependenciesNode;
731
673
- arrayExpression.elements.forEach(declaredDependencyNode => {
674
- // Skip elided elements.
675
- if (declaredDependencyNode === null) {
676
- return;
677
- }
678
- // If we see a spread element then add a special warning.
679
- if (declaredDependencyNode.type === 'SpreadElement') {
680
- reportProblem({
681
- node: declaredDependencyNode,
682
- message:
683
- `React Hook ${getSource(reactiveHook)} has a spread ` +
684
- "element in its dependency array. This means we can't " +
685
- "statically verify whether you've passed the " +
686
- 'correct dependencies.',
687
- });
688
- return;
689
- }
690
- if (useEffectEventVariables.has(declaredDependencyNode)) {
691
- reportProblem({
692
- node: declaredDependencyNode,
693
- message:
694
- 'Functions returned from `useEffectEvent` must not be included in the dependency array. ' +
695
- `Remove \`${getSource(
696
- declaredDependencyNode,
697
- )}\` from the list.`,
698
- suggest: [
699
- {
700
- desc: `Remove the dependency \`${getSource(
732
+ (arrayExpression as ArrayExpression).elements.forEach(
733
+ declaredDependencyNode => {
734
+ // Skip elided elements.
735
+ if (declaredDependencyNode === null) {
736
+ return;
737
+ }
738
+ // If we see a spread element then add a special warning.
739
+ if (declaredDependencyNode.type === 'SpreadElement') {
740
+ reportProblem({
741
+ node: declaredDependencyNode,
742
+ message:
743
+ `React Hook ${getSourceCode().getText(reactiveHook)} has a spread ` +
744
+ "element in its dependency array. This means we can't " +
745
+ "statically verify whether you've passed the " +
746
+ 'correct dependencies.',
747
+ });
748
+ return;
749
+ }
750
+ if (useEffectEventVariables.has(declaredDependencyNode)) {
751
+ reportProblem({
752
+ node: declaredDependencyNode,
753
+ message:
754
+ 'Functions returned from `useEffectEvent` must not be included in the dependency array. ' +
755
+ `Remove \`${getSourceCode().getText(
756
declaredDependencyNode,
702
- )}\``,
703
- fix(fixer) {
704
- return fixer.removeRange(declaredDependencyNode.range);
757
+ )}\` from the list.`,
758
+ suggest: [
759
+ {
760
+ desc: `Remove the dependency \`${getSourceCode().getText(
761
+ declaredDependencyNode,
762
+ )}\``,
763
+ fix(fixer) {
764
+ return fixer.removeRange(declaredDependencyNode.range!);
765
+ },
766
},
706
- },
707
- ],
708
- });
709
- }
710
- // Try to normalize the declared dependency. If we can't then an error
711
- // will be thrown. We will catch that error and report an error.
712
- let declaredDependency;
713
- try {
714
- declaredDependency = analyzePropertyChain(
715
- declaredDependencyNode,
716
- null,
717
- );
718
- } catch (error) {
719
- if (/Unsupported node type/.test(error.message)) {
720
- if (declaredDependencyNode.type === 'Literal') {
721
- if (dependencies.has(declaredDependencyNode.value)) {
722
- reportProblem({
723
- node: declaredDependencyNode,
724
- message:
725
- `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
726
- `because it never changes. ` +
727
- `Did you mean to include ${declaredDependencyNode.value} in the array instead?`,
728
- });
767
+ ],
768
+ });
769
+ }
770
+ // Try to normalize the declared dependency. If we can't then an error
771
+ // will be thrown. We will catch that error and report an error.
772
+ let declaredDependency;
773
+ try {
774
+ declaredDependency = analyzePropertyChain(
775
+ declaredDependencyNode,
776
+ null,
777
+ );
778
+ } catch (error: unknown) {
779
+ if (
780
+ error instanceof Error &&
781
+ /Unsupported node type/.test(error.message)
782
+ ) {
783
+ if (declaredDependencyNode.type === 'Literal') {
784
+ if (
785
+ declaredDependencyNode.value &&
786
+ dependencies.has(declaredDependencyNode.value as string)
787
+ ) {
788
+ reportProblem({
789
+ node: declaredDependencyNode,
790
+ message:
791
+ `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
792
+ `because it never changes. ` +
793
+ `Did you mean to include ${declaredDependencyNode.value} in the array instead?`,
794
+ });
795
+ } else {
796
+ reportProblem({
797
+ node: declaredDependencyNode,
798
+ message:
799
+ `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
800
+ 'because it never changes. You can safely remove it.',
801
+ });
802
+ }
803
} else {
804
reportProblem({
805
node: declaredDependencyNode,
806
message:
733
- `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
734
- 'because it never changes. You can safely remove it.',
807
+ `React Hook ${getSourceCode().getText(reactiveHook)} has a ` +
808
+ `complex expression in the dependency array. ` +
809
+ 'Extract it to a separate variable so it can be statically checked.',
810
});
811
}
812
+
813
+ return;
814
} else {
738
- reportProblem({
739
- node: declaredDependencyNode,
740
- message:
741
- `React Hook ${getSource(reactiveHook)} has a ` +
742
- `complex expression in the dependency array. ` +
743
- 'Extract it to a separate variable so it can be statically checked.',
744
- });
815
+ throw error;
816
}
746
-
747
- return;
748
- } else {
749
- throw error;
817
}
751
- }
818
753
- let maybeID = declaredDependencyNode;
754
- while (
755
- maybeID.type === 'MemberExpression' ||
756
- maybeID.type === 'OptionalMemberExpression' ||
757
- maybeID.type === 'ChainExpression'
758
- ) {
759
- maybeID = maybeID.object || maybeID.expression.object;
760
- }
761
- const isDeclaredInComponent = !componentScope.through.some(
762
- ref => ref.identifier === maybeID,
763
- );
819
+ let maybeID = declaredDependencyNode;
820
+ while (
821
+ maybeID.type === 'MemberExpression' ||
822
+ maybeID.type === 'OptionalMemberExpression' ||
823
+ maybeID.type === 'ChainExpression'
824
+ ) {
825
+ // @ts-expect-error This can be done better
826
+ maybeID = maybeID.object || maybeID.expression.object;
827
+ }
828
+ const isDeclaredInComponent = !componentScope.through.some(
829
+ ref => ref.identifier === maybeID,
830
+ );
831
765
- // Add the dependency to our declared dependency map.
766
- declaredDependencies.push({
767
- key: declaredDependency,
768
- node: declaredDependencyNode,
769
- });
832
+ // Add the dependency to our declared dependency map.
833
+ declaredDependencies.push({
834
+ key: declaredDependency,
835
+ node: declaredDependencyNode,
836
+ });
837
771
- if (!isDeclaredInComponent) {
772
- externalDependencies.add(declaredDependency);
773
- }
774
- });
838
+ if (!isDeclaredInComponent) {
839
+ externalDependencies.add(declaredDependency);
840
+ }
841
+ },
842
+ );
843
}
844
845
const {
892
893
const message =
894
`The '${construction.name.name}' ${depType} ${causation} the dependencies of ` +
827
- `${reactiveHookName} Hook (at line ${declaredDependenciesNode.loc.start.line}) ` +
895
+ `${reactiveHookName} Hook (at line ${declaredDependenciesNode.loc?.start.line}) ` +
896
`change on every render. ${advice}`;
897
830
- let suggest;
898
+ let suggest: Rule.ReportDescriptor['suggest'];
899
// Only handle the simple case of variable assignments.
900
// Wrapping function declarations can mess up hoisting.
901
if (
916
: ['useCallback(', ')'];
917
return [
918
// TODO: also add an import?
851
- fixer.insertTextBefore(construction.node.init, before),
919
+ fixer.insertTextBefore(construction.node.init!, before),
920
// TODO: ideally we'd gather deps here but it would require
921
// restructuring the rule code. This will cause a new lint
922
// error to appear immediately for useCallback. Note we're
923
// not adding [] because would that changes semantics.
856
- fixer.insertTextAfter(construction.node.init, after),
924
+ fixer.insertTextAfter(construction.node.init!, after),
925
];
926
},
927
},
957
}
958
959
// Alphabetize the suggestions, but only if deps were already alphabetized.
892
- function areDeclaredDepsAlphabetized() {
960
+ function areDeclaredDepsAlphabetized(): boolean {
961
if (declaredDependencies.length === 0) {
962
return true;
963
}
973
// This function is the last step before printing a dependency, so now is a good time to
974
// check whether any members in our path are always used as optional-only. In that case,
975
// we will use ?. instead of . to concatenate those parts of the path.
908
- function formatDependency(path) {
976
+ function formatDependency(path: string): string {
977
const members = path.split('.');
978
let finalPath = '';
979
for (let i = 0; i < members.length; i++) {
987
return finalPath;
988
}
989
922
- function getWarningMessage(deps, singlePrefix, label, fixVerb) {
990
+ function getWarningMessage(
991
+ deps: Set<string>,
992
+ singlePrefix: string,
993
+ label: string,
994
+ fixVerb: string,
995
+ ): string | null {
996
if (deps.size === 0) {
997
return null;
998
}
1015
1016
let extraWarning = '';
1017
if (unnecessaryDependencies.size > 0) {
945
- let badRef = null;
1018
+ let badRef: string | null = null;
1019
Array.from(unnecessaryDependencies.keys()).forEach(key => {
1020
if (badRef !== null) {
1021
return;
1029
` Mutable values like '${badRef}' aren't valid dependencies ` +
1030
"because mutating them doesn't re-render the component.";
1031
} else if (externalDependencies.size > 0) {
959
- const dep = Array.from(externalDependencies)[0];
1032
+ const dep = Array.from(externalDependencies)[0]!;
1033
// Don't show this warning for things that likely just got moved *inside* the callback
1034
// because in that case they're clearly not referring to globals.
1035
if (!scope.set.has(dep)) {
1053
return;
1054
}
1055
let isPropsOnlyUsedInMembers = true;
983
- for (let i = 0; i < refs.length; i++) {
984
- const ref = refs[i];
1056
+ for (const ref of refs) {
1057
const id = fastFindReferenceWithParent(
1058
componentScope.block,
1059
ref.identifier,
1080
` However, 'props' will change when *any* prop changes, so the ` +
1081
`preferred fix is to destructure the 'props' object outside of ` +
1082
`the ${reactiveHookName} call and refer to those specific props ` +
1011
- `inside ${getSource(reactiveHook)}.`;
1083
+ `inside ${getSourceCode().getText(reactiveHook)}.`;
1084
}
1085
}
1086
1087
if (!extraWarning && missingDependencies.size > 0) {
1088
// See if the user is trying to avoid specifying a callable prop.
1089
// This usually means they're unaware of useCallback.
1018
- let missingCallbackDep = null;
1090
+ let missingCallbackDep: string | null = null;
1091
missingDependencies.forEach(missingDep => {
1092
if (missingCallbackDep) {
1093
return;
1095
// Is this a variable from top scope?
1096
const topScopeRef = componentScope.set.get(missingDep);
1097
const usedDep = dependencies.get(missingDep);
1026
- if (usedDep.references[0].resolved !== topScopeRef) {
1098
+ if (
1099
+ !usedDep?.references ||
1100
+ usedDep?.references[0]?.resolved !== topScopeRef
1101
+ ) {
1102
return;
1103
}
1104
// Is this a destructured prop?
1030
- const def = topScopeRef.defs[0];
1105
+ const def = topScopeRef?.defs[0];
1106
if (def == null || def.name == null || def.type !== 'Parameter') {
1107
return;
1108
}
1109
// Was it called in at least one case? Then it's a function.
1110
let isFunctionCall = false;
1036
- let id;
1037
- for (let i = 0; i < usedDep.references.length; i++) {
1038
- id = usedDep.references[i].identifier;
1111
+ let id: Identifier | undefined;
1112
+ for (const reference of usedDep.references) {
1113
+ id = reference.identifier;
1114
if (
1115
id != null &&
1116
id.parent != null &&
1139
}
1140
1141
if (!extraWarning && missingDependencies.size > 0) {
1067
- let setStateRecommendation = null;
1068
- missingDependencies.forEach(missingDep => {
1142
+ let setStateRecommendation: {
1143
+ missingDep: string;
1144
+ setter: string;
1145
+ form: 'reducer' | 'updater' | 'inlineReducer';
1146
+ } | null = null;
1147
+ for (const missingDep of missingDependencies) {
1148
if (setStateRecommendation !== null) {
1070
- return;
1149
+ break;
1150
}
1072
- const usedDep = dependencies.get(missingDep);
1151
+ const usedDep = dependencies.get(missingDep)!;
1152
const references = usedDep.references;
1153
let id;
1154
let maybeCall;
1076
- for (let i = 0; i < references.length; i++) {
1077
- id = references[i].identifier;
1155
+ for (const reference of references) {
1156
+ id = reference.identifier;
1157
maybeCall = id.parent;
1158
// Try to see if we have setState(someExpr(missingDep)).
1159
while (maybeCall != null && maybeCall !== componentScope.block) {
1162
maybeCall.callee,
1163
);
1164
if (correspondingStateVariable != null) {
1086
- if (correspondingStateVariable.name === missingDep) {
1165
+ if (
1166
+ 'name' in correspondingStateVariable &&
1167
+ correspondingStateVariable.name === missingDep
1168
+ ) {
1169
// setCount(count + 1)
1170
setStateRecommendation = {
1171
missingDep,
1090
- setter: maybeCall.callee.name,
1172
+ setter:
1173
+ 'name' in maybeCall.callee ? maybeCall.callee.name : '',
1174
form: 'updater',
1175
};
1176
} else if (stateVariables.has(id)) {
1177
// setCount(count + increment)
1178
setStateRecommendation = {
1179
missingDep,
1097
- setter: maybeCall.callee.name,
1180
+ setter:
1181
+ 'name' in maybeCall.callee ? maybeCall.callee.name : '',
1182
form: 'reducer',
1183
};
1184
} else {
1101
- const resolved = references[i].resolved;
1185
+ const resolved = reference.resolved;
1186
if (resolved != null) {
1187
// If it's a parameter *and* a missing dep,
1188
// it must be a prop or something inside a prop.
1191
if (def != null && def.type === 'Parameter') {
1192
setStateRecommendation = {
1193
missingDep,
1110
- setter: maybeCall.callee.name,
1194
+ setter:
1195
+ 'name' in maybeCall.callee
1196
+ ? maybeCall.callee.name
1197
+ : '',
1198
form: 'inlineReducer',
1199
};
1200
}
1209
break;
1210
}
1211
}
1125
- });
1212
+ }
1213
if (setStateRecommendation !== null) {
1214
switch (setStateRecommendation.form) {
1215
case 'reducer':
1245
reportProblem({
1246
node: declaredDependenciesNode,
1247
message:
1161
- `React Hook ${getSource(reactiveHook)} has ` +
1248
+ `React Hook ${getSourceCode().getText(reactiveHook)} has ` +
1249
// To avoid a long message, show the next actionable item.
1250
(getWarningMessage(missingDependencies, 'a', 'missing', 'include') ||
1251
getWarningMessage(
1278
});
1279
}
1280
1194
- function visitCallExpression(node) {
1281
+ function visitCallExpression(node: CallExpression): void {
1282
const callbackIndex = getReactiveHookCallbackIndex(node.callee, options);
1283
if (callbackIndex === -1) {
1284
// Not a React Hook call that needs deps.
1286
}
1287
let callback = node.arguments[callbackIndex];
1288
const reactiveHook = node.callee;
1202
- const reactiveHookName = getNodeWithoutReactNamespace(reactiveHook).name;
1289
+ const nodeWithoutNamespace = getNodeWithoutReactNamespace(reactiveHook);
1290
+ const reactiveHookName =
1291
+ 'name' in nodeWithoutNamespace ? nodeWithoutNamespace.name : '';
1292
const maybeNode = node.arguments[callbackIndex + 1];
1293
const declaredDependenciesNode =
1294
maybeNode &&
1357
// The function passed as a callback is not written inline.
1358
// But perhaps it's in the dependencies array?
1359
if (
1360
+ 'elements' in declaredDependenciesNode &&
1361
declaredDependenciesNode.elements &&
1362
declaredDependenciesNode.elements.some(
1363
el => el && el.type === 'Identifier' && el.name === callback.name,
1458
CallExpression: visitCallExpression,
1459
};
1460
},
1371
-};
1461
+} satisfies Rule.RuleModule;
1462
1463
// The meat of the logic.
1464
function collectRecommendations({
1467
stableDependencies,
1468
externalDependencies,
1469
isEffect,
1470
+}: {
1471
+ dependencies: Map<string, Dependency>;
1472
+ declaredDependencies: DeclaredDependency[];
1473
+ stableDependencies: Set<string>;
1474
+ externalDependencies: Set<string>;
1475
+ isEffect: boolean;
1476
}) {
1477
// Our primary data structure.
1478
// It is a logical representation of property chains:
1484
// and the nodes that were *declared* as deps. Then we will
1485
// traverse it to learn which deps are missing or unnecessary.
1486
const depTree = createDepTree();
1391
- function createDepTree() {
1487
+ function createDepTree(): DependencyTreeNode {
1488
return {
1489
isUsed: false, // True if used in code
1490
isSatisfiedRecursively: false, // True if specified in deps
1515
});
1516
1517
// Tree manipulation helpers.
1422
- function getOrCreateNodeByPath(rootNode, path) {
1518
+ function getOrCreateNodeByPath(
1519
+ rootNode: DependencyTreeNode,
1520
+ path: string,
1521
+ ): DependencyTreeNode {
1522
const keys = path.split('.');
1523
let node = rootNode;
1524
for (const key of keys) {
1531
}
1532
return node;
1533
}
1435
- function markAllParentsByPath(rootNode, path, fn) {
1534
+ function markAllParentsByPath(
1535
+ rootNode: DependencyTreeNode,
1536
+ path: string,
1537
+ fn: (node: DependencyTreeNode) => void,
1538
+ ): void {
1539
const keys = path.split('.');
1540
let node = rootNode;
1541
for (const key of keys) {
1549
}
1550
1551
// Now we can learn which dependencies are missing or necessary.
1449
- const missingDependencies = new Set();
1450
- const satisfyingDependencies = new Set();
1552
+ const missingDependencies = new Set<string>();
1553
+ const satisfyingDependencies = new Set<string>();
1554
scanTreeRecursively(
1555
depTree,
1556
missingDependencies,
1557
satisfyingDependencies,
1558
key => key,
1559
);
1457
- function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) {
1560
+ function scanTreeRecursively(
1561
+ node: DependencyTreeNode,
1562
+ missingPaths: Set<string>,
1563
+ satisfyingPaths: Set<string>,
1564
+ keyToPath: (key: string) => string,
1565
+ ): void {
1566
node.children.forEach((child, key) => {
1567
const path = keyToPath(key);
1568
if (child.isSatisfiedRecursively) {
1592
}
1593
1594
// Collect suggestions in the order they were originally specified.
1487
- const suggestedDependencies = [];
1488
- const unnecessaryDependencies = new Set();
1489
- const duplicateDependencies = new Set();
1595
+ const suggestedDependencies: string[] = [];
1596
+ const unnecessaryDependencies = new Set<string>();
1597
+ const duplicateDependencies = new Set<string>();
1598
declaredDependencies.forEach(({key}) => {
1599
// Does this declared dep satisfy a real need?
1600
if (satisfyingDependencies.has(key)) {
1640
1641
// If the node will result in constructing a referentially unique value, return
1642
// its human readable type name, else return null.
1535
-function getConstructionExpressionType(node) {
1643
+function getConstructionExpressionType(node: Node): string | null {
1644
switch (node.type) {
1645
case 'ObjectExpression':
1646
return 'object';
1698
declaredDependenciesNode,
1699
componentScope,
1700
scope,
1701
+}: {
1702
+ declaredDependencies: DeclaredDependency[];
1703
+ declaredDependenciesNode: Node;
1704
+ componentScope: Scope.Scope;
1705
+ scope: Scope.Scope;
1706
}) {
1707
const constructions = declaredDependencies
1708
.map(({key}) => {
1729
const constantExpressionType = getConstructionExpressionType(
1730
node.node.init,
1731
);
1619
- if (constantExpressionType != null) {
1732
+ if (constantExpressionType) {
1733
return [ref, constantExpressionType];
1734
}
1735
}
1747
}
1748
return null;
1749
})
1637
- .filter(Boolean);
1750
+ .filter(Boolean) as [Scope.Variable, string][];
1751
1639
- function isUsedOutsideOfHook(ref) {
1752
+ function isUsedOutsideOfHook(ref: Scope.Variable): boolean {
1753
let foundWriteExpr = false;
1641
- for (let i = 0; i < ref.references.length; i++) {
1642
- const reference = ref.references[i];
1754
+ for (const reference of ref.references) {
1755
if (reference.writeExpr) {
1756
if (foundWriteExpr) {
1757
// Two writes to the same function.
1762
continue;
1763
}
1764
}
1653
- let currentScope = reference.from;
1765
+ let currentScope: Scope.Scope | null = reference.from;
1766
while (currentScope !== scope && currentScope != null) {
1767
currentScope = currentScope.upper;
1768
}
1778
}
1779
1780
return constructions.map(([ref, depType]) => ({
1669
- construction: ref.defs[0],
1781
+ construction: ref.defs[0] as Scope.Definition,
1782
depType,
1783
isUsedOutsideOfHook: isUsedOutsideOfHook(ref),
1784
}));
1791
* props.foo.(bar) => (props).foo.bar
1792
* props.foo.bar.(baz) => (props).foo.bar.baz
1793
*/
1682
-function getDependency(node) {
1794
+function getDependency(node: Node): Node {
1795
if (
1796
+ node.parent &&
1797
(node.parent.type === 'MemberExpression' ||
1798
node.parent.type === 'OptionalMemberExpression') &&
1799
node.parent.object === node &&
1800
+ 'name' in node.parent.property &&
1801
node.parent.property.name !== 'current' &&
1802
!node.parent.computed &&
1803
!(
1827
* It just means there is an optional member somewhere inside.
1828
* This particular node might still represent a required member, so check .optional field.
1829
*/
1716
-function markNode(node, optionalChains, result) {
1830
+function markNode(
1831
+ node: Node,
1832
+ optionalChains: Map<string, boolean> | null,
1833
+ result: string,
1834
+): void {
1835
if (optionalChains) {
1718
- if (node.optional) {
1836
+ if ('optional' in node && node.optional) {
1837
// We only want to consider it optional if *all* usages were optional.
1838
if (!optionalChains.has(result)) {
1839
// Mark as (maybe) optional. If there's a required usage, this will be overridden.
1853
* foo.bar(.)baz -> 'foo.bar.baz'
1854
* Otherwise throw.
1855
*/
1738
-function analyzePropertyChain(node, optionalChains) {
1856
+function analyzePropertyChain(
1857
+ node: Node,
1858
+ optionalChains: Map<string, boolean> | null,
1859
+): string {
1860
if (node.type === 'Identifier' || node.type === 'JSXIdentifier') {
1861
const result = node.name;
1862
if (optionalChains) {
1876
const result = `${object}.${property}`;
1877
markNode(node, optionalChains, result);
1878
return result;
1758
- } else if (node.type === 'ChainExpression' && !node.computed) {
1879
+ } else if (
1880
+ node.type === 'ChainExpression' &&
1881
+ (!('computed' in node) || !node.computed)
1882
+ ) {
1883
const expression = node.expression;
1884
1885
if (expression.type === 'CallExpression') {
1896
}
1897
}
1898
1775
-function getNodeWithoutReactNamespace(node, options) {
1899
+function getNodeWithoutReactNamespace(
1900
+ node: Expression | Super,
1901
+): Expression | Identifier | Super {
1902
if (
1903
node.type === 'MemberExpression' &&
1904
node.object.type === 'Identifier' &&
1916
// 0 for useEffect/useMemo/useCallback(fn).
1917
// 1 for useImperativeHandle(ref, fn).
1918
// For additionally configured Hooks, assume that they're like useEffect (0).
1793
-function getReactiveHookCallbackIndex(calleeNode, options) {
1919
+function getReactiveHookCallbackIndex(
1920
+ calleeNode: Expression | Super,
1921
+ options?: {
1922
+ additionalHooks: RegExp | undefined;
1923
+ enableDangerousAutofixThisMayCauseInfiniteLoops?: boolean;
1924
+ },
1925
+): 0 | -1 | 1 {
1926
const node = getNodeWithoutReactNamespace(calleeNode);
1927
if (node.type !== 'Identifier') {
1928
return -1;
1944
let name;
1945
try {
1946
name = analyzePropertyChain(node, null);
1815
- } catch (error) {
1816
- if (/Unsupported node type/.test(error.message)) {
1947
+ } catch (error: unknown) {
1948
+ if (
1949
+ error instanceof Error &&
1950
+ /Unsupported node type/.test(error.message)
1951
+ ) {
1952
return 0;
1953
} else {
1954
throw error;
1971
* - optimized by only searching nodes with a range surrounding our target node
1972
* - agnostic to AST node types, it looks for `{ type: string, ... }`
1973
*/
1839
-function fastFindReferenceWithParent(start, target) {
1974
+function fastFindReferenceWithParent(start: Node, target: Node): Node | null {
1975
const queue = [start];
1841
- let item = null;
1976
+ let item: Node;
1977
1978
while (queue.length) {
1844
- item = queue.shift();
1979
+ item = queue.shift() as Node;
1980
1981
if (isSameIdentifier(item, target)) {
1982
return item;
2007
return null;
2008
}
2009
1875
-function joinEnglish(arr) {
2010
+function joinEnglish(arr: string[]): string {
2011
let s = '';
2012
for (let i = 0; i < arr.length; i++) {
2013
s += arr[i];
2022
return s;
2023
}
2024
1890
-function isNodeLike(val) {
2025
+function isNodeLike(val: unknown): boolean {
2026
return (
2027
typeof val === 'object' &&
2028
val !== null &&
2029
!Array.isArray(val) &&
2030
+ 'type' in val &&
2031
typeof val.type === 'string'
2032
);
2033
}
2034
1899
-function isSameIdentifier(a, b) {
2035
+function isSameIdentifier(a: Node, b: Node): boolean {
2036
return (
2037
(a.type === 'Identifier' || a.type === 'JSXIdentifier') &&
2038
a.type === b.type &&
2039
a.name === b.name &&
2040
+ !!a.range &&
2041
+ !!b.range &&
2042
a.range[0] === b.range[0] &&
2043
a.range[1] === b.range[1]
2044
);
2045
}
2046
1909
-function isAncestorNodeOf(a, b) {
1910
- return a.range[0] <= b.range[0] && a.range[1] >= b.range[1];
2047
+function isAncestorNodeOf(a: Node, b: Node): boolean {
2048
+ return (
2049
+ !!a.range &&
2050
+ !!b.range &&
2051
+ a.range[0] <= b.range[0] &&
2052
+ a.range[1] >= b.range[1]
2053
+ );
2054
}
2055
1913
-function isUseEffectEventIdentifier(node) {
2056
+function isUseEffectEventIdentifier(node: Node): boolean {
2057
if (__EXPERIMENTAL__) {
2058
return node.type === 'Identifier' && node.name === 'useEffectEvent';
2059
}
2060
return false;
2061
}
2062
1920
-function getUnknownDependenciesMessage(reactiveHookName) {
2063
+function getUnknownDependenciesMessage(reactiveHookName: string): string {
2064
return (
2065
`React Hook ${reactiveHookName} received a function whose dependencies ` +
2066
`are unknown. Pass an inline function instead.`
2067
);
2068
}
2069
+
2070
+export default rule;