Skip to content

fix(cookies): deduplicate server cookie writes - #246

Merged
mandarini merged 4 commits into
supabase:mainfrom
pjpjq:codex/dedupe-server-cookie-writes
Jul 8, 2026
Merged

fix(cookies): deduplicate server cookie writes#246
mandarini merged 4 commits into
supabase:mainfrom
pjpjq:codex/dedupe-server-cookie-writes

Conversation

@pjpjq

@pjpjq pjpjq commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

What changed

  • Wrap server-side cookies.setAll with a duplicate-payload guard.
  • Skip repeated identical cookies+headers batches for the same setAll handler while still allowing different payloads through.
  • Add regression coverage for duplicate and changed payload writes.

Why

When multiple server clients are created during the same SSR request, identical refresh writes can call the same response cookie writer repeatedly and emit duplicate Set-Cookie headers.

Fixes #144.

Validation

  • pnpm test src/cookies.spec.ts --run
  • pnpm build
  • git diff --check

@pjpjq
pjpjq requested review from a team as code owners June 8, 2026 10:24
@pjpjq pjpjq changed the title fix(cookies): 去重服务端重复 cookie 写入 fix(cookies): deduplicate server cookie writes Jun 9, 2026
@pjpjq
pjpjq force-pushed the codex/dedupe-server-cookie-writes branch from 38c15bd to e9159da Compare June 9, 2026 10:57

@mandarini mandarini left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @pjpjq, thank you so much for contributing to Supabase 💚

Really appreciate you digging into #144, this is a nasty one in production (CloudFront 502s from blown header limits are no fun to debug), and the wrapper itself is written carefully: the WeakMap, the sync check-then-set, the error rollback are all thoughtful.

That said, I want to flag something before we go further, because I think the current approach won't actually fix the bug for most users hitting it.

The dedupe is keyed on the setAll function reference:

const lastServerSetAllSignatures = new WeakMap<SetAllCookies, string>();

But in the canonical Next.js pattern (which is what almost every user reporting this is on), each call to the createClient() factory produces a brand new setAll closure:

export async function createClient() {
  const cookieStore = await cookies()
  return createServerClient(url, key, {
    cookies: {
      getAll() { return cookieStore.getAll() },
      setAll(cookiesToSet) { /* ... */ }
    }
  })
}

So when issue #144 says "createServerClient might be called in the auth loader, and again later in some server function", those are two separate factory calls, each with its own setAll reference. The WeakMap stores them as separate keys, the dedup never fires, and duplicate Set-Cookie headers still go out.

The two new tests pass because they share sharedSetAll between both storages, which is exactly the shape the implementation supports but not the shape the issue describes. If you write a test that mirrors the real scenario (two separate setAll closures targeting the same underlying response), you'll see it still emits duplicates.

I think there's a cleaner fix here that sidesteps the keying problem entirely.

The cookies you're about to write are already known by applyServerStorage via its getAll() call. If you diff what you're about to set against what's already there and skip the no-op writes, you get dedup for free, without any module-scoped state:

// inside applyServerStorage, after computing setCookies/removeCookies
const currentByName = new Map(allCookies?.map(c => [c.name, c.value]));

const setsNeeded = setCookies.filter(c => currentByName.get(c.name) !== c.value);
const removesNeeded = removeCookies.filter(name => currentByName.has(name));

if (setsNeeded.length === 0 && removesNeeded.length === 0) {
  return;
}

Why this works for the #144 scenario: the first client refreshes and writes the new tokens, the shared cookieStore now reflects them, the second client's getAll() returns those new tokens, its diff comes back empty, and setAll is not called. No duplicate header. Works regardless of how the user structured their setAll closures, no cross-request state, no WeakMap, and it's a few lines.

Would you be up for restructuring the PR around that approach? Happy to chat through edge cases if anything's unclear (one to think about: what if the user's getAll doesn't reflect writes from setAll, which violates the documented invariant but does happen in the wild). And it would be great to add a regression test that mirrors the real #144 setup, with two distinct setAll closures, so future refactors can't silently regress this.

Thank you again for taking the time, contributions like this are how this library gets better.

@pjpjq
pjpjq requested a review from mandarini June 15, 2026 04:31
@pjpjq

pjpjq commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — I reworked the PR around the reflected-cookie-state approach.

What changed:

Local validation:

  • pnpm exec vitest run src/cookies.spec.ts — 36 passed
  • pnpm run build — passed
  • pnpm exec prettier --check src/cookies.ts src/cookies.spec.ts — passed
  • pnpm exec vitest run — 99 passed

@mandarini
mandarini merged commit 035eabe into supabase:main Jul 8, 2026
2 checks passed
mandarini pushed a commit that referenced this pull request Jul 13, 2026
🤖 I have created a release *beep* *boop*
---


## [0.12.1](v0.12.0...v0.12.1)
(2026-07-13)


### Bug Fixes

* **cookies:** deduplicate server cookie writes
([#246](#246))
([035eabe](035eabe))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: supabase-releaser[bot] <223506987+supabase-releaser[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants