@agilebot/eslint-plugin 0.1.2 → 0.1.3

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