@agilebot/eslint-plugin 0.2.0 → 0.2.1

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