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