react-doctor 0.0.6 → 0.0.8
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 +112 -10
- package/dist/cli.js.map +1 -1
- package/dist/react-doctor-plugin.d.ts.map +1 -1
- package/dist/react-doctor-plugin.js +104 -0
- 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") {
|
|
@@ -767,6 +832,44 @@ const nextjsNoHeadImport = { create: (context) => ({ ImportDeclaration(node) {
|
|
|
767
832
|
message: "next/head is not supported in the App Router — use the Metadata API instead"
|
|
768
833
|
});
|
|
769
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
|
+
} }) };
|
|
770
873
|
|
|
771
874
|
//#endregion
|
|
772
875
|
//#region src/plugin/rules/performance.ts
|
|
@@ -1231,6 +1334,7 @@ const plugin = {
|
|
|
1231
1334
|
"nextjs-no-css-link": nextjsNoCssLink,
|
|
1232
1335
|
"nextjs-no-polyfill-script": nextjsNoPolyfillScript,
|
|
1233
1336
|
"nextjs-no-head-import": nextjsNoHeadImport,
|
|
1337
|
+
"nextjs-no-side-effect-in-get-handler": nextjsNoSideEffectInGetHandler,
|
|
1234
1338
|
"server-auth-actions": serverAuthActions,
|
|
1235
1339
|
"server-after-nonblocking": serverAfterNonblocking,
|
|
1236
1340
|
"client-passive-event-listeners": clientPassiveEventListeners,
|