From e275f10a1868e4db44038afb556321d52e6c48f5 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 22 May 2026 15:57:47 +0100 Subject: [PATCH] fix(app-router): preserve 307 status on document loads in prerender The App Router prerender harness forwarded requests through `fetch` with fetch's default `redirect: "follow"` behavior. When a page called `redirect()` the prod server emitted a 307, fetch silently followed the Location header to the destination page, and the harness wrote the destination's HTML under the redirecting route's filename. The seeded cache then served that body with status 200 for every document request, breaking Next.js parity (test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts expects 307). Set `redirect: "manual"` on the App Router prerender `rscHandler` so the original 3xx surfaces back. `htmlRender.ok` is then false, the route is marked skipped, and at runtime the document request hits the live render path that already builds a proper 307 + Location response via `buildAppPageSpecialErrorResponse`. RSC flight-payload handling (#1347) is untouched. The pages-prerender `renderPage` helper already used `redirect: "manual"`, so this brings the App Router phase in line. Closes #1530 --- packages/vinext/src/build/prerender.ts | 9 +++++++++ tests/app-router.test.ts | 17 +++++++++++++++++ tests/prerender.test.ts | 21 +++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index 10ed20a20..274d802af 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -897,12 +897,21 @@ export async function prerenderApp({ rscHandler = (req: Request) => { // Forward the request to the local prod server. + // `redirect: "manual"` ensures pages that call `redirect()` surface as + // their original 3xx response — otherwise fetch follows the Location + // header server-side, the prerender harness sees a 200 for the + // destination page, and that destination HTML gets written under the + // redirecting route's filename. At runtime the prod server then serves + // the cached HTML with status 200 instead of emitting a 307 for the + // document load. Mirrors the pages-prerender `renderPage` helper above. + // See: https://github.com/cloudflare/vinext/issues/1530 const parsed = new URL(req.url); const url = `${baseUrl}${parsed.pathname}${parsed.search}`; return fetch(url, { method: req.method, headers: { ...secretHeaders, ...Object.fromEntries(req.headers.entries()) }, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, + redirect: "manual", }); }; diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index eb4bb4c5c..cf709a980 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2440,6 +2440,23 @@ describe("App Router Production server (startProdServer)", () => { expect(res.status).toBe(404); }); + // Ported from Next.js: test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts + // + // Document request (no `Rsc` header) to a page that calls `redirect()` must + // respond with HTTP 307 + Location. The RSC variant (`.rsc` URL or Rsc:1 + // header) returns 200 with a flight payload — that path is covered by the + // sibling `.rsc` redirect tests above and by issue #1347. + // + // See: https://github.com/cloudflare/vinext/issues/1530 + it("redirect() from Server Component returns 307 on document load (production)", async () => { + const res = await fetch(`${baseUrl}/redirect-test`, { redirect: "manual" }); + expect(res.status).toBe(307); + const location = res.headers.get("location"); + expect(location).toBeTruthy(); + expect(location).toContain("/about"); + }); + it("serves static assets with cache headers", async () => { // Find an actual hashed asset from the build (on disk under // `_next/static/`, matching `resolveAssetsDir("")`). diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index 4124b17d9..d9a5c9f78 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -927,6 +927,27 @@ describe("prerenderApp — default mode (app-basic)", () => { expect(r?.status).toBe("skipped"); }); + // Ported from Next.js: test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts + // ('should get 307 status code for document request') + // + // A speculative prerender of a route that calls `redirect()` must not + // follow the redirect server-side and cache the destination's HTML under + // the redirecting URL. Doing so makes the prod server reply with 200 and + // the destination's body on every document request to the redirecting + // route, instead of emitting an HTTP 307 with a Location header. + // + // See: https://github.com/cloudflare/vinext/issues/1530 + it("skips /redirect-test instead of capturing the destination HTML", () => { + const r = findRoute(results, "/redirect-test"); + expect(r).toBeDefined(); + expect(r?.status).toBe("skipped"); + // No HTML/RSC must be written for the redirecting route — otherwise the + // prod server serves the cached destination body with status 200 for + // every document request to /redirect-test. + expect(fs.existsSync(path.join(outDir, "redirect-test.html"))).toBe(false); + expect(fs.existsSync(path.join(outDir, "redirect-test.rsc"))).toBe(false); + }); + // ── API routes — always skipped ──────────────────────────────────────────── it("skips all API route handlers", () => {