@blumintinc/eslint-plugin-blumint 1.20.22 → 1.20.24
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
CHANGED
|
@@ -97,6 +97,122 @@ function findEnclosingFunction(node) {
|
|
|
97
97
|
}
|
|
98
98
|
return null;
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The memo declaration must land immediately before the statement containing
|
|
102
|
+
* the hook call, inside the innermost enclosing block. Module scope is never
|
|
103
|
+
* a valid target: useMemo is only legal inside a component/hook, and the
|
|
104
|
+
* tracked array is typically function-local, so a top-level insertion would
|
|
105
|
+
* reference an unbound variable and violate the rules of hooks.
|
|
106
|
+
*/
|
|
107
|
+
function findInsertionPoint(node) {
|
|
108
|
+
const enclosingFunction = findEnclosingFunction(node);
|
|
109
|
+
if (!enclosingFunction)
|
|
110
|
+
return null;
|
|
111
|
+
let current = node;
|
|
112
|
+
while (current.parent) {
|
|
113
|
+
// Reaching the function before any block means an expression-bodied
|
|
114
|
+
// arrow: there is no statement position to hold the declaration.
|
|
115
|
+
if (current === enclosingFunction)
|
|
116
|
+
return null;
|
|
117
|
+
const parent = current.parent;
|
|
118
|
+
if (parent.type === utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
119
|
+
return { statement: current, block: parent };
|
|
120
|
+
}
|
|
121
|
+
current = parent;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Collects the identifiers a hoisted `stableHash(<base>)` expression would
|
|
127
|
+
* read: the root object of the member chain plus any computed keys. Property
|
|
128
|
+
* names are not variable references. Returns false for shapes that cannot be
|
|
129
|
+
* hoisted verbatim (this-expressions, calls, casts) so the fixer bails.
|
|
130
|
+
*/
|
|
131
|
+
function collectBaseReferences(expr, out) {
|
|
132
|
+
switch (expr.type) {
|
|
133
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
134
|
+
out.push(expr);
|
|
135
|
+
return true;
|
|
136
|
+
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
137
|
+
if (!collectBaseReferences(expr.object, out))
|
|
138
|
+
return false;
|
|
139
|
+
if (expr.computed)
|
|
140
|
+
return collectBaseReferences(expr.property, out);
|
|
141
|
+
return true;
|
|
142
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
143
|
+
return collectBaseReferences(expr.expression, out);
|
|
144
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
145
|
+
return collectBaseReferences(expr.expression, out);
|
|
146
|
+
case utils_1.AST_NODE_TYPES.Literal:
|
|
147
|
+
return true;
|
|
148
|
+
default:
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A base is safe to memoize at the insertion point only when every variable
|
|
154
|
+
* it reads is provably bound there: declared in a scope that encloses the
|
|
155
|
+
* insertion block, and (for lexical declarations) positioned before the
|
|
156
|
+
* consuming statement so the hoisted read cannot hit the temporal dead zone.
|
|
157
|
+
* Unresolvable or ambient names are rejected — a report without a fix is
|
|
158
|
+
* always preferable to generated code that references an unbound variable.
|
|
159
|
+
*/
|
|
160
|
+
function isBaseSafeToHoist(context, baseExpr, insertion) {
|
|
161
|
+
const identifiers = [];
|
|
162
|
+
if (!collectBaseReferences(baseExpr, identifiers))
|
|
163
|
+
return false;
|
|
164
|
+
if (identifiers.length === 0)
|
|
165
|
+
return false;
|
|
166
|
+
for (const identifier of identifiers) {
|
|
167
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, identifier);
|
|
168
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, identifier.name);
|
|
169
|
+
if (!variable || variable.defs.length === 0)
|
|
170
|
+
return false;
|
|
171
|
+
// Visibility: the variable's scope must enclose the insertion block.
|
|
172
|
+
// Because the insertion point shares the hook's scope chain, an enclosing
|
|
173
|
+
// scope here guarantees the same binding resolves at both positions.
|
|
174
|
+
const scopeBlock = variable.scope.block;
|
|
175
|
+
if (!rangeContains(scopeBlock, insertion.block))
|
|
176
|
+
return false;
|
|
177
|
+
for (const def of variable.defs) {
|
|
178
|
+
if (def.type === 'Parameter' || def.type === 'ImportBinding')
|
|
179
|
+
continue;
|
|
180
|
+
if (def.type === 'FunctionName' &&
|
|
181
|
+
def.node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Lexical declarations must precede the insertion point textually.
|
|
185
|
+
if (def.name.range[1] > insertion.statement.range[0])
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* An `eslint-disable-next-line` comment directly above the hook statement
|
|
193
|
+
* targets the hook, so the memo declaration must go above the comment —
|
|
194
|
+
* inserting between them would silently re-point the suppression at the memo.
|
|
195
|
+
*/
|
|
196
|
+
function findDeclarationAnchor(sourceCode, statement) {
|
|
197
|
+
let anchor = statement;
|
|
198
|
+
const comments = sourceCode.getCommentsBefore(statement);
|
|
199
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
200
|
+
const comment = comments[i];
|
|
201
|
+
if (comment.loc.end.line === anchor.loc.start.line - 1 &&
|
|
202
|
+
/^\s*eslint-disable-next-line\b/.test(comment.value)) {
|
|
203
|
+
anchor = comment;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return anchor;
|
|
210
|
+
}
|
|
211
|
+
function getAnchorIndent(sourceCode, anchor) {
|
|
212
|
+
const line = sourceCode.lines[anchor.loc.start.line - 1] ?? '';
|
|
213
|
+
const prefix = line.slice(0, anchor.loc.start.column);
|
|
214
|
+
return /^\s*$/.test(prefix) ? prefix : '';
|
|
215
|
+
}
|
|
100
216
|
function ensureWeakMapEntry(map, key, factory) {
|
|
101
217
|
const existing = map.get(key);
|
|
102
218
|
if (existing)
|
|
@@ -105,22 +221,48 @@ function ensureWeakMapEntry(map, key, factory) {
|
|
|
105
221
|
map.set(key, next);
|
|
106
222
|
return next;
|
|
107
223
|
}
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
224
|
+
function getValueImports(sourceCode, source) {
|
|
225
|
+
return sourceCode.ast.body.filter((node) => node.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
226
|
+
node.source.value === source &&
|
|
227
|
+
node.importKind !== 'type');
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The generated code calls the helper by its canonical name, so an aliased
|
|
231
|
+
* specifier (`useMemo as um`) or a type-only specifier does not count — the
|
|
232
|
+
* value binding under the exact local name must exist.
|
|
233
|
+
*/
|
|
234
|
+
function hasNamedValueImport(sourceCode, source, name) {
|
|
235
|
+
for (const declaration of getValueImports(sourceCode, source)) {
|
|
236
|
+
for (const spec of declaration.specifiers) {
|
|
237
|
+
if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
238
|
+
spec.importKind !== 'type' &&
|
|
239
|
+
spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
240
|
+
spec.imported.name === name &&
|
|
241
|
+
spec.local.name === name) {
|
|
242
|
+
return true;
|
|
119
243
|
}
|
|
120
244
|
}
|
|
121
245
|
}
|
|
122
246
|
return false;
|
|
123
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Extends an existing import from `source` with `name` instead of prepending
|
|
250
|
+
* a duplicate declaration. Namespace-only imports cannot host a named
|
|
251
|
+
* specifier, so those fall through to a separate declaration (null).
|
|
252
|
+
*/
|
|
253
|
+
function buildImportExtensionFix(fixer, sourceCode, source, name) {
|
|
254
|
+
for (const declaration of getValueImports(sourceCode, source)) {
|
|
255
|
+
const named = declaration.specifiers.filter((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
256
|
+
if (named.length > 0) {
|
|
257
|
+
return fixer.insertTextAfter(named[named.length - 1], `, ${name}`);
|
|
258
|
+
}
|
|
259
|
+
const defaultSpec = declaration.specifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
260
|
+
if (defaultSpec) {
|
|
261
|
+
return fixer.insertTextAfter(defaultSpec, `, { ${name} }`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
124
266
|
/**
|
|
125
267
|
* Hook callback = first function-typed argument (the effect/factory/memo fn).
|
|
126
268
|
* Deps array is the LAST argument; the callback precedes it.
|
|
@@ -184,22 +326,6 @@ function isLengthOnlyUsage(context, callback, baseExpr) {
|
|
|
184
326
|
}
|
|
185
327
|
return bodyReferences.every((ref) => isLengthAccessOf(ref.identifier));
|
|
186
328
|
}
|
|
187
|
-
function isStableHashImported(sourceCode, hashSource, hashImportName) {
|
|
188
|
-
const program = sourceCode.ast;
|
|
189
|
-
for (const node of program.body) {
|
|
190
|
-
if (node.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
191
|
-
node.source.value === hashSource) {
|
|
192
|
-
for (const spec of node.specifiers) {
|
|
193
|
-
if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
194
|
-
spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
195
|
-
spec.imported.name === hashImportName) {
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
329
|
exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
204
330
|
name: 'no-array-length-in-deps',
|
|
205
331
|
meta: {
|
|
@@ -237,10 +363,13 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
237
363
|
source: hashImport?.source ?? DEFAULT_HASH_IMPORT.source,
|
|
238
364
|
importName: hashImport?.importName ?? DEFAULT_HASH_IMPORT.importName,
|
|
239
365
|
};
|
|
240
|
-
// Track planned file-wide changes to avoid overlapping fixers
|
|
366
|
+
// Track planned file-wide changes to avoid overlapping fixers. Bases are
|
|
367
|
+
// deduplicated per insertion block: a memo declared in one block is not
|
|
368
|
+
// visible in a sibling block, so sharing across a whole function would
|
|
369
|
+
// strand references.
|
|
241
370
|
let importsPlanned = false;
|
|
242
|
-
const
|
|
243
|
-
const
|
|
371
|
+
const perBlockDeclaredBases = new WeakMap();
|
|
372
|
+
const perBlockBaseToVar = new WeakMap();
|
|
244
373
|
return {
|
|
245
374
|
CallExpression(node) {
|
|
246
375
|
if (!isHookCall(node))
|
|
@@ -283,10 +412,20 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
283
412
|
dependencies,
|
|
284
413
|
},
|
|
285
414
|
fix(fixer) {
|
|
415
|
+
// All bail checks precede any shared-state mutation so a skipped
|
|
416
|
+
// fix cannot make a later fix believe imports or declarations are
|
|
417
|
+
// already handled.
|
|
418
|
+
const insertion = findInsertionPoint(node);
|
|
419
|
+
if (!insertion)
|
|
420
|
+
return null;
|
|
421
|
+
for (const { member } of lengthDeps) {
|
|
422
|
+
if (!isBaseSafeToHoist(context, getBaseExpression(member), insertion)) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
286
426
|
const fixes = [];
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
const baseToVar = ensureWeakMapEntry(perFuncBaseToVar, hostFn, () => new Map());
|
|
427
|
+
const declaredBases = ensureWeakMapEntry(perBlockDeclaredBases, insertion.block, () => new Set());
|
|
428
|
+
const baseToVar = ensureWeakMapEntry(perBlockBaseToVar, insertion.block, () => new Map());
|
|
290
429
|
// Prepare variable names (consistent across file) and taken names (across all scopes)
|
|
291
430
|
const allTaken = collectAllTakenNames(sourceCode);
|
|
292
431
|
for (const name of baseToVar.values()) {
|
|
@@ -302,71 +441,52 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
302
441
|
allTaken.add(varName);
|
|
303
442
|
}
|
|
304
443
|
}
|
|
305
|
-
// Build
|
|
444
|
+
// Build declaration lines (one per base) that land immediately
|
|
445
|
+
// before the statement consuming the hook, inside the same block
|
|
446
|
+
// as the tracked variable.
|
|
447
|
+
const anchor = findDeclarationAnchor(sourceCode, insertion.statement);
|
|
448
|
+
const indent = getAnchorIndent(sourceCode, anchor);
|
|
306
449
|
let declText = '';
|
|
307
450
|
for (const { member } of lengthDeps) {
|
|
308
451
|
const baseExpr = getBaseExpression(member);
|
|
309
452
|
const baseText = sourceCode.getText(baseExpr);
|
|
310
453
|
if (!declaredBases.has(baseText)) {
|
|
311
454
|
const varName = baseToVar.get(baseText);
|
|
312
|
-
declText += `const ${varName} = useMemo(() => ${hashImportConfig.importName}(${baseText}), [${baseText}]);\n`;
|
|
455
|
+
declText += `const ${varName} = useMemo(() => ${hashImportConfig.importName}(${baseText}), [${baseText}]);\n${indent}`;
|
|
313
456
|
declaredBases.add(baseText);
|
|
314
457
|
}
|
|
315
458
|
}
|
|
316
459
|
if (declText) {
|
|
317
|
-
|
|
318
|
-
declText += `\n`;
|
|
460
|
+
fixes.push(fixer.insertTextBeforeRange(anchor.range, declText));
|
|
319
461
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
? prefixBeforeProgram.slice(lastNewlineIndex + 1)
|
|
329
|
-
: prefixBeforeProgram;
|
|
330
|
-
let importText = '';
|
|
331
|
-
const needUseMemo = !isUseMemoImported(sourceCode);
|
|
332
|
-
const needStableHash = !isStableHashImported(sourceCode, hashImportConfig.source, hashImportConfig.importName);
|
|
333
|
-
if (needUseMemo)
|
|
334
|
-
importText += `${indent}import { useMemo } from 'react';\n`;
|
|
335
|
-
if (needStableHash)
|
|
336
|
-
importText += `${indent}import { ${hashImportConfig.importName} } from '${hashImportConfig.source}';\n`;
|
|
337
|
-
if (importDecls.length === 0) {
|
|
338
|
-
// No existing imports. Normalize by removing leading whitespace and inserting at file start with no indentation.
|
|
339
|
-
if (declText || importText) {
|
|
340
|
-
// Build non-indented versions of import and decl blocks
|
|
341
|
-
let importTextNoIndent = '';
|
|
342
|
-
if (needUseMemo)
|
|
343
|
-
importTextNoIndent += `import { useMemo } from 'react';\n`;
|
|
344
|
-
if (needStableHash)
|
|
345
|
-
importTextNoIndent += `import { ${hashImportConfig.importName} } from '${hashImportConfig.source}';\n`;
|
|
346
|
-
const declNoIndent = declText;
|
|
347
|
-
const combined = `${importTextNoIndent}${importTextNoIndent && declNoIndent ? '\n' : ''}${declNoIndent}`;
|
|
348
|
-
// Remove leading whitespace
|
|
349
|
-
fixes.push(fixer.replaceTextRange([0, program.range[0]], ''));
|
|
350
|
-
// Insert at column 0
|
|
351
|
-
fixes.push(fixer.insertTextBeforeRange([0, 0], combined));
|
|
462
|
+
if (!importsPlanned) {
|
|
463
|
+
const newImportLines = [];
|
|
464
|
+
if (!hasNamedValueImport(sourceCode, 'react', 'useMemo')) {
|
|
465
|
+
const extension = buildImportExtensionFix(fixer, sourceCode, 'react', 'useMemo');
|
|
466
|
+
if (extension)
|
|
467
|
+
fixes.push(extension);
|
|
468
|
+
else
|
|
469
|
+
newImportLines.push(`import { useMemo } from 'react';`);
|
|
352
470
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
fixes.push(fixer.insertTextBefore(firstImport, importText));
|
|
361
|
-
importsPlanned = true;
|
|
471
|
+
if (!hasNamedValueImport(sourceCode, hashImportConfig.source, hashImportConfig.importName)) {
|
|
472
|
+
const extension = buildImportExtensionFix(fixer, sourceCode, hashImportConfig.source, hashImportConfig.importName);
|
|
473
|
+
if (extension)
|
|
474
|
+
fixes.push(extension);
|
|
475
|
+
else {
|
|
476
|
+
newImportLines.push(`import { ${hashImportConfig.importName} } from '${hashImportConfig.source}';`);
|
|
477
|
+
}
|
|
362
478
|
}
|
|
363
|
-
if (
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
.
|
|
368
|
-
|
|
479
|
+
if (newImportLines.length > 0) {
|
|
480
|
+
const importText = `${newImportLines.join('\n')}\n`;
|
|
481
|
+
const firstImport = sourceCode.ast.body.find((n) => n.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
482
|
+
if (firstImport) {
|
|
483
|
+
fixes.push(fixer.insertTextBefore(firstImport, importText));
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
fixes.push(fixer.insertTextBeforeRange([0, 0], importText));
|
|
487
|
+
}
|
|
369
488
|
}
|
|
489
|
+
importsPlanned = true;
|
|
370
490
|
}
|
|
371
491
|
// Replace each .length dep with the corresponding var name
|
|
372
492
|
for (const { element, member } of lengthDeps) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.RULE_NAME = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
4
5
|
const createRule_1 = require("../utils/createRule");
|
|
5
6
|
exports.RULE_NAME = 'require-dynamic-firebase-imports';
|
|
6
7
|
exports.default = (0, createRule_1.createRule)({
|
|
@@ -23,62 +24,105 @@ exports.default = (0, createRule_1.createRule)({
|
|
|
23
24
|
return (source.startsWith('firebase/') ||
|
|
24
25
|
source.includes('config/firebase-client'));
|
|
25
26
|
};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/**
|
|
28
|
+
* `await import()` is only valid inside an async function. Rewriting a
|
|
29
|
+
* module-scope import would introduce top-level await, silently converting
|
|
30
|
+
* a synchronous module into an async one (and failing outright on build
|
|
31
|
+
* targets without top-level await support) — so those sites are reported
|
|
32
|
+
* without a fix.
|
|
33
|
+
*/
|
|
34
|
+
const isInsideAsyncFunction = (node) => {
|
|
35
|
+
let current = node.parent;
|
|
36
|
+
while (current) {
|
|
37
|
+
if (current.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
38
|
+
current.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
39
|
+
current.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
|
|
40
|
+
// The nearest enclosing function decides await validity; an async
|
|
41
|
+
// ancestor beyond a sync function cannot host the await.
|
|
42
|
+
return current.async;
|
|
43
|
+
}
|
|
44
|
+
current = current.parent;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
const isTypeOnlySpecifier = (spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
49
|
+
spec.importKind === 'type';
|
|
50
|
+
const buildDynamicImport = (importSource, valueSpecifiers) => {
|
|
51
|
+
if (valueSpecifiers.length === 0) {
|
|
52
|
+
// Side-effect imports have no bindings to destructure.
|
|
31
53
|
return `await import('${importSource}');`;
|
|
32
54
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const localName = spec.local.name;
|
|
43
|
-
if (importedName === localName) {
|
|
44
|
-
return `const { ${localName} } = await import('${importSource}');`;
|
|
45
|
-
}
|
|
46
|
-
return `const { ${importedName}: ${localName} } = await import('${importSource}');`;
|
|
55
|
+
const namespaceSpecifier = valueSpecifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
|
|
56
|
+
const defaultSpecifier = valueSpecifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
57
|
+
const namedSpecifiers = valueSpecifiers.filter((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
58
|
+
if (namespaceSpecifier) {
|
|
59
|
+
// The promise resolved by `import()` IS the module namespace object,
|
|
60
|
+
// so the `* as ns` binding maps directly onto it.
|
|
61
|
+
const namespaceDeclaration = `const ${namespaceSpecifier.local.name} = await import('${importSource}');`;
|
|
62
|
+
if (defaultSpecifier) {
|
|
63
|
+
return `${namespaceDeclaration} const ${defaultSpecifier.local.name} = ${namespaceSpecifier.local.name}.default;`;
|
|
47
64
|
}
|
|
65
|
+
return namespaceDeclaration;
|
|
48
66
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
const
|
|
55
|
-
const localName = spec.local.name;
|
|
56
|
-
return importedName === localName
|
|
57
|
-
? localName
|
|
58
|
-
: `${importedName}: ${localName}`;
|
|
67
|
+
const destructuredNames = namedSpecifiers.map((spec) => spec.imported.name === spec.local.name
|
|
68
|
+
? spec.local.name
|
|
69
|
+
: `${spec.imported.name}: ${spec.local.name}`);
|
|
70
|
+
if (defaultSpecifier) {
|
|
71
|
+
if (destructuredNames.length === 0) {
|
|
72
|
+
return `const ${defaultSpecifier.local.name} = (await import('${importSource}')).default;`;
|
|
59
73
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
// The namespace object exposes the default export under `default`.
|
|
75
|
+
return `const { default: ${defaultSpecifier.local.name}, ${destructuredNames.join(', ')} } = await import('${importSource}');`;
|
|
76
|
+
}
|
|
77
|
+
return `const { ${destructuredNames.join(', ')} } = await import('${importSource}');`;
|
|
78
|
+
};
|
|
79
|
+
const buildStaticTypeImport = (importSource, typeSpecifiers) => {
|
|
80
|
+
const names = typeSpecifiers.map((spec) => spec.imported.name === spec.local.name
|
|
81
|
+
? spec.local.name
|
|
82
|
+
: `${spec.imported.name} as ${spec.local.name}`);
|
|
83
|
+
return `import type { ${names.join(', ')} } from '${importSource}';\n`;
|
|
65
84
|
};
|
|
66
85
|
return {
|
|
67
86
|
ImportDeclaration(node) {
|
|
68
87
|
const importSource = node.source.value;
|
|
69
|
-
if (typeof importSource
|
|
70
|
-
isFirebaseImport(importSource)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
if (typeof importSource !== 'string' ||
|
|
89
|
+
!isFirebaseImport(importSource)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// `import type` statements are erased at compile time, so they add no
|
|
93
|
+
// bundle weight — and a dynamic import cannot supply types anyway.
|
|
94
|
+
if (node.importKind === 'type') {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const typeSpecifiers = node.specifiers.filter(isTypeOnlySpecifier);
|
|
98
|
+
const valueSpecifiers = node.specifiers.filter((spec) => !isTypeOnlySpecifier(spec));
|
|
99
|
+
// Inline `type` markers on every specifier make the whole statement
|
|
100
|
+
// erasable, exactly like `import type` — nothing to report.
|
|
101
|
+
if (node.specifiers.length > 0 && valueSpecifiers.length === 0) {
|
|
102
|
+
return;
|
|
81
103
|
}
|
|
104
|
+
context.report({
|
|
105
|
+
node,
|
|
106
|
+
messageId: 'requireDynamicImport',
|
|
107
|
+
data: { importSource },
|
|
108
|
+
fix(fixer) {
|
|
109
|
+
if (!isInsideAsyncFunction(node)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const fixes = [
|
|
113
|
+
fixer.replaceText(node, buildDynamicImport(importSource, valueSpecifiers)),
|
|
114
|
+
];
|
|
115
|
+
if (typeSpecifiers.length > 0) {
|
|
116
|
+
// Type specifiers must not travel into the runtime
|
|
117
|
+
// destructuring: they have no runtime value, and dropping the
|
|
118
|
+
// `type` marker turns type references into dangling value
|
|
119
|
+
// bindings. Hoist them into a static `import type` at module
|
|
120
|
+
// scope, which is erased at compile time.
|
|
121
|
+
fixes.push(fixer.insertTextBeforeRange([0, 0], buildStaticTypeImport(importSource, typeSpecifiers)));
|
|
122
|
+
}
|
|
123
|
+
return fixes;
|
|
124
|
+
},
|
|
125
|
+
});
|
|
82
126
|
},
|
|
83
127
|
};
|
|
84
128
|
},
|
|
@@ -197,10 +197,21 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
197
197
|
return;
|
|
198
198
|
}
|
|
199
199
|
const specifiers = useCallbackSpecifiersOf(statement);
|
|
200
|
+
// A conversion nested inside another conversion cannot join the atomic
|
|
201
|
+
// batch: the outer replacement re-emits the inner call's original text,
|
|
202
|
+
// so fixing both at once would produce overlapping edits. The inner
|
|
203
|
+
// call is reported without a fix; because its callee then counts as a
|
|
204
|
+
// surviving reference, the react import is preserved and a later pass
|
|
205
|
+
// converts it against the rewritten text.
|
|
206
|
+
const isNestedConversion = (candidate) => conversions.some((other) => other.node !== candidate.node &&
|
|
207
|
+
other.node.range[0] <= candidate.node.range[0] &&
|
|
208
|
+
candidate.node.range[1] <= other.node.range[1]);
|
|
209
|
+
const batchedConversions = conversions.filter((conversion) => !isNestedConversion(conversion));
|
|
210
|
+
const deferredConversions = conversions.filter(isNestedConversion);
|
|
200
211
|
// A reference the fix does not rewrite (a JSX-returning call, an
|
|
201
212
|
// argument-less call, or `useCallback` used as a value) keeps needing
|
|
202
213
|
// react's binding, so the import must be preserved verbatim.
|
|
203
|
-
const convertedCallees = new Set(
|
|
214
|
+
const convertedCallees = new Set(batchedConversions
|
|
204
215
|
.map((conversion) => conversion.callee)
|
|
205
216
|
.filter((callee) => !!callee));
|
|
206
217
|
const hasSurvivingReference = context
|
|
@@ -231,7 +242,84 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
231
242
|
: useCallbackLocalName === 'useCallback'
|
|
232
243
|
? 'useLatestCallback'
|
|
233
244
|
: useCallbackLocalName;
|
|
234
|
-
|
|
245
|
+
const importText = `import ${recommendedHook} from 'use-latest-callback';`;
|
|
246
|
+
// The react import statement participates in the change set only when
|
|
247
|
+
// it binds useCallback or anchors a React.useCallback member call.
|
|
248
|
+
const touchesImport = specifiers.length > 0 || hasReactMemberUseCallback;
|
|
249
|
+
const importFixes = (fixer) => {
|
|
250
|
+
if (!touchesImport) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
// A surviving reference (or a member-only file) leaves the react
|
|
254
|
+
// import untouched; only the new import is added when missing.
|
|
255
|
+
if (hasSurvivingReference || specifiers.length === 0) {
|
|
256
|
+
if (hasUseLatestCallbackImport) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
return [fixer.insertTextBefore(statement, `${importText}\n`)];
|
|
260
|
+
}
|
|
261
|
+
const defaultOrNamespace = statement.specifiers.find((s) => s.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
|
|
262
|
+
s.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
|
|
263
|
+
const remainingNamed = statement.specifiers.filter((s) => s.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
264
|
+
s.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
265
|
+
s.imported.name !== 'useCallback');
|
|
266
|
+
const prefix = statement.importKind && statement.importKind !== 'value'
|
|
267
|
+
? 'import type'
|
|
268
|
+
: 'import';
|
|
269
|
+
if (remainingNamed.length === 0 && !defaultOrNamespace) {
|
|
270
|
+
if (!hasUseLatestCallbackImport) {
|
|
271
|
+
return [fixer.replaceText(statement, importText)];
|
|
272
|
+
}
|
|
273
|
+
return [fixer.remove(statement)];
|
|
274
|
+
}
|
|
275
|
+
const parts = [];
|
|
276
|
+
if (defaultOrNamespace) {
|
|
277
|
+
parts.push(sourceCode.getText(defaultOrNamespace));
|
|
278
|
+
}
|
|
279
|
+
if (remainingNamed.length > 0) {
|
|
280
|
+
parts.push(`{ ${remainingNamed
|
|
281
|
+
.map((s) => sourceCode.getText(s))
|
|
282
|
+
.join(', ')} }`);
|
|
283
|
+
}
|
|
284
|
+
const replacement = `${prefix} ${parts.join(', ')} from 'react';`;
|
|
285
|
+
const fixes = [];
|
|
286
|
+
if (!hasUseLatestCallbackImport) {
|
|
287
|
+
fixes.push(fixer.insertTextBefore(statement, `${importText}\n`));
|
|
288
|
+
}
|
|
289
|
+
fixes.push(fixer.replaceText(statement, replacement));
|
|
290
|
+
return fixes;
|
|
291
|
+
};
|
|
292
|
+
const conversionFix = (fixer, conversion) => {
|
|
293
|
+
const callbackText = sourceCode.getText(conversion.node.arguments[0]);
|
|
294
|
+
const typeParams = conversion.node.typeParameters
|
|
295
|
+
? sourceCode.getText(conversion.node.typeParameters)
|
|
296
|
+
: '';
|
|
297
|
+
// Replace useCallback with useLatestCallback and remove the dependency array
|
|
298
|
+
return fixer.replaceText(conversion.node, `${recommendedHook}${typeParams}(${callbackText})`);
|
|
299
|
+
};
|
|
300
|
+
// Every call-site conversion and the import rewrite ride on ONE fix
|
|
301
|
+
// from ONE report. ESLint discards a multi-part fix wholesale when any
|
|
302
|
+
// part conflicts with another rule's fix and retries it on the next
|
|
303
|
+
// pass against the updated text, whereas fixes split across reports
|
|
304
|
+
// land piecemeal: the disjoint import rewrite would apply even when
|
|
305
|
+
// the call-site conversion is deferred, permanently stranding a
|
|
306
|
+
// `useCallback(...)` call with no import (issue #1400).
|
|
307
|
+
const [fixOwner, ...followers] = batchedConversions;
|
|
308
|
+
context.report({
|
|
309
|
+
node: fixOwner.node,
|
|
310
|
+
messageId: 'useLatestCallback',
|
|
311
|
+
data: {
|
|
312
|
+
currentHook: fixOwner.currentHook,
|
|
313
|
+
recommendedHook,
|
|
314
|
+
},
|
|
315
|
+
fix(fixer) {
|
|
316
|
+
return [
|
|
317
|
+
...batchedConversions.map((conversion) => conversionFix(fixer, conversion)),
|
|
318
|
+
...importFixes(fixer),
|
|
319
|
+
];
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
for (const conversion of [...followers, ...deferredConversions]) {
|
|
235
323
|
context.report({
|
|
236
324
|
node: conversion.node,
|
|
237
325
|
messageId: 'useLatestCallback',
|
|
@@ -239,34 +327,13 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
239
327
|
currentHook: conversion.currentHook,
|
|
240
328
|
recommendedHook,
|
|
241
329
|
},
|
|
242
|
-
fix(fixer) {
|
|
243
|
-
const callbackText = sourceCode.getText(conversion.node.arguments[0]);
|
|
244
|
-
const typeParams = conversion.node.typeParameters
|
|
245
|
-
? sourceCode.getText(conversion.node.typeParameters)
|
|
246
|
-
: '';
|
|
247
|
-
// Replace useCallback with useLatestCallback and remove the dependency array
|
|
248
|
-
return fixer.replaceText(conversion.node, `${recommendedHook}${typeParams}(${callbackText})`);
|
|
249
|
-
},
|
|
250
330
|
});
|
|
251
331
|
}
|
|
252
|
-
if (
|
|
332
|
+
if (!touchesImport) {
|
|
253
333
|
return;
|
|
254
334
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (hasUseLatestCallbackImport) {
|
|
258
|
-
return; // The react import stays as is and the new import exists
|
|
259
|
-
}
|
|
260
|
-
context.report({
|
|
261
|
-
node: statement,
|
|
262
|
-
messageId: 'useLatestCallback',
|
|
263
|
-
data: {
|
|
264
|
-
currentHook: useCallbackLocalName,
|
|
265
|
-
recommendedHook,
|
|
266
|
-
},
|
|
267
|
-
fix: (fixer) => fixer.insertTextBefore(statement, `${importText}\n`),
|
|
268
|
-
});
|
|
269
|
-
return;
|
|
335
|
+
if (hasSurvivingReference && hasUseLatestCallbackImport) {
|
|
336
|
+
return; // The react import stays as is and the new import exists
|
|
270
337
|
}
|
|
271
338
|
context.report({
|
|
272
339
|
node: statement,
|
|
@@ -275,43 +342,6 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
275
342
|
currentHook: useCallbackLocalName,
|
|
276
343
|
recommendedHook,
|
|
277
344
|
},
|
|
278
|
-
fix(fixer) {
|
|
279
|
-
if (specifiers.length === 0) {
|
|
280
|
-
if (hasUseLatestCallbackImport)
|
|
281
|
-
return null;
|
|
282
|
-
return fixer.insertTextBefore(statement, `${importText}\n`);
|
|
283
|
-
}
|
|
284
|
-
const defaultOrNamespace = statement.specifiers.find((s) => s.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
|
|
285
|
-
s.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
|
|
286
|
-
const remainingNamed = statement.specifiers.filter((s) => s.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
287
|
-
s.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
288
|
-
s.imported.name !== 'useCallback');
|
|
289
|
-
const prefix = statement.importKind && statement.importKind !== 'value'
|
|
290
|
-
? 'import type'
|
|
291
|
-
: 'import';
|
|
292
|
-
if (remainingNamed.length === 0 && !defaultOrNamespace) {
|
|
293
|
-
if (!hasUseLatestCallbackImport) {
|
|
294
|
-
return fixer.replaceText(statement, importText);
|
|
295
|
-
}
|
|
296
|
-
return fixer.remove(statement);
|
|
297
|
-
}
|
|
298
|
-
const parts = [];
|
|
299
|
-
if (defaultOrNamespace) {
|
|
300
|
-
parts.push(sourceCode.getText(defaultOrNamespace));
|
|
301
|
-
}
|
|
302
|
-
if (remainingNamed.length > 0) {
|
|
303
|
-
parts.push(`{ ${remainingNamed
|
|
304
|
-
.map((s) => sourceCode.getText(s))
|
|
305
|
-
.join(', ')} }`);
|
|
306
|
-
}
|
|
307
|
-
const replacement = `${prefix} ${parts.join(', ')} from 'react';`;
|
|
308
|
-
const fixes = [];
|
|
309
|
-
if (!hasUseLatestCallbackImport) {
|
|
310
|
-
fixes.push(fixer.insertTextBefore(statement, `${importText}\n`));
|
|
311
|
-
}
|
|
312
|
-
fixes.push(fixer.replaceText(statement, replacement));
|
|
313
|
-
return fixes;
|
|
314
|
-
},
|
|
315
345
|
});
|
|
316
346
|
},
|
|
317
347
|
};
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.24",
|
|
4
|
+
"date": "2026-07-30T02:00:54.523Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "use-latest-callback",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1400
|
|
11
|
+
],
|
|
12
|
+
"summary": "apply the import rewrite and call conversions atomically (closes #1400)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.23",
|
|
18
|
+
"date": "2026-07-30T01:37:58.854Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "no-array-length-in-deps",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1398
|
|
25
|
+
],
|
|
26
|
+
"summary": "insert generated memo in the variable's own scope (closes #1398)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "require-dynamic-firebase-imports",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1399
|
|
33
|
+
],
|
|
34
|
+
"summary": "keep type-only specifiers out of the runtime import (closes #1399)"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
2
38
|
{
|
|
3
39
|
"version": "1.20.22",
|
|
4
40
|
"date": "2026-07-30T00:39:18.760Z",
|