@blumintinc/eslint-plugin-blumint 1.19.21 → 1.19.22

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.22',
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
  }
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.22",
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.19.22",
4
+ "date": "2026-07-22T09:39:07.911Z",
5
+ "rules": [
6
+ {
7
+ "name": "memo-compare-deeply-complex-props",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1327
11
+ ],
12
+ "summary": "exempt DOM-node props (HTMLElement | null) from complex-prop check (closes #1327)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.19.21",
4
18
  "date": "2026-07-21T21:24:23.464Z",