react-doctor 0.0.5 → 0.0.7
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 +146 -12
- package/dist/cli.js.map +1 -1
- package/dist/react-doctor-plugin.d.ts.map +1 -1
- package/dist/react-doctor-plugin.js +123 -6
- package/dist/react-doctor-plugin.js.map +1 -1
- package/package.json +5 -1
|
@@ -129,6 +129,37 @@ const NEXTJS_NAVIGATION_FUNCTIONS = new Set([
|
|
|
129
129
|
const GOOGLE_FONTS_PATTERN = /fonts\.googleapis\.com/;
|
|
130
130
|
const POLYFILL_SCRIPT_PATTERN = /polyfill\.io|polyfill\.min\.js|cdn\.polyfill/;
|
|
131
131
|
const APP_DIRECTORY_PATTERN = /\/app\//;
|
|
132
|
+
const ROUTE_HANDLER_FILE_PATTERN = /\/route\.(tsx?|jsx?)$/;
|
|
133
|
+
const MUTATION_METHOD_NAMES = new Set([
|
|
134
|
+
"create",
|
|
135
|
+
"insert",
|
|
136
|
+
"insertInto",
|
|
137
|
+
"update",
|
|
138
|
+
"upsert",
|
|
139
|
+
"delete",
|
|
140
|
+
"remove",
|
|
141
|
+
"destroy",
|
|
142
|
+
"set",
|
|
143
|
+
"append"
|
|
144
|
+
]);
|
|
145
|
+
const MUTATING_HTTP_METHODS = new Set([
|
|
146
|
+
"POST",
|
|
147
|
+
"PUT",
|
|
148
|
+
"DELETE",
|
|
149
|
+
"PATCH"
|
|
150
|
+
]);
|
|
151
|
+
const MUTATING_ROUTE_SEGMENTS = new Set([
|
|
152
|
+
"logout",
|
|
153
|
+
"log-out",
|
|
154
|
+
"signout",
|
|
155
|
+
"sign-out",
|
|
156
|
+
"unsubscribe",
|
|
157
|
+
"delete",
|
|
158
|
+
"remove",
|
|
159
|
+
"revoke",
|
|
160
|
+
"cancel",
|
|
161
|
+
"deactivate"
|
|
162
|
+
]);
|
|
132
163
|
const EFFECT_HOOK_NAMES = new Set(["useEffect", "useLayoutEffect"]);
|
|
133
164
|
const HOOKS_WITH_DEPS = new Set([
|
|
134
165
|
"useEffect",
|
|
@@ -230,6 +261,40 @@ const createLoopAwareVisitors = (innerVisitors) => {
|
|
|
230
261
|
};
|
|
231
262
|
return visitors;
|
|
232
263
|
};
|
|
264
|
+
const isCookiesOrHeadersCall = (node, methodName) => {
|
|
265
|
+
if (node.type !== "CallExpression" || node.callee?.type !== "MemberExpression") return false;
|
|
266
|
+
const { object, property } = node.callee;
|
|
267
|
+
if (property?.type !== "Identifier" || !MUTATION_METHOD_NAMES.has(property.name)) return false;
|
|
268
|
+
if (object?.type !== "CallExpression" || object.callee?.type !== "Identifier") return false;
|
|
269
|
+
return object.callee.name === methodName;
|
|
270
|
+
};
|
|
271
|
+
const isMutatingDbCall = (node) => {
|
|
272
|
+
if (node.type !== "CallExpression" || node.callee?.type !== "MemberExpression") return false;
|
|
273
|
+
const { property } = node.callee;
|
|
274
|
+
return property?.type === "Identifier" && MUTATION_METHOD_NAMES.has(property.name);
|
|
275
|
+
};
|
|
276
|
+
const isMutatingFetchCall = (node) => {
|
|
277
|
+
if (node.type !== "CallExpression") return false;
|
|
278
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "fetch") return false;
|
|
279
|
+
const optionsArgument = node.arguments?.[1];
|
|
280
|
+
if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return false;
|
|
281
|
+
return optionsArgument.properties?.some((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()));
|
|
282
|
+
};
|
|
283
|
+
const findSideEffect = (node) => {
|
|
284
|
+
let sideEffectDescription = null;
|
|
285
|
+
walkAst(node, (child) => {
|
|
286
|
+
if (sideEffectDescription) return;
|
|
287
|
+
if (isCookiesOrHeadersCall(child, "cookies")) sideEffectDescription = `cookies().${child.callee.property.name}()`;
|
|
288
|
+
else if (isCookiesOrHeadersCall(child, "headers")) sideEffectDescription = `headers().${child.callee.property.name}()`;
|
|
289
|
+
else if (isMutatingFetchCall(child)) sideEffectDescription = `fetch() with method ${child.arguments[1].properties.find((property) => property.key?.type === "Identifier" && property.key.name === "method").value.value}`;
|
|
290
|
+
else if (isMutatingDbCall(child)) {
|
|
291
|
+
const methodName = child.callee.property.name;
|
|
292
|
+
const objectName = child.callee.object?.type === "Identifier" ? child.callee.object.name : null;
|
|
293
|
+
sideEffectDescription = objectName ? `${objectName}.${methodName}()` : `.${methodName}()`;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
return sideEffectDescription;
|
|
297
|
+
};
|
|
233
298
|
const extractDestructuredPropNames = (params) => {
|
|
234
299
|
const propNames = /* @__PURE__ */ new Set();
|
|
235
300
|
for (const param of params) if (param.type === "ObjectPattern") {
|
|
@@ -273,7 +338,9 @@ const noGiantComponent = { create: (context) => {
|
|
|
273
338
|
const noRenderInRender = { create: (context) => ({ JSXExpressionContainer(node) {
|
|
274
339
|
const expression = node.expression;
|
|
275
340
|
if (expression?.type !== "CallExpression") return;
|
|
276
|
-
|
|
341
|
+
let calleeName = null;
|
|
342
|
+
if (expression.callee?.type === "Identifier") calleeName = expression.callee.name;
|
|
343
|
+
else if (expression.callee?.type === "MemberExpression" && expression.callee.property?.type === "Identifier") calleeName = expression.callee.property.name;
|
|
277
344
|
if (calleeName && RENDER_FUNCTION_PATTERN.test(calleeName)) context.report({
|
|
278
345
|
node: expression,
|
|
279
346
|
message: `Inline render function "${calleeName}()" — extract to a separate component for proper reconciliation`
|
|
@@ -382,7 +449,10 @@ const clientPassiveEventListeners = { create: (context) => ({ CallExpression(nod
|
|
|
382
449
|
//#region src/plugin/rules/correctness.ts
|
|
383
450
|
const extractIndexName = (node) => {
|
|
384
451
|
if (node.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.name)) return node.name;
|
|
385
|
-
if (node.type === "TemplateLiteral"
|
|
452
|
+
if (node.type === "TemplateLiteral") {
|
|
453
|
+
const indexExpression = node.expressions?.find((expression) => expression.type === "Identifier" && INDEX_PARAMETER_NAMES.has(expression.name));
|
|
454
|
+
if (indexExpression) return indexExpression.name;
|
|
455
|
+
}
|
|
386
456
|
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;
|
|
387
457
|
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;
|
|
388
458
|
return null;
|
|
@@ -600,7 +670,9 @@ const nextjsNoAElement = { create: (context) => ({ JSXOpeningElement(node) {
|
|
|
600
670
|
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "a") return;
|
|
601
671
|
const hrefAttribute = findJsxAttribute(node.attributes ?? [], "href");
|
|
602
672
|
if (!hrefAttribute?.value) return;
|
|
603
|
-
|
|
673
|
+
let hrefValue = null;
|
|
674
|
+
if (hrefAttribute.value.type === "Literal") hrefValue = hrefAttribute.value.value;
|
|
675
|
+
else if (hrefAttribute.value.type === "JSXExpressionContainer" && hrefAttribute.value.expression?.type === "Literal") hrefValue = hrefAttribute.value.expression.value;
|
|
604
676
|
if (typeof hrefValue === "string" && hrefValue.startsWith("/")) context.report({
|
|
605
677
|
node,
|
|
606
678
|
message: "Use next/link instead of <a> for internal links — enables client-side navigation and prefetching"
|
|
@@ -760,6 +832,44 @@ const nextjsNoHeadImport = { create: (context) => ({ ImportDeclaration(node) {
|
|
|
760
832
|
message: "next/head is not supported in the App Router — use the Metadata API instead"
|
|
761
833
|
});
|
|
762
834
|
} }) };
|
|
835
|
+
const extractMutatingRouteSegment = (filename) => {
|
|
836
|
+
const segments = filename.split("/");
|
|
837
|
+
for (const segment of segments) {
|
|
838
|
+
const cleaned = segment.replace(/^\[.*\]$/, "");
|
|
839
|
+
if (MUTATING_ROUTE_SEGMENTS.has(cleaned)) return cleaned;
|
|
840
|
+
}
|
|
841
|
+
return null;
|
|
842
|
+
};
|
|
843
|
+
const getExportedGetHandlerBody = (node) => {
|
|
844
|
+
if (node.type !== "ExportNamedDeclaration") return null;
|
|
845
|
+
const declaration = node.declaration;
|
|
846
|
+
if (!declaration) return null;
|
|
847
|
+
if (declaration.type === "FunctionDeclaration" && declaration.id?.name === "GET") return declaration.body;
|
|
848
|
+
if (declaration.type === "VariableDeclaration") {
|
|
849
|
+
const declarator = declaration.declarations?.[0];
|
|
850
|
+
if (declarator?.id?.type === "Identifier" && declarator.id.name === "GET" && declarator.init && (declarator.init.type === "ArrowFunctionExpression" || declarator.init.type === "FunctionExpression")) return declarator.init.body;
|
|
851
|
+
}
|
|
852
|
+
return null;
|
|
853
|
+
};
|
|
854
|
+
const nextjsNoSideEffectInGetHandler = { create: (context) => ({ ExportNamedDeclaration(node) {
|
|
855
|
+
const filename = context.getFilename?.() ?? "";
|
|
856
|
+
if (!ROUTE_HANDLER_FILE_PATTERN.test(filename)) return;
|
|
857
|
+
const handlerBody = getExportedGetHandlerBody(node);
|
|
858
|
+
if (!handlerBody) return;
|
|
859
|
+
const mutatingSegment = extractMutatingRouteSegment(filename);
|
|
860
|
+
if (mutatingSegment) {
|
|
861
|
+
context.report({
|
|
862
|
+
node,
|
|
863
|
+
message: `GET handler on "/${mutatingSegment}" route — use POST to prevent CSRF and unintended prefetch triggers`
|
|
864
|
+
});
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const sideEffect = findSideEffect(handlerBody);
|
|
868
|
+
if (sideEffect) context.report({
|
|
869
|
+
node,
|
|
870
|
+
message: `GET handler has side effects (${sideEffect}) — use POST to prevent CSRF and unintended prefetch triggers`
|
|
871
|
+
});
|
|
872
|
+
} }) };
|
|
763
873
|
|
|
764
874
|
//#endregion
|
|
765
875
|
//#region src/plugin/rules/performance.ts
|
|
@@ -768,7 +878,9 @@ const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node)
|
|
|
768
878
|
const callback = node.arguments?.[0];
|
|
769
879
|
if (!callback) return;
|
|
770
880
|
if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") return;
|
|
771
|
-
|
|
881
|
+
let returnExpression = null;
|
|
882
|
+
if (callback.body?.type !== "BlockStatement") returnExpression = callback.body;
|
|
883
|
+
else if (callback.body.body?.length === 1 && callback.body.body[0].type === "ReturnStatement") returnExpression = callback.body.body[0].argument;
|
|
772
884
|
if (returnExpression && isSimpleExpression(returnExpression)) context.report({
|
|
773
885
|
node,
|
|
774
886
|
message: "useMemo wrapping a trivially cheap expression — memo overhead exceeds the computation"
|
|
@@ -781,7 +893,9 @@ const noLayoutPropertyAnimation = { create: (context) => ({ JSXAttribute(node) {
|
|
|
781
893
|
if (expression?.type !== "ObjectExpression") return;
|
|
782
894
|
for (const property of expression.properties ?? []) {
|
|
783
895
|
if (property.type !== "Property") continue;
|
|
784
|
-
|
|
896
|
+
let propertyName = null;
|
|
897
|
+
if (property.key?.type === "Identifier") propertyName = property.key.name;
|
|
898
|
+
else if (property.key?.type === "Literal") propertyName = property.key.value;
|
|
785
899
|
if (propertyName && LAYOUT_PROPERTIES.has(propertyName)) context.report({
|
|
786
900
|
node: property,
|
|
787
901
|
message: `Animating layout property "${propertyName}" triggers layout recalculation every frame — use transform/scale or the layout prop`
|
|
@@ -980,7 +1094,9 @@ const containsAuthCheck = (statements) => {
|
|
|
980
1094
|
let foundAuthCall = false;
|
|
981
1095
|
for (const statement of statements) walkAst(statement, (child) => {
|
|
982
1096
|
if (foundAuthCall) return;
|
|
983
|
-
|
|
1097
|
+
let callNode = null;
|
|
1098
|
+
if (child.type === "CallExpression") callNode = child;
|
|
1099
|
+
else if (child.type === "AwaitExpression" && child.argument?.type === "CallExpression") callNode = child.argument;
|
|
984
1100
|
if (callNode?.callee?.type === "Identifier" && AUTH_FUNCTION_NAMES.has(callNode.callee.name)) foundAuthCall = true;
|
|
985
1101
|
});
|
|
986
1102
|
return foundAuthCall;
|
|
@@ -1218,6 +1334,7 @@ const plugin = {
|
|
|
1218
1334
|
"nextjs-no-css-link": nextjsNoCssLink,
|
|
1219
1335
|
"nextjs-no-polyfill-script": nextjsNoPolyfillScript,
|
|
1220
1336
|
"nextjs-no-head-import": nextjsNoHeadImport,
|
|
1337
|
+
"nextjs-no-side-effect-in-get-handler": nextjsNoSideEffectInGetHandler,
|
|
1221
1338
|
"server-auth-actions": serverAuthActions,
|
|
1222
1339
|
"server-after-nonblocking": serverAfterNonblocking,
|
|
1223
1340
|
"client-passive-event-listeners": clientPassiveEventListeners,
|