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.
@@ -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
- const calleeName = expression.callee?.type === "Identifier" ? expression.callee.name : expression.callee?.type === "MemberExpression" && expression.callee.property?.type === "Identifier" ? expression.callee.property.name : null;
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" && node.expressions?.some((expression) => expression.type === "Identifier" && INDEX_PARAMETER_NAMES.has(expression.name))) return node.expressions.find((expression) => expression.type === "Identifier" && INDEX_PARAMETER_NAMES.has(expression.name))?.name;
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
- const hrefValue = hrefAttribute.value.type === "Literal" ? hrefAttribute.value.value : hrefAttribute.value.type === "JSXExpressionContainer" && hrefAttribute.value.expression?.type === "Literal" ? hrefAttribute.value.expression.value : null;
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
- const returnExpression = callback.body?.type !== "BlockStatement" ? callback.body : callback.body.body?.length === 1 && callback.body.body[0].type === "ReturnStatement" ? callback.body.body[0].argument : null;
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
- const propertyName = property.key?.type === "Identifier" ? property.key.name : property.key?.type === "Literal" ? property.key.value : null;
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
- const callNode = child.type === "CallExpression" ? child : child.type === "AwaitExpression" && child.argument?.type === "CallExpression" ? child.argument : null;
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,