Skip to content

Commit 1ed2336

Browse files
committed
fix(nextjs): ignore response header mutations in GET handlers
1 parent 052cd20 commit 1ed2336

5 files changed

Lines changed: 131 additions & 0 deletions

File tree

.changeset/quiet-headers-scope.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-doctor": patch
3+
---
4+
5+
Avoid reporting response header mutations and request-scoped collections as GET handler side effects.

packages/react-doctor/src/plugin/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,10 @@ export const MUTATION_METHOD_NAMES = new Set([
362362
"append",
363363
]);
364364

365+
export const HEADERS_API_MUTATION_METHOD_NAMES = new Set(["append", "delete", "set"]);
366+
367+
export const REQUEST_SCOPED_MUTATION_CONSTRUCTOR_NAMES = new Set(["Headers", "Map", "Set"]);
368+
365369
// In-place Array.prototype mutators. These are the canonical "mutating"
366370
// methods used to flag direct mutation of useState values (e.g. an
367371
// `items` from `useState([])` that gets `.push()`ed). The immutable

packages/react-doctor/src/plugin/helpers.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {
22
FETCH_CALLEE_NAMES,
33
FETCH_MEMBER_OBJECTS,
4+
HEADERS_API_MUTATION_METHOD_NAMES,
45
LOOP_TYPES,
56
MUTATING_HTTP_METHODS,
67
MUTATION_METHOD_NAMES,
8+
REQUEST_SCOPED_MUTATION_CONSTRUCTOR_NAMES,
79
SETTER_PATTERN,
810
UPPERCASE_PATTERN,
911
} from "./constants.js";
@@ -333,6 +335,61 @@ const isCookiesOrHeadersCall = (node: EsTreeNode, methodName: string): boolean =
333335
return object.callee.name === methodName;
334336
};
335337

338+
const isRequestScopedMutationInitializer = (node: EsTreeNode): boolean =>
339+
node.type === "NewExpression" &&
340+
node.callee?.type === "Identifier" &&
341+
REQUEST_SCOPED_MUTATION_CONSTRUCTOR_NAMES.has(node.callee.name);
342+
343+
const collectRequestScopedMutationBindings = (node: EsTreeNode): Set<string> => {
344+
const bindings = new Set<string>();
345+
walkAst(node, (child: EsTreeNode) => {
346+
if (child.type !== "VariableDeclarator") return;
347+
if (child.id?.type !== "Identifier") return;
348+
if (!child.init || !isRequestScopedMutationInitializer(child.init)) return;
349+
bindings.add(child.id.name);
350+
});
351+
return bindings;
352+
};
353+
354+
const isRequestScopedMutationCall = (
355+
node: EsTreeNode,
356+
requestScopedMutationBindings: Set<string>,
357+
): boolean => {
358+
if (node.type !== "CallExpression" || node.callee?.type !== "MemberExpression") return false;
359+
const { object, property } = node.callee;
360+
if (object?.type !== "Identifier") return false;
361+
if (property?.type !== "Identifier") return false;
362+
return (
363+
requestScopedMutationBindings.has(object.name) && MUTATION_METHOD_NAMES.has(property.name)
364+
);
365+
};
366+
367+
const isHeadersApiMutationCall = (node: EsTreeNode): boolean => {
368+
if (node.type !== "CallExpression" || node.callee?.type !== "MemberExpression") return false;
369+
const { object, property } = node.callee;
370+
if (
371+
property?.type !== "Identifier" ||
372+
!HEADERS_API_MUTATION_METHOD_NAMES.has(property.name)
373+
) {
374+
return false;
375+
}
376+
if (object?.type !== "MemberExpression") return false;
377+
return object.property?.type === "Identifier" && object.property.name === "headers";
378+
};
379+
380+
const isHeadersFunctionMutationCall = (node: EsTreeNode): boolean => {
381+
if (node.type !== "CallExpression" || node.callee?.type !== "MemberExpression") return false;
382+
const { object, property } = node.callee;
383+
if (
384+
property?.type !== "Identifier" ||
385+
!HEADERS_API_MUTATION_METHOD_NAMES.has(property.name)
386+
) {
387+
return false;
388+
}
389+
if (object?.type !== "CallExpression" || object.callee?.type !== "Identifier") return false;
390+
return object.callee.name === "headers";
391+
};
392+
336393
const isMutatingDbCall = (node: EsTreeNode): boolean => {
337394
if (node.type !== "CallExpression" || node.callee?.type !== "MemberExpression") return false;
338395
const { property } = node.callee;
@@ -363,8 +420,15 @@ const isMutatingFetchCall = (node: EsTreeNode): boolean => {
363420

364421
export const findSideEffect = (node: EsTreeNode): string | null => {
365422
let sideEffectDescription: string | null = null;
423+
const requestScopedMutationBindings = collectRequestScopedMutationBindings(node);
366424
walkAst(node, (child: EsTreeNode) => {
367425
if (sideEffectDescription) return;
426+
if (isHeadersApiMutationCall(child) || isHeadersFunctionMutationCall(child)) {
427+
return;
428+
}
429+
if (isRequestScopedMutationCall(child, requestScopedMutationBindings)) {
430+
return;
431+
}
368432
if (isCookiesOrHeadersCall(child, "cookies")) {
369433
const methodName = child.callee.property.name;
370434
sideEffectDescription = `cookies().${methodName}()`;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterAll, describe, expect, it } from "vite-plus/test";
5+
6+
import { runOxlint } from "../../src/utils/run-oxlint.js";
7+
import { setupReactProject } from "./_helpers.js";
8+
9+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nextjs-side-effects-"));
10+
11+
afterAll(() => {
12+
fs.rmSync(tempRoot, { recursive: true, force: true });
13+
});
14+
15+
describe("issue #206: nextjs-no-side-effect-in-get-handler avoids request-local mutations", () => {
16+
it("does not flag response header shaping in GET route handlers", async () => {
17+
const projectDir = setupReactProject(tempRoot, "issue-206-headers", {
18+
packageJsonExtras: {
19+
dependencies: { next: "^15.0.0", react: "^19.0.0", "react-dom": "^19.0.0" },
20+
},
21+
files: {
22+
"src/app/headers/route.tsx": `import { NextResponse } from "next/server";
23+
24+
export async function GET() {
25+
const response = NextResponse.json({ ok: true });
26+
response.headers.set("Cache-Control", "no-store");
27+
response.headers.append("Vary", "Accept");
28+
response.headers.delete("X-Deprecated");
29+
30+
const headers = new Headers();
31+
headers.set("X-Route", "headers");
32+
33+
const requestScope = new Map<string, string>();
34+
requestScope.set("seen", "true");
35+
36+
return response;
37+
}
38+
`,
39+
},
40+
});
41+
42+
const diagnostics = await runOxlint({
43+
rootDirectory: projectDir,
44+
hasTypeScript: true,
45+
framework: "nextjs",
46+
hasReactCompiler: false,
47+
hasTanStackQuery: false,
48+
});
49+
50+
const headerRouteIssues = diagnostics.filter(
51+
(diagnostic) =>
52+
diagnostic.rule === "nextjs-no-side-effect-in-get-handler" &&
53+
diagnostic.filePath.includes("app/headers/route"),
54+
);
55+
expect(headerRouteIssues).toHaveLength(0);
56+
});
57+
});

packages/react-doctor/tests/run-oxlint.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ describe("runOxlint", () => {
245245
);
246246
expect(wrappedPageIssues).toHaveLength(0);
247247
});
248+
248249
});
249250

250251
describe("server rule scope", () => {

0 commit comments

Comments
 (0)