From 95b28b2ec55a5ca54978e0ae3ec755a639b264ff Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:45:32 +1100 Subject: [PATCH] fix: preserve multi-valued Set-Cookie headers in route handler ISR cache buildAppRouteCacheValue() used headers.forEach() which overwrites same-key entries, causing all but the last Set-Cookie header to be silently dropped from cached route handler responses. Uses getSetCookie() to preserve all Set-Cookie headers as a string array, matching the pattern used elsewhere in the codebase (worker-utils.ts, prod-server.ts). --- .../src/server/app-route-handler-response.ts | 9 ++-- tests/app-route-handler-response.test.ts | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/app-route-handler-response.ts b/packages/vinext/src/server/app-route-handler-response.ts index 8a84310f7..8840b78d1 100644 --- a/packages/vinext/src/server/app-route-handler-response.ts +++ b/packages/vinext/src/server/app-route-handler-response.ts @@ -92,10 +92,13 @@ export async function buildAppRouteCacheValue(response: Response): Promise { - if (key !== "x-vinext-cache" && key !== "cache-control") { - headers[key] = value; - } + if (key === "set-cookie" || key === "x-vinext-cache" || key === "cache-control") return; + headers[key] = value; }); + const setCookies = response.headers.getSetCookie?.() ?? []; + if (setCookies.length > 0) { + headers["set-cookie"] = setCookies; + } return { kind: "APP_ROUTE", diff --git a/tests/app-route-handler-response.test.ts b/tests/app-route-handler-response.test.ts index 9b7423957..42a8787fc 100644 --- a/tests/app-route-handler-response.test.ts +++ b/tests/app-route-handler-response.test.ts @@ -103,6 +103,60 @@ describe("app route handler response helpers", () => { expect(new TextDecoder().decode(value.body)).toBe("cache me"); }); + it("preserves multiple Set-Cookie headers when building cache value", async () => { + const response = new Response("with cookies", { + status: 200, + headers: [ + ["content-type", "application/json"], + ["set-cookie", "session=abc; Path=/; HttpOnly"], + ["set-cookie", "theme=dark; Path=/"], + ["set-cookie", "lang=en; Path=/; SameSite=Lax"], + ], + }); + + const value = await buildAppRouteCacheValue(response); + + expect(value.headers["set-cookie"]).toEqual([ + "session=abc; Path=/; HttpOnly", + "theme=dark; Path=/", + "lang=en; Path=/; SameSite=Lax", + ]); + expect(value.headers["content-type"]).toBe("application/json"); + }); + + it("omits set-cookie key when response has no Set-Cookie headers", async () => { + const response = new Response("no cookies", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + + const value = await buildAppRouteCacheValue(response); + + expect(value.headers).toEqual({ "content-type": "text/plain" }); + expect(value.headers["set-cookie"]).toBeUndefined(); + }); + + it("round-trips multiple Set-Cookie headers through cache store and restore", async () => { + const original = new Response("round trip", { + status: 200, + headers: [ + ["content-type", "text/plain"], + ["set-cookie", "a=1; Path=/"], + ["set-cookie", "b=2; Path=/"], + ], + }); + + const cached = await buildAppRouteCacheValue(original); + const restored = buildRouteHandlerCachedResponse(cached, { + cacheState: "HIT", + isHead: false, + revalidateSeconds: 60, + }); + + expect(restored.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]); + await expect(restored.text()).resolves.toBe("round trip"); + }); + it("finalizes route handler responses with cookies and auto-head semantics", async () => { const response = new Response("body", { status: 202,