@blumintinc/eslint-plugin-blumint 1.17.0 → 1.17.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/lib/index.js CHANGED
@@ -217,7 +217,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
217
217
  module.exports = {
218
218
  meta: {
219
219
  name: '@blumintinc/eslint-plugin-blumint',
220
- version: '1.17.0',
220
+ version: '1.17.1',
221
221
  },
222
222
  parseOptions: {
223
223
  ecmaVersion: 2020,
@@ -353,36 +353,28 @@ exports.noHungarian = (0, createRule_1.createRule)({
353
353
  return true;
354
354
  }
355
355
  // Abbreviation markers (str, num, int, bool, arr, obj, fn, func) are
356
- // short enough that the raw-character boundary heuristic below can fire
357
- // on them as substrings inside real English words (e.g. "int" inside
358
- // "Mint", "str" inside "stream"). For these markers, require an exact
359
- // match against a full camelCase token so that "Mint" → ["Mint"] never
360
- // matches "int", while genuine Hungarian like intValue → ["int","Value"]
361
- // still does.
356
+ // short enough that a raw-character boundary check can fire on them as
357
+ // substrings inside real English words (e.g. "int" inside "Mint", "str"
358
+ // inside "stream"). Require an exact match against a full camelCase
359
+ // token so that "Mint" → ["Mint"] never matches "int", while genuine
360
+ // Hungarian like intValue → ["int","Value"] still does.
362
361
  if (ABBREVIATION_MARKER_SET.has(normalizedMarker)) {
363
362
  const segments = splitCamelSegments(variableName);
364
363
  return segments.some((s) => s.toLowerCase() === normalizedMarker);
365
364
  }
366
- // Check for word boundaries to avoid matching substrings
367
- // For example, avoid matching "int" in "points" or "str" in "stream"
368
- const markerIndex = normalizedVarName.indexOf(normalizedMarker);
369
- if (markerIndex === -1) {
370
- return false;
371
- }
372
- const preMarkerPrefix = markerIndex > 0 ? variableName[markerIndex - 1] : '';
373
- const suffix = variableName[markerIndex + normalizedMarker.length] ?? '';
374
- // Ensure we have proper word boundaries
375
- // A word boundary is defined by:
376
- // 1. Start of string OR underscore OR capital letter before the marker
377
- // 2. End of string OR underscore OR capital letter after the marker
378
- const hasStartBoundary = markerIndex === 0 ||
379
- preMarkerPrefix === '_' ||
380
- /[A-Z]/.test(preMarkerPrefix) ||
381
- /[A-Z]/.test(variableName[markerIndex]);
382
- const hasEndBoundary = markerIndex + normalizedMarker.length === normalizedVarName.length ||
383
- suffix === '_' ||
384
- /[A-Z]/.test(suffix);
385
- return hasStartBoundary && hasEndBoundary;
365
+ // Full type-word markers (non-abbreviations: String, Number, Function,
366
+ // Array, Object, Boolean, …) are Hungarian only when they occupy the
367
+ // first or last camelCase segment — a genuine prefix or suffix that
368
+ // tags the entity's runtime type. The prefix/suffix character checks
369
+ // above already return `true` for those positions, so reaching this
370
+ // point means the marker sits in a middle segment of the identifier
371
+ // (e.g. cloud·Function·Registry, user·String·Name). A middle
372
+ // full-type-word qualifies a domain concept ("a registry of cloud
373
+ // functions") rather than redundantly encoding the entity's type.
374
+ // Accepting the resulting false negatives is the deliberate trade-off:
375
+ // middle-segment full-type-words are overwhelmingly domain vocabulary,
376
+ // not type tags.
377
+ return false;
386
378
  });
387
379
  }
388
380
  // Check if the identifier is a built-in method or imported from an external module
@@ -485,6 +485,98 @@ function isStyleJSXAttributeValue(node) {
485
485
  }
486
486
  return false;
487
487
  }
