@agilebot/eslint-plugin 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }