react-doctor 0.0.41 → 0.0.44
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 +169 -41
- package/bin/react-doctor.js +13 -0
- package/dist/{process-browser-diagnostics-DpaZeYLI.js → browser-BOxs7MrK.js} +39 -45
- package/dist/{diagnose-browser-B17IqMa3.d.ts → browser-Dcq3yn-p.d.ts} +32 -17
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +2 -3
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +1470 -517
- package/dist/index.d.ts +119 -12
- package/dist/index.js +1178 -363
- package/dist/react-doctor-plugin.js +2339 -169
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +2 -3
- package/package.json +35 -13
- package/dist/cli.js.map +0 -1
- package/dist/diagnose-browser-B17IqMa3.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/process-browser-diagnostics-DpaZeYLI.js.map +0 -1
- package/dist/react-doctor-plugin.d.ts.map +0 -1
- package/dist/react-doctor-plugin.js.map +0 -1
|
@@ -1,12 +1,4 @@
|
|
|
1
1
|
//#region src/plugin/constants.ts
|
|
2
|
-
const GIANT_COMPONENT_LINE_THRESHOLD = 300;
|
|
3
|
-
const CASCADING_SET_STATE_THRESHOLD = 3;
|
|
4
|
-
const RELATED_USE_STATE_THRESHOLD = 5;
|
|
5
|
-
const DEEP_NESTING_THRESHOLD = 3;
|
|
6
|
-
const DUPLICATE_STORAGE_READ_THRESHOLD = 2;
|
|
7
|
-
const SEQUENTIAL_AWAIT_THRESHOLD = 3;
|
|
8
|
-
const SECRET_MIN_LENGTH_CHARS = 8;
|
|
9
|
-
const AUTH_CHECK_LOOKAHEAD_STATEMENTS = 3;
|
|
10
2
|
const LAYOUT_PROPERTIES = new Set([
|
|
11
3
|
"width",
|
|
12
4
|
"height",
|
|
@@ -52,11 +44,20 @@ const HEAVY_LIBRARIES = new Set([
|
|
|
52
44
|
"@toast-ui/editor",
|
|
53
45
|
"draft-js"
|
|
54
46
|
]);
|
|
55
|
-
const FETCH_CALLEE_NAMES = new Set([
|
|
47
|
+
const FETCH_CALLEE_NAMES = new Set([
|
|
48
|
+
"fetch",
|
|
49
|
+
"ky",
|
|
50
|
+
"got",
|
|
51
|
+
"wretch",
|
|
52
|
+
"ofetch"
|
|
53
|
+
]);
|
|
56
54
|
const FETCH_MEMBER_OBJECTS = new Set([
|
|
57
55
|
"axios",
|
|
58
56
|
"ky",
|
|
59
|
-
"got"
|
|
57
|
+
"got",
|
|
58
|
+
"ofetch",
|
|
59
|
+
"wretch",
|
|
60
|
+
"request"
|
|
60
61
|
]);
|
|
61
62
|
const INDEX_PARAMETER_NAMES = new Set([
|
|
62
63
|
"index",
|
|
@@ -169,6 +170,7 @@ const SECRET_FALSE_POSITIVE_SUFFIXES = new Set([
|
|
|
169
170
|
"schema",
|
|
170
171
|
"constant"
|
|
171
172
|
]);
|
|
173
|
+
const LOADING_STATE_PATTERN = /^(?:isLoading|isPending)$/;
|
|
172
174
|
const TANSTACK_ROUTE_FILE_PATTERN = /\/routes\//;
|
|
173
175
|
const TANSTACK_ROOT_ROUTE_FILE_PATTERN = /__root\.(tsx?|jsx?)$/;
|
|
174
176
|
const TANSTACK_ROUTE_PROPERTY_ORDER = [
|
|
@@ -204,7 +206,6 @@ const TANSTACK_MIDDLEWARE_METHOD_ORDER = [
|
|
|
204
206
|
];
|
|
205
207
|
const TANSTACK_REDIRECT_FUNCTIONS = new Set(["redirect", "notFound"]);
|
|
206
208
|
const TANSTACK_SERVER_FN_FILE_PATTERN = /\.functions(\.[jt]sx?)?$/;
|
|
207
|
-
const SEQUENTIAL_AWAIT_THRESHOLD_FOR_LOADER = 2;
|
|
208
209
|
const TANSTACK_QUERY_HOOKS = new Set([
|
|
209
210
|
"useQuery",
|
|
210
211
|
"useInfiniteQuery",
|
|
@@ -212,13 +213,29 @@ const TANSTACK_QUERY_HOOKS = new Set([
|
|
|
212
213
|
"useSuspenseInfiniteQuery"
|
|
213
214
|
]);
|
|
214
215
|
const TANSTACK_MUTATION_HOOKS = new Set(["useMutation"]);
|
|
215
|
-
const
|
|
216
|
+
const QUERY_CACHE_UPDATE_METHODS = new Set([
|
|
217
|
+
"invalidateQueries",
|
|
218
|
+
"setQueryData",
|
|
219
|
+
"setQueriesData",
|
|
220
|
+
"resetQueries",
|
|
221
|
+
"refetchQueries",
|
|
222
|
+
"removeQueries",
|
|
223
|
+
"cancelQueries",
|
|
224
|
+
"clear"
|
|
225
|
+
]);
|
|
216
226
|
const STABLE_HOOK_WRAPPERS = new Set([
|
|
217
227
|
"useState",
|
|
218
228
|
"useMemo",
|
|
219
229
|
"useRef"
|
|
220
230
|
]);
|
|
221
231
|
const SCRIPT_LOADING_ATTRIBUTES = new Set(["defer", "async"]);
|
|
232
|
+
const GENERIC_EVENT_SUFFIXES = new Set([
|
|
233
|
+
"Click",
|
|
234
|
+
"Change",
|
|
235
|
+
"Input",
|
|
236
|
+
"Blur",
|
|
237
|
+
"Focus"
|
|
238
|
+
]);
|
|
222
239
|
const TRIVIAL_INITIALIZER_NAMES = new Set([
|
|
223
240
|
"Boolean",
|
|
224
241
|
"String",
|
|
@@ -296,11 +313,9 @@ const CHAINABLE_ITERATION_METHODS = new Set([
|
|
|
296
313
|
"forEach",
|
|
297
314
|
"flatMap"
|
|
298
315
|
]);
|
|
299
|
-
const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
|
|
300
|
-
const LARGE_BLUR_THRESHOLD_PX = 10;
|
|
316
|
+
const STORAGE_OBJECTS$1 = new Set(["localStorage", "sessionStorage"]);
|
|
301
317
|
const BLUR_VALUE_PATTERN = /blur\((\d+(?:\.\d+)?)px\)/;
|
|
302
318
|
const ANIMATION_CALLBACK_NAMES = new Set(["requestAnimationFrame", "setInterval"]);
|
|
303
|
-
const RAW_TEXT_PREVIEW_MAX_CHARS = 30;
|
|
304
319
|
const REACT_NATIVE_TEXT_COMPONENTS = new Set([
|
|
305
320
|
"Text",
|
|
306
321
|
"TextInput",
|
|
@@ -369,23 +384,12 @@ const BOUNCE_ANIMATION_NAMES = new Set([
|
|
|
369
384
|
"jiggle",
|
|
370
385
|
"spring"
|
|
371
386
|
]);
|
|
372
|
-
const Z_INDEX_ABSURD_THRESHOLD = 100;
|
|
373
|
-
const INLINE_STYLE_PROPERTY_THRESHOLD = 8;
|
|
374
|
-
const SIDE_TAB_BORDER_WIDTH_WITHOUT_RADIUS_PX = 3;
|
|
375
|
-
const SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX = 1;
|
|
376
|
-
const SIDE_TAB_TAILWIND_WIDTH_WITHOUT_RADIUS = 4;
|
|
377
|
-
const DARK_GLOW_BLUR_THRESHOLD_PX = 4;
|
|
378
|
-
const DARK_BACKGROUND_CHANNEL_MAX = 35;
|
|
379
|
-
const COLOR_CHROMA_THRESHOLD = 30;
|
|
380
|
-
const TINY_TEXT_THRESHOLD_PX = 12;
|
|
381
|
-
const WIDE_TRACKING_THRESHOLD_EM = .05;
|
|
382
387
|
const LONG_TRANSITION_DURATION_THRESHOLD_MS = 1e3;
|
|
383
|
-
|
|
384
388
|
//#endregion
|
|
385
389
|
//#region src/plugin/helpers.ts
|
|
386
390
|
const walkAst = (node, visitor) => {
|
|
387
391
|
if (!node || typeof node !== "object") return;
|
|
388
|
-
visitor(node);
|
|
392
|
+
if (visitor(node) === false) return;
|
|
389
393
|
for (const key of Object.keys(node)) {
|
|
390
394
|
if (key === "parent") continue;
|
|
391
395
|
const child = node[key];
|
|
@@ -487,12 +491,13 @@ const isMutatingDbCall = (node) => {
|
|
|
487
491
|
const { property } = node.callee;
|
|
488
492
|
return property?.type === "Identifier" && MUTATION_METHOD_NAMES.has(property.name);
|
|
489
493
|
};
|
|
494
|
+
const isMutatingMethodProperty = (property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "method" && property.value?.type === "Literal" && typeof property.value.value === "string" && MUTATING_HTTP_METHODS.has(property.value.value.toUpperCase());
|
|
490
495
|
const isMutatingFetchCall = (node) => {
|
|
491
496
|
if (node.type !== "CallExpression") return false;
|
|
492
497
|
if (node.callee?.type !== "Identifier" || node.callee.name !== "fetch") return false;
|
|
493
498
|
const optionsArgument = node.arguments?.[1];
|
|
494
499
|
if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return false;
|
|
495
|
-
return optionsArgument.properties?.some(
|
|
500
|
+
return Boolean(optionsArgument.properties?.some(isMutatingMethodProperty));
|
|
496
501
|
};
|
|
497
502
|
const findSideEffect = (node) => {
|
|
498
503
|
let sideEffectDescription = null;
|
|
@@ -500,7 +505,7 @@ const findSideEffect = (node) => {
|
|
|
500
505
|
if (sideEffectDescription) return;
|
|
501
506
|
if (isCookiesOrHeadersCall(child, "cookies")) sideEffectDescription = `cookies().${child.callee.property.name}()`;
|
|
502
507
|
else if (isCookiesOrHeadersCall(child, "headers")) sideEffectDescription = `headers().${child.callee.property.name}()`;
|
|
503
|
-
else if (isMutatingFetchCall(child)) sideEffectDescription = `fetch() with method ${child.arguments[1].properties.find(
|
|
508
|
+
else if (isMutatingFetchCall(child)) sideEffectDescription = `fetch() with method ${child.arguments[1].properties.find(isMutatingMethodProperty).value.value}`;
|
|
504
509
|
else if (isMutatingDbCall(child)) {
|
|
505
510
|
const methodName = child.callee.property.name;
|
|
506
511
|
const objectName = child.callee.object?.type === "Identifier" ? child.callee.object.name : null;
|
|
@@ -509,21 +514,56 @@ const findSideEffect = (node) => {
|
|
|
509
514
|
});
|
|
510
515
|
return sideEffectDescription;
|
|
511
516
|
};
|
|
517
|
+
const collectPatternNames = (pattern, into) => {
|
|
518
|
+
if (!pattern) return;
|
|
519
|
+
if (pattern.type === "Identifier") {
|
|
520
|
+
into.add(pattern.name);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (pattern.type === "AssignmentPattern") {
|
|
524
|
+
collectPatternNames(pattern.left, into);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (pattern.type === "RestElement") {
|
|
528
|
+
collectPatternNames(pattern.argument, into);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (pattern.type === "ArrayPattern") {
|
|
532
|
+
for (const element of pattern.elements ?? []) collectPatternNames(element, into);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (pattern.type === "ObjectPattern") for (const property of pattern.properties ?? []) {
|
|
536
|
+
if (property.type === "RestElement") {
|
|
537
|
+
collectPatternNames(property.argument, into);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (property.type === "Property") collectPatternNames(property.value, into);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
512
543
|
const extractDestructuredPropNames = (params) => {
|
|
513
544
|
const propNames = /* @__PURE__ */ new Set();
|
|
514
|
-
for (const param of params)
|
|
515
|
-
for (const property of param.properties ?? []) if (property.type === "Property" && property.key?.type === "Identifier") propNames.add(property.key.name);
|
|
516
|
-
} else if (param.type === "Identifier") propNames.add(param.name);
|
|
545
|
+
for (const param of params) collectPatternNames(param, propNames);
|
|
517
546
|
return propNames;
|
|
518
547
|
};
|
|
519
|
-
|
|
520
548
|
//#endregion
|
|
521
549
|
//#region src/plugin/rules/architecture.ts
|
|
550
|
+
const noGenericHandlerNames = { create: (context) => ({ JSXAttribute(node) {
|
|
551
|
+
if (node.name?.type !== "JSXIdentifier" || !node.name.name.startsWith("on")) return;
|
|
552
|
+
if (!node.value || node.value.type !== "JSXExpressionContainer") return;
|
|
553
|
+
const eventSuffix = node.name.name.slice(2);
|
|
554
|
+
if (!GENERIC_EVENT_SUFFIXES.has(eventSuffix)) return;
|
|
555
|
+
const mirroredHandlerName = `handle${eventSuffix}`;
|
|
556
|
+
const expression = node.value.expression;
|
|
557
|
+
if (expression?.type === "Identifier" && expression.name === mirroredHandlerName) context.report({
|
|
558
|
+
node,
|
|
559
|
+
message: `Non-descriptive handler name "${expression.name}" — name should describe what it does, not when it runs`
|
|
560
|
+
});
|
|
561
|
+
} }) };
|
|
522
562
|
const noGiantComponent = { create: (context) => {
|
|
523
563
|
const reportOversizedComponent = (nameNode, componentName, bodyNode) => {
|
|
524
564
|
if (!bodyNode.loc) return;
|
|
525
565
|
const lineCount = bodyNode.loc.end.line - bodyNode.loc.start.line + 1;
|
|
526
|
-
if (lineCount >
|
|
566
|
+
if (lineCount > 300) context.report({
|
|
527
567
|
node: nameNode,
|
|
528
568
|
message: `Component "${componentName}" is ${lineCount} lines — consider breaking it into smaller focused components`
|
|
529
569
|
});
|
|
@@ -577,7 +617,193 @@ const noNestedComponentDefinition = { create: (context) => {
|
|
|
577
617
|
}
|
|
578
618
|
};
|
|
579
619
|
} };
|
|
580
|
-
|
|
620
|
+
const BOOLEAN_PROP_PREFIX_PATTERN = /^(?:is|has|should|can|show|hide|enable|disable|with)[A-Z]/;
|
|
621
|
+
const collectBooleanLikePropsFromBody = (componentBody, propsParamName) => {
|
|
622
|
+
const found = /* @__PURE__ */ new Set();
|
|
623
|
+
if (!componentBody) return found;
|
|
624
|
+
walkAst(componentBody, (child) => {
|
|
625
|
+
if (child.type !== "MemberExpression") return;
|
|
626
|
+
if (child.computed) return;
|
|
627
|
+
if (child.object?.type !== "Identifier") return;
|
|
628
|
+
if (child.object.name !== propsParamName) return;
|
|
629
|
+
if (child.property?.type !== "Identifier") return;
|
|
630
|
+
if (!BOOLEAN_PROP_PREFIX_PATTERN.test(child.property.name)) return;
|
|
631
|
+
found.add(child.property.name);
|
|
632
|
+
});
|
|
633
|
+
return found;
|
|
634
|
+
};
|
|
635
|
+
const noManyBooleanProps = { create: (context) => {
|
|
636
|
+
const reportIfMany = (booleanLikePropNames, componentName, reportNode) => {
|
|
637
|
+
if (booleanLikePropNames.length >= 4) context.report({
|
|
638
|
+
node: reportNode,
|
|
639
|
+
message: `Component "${componentName}" takes ${booleanLikePropNames.length} boolean-like props (${booleanLikePropNames.slice(0, 3).join(", ")}…) — consider compound components or explicit variants instead of stacking flags`
|
|
640
|
+
});
|
|
641
|
+
};
|
|
642
|
+
const checkComponent = (param, body, componentName, reportNode) => {
|
|
643
|
+
if (!param) return;
|
|
644
|
+
if (param.type === "ObjectPattern") {
|
|
645
|
+
const booleanLikePropNames = [];
|
|
646
|
+
for (const property of param.properties ?? []) {
|
|
647
|
+
if (property.type !== "Property") continue;
|
|
648
|
+
const keyName = property.key?.type === "Identifier" ? property.key.name : null;
|
|
649
|
+
if (!keyName) continue;
|
|
650
|
+
if (BOOLEAN_PROP_PREFIX_PATTERN.test(keyName)) booleanLikePropNames.push(keyName);
|
|
651
|
+
}
|
|
652
|
+
reportIfMany(booleanLikePropNames, componentName, reportNode);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (param.type === "Identifier") reportIfMany([...collectBooleanLikePropsFromBody(body, param.name)], componentName, reportNode);
|
|
656
|
+
};
|
|
657
|
+
return {
|
|
658
|
+
FunctionDeclaration(node) {
|
|
659
|
+
if (!isComponentDeclaration(node)) return;
|
|
660
|
+
checkComponent(node.params?.[0], node.body, node.id.name, node.id);
|
|
661
|
+
},
|
|
662
|
+
VariableDeclarator(node) {
|
|
663
|
+
if (!isComponentAssignment(node)) return;
|
|
664
|
+
checkComponent(node.init?.params?.[0], node.init?.body, node.id.name, node.id);
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
} };
|
|
668
|
+
const REACT_19_DEPRECATED_MESSAGES = {
|
|
669
|
+
forwardRef: "forwardRef is no longer needed on React 19+ — refs are regular props on function components; remove forwardRef and pass ref directly",
|
|
670
|
+
useContext: "useContext is superseded by `use()` on React 19+ — `use()` reads context conditionally inside hooks, branches, and loops; switch to `import { use } from 'react'`"
|
|
671
|
+
};
|
|
672
|
+
const noReact19DeprecatedApis = { create: (context) => {
|
|
673
|
+
const reactNamespaceBindings = /* @__PURE__ */ new Set();
|
|
674
|
+
return {
|
|
675
|
+
ImportDeclaration(node) {
|
|
676
|
+
if (node.source?.value !== "react") return;
|
|
677
|
+
for (const specifier of node.specifiers ?? []) {
|
|
678
|
+
if (specifier.type === "ImportSpecifier") {
|
|
679
|
+
const importedName = specifier.imported?.name;
|
|
680
|
+
if (!importedName) continue;
|
|
681
|
+
const message = REACT_19_DEPRECATED_MESSAGES[importedName];
|
|
682
|
+
if (message) context.report({
|
|
683
|
+
node: specifier,
|
|
684
|
+
message
|
|
685
|
+
});
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier") {
|
|
689
|
+
const localName = specifier.local?.name;
|
|
690
|
+
if (localName) reactNamespaceBindings.add(localName);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
MemberExpression(node) {
|
|
695
|
+
if (reactNamespaceBindings.size === 0) return;
|
|
696
|
+
if (node.computed) return;
|
|
697
|
+
if (node.object?.type !== "Identifier") return;
|
|
698
|
+
if (!reactNamespaceBindings.has(node.object.name)) return;
|
|
699
|
+
if (node.property?.type !== "Identifier") return;
|
|
700
|
+
const message = REACT_19_DEPRECATED_MESSAGES[node.property.name];
|
|
701
|
+
if (message) context.report({
|
|
702
|
+
node,
|
|
703
|
+
message
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
} };
|
|
708
|
+
const RENDER_PROP_PATTERN = /^render[A-Z]/;
|
|
709
|
+
const noRenderPropChildren = { create: (context) => ({ JSXOpeningElement(node) {
|
|
710
|
+
const renderPropAttrs = [];
|
|
711
|
+
for (const attr of node.attributes ?? []) {
|
|
712
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
713
|
+
if (attr.name?.type !== "JSXIdentifier") continue;
|
|
714
|
+
const name = attr.name.name;
|
|
715
|
+
if (!RENDER_PROP_PATTERN.test(name)) continue;
|
|
716
|
+
renderPropAttrs.push({
|
|
717
|
+
name,
|
|
718
|
+
node: attr
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
if (renderPropAttrs.length < 3) return;
|
|
722
|
+
const propList = renderPropAttrs.slice(0, 3).map((entry) => entry.name).join(", ");
|
|
723
|
+
context.report({
|
|
724
|
+
node: renderPropAttrs[0].node,
|
|
725
|
+
message: `${renderPropAttrs.length} render-prop slots on the same element (${propList}…) — collapse into compound subcomponents or \`children\` so consumers don't need to know about every customization point`
|
|
726
|
+
});
|
|
727
|
+
} }) };
|
|
728
|
+
const HOOK_OBJECTS_WITH_METHODS = new Map([
|
|
729
|
+
["useRouter", new Set([
|
|
730
|
+
"push",
|
|
731
|
+
"replace",
|
|
732
|
+
"back",
|
|
733
|
+
"forward",
|
|
734
|
+
"refresh",
|
|
735
|
+
"prefetch"
|
|
736
|
+
])],
|
|
737
|
+
["useNavigation", new Set([
|
|
738
|
+
"navigate",
|
|
739
|
+
"push",
|
|
740
|
+
"goBack",
|
|
741
|
+
"popToTop",
|
|
742
|
+
"reset",
|
|
743
|
+
"replace",
|
|
744
|
+
"dispatch"
|
|
745
|
+
])],
|
|
746
|
+
["useSearchParams", new Set([
|
|
747
|
+
"get",
|
|
748
|
+
"getAll",
|
|
749
|
+
"has",
|
|
750
|
+
"set"
|
|
751
|
+
])]
|
|
752
|
+
]);
|
|
753
|
+
const buildHookBindingMap = (componentBody) => {
|
|
754
|
+
const result = /* @__PURE__ */ new Map();
|
|
755
|
+
if (componentBody?.type !== "BlockStatement") return result;
|
|
756
|
+
for (const statement of componentBody.body ?? []) {
|
|
757
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
758
|
+
for (const declarator of statement.declarations ?? []) {
|
|
759
|
+
if (declarator.id?.type !== "Identifier") continue;
|
|
760
|
+
if (declarator.init?.type !== "CallExpression") continue;
|
|
761
|
+
const callee = declarator.init.callee;
|
|
762
|
+
if (callee?.type !== "Identifier") continue;
|
|
763
|
+
result.set(declarator.id.name, callee.name);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return result;
|
|
767
|
+
};
|
|
768
|
+
const reactCompilerDestructureMethod = { create: (context) => {
|
|
769
|
+
const hookBindingMapStack = [];
|
|
770
|
+
const isComponent = (node) => {
|
|
771
|
+
if (node.type === "FunctionDeclaration") return Boolean(node.id?.name && isUppercaseName(node.id.name));
|
|
772
|
+
if (node.type === "VariableDeclarator") return isComponentAssignment(node);
|
|
773
|
+
return false;
|
|
774
|
+
};
|
|
775
|
+
const enter = (node) => {
|
|
776
|
+
if (!isComponent(node)) return;
|
|
777
|
+
const body = node.type === "FunctionDeclaration" ? node.body : node.init?.body;
|
|
778
|
+
hookBindingMapStack.push(buildHookBindingMap(body));
|
|
779
|
+
};
|
|
780
|
+
const exit = (node) => {
|
|
781
|
+
if (isComponent(node)) hookBindingMapStack.pop();
|
|
782
|
+
};
|
|
783
|
+
return {
|
|
784
|
+
FunctionDeclaration: enter,
|
|
785
|
+
"FunctionDeclaration:exit": exit,
|
|
786
|
+
VariableDeclarator: enter,
|
|
787
|
+
"VariableDeclarator:exit": exit,
|
|
788
|
+
MemberExpression(node) {
|
|
789
|
+
if (hookBindingMapStack.length === 0) return;
|
|
790
|
+
if (node.computed) return;
|
|
791
|
+
if (node.object?.type !== "Identifier") return;
|
|
792
|
+
if (node.property?.type !== "Identifier") return;
|
|
793
|
+
const bindingName = node.object.name;
|
|
794
|
+
const methodName = node.property.name;
|
|
795
|
+
const hookSource = hookBindingMapStack[hookBindingMapStack.length - 1].get(bindingName);
|
|
796
|
+
if (!hookSource) return;
|
|
797
|
+
const allowedMethods = HOOK_OBJECTS_WITH_METHODS.get(hookSource);
|
|
798
|
+
if (!allowedMethods || !allowedMethods.has(methodName)) return;
|
|
799
|
+
if (node.parent?.type !== "CallExpression" || node.parent.callee !== node) return;
|
|
800
|
+
context.report({
|
|
801
|
+
node,
|
|
802
|
+
message: `Destructure for clarity: \`const { ${methodName} } = ${hookSource}()\` then call \`${methodName}(...)\` directly — easier for React Compiler to memoize and clearer about which methods this component depends on`
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
} };
|
|
581
807
|
//#endregion
|
|
582
808
|
//#region src/plugin/rules/bundle-size.ts
|
|
583
809
|
const noBarrelImport = { create: (context) => {
|
|
@@ -623,6 +849,38 @@ const useLazyMotion = { create: (context) => ({ ImportDeclaration(node) {
|
|
|
623
849
|
message: "Import \"m\" with LazyMotion instead of \"motion\" — saves ~30kb in bundle size"
|
|
624
850
|
});
|
|
625
851
|
} }) };
|
|
852
|
+
const noDynamicImportPath = { create: (context) => ({
|
|
853
|
+
ImportExpression(node) {
|
|
854
|
+
const source = node.source;
|
|
855
|
+
if (source && source.type !== "Literal" && source.type !== "TemplateLiteral") {
|
|
856
|
+
context.report({
|
|
857
|
+
node,
|
|
858
|
+
message: "Dynamic import path is not statically analyzable — use a string literal so the bundler can split this chunk"
|
|
859
|
+
});
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (source?.type === "TemplateLiteral" && (source.expressions?.length ?? 0) > 0) context.report({
|
|
863
|
+
node,
|
|
864
|
+
message: "Template literal with interpolation in dynamic import — use a string literal so the bundler can split this chunk"
|
|
865
|
+
});
|
|
866
|
+
},
|
|
867
|
+
CallExpression(node) {
|
|
868
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "require") return;
|
|
869
|
+
const arg = node.arguments?.[0];
|
|
870
|
+
if (!arg) return;
|
|
871
|
+
if (arg.type !== "Literal" && arg.type !== "TemplateLiteral") {
|
|
872
|
+
context.report({
|
|
873
|
+
node,
|
|
874
|
+
message: "Dynamic require() path is not statically analyzable — use a string literal so the bundler can trace this dependency"
|
|
875
|
+
});
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (arg.type === "TemplateLiteral" && (arg.expressions?.length ?? 0) > 0) context.report({
|
|
879
|
+
node,
|
|
880
|
+
message: "Template literal with interpolation in require() — use a string literal so the bundler can trace this dependency"
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}) };
|
|
626
884
|
const noUndeferredThirdParty = { create: (context) => ({ JSXOpeningElement(node) {
|
|
627
885
|
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "script") return;
|
|
628
886
|
const attributes = node.attributes ?? [];
|
|
@@ -632,12 +890,11 @@ const noUndeferredThirdParty = { create: (context) => ({ JSXOpeningElement(node)
|
|
|
632
890
|
message: "Synchronous <script> with src — add defer or async to avoid blocking first paint"
|
|
633
891
|
});
|
|
634
892
|
} }) };
|
|
635
|
-
|
|
636
893
|
//#endregion
|
|
637
894
|
//#region src/plugin/rules/client.ts
|
|
638
895
|
const clientPassiveEventListeners = { create: (context) => ({ CallExpression(node) {
|
|
639
896
|
if (!isMemberProperty(node.callee, "addEventListener")) return;
|
|
640
|
-
if (node.arguments?.length < 2) return;
|
|
897
|
+
if ((node.arguments?.length ?? 0) < 2) return;
|
|
641
898
|
const eventNameNode = node.arguments[0];
|
|
642
899
|
if (eventNameNode.type !== "Literal" || !PASSIVE_EVENT_NAMES.has(eventNameNode.value)) return;
|
|
643
900
|
const eventName = eventNameNode.value;
|
|
@@ -645,17 +902,45 @@ const clientPassiveEventListeners = { create: (context) => ({ CallExpression(nod
|
|
|
645
902
|
if (!optionsArgument) {
|
|
646
903
|
context.report({
|
|
647
904
|
node,
|
|
648
|
-
message: `"${eventName}" listener without { passive: true } — blocks scrolling performance`
|
|
905
|
+
message: `"${eventName}" listener without { passive: true } — blocks scrolling performance. Only add { passive: true } if the handler does NOT call event.preventDefault() (passive listeners silently ignore preventDefault())`
|
|
649
906
|
});
|
|
650
907
|
return;
|
|
651
908
|
}
|
|
652
909
|
if (optionsArgument.type !== "ObjectExpression") return;
|
|
653
910
|
if (!optionsArgument.properties?.some((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "passive" && property.value?.type === "Literal" && property.value.value === true)) context.report({
|
|
654
911
|
node,
|
|
655
|
-
message: `"${eventName}" listener without { passive: true } — blocks scrolling performance`
|
|
912
|
+
message: `"${eventName}" listener without { passive: true } — blocks scrolling performance. Only add { passive: true } if the handler does NOT call event.preventDefault() (passive listeners silently ignore preventDefault())`
|
|
913
|
+
});
|
|
914
|
+
} }) };
|
|
915
|
+
const VERSIONED_KEY_PATTERN = /(?:[._:-]v\d+|@\d+|\bv\d+\b)/i;
|
|
916
|
+
const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
|
|
917
|
+
const isJsonStringifyCall = (node) => {
|
|
918
|
+
if (node.type !== "CallExpression") return false;
|
|
919
|
+
if (node.callee?.type !== "MemberExpression") return false;
|
|
920
|
+
if (node.callee.object?.type !== "Identifier") return false;
|
|
921
|
+
if (node.callee.object.name !== "JSON") return false;
|
|
922
|
+
if (node.callee.property?.type !== "Identifier") return false;
|
|
923
|
+
return node.callee.property.name === "stringify";
|
|
924
|
+
};
|
|
925
|
+
const clientLocalstorageNoVersion = { create: (context) => ({ CallExpression(node) {
|
|
926
|
+
if (node.callee?.type !== "MemberExpression") return;
|
|
927
|
+
if (node.callee.object?.type !== "Identifier") return;
|
|
928
|
+
if (!STORAGE_OBJECTS.has(node.callee.object.name)) return;
|
|
929
|
+
if (node.callee.property?.type !== "Identifier") return;
|
|
930
|
+
if (node.callee.property.name !== "setItem") return;
|
|
931
|
+
const keyArg = node.arguments?.[0];
|
|
932
|
+
if (!keyArg) return;
|
|
933
|
+
if (keyArg.type !== "Literal") return;
|
|
934
|
+
if (typeof keyArg.value !== "string") return;
|
|
935
|
+
if (VERSIONED_KEY_PATTERN.test(keyArg.value)) return;
|
|
936
|
+
const valueArg = node.arguments?.[1];
|
|
937
|
+
if (!valueArg) return;
|
|
938
|
+
if (!isJsonStringifyCall(valueArg)) return;
|
|
939
|
+
context.report({
|
|
940
|
+
node: keyArg,
|
|
941
|
+
message: `${node.callee.object.name}.setItem("${keyArg.value}", JSON.stringify(...)) — bake a version into the key (e.g. "${keyArg.value}:v1") so a future schema change can ignore old data instead of crashing on it`
|
|
656
942
|
});
|
|
657
943
|
} }) };
|
|
658
|
-
|
|
659
944
|
//#endregion
|
|
660
945
|
//#region src/plugin/rules/design.ts
|
|
661
946
|
const isOvershootCubicBezier = (value) => {
|
|
@@ -702,12 +987,24 @@ const getStylePropertyKey = (property) => {
|
|
|
702
987
|
};
|
|
703
988
|
const parseColorToRgb = (value) => {
|
|
704
989
|
const trimmed = value.trim().toLowerCase();
|
|
990
|
+
const hex8Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})[0-9a-f]{2}$/);
|
|
991
|
+
if (hex8Match) return {
|
|
992
|
+
red: parseInt(hex8Match[1], 16),
|
|
993
|
+
green: parseInt(hex8Match[2], 16),
|
|
994
|
+
blue: parseInt(hex8Match[3], 16)
|
|
995
|
+
};
|
|
705
996
|
const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
|
706
997
|
if (hex6Match) return {
|
|
707
998
|
red: parseInt(hex6Match[1], 16),
|
|
708
999
|
green: parseInt(hex6Match[2], 16),
|
|
709
1000
|
blue: parseInt(hex6Match[3], 16)
|
|
710
1001
|
};
|
|
1002
|
+
const hex4Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])[0-9a-f]$/);
|
|
1003
|
+
if (hex4Match) return {
|
|
1004
|
+
red: parseInt(hex4Match[1] + hex4Match[1], 16),
|
|
1005
|
+
green: parseInt(hex4Match[2] + hex4Match[2], 16),
|
|
1006
|
+
blue: parseInt(hex4Match[3] + hex4Match[3], 16)
|
|
1007
|
+
};
|
|
711
1008
|
const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
|
712
1009
|
if (hex3Match) return {
|
|
713
1010
|
red: parseInt(hex3Match[1] + hex3Match[1], 16),
|
|
@@ -722,7 +1019,7 @@ const parseColorToRgb = (value) => {
|
|
|
722
1019
|
};
|
|
723
1020
|
return null;
|
|
724
1021
|
};
|
|
725
|
-
const hasColorChroma = (parsed) => Math.max(parsed.red, parsed.green, parsed.blue) - Math.min(parsed.red, parsed.green, parsed.blue) >=
|
|
1022
|
+
const hasColorChroma = (parsed) => Math.max(parsed.red, parsed.green, parsed.blue) - Math.min(parsed.red, parsed.green, parsed.blue) >= 30;
|
|
726
1023
|
const isNeutralBorderColor = (value) => {
|
|
727
1024
|
const trimmed = value.trim().toLowerCase();
|
|
728
1025
|
if ([
|
|
@@ -768,7 +1065,7 @@ const parseShadowLayerBlur = (layer) => {
|
|
|
768
1065
|
const hasColoredGlowShadow = (shadowValue) => {
|
|
769
1066
|
for (const layer of splitShadowLayers(shadowValue)) {
|
|
770
1067
|
const color = extractColorFromShadowLayer(layer);
|
|
771
|
-
if (color && hasColorChroma(color) && parseShadowLayerBlur(layer) >
|
|
1068
|
+
if (color && hasColorChroma(color) && parseShadowLayerBlur(layer) > 4) return true;
|
|
772
1069
|
}
|
|
773
1070
|
return false;
|
|
774
1071
|
};
|
|
@@ -777,7 +1074,7 @@ const isBackgroundDark = (bgValue) => {
|
|
|
777
1074
|
if (isPureBlackColor(trimmed)) return true;
|
|
778
1075
|
const parsed = parseColorToRgb(trimmed);
|
|
779
1076
|
if (!parsed) return false;
|
|
780
|
-
return parsed.red <=
|
|
1077
|
+
return parsed.red <= 35 && parsed.green <= 35 && parsed.blue <= 35;
|
|
781
1078
|
};
|
|
782
1079
|
const BORDER_SIDE_KEYS = {
|
|
783
1080
|
borderLeft: "left",
|
|
@@ -826,7 +1123,7 @@ const noZIndex9999 = { create: (context) => ({
|
|
|
826
1123
|
for (const property of expression.properties ?? []) {
|
|
827
1124
|
if (getStylePropertyKey(property) !== "zIndex") continue;
|
|
828
1125
|
const zValue = getStylePropertyNumberValue(property);
|
|
829
|
-
if (zValue !== null && Math.abs(zValue) >=
|
|
1126
|
+
if (zValue !== null && Math.abs(zValue) >= 100) context.report({
|
|
830
1127
|
node: property,
|
|
831
1128
|
message: `z-index: ${zValue} is arbitrarily high — use a deliberate z-index scale (1–50). Extreme values signal a stacking context problem, not a fix`
|
|
832
1129
|
});
|
|
@@ -843,7 +1140,7 @@ const noZIndex9999 = { create: (context) => ({
|
|
|
843
1140
|
if (getStylePropertyKey(child) !== "zIndex") return;
|
|
844
1141
|
if (child.value?.type === "Literal" && typeof child.value.value === "number") {
|
|
845
1142
|
const zValue = child.value.value;
|
|
846
|
-
if (Math.abs(zValue) >=
|
|
1143
|
+
if (Math.abs(zValue) >= 100) context.report({
|
|
847
1144
|
node: child,
|
|
848
1145
|
message: `z-index: ${zValue} is arbitrarily high — use a deliberate z-index scale (1–50). Extreme values signal a stacking context problem, not a fix`
|
|
849
1146
|
});
|
|
@@ -855,7 +1152,7 @@ const noInlineExhaustiveStyle = { create: (context) => ({ JSXAttribute(node) {
|
|
|
855
1152
|
const expression = getInlineStyleExpression(node);
|
|
856
1153
|
if (!expression) return;
|
|
857
1154
|
const propertyCount = expression.properties?.filter((property) => property.type === "Property").length ?? 0;
|
|
858
|
-
if (propertyCount >=
|
|
1155
|
+
if (propertyCount >= 8) context.report({
|
|
859
1156
|
node: expression,
|
|
860
1157
|
message: `${propertyCount} inline style properties — extract to a CSS class, CSS module, or styled component for maintainability and reuse`
|
|
861
1158
|
});
|
|
@@ -870,7 +1167,7 @@ const noSideTabBorder = { create: (context) => ({
|
|
|
870
1167
|
const strValue = getStylePropertyStringValue(property);
|
|
871
1168
|
if (numValue !== null && numValue > 0 || strValue !== null && parseFloat(strValue) > 0) hasBorderRadius = true;
|
|
872
1169
|
}
|
|
873
|
-
const threshold = hasBorderRadius ?
|
|
1170
|
+
const threshold = hasBorderRadius ? 1 : 3;
|
|
874
1171
|
for (const property of expression.properties ?? []) {
|
|
875
1172
|
const key = getStylePropertyKey(property);
|
|
876
1173
|
if (!key) continue;
|
|
@@ -911,7 +1208,7 @@ const noSideTabBorder = { create: (context) => ({
|
|
|
911
1208
|
const sideMatch = classStr.match(/\bborder-[lrse]-(\d+)\b/);
|
|
912
1209
|
if (!sideMatch) return;
|
|
913
1210
|
if (/\bborder-(?:(?:gray|slate|zinc|neutral|stone)-\d+|white|black|transparent)\b/.test(classStr)) return;
|
|
914
|
-
if (parseInt(sideMatch[1], 10) >= (/\brounded(?:-(?!none\b)\w+)?\b/.test(classStr) && !/\brounded-none\b/.test(classStr) ?
|
|
1211
|
+
if (parseInt(sideMatch[1], 10) >= (/\brounded(?:-(?!none\b)\w+)?\b/.test(classStr) && !/\brounded-none\b/.test(classStr) ? 1 : 4)) context.report({
|
|
915
1212
|
node,
|
|
916
1213
|
message: `Thick one-sided border (${sideMatch[0]}) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
|
|
917
1214
|
});
|
|
@@ -1023,9 +1320,9 @@ const noTinyText = { create: (context) => ({ JSXAttribute(node) {
|
|
|
1023
1320
|
const remMatch = strValue.match(/^([\d.]+)rem$/);
|
|
1024
1321
|
if (remMatch) pxValue = parseFloat(remMatch[1]) * 16;
|
|
1025
1322
|
}
|
|
1026
|
-
if (pxValue !== null && pxValue > 0 && pxValue <
|
|
1323
|
+
if (pxValue !== null && pxValue > 0 && pxValue < 12) context.report({
|
|
1027
1324
|
node: property,
|
|
1028
|
-
message: `Font size ${pxValue}px is too small — body text should be at least
|
|
1325
|
+
message: `Font size ${pxValue}px is too small — body text should be at least 12px for readability, 16px is ideal`
|
|
1029
1326
|
});
|
|
1030
1327
|
}
|
|
1031
1328
|
} }) };
|
|
@@ -1054,7 +1351,7 @@ const noWideLetterSpacing = { create: (context) => ({ JSXAttribute(node) {
|
|
|
1054
1351
|
if (numValue !== null && numValue > 0) letterSpacingEm = numValue / 16;
|
|
1055
1352
|
}
|
|
1056
1353
|
}
|
|
1057
|
-
if (!isUppercase && letterSpacingProperty && letterSpacingEm !== null && letterSpacingEm >
|
|
1354
|
+
if (!isUppercase && letterSpacingProperty && letterSpacingEm !== null && letterSpacingEm > .05) context.report({
|
|
1058
1355
|
node: letterSpacingProperty,
|
|
1059
1356
|
message: `Letter spacing ${letterSpacingEm.toFixed(2)}em on body text disrupts natural character groupings. Reserve wide tracking for short uppercase labels only`
|
|
1060
1357
|
});
|
|
@@ -1164,15 +1461,15 @@ const noLongTransitionDuration = { create: (context) => ({ JSXAttribute(node) {
|
|
|
1164
1461
|
}
|
|
1165
1462
|
if (longestDurationMs > 0) durationMs = longestDurationMs;
|
|
1166
1463
|
}
|
|
1167
|
-
if (durationMs !== null && durationMs >
|
|
1464
|
+
if (durationMs !== null && durationMs > 1e3) context.report({
|
|
1168
1465
|
node: property,
|
|
1169
1466
|
message: `${durationMs}ms transition is too slow for UI feedback — keep transitions under ${LONG_TRANSITION_DURATION_THRESHOLD_MS}ms. Use longer durations only for page-load hero animations`
|
|
1170
1467
|
});
|
|
1171
1468
|
}
|
|
1172
1469
|
} }) };
|
|
1173
|
-
|
|
1174
1470
|
//#endregion
|
|
1175
1471
|
//#region src/plugin/rules/correctness.ts
|
|
1472
|
+
const STRING_COERCION_FUNCTIONS = new Set(["String", "Number"]);
|
|
1176
1473
|
const extractIndexName = (node) => {
|
|
1177
1474
|
if (node.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.name)) return node.name;
|
|
1178
1475
|
if (node.type === "TemplateLiteral") {
|
|
@@ -1180,7 +1477,8 @@ const extractIndexName = (node) => {
|
|
|
1180
1477
|
if (indexExpression) return indexExpression.name;
|
|
1181
1478
|
}
|
|
1182
1479
|
if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.callee.object.name) && node.callee.property?.type === "Identifier" && node.callee.property.name === "toString") return node.callee.object.name;
|
|
1183
|
-
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name
|
|
1480
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && STRING_COERCION_FUNCTIONS.has(node.callee.name) && node.arguments?.[0]?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.arguments[0].name)) return node.arguments[0].name;
|
|
1481
|
+
if (node.type === "BinaryExpression" && node.operator === "+" && (node.left?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.left.name) && node.right?.type === "Literal" && node.right.value === "" || node.right?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.right.name) && node.left?.type === "Literal" && node.left.value === "")) return node.left?.type === "Identifier" ? node.left.name : node.right.name;
|
|
1184
1482
|
return null;
|
|
1185
1483
|
};
|
|
1186
1484
|
const isInsideStaticPlaceholderMap = (node) => {
|
|
@@ -1210,8 +1508,8 @@ const noArrayIndexAsKey = { create: (context) => ({ JSXAttribute(node) {
|
|
|
1210
1508
|
});
|
|
1211
1509
|
} }) };
|
|
1212
1510
|
const PREVENT_DEFAULT_ELEMENTS = {
|
|
1213
|
-
form: "onSubmit",
|
|
1214
|
-
a: "onClick"
|
|
1511
|
+
form: ["onSubmit"],
|
|
1512
|
+
a: ["onClick"]
|
|
1215
1513
|
};
|
|
1216
1514
|
const containsPreventDefaultCall = (node) => {
|
|
1217
1515
|
let didFindPreventDefault = false;
|
|
@@ -1221,31 +1519,86 @@ const containsPreventDefaultCall = (node) => {
|
|
|
1221
1519
|
});
|
|
1222
1520
|
return didFindPreventDefault;
|
|
1223
1521
|
};
|
|
1522
|
+
const buildPreventDefaultMessage = (elementName) => {
|
|
1523
|
+
if (elementName === "form") return "preventDefault() on <form> onSubmit — form won't work without JavaScript. Consider using a server action for progressive enhancement";
|
|
1524
|
+
return "preventDefault() on <a> onClick — use a <button> or routing component instead";
|
|
1525
|
+
};
|
|
1224
1526
|
const noPreventDefault = { create: (context) => ({ JSXOpeningElement(node) {
|
|
1225
1527
|
const elementName = node.name?.type === "JSXIdentifier" ? node.name.name : null;
|
|
1226
1528
|
if (!elementName) return;
|
|
1227
|
-
const
|
|
1228
|
-
if (!
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1529
|
+
const targetEventProps = PREVENT_DEFAULT_ELEMENTS[elementName];
|
|
1530
|
+
if (!targetEventProps) return;
|
|
1531
|
+
for (const targetEventProp of targetEventProps) {
|
|
1532
|
+
const eventAttribute = findJsxAttribute(node.attributes ?? [], targetEventProp);
|
|
1533
|
+
if (!eventAttribute?.value || eventAttribute.value.type !== "JSXExpressionContainer") continue;
|
|
1534
|
+
const expression = eventAttribute.value.expression;
|
|
1535
|
+
if (expression?.type !== "ArrowFunctionExpression" && expression?.type !== "FunctionExpression") continue;
|
|
1536
|
+
if (!containsPreventDefaultCall(expression)) continue;
|
|
1537
|
+
context.report({
|
|
1538
|
+
node,
|
|
1539
|
+
message: buildPreventDefaultMessage(elementName)
|
|
1540
|
+
});
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1239
1543
|
} }) };
|
|
1544
|
+
const NUMERIC_NAME_HINTS = [
|
|
1545
|
+
"count",
|
|
1546
|
+
"length",
|
|
1547
|
+
"total",
|
|
1548
|
+
"size",
|
|
1549
|
+
"num"
|
|
1550
|
+
];
|
|
1551
|
+
const isNumericName = (name) => {
|
|
1552
|
+
for (const hint of NUMERIC_NAME_HINTS) {
|
|
1553
|
+
if (name === hint) return true;
|
|
1554
|
+
const camelSuffix = hint.charAt(0).toUpperCase() + hint.slice(1);
|
|
1555
|
+
if (name.endsWith(camelSuffix)) return true;
|
|
1556
|
+
if (name.endsWith(`_${hint}`)) return true;
|
|
1557
|
+
if (name.endsWith(`_${hint.toUpperCase()}`)) return true;
|
|
1558
|
+
}
|
|
1559
|
+
return false;
|
|
1560
|
+
};
|
|
1240
1561
|
const renderingConditionalRender = { create: (context) => ({ LogicalExpression(node) {
|
|
1241
1562
|
if (node.operator !== "&&") return;
|
|
1242
1563
|
if (!(node.right?.type === "JSXElement" || node.right?.type === "JSXFragment")) return;
|
|
1243
|
-
|
|
1564
|
+
const left = node.left;
|
|
1565
|
+
if (!left) return;
|
|
1566
|
+
const isLengthMemberAccess = left.type === "MemberExpression" && left.property?.type === "Identifier" && left.property.name === "length";
|
|
1567
|
+
const isNumericIdentifier = left.type === "Identifier" && isNumericName(left.name);
|
|
1568
|
+
if (isLengthMemberAccess || isNumericIdentifier) context.report({
|
|
1244
1569
|
node,
|
|
1245
|
-
message: "Conditional rendering with
|
|
1570
|
+
message: "Conditional rendering with a numeric value can render '0' — use `value > 0`, `Boolean(value)`, or a ternary"
|
|
1571
|
+
});
|
|
1572
|
+
} }) };
|
|
1573
|
+
const noPolymorphicChildren = { create: (context) => ({ BinaryExpression(node) {
|
|
1574
|
+
if (node.operator !== "===" && node.operator !== "==") return;
|
|
1575
|
+
const isTypeofChildren = (operand) => operand?.type === "UnaryExpression" && operand.operator === "typeof" && operand.argument?.type === "Identifier" && operand.argument.name === "children";
|
|
1576
|
+
if (!isTypeofChildren(node.left) && !isTypeofChildren(node.right)) return;
|
|
1577
|
+
const isStringLiteral = (operand) => operand?.type === "Literal" && operand.value === "string";
|
|
1578
|
+
if (!isStringLiteral(node.left) && !isStringLiteral(node.right)) return;
|
|
1579
|
+
context.report({
|
|
1580
|
+
node,
|
|
1581
|
+
message: "Polymorphic `typeof children === \"string\"` check — expose explicit subcomponents (e.g. `<Button.Text>`) instead of branching on what the consumer passed"
|
|
1582
|
+
});
|
|
1583
|
+
} }) };
|
|
1584
|
+
const SVG_PATH_HIGH_PRECISION_PATTERN = /\d+\.\d{4,}/;
|
|
1585
|
+
const SVG_PATH_ATTRIBUTES = new Set([
|
|
1586
|
+
"d",
|
|
1587
|
+
"points",
|
|
1588
|
+
"transform"
|
|
1589
|
+
]);
|
|
1590
|
+
const renderingSvgPrecision = { create: (context) => ({ JSXAttribute(node) {
|
|
1591
|
+
if (node.name?.type !== "JSXIdentifier") return;
|
|
1592
|
+
if (!SVG_PATH_ATTRIBUTES.has(node.name.name)) return;
|
|
1593
|
+
if (node.value?.type !== "Literal") return;
|
|
1594
|
+
const value = node.value.value;
|
|
1595
|
+
if (typeof value !== "string") return;
|
|
1596
|
+
if (!SVG_PATH_HIGH_PRECISION_PATTERN.test(value)) return;
|
|
1597
|
+
context.report({
|
|
1598
|
+
node,
|
|
1599
|
+
message: `SVG ${node.name.name} attribute uses 4+ decimal precision — truncate to 1–2 decimals to shrink markup with no visible difference`
|
|
1246
1600
|
});
|
|
1247
1601
|
} }) };
|
|
1248
|
-
|
|
1249
1602
|
//#endregion
|
|
1250
1603
|
//#region src/plugin/rules/js-performance.ts
|
|
1251
1604
|
const jsCombineIterations = { create: (context) => ({ CallExpression(node) {
|
|
@@ -1323,12 +1676,12 @@ const jsCacheStorage = { create: (context) => {
|
|
|
1323
1676
|
const storageReadCounts = /* @__PURE__ */ new Map();
|
|
1324
1677
|
return { CallExpression(node) {
|
|
1325
1678
|
if (!isMemberProperty(node.callee, "getItem")) return;
|
|
1326
|
-
if (node.callee.object?.type !== "Identifier" || !STORAGE_OBJECTS.has(node.callee.object.name)) return;
|
|
1679
|
+
if (node.callee.object?.type !== "Identifier" || !STORAGE_OBJECTS$1.has(node.callee.object.name)) return;
|
|
1327
1680
|
if (node.arguments?.[0]?.type !== "Literal") return;
|
|
1328
1681
|
const storageKey = String(node.arguments[0].value);
|
|
1329
1682
|
const readCount = (storageReadCounts.get(storageKey) ?? 0) + 1;
|
|
1330
1683
|
storageReadCounts.set(storageKey, readCount);
|
|
1331
|
-
if (readCount ===
|
|
1684
|
+
if (readCount === 2) {
|
|
1332
1685
|
const storageName = node.callee.object.name;
|
|
1333
1686
|
context.report({
|
|
1334
1687
|
node,
|
|
@@ -1347,7 +1700,7 @@ const jsEarlyExit = { create: (context) => ({ IfStatement(node) {
|
|
|
1347
1700
|
nestingDepth++;
|
|
1348
1701
|
currentBlock = innerStatement.consequent;
|
|
1349
1702
|
}
|
|
1350
|
-
if (nestingDepth >=
|
|
1703
|
+
if (nestingDepth >= 3) context.report({
|
|
1351
1704
|
node,
|
|
1352
1705
|
message: `${nestingDepth + 1} levels of nested if statements — use early returns to flatten`
|
|
1353
1706
|
});
|
|
@@ -1359,7 +1712,7 @@ const asyncParallel = { create: (context) => {
|
|
|
1359
1712
|
if (isTestFile) return;
|
|
1360
1713
|
const consecutiveAwaitStatements = [];
|
|
1361
1714
|
const flushConsecutiveAwaits = () => {
|
|
1362
|
-
if (consecutiveAwaitStatements.length >=
|
|
1715
|
+
if (consecutiveAwaitStatements.length >= 3) reportIfIndependent(consecutiveAwaitStatements, context);
|
|
1363
1716
|
consecutiveAwaitStatements.length = 0;
|
|
1364
1717
|
};
|
|
1365
1718
|
for (const statement of node.body ?? []) if (statement.type === "VariableDeclaration" && statement.declarations?.length === 1 && statement.declarations[0].init?.type === "AwaitExpression" || statement.type === "ExpressionStatement" && statement.expression?.type === "AwaitExpression") consecutiveAwaitStatements.push(statement);
|
|
@@ -1400,7 +1753,193 @@ const jsFlatmapFilter = { create: (context) => ({ CallExpression(node) {
|
|
|
1400
1753
|
message: ".map().filter(Boolean) iterates twice — use .flatMap() to transform and filter in a single pass"
|
|
1401
1754
|
});
|
|
1402
1755
|
} }) };
|
|
1403
|
-
|
|
1756
|
+
const buildMemberAccessKey = (node) => {
|
|
1757
|
+
if (node.type === "Identifier") return node.name;
|
|
1758
|
+
if (node.type === "ThisExpression") return "this";
|
|
1759
|
+
if (node.type !== "MemberExpression" || node.computed) return null;
|
|
1760
|
+
const objectKey = buildMemberAccessKey(node.object);
|
|
1761
|
+
if (!objectKey) return null;
|
|
1762
|
+
if (node.property?.type !== "Identifier") return null;
|
|
1763
|
+
return `${objectKey}.${node.property.name}`;
|
|
1764
|
+
};
|
|
1765
|
+
const jsCachePropertyAccess = { create: (context) => {
|
|
1766
|
+
const inspectLoopBody = (loopBody) => {
|
|
1767
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1768
|
+
walkAst(loopBody, (child) => {
|
|
1769
|
+
if (child.type !== "MemberExpression") return;
|
|
1770
|
+
if (child.computed) return;
|
|
1771
|
+
if (child.parent?.type === "MemberExpression" && child.parent.object === child) return;
|
|
1772
|
+
const key = buildMemberAccessKey(child);
|
|
1773
|
+
if (!key) return;
|
|
1774
|
+
if (key.split(".").length < 3) return;
|
|
1775
|
+
const existing = counts.get(key);
|
|
1776
|
+
if (existing) existing.count++;
|
|
1777
|
+
else counts.set(key, {
|
|
1778
|
+
count: 1,
|
|
1779
|
+
firstNode: child
|
|
1780
|
+
});
|
|
1781
|
+
});
|
|
1782
|
+
for (const [key, { count, firstNode }] of counts) if (count >= 3) context.report({
|
|
1783
|
+
node: firstNode,
|
|
1784
|
+
message: `${key} is read ${count} times inside this loop — hoist into a const at the top of the loop body`
|
|
1785
|
+
});
|
|
1786
|
+
};
|
|
1787
|
+
const handleLoop = (node) => {
|
|
1788
|
+
if (node.body) inspectLoopBody(node.body);
|
|
1789
|
+
};
|
|
1790
|
+
return {
|
|
1791
|
+
ForStatement: handleLoop,
|
|
1792
|
+
ForInStatement: handleLoop,
|
|
1793
|
+
ForOfStatement: handleLoop,
|
|
1794
|
+
WhileStatement: handleLoop,
|
|
1795
|
+
DoWhileStatement: handleLoop
|
|
1796
|
+
};
|
|
1797
|
+
} };
|
|
1798
|
+
const jsLengthCheckFirst = { create: (context) => ({ CallExpression(node) {
|
|
1799
|
+
if (node.callee?.type !== "MemberExpression") return;
|
|
1800
|
+
if (node.callee.property?.type !== "Identifier") return;
|
|
1801
|
+
if (node.callee.property.name !== "every") return;
|
|
1802
|
+
const callback = node.arguments?.[0];
|
|
1803
|
+
if (callback?.type !== "ArrowFunctionExpression" && callback?.type !== "FunctionExpression") return;
|
|
1804
|
+
const params = callback.params ?? [];
|
|
1805
|
+
if (params.length < 2) return;
|
|
1806
|
+
let referencesOtherArrayByIndex = false;
|
|
1807
|
+
walkAst(callback.body, (child) => {
|
|
1808
|
+
if (referencesOtherArrayByIndex) return;
|
|
1809
|
+
if (child.type === "MemberExpression" && child.computed && child.property?.type === "Identifier" && params[1]?.type === "Identifier" && child.property.name === params[1].name) referencesOtherArrayByIndex = true;
|
|
1810
|
+
});
|
|
1811
|
+
if (!referencesOtherArrayByIndex) return;
|
|
1812
|
+
let guard = node.parent ?? null;
|
|
1813
|
+
while (guard && guard.type !== "LogicalExpression" && guard.type !== "IfStatement") guard = guard.parent ?? null;
|
|
1814
|
+
if (guard?.type === "LogicalExpression" && guard.operator === "&&") {
|
|
1815
|
+
const left = guard.left;
|
|
1816
|
+
if (left?.type === "BinaryExpression" && left.operator === "===" && (isMemberProperty(left.left, "length") || isMemberProperty(left.right, "length"))) return;
|
|
1817
|
+
}
|
|
1818
|
+
context.report({
|
|
1819
|
+
node,
|
|
1820
|
+
message: ".every() over an array compared to another array — short-circuit with `a.length === b.length && a.every(...)` so unequal-length arrays exit immediately"
|
|
1821
|
+
});
|
|
1822
|
+
} }) };
|
|
1823
|
+
const INTL_CLASSES = new Set([
|
|
1824
|
+
"NumberFormat",
|
|
1825
|
+
"DateTimeFormat",
|
|
1826
|
+
"Collator",
|
|
1827
|
+
"RelativeTimeFormat",
|
|
1828
|
+
"ListFormat",
|
|
1829
|
+
"PluralRules",
|
|
1830
|
+
"Segmenter",
|
|
1831
|
+
"DisplayNames"
|
|
1832
|
+
]);
|
|
1833
|
+
const isIntlNewExpression = (node) => {
|
|
1834
|
+
if (node.type !== "NewExpression") return false;
|
|
1835
|
+
const callee = node.callee;
|
|
1836
|
+
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "Intl" && callee.property?.type === "Identifier" && INTL_CLASSES.has(callee.property.name)) return true;
|
|
1837
|
+
return false;
|
|
1838
|
+
};
|
|
1839
|
+
const jsHoistIntl = { create: (context) => ({ NewExpression(node) {
|
|
1840
|
+
if (!isIntlNewExpression(node)) return;
|
|
1841
|
+
let cursor = node.parent ?? null;
|
|
1842
|
+
let inFunctionBody = false;
|
|
1843
|
+
while (cursor) {
|
|
1844
|
+
if (cursor.type === "FunctionDeclaration" || cursor.type === "FunctionExpression" || cursor.type === "ArrowFunctionExpression") {
|
|
1845
|
+
inFunctionBody = true;
|
|
1846
|
+
break;
|
|
1847
|
+
}
|
|
1848
|
+
cursor = cursor.parent ?? null;
|
|
1849
|
+
}
|
|
1850
|
+
if (!inFunctionBody) return;
|
|
1851
|
+
const className = node.callee.property?.name ?? "Intl";
|
|
1852
|
+
context.report({
|
|
1853
|
+
node,
|
|
1854
|
+
message: `new Intl.${className}() inside a function — hoist to module scope or wrap in useMemo so it isn't recreated each call`
|
|
1855
|
+
});
|
|
1856
|
+
} }) };
|
|
1857
|
+
const findFirstAwaitOutsideNestedFunctions = (block) => {
|
|
1858
|
+
let firstAwait = null;
|
|
1859
|
+
walkAst(block, (child) => {
|
|
1860
|
+
if (firstAwait) return false;
|
|
1861
|
+
if (child !== block && (child.type === "FunctionDeclaration" || child.type === "FunctionExpression" || child.type === "ArrowFunctionExpression")) return false;
|
|
1862
|
+
if (child.type === "AwaitExpression") firstAwait = child;
|
|
1863
|
+
});
|
|
1864
|
+
return firstAwait;
|
|
1865
|
+
};
|
|
1866
|
+
const isFunctionishExpression = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
|
1867
|
+
const ITERATION_METHOD_NAMES_WITH_CALLBACK = new Set([
|
|
1868
|
+
"forEach",
|
|
1869
|
+
"map",
|
|
1870
|
+
"filter",
|
|
1871
|
+
"reduce",
|
|
1872
|
+
"reduceRight",
|
|
1873
|
+
"find",
|
|
1874
|
+
"findIndex",
|
|
1875
|
+
"some",
|
|
1876
|
+
"every",
|
|
1877
|
+
"flatMap"
|
|
1878
|
+
]);
|
|
1879
|
+
const PROMISE_CONCURRENCY_METHODS = new Set([
|
|
1880
|
+
"all",
|
|
1881
|
+
"allSettled",
|
|
1882
|
+
"race",
|
|
1883
|
+
"any"
|
|
1884
|
+
]);
|
|
1885
|
+
const isWrappedInPromiseConcurrency = (mapCall) => {
|
|
1886
|
+
const parent = mapCall.parent;
|
|
1887
|
+
if (parent?.type !== "CallExpression") return false;
|
|
1888
|
+
if (parent.arguments?.[0] !== mapCall) return false;
|
|
1889
|
+
const callee = parent.callee;
|
|
1890
|
+
if (callee?.type !== "MemberExpression" || callee.computed) return false;
|
|
1891
|
+
if (callee.object?.type !== "Identifier" || callee.object.name !== "Promise") return false;
|
|
1892
|
+
if (callee.property?.type !== "Identifier") return false;
|
|
1893
|
+
return PROMISE_CONCURRENCY_METHODS.has(callee.property.name);
|
|
1894
|
+
};
|
|
1895
|
+
const asyncAwaitInLoop = { create: (context) => {
|
|
1896
|
+
const inspectLoopBody = (loopBody, label) => {
|
|
1897
|
+
if (!loopBody) return;
|
|
1898
|
+
const firstAwait = findFirstAwaitOutsideNestedFunctions(loopBody);
|
|
1899
|
+
if (firstAwait) context.report({
|
|
1900
|
+
node: firstAwait,
|
|
1901
|
+
message: `await inside a ${label} runs the calls sequentially — for independent operations, collect them and use \`await Promise.all(items.map(...))\` to run them concurrently`
|
|
1902
|
+
});
|
|
1903
|
+
};
|
|
1904
|
+
return {
|
|
1905
|
+
ForStatement(node) {
|
|
1906
|
+
inspectLoopBody(node.body, "for-loop");
|
|
1907
|
+
},
|
|
1908
|
+
ForInStatement(node) {
|
|
1909
|
+
inspectLoopBody(node.body, "for…in loop");
|
|
1910
|
+
},
|
|
1911
|
+
ForOfStatement(node) {
|
|
1912
|
+
if (node.await) return;
|
|
1913
|
+
inspectLoopBody(node.body, "for…of loop");
|
|
1914
|
+
},
|
|
1915
|
+
WhileStatement(node) {
|
|
1916
|
+
inspectLoopBody(node.body, "while-loop");
|
|
1917
|
+
},
|
|
1918
|
+
DoWhileStatement(node) {
|
|
1919
|
+
inspectLoopBody(node.body, "do-while loop");
|
|
1920
|
+
},
|
|
1921
|
+
CallExpression(node) {
|
|
1922
|
+
if (node.callee?.type !== "MemberExpression") return;
|
|
1923
|
+
if (node.callee.property?.type !== "Identifier") return;
|
|
1924
|
+
const methodName = node.callee.property.name;
|
|
1925
|
+
if (!ITERATION_METHOD_NAMES_WITH_CALLBACK.has(methodName)) return;
|
|
1926
|
+
const callback = node.arguments?.[0];
|
|
1927
|
+
if (!callback || !isFunctionishExpression(callback)) return;
|
|
1928
|
+
if (!callback.async) return;
|
|
1929
|
+
const body = callback.body;
|
|
1930
|
+
if (!body) return;
|
|
1931
|
+
if ((methodName === "map" || methodName === "flatMap") && isWrappedInPromiseConcurrency(node)) return;
|
|
1932
|
+
const firstAwait = findFirstAwaitOutsideNestedFunctions(body);
|
|
1933
|
+
if (firstAwait) {
|
|
1934
|
+
const message = methodName === "forEach" ? "Async callback in .forEach — return values are dropped, so awaits don't actually wait. Use a `for…of` loop or `await Promise.all(items.map(async (item) => {...}))`" : `Async callback in .${methodName} — sequential awaits inside the callback waterfall. Use \`await Promise.all(items.map(async (item) => {...}))\` to run them concurrently`;
|
|
1935
|
+
context.report({
|
|
1936
|
+
node: firstAwait,
|
|
1937
|
+
message
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
};
|
|
1942
|
+
} };
|
|
1404
1943
|
//#endregion
|
|
1405
1944
|
//#region src/plugin/rules/nextjs.ts
|
|
1406
1945
|
const nextjsNoImgElement = { create: (context) => {
|
|
@@ -1450,13 +1989,39 @@ const nextjsNoAElement = { create: (context) => ({ JSXOpeningElement(node) {
|
|
|
1450
1989
|
message: "Use next/link instead of <a> for internal links — enables client-side navigation and prefetching"
|
|
1451
1990
|
});
|
|
1452
1991
|
} }) };
|
|
1453
|
-
const
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1992
|
+
const fileMentionsSuspense = (programNode) => {
|
|
1993
|
+
let didSee = false;
|
|
1994
|
+
walkAst(programNode, (child) => {
|
|
1995
|
+
if (didSee) return false;
|
|
1996
|
+
if (child.type === "JSXOpeningElement" && child.name?.type === "JSXIdentifier" && child.name.name === "Suspense") {
|
|
1997
|
+
didSee = true;
|
|
1998
|
+
return false;
|
|
1999
|
+
}
|
|
2000
|
+
if (child.type === "ImportDeclaration" && child.source?.value === "react") {
|
|
2001
|
+
if ((child.specifiers ?? []).some((specifier) => specifier.type === "ImportSpecifier" && specifier.imported?.name === "Suspense")) {
|
|
2002
|
+
didSee = true;
|
|
2003
|
+
return false;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
1458
2006
|
});
|
|
1459
|
-
|
|
2007
|
+
return didSee;
|
|
2008
|
+
};
|
|
2009
|
+
const nextjsNoUseSearchParamsWithoutSuspense = { create: (context) => {
|
|
2010
|
+
let hasSuspenseInFile = false;
|
|
2011
|
+
return {
|
|
2012
|
+
Program(programNode) {
|
|
2013
|
+
hasSuspenseInFile = fileMentionsSuspense(programNode);
|
|
2014
|
+
},
|
|
2015
|
+
CallExpression(node) {
|
|
2016
|
+
if (hasSuspenseInFile) return;
|
|
2017
|
+
if (!isHookCall(node, "useSearchParams")) return;
|
|
2018
|
+
context.report({
|
|
2019
|
+
node,
|
|
2020
|
+
message: "useSearchParams() requires a <Suspense> boundary — without one, the entire page bails out to client-side rendering"
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
};
|
|
2024
|
+
} };
|
|
1460
2025
|
const nextjsNoClientFetchForServerData = { create: (context) => {
|
|
1461
2026
|
let fileHasUseClient = false;
|
|
1462
2027
|
return {
|
|
@@ -1630,8 +2195,7 @@ const getExportedGetHandlerBody = (node) => {
|
|
|
1630
2195
|
if (!declaration) return null;
|
|
1631
2196
|
if (declaration.type === "FunctionDeclaration" && declaration.id?.name === "GET") return declaration.body;
|
|
1632
2197
|
if (declaration.type === "VariableDeclaration") {
|
|
1633
|
-
const declarator
|
|
1634
|
-
if (declarator?.id?.type === "Identifier" && declarator.id.name === "GET" && declarator.init && (declarator.init.type === "ArrowFunctionExpression" || declarator.init.type === "FunctionExpression")) return declarator.init.body;
|
|
2198
|
+
for (const declarator of declaration.declarations ?? []) if (declarator?.id?.type === "Identifier" && declarator.id.name === "GET" && declarator.init && (declarator.init.type === "ArrowFunctionExpression" || declarator.init.type === "FunctionExpression")) return declarator.init.body;
|
|
1635
2199
|
}
|
|
1636
2200
|
return null;
|
|
1637
2201
|
};
|
|
@@ -1654,7 +2218,6 @@ const nextjsNoSideEffectInGetHandler = { create: (context) => ({ ExportNamedDecl
|
|
|
1654
2218
|
message: `GET handler has side effects (${sideEffect}) — use POST to prevent CSRF and unintended prefetch triggers`
|
|
1655
2219
|
});
|
|
1656
2220
|
} }) };
|
|
1657
|
-
|
|
1658
2221
|
//#endregion
|
|
1659
2222
|
//#region src/plugin/rules/performance.ts
|
|
1660
2223
|
const isMemoCall = (node) => {
|
|
@@ -1698,6 +2261,13 @@ const noInlinePropOnMemoComponent = { create: (context) => {
|
|
|
1698
2261
|
}
|
|
1699
2262
|
};
|
|
1700
2263
|
} };
|
|
2264
|
+
const isTriviallyCheapExpression = (node) => {
|
|
2265
|
+
if (!node) return false;
|
|
2266
|
+
if (!isSimpleExpression(node)) return false;
|
|
2267
|
+
if (node.type === "Identifier") return false;
|
|
2268
|
+
if (node.type === "MemberExpression") return false;
|
|
2269
|
+
return true;
|
|
2270
|
+
};
|
|
1701
2271
|
const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node) {
|
|
1702
2272
|
if (!isHookCall(node, "useMemo")) return;
|
|
1703
2273
|
const callback = node.arguments?.[0];
|
|
@@ -1706,7 +2276,7 @@ const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node)
|
|
|
1706
2276
|
let returnExpression = null;
|
|
1707
2277
|
if (callback.body?.type !== "BlockStatement") returnExpression = callback.body;
|
|
1708
2278
|
else if (callback.body.body?.length === 1 && callback.body.body[0].type === "ReturnStatement") returnExpression = callback.body.body[0].argument;
|
|
1709
|
-
if (returnExpression &&
|
|
2279
|
+
if (returnExpression && isTriviallyCheapExpression(returnExpression)) context.report({
|
|
1710
2280
|
node,
|
|
1711
2281
|
message: "useMemo wrapping a trivially cheap expression — memo overhead exceeds the computation"
|
|
1712
2282
|
});
|
|
@@ -1782,7 +2352,7 @@ const noLargeAnimatedBlur = { create: (context) => ({ JSXAttribute(node) {
|
|
|
1782
2352
|
const match = BLUR_VALUE_PATTERN.exec(property.value.value);
|
|
1783
2353
|
if (!match) continue;
|
|
1784
2354
|
const blurRadius = Number.parseFloat(match[1]);
|
|
1785
|
-
if (blurRadius >
|
|
2355
|
+
if (blurRadius > 10) context.report({
|
|
1786
2356
|
node: property,
|
|
1787
2357
|
message: `blur(${blurRadius}px) is expensive — cost escalates with radius and layer size, can exceed GPU memory on mobile`
|
|
1788
2358
|
});
|
|
@@ -1853,8 +2423,21 @@ const renderingAnimateSvgWrapper = { create: (context) => ({ JSXOpeningElement(n
|
|
|
1853
2423
|
message: "Animation props directly on <svg> — wrap in a <div> or <motion.div> for better rendering performance"
|
|
1854
2424
|
});
|
|
1855
2425
|
} }) };
|
|
2426
|
+
const renderingUsetransitionLoading = { create: (context) => ({ VariableDeclarator(node) {
|
|
2427
|
+
if (node.id?.type !== "ArrayPattern" || !node.id.elements?.length) return;
|
|
2428
|
+
if (!node.init || !isHookCall(node.init, "useState")) return;
|
|
2429
|
+
if (!node.init.arguments?.length) return;
|
|
2430
|
+
const initializer = node.init.arguments[0];
|
|
2431
|
+
if (initializer.type !== "Literal" || initializer.value !== false) return;
|
|
2432
|
+
const stateVariableName = node.id.elements[0]?.name;
|
|
2433
|
+
if (!stateVariableName || !LOADING_STATE_PATTERN.test(stateVariableName)) return;
|
|
2434
|
+
context.report({
|
|
2435
|
+
node: node.init,
|
|
2436
|
+
message: `useState for "${stateVariableName}" — if this guards a state transition (not an async fetch), consider useTransition instead`
|
|
2437
|
+
});
|
|
2438
|
+
} }) };
|
|
1856
2439
|
const renderingHydrationNoFlicker = { create: (context) => ({ CallExpression(node) {
|
|
1857
|
-
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments?.length < 2) return;
|
|
2440
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
|
|
1858
2441
|
const depsNode = node.arguments[1];
|
|
1859
2442
|
if (depsNode.type !== "ArrayExpression" || depsNode.elements?.length !== 0) return;
|
|
1860
2443
|
const callback = getEffectCallback(node);
|
|
@@ -1880,38 +2463,365 @@ const renderingScriptDeferAsync = { create: (context) => ({ JSXOpeningElement(no
|
|
|
1880
2463
|
message: "<script src> without defer or async — blocks HTML parsing and delays First Contentful Paint. Add defer for DOM-dependent scripts or async for independent ones"
|
|
1881
2464
|
});
|
|
1882
2465
|
} }) };
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
return null;
|
|
1892
|
-
};
|
|
1893
|
-
const truncateText = (text) => text.length > RAW_TEXT_PREVIEW_MAX_CHARS ? `${text.slice(0, RAW_TEXT_PREVIEW_MAX_CHARS)}...` : text;
|
|
1894
|
-
const isRawTextContent = (child) => {
|
|
1895
|
-
if (child.type === "JSXText") return Boolean(child.value?.trim());
|
|
1896
|
-
if (child.type !== "JSXExpressionContainer" || !child.expression) return false;
|
|
1897
|
-
const expression = child.expression;
|
|
1898
|
-
return expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") || expression.type === "TemplateLiteral";
|
|
2466
|
+
const jsxReferencesLocalScope = (jsxNode) => {
|
|
2467
|
+
let referencesScope = false;
|
|
2468
|
+
walkAst(jsxNode, (child) => {
|
|
2469
|
+
if (referencesScope) return;
|
|
2470
|
+
if (child.type === "JSXExpressionContainer" && child.expression?.type !== "JSXEmptyExpression") referencesScope = true;
|
|
2471
|
+
if (child.type === "JSXSpreadAttribute") referencesScope = true;
|
|
2472
|
+
});
|
|
2473
|
+
return referencesScope;
|
|
1899
2474
|
};
|
|
1900
|
-
const
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
if (
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
2475
|
+
const renderingHoistJsx = { create: (context) => {
|
|
2476
|
+
let componentDepth = 0;
|
|
2477
|
+
const isComponentLike = (node) => {
|
|
2478
|
+
if (node.type === "FunctionDeclaration" && node.id?.name && isUppercaseName(node.id.name)) return true;
|
|
2479
|
+
if (node.type === "VariableDeclarator" && isComponentAssignment(node)) return true;
|
|
2480
|
+
return false;
|
|
2481
|
+
};
|
|
2482
|
+
const enter = (node) => {
|
|
2483
|
+
if (isComponentLike(node)) componentDepth++;
|
|
2484
|
+
};
|
|
2485
|
+
const exit = (node) => {
|
|
2486
|
+
if (isComponentLike(node)) componentDepth = Math.max(0, componentDepth - 1);
|
|
2487
|
+
};
|
|
2488
|
+
return {
|
|
2489
|
+
FunctionDeclaration: enter,
|
|
2490
|
+
"FunctionDeclaration:exit": exit,
|
|
2491
|
+
VariableDeclarator: enter,
|
|
2492
|
+
"VariableDeclarator:exit": exit,
|
|
2493
|
+
VariableDeclaration(node) {
|
|
2494
|
+
if (componentDepth === 0) return;
|
|
2495
|
+
if (node.kind !== "const") return;
|
|
2496
|
+
for (const declarator of node.declarations ?? []) {
|
|
2497
|
+
const init = declarator.init;
|
|
2498
|
+
if (!init) continue;
|
|
2499
|
+
if (init.type !== "JSXElement" && init.type !== "JSXFragment") continue;
|
|
2500
|
+
if (jsxReferencesLocalScope(init)) continue;
|
|
2501
|
+
const name = declarator.id?.type === "Identifier" ? declarator.id.name : "<unnamed>";
|
|
2502
|
+
context.report({
|
|
2503
|
+
node: declarator,
|
|
2504
|
+
message: `Static JSX "${name}" inside a component — hoist to module scope so it isn't recreated each render`
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
};
|
|
2509
|
+
} };
|
|
2510
|
+
const callbackReturnsJsx = (callback) => {
|
|
2511
|
+
if (!callback) return false;
|
|
2512
|
+
if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") return false;
|
|
2513
|
+
const body = callback.body;
|
|
2514
|
+
if (body?.type === "JSXElement" || body?.type === "JSXFragment") return true;
|
|
2515
|
+
if (body?.type !== "BlockStatement") return false;
|
|
2516
|
+
for (const stmt of body.body ?? []) if (stmt.type === "ReturnStatement" && (stmt.argument?.type === "JSXElement" || stmt.argument?.type === "JSXFragment")) return true;
|
|
2517
|
+
return false;
|
|
1909
2518
|
};
|
|
1910
|
-
const
|
|
1911
|
-
|
|
1912
|
-
|
|
2519
|
+
const containsEarlyReturn = (ifStatement) => {
|
|
2520
|
+
const consequent = ifStatement.consequent;
|
|
2521
|
+
if (!consequent) return false;
|
|
2522
|
+
if (consequent.type === "ReturnStatement") return true;
|
|
2523
|
+
if (consequent.type !== "BlockStatement") return false;
|
|
2524
|
+
for (const stmt of consequent.body ?? []) if (stmt.type === "ReturnStatement") return true;
|
|
2525
|
+
return false;
|
|
1913
2526
|
};
|
|
1914
|
-
const
|
|
2527
|
+
const rerenderMemoBeforeEarlyReturn = { create: (context) => {
|
|
2528
|
+
const inspectFunctionBody = (statements) => {
|
|
2529
|
+
let memoNode = null;
|
|
2530
|
+
for (const stmt of statements) {
|
|
2531
|
+
if (!memoNode) {
|
|
2532
|
+
if (stmt.type !== "VariableDeclaration") continue;
|
|
2533
|
+
for (const declarator of stmt.declarations ?? []) {
|
|
2534
|
+
const init = declarator.init;
|
|
2535
|
+
if (init?.type === "CallExpression" && isHookCall(init, "useMemo") && callbackReturnsJsx(init.arguments?.[0])) {
|
|
2536
|
+
memoNode = declarator;
|
|
2537
|
+
break;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
if (stmt.type === "IfStatement" && containsEarlyReturn(stmt)) {
|
|
2543
|
+
context.report({
|
|
2544
|
+
node: memoNode,
|
|
2545
|
+
message: "useMemo returning JSX runs before an early return — extract the JSX into a memoized child component so the parent bails out before the subtree renders"
|
|
2546
|
+
});
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
};
|
|
2551
|
+
return {
|
|
2552
|
+
FunctionDeclaration(node) {
|
|
2553
|
+
if (!isUppercaseName(node.id?.name ?? "")) return;
|
|
2554
|
+
if (node.body?.type !== "BlockStatement") return;
|
|
2555
|
+
inspectFunctionBody(node.body.body ?? []);
|
|
2556
|
+
},
|
|
2557
|
+
VariableDeclarator(node) {
|
|
2558
|
+
if (!isComponentAssignment(node)) return;
|
|
2559
|
+
const body = node.init?.body;
|
|
2560
|
+
if (body?.type !== "BlockStatement") return;
|
|
2561
|
+
inspectFunctionBody(body.body ?? []);
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
} };
|
|
2565
|
+
const NONDETERMINISTIC_RENDER_PATTERNS = [
|
|
2566
|
+
{
|
|
2567
|
+
display: "new Date()",
|
|
2568
|
+
matches: (n) => n.type === "NewExpression" && n.callee?.type === "Identifier" && n.callee.name === "Date"
|
|
2569
|
+
},
|
|
2570
|
+
{
|
|
2571
|
+
display: "Date.now()",
|
|
2572
|
+
matches: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "Date" && n.callee.property?.type === "Identifier" && n.callee.property.name === "now"
|
|
2573
|
+
},
|
|
2574
|
+
{
|
|
2575
|
+
display: "Math.random()",
|
|
2576
|
+
matches: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "Math" && n.callee.property?.type === "Identifier" && n.callee.property.name === "random"
|
|
2577
|
+
},
|
|
2578
|
+
{
|
|
2579
|
+
display: "performance.now()",
|
|
2580
|
+
matches: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "performance" && n.callee.property?.type === "Identifier" && n.callee.property.name === "now"
|
|
2581
|
+
},
|
|
2582
|
+
{
|
|
2583
|
+
display: "crypto.randomUUID()",
|
|
2584
|
+
matches: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "crypto" && n.callee.property?.type === "Identifier" && n.callee.property.name === "randomUUID"
|
|
2585
|
+
}
|
|
2586
|
+
];
|
|
2587
|
+
const findOpeningElementOfChild = (jsxNode) => {
|
|
2588
|
+
let cursor = jsxNode.parent ?? null;
|
|
2589
|
+
while (cursor) {
|
|
2590
|
+
if (cursor.type === "JSXElement") return cursor.openingElement;
|
|
2591
|
+
if (cursor.type === "JSXFragment") return null;
|
|
2592
|
+
cursor = cursor.parent ?? null;
|
|
2593
|
+
}
|
|
2594
|
+
return null;
|
|
2595
|
+
};
|
|
2596
|
+
const hasSuppressHydrationWarningAttribute = (openingElement) => {
|
|
2597
|
+
if (!openingElement) return false;
|
|
2598
|
+
for (const attr of openingElement.attributes ?? []) if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "suppressHydrationWarning") return true;
|
|
2599
|
+
return false;
|
|
2600
|
+
};
|
|
2601
|
+
const HIGH_FREQUENCY_DOM_EVENTS = new Set([
|
|
2602
|
+
"scroll",
|
|
2603
|
+
"mousemove",
|
|
2604
|
+
"wheel",
|
|
2605
|
+
"pointermove",
|
|
2606
|
+
"touchmove",
|
|
2607
|
+
"drag"
|
|
2608
|
+
]);
|
|
2609
|
+
const isAddEventListenerCall = (node) => {
|
|
2610
|
+
if (node.type !== "CallExpression") return false;
|
|
2611
|
+
if (node.callee?.type !== "MemberExpression") return false;
|
|
2612
|
+
if (node.callee.property?.type !== "Identifier") return false;
|
|
2613
|
+
if (node.callee.property.name !== "addEventListener") return false;
|
|
2614
|
+
return true;
|
|
2615
|
+
};
|
|
2616
|
+
const handlerCallsSetState = (handler) => {
|
|
2617
|
+
if (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression") return null;
|
|
2618
|
+
let setStateCall = null;
|
|
2619
|
+
walkAst(handler.body, (child) => {
|
|
2620
|
+
if (setStateCall) return;
|
|
2621
|
+
if (child.type === "CallExpression" && child.callee?.type === "Identifier" && /^set[A-Z]/.test(child.callee.name)) setStateCall = child;
|
|
2622
|
+
});
|
|
2623
|
+
return setStateCall;
|
|
2624
|
+
};
|
|
2625
|
+
const rerenderTransitionsScroll = { create: (context) => ({ CallExpression(node) {
|
|
2626
|
+
if (!isAddEventListenerCall(node)) return;
|
|
2627
|
+
const eventArg = node.arguments?.[0];
|
|
2628
|
+
if (eventArg?.type !== "Literal") return;
|
|
2629
|
+
const eventName = eventArg.value;
|
|
2630
|
+
if (typeof eventName !== "string" || !HIGH_FREQUENCY_DOM_EVENTS.has(eventName)) return;
|
|
2631
|
+
const handler = node.arguments?.[1];
|
|
2632
|
+
if (!handler) return;
|
|
2633
|
+
const setStateCall = handlerCallsSetState(handler);
|
|
2634
|
+
if (!setStateCall) return;
|
|
2635
|
+
let cursor = setStateCall.parent ?? null;
|
|
2636
|
+
while (cursor && cursor !== handler) {
|
|
2637
|
+
if (cursor.type === "CallExpression" && cursor.callee?.type === "Identifier" && (cursor.callee.name === "startTransition" || cursor.callee.name === "requestAnimationFrame" || cursor.callee.name === "requestIdleCallback")) return;
|
|
2638
|
+
cursor = cursor.parent ?? null;
|
|
2639
|
+
}
|
|
2640
|
+
context.report({
|
|
2641
|
+
node: setStateCall,
|
|
2642
|
+
message: `setState in a "${eventName}" handler triggers re-renders at scroll/pointer frequency — wrap in startTransition (mark as non-urgent), use useDeferredValue, or stash in a ref + rAF throttle`
|
|
2643
|
+
});
|
|
2644
|
+
} }) };
|
|
2645
|
+
const renderingHydrationMismatchTime = { create: (context) => ({ JSXExpressionContainer(node) {
|
|
2646
|
+
if (!node.expression) return;
|
|
2647
|
+
const matched = NONDETERMINISTIC_RENDER_PATTERNS.find((pattern) => pattern.matches(node.expression));
|
|
2648
|
+
if (matched) {
|
|
2649
|
+
if (hasSuppressHydrationWarningAttribute(findOpeningElementOfChild(node))) return;
|
|
2650
|
+
context.report({
|
|
2651
|
+
node,
|
|
2652
|
+
message: `${matched.display} in JSX renders differently on server vs client — wrap in useEffect+useState (client-only) or add suppressHydrationWarning to the parent if intentional`
|
|
2653
|
+
});
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
walkAst(node.expression, (child) => {
|
|
2657
|
+
for (const pattern of NONDETERMINISTIC_RENDER_PATTERNS) if (pattern.matches(child)) {
|
|
2658
|
+
if (hasSuppressHydrationWarningAttribute(findOpeningElementOfChild(node))) return;
|
|
2659
|
+
context.report({
|
|
2660
|
+
node: child,
|
|
2661
|
+
message: `${pattern.display} reachable from JSX renders differently on server vs client — wrap in useEffect+useState (client-only) or add suppressHydrationWarning to the parent if intentional`
|
|
2662
|
+
});
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
});
|
|
2666
|
+
} }) };
|
|
2667
|
+
const collectIdentifierNames = (node, into) => {
|
|
2668
|
+
if (!node) return;
|
|
2669
|
+
walkAst(node, (child) => {
|
|
2670
|
+
if (child.type === "Identifier") into.add(child.name);
|
|
2671
|
+
});
|
|
2672
|
+
};
|
|
2673
|
+
const isEarlyReturnIfStatement = (statement) => {
|
|
2674
|
+
if (statement.type !== "IfStatement") return false;
|
|
2675
|
+
const consequent = statement.consequent;
|
|
2676
|
+
if (!consequent) return false;
|
|
2677
|
+
if (consequent.type === "ReturnStatement") return true;
|
|
2678
|
+
if (consequent.type !== "BlockStatement") return false;
|
|
2679
|
+
for (const inner of consequent.body ?? []) if (inner.type === "ReturnStatement") return true;
|
|
2680
|
+
return false;
|
|
2681
|
+
};
|
|
2682
|
+
const asyncDeferAwait = { create: (context) => {
|
|
2683
|
+
const inspectStatements = (statements) => {
|
|
2684
|
+
for (let statementIndex = 0; statementIndex < statements.length - 1; statementIndex++) {
|
|
2685
|
+
const currentStatement = statements[statementIndex];
|
|
2686
|
+
if (currentStatement.type !== "VariableDeclaration") continue;
|
|
2687
|
+
const awaitedBindingNames = /* @__PURE__ */ new Set();
|
|
2688
|
+
let didAwait = false;
|
|
2689
|
+
for (const declarator of currentStatement.declarations ?? []) if (declarator.init?.type === "AwaitExpression") {
|
|
2690
|
+
didAwait = true;
|
|
2691
|
+
if (declarator.id?.type === "Identifier") awaitedBindingNames.add(declarator.id.name);
|
|
2692
|
+
else if (declarator.id?.type === "ObjectPattern") {
|
|
2693
|
+
for (const property of declarator.id.properties ?? []) if (property.type === "Property" && property.value?.type === "Identifier") awaitedBindingNames.add(property.value.name);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
if (!didAwait) continue;
|
|
2697
|
+
const nextStatement = statements[statementIndex + 1];
|
|
2698
|
+
if (!isEarlyReturnIfStatement(nextStatement)) continue;
|
|
2699
|
+
const testIdentifiers = /* @__PURE__ */ new Set();
|
|
2700
|
+
collectIdentifierNames(nextStatement.test, testIdentifiers);
|
|
2701
|
+
if ([...awaitedBindingNames].some((name) => testIdentifiers.has(name))) continue;
|
|
2702
|
+
const consequentIdentifiers = /* @__PURE__ */ new Set();
|
|
2703
|
+
collectIdentifierNames(nextStatement.consequent, consequentIdentifiers);
|
|
2704
|
+
if ([...awaitedBindingNames].some((name) => consequentIdentifiers.has(name))) continue;
|
|
2705
|
+
context.report({
|
|
2706
|
+
node: currentStatement,
|
|
2707
|
+
message: "await blocks the function before an early-return that doesn't use the awaited value — move the await after the synchronous guard so the skip path stays fast"
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
};
|
|
2711
|
+
const enterFunction = (node) => {
|
|
2712
|
+
if (!node.async) return;
|
|
2713
|
+
if (node.body?.type !== "BlockStatement") return;
|
|
2714
|
+
inspectStatements(node.body.body ?? []);
|
|
2715
|
+
};
|
|
2716
|
+
return {
|
|
2717
|
+
FunctionDeclaration: enterFunction,
|
|
2718
|
+
FunctionExpression: enterFunction,
|
|
2719
|
+
ArrowFunctionExpression: enterFunction
|
|
2720
|
+
};
|
|
2721
|
+
} };
|
|
2722
|
+
const CONTINUOUS_VALUE_HOOK_PATTERN = /^use(?:Window(?:Width|Height|Dimensions)|Scroll(?:Position|Y|X)|MousePosition|ResizeObserver|IntersectionObserver)/;
|
|
2723
|
+
const isThresholdComparison = (node, valueName) => {
|
|
2724
|
+
if (node.type !== "BinaryExpression") return false;
|
|
2725
|
+
if (![
|
|
2726
|
+
"<",
|
|
2727
|
+
"<=",
|
|
2728
|
+
">",
|
|
2729
|
+
">=",
|
|
2730
|
+
"===",
|
|
2731
|
+
"!==",
|
|
2732
|
+
"==",
|
|
2733
|
+
"!="
|
|
2734
|
+
].includes(node.operator)) return false;
|
|
2735
|
+
if (!(node.left?.type === "Identifier" && node.left.name === valueName || node.right?.type === "Identifier" && node.right.name === valueName)) return false;
|
|
2736
|
+
return node.left?.type === "Literal" || node.right?.type === "Literal";
|
|
2737
|
+
};
|
|
2738
|
+
const findThresholdDerivedBindings = (componentBody) => {
|
|
2739
|
+
const out = [];
|
|
2740
|
+
if (componentBody?.type !== "BlockStatement") return out;
|
|
2741
|
+
const statements = componentBody.body ?? [];
|
|
2742
|
+
for (let outerIndex = 0; outerIndex < statements.length; outerIndex++) {
|
|
2743
|
+
const outerStatement = statements[outerIndex];
|
|
2744
|
+
if (outerStatement.type !== "VariableDeclaration") continue;
|
|
2745
|
+
for (const declarator of outerStatement.declarations ?? []) {
|
|
2746
|
+
if (declarator.id?.type !== "Identifier") continue;
|
|
2747
|
+
const init = declarator.init;
|
|
2748
|
+
if (init?.type !== "CallExpression") continue;
|
|
2749
|
+
if (init.callee?.type !== "Identifier") continue;
|
|
2750
|
+
if (!CONTINUOUS_VALUE_HOOK_PATTERN.test(init.callee.name)) continue;
|
|
2751
|
+
const continuousName = declarator.id.name;
|
|
2752
|
+
const hookName = init.callee.name;
|
|
2753
|
+
for (let innerIndex = outerIndex + 1; innerIndex < statements.length; innerIndex++) {
|
|
2754
|
+
const innerStatement = statements[innerIndex];
|
|
2755
|
+
if (innerStatement.type !== "VariableDeclaration") break;
|
|
2756
|
+
let foundThreshold = false;
|
|
2757
|
+
for (const innerDecl of innerStatement.declarations ?? []) if (innerDecl.init && isThresholdComparison(innerDecl.init, continuousName)) {
|
|
2758
|
+
foundThreshold = true;
|
|
2759
|
+
break;
|
|
2760
|
+
}
|
|
2761
|
+
if (foundThreshold) {
|
|
2762
|
+
out.push({
|
|
2763
|
+
continuousName,
|
|
2764
|
+
hookName,
|
|
2765
|
+
declarator
|
|
2766
|
+
});
|
|
2767
|
+
break;
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
return out;
|
|
2773
|
+
};
|
|
2774
|
+
const rerenderDerivedStateFromHook = { create: (context) => {
|
|
2775
|
+
const checkComponent = (componentBody) => {
|
|
2776
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
2777
|
+
const bindings = findThresholdDerivedBindings(componentBody);
|
|
2778
|
+
for (const binding of bindings) context.report({
|
|
2779
|
+
node: binding.declarator,
|
|
2780
|
+
message: `${binding.hookName}() returns a continuously-changing value but you only compare it to a threshold — use a media-query / threshold hook (e.g. \`useMediaQuery("(max-width: 767px)")\`) so the component re-renders only when the threshold flips`
|
|
2781
|
+
});
|
|
2782
|
+
};
|
|
2783
|
+
return {
|
|
2784
|
+
FunctionDeclaration(node) {
|
|
2785
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
2786
|
+
checkComponent(node.body);
|
|
2787
|
+
},
|
|
2788
|
+
VariableDeclarator(node) {
|
|
2789
|
+
if (!isComponentAssignment(node)) return;
|
|
2790
|
+
checkComponent(node.init?.body);
|
|
2791
|
+
}
|
|
2792
|
+
};
|
|
2793
|
+
} };
|
|
2794
|
+
//#endregion
|
|
2795
|
+
//#region src/plugin/rules/react-native.ts
|
|
2796
|
+
const resolveJsxElementName = (openingElement) => {
|
|
2797
|
+
const elementName = openingElement?.name;
|
|
2798
|
+
if (!elementName) return null;
|
|
2799
|
+
if (elementName.type === "JSXIdentifier") return elementName.name;
|
|
2800
|
+
if (elementName.type === "JSXMemberExpression") return elementName.property?.name ?? null;
|
|
2801
|
+
return null;
|
|
2802
|
+
};
|
|
2803
|
+
const truncateText = (text) => text.length > 30 ? `${text.slice(0, 30)}...` : text;
|
|
2804
|
+
const isRawTextContent = (child) => {
|
|
2805
|
+
if (child.type === "JSXText") return Boolean(child.value?.trim());
|
|
2806
|
+
if (child.type !== "JSXExpressionContainer" || !child.expression) return false;
|
|
2807
|
+
const expression = child.expression;
|
|
2808
|
+
return expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") || expression.type === "TemplateLiteral";
|
|
2809
|
+
};
|
|
2810
|
+
const getRawTextDescription = (child) => {
|
|
2811
|
+
if (child.type === "JSXText") return `"${truncateText(child.value.trim())}"`;
|
|
2812
|
+
if (child.type === "JSXExpressionContainer" && child.expression) {
|
|
2813
|
+
const expression = child.expression;
|
|
2814
|
+
if (expression.type === "Literal" && typeof expression.value === "string") return `"${truncateText(expression.value)}"`;
|
|
2815
|
+
if (expression.type === "Literal" && typeof expression.value === "number") return `{${expression.value}}`;
|
|
2816
|
+
if (expression.type === "TemplateLiteral") return "template literal";
|
|
2817
|
+
}
|
|
2818
|
+
return "text content";
|
|
2819
|
+
};
|
|
2820
|
+
const isTextHandlingComponent = (elementName) => {
|
|
2821
|
+
if (REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return true;
|
|
2822
|
+
return [...REACT_NATIVE_TEXT_COMPONENT_KEYWORDS].some((keyword) => elementName.includes(keyword));
|
|
2823
|
+
};
|
|
2824
|
+
const rnNoRawText = { create: (context) => {
|
|
1915
2825
|
let isDomComponentFile = false;
|
|
1916
2826
|
return {
|
|
1917
2827
|
Program(programNode) {
|
|
@@ -2043,7 +2953,449 @@ const rnNoSingleElementStyleArray = { create: (context) => ({ JSXAttribute(node)
|
|
|
2043
2953
|
message: `Single-element style array on "${propName}" — use ${propName}={value} instead of ${propName}={[value]} to avoid unnecessary array allocation`
|
|
2044
2954
|
});
|
|
2045
2955
|
} }) };
|
|
2046
|
-
|
|
2956
|
+
const TOUCHABLE_COMPONENTS = new Set([
|
|
2957
|
+
"TouchableOpacity",
|
|
2958
|
+
"TouchableHighlight",
|
|
2959
|
+
"TouchableWithoutFeedback",
|
|
2960
|
+
"TouchableNativeFeedback"
|
|
2961
|
+
]);
|
|
2962
|
+
const rnPreferPressable = { create: (context) => ({ ImportDeclaration(node) {
|
|
2963
|
+
if (node.source?.value !== "react-native") return;
|
|
2964
|
+
for (const specifier of node.specifiers ?? []) {
|
|
2965
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
2966
|
+
const importedName = specifier.imported?.name;
|
|
2967
|
+
if (!importedName || !TOUCHABLE_COMPONENTS.has(importedName)) continue;
|
|
2968
|
+
context.report({
|
|
2969
|
+
node: specifier,
|
|
2970
|
+
message: `${importedName} is legacy — use <Pressable> from react-native (or react-native-gesture-handler) for modern press handling`
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
} }) };
|
|
2974
|
+
const rnPreferExpoImage = { create: (context) => ({ ImportDeclaration(node) {
|
|
2975
|
+
if (node.source?.value !== "react-native") return;
|
|
2976
|
+
for (const specifier of node.specifiers ?? []) {
|
|
2977
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
2978
|
+
if (specifier.imported?.name !== "Image") continue;
|
|
2979
|
+
context.report({
|
|
2980
|
+
node: specifier,
|
|
2981
|
+
message: "Importing Image from react-native — prefer expo-image for caching, placeholders, and progressive loading (drop-in API)"
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
} }) };
|
|
2985
|
+
const NON_NATIVE_NAVIGATOR_PACKAGES = new Set(["@react-navigation/stack", "@react-navigation/drawer"]);
|
|
2986
|
+
const rnNoNonNativeNavigator = { create: (context) => ({ ImportDeclaration(node) {
|
|
2987
|
+
const source = node.source?.value;
|
|
2988
|
+
if (typeof source !== "string" || !NON_NATIVE_NAVIGATOR_PACKAGES.has(source)) return;
|
|
2989
|
+
const replacement = source.replace("@react-navigation/", "@react-navigation/native-");
|
|
2990
|
+
context.report({
|
|
2991
|
+
node,
|
|
2992
|
+
message: `${source} uses a JS-implemented navigator — use ${replacement} for native iOS/Android transitions and gestures`
|
|
2993
|
+
});
|
|
2994
|
+
} }) };
|
|
2995
|
+
const rnNoScrollState = { create: (context) => ({ JSXAttribute(node) {
|
|
2996
|
+
if (node.name?.type !== "JSXIdentifier") return;
|
|
2997
|
+
if (node.name.name !== "onScroll") return;
|
|
2998
|
+
if (node.value?.type !== "JSXExpressionContainer") return;
|
|
2999
|
+
const expression = node.value.expression;
|
|
3000
|
+
if (expression?.type !== "ArrowFunctionExpression" && expression?.type !== "FunctionExpression") return;
|
|
3001
|
+
let setStateCallNode = null;
|
|
3002
|
+
walkAst(expression.body, (child) => {
|
|
3003
|
+
if (setStateCallNode) return;
|
|
3004
|
+
if (child.type === "CallExpression" && child.callee?.type === "Identifier" && /^set[A-Z]/.test(child.callee.name)) setStateCallNode = child;
|
|
3005
|
+
});
|
|
3006
|
+
if (setStateCallNode) context.report({
|
|
3007
|
+
node: setStateCallNode,
|
|
3008
|
+
message: "setState in onScroll triggers re-renders on every scroll event — use a Reanimated shared value (useAnimatedScrollHandler) or a ref to track scroll position"
|
|
3009
|
+
});
|
|
3010
|
+
} }) };
|
|
3011
|
+
const SCROLLVIEW_NAMES = new Set(["ScrollView"]);
|
|
3012
|
+
const rnNoScrollviewMappedList = { create: (context) => ({ JSXElement(node) {
|
|
3013
|
+
const elementName = resolveJsxElementName(node.openingElement);
|
|
3014
|
+
if (!elementName || !SCROLLVIEW_NAMES.has(elementName)) return;
|
|
3015
|
+
for (const child of node.children ?? []) {
|
|
3016
|
+
if (child.type !== "JSXExpressionContainer") continue;
|
|
3017
|
+
const expression = child.expression;
|
|
3018
|
+
if (expression?.type === "CallExpression" && expression.callee?.type === "MemberExpression" && expression.callee.property?.type === "Identifier" && expression.callee.property.name === "map") {
|
|
3019
|
+
context.report({
|
|
3020
|
+
node: child,
|
|
3021
|
+
message: `<${elementName}> rendering items.map(...) — use FlashList, LegendList, or FlatList so only visible rows mount`
|
|
3022
|
+
});
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
} }) };
|
|
3027
|
+
const RENDER_ITEM_PROP_NAMES = new Set([
|
|
3028
|
+
"renderItem",
|
|
3029
|
+
"renderSectionHeader",
|
|
3030
|
+
"renderSectionFooter"
|
|
3031
|
+
]);
|
|
3032
|
+
const rnNoInlineObjectInListItem = { create: (context) => {
|
|
3033
|
+
let renderItemDepth = 0;
|
|
3034
|
+
const isRenderItemAttribute = (parent) => {
|
|
3035
|
+
if (parent?.type !== "JSXAttribute") return false;
|
|
3036
|
+
const attrName = parent.name?.type === "JSXIdentifier" ? parent.name.name : null;
|
|
3037
|
+
return attrName ? RENDER_ITEM_PROP_NAMES.has(attrName) : false;
|
|
3038
|
+
};
|
|
3039
|
+
const isRenderItemFunction = (node) => {
|
|
3040
|
+
if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return false;
|
|
3041
|
+
const expressionContainer = node.parent;
|
|
3042
|
+
if (expressionContainer?.type !== "JSXExpressionContainer") return false;
|
|
3043
|
+
return isRenderItemAttribute(expressionContainer.parent);
|
|
3044
|
+
};
|
|
3045
|
+
const enter = (node) => {
|
|
3046
|
+
if (isRenderItemFunction(node)) renderItemDepth++;
|
|
3047
|
+
};
|
|
3048
|
+
const exit = (node) => {
|
|
3049
|
+
if (isRenderItemFunction(node)) renderItemDepth = Math.max(0, renderItemDepth - 1);
|
|
3050
|
+
};
|
|
3051
|
+
return {
|
|
3052
|
+
ArrowFunctionExpression: enter,
|
|
3053
|
+
"ArrowFunctionExpression:exit": exit,
|
|
3054
|
+
FunctionExpression: enter,
|
|
3055
|
+
"FunctionExpression:exit": exit,
|
|
3056
|
+
JSXAttribute(node) {
|
|
3057
|
+
if (renderItemDepth === 0) return;
|
|
3058
|
+
if (node.value?.type !== "JSXExpressionContainer") return;
|
|
3059
|
+
if (node.value.expression?.type !== "ObjectExpression") return;
|
|
3060
|
+
const propName = node.name?.type === "JSXIdentifier" ? node.name.name : "<unknown>";
|
|
3061
|
+
context.report({
|
|
3062
|
+
node,
|
|
3063
|
+
message: `Inline object literal on "${propName}" inside renderItem — allocates a fresh reference per row and breaks memo() on the row component. Hoist outside renderItem or pass primitives`
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
};
|
|
3067
|
+
} };
|
|
3068
|
+
const REANIMATED_LAYOUT_KEYS = new Set([
|
|
3069
|
+
"width",
|
|
3070
|
+
"height",
|
|
3071
|
+
"top",
|
|
3072
|
+
"left",
|
|
3073
|
+
"right",
|
|
3074
|
+
"bottom",
|
|
3075
|
+
"minWidth",
|
|
3076
|
+
"minHeight",
|
|
3077
|
+
"maxWidth",
|
|
3078
|
+
"maxHeight",
|
|
3079
|
+
"marginTop",
|
|
3080
|
+
"marginBottom",
|
|
3081
|
+
"marginLeft",
|
|
3082
|
+
"marginRight",
|
|
3083
|
+
"paddingTop",
|
|
3084
|
+
"paddingBottom",
|
|
3085
|
+
"paddingLeft",
|
|
3086
|
+
"paddingRight",
|
|
3087
|
+
"flex",
|
|
3088
|
+
"flexBasis",
|
|
3089
|
+
"flexGrow",
|
|
3090
|
+
"flexShrink"
|
|
3091
|
+
]);
|
|
3092
|
+
const findReturnedObject = (callback) => {
|
|
3093
|
+
if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") return null;
|
|
3094
|
+
const body = callback.body;
|
|
3095
|
+
if (body?.type === "ObjectExpression") return body;
|
|
3096
|
+
if (body?.type !== "BlockStatement") return null;
|
|
3097
|
+
for (const stmt of body.body ?? []) if (stmt.type === "ReturnStatement" && stmt.argument?.type === "ObjectExpression") return stmt.argument;
|
|
3098
|
+
return null;
|
|
3099
|
+
};
|
|
3100
|
+
const rnAnimateLayoutProperty = { create: (context) => ({ CallExpression(node) {
|
|
3101
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "useAnimatedStyle") return;
|
|
3102
|
+
const callback = node.arguments?.[0];
|
|
3103
|
+
if (!callback) return;
|
|
3104
|
+
const returnedObject = findReturnedObject(callback);
|
|
3105
|
+
if (!returnedObject) return;
|
|
3106
|
+
for (const property of returnedObject.properties ?? []) {
|
|
3107
|
+
if (property.type !== "Property") continue;
|
|
3108
|
+
if (property.key?.type !== "Identifier") continue;
|
|
3109
|
+
if (!REANIMATED_LAYOUT_KEYS.has(property.key.name)) continue;
|
|
3110
|
+
context.report({
|
|
3111
|
+
node: property,
|
|
3112
|
+
message: `useAnimatedStyle animating "${property.key.name}" — layout properties run on the layout thread; use transform: [{ translateX/Y }, { scale }] or opacity for GPU-accelerated animation`
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
3115
|
+
} }) };
|
|
3116
|
+
const rnPreferContentInsetAdjustment = { create: (context) => ({ JSXElement(node) {
|
|
3117
|
+
if (resolveJsxElementName(node.openingElement) !== "SafeAreaView") return;
|
|
3118
|
+
for (const child of node.children ?? []) {
|
|
3119
|
+
if (child.type !== "JSXElement") continue;
|
|
3120
|
+
const childName = resolveJsxElementName(child.openingElement);
|
|
3121
|
+
if (!childName || !SCROLLVIEW_NAMES.has(childName)) continue;
|
|
3122
|
+
context.report({
|
|
3123
|
+
node,
|
|
3124
|
+
message: "<SafeAreaView> wrapping <ScrollView> — set `contentInsetAdjustmentBehavior=\"automatic\"` on the ScrollView and drop the SafeAreaView wrapper for native safe-area handling"
|
|
3125
|
+
});
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
} }) };
|
|
3129
|
+
const PRESS_HANDLER_PROP_NAMES = new Set(["onPressIn", "onPressOut"]);
|
|
3130
|
+
const handlerMutatesIdentifier = (handler, sharedValueBindings) => {
|
|
3131
|
+
if (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression") return false;
|
|
3132
|
+
if (sharedValueBindings.size === 0) return false;
|
|
3133
|
+
let didMutate = false;
|
|
3134
|
+
walkAst(handler.body, (child) => {
|
|
3135
|
+
if (didMutate) return;
|
|
3136
|
+
if (child.type === "AssignmentExpression" && child.left?.type === "MemberExpression" && child.left.object?.type === "Identifier" && sharedValueBindings.has(child.left.object.name) && child.left.property?.type === "Identifier" && child.left.property.name === "value") didMutate = true;
|
|
3137
|
+
if (child.type === "CallExpression" && child.callee?.type === "MemberExpression" && child.callee.object?.type === "Identifier" && sharedValueBindings.has(child.callee.object.name) && child.callee.property?.type === "Identifier" && (child.callee.property.name === "set" || child.callee.property.name === "value")) didMutate = true;
|
|
3138
|
+
});
|
|
3139
|
+
return didMutate;
|
|
3140
|
+
};
|
|
3141
|
+
const rnPressableSharedValueMutation = { create: (context) => {
|
|
3142
|
+
const sharedValueBindingsByComponent = [];
|
|
3143
|
+
const enterScope = () => {
|
|
3144
|
+
sharedValueBindingsByComponent.push(/* @__PURE__ */ new Set());
|
|
3145
|
+
};
|
|
3146
|
+
const exitScope = () => {
|
|
3147
|
+
sharedValueBindingsByComponent.pop();
|
|
3148
|
+
};
|
|
3149
|
+
const trackSharedValueBinding = (declarator) => {
|
|
3150
|
+
if (sharedValueBindingsByComponent.length === 0) return;
|
|
3151
|
+
if (declarator.id?.type !== "Identifier") return;
|
|
3152
|
+
if (declarator.init?.type !== "CallExpression") return;
|
|
3153
|
+
const callee = declarator.init.callee;
|
|
3154
|
+
if (callee?.type !== "Identifier") return;
|
|
3155
|
+
if (callee.name !== "useSharedValue") return;
|
|
3156
|
+
sharedValueBindingsByComponent[sharedValueBindingsByComponent.length - 1].add(declarator.id.name);
|
|
3157
|
+
};
|
|
3158
|
+
return {
|
|
3159
|
+
FunctionDeclaration: enterScope,
|
|
3160
|
+
"FunctionDeclaration:exit": exitScope,
|
|
3161
|
+
FunctionExpression: enterScope,
|
|
3162
|
+
"FunctionExpression:exit": exitScope,
|
|
3163
|
+
ArrowFunctionExpression: enterScope,
|
|
3164
|
+
"ArrowFunctionExpression:exit": exitScope,
|
|
3165
|
+
VariableDeclarator(node) {
|
|
3166
|
+
trackSharedValueBinding(node);
|
|
3167
|
+
},
|
|
3168
|
+
JSXOpeningElement(node) {
|
|
3169
|
+
if (resolveJsxElementName(node) !== "Pressable") return;
|
|
3170
|
+
if (sharedValueBindingsByComponent.length === 0) return;
|
|
3171
|
+
const activeBindings = /* @__PURE__ */ new Set();
|
|
3172
|
+
for (const frame of sharedValueBindingsByComponent) for (const binding of frame) activeBindings.add(binding);
|
|
3173
|
+
if (activeBindings.size === 0) return;
|
|
3174
|
+
for (const attr of node.attributes ?? []) {
|
|
3175
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
3176
|
+
if (attr.name?.type !== "JSXIdentifier") continue;
|
|
3177
|
+
if (!PRESS_HANDLER_PROP_NAMES.has(attr.name.name)) continue;
|
|
3178
|
+
if (attr.value?.type !== "JSXExpressionContainer") continue;
|
|
3179
|
+
const handler = attr.value.expression;
|
|
3180
|
+
if (!handler) continue;
|
|
3181
|
+
if (!handlerMutatesIdentifier(handler, activeBindings)) continue;
|
|
3182
|
+
context.report({
|
|
3183
|
+
node: attr,
|
|
3184
|
+
message: `<Pressable> ${attr.name.name} mutates a Reanimated shared value — use a Gesture.Tap() inside <GestureDetector> for press animations that stay on the UI thread`
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
};
|
|
3189
|
+
} };
|
|
3190
|
+
const VIRTUALIZED_LIST_NAMES = new Set([
|
|
3191
|
+
"FlatList",
|
|
3192
|
+
"FlashList",
|
|
3193
|
+
"LegendList",
|
|
3194
|
+
"SectionList",
|
|
3195
|
+
"VirtualizedList"
|
|
3196
|
+
]);
|
|
3197
|
+
const rnListDataMapped = { create: (context) => ({ JSXOpeningElement(node) {
|
|
3198
|
+
const elementName = resolveJsxElementName(node);
|
|
3199
|
+
if (!elementName || !VIRTUALIZED_LIST_NAMES.has(elementName)) return;
|
|
3200
|
+
for (const attr of node.attributes ?? []) {
|
|
3201
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
3202
|
+
if (attr.name?.type !== "JSXIdentifier" || attr.name.name !== "data") continue;
|
|
3203
|
+
if (attr.value?.type !== "JSXExpressionContainer") continue;
|
|
3204
|
+
const expression = attr.value.expression;
|
|
3205
|
+
if (expression?.type !== "CallExpression") continue;
|
|
3206
|
+
if (expression.callee?.type !== "MemberExpression") continue;
|
|
3207
|
+
if (expression.callee.property?.type !== "Identifier") continue;
|
|
3208
|
+
const methodName = expression.callee.property.name;
|
|
3209
|
+
if (methodName !== "map" && methodName !== "filter") continue;
|
|
3210
|
+
context.report({
|
|
3211
|
+
node: attr,
|
|
3212
|
+
message: `<${elementName} data={items.${methodName}(...)}> allocates a fresh array per render — wrap in useMemo at list scope so the data reference stays stable across parent renders`
|
|
3213
|
+
});
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
} }) };
|
|
3217
|
+
const rnAnimationReactionAsDerived = { create: (context) => ({ CallExpression(node) {
|
|
3218
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "useAnimatedReaction") return;
|
|
3219
|
+
const reactionFn = node.arguments?.[1];
|
|
3220
|
+
if (!reactionFn) return;
|
|
3221
|
+
if (reactionFn.type !== "ArrowFunctionExpression" && reactionFn.type !== "FunctionExpression") return;
|
|
3222
|
+
const body = reactionFn.body;
|
|
3223
|
+
let singleAssignment = null;
|
|
3224
|
+
if (body?.type === "BlockStatement") {
|
|
3225
|
+
const statements = body.body ?? [];
|
|
3226
|
+
if (statements.length !== 1) return;
|
|
3227
|
+
const onlyStatement = statements[0];
|
|
3228
|
+
if (onlyStatement.type !== "ExpressionStatement") return;
|
|
3229
|
+
singleAssignment = onlyStatement.expression;
|
|
3230
|
+
} else if (body) singleAssignment = body;
|
|
3231
|
+
if (!singleAssignment) return;
|
|
3232
|
+
if (singleAssignment.type !== "AssignmentExpression") return;
|
|
3233
|
+
if (singleAssignment.left?.type !== "MemberExpression") return;
|
|
3234
|
+
if (singleAssignment.left.property?.type !== "Identifier") return;
|
|
3235
|
+
if (singleAssignment.left.property.name !== "value") return;
|
|
3236
|
+
context.report({
|
|
3237
|
+
node,
|
|
3238
|
+
message: "useAnimatedReaction body is a single shared-value assignment — useDerivedValue is shorter and tracks dependencies natively"
|
|
3239
|
+
});
|
|
3240
|
+
} }) };
|
|
3241
|
+
const JS_BOTTOM_SHEET_PACKAGES = new Set([
|
|
3242
|
+
"@gorhom/bottom-sheet",
|
|
3243
|
+
"react-native-bottom-sheet",
|
|
3244
|
+
"react-native-modal-bottom-sheet",
|
|
3245
|
+
"react-native-raw-bottom-sheet"
|
|
3246
|
+
]);
|
|
3247
|
+
const rnBottomSheetPreferNative = { create: (context) => ({ ImportDeclaration(node) {
|
|
3248
|
+
const source = node.source?.value;
|
|
3249
|
+
if (typeof source !== "string" || !JS_BOTTOM_SHEET_PACKAGES.has(source)) return;
|
|
3250
|
+
context.report({
|
|
3251
|
+
node,
|
|
3252
|
+
message: `${source} is a JS-implemented bottom sheet — for v7+ RN, prefer <Modal presentationStyle="formSheet"> for native gesture handling and snap points`
|
|
3253
|
+
});
|
|
3254
|
+
} }) };
|
|
3255
|
+
const rnScrollviewDynamicPadding = { create: (context) => ({ JSXOpeningElement(node) {
|
|
3256
|
+
const elementName = resolveJsxElementName(node);
|
|
3257
|
+
if (!elementName) return;
|
|
3258
|
+
if (!SCROLLVIEW_NAMES.has(elementName) && elementName !== "FlatList" && elementName !== "FlashList") return;
|
|
3259
|
+
for (const attr of node.attributes ?? []) {
|
|
3260
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
3261
|
+
if (attr.name?.type !== "JSXIdentifier" || attr.name.name !== "contentContainerStyle") continue;
|
|
3262
|
+
if (attr.value?.type !== "JSXExpressionContainer") continue;
|
|
3263
|
+
const expression = attr.value.expression;
|
|
3264
|
+
if (expression?.type !== "ObjectExpression") continue;
|
|
3265
|
+
for (const property of expression.properties ?? []) {
|
|
3266
|
+
if (property.type !== "Property") continue;
|
|
3267
|
+
if (property.key?.type !== "Identifier") continue;
|
|
3268
|
+
const key = property.key.name;
|
|
3269
|
+
if (key !== "paddingBottom" && key !== "paddingTop") continue;
|
|
3270
|
+
const value = property.value;
|
|
3271
|
+
if (!value) continue;
|
|
3272
|
+
if (value.type === "Literal") continue;
|
|
3273
|
+
context.report({
|
|
3274
|
+
node: property,
|
|
3275
|
+
message: `Dynamic ${key} on contentContainerStyle reflows the scroll content — use \`contentInset\` (OS-level offset, no relayout) instead`
|
|
3276
|
+
});
|
|
3277
|
+
return;
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
} }) };
|
|
3281
|
+
const LIST_ROW_PRESS_HANDLER_PROPS = new Set([
|
|
3282
|
+
"onPress",
|
|
3283
|
+
"onLongPress",
|
|
3284
|
+
"onPressIn",
|
|
3285
|
+
"onPressOut",
|
|
3286
|
+
"onSelect",
|
|
3287
|
+
"onClick"
|
|
3288
|
+
]);
|
|
3289
|
+
const detectInlineRowHandlers = (renderItemFn) => {
|
|
3290
|
+
const inlineHandlers = [];
|
|
3291
|
+
walkAst(renderItemFn.body, (child) => {
|
|
3292
|
+
if (child.type !== "JSXAttribute") return;
|
|
3293
|
+
if (child.name?.type !== "JSXIdentifier") return;
|
|
3294
|
+
if (!LIST_ROW_PRESS_HANDLER_PROPS.has(child.name.name)) return;
|
|
3295
|
+
if (child.value?.type !== "JSXExpressionContainer") return;
|
|
3296
|
+
const expression = child.value.expression;
|
|
3297
|
+
if (expression?.type === "ArrowFunctionExpression" || expression?.type === "FunctionExpression") inlineHandlers.push(child);
|
|
3298
|
+
});
|
|
3299
|
+
return inlineHandlers;
|
|
3300
|
+
};
|
|
3301
|
+
const isRenderItemJsxAttribute = (parent) => {
|
|
3302
|
+
if (parent?.type !== "JSXAttribute") return false;
|
|
3303
|
+
return (parent.name?.type === "JSXIdentifier" ? parent.name.name : null) === "renderItem";
|
|
3304
|
+
};
|
|
3305
|
+
const isRenderItemFunction = (node) => {
|
|
3306
|
+
const parent = node.parent;
|
|
3307
|
+
if (parent?.type !== "JSXExpressionContainer") return false;
|
|
3308
|
+
return isRenderItemJsxAttribute(parent.parent);
|
|
3309
|
+
};
|
|
3310
|
+
const rnListCallbackPerRow = { create: (context) => {
|
|
3311
|
+
const inspect = (node) => {
|
|
3312
|
+
if (!isRenderItemFunction(node)) return;
|
|
3313
|
+
const inlineHandlers = detectInlineRowHandlers(node);
|
|
3314
|
+
for (const handler of inlineHandlers) {
|
|
3315
|
+
const handlerName = handler.name?.type === "JSXIdentifier" ? handler.name.name : "<handler>";
|
|
3316
|
+
context.report({
|
|
3317
|
+
node: handler,
|
|
3318
|
+
message: `Inline ${handlerName} arrow inside renderItem creates a fresh closure per row — hoist with useCallback at list scope and pass the row id as a primitive prop`
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
};
|
|
3322
|
+
return {
|
|
3323
|
+
ArrowFunctionExpression: inspect,
|
|
3324
|
+
FunctionExpression: inspect
|
|
3325
|
+
};
|
|
3326
|
+
} };
|
|
3327
|
+
const LEGACY_SHADOW_KEYS = new Set([
|
|
3328
|
+
"shadowColor",
|
|
3329
|
+
"shadowOffset",
|
|
3330
|
+
"shadowOpacity",
|
|
3331
|
+
"shadowRadius",
|
|
3332
|
+
"elevation"
|
|
3333
|
+
]);
|
|
3334
|
+
const findLegacyShadowProperty = (objectExpression) => {
|
|
3335
|
+
for (const property of objectExpression.properties ?? []) {
|
|
3336
|
+
if (property.type !== "Property") continue;
|
|
3337
|
+
if (property.key?.type !== "Identifier") continue;
|
|
3338
|
+
if (LEGACY_SHADOW_KEYS.has(property.key.name)) return {
|
|
3339
|
+
keyName: property.key.name,
|
|
3340
|
+
node: property
|
|
3341
|
+
};
|
|
3342
|
+
}
|
|
3343
|
+
return null;
|
|
3344
|
+
};
|
|
3345
|
+
const rnStylePreferBoxShadow = { create: (context) => ({
|
|
3346
|
+
JSXAttribute(node) {
|
|
3347
|
+
if (node.name?.type !== "JSXIdentifier") return;
|
|
3348
|
+
const attrName = node.name.name;
|
|
3349
|
+
if (attrName !== "style" && !attrName.endsWith("Style")) return;
|
|
3350
|
+
if (node.value?.type !== "JSXExpressionContainer") return;
|
|
3351
|
+
const expression = node.value.expression;
|
|
3352
|
+
if (expression?.type !== "ObjectExpression") return;
|
|
3353
|
+
const match = findLegacyShadowProperty(expression);
|
|
3354
|
+
if (!match) return;
|
|
3355
|
+
context.report({
|
|
3356
|
+
node: match.node,
|
|
3357
|
+
message: `${match.keyName} is iOS/Android-platform-specific — use the cross-platform CSS \`boxShadow\` string (e.g. \`boxShadow: "0 2px 8px rgba(0,0,0,0.1)"\`) on RN v7+`
|
|
3358
|
+
});
|
|
3359
|
+
},
|
|
3360
|
+
CallExpression(node) {
|
|
3361
|
+
if (node.callee?.type !== "MemberExpression") return;
|
|
3362
|
+
if (node.callee.object?.type !== "Identifier") return;
|
|
3363
|
+
if (node.callee.object.name !== "StyleSheet") return;
|
|
3364
|
+
if (node.callee.property?.type !== "Identifier") return;
|
|
3365
|
+
if (node.callee.property.name !== "create") return;
|
|
3366
|
+
const arg = node.arguments?.[0];
|
|
3367
|
+
if (arg?.type !== "ObjectExpression") return;
|
|
3368
|
+
for (const property of arg.properties ?? []) {
|
|
3369
|
+
if (property.type !== "Property") continue;
|
|
3370
|
+
if (property.value?.type !== "ObjectExpression") continue;
|
|
3371
|
+
const match = findLegacyShadowProperty(property.value);
|
|
3372
|
+
if (!match) continue;
|
|
3373
|
+
context.report({
|
|
3374
|
+
node: match.node,
|
|
3375
|
+
message: `${match.keyName} is iOS/Android-platform-specific — use the cross-platform CSS \`boxShadow\` string on RN v7+`
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
}) };
|
|
3380
|
+
const RECYCLABLE_LIST_NAMES = new Set(["FlashList", "LegendList"]);
|
|
3381
|
+
const rnListRecyclableWithoutTypes = { create: (context) => ({ JSXOpeningElement(node) {
|
|
3382
|
+
const elementName = resolveJsxElementName(node);
|
|
3383
|
+
if (!elementName || !RECYCLABLE_LIST_NAMES.has(elementName)) return;
|
|
3384
|
+
let hasRecycleItemsEnabled = false;
|
|
3385
|
+
let hasGetItemType = false;
|
|
3386
|
+
for (const attr of node.attributes ?? []) {
|
|
3387
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
3388
|
+
if (attr.name?.type !== "JSXIdentifier") continue;
|
|
3389
|
+
if (attr.name.name === "recycleItems") if (!attr.value) hasRecycleItemsEnabled = true;
|
|
3390
|
+
else if (attr.value.type === "JSXExpressionContainer" && attr.value.expression?.type === "Literal") hasRecycleItemsEnabled = attr.value.expression.value === true;
|
|
3391
|
+
else hasRecycleItemsEnabled = true;
|
|
3392
|
+
if (attr.name.name === "getItemType") hasGetItemType = true;
|
|
3393
|
+
}
|
|
3394
|
+
if (hasRecycleItemsEnabled && !hasGetItemType) context.report({
|
|
3395
|
+
node,
|
|
3396
|
+
message: `<${elementName} recycleItems> without \`getItemType\` — heterogeneous rows mount into the wrong recycled cells. Add \`getItemType={item => item.kind}\` so FlashList keeps separate recycle pools per type`
|
|
3397
|
+
});
|
|
3398
|
+
} }) };
|
|
2047
3399
|
//#endregion
|
|
2048
3400
|
//#region src/plugin/rules/tanstack-query.ts
|
|
2049
3401
|
const queryStableQueryClient = { create: (context) => {
|
|
@@ -2063,15 +3415,15 @@ const queryStableQueryClient = { create: (context) => {
|
|
|
2063
3415
|
if (node.id?.type === "Identifier" && UPPERCASE_PATTERN.test(node.id.name) && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) componentDepth--;
|
|
2064
3416
|
},
|
|
2065
3417
|
CallExpression(node) {
|
|
2066
|
-
if (node
|
|
3418
|
+
if (isHookCall(node, STABLE_HOOK_WRAPPERS)) stableHookDepth++;
|
|
2067
3419
|
},
|
|
2068
3420
|
"CallExpression:exit"(node) {
|
|
2069
|
-
if (node
|
|
3421
|
+
if (isHookCall(node, STABLE_HOOK_WRAPPERS)) stableHookDepth = Math.max(0, stableHookDepth - 1);
|
|
2070
3422
|
},
|
|
2071
3423
|
NewExpression(node) {
|
|
2072
3424
|
if (componentDepth <= 0) return;
|
|
2073
3425
|
if (stableHookDepth > 0) return;
|
|
2074
|
-
if (node.callee?.type !== "Identifier" || node.callee.name !==
|
|
3426
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "QueryClient") return;
|
|
2075
3427
|
context.report({
|
|
2076
3428
|
node,
|
|
2077
3429
|
message: "new QueryClient() inside a component — creates a new cache on every render. Move to module scope or wrap in useState(() => new QueryClient())"
|
|
@@ -2125,14 +3477,17 @@ const queryMutationMissingInvalidation = { create: (context) => ({ CallExpressio
|
|
|
2125
3477
|
const optionsArgument = node.arguments?.[0];
|
|
2126
3478
|
if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return;
|
|
2127
3479
|
if (!optionsArgument.properties?.some((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "mutationFn")) return;
|
|
2128
|
-
let
|
|
3480
|
+
let hasCacheUpdate = false;
|
|
2129
3481
|
walkAst(optionsArgument, (child) => {
|
|
2130
|
-
if (
|
|
2131
|
-
if (child.type === "CallExpression" && child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier" && child.callee.property.name
|
|
3482
|
+
if (hasCacheUpdate) return false;
|
|
3483
|
+
if (child.type === "CallExpression" && child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier" && QUERY_CACHE_UPDATE_METHODS.has(child.callee.property.name)) {
|
|
3484
|
+
hasCacheUpdate = true;
|
|
3485
|
+
return false;
|
|
3486
|
+
}
|
|
2132
3487
|
});
|
|
2133
|
-
if (!
|
|
3488
|
+
if (!hasCacheUpdate) context.report({
|
|
2134
3489
|
node,
|
|
2135
|
-
message: "useMutation without
|
|
3490
|
+
message: "useMutation without a cache update — stale data may remain after the mutation. Call queryClient.invalidateQueries / setQueryData / resetQueries / refetchQueries inside onSuccess (or trigger a router refresh)"
|
|
2136
3491
|
});
|
|
2137
3492
|
} }) };
|
|
2138
3493
|
const queryNoUseQueryForMutation = { create: (context) => ({ CallExpression(node) {
|
|
@@ -2156,7 +3511,6 @@ const queryNoUseQueryForMutation = { create: (context) => ({ CallExpression(node
|
|
|
2156
3511
|
message: `${calleeName}() with a mutating fetch (POST/PUT/DELETE) — use useMutation() instead, which provides onSuccess/onError callbacks and doesn't auto-refetch`
|
|
2157
3512
|
});
|
|
2158
3513
|
} }) };
|
|
2159
|
-
|
|
2160
3514
|
//#endregion
|
|
2161
3515
|
//#region src/plugin/rules/security.ts
|
|
2162
3516
|
const noEval = { create: (context) => ({
|
|
@@ -2187,7 +3541,7 @@ const noSecretsInClientCode = { create: (context) => ({ VariableDeclarator(node)
|
|
|
2187
3541
|
const literalValue = node.init.value;
|
|
2188
3542
|
const trailingSuffix = variableName.split("_").pop()?.toLowerCase() ?? "";
|
|
2189
3543
|
const isUiConstant = SECRET_FALSE_POSITIVE_SUFFIXES.has(trailingSuffix);
|
|
2190
|
-
if (SECRET_VARIABLE_PATTERN.test(variableName) && !isUiConstant && literalValue.length >
|
|
3544
|
+
if (SECRET_VARIABLE_PATTERN.test(variableName) && !isUiConstant && literalValue.length > 24) {
|
|
2191
3545
|
context.report({
|
|
2192
3546
|
node,
|
|
2193
3547
|
message: `Possible hardcoded secret in "${variableName}" — use environment variables instead`
|
|
@@ -2199,7 +3553,6 @@ const noSecretsInClientCode = { create: (context) => ({ VariableDeclarator(node)
|
|
|
2199
3553
|
message: "Hardcoded secret detected — use environment variables instead"
|
|
2200
3554
|
});
|
|
2201
3555
|
} }) };
|
|
2202
|
-
|
|
2203
3556
|
//#endregion
|
|
2204
3557
|
//#region src/plugin/rules/server.ts
|
|
2205
3558
|
const containsAuthCheck = (statements) => {
|
|
@@ -2223,7 +3576,7 @@ const serverAuthActions = { create: (context) => {
|
|
|
2223
3576
|
const declaration = node.declaration;
|
|
2224
3577
|
if (declaration?.type !== "FunctionDeclaration" || !declaration?.async) return;
|
|
2225
3578
|
if (!(fileHasUseServerDirective || hasUseServerDirective(declaration))) return;
|
|
2226
|
-
if (!containsAuthCheck((declaration.body?.body ?? []).slice(0,
|
|
3579
|
+
if (!containsAuthCheck((declaration.body?.body ?? []).slice(0, 10))) {
|
|
2227
3580
|
const functionName = declaration.id?.name ?? "anonymous";
|
|
2228
3581
|
context.report({
|
|
2229
3582
|
node: declaration.id ?? node,
|
|
@@ -2233,28 +3586,384 @@ const serverAuthActions = { create: (context) => {
|
|
|
2233
3586
|
}
|
|
2234
3587
|
};
|
|
2235
3588
|
} };
|
|
3589
|
+
const MUTABLE_CONTAINER_CONSTRUCTORS = new Set([
|
|
3590
|
+
"Map",
|
|
3591
|
+
"Set",
|
|
3592
|
+
"WeakMap",
|
|
3593
|
+
"WeakSet"
|
|
3594
|
+
]);
|
|
3595
|
+
const isMutableConstInitializer = (init) => {
|
|
3596
|
+
if (!init) return null;
|
|
3597
|
+
if (init.type === "ArrayExpression") return "[]";
|
|
3598
|
+
if (init.type === "ObjectExpression") return "{}";
|
|
3599
|
+
if (init.type === "NewExpression" && init.callee?.type === "Identifier" && MUTABLE_CONTAINER_CONSTRUCTORS.has(init.callee.name)) return `new ${init.callee.name}()`;
|
|
3600
|
+
return null;
|
|
3601
|
+
};
|
|
3602
|
+
const serverNoMutableModuleState = { create: (context) => {
|
|
3603
|
+
let fileHasUseServerDirective = false;
|
|
3604
|
+
return {
|
|
3605
|
+
Program(programNode) {
|
|
3606
|
+
fileHasUseServerDirective = hasDirective(programNode, "use server");
|
|
3607
|
+
},
|
|
3608
|
+
VariableDeclaration(node) {
|
|
3609
|
+
if (!fileHasUseServerDirective) return;
|
|
3610
|
+
if (node.parent?.type !== "Program") return;
|
|
3611
|
+
for (const declarator of node.declarations ?? []) {
|
|
3612
|
+
const variableName = declarator.id?.type === "Identifier" ? declarator.id.name : "<unnamed>";
|
|
3613
|
+
if (node.kind === "let" || node.kind === "var") {
|
|
3614
|
+
context.report({
|
|
3615
|
+
node: declarator,
|
|
3616
|
+
message: `Module-scoped ${node.kind} "${variableName}" in a "use server" file — this is shared across requests; move per-request data into the action body`
|
|
3617
|
+
});
|
|
3618
|
+
continue;
|
|
3619
|
+
}
|
|
3620
|
+
const containerKind = isMutableConstInitializer(declarator.init);
|
|
3621
|
+
if (containerKind) context.report({
|
|
3622
|
+
node: declarator,
|
|
3623
|
+
message: `Module-scoped const "${variableName} = ${containerKind}" in a "use server" file — the container itself is shared across requests; move per-request data into the action body`
|
|
3624
|
+
});
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
};
|
|
3628
|
+
} };
|
|
3629
|
+
const serverCacheWithObjectLiteral = { create: (context) => {
|
|
3630
|
+
const cachedFunctionNames = /* @__PURE__ */ new Set();
|
|
3631
|
+
return {
|
|
3632
|
+
VariableDeclarator(node) {
|
|
3633
|
+
if (node.id?.type !== "Identifier") return;
|
|
3634
|
+
const init = node.init;
|
|
3635
|
+
if (init?.type !== "CallExpression") return;
|
|
3636
|
+
const callee = init.callee;
|
|
3637
|
+
if (!(callee?.type === "Identifier" && callee.name === "cache" || callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "React" && callee.property?.type === "Identifier" && callee.property.name === "cache")) return;
|
|
3638
|
+
cachedFunctionNames.add(node.id.name);
|
|
3639
|
+
},
|
|
3640
|
+
CallExpression(node) {
|
|
3641
|
+
if (node.callee?.type !== "Identifier") return;
|
|
3642
|
+
if (!cachedFunctionNames.has(node.callee.name)) return;
|
|
3643
|
+
if ((node.arguments?.[0])?.type !== "ObjectExpression") return;
|
|
3644
|
+
context.report({
|
|
3645
|
+
node,
|
|
3646
|
+
message: `${node.callee.name} is React.cache()-wrapped, but you're passing an object literal — the cache keys on argument identity, so a fresh {} per render bypasses dedup. Pass primitives or hoist the object`
|
|
3647
|
+
});
|
|
3648
|
+
}
|
|
3649
|
+
};
|
|
3650
|
+
} };
|
|
3651
|
+
const CONSOLE_DEFERRABLE_METHODS = new Set([
|
|
3652
|
+
"log",
|
|
3653
|
+
"info",
|
|
3654
|
+
"warn"
|
|
3655
|
+
]);
|
|
3656
|
+
const ANALYTICS_DEFERRABLE_OBJECTS = new Set([
|
|
3657
|
+
"analytics",
|
|
3658
|
+
"posthog",
|
|
3659
|
+
"mixpanel",
|
|
3660
|
+
"segment",
|
|
3661
|
+
"amplitude",
|
|
3662
|
+
"datadog",
|
|
3663
|
+
"sentry"
|
|
3664
|
+
]);
|
|
3665
|
+
const ANALYTICS_DEFERRABLE_METHODS = new Set([
|
|
3666
|
+
"track",
|
|
3667
|
+
"identify",
|
|
3668
|
+
"page",
|
|
3669
|
+
"capture",
|
|
3670
|
+
"captureMessage",
|
|
3671
|
+
"captureException",
|
|
3672
|
+
"log"
|
|
3673
|
+
]);
|
|
3674
|
+
const isDeferrableSideEffectCall = (objectName, methodName) => {
|
|
3675
|
+
if (objectName === "console") return CONSOLE_DEFERRABLE_METHODS.has(methodName);
|
|
3676
|
+
if (ANALYTICS_DEFERRABLE_OBJECTS.has(objectName)) return ANALYTICS_DEFERRABLE_METHODS.has(methodName);
|
|
3677
|
+
return false;
|
|
3678
|
+
};
|
|
2236
3679
|
const serverAfterNonblocking = { create: (context) => {
|
|
2237
3680
|
let fileHasUseServerDirective = false;
|
|
3681
|
+
let serverFunctionDepth = 0;
|
|
3682
|
+
const enterIfServerFunction = (node) => {
|
|
3683
|
+
if (hasUseServerDirective(node)) serverFunctionDepth++;
|
|
3684
|
+
};
|
|
3685
|
+
const leaveIfServerFunction = (node) => {
|
|
3686
|
+
if (hasUseServerDirective(node)) serverFunctionDepth = Math.max(0, serverFunctionDepth - 1);
|
|
3687
|
+
};
|
|
2238
3688
|
return {
|
|
2239
3689
|
Program(programNode) {
|
|
2240
3690
|
fileHasUseServerDirective = hasDirective(programNode, "use server");
|
|
2241
3691
|
},
|
|
3692
|
+
FunctionDeclaration: enterIfServerFunction,
|
|
3693
|
+
"FunctionDeclaration:exit": leaveIfServerFunction,
|
|
3694
|
+
FunctionExpression: enterIfServerFunction,
|
|
3695
|
+
"FunctionExpression:exit": leaveIfServerFunction,
|
|
3696
|
+
ArrowFunctionExpression: enterIfServerFunction,
|
|
3697
|
+
"ArrowFunctionExpression:exit": leaveIfServerFunction,
|
|
2242
3698
|
CallExpression(node) {
|
|
2243
|
-
if (!fileHasUseServerDirective) return;
|
|
3699
|
+
if (!fileHasUseServerDirective && serverFunctionDepth === 0) return;
|
|
2244
3700
|
if (node.callee?.type !== "MemberExpression") return;
|
|
2245
3701
|
if (node.callee.property?.type !== "Identifier") return;
|
|
2246
3702
|
const objectName = node.callee.object?.type === "Identifier" ? node.callee.object.name : null;
|
|
2247
3703
|
if (!objectName) return;
|
|
2248
3704
|
const methodName = node.callee.property.name;
|
|
2249
|
-
if (!(objectName
|
|
3705
|
+
if (!isDeferrableSideEffectCall(objectName, methodName)) return;
|
|
2250
3706
|
context.report({
|
|
2251
3707
|
node,
|
|
2252
|
-
message: `${objectName}.${methodName}() in server action —
|
|
3708
|
+
message: `${objectName}.${methodName}() in server action — wrap in \`after(() => ${objectName}.${methodName}(...))\` so it doesn't delay the user-visible response`
|
|
3709
|
+
});
|
|
3710
|
+
}
|
|
3711
|
+
};
|
|
3712
|
+
} };
|
|
3713
|
+
const ROUTE_HANDLER_HTTP_METHODS = new Set([
|
|
3714
|
+
"GET",
|
|
3715
|
+
"POST",
|
|
3716
|
+
"PUT",
|
|
3717
|
+
"PATCH",
|
|
3718
|
+
"DELETE",
|
|
3719
|
+
"OPTIONS",
|
|
3720
|
+
"HEAD"
|
|
3721
|
+
]);
|
|
3722
|
+
const STATIC_IO_FUNCTIONS = new Set([
|
|
3723
|
+
"readFileSync",
|
|
3724
|
+
"readFile",
|
|
3725
|
+
"readdir",
|
|
3726
|
+
"readdirSync",
|
|
3727
|
+
"stat",
|
|
3728
|
+
"statSync",
|
|
3729
|
+
"access",
|
|
3730
|
+
"accessSync"
|
|
3731
|
+
]);
|
|
3732
|
+
const isStaticIoCall = (call) => {
|
|
3733
|
+
if (call.type !== "CallExpression") return false;
|
|
3734
|
+
const callee = call.callee;
|
|
3735
|
+
if (callee?.type === "Identifier" && STATIC_IO_FUNCTIONS.has(callee.name)) return true;
|
|
3736
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
3737
|
+
const propertyName = callee.property?.type === "Identifier" ? callee.property.name : null;
|
|
3738
|
+
if (!propertyName || !STATIC_IO_FUNCTIONS.has(propertyName)) return false;
|
|
3739
|
+
return true;
|
|
3740
|
+
};
|
|
3741
|
+
const isFetchOfImportMetaUrl = (call) => {
|
|
3742
|
+
if (call.type !== "CallExpression") return false;
|
|
3743
|
+
if (call.callee?.type !== "Identifier" || call.callee.name !== "fetch") return false;
|
|
3744
|
+
const arg = call.arguments?.[0];
|
|
3745
|
+
if (!arg) return false;
|
|
3746
|
+
if (arg.type !== "NewExpression") return false;
|
|
3747
|
+
if (arg.callee?.type !== "Identifier" || arg.callee.name !== "URL") return false;
|
|
3748
|
+
const secondArg = arg.arguments?.[1];
|
|
3749
|
+
if (!secondArg) return false;
|
|
3750
|
+
return secondArg.type === "MemberExpression" && secondArg.object?.type === "MetaProperty" && secondArg.property?.type === "Identifier" && secondArg.property.name === "url";
|
|
3751
|
+
};
|
|
3752
|
+
const callReadsHandlerArgs = (call, handlerParamNames) => {
|
|
3753
|
+
if (handlerParamNames.size === 0) return false;
|
|
3754
|
+
let referencesArg = false;
|
|
3755
|
+
walkAst(call, (child) => {
|
|
3756
|
+
if (referencesArg) return;
|
|
3757
|
+
if (child.type === "Identifier" && handlerParamNames.has(child.name)) referencesArg = true;
|
|
3758
|
+
});
|
|
3759
|
+
return referencesArg;
|
|
3760
|
+
};
|
|
3761
|
+
const DERIVING_ARRAY_METHODS = new Set([
|
|
3762
|
+
"toSorted",
|
|
3763
|
+
"toReversed",
|
|
3764
|
+
"filter",
|
|
3765
|
+
"map",
|
|
3766
|
+
"slice"
|
|
3767
|
+
]);
|
|
3768
|
+
const getRootIdentifierName = (node) => {
|
|
3769
|
+
let cursor = node;
|
|
3770
|
+
while (cursor && (cursor.type === "MemberExpression" || cursor.type === "CallExpression")) if (cursor.type === "MemberExpression") cursor = cursor.object;
|
|
3771
|
+
else if (cursor.type === "CallExpression") {
|
|
3772
|
+
const callee = cursor.callee;
|
|
3773
|
+
if (callee?.type === "MemberExpression") cursor = callee.object;
|
|
3774
|
+
else return null;
|
|
3775
|
+
}
|
|
3776
|
+
return cursor?.type === "Identifier" ? cursor.name : null;
|
|
3777
|
+
};
|
|
3778
|
+
const expressionDerivesFromIdentifier = (node, identifierName) => {
|
|
3779
|
+
if (node.type !== "CallExpression") return false;
|
|
3780
|
+
const callee = node.callee;
|
|
3781
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
3782
|
+
if (callee.property?.type !== "Identifier") return false;
|
|
3783
|
+
if (!DERIVING_ARRAY_METHODS.has(callee.property.name)) return false;
|
|
3784
|
+
return getRootIdentifierName(callee) === identifierName;
|
|
3785
|
+
};
|
|
3786
|
+
const serverDedupProps = { create: (context) => ({ JSXOpeningElement(node) {
|
|
3787
|
+
const identifierAttributes = /* @__PURE__ */ new Map();
|
|
3788
|
+
const derivedAttributes = [];
|
|
3789
|
+
for (const attr of node.attributes ?? []) {
|
|
3790
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
3791
|
+
if (attr.name?.type !== "JSXIdentifier") continue;
|
|
3792
|
+
if (attr.value?.type !== "JSXExpressionContainer") continue;
|
|
3793
|
+
const expression = attr.value.expression;
|
|
3794
|
+
if (!expression) continue;
|
|
3795
|
+
if (expression.type === "Identifier") identifierAttributes.set(expression.name, attr.name.name);
|
|
3796
|
+
else if (expression.type === "CallExpression") {
|
|
3797
|
+
const root = getRootIdentifierName(expression);
|
|
3798
|
+
if (root && DERIVING_ARRAY_METHODS.has(getDerivingMethodName(expression) ?? "")) {
|
|
3799
|
+
if (expressionDerivesFromIdentifier(expression, root)) derivedAttributes.push({
|
|
3800
|
+
propName: attr.name.name,
|
|
3801
|
+
rootName: root,
|
|
3802
|
+
node: attr
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
for (const derived of derivedAttributes) {
|
|
3808
|
+
const sourcePropName = identifierAttributes.get(derived.rootName);
|
|
3809
|
+
if (sourcePropName) context.report({
|
|
3810
|
+
node: derived.node,
|
|
3811
|
+
message: `"${derived.propName}" is derived from "${sourcePropName}" (same source: ${derived.rootName}) — passing both doubles RSC serialization. Pass the source once and derive on the client`
|
|
3812
|
+
});
|
|
3813
|
+
}
|
|
3814
|
+
} }) };
|
|
3815
|
+
const getDerivingMethodName = (node) => {
|
|
3816
|
+
if (node.type !== "CallExpression") return null;
|
|
3817
|
+
if (node.callee?.type !== "MemberExpression") return null;
|
|
3818
|
+
if (node.callee.property?.type !== "Identifier") return null;
|
|
3819
|
+
return node.callee.property.name;
|
|
3820
|
+
};
|
|
3821
|
+
const PAGES_ROUTER_API_PATH_PATTERN = /\/pages\/api\//;
|
|
3822
|
+
const inspectHandlerBody = (context, handlerBody, handlerLabel, handlerParamNames) => {
|
|
3823
|
+
walkAst(handlerBody, (child) => {
|
|
3824
|
+
let staticCall = null;
|
|
3825
|
+
if (isStaticIoCall(child)) staticCall = child;
|
|
3826
|
+
else if (isFetchOfImportMetaUrl(child)) staticCall = child;
|
|
3827
|
+
else if (child.type === "AwaitExpression" && child.argument && (isStaticIoCall(child.argument) || isFetchOfImportMetaUrl(child.argument))) staticCall = child.argument;
|
|
3828
|
+
if (!staticCall) return;
|
|
3829
|
+
if (callReadsHandlerArgs(staticCall, handlerParamNames)) return;
|
|
3830
|
+
const calleeText = staticCall.callee?.type === "MemberExpression" && staticCall.callee.property?.type === "Identifier" ? `${staticCall.callee.object?.type === "Identifier" ? staticCall.callee.object.name : "?"}.${staticCall.callee.property.name}` : staticCall.callee?.type === "Identifier" ? staticCall.callee.name : "io";
|
|
3831
|
+
context.report({
|
|
3832
|
+
node: staticCall,
|
|
3833
|
+
message: `${calleeText}() in ${handlerLabel} reads the same static asset every request — hoist to module scope so the read happens once at module load`
|
|
3834
|
+
});
|
|
3835
|
+
});
|
|
3836
|
+
};
|
|
3837
|
+
const collectIdentifierParams = (params) => {
|
|
3838
|
+
const names = /* @__PURE__ */ new Set();
|
|
3839
|
+
for (const param of params) if (param.type === "Identifier") names.add(param.name);
|
|
3840
|
+
return names;
|
|
3841
|
+
};
|
|
3842
|
+
const serverHoistStaticIo = { create: (context) => ({
|
|
3843
|
+
ExportNamedDeclaration(node) {
|
|
3844
|
+
const declaration = node.declaration;
|
|
3845
|
+
if (declaration?.type !== "FunctionDeclaration") return;
|
|
3846
|
+
const handlerName = declaration.id?.name;
|
|
3847
|
+
if (!handlerName || !ROUTE_HANDLER_HTTP_METHODS.has(handlerName)) return;
|
|
3848
|
+
if (declaration.body?.type !== "BlockStatement") return;
|
|
3849
|
+
inspectHandlerBody(context, declaration.body, `${handlerName} route handler`, collectIdentifierParams(declaration.params ?? []));
|
|
3850
|
+
},
|
|
3851
|
+
ExportDefaultDeclaration(node) {
|
|
3852
|
+
const filename = context.getFilename?.() ?? "";
|
|
3853
|
+
if (!PAGES_ROUTER_API_PATH_PATTERN.test(filename)) return;
|
|
3854
|
+
const declaration = node.declaration;
|
|
3855
|
+
if (!declaration || declaration.type !== "FunctionDeclaration" && declaration.type !== "FunctionExpression" && declaration.type !== "ArrowFunctionExpression") return;
|
|
3856
|
+
if (!declaration.async) return;
|
|
3857
|
+
const body = declaration.body;
|
|
3858
|
+
if (body?.type !== "BlockStatement") return;
|
|
3859
|
+
inspectHandlerBody(context, body, "pages/api handler", collectIdentifierParams(declaration.params ?? []));
|
|
3860
|
+
}
|
|
3861
|
+
}) };
|
|
3862
|
+
const collectDeclaredNames = (declaration) => {
|
|
3863
|
+
const names = /* @__PURE__ */ new Set();
|
|
3864
|
+
for (const declarator of declaration.declarations ?? []) if (declarator.id?.type === "Identifier") names.add(declarator.id.name);
|
|
3865
|
+
else if (declarator.id?.type === "ObjectPattern") {
|
|
3866
|
+
for (const property of declarator.id.properties ?? []) if (property.type === "Property" && property.value?.type === "Identifier") names.add(property.value.name);
|
|
3867
|
+
else if (property.type === "RestElement" && property.argument?.type === "Identifier") names.add(property.argument.name);
|
|
3868
|
+
} else if (declarator.id?.type === "ArrayPattern") {
|
|
3869
|
+
for (const element of declarator.id.elements ?? []) if (element?.type === "Identifier") names.add(element.name);
|
|
3870
|
+
}
|
|
3871
|
+
return names;
|
|
3872
|
+
};
|
|
3873
|
+
const declarationStartsWithAwait = (declaration) => {
|
|
3874
|
+
for (const declarator of declaration.declarations ?? []) if (declarator.init?.type === "AwaitExpression") return true;
|
|
3875
|
+
return false;
|
|
3876
|
+
};
|
|
3877
|
+
const declarationReadsAnyName = (declaration, names) => {
|
|
3878
|
+
if (names.size === 0) return false;
|
|
3879
|
+
let didRead = false;
|
|
3880
|
+
walkAst(declaration, (child) => {
|
|
3881
|
+
if (didRead) return;
|
|
3882
|
+
if (child.type === "Identifier" && names.has(child.name)) didRead = true;
|
|
3883
|
+
});
|
|
3884
|
+
return didRead;
|
|
3885
|
+
};
|
|
3886
|
+
const serverSequentialIndependentAwait = { create: (context) => {
|
|
3887
|
+
const inspectStatements = (statements) => {
|
|
3888
|
+
for (let statementIndex = 0; statementIndex < statements.length - 1; statementIndex++) {
|
|
3889
|
+
const currentStatement = statements[statementIndex];
|
|
3890
|
+
if (currentStatement.type !== "VariableDeclaration") continue;
|
|
3891
|
+
if (!declarationStartsWithAwait(currentStatement)) continue;
|
|
3892
|
+
const declaredNames = collectDeclaredNames(currentStatement);
|
|
3893
|
+
const nextStatement = statements[statementIndex + 1];
|
|
3894
|
+
if (nextStatement.type !== "VariableDeclaration") continue;
|
|
3895
|
+
if (!declarationStartsWithAwait(nextStatement)) continue;
|
|
3896
|
+
if (declarationReadsAnyName(nextStatement, declaredNames)) continue;
|
|
3897
|
+
context.report({
|
|
3898
|
+
node: nextStatement,
|
|
3899
|
+
message: "Sequential `await` without a data dependency on the previous result — wrap the independent calls in `Promise.all([...])` so they race instead of waterfalling"
|
|
3900
|
+
});
|
|
3901
|
+
statementIndex++;
|
|
3902
|
+
}
|
|
3903
|
+
};
|
|
3904
|
+
const visitFunctionBody = (node) => {
|
|
3905
|
+
if (!node.async) return;
|
|
3906
|
+
if (node.body?.type !== "BlockStatement") return;
|
|
3907
|
+
inspectStatements(node.body.body ?? []);
|
|
3908
|
+
};
|
|
3909
|
+
return {
|
|
3910
|
+
FunctionDeclaration: visitFunctionBody,
|
|
3911
|
+
FunctionExpression: visitFunctionBody,
|
|
3912
|
+
ArrowFunctionExpression: visitFunctionBody
|
|
3913
|
+
};
|
|
3914
|
+
} };
|
|
3915
|
+
const isFetchCall = (node) => {
|
|
3916
|
+
if (node.type !== "CallExpression") return false;
|
|
3917
|
+
return node.callee?.type === "Identifier" && node.callee.name === "fetch";
|
|
3918
|
+
};
|
|
3919
|
+
const objectExpressionHasNextRevalidate = (objectExpression) => {
|
|
3920
|
+
if (objectExpression.type !== "ObjectExpression") return false;
|
|
3921
|
+
for (const property of objectExpression.properties ?? []) {
|
|
3922
|
+
if (property.type !== "Property") continue;
|
|
3923
|
+
if (property.key?.type !== "Identifier") continue;
|
|
3924
|
+
if (property.key.name === "cache") return true;
|
|
3925
|
+
if (property.key.name !== "next") continue;
|
|
3926
|
+
if (property.value?.type !== "ObjectExpression") return true;
|
|
3927
|
+
for (const innerProperty of property.value.properties ?? []) {
|
|
3928
|
+
if (innerProperty.type !== "Property") continue;
|
|
3929
|
+
if (innerProperty.key?.type !== "Identifier") continue;
|
|
3930
|
+
if (innerProperty.key.name === "revalidate" || innerProperty.key.name === "tags") return true;
|
|
3931
|
+
}
|
|
3932
|
+
return true;
|
|
3933
|
+
}
|
|
3934
|
+
return false;
|
|
3935
|
+
};
|
|
3936
|
+
const APP_ROUTER_FILE_PATTERN = /\/app\/(?:[^/]+\/)*(?:route|page|layout|template|loading|error|default)\.(?:tsx?|jsx?)$/;
|
|
3937
|
+
const NON_PROJECT_PATH_PATTERN = /\/(?:node_modules|dist|build|\.next)\//;
|
|
3938
|
+
const serverFetchWithoutRevalidate = { create: (context) => {
|
|
3939
|
+
let isServerSideFile = false;
|
|
3940
|
+
return {
|
|
3941
|
+
Program(node) {
|
|
3942
|
+
const filename = context.getFilename?.() ?? "";
|
|
3943
|
+
if (!APP_ROUTER_FILE_PATTERN.test(filename)) {
|
|
3944
|
+
isServerSideFile = false;
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
if (NON_PROJECT_PATH_PATTERN.test(filename)) {
|
|
3948
|
+
isServerSideFile = false;
|
|
3949
|
+
return;
|
|
3950
|
+
}
|
|
3951
|
+
isServerSideFile = !(node.body ?? []).some((statement) => statement.type === "ExpressionStatement" && statement.expression?.type === "Literal" && statement.expression.value === "use client");
|
|
3952
|
+
},
|
|
3953
|
+
CallExpression(node) {
|
|
3954
|
+
if (!isServerSideFile) return;
|
|
3955
|
+
if (!isFetchCall(node)) return;
|
|
3956
|
+
const optionsArg = node.arguments?.[1];
|
|
3957
|
+
if (optionsArg && objectExpressionHasNextRevalidate(optionsArg)) return;
|
|
3958
|
+
const urlArg = node.arguments?.[0];
|
|
3959
|
+
const urlText = urlArg?.type === "Literal" && typeof urlArg.value === "string" ? `"${urlArg.value}"` : "url";
|
|
3960
|
+
context.report({
|
|
3961
|
+
node,
|
|
3962
|
+
message: `fetch(${urlText}) in a Server Component / route handler defaults to forever-caching — pass { next: { revalidate: <seconds> } } / { next: { tags: [...] } } / { cache: "no-store" } so stale data doesn't quietly persist`
|
|
2253
3963
|
});
|
|
2254
3964
|
}
|
|
2255
3965
|
};
|
|
2256
3966
|
} };
|
|
2257
|
-
|
|
2258
3967
|
//#endregion
|
|
2259
3968
|
//#region src/plugin/rules/tanstack-start.ts
|
|
2260
3969
|
const getRouteOptionsObject = (node) => {
|
|
@@ -2362,8 +4071,7 @@ const tanstackStartServerFnValidateInput = { create: (context) => ({ CallExpress
|
|
|
2362
4071
|
const tanstackStartNoUseEffectFetch = { create: (context) => ({ CallExpression(node) {
|
|
2363
4072
|
const filename = context.getFilename?.() ?? "";
|
|
2364
4073
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2365
|
-
if (node
|
|
2366
|
-
if (node.callee.name !== "useEffect" && node.callee.name !== "useLayoutEffect") return;
|
|
4074
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
|
|
2367
4075
|
const callback = node.arguments?.[0];
|
|
2368
4076
|
if (!callback) return;
|
|
2369
4077
|
let hasFetchCall = false;
|
|
@@ -2438,15 +4146,17 @@ const tanstackStartServerFnMethodOrder = { create: (context) => ({ CallExpressio
|
|
|
2438
4146
|
}
|
|
2439
4147
|
} }) };
|
|
2440
4148
|
const tanstackStartNoNavigateInRender = { create: (context) => {
|
|
2441
|
-
let
|
|
4149
|
+
let deferredCallbackDepth = 0;
|
|
2442
4150
|
let eventHandlerDepth = 0;
|
|
4151
|
+
const isDeferredHookCall = (node) => isHookCall(node, EFFECT_HOOK_NAMES) || isHookCall(node, "useCallback") || isHookCall(node, "useMemo");
|
|
4152
|
+
const isEventHandlerAttribute = (node) => node.name?.type === "JSXIdentifier" && typeof node.name.name === "string" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2));
|
|
2443
4153
|
return {
|
|
2444
4154
|
CallExpression(node) {
|
|
2445
4155
|
const filename = context.getFilename?.() ?? "";
|
|
2446
4156
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2447
|
-
if (
|
|
2448
|
-
if (
|
|
2449
|
-
if (node.callee?.type === "Identifier" && node.callee.name === "navigate" && node.arguments?.length > 0) context.report({
|
|
4157
|
+
if (isDeferredHookCall(node)) deferredCallbackDepth++;
|
|
4158
|
+
if (deferredCallbackDepth > 0 || eventHandlerDepth > 0) return;
|
|
4159
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "navigate" && (node.arguments?.length ?? 0) > 0) context.report({
|
|
2450
4160
|
node,
|
|
2451
4161
|
message: "navigate() called during render — use redirect() in beforeLoad/loader for route-level redirects"
|
|
2452
4162
|
});
|
|
@@ -2454,17 +4164,17 @@ const tanstackStartNoNavigateInRender = { create: (context) => {
|
|
|
2454
4164
|
"CallExpression:exit"(node) {
|
|
2455
4165
|
const filename = context.getFilename?.() ?? "";
|
|
2456
4166
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2457
|
-
if (node
|
|
4167
|
+
if (isDeferredHookCall(node)) deferredCallbackDepth = Math.max(0, deferredCallbackDepth - 1);
|
|
2458
4168
|
},
|
|
2459
4169
|
JSXAttribute(node) {
|
|
2460
4170
|
const filename = context.getFilename?.() ?? "";
|
|
2461
4171
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2462
|
-
if (
|
|
4172
|
+
if (isEventHandlerAttribute(node)) eventHandlerDepth++;
|
|
2463
4173
|
},
|
|
2464
4174
|
"JSXAttribute:exit"(node) {
|
|
2465
4175
|
const filename = context.getFilename?.() ?? "";
|
|
2466
4176
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2467
|
-
if (node
|
|
4177
|
+
if (isEventHandlerAttribute(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
|
|
2468
4178
|
}
|
|
2469
4179
|
};
|
|
2470
4180
|
} };
|
|
@@ -2491,6 +4201,17 @@ const tanstackStartNoUseServerInHandler = { create: (context) => ({ CallExpressi
|
|
|
2491
4201
|
message: "\"use server\" inside createServerFn handler — TanStack Start handles this automatically, remove the directive"
|
|
2492
4202
|
});
|
|
2493
4203
|
} }) };
|
|
4204
|
+
const SAFE_BUILD_ENV_VARS = new Set([
|
|
4205
|
+
"NODE_ENV",
|
|
4206
|
+
"MODE",
|
|
4207
|
+
"DEV",
|
|
4208
|
+
"PROD"
|
|
4209
|
+
]);
|
|
4210
|
+
const SECRET_KEYWORD_PATTERN = /(?:secret|token|api[_]?key|password|private)/i;
|
|
4211
|
+
const isLikelySecret = (envVarName) => {
|
|
4212
|
+
if (SAFE_BUILD_ENV_VARS.has(envVarName)) return false;
|
|
4213
|
+
return SECRET_KEYWORD_PATTERN.test(envVarName);
|
|
4214
|
+
};
|
|
2494
4215
|
const tanstackStartNoSecretsInLoader = { create: (context) => ({ CallExpression(node) {
|
|
2495
4216
|
const optionsObject = getRouteOptionsObject(node);
|
|
2496
4217
|
if (!optionsObject) return;
|
|
@@ -2500,11 +4221,15 @@ const tanstackStartNoSecretsInLoader = { create: (context) => ({ CallExpression(
|
|
|
2500
4221
|
if (keyName !== "loader" && keyName !== "beforeLoad") continue;
|
|
2501
4222
|
walkAst(property.value ?? property, (child) => {
|
|
2502
4223
|
if (child.type !== "MemberExpression") return;
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
4224
|
+
const isProcessEnvAccess = child.object?.type === "MemberExpression" && child.object.object?.type === "Identifier" && child.object.object.name === "process" && child.object.property?.type === "Identifier" && child.object.property.name === "env";
|
|
4225
|
+
const isImportMetaEnvAccess = child.object?.type === "MemberExpression" && child.object.object?.type === "MetaProperty" && child.object.property?.type === "Identifier" && child.object.property.name === "env";
|
|
4226
|
+
if (!isProcessEnvAccess && !isImportMetaEnvAccess) return;
|
|
4227
|
+
const envVarName = child.property?.type === "Identifier" ? child.property.name : null;
|
|
4228
|
+
if (envVarName && isLikelySecret(envVarName)) {
|
|
4229
|
+
const envSource = isImportMetaEnvAccess ? "import.meta.env" : "process.env";
|
|
4230
|
+
context.report({
|
|
2506
4231
|
node: child,
|
|
2507
|
-
message:
|
|
4232
|
+
message: `${envSource}.${envVarName} in ${keyName} — loaders are isomorphic and may leak secrets to the client. Move to a createServerFn()`
|
|
2508
4233
|
});
|
|
2509
4234
|
}
|
|
2510
4235
|
});
|
|
@@ -2558,6 +4283,7 @@ const hasTopLevelAwait = (statement) => {
|
|
|
2558
4283
|
if (statement.type === "VariableDeclaration") return statement.declarations?.some((declarator) => declarator.init?.type === "AwaitExpression");
|
|
2559
4284
|
if (statement.type === "ExpressionStatement") return statement.expression?.type === "AwaitExpression" || statement.expression?.type === "AssignmentExpression" && statement.expression.right?.type === "AwaitExpression";
|
|
2560
4285
|
if (statement.type === "ReturnStatement") return statement.argument?.type === "AwaitExpression";
|
|
4286
|
+
if (statement.type === "ForOfStatement" && statement.await) return true;
|
|
2561
4287
|
return false;
|
|
2562
4288
|
};
|
|
2563
4289
|
const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpression(node) {
|
|
@@ -2573,7 +4299,7 @@ const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpressio
|
|
|
2573
4299
|
let sequentialAwaitCount = 0;
|
|
2574
4300
|
for (const statement of functionBody.body ?? []) {
|
|
2575
4301
|
if (hasTopLevelAwait(statement)) sequentialAwaitCount++;
|
|
2576
|
-
if (sequentialAwaitCount >=
|
|
4302
|
+
if (sequentialAwaitCount >= 2) {
|
|
2577
4303
|
context.report({
|
|
2578
4304
|
node: property,
|
|
2579
4305
|
message: "Multiple sequential awaits in loader — use Promise.all() to fetch data in parallel and avoid waterfalls"
|
|
@@ -2583,11 +4309,10 @@ const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpressio
|
|
|
2583
4309
|
}
|
|
2584
4310
|
}
|
|
2585
4311
|
} }) };
|
|
2586
|
-
|
|
2587
4312
|
//#endregion
|
|
2588
4313
|
//#region src/plugin/rules/state-and-effects.ts
|
|
2589
4314
|
const noDerivedStateEffect = { create: (context) => ({ CallExpression(node) {
|
|
2590
|
-
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments
|
|
4315
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
|
|
2591
4316
|
const callback = getEffectCallback(node);
|
|
2592
4317
|
if (!callback) return;
|
|
2593
4318
|
const depsNode = node.arguments[1];
|
|
@@ -2635,13 +4360,13 @@ const noCascadingSetState = { create: (context) => ({ CallExpression(node) {
|
|
|
2635
4360
|
const callback = getEffectCallback(node);
|
|
2636
4361
|
if (!callback) return;
|
|
2637
4362
|
const setStateCallCount = countSetStateCalls(callback);
|
|
2638
|
-
if (setStateCallCount >=
|
|
4363
|
+
if (setStateCallCount >= 3) context.report({
|
|
2639
4364
|
node,
|
|
2640
4365
|
message: `${setStateCallCount} setState calls in a single useEffect — consider using useReducer or deriving state`
|
|
2641
4366
|
});
|
|
2642
4367
|
} }) };
|
|
2643
4368
|
const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
|
|
2644
|
-
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments
|
|
4369
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
|
|
2645
4370
|
const callback = getEffectCallback(node);
|
|
2646
4371
|
if (!callback) return;
|
|
2647
4372
|
const depsNode = node.arguments[1];
|
|
@@ -2656,24 +4381,57 @@ const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
|
|
|
2656
4381
|
});
|
|
2657
4382
|
} }) };
|
|
2658
4383
|
const noDerivedUseState = { create: (context) => {
|
|
2659
|
-
const
|
|
4384
|
+
const componentPropStack = [];
|
|
4385
|
+
const isPropName = (name) => {
|
|
4386
|
+
for (let stackIndex = componentPropStack.length - 1; stackIndex >= 0; stackIndex--) if (componentPropStack[stackIndex].has(name)) return true;
|
|
4387
|
+
return false;
|
|
4388
|
+
};
|
|
4389
|
+
const isFunctionLikeVariableDeclarator = (node) => {
|
|
4390
|
+
if (node.type !== "VariableDeclarator") return false;
|
|
4391
|
+
return node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression";
|
|
4392
|
+
};
|
|
2660
4393
|
return {
|
|
2661
4394
|
FunctionDeclaration(node) {
|
|
2662
|
-
if (!node.id?.name || !isUppercaseName(node.id.name))
|
|
2663
|
-
|
|
4395
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) {
|
|
4396
|
+
componentPropStack.push(/* @__PURE__ */ new Set());
|
|
4397
|
+
return;
|
|
4398
|
+
}
|
|
4399
|
+
componentPropStack.push(extractDestructuredPropNames(node.params ?? []));
|
|
4400
|
+
},
|
|
4401
|
+
"FunctionDeclaration:exit"() {
|
|
4402
|
+
componentPropStack.pop();
|
|
2664
4403
|
},
|
|
2665
4404
|
VariableDeclarator(node) {
|
|
2666
|
-
if (
|
|
2667
|
-
|
|
4405
|
+
if (isComponentAssignment(node)) {
|
|
4406
|
+
componentPropStack.push(extractDestructuredPropNames(node.init?.params ?? []));
|
|
4407
|
+
return;
|
|
4408
|
+
}
|
|
4409
|
+
if (isFunctionLikeVariableDeclarator(node)) componentPropStack.push(/* @__PURE__ */ new Set());
|
|
4410
|
+
},
|
|
4411
|
+
"VariableDeclarator:exit"(node) {
|
|
4412
|
+
if (isComponentAssignment(node) || isFunctionLikeVariableDeclarator(node)) componentPropStack.pop();
|
|
2668
4413
|
},
|
|
2669
4414
|
CallExpression(node) {
|
|
2670
4415
|
if (!isHookCall(node, "useState") || !node.arguments?.length) return;
|
|
4416
|
+
if (componentPropStack.length === 0) return;
|
|
2671
4417
|
const initializer = node.arguments[0];
|
|
2672
|
-
if (initializer.type
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
4418
|
+
if (initializer.type === "Identifier" && isPropName(initializer.name)) {
|
|
4419
|
+
context.report({
|
|
4420
|
+
node,
|
|
4421
|
+
message: `useState initialized from prop "${initializer.name}" — if this value should stay in sync with the prop, derive it during render instead`
|
|
4422
|
+
});
|
|
4423
|
+
return;
|
|
4424
|
+
}
|
|
4425
|
+
if (initializer.type === "MemberExpression" && !initializer.computed) {
|
|
4426
|
+
let rootIdentifierName = null;
|
|
4427
|
+
let cursor = initializer;
|
|
4428
|
+
while (cursor?.type === "MemberExpression") cursor = cursor.object;
|
|
4429
|
+
if (cursor?.type === "Identifier") rootIdentifierName = cursor.name;
|
|
4430
|
+
if (rootIdentifierName && isPropName(rootIdentifierName)) context.report({
|
|
4431
|
+
node,
|
|
4432
|
+
message: `useState initialized from prop "${rootIdentifierName}" — if this value should stay in sync with the prop, derive it during render instead`
|
|
4433
|
+
});
|
|
4434
|
+
}
|
|
2677
4435
|
}
|
|
2678
4436
|
};
|
|
2679
4437
|
} };
|
|
@@ -2685,7 +4443,7 @@ const preferUseReducer = { create: (context) => {
|
|
|
2685
4443
|
if (statement.type !== "VariableDeclaration") continue;
|
|
2686
4444
|
for (const declarator of statement.declarations ?? []) if (isHookCall(declarator.init, "useState")) useStateCount++;
|
|
2687
4445
|
}
|
|
2688
|
-
if (useStateCount >=
|
|
4446
|
+
if (useStateCount >= 5) context.report({
|
|
2689
4447
|
node: body,
|
|
2690
4448
|
message: `Component "${componentName}" has ${useStateCount} useState calls — consider useReducer for related state`
|
|
2691
4449
|
});
|
|
@@ -2712,15 +4470,42 @@ const rerenderLazyStateInit = { create: (context) => ({ CallExpression(node) {
|
|
|
2712
4470
|
message: `useState(${calleeName}()) calls initializer on every render — use useState(() => ${calleeName}()) for lazy initialization`
|
|
2713
4471
|
});
|
|
2714
4472
|
} }) };
|
|
4473
|
+
const STATE_ARITHMETIC_OPERATORS = new Set([
|
|
4474
|
+
"+",
|
|
4475
|
+
"-",
|
|
4476
|
+
"*",
|
|
4477
|
+
"/",
|
|
4478
|
+
"%",
|
|
4479
|
+
"**"
|
|
4480
|
+
]);
|
|
4481
|
+
const deriveStateVariableName = (setterName) => {
|
|
4482
|
+
if (!setterName.startsWith("set") || setterName.length < 4) return null;
|
|
4483
|
+
return setterName.charAt(3).toLowerCase() + setterName.slice(4);
|
|
4484
|
+
};
|
|
2715
4485
|
const rerenderFunctionalSetstate = { create: (context) => ({ CallExpression(node) {
|
|
2716
4486
|
if (!isSetterCall(node)) return;
|
|
2717
4487
|
if (!node.arguments?.length) return;
|
|
2718
4488
|
const calleeName = node.callee.name;
|
|
2719
4489
|
const argument = node.arguments[0];
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
4490
|
+
const expectedStateName = deriveStateVariableName(calleeName);
|
|
4491
|
+
if (argument.type === "BinaryExpression" && STATE_ARITHMETIC_OPERATORS.has(argument.operator) && expectedStateName) {
|
|
4492
|
+
const matchesExpected = (operand) => operand?.type === "Identifier" && operand.name === expectedStateName;
|
|
4493
|
+
const stateIdentifier = matchesExpected(argument.left) ? argument.left : matchesExpected(argument.right) ? argument.right : null;
|
|
4494
|
+
if (stateIdentifier) {
|
|
4495
|
+
context.report({
|
|
4496
|
+
node,
|
|
4497
|
+
message: `${calleeName}(${stateIdentifier.name} ${argument.operator} ...) — use functional update to avoid stale closures`
|
|
4498
|
+
});
|
|
4499
|
+
return;
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
if (argument.type === "UpdateExpression" && (argument.operator === "++" || argument.operator === "--") && argument.argument?.type === "Identifier" && argument.argument.name === expectedStateName) {
|
|
4503
|
+
const display = argument.prefix ? `${argument.operator}${argument.argument.name}` : `${argument.argument.name}${argument.operator}`;
|
|
4504
|
+
context.report({
|
|
4505
|
+
node,
|
|
4506
|
+
message: `${calleeName}(${display}) — use functional update to avoid stale closures (and reading the post-increment value bug)`
|
|
4507
|
+
});
|
|
4508
|
+
}
|
|
2724
4509
|
} }) };
|
|
2725
4510
|
const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
|
|
2726
4511
|
if (!isHookCall(node, HOOKS_WITH_DEPS) || node.arguments.length < 2) return;
|
|
@@ -2738,7 +4523,295 @@ const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
|
|
|
2738
4523
|
});
|
|
2739
4524
|
}
|
|
2740
4525
|
} }) };
|
|
2741
|
-
|
|
4526
|
+
const noPropCallbackInEffect = { create: (context) => {
|
|
4527
|
+
const componentPropParamStack = [];
|
|
4528
|
+
const enterComponentParams = (params) => {
|
|
4529
|
+
const propNames = extractDestructuredPropNames(params ?? []);
|
|
4530
|
+
componentPropParamStack.push(propNames);
|
|
4531
|
+
};
|
|
4532
|
+
const isPropName = (name) => {
|
|
4533
|
+
for (let stackIndex = componentPropParamStack.length - 1; stackIndex >= 0; stackIndex--) if (componentPropParamStack[stackIndex].has(name)) return true;
|
|
4534
|
+
return false;
|
|
4535
|
+
};
|
|
4536
|
+
const isFunctionLikeVariableDeclarator = (node) => {
|
|
4537
|
+
if (node.type !== "VariableDeclarator") return false;
|
|
4538
|
+
return node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression";
|
|
4539
|
+
};
|
|
4540
|
+
return {
|
|
4541
|
+
FunctionDeclaration(node) {
|
|
4542
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) {
|
|
4543
|
+
componentPropParamStack.push(/* @__PURE__ */ new Set());
|
|
4544
|
+
return;
|
|
4545
|
+
}
|
|
4546
|
+
enterComponentParams(node.params);
|
|
4547
|
+
},
|
|
4548
|
+
"FunctionDeclaration:exit"() {
|
|
4549
|
+
componentPropParamStack.pop();
|
|
4550
|
+
},
|
|
4551
|
+
VariableDeclarator(node) {
|
|
4552
|
+
if (isComponentAssignment(node)) {
|
|
4553
|
+
enterComponentParams(node.init?.params);
|
|
4554
|
+
return;
|
|
4555
|
+
}
|
|
4556
|
+
if (isFunctionLikeVariableDeclarator(node)) componentPropParamStack.push(/* @__PURE__ */ new Set());
|
|
4557
|
+
},
|
|
4558
|
+
"VariableDeclarator:exit"(node) {
|
|
4559
|
+
if (isComponentAssignment(node) || isFunctionLikeVariableDeclarator(node)) componentPropParamStack.pop();
|
|
4560
|
+
},
|
|
4561
|
+
CallExpression(node) {
|
|
4562
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
|
|
4563
|
+
if (componentPropParamStack.length === 0) return;
|
|
4564
|
+
const callback = getEffectCallback(node);
|
|
4565
|
+
if (!callback) return;
|
|
4566
|
+
const depsNode = node.arguments[1];
|
|
4567
|
+
if (depsNode.type !== "ArrayExpression" || !depsNode.elements?.length) return;
|
|
4568
|
+
const bodyStatements = getCallbackStatements(callback);
|
|
4569
|
+
for (const stmt of bodyStatements) {
|
|
4570
|
+
let invokedPropName = null;
|
|
4571
|
+
if (stmt.type === "ExpressionStatement" && stmt.expression?.type === "CallExpression" && stmt.expression.callee?.type === "Identifier" && isPropName(stmt.expression.callee.name)) invokedPropName = stmt.expression.callee.name;
|
|
4572
|
+
if (!invokedPropName) continue;
|
|
4573
|
+
if (!depsNode.elements.some((element) => element?.type === "Identifier" && !isPropName(element.name))) continue;
|
|
4574
|
+
context.report({
|
|
4575
|
+
node: stmt,
|
|
4576
|
+
message: `useEffect calls prop callback "${invokedPropName}" with local state in deps — this is the "lift state via callback" anti-pattern; lift state into a shared Provider so both sides read the same source`
|
|
4577
|
+
});
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
};
|
|
4581
|
+
} };
|
|
4582
|
+
const noEffectEventInDeps = { create: (context) => {
|
|
4583
|
+
const componentBindingStack = [];
|
|
4584
|
+
const isEffectEventBinding = (name) => {
|
|
4585
|
+
for (let stackIndex = componentBindingStack.length - 1; stackIndex >= 0; stackIndex--) if (componentBindingStack[stackIndex].has(name)) return true;
|
|
4586
|
+
return false;
|
|
4587
|
+
};
|
|
4588
|
+
const enterComponent = () => {
|
|
4589
|
+
componentBindingStack.push(/* @__PURE__ */ new Set());
|
|
4590
|
+
};
|
|
4591
|
+
const exitComponent = () => {
|
|
4592
|
+
componentBindingStack.pop();
|
|
4593
|
+
};
|
|
4594
|
+
return {
|
|
4595
|
+
FunctionDeclaration(node) {
|
|
4596
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
4597
|
+
enterComponent();
|
|
4598
|
+
},
|
|
4599
|
+
"FunctionDeclaration:exit"(node) {
|
|
4600
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
4601
|
+
exitComponent();
|
|
4602
|
+
},
|
|
4603
|
+
VariableDeclarator(node) {
|
|
4604
|
+
if (isComponentAssignment(node)) {
|
|
4605
|
+
enterComponent();
|
|
4606
|
+
return;
|
|
4607
|
+
}
|
|
4608
|
+
if (componentBindingStack.length === 0) return;
|
|
4609
|
+
if (node.id?.type !== "Identifier") return;
|
|
4610
|
+
const init = node.init;
|
|
4611
|
+
if (!init || init.type !== "CallExpression") return;
|
|
4612
|
+
if (!isHookCall(init, "useEffectEvent")) return;
|
|
4613
|
+
componentBindingStack[componentBindingStack.length - 1].add(node.id.name);
|
|
4614
|
+
},
|
|
4615
|
+
"VariableDeclarator:exit"(node) {
|
|
4616
|
+
if (isComponentAssignment(node)) exitComponent();
|
|
4617
|
+
},
|
|
4618
|
+
CallExpression(node) {
|
|
4619
|
+
if (!isHookCall(node, HOOKS_WITH_DEPS) || node.arguments.length < 2) return;
|
|
4620
|
+
if (componentBindingStack.length === 0) return;
|
|
4621
|
+
const depsNode = node.arguments[1];
|
|
4622
|
+
if (depsNode.type !== "ArrayExpression") return;
|
|
4623
|
+
for (const element of depsNode.elements ?? []) {
|
|
4624
|
+
if (element?.type !== "Identifier") continue;
|
|
4625
|
+
if (isEffectEventBinding(element.name)) context.report({
|
|
4626
|
+
node: element,
|
|
4627
|
+
message: `"${element.name}" is from useEffectEvent and must not be in the deps array — its identity is intentionally unstable; call it inside the effect without listing it`
|
|
4628
|
+
});
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
};
|
|
4632
|
+
} };
|
|
4633
|
+
const collectUseStateBindings = (componentBody) => {
|
|
4634
|
+
const bindings = [];
|
|
4635
|
+
if (componentBody?.type !== "BlockStatement") return bindings;
|
|
4636
|
+
for (const statement of componentBody.body ?? []) {
|
|
4637
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
4638
|
+
for (const declarator of statement.declarations ?? []) {
|
|
4639
|
+
if (declarator.id?.type !== "ArrayPattern") continue;
|
|
4640
|
+
const elements = declarator.id.elements ?? [];
|
|
4641
|
+
if (elements.length < 2) continue;
|
|
4642
|
+
const valueElement = elements[0];
|
|
4643
|
+
const setterElement = elements[1];
|
|
4644
|
+
if (valueElement?.type !== "Identifier" || setterElement?.type !== "Identifier" || !isSetterIdentifier(setterElement.name)) continue;
|
|
4645
|
+
if (declarator.init?.type !== "CallExpression") continue;
|
|
4646
|
+
if (!isHookCall(declarator.init, "useState")) continue;
|
|
4647
|
+
bindings.push({
|
|
4648
|
+
valueName: valueElement.name,
|
|
4649
|
+
setterName: setterElement.name,
|
|
4650
|
+
declarator
|
|
4651
|
+
});
|
|
4652
|
+
}
|
|
4653
|
+
}
|
|
4654
|
+
return bindings;
|
|
4655
|
+
};
|
|
4656
|
+
const collectReturnExpressions = (componentBody) => {
|
|
4657
|
+
if (componentBody?.type !== "BlockStatement") return [];
|
|
4658
|
+
const returns = [];
|
|
4659
|
+
for (const statement of componentBody.body ?? []) {
|
|
4660
|
+
if (statement.type === "ReturnStatement" && statement.argument) {
|
|
4661
|
+
returns.push(statement.argument);
|
|
4662
|
+
continue;
|
|
4663
|
+
}
|
|
4664
|
+
walkInsideStatementBlocks(statement, (child) => {
|
|
4665
|
+
if (child.type === "ReturnStatement" && child.argument) returns.push(child.argument);
|
|
4666
|
+
});
|
|
4667
|
+
}
|
|
4668
|
+
return returns;
|
|
4669
|
+
};
|
|
4670
|
+
const walkInsideStatementBlocks = (node, visitor) => {
|
|
4671
|
+
if (!node || typeof node !== "object") return;
|
|
4672
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") return;
|
|
4673
|
+
visitor(node);
|
|
4674
|
+
for (const key of Object.keys(node)) {
|
|
4675
|
+
if (key === "parent") continue;
|
|
4676
|
+
const child = node[key];
|
|
4677
|
+
if (Array.isArray(child)) {
|
|
4678
|
+
for (const item of child) if (item && typeof item === "object" && item.type) walkInsideStatementBlocks(item, visitor);
|
|
4679
|
+
} else if (child && typeof child === "object" && child.type) walkInsideStatementBlocks(child, visitor);
|
|
4680
|
+
}
|
|
4681
|
+
};
|
|
4682
|
+
const expressionReadsName = (expression, name) => {
|
|
4683
|
+
let didRead = false;
|
|
4684
|
+
walkAst(expression, (child) => {
|
|
4685
|
+
if (didRead) return;
|
|
4686
|
+
if (child.type === "Identifier" && child.name === name) didRead = true;
|
|
4687
|
+
});
|
|
4688
|
+
return didRead;
|
|
4689
|
+
};
|
|
4690
|
+
const rerenderStateOnlyInHandlers = { create: (context) => {
|
|
4691
|
+
const checkComponent = (componentBody) => {
|
|
4692
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
4693
|
+
const bindings = collectUseStateBindings(componentBody);
|
|
4694
|
+
if (bindings.length === 0) return;
|
|
4695
|
+
const returnExpressions = collectReturnExpressions(componentBody);
|
|
4696
|
+
if (returnExpressions.length === 0) return;
|
|
4697
|
+
for (const binding of bindings) {
|
|
4698
|
+
if (returnExpressions.some((expression) => expressionReadsName(expression, binding.valueName))) continue;
|
|
4699
|
+
let setterCalled = false;
|
|
4700
|
+
walkAst(componentBody, (child) => {
|
|
4701
|
+
if (setterCalled) return;
|
|
4702
|
+
if (child.type === "CallExpression" && child.callee?.type === "Identifier" && child.callee.name === binding.setterName) setterCalled = true;
|
|
4703
|
+
});
|
|
4704
|
+
if (!setterCalled) continue;
|
|
4705
|
+
context.report({
|
|
4706
|
+
node: binding.declarator,
|
|
4707
|
+
message: `useState "${binding.valueName}" is updated but never read in the component's return — use useRef so updates don't trigger re-renders`
|
|
4708
|
+
});
|
|
4709
|
+
}
|
|
4710
|
+
};
|
|
4711
|
+
return {
|
|
4712
|
+
FunctionDeclaration(node) {
|
|
4713
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
4714
|
+
checkComponent(node.body);
|
|
4715
|
+
},
|
|
4716
|
+
VariableDeclarator(node) {
|
|
4717
|
+
if (!isComponentAssignment(node)) return;
|
|
4718
|
+
checkComponent(node.init?.body);
|
|
4719
|
+
}
|
|
4720
|
+
};
|
|
4721
|
+
} };
|
|
4722
|
+
const SUBSCRIPTION_METHOD_NAMES = new Set([
|
|
4723
|
+
"addEventListener",
|
|
4724
|
+
"subscribe",
|
|
4725
|
+
"on",
|
|
4726
|
+
"addListener"
|
|
4727
|
+
]);
|
|
4728
|
+
const advancedEventHandlerRefs = { create: (context) => ({ CallExpression(node) {
|
|
4729
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
|
|
4730
|
+
if ((node.arguments?.length ?? 0) < 2) return;
|
|
4731
|
+
const callback = getEffectCallback(node);
|
|
4732
|
+
if (!callback) return;
|
|
4733
|
+
const depsNode = node.arguments[1];
|
|
4734
|
+
if (depsNode.type !== "ArrayExpression" || !depsNode.elements?.length) return;
|
|
4735
|
+
const depIdentifierNames = /* @__PURE__ */ new Set();
|
|
4736
|
+
for (const element of depsNode.elements) if (element?.type === "Identifier") depIdentifierNames.add(element.name);
|
|
4737
|
+
if (depIdentifierNames.size === 0) return;
|
|
4738
|
+
let registeredHandlerName = null;
|
|
4739
|
+
walkAst(callback.body, (child) => {
|
|
4740
|
+
if (registeredHandlerName) return;
|
|
4741
|
+
if (child.type !== "CallExpression") return;
|
|
4742
|
+
if (child.callee?.type !== "MemberExpression") return;
|
|
4743
|
+
if (child.callee.property?.type !== "Identifier") return;
|
|
4744
|
+
if (!SUBSCRIPTION_METHOD_NAMES.has(child.callee.property.name)) return;
|
|
4745
|
+
const handlerArg = child.arguments?.[1];
|
|
4746
|
+
if (handlerArg?.type !== "Identifier") return;
|
|
4747
|
+
if (depIdentifierNames.has(handlerArg.name)) registeredHandlerName = handlerArg.name;
|
|
4748
|
+
});
|
|
4749
|
+
if (registeredHandlerName) context.report({
|
|
4750
|
+
node,
|
|
4751
|
+
message: `useEffect re-subscribes a "${registeredHandlerName}" listener every time the handler identity changes — store the handler in a ref and have the listener read \`handlerRef.current()\`, then drop it from the deps`
|
|
4752
|
+
});
|
|
4753
|
+
} }) };
|
|
4754
|
+
const DEFERRABLE_HOOK_NAMES = new Set([
|
|
4755
|
+
"useSearchParams",
|
|
4756
|
+
"useParams",
|
|
4757
|
+
"usePathname"
|
|
4758
|
+
]);
|
|
4759
|
+
const findHookCallBindings = (componentBody) => {
|
|
4760
|
+
const bindings = [];
|
|
4761
|
+
if (componentBody?.type !== "BlockStatement") return bindings;
|
|
4762
|
+
for (const statement of componentBody.body ?? []) {
|
|
4763
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
4764
|
+
for (const declarator of statement.declarations ?? []) {
|
|
4765
|
+
if (declarator.id?.type !== "Identifier") continue;
|
|
4766
|
+
if (declarator.init?.type !== "CallExpression") continue;
|
|
4767
|
+
const callee = declarator.init.callee;
|
|
4768
|
+
if (callee?.type !== "Identifier") continue;
|
|
4769
|
+
if (!DEFERRABLE_HOOK_NAMES.has(callee.name)) continue;
|
|
4770
|
+
bindings.push({
|
|
4771
|
+
valueName: declarator.id.name,
|
|
4772
|
+
hookName: callee.name,
|
|
4773
|
+
declarator
|
|
4774
|
+
});
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
return bindings;
|
|
4778
|
+
};
|
|
4779
|
+
const collectHandlerBindingNames = (componentBody) => {
|
|
4780
|
+
const handlerNames = /* @__PURE__ */ new Set();
|
|
4781
|
+
walkAst(componentBody, (child) => {
|
|
4782
|
+
if (child.type !== "JSXAttribute") return;
|
|
4783
|
+
if (child.name?.type !== "JSXIdentifier") return;
|
|
4784
|
+
if (!/^on[A-Z]/.test(child.name.name)) return;
|
|
4785
|
+
if (child.value?.type !== "JSXExpressionContainer") return;
|
|
4786
|
+
const expression = child.value.expression;
|
|
4787
|
+
if (expression?.type === "Identifier") handlerNames.add(expression.name);
|
|
4788
|
+
});
|
|
4789
|
+
return handlerNames;
|
|
4790
|
+
};
|
|
4791
|
+
const isInsideEventHandler = (node, handlerBindingNames) => {
|
|
4792
|
+
let cursor = node.parent ?? null;
|
|
4793
|
+
while (cursor) {
|
|
4794
|
+
if (cursor.type === "ArrowFunctionExpression" || cursor.type === "FunctionExpression" || cursor.type === "FunctionDeclaration") {
|
|
4795
|
+
let outer = cursor.parent ?? null;
|
|
4796
|
+
while (outer) {
|
|
4797
|
+
if (outer.type === "JSXAttribute") {
|
|
4798
|
+
const attrName = outer.name?.type === "JSXIdentifier" ? outer.name.name : null;
|
|
4799
|
+
if (attrName && /^on[A-Z]/.test(attrName)) return true;
|
|
4800
|
+
return false;
|
|
4801
|
+
}
|
|
4802
|
+
if (outer.type === "VariableDeclarator") {
|
|
4803
|
+
const declaredName = outer.id?.type === "Identifier" ? outer.id.name : null;
|
|
4804
|
+
return Boolean(declaredName && handlerBindingNames.has(declaredName));
|
|
4805
|
+
}
|
|
4806
|
+
if (outer.type === "Program") return false;
|
|
4807
|
+
outer = outer.parent ?? null;
|
|
4808
|
+
}
|
|
4809
|
+
return false;
|
|
4810
|
+
}
|
|
4811
|
+
cursor = cursor.parent ?? null;
|
|
4812
|
+
}
|
|
4813
|
+
return false;
|
|
4814
|
+
};
|
|
2742
4815
|
//#endregion
|
|
2743
4816
|
//#region src/plugin/index.ts
|
|
2744
4817
|
const plugin = {
|
|
@@ -2748,21 +4821,69 @@ const plugin = {
|
|
|
2748
4821
|
"no-fetch-in-effect": noFetchInEffect,
|
|
2749
4822
|
"no-cascading-set-state": noCascadingSetState,
|
|
2750
4823
|
"no-effect-event-handler": noEffectEventHandler,
|
|
4824
|
+
"no-effect-event-in-deps": noEffectEventInDeps,
|
|
4825
|
+
"no-prop-callback-in-effect": noPropCallbackInEffect,
|
|
2751
4826
|
"no-derived-useState": noDerivedUseState,
|
|
2752
4827
|
"prefer-useReducer": preferUseReducer,
|
|
2753
4828
|
"rerender-lazy-state-init": rerenderLazyStateInit,
|
|
2754
4829
|
"rerender-functional-setstate": rerenderFunctionalSetstate,
|
|
2755
4830
|
"rerender-dependencies": rerenderDependencies,
|
|
4831
|
+
"rerender-state-only-in-handlers": rerenderStateOnlyInHandlers,
|
|
4832
|
+
"rerender-defer-reads-hook": { create: (context) => {
|
|
4833
|
+
const checkComponent = (componentBody) => {
|
|
4834
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
4835
|
+
const bindings = findHookCallBindings(componentBody);
|
|
4836
|
+
if (bindings.length === 0) return;
|
|
4837
|
+
const handlerBindingNames = collectHandlerBindingNames(componentBody);
|
|
4838
|
+
for (const binding of bindings) {
|
|
4839
|
+
const referenceLocations = [];
|
|
4840
|
+
walkAst(componentBody, (child) => {
|
|
4841
|
+
if (child === binding.declarator.id) return;
|
|
4842
|
+
if (child.type === "Identifier" && child.name === binding.valueName) referenceLocations.push(child);
|
|
4843
|
+
});
|
|
4844
|
+
if (referenceLocations.length === 0) continue;
|
|
4845
|
+
if (!referenceLocations.every((ref) => isInsideEventHandler(ref, handlerBindingNames))) continue;
|
|
4846
|
+
context.report({
|
|
4847
|
+
node: binding.declarator,
|
|
4848
|
+
message: `${binding.hookName}() return is only read inside event handlers — defer the read into the handler (e.g. \`new URL(window.location.href).searchParams\`) so the component doesn't re-render on every URL change`
|
|
4849
|
+
});
|
|
4850
|
+
}
|
|
4851
|
+
};
|
|
4852
|
+
return {
|
|
4853
|
+
FunctionDeclaration(node) {
|
|
4854
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
4855
|
+
checkComponent(node.body);
|
|
4856
|
+
},
|
|
4857
|
+
VariableDeclarator(node) {
|
|
4858
|
+
if (!isComponentAssignment(node)) return;
|
|
4859
|
+
checkComponent(node.init?.body);
|
|
4860
|
+
}
|
|
4861
|
+
};
|
|
4862
|
+
} },
|
|
4863
|
+
"advanced-event-handler-refs": advancedEventHandlerRefs,
|
|
4864
|
+
"no-generic-handler-names": noGenericHandlerNames,
|
|
2756
4865
|
"no-giant-component": noGiantComponent,
|
|
4866
|
+
"no-many-boolean-props": noManyBooleanProps,
|
|
4867
|
+
"no-react19-deprecated-apis": noReact19DeprecatedApis,
|
|
4868
|
+
"no-render-prop-children": noRenderPropChildren,
|
|
2757
4869
|
"no-render-in-render": noRenderInRender,
|
|
2758
4870
|
"no-nested-component-definition": noNestedComponentDefinition,
|
|
4871
|
+
"react-compiler-destructure-method": reactCompilerDestructureMethod,
|
|
2759
4872
|
"no-usememo-simple-expression": noUsememoSimpleExpression,
|
|
2760
4873
|
"no-layout-property-animation": noLayoutPropertyAnimation,
|
|
2761
4874
|
"rerender-memo-with-default-value": rerenderMemoWithDefaultValue,
|
|
4875
|
+
"rerender-memo-before-early-return": rerenderMemoBeforeEarlyReturn,
|
|
4876
|
+
"rerender-transitions-scroll": rerenderTransitionsScroll,
|
|
4877
|
+
"rerender-derived-state-from-hook": rerenderDerivedStateFromHook,
|
|
4878
|
+
"async-defer-await": asyncDeferAwait,
|
|
4879
|
+
"async-await-in-loop": asyncAwaitInLoop,
|
|
2762
4880
|
"rendering-animate-svg-wrapper": renderingAnimateSvgWrapper,
|
|
4881
|
+
"rendering-hoist-jsx": renderingHoistJsx,
|
|
4882
|
+
"rendering-hydration-mismatch-time": renderingHydrationMismatchTime,
|
|
2763
4883
|
"no-inline-prop-on-memo-component": noInlinePropOnMemoComponent,
|
|
2764
4884
|
"rendering-hydration-no-flicker": renderingHydrationNoFlicker,
|
|
2765
4885
|
"rendering-script-defer-async": renderingScriptDeferAsync,
|
|
4886
|
+
"rendering-usetransition-loading": renderingUsetransitionLoading,
|
|
2766
4887
|
"no-transition-all": noTransitionAll,
|
|
2767
4888
|
"no-global-css-variable-animation": noGlobalCssVariableAnimation,
|
|
2768
4889
|
"no-large-animated-blur": noLargeAnimatedBlur,
|
|
@@ -2771,14 +4892,37 @@ const plugin = {
|
|
|
2771
4892
|
"no-eval": noEval,
|
|
2772
4893
|
"no-secrets-in-client-code": noSecretsInClientCode,
|
|
2773
4894
|
"no-barrel-import": noBarrelImport,
|
|
4895
|
+
"no-dynamic-import-path": noDynamicImportPath,
|
|
2774
4896
|
"no-full-lodash-import": noFullLodashImport,
|
|
2775
4897
|
"no-moment": noMoment,
|
|
2776
4898
|
"prefer-dynamic-import": preferDynamicImport,
|
|
2777
4899
|
"use-lazy-motion": useLazyMotion,
|
|
2778
4900
|
"no-undeferred-third-party": noUndeferredThirdParty,
|
|
2779
4901
|
"no-array-index-as-key": noArrayIndexAsKey,
|
|
4902
|
+
"no-polymorphic-children": noPolymorphicChildren,
|
|
2780
4903
|
"rendering-conditional-render": renderingConditionalRender,
|
|
4904
|
+
"rendering-svg-precision": renderingSvgPrecision,
|
|
2781
4905
|
"no-prevent-default": noPreventDefault,
|
|
4906
|
+
"no-document-start-view-transition": { create: (context) => ({ CallExpression(node) {
|
|
4907
|
+
const callee = node.callee;
|
|
4908
|
+
if (callee?.type !== "MemberExpression") return;
|
|
4909
|
+
if (callee.object?.type !== "Identifier" || callee.object.name !== "document") return;
|
|
4910
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "startViewTransition") return;
|
|
4911
|
+
context.report({
|
|
4912
|
+
node,
|
|
4913
|
+
message: "document.startViewTransition() bypasses React's <ViewTransition> integration — render a <ViewTransition> component and let React drive the transition (around startTransition / useDeferredValue / Suspense)"
|
|
4914
|
+
});
|
|
4915
|
+
} }) },
|
|
4916
|
+
"no-flush-sync": { create: (context) => ({ ImportDeclaration(node) {
|
|
4917
|
+
if (node.source?.value !== "react-dom") return;
|
|
4918
|
+
for (const specifier of node.specifiers ?? []) {
|
|
4919
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
4920
|
+
if (specifier.imported?.name === "flushSync") context.report({
|
|
4921
|
+
node: specifier,
|
|
4922
|
+
message: "flushSync from react-dom skips View Transition snapshots and concurrent rendering — prefer startTransition for non-urgent updates"
|
|
4923
|
+
});
|
|
4924
|
+
}
|
|
4925
|
+
} }) },
|
|
2782
4926
|
"nextjs-no-img-element": nextjsNoImgElement,
|
|
2783
4927
|
"nextjs-async-client-component": nextjsAsyncClientComponent,
|
|
2784
4928
|
"nextjs-no-a-element": nextjsNoAElement,
|
|
@@ -2797,10 +4941,20 @@ const plugin = {
|
|
|
2797
4941
|
"nextjs-no-side-effect-in-get-handler": nextjsNoSideEffectInGetHandler,
|
|
2798
4942
|
"server-auth-actions": serverAuthActions,
|
|
2799
4943
|
"server-after-nonblocking": serverAfterNonblocking,
|
|
4944
|
+
"server-no-mutable-module-state": serverNoMutableModuleState,
|
|
4945
|
+
"server-cache-with-object-literal": serverCacheWithObjectLiteral,
|
|
4946
|
+
"server-hoist-static-io": serverHoistStaticIo,
|
|
4947
|
+
"server-dedup-props": serverDedupProps,
|
|
4948
|
+
"server-sequential-independent-await": serverSequentialIndependentAwait,
|
|
4949
|
+
"server-fetch-without-revalidate": serverFetchWithoutRevalidate,
|
|
2800
4950
|
"client-passive-event-listeners": clientPassiveEventListeners,
|
|
4951
|
+
"client-localstorage-no-version": clientLocalstorageNoVersion,
|
|
2801
4952
|
"js-combine-iterations": jsCombineIterations,
|
|
2802
4953
|
"js-tosorted-immutable": jsTosortedImmutable,
|
|
2803
4954
|
"js-hoist-regexp": jsHoistRegexp,
|
|
4955
|
+
"js-hoist-intl": jsHoistIntl,
|
|
4956
|
+
"js-cache-property-access": jsCachePropertyAccess,
|
|
4957
|
+
"js-length-check-first": jsLengthCheckFirst,
|
|
2804
4958
|
"js-min-max-loop": jsMinMaxLoop,
|
|
2805
4959
|
"js-set-map-lookups": jsSetMapLookups,
|
|
2806
4960
|
"js-batch-dom-css": jsBatchDomCss,
|
|
@@ -2817,6 +4971,22 @@ const plugin = {
|
|
|
2817
4971
|
"rn-no-legacy-shadow-styles": rnNoLegacyShadowStyles,
|
|
2818
4972
|
"rn-prefer-reanimated": rnPreferReanimated,
|
|
2819
4973
|
"rn-no-single-element-style-array": rnNoSingleElementStyleArray,
|
|
4974
|
+
"rn-prefer-pressable": rnPreferPressable,
|
|
4975
|
+
"rn-prefer-expo-image": rnPreferExpoImage,
|
|
4976
|
+
"rn-no-non-native-navigator": rnNoNonNativeNavigator,
|
|
4977
|
+
"rn-no-scroll-state": rnNoScrollState,
|
|
4978
|
+
"rn-no-scrollview-mapped-list": rnNoScrollviewMappedList,
|
|
4979
|
+
"rn-no-inline-object-in-list-item": rnNoInlineObjectInListItem,
|
|
4980
|
+
"rn-animate-layout-property": rnAnimateLayoutProperty,
|
|
4981
|
+
"rn-prefer-content-inset-adjustment": rnPreferContentInsetAdjustment,
|
|
4982
|
+
"rn-pressable-shared-value-mutation": rnPressableSharedValueMutation,
|
|
4983
|
+
"rn-list-data-mapped": rnListDataMapped,
|
|
4984
|
+
"rn-list-callback-per-row": rnListCallbackPerRow,
|
|
4985
|
+
"rn-list-recyclable-without-types": rnListRecyclableWithoutTypes,
|
|
4986
|
+
"rn-animation-reaction-as-derived": rnAnimationReactionAsDerived,
|
|
4987
|
+
"rn-bottom-sheet-prefer-native": rnBottomSheetPreferNative,
|
|
4988
|
+
"rn-scrollview-dynamic-padding": rnScrollviewDynamicPadding,
|
|
4989
|
+
"rn-style-prefer-boxshadow": rnStylePreferBoxShadow,
|
|
2820
4990
|
"tanstack-start-route-property-order": tanstackStartRoutePropertyOrder,
|
|
2821
4991
|
"tanstack-start-no-direct-fetch-in-loader": tanstackStartNoDirectFetchInLoader,
|
|
2822
4992
|
"tanstack-start-server-fn-validate-input": tanstackStartServerFnValidateInput,
|
|
@@ -2854,7 +5024,7 @@ const plugin = {
|
|
|
2854
5024
|
"no-long-transition-duration": noLongTransitionDuration
|
|
2855
5025
|
}
|
|
2856
5026
|
};
|
|
2857
|
-
|
|
2858
5027
|
//#endregion
|
|
2859
5028
|
export { plugin as default };
|
|
5029
|
+
|
|
2860
5030
|
//# sourceMappingURL=react-doctor-plugin.js.map
|