react-doctor 0.0.8 → 0.0.9
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/dist/cli.js +319 -82
- package/dist/cli.js.map +1 -1
- package/dist/react-doctor-plugin.d.ts.map +1 -1
- package/dist/react-doctor-plugin.js +218 -75
- package/dist/react-doctor-plugin.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
//#region src/plugin/constants.ts
|
|
2
|
-
const GIANT_COMPONENT_LINE_THRESHOLD =
|
|
3
|
-
const CASCADING_SET_STATE_THRESHOLD =
|
|
4
|
-
const RELATED_USE_STATE_THRESHOLD =
|
|
2
|
+
const GIANT_COMPONENT_LINE_THRESHOLD = 300;
|
|
3
|
+
const CASCADING_SET_STATE_THRESHOLD = 3;
|
|
4
|
+
const RELATED_USE_STATE_THRESHOLD = 5;
|
|
5
5
|
const DEEP_NESTING_THRESHOLD = 3;
|
|
6
6
|
const DUPLICATE_STORAGE_READ_THRESHOLD = 2;
|
|
7
|
-
const SEQUENTIAL_AWAIT_THRESHOLD =
|
|
7
|
+
const SEQUENTIAL_AWAIT_THRESHOLD = 3;
|
|
8
8
|
const SECRET_MIN_LENGTH_CHARS = 8;
|
|
9
9
|
const AUTH_CHECK_LOOKAHEAD_STATEMENTS = 3;
|
|
10
10
|
const LAYOUT_PROPERTIES = new Set([
|
|
@@ -109,16 +109,84 @@ const SECRET_PATTERNS = [
|
|
|
109
109
|
/^sk-[a-zA-Z0-9]{32,}$/
|
|
110
110
|
];
|
|
111
111
|
const SECRET_VARIABLE_PATTERN = /(?:api_?key|secret|token|password|credential|auth)/i;
|
|
112
|
-
const
|
|
113
|
-
|
|
112
|
+
const SECRET_FALSE_POSITIVE_SUFFIXES = new Set([
|
|
113
|
+
"modal",
|
|
114
|
+
"label",
|
|
115
|
+
"text",
|
|
116
|
+
"title",
|
|
117
|
+
"name",
|
|
118
|
+
"id",
|
|
119
|
+
"key",
|
|
120
|
+
"url",
|
|
121
|
+
"path",
|
|
122
|
+
"route",
|
|
123
|
+
"page",
|
|
124
|
+
"param",
|
|
125
|
+
"field",
|
|
126
|
+
"column",
|
|
127
|
+
"header",
|
|
128
|
+
"placeholder",
|
|
129
|
+
"description",
|
|
130
|
+
"type",
|
|
131
|
+
"icon",
|
|
132
|
+
"class",
|
|
133
|
+
"style",
|
|
134
|
+
"variant",
|
|
135
|
+
"event",
|
|
136
|
+
"action",
|
|
137
|
+
"status",
|
|
138
|
+
"state",
|
|
139
|
+
"mode",
|
|
140
|
+
"flag",
|
|
141
|
+
"option",
|
|
142
|
+
"config",
|
|
143
|
+
"message",
|
|
144
|
+
"error",
|
|
145
|
+
"display",
|
|
146
|
+
"view",
|
|
147
|
+
"component",
|
|
148
|
+
"element",
|
|
149
|
+
"container",
|
|
150
|
+
"wrapper",
|
|
151
|
+
"button",
|
|
152
|
+
"link",
|
|
153
|
+
"input",
|
|
154
|
+
"select",
|
|
155
|
+
"dialog",
|
|
156
|
+
"menu",
|
|
157
|
+
"form",
|
|
158
|
+
"step",
|
|
159
|
+
"index",
|
|
160
|
+
"count",
|
|
161
|
+
"length",
|
|
162
|
+
"role",
|
|
163
|
+
"scope",
|
|
164
|
+
"context",
|
|
165
|
+
"provider",
|
|
166
|
+
"ref",
|
|
167
|
+
"handler",
|
|
168
|
+
"query",
|
|
169
|
+
"schema",
|
|
170
|
+
"constant"
|
|
171
|
+
]);
|
|
172
|
+
const TRIVIAL_INITIALIZER_NAMES = new Set([
|
|
173
|
+
"Boolean",
|
|
174
|
+
"String",
|
|
175
|
+
"Number",
|
|
176
|
+
"Array",
|
|
177
|
+
"Object",
|
|
178
|
+
"parseInt",
|
|
179
|
+
"parseFloat"
|
|
180
|
+
]);
|
|
114
181
|
const SETTER_PATTERN = /^set[A-Z]/;
|
|
115
182
|
const RENDER_FUNCTION_PATTERN = /^render[A-Z]/;
|
|
116
183
|
const UPPERCASE_PATTERN = /^[A-Z]/;
|
|
117
184
|
const PAGE_FILE_PATTERN = /\/page\.(tsx?|jsx?)$/;
|
|
118
185
|
const PAGE_OR_LAYOUT_FILE_PATTERN = /\/(page|layout)\.(tsx?|jsx?)$/;
|
|
186
|
+
const INTERNAL_PAGE_PATH_PATTERN = /\/(?:(?:\((?:dashboard|admin|settings|account|internal|manage|console|portal|auth|onboarding|app|ee|protected)\))|(?:dashboard|admin|settings|account|internal|manage|console|portal))\//i;
|
|
187
|
+
const TEST_FILE_PATTERN = /\.(?:test|spec|stories)\.[tj]sx?$/;
|
|
188
|
+
const OG_ROUTE_PATTERN = /\/og\b/i;
|
|
119
189
|
const PAGES_DIRECTORY_PATTERN = /\/pages\//;
|
|
120
|
-
const SERVER_ACTION_FILE_PATTERN = /actions?\.(tsx?|jsx?)$/;
|
|
121
|
-
const SERVER_ACTION_DIRECTORY_PATTERN = /\/actions\//;
|
|
122
190
|
const NEXTJS_NAVIGATION_FUNCTIONS = new Set([
|
|
123
191
|
"redirect",
|
|
124
192
|
"permanentRedirect",
|
|
@@ -305,16 +373,6 @@ const extractDestructuredPropNames = (params) => {
|
|
|
305
373
|
|
|
306
374
|
//#endregion
|
|
307
375
|
//#region src/plugin/rules/architecture.ts
|
|
308
|
-
const noGenericHandlerNames = { create: (context) => ({ JSXAttribute(node) {
|
|
309
|
-
if (node.name?.type !== "JSXIdentifier" || !EVENT_PROP_PATTERN.test(node.name.name)) return;
|
|
310
|
-
if (!node.value || node.value.type !== "JSXExpressionContainer") return;
|
|
311
|
-
const mirroredHandlerName = `handle${node.name.name.slice(2)}`;
|
|
312
|
-
const expression = node.value.expression;
|
|
313
|
-
if (expression?.type === "Identifier" && expression.name === mirroredHandlerName) context.report({
|
|
314
|
-
node,
|
|
315
|
-
message: `Non-descriptive handler name "${expression.name}" — name should describe what it does, not when it runs`
|
|
316
|
-
});
|
|
317
|
-
} }) };
|
|
318
376
|
const noGiantComponent = { create: (context) => {
|
|
319
377
|
const reportOversizedComponent = (nameNode, componentName, bodyNode) => {
|
|
320
378
|
if (!bodyNode.loc) return;
|
|
@@ -376,14 +434,21 @@ const noNestedComponentDefinition = { create: (context) => {
|
|
|
376
434
|
|
|
377
435
|
//#endregion
|
|
378
436
|
//#region src/plugin/rules/bundle-size.ts
|
|
379
|
-
const noBarrelImport = { create: (context) =>
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
node
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
437
|
+
const noBarrelImport = { create: (context) => {
|
|
438
|
+
let didReportForFile = false;
|
|
439
|
+
return { ImportDeclaration(node) {
|
|
440
|
+
if (didReportForFile) return;
|
|
441
|
+
const source = node.source?.value;
|
|
442
|
+
if (typeof source !== "string" || !source.startsWith(".")) return;
|
|
443
|
+
if (BARREL_INDEX_SUFFIXES.some((suffix) => source.endsWith(suffix))) {
|
|
444
|
+
didReportForFile = true;
|
|
445
|
+
context.report({
|
|
446
|
+
node,
|
|
447
|
+
message: "Import from barrel/index file — import directly from the source module for better tree-shaking"
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
} };
|
|
451
|
+
} };
|
|
387
452
|
const noFullLodashImport = { create: (context) => ({ ImportDeclaration(node) {
|
|
388
453
|
const source = node.source?.value;
|
|
389
454
|
if (source === "lodash" || source === "lodash-es") context.report({
|
|
@@ -457,11 +522,28 @@ const extractIndexName = (node) => {
|
|
|
457
522
|
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "String" && node.arguments?.[0]?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.arguments[0].name)) return node.arguments[0].name;
|
|
458
523
|
return null;
|
|
459
524
|
};
|
|
525
|
+
const isInsideStaticPlaceholderMap = (node) => {
|
|
526
|
+
let current = node;
|
|
527
|
+
while (current.parent) {
|
|
528
|
+
current = current.parent;
|
|
529
|
+
if (current.type === "CallExpression" && current.callee?.type === "MemberExpression" && current.callee.property?.name === "map") {
|
|
530
|
+
const receiver = current.callee.object;
|
|
531
|
+
if (receiver?.type === "CallExpression") {
|
|
532
|
+
const callee = receiver.callee;
|
|
533
|
+
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "Array" && callee.property?.name === "from") return true;
|
|
534
|
+
}
|
|
535
|
+
if (receiver?.type === "NewExpression" && receiver.callee?.type === "Identifier" && receiver.callee.name === "Array") return true;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return false;
|
|
539
|
+
};
|
|
460
540
|
const noArrayIndexAsKey = { create: (context) => ({ JSXAttribute(node) {
|
|
461
541
|
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "key") return;
|
|
462
542
|
if (!node.value || node.value.type !== "JSXExpressionContainer") return;
|
|
463
543
|
const indexName = extractIndexName(node.value.expression);
|
|
464
|
-
if (indexName)
|
|
544
|
+
if (!indexName) return;
|
|
545
|
+
if (isInsideStaticPlaceholderMap(node)) return;
|
|
546
|
+
context.report({
|
|
465
547
|
node,
|
|
466
548
|
message: `Array index "${indexName}" used as key — causes bugs when list is reordered or filtered`
|
|
467
549
|
});
|
|
@@ -488,7 +570,7 @@ const noPreventDefault = { create: (context) => ({ JSXOpeningElement(node) {
|
|
|
488
570
|
const expression = eventAttribute.value.expression;
|
|
489
571
|
if (expression?.type !== "ArrowFunctionExpression" && expression?.type !== "FunctionExpression") return;
|
|
490
572
|
if (!containsPreventDefaultCall(expression)) return;
|
|
491
|
-
const message = elementName === "form" ? "preventDefault() on <form> onSubmit — form won't work without JavaScript" : "preventDefault() on <a> onClick — use a <button> or routing component instead";
|
|
573
|
+
const message = elementName === "form" ? "preventDefault() on <form> onSubmit — form won't work without JavaScript. Consider using a server action for progressive enhancement" : "preventDefault() on <a> onClick — use a <button> or routing component instead";
|
|
492
574
|
context.report({
|
|
493
575
|
node,
|
|
494
576
|
message
|
|
@@ -605,16 +687,21 @@ const jsEarlyExit = { create: (context) => ({ IfStatement(node) {
|
|
|
605
687
|
message: `${nestingDepth + 1} levels of nested if statements — use early returns to flatten`
|
|
606
688
|
});
|
|
607
689
|
} }) };
|
|
608
|
-
const asyncParallel = { create: (context) =>
|
|
609
|
-
const
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
}
|
|
690
|
+
const asyncParallel = { create: (context) => {
|
|
691
|
+
const filename = context.getFilename?.() ?? "";
|
|
692
|
+
const isTestFile = TEST_FILE_PATTERN.test(filename);
|
|
693
|
+
return { BlockStatement(node) {
|
|
694
|
+
if (isTestFile) return;
|
|
695
|
+
const consecutiveAwaitStatements = [];
|
|
696
|
+
const flushConsecutiveAwaits = () => {
|
|
697
|
+
if (consecutiveAwaitStatements.length >= SEQUENTIAL_AWAIT_THRESHOLD) reportIfIndependent(consecutiveAwaitStatements, context);
|
|
698
|
+
consecutiveAwaitStatements.length = 0;
|
|
699
|
+
};
|
|
700
|
+
for (const statement of node.body ?? []) if (statement.type === "VariableDeclaration" && statement.declarations?.length === 1 && statement.declarations[0].init?.type === "AwaitExpression" || statement.type === "ExpressionStatement" && statement.expression?.type === "AwaitExpression") consecutiveAwaitStatements.push(statement);
|
|
701
|
+
else flushConsecutiveAwaits();
|
|
702
|
+
flushConsecutiveAwaits();
|
|
703
|
+
} };
|
|
704
|
+
} };
|
|
618
705
|
const reportIfIndependent = (statements, context) => {
|
|
619
706
|
const declaredNames = /* @__PURE__ */ new Set();
|
|
620
707
|
for (const statement of statements) {
|
|
@@ -636,12 +723,17 @@ const reportIfIndependent = (statements, context) => {
|
|
|
636
723
|
|
|
637
724
|
//#endregion
|
|
638
725
|
//#region src/plugin/rules/nextjs.ts
|
|
639
|
-
const nextjsNoImgElement = { create: (context) =>
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
726
|
+
const nextjsNoImgElement = { create: (context) => {
|
|
727
|
+
const filename = context.getFilename?.() ?? "";
|
|
728
|
+
const isOgRoute = OG_ROUTE_PATTERN.test(filename);
|
|
729
|
+
return { JSXOpeningElement(node) {
|
|
730
|
+
if (isOgRoute) return;
|
|
731
|
+
if (node.name?.type === "JSXIdentifier" && node.name.name === "img") context.report({
|
|
732
|
+
node,
|
|
733
|
+
message: "Use next/image instead of <img> — provides automatic optimization, lazy loading, and responsive srcset"
|
|
734
|
+
});
|
|
735
|
+
} };
|
|
736
|
+
} };
|
|
645
737
|
const nextjsAsyncClientComponent = { create: (context) => {
|
|
646
738
|
let fileHasUseClient = false;
|
|
647
739
|
return {
|
|
@@ -706,6 +798,7 @@ const nextjsNoClientFetchForServerData = { create: (context) => {
|
|
|
706
798
|
const nextjsMissingMetadata = { create: (context) => ({ Program(programNode) {
|
|
707
799
|
const filename = context.getFilename?.() ?? "";
|
|
708
800
|
if (!PAGE_FILE_PATTERN.test(filename)) return;
|
|
801
|
+
if (INTERNAL_PAGE_PATH_PATTERN.test(filename)) return;
|
|
709
802
|
if (!programNode.body?.some((statement) => {
|
|
710
803
|
if (statement.type !== "ExportNamedDeclaration") return false;
|
|
711
804
|
const declaration = statement.declaration;
|
|
@@ -873,6 +966,47 @@ const nextjsNoSideEffectInGetHandler = { create: (context) => ({ ExportNamedDecl
|
|
|
873
966
|
|
|
874
967
|
//#endregion
|
|
875
968
|
//#region src/plugin/rules/performance.ts
|
|
969
|
+
const isMemoCall = (node) => {
|
|
970
|
+
if (node.type !== "CallExpression") return false;
|
|
971
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "memo") return true;
|
|
972
|
+
if (node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "React" && node.callee.property?.type === "Identifier" && node.callee.property.name === "memo") return true;
|
|
973
|
+
return false;
|
|
974
|
+
};
|
|
975
|
+
const isInlineReference = (node) => {
|
|
976
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.name === "bind") return "functions";
|
|
977
|
+
if (node.type === "ObjectExpression") return "objects";
|
|
978
|
+
if (node.type === "ArrayExpression") return "Arrays";
|
|
979
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") return "JSX";
|
|
980
|
+
return null;
|
|
981
|
+
};
|
|
982
|
+
const noInlinePropOnMemoComponent = { create: (context) => {
|
|
983
|
+
const memoizedComponentNames = /* @__PURE__ */ new Set();
|
|
984
|
+
return {
|
|
985
|
+
VariableDeclarator(node) {
|
|
986
|
+
if (node.id?.type !== "Identifier" || !node.init) return;
|
|
987
|
+
if (isMemoCall(node.init)) memoizedComponentNames.add(node.id.name);
|
|
988
|
+
},
|
|
989
|
+
ExportDefaultDeclaration(node) {
|
|
990
|
+
if (node.declaration && isMemoCall(node.declaration)) {
|
|
991
|
+
const innerArgument = node.declaration.arguments?.[0];
|
|
992
|
+
if (innerArgument?.type === "Identifier") memoizedComponentNames.add(innerArgument.name);
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
JSXAttribute(node) {
|
|
996
|
+
if (!node.value || node.value.type !== "JSXExpressionContainer") return;
|
|
997
|
+
const openingElement = node.parent;
|
|
998
|
+
if (!openingElement || openingElement.type !== "JSXOpeningElement") return;
|
|
999
|
+
let elementName = null;
|
|
1000
|
+
if (openingElement.name?.type === "JSXIdentifier") elementName = openingElement.name.name;
|
|
1001
|
+
if (!elementName || !memoizedComponentNames.has(elementName)) return;
|
|
1002
|
+
const propType = isInlineReference(node.value.expression);
|
|
1003
|
+
if (propType) context.report({
|
|
1004
|
+
node: node.value.expression,
|
|
1005
|
+
message: `JSX attribute values should not contain ${propType} created in the same scope — ${elementName} is wrapped in memo(), so new references cause unnecessary re-renders`
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
} };
|
|
876
1010
|
const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node) {
|
|
877
1011
|
if (!isHookCall(node, "useMemo")) return;
|
|
878
1012
|
const callback = node.arguments?.[0];
|
|
@@ -886,9 +1020,18 @@ const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node)
|
|
|
886
1020
|
message: "useMemo wrapping a trivially cheap expression — memo overhead exceeds the computation"
|
|
887
1021
|
});
|
|
888
1022
|
} }) };
|
|
1023
|
+
const isMotionElement = (attributeNode) => {
|
|
1024
|
+
const openingElement = attributeNode.parent;
|
|
1025
|
+
if (!openingElement || openingElement.type !== "JSXOpeningElement") return false;
|
|
1026
|
+
const elementName = openingElement.name;
|
|
1027
|
+
if (elementName?.type === "JSXMemberExpression" && elementName.object?.type === "JSXIdentifier" && (elementName.object.name === "motion" || elementName.object.name === "m")) return true;
|
|
1028
|
+
if (elementName?.type === "JSXIdentifier" && elementName.name.startsWith("Motion")) return true;
|
|
1029
|
+
return false;
|
|
1030
|
+
};
|
|
889
1031
|
const noLayoutPropertyAnimation = { create: (context) => ({ JSXAttribute(node) {
|
|
890
1032
|
if (node.name?.type !== "JSXIdentifier" || !MOTION_ANIMATE_PROPS.has(node.name.name)) return;
|
|
891
1033
|
if (!node.value || node.value.type !== "JSXExpressionContainer") return;
|
|
1034
|
+
if (isMotionElement(node)) return;
|
|
892
1035
|
const expression = node.value.expression;
|
|
893
1036
|
if (expression?.type !== "ObjectExpression") return;
|
|
894
1037
|
for (const property of expression.properties ?? []) {
|
|
@@ -1019,19 +1162,6 @@ const renderingAnimateSvgWrapper = { create: (context) => ({ JSXOpeningElement(n
|
|
|
1019
1162
|
message: "Animation props directly on <svg> — wrap in a <div> or <motion.div> for better rendering performance"
|
|
1020
1163
|
});
|
|
1021
1164
|
} }) };
|
|
1022
|
-
const renderingUsetransitionLoading = { create: (context) => ({ VariableDeclarator(node) {
|
|
1023
|
-
if (node.id?.type !== "ArrayPattern" || !node.id.elements?.length) return;
|
|
1024
|
-
if (!node.init || !isHookCall(node.init, "useState")) return;
|
|
1025
|
-
if (!node.init.arguments?.length) return;
|
|
1026
|
-
const initializer = node.init.arguments[0];
|
|
1027
|
-
if (initializer.type !== "Literal" || initializer.value !== false) return;
|
|
1028
|
-
const stateVariableName = node.id.elements[0]?.name;
|
|
1029
|
-
if (!stateVariableName || !LOADING_STATE_PATTERN.test(stateVariableName)) return;
|
|
1030
|
-
context.report({
|
|
1031
|
-
node: node.init,
|
|
1032
|
-
message: `useState for "${stateVariableName}" — consider useTransition for non-urgent loading states`
|
|
1033
|
-
});
|
|
1034
|
-
} }) };
|
|
1035
1165
|
const renderingHydrationNoFlicker = { create: (context) => ({ CallExpression(node) {
|
|
1036
1166
|
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments?.length < 2) return;
|
|
1037
1167
|
const depsNode = node.arguments[1];
|
|
@@ -1075,7 +1205,9 @@ const noSecretsInClientCode = { create: (context) => ({ VariableDeclarator(node)
|
|
|
1075
1205
|
if (node.init?.type !== "Literal" || typeof node.init.value !== "string") return;
|
|
1076
1206
|
const variableName = node.id.name;
|
|
1077
1207
|
const literalValue = node.init.value;
|
|
1078
|
-
|
|
1208
|
+
const trailingSuffix = variableName.split("_").pop()?.toLowerCase() ?? "";
|
|
1209
|
+
const isUiConstant = SECRET_FALSE_POSITIVE_SUFFIXES.has(trailingSuffix);
|
|
1210
|
+
if (SECRET_VARIABLE_PATTERN.test(variableName) && !isUiConstant && literalValue.length > SECRET_MIN_LENGTH_CHARS) {
|
|
1079
1211
|
context.report({
|
|
1080
1212
|
node,
|
|
1081
1213
|
message: `Possible hardcoded secret in "${variableName}" — use environment variables instead`
|
|
@@ -1121,19 +1253,27 @@ const serverAuthActions = { create: (context) => {
|
|
|
1121
1253
|
}
|
|
1122
1254
|
};
|
|
1123
1255
|
} };
|
|
1124
|
-
const serverAfterNonblocking = { create: (context) =>
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1256
|
+
const serverAfterNonblocking = { create: (context) => {
|
|
1257
|
+
let fileHasUseServerDirective = false;
|
|
1258
|
+
return {
|
|
1259
|
+
Program(programNode) {
|
|
1260
|
+
fileHasUseServerDirective = hasDirective(programNode, "use server");
|
|
1261
|
+
},
|
|
1262
|
+
CallExpression(node) {
|
|
1263
|
+
if (!fileHasUseServerDirective) return;
|
|
1264
|
+
if (node.callee?.type !== "MemberExpression") return;
|
|
1265
|
+
if (node.callee.property?.type !== "Identifier") return;
|
|
1266
|
+
const objectName = node.callee.object?.type === "Identifier" ? node.callee.object.name : null;
|
|
1267
|
+
if (!objectName) return;
|
|
1268
|
+
const methodName = node.callee.property.name;
|
|
1269
|
+
if (!(objectName === "console" && (methodName === "log" || methodName === "info" || methodName === "warn") || objectName === "analytics" && (methodName === "track" || methodName === "identify" || methodName === "page"))) return;
|
|
1270
|
+
context.report({
|
|
1271
|
+
node,
|
|
1272
|
+
message: `${objectName}.${methodName}() in server action — use after() for non-blocking logging/analytics`
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
} };
|
|
1137
1277
|
|
|
1138
1278
|
//#endregion
|
|
1139
1279
|
//#region src/plugin/rules/state-and-effects.ts
|
|
@@ -1192,7 +1332,10 @@ const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
|
|
|
1192
1332
|
const depsNode = node.arguments[1];
|
|
1193
1333
|
if (depsNode.type !== "ArrayExpression" || !depsNode.elements?.length) return;
|
|
1194
1334
|
const dependencyNames = new Set(depsNode.elements.filter((element) => element?.type === "Identifier").map((element) => element.name));
|
|
1195
|
-
|
|
1335
|
+
const statements = getCallbackStatements(callback);
|
|
1336
|
+
if (statements.length !== 1) return;
|
|
1337
|
+
const soleStatement = statements[0];
|
|
1338
|
+
if (soleStatement.type === "IfStatement" && soleStatement.test?.type === "Identifier" && dependencyNames.has(soleStatement.test.name)) context.report({
|
|
1196
1339
|
node,
|
|
1197
1340
|
message: "useEffect simulating an event handler — move logic to an actual event handler instead"
|
|
1198
1341
|
});
|
|
@@ -1214,7 +1357,7 @@ const noDerivedUseState = { create: (context) => {
|
|
|
1214
1357
|
if (initializer.type !== "Identifier") return;
|
|
1215
1358
|
if (componentPropNames.has(initializer.name)) context.report({
|
|
1216
1359
|
node,
|
|
1217
|
-
message: `useState initialized from prop "${initializer.name}" — derive it during render instead
|
|
1360
|
+
message: `useState initialized from prop "${initializer.name}" — if this value should stay in sync with the prop, derive it during render instead`
|
|
1218
1361
|
});
|
|
1219
1362
|
}
|
|
1220
1363
|
};
|
|
@@ -1248,6 +1391,7 @@ const rerenderLazyStateInit = { create: (context) => ({ CallExpression(node) {
|
|
|
1248
1391
|
const initializer = node.arguments[0];
|
|
1249
1392
|
if (initializer.type !== "CallExpression") return;
|
|
1250
1393
|
const calleeName = initializer.callee?.type === "Identifier" ? initializer.callee.name : initializer.callee?.property?.name ?? "fn";
|
|
1394
|
+
if (TRIVIAL_INITIALIZER_NAMES.has(calleeName)) return;
|
|
1251
1395
|
context.report({
|
|
1252
1396
|
node: initializer,
|
|
1253
1397
|
message: `useState(${calleeName}()) calls initializer on every render — use useState(() => ${calleeName}()) for lazy initialization`
|
|
@@ -1293,7 +1437,6 @@ const plugin = {
|
|
|
1293
1437
|
"rerender-lazy-state-init": rerenderLazyStateInit,
|
|
1294
1438
|
"rerender-functional-setstate": rerenderFunctionalSetstate,
|
|
1295
1439
|
"rerender-dependencies": rerenderDependencies,
|
|
1296
|
-
"no-generic-handler-names": noGenericHandlerNames,
|
|
1297
1440
|
"no-giant-component": noGiantComponent,
|
|
1298
1441
|
"no-render-in-render": noRenderInRender,
|
|
1299
1442
|
"no-nested-component-definition": noNestedComponentDefinition,
|
|
@@ -1301,7 +1444,7 @@ const plugin = {
|
|
|
1301
1444
|
"no-layout-property-animation": noLayoutPropertyAnimation,
|
|
1302
1445
|
"rerender-memo-with-default-value": rerenderMemoWithDefaultValue,
|
|
1303
1446
|
"rendering-animate-svg-wrapper": renderingAnimateSvgWrapper,
|
|
1304
|
-
"
|
|
1447
|
+
"no-inline-prop-on-memo-component": noInlinePropOnMemoComponent,
|
|
1305
1448
|
"rendering-hydration-no-flicker": renderingHydrationNoFlicker,
|
|
1306
1449
|
"no-transition-all": noTransitionAll,
|
|
1307
1450
|
"no-global-css-variable-animation": noGlobalCssVariableAnimation,
|