@blumintinc/eslint-plugin-blumint 1.19.21 → 1.19.23

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
@@ -222,7 +222,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
222
222
  module.exports = {
223
223
  meta: {
224
224
  name: '@blumintinc/eslint-plugin-blumint',
225
- version: '1.19.21',
225
+ version: '1.19.23',
226
226
  },
227
227
  parseOptions: {
228
228
  ecmaVersion: 2020,
@@ -394,6 +394,135 @@ function isReactRenderType(type) {
394
394
  }
395
395
  return false;
396
396
  }
397
+ /**
398
+ * Root DOM interfaces that a concrete element type extends. Any interface whose
399
+ * heritage chain reaches one of these represents a live DOM node. DOM nodes are
400
+ * stable references (never recreated literals), so deep-comparing them yields no
401
+ * benefit — and worse, walking their circular `__reactFiber$*` / `__reactProps$*`
402
+ * back-references risks a stack overflow. They are excluded from the complex-prop
403
+ * check for the same reason `ReactElement` is.
404
+ */
405
+ const DOM_ELEMENT_BASE_NAMES = new Set([
406
+ 'HTMLElement',
407
+ 'SVGElement',
408
+ 'Element',
409
+ 'Node',
410
+ 'EventTarget',
411
+ ]);
412
+ /**
413
+ * Returns true when `sym` is declared inside a DOM lib `.d.ts` file (e.g.
414
+ * `lib.dom.d.ts`). Gating on this origin ensures a user-defined type that
415
+ * happens to be named `Element` / `Node` is still treated as a genuine data
416
+ * prop rather than silently carved out. Mirrors
417
+ * `isSymbolFromReactDeclarationFile`, but keys on the DOM lib filename.
418
+ */
419
+ function isSymbolFromDomDeclarationFile(sym) {
420
+ const declarations = sym.declarations;
421
+ if (!declarations || declarations.length === 0)
422
+ return false;
423
+ return declarations.some((decl) => {
424
+ const fileName = decl.getSourceFile?.()?.fileName ?? '';
425
+ return fileName.endsWith('.d.ts') && /lib\.dom/i.test(fileName);
426
+ });
427
+ }
428
+ /**
429
+ * Walks a type's base-class/heritage chain (via `getBaseTypes`) looking for a
430
+ * root DOM interface name. Handles concrete subclasses like `HTMLDivElement` or
431
+ * `HTMLButtonElement`, whose own name is not a root but whose ancestry reaches
432
+ * `HTMLElement` → `Element` → `Node` → `EventTarget`.
433
+ */
434
+ function domHeritageIncludesElementBase(type, checker, visited) {
435
+ if (visited.has(type))
436
+ return false;
437
+ visited.add(type);
438
+ const sym = type.symbol;
439
+ if (sym && DOM_ELEMENT_BASE_NAMES.has(sym.escapedName)) {
440
+ return true;
441
+ }
442
+ if (typeof type.isClassOrInterface === 'function' &&
443
+ type.isClassOrInterface()) {
444
+ let baseTypes = [];
445
+ try {
446
+ baseTypes = checker.getBaseTypes(type);
447
+ }
448
+ catch {
449
+ baseTypes = [];
450
+ }
451
+ return baseTypes.some((base) => domHeritageIncludesElementBase(base, checker, visited));
452
+ }
453
+ return false;
454
+ }
455
+ /**
456
+ * Returns true when `type` resolves to a DOM element type that must be excluded
457
+ * from the complex-prop check. Requires BOTH that the type originates from a DOM
458
+ * lib `.d.ts` file AND that its heritage chain reaches a root DOM interface, so
459
+ * only real DOM nodes — not identically named user types — are carved out.
460
+ *
461
+ * For union types (e.g. the ubiquitous `HTMLElement | null` MUI anchor prop) the
462
+ * whole union counts as a DOM element only when every non-nullish member is one,
463
+ * so a mixed union like `HTMLElement | { theme: string }` still surfaces its
464
+ * genuine object member as complex.
465
+ */
466
+ function isDomElementType(ts, type, checker, visited) {
467
+ if (visited.has(type))
468
+ return false;
469
+ visited.add(type);
470
+ const flags = type.flags ?? 0;
471
+ if ((flags & ts.TypeFlags.Union) !== 0) {
472
+ const nonNullishMembers = type.types.filter((member) => (member.flags &
473
+ (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) ===
474
+ 0);
475
+ return (nonNullishMembers.length > 0 &&
476
+ nonNullishMembers.every((member) => isDomElementType(ts, member, checker, visited)));
477
+ }
478
+ const sym = type.symbol;
479
+ if (!sym)
480
+ return false;
481
+ if (!isSymbolFromDomDeclarationFile(sym))
482
+ return false;
483
+ return domHeritageIncludesElementBase(type, checker, new Set());
484
+ }
485
+ /**
486
+ * Root DOM interface names that have no shared `*Element` suffix and so must be
487
+ * matched exactly (unlike `HTMLDivElement`, which is caught by the family
488
+ * pattern below).
489
+ */
490
+ const DOM_ELEMENT_TYPE_NAME_EXACT = new Set([
491
+ 'Element',
492
+ 'Node',
493
+ 'EventTarget',
494
+ 'HTMLElement',
495
+ 'SVGElement',
496
+ 'MathMLElement',
497
+ ]);
498
+ /**
499
+ * Matches concrete DOM element interface names (`HTMLDivElement`,
500
+ * `HTMLButtonElement`, `SVGRectElement`, `MathMLMathElement`, …) as a family so
501
+ * subclasses are covered without enumerating every tag. Used only on the
502
+ * annotation fallback path, where the type resolves to `any` (DOM lib absent
503
+ * from the tsconfig `lib`) and no heritage chain is available to walk.
504
+ */
505
+ const DOM_ELEMENT_SUBCLASS_PATTERN = /^(?:HTML|SVG|MathML)[A-Za-z0-9]*Element$/;
506
+ function isDomElementTypeName(name) {
507
+ return (DOM_ELEMENT_TYPE_NAME_EXACT.has(name) ||
508
+ DOM_ELEMENT_SUBCLASS_PATTERN.test(name));
509
+ }
510
+ /**
511
+ * Origin gate for the annotation fallback path. A symbol declared in a DOM lib
512
+ * `.d.ts`, or with no declarations at all (the DOM lib is absent so the global
513
+ * resolved to `any`), is treated as DOM-sourced. A user-defined type declared in
514
+ * project source is not — so a coincidentally named `Element`/`Node` still
515
+ * flags.
516
+ */
517
+ function isDomSourcedSymbol(sym) {
518
+ const declarations = sym.declarations;
519
+ if (!declarations || declarations.length === 0)
520
+ return true;
521
+ return declarations.some((decl) => {
522
+ const fileName = decl.getSourceFile?.()?.fileName ?? '';
523
+ return fileName.endsWith('.d.ts') && /lib\.dom/i.test(fileName);
524
+ });
525
+ }
397
526
  function isComplexType(ts, type, checker) {
398
527
  return isComplexTypeInternal(ts, type, checker, new Set());
399
528
  }
@@ -408,6 +537,13 @@ function isComplexTypeInternal(ts, type, checker, visited) {
408
537
  if (isReactRenderType(type)) {
409
538
  return false;
410
539
  }
540
+ // Exclude DOM element types (e.g. the MUI `anchorEl: HTMLElement | null`)
541
+ // for the same reason ReactElement is excluded: DOM nodes are stable
542
+ // references and deep-comparing them walks React's circular fiber
543
+ // back-references, risking a stack overflow.
544
+ if (isDomElementType(ts, type, checker, new Set())) {
545
+ return false;
546
+ }
411
547
  const flags = type.flags ?? 0;
412
548
  if (isUnionType(ts, flags)) {
413
549
  return checkUnionType(ts, type, checker, visited);
@@ -545,6 +681,60 @@ function isAnnotationReactRenderType(annotationType, checker, ts) {
545
681
  return false;
546
682
  }
547
683
  }
684
+ /**
685
+ * Annotation-path DOM carve-out, parallel to `isAnnotationReactRenderType`.
686
+ *
687
+ * When the DOM lib is absent from the tsconfig `lib`, a prop typed as
688
+ * `HTMLElement` / `HTMLDivElement` / `Element` / `Node` resolves to `any`, so
689
+ * the structural `isDomElementType` check cannot see it. The annotation node,
690
+ * however, still carries the written name via the resolved type's alias/own
691
+ * symbol. Match that name against the DOM element family (gated on DOM origin so
692
+ * a user-defined lookalike still flags). Union annotations (e.g. the ubiquitous
693
+ * `HTMLElement | null` MUI anchor prop) qualify only when every non-nullish
694
+ * member is a DOM element type.
695
+ */
696
+ function isAnnotationDomElementType(annotationType, checker, ts) {
697
+ try {
698
+ const tsModule = ts;
699
+ if (tsModule.isUnionTypeNode?.(annotationType)) {
700
+ const nonNullishMembers = annotationType.types.filter((member) => {
701
+ if (member.kind === tsModule.SyntaxKind.NullKeyword ||
702
+ member.kind === tsModule.SyntaxKind.UndefinedKeyword ||
703
+ member.kind === tsModule.SyntaxKind.VoidKeyword) {
704
+ return false;
705
+ }
706
+ if (tsModule.isLiteralTypeNode?.(member)) {
707
+ const lit = member.literal;
708
+ if (lit.kind === tsModule.SyntaxKind.NullKeyword ||
709
+ lit.kind === tsModule.SyntaxKind.UndefinedKeyword) {
710
+ return false;
711
+ }
712
+ }
713
+ return true;
714
+ });
715
+ return (nonNullishMembers.length > 0 &&
716
+ nonNullishMembers.every((member) => isAnnotationDomElementType(member, checker, ts)));
717
+ }
718
+ const resolvedType = checker.getTypeFromTypeNode?.(annotationType);
719
+ if (!resolvedType)
720
+ return false;
721
+ // Prefer the structural heritage check when the DOM lib IS loaded.
722
+ if (isDomElementType(ts, resolvedType, checker, new Set())) {
723
+ return true;
724
+ }
725
+ // Fallback for the `any` case: the resolved type surfaces the written name
726
+ // via its alias (e.g. `HTMLElement`) or own symbol.
727
+ const sym = resolvedType
728
+ .aliasSymbol ?? resolvedType.symbol;
729
+ if (!sym)
730
+ return false;
731
+ const name = sym.escapedName;
732
+ return isDomElementTypeName(name) && isDomSourcedSymbol(sym);
733
+ }
734
+ catch {
735
+ return false;
736
+ }
737
+ }
548
738
  function shouldTreatAnyAsComplex(prop, propType, ts, treatAnyAsComplex, parentTypeFlags, checker) {
549
739
  if (!(propType.flags & ts.TypeFlags.Any))
550
740
  return false;
@@ -560,6 +750,15 @@ function shouldTreatAnyAsComplex(prop, propType, ts, treatAnyAsComplex, parentTy
560
750
  isAnnotationReactRenderType(annotationType, checker, ts)) {
561
751
  return false;
562
752
  }
753
+ // Likewise, when the annotation resolves to a DOM element type (e.g. the MUI
754
+ // `anchorEl: HTMLElement | null`), the prop is a stable DOM-node reference.
755
+ // Deep-comparing it walks React's circular fiber back-references and yields
756
+ // no benefit — exclude it the same way React render types are excluded.
757
+ if (annotationType &&
758
+ checker &&
759
+ isAnnotationDomElementType(annotationType, checker, ts)) {
760
+ return false;
761
+ }
563
762
  return ((annotationType && annotationType.kind !== ts.SyntaxKind.AnyKeyword) ||
564
763
  (!annotationType && Boolean(parentTypeFlags & ts.TypeFlags.Object)));
565
764
  }
@@ -86,6 +86,21 @@ const ITERATION_METHODS = new Set([
86
86
  'flatMap',
87
87
  'sort',
88
88
  ]);
89
+ /**
90
+ * Comparison operators whose result is a boolean primitive. A value produced by
91
+ * one of these can never carry a referential identity, so a call whose result
92
+ * feeds such a comparison never lets its argument's identity escape.
93
+ */
94
+ const COMPARISON_OPERATORS = new Set([
95
+ '===',
96
+ '!==',
97
+ '==',
98
+ '!=',
99
+ '<',
100
+ '>',
101
+ '<=',
102
+ '>=',
103
+ ]);
89
104
  const MEMOIZATION_DEPS_TODO_PLACEHOLDER = '__TODO_MEMOIZATION_DEPENDENCIES__';
90
105
  const TODO_DEPS_COMMENT = `/* ${MEMOIZATION_DEPS_TODO_PLACEHOLDER} */`;
91
106
  const PARENTHESIZED_EXPRESSION_TYPE = utils_1.AST_NODE_TYPES.ParenthesizedExpression ??
@@ -847,6 +862,108 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
847
862
  }
848
863
  return usages.every((ref) => isStyleJSXAttributeValue(ref.identifier));
849
864
  }
865
+ /**
866
+ * True when `expr` — a plain function call's result, or a reference to the
867
+ * variable holding that result — is consumed only for its primitive value,
868
+ * never by reference.
869
+ *
870
+ * Referential stability of a call's ARGUMENT matters only if the call's
871
+ * RESULT reaches a memoization boundary (a hook dependency, a memoized
872
+ * child's prop, an effect capture) where identities are reference-compared.
873
+ * A result that lands in a boolean-test position — a ternary/if/while/for
874
+ * test, a `!`, a comparison, or a logical chain that itself ends in such a
875
+ * position — is coerced to (or already is) a primitive, so the argument's
876
+ * identity provably never crosses such a boundary regardless of what the
877
+ * callee returns. Reasoning from how the CALLER consumes the result (not the
878
+ * callee's body) keeps the analysis purely syntactic and type-free.
879
+ *
880
+ * The whitelist is deliberately tight: any position not proven primitive
881
+ * (JSX attribute value, hook dependency element, argument to another call,
882
+ * spread, return, object/array member) yields false, so the exemption never
883
+ * silences a literal whose identity could still escape.
884
+ */
885
+ function isPrimitivelyConsumed(expr) {
886
+ const parent = expr.parent;
887
+ if (!parent) {
888
+ return false;
889
+ }
890
+ // TS-assertion/parenthesized wrappers are transparent to consumption:
891
+ // analyze how the wrapped value is ultimately used.
892
+ if (isExpressionWrapper(parent)) {
893
+ const wrapper = parent;
894
+ return wrapper.expression === expr && isPrimitivelyConsumed(parent);
895
+ }
896
+ switch (parent.type) {
897
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
898
+ return parent.test === expr;
899
+ case utils_1.AST_NODE_TYPES.IfStatement:
900
+ case utils_1.AST_NODE_TYPES.WhileStatement:
901
+ case utils_1.AST_NODE_TYPES.DoWhileStatement:
902
+ case utils_1.AST_NODE_TYPES.ForStatement:
903
+ return parent.test === expr;
904
+ case utils_1.AST_NODE_TYPES.UnaryExpression:
905
+ return parent.operator === '!';
906
+ case utils_1.AST_NODE_TYPES.BinaryExpression:
907
+ return COMPARISON_OPERATORS.has(parent.operator);
908
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
909
+ // A logical operand inherits the consumption of the whole expression:
910
+ // safe only if that ultimately lands in a primitive position too.
911
+ return isPrimitivelyConsumed(parent);
912
+ case utils_1.AST_NODE_TYPES.VariableDeclarator: {
913
+ if (parent.init !== expr ||
914
+ parent.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
915
+ return false;
916
+ }
917
+ const variables = ASTHelpers_1.ASTHelpers.getDeclaredVariables(context, parent);
918
+ if (variables.length === 0) {
919
+ return false;
920
+ }
921
+ const usages = variables[0].references.filter((ref) => !ref.init);
922
+ // No usages (dead code): can't prove the result stays primitive, so
923
+ // keep the literal reported (mirrors isStyleVariableInitializer).
924
+ if (usages.length === 0) {
925
+ return false;
926
+ }
927
+ return usages.every((ref) => isPrimitivelyConsumed(ref.identifier));
928
+ }
929
+ default:
930
+ return false;
931
+ }
932
+ }
933
+ /**
934
+ * True when the literal is a direct argument of a plain function call whose
935
+ * result is only ever consumed primitively (see isPrimitivelyConsumed). In
936
+ * that case the literal's identity provably never reaches a memoization
937
+ * boundary — it is neither a JSX prop, nor a hook dependency, nor captured
938
+ * by an effect — so re-creating it each render costs nothing and memoizing
939
+ * it buys nothing. The callee must be a plain Identifier: member calls
940
+ * (`obj.method({...})`) are excluded because the receiver could retain the
941
+ * reference, and this keeps the guard to plain, non-hook synchronous calls.
942
+ */
943
+ function isPrimitiveConsumedCallArgument(node) {
944
+ // Walk up through transparent wrappers to the position the literal
945
+ // effectively occupies as a call argument.
946
+ let effective = node;
947
+ let parent = effective.parent;
948
+ while (parent &&
949
+ isExpressionWrapper(parent) &&
950
+ parent
951
+ .expression === effective) {
952
+ effective = parent;
953
+ parent = effective.parent;
954
+ }
955
+ if (!parent || parent.type !== utils_1.AST_NODE_TYPES.CallExpression) {
956
+ return false;
957
+ }
958
+ const isDirectArgument = parent.arguments.some((arg) => arg === effective);
959
+ if (!isDirectArgument) {
960
+ return false;
961
+ }
962
+ if (parent.callee.type !== utils_1.AST_NODE_TYPES.Identifier) {
963
+ return false;
964
+ }
965
+ return isPrimitivelyConsumed(parent);
966
+ }
850
967
  function reportLiteral(node) {
851
968
  const descriptor = getLiteralDescriptor(node);
852
969
  if (!descriptor)
@@ -937,6 +1054,14 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
937
1054
  });
