@agilebot/eslint-plugin 0.1.1

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