@blumintinc/eslint-plugin-blumint 1.15.0 → 1.16.0

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.
Files changed (37) hide show
  1. package/README.md +28 -5
  2. package/lib/index.js +7 -1
  3. package/lib/rules/consistent-callback-naming.js +26 -0
  4. package/lib/rules/dynamic-https-errors.js +5 -26
  5. package/lib/rules/enforce-boolean-naming-prefixes.d.ts +1 -0
  6. package/lib/rules/enforce-boolean-naming-prefixes.js +86 -119
  7. package/lib/rules/enforce-dynamic-imports.d.ts +2 -1
  8. package/lib/rules/enforce-dynamic-imports.js +42 -21
  9. package/lib/rules/enforce-memoize-async.js +1 -4
  10. package/lib/rules/enforce-mui-rounded-icons.js +42 -1
  11. package/lib/rules/enforce-verb-noun-naming.js +3 -0
  12. package/lib/rules/global-const-style.js +9 -0
  13. package/lib/rules/logical-top-to-bottom-grouping.js +80 -2
  14. package/lib/rules/memo-compare-deeply-complex-props.js +167 -3
  15. package/lib/rules/memo-nested-react-components.js +143 -8
  16. package/lib/rules/no-array-length-in-deps.js +74 -3
  17. package/lib/rules/no-circular-references.js +145 -482
  18. package/lib/rules/no-compositing-layer-props.js +31 -0
  19. package/lib/rules/no-entire-object-hook-deps.js +132 -97
  20. package/lib/rules/no-explicit-return-type.js +6 -0
  21. package/lib/rules/no-hungarian.js +119 -24
  22. package/lib/rules/no-margin-properties.js +7 -38
  23. package/lib/rules/no-unnecessary-verb-suffix.js +79 -0
  24. package/lib/rules/no-unused-props.js +215 -37
  25. package/lib/rules/no-useless-fragment.js +10 -2
  26. package/lib/rules/parallelize-async-operations.js +1 -3
  27. package/lib/rules/prefer-type-alias-over-typeof-constant.js +73 -11
  28. package/lib/rules/prefer-use-deep-compare-memo.js +8 -14
  29. package/lib/rules/react-memoize-literals.js +87 -1
  30. package/lib/rules/require-migration-script-metadata.d.ts +9 -0
  31. package/lib/rules/require-migration-script-metadata.js +206 -0
  32. package/lib/rules/warn-https-error-message-user-friendly.d.ts +1 -0
  33. package/lib/rules/warn-https-error-message-user-friendly.js +239 -0
  34. package/lib/utils/ASTHelpers.d.ts +15 -0
  35. package/lib/utils/ASTHelpers.js +48 -0
  36. package/package.json +7 -6
  37. package/release-manifest.json +166 -0
@@ -1,21 +1,37 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RULE_NAME = void 0;
3
+ exports.DEFAULT_IGNORED_LIBRARIES = exports.RULE_NAME = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
+ const minimatch_1 = require("minimatch");
5
6
  exports.RULE_NAME = 'enforce-dynamic-imports';