488
+ /**
489
+ * Returns true when the node is a JSX element or fragment. JSX nodes are
490
+ * always fresh references each render — wrapping a containing literal in
491
+ * useMemo provides no referential-stability benefit because the JSX member
492
+ * changes on every call regardless of the wrapper.
493
+ */
494
+ function isJSXNode(node) {
495
+ return (node.type === utils_1.AST_NODE_TYPES.JSXElement ||
496
+ node.type === utils_1.AST_NODE_TYPES.JSXFragment);
497
+ }
498
+ /**
499
+ * Collects the names of variables declared in the owning function's top-level
500
+ * block body whose initializers resolve to JSX elements or fragments. Used to
501
+ * detect shorthand object properties like `{ Portal }` where
502
+ * `const Portal = <div />` precedes the return statement.
503
+ *
504
+ * Only top-level declarations are scanned; inner function scopes have their
505
+ * own render lifecycles and are not candidates for this exemption.
506
+ */
507
+ function collectLocalJSXBindings(owner) {
508
+ const bindings = new Set();
509
+ const body = owner.body;
510
+ if (body.type !== utils_1.AST_NODE_TYPES.BlockStatement)
511
+ return bindings;
512
+ for (const stmt of body.body) {
513
+ if (stmt.type !== utils_1.AST_NODE_TYPES.VariableDeclaration)
514
+ continue;
515
+ for (const declarator of stmt.declarations) {
516
+ if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier)
517
+ continue;
518
+ if (!declarator.init)
519
+ continue;
520
+ const unwrapped = unwrapNestedExpressions(declarator.init);
521
+ if (isJSXNode(unwrapped)) {
522
+ bindings.add(declarator.id.name);
523
+ }
524
+ }
525
+ }
526
+ return bindings;
527
+ }
528
+ /**
529
+ * Returns true when an ObjectExpression returned from a hook contains at
530
+ * least one property whose value is a JSX element or fragment — either
531
+ * directly inline (`{ Portal: <div /> }`) or via a shorthand identifier
532
+ * (`{ Portal }`) that resolves to a JSX initializer in the same function
533
+ * scope. Such objects cannot be stabilised by wrapping them in useMemo
534
+ * because the JSX member is a fresh reference on every render regardless of
535
+ * the wrapper. This is the same "no stability benefit" rationale applied to
536
+ * sx/style JSX attribute values.
537
+ */
538
+ function objectLiteralContainsJSXValue(node, owner) {
539
+ // Resolve shorthand bindings lazily — only when at least one shorthand
540
+ // property is present, to avoid the body scan for the common inline case.
541
+ let localJSXBindings = null;
542
+ for (const prop of node.properties) {
543
+ if (prop.type === utils_1.AST_NODE_TYPES.SpreadElement)
544
+ continue;
545
+ const value = unwrapNestedExpressions(prop.value);
546
+ // Direct inline JSX: { Portal: <div /> } or { el: <></> }
547
+ if (isJSXNode(value)) {
548
+ return true;
549
+ }
550
+ // Shorthand identifier: { Portal } — resolve to a local JSX binding.
551
+ if (prop.shorthand && value.type === utils_1.AST_NODE_TYPES.Identifier) {
552
+ if (!localJSXBindings) {
553
+ localJSXBindings = collectLocalJSXBindings(owner);
554
+ }
555
+ if (localJSXBindings.has(value.name)) {
556
+ return true;
557
+ }
558
+ }
559
+ }
560
+ return false;
561
+ }
562
+ /**
563
+ * Returns true when an ArrayExpression returned from a hook contains at least
564
+ * one element that is a JSX element or fragment (after unwrapping expression
565
+ * wrappers). The same "no stability benefit" rationale applies: a JSX element
566
+ * is a fresh reference each render, so wrapping the array in useMemo would
567
+ * recompute on every call without improving referential stability.
568
+ */
569
+ function arrayLiteralContainsJSXValue(node) {
570
+ for (const element of node.elements) {
571
+ if (!element)
572
+ continue;
573
+ const unwrapped = unwrapNestedExpressions(element);
574
+ if (isJSXNode(unwrapped)) {
575
+ return true;
576
+ }
577
+ }
578
+ return false;
579
+ }
488
580
  /**
489
581
  * Formats a readable label for diagnostics based on the owning function.
490
582
  * @param fn Owning component or hook function.
@@ -511,7 +603,7 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
511
603
  messages: {
512
604
  componentLiteral: 'New {{literalType}} inside {{context}} is created on every render → Breaks referential stability for hooks/props and can re-run effects or re-render children → Hoist it to a module-level constant or wrap it in {{memoHook}} with the right dependencies.',
513
605
  nestedHookLiteral: 'Nested {{literalType}} inside {{hookName}} arguments is recreated on every render → Dependency/reference comparisons change each time and defeat memoization/caching → Extract it into a memoized value (useMemo/useCallback) or hoist it to a module constant before passing it to {{hookName}}.',
514
- hookReturnLiteral: '{{hookName}} returns a {{literalType}} literal on each render → Callers receive a fresh reference and may re-render or re-run effects → Memoize the returned value with useMemo/useCallback or return pre-memoized pieces so callers see stable references.',
606
+ hookReturnLiteral: '{{hookName}} returns an {{literalType}} on each render → Callers receive a fresh reference and may re-render or re-run effects → Memoize the returned value with useMemo/useCallback or return pre-memoized pieces so callers see stable references.',
515
607
  memoizeLiteralSuggestion: 'This {{literalType}} is created inline → It produces a new reference each render → Wrap it in {{memoHook}} and include every closed-over value in the dependency array.',
516
608
  },
517
609
  },
@@ -659,6 +751,19 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
659
751
  return;
660
752
  }
661
753
  if (isReturnValueFromHook(node, owner)) {
754
+ // A returned literal whose members include a JSX element or fragment
755
+ // cannot be stabilised by useMemo: JSX nodes are inherently fresh
756
+ // references each render, so the wrapper recomputes every call
757
+ // regardless. This mirrors the "no stability benefit" carve-out for
758
+ // sx/style JSX attribute values (see isStyleJSXAttributeValue).
759
+ if (node.type === utils_1.AST_NODE_TYPES.ObjectExpression &&
760
+ objectLiteralContainsJSXValue(node, owner)) {
761
+ return;
762
+ }
763
+ if (node.type === utils_1.AST_NODE_TYPES.ArrayExpression &&
764
+ arrayLiteralContainsJSXValue(node)) {
765
+ return;
766
+ }
662
767
  const hookName = getFunctionName(owner) ?? 'this hook';
663
768
  context.report({
664
769
  node,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.17.0",
3
+ "version": "1.17.1",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,26 @@
1
1
  [
2
+ {
3
+ "version": "1.17.1",
4
+ "date": "2026-07-01T22:31:34.840Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-hungarian",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1250
11
+ ],
12
+ "summary": "exempt middle-segment full-type-words from Hungarian detection (closes #1250)"
13
+ },
14
+ {
15
+ "name": "react-memoize-literals",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1251
19
+ ],
20
+ "summary": "exempt hook returns containing JSX-valued members (closes #1251)"
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "1.17.0",
4
26
  "date": "2026-07-01T05:16:18.225Z",