From 8118c219cc60802f7027015cae3d8919983f43e7 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Sat, 23 May 2026 13:50:18 +0200 Subject: [PATCH 1/2] fix: preserve multiple Set-Cookie headers on 304 responses `Headers.get('set-cookie')` collapses multiple values into a single comma-joined string that browsers cannot parse. Iterate `Headers.getSetCookie()` and append each cookie individually so all cookies survive an `If-None-Match` revalidation. Fixes #15527 Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fix-set-cookie-304.md | 10 +++++++ packages/kit/src/runtime/server/respond.js | 17 ++++++------ .../+page.server.js | 6 +++++ .../forwarded-in-etag-multiple/+page.svelte | 27 +++++++++++++++++++ .../basics/test/cross-platform/client.test.js | 7 +++++ 5 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-set-cookie-304.md create mode 100644 packages/kit/test/apps/basics/src/routes/cookies/forwarded-in-etag-multiple/+page.server.js create mode 100644 packages/kit/test/apps/basics/src/routes/cookies/forwarded-in-etag-multiple/+page.svelte diff --git a/.changeset/fix-set-cookie-304.md b/.changeset/fix-set-cookie-304.md new file mode 100644 index 000000000000..b0ebee71610e --- /dev/null +++ b/.changeset/fix-set-cookie-304.md @@ -0,0 +1,10 @@ +--- +'@sveltejs/kit': patch +--- + +fix: preserve multiple `Set-Cookie` headers on 304 responses + +`Headers.get('set-cookie')` collapses multiple `Set-Cookie` values into a single +comma-joined string, which browsers cannot parse. The 304 response path now +iterates `Headers.getSetCookie()` and appends each cookie individually, so all +cookies survive when a request matches `If-None-Match`. diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 6a7395b40b44..38e31e38376a 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -511,19 +511,18 @@ export async function internal_respond(request, options, manifest, state) { if (if_none_match_value === etag) { const headers = new Headers({ etag }); - // https://datatracker.ietf.org/doc/html/rfc7232#section-4.1 + set-cookie - for (const key of [ - 'cache-control', - 'content-location', - 'date', - 'expires', - 'vary', - 'set-cookie' - ]) { + // https://datatracker.ietf.org/doc/html/rfc7232#section-4.1 + for (const key of ['cache-control', 'content-location', 'date', 'expires', 'vary']) { const value = response.headers.get(key); if (value) headers.set(key, value); } + // `Headers.get('set-cookie')` collapses multiple values into a single + // comma-joined string that browsers cannot parse correctly + for (const cookie of response.headers.getSetCookie()) { + headers.append('set-cookie', cookie); + } + return new Response(undefined, { status: 304, headers diff --git a/packages/kit/test/apps/basics/src/routes/cookies/forwarded-in-etag-multiple/+page.server.js b/packages/kit/test/apps/basics/src/routes/cookies/forwarded-in-etag-multiple/+page.server.js new file mode 100644 index 000000000000..eaa867774f12 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/cookies/forwarded-in-etag-multiple/+page.server.js @@ -0,0 +1,6 @@ +/** @type {import('./$types').PageServerLoad} */ +export function load({ cookies }) { + cookies.set('one', '1', { path: '', httpOnly: false }); + cookies.set('two', '2', { path: '', httpOnly: false }); + cookies.set('three', '3', { path: '', httpOnly: false }); +} diff --git a/packages/kit/test/apps/basics/src/routes/cookies/forwarded-in-etag-multiple/+page.svelte b/packages/kit/test/apps/basics/src/routes/cookies/forwarded-in-etag-multiple/+page.svelte new file mode 100644 index 000000000000..63892ef9685b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/cookies/forwarded-in-etag-multiple/+page.svelte @@ -0,0 +1,27 @@ + + + + +

{cookies}

diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js index 3a9322c1560f..3d888b74113e 100644 --- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js +++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js @@ -1188,6 +1188,13 @@ test.describe('cookies', () => { await expect(page.locator('p')).toHaveText('foo=bar'); }); + test('etag forwards multiple cookies', async ({ page }) => { + await page.goto('/cookies/forwarded-in-etag-multiple'); + await expect(page.locator('p')).toHaveText('one=1; three=3; two=2'); + await page.locator('button').click(); + await expect(page.locator('p')).toHaveText('one=1; three=3; two=2'); + }); + test("fetch during SSR doesn't un- and re-escape cookies", async ({ page }) => { await page.goto('/cookies/collect-without-re-escaping'); await expect(page.locator('p')).toHaveText('cookie-special-characters="foo"'); From 0d56264496c645a56c36aa4997aff669c2b00ca2 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Sat, 23 May 2026 13:51:39 +0200 Subject: [PATCH 2/2] chore: trim changeset to headline Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fix-set-cookie-304.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.changeset/fix-set-cookie-304.md b/.changeset/fix-set-cookie-304.md index b0ebee71610e..358ae26dc01f 100644 --- a/.changeset/fix-set-cookie-304.md +++ b/.changeset/fix-set-cookie-304.md @@ -3,8 +3,3 @@ --- fix: preserve multiple `Set-Cookie` headers on 304 responses - -`Headers.get('set-cookie')` collapses multiple `Set-Cookie` values into a single -comma-joined string, which browsers cannot parse. The 304 response path now -iterates `Headers.getSetCookie()` and appends each cookie individually, so all -cookies survive when a request matches `If-None-Match`.