@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
|
@@ -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
package/release-manifest.json
CHANGED
|
@@ -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",
|