@blumintinc/eslint-plugin-blumint 1.20.23 → 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
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.23',
226
+ version: '1.20.24',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -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(conversions
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
- for (const conversion of conversions) {
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 (specifiers.length === 0 && !hasReactMemberUseCallback) {
332
+ if (!touchesImport) {
253
333
  return;
254
334
  }
255
- const importText = `import ${recommendedHook} from 'use-latest-callback';`;
256
- if (hasSurvivingReference) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.23",
3
+ "version": "1.20.24",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,18 @@
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
+ },
2
16
  {
3
17
  "version": "1.20.23",
4
18
  "date": "2026-07-30T01:37:58.854Z",