@agilebot/eslint-plugin 0.1.3 → 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,1921 +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) 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) 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
+ }