7
+ exports.DEFAULT_IGNORED_LIBRARIES = [
8
+ 'react',
9
+ 'react/**',
10
+ 'react-dom',
11
+ 'react-dom/**',
12
+ 'next',
13
+ 'next/**',
14
+ '@mui/material',
15
+ '@mui/material/**',
16
+ '@mui/icons-material',
17
+ '@mui/icons-material/**',
18
+ '@emotion/**',
19
+ 'clsx',
20
+ 'tailwind-merge',
21
+ ];
6
22
  exports.default = (0, createRule_1.createRule)({
7
23
  name: exports.RULE_NAME,
8
24
  meta: {
9
25
  type: 'suggestion',
10
26
  docs: {
11
- description: 'Enforce dynamic imports for specified libraries to optimize bundle size',
27
+ description: 'Enforce dynamic imports for external libraries by default to optimize bundle size, unless explicitly ignored',
12
28
  recommended: 'error',
13
29
  },
14
30
  schema: [
15
31
  {
16
32
  type: 'object',
17
33
  properties: {
18
- libraries: {
34
+ ignoredLibraries: {
19
35
  type: 'array',
20
36
  items: {
21
37
  type: 'string',
@@ -29,46 +45,51 @@ exports.default = (0, createRule_1.createRule)({
29
45
  },
30
46
  ],
31
47
  messages: {
32
- dynamicImportRequired: 'Static import from "{{source}}" eagerly pulls the entire package into the entry bundle, inflating download size and delaying the first render. Load it lazily with a dynamic import (for example, useDynamic(() => import("{{source}}"))) so the code is fetched only when needed; if you only need types, use a type-only import with allowImportType enabled.',
48
+ dynamicImportRequired: 'Static import from "{{source}}" loads the full package into the initial bundle. This increases download size and delays first render, undermining our lazy‑loading pattern for external dependencies. → Use a dynamic import (e.g., useDynamic(() => import("{{source}}"))), add "{{source}}" to ignoredLibraries for intentional static usage, or use a typeonly import when you only need types.',
33
49
  },
34
50
  },
35
51
  defaultOptions: [
36
52
  {
37
- libraries: ['@stream-io/video-react-sdk'],
53
+ ignoredLibraries: exports.DEFAULT_IGNORED_LIBRARIES,
38
54
  allowImportType: true,
39
55
  },
40
56
  ],
41
57
  create(context, [options]) {
42
- const { libraries, allowImportType = true } = options;
43
- // Check if the import source matches any of the specified libraries
44
- const isLibraryMatch = (source) => {
45
- return libraries.some((lib) => {
46
- // Simple glob pattern matching
47
- if (lib.includes('*')) {
48
- const pattern = lib.replace(/\*/g, '.*');
49
- const regex = new RegExp(`^${pattern}$`);
50
- return regex.test(source);
51
- }
52
- return source === lib;
53
- });
58
+ const { ignoredLibraries = exports.DEFAULT_IGNORED_LIBRARIES, allowImportType = true, } = options;
59
+ const exactIgnored = new Set();
60
+ const globIgnored = [];
61
+ for (const lib of ignoredLibraries) {
62
+ const mm = new minimatch_1.Minimatch(lib);
63
+ if (mm.hasMagic()) {
64
+ globIgnored.push(mm);
65
+ }
66
+ else {
67
+ exactIgnored.add(lib);
68
+ }
69
+ }
70
+ const isIgnored = (source) => {
71
+ return (exactIgnored.has(source) ||
72
+ globIgnored.some((mm) => mm.match(source)));
73
+ };
74
+ const isExternal = (source) => {
75
+ // Treat npm-style specifiers (including numeric names like '3d-force-graph') as external; internal paths are excluded.
76
+ return /^[a-z0-9@]/i.test(source) && !source.startsWith('@/');
54
77
  };
55
78
  return {
56
79
  ImportDeclaration(node) {
57
80
  const importSource = node.source.value;
58
81
  // Skip type-only imports if allowed
59
82
  if (allowImportType) {
60
- // Check if it's a type-only import declaration
61
83
  if (node.importKind === 'type') {
62
84
  return;
63
85
  }
64
- // Check if all specifiers are type imports
65
86
  if (node.specifiers.length > 0 &&
66
87
  node.specifiers.every((spec) => spec.type === 'ImportSpecifier' && spec.importKind === 'type')) {
67
88
  return;
68
89
  }
69
90
  }
70
- // Check if the import is from a library that should be dynamically imported
71
- if (typeof importSource === 'string' && isLibraryMatch(importSource)) {
91
+ // Only enforce for external libraries that are NOT ignored
92
+ if (isExternal(importSource) && !isIgnored(importSource)) {
72
93
  context.report({
73
94
  node,
74
95
  messageId: 'dynamicImportRequired',
@@ -4,10 +4,7 @@ exports.enforceMemoizeAsync = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const MEMOIZE_MODULE = '@blumintinc/typescript-memoize';
7
- const ALLOWED_MEMOIZE_MODULES = new Set([
8
- MEMOIZE_MODULE,
9
- 'typescript-memoize',
10
- ]);
7
+ const ALLOWED_MEMOIZE_MODULES = new Set([MEMOIZE_MODULE, 'typescript-memoize']);
11
8
  /**
12
9
  * Matches a memoize decorator in supported syntaxes:
13
10
  * - @Alias()
@@ -3,6 +3,39 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceMuiRoundedIcons = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const utils_1 = require("@typescript-eslint/utils");
6
+ /**
7
+ * Non-Rounded MUI variant suffixes. An icon imported as one of these variants
8
+ * (e.g. `AddReactionOutlined`) maps to the Rounded variant of its BASE name
9
+ * (`AddReactionRounded`), not `AddReactionOutlinedRounded` (which doesn't exist).
10
+ * Matched exactly, so a distinct icon like `MailOutline` (ends in "Outline", not
11
+ * "Outlined") is left intact → `MailOutlineRounded`, which does exist.
12
+ */
13
+ const VARIANT_SUFFIXES = ['Outlined', 'Sharp', 'TwoTone'];
14
+ /**
15
+ * @mui/icons-material brand icons that ship in a single (Filled) variant only —
16
+ * they have no Rounded counterpart, so the rule must not demand one. Computed
17
+ * from @mui/icons-material (the only base icons lacking a `*Rounded` sibling).
18
+ */
19
+ const ICONS_WITHOUT_ROUNDED = new Set([
20
+ 'Apple',
21
+ 'GitHub',
22
+ 'Google',
23
+ 'Instagram',
24
+ 'LinkedIn',
25
+ 'Microsoft',
26
+ 'Pinterest',
27
+ 'Reddit',
28
+ 'Telegram',
29
+ 'Twitter',
30
+ 'WhatsApp',
31
+ 'X',
32
+ 'YouTube',
33
+ ]);
34
+ /** Strips a trailing non-Rounded variant suffix to recover the base icon name. */
35
+ const toBaseIconName = (iconName) => {
36
+ const suffix = VARIANT_SUFFIXES.find((variant) => iconName.endsWith(variant));
37
+ return suffix ? iconName.slice(0, -suffix.length) : iconName;
38
+ };
6
39
  exports.enforceMuiRoundedIcons = (0, createRule_1.createRule)({
7
40
  name: 'enforce-mui-rounded-icons',
8
41
  meta: {
@@ -36,8 +69,16 @@ exports.enforceMuiRoundedIcons = (0, createRule_1.createRule)({
36
69
  if (!iconName) {
37
70
  return;
38
71
  }
72
+ // Map a non-Rounded variant import to its base icon so the Rounded
73
+ // suggestion/fix targets a name that actually exists.
74
+ const baseIconName = toBaseIconName(iconName);
75
+ // Brand icons have no Rounded variant — demanding one is a false
76
+ // positive and the fix would point at a non-existent module.
77
+ if (ICONS_WITHOUT_ROUNDED.has(baseIconName)) {
78
+ return;
79
+ }
39
80
  // Create the rounded variant name
40
- const roundedVariant = `${iconName}Rounded`;
81
+ const roundedVariant = `${baseIconName}Rounded`;
41
82
  context.report({
42
83
  node,
43
84
  messageId: 'enforceRoundedVariant',
@@ -20,6 +20,7 @@ const ALLOWLIST = {
20
20
  domain: new Set([
21
21
  'ai',
22
22
  'blumint',
23
+ 'main',
23
24
  'middleware',
24
25
  'noop',
25
26
  'nth',
@@ -406,6 +407,8 @@ const ALLOWLIST = {
406
407
  'brush',
407
408
  'bubble',
408
409
  'buck',
410
+ 'bucket',
411
+ 'bucketize',
409
412
  'buckle',
410
413
  'bud',
411
414
  'budge',
@@ -158,6 +158,15 @@ exports.default = (0, createRule_1.createRule)({
158
158
  if (target.type === utils_1.AST_NODE_TYPES.Literal && 'regex' in target) {
159
159
  return false;
160
160
  }
161
+ // Skip null and boolean literals. `null as const` is invalid
162
+ // TypeScript (TS1355), so the autofix would produce uncompilable
163
+ // code; `true`/`false` already have literal types, so `as const`
164
+ // is redundant. (`undefined` is an Identifier, not a Literal, so
165
+ // it never reaches the literal branch below.)
166
+ if (target.type === utils_1.AST_NODE_TYPES.Literal &&
167
+ (target.value === null || typeof target.value === 'boolean')) {
168
+ return false;
169
+ }
161
170
  return (target.type === utils_1.AST_NODE_TYPES.Literal ||
162
171
  target.type === utils_1.AST_NODE_TYPES.ArrayExpression ||
163
172
  target.type === utils_1.AST_NODE_TYPES.ObjectExpression);
@@ -760,18 +760,26 @@ function handleGuardHoists(ruleContext, body, parent) {
760
760
  }
761
761
  function handleDerivedGrouping(ruleContext, body, parent) {
762
762
  const declaredIndices = new Map();
763
+ /**
764
+ * Each binding name → the declarator that introduced it. Sibling bindings of
765
+ * one multi-target declarator (e.g. `const [a, b] = ...`) share one node, so
766
+ * a statement deriving from `a` is recognizable as a sibling of one deriving
767
+ * from `b` even though `a` separates `b` from its declaration.
768
+ */
769
+ const sourceDeclarators = new Map();
763
770
  const { sourceCode } = ruleContext;
764
771
  body.forEach((statement, index) => {
765
772
  if (isVariableDeclaration(statement)) {
766
- processVariableDeclaration(ruleContext, statement, index, body, declaredIndices, parent, sourceCode);
773
+ processVariableDeclaration(ruleContext, statement, index, body, declaredIndices, sourceDeclarators, parent, sourceCode);
767
774
  }
768
775
  trackDeclaredNames(statement, index, declaredIndices);
776
+ trackSourceDeclarators(statement, sourceDeclarators);
769
777
  });
770
778
  }
771
779
  function isVariableDeclaration(statement) {
772
780
  return statement.type === utils_1.AST_NODE_TYPES.VariableDeclaration;
773
781
  }
774
- function processVariableDeclaration(ruleContext, statement, index, body, declaredIndices, parent, sourceCode) {
782
+ function processVariableDeclaration(ruleContext, statement, index, body, declaredIndices, sourceDeclarators, parent, sourceCode) {
775
783
  const dependencies = collectDependencies(statement);
776
784
  const priorDependencies = findPriorDependencies(dependencies, declaredIndices);
777
785
  if (priorDependencies.length === 0 ||
@@ -784,6 +792,9 @@ function processVariableDeclaration(ruleContext, statement, index, body, declare
784
792
  }
785
793
  const declaredNames = getDeclaredNames(statement);
786
794
  const priorDependencySet = new Set(priorDependencies);
795
+ if (interveningAreSiblingDerivations(body, lastDependencyIndex, index, priorDependencySet, sourceDeclarators)) {
796
+ return;
797
+ }
787
798
  if (hasBlockers(body, lastDependencyIndex, index, priorDependencySet, declaredNames)) {
788
799
  return;
789
800
  }
@@ -829,6 +840,73 @@ function trackDeclaredNames(statement, index, declaredIndices) {
829
840
  const declared = getDeclaredNames(statement);
830
841
  declared.forEach((name) => declaredIndices.set(name, index));
831
842
  }
843
+ function trackSourceDeclarators(statement, sourceDeclarators) {
844
+ if (statement.type !== utils_1.AST_NODE_TYPES.VariableDeclaration) {
845
+ return;
846
+ }
847
+ statement.declarations.forEach((declarator) => {
848
+ const names = new Set();
849
+ collectDeclaredNamesFromPattern(declarator.id, names);
850
+ names.forEach((name) => sourceDeclarators.set(name, declarator));
851
+ });
852
+ }
853
+ /**
854
+ * True when every statement between a dependency and the statement deriving from
855
+ * it is itself a pure derivation from a *sibling binding of the same declarator*.
856
+ *
857
+ * `const [a, b] = await Promise.all([...]); const x = a; const y = b;` is already
858
+ * grouped: `x` and `y` unpack one source declaration in order, so the `x` line is
859
+ * not "unrelated" separation between `b` and `y`. Suppressing here keeps such
860
+ * cohesive sibling-destructure groups intact instead of reordering them.
861
+ */
862
+ function interveningAreSiblingDerivations(body, lastDependencyIndex, currentIndex, priorDependencySet, sourceDeclarators) {
863
+ const sourceNodes = new Set();
864
+ priorDependencySet.forEach((name) => {
865
+ const declarator = sourceDeclarators.get(name);
866
+ if (declarator) {
867
+ sourceNodes.add(declarator);
868
+ }
869
+ });
870
+ if (sourceNodes.size === 0) {
871
+ return false;
872
+ }
873
+ const intervening = body.slice(lastDependencyIndex + 1, currentIndex);
874
+ if (intervening.length === 0) {
875
+ return false;
876
+ }
877
+ return intervening.every((between) => isSiblingSourceDerivation(between, sourceNodes, sourceDeclarators));
878
+ }
879
+ /**
880
+ * A statement is a sibling-source derivation when it is a pure declaration whose
881
+ * every prior-declared dependency was introduced by one of `sourceNodes` — i.e.
882
+ * it unpacks a sibling binding of the same multi-target declaration the dependent
883
+ * statement derives from.
884
+ */
885
+ function isSiblingSourceDerivation(statement, sourceNodes, sourceDeclarators) {
886
+ if (statement.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
887
+ !isPureDeclaration(statement, { allowHooks: false })) {
888
+ return false;
889
+ }
890
+ const dependencies = collectDependencies(statement);
891
+ const siblingSourced = Array.from(dependencies).filter((name) => {
892
+ const declarator = sourceDeclarators.get(name);
893
+ return Boolean(declarator && sourceNodes.has(declarator));
894
+ });
895
+ if (siblingSourced.length === 0) {
896
+ return false;
897
+ }
898
+ /**
899
+ * Reject when the statement also pulls in any prior-declared name from outside
900
+ * the sibling source: that would be genuine extra coupling, not a clean sibling
901
+ * unpack.
902
+ */
903
+ return Array.from(dependencies).every((name) => {
904
+ if (siblingSourced.includes(name)) {
905
+ return true;
906
+ }
907
+ return !sourceDeclarators.has(name);
908
+ });
909
+ }
832
910
  function handleLateDeclarations(ruleContext, body, parent) {
833
911
  const { sourceCode } = ruleContext;
834
912
  body.forEach((statement, index) => {
@@ -6,6 +6,19 @@ const createRule_1 = require("../utils/createRule");
6
6
  function isUtilMemoModulePath(path) {
7
7
  return /(?:^|\/|\\)util\/memo$/.test(path);
8
8
  }
9
+ /**
10
+ * Reserved React slots that never reach a component's props object at runtime.
11
+ * React extracts `ref` and `key` before invoking the memo equality function, so
12
+ * a deep comparator can never observe them. Some prop-type sources (notably the
13
+ * `forwardRef<T, P>` exotic-component signature, whose params include the
14
+ * synthetic `RefAttributes<T> = { ref?: Ref<T> }` member) surface them as
15
+ * structural members anyway; flagging them would emit dead `compareDeeply('ref')`
16
+ * advice. Excluded the same way `children` is.
17
+ */
18
+ const RESERVED_REACT_PROP_NAMES = new Set(['ref', 'key', 'children']);
19
+ function isReservedReactPropName(name) {
20
+ return RESERVED_REACT_PROP_NAMES.has(name);
21
+ }
9
22
  /**
10
23
  * Checks if a property name is handled by the default deep equality logic
11
24
  * in our custom memo implementation (blumintAreEqual).
@@ -296,6 +309,91 @@ function ensureCompareDeeplyImportFixes(sourceCode, fixer, usedNames, preferredS
296
309
  localName: preferredLocalName,
297
310
  };
298
311
  }
312
+ /**
313
+ * Names of React types that represent renderable UI or component references.
314
+ * Props typed as any of these are stable references (JSX elements, component
315
+ * constructors, render-prop functions) and do NOT benefit from deep value
316
+ * comparison — flagging them would produce false positives.
317
+ */
318
+ const REACT_RENDER_TYPE_NAMES = new Set([
319
+ 'ReactNode',
320
+ 'ReactChild',
321
+ 'ReactElement',
322
+ 'ReactPortal',
323
+ 'ReactFragment',
324
+ // JSX.Element resolves to a global symbol named 'Element' in the JSX namespace
325
+ 'Element',
326
+ 'Component',
327
+ 'PureComponent',
328
+ 'ComponentClass',
329
+ 'FunctionComponent',
330
+ 'FC',
331
+ 'ComponentType',
332
+ 'ExoticComponent',
333
+ 'NamedExoticComponent',
334
+ 'MemoExoticComponent',
335
+ 'ForwardRefExoticComponent',
336
+ 'LazyExoticComponent',
337
+ 'JSXElementConstructor',
338
+ 'RefObject',
339
+ 'RefCallback',
340
+ 'Ref',
341
+ ]);
342
+ /**
343
+ * Returns true when `sym` is declared inside a `.d.ts` file whose path
344
+ * contains "react" — indicating it originates from @types/react rather than
345
+ * from user code that happens to use the same name.
346
+ *
347
+ * Also returns true when the symbol has NO declarations at all: this happens
348
+ * when TypeScript resolves the type as `any` because the source module (e.g.
349
+ * @types/react) is not included in the tsconfig `types` array. A symbol with
350
+ * no declarations cannot be a user-defined type, so treating it as "from
351
+ * React" is safe — it avoids flagging props that would otherwise be excluded
352
+ * once the types are properly wired.
353
+ */
354
+ function isSymbolFromReactDeclarationFile(sym) {
355
+ const declarations = sym.declarations;
356
+ // No declarations ⟹ the type was resolved from an unresolvable external
357
+ // module (common when @types/react is absent from the tsconfig types list).
358
+ // User-defined types always have at least one declaration.
359
+ if (!declarations || declarations.length === 0)
360
+ return true;
361
+ return declarations.some((decl) => {
362
+ const fileName = decl.getSourceFile?.()?.fileName ?? '';
363
+ return fileName.endsWith('.d.ts') && /react/i.test(fileName);
364
+ });
365
+ }
366
+ /**
367
+ * Returns true when `type` is a React render-related type that should be
368
+ * excluded from the "complex prop" check. Detects by matching the type's
369
+ * alias symbol or own symbol name against a known React-type allowlist AND
370
+ * verifying the matched symbol's declaration originates from a React `.d.ts`
371
+ * file to avoid false negatives for user types that coincidentally share the
372
+ * same name.
373
+ */
374
+ function isReactRenderType(type) {
375
+ // Check the aliasSymbol first (covers type aliases like ReactNode, FC, etc.)
376
+ const aliasSymbol = type
377
+ .aliasSymbol;
378
+ if (aliasSymbol) {
379
+ const name = aliasSymbol.escapedName;
380
+ if (REACT_RENDER_TYPE_NAMES.has(name) &&
381
+ isSymbolFromReactDeclarationFile(aliasSymbol)) {
382
+ return true;
383
+ }
384
+ }
385
+ // Check the type's own symbol (covers interfaces / classes like ReactElement,
386
+ // ComponentClass, ForwardRefExoticComponent, etc.)
387
+ const sym = type.symbol;
388
+ if (sym) {
389
+ const name = sym.escapedName;
390
+ if (REACT_RENDER_TYPE_NAMES.has(name) &&
391
+ isSymbolFromReactDeclarationFile(sym)) {
392
+ return true;
393
+ }
394
+ }
395
+ return false;
396
+ }
299
397
  function isComplexType(ts, type, checker) {
300
398
  return isComplexTypeInternal(ts, type, checker, new Set());
301
399
  }
@@ -303,6 +401,13 @@ function isComplexTypeInternal(ts, type, checker, visited) {
303
401
  if (visited.has(type))
304
402
  return false;
305
403
  visited.add(type);
404
+ // Exclude React render-related types before any structural checks.
405
+ // This must come early so that ReactElement (an object type) and ReactNode
406
+ // (a union containing ReactElement) are both short-circuited here rather
407
+ // than being caught by the isObjectType / checkUnionType branches below.
408
+ if (isReactRenderType(type)) {
409
+ return false;
410
+ }
306
411
  const flags = type.flags ?? 0;
307
412
  if (isUnionType(ts, flags)) {
308
413
  return checkUnionType(ts, type, checker, visited);
@@ -389,13 +494,72 @@ function extractAnnotationType(propDeclaration) {
389
494
  }
390
495
  return undefined;
391
496
  }
392
- function shouldTreatAnyAsComplex(prop, propType, ts, treatAnyAsComplex, parentTypeFlags) {
497
+ /**
498
+ * Returns true when an annotation TypeNode (the explicit type written in
499
+ * source, e.g. `React.ComponentType` or `React.ReactNode | null`) resolves to
500
+ * a React render type that must be excluded from the complex-prop check.
501
+ *
502
+ * This path is taken when the prop type resolves to `any` (common when
503
+ * @types/react is not listed in the tsconfig `types` array) but the
504
+ * annotation still carries the original alias name via the TypeScript
505
+ * type-checker.
506
+ *
507
+ * For union annotation nodes (e.g. `ReactNode | null`), all non-null /
508
+ * non-undefined members must themselves be React render types for the whole
509
+ * union to be considered a React render type.
510
+ */
511
+ function isAnnotationReactRenderType(annotationType, checker, ts) {
512
+ try {
513
+ const tsModule = ts;
514
+ // Handle union annotation nodes like `React.ReactNode | null`.
515
+ if (tsModule.isUnionTypeNode?.(annotationType)) {
516
+ const nonNullishMembers = annotationType.types.filter((member) => {
517
+ // Nullish keywords appearing directly (older TypeScript AST forms)
518
+ if (member.kind === tsModule.SyntaxKind.NullKeyword ||
519
+ member.kind === tsModule.SyntaxKind.UndefinedKeyword ||
520
+ member.kind === tsModule.SyntaxKind.VoidKeyword) {
521
+ return false;
522
+ }
523
+ // `null` and `undefined` appear as LiteralTypeNode wrapping the
524
+ // corresponding keyword in modern TypeScript AST.
525
+ if (tsModule.isLiteralTypeNode?.(member)) {
526
+ const lit = member.literal;
527
+ if (lit.kind === tsModule.SyntaxKind.NullKeyword ||
528
+ lit.kind === tsModule.SyntaxKind.UndefinedKeyword) {
529
+ return false;
530
+ }
531
+ }
532
+ return true;
533
+ });
534
+ // If every non-nullish member is a React render type, the union is too.
535
+ return (nonNullishMembers.length > 0 &&
536
+ nonNullishMembers.every((member) => isAnnotationReactRenderType(member, checker, ts)));
537
+ }
538
+ // For plain TypeReference nodes (the common case), resolve via the checker.
539
+ const resolvedType = checker.getTypeFromTypeNode?.(annotationType);
540
+ if (!resolvedType)
541
+ return false;
542
+ return isReactRenderType(resolvedType);
543
+ }
544
+ catch {
545
+ return false;
546
+ }
547
+ }
548
+ function shouldTreatAnyAsComplex(prop, propType, ts, treatAnyAsComplex, parentTypeFlags, checker) {
393
549
  if (!(propType.flags & ts.TypeFlags.Any))
394
550
  return false;
395
551
  if (treatAnyAsComplex)
396
552
  return true;
397
553
  const propDeclaration = extractPropertyDeclaration(prop);
398
554
  const annotationType = extractAnnotationType(propDeclaration);
555
+ // When the annotation resolves to a React render type, the prop is a
556
+ // stable component/render-prop reference that does not benefit from deep
557
+ // value comparison — exclude it regardless of the any-as-complex heuristic.
558
+ if (annotationType &&
559
+ checker &&
560
+ isAnnotationReactRenderType(annotationType, checker, ts)) {
561
+ return false;
562
+ }
399
563
  return ((annotationType && annotationType.kind !== ts.SyntaxKind.AnyKeyword) ||
400
564
  (!annotationType && Boolean(parentTypeFlags & ts.TypeFlags.Object)));
401
565
  }
@@ -404,13 +568,13 @@ function isPropertyComplex(prop, checker, tsNode, ts, treatAnyAsComplex, parentT
404
568
  if (isComplexType(ts, propType, checker)) {
405
569
  return true;
406
570
  }
407
- return shouldTreatAnyAsComplex(prop, propType, ts, treatAnyAsComplex, parentTypeFlags);
571
+ return shouldTreatAnyAsComplex(prop, propType, ts, treatAnyAsComplex, parentTypeFlags, checker);
408
572
  }
409
573
  function getComplexPropertiesFromType(type, checker, tsNode, ts, treatAnyAsComplex = false, parentTypeFlags = 0) {
410
574
  const properties = checker.getPropertiesOfType(type);
411
575
  const complexProps = [];
412
576
  for (const prop of properties) {
413
- if (prop.name === 'children')
577
+ if (isReservedReactPropName(prop.name))
414
578
  continue;
415
579
  if (isPropertyComplex(prop, checker, tsNode, ts, treatAnyAsComplex, parentTypeFlags)) {
416
580
  complexProps.push(prop.name);