@blumintinc/eslint-plugin-blumint 1.20.30 → 1.20.31
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/lib/index.js +1 -1
- package/lib/rules/enforce-assert-safe-object-key.js +112 -25
- package/lib/rules/enforce-memoize-async.js +97 -19
- package/lib/rules/enforce-memoize-getters.js +108 -21
- package/lib/rules/no-array-length-in-deps.js +65 -9
- package/lib/rules/prefer-fragment-component.js +127 -73
- package/lib/rules/require-memo.js +101 -26
- package/lib/rules/use-latest-callback.js +86 -39
- package/package.json +1 -1
- package/release-manifest.json +62 -0
|
@@ -3,7 +3,61 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.preferFragmentComponent = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
6
7
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
8
|
+
const REACT_MODULE = 'react';
|
|
9
|
+
const FRAGMENT_NAME = 'Fragment';
|
|
10
|
+
/**
|
|
11
|
+
* Value `react` import declarations in source order. A type-only declaration
|
|
12
|
+
* (`import type { FC } from 'react'`) is excluded because a specifier appended
|
|
13
|
+
* to one erases at compile time, leaving the rewritten <Fragment> unbound at
|
|
14
|
+
* runtime.
|
|
15
|
+
*/
|
|
16
|
+
function reactValueImports(program) {
|
|
17
|
+
return program.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
18
|
+
statement.source.value === REACT_MODULE &&
|
|
19
|
+
statement.importKind !== 'type');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A named specifier that binds `Fragment` under its own name — the only shape
|
|
23
|
+
* that makes a bare <Fragment> element resolve to react's Fragment. An alias
|
|
24
|
+
* (`import { Fragment as Frag }`) leaves the name free, and a type-only
|
|
25
|
+
* specifier binds nothing at runtime.
|
|
26
|
+
*/
|
|
27
|
+
function isFragmentSpecifier(specifier) {
|
|
28
|
+
return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
29
|
+
specifier.importKind !== 'type' &&
|
|
30
|
+
specifier.imported.name === FRAGMENT_NAME &&
|
|
31
|
+
specifier.local.name === FRAGMENT_NAME);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Import state is read off `Program.body` at fix time rather than from a
|
|
35
|
+
* traversal flag: a fragment that precedes the import declaration in source
|
|
36
|
+
* order is fixed before the `ImportDeclaration` visitor has run, so a flag
|
|
37
|
+
* would call the import missing and insert a duplicate.
|
|
38
|
+
*/
|
|
39
|
+
function importsFragment(program) {
|
|
40
|
+
return reactValueImports(program).some((declaration) => declaration.specifiers.some(isFragmentSpecifier));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether every declaration of a visible `Fragment` binding is react's Fragment
|
|
44
|
+
* import. A const/let/function/class, a parameter, a namespace or default
|
|
45
|
+
* import, an alias, or a named import from another module all mean the
|
|
46
|
+
* rewritten <Fragment> renders something other than react's Fragment.
|
|
47
|
+
*/
|
|
48
|
+
function bindsReactFragment(variable) {
|
|
49
|
+
return (variable.defs.length > 0 &&
|
|
50
|
+
variable.defs.every((def) => {
|
|
51
|
+
const specifier = def.node;
|
|
52
|
+
if (!isFragmentSpecifier(specifier)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const declaration = specifier.parent;
|
|
56
|
+
return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
57
|
+
declaration.importKind !== 'type' &&
|
|
58
|
+
declaration.source.value === REACT_MODULE);
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
7
61
|
exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
8
62
|
name: 'prefer-fragment-component',
|
|
9
63
|
meta: {
|
|
@@ -29,11 +83,34 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
29
83
|
// violations still emit <Fragment>. Resolving suppression before the latch
|
|
30
84
|
// is read hands the carrier slot to the first violation that survives.
|
|
31
85
|
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
86
|
+
// One pass rewrites several fragments but must insert the import once. The
|
|
87
|
+
// AST does not change mid-pass, so the latch records only what an earlier
|
|
88
|
+
// fix in this same pass already scheduled; existing imports are read from
|
|
89
|
+
// the AST instead.
|
|
90
|
+
let importScheduled = false;
|
|
35
91
|
// Track nodes we've already reported to avoid duplicates
|
|
36
92
|
const reportedNodes = new Set();
|
|
93
|
+
/**
|
|
94
|
+
* Whether a bare <Fragment> emitted at `node` would resolve to react's
|
|
95
|
+
* Fragment. Any other visible binding of the name breaks the edit two ways:
|
|
96
|
+
* the inserted import collides with the existing declaration (TS2440, or
|
|
97
|
+
* TS2300 when that declaration is itself an import), and a narrower-scope
|
|
98
|
+
* shadow captures the emitted element with no compile error at all.
|
|
99
|
+
* Resolving through the scope chain from the reported element covers both,
|
|
100
|
+
* and keeps `React.Fragment` — a member access on the default import, not a
|
|
101
|
+
* `Fragment` binding — off the collision path.
|
|
102
|
+
*/
|
|
103
|
+
function resolvesToReactFragment(node) {
|
|
104
|
+
const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), FRAGMENT_NAME);
|
|
105
|
+
return !existing || bindsReactFragment(existing);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* True once the file binds react's Fragment, counting an insertion an
|
|
109
|
+
* earlier fix in this pass already scheduled.
|
|
110
|
+
*/
|
|
111
|
+
function fragmentImportPresent() {
|
|
112
|
+
return importScheduled || importsFragment(sourceCode.ast);
|
|
113
|
+
}
|
|
37
114
|
/**
|
|
38
115
|
* Checks if a node is a React.Fragment element
|
|
39
116
|
*/
|
|
@@ -74,30 +151,6 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
74
151
|
}
|
|
75
152
|
return null;
|
|
76
153
|
}
|
|
77
|
-
/**
|
|
78
|
-
* Checks for Fragment import from react
|
|
79
|
-
*/
|
|
80
|
-
function checkFragmentImport(node) {
|
|
81
|
-
if (node.source.value === 'react') {
|
|
82
|
-
// Keep track of all React imports
|
|
83
|
-
if (!reactImportNode) {
|
|
84
|
-
reactImportNode = node;
|
|
85
|
-
}
|
|
86
|
-
// Keep track of default import for prioritization
|
|
87
|
-
const hasDefaultImport = node.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
88
|
-
if (hasDefaultImport) {
|
|
89
|
-
defaultReactImportNode = node;
|
|
90
|
-
}
|
|
91
|
-
// Check if Fragment is already imported
|
|
92
|
-
for (const specifier of node.specifiers) {
|
|
93
|
-
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
94
|
-
specifier.imported.name === 'Fragment') {
|
|
95
|
-
hasFragmentImport = true;
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
154
|
/**
|
|
102
155
|
* Where a brand-new import statement can be spliced in without changing
|
|
103
156
|
* what the file's directives govern. A whole-line insertion directly before
|
|
@@ -122,35 +175,42 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
122
175
|
return anchor;
|
|
123
176
|
}
|
|
124
177
|
/**
|
|
125
|
-
* Adds Fragment
|
|
178
|
+
* Adds Fragment to an existing react import or creates a new one. The
|
|
179
|
+
* emitted shape is a value named specifier, since the rewritten element
|
|
180
|
+
* references `Fragment` at runtime. Declarations come from `Program.body`
|
|
181
|
+
* at fix time so the choice does not depend on how far traversal has
|
|
182
|
+
* progressed.
|
|
126
183
|
*/
|
|
127
184
|
function addFragmentImport(fixer) {
|
|
128
|
-
|
|
129
|
-
|
|
185
|
+
const declarations = reactValueImports(sourceCode.ast);
|
|
186
|
+
// A declaration carrying the default import keeps `React` and `Fragment`
|
|
187
|
+
// on one line; otherwise the first declaration that can host a specifier
|
|
188
|
+
// wins. A bare `import 'react';` hosts none, so it falls through.
|
|
189
|
+
const targetImportNode = declarations.find((declaration) => declaration.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier)) ??
|
|
190
|
+
declarations.find((declaration) => declaration.specifiers.length > 0);
|
|
130
191
|
if (targetImportNode) {
|
|
131
|
-
//
|
|
192
|
+
// `import * as React, { Fragment }` is a syntax error, so a namespace
|
|
193
|
+
// import gets its own declaration beside it.
|
|
132
194
|
const hasNamespaceImport = targetImportNode.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
|
|
133
195
|
if (hasNamespaceImport) {
|
|
134
|
-
|
|
135
|
-
return fixer.insertTextAfter(targetImportNode, "\nimport { Fragment } from 'react';");
|
|
196
|
+
return fixer.insertTextAfter(targetImportNode, `\nimport { ${FRAGMENT_NAME} } from '${REACT_MODULE}';`);
|
|
136
197
|
}
|
|
137
198
|
// Add Fragment to existing React import
|
|
138
199
|
const lastSpecifier = targetImportNode.specifiers[targetImportNode.specifiers.length - 1];
|
|
139
200
|
const hasNamedImports = targetImportNode.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
140
201
|
if (hasNamedImports) {
|
|
141
|
-
return fixer.insertTextAfter(lastSpecifier,
|
|
202
|
+
return fixer.insertTextAfter(lastSpecifier, `, ${FRAGMENT_NAME}`);
|
|
142
203
|
}
|
|
143
204
|
else {
|
|
144
|
-
return fixer.insertTextAfter(lastSpecifier,
|
|
205
|
+
return fixer.insertTextAfter(lastSpecifier, `, { ${FRAGMENT_NAME} }`);
|
|
145
206
|
}
|
|
146
207
|
}
|
|
147
208
|
// No React import found, create a new one
|
|
148
|
-
const importText =
|
|
209
|
+
const importText = `import { ${FRAGMENT_NAME} } from '${REACT_MODULE}';\n`;
|
|
149
210
|
const indentation = sourceCode.text.match(/^[ \t]*/m)?.[0] || '';
|
|
150
211
|
return fixer.insertTextBefore(findImportAnchor(), indentation + importText);
|
|
151
212
|
}
|
|
152
213
|
return {
|
|
153
|
-
ImportDeclaration: checkFragmentImport,
|
|
154
214
|
// Find JSX Fragment shorthand (<></>)
|
|
155
215
|
JSXFragment(node) {
|
|
156
216
|
// Skip if already reported
|
|
@@ -185,11 +245,18 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
185
245
|
if (isReportSuppressed(node)) {
|
|
186
246
|
return null;
|
|
187
247
|
}
|
|
248
|
+
// Both tags are rewritten to the same bare name, so resolving at
|
|
249
|
+
// the inner fragment — the deeper of the two scopes — covers the
|
|
250
|
+
// outer one too. Every bail precedes the latch so a withheld edit
|
|
251
|
+
// cannot make a later fix believe the import is handled.
|
|
252
|
+
if (!resolvesToReactFragment(node)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
188
255
|
const fixes = [];
|
|
189
256
|
// Add Fragment import if needed
|
|
190
|
-
if (!
|
|
257
|
+
if (!fragmentImportPresent()) {
|
|
191
258
|
fixes.push(addFragmentImport(fixer));
|
|
192
|
-
|
|
259
|
+
importScheduled = true;
|
|
193
260
|
}
|
|
194
261
|
// Fix the outer React.Fragment
|
|
195
262
|
const outerOpeningElement = reactFragmentParent.openingElement;
|
|
@@ -245,11 +312,16 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
245
312
|
if (isReportSuppressed(node)) {
|
|
246
313
|
return null;
|
|
247
314
|
}
|
|
315
|
+
// Every bail precedes the latch so a withheld edit cannot make a
|
|
316
|
+
// later fix believe the import is already handled.
|
|
317
|
+
if (!resolvesToReactFragment(node)) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
248
320
|
const fixes = [];
|
|
249
321
|
// Add Fragment import if needed
|
|
250
|
-
if (!
|
|
322
|
+
if (!fragmentImportPresent()) {
|
|
251
323
|
fixes.push(addFragmentImport(fixer));
|
|
252
|
-
|
|
324
|
+
importScheduled = true;
|
|
253
325
|
}
|
|
254
326
|
// Replace fragment tags
|
|
255
327
|
const openingText = sourceCode.getText(node.openingFragment);
|
|
@@ -296,11 +368,16 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
296
368
|
if (isReportSuppressed(node.name)) {
|
|
297
369
|
return null;
|
|
298
370
|
}
|
|
371
|
+
// Every bail precedes the latch so a withheld edit cannot make a
|
|
372
|
+
// later fix believe the import is already handled.
|
|
373
|
+
if (!resolvesToReactFragment(node)) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
299
376
|
const fixes = [];
|
|
300
377
|
// Add Fragment import if needed
|
|
301
|
-
if (!
|
|
378
|
+
if (!fragmentImportPresent()) {
|
|
302
379
|
fixes.push(addFragmentImport(fixer));
|
|
303
|
-
|
|
380
|
+
importScheduled = true;
|
|
304
381
|
}
|
|
305
382
|
// Replace opening tag
|
|
306
383
|
const openingText = sourceCode.getText(node);
|
|
@@ -332,11 +409,16 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
332
409
|
if (isReportSuppressed(node.name)) {
|
|
333
410
|
return null;
|
|
334
411
|
}
|
|
412
|
+
// Every bail precedes the latch so a withheld edit cannot make a
|
|
413
|
+
// later fix believe the import is already handled.
|
|
414
|
+
if (!resolvesToReactFragment(node)) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
335
417
|
const fixes = [];
|
|
336
418
|
// Add Fragment import if needed
|
|
337
|
-
if (!
|
|
419
|
+
if (!fragmentImportPresent()) {
|
|
338
420
|
fixes.push(addFragmentImport(fixer));
|
|
339
|
-
|
|
421
|
+
importScheduled = true;
|
|
340
422
|
}
|
|
341
423
|
// Replace opening tag
|
|
342
424
|
const openingText = sourceCode.getText(node);
|
|
@@ -352,34 +434,6 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
352
434
|
},
|
|
353
435
|
});
|
|
354
436
|
},
|
|
355
|
-
'Program:exit'() {
|
|
356
|
-
// Find appropriate React import for adding Fragment
|
|
357
|
-
if (!hasFragmentImport) {
|
|
358
|
-
// Get all React imports
|
|
359
|
-
const reactImports = [];
|
|
360
|
-
for (const node of sourceCode.ast.body) {
|
|
361
|
-
if (node.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
362
|
-
node.source.value === 'react') {
|
|
363
|
-
reactImports.push(node);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
// Prioritize default import for adding Fragment
|
|
367
|
-
if (reactImports.length > 0) {
|
|
368
|
-
for (const importNode of reactImports) {
|
|
369
|
-
const hasDefaultImport = importNode.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
370
|
-
if (hasDefaultImport) {
|
|
371
|
-
defaultReactImportNode = importNode;
|
|
372
|
-
reactImportNode = importNode;
|
|
373
|
-
break;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
// If no default import found, use the first import
|
|
377
|
-
if (!defaultReactImportNode && reactImports.length > 0) {
|
|
378
|
-
reactImportNode = reactImports[0];
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
},
|
|
383
437
|
};
|
|
384
438
|
},
|
|
385
439
|
});
|
|
@@ -55,10 +55,81 @@ const isUnmemoizedExportedFunctionComponent = (parentNode, node) => {
|
|
|
55
55
|
startsWithUppercase(node.id.name) &&
|
|
56
56
|
!isComponentExplicitlyUnmemoized(node.id.name));
|
|
57
57
|
};
|
|
58
|
+
const MEMO_NAME = 'memo';
|
|
58
59
|
function isMemoImport(importPath) {
|
|
59
60
|
// Match both absolute and relative paths ending with util/memo
|
|
60
61
|
return /(?:^|\/|\\)util\/memo$/.test(importPath);
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Value `util/memo` import declarations in source order. A type-only
|
|
65
|
+
* declaration (`import type { MemoOptions } from '../util/memo'`) is excluded
|
|
66
|
+
* because a specifier appended to one erases at compile time, leaving the
|
|
67
|
+
* emitted `memo(...)` call unbound at runtime.
|
|
68
|
+
*/
|
|
69
|
+
function memoValueImports(program) {
|
|
70
|
+
return program.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
71
|
+
statement.importKind !== 'type' &&
|
|
72
|
+
isMemoImport(statement.source.value));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A named specifier that binds `memo` under its own name — the only shape that
|
|
76
|
+
* makes the emitted `memo(...)` call reach the helper. An alias in either
|
|
77
|
+
* direction (`memo as m`, `createMemo as memo`) or a type-only specifier binds
|
|
78
|
+
* something else, or nothing at all, at runtime.
|
|
79
|
+
*/
|
|
80
|
+
function isMemoSpecifier(specifier) {
|
|
81
|
+
return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
82
|
+
specifier.importKind !== 'type' &&
|
|
83
|
+
specifier.imported.name === MEMO_NAME &&
|
|
84
|
+
specifier.local.name === MEMO_NAME);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Import state is read off `Program.body` at fix time rather than from a
|
|
88
|
+
* traversal flag: a component that precedes the import declaration in source
|
|
89
|
+
* order is fixed before an `ImportDeclaration` visitor would have run, so a
|
|
90
|
+
* flag would call the import missing and insert a duplicate.
|
|
91
|
+
*/
|
|
92
|
+
function importsMemo(program) {
|
|
93
|
+
return memoValueImports(program).some((declaration) => declaration.specifiers.some(isMemoSpecifier));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether every declaration of a visible `memo` binding is the helper import.
|
|
97
|
+
* A const/let/function/class, a parameter, a namespace or default import, an
|
|
98
|
+
* alias, or a named import from another module all mean the emitted `memo(...)`
|
|
99
|
+
* call would resolve somewhere other than the helper.
|
|
100
|
+
*/
|
|
101
|
+
function bindsMemoHelper(variable) {
|
|
102
|
+
return (variable.defs.length > 0 &&
|
|
103
|
+
variable.defs.every((def) => {
|
|
104
|
+
const specifier = def.node;
|
|
105
|
+
if (!isMemoSpecifier(specifier)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const declaration = specifier.parent;
|
|
109
|
+
return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
110
|
+
declaration.importKind !== 'type' &&
|
|
111
|
+
isMemoImport(declaration.source.value));
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Extends an existing value `util/memo` import with a `memo` specifier instead
|
|
116
|
+
* of adding a second declaration. A namespace-only or side-effect-only
|
|
117
|
+
* declaration has nowhere to put a named specifier, so those fall through to a
|
|
118
|
+
* separate declaration (null).
|
|
119
|
+
*/
|
|
120
|
+
function buildImportExtensionFix(fixer, program) {
|
|
121
|
+
for (const declaration of memoValueImports(program)) {
|
|
122
|
+
const named = declaration.specifiers.filter((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
123
|
+
if (named.length > 0) {
|
|
124
|
+
return fixer.insertTextAfter(named[named.length - 1], `, ${MEMO_NAME}`);
|
|
125
|
+
}
|
|
126
|
+
const defaultSpecifier = declaration.specifiers.find((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
127
|
+
if (defaultSpecifier) {
|
|
128
|
+
return fixer.insertTextAfter(defaultSpecifier, `, { ${MEMO_NAME} }`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
62
133
|
function checkFunction(context, node) {
|
|
63
134
|
const fileName = context.getFilename();
|
|
64
135
|
if (!fileName.endsWith('.tsx')) {
|
|
@@ -96,33 +167,37 @@ function checkFunction(context, node) {
|
|
|
96
167
|
return null;
|
|
97
168
|
}
|
|
98
169
|
const sourceCode = context.sourceCode;
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
170
|
+
const program = sourceCode.ast;
|
|
171
|
+
// Resolve `memo` through the scope chain at the fixed node. A
|
|
172
|
+
// binding that is not the helper import breaks the edit two
|
|
173
|
+
// ways: the inserted import collides with the existing
|
|
174
|
+
// declaration (TS2440, or TS2300 when that declaration is
|
|
175
|
+
// itself an import), and a binding visible at the fix site
|
|
176
|
+
// captures the emitted call with no compile error at all.
|
|
177
|
+
// Declining leaves the report standing so the author resolves
|
|
178
|
+
// the clash deliberately. `React.memo` is a member access on
|
|
179
|
+
// the default import rather than a `memo` binding, so it never
|
|
180
|
+
// reaches this path.
|
|
181
|
+
const existingMemo = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), MEMO_NAME);
|
|
182
|
+
if (existingMemo && !bindsMemoHelper(existingMemo)) {
|
|
183
|
+
return null;
|
|
110
184
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
185
|
+
let importFix = null;
|
|
186
|
+
if (!importsMemo(program)) {
|
|
187
|
+
importFix = buildImportExtensionFix(fixer, program);
|
|
188
|
+
if (!importFix) {
|
|
189
|
+
// Calculate relative path based on current file location
|
|
190
|
+
const currentFilePath = context.getFilename();
|
|
191
|
+
const importPath = calculateImportPath(currentFilePath);
|
|
192
|
+
// Find the first import statement to insert after
|
|
193
|
+
const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
194
|
+
// Add new import statement for memo
|
|
195
|
+
const importStatement = `import { memo } from '${importPath}';`;
|
|
196
|
+
importFix = firstImport
|
|
197
|
+
? // Insert after the first import with a single newline
|
|
198
|
+
fixer.insertTextAfter(firstImport, '\n' + importStatement)
|
|
199
|
+
: // Insert at the start of the file
|
|
200
|
+
fixer.insertTextBeforeRange([program.range[0], program.range[0]], importStatement + '\n');
|
|
126
201
|
}
|
|
127
202
|
}
|
|
128
203
|
const functionKeywordRange = [
|
|
@@ -3,6 +3,47 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.useLatestCallback = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const LATEST_CALLBACK_MODULE = 'use-latest-callback';
|
|
8
|
+
const LATEST_CALLBACK_HOOK = 'useLatestCallback';
|
|
9
|
+
/**
|
|
10
|
+
* A specifier that binds the hook itself as a value. The module's sole export
|
|
11
|
+
* is the hook, so a default specifier binds it under any local name. A
|
|
12
|
+
* namespace specifier binds the module object rather than a callable, and a
|
|
13
|
+
* type-only specifier erases at compile time, so neither can carry the call.
|
|
14
|
+
*/
|
|
15
|
+
const bindsLatestCallback = (specifier) => {
|
|
16
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
20
|
+
specifier.importKind !== 'type' &&
|
|
21
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
22
|
+
specifier.imported.name === LATEST_CALLBACK_HOOK);
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The hook's import read off `Program.body` rather than off a flag set by an
|
|
26
|
+
* ImportDeclaration visitor, so the answer holds wherever the import sits
|
|
27
|
+
* relative to the fix site. Reading the AST also exposes the shapes a
|
|
28
|
+
* name-only flag conflates with a usable binding: a namespace import, a
|
|
29
|
+
* type-only import, and a reverse alias
|
|
30
|
+
* (`import { other as useLatestCallback }`) each bind a name without binding
|
|
31
|
+
* the hook.
|
|
32
|
+
*/
|
|
33
|
+
const findLatestCallbackImport = (program) => {
|
|
34
|
+
for (const statement of program.body) {
|
|
35
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
|
|
36
|
+
statement.source.value !== LATEST_CALLBACK_MODULE ||
|
|
37
|
+
(statement.importKind && statement.importKind !== 'value')) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const specifier = statement.specifiers.find(bindsLatestCallback);
|
|
41
|
+
if (specifier) {
|
|
42
|
+
return { specifier, reference: specifier.local.name };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
6
47
|
exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
7
48
|
name: 'use-latest-callback',
|
|
8
49
|
meta: {
|
|
@@ -23,9 +64,6 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
23
64
|
if (filename.includes('/node_modules/')) {
|
|
24
65
|
return {};
|
|
25
66
|
}
|
|
26
|
-
// Track if the file already has a useLatestCallback import
|
|
27
|
-
let hasUseLatestCallbackImport = false;
|
|
28
|
-
let useLatestCallbackImportName = 'useLatestCallback';
|
|
29
67
|
const useCallbackLocalNames = new Set();
|
|
30
68
|
const reactNamespaceNames = new Set();
|
|
31
69
|
const reactDefaultLikeNames = new Set();
|
|
@@ -77,31 +115,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
77
115
|
ImportDeclaration(node) {
|
|
78
116
|
if (node.importKind && node.importKind !== 'value')
|
|
79
117
|
return;
|
|
80
|
-
if (node.source.value === '
|
|
81
|
-
// Check if useLatestCallback is imported
|
|
82
|
-
const specifiers = node.specifiers.filter((specifier) => {
|
|
83
|
-
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
|
|
84
|
-
return (specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
85
|
-
specifier.imported.name === 'useLatestCallback');
|
|
86
|
-
}
|
|
87
|
-
return (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
|
|
88
|
-
specifier.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
|
|
89
|
-
});
|
|
90
|
-
if (specifiers.length > 0) {
|
|
91
|
-
hasUseLatestCallbackImport = true;
|
|
92
|
-
// Get the local name of the import (in case it's renamed)
|
|
93
|
-
if (specifiers[0].type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
|
|
94
|
-
useLatestCallbackImportName = specifiers[0].local.name;
|
|
95
|
-
}
|
|
96
|
-
else if (specifiers[0].type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
|
|
97
|
-
useLatestCallbackImportName = specifiers[0].local.name;
|
|
98
|
-
}
|
|
99
|
-
else if (specifiers[0].type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier) {
|
|
100
|
-
useLatestCallbackImportName = `${specifiers[0].local.name}.useLatestCallback`;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
else if (node.source.value === 'react') {
|
|
118
|
+
if (node.source.value === 'react') {
|
|
105
119
|
// Check if useCallback is imported from React
|
|
106
120
|
const useCallbackSpecifiers = node.specifiers.filter((specifier) => {
|
|
107
121
|
return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
@@ -171,6 +185,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
171
185
|
callee: node.callee.type === utils_1.AST_NODE_TYPES.Identifier
|
|
172
186
|
? node.callee
|
|
173
187
|
: null,
|
|
188
|
+
scope: ASTHelpers_1.ASTHelpers.getScope(context, node),
|
|
174
189
|
});
|
|
175
190
|
}
|
|
176
191
|
}
|
|
@@ -226,23 +241,47 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
226
241
|
scope.variables.forEach((variable) => taken.add(variable.name));
|
|
227
242
|
scope.childScopes.forEach(collectNames);
|
|
228
243
|
};
|
|
229
|
-
collectNames(
|
|
230
|
-
let name =
|
|
244
|
+
collectNames(ASTHelpers_1.ASTHelpers.getScope(context, program));
|
|
245
|
+
let name = LATEST_CALLBACK_HOOK;
|
|
231
246
|
let suffix = 2;
|
|
232
247
|
while (taken.has(name)) {
|
|
233
|
-
name =
|
|
248
|
+
name = `${LATEST_CALLBACK_HOOK}${suffix++}`;
|
|
234
249
|
}
|
|
235
250
|
return name;
|
|
236
251
|
};
|
|
252
|
+
const latestCallbackImport = findLatestCallbackImport(program);
|
|
237
253
|
const useCallbackLocalName = specifiers[0]?.local.name ?? 'useCallback';
|
|
238
|
-
const recommendedHook =
|
|
239
|
-
?
|
|
254
|
+
const recommendedHook = latestCallbackImport
|
|
255
|
+
? latestCallbackImport.reference
|
|
240
256
|
: hasSurvivingReference
|
|
241
257
|
? freeImportName()
|
|
242
258
|
: useCallbackLocalName === 'useCallback'
|
|
243
|
-
?
|
|
259
|
+
? LATEST_CALLBACK_HOOK
|
|
244
260
|
: useCallbackLocalName;
|
|
245
|
-
const importText = `import ${recommendedHook} from '
|
|
261
|
+
const importText = `import ${recommendedHook} from '${LATEST_CALLBACK_MODULE}';`;
|
|
262
|
+
// The react specifiers this fix deletes stop binding their names, so a
|
|
263
|
+
// name that comes only from them is free for the rewritten calls to
|
|
264
|
+
// claim — that is how an aliased `useCallback as uc` hands `uc` over to
|
|
265
|
+
// the new import.
|
|
266
|
+
const releasedSpecifiers = hasSurvivingReference || specifiers.length === 0 ? [] : specifiers;
|
|
267
|
+
const claimableDefinitions = new Set(releasedSpecifiers);
|
|
268
|
+
if (latestCallbackImport) {
|
|
269
|
+
claimableDefinitions.add(latestCallbackImport.specifier);
|
|
270
|
+
}
|
|
271
|
+
// Whether the rewritten call reaches the hook rather than an unrelated
|
|
272
|
+
// binding of the same name. Resolving through the scope chain at each
|
|
273
|
+
// rewritten call catches both failure modes: a module-scope binding
|
|
274
|
+
// that the inserted import would redeclare (TS2440, or TS2300 against
|
|
275
|
+
// another import), and a narrower shadow that silently captures the
|
|
276
|
+
// call with no diagnostic at all.
|
|
277
|
+
const reachesHook = (conversion) => {
|
|
278
|
+
const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(conversion.scope, recommendedHook);
|
|
279
|
+
if (!existing) {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
return (existing.defs.length > 0 &&
|
|
283
|
+
existing.defs.every((def) => claimableDefinitions.has(def.node)));
|
|
284
|
+
};
|
|
246
285
|
// The react import statement participates in the change set only when
|
|
247
286
|
// it binds useCallback or anchors a React.useCallback member call.
|
|
248
287
|
const touchesImport = specifiers.length > 0 || hasReactMemberUseCallback;
|
|
@@ -253,7 +292,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
253
292
|
// A surviving reference (or a member-only file) leaves the react
|
|
254
293
|
// import untouched; only the new import is added when missing.
|
|
255
294
|
if (hasSurvivingReference || specifiers.length === 0) {
|
|
256
|
-
if (
|
|
295
|
+
if (latestCallbackImport) {
|
|
257
296
|
return [];
|
|
258
297
|
}
|
|
259
298
|
return [fixer.insertTextBefore(statement, `${importText}\n`)];
|
|
@@ -267,7 +306,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
267
306
|
? 'import type'
|
|
268
307
|
: 'import';
|
|
269
308
|
if (remainingNamed.length === 0 && !defaultOrNamespace) {
|
|
270
|
-
if (!
|
|
309
|
+
if (!latestCallbackImport) {
|
|
271
310
|
return [fixer.replaceText(statement, importText)];
|
|
272
311
|
}
|
|
273
312
|
return [fixer.remove(statement)];
|
|
@@ -283,7 +322,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
283
322
|
}
|
|
284
323
|
const replacement = `${prefix} ${parts.join(', ')} from 'react';`;
|
|
285
324
|
const fixes = [];
|
|
286
|
-
if (!
|
|
325
|
+
if (!latestCallbackImport) {
|
|
287
326
|
fixes.push(fixer.insertTextBefore(statement, `${importText}\n`));
|
|
288
327
|
}
|
|
289
328
|
fixes.push(fixer.replaceText(statement, replacement));
|
|
@@ -313,6 +352,14 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
313
352
|
recommendedHook,
|
|
314
353
|
},
|
|
315
354
|
fix(fixer) {
|
|
355
|
+
// Withhold the whole edit — nothing before this point mutates
|
|
356
|
+
// state, so a withheld pass cannot leave a later one believing the
|
|
357
|
+
// import is in place. The violation still stands as a report so
|
|
358
|
+
// the author resolves the name clash deliberately. The batch is
|
|
359
|
+
// atomic, so one unreachable call site withholds all of it.
|
|
360
|
+
if (!batchedConversions.every(reachesHook)) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
316
363
|
return [
|
|
317
364
|
...batchedConversions.map((conversion) => conversionFix(fixer, conversion)),
|
|
318
365
|
...importFixes(fixer),
|
|
@@ -332,7 +379,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
332
379
|
if (!touchesImport) {
|
|
333
380
|
return;
|
|
334
381
|
}
|
|
335
|
-
if (hasSurvivingReference &&
|
|
382
|
+
if (hasSurvivingReference && latestCallbackImport) {
|
|
336
383
|
return; // The react import stays as is and the new import exists
|
|
337
384
|
}
|
|
338
385
|
context.report({
|