@agilebot/eslint-plugin 0.1.5 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,1921 +1,1923 @@
1
- /**
2
- * Copyright (c) Facebook, Inc. and its 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
- module.exports = {
9
- meta: {
10
- type: 'suggestion',
11
- docs: {
12
- description:
13
- 'verifies the list of dependencies for Hooks like useEffect and similar',
14
- recommended: true,
15
- url: 'https://github.com/facebook/react/issues/14920'
16
- },
17
- fixable: 'code',
18
- hasSuggestions: true,
19
- schema: [
20
- {
21
- type: 'object',
22
- additionalProperties: false,
23
- enableDangerousAutofixThisMayCauseInfiniteLoops: false,
24
- properties: {
25
- additionalHooks: {
26
- type: 'string'
27
- },
28
- enableDangerousAutofixThisMayCauseInfiniteLoops: {
29
- type: 'boolean'
30
- },
31
- staticHooks: {
32
- type: 'object',
33
- additionalProperties: {
34
- oneOf: [
35
- {
36
- type: 'boolean'
37
- },
38
- {
39
- type: 'array',
40
- items: {
41
- type: 'boolean'
42
- }
43
- },
44
- {
45
- type: 'object',
46
- additionalProperties: {
47
- type: 'boolean'
48
- }
49
- }
50
- ]
51
- }
52
- },
53
- checkMemoizedVariableIsStatic: {
54
- type: 'boolean'
55
- }
56
- }
57
- }
58
- ]
59
- },
60
- create(context) {
61
- // Parse the `additionalHooks` regex.
62
- const additionalHooks =
63
- context.options &&
64
- context.options[0] &&
65
- context.options[0].additionalHooks
66
- ? new RegExp(context.options[0].additionalHooks)
67
- : undefined;
68
-
69
- const enableDangerousAutofixThisMayCauseInfiniteLoops =
70
- (context.options &&
71
- context.options[0] &&
72
- context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) ||
73
- false;
74
-
75
- // Parse the `staticHooks` object.
76
- const staticHooks =
77
- (context.options &&
78
- context.options[0] &&
79
- context.options[0].staticHooks) ||
80
- {};
81
-
82
- const checkMemoizedVariableIsStatic =
83
- (context.options &&
84
- context.options[0] &&
85
- context.options[0].checkMemoizedVariableIsStatic) ||
86
- false;
87
-
88
- const options = {
89
- additionalHooks,
90
- enableDangerousAutofixThisMayCauseInfiniteLoops,
91
- staticHooks,
92
- checkMemoizedVariableIsStatic
93
- };
94
-
95
- function reportProblem(problem) {
96
- if (
97
- enableDangerousAutofixThisMayCauseInfiniteLoops && // Used to enable legacy behavior. Dangerous.
98
- // Keep this as an option until major IDEs upgrade (including VSCode FB ESLint extension).
99
- Array.isArray(problem.suggest) &&
100
- problem.suggest.length > 0
101
- ) {
102
- problem.fix = problem.suggest[0].fix;
103
- }
104
- context.report(problem);
105
- }
106
-
107
- const scopeManager = context.getSourceCode().scopeManager;
108
-
109
- // Should be shared between visitors.
110
- const setStateCallSites = new WeakMap();
111
- const stateVariables = new WeakSet();
112
- const stableKnownValueCache = new WeakMap();
113
- const functionWithoutCapturedValueCache = new WeakMap();
114
- function memoizeWithWeakMap(fn, map) {
115
- return function (arg) {
116
- if (map.has(arg)) {
117
- // to verify cache hits:
118
- // console.log(arg.name)
119
- return map.get(arg);
120
- }
121
- const result = fn(arg);
122
- map.set(arg, result);
123
- return result;
124
- };
125
- }
126
- /**
127
- * Visitor for both function expressions and arrow function expressions.
128
- */
129
- function visitFunctionWithDependencies(
130
- node,
131
- declaredDependenciesNode,
132
- reactiveHook,
133
- reactiveHookName,
134
- isEffect
135
- ) {
136
- if (isEffect && node.async) {
137
- reportProblem({
138
- node: node,
139
- message:
140
- `Effect callbacks are synchronous to prevent race conditions. ` +
141
- `Put the async function inside:\n\n` +
142
- 'useEffect(() => {\n' +
143
- ' async function fetchData() {\n' +
144
- ' // You can await here\n' +
145
- ' const response = await MyAPI.getData(someId);\n' +
146
- ' // ...\n' +
147
- ' }\n' +
148
- ' fetchData();\n' +
149
- `}, [someId]); // Or [] if effect doesn't need props or state\n\n` +
150
- 'Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching'
151
- });
152
- }
153
-
154
- // Get the current scope.
155
- const scope = scopeManager.acquire(node);
156
-
157
- // Find all our "pure scopes". On every re-render of a component these
158
- // pure scopes may have changes to the variables declared within. So all
159
- // variables used in our reactive hook callback but declared in a pure
160
- // scope need to be listed as dependencies of our reactive hook callback.
161
- //
162
- // According to the rules of React you can't read a mutable value in pure
163
- // scope. We can't enforce this in a lint so we trust that all variables
164
- // declared outside of pure scope are indeed frozen.
165
- const pureScopes = new Set();
166
- let componentScope = null;
167
- {
168
- let currentScope = scope.upper;
169
- while (currentScope) {
170
- pureScopes.add(currentScope);
171
- if (currentScope.type === 'function') {
172
- break;
173
- }
174
- currentScope = currentScope.upper;
175
- }
176
- // If there is no parent function scope then there are no pure scopes.
177
- // The ones we've collected so far are incorrect. So don't continue with
178
- // the lint.
179
- if (!currentScope) {
180
- return;
181
- }
182
- componentScope = currentScope;
183
- }
184
-
185
- const isArray = Array.isArray;
186
-
187
- // Remember such values. Avoid re-running extra checks on them.
188
- const memoizedIsStableKnownHookValue = memoizeWithWeakMap(
189
- isStableKnownHookValue,
190
- stableKnownValueCache
191
- );
192
- const memoizedIsFunctionWithoutCapturedValues = memoizeWithWeakMap(
193
- isFunctionWithoutCapturedValues,
194
- functionWithoutCapturedValueCache
195
- );
196
-
197
- // Next we'll define a few helpers that helps us
198
- // tell if some values don't have to be declared as deps.
199
-
200
- // Some are known to be stable based on Hook calls.
201
- // const [state, setState] = useState() / React.useState()
202
- // ^^^ true for this reference
203
- // const [state, dispatch] = useReducer() / React.useReducer()
204
- // ^^^ true for this reference
205
- // const ref = useRef()
206
- // ^^^ true for this reference
207
- // False for everything else.
208
- // True if the value is registered in staticHooks.
209
- // True if the value is from useMemo() or useCallback() and has zero deps.
210
- function isStableKnownHookValue(resolved) {
211
- if (!isArray(resolved.defs)) {
212
- return false;
213
- }
214
- const def = resolved.defs[0];
215
- if (def == null) {
216
- return false;
217
- }
218
- // Look for `let stuff = ...`
219
- if (def.node.type !== 'VariableDeclarator') {
220
- return false;
221
- }
222
- let init = def.node.init;
223
- if (init == null) {
224
- return false;
225
- }
226
- while (init.type === 'TSAsExpression') {
227
- init = init.expression;
228
- }
229
- // Detect primitive constants
230
- // const foo = 42
231
- let declaration = def.node.parent;
232
- if (declaration == null) {
233
- // This might happen if variable is declared after the callback.
234
- // In that case ESLint won't set up .parent refs.
235
- // So we'll set them up manually.
236
- fastFindReferenceWithParent(componentScope.block, def.node.id);
237
- declaration = def.node.parent;
238
- if (declaration == null) {
239
- return false;
240
- }
241
- }
242
- if (
243
- declaration.kind === 'const' &&
244
- init.type === 'Literal' &&
245
- (typeof init.value === 'string' ||
246
- typeof init.value === 'number' ||
247
- init.value == null)
248
- ) {
249
- // Definitely stable
250
- return true;
251
- }
252
- // Detect known Hook calls
253
- // const [_, setState] = useState()
254
- if (init.type !== 'CallExpression') {
255
- return false;
256
- }
257
- let callee = init.callee;
258
- // Step into `= React.something` initializer.
259
- if (
260
- callee.type === 'MemberExpression' &&
261
- callee.object.name === 'React' &&
262
- callee.property != null &&
263
- !callee.computed
264
- ) {
265
- callee = callee.property;
266
- }
267
- if (callee.type !== 'Identifier') {
268
- return false;
269
- }
270
- const id = def.node.id;
271
- const { name } = callee;
272
- if (name === 'useRef' && id.type === 'Identifier') {
273
- // useRef() return value is stable.
274
- return true;
275
- } else if (name === 'useState' || name === 'useReducer') {
276
- // Only consider second value in initializing tuple stable.
277
- if (
278
- id.type === 'ArrayPattern' &&
279
- id.elements.length === 2 &&
280
- isArray(resolved.identifiers)
281
- ) {
282
- // Is second tuple value the same reference we're checking?
283
- if (id.elements[1] === resolved.identifiers[0]) {
284
- if (name === 'useState') {
285
- const references = resolved.references;
286
- let writeCount = 0;
287
- for (const reference of references) {
288
- if (reference.isWrite()) {
289
- writeCount++;
290
- }
291
- if (writeCount > 1) {
292
- return false;
293
- }
294
- setStateCallSites.set(reference.identifier, id.elements[0]);
295
- }
296
- }
297
- // Setter is stable.
298
- return true;
299
- } else if (id.elements[0] === resolved.identifiers[0]) {
300
- if (name === 'useState') {
301
- const references = resolved.references;
302
- for (const reference of references) {
303
- stateVariables.add(reference.identifier);
304
- }
305
- }
306
- // State variable itself is dynamic.
307
- return false;
308
- }
309
- }
310
- } else if (name === 'useTransition') {
311
- // Only consider second value in initializing tuple stable.
312
- if (
313
- id.type === 'ArrayPattern' &&
314
- id.elements.length === 2 &&
315
- Array.isArray(resolved.identifiers) && // Is second tuple value the same reference we're checking?
316
- id.elements[1] === resolved.identifiers[0]
317
- ) {
318
- // Setter is stable.
319
- return true;
320
- }
321
- } else if (
322
- options.checkMemoizedVariableIsStatic &&
323
- (name === 'useMemo' || name === 'useCallback')
324
- ) {
325
- // check memoized value is stable
326
- // useMemo(() => { ... }, []) / useCallback((...) => { ... }, [])
327
- const hookArgs = callee.parent.arguments;
328
- // check it has dependency list
329
- if (hookArgs.length < 2) return false;
330
-
331
- const dependencies = hookArgs[1].elements;
332
- if (dependencies.length === 0) {
333
- // no dependency, so it's stable
334
- return true;
335
- }
336
-
337
- // check all dependency is stable
338
- for (const dependencyNode of dependencies) {
339
- // find resolved from resolved's scope
340
- // TODO: check dependencyNode is in arguments?
341
- const dependencyRefernece = resolved.scope.references.find(
342
- reference => reference.identifier === dependencyNode
343
- );
344
-
345
- if (
346
- dependencyRefernece !== undefined &&
347
- memoizedIsStableKnownHookValue(dependencyRefernece.resolved)
348
- ) {
349
- continue;
350
- } else {
351
- return false;
352
- }
353
- }
354
-
355
- return true;
356
- } else {
357
- // filter regexp first
358
- Object.entries(options.staticHooks).forEach(([key, staticParts]) => {
359
- if (
360
- typeof staticParts === 'object' &&
361
- staticParts.regexp &&
362
- new RegExp(key).test(name)
363
- ) {
364
- options.staticHooks[name] = staticParts.value;
365
- }
366
- });
367
-
368
- if (options.staticHooks[name]) {
369
- const staticParts = options.staticHooks[name];
370
- if (staticParts === true) {
371
- // entire return value is static
372
- return true;
373
- } else if (Array.isArray(staticParts)) {
374
- // destructured tuple return where some elements are static
375
- if (
376
- id.type === 'ArrayPattern' &&
377
- id.elements.length <= staticParts.length &&
378
- Array.isArray(resolved.identifiers)
379
- ) {
380
- // find index of the resolved ident in the array pattern
381
- const idx = id.elements.indexOf(resolved.identifiers[0]);
382
- if (idx >= 0) {
383
- return staticParts[idx];
384
- }
385
- }
386
- } else if (
387
- typeof staticParts === 'object' &&
388
- id.type === 'ObjectPattern'
389
- ) {
390
- // destructured object return where some properties are static
391
- const property = id.properties.find(
392
- p => p.key === resolved.identifiers[0]
393
- );
394
- if (property) {
395
- return staticParts[property.key.name];
396
- }
397
- }
398
- }
399
- }
400
- // By default assume it's dynamic.
401
- return false;
402
- }
403
-
404
- // Some are just functions that don't reference anything dynamic.
405
- function isFunctionWithoutCapturedValues(resolved) {
406
- if (!isArray(resolved.defs)) {
407
- return false;
408
- }
409
- const def = resolved.defs[0];
410
- if (def == null) {
411
- return false;
412
- }
413
- if (def.node == null || def.node.id == null) {
414
- return false;
415
- }
416
- // Search the direct component subscopes for
417
- // top-level function definitions matching this reference.
418
- const fnNode = def.node;
419
- const childScopes = componentScope.childScopes;
420
- let fnScope = null;
421
- let i;
422
- for (i = 0; i < childScopes.length; i++) {
423
- const childScope = childScopes[i];
424
- const childScopeBlock = childScope.block;
425
- if (
426
- // function handleChange() {}
427
- (fnNode.type === 'FunctionDeclaration' &&
428
- childScopeBlock === fnNode) ||
429
- // const handleChange = () => {}
430
- // const handleChange = function() {}
431
- (fnNode.type === 'VariableDeclarator' &&
432
- childScopeBlock.parent === fnNode)
433
- ) {
434
- // Found it!
435
- fnScope = childScope;
436
- break;
437
- }
438
- }
439
- if (fnScope == null) {
440
- return false;
441
- }
442
- // Does this function capture any values
443
- // that are in pure scopes (aka render)?
444
- for (i = 0; i < fnScope.through.length; i++) {
445
- const ref = fnScope.through[i];
446
- if (ref.resolved == null) {
447
- continue;
448
- }
449
- if (
450
- pureScopes.has(ref.resolved.scope) &&
451
- // Stable values are fine though,
452
- // although we won't check functions deeper.
453
- !memoizedIsStableKnownHookValue(ref.resolved)
454
- ) {
455
- return false;
456
- }
457
- }
458
- // If we got here, this function doesn't capture anything
459
- // from render--or everything it captures is known stable.
460
- return true;
461
- }
462
-
463
- // These are usually mistaken. Collect them.
464
- const currentRefsInEffectCleanup = new Map();
465
-
466
- // Is this reference inside a cleanup function for this effect node?
467
- // We can check by traversing scopes upwards from the reference, and checking
468
- // if the last "return () => " we encounter is located directly inside the effect.
469
- function isInsideEffectCleanup(reference) {
470
- let curScope = reference.from;
471
- let isInReturnedFunction = false;
472
- while (curScope.block !== node) {
473
- if (curScope.type === 'function') {
474
- isInReturnedFunction =
475
- curScope.block.parent != null &&
476
- curScope.block.parent.type === 'ReturnStatement';
477
- }
478
- curScope = curScope.upper;
479
- }
480
- return isInReturnedFunction;
481
- }
482
-
483
- // Get dependencies from all our resolved references in pure scopes.
484
- // Key is dependency string, value is whether it's stable.
485
- const dependencies = new Map();
486
- const optionalChains = new Map();
487
- gatherDependenciesRecursively(scope);
488
-
489
- function gatherDependenciesRecursively(currentScope) {
490
- for (const reference of currentScope.references) {
491
- // If this reference is not resolved or it is not declared in a pure
492
- // scope then we don't care about this reference.
493
- if (!reference.resolved) {
494
- continue;
495
- }
496
- if (!pureScopes.has(reference.resolved.scope)) {
497
- continue;
498
- }
499
-
500
- // Narrow the scope of a dependency if it is, say, a member expression.
501
- // Then normalize the narrowed dependency.
502
- const referenceNode = fastFindReferenceWithParent(
503
- node,
504
- reference.identifier
505
- );
506
- const dependencyNode = getDependency(referenceNode);
507
- const dependency = analyzePropertyChain(
508
- dependencyNode,
509
- optionalChains
510
- );
511
-
512
- // Accessing ref.current inside effect cleanup is bad.
513
- if (
514
- // We're in an effect...
515
- isEffect &&
516
- // ... and this look like accessing .current...
517
- dependencyNode.type === 'Identifier' &&
518
- (dependencyNode.parent.type === 'MemberExpression' ||
519
- dependencyNode.parent.type === 'OptionalMemberExpression') &&
520
- !dependencyNode.parent.computed &&
521
- dependencyNode.parent.property.type === 'Identifier' &&
522
- dependencyNode.parent.property.name === 'current' &&
523
- // ...in a cleanup function or below...
524
- isInsideEffectCleanup(reference)
525
- ) {
526
- currentRefsInEffectCleanup.set(dependency, {
527
- reference,
528
- dependencyNode
529
- });
530
- }
531
-
532
- if (
533
- dependencyNode.parent.type === 'TSTypeQuery' ||
534
- dependencyNode.parent.type === 'TSTypeReference'
535
- ) {
536
- continue;
537
- }
538
-
539
- const def = reference.resolved.defs[0];
540
- if (def == null) {
541
- continue;
542
- }
543
- // Ignore references to the function itself as it's not defined yet.
544
- if (def.node != null && def.node.init === node.parent) {
545
- continue;
546
- }
547
- // Ignore Flow type parameters
548
- if (def.type === 'TypeParameter') {
549
- continue;
550
- }
551
-
552
- // Add the dependency to a map so we can make sure it is referenced
553
- // again in our dependencies array. Remember whether it's stable.
554
- if (!dependencies.has(dependency)) {
555
- const resolved = reference.resolved;
556
- const isStable =
557
- memoizedIsStableKnownHookValue(resolved) ||
558
- memoizedIsFunctionWithoutCapturedValues(resolved);
559
- dependencies.set(dependency, {
560
- isStable,
561
- references: [reference]
562
- });
563
- } else {
564
- dependencies.get(dependency).references.push(reference);
565
- }
566
- }
567
-
568
- for (const childScope of currentScope.childScopes) {
569
- gatherDependenciesRecursively(childScope);
570
- }
571
- }
572
-
573
- // Warn about accessing .current in cleanup effects.
574
- currentRefsInEffectCleanup.forEach(
575
- ({ reference, dependencyNode }, dependency) => {
576
- const references = reference.resolved.references;
577
- // Is React managing this ref or us?
578
- // Let's see if we can find a .current assignment.
579
- let foundCurrentAssignment = false;
580
- for (const { identifier } of references) {
581
- const { parent } = identifier;
582
- if (
583
- parent != null &&
584
- // ref.current
585
- // Note: no need to handle OptionalMemberExpression because it can't be LHS.
586
- parent.type === 'MemberExpression' &&
587
- !parent.computed &&
588
- parent.property.type === 'Identifier' &&
589
- parent.property.name === 'current' &&
590
- // ref.current = <something>
591
- parent.parent.type === 'AssignmentExpression' &&
592
- parent.parent.left === parent
593
- ) {
594
- foundCurrentAssignment = true;
595
- break;
596
- }
597
- }
598
- // We only want to warn about React-managed refs.
599
- if (foundCurrentAssignment) {
600
- return;
601
- }
602
- reportProblem({
603
- node: dependencyNode.parent.property,
604
- message:
605
- `The ref value '${dependency}.current' will likely have ` +
606
- `changed by the time this effect cleanup function runs. If ` +
607
- `this ref points to a node rendered by React, copy ` +
608
- `'${dependency}.current' to a variable inside the effect, and ` +
609
- `use that variable in the cleanup function.`
610
- });
611
- }
612
- );
613
-
614
- // Warn about assigning to variables in the outer scope.
615
- // Those are usually bugs.
616
- const staleAssignments = new Set();
617
- function reportStaleAssignment(writeExpr, key) {
618
- if (staleAssignments.has(key)) {
619
- return;
620
- }
621
- staleAssignments.add(key);
622
- reportProblem({
623
- node: writeExpr,
624
- message:
625
- `Assignments to the '${key}' variable from inside React Hook ` +
626
- `${context.getSource(reactiveHook)} will be lost after each ` +
627
- `render. To preserve the value over time, store it in a useRef ` +
628
- `Hook and keep the mutable value in the '.current' property. ` +
629
- `Otherwise, you can move this variable directly inside ` +
630
- `${context.getSource(reactiveHook)}.`
631
- });
632
- }
633
-
634
- // Remember which deps are stable and report bad usage first.
635
- const stableDependencies = new Set();
636
- dependencies.forEach(({ isStable, references }, key) => {
637
- if (isStable) {
638
- stableDependencies.add(key);
639
- }
640
- references.forEach(reference => {
641
- if (reference.writeExpr) {
642
- reportStaleAssignment(reference.writeExpr, key);
643
- }
644
- });
645
- });
646
-
647
- if (staleAssignments.size > 0) {
648
- // The intent isn't clear so we'll wait until you fix those first.
649
- return;
650
- }
651
-
652
- if (!declaredDependenciesNode) {
653
- // Check if there are any top-level setState() calls.
654
- // Those tend to lead to infinite loops.
655
- let setStateInsideEffectWithoutDeps = null;
656
- dependencies.forEach(({ references }, key) => {
657
- if (setStateInsideEffectWithoutDeps) {
658
- return;
659
- }
660
- references.forEach(reference => {
661
- if (setStateInsideEffectWithoutDeps) {
662
- return;
663
- }
664
-
665
- const id = reference.identifier;
666
- const isSetState = setStateCallSites.has(id);
667
- if (!isSetState) {
668
- return;
669
- }
670
-
671
- let fnScope = reference.from;
672
- while (fnScope.type !== 'function') {
673
- fnScope = fnScope.upper;
674
- }
675
- const isDirectlyInsideEffect = fnScope.block === node;
676
- if (isDirectlyInsideEffect) {
677
- // TODO: we could potentially ignore early returns.
678
- setStateInsideEffectWithoutDeps = key;
679
- }
680
- });
681
- });
682
- if (setStateInsideEffectWithoutDeps) {
683
- const { suggestedDependencies } = collectRecommendations({
684
- dependencies,
685
- declaredDependencies: [],
686
- stableDependencies,
687
- externalDependencies: new Set(),
688
- isEffect: true
689
- });
690
- reportProblem({
691
- node: reactiveHook,
692
- message:
693
- `React Hook ${reactiveHookName} contains a call to '${setStateInsideEffectWithoutDeps}'. ` +
694
- `Without a list of dependencies, this can lead to an infinite chain of updates. ` +
695
- `To fix this, pass [` +
696
- suggestedDependencies.join(', ') +
697
- `] as a second argument to the ${reactiveHookName} Hook.`,
698
- suggest: [
699
- {
700
- desc: `Add dependencies array: [${suggestedDependencies.join(
701
- ', '
702
- )}]`,
703
- fix(fixer) {
704
- return fixer.insertTextAfter(
705
- node,
706
- `, [${suggestedDependencies.join(', ')}]`
707
- );
708
- }
709
- }
710
- ]
711
- });
712
- }
713
- return;
714
- }
715
-
716
- const declaredDependencies = [];
717
- const externalDependencies = new Set();
718
- if (declaredDependenciesNode.type !== 'ArrayExpression') {
719
- // If the declared dependencies are not an array expression then we
720
- // can't verify that the user provided the correct dependencies. Tell
721
- // the user this in an error.
722
- reportProblem({
723
- node: declaredDependenciesNode,
724
- message:
725
- `React Hook ${context.getSource(reactiveHook)} was passed a ` +
726
- 'dependency list that is not an array literal. This means we ' +
727
- "can't statically verify whether you've passed the correct " +
728
- 'dependencies.'
729
- });
730
- } else {
731
- declaredDependenciesNode.elements.forEach(declaredDependencyNode => {
732
- // Skip elided elements.
733
- if (declaredDependencyNode == null) {
734
- return;
735
- }
736
- // If we see a spread element then add a special warning.
737
- if (declaredDependencyNode.type === 'SpreadElement') {
738
- reportProblem({
739
- node: declaredDependencyNode,
740
- message:
741
- `React Hook ${context.getSource(reactiveHook)} has a spread ` +
742
- "element in its dependency array. This means we can't " +
743
- "statically verify whether you've passed the " +
744
- 'correct dependencies.'
745
- });
746
- return;
747
- }
748
- // Try to normalize the declared dependency. If we can't then an error
749
- // will be thrown. We will catch that error and report an error.
750
- let declaredDependency;
751
- try {
752
- declaredDependency = analyzePropertyChain(
753
- declaredDependencyNode,
754
- null
755
- );
756
- } catch (err) {
757
- if (/Unsupported node type/.test(err.message)) {
758
- if (declaredDependencyNode.type === 'Literal') {
759
- if (dependencies.has(declaredDependencyNode.value)) {
760
- reportProblem({
761
- node: declaredDependencyNode,
762
- message:
763
- `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
764
- `because it never changes. ` +
765
- `Did you mean to include ${declaredDependencyNode.value} in the array instead?`
766
- });
767
- } else {
768
- reportProblem({
769
- node: declaredDependencyNode,
770
- message:
771
- `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
772
- 'because it never changes. You can safely remove it.'
773
- });
774
- }
775
- } else {
776
- reportProblem({
777
- node: declaredDependencyNode,
778
- message:
779
- `React Hook ${context.getSource(reactiveHook)} has a ` +
780
- `complex expression in the dependency array. ` +
781
- 'Extract it to a separate variable so it can be statically checked.'
782
- });
783
- }
784
-
785
- return;
786
- }
787
- throw err;
788
- }
789
-
790
- let maybeID = declaredDependencyNode;
791
- while (
792
- maybeID.type === 'MemberExpression' ||
793
- maybeID.type === 'OptionalMemberExpression' ||
794
- maybeID.type === 'ChainExpression'
795
- ) {
796
- maybeID = maybeID.object || maybeID.expression.object;
797
- }
798
- const isDeclaredInComponent = !componentScope.through.some(
799
- ref => ref.identifier === maybeID
800
- );
801
-
802
- // Add the dependency to our declared dependency map.
803
- declaredDependencies.push({
804
- key: declaredDependency,
805
- node: declaredDependencyNode
806
- });
807
-
808
- if (!isDeclaredInComponent) {
809
- externalDependencies.add(declaredDependency);
810
- }
811
- });
812
- }
813
-
814
- const {
815
- suggestedDependencies,
816
- unnecessaryDependencies,
817
- missingDependencies,
818
- duplicateDependencies
819
- } = collectRecommendations({
820
- dependencies,
821
- declaredDependencies,
822
- stableDependencies,
823
- externalDependencies,
824
- isEffect
825
- });
826
-
827
- let suggestedDeps = suggestedDependencies;
828
-
829
- const problemCount =
830
- duplicateDependencies.size +
831
- missingDependencies.size +
832
- unnecessaryDependencies.size;
833
-
834
- if (problemCount === 0) {
835
- // If nothing else to report, check if some dependencies would
836
- // invalidate on every render.
837
- const constructions = scanForConstructions({
838
- declaredDependencies,
839
- declaredDependenciesNode,
840
- componentScope,
841
- scope
842
- });
843
- constructions.forEach(
844
- ({ construction, isUsedOutsideOfHook, depType }) => {
845
- const wrapperHook =
846
- depType === 'function' ? 'useCallback' : 'useMemo';
847
-
848
- const constructionType =
849
- depType === 'function' ? 'definition' : 'initialization';
850
-
851
- const defaultAdvice = `wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`;
852
-
853
- const advice = isUsedOutsideOfHook
854
- ? `To fix this, ${defaultAdvice}`
855
- : `Move it inside the ${reactiveHookName} callback. Alternatively, ${defaultAdvice}`;
856
-
857
- const causation =
858
- depType === 'conditional' || depType === 'logical expression'
859
- ? 'could make'
860
- : 'makes';
861
-
862
- const message =
863
- `The '${construction.name.name}' ${depType} ${causation} the dependencies of ` +
864
- `${reactiveHookName} Hook (at line ${declaredDependenciesNode.loc.start.line}) ` +
865
- `change on every render. ${advice}`;
866
-
867
- let suggest;
868
- // Only handle the simple case of variable assignments.
869
- // Wrapping function declarations can mess up hoisting.
870
- if (
871
- isUsedOutsideOfHook &&
872
- construction.type === 'Variable' &&
873
- // Objects may be mutated after construction, which would make this
874
- // fix unsafe. Functions _probably_ won't be mutated, so we'll
875
- // allow this fix for them.
876
- depType === 'function'
877
- ) {
878
- suggest = [
879
- {
880
- desc: `Wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`,
881
- fix(fixer) {
882
- const [before, after] =
883
- wrapperHook === 'useMemo'
884
- ? [`useMemo(() => { return `, '; })']
885
- : ['useCallback(', ')'];
886
- return [
887
- // TODO: also add an import?
888
- fixer.insertTextBefore(construction.node.init, before),
889
- // TODO: ideally we'd gather deps here but it would require
890
- // restructuring the rule code. This will cause a new lint
891
- // error to appear immediately for useCallback. Note we're
892
- // not adding [] because would that changes semantics.
893
- fixer.insertTextAfter(construction.node.init, after)
894
- ];
895
- }
896
- }
897
- ];
898
- }
899
- // TODO: What if the function needs to change on every render anyway?
900
- // Should we suggest removing effect deps as an appropriate fix too?
901
- reportProblem({
902
- // TODO: Why not report this at the dependency site?
903
- node: construction.node,
904
- message,
905
- suggest
906
- });
907
- }
908
- );
909
- return;
910
- }
911
-
912
- // If we're going to report a missing dependency,
913
- // we might as well recalculate the list ignoring
914
- // the currently specified deps. This can result
915
- // in some extra deduplication. We can't do this
916
- // for effects though because those have legit
917
- // use cases for over-specifying deps.
918
- if (!isEffect && missingDependencies.size > 0) {
919
- suggestedDeps = collectRecommendations({
920
- dependencies,
921
- declaredDependencies: [], // Pretend we don't know
922
- stableDependencies,
923
- externalDependencies,
924
- isEffect
925
- }).suggestedDependencies;
926
- }
927
-
928
- // Alphabetize the suggestions, but only if deps were already alphabetized.
929
- function areDeclaredDepsAlphabetized() {
930
- if (declaredDependencies.length === 0) {
931
- return true;
932
- }
933
- const declaredDepKeys = declaredDependencies.map(dep => dep.key);
934
- const sortedDeclaredDepKeys = [...declaredDepKeys].sort();
935
- return declaredDepKeys.join(',') === sortedDeclaredDepKeys.join(',');
936
- }
937
- if (areDeclaredDepsAlphabetized()) {
938
- suggestedDeps.sort();
939
- }
940
-
941
- // Most of our algorithm deals with dependency paths with optional chaining stripped.
942
- // This function is the last step before printing a dependency, so now is a good time to
943
- // check whether any members in our path are always used as optional-only. In that case,
944
- // we will use ?. instead of . to concatenate those parts of the path.
945
- function formatDependency(path) {
946
- const members = path.split('.');
947
- let finalPath = '';
948
- for (let i = 0; i < members.length; i++) {
949
- if (i !== 0) {
950
- const pathSoFar = members.slice(0, i + 1).join('.');
951
- const isOptional = optionalChains.get(pathSoFar) === true;
952
- finalPath += isOptional ? '?.' : '.';
953
- }
954
- finalPath += members[i];
955
- }
956
- return finalPath;
957
- }
958
-
959
- function getWarningMessage(deps, singlePrefix, label, fixVerb) {
960
- if (deps.size === 0) {
961
- return null;
962
- }
963
- return (
964
- (deps.size > 1 ? '' : singlePrefix + ' ') +
965
- label +
966
- ' ' +
967
- (deps.size > 1 ? 'dependencies' : 'dependency') +
968
- ': ' +
969
- joinEnglish(
970
- [...deps].sort().map(name => "'" + formatDependency(name) + "'")
971
- ) +
972
- `. Either ${fixVerb} ${
973
- deps.size > 1 ? 'them' : 'it'
974
- } or remove the dependency array.`
975
- );
976
- }
977
-
978
- let extraWarning = '';
979
- if (unnecessaryDependencies.size > 0) {
980
- let badRef = null;
981
- [...unnecessaryDependencies.keys()].forEach(key => {
982
- if (badRef != null) {
983
- return;
984
- }
985
- if (key.endsWith('.current')) {
986
- badRef = key;
987
- }
988
- });
989
- if (badRef != null) {
990
- extraWarning =
991
- ` Mutable values like '${badRef}' aren't valid dependencies ` +
992
- "because mutating them doesn't re-render the component.";
993
- } else if (externalDependencies.size > 0) {
994
- const dep = [...externalDependencies][0];
995
- // Don't show this warning for things that likely just got moved *inside* the callback
996
- // because in that case they're clearly not referring to globals.
997
- if (!scope.set.has(dep)) {
998
- extraWarning =
999
- ` Outer scope values like '${dep}' aren't valid dependencies ` +
1000
- `because mutating them doesn't re-render the component.`;
1001
- }
1002
- }
1003
- }
1004
-
1005
- // `props.foo()` marks `props` as a dependency because it has
1006
- // a `this` value. This warning can be confusing.
1007
- // So if we're going to show it, append a clarification.
1008
- if (!extraWarning && missingDependencies.has('props')) {
1009
- const propDep = dependencies.get('props');
1010
- if (propDep == null) {
1011
- return;
1012
- }
1013
- const refs = propDep.references;
1014
- if (!Array.isArray(refs)) {
1015
- return;
1016
- }
1017
- let isPropsOnlyUsedInMembers = true;
1018
- for (const ref of refs) {
1019
- const id = fastFindReferenceWithParent(
1020
- componentScope.block,
1021
- ref.identifier
1022
- );
1023
- if (!id) {
1024
- isPropsOnlyUsedInMembers = false;
1025
- break;
1026
- }
1027
- const parent = id.parent;
1028
- if (parent == null) {
1029
- isPropsOnlyUsedInMembers = false;
1030
- break;
1031
- }
1032
- if (
1033
- parent.type !== 'MemberExpression' &&
1034
- parent.type !== 'OptionalMemberExpression'
1035
- ) {
1036
- isPropsOnlyUsedInMembers = false;
1037
- break;
1038
- }
1039
- }
1040
- if (isPropsOnlyUsedInMembers) {
1041
- extraWarning =
1042
- ` However, 'props' will change when *any* prop changes, so the ` +
1043
- `preferred fix is to destructure the 'props' object outside of ` +
1044
- `the ${reactiveHookName} call and refer to those specific props ` +
1045
- `inside ${context.getSource(reactiveHook)}.`;
1046
- }
1047
- }
1048
-
1049
- if (!extraWarning && missingDependencies.size > 0) {
1050
- // See if the user is trying to avoid specifying a callable prop.
1051
- // This usually means they're unaware of useCallback.
1052
- let missingCallbackDep = null;
1053
- missingDependencies.forEach(missingDep => {
1054
- if (missingCallbackDep) {
1055
- return;
1056
- }
1057
- // Is this a variable from top scope?
1058
- const topScopeRef = componentScope.set.get(missingDep);
1059
- const usedDep = dependencies.get(missingDep);
1060
- if (usedDep.references[0].resolved !== topScopeRef) {
1061
- return;
1062
- }
1063
- // Is this a destructured prop?
1064
- const def = topScopeRef.defs[0];
1065
- if (def == null || def.name == null || def.type !== 'Parameter') {
1066
- return;
1067
- }
1068
- // Was it called in at least one case? Then it's a function.
1069
- let isFunctionCall = false;
1070
- let id;
1071
- for (let i = 0; i < usedDep.references.length; i++) {
1072
- id = usedDep.references[i].identifier;
1073
- if (
1074
- id != null &&
1075
- id.parent != null &&
1076
- (id.parent.type === 'CallExpression' ||
1077
- id.parent.type === 'OptionalCallExpression') &&
1078
- id.parent.callee === id
1079
- ) {
1080
- isFunctionCall = true;
1081
- break;
1082
- }
1083
- }
1084
- if (!isFunctionCall) {
1085
- return;
1086
- }
1087
- // If it's missing (i.e. in component scope) *and* it's a parameter
1088
- // then it is definitely coming from props destructuring.
1089
- // (It could also be props itself but we wouldn't be calling it then.)
1090
- missingCallbackDep = missingDep;
1091
- });
1092
- if (missingCallbackDep != null) {
1093
- extraWarning =
1094
- ` If '${missingCallbackDep}' changes too often, ` +
1095
- `find the parent component that defines it ` +
1096
- `and wrap that definition in useCallback.`;
1097
- }
1098
- }
1099
-
1100
- if (!extraWarning && missingDependencies.size > 0) {
1101
- let setStateRecommendation = null;
1102
- missingDependencies.forEach(missingDep => {
1103
- if (setStateRecommendation != null) {
1104
- return;
1105
- }
1106
- const usedDep = dependencies.get(missingDep);
1107
- const references = usedDep.references;
1108
- let id;
1109
- let maybeCall;
1110
- for (const reference of references) {
1111
- id = reference.identifier;
1112
- maybeCall = id.parent;
1113
- // Try to see if we have setState(someExpr(missingDep)).
1114
- while (maybeCall != null && maybeCall !== componentScope.block) {
1115
- if (maybeCall.type === 'CallExpression') {
1116
- const correspondingStateVariable = setStateCallSites.get(
1117
- maybeCall.callee
1118
- );
1119
- if (correspondingStateVariable != null) {
1120
- if (correspondingStateVariable.name === missingDep) {
1121
- // setCount(count + 1)
1122
- setStateRecommendation = {
1123
- missingDep,
1124
- setter: maybeCall.callee.name,
1125
- form: 'updater'
1126
- };
1127
- } else if (stateVariables.has(id)) {
1128
- // setCount(count + increment)
1129
- setStateRecommendation = {
1130
- missingDep,
1131
- setter: maybeCall.callee.name,
1132
- form: 'reducer'
1133
- };
1134
- } else {
1135
- const resolved = reference.resolved;
1136
- if (resolved != null) {
1137
- // If it's a parameter *and* a missing dep,
1138
- // it must be a prop or something inside a prop.
1139
- // Therefore, recommend an inline reducer.
1140
- const def = resolved.defs[0];
1141
- if (def != null && def.type === 'Parameter') {
1142
- setStateRecommendation = {
1143
- missingDep,
1144
- setter: maybeCall.callee.name,
1145
- form: 'inlineReducer'
1146
- };
1147
- }
1148
- }
1149
- }
1150
- break;
1151
- }
1152
- }
1153
- maybeCall = maybeCall.parent;
1154
- }
1155
- if (setStateRecommendation != null) {
1156
- break;
1157
- }
1158
- }
1159
- });
1160
- if (setStateRecommendation != null) {
1161
- switch (setStateRecommendation.form) {
1162
- case 'reducer':
1163
- extraWarning =
1164
- ` You can also replace multiple useState variables with useReducer ` +
1165
- `if '${setStateRecommendation.setter}' needs the ` +
1166
- `current value of '${setStateRecommendation.missingDep}'.`;
1167
- break;
1168
- case 'inlineReducer':
1169
- extraWarning =
1170
- ` If '${setStateRecommendation.setter}' needs the ` +
1171
- `current value of '${setStateRecommendation.missingDep}', ` +
1172
- `you can also switch to useReducer instead of useState and ` +
1173
- `read '${setStateRecommendation.missingDep}' in the reducer.`;
1174
- break;
1175
- case 'updater':
1176
- extraWarning = ` You can also do a functional update '${
1177
- setStateRecommendation.setter
1178
- }(${setStateRecommendation.missingDep.slice(
1179
- 0,
1180
- 1
1181
- )} => ...)' if you only need '${
1182
- setStateRecommendation.missingDep
1183
- }' in the '${setStateRecommendation.setter}' call.`;
1184
- break;
1185
- default:
1186
- throw new Error('Unknown case.');
1187
- }
1188
- }
1189
- }
1190
-
1191
- reportProblem({
1192
- node: declaredDependenciesNode,
1193
- message:
1194
- `React Hook ${context.getSource(reactiveHook)} has ` +
1195
- // To avoid a long message, show the next actionable item.
1196
- (getWarningMessage(missingDependencies, 'a', 'missing', 'include') ||
1197
- getWarningMessage(
1198
- unnecessaryDependencies,
1199
- 'an',
1200
- 'unnecessary',
1201
- 'exclude'
1202
- ) ||
1203
- getWarningMessage(
1204
- duplicateDependencies,
1205
- 'a',
1206
- 'duplicate',
1207
- 'omit'
1208
- )) +
1209
- extraWarning,
1210
- suggest: [
1211
- {
1212
- desc: `Update the dependencies array to be: [${suggestedDeps
1213
- .map(element => formatDependency(element))
1214
- .join(', ')}]`,
1215
- fix(fixer) {
1216
- // TODO: consider preserving the comments or formatting?
1217
- return fixer.replaceText(
1218
- declaredDependenciesNode,
1219
- `[${suggestedDeps.map(element => formatDependency(element)).join(', ')}]`
1220
- );
1221
- }
1222
- }
1223
- ]
1224
- });
1225
- }
1226
-
1227
- function visitCallExpression(node) {
1228
- const callbackIndex = getReactiveHookCallbackIndex(node.callee, options);
1229
- if (callbackIndex === -1) {
1230
- // Not a React Hook call that needs deps.
1231
- return;
1232
- }
1233
- const callback = node.arguments[callbackIndex];
1234
- const reactiveHook = node.callee;
1235
- const reactiveHookName = getNodeWithoutReactNamespace(reactiveHook).name;
1236
- const declaredDependenciesNode = node.arguments[callbackIndex + 1];
1237
- const isEffect = /Effect($|[^a-z])/g.test(reactiveHookName);
1238
-
1239
- // Check whether a callback is supplied. If there is no callback supplied
1240
- // then the hook will not work and React will throw a TypeError.
1241
- // So no need to check for dependency inclusion.
1242
- if (!callback) {
1243
- reportProblem({
1244
- node: reactiveHook,
1245
- message:
1246
- `React Hook ${reactiveHookName} requires an effect callback. ` +
1247
- `Did you forget to pass a callback to the hook?`
1248
- });
1249
- return;
1250
- }
1251
-
1252
- // Check the declared dependencies for this reactive hook. If there is no
1253
- // second argument then the reactive callback will re-run on every render.
1254
- // So no need to check for dependency inclusion.
1255
- if (!declaredDependenciesNode && !isEffect) {
1256
- // These are only used for optimization.
1257
- if (
1258
- reactiveHookName === 'useMemo' ||
1259
- reactiveHookName === 'useCallback'
1260
- ) {
1261
- // TODO: Can this have a suggestion?
1262
- reportProblem({
1263
- node: reactiveHook,
1264
- message:
1265
- `React Hook ${reactiveHookName} does nothing when called with ` +
1266
- `only one argument. Did you forget to pass an array of ` +
1267
- `dependencies?`
1268
- });
1269
- }
1270
- return;
1271
- }
1272
-
1273
- switch (callback.type) {
1274
- case 'FunctionExpression':
1275
- case 'ArrowFunctionExpression':
1276
- visitFunctionWithDependencies(
1277
- callback,
1278
- declaredDependenciesNode,
1279
- reactiveHook,
1280
- reactiveHookName,
1281
- isEffect
1282
- );
1283
- return; // Handled
1284
- case 'Identifier':
1285
- if (!declaredDependenciesNode) {
1286
- // No deps, no problems.
1287
- return; // Handled
1288
- }
1289
- // The function passed as a callback is not written inline.
1290
- // But perhaps it's in the dependencies array?
1291
- if (
1292
- declaredDependenciesNode.elements &&
1293
- declaredDependenciesNode.elements.some(
1294
- el => el && el.type === 'Identifier' && el.name === callback.name
1295
- )
1296
- ) {
1297
- // If it's already in the list of deps, we don't care because
1298
- // this is valid regardless.
1299
- return; // Handled
1300
- }
1301
- // We'll do our best effort to find it, complain otherwise.
1302
- const variable = context.getScope().set.get(callback.name);
1303
- if (variable == null || variable.defs == null) {
1304
- // If it's not in scope, we don't care.
1305
- return; // Handled
1306
- }
1307
- // The function passed as a callback is not written inline.
1308
- // But it's defined somewhere in the render scope.
1309
- // We'll do our best effort to find and check it, complain otherwise.
1310
- const def = variable.defs[0];
1311
- if (!def || !def.node) {
1312
- break; // Unhandled
1313
- }
1314
- if (def.type !== 'Variable' && def.type !== 'FunctionName') {
1315
- // Parameter or an unusual pattern. Bail out.
1316
- break; // Unhandled
1317
- }
1318
- switch (def.node.type) {
1319
- case 'FunctionDeclaration':
1320
- // useEffect(() => { ... }, []);
1321
- visitFunctionWithDependencies(
1322
- def.node,
1323
- declaredDependenciesNode,
1324
- reactiveHook,
1325
- reactiveHookName,
1326
- isEffect
1327
- );
1328
- return; // Handled
1329
- case 'VariableDeclarator':
1330
- const init = def.node.init;
1331
- if (!init) {
1332
- break; // Unhandled
1333
- }
1334
- switch (init.type) {
1335
- // const effectBody = () => {...};
1336
- // useEffect(effectBody, []);
1337
- case 'ArrowFunctionExpression':
1338
- case 'FunctionExpression':
1339
- // We can inspect this function as if it were inline.
1340
- visitFunctionWithDependencies(
1341
- init,
1342
- declaredDependenciesNode,
1343
- reactiveHook,
1344
- reactiveHookName,
1345
- isEffect
1346
- );
1347
- return; // Handled
1348
- }
1349
- break; // Unhandled
1350
- }
1351
- break; // Unhandled
1352
- default:
1353
- // useEffect(generateEffectBody(), []);
1354
- reportProblem({
1355
- node: reactiveHook,
1356
- message:
1357
- `React Hook ${reactiveHookName} received a function whose dependencies ` +
1358
- `are unknown. Pass an inline function instead.`
1359
- });
1360
- return; // Handled
1361
- }
1362
-
1363
- // Something unusual. Fall back to suggesting to add the body itself as a dep.
1364
- reportProblem({
1365
- node: reactiveHook,
1366
- message:
1367
- `React Hook ${reactiveHookName} has a missing dependency: '${callback.name}'. ` +
1368
- `Either include it or remove the dependency array.`,
1369
- suggest: [
1370
- {
1371
- desc: `Update the dependencies array to be: [${callback.name}]`,
1372
- fix(fixer) {
1373
- return fixer.replaceText(
1374
- declaredDependenciesNode,
1375
- `[${callback.name}]`
1376
- );
1377
- }
1378
- }
1379
- ]
1380
- });
1381
- }
1382
-
1383
- return {
1384
- CallExpression: visitCallExpression
1385
- };
1386
- }
1387
- };
1388
-
1389
- // The meat of the logic.
1390
- function collectRecommendations({
1391
- dependencies,
1392
- declaredDependencies,
1393
- stableDependencies,
1394
- externalDependencies,
1395
- isEffect
1396
- }) {
1397
- // Our primary data structure.
1398
- // It is a logical representation of property chains:
1399
- // `props` -> `props.foo` -> `props.foo.bar` -> `props.foo.bar.baz`
1400
- // -> `props.lol`
1401
- // -> `props.huh` -> `props.huh.okay`
1402
- // -> `props.wow`
1403
- // We'll use it to mark nodes that are *used* by the programmer,
1404
- // and the nodes that were *declared* as deps. Then we will
1405
- // traverse it to learn which deps are missing or unnecessary.
1406
- const depTree = createDepTree();
1407
- function createDepTree() {
1408
- return {
1409
- isUsed: false, // True if used in code
1410
- isSatisfiedRecursively: false, // True if specified in deps
1411
- isSubtreeUsed: false, // True if something deeper is used by code
1412
- children: new Map() // Nodes for properties
1413
- };
1414
- }
1415
-
1416
- // Mark all required nodes first.
1417
- // Imagine exclamation marks next to each used deep property.
1418
- dependencies.forEach((_, key) => {
1419
- const node = getOrCreateNodeByPath(depTree, key);
1420
- node.isUsed = true;
1421
- markAllParentsByPath(depTree, key, parent => {
1422
- parent.isSubtreeUsed = true;
1423
- });
1424
- });
1425
-
1426
- // Mark all satisfied nodes.
1427
- // Imagine checkmarks next to each declared dependency.
1428
- declaredDependencies.forEach(({ key }) => {
1429
- const node = getOrCreateNodeByPath(depTree, key);
1430
- node.isSatisfiedRecursively = true;
1431
- });
1432
- stableDependencies.forEach(key => {
1433
- const node = getOrCreateNodeByPath(depTree, key);
1434
- node.isSatisfiedRecursively = true;
1435
- });
1436
-
1437
- // Tree manipulation helpers.
1438
- function getOrCreateNodeByPath(rootNode, path) {
1439
- const keys = path.split('.');
1440
- let node = rootNode;
1441
- for (const key of keys) {
1442
- let child = node.children.get(key);
1443
- if (!child) {
1444
- child = createDepTree();
1445
- node.children.set(key, child);
1446
- }
1447
- node = child;
1448
- }
1449
- return node;
1450
- }
1451
- function markAllParentsByPath(rootNode, path, fn) {
1452
- const keys = path.split('.');
1453
- let node = rootNode;
1454
- for (const key of keys) {
1455
- const child = node.children.get(key);
1456
- if (!child) {
1457
- return;
1458
- }
1459
- fn(child);
1460
- node = child;
1461
- }
1462
- }
1463
-
1464
- // Now we can learn which dependencies are missing or necessary.
1465
- const missingDependencies = new Set();
1466
- const satisfyingDependencies = new Set();
1467
- scanTreeRecursively(
1468
- depTree,
1469
- missingDependencies,
1470
- satisfyingDependencies,
1471
- key => key
1472
- );
1473
- function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) {
1474
- node.children.forEach((child, key) => {
1475
- const path = keyToPath(key);
1476
- if (child.isSatisfiedRecursively) {
1477
- if (child.isSubtreeUsed) {
1478
- // Remember this dep actually satisfied something.
1479
- satisfyingPaths.add(path);
1480
- }
1481
- // It doesn't matter if there's something deeper.
1482
- // It would be transitively satisfied since we assume immutability.
1483
- // `props.foo` is enough if you read `props.foo.id`.
1484
- return;
1485
- }
1486
- if (child.isUsed) {
1487
- // Remember that no declared deps satisfied this node.
1488
- missingPaths.add(path);
1489
- // If we got here, nothing in its subtree was satisfied.
1490
- // No need to search further.
1491
- return;
1492
- }
1493
- scanTreeRecursively(
1494
- child,
1495
- missingPaths,
1496
- satisfyingPaths,
1497
- childKey => path + '.' + childKey
1498
- );
1499
- });
1500
- }
1501
-
1502
- // Collect suggestions in the order they were originally specified.
1503
- const suggestedDependencies = [];
1504
- const unnecessaryDependencies = new Set();
1505
- const duplicateDependencies = new Set();
1506
- declaredDependencies.forEach(({ key }) => {
1507
- // Does this declared dep satisfy a real need?
1508
- if (satisfyingDependencies.has(key)) {
1509
- if (!suggestedDependencies.includes(key)) {
1510
- // Good one.
1511
- suggestedDependencies.push(key);
1512
- } else {
1513
- // Duplicate.
1514
- duplicateDependencies.add(key);
1515
- }
1516
- } else if (
1517
- isEffect &&
1518
- !key.endsWith('.current') &&
1519
- !externalDependencies.has(key)
1520
- ) {
1521
- // Effects are allowed extra "unnecessary" deps.
1522
- // Such as resetting scroll when ID changes.
1523
- // Consider them legit.
1524
- // The exception is ref.current which is always wrong.
1525
- if (!suggestedDependencies.includes(key)) {
1526
- suggestedDependencies.push(key);
1527
- }
1528
- } else {
1529
- // It's definitely not needed.
1530
- unnecessaryDependencies.add(key);
1531
- }
1532
- });
1533
-
1534
- // Then add the missing ones at the end.
1535
- missingDependencies.forEach(key => {
1536
- suggestedDependencies.push(key);
1537
- });
1538
-
1539
- return {
1540
- suggestedDependencies,
1541
- unnecessaryDependencies,
1542
- duplicateDependencies,
1543
- missingDependencies
1544
- };
1545
- }
1546
-
1547
- // If the node will result in constructing a referentially unique value, return
1548
- // its human readable type name, else return null.
1549
- function getConstructionExpressionType(node) {
1550
- switch (node.type) {
1551
- case 'ObjectExpression':
1552
- return 'object';
1553
- case 'ArrayExpression':
1554
- return 'array';
1555
- case 'ArrowFunctionExpression':
1556
- case 'FunctionExpression':
1557
- return 'function';
1558
- case 'ClassExpression':
1559
- return 'class';
1560
- case 'ConditionalExpression':
1561
- if (
1562
- getConstructionExpressionType(node.consequent) != null ||
1563
- getConstructionExpressionType(node.alternate) != null
1564
- ) {
1565
- return 'conditional';
1566
- }
1567
- return null;
1568
- case 'LogicalExpression':
1569
- if (
1570
- getConstructionExpressionType(node.left) != null ||
1571
- getConstructionExpressionType(node.right) != null
1572
- ) {
1573
- return 'logical expression';
1574
- }
1575
- return null;
1576
- case 'JSXFragment':
1577
- return 'JSX fragment';
1578
- case 'JSXElement':
1579
- return 'JSX element';
1580
- case 'AssignmentExpression':
1581
- if (getConstructionExpressionType(node.right) != null) {
1582
- return 'assignment expression';
1583
- }
1584
- return null;
1585
- case 'NewExpression':
1586
- return 'object construction';
1587
- case 'Literal':
1588
- if (node.value instanceof RegExp) {
1589
- return 'regular expression';
1590
- }
1591
- return null;
1592
- case 'TypeCastExpression':
1593
- return getConstructionExpressionType(node.expression);
1594
- case 'TSAsExpression':
1595
- return getConstructionExpressionType(node.expression);
1596
- }
1597
- return null;
1598
- }
1599
-
1600
- // Finds variables declared as dependencies
1601
- // that would invalidate on every render.
1602
- function scanForConstructions({
1603
- declaredDependencies,
1604
- declaredDependenciesNode,
1605
- componentScope,
1606
- scope
1607
- }) {
1608
- const constructions = declaredDependencies
1609
- .map(({ key }) => {
1610
- const ref = componentScope.variables.find(v => v.name === key);
1611
- if (ref == null) {
1612
- return null;
1613
- }
1614
-
1615
- const node = ref.defs[0];
1616
- if (node == null) {
1617
- return null;
1618
- }
1619
- // const handleChange = function () {}
1620
- // const handleChange = () => {}
1621
- // const foo = {}
1622
- // const foo = []
1623
- // etc.
1624
- if (
1625
- node.type === 'Variable' &&
1626
- node.node.type === 'VariableDeclarator' &&
1627
- node.node.id.type === 'Identifier' && // Ensure this is not destructed assignment
1628
- node.node.init != null
1629
- ) {
1630
- const constantExpressionType = getConstructionExpressionType(
1631
- node.node.init
1632
- );
1633
- if (constantExpressionType != null) {
1634
- return [ref, constantExpressionType];
1635
- }
1636
- }
1637
- // function handleChange() {}
1638
- if (
1639
- node.type === 'FunctionName' &&
1640
- node.node.type === 'FunctionDeclaration'
1641
- ) {
1642
- return [ref, 'function'];
1643
- }
1644
-
1645
- // class Foo {}
1646
- if (node.type === 'ClassName' && node.node.type === 'ClassDeclaration') {
1647
- return [ref, 'class'];
1648
- }
1649
- return null;
1650
- })
1651
- .filter(Boolean);
1652
-
1653
- function isUsedOutsideOfHook(ref) {
1654
- let foundWriteExpr = false;
1655
- for (let i = 0; i < ref.references.length; i++) {
1656
- const reference = ref.references[i];
1657
- if (reference.writeExpr) {
1658
- if (foundWriteExpr) {
1659
- // Two writes to the same function.
1660
- return true;
1661
- }
1662
- // Ignore first write as it's not usage.
1663
- foundWriteExpr = true;
1664
- continue;
1665
- }
1666
- let currentScope = reference.from;
1667
- while (currentScope !== scope && currentScope != null) {
1668
- currentScope = currentScope.upper;
1669
- }
1670
- if (
1671
- currentScope !== scope && // This reference is outside the Hook callback.
1672
- // It can only be legit if it's the deps array.
1673
- !isAncestorNodeOf(declaredDependenciesNode, reference.identifier)
1674
- ) {
1675
- return true;
1676
- }
1677
- }
1678
- return false;
1679
- }
1680
-
1681
- return constructions.map(([ref, depType]) => ({
1682
- construction: ref.defs[0],
1683
- depType,
1684
- isUsedOutsideOfHook: isUsedOutsideOfHook(ref)
1685
- }));
1686
- }
1687
-
1688
- /**
1689
- * Assuming () means the passed/returned node:
1690
- * (props) => (props)
1691
- * props.(foo) => (props.foo)
1692
- * props.foo.(bar) => (props).foo.bar
1693
- * props.foo.bar.(baz) => (props).foo.bar.baz
1694
- */
1695
- function getDependency(node) {
1696
- if (
1697
- (node.parent.type === 'MemberExpression' ||
1698
- node.parent.type === 'OptionalMemberExpression') &&
1699
- node.parent.object === node &&
1700
- node.parent.property.name !== 'current' &&
1701
- !node.parent.computed &&
1702
- !(
1703
- node.parent.parent != null &&
1704
- (node.parent.parent.type === 'CallExpression' ||
1705
- node.parent.parent.type === 'OptionalCallExpression') &&
1706
- node.parent.parent.callee === node.parent
1707
- )
1708
- ) {
1709
- return getDependency(node.parent);
1710
- } else if (
1711
- // Note: we don't check OptionalMemberExpression because it can't be LHS.
1712
- node.type === 'MemberExpression' &&
1713
- node.parent &&
1714
- node.parent.type === 'AssignmentExpression' &&
1715
- node.parent.left === node
1716
- ) {
1717
- return node.object;
1718
- }
1719
- return node;
1720
- }
1721
-
1722
- /**
1723
- * Mark a node as either optional or required.
1724
- * Note: If the node argument is an OptionalMemberExpression, it doesn't necessarily mean it is optional.
1725
- * It just means there is an optional member somewhere inside.
1726
- * This particular node might still represent a required member, so check .optional field.
1727
- */
1728
- function markNode(node, optionalChains, result) {
1729
- if (optionalChains) {
1730
- if (node.optional) {
1731
- // We only want to consider it optional if *all* usages were optional.
1732
- if (!optionalChains.has(result)) {
1733
- // Mark as (maybe) optional. If there's a required usage, this will be overridden.
1734
- optionalChains.set(result, true);
1735
- }
1736
- } else {
1737
- // Mark as required.
1738
- optionalChains.set(result, false);
1739
- }
1740
- }
1741
- }
1742
-
1743
- /**
1744
- * Assuming () means the passed node.
1745
- * (foo) -> 'foo'
1746
- * foo(.)bar -> 'foo.bar'
1747
- * foo.bar(.)baz -> 'foo.bar.baz'
1748
- * Otherwise throw.
1749
- */
1750
- function analyzePropertyChain(node, optionalChains) {
1751
- if (node.type === 'Identifier' || node.type === 'JSXIdentifier') {
1752
- const result = node.name;
1753
- if (optionalChains) {
1754
- // Mark as required.
1755
- optionalChains.set(result, false);
1756
- }
1757
- return result;
1758
- } else if (node.type === 'MemberExpression' && !node.computed) {
1759
- const object = analyzePropertyChain(node.object, optionalChains);
1760
- const property = analyzePropertyChain(node.property, null);
1761
- const result = `${object}.${property}`;
1762
- markNode(node, optionalChains, result);
1763
- return result;
1764
- } else if (node.type === 'OptionalMemberExpression' && !node.computed) {
1765
- const object = analyzePropertyChain(node.object, optionalChains);
1766
- const property = analyzePropertyChain(node.property, null);
1767
- const result = `${object}.${property}`;
1768
- markNode(node, optionalChains, result);
1769
- return result;
1770
- } else if (node.type === 'ChainExpression' && !node.computed) {
1771
- const expression = node.expression;
1772
-
1773
- if (expression.type === 'CallExpression') {
1774
- throw new Error(`Unsupported node type: ${expression.type}`);
1775
- }
1776
-
1777
- const object = analyzePropertyChain(expression.object, optionalChains);
1778
- const property = analyzePropertyChain(expression.property, null);
1779
- const result = `${object}.${property}`;
1780
- markNode(expression, optionalChains, result);
1781
- return result;
1782
- }
1783
- throw new Error(`Unsupported node type: ${node.type}`);
1784
- }
1785
-
1786
- function getNodeWithoutReactNamespace(node) {
1787
- if (
1788
- node.type === 'MemberExpression' &&
1789
- node.object.type === 'Identifier' &&
1790
- node.object.name === 'React' &&
1791
- node.property.type === 'Identifier' &&
1792
- !node.computed
1793
- ) {
1794
- return node.property;
1795
- }
1796
- return node;
1797
- }
1798
-
1799
- // What's the index of callback that needs to be analyzed for a given Hook?
1800
- // -1 if it's not a Hook we care about (e.g. useState).
1801
- // 0 for useEffect/useMemo/useCallback(fn).
1802
- // 1 for useImperativeHandle(ref, fn).
1803
- // For additionally configured Hooks, assume that they're like useEffect (0).
1804
- function getReactiveHookCallbackIndex(calleeNode, options) {
1805
- const node = getNodeWithoutReactNamespace(calleeNode);
1806
- if (node.type !== 'Identifier') {
1807
- return -1;
1808
- }
1809
- switch (node.name) {
1810
- case 'useEffect':
1811
- case 'useLayoutEffect':
1812
- case 'useCallback':
1813
- case 'useMemo':
1814
- // useEffect(fn)
1815
- return 0;
1816
- case 'useImperativeHandle':
1817
- // useImperativeHandle(ref, fn)
1818
- return 1;
1819
- default:
1820
- if (node === calleeNode && options && options.additionalHooks) {
1821
- // Allow the user to provide a regular expression which enables the lint to
1822
- // target custom reactive hooks.
1823
- let name;
1824
- try {
1825
- name = analyzePropertyChain(node, null);
1826
- } catch (err) {
1827
- if (/Unsupported node type/.test(err.message)) {
1828
- return 0;
1829
- }
1830
- throw err;
1831
- }
1832
- return options.additionalHooks.test(name) ? 0 : -1;
1833
- }
1834
- return -1;
1835
- }
1836
- }
1837
-
1838
- /**
1839
- * ESLint won't assign node.parent to references from context.getScope()
1840
- *
1841
- * So instead we search for the node from an ancestor assigning node.parent
1842
- * as we go. This mutates the AST.
1843
- *
1844
- * This traversal is:
1845
- * - optimized by only searching nodes with a range surrounding our target node
1846
- * - agnostic to AST node types, it looks for `{ type: string, ... }`
1847
- */
1848
- function fastFindReferenceWithParent(start, target) {
1849
- const queue = [start];
1850
- let item = null;
1851
-
1852
- while (queue.length > 0) {
1853
- item = queue.shift();
1854
-
1855
- if (isSameIdentifier(item, target)) {
1856
- return item;
1857
- }
1858
-
1859
- if (!isAncestorNodeOf(item, target)) {
1860
- continue;
1861
- }
1862
-
1863
- for (const [key, value] of Object.entries(item)) {
1864
- if (key === 'parent') {
1865
- continue;
1866
- }
1867
- if (isNodeLike(value)) {
1868
- value.parent = item;
1869
- queue.push(value);
1870
- } else if (Array.isArray(value)) {
1871
- // eslint-disable-next-line no-loop-func -- FIXME
1872
- value.forEach(val => {
1873
- if (isNodeLike(val)) {
1874
- val.parent = item;
1875
- queue.push(val);
1876
- }
1877
- });
1878
- }
1879
- }
1880
- }
1881
-
1882
- return null;
1883
- }
1884
-
1885
- function joinEnglish(arr) {
1886
- let s = '';
1887
- for (let i = 0; i < arr.length; i++) {
1888
- s += arr[i];
1889
- if (i === 0 && arr.length === 2) {
1890
- s += ' and ';
1891
- } else if (i === arr.length - 2 && arr.length > 2) {
1892
- s += ', and ';
1893
- } else if (i < arr.length - 1) {
1894
- s += ', ';
1895
- }
1896
- }
1897
- return s;
1898
- }
1899
-
1900
- function isNodeLike(val) {
1901
- return (
1902
- typeof val === 'object' &&
1903
- val != null &&
1904
- !Array.isArray(val) &&
1905
- typeof val.type === 'string'
1906
- );
1907
- }
1908
-
1909
- function isSameIdentifier(a, b) {
1910
- return (
1911
- (a.type === 'Identifier' || a.type === 'JSXIdentifier') &&
1912
- a.type === b.type &&
1913
- a.name === b.name &&
1914
- a.range[0] === b.range[0] &&
1915
- a.range[1] === b.range[1]
1916
- );
1917
- }
1918
-
1919
- function isAncestorNodeOf(a, b) {
1920
- return a.range[0] <= b.range[0] && a.range[1] >= b.range[1];
1921
- }
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its 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
+ module.exports = {
9
+ meta: {
10
+ type: 'suggestion',
11
+ docs: {
12
+ description:
13
+ 'verifies the list of dependencies for Hooks like useEffect and similar',
14
+ recommended: true,
15
+ url: 'https://github.com/facebook/react/issues/14920'
16
+ },
17
+ fixable: 'code',
18
+ hasSuggestions: true,
19
+ schema: [
20
+ {
21
+ type: 'object',
22
+ additionalProperties: false,
23
+ enableDangerousAutofixThisMayCauseInfiniteLoops: false,
24
+ properties: {
25
+ additionalHooks: {
26
+ type: 'string'
27
+ },
28
+ enableDangerousAutofixThisMayCauseInfiniteLoops: {
29
+ type: 'boolean'
30
+ },
31
+ staticHooks: {
32
+ type: 'object',
33
+ additionalProperties: {
34
+ oneOf: [
35
+ {
36
+ type: 'boolean'
37
+ },
38
+ {
39
+ type: 'array',
40
+ items: {
41
+ type: 'boolean'
42
+ }
43
+ },
44
+ {
45
+ type: 'object',
46
+ additionalProperties: {
47
+ type: 'boolean'
48
+ }
49
+ }
50
+ ]
51
+ }
52
+ },
53
+ checkMemoizedVariableIsStatic: {
54
+ type: 'boolean'
55
+ }
56
+ }
57
+ }
58
+ ]
59
+ },
60
+ create(context) {
61
+ // Parse the `additionalHooks` regex.
62
+ const additionalHooks =
63
+ context.options &&
64
+ context.options[0] &&
65
+ context.options[0].additionalHooks
66
+ ? new RegExp(context.options[0].additionalHooks)
67
+ : undefined;
68
+
69
+ const enableDangerousAutofixThisMayCauseInfiniteLoops =
70
+ (context.options &&
71
+ context.options[0] &&
72
+ context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) ||
73
+ false;
74
+
75
+ // Parse the `staticHooks` object.
76
+ const staticHooks =
77
+ (context.options &&
78
+ context.options[0] &&
79
+ context.options[0].staticHooks) ||
80
+ {};
81
+
82
+ const checkMemoizedVariableIsStatic =
83
+ (context.options &&
84
+ context.options[0] &&
85
+ context.options[0].checkMemoizedVariableIsStatic) ||
86
+ false;
87
+
88
+ const options = {
89
+ additionalHooks,
90
+ enableDangerousAutofixThisMayCauseInfiniteLoops,
91
+ staticHooks,
92
+ checkMemoizedVariableIsStatic
93
+ };
94
+
95
+ function reportProblem(problem) {
96
+ if (
97
+ enableDangerousAutofixThisMayCauseInfiniteLoops && // Used to enable legacy behavior. Dangerous.
98
+ // Keep this as an option until major IDEs upgrade (including VSCode FB ESLint extension).
99
+ Array.isArray(problem.suggest) &&
100
+ problem.suggest.length > 0
101
+ ) {
102
+ problem.fix = problem.suggest[0].fix;
103
+ }
104
+ context.report(problem);
105
+ }
106
+
107
+ const scopeManager = context.getSourceCode().scopeManager;
108
+
109
+ // Should be shared between visitors.
110
+ const setStateCallSites = new WeakMap();
111
+ const stateVariables = new WeakSet();
112
+ const stableKnownValueCache = new WeakMap();
113
+ const functionWithoutCapturedValueCache = new WeakMap();
114
+ function memoizeWithWeakMap(fn, map) {
115
+ return function (arg) {
116
+ if (map.has(arg)) {
117
+ // to verify cache hits:
118
+ // console.log(arg.name)
119
+ return map.get(arg);
120
+ }
121
+ const result = fn(arg);
122
+ map.set(arg, result);
123
+ return result;
124
+ };
125
+ }
126
+ /**
127
+ * Visitor for both function expressions and arrow function expressions.
128
+ */
129
+ function visitFunctionWithDependencies(
130
+ node,
131
+ declaredDependenciesNode,
132
+ reactiveHook,
133
+ reactiveHookName,
134
+ isEffect
135
+ ) {
136
+ if (isEffect && node.async) {
137
+ reportProblem({
138
+ node: node,
139
+ message:
140
+ `Effect callbacks are synchronous to prevent race conditions. ` +
141
+ `Put the async function inside:\n\n` +
142
+ 'useEffect(() => {\n' +
143
+ ' async function fetchData() {\n' +
144
+ ' // You can await here\n' +
145
+ ' const response = await MyAPI.getData(someId);\n' +
146
+ ' // ...\n' +
147
+ ' }\n' +
148
+ ' fetchData();\n' +
149
+ `}, [someId]); // Or [] if effect doesn't need props or state\n\n` +
150
+ 'Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching'
151
+ });
152
+ }
153
+
154
+ // Get the current scope.
155
+ const scope = scopeManager.acquire(node);
156
+
157
+ // Find all our "pure scopes". On every re-render of a component these
158
+ // pure scopes may have changes to the variables declared within. So all
159
+ // variables used in our reactive hook callback but declared in a pure
160
+ // scope need to be listed as dependencies of our reactive hook callback.
161
+ //
162
+ // According to the rules of React you can't read a mutable value in pure
163
+ // scope. We can't enforce this in a lint so we trust that all variables
164
+ // declared outside of pure scope are indeed frozen.
165
+ const pureScopes = new Set();
166
+ let componentScope = null;
167
+ {
168
+ let currentScope = scope.upper;
169
+ while (currentScope) {
170
+ pureScopes.add(currentScope);
171
+ if (currentScope.type === 'function') {
172
+ break;
173
+ }
174
+ currentScope = currentScope.upper;
175
+ }
176
+ // If there is no parent function scope then there are no pure scopes.
177
+ // The ones we've collected so far are incorrect. So don't continue with
178
+ // the lint.
179
+ if (!currentScope) {
180
+ return;
181
+ }
182
+ componentScope = currentScope;
183
+ }
184
+
185
+ const isArray = Array.isArray;
186
+
187
+ // Remember such values. Avoid re-running extra checks on them.
188
+ const memoizedIsStableKnownHookValue = memoizeWithWeakMap(
189
+ isStableKnownHookValue,
190
+ stableKnownValueCache
191
+ );
192
+ const memoizedIsFunctionWithoutCapturedValues = memoizeWithWeakMap(
193
+ isFunctionWithoutCapturedValues,
194
+ functionWithoutCapturedValueCache
195
+ );
196
+
197
+ // Next we'll define a few helpers that helps us
198
+ // tell if some values don't have to be declared as deps.
199
+
200
+ // Some are known to be stable based on Hook calls.
201
+ // const [state, setState] = useState() / React.useState()
202
+ // ^^^ true for this reference
203
+ // const [state, dispatch] = useReducer() / React.useReducer()
204
+ // ^^^ true for this reference
205
+ // const ref = useRef()
206
+ // ^^^ true for this reference
207
+ // False for everything else.
208
+ // True if the value is registered in staticHooks.
209
+ // True if the value is from useMemo() or useCallback() and has zero deps.
210
+ function isStableKnownHookValue(resolved) {
211
+ if (!isArray(resolved.defs)) {
212
+ return false;
213
+ }
214
+ const def = resolved.defs[0];
215
+ if (def == null) {
216
+ return false;
217
+ }
218
+ // Look for `let stuff = ...`
219
+ if (def.node.type !== 'VariableDeclarator') {
220
+ return false;
221
+ }
222
+ let init = def.node.init;
223
+ if (init == null) {
224
+ return false;
225
+ }
226
+ while (init.type === 'TSAsExpression') {
227
+ init = init.expression;
228
+ }
229
+ // Detect primitive constants
230
+ // const foo = 42
231
+ let declaration = def.node.parent;
232
+ if (declaration == null) {
233
+ // This might happen if variable is declared after the callback.
234
+ // In that case ESLint won't set up .parent refs.
235
+ // So we'll set them up manually.
236
+ fastFindReferenceWithParent(componentScope.block, def.node.id);
237
+ declaration = def.node.parent;
238
+ if (declaration == null) {
239
+ return false;
240
+ }
241
+ }
242
+ if (
243
+ declaration.kind === 'const' &&
244
+ init.type === 'Literal' &&
245
+ (typeof init.value === 'string' ||
246
+ typeof init.value === 'number' ||
247
+ init.value == null)
248
+ ) {
249
+ // Definitely stable
250
+ return true;
251
+ }
252
+ // Detect known Hook calls
253
+ // const [_, setState] = useState()
254
+ if (init.type !== 'CallExpression') {
255
+ return false;
256
+ }
257
+ let callee = init.callee;
258
+ // Step into `= React.something` initializer.
259
+ if (
260
+ callee.type === 'MemberExpression' &&
261
+ callee.object.name === 'React' &&
262
+ callee.property != null &&
263
+ !callee.computed
264
+ ) {
265
+ callee = callee.property;
266
+ }
267
+ if (callee.type !== 'Identifier') {
268
+ return false;
269
+ }
270
+ const id = def.node.id;
271
+ const { name } = callee;
272
+ if (name === 'useRef' && id.type === 'Identifier') {
273
+ // useRef() return value is stable.
274
+ return true;
275
+ } else if (name === 'useState' || name === 'useReducer') {
276
+ // Only consider second value in initializing tuple stable.
277
+ if (
278
+ id.type === 'ArrayPattern' &&
279
+ id.elements.length === 2 &&
280
+ isArray(resolved.identifiers)
281
+ ) {
282
+ // Is second tuple value the same reference we're checking?
283
+ if (id.elements[1] === resolved.identifiers[0]) {
284
+ if (name === 'useState') {
285
+ const references = resolved.references;
286
+ let writeCount = 0;
287
+ for (const reference of references) {
288
+ if (reference.isWrite()) {
289
+ writeCount++;
290
+ }
291
+ if (writeCount > 1) {
292
+ return false;
293
+ }
294
+ setStateCallSites.set(reference.identifier, id.elements[0]);
295
+ }
296
+ }
297
+ // Setter is stable.
298
+ return true;
299
+ } else if (id.elements[0] === resolved.identifiers[0]) {
300
+ if (name === 'useState') {
301
+ const references = resolved.references;
302
+ for (const reference of references) {
303
+ stateVariables.add(reference.identifier);
304
+ }
305
+ }
306
+ // State variable itself is dynamic.
307
+ return false;
308
+ }
309
+ }
310
+ } else if (name === 'useTransition') {
311
+ // Only consider second value in initializing tuple stable.
312
+ if (
313
+ id.type === 'ArrayPattern' &&
314
+ id.elements.length === 2 &&
315
+ Array.isArray(resolved.identifiers) && // Is second tuple value the same reference we're checking?
316
+ id.elements[1] === resolved.identifiers[0]
317
+ ) {
318
+ // Setter is stable.
319
+ return true;
320
+ }
321
+ } else if (
322
+ options.checkMemoizedVariableIsStatic &&
323
+ (name === 'useMemo' || name === 'useCallback')
324
+ ) {
325
+ // check memoized value is stable
326
+ // useMemo(() => { ... }, []) / useCallback((...) => { ... }, [])
327
+ const hookArgs = callee.parent.arguments;
328
+ // check it has dependency list
329
+ if (hookArgs.length < 2) {
330
+ return false;
331
+ }
332
+
333
+ const dependencies = hookArgs[1].elements;
334
+ if (dependencies.length === 0) {
335
+ // no dependency, so it's stable
336
+ return true;
337
+ }
338
+
339
+ // check all dependency is stable
340
+ for (const dependencyNode of dependencies) {
341
+ // find resolved from resolved's scope
342
+ // TODO: check dependencyNode is in arguments?
343
+ const dependencyRefernece = resolved.scope.references.find(
344
+ reference => reference.identifier === dependencyNode
345
+ );
346
+
347
+ if (
348
+ dependencyRefernece !== undefined &&
349
+ memoizedIsStableKnownHookValue(dependencyRefernece.resolved)
350
+ ) {
351
+ continue;
352
+ } else {
353
+ return false;
354
+ }
355
+ }
356
+
357
+ return true;
358
+ } else {
359
+ // filter regexp first
360
+ Object.entries(options.staticHooks).forEach(([key, staticParts]) => {
361
+ if (
362
+ typeof staticParts === 'object' &&
363
+ staticParts.regexp &&
364
+ new RegExp(key).test(name)
365
+ ) {
366
+ options.staticHooks[name] = staticParts.value;
367
+ }
368
+ });
369
+
370
+ if (options.staticHooks[name]) {
371
+ const staticParts = options.staticHooks[name];
372
+ if (staticParts === true) {
373
+ // entire return value is static
374
+ return true;
375
+ } else if (Array.isArray(staticParts)) {
376
+ // destructured tuple return where some elements are static
377
+ if (
378
+ id.type === 'ArrayPattern' &&
379
+ id.elements.length <= staticParts.length &&
380
+ Array.isArray(resolved.identifiers)
381
+ ) {
382
+ // find index of the resolved ident in the array pattern
383
+ const idx = id.elements.indexOf(resolved.identifiers[0]);
384
+ if (idx >= 0) {
385
+ return staticParts[idx];
386
+ }
387
+ }
388
+ } else if (
389
+ typeof staticParts === 'object' &&
390
+ id.type === 'ObjectPattern'
391
+ ) {
392
+ // destructured object return where some properties are static
393
+ const property = id.properties.find(
394
+ p => p.key === resolved.identifiers[0]
395
+ );
396
+ if (property) {
397
+ return staticParts[property.key.name];
398
+ }
399
+ }
400
+ }
401
+ }
402
+ // By default assume it's dynamic.
403
+ return false;
404
+ }
405
+
406
+ // Some are just functions that don't reference anything dynamic.
407
+ function isFunctionWithoutCapturedValues(resolved) {
408
+ if (!isArray(resolved.defs)) {
409
+ return false;
410
+ }
411
+ const def = resolved.defs[0];
412
+ if (def == null) {
413
+ return false;
414
+ }
415
+ if (def.node == null || def.node.id == null) {
416
+ return false;
417
+ }
418
+ // Search the direct component subscopes for
419
+ // top-level function definitions matching this reference.
420
+ const fnNode = def.node;
421
+ const childScopes = componentScope.childScopes;
422
+ let fnScope = null;
423
+ let i;
424
+ for (i = 0; i < childScopes.length; i++) {
425
+ const childScope = childScopes[i];
426
+ const childScopeBlock = childScope.block;
427
+ if (
428
+ // function handleChange() {}
429
+ (fnNode.type === 'FunctionDeclaration' &&
430
+ childScopeBlock === fnNode) ||
431
+ // const handleChange = () => {}
432
+ // const handleChange = function() {}
433
+ (fnNode.type === 'VariableDeclarator' &&
434
+ childScopeBlock.parent === fnNode)
435
+ ) {
436
+ // Found it!
437
+ fnScope = childScope;
438
+ break;
439
+ }
440
+ }
441
+ if (fnScope == null) {
442
+ return false;
443
+ }
444
+ // Does this function capture any values
445
+ // that are in pure scopes (aka render)?
446
+ for (i = 0; i < fnScope.through.length; i++) {
447
+ const ref = fnScope.through[i];
448
+ if (ref.resolved == null) {
449
+ continue;
450
+ }
451
+ if (
452
+ pureScopes.has(ref.resolved.scope) &&
453
+ // Stable values are fine though,
454
+ // although we won't check functions deeper.
455
+ !memoizedIsStableKnownHookValue(ref.resolved)
456
+ ) {
457
+ return false;
458
+ }
459
+ }
460
+ // If we got here, this function doesn't capture anything
461
+ // from render--or everything it captures is known stable.
462
+ return true;
463
+ }
464
+
465
+ // These are usually mistaken. Collect them.
466
+ const currentRefsInEffectCleanup = new Map();
467
+
468
+ // Is this reference inside a cleanup function for this effect node?
469
+ // We can check by traversing scopes upwards from the reference, and checking
470
+ // if the last "return () => " we encounter is located directly inside the effect.
471
+ function isInsideEffectCleanup(reference) {
472
+ let curScope = reference.from;
473
+ let isInReturnedFunction = false;
474
+ while (curScope.block !== node) {
475
+ if (curScope.type === 'function') {
476
+ isInReturnedFunction =
477
+ curScope.block.parent != null &&
478
+ curScope.block.parent.type === 'ReturnStatement';
479
+ }
480
+ curScope = curScope.upper;
481
+ }
482
+ return isInReturnedFunction;
483
+ }
484
+
485
+ // Get dependencies from all our resolved references in pure scopes.
486
+ // Key is dependency string, value is whether it's stable.
487
+ const dependencies = new Map();
488
+ const optionalChains = new Map();
489
+ gatherDependenciesRecursively(scope);
490
+
491
+ function gatherDependenciesRecursively(currentScope) {
492
+ for (const reference of currentScope.references) {
493
+ // If this reference is not resolved or it is not declared in a pure
494
+ // scope then we don't care about this reference.
495
+ if (!reference.resolved) {
496
+ continue;
497
+ }
498
+ if (!pureScopes.has(reference.resolved.scope)) {
499
+ continue;
500
+ }
501
+
502
+ // Narrow the scope of a dependency if it is, say, a member expression.
503
+ // Then normalize the narrowed dependency.
504
+ const referenceNode = fastFindReferenceWithParent(
505
+ node,
506
+ reference.identifier
507
+ );
508
+ const dependencyNode = getDependency(referenceNode);
509
+ const dependency = analyzePropertyChain(
510
+ dependencyNode,
511
+ optionalChains
512
+ );
513
+
514
+ // Accessing ref.current inside effect cleanup is bad.
515
+ if (
516
+ // We're in an effect...
517
+ isEffect &&
518
+ // ... and this look like accessing .current...
519
+ dependencyNode.type === 'Identifier' &&
520
+ (dependencyNode.parent.type === 'MemberExpression' ||
521
+ dependencyNode.parent.type === 'OptionalMemberExpression') &&
522
+ !dependencyNode.parent.computed &&
523
+ dependencyNode.parent.property.type === 'Identifier' &&
524
+ dependencyNode.parent.property.name === 'current' &&
525
+ // ...in a cleanup function or below...
526
+ isInsideEffectCleanup(reference)
527
+ ) {
528
+ currentRefsInEffectCleanup.set(dependency, {
529
+ reference,
530
+ dependencyNode
531
+ });
532
+ }
533
+
534
+ if (
535
+ dependencyNode.parent.type === 'TSTypeQuery' ||
536
+ dependencyNode.parent.type === 'TSTypeReference'
537
+ ) {
538
+ continue;
539
+ }
540
+
541
+ const def = reference.resolved.defs[0];
542
+ if (def == null) {
543
+ continue;
544
+ }
545
+ // Ignore references to the function itself as it's not defined yet.
546
+ if (def.node != null && def.node.init === node.parent) {
547
+ continue;
548
+ }
549
+ // Ignore Flow type parameters
550
+ if (def.type === 'TypeParameter') {
551
+ continue;
552
+ }
553
+
554
+ // Add the dependency to a map so we can make sure it is referenced
555
+ // again in our dependencies array. Remember whether it's stable.
556
+ if (!dependencies.has(dependency)) {
557
+ const resolved = reference.resolved;
558
+ const isStable =
559
+ memoizedIsStableKnownHookValue(resolved) ||
560
+ memoizedIsFunctionWithoutCapturedValues(resolved);
561
+ dependencies.set(dependency, {
562
+ isStable,
563
+ references: [reference]
564
+ });
565
+ } else {
566
+ dependencies.get(dependency).references.push(reference);
567
+ }
568
+ }
569
+
570
+ for (const childScope of currentScope.childScopes) {
571
+ gatherDependenciesRecursively(childScope);
572
+ }
573
+ }
574
+
575
+ // Warn about accessing .current in cleanup effects.
576
+ currentRefsInEffectCleanup.forEach(
577
+ ({ reference, dependencyNode }, dependency) => {
578
+ const references = reference.resolved.references;
579
+ // Is React managing this ref or us?
580
+ // Let's see if we can find a .current assignment.
581
+ let foundCurrentAssignment = false;
582
+ for (const { identifier } of references) {
583
+ const { parent } = identifier;
584
+ if (
585
+ parent != null &&
586
+ // ref.current
587
+ // Note: no need to handle OptionalMemberExpression because it can't be LHS.
588
+ parent.type === 'MemberExpression' &&
589
+ !parent.computed &&
590
+ parent.property.type === 'Identifier' &&
591
+ parent.property.name === 'current' &&
592
+ // ref.current = <something>
593
+ parent.parent.type === 'AssignmentExpression' &&
594
+ parent.parent.left === parent
595
+ ) {
596
+ foundCurrentAssignment = true;
597
+ break;
598
+ }
599
+ }
600
+ // We only want to warn about React-managed refs.
601
+ if (foundCurrentAssignment) {
602
+ return;
603
+ }
604
+ reportProblem({
605
+ node: dependencyNode.parent.property,
606
+ message:
607
+ `The ref value '${dependency}.current' will likely have ` +
608
+ `changed by the time this effect cleanup function runs. If ` +
609
+ `this ref points to a node rendered by React, copy ` +
610
+ `'${dependency}.current' to a variable inside the effect, and ` +
611
+ `use that variable in the cleanup function.`
612
+ });
613
+ }
614
+ );
615
+
616
+ // Warn about assigning to variables in the outer scope.
617
+ // Those are usually bugs.
618
+ const staleAssignments = new Set();
619
+ function reportStaleAssignment(writeExpr, key) {
620
+ if (staleAssignments.has(key)) {
621
+ return;
622
+ }
623
+ staleAssignments.add(key);
624
+ reportProblem({
625
+ node: writeExpr,
626
+ message:
627
+ `Assignments to the '${key}' variable from inside React Hook ` +
628
+ `${context.getSource(reactiveHook)} will be lost after each ` +
629
+ `render. To preserve the value over time, store it in a useRef ` +
630
+ `Hook and keep the mutable value in the '.current' property. ` +
631
+ `Otherwise, you can move this variable directly inside ` +
632
+ `${context.getSource(reactiveHook)}.`
633
+ });
634
+ }
635
+
636
+ // Remember which deps are stable and report bad usage first.
637
+ const stableDependencies = new Set();
638
+ dependencies.forEach(({ isStable, references }, key) => {
639
+ if (isStable) {
640
+ stableDependencies.add(key);
641
+ }
642
+ references.forEach(reference => {
643
+ if (reference.writeExpr) {
644
+ reportStaleAssignment(reference.writeExpr, key);
645
+ }
646
+ });
647
+ });
648
+
649
+ if (staleAssignments.size > 0) {
650
+ // The intent isn't clear so we'll wait until you fix those first.
651
+ return;
652
+ }
653
+
654
+ if (!declaredDependenciesNode) {
655
+ // Check if there are any top-level setState() calls.
656
+ // Those tend to lead to infinite loops.
657
+ let setStateInsideEffectWithoutDeps = null;
658
+ dependencies.forEach(({ references }, key) => {
659
+ if (setStateInsideEffectWithoutDeps) {
660
+ return;
661
+ }
662
+ references.forEach(reference => {
663
+ if (setStateInsideEffectWithoutDeps) {
664
+ return;
665
+ }
666
+
667
+ const id = reference.identifier;
668
+ const isSetState = setStateCallSites.has(id);
669
+ if (!isSetState) {
670
+ return;
671
+ }
672
+
673
+ let fnScope = reference.from;
674
+ while (fnScope.type !== 'function') {
675
+ fnScope = fnScope.upper;
676
+ }
677
+ const isDirectlyInsideEffect = fnScope.block === node;
678
+ if (isDirectlyInsideEffect) {
679
+ // TODO: we could potentially ignore early returns.
680
+ setStateInsideEffectWithoutDeps = key;
681
+ }
682
+ });
683
+ });
684
+ if (setStateInsideEffectWithoutDeps) {
685
+ const { suggestedDependencies } = collectRecommendations({
686
+ dependencies,
687
+ declaredDependencies: [],
688
+ stableDependencies,
689
+ externalDependencies: new Set(),
690
+ isEffect: true
691
+ });
692
+ reportProblem({
693
+ node: reactiveHook,
694
+ message:
695
+ `React Hook ${reactiveHookName} contains a call to '${setStateInsideEffectWithoutDeps}'. ` +
696
+ `Without a list of dependencies, this can lead to an infinite chain of updates. ` +
697
+ `To fix this, pass [` +
698
+ suggestedDependencies.join(', ') +
699
+ `] as a second argument to the ${reactiveHookName} Hook.`,
700
+ suggest: [
701
+ {
702
+ desc: `Add dependencies array: [${suggestedDependencies.join(
703
+ ', '
704
+ )}]`,
705
+ fix(fixer) {
706
+ return fixer.insertTextAfter(
707
+ node,
708
+ `, [${suggestedDependencies.join(', ')}]`
709
+ );
710
+ }
711
+ }
712
+ ]
713
+ });
714
+ }
715
+ return;
716
+ }
717
+
718
+ const declaredDependencies = [];
719
+ const externalDependencies = new Set();
720
+ if (declaredDependenciesNode.type !== 'ArrayExpression') {
721
+ // If the declared dependencies are not an array expression then we
722
+ // can't verify that the user provided the correct dependencies. Tell
723
+ // the user this in an error.
724
+ reportProblem({
725
+ node: declaredDependenciesNode,
726
+ message:
727
+ `React Hook ${context.getSource(reactiveHook)} was passed a ` +
728
+ 'dependency list that is not an array literal. This means we ' +
729
+ "can't statically verify whether you've passed the correct " +
730
+ 'dependencies.'
731
+ });
732
+ } else {
733
+ declaredDependenciesNode.elements.forEach(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 ${context.getSource(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
+ // Try to normalize the declared dependency. If we can't then an error
751
+ // will be thrown. We will catch that error and report an error.
752
+ let declaredDependency;
753
+ try {
754
+ declaredDependency = analyzePropertyChain(
755
+ declaredDependencyNode,
756
+ null
757
+ );
758
+ } catch (err) {
759
+ if (/Unsupported node type/.test(err.message)) {
760
+ if (declaredDependencyNode.type === 'Literal') {
761
+ if (dependencies.has(declaredDependencyNode.value)) {
762
+ reportProblem({
763
+ node: declaredDependencyNode,
764
+ message:
765
+ `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
766
+ `because it never changes. ` +
767
+ `Did you mean to include ${declaredDependencyNode.value} in the array instead?`
768
+ });
769
+ } else {
770
+ reportProblem({
771
+ node: declaredDependencyNode,
772
+ message:
773
+ `The ${declaredDependencyNode.raw} literal is not a valid dependency ` +
774
+ 'because it never changes. You can safely remove it.'
775
+ });
776
+ }
777
+ } else {
778
+ reportProblem({
779
+ node: declaredDependencyNode,
780
+ message:
781
+ `React Hook ${context.getSource(reactiveHook)} has a ` +
782
+ `complex expression in the dependency array. ` +
783
+ 'Extract it to a separate variable so it can be statically checked.'
784
+ });
785
+ }
786
+
787
+ return;
788
+ }
789
+ throw err;
790
+ }
791
+
792
+ let maybeID = declaredDependencyNode;
793
+ while (
794
+ maybeID.type === 'MemberExpression' ||
795
+ maybeID.type === 'OptionalMemberExpression' ||
796
+ maybeID.type === 'ChainExpression'
797
+ ) {
798
+ maybeID = maybeID.object || maybeID.expression.object;
799
+ }
800
+ const isDeclaredInComponent = !componentScope.through.some(
801
+ ref => ref.identifier === maybeID
802
+ );
803
+
804
+ // Add the dependency to our declared dependency map.
805
+ declaredDependencies.push({
806
+ key: declaredDependency,
807
+ node: declaredDependencyNode
808
+ });
809
+
810
+ if (!isDeclaredInComponent) {
811
+ externalDependencies.add(declaredDependency);
812
+ }
813
+ });
814
+ }
815
+
816
+ const {
817
+ suggestedDependencies,
818
+ unnecessaryDependencies,
819
+ missingDependencies,
820
+ duplicateDependencies
821
+ } = collectRecommendations({
822
+ dependencies,
823
+ declaredDependencies,
824
+ stableDependencies,
825
+ externalDependencies,
826
+ isEffect
827
+ });
828
+
829
+ let suggestedDeps = suggestedDependencies;
830
+
831
+ const problemCount =
832
+ duplicateDependencies.size +
833
+ missingDependencies.size +
834
+ unnecessaryDependencies.size;
835
+
836
+ if (problemCount === 0) {
837
+ // If nothing else to report, check if some dependencies would
838
+ // invalidate on every render.
839
+ const constructions = scanForConstructions({
840
+ declaredDependencies,
841
+ declaredDependenciesNode,
842
+ componentScope,
843
+ scope
844
+ });
845
+ constructions.forEach(
846
+ ({ construction, isUsedOutsideOfHook, depType }) => {
847
+ const wrapperHook =
848
+ depType === 'function' ? 'useCallback' : 'useMemo';
849
+
850
+ const constructionType =
851
+ depType === 'function' ? 'definition' : 'initialization';
852
+
853
+ const defaultAdvice = `wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`;
854
+
855
+ const advice = isUsedOutsideOfHook
856
+ ? `To fix this, ${defaultAdvice}`
857
+ : `Move it inside the ${reactiveHookName} callback. Alternatively, ${defaultAdvice}`;
858
+
859
+ const causation =
860
+ depType === 'conditional' || depType === 'logical expression'
861
+ ? 'could make'
862
+ : 'makes';
863
+
864
+ const message =
865
+ `The '${construction.name.name}' ${depType} ${causation} the dependencies of ` +
866
+ `${reactiveHookName} Hook (at line ${declaredDependenciesNode.loc.start.line}) ` +
867
+ `change on every render. ${advice}`;
868
+
869
+ let suggest;
870
+ // Only handle the simple case of variable assignments.
871
+ // Wrapping function declarations can mess up hoisting.
872
+ if (
873
+ isUsedOutsideOfHook &&
874
+ construction.type === 'Variable' &&
875
+ // Objects may be mutated after construction, which would make this
876
+ // fix unsafe. Functions _probably_ won't be mutated, so we'll
877
+ // allow this fix for them.
878
+ depType === 'function'
879
+ ) {
880
+ suggest = [
881
+ {
882
+ desc: `Wrap the ${constructionType} of '${construction.name.name}' in its own ${wrapperHook}() Hook.`,
883
+ fix(fixer) {
884
+ const [before, after] =
885
+ wrapperHook === 'useMemo'
886
+ ? [`useMemo(() => { return `, '; })']
887
+ : ['useCallback(', ')'];
888
+ return [
889
+ // TODO: also add an import?
890
+ fixer.insertTextBefore(construction.node.init, before),
891
+ // TODO: ideally we'd gather deps here but it would require
892
+ // restructuring the rule code. This will cause a new lint
893
+ // error to appear immediately for useCallback. Note we're
894
+ // not adding [] because would that changes semantics.
895
+ fixer.insertTextAfter(construction.node.init, after)
896
+ ];
897
+ }
898
+ }
899
+ ];
900
+ }
901
+ // TODO: What if the function needs to change on every render anyway?
902
+ // Should we suggest removing effect deps as an appropriate fix too?
903
+ reportProblem({
904
+ // TODO: Why not report this at the dependency site?
905
+ node: construction.node,
906
+ message,
907
+ suggest
908
+ });
909
+ }
910
+ );
911
+ return;
912
+ }
913
+
914
+ // If we're going to report a missing dependency,
915
+ // we might as well recalculate the list ignoring
916
+ // the currently specified deps. This can result
917
+ // in some extra deduplication. We can't do this
918
+ // for effects though because those have legit
919
+ // use cases for over-specifying deps.
920
+ if (!isEffect && missingDependencies.size > 0) {
921
+ suggestedDeps = collectRecommendations({
922
+ dependencies,
923
+ declaredDependencies: [], // Pretend we don't know
924
+ stableDependencies,
925
+ externalDependencies,
926
+ isEffect
927
+ }).suggestedDependencies;
928
+ }
929
+
930
+ // Alphabetize the suggestions, but only if deps were already alphabetized.
931
+ function areDeclaredDepsAlphabetized() {
932
+ if (declaredDependencies.length === 0) {
933
+ return true;
934
+ }
935
+ const declaredDepKeys = declaredDependencies.map(dep => dep.key);
936
+ const sortedDeclaredDepKeys = [...declaredDepKeys].sort();
937
+ return declaredDepKeys.join(',') === sortedDeclaredDepKeys.join(',');
938
+ }
939
+ if (areDeclaredDepsAlphabetized()) {
940
+ suggestedDeps.sort();
941
+ }
942
+
943
+ // Most of our algorithm deals with dependency paths with optional chaining stripped.
944
+ // This function is the last step before printing a dependency, so now is a good time to
945
+ // check whether any members in our path are always used as optional-only. In that case,
946
+ // we will use ?. instead of . to concatenate those parts of the path.
947
+ function formatDependency(path) {
948
+ const members = path.split('.');
949
+ let finalPath = '';
950
+ for (let i = 0; i < members.length; i++) {
951
+ if (i !== 0) {
952
+ const pathSoFar = members.slice(0, i + 1).join('.');
953
+ const isOptional = optionalChains.get(pathSoFar) === true;
954
+ finalPath += isOptional ? '?.' : '.';
955
+ }
956
+ finalPath += members[i];
957
+ }
958
+ return finalPath;
959
+ }
960
+
961
+ function getWarningMessage(deps, singlePrefix, label, fixVerb) {
962
+ if (deps.size === 0) {
963
+ return null;
964
+ }
965
+ return (
966
+ (deps.size > 1 ? '' : singlePrefix + ' ') +
967
+ label +
968
+ ' ' +
969
+ (deps.size > 1 ? 'dependencies' : 'dependency') +
970
+ ': ' +
971
+ joinEnglish(
972
+ [...deps].sort().map(name => "'" + formatDependency(name) + "'")
973
+ ) +
974
+ `. Either ${fixVerb} ${
975
+ deps.size > 1 ? 'them' : 'it'
976
+ } or remove the dependency array.`
977
+ );
978
+ }
979
+
980
+ let extraWarning = '';
981
+ if (unnecessaryDependencies.size > 0) {
982
+ let badRef = null;
983
+ [...unnecessaryDependencies.keys()].forEach(key => {
984
+ if (badRef != null) {
985
+ return;
986
+ }
987
+ if (key.endsWith('.current')) {
988
+ badRef = key;
989
+ }
990
+ });
991
+ if (badRef != null) {
992
+ extraWarning =
993
+ ` Mutable values like '${badRef}' aren't valid dependencies ` +
994
+ "because mutating them doesn't re-render the component.";
995
+ } else if (externalDependencies.size > 0) {
996
+ const dep = [...externalDependencies][0];
997
+ // Don't show this warning for things that likely just got moved *inside* the callback
998
+ // because in that case they're clearly not referring to globals.
999
+ if (!scope.set.has(dep)) {
1000
+ extraWarning =
1001
+ ` Outer scope values like '${dep}' aren't valid dependencies ` +
1002
+ `because mutating them doesn't re-render the component.`;
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ // `props.foo()` marks `props` as a dependency because it has
1008
+ // a `this` value. This warning can be confusing.
1009
+ // So if we're going to show it, append a clarification.
1010
+ if (!extraWarning && missingDependencies.has('props')) {
1011
+ const propDep = dependencies.get('props');
1012
+ if (propDep == null) {
1013
+ return;
1014
+ }
1015
+ const refs = propDep.references;
1016
+ if (!Array.isArray(refs)) {
1017
+ return;
1018
+ }
1019
+ let isPropsOnlyUsedInMembers = true;
1020
+ for (const ref of refs) {
1021
+ const id = fastFindReferenceWithParent(
1022
+ componentScope.block,
1023
+ ref.identifier
1024
+ );
1025
+ if (!id) {
1026
+ isPropsOnlyUsedInMembers = false;
1027
+ break;
1028
+ }
1029
+ const parent = id.parent;
1030
+ if (parent == null) {
1031
+ isPropsOnlyUsedInMembers = false;
1032
+ break;
1033
+ }
1034
+ if (
1035
+ parent.type !== 'MemberExpression' &&
1036
+ parent.type !== 'OptionalMemberExpression'
1037
+ ) {
1038
+ isPropsOnlyUsedInMembers = false;
1039
+ break;
1040
+ }
1041
+ }
1042
+ if (isPropsOnlyUsedInMembers) {
1043
+ extraWarning =
1044
+ ` However, 'props' will change when *any* prop changes, so the ` +
1045
+ `preferred fix is to destructure the 'props' object outside of ` +
1046
+ `the ${reactiveHookName} call and refer to those specific props ` +
1047
+ `inside ${context.getSource(reactiveHook)}.`;
1048
+ }
1049
+ }
1050
+
1051
+ if (!extraWarning && missingDependencies.size > 0) {
1052
+ // See if the user is trying to avoid specifying a callable prop.
1053
+ // This usually means they're unaware of useCallback.
1054
+ let missingCallbackDep = null;
1055
+ missingDependencies.forEach(missingDep => {
1056
+ if (missingCallbackDep) {
1057
+ return;
1058
+ }
1059
+ // Is this a variable from top scope?
1060
+ const topScopeRef = componentScope.set.get(missingDep);
1061
+ const usedDep = dependencies.get(missingDep);
1062
+ if (usedDep.references[0].resolved !== topScopeRef) {
1063
+ return;
1064
+ }
1065
+ // Is this a destructured prop?
1066
+ const def = topScopeRef.defs[0];
1067
+ if (def == null || def.name == null || def.type !== 'Parameter') {
1068
+ return;
1069
+ }
1070
+ // Was it called in at least one case? Then it's a function.
1071
+ let isFunctionCall = false;
1072
+ let id;
1073
+ for (let i = 0; i < usedDep.references.length; i++) {
1074
+ id = usedDep.references[i].identifier;
1075
+ if (
1076
+ id != null &&
1077
+ id.parent != null &&
1078
+ (id.parent.type === 'CallExpression' ||
1079
+ id.parent.type === 'OptionalCallExpression') &&
1080
+ id.parent.callee === id
1081
+ ) {
1082
+ isFunctionCall = true;
1083
+ break;
1084
+ }
1085
+ }
1086
+ if (!isFunctionCall) {
1087
+ return;
1088
+ }
1089
+ // If it's missing (i.e. in component scope) *and* it's a parameter
1090
+ // then it is definitely coming from props destructuring.
1091
+ // (It could also be props itself but we wouldn't be calling it then.)
1092
+ missingCallbackDep = missingDep;
1093
+ });
1094
+ if (missingCallbackDep != null) {
1095
+ extraWarning =
1096
+ ` If '${missingCallbackDep}' changes too often, ` +
1097
+ `find the parent component that defines it ` +
1098
+ `and wrap that definition in useCallback.`;
1099
+ }
1100
+ }
1101
+
1102
+ if (!extraWarning && missingDependencies.size > 0) {
1103
+ let setStateRecommendation = null;
1104
+ missingDependencies.forEach(missingDep => {
1105
+ if (setStateRecommendation != null) {
1106
+ return;
1107
+ }
1108
+ const usedDep = dependencies.get(missingDep);
1109
+ const references = usedDep.references;
1110
+ let id;
1111
+ let maybeCall;
1112
+ for (const reference of references) {
1113
+ id = reference.identifier;
1114
+ maybeCall = id.parent;
1115
+ // Try to see if we have setState(someExpr(missingDep)).
1116
+ while (maybeCall != null && maybeCall !== componentScope.block) {
1117
+ if (maybeCall.type === 'CallExpression') {
1118
+ const correspondingStateVariable = setStateCallSites.get(
1119
+ maybeCall.callee
1120
+ );
1121
+ if (correspondingStateVariable != null) {
1122
+ if (correspondingStateVariable.name === missingDep) {
1123
+ // setCount(count + 1)
1124
+ setStateRecommendation = {
1125
+ missingDep,
1126
+ setter: maybeCall.callee.name,
1127
+ form: 'updater'
1128
+ };
1129
+ } else if (stateVariables.has(id)) {
1130
+ // setCount(count + increment)
1131
+ setStateRecommendation = {
1132
+ missingDep,
1133
+ setter: maybeCall.callee.name,
1134
+ form: 'reducer'
1135
+ };
1136
+ } else {
1137
+ const resolved = reference.resolved;
1138
+ if (resolved != null) {
1139
+ // If it's a parameter *and* a missing dep,
1140
+ // it must be a prop or something inside a prop.
1141
+ // Therefore, recommend an inline reducer.
1142
+ const def = resolved.defs[0];
1143
+ if (def != null && def.type === 'Parameter') {
1144
+ setStateRecommendation = {
1145
+ missingDep,
1146
+ setter: maybeCall.callee.name,
1147
+ form: 'inlineReducer'
1148
+ };
1149
+ }
1150
+ }
1151
+ }
1152
+ break;
1153
+ }
1154
+ }
1155
+ maybeCall = maybeCall.parent;
1156
+ }
1157
+ if (setStateRecommendation != null) {
1158
+ break;
1159
+ }
1160
+ }
1161
+ });
1162
+ if (setStateRecommendation != null) {
1163
+ switch (setStateRecommendation.form) {
1164
+ case 'reducer':
1165
+ extraWarning =
1166
+ ` You can also replace multiple useState variables with useReducer ` +
1167
+ `if '${setStateRecommendation.setter}' needs the ` +
1168
+ `current value of '${setStateRecommendation.missingDep}'.`;
1169
+ break;
1170
+ case 'inlineReducer':
1171
+ extraWarning =
1172
+ ` If '${setStateRecommendation.setter}' needs the ` +
1173
+ `current value of '${setStateRecommendation.missingDep}', ` +
1174
+ `you can also switch to useReducer instead of useState and ` +
1175
+ `read '${setStateRecommendation.missingDep}' in the reducer.`;
1176
+ break;
1177
+ case 'updater':
1178
+ extraWarning = ` You can also do a functional update '${
1179
+ setStateRecommendation.setter
1180
+ }(${setStateRecommendation.missingDep.slice(
1181
+ 0,
1182
+ 1
1183
+ )} => ...)' if you only need '${
1184
+ setStateRecommendation.missingDep
1185
+ }' in the '${setStateRecommendation.setter}' call.`;
1186
+ break;
1187
+ default:
1188
+ throw new Error('Unknown case.');
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ reportProblem({
1194
+ node: declaredDependenciesNode,
1195
+ message:
1196
+ `React Hook ${context.getSource(reactiveHook)} has ` +
1197
+ // To avoid a long message, show the next actionable item.
1198
+ (getWarningMessage(missingDependencies, 'a', 'missing', 'include') ||
1199
+ getWarningMessage(
1200
+ unnecessaryDependencies,
1201
+ 'an',
1202
+ 'unnecessary',
1203
+ 'exclude'
1204
+ ) ||
1205
+ getWarningMessage(
1206
+ duplicateDependencies,
1207
+ 'a',
1208
+ 'duplicate',
1209
+ 'omit'
1210
+ )) +
1211
+ extraWarning,
1212
+ suggest: [
1213
+ {
1214
+ desc: `Update the dependencies array to be: [${suggestedDeps
1215
+ .map(element => formatDependency(element))
1216
+ .join(', ')}]`,
1217
+ fix(fixer) {
1218
+ // TODO: consider preserving the comments or formatting?
1219
+ return fixer.replaceText(
1220
+ declaredDependenciesNode,
1221
+ `[${suggestedDeps.map(element => formatDependency(element)).join(', ')}]`
1222
+ );
1223
+ }
1224
+ }
1225
+ ]
1226
+ });
1227
+ }
1228
+
1229
+ function visitCallExpression(node) {
1230
+ const callbackIndex = getReactiveHookCallbackIndex(node.callee, options);
1231
+ if (callbackIndex === -1) {
1232
+ // Not a React Hook call that needs deps.
1233
+ return;
1234
+ }
1235
+ const callback = node.arguments[callbackIndex];
1236
+ const reactiveHook = node.callee;
1237
+ const reactiveHookName = getNodeWithoutReactNamespace(reactiveHook).name;
1238
+ const declaredDependenciesNode = node.arguments[callbackIndex + 1];
1239
+ const isEffect = /Effect($|[^a-z])/g.test(reactiveHookName);
1240
+
1241
+ // Check whether a callback is supplied. If there is no callback supplied
1242
+ // then the hook will not work and React will throw a TypeError.
1243
+ // So no need to check for dependency inclusion.
1244
+ if (!callback) {
1245
+ reportProblem({
1246
+ node: reactiveHook,
1247
+ message:
1248
+ `React Hook ${reactiveHookName} requires an effect callback. ` +
1249
+ `Did you forget to pass a callback to the hook?`
1250
+ });
1251
+ return;
1252
+ }
1253
+
1254
+ // Check the declared dependencies for this reactive hook. If there is no
1255
+ // second argument then the reactive callback will re-run on every render.
1256
+ // So no need to check for dependency inclusion.
1257
+ if (!declaredDependenciesNode && !isEffect) {
1258
+ // These are only used for optimization.
1259
+ if (
1260
+ reactiveHookName === 'useMemo' ||
1261
+ reactiveHookName === 'useCallback'
1262
+ ) {
1263
+ // TODO: Can this have a suggestion?
1264
+ reportProblem({
1265
+ node: reactiveHook,
1266
+ message:
1267
+ `React Hook ${reactiveHookName} does nothing when called with ` +
1268
+ `only one argument. Did you forget to pass an array of ` +
1269
+ `dependencies?`
1270
+ });
1271
+ }
1272
+ return;
1273
+ }
1274
+
1275
+ switch (callback.type) {
1276
+ case 'FunctionExpression':
1277
+ case 'ArrowFunctionExpression':
1278
+ visitFunctionWithDependencies(
1279
+ callback,
1280
+ declaredDependenciesNode,
1281
+ reactiveHook,
1282
+ reactiveHookName,
1283
+ isEffect
1284
+ );
1285
+ return; // Handled
1286
+ case 'Identifier':
1287
+ if (!declaredDependenciesNode) {
1288
+ // No deps, no problems.
1289
+ return; // Handled
1290
+ }
1291
+ // The function passed as a callback is not written inline.
1292
+ // But perhaps it's in the dependencies array?
1293
+ if (
1294
+ declaredDependenciesNode.elements &&
1295
+ declaredDependenciesNode.elements.some(
1296
+ el => el && el.type === 'Identifier' && el.name === callback.name
1297
+ )
1298
+ ) {
1299
+ // If it's already in the list of deps, we don't care because
1300
+ // this is valid regardless.
1301
+ return; // Handled
1302
+ }
1303
+ // We'll do our best effort to find it, complain otherwise.
1304
+ const variable = context.getScope().set.get(callback.name);
1305
+ if (variable == null || variable.defs == null) {
1306
+ // If it's not in scope, we don't care.
1307
+ return; // Handled
1308
+ }
1309
+ // The function passed as a callback is not written inline.
1310
+ // But it's defined somewhere in the render scope.
1311
+ // We'll do our best effort to find and check it, complain otherwise.
1312
+ const def = variable.defs[0];
1313
+ if (!def || !def.node) {
1314
+ break; // Unhandled
1315
+ }
1316
+ if (def.type !== 'Variable' && def.type !== 'FunctionName') {
1317
+ // Parameter or an unusual pattern. Bail out.
1318
+ break; // Unhandled
1319
+ }
1320
+ switch (def.node.type) {
1321
+ case 'FunctionDeclaration':
1322
+ // useEffect(() => { ... }, []);
1323
+ visitFunctionWithDependencies(
1324
+ def.node,
1325
+ declaredDependenciesNode,
1326
+ reactiveHook,
1327
+ reactiveHookName,
1328
+ isEffect
1329
+ );
1330
+ return; // Handled
1331
+ case 'VariableDeclarator':
1332
+ const init = def.node.init;
1333
+ if (!init) {
1334
+ break; // Unhandled
1335
+ }
1336
+ switch (init.type) {
1337
+ // const effectBody = () => {...};
1338
+ // useEffect(effectBody, []);
1339
+ case 'ArrowFunctionExpression':
1340
+ case 'FunctionExpression':
1341
+ // We can inspect this function as if it were inline.
1342
+ visitFunctionWithDependencies(
1343
+ init,
1344
+ declaredDependenciesNode,
1345
+ reactiveHook,
1346
+ reactiveHookName,
1347
+ isEffect
1348
+ );
1349
+ return; // Handled
1350
+ }
1351
+ break; // Unhandled
1352
+ }
1353
+ break; // Unhandled
1354
+ default:
1355
+ // useEffect(generateEffectBody(), []);
1356
+ reportProblem({
1357
+ node: reactiveHook,
1358
+ message:
1359
+ `React Hook ${reactiveHookName} received a function whose dependencies ` +
1360
+ `are unknown. Pass an inline function instead.`
1361
+ });
1362
+ return; // Handled
1363
+ }
1364
+
1365
+ // Something unusual. Fall back to suggesting to add the body itself as a dep.
1366
+ reportProblem({
1367
+ node: reactiveHook,
1368
+ message:
1369
+ `React Hook ${reactiveHookName} has a missing dependency: '${callback.name}'. ` +
1370
+ `Either include it or remove the dependency array.`,
1371
+ suggest: [
1372
+ {
1373
+ desc: `Update the dependencies array to be: [${callback.name}]`,
1374
+ fix(fixer) {
1375
+ return fixer.replaceText(
1376
+ declaredDependenciesNode,
1377
+ `[${callback.name}]`
1378
+ );
1379
+ }
1380
+ }
1381
+ ]
1382
+ });
1383
+ }
1384
+
1385
+ return {
1386
+ CallExpression: visitCallExpression
1387
+ };
1388
+ }
1389
+ };
1390
+
1391
+ // The meat of the logic.
1392
+ function collectRecommendations({
1393
+ dependencies,
1394
+ declaredDependencies,
1395
+ stableDependencies,
1396
+ externalDependencies,
1397
+ isEffect
1398
+ }) {
1399
+ // Our primary data structure.
1400
+ // It is a logical representation of property chains:
1401
+ // `props` -> `props.foo` -> `props.foo.bar` -> `props.foo.bar.baz`
1402
+ // -> `props.lol`
1403
+ // -> `props.huh` -> `props.huh.okay`
1404
+ // -> `props.wow`
1405
+ // We'll use it to mark nodes that are *used* by the programmer,
1406
+ // and the nodes that were *declared* as deps. Then we will
1407
+ // traverse it to learn which deps are missing or unnecessary.
1408
+ const depTree = createDepTree();
1409
+ function createDepTree() {
1410
+ return {
1411
+ isUsed: false, // True if used in code
1412
+ isSatisfiedRecursively: false, // True if specified in deps
1413
+ isSubtreeUsed: false, // True if something deeper is used by code
1414
+ children: new Map() // Nodes for properties
1415
+ };
1416
+ }
1417
+
1418
+ // Mark all required nodes first.
1419
+ // Imagine exclamation marks next to each used deep property.
1420
+ dependencies.forEach((_, key) => {
1421
+ const node = getOrCreateNodeByPath(depTree, key);
1422
+ node.isUsed = true;
1423
+ markAllParentsByPath(depTree, key, parent => {
1424
+ parent.isSubtreeUsed = true;
1425
+ });
1426
+ });
1427
+
1428
+ // Mark all satisfied nodes.
1429
+ // Imagine checkmarks next to each declared dependency.
1430
+ declaredDependencies.forEach(({ key }) => {
1431
+ const node = getOrCreateNodeByPath(depTree, key);
1432
+ node.isSatisfiedRecursively = true;
1433
+ });
1434
+ stableDependencies.forEach(key => {
1435
+ const node = getOrCreateNodeByPath(depTree, key);
1436
+ node.isSatisfiedRecursively = true;
1437
+ });
1438
+
1439
+ // Tree manipulation helpers.
1440
+ function getOrCreateNodeByPath(rootNode, path) {
1441
+ const keys = path.split('.');
1442
+ let node = rootNode;
1443
+ for (const key of keys) {
1444
+ let child = node.children.get(key);
1445
+ if (!child) {
1446
+ child = createDepTree();
1447
+ node.children.set(key, child);
1448
+ }
1449
+ node = child;
1450
+ }
1451
+ return node;
1452
+ }
1453
+ function markAllParentsByPath(rootNode, path, fn) {
1454
+ const keys = path.split('.');
1455
+ let node = rootNode;
1456
+ for (const key of keys) {
1457
+ const child = node.children.get(key);
1458
+ if (!child) {
1459
+ return;
1460
+ }
1461
+ fn(child);
1462
+ node = child;
1463
+ }
1464
+ }
1465
+
1466
+ // Now we can learn which dependencies are missing or necessary.
1467
+ const missingDependencies = new Set();
1468
+ const satisfyingDependencies = new Set();
1469
+ scanTreeRecursively(
1470
+ depTree,
1471
+ missingDependencies,
1472
+ satisfyingDependencies,
1473
+ key => key
1474
+ );
1475
+ function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) {
1476
+ node.children.forEach((child, key) => {
1477
+ const path = keyToPath(key);
1478
+ if (child.isSatisfiedRecursively) {
1479
+ if (child.isSubtreeUsed) {
1480
+ // Remember this dep actually satisfied something.
1481
+ satisfyingPaths.add(path);
1482
+ }
1483
+ // It doesn't matter if there's something deeper.
1484
+ // It would be transitively satisfied since we assume immutability.
1485
+ // `props.foo` is enough if you read `props.foo.id`.
1486
+ return;
1487
+ }
1488
+ if (child.isUsed) {
1489
+ // Remember that no declared deps satisfied this node.
1490
+ missingPaths.add(path);
1491
+ // If we got here, nothing in its subtree was satisfied.
1492
+ // No need to search further.
1493
+ return;
1494
+ }
1495
+ scanTreeRecursively(
1496
+ child,
1497
+ missingPaths,
1498
+ satisfyingPaths,
1499
+ childKey => path + '.' + childKey
1500
+ );
1501
+ });
1502
+ }
1503
+
1504
+ // Collect suggestions in the order they were originally specified.
1505
+ const suggestedDependencies = [];
1506
+ const unnecessaryDependencies = new Set();
1507
+ const duplicateDependencies = new Set();
1508
+ declaredDependencies.forEach(({ key }) => {
1509
+ // Does this declared dep satisfy a real need?
1510
+ if (satisfyingDependencies.has(key)) {
1511
+ if (!suggestedDependencies.includes(key)) {
1512
+ // Good one.
1513
+ suggestedDependencies.push(key);
1514
+ } else {
1515
+ // Duplicate.
1516
+ duplicateDependencies.add(key);
1517
+ }
1518
+ } else if (
1519
+ isEffect &&
1520
+ !key.endsWith('.current') &&
1521
+ !externalDependencies.has(key)
1522
+ ) {
1523
+ // Effects are allowed extra "unnecessary" deps.
1524
+ // Such as resetting scroll when ID changes.
1525
+ // Consider them legit.
1526
+ // The exception is ref.current which is always wrong.
1527
+ if (!suggestedDependencies.includes(key)) {
1528
+ suggestedDependencies.push(key);
1529
+ }
1530
+ } else {
1531
+ // It's definitely not needed.
1532
+ unnecessaryDependencies.add(key);
1533
+ }
1534
+ });
1535
+
1536
+ // Then add the missing ones at the end.
1537
+ missingDependencies.forEach(key => {
1538
+ suggestedDependencies.push(key);
1539
+ });
1540
+
1541
+ return {
1542
+ suggestedDependencies,
1543
+ unnecessaryDependencies,
1544
+ duplicateDependencies,
1545
+ missingDependencies
1546
+ };
1547
+ }
1548
+
1549
+ // If the node will result in constructing a referentially unique value, return
1550
+ // its human readable type name, else return null.
1551
+ function getConstructionExpressionType(node) {
1552
+ switch (node.type) {
1553
+ case 'ObjectExpression':
1554
+ return 'object';
1555
+ case 'ArrayExpression':
1556
+ return 'array';
1557
+ case 'ArrowFunctionExpression':
1558
+ case 'FunctionExpression':
1559
+ return 'function';
1560
+ case 'ClassExpression':
1561
+ return 'class';
1562
+ case 'ConditionalExpression':
1563
+ if (
1564
+ getConstructionExpressionType(node.consequent) != null ||
1565
+ getConstructionExpressionType(node.alternate) != null
1566
+ ) {
1567
+ return 'conditional';
1568
+ }
1569
+ return null;
1570
+ case 'LogicalExpression':
1571
+ if (
1572
+ getConstructionExpressionType(node.left) != null ||
1573
+ getConstructionExpressionType(node.right) != null
1574
+ ) {
1575
+ return 'logical expression';
1576
+ }
1577
+ return null;
1578
+ case 'JSXFragment':
1579
+ return 'JSX fragment';
1580
+ case 'JSXElement':
1581
+ return 'JSX element';
1582
+ case 'AssignmentExpression':
1583
+ if (getConstructionExpressionType(node.right) != null) {
1584
+ return 'assignment expression';
1585
+ }
1586
+ return null;
1587
+ case 'NewExpression':
1588
+ return 'object construction';
1589
+ case 'Literal':
1590
+ if (node.value instanceof RegExp) {
1591
+ return 'regular expression';
1592
+ }
1593
+ return null;
1594
+ case 'TypeCastExpression':
1595
+ return getConstructionExpressionType(node.expression);
1596
+ case 'TSAsExpression':
1597
+ return getConstructionExpressionType(node.expression);
1598
+ }
1599
+ return null;
1600
+ }
1601
+
1602
+ // Finds variables declared as dependencies
1603
+ // that would invalidate on every render.
1604
+ function scanForConstructions({
1605
+ declaredDependencies,
1606
+ declaredDependenciesNode,
1607
+ componentScope,
1608
+ scope
1609
+ }) {
1610
+ const constructions = declaredDependencies
1611
+ .map(({ key }) => {
1612
+ const ref = componentScope.variables.find(v => v.name === key);
1613
+ if (ref == null) {
1614
+ return null;
1615
+ }
1616
+
1617
+ const node = ref.defs[0];
1618
+ if (node == null) {
1619
+ return null;
1620
+ }
1621
+ // const handleChange = function () {}
1622
+ // const handleChange = () => {}
1623
+ // const foo = {}
1624
+ // const foo = []
1625
+ // etc.
1626
+ if (
1627
+ node.type === 'Variable' &&
1628
+ node.node.type === 'VariableDeclarator' &&
1629
+ node.node.id.type === 'Identifier' && // Ensure this is not destructed assignment
1630
+ node.node.init != null
1631
+ ) {
1632
+ const constantExpressionType = getConstructionExpressionType(
1633
+ node.node.init
1634
+ );
1635
+ if (constantExpressionType != null) {
1636
+ return [ref, constantExpressionType];
1637
+ }
1638
+ }
1639
+ // function handleChange() {}
1640
+ if (
1641
+ node.type === 'FunctionName' &&
1642
+ node.node.type === 'FunctionDeclaration'
1643
+ ) {
1644
+ return [ref, 'function'];
1645
+ }
1646
+
1647
+ // class Foo {}
1648
+ if (node.type === 'ClassName' && node.node.type === 'ClassDeclaration') {
1649
+ return [ref, 'class'];
1650
+ }
1651
+ return null;
1652
+ })
1653
+ .filter(Boolean);
1654
+
1655
+ function isUsedOutsideOfHook(ref) {
1656
+ let foundWriteExpr = false;
1657
+ for (let i = 0; i < ref.references.length; i++) {
1658
+ const reference = ref.references[i];
1659
+ if (reference.writeExpr) {
1660
+ if (foundWriteExpr) {
1661
+ // Two writes to the same function.
1662
+ return true;
1663
+ }
1664
+ // Ignore first write as it's not usage.
1665
+ foundWriteExpr = true;
1666
+ continue;
1667
+ }
1668
+ let currentScope = reference.from;
1669
+ while (currentScope !== scope && currentScope != null) {
1670
+ currentScope = currentScope.upper;
1671
+ }
1672
+ if (
1673
+ currentScope !== scope && // This reference is outside the Hook callback.
1674
+ // It can only be legit if it's the deps array.
1675
+ !isAncestorNodeOf(declaredDependenciesNode, reference.identifier)
1676
+ ) {
1677
+ return true;
1678
+ }
1679
+ }
1680
+ return false;
1681
+ }
1682
+
1683
+ return constructions.map(([ref, depType]) => ({
1684
+ construction: ref.defs[0],
1685
+ depType,
1686
+ isUsedOutsideOfHook: isUsedOutsideOfHook(ref)
1687
+ }));
1688
+ }
1689
+
1690
+ /**
1691
+ * Assuming () means the passed/returned node:
1692
+ * (props) => (props)
1693
+ * props.(foo) => (props.foo)
1694
+ * props.foo.(bar) => (props).foo.bar
1695
+ * props.foo.bar.(baz) => (props).foo.bar.baz
1696
+ */
1697
+ function getDependency(node) {
1698
+ if (
1699
+ (node.parent.type === 'MemberExpression' ||
1700
+ node.parent.type === 'OptionalMemberExpression') &&
1701
+ node.parent.object === node &&
1702
+ node.parent.property.name !== 'current' &&
1703
+ !node.parent.computed &&
1704
+ !(
1705
+ node.parent.parent != null &&
1706
+ (node.parent.parent.type === 'CallExpression' ||
1707
+ node.parent.parent.type === 'OptionalCallExpression') &&
1708
+ node.parent.parent.callee === node.parent
1709
+ )
1710
+ ) {
1711
+ return getDependency(node.parent);
1712
+ } else if (
1713
+ // Note: we don't check OptionalMemberExpression because it can't be LHS.
1714
+ node.type === 'MemberExpression' &&
1715
+ node.parent &&
1716
+ node.parent.type === 'AssignmentExpression' &&
1717
+ node.parent.left === node
1718
+ ) {
1719
+ return node.object;
1720
+ }
1721
+ return node;
1722
+ }
1723
+
1724
+ /**
1725
+ * Mark a node as either optional or required.
1726
+ * Note: If the node argument is an OptionalMemberExpression, it doesn't necessarily mean it is optional.
1727
+ * It just means there is an optional member somewhere inside.
1728
+ * This particular node might still represent a required member, so check .optional field.
1729
+ */
1730
+ function markNode(node, optionalChains, result) {
1731
+ if (optionalChains) {
1732
+ if (node.optional) {
1733
+ // We only want to consider it optional if *all* usages were optional.
1734
+ if (!optionalChains.has(result)) {
1735
+ // Mark as (maybe) optional. If there's a required usage, this will be overridden.
1736
+ optionalChains.set(result, true);
1737
+ }
1738
+ } else {
1739
+ // Mark as required.
1740
+ optionalChains.set(result, false);
1741
+ }
1742
+ }
1743
+ }
1744
+
1745
+ /**
1746
+ * Assuming () means the passed node.
1747
+ * (foo) -> 'foo'
1748
+ * foo(.)bar -> 'foo.bar'
1749
+ * foo.bar(.)baz -> 'foo.bar.baz'
1750
+ * Otherwise throw.
1751
+ */
1752
+ function analyzePropertyChain(node, optionalChains) {
1753
+ if (node.type === 'Identifier' || node.type === 'JSXIdentifier') {
1754
+ const result = node.name;
1755
+ if (optionalChains) {
1756
+ // Mark as required.
1757
+ optionalChains.set(result, false);
1758
+ }
1759
+ return result;
1760
+ } else if (node.type === 'MemberExpression' && !node.computed) {
1761
+ const object = analyzePropertyChain(node.object, optionalChains);
1762
+ const property = analyzePropertyChain(node.property, null);
1763
+ const result = `${object}.${property}`;
1764
+ markNode(node, optionalChains, result);
1765
+ return result;
1766
+ } else if (node.type === 'OptionalMemberExpression' && !node.computed) {
1767
+ const object = analyzePropertyChain(node.object, optionalChains);
1768
+ const property = analyzePropertyChain(node.property, null);
1769
+ const result = `${object}.${property}`;
1770
+ markNode(node, optionalChains, result);
1771
+ return result;
1772
+ } else if (node.type === 'ChainExpression' && !node.computed) {
1773
+ const expression = node.expression;
1774
+
1775
+ if (expression.type === 'CallExpression') {
1776
+ throw new Error(`Unsupported node type: ${expression.type}`);
1777
+ }
1778
+
1779
+ const object = analyzePropertyChain(expression.object, optionalChains);
1780
+ const property = analyzePropertyChain(expression.property, null);
1781
+ const result = `${object}.${property}`;
1782
+ markNode(expression, optionalChains, result);
1783
+ return result;
1784
+ }
1785
+ throw new Error(`Unsupported node type: ${node.type}`);
1786
+ }
1787
+
1788
+ function getNodeWithoutReactNamespace(node) {
1789
+ if (
1790
+ node.type === 'MemberExpression' &&
1791
+ node.object.type === 'Identifier' &&
1792
+ node.object.name === 'React' &&
1793
+ node.property.type === 'Identifier' &&
1794
+ !node.computed
1795
+ ) {
1796
+ return node.property;
1797
+ }
1798
+ return node;
1799
+ }
1800
+
1801
+ // What's the index of callback that needs to be analyzed for a given Hook?
1802
+ // -1 if it's not a Hook we care about (e.g. useState).
1803
+ // 0 for useEffect/useMemo/useCallback(fn).
1804
+ // 1 for useImperativeHandle(ref, fn).
1805
+ // For additionally configured Hooks, assume that they're like useEffect (0).
1806
+ function getReactiveHookCallbackIndex(calleeNode, options) {
1807
+ const node = getNodeWithoutReactNamespace(calleeNode);
1808
+ if (node.type !== 'Identifier') {
1809
+ return -1;
1810
+ }
1811
+ switch (node.name) {
1812
+ case 'useEffect':
1813
+ case 'useLayoutEffect':
1814
+ case 'useCallback':
1815
+ case 'useMemo':
1816
+ // useEffect(fn)
1817
+ return 0;
1818
+ case 'useImperativeHandle':
1819
+ // useImperativeHandle(ref, fn)
1820
+ return 1;
1821
+ default:
1822
+ if (node === calleeNode && options && options.additionalHooks) {
1823
+ // Allow the user to provide a regular expression which enables the lint to
1824
+ // target custom reactive hooks.
1825
+ let name;
1826
+ try {
1827
+ name = analyzePropertyChain(node, null);
1828
+ } catch (err) {
1829
+ if (/Unsupported node type/.test(err.message)) {
1830
+ return 0;
1831
+ }
1832
+ throw err;
1833
+ }
1834
+ return options.additionalHooks.test(name) ? 0 : -1;
1835
+ }
1836
+ return -1;
1837
+ }
1838
+ }
1839
+
1840
+ /**
1841
+ * ESLint won't assign node.parent to references from context.getScope()
1842
+ *
1843
+ * So instead we search for the node from an ancestor assigning node.parent
1844
+ * as we go. This mutates the AST.
1845
+ *
1846
+ * This traversal is:
1847
+ * - optimized by only searching nodes with a range surrounding our target node
1848
+ * - agnostic to AST node types, it looks for `{ type: string, ... }`
1849
+ */
1850
+ function fastFindReferenceWithParent(start, target) {
1851
+ const queue = [start];
1852
+ let item = null;
1853
+
1854
+ while (queue.length > 0) {
1855
+ item = queue.shift();
1856
+
1857
+ if (isSameIdentifier(item, target)) {
1858
+ return item;
1859
+ }
1860
+
1861
+ if (!isAncestorNodeOf(item, target)) {
1862
+ continue;
1863
+ }
1864
+
1865
+ for (const [key, value] of Object.entries(item)) {
1866
+ if (key === 'parent') {
1867
+ continue;
1868
+ }
1869
+ if (isNodeLike(value)) {
1870
+ value.parent = item;
1871
+ queue.push(value);
1872
+ } else if (Array.isArray(value)) {
1873
+ // eslint-disable-next-line no-loop-func -- FIXME
1874
+ value.forEach(val => {
1875
+ if (isNodeLike(val)) {
1876
+ val.parent = item;
1877
+ queue.push(val);
1878
+ }
1879
+ });
1880
+ }
1881
+ }
1882
+ }
1883
+
1884
+ return null;
1885
+ }
1886
+
1887
+ function joinEnglish(arr) {
1888
+ let s = '';
1889
+ for (let i = 0; i < arr.length; i++) {
1890
+ s += arr[i];
1891
+ if (i === 0 && arr.length === 2) {
1892
+ s += ' and ';
1893
+ } else if (i === arr.length - 2 && arr.length > 2) {
1894
+ s += ', and ';
1895
+ } else if (i < arr.length - 1) {
1896
+ s += ', ';
1897
+ }
1898
+ }
1899
+ return s;
1900
+ }
1901
+
1902
+ function isNodeLike(val) {
1903
+ return (
1904
+ typeof val === 'object' &&
1905
+ val != null &&
1906
+ !Array.isArray(val) &&
1907
+ typeof val.type === 'string'
1908
+ );
1909
+ }
1910
+
1911
+ function isSameIdentifier(a, b) {
1912
+ return (
1913
+ (a.type === 'Identifier' || a.type === 'JSXIdentifier') &&
1914
+ a.type === b.type &&
1915
+ a.name === b.name &&
1916
+ a.range[0] === b.range[0] &&
1917
+ a.range[1] === b.range[1]
1918
+ );
1919
+ }
1920
+
1921
+ function isAncestorNodeOf(a, b) {
1922
+ return a.range[0] <= b.range[0] && a.range[1] >= b.range[1];
1923
+ }