diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 1bc910d13..439241f30 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -583,6 +583,19 @@ export function createSSRHandler( gsspExtraHeaders[key] = String(val); } } + + // Default Cache-Control for getServerSideProps responses, matching + // Next.js's pages-handler.ts (revalidate: 0 → getCacheControlHeader). + // Skip when gSSP already set one via res.setHeader (case-insensitive) + // or when ISR is layered on top below — that branch overwrites this + // default with the ISR cache-control. Fixes #1461. + const hasUserCacheControl = Object.keys(gsspExtraHeaders).some( + (k) => k.toLowerCase() === "cache-control", + ); + if (!hasUserCacheControl) { + gsspExtraHeaders["Cache-Control"] = + "private, no-cache, no-store, max-age=0, must-revalidate"; + } } // Collect font preloads early so ISR cached responses can include // the Link header (font preloads are module-level state that persists diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index 45bb50921..82a9b4782 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -375,6 +375,11 @@ export async function renderPagesPageResponse( const responseHeaders = new Headers({ "Content-Type": "text/html" }); const finalStatus = applyGsspHeaders(responseHeaders, options.gsspRes); + // Capture user-set Cache-Control (from getServerSideProps's res.setHeader) + // so a downstream user override survives the gssp default below — and only + // the default, never ISR/nonce Cache-Control which the runtime owns. Matches + // Next.js's pages-handler.ts: `if (!res.getHeader('Cache-Control'))`. + const userSetCacheControl = responseHeaders.has("Cache-Control"); if (options.scriptNonce) { responseHeaders.set("Cache-Control", "no-store, must-revalidate"); @@ -384,6 +389,11 @@ export async function renderPagesPageResponse( buildRevalidateCacheControl(options.isrRevalidateSeconds, options.expireSeconds), ); setCacheStateHeaders(responseHeaders, "MISS"); + } else if (options.gsspRes && !userSetCacheControl) { + // Default for getServerSideProps responses, matching Next.js + // pages-handler.ts (revalidate: 0 → getCacheControlHeader). Without this, + // CDNs and browsers could cache per-request gssp responses. + responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate"); } if (options.fontLinkHeader) { responseHeaders.set("Link", options.fontLinkHeader); diff --git a/tests/fixtures/pages-basic/pages/ssr-cache-control.tsx b/tests/fixtures/pages-basic/pages/ssr-cache-control.tsx new file mode 100644 index 000000000..445918d5f --- /dev/null +++ b/tests/fixtures/pages-basic/pages/ssr-cache-control.tsx @@ -0,0 +1,25 @@ +interface Props { + message: string; +} + +// Regression fixture for #1461: a getServerSideProps page that overrides +// Cache-Control via res.setHeader. The override must be forwarded to the +// final HTTP response instead of being replaced by the gssp default. +export default function SSRCacheControlPage({ message }: Props) { + return ( +
+

SSR Cache-Control Override

+

{message}

+
+ ); +} + +// oxlint-disable-next-line typescript/no-explicit-any +export async function getServerSideProps({ res }: { res: any }) { + res.setHeader("Cache-Control", "public, max-age=42"); + return { + props: { + message: "Cache-Control override applied", + }, + }; +} diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 66f3a8123..09d63c320 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -441,6 +441,26 @@ describe("Pages Router integration", () => { expect(setCookie).toContain("gssp_token=abc123"); }); + // Regression for #1461: gSSP responses must carry the default Cache-Control + // header that Next.js applies for getServerSideProps pages so CDNs and + // browsers do not cache the per-request payload. + it("sets the default Cache-Control header on getServerSideProps responses", async () => { + const res = await fetch(`${baseUrl}/ssr`); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + // Regression for #1461: when getServerSideProps overrides Cache-Control via + // res.setHeader, the user-provided value must reach the final HTTP response + // instead of being clobbered by the default. + it("preserves res.setHeader Cache-Control overrides set in getServerSideProps", async () => { + const res = await fetch(`${baseUrl}/ssr-cache-control`); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("public, max-age=42"); + }); + it("getServerSideProps calling res.end() short-circuits the response", async () => { const res = await fetch(`${baseUrl}/ssr-res-end`); // gSSP calls res.end() with a JSON body and status 202 @@ -2847,9 +2867,19 @@ export default function CounterPage() { // Test: SSR page with getServerSideProps const ssrRes = await fetch(`${prodUrl}/ssr`); expect(ssrRes.status).toBe(200); + // Regression for #1461: gssp pages get the default Cache-Control header. + expect(ssrRes.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); const ssrHtml = await ssrRes.text(); expect(ssrHtml).toContain("Server-Side Rendered"); + // Regression for #1461: user-set Cache-Control via res.setHeader sticks. + const ssrCcRes = await fetch(`${prodUrl}/ssr-cache-control`); + expect(ssrCcRes.status).toBe(200); + expect(ssrCcRes.headers.get("cache-control")).toBe("public, max-age=42"); + await ssrCcRes.text(); + // Regression test for #1354: a page that exports `getServerSideProps` // via a separate `export { getServerSideProps }` re-export must build // and render in production. Previously, the client bundle transform