react-doctor 0.0.42 → 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-BHiLPUJT.js → browser-BOxs7MrK.js} +35 -21
- package/dist/{browser-DFbjNpPb.d.ts → browser-Dcq3yn-p.d.ts} +18 -3
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +1 -1
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +1391 -426
- package/dist/index.d.ts +119 -12
- package/dist/index.js +1136 -327
- package/dist/react-doctor-plugin.js +2335 -127
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +2 -2
- package/package.json +35 -13
- package/dist/browser-DFbjNpPb.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/process-browser-diagnostics-BHiLPUJT.js.map +0 -1
- package/dist/react-doctor-plugin.d.ts.map +0 -1
- package/dist/react-doctor-plugin.js.map +0 -1
|
@@ -44,11 +44,20 @@ const HEAVY_LIBRARIES = new Set([
|
|
|
44
44
|
"@toast-ui/editor",
|
|
45
45
|
"draft-js"
|
|
46
46
|
]);
|
|
47
|
-
const FETCH_CALLEE_NAMES = new Set([
|
|
47
|
+
const FETCH_CALLEE_NAMES = new Set([
|
|
48
|
+
"fetch",
|
|
49
|
+
"ky",
|
|
50
|
+
"got",
|
|
51
|
+
"wretch",
|
|
52
|
+
"ofetch"
|
|
53
|
+
]);
|
|
48
54
|
const FETCH_MEMBER_OBJECTS = new Set([
|
|
49
55
|
"axios",
|
|
50
56
|
"ky",
|
|
51
|
-
"got"
|
|
57
|
+
"got",
|
|
58
|
+
"ofetch",
|
|
59
|
+
"wretch",
|
|
60
|
+
"request"
|
|
52
61
|
]);
|
|
53
62
|
const INDEX_PARAMETER_NAMES = new Set([
|
|
54
63
|
"index",
|
|
@@ -161,6 +170,7 @@ const SECRET_FALSE_POSITIVE_SUFFIXES = new Set([
|
|
|
161
170
|
"schema",
|
|
162
171
|
"constant"
|
|
163
172
|
]);
|
|
173
|
+
const LOADING_STATE_PATTERN = /^(?:isLoading|isPending)$/;
|
|
164
174
|
const TANSTACK_ROUTE_FILE_PATTERN = /\/routes\//;
|
|
165
175
|
const TANSTACK_ROOT_ROUTE_FILE_PATTERN = /__root\.(tsx?|jsx?)$/;
|
|
166
176
|
const TANSTACK_ROUTE_PROPERTY_ORDER = [
|
|
@@ -203,12 +213,29 @@ const TANSTACK_QUERY_HOOKS = new Set([
|
|
|
203
213
|
"useSuspenseInfiniteQuery"
|
|
204
214
|
]);
|
|
205
215
|
const TANSTACK_MUTATION_HOOKS = new Set(["useMutation"]);
|
|
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
|
+
]);
|
|
206
226
|
const STABLE_HOOK_WRAPPERS = new Set([
|
|
207
227
|
"useState",
|
|
208
228
|
"useMemo",
|
|
209
229
|
"useRef"
|
|
210
230
|
]);
|
|
211
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
|
+
]);
|
|
212
239
|
const TRIVIAL_INITIALIZER_NAMES = new Set([
|
|
213
240
|
"Boolean",
|
|
214
241
|
"String",
|
|
@@ -286,7 +313,7 @@ const CHAINABLE_ITERATION_METHODS = new Set([
|
|
|
286
313
|
"forEach",
|
|
287
314
|
"flatMap"
|
|
288
315
|
]);
|
|
289
|
-
const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
|
|
316
|
+
const STORAGE_OBJECTS$1 = new Set(["localStorage", "sessionStorage"]);
|
|
290
317
|
const BLUR_VALUE_PATTERN = /blur\((\d+(?:\.\d+)?)px\)/;
|
|
291
318
|
const ANIMATION_CALLBACK_NAMES = new Set(["requestAnimationFrame", "setInterval"]);
|
|
292
319
|
const REACT_NATIVE_TEXT_COMPONENTS = new Set([
|
|
@@ -362,7 +389,7 @@ const LONG_TRANSITION_DURATION_THRESHOLD_MS = 1e3;
|
|
|
362
389
|
//#region src/plugin/helpers.ts
|
|
363
390
|
const walkAst = (node, visitor) => {
|
|
364
391
|
if (!node || typeof node !== "object") return;
|
|
365
|
-
visitor(node);
|
|
392
|
+
if (visitor(node) === false) return;
|
|
366
393
|
for (const key of Object.keys(node)) {
|
|
367
394
|
if (key === "parent") continue;
|
|
368
395
|
const child = node[key];
|
|
@@ -464,12 +491,13 @@ const isMutatingDbCall = (node) => {
|
|
|
464
491
|
const { property } = node.callee;
|
|
465
492
|
return property?.type === "Identifier" && MUTATION_METHOD_NAMES.has(property.name);
|
|
466
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());
|
|
467
495
|
const isMutatingFetchCall = (node) => {
|
|
468
496
|
if (node.type !== "CallExpression") return false;
|
|
469
497
|
if (node.callee?.type !== "Identifier" || node.callee.name !== "fetch") return false;
|
|
470
498
|
const optionsArgument = node.arguments?.[1];
|
|
471
499
|
if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return false;
|
|
472
|
-
return optionsArgument.properties?.some(
|
|
500
|
+
return Boolean(optionsArgument.properties?.some(isMutatingMethodProperty));
|
|
473
501
|
};
|
|
474
502
|
const findSideEffect = (node) => {
|
|
475
503
|
let sideEffectDescription = null;
|
|
@@ -477,7 +505,7 @@ const findSideEffect = (node) => {
|
|
|
477
505
|
if (sideEffectDescription) return;
|
|
478
506
|
if (isCookiesOrHeadersCall(child, "cookies")) sideEffectDescription = `cookies().${child.callee.property.name}()`;
|
|
479
507
|
else if (isCookiesOrHeadersCall(child, "headers")) sideEffectDescription = `headers().${child.callee.property.name}()`;
|
|
480
|
-
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}`;
|
|
481
509
|
else if (isMutatingDbCall(child)) {
|
|
482
510
|
const methodName = child.callee.property.name;
|
|
483
511
|
const objectName = child.callee.object?.type === "Identifier" ? child.callee.object.name : null;
|
|
@@ -486,15 +514,51 @@ const findSideEffect = (node) => {
|
|
|
486
514
|
});
|
|
487
515
|
return sideEffectDescription;
|
|
488
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
|
+
};
|
|
489
543
|
const extractDestructuredPropNames = (params) => {
|
|
490
544
|
const propNames = /* @__PURE__ */ new Set();
|
|
491
|
-
for (const param of params)
|
|
492
|
-
for (const property of param.properties ?? []) if (property.type === "Property" && property.key?.type === "Identifier") propNames.add(property.key.name);
|
|
493
|
-
} else if (param.type === "Identifier") propNames.add(param.name);
|
|
545
|
+
for (const param of params) collectPatternNames(param, propNames);
|
|
494
546
|
return propNames;
|
|
495
547
|
};
|
|
496
548
|
//#endregion
|
|
497
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
|
+
} }) };
|
|
498
562
|
const noGiantComponent = { create: (context) => {
|
|
499
563
|
const reportOversizedComponent = (nameNode, componentName, bodyNode) => {
|
|
500
564
|
if (!bodyNode.loc) return;
|
|
@@ -553,6 +617,193 @@ const noNestedComponentDefinition = { create: (context) => {
|
|
|
553
617
|
}
|
|
554
618
|
};
|
|
555
619
|
} };
|
|
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
|
+
} };
|
|
556
807
|
//#endregion
|
|
557
808
|
//#region src/plugin/rules/bundle-size.ts
|
|
558
809
|
const noBarrelImport = { create: (context) => {
|
|
@@ -598,6 +849,38 @@ const useLazyMotion = { create: (context) => ({ ImportDeclaration(node) {
|
|
|
598
849
|
message: "Import \"m\" with LazyMotion instead of \"motion\" — saves ~30kb in bundle size"
|
|
599
850
|
});
|
|
600
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
|
+
}) };
|
|
601
884
|
const noUndeferredThirdParty = { create: (context) => ({ JSXOpeningElement(node) {
|
|
602
885
|
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "script") return;
|
|
603
886
|
const attributes = node.attributes ?? [];
|
|
@@ -611,7 +894,7 @@ const noUndeferredThirdParty = { create: (context) => ({ JSXOpeningElement(node)
|
|
|
611
894
|
//#region src/plugin/rules/client.ts
|
|
612
895
|
const clientPassiveEventListeners = { create: (context) => ({ CallExpression(node) {
|
|
613
896
|
if (!isMemberProperty(node.callee, "addEventListener")) return;
|
|
614
|
-
if (node.arguments?.length < 2) return;
|
|
897
|
+
if ((node.arguments?.length ?? 0) < 2) return;
|
|
615
898
|
const eventNameNode = node.arguments[0];
|
|
616
899
|
if (eventNameNode.type !== "Literal" || !PASSIVE_EVENT_NAMES.has(eventNameNode.value)) return;
|
|
617
900
|
const eventName = eventNameNode.value;
|
|
@@ -619,14 +902,43 @@ const clientPassiveEventListeners = { create: (context) => ({ CallExpression(nod
|
|
|
619
902
|
if (!optionsArgument) {
|
|
620
903
|
context.report({
|
|
621
904
|
node,
|
|
622
|
-
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())`
|
|
623
906
|
});
|
|
624
907
|
return;
|
|
625
908
|
}
|
|
626
909
|
if (optionsArgument.type !== "ObjectExpression") return;
|
|
627
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({
|
|
628
911
|
node,
|
|
629
|
-
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`
|
|
630
942
|
});
|
|
631
943
|
} }) };
|
|
632
944
|
//#endregion
|
|
@@ -675,12 +987,24 @@ const getStylePropertyKey = (property) => {
|
|
|
675
987
|
};
|
|
676
988
|
const parseColorToRgb = (value) => {
|
|
677
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
|
+
};
|
|
678
996
|
const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
|
679
997
|
if (hex6Match) return {
|
|
680
998
|
red: parseInt(hex6Match[1], 16),
|
|
681
999
|
green: parseInt(hex6Match[2], 16),
|
|
682
1000
|
blue: parseInt(hex6Match[3], 16)
|
|
683
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
|
+
};
|
|
684
1008
|
const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
|
685
1009
|
if (hex3Match) return {
|
|
686
1010
|
red: parseInt(hex3Match[1] + hex3Match[1], 16),
|
|
@@ -1145,6 +1469,7 @@ const noLongTransitionDuration = { create: (context) => ({ JSXAttribute(node) {
|
|
|
1145
1469
|
} }) };
|
|
1146
1470
|
//#endregion
|
|
1147
1471
|
//#region src/plugin/rules/correctness.ts
|
|
1472
|
+
const STRING_COERCION_FUNCTIONS = new Set(["String", "Number"]);
|
|
1148
1473
|
const extractIndexName = (node) => {
|
|
1149
1474
|
if (node.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.name)) return node.name;
|
|
1150
1475
|
if (node.type === "TemplateLiteral") {
|
|
@@ -1152,7 +1477,8 @@ const extractIndexName = (node) => {
|
|
|
1152
1477
|
if (indexExpression) return indexExpression.name;
|
|
1153
1478
|
}
|
|
1154
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;
|
|
1155
|
-
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;
|
|
1156
1482
|
return null;
|
|
1157
1483
|
};
|
|
1158
1484
|
const isInsideStaticPlaceholderMap = (node) => {
|
|
@@ -1182,8 +1508,8 @@ const noArrayIndexAsKey = { create: (context) => ({ JSXAttribute(node) {
|
|
|
1182
1508
|
});
|
|
1183
1509
|
} }) };
|
|
1184
1510
|
const PREVENT_DEFAULT_ELEMENTS = {
|
|
1185
|
-
form: "onSubmit",
|
|
1186
|
-
a: "onClick"
|
|
1511
|
+
form: ["onSubmit"],
|
|
1512
|
+
a: ["onClick"]
|
|
1187
1513
|
};
|
|
1188
1514
|
const containsPreventDefaultCall = (node) => {
|
|
1189
1515
|
let didFindPreventDefault = false;
|
|
@@ -1193,28 +1519,84 @@ const containsPreventDefaultCall = (node) => {
|
|
|
1193
1519
|
});
|
|
1194
1520
|
return didFindPreventDefault;
|
|
1195
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
|
+
};
|
|
1196
1526
|
const noPreventDefault = { create: (context) => ({ JSXOpeningElement(node) {
|
|
1197
1527
|
const elementName = node.name?.type === "JSXIdentifier" ? node.name.name : null;
|
|
1198
1528
|
if (!elementName) return;
|
|
1199
|
-
const
|
|
1200
|
-
if (!
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
+
}
|
|
1211
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
|
+
};
|
|
1212
1561
|
const renderingConditionalRender = { create: (context) => ({ LogicalExpression(node) {
|
|
1213
1562
|
if (node.operator !== "&&") return;
|
|
1214
1563
|
if (!(node.right?.type === "JSXElement" || node.right?.type === "JSXFragment")) return;
|
|
1215
|
-
|
|
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({
|
|
1569
|
+
node,
|
|
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({
|
|
1216
1598
|
node,
|
|
1217
|
-
message:
|
|
1599
|
+
message: `SVG ${node.name.name} attribute uses 4+ decimal precision — truncate to 1–2 decimals to shrink markup with no visible difference`
|
|
1218
1600
|
});
|
|
1219
1601
|
} }) };
|
|
1220
1602
|
//#endregion
|
|
@@ -1294,7 +1676,7 @@ const jsCacheStorage = { create: (context) => {
|
|
|
1294
1676
|
const storageReadCounts = /* @__PURE__ */ new Map();
|
|
1295
1677
|
return { CallExpression(node) {
|
|
1296
1678
|
if (!isMemberProperty(node.callee, "getItem")) return;
|
|
1297
|
-
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;
|
|
1298
1680
|
if (node.arguments?.[0]?.type !== "Literal") return;
|
|
1299
1681
|
const storageKey = String(node.arguments[0].value);
|
|
1300
1682
|
const readCount = (storageReadCounts.get(storageKey) ?? 0) + 1;
|
|
@@ -1371,6 +1753,193 @@ const jsFlatmapFilter = { create: (context) => ({ CallExpression(node) {
|
|
|
1371
1753
|
message: ".map().filter(Boolean) iterates twice — use .flatMap() to transform and filter in a single pass"
|
|
1372
1754
|
});
|
|
1373
1755
|
} }) };
|
|
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
|
+
} };
|
|
1374
1943
|
//#endregion
|
|
1375
1944
|
//#region src/plugin/rules/nextjs.ts
|
|
1376
1945
|
const nextjsNoImgElement = { create: (context) => {
|
|
@@ -1420,13 +1989,39 @@ const nextjsNoAElement = { create: (context) => ({ JSXOpeningElement(node) {
|
|
|
1420
1989
|
message: "Use next/link instead of <a> for internal links — enables client-side navigation and prefetching"
|
|
1421
1990
|
});
|
|
1422
1991
|
} }) };
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
+
}
|
|
1428
2006
|
});
|
|
1429
|
-
|
|
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
|
+
} };
|
|
1430
2025
|
const nextjsNoClientFetchForServerData = { create: (context) => {
|
|
1431
2026
|
let fileHasUseClient = false;
|
|
1432
2027
|
return {
|
|
@@ -1600,8 +2195,7 @@ const getExportedGetHandlerBody = (node) => {
|
|
|
1600
2195
|
if (!declaration) return null;
|
|
1601
2196
|
if (declaration.type === "FunctionDeclaration" && declaration.id?.name === "GET") return declaration.body;
|
|
1602
2197
|
if (declaration.type === "VariableDeclaration") {
|
|
1603
|
-
const declarator
|
|
1604
|
-
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;
|
|
1605
2199
|
}
|
|
1606
2200
|
return null;
|
|
1607
2201
|
};
|
|
@@ -1667,6 +2261,13 @@ const noInlinePropOnMemoComponent = { create: (context) => {
|
|
|
1667
2261
|
}
|
|
1668
2262
|
};
|
|
1669
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
|
+
};
|
|
1670
2271
|
const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node) {
|
|
1671
2272
|
if (!isHookCall(node, "useMemo")) return;
|
|
1672
2273
|
const callback = node.arguments?.[0];
|
|
@@ -1675,7 +2276,7 @@ const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node)
|
|
|
1675
2276
|
let returnExpression = null;
|
|
1676
2277
|
if (callback.body?.type !== "BlockStatement") returnExpression = callback.body;
|
|
1677
2278
|
else if (callback.body.body?.length === 1 && callback.body.body[0].type === "ReturnStatement") returnExpression = callback.body.body[0].argument;
|
|
1678
|
-
if (returnExpression &&
|
|
2279
|
+
if (returnExpression && isTriviallyCheapExpression(returnExpression)) context.report({
|
|
1679
2280
|
node,
|
|
1680
2281
|
message: "useMemo wrapping a trivially cheap expression — memo overhead exceeds the computation"
|
|
1681
2282
|
});
|
|
@@ -1822,8 +2423,21 @@ const renderingAnimateSvgWrapper = { create: (context) => ({ JSXOpeningElement(n
|
|
|
1822
2423
|
message: "Animation props directly on <svg> — wrap in a <div> or <motion.div> for better rendering performance"
|
|
1823
2424
|
});
|
|
1824
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
|
+
} }) };
|
|
1825
2439
|
const renderingHydrationNoFlicker = { create: (context) => ({ CallExpression(node) {
|
|
1826
|
-
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments?.length < 2) return;
|
|
2440
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
|
|
1827
2441
|
const depsNode = node.arguments[1];
|
|
1828
2442
|
if (depsNode.type !== "ArrayExpression" || depsNode.elements?.length !== 0) return;
|
|
1829
2443
|
const callback = getEffectCallback(node);
|
|
@@ -1849,60 +2463,388 @@ const renderingScriptDeferAsync = { create: (context) => ({ JSXOpeningElement(no
|
|
|
1849
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"
|
|
1850
2464
|
});
|
|
1851
2465
|
} }) };
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
return
|
|
1860
|
-
};
|
|
1861
|
-
const truncateText = (text) => text.length > 30 ? `${text.slice(0, 30)}...` : text;
|
|
1862
|
-
const isRawTextContent = (child) => {
|
|
1863
|
-
if (child.type === "JSXText") return Boolean(child.value?.trim());
|
|
1864
|
-
if (child.type !== "JSXExpressionContainer" || !child.expression) return false;
|
|
1865
|
-
const expression = child.expression;
|
|
1866
|
-
return expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") || expression.type === "TemplateLiteral";
|
|
1867
|
-
};
|
|
1868
|
-
const getRawTextDescription = (child) => {
|
|
1869
|
-
if (child.type === "JSXText") return `"${truncateText(child.value.trim())}"`;
|
|
1870
|
-
if (child.type === "JSXExpressionContainer" && child.expression) {
|
|
1871
|
-
const expression = child.expression;
|
|
1872
|
-
if (expression.type === "Literal" && typeof expression.value === "string") return `"${truncateText(expression.value)}"`;
|
|
1873
|
-
if (expression.type === "Literal" && typeof expression.value === "number") return `{${expression.value}}`;
|
|
1874
|
-
if (expression.type === "TemplateLiteral") return "template literal";
|
|
1875
|
-
}
|
|
1876
|
-
return "text content";
|
|
1877
|
-
};
|
|
1878
|
-
const isTextHandlingComponent = (elementName) => {
|
|
1879
|
-
if (REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return true;
|
|
1880
|
-
return [...REACT_NATIVE_TEXT_COMPONENT_KEYWORDS].some((keyword) => elementName.includes(keyword));
|
|
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;
|
|
1881
2474
|
};
|
|
1882
|
-
const
|
|
1883
|
-
let
|
|
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
|
+
};
|
|
1884
2488
|
return {
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
if (
|
|
1892
|
-
for (const
|
|
1893
|
-
|
|
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>";
|
|
1894
2502
|
context.report({
|
|
1895
|
-
node:
|
|
1896
|
-
message: `
|
|
2503
|
+
node: declarator,
|
|
2504
|
+
message: `Static JSX "${name}" inside a component — hoist to module scope so it isn't recreated each render`
|
|
1897
2505
|
});
|
|
1898
2506
|
}
|
|
1899
2507
|
}
|
|
1900
2508
|
};
|
|
1901
2509
|
} };
|
|
1902
|
-
const
|
|
1903
|
-
if (
|
|
1904
|
-
|
|
1905
|
-
|
|
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;
|
|
2518
|
+
};
|
|
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;
|
|
2526
|
+
};
|
|
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) => {
|
|
2825
|
+
let isDomComponentFile = false;
|
|
2826
|
+
return {
|
|
2827
|
+
Program(programNode) {
|
|
2828
|
+
isDomComponentFile = hasDirective(programNode, "use dom");
|
|
2829
|
+
},
|
|
2830
|
+
JSXElement(node) {
|
|
2831
|
+
if (isDomComponentFile) return;
|
|
2832
|
+
const elementName = resolveJsxElementName(node.openingElement);
|
|
2833
|
+
if (elementName && isTextHandlingComponent(elementName)) return;
|
|
2834
|
+
for (const child of node.children ?? []) {
|
|
2835
|
+
if (!isRawTextContent(child)) continue;
|
|
2836
|
+
context.report({
|
|
2837
|
+
node: child,
|
|
2838
|
+
message: `Raw ${getRawTextDescription(child)} outside a <Text> component — this will crash on React Native`
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
};
|
|
2843
|
+
} };
|
|
2844
|
+
const rnNoDeprecatedModules = { create: (context) => ({ ImportDeclaration(node) {
|
|
2845
|
+
if (node.source?.value !== "react-native") return;
|
|
2846
|
+
for (const specifier of node.specifiers ?? []) {
|
|
2847
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
1906
2848
|
const importedName = specifier.imported?.name;
|
|
1907
2849
|
if (!importedName) continue;
|
|
1908
2850
|
const replacement = DEPRECATED_RN_MODULE_REPLACEMENTS[importedName];
|
|
@@ -2011,6 +2953,449 @@ const rnNoSingleElementStyleArray = { create: (context) => ({ JSXAttribute(node)
|
|
|
2011
2953
|
message: `Single-element style array on "${propName}" — use ${propName}={value} instead of ${propName}={[value]} to avoid unnecessary array allocation`
|
|
2012
2954
|
});
|
|
2013
2955
|
} }) };
|
|
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
|
+
} }) };
|
|
2014
3399
|
//#endregion
|
|
2015
3400
|
//#region src/plugin/rules/tanstack-query.ts
|
|
2016
3401
|
const queryStableQueryClient = { create: (context) => {
|
|
@@ -2030,10 +3415,10 @@ const queryStableQueryClient = { create: (context) => {
|
|
|
2030
3415
|
if (node.id?.type === "Identifier" && UPPERCASE_PATTERN.test(node.id.name) && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) componentDepth--;
|
|
2031
3416
|
},
|
|
2032
3417
|
CallExpression(node) {
|
|
2033
|
-
if (node
|
|
3418
|
+
if (isHookCall(node, STABLE_HOOK_WRAPPERS)) stableHookDepth++;
|
|
2034
3419
|
},
|
|
2035
3420
|
"CallExpression:exit"(node) {
|
|
2036
|
-
if (node
|
|
3421
|
+
if (isHookCall(node, STABLE_HOOK_WRAPPERS)) stableHookDepth = Math.max(0, stableHookDepth - 1);
|
|
2037
3422
|
},
|
|
2038
3423
|
NewExpression(node) {
|
|
2039
3424
|
if (componentDepth <= 0) return;
|
|
@@ -2092,14 +3477,17 @@ const queryMutationMissingInvalidation = { create: (context) => ({ CallExpressio
|
|
|
2092
3477
|
const optionsArgument = node.arguments?.[0];
|
|
2093
3478
|
if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return;
|
|
2094
3479
|
if (!optionsArgument.properties?.some((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "mutationFn")) return;
|
|
2095
|
-
let
|
|
3480
|
+
let hasCacheUpdate = false;
|
|
2096
3481
|
walkAst(optionsArgument, (child) => {
|
|
2097
|
-
if (
|
|
2098
|
-
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
|
+
}
|
|
2099
3487
|
});
|
|
2100
|
-
if (!
|
|
3488
|
+
if (!hasCacheUpdate) context.report({
|
|
2101
3489
|
node,
|
|
2102
|
-
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)"
|
|
2103
3491
|
});
|
|
2104
3492
|
} }) };
|
|
2105
3493
|
const queryNoUseQueryForMutation = { create: (context) => ({ CallExpression(node) {
|
|
@@ -2153,7 +3541,7 @@ const noSecretsInClientCode = { create: (context) => ({ VariableDeclarator(node)
|
|
|
2153
3541
|
const literalValue = node.init.value;
|
|
2154
3542
|
const trailingSuffix = variableName.split("_").pop()?.toLowerCase() ?? "";
|
|
2155
3543
|
const isUiConstant = SECRET_FALSE_POSITIVE_SUFFIXES.has(trailingSuffix);
|
|
2156
|
-
if (SECRET_VARIABLE_PATTERN.test(variableName) && !isUiConstant && literalValue.length >
|
|
3544
|
+
if (SECRET_VARIABLE_PATTERN.test(variableName) && !isUiConstant && literalValue.length > 24) {
|
|
2157
3545
|
context.report({
|
|
2158
3546
|
node,
|
|
2159
3547
|
message: `Possible hardcoded secret in "${variableName}" — use environment variables instead`
|
|
@@ -2188,7 +3576,7 @@ const serverAuthActions = { create: (context) => {
|
|
|
2188
3576
|
const declaration = node.declaration;
|
|
2189
3577
|
if (declaration?.type !== "FunctionDeclaration" || !declaration?.async) return;
|
|
2190
3578
|
if (!(fileHasUseServerDirective || hasUseServerDirective(declaration))) return;
|
|
2191
|
-
if (!containsAuthCheck((declaration.body?.body ?? []).slice(0,
|
|
3579
|
+
if (!containsAuthCheck((declaration.body?.body ?? []).slice(0, 10))) {
|
|
2192
3580
|
const functionName = declaration.id?.name ?? "anonymous";
|
|
2193
3581
|
context.report({
|
|
2194
3582
|
node: declaration.id ?? node,
|
|
@@ -2198,23 +3586,380 @@ const serverAuthActions = { create: (context) => {
|
|
|
2198
3586
|
}
|
|
2199
3587
|
};
|
|
2200
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
|
+
};
|
|
2201
3679
|
const serverAfterNonblocking = { create: (context) => {
|
|
2202
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
|
+
};
|
|
2203
3688
|
return {
|
|
2204
3689
|
Program(programNode) {
|
|
2205
3690
|
fileHasUseServerDirective = hasDirective(programNode, "use server");
|
|
2206
3691
|
},
|
|
3692
|
+
FunctionDeclaration: enterIfServerFunction,
|
|
3693
|
+
"FunctionDeclaration:exit": leaveIfServerFunction,
|
|
3694
|
+
FunctionExpression: enterIfServerFunction,
|
|
3695
|
+
"FunctionExpression:exit": leaveIfServerFunction,
|
|
3696
|
+
ArrowFunctionExpression: enterIfServerFunction,
|
|
3697
|
+
"ArrowFunctionExpression:exit": leaveIfServerFunction,
|
|
2207
3698
|
CallExpression(node) {
|
|
2208
|
-
if (!fileHasUseServerDirective) return;
|
|
3699
|
+
if (!fileHasUseServerDirective && serverFunctionDepth === 0) return;
|
|
2209
3700
|
if (node.callee?.type !== "MemberExpression") return;
|
|
2210
3701
|
if (node.callee.property?.type !== "Identifier") return;
|
|
2211
3702
|
const objectName = node.callee.object?.type === "Identifier" ? node.callee.object.name : null;
|
|
2212
3703
|
if (!objectName) return;
|
|
2213
3704
|
const methodName = node.callee.property.name;
|
|
2214
|
-
if (!(objectName
|
|
3705
|
+
if (!isDeferrableSideEffectCall(objectName, methodName)) return;
|
|
3706
|
+
context.report({
|
|
3707
|
+
node,
|
|
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";
|
|
2215
3960
|
context.report({
|
|
2216
3961
|
node,
|
|
2217
|
-
message:
|
|
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`
|
|
2218
3963
|
});
|
|
2219
3964
|
}
|
|
2220
3965
|
};
|
|
@@ -2326,8 +4071,7 @@ const tanstackStartServerFnValidateInput = { create: (context) => ({ CallExpress
|
|
|
2326
4071
|
const tanstackStartNoUseEffectFetch = { create: (context) => ({ CallExpression(node) {
|
|
2327
4072
|
const filename = context.getFilename?.() ?? "";
|
|
2328
4073
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2329
|
-
if (node
|
|
2330
|
-
if (node.callee.name !== "useEffect" && node.callee.name !== "useLayoutEffect") return;
|
|
4074
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
|
|
2331
4075
|
const callback = node.arguments?.[0];
|
|
2332
4076
|
if (!callback) return;
|
|
2333
4077
|
let hasFetchCall = false;
|
|
@@ -2402,15 +4146,17 @@ const tanstackStartServerFnMethodOrder = { create: (context) => ({ CallExpressio
|
|
|
2402
4146
|
}
|
|
2403
4147
|
} }) };
|
|
2404
4148
|
const tanstackStartNoNavigateInRender = { create: (context) => {
|
|
2405
|
-
let
|
|
4149
|
+
let deferredCallbackDepth = 0;
|
|
2406
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));
|
|
2407
4153
|
return {
|
|
2408
4154
|
CallExpression(node) {
|
|
2409
4155
|
const filename = context.getFilename?.() ?? "";
|
|
2410
4156
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2411
|
-
if (
|
|
2412
|
-
if (
|
|
2413
|
-
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({
|
|
2414
4160
|
node,
|
|
2415
4161
|
message: "navigate() called during render — use redirect() in beforeLoad/loader for route-level redirects"
|
|
2416
4162
|
});
|
|
@@ -2418,17 +4164,17 @@ const tanstackStartNoNavigateInRender = { create: (context) => {
|
|
|
2418
4164
|
"CallExpression:exit"(node) {
|
|
2419
4165
|
const filename = context.getFilename?.() ?? "";
|
|
2420
4166
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2421
|
-
if (node
|
|
4167
|
+
if (isDeferredHookCall(node)) deferredCallbackDepth = Math.max(0, deferredCallbackDepth - 1);
|
|
2422
4168
|
},
|
|
2423
4169
|
JSXAttribute(node) {
|
|
2424
4170
|
const filename = context.getFilename?.() ?? "";
|
|
2425
4171
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2426
|
-
if (
|
|
4172
|
+
if (isEventHandlerAttribute(node)) eventHandlerDepth++;
|
|
2427
4173
|
},
|
|
2428
4174
|
"JSXAttribute:exit"(node) {
|
|
2429
4175
|
const filename = context.getFilename?.() ?? "";
|
|
2430
4176
|
if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
|
|
2431
|
-
if (node
|
|
4177
|
+
if (isEventHandlerAttribute(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
|
|
2432
4178
|
}
|
|
2433
4179
|
};
|
|
2434
4180
|
} };
|
|
@@ -2455,6 +4201,17 @@ const tanstackStartNoUseServerInHandler = { create: (context) => ({ CallExpressi
|
|
|
2455
4201
|
message: "\"use server\" inside createServerFn handler — TanStack Start handles this automatically, remove the directive"
|
|
2456
4202
|
});
|
|
2457
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
|
+
};
|
|
2458
4215
|
const tanstackStartNoSecretsInLoader = { create: (context) => ({ CallExpression(node) {
|
|
2459
4216
|
const optionsObject = getRouteOptionsObject(node);
|
|
2460
4217
|
if (!optionsObject) return;
|
|
@@ -2464,11 +4221,15 @@ const tanstackStartNoSecretsInLoader = { create: (context) => ({ CallExpression(
|
|
|
2464
4221
|
if (keyName !== "loader" && keyName !== "beforeLoad") continue;
|
|
2465
4222
|
walkAst(property.value ?? property, (child) => {
|
|
2466
4223
|
if (child.type !== "MemberExpression") return;
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
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({
|
|
2470
4231
|
node: child,
|
|
2471
|
-
message:
|
|
4232
|
+
message: `${envSource}.${envVarName} in ${keyName} — loaders are isomorphic and may leak secrets to the client. Move to a createServerFn()`
|
|
2472
4233
|
});
|
|
2473
4234
|
}
|
|
2474
4235
|
});
|
|
@@ -2522,6 +4283,7 @@ const hasTopLevelAwait = (statement) => {
|
|
|
2522
4283
|
if (statement.type === "VariableDeclaration") return statement.declarations?.some((declarator) => declarator.init?.type === "AwaitExpression");
|
|
2523
4284
|
if (statement.type === "ExpressionStatement") return statement.expression?.type === "AwaitExpression" || statement.expression?.type === "AssignmentExpression" && statement.expression.right?.type === "AwaitExpression";
|
|
2524
4285
|
if (statement.type === "ReturnStatement") return statement.argument?.type === "AwaitExpression";
|
|
4286
|
+
if (statement.type === "ForOfStatement" && statement.await) return true;
|
|
2525
4287
|
return false;
|
|
2526
4288
|
};
|
|
2527
4289
|
const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpression(node) {
|
|
@@ -2550,7 +4312,7 @@ const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpressio
|
|
|
2550
4312
|
//#endregion
|
|
2551
4313
|
//#region src/plugin/rules/state-and-effects.ts
|
|
2552
4314
|
const noDerivedStateEffect = { create: (context) => ({ CallExpression(node) {
|
|
2553
|
-
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments
|
|
4315
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
|
|
2554
4316
|
const callback = getEffectCallback(node);
|
|
2555
4317
|
if (!callback) return;
|
|
2556
4318
|
const depsNode = node.arguments[1];
|
|
@@ -2604,7 +4366,7 @@ const noCascadingSetState = { create: (context) => ({ CallExpression(node) {
|
|
|
2604
4366
|
});
|
|
2605
4367
|
} }) };
|
|
2606
4368
|
const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
|
|
2607
|
-
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments
|
|
4369
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
|
|
2608
4370
|
const callback = getEffectCallback(node);
|
|
2609
4371
|
if (!callback) return;
|
|
2610
4372
|
const depsNode = node.arguments[1];
|
|
@@ -2619,24 +4381,57 @@ const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
|
|
|
2619
4381
|
});
|
|
2620
4382
|
} }) };
|
|
2621
4383
|
const noDerivedUseState = { create: (context) => {
|
|
2622
|
-
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
|
+
};
|
|
2623
4393
|
return {
|
|
2624
4394
|
FunctionDeclaration(node) {
|
|
2625
|
-
if (!node.id?.name || !isUppercaseName(node.id.name))
|
|
2626
|
-
|
|
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();
|
|
2627
4403
|
},
|
|
2628
4404
|
VariableDeclarator(node) {
|
|
2629
|
-
if (
|
|
2630
|
-
|
|
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();
|
|
2631
4413
|
},
|
|
2632
4414
|
CallExpression(node) {
|
|
2633
4415
|
if (!isHookCall(node, "useState") || !node.arguments?.length) return;
|
|
4416
|
+
if (componentPropStack.length === 0) return;
|
|
2634
4417
|
const initializer = node.arguments[0];
|
|
2635
|
-
if (initializer.type
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
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
|
+
}
|
|
2640
4435
|
}
|
|
2641
4436
|
};
|
|
2642
4437
|
} };
|
|
@@ -2675,15 +4470,42 @@ const rerenderLazyStateInit = { create: (context) => ({ CallExpression(node) {
|
|
|
2675
4470
|
message: `useState(${calleeName}()) calls initializer on every render — use useState(() => ${calleeName}()) for lazy initialization`
|
|
2676
4471
|
});
|
|
2677
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
|
+
};
|
|
2678
4485
|
const rerenderFunctionalSetstate = { create: (context) => ({ CallExpression(node) {
|
|
2679
4486
|
if (!isSetterCall(node)) return;
|
|
2680
4487
|
if (!node.arguments?.length) return;
|
|
2681
4488
|
const calleeName = node.callee.name;
|
|
2682
4489
|
const argument = node.arguments[0];
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
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
|
+
}
|
|
2687
4509
|
} }) };
|
|
2688
4510
|
const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
|
|
2689
4511
|
if (!isHookCall(node, HOOKS_WITH_DEPS) || node.arguments.length < 2) return;
|
|
@@ -2701,6 +4523,295 @@ const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
|
|
|
2701
4523
|
});
|
|
2702
4524
|
}
|
|
2703
4525
|
} }) };
|
|
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
|
+
};
|
|
2704
4815
|
//#endregion
|
|
2705
4816
|
//#region src/plugin/index.ts
|
|
2706
4817
|
const plugin = {
|
|
@@ -2710,21 +4821,69 @@ const plugin = {
|
|
|
2710
4821
|
"no-fetch-in-effect": noFetchInEffect,
|
|
2711
4822
|
"no-cascading-set-state": noCascadingSetState,
|
|
2712
4823
|
"no-effect-event-handler": noEffectEventHandler,
|
|
4824
|
+
"no-effect-event-in-deps": noEffectEventInDeps,
|
|
4825
|
+
"no-prop-callback-in-effect": noPropCallbackInEffect,
|
|
2713
4826
|
"no-derived-useState": noDerivedUseState,
|
|
2714
4827
|
"prefer-useReducer": preferUseReducer,
|
|
2715
4828
|
"rerender-lazy-state-init": rerenderLazyStateInit,
|
|
2716
4829
|
"rerender-functional-setstate": rerenderFunctionalSetstate,
|
|
2717
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,
|
|
2718
4865
|
"no-giant-component": noGiantComponent,
|
|
4866
|
+
"no-many-boolean-props": noManyBooleanProps,
|
|
4867
|
+
"no-react19-deprecated-apis": noReact19DeprecatedApis,
|
|
4868
|
+
"no-render-prop-children": noRenderPropChildren,
|
|
2719
4869
|
"no-render-in-render": noRenderInRender,
|
|
2720
4870
|
"no-nested-component-definition": noNestedComponentDefinition,
|
|
4871
|
+
"react-compiler-destructure-method": reactCompilerDestructureMethod,
|
|
2721
4872
|
"no-usememo-simple-expression": noUsememoSimpleExpression,
|
|
2722
4873
|
"no-layout-property-animation": noLayoutPropertyAnimation,
|
|
2723
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,
|
|
2724
4880
|
"rendering-animate-svg-wrapper": renderingAnimateSvgWrapper,
|
|
4881
|
+
"rendering-hoist-jsx": renderingHoistJsx,
|
|
4882
|
+
"rendering-hydration-mismatch-time": renderingHydrationMismatchTime,
|
|
2725
4883
|
"no-inline-prop-on-memo-component": noInlinePropOnMemoComponent,
|
|
2726
4884
|
"rendering-hydration-no-flicker": renderingHydrationNoFlicker,
|
|
2727
4885
|
"rendering-script-defer-async": renderingScriptDeferAsync,
|
|
4886
|
+
"rendering-usetransition-loading": renderingUsetransitionLoading,
|
|
2728
4887
|
"no-transition-all": noTransitionAll,
|
|
2729
4888
|
"no-global-css-variable-animation": noGlobalCssVariableAnimation,
|
|
2730
4889
|
"no-large-animated-blur": noLargeAnimatedBlur,
|
|
@@ -2733,14 +4892,37 @@ const plugin = {
|
|
|
2733
4892
|
"no-eval": noEval,
|
|
2734
4893
|
"no-secrets-in-client-code": noSecretsInClientCode,
|
|
2735
4894
|
"no-barrel-import": noBarrelImport,
|
|
4895
|
+
"no-dynamic-import-path": noDynamicImportPath,
|
|
2736
4896
|
"no-full-lodash-import": noFullLodashImport,
|
|
2737
4897
|
"no-moment": noMoment,
|
|
2738
4898
|
"prefer-dynamic-import": preferDynamicImport,
|
|
2739
4899
|
"use-lazy-motion": useLazyMotion,
|
|
2740
4900
|
"no-undeferred-third-party": noUndeferredThirdParty,
|
|
2741
4901
|
"no-array-index-as-key": noArrayIndexAsKey,
|
|
4902
|
+
"no-polymorphic-children": noPolymorphicChildren,
|
|
2742
4903
|
"rendering-conditional-render": renderingConditionalRender,
|
|
4904
|
+
"rendering-svg-precision": renderingSvgPrecision,
|
|
2743
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
|
+
} }) },
|
|
2744
4926
|
"nextjs-no-img-element": nextjsNoImgElement,
|
|
2745
4927
|
"nextjs-async-client-component": nextjsAsyncClientComponent,
|
|
2746
4928
|
"nextjs-no-a-element": nextjsNoAElement,
|
|
@@ -2759,10 +4941,20 @@ const plugin = {
|
|
|
2759
4941
|
"nextjs-no-side-effect-in-get-handler": nextjsNoSideEffectInGetHandler,
|
|
2760
4942
|
"server-auth-actions": serverAuthActions,
|
|
2761
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,
|
|
2762
4950
|
"client-passive-event-listeners": clientPassiveEventListeners,
|
|
4951
|
+
"client-localstorage-no-version": clientLocalstorageNoVersion,
|
|
2763
4952
|
"js-combine-iterations": jsCombineIterations,
|
|
2764
4953
|
"js-tosorted-immutable": jsTosortedImmutable,
|
|
2765
4954
|
"js-hoist-regexp": jsHoistRegexp,
|
|
4955
|
+
"js-hoist-intl": jsHoistIntl,
|
|
4956
|
+
"js-cache-property-access": jsCachePropertyAccess,
|
|
4957
|
+
"js-length-check-first": jsLengthCheckFirst,
|
|
2766
4958
|
"js-min-max-loop": jsMinMaxLoop,
|
|
2767
4959
|
"js-set-map-lookups": jsSetMapLookups,
|
|
2768
4960
|
"js-batch-dom-css": jsBatchDomCss,
|
|
@@ -2779,6 +4971,22 @@ const plugin = {
|
|
|
2779
4971
|
"rn-no-legacy-shadow-styles": rnNoLegacyShadowStyles,
|
|
2780
4972
|
"rn-prefer-reanimated": rnPreferReanimated,
|
|
2781
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,
|
|
2782
4990
|
"tanstack-start-route-property-order": tanstackStartRoutePropertyOrder,
|
|
2783
4991
|
"tanstack-start-no-direct-fetch-in-loader": tanstackStartNoDirectFetchInLoader,
|
|
2784
4992
|
"tanstack-start-server-fn-validate-input": tanstackStartServerFnValidateInput,
|