@blumintinc/eslint-plugin-blumint 1.15.0 → 1.16.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/README.md +28 -5
- package/lib/index.js +7 -1
- package/lib/rules/consistent-callback-naming.js +26 -0
- package/lib/rules/dynamic-https-errors.js +5 -26
- package/lib/rules/enforce-assert-safe-object-key.js +29 -0
- package/lib/rules/enforce-boolean-naming-prefixes.d.ts +1 -0
- package/lib/rules/enforce-boolean-naming-prefixes.js +86 -119
- package/lib/rules/enforce-dynamic-imports.d.ts +5 -1
- package/lib/rules/enforce-dynamic-imports.js +90 -19
- package/lib/rules/enforce-memoize-async.js +1 -4
- package/lib/rules/enforce-mui-rounded-icons.js +42 -1
- package/lib/rules/enforce-verb-noun-naming.js +3 -0
- package/lib/rules/global-const-style.js +9 -0
- package/lib/rules/logical-top-to-bottom-grouping.js +80 -2
- package/lib/rules/memo-compare-deeply-complex-props.js +167 -3
- package/lib/rules/memo-nested-react-components.js +143 -8
- package/lib/rules/no-array-length-in-deps.js +74 -3
- package/lib/rules/no-circular-references.js +145 -482
- package/lib/rules/no-compositing-layer-props.js +31 -0
- package/lib/rules/no-entire-object-hook-deps.js +132 -97
- package/lib/rules/no-explicit-return-type.js +6 -0
- package/lib/rules/no-hungarian.js +119 -24
- package/lib/rules/no-margin-properties.js +7 -38
- package/lib/rules/no-unnecessary-verb-suffix.js +79 -0
- package/lib/rules/no-unused-props.js +215 -37
- package/lib/rules/no-useless-fragment.js +10 -2
- package/lib/rules/parallelize-async-operations.js +1 -3
- package/lib/rules/prefer-type-alias-over-typeof-constant.js +73 -11
- package/lib/rules/prefer-use-deep-compare-memo.js +8 -14
- package/lib/rules/react-memoize-literals.js +87 -1
- package/lib/rules/require-memo.js +8 -0
- package/lib/rules/require-migration-script-metadata.d.ts +9 -0
- package/lib/rules/require-migration-script-metadata.js +206 -0
- package/lib/rules/warn-https-error-message-user-friendly.d.ts +1 -0
- package/lib/rules/warn-https-error-message-user-friendly.js +239 -0
- package/lib/utils/ASTHelpers.d.ts +15 -0
- package/lib/utils/ASTHelpers.js +48 -0
- package/package.json +7 -6
- package/release-manifest.json +196 -0
- package/lib/rules/prefer-memoized-props.d.ts +0 -3
- package/lib/rules/prefer-memoized-props.js +0 -445
- package/lib/rules/prefer-nullish-coalescing-override.d.ts +0 -7
- package/lib/rules/prefer-nullish-coalescing-override.js +0 -188
- package/lib/rules/require-usememo-object-literals.d.ts +0 -4
- package/lib/rules/require-usememo-object-literals.js +0 -64
|
@@ -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
|
-
|
|
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
|
|
577
|
+
if (isReservedReactPropName(prop.name))
|
|
414
578
|
continue;
|
|
415
579
|
if (isPropertyComplex(prop, checker, tsNode, ts, treatAnyAsComplex, parentTypeFlags)) {
|
|
416
580
|
complexProps.push(prop.name);
|
|
@@ -252,18 +252,134 @@ const getVariableName = (node) => {
|
|
|
252
252
|
}
|
|
253
253
|
return null;
|
|
254
254
|
};
|
|
255
|
-
const
|
|
255
|
+
const isEnclosingFunction = (node) => {
|
|
256
|
+
return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
257
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
258
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration);
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Nearest ancestor function whose body directly contains `node`. Climbs from
|
|
262
|
+
* the node's parent so a function node itself is not treated as its own
|
|
263
|
+
* enclosing function.
|
|
264
|
+
*/
|
|
265
|
+
const findEnclosingFunction = (node) => {
|
|
256
266
|
let parent = node.parent;
|
|
257
267
|
while (parent) {
|
|
258
|
-
if (parent
|
|
259
|
-
parent
|
|
260
|
-
parent.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
261
|
-
return true;
|
|
268
|
+
if (isEnclosingFunction(parent)) {
|
|
269
|
+
return parent;
|
|
262
270
|
}
|
|
263
271
|
parent = parent.parent;
|
|
264
272
|
}
|
|
273
|
+
return null;
|
|
274
|
+
};
|
|
275
|
+
const isInsideFunction = (node) => {
|
|
276
|
+
return findEnclosingFunction(node) !== null;
|
|
277
|
+
};
|
|
278
|
+
const isPascalCaseName = (name) => /^[A-Z]/.test(name);
|
|
279
|
+
/**
|
|
280
|
+
* A return value is a *component* (vs. rendered output) when it is a
|
|
281
|
+
* memo()/forwardRef() call, a PascalCase identifier (returned component
|
|
282
|
+
* reference), or an inline function that itself creates a component. Such a
|
|
283
|
+
* value identifies the surrounding function as an HOC factory rather than a
|
|
284
|
+
* render body.
|
|
285
|
+
*/
|
|
286
|
+
const returnExpressionIsComponent = (expression, reactImports) => {
|
|
287
|
+
if (!expression) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
const unwrapped = unwrapExpression(expression);
|
|
291
|
+
if (unwrapped.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
292
|
+
if (isMemoCall(unwrapped, reactImports) ||
|
|
293
|
+
isForwardRefCall(unwrapped, reactImports)) {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
if (unwrapped.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
299
|
+
return isPascalCaseName(unwrapped.name);
|
|
300
|
+
}
|
|
301
|
+
if (isFunctionExpression(unwrapped)) {
|
|
302
|
+
return Boolean(functionCreatesComponent(unwrapped, reactImports));
|
|
303
|
+
}
|
|
265
304
|
return false;
|
|
266
305
|
};
|
|
306
|
+
const returnExpressionIsJsx = (expression) => {
|
|
307
|
+
if (!expression) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
const unwrapped = unwrapExpression(expression);
|
|
311
|
+
return (unwrapped.type === utils_1.AST_NODE_TYPES.JSXElement ||
|
|
312
|
+
unwrapped.type === utils_1.AST_NODE_TYPES.JSXFragment);
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Direct return-statement arguments of a function, traversing control flow
|
|
316
|
+
* (if/switch/try/loops) but NOT descending into nested function definitions—
|
|
317
|
+
* returns inside nested functions belong to those functions, not this one.
|
|
318
|
+
*/
|
|
319
|
+
const collectDirectReturnExpressions = (fn) => {
|
|
320
|
+
if (fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
321
|
+
return [fn.body];
|
|
322
|
+
}
|
|
323
|
+
const returns = [];
|
|
324
|
+
const visitStatement = (statement) => {
|
|
325
|
+
switch (statement.type) {
|
|
326
|
+
case utils_1.AST_NODE_TYPES.ReturnStatement:
|
|
327
|
+
returns.push(statement.argument);
|
|
328
|
+
break;
|
|
329
|
+
case utils_1.AST_NODE_TYPES.BlockStatement:
|
|
330
|
+
statement.body.forEach(visitStatement);
|
|
331
|
+
break;
|
|
332
|
+
case utils_1.AST_NODE_TYPES.IfStatement:
|
|
333
|
+
visitStatement(statement.consequent);
|
|
334
|
+
if (statement.alternate) {
|
|
335
|
+
visitStatement(statement.alternate);
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
338
|
+
case utils_1.AST_NODE_TYPES.SwitchStatement:
|
|
339
|
+
for (const switchCase of statement.cases) {
|
|
340
|
+
switchCase.consequent.forEach(visitStatement);
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
case utils_1.AST_NODE_TYPES.ForStatement:
|
|
344
|
+
case utils_1.AST_NODE_TYPES.ForInStatement:
|
|
345
|
+
case utils_1.AST_NODE_TYPES.ForOfStatement:
|
|
346
|
+
case utils_1.AST_NODE_TYPES.WhileStatement:
|
|
347
|
+
case utils_1.AST_NODE_TYPES.DoWhileStatement:
|
|
348
|
+
visitStatement(statement.body);
|
|
349
|
+
break;
|
|
350
|
+
case utils_1.AST_NODE_TYPES.TryStatement:
|
|
351
|
+
visitStatement(statement.block);
|
|
352
|
+
if (statement.handler) {
|
|
353
|
+
visitStatement(statement.handler.body);
|
|
354
|
+
}
|
|
355
|
+
if (statement.finalizer) {
|
|
356
|
+
visitStatement(statement.finalizer);
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
default:
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
fn.body.body.forEach(visitStatement);
|
|
364
|
+
return returns;
|
|
365
|
+
};
|
|
366
|
+
/**
|
|
367
|
+
* True when the nearest enclosing function is an HOC factory—it returns a
|
|
368
|
+
* component (memo/forwardRef/component reference) and never returns JSX. Such
|
|
369
|
+
* a function runs once per call, so components defined inside it have stable
|
|
370
|
+
* identities and must NOT be flagged. A function that returns JSX is a render
|
|
371
|
+
* body, where nested components DO remount and remain flagged.
|
|
372
|
+
*/
|
|
373
|
+
const isInsideHocFactory = (node, reactImports) => {
|
|
374
|
+
const enclosing = findEnclosingFunction(node);
|
|
375
|
+
if (!enclosing) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
const returns = collectDirectReturnExpressions(enclosing);
|
|
379
|
+
const returnsComponent = returns.some((expression) => returnExpressionIsComponent(expression, reactImports));
|
|
380
|
+
const returnsJsx = returns.some((expression) => returnExpressionIsJsx(expression));
|
|
381
|
+
return returnsComponent && !returnsJsx;
|
|
382
|
+
};
|
|
267
383
|
exports.memoNestedReactComponents = (0, createRule_1.createRule)({
|
|
268
384
|
name: 'memo-nested-react-components',
|
|
269
385
|
meta: {
|
|
@@ -343,7 +459,18 @@ See: https://react.dev/learn/your-first-component#nesting-and-organizing-compone
|
|
|
343
459
|
return;
|
|
344
460
|
}
|
|
345
461
|
}
|
|
346
|
-
const
|
|
462
|
+
const variableName = getVariableName(node);
|
|
463
|
+
// A non-PascalCase binding (e.g. renderHit) is a render callback used
|
|
464
|
+
// with a render={...} prop, not a component—skip it.
|
|
465
|
+
if (variableName && !isPascalCaseName(variableName)) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
// Inside an HOC factory the binding has a stable identity, so it does
|
|
469
|
+
// not remount on re-render and must not be flagged.
|
|
470
|
+
if (isInsideHocFactory(node, reactImports)) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const componentName = variableName ??
|
|
347
474
|
(isFunctionExpression(callback) &&
|
|
348
475
|
callback.id?.type === utils_1.AST_NODE_TYPES.Identifier
|
|
349
476
|
? callback.id.name
|
|
@@ -356,10 +483,14 @@ See: https://react.dev/learn/your-first-component#nesting-and-organizing-compone
|
|
|
356
483
|
if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
357
484
|
return;
|
|
358
485
|
// Only check if name starts with uppercase (convention for components)
|
|
359
|
-
if (
|
|
486
|
+
if (!isPascalCaseName(node.id.name))
|
|
360
487
|
return;
|
|
361
488
|
if (!isInsideFunction(node))
|
|
362
489
|
return;
|
|
490
|
+
// Inside an HOC factory the component has a stable identity (the factory
|
|
491
|
+
// runs once per call), so it does not remount and must not be flagged.
|
|
492
|
+
if (isInsideHocFactory(node, reactImports))
|
|
493
|
+
return;
|
|
363
494
|
// Skip if it's already a hook call (handled by CallExpression visitor)
|
|
364
495
|
if (node.init.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
365
496
|
const hook = isHookCall(node.init.callee);
|
|
@@ -375,10 +506,14 @@ See: https://react.dev/learn/your-first-component#nesting-and-organizing-compone
|
|
|
375
506
|
reportNestedComponentViolation(node, node.id.name, 'a render body');
|
|
376
507
|
},
|
|
377
508
|
FunctionDeclaration(node) {
|
|
378
|
-
if (!node.id ||
|
|
509
|
+
if (!node.id || !isPascalCaseName(node.id.name))
|
|
379
510
|
return;
|
|
380
511
|
if (!isInsideFunction(node))
|
|
381
512
|
return;
|
|
513
|
+
// Inside an HOC factory the component has a stable identity (the factory
|
|
514
|
+
// runs once per call), so it does not remount and must not be flagged.
|
|
515
|
+
if (isInsideHocFactory(node, reactImports))
|
|
516
|
+
return;
|
|
382
517
|
const componentMatch = functionCreatesComponent(node, reactImports);
|
|
383
518
|
if (!componentMatch)
|
|
384
519
|
return;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.noArrayLengthInDeps = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
6
7
|
// React hooks to check
|
|
7
8
|
const HOOK_NAMES = new Set(['useEffect', 'useCallback', 'useMemo']);
|
|
8
9
|
const DEFAULT_HASH_IMPORT = {
|
|
@@ -58,7 +59,7 @@ function getLastPropertyName(expr) {
|
|
|
58
59
|
return null;
|
|
59
60
|
}
|
|
60
61
|
function generateUniqueName(base, taken) {
|
|
61
|
-
|
|
62
|
+
const candidate = `${base}Hash`;
|
|
62
63
|
if (!taken.has(candidate))
|
|
63
64
|
return candidate;
|
|
64
65
|
let i = 2;
|
|
@@ -120,6 +121,69 @@ function isUseMemoImported(sourceCode) {
|
|
|
120
121
|
}
|
|
121
122
|
return false;
|
|
122
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Hook callback = first function-typed argument (the effect/factory/memo fn).
|
|
126
|
+
* Deps array is the LAST argument; the callback precedes it.
|
|
127
|
+
*/
|
|
128
|
+
function getHookCallback(node) {
|
|
129
|
+
for (const arg of node.arguments) {
|
|
130
|
+
if (arg.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
131
|
+
arg.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
132
|
+
return arg;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
function rangeContains(outer, inner) {
|
|
138
|
+
return inner.range[0] >= outer.range[0] && inner.range[1] <= outer.range[1];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* True when `identifier` is the object of a non-computed `.length` access,
|
|
142
|
+
* i.e. the reference reads only the array's length, not its contents.
|
|
143
|
+
*/
|
|
144
|
+
function isLengthAccessOf(identifier) {
|
|
145
|
+
const parent = identifier.parent;
|
|
146
|
+
return (!!parent &&
|
|
147
|
+
parent.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
148
|
+
!parent.computed &&
|
|
149
|
+
parent.object === identifier &&
|
|
150
|
+
parent.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
151
|
+
parent.property.name === 'length');
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Decide whether a `<array>.length` dependency is safe to keep (suppress the
|
|
155
|
+
* report) by inspecting how the hook callback body uses the array binding.
|
|
156
|
+
*
|
|
157
|
+
* Safe (suppress) only when the array is referenced at least once inside the
|
|
158
|
+
* callback body AND every such reference is the object of a `.length` access —
|
|
159
|
+
* then depending on `.length` correctly avoids reruns on content changes.
|
|
160
|
+
*
|
|
161
|
+
* Returns false (keep reporting) for any non-`.length` use (element access,
|
|
162
|
+
* spread, iteration/method calls, bare reference, passed as an argument), when
|
|
163
|
+
* the array is never referenced in the body, and whenever the base cannot be
|
|
164
|
+
* confidently resolved (complex member-chain bases) — never hide a real bug.
|
|
165
|
+
*/
|
|
166
|
+
function isLengthOnlyUsage(context, callback, baseExpr) {
|
|
167
|
+
// Only resolvable for a bare identifier base (e.g. `items` in `items.length`).
|
|
168
|
+
// Member-chain bases (`a.b`, `data?.items`) have no single binding to track.
|
|
169
|
+
if (baseExpr.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
// Resolve the binding the DEP refers to (from the deps-array identifier's
|
|
173
|
+
// scope), so a binding shadowed inside the callback body is not mistaken for
|
|
174
|
+
// it — the outer binding then has zero body references and we keep reporting.
|
|
175
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, baseExpr);
|
|
176
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, baseExpr.name);
|
|
177
|
+
if (!variable) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const bodyReferences = variable.references.filter((ref) => rangeContains(callback.body, ref.identifier));
|
|
181
|
+
// No body usage → not the content-vs-length bug, but keep current behavior.
|
|
182
|
+
if (bodyReferences.length === 0) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return bodyReferences.every((ref) => isLengthAccessOf(ref.identifier));
|
|
186
|
+
}
|
|
123
187
|
function isStableHashImported(sourceCode, hashSource, hashImportName) {
|
|
124
188
|
const program = sourceCode.ast;
|
|
125
189
|
for (const node of program.body) {
|
|
@@ -188,15 +252,22 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
188
252
|
return;
|
|
189
253
|
// Collect .length deps
|
|
190
254
|
const lengthDeps = [];
|
|
255
|
+
const callback = getHookCallback(node);
|
|
191
256
|
for (const el of depsArg.elements) {
|
|
192
257
|
if (!el)
|
|
193
258
|
continue;
|
|
194
259
|
if (el.type === utils_1.AST_NODE_TYPES.SpreadElement)
|
|
195
260
|
continue;
|
|
196
261
|
const member = getLengthMember(el);
|
|
197
|
-
if (member)
|
|
198
|
-
|
|
262
|
+
if (!member)
|
|
263
|
+
continue;
|
|
264
|
+
// Suppress when the callback body reads only `<array>.length`, never
|
|
265
|
+
// the array's contents — then `.length` is the correct dependency.
|
|
266
|
+
if (callback &&
|
|
267
|
+
isLengthOnlyUsage(context, callback, getBaseExpression(member))) {
|
|
268
|
+
continue;
|
|
199
269
|
}
|
|
270
|
+
lengthDeps.push({ element: el, member });
|
|
200
271
|
}
|
|
201
272
|
if (lengthDeps.length === 0)
|
|
202
273
|
return;
|