938
1055
  return;
939
1056
  }
1057
+ // A literal passed only as an argument to a plain synchronous call whose
1058
+ // result is consumed primitively never carries its identity to a
1059
+ // memoization boundary, so memoizing it is pointless (issue #1329). Placed
1060
+ // last so hook-argument and hook-return handling run first and are
1061
+ // unaffected.
1062
+ if (isPrimitiveConsumedCallArgument(node)) {
1063
+ return;
1064
+ }
940
1065
  const contextLabel = formatContextLabel(owner);
941
1066
  // Only emit auto-fix suggestions for simple variable initializers; other
942
1067
  // contexts (returns, JSX props, nested expressions) risk unsafe rewrites.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.19.21",
3
+ "version": "1.19.23",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.19.23",
4
+ "date": "2026-07-22T15:32:41.825Z",
5
+ "rules": [
6
+ {
7
+ "name": "react-memoize-literals",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1329
11
+ ],
12
+ "summary": "exempt literal args to primitively-consumed plain calls (closes #1329)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.19.22",
18
+ "date": "2026-07-22T09:39:07.911Z",
19
+ "rules": [
20
+ {
21
+ "name": "memo-compare-deeply-complex-props",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1327
25
+ ],
26
+ "summary": "exempt DOM-node props (HTMLElement | null) from complex-prop check (closes #1327)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.19.21",
4
32
  "date": "2026-07-21T21:24:23.464Z",