Skip to content

fix: align next/headers cookie path semantics#555

Merged
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:fix/cookies-path-default-delete-options
Mar 16, 2026
Merged

fix: align next/headers cookie path semantics#555
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:fix/cookies-path-default-delete-options

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

This aligns the mutable next/headers cookie shim with Next.js cookie semantics in two places: cookies().set() now defaults Path=/, and cookies().delete() now accepts an options object with path and domain.

What Changed

  • default cookies().set() to emit Path=/ when no path is provided
  • allow cookies().delete({ name, path, domain }) and emit an expired cookie header with those attributes
  • update existing delete expectations to match upstream expired-cookie semantics instead of assuming Max-Age=0
  • add regression coverage in tests/shims.test.ts for both behaviors

Why It Matters

Without the default path, cookies().set("name", "value") scopes the cookie to the current request path instead of /, which differs from Next.js and can break session visibility across routes. Without the object-form delete, vinext cannot reliably clear cookies that were originally set on a non-default path or domain.

Risks Or Limits

This only changes the mutable next/headers cookie surface in shims/headers.ts. It does not change NextResponse.cookies in next/server. The deletion header now uses an expired Expires= value, matching upstream ResponseCookies.delete() behavior.

Verification

  • ./node_modules/.bin/vp test run tests/shims.test.ts -t "next/headers phase-aware cookies"
  • ./node_modules/.bin/vp test run tests/nextjs-compat/app-routes.test.ts tests/nextjs-compat/set-cookies.test.ts tests/app-router.test.ts
  • git diff --check

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@555

commit: 2031626

@NathanDrake2406 NathanDrake2406 changed the title Align next/headers cookie path semantics fix: align next/headers cookie path semantics Mar 16, 2026
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review: align next/headers cookie path semantics

Good change overall — defaulting Path=/ on set() and accepting the object form on delete() are both real behavioral gaps. The implementation is clean, tests are well structured, and the integration test updates are correct.

A few items below, one of which is a bug.

Comment thread packages/vinext/src/shims/headers.ts Outdated
@@ -790,10 +789,22 @@ class RequestCookies {
/**
* Delete a cookie by setting it with Max-Age=0.
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.

The JSDoc still says "Delete a cookie by setting it with Max-Age=0" but the implementation now uses Expires=Thu, 01 Jan 1970 00:00:00 GMT. Should be updated to match the new behavior.

Suggested change
* Delete a cookie by setting it with Max-Age=0.
* Delete a cookie by emitting an expired Set-Cookie header.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated the JSDoc to describe the actual deletion mechanism. The implementation emits an expired Set-Cookie header now, so the old Max-Age=0 wording was stale.

Comment thread packages/vinext/src/shims/headers.ts Outdated
_getState().pendingSetCookies.push(`${name}=; Path=/; Max-Age=0`);
const parts = [`${name}=`, `Path=${path}`];
if (domain) parts.push(`Domain=${domain}`);
parts.push(`Expires=${new Date(0).toUTCString()}`);
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.

This creates a new Date(0) object on every delete() call. Since the value is always the same (Thu, 01 Jan 1970 00:00:00 GMT), consider hoisting it to a module-level constant:

const EXPIRED_COOKIE_DATE = new Date(0).toUTCString();

Minor optimization but it's a hot path in route handlers that clear multiple cookies.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hoisted the expired date string to a module constant. That keeps the hot path honest without changing the cookie semantics.

* Delete a cookie by setting it with Max-Age=0.
*/
delete(name: string): this {
delete(nameOrOptions: string | { name: string; path?: string; domain?: string }): this {
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.

Bug: delete() only accepts string | { name, path?, domain? } but Next.js also accepts an array of names — cookies().delete(["a", "b"]). The old delete(name: string) didn't support it either, so this isn't a regression, but since you're touching the signature it's worth noting.

More importantly: Next.js's RequestCookies.delete() in @edge-runtime/cookies (which is what cookies() from next/headers delegates to internally) uses the signature delete(names: string | string[]) — it accepts arrays. If you want to match upstream, consider adding array support too. That said, the object form { name, path, domain } you're adding here is correct for ResponseCookies.delete() semantics, which is the right reference since cookies().delete() in route handlers actually produces Set-Cookie headers (response-side behavior).

Not a blocker — the current scope is fine — but documenting this for awareness.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept array support out of this PR. The bug here was path/default-path parity, and widening the delete signature further would expand the public API surface beyond the tracked issue.

* Delete a cookie by setting it with Max-Age=0.
*/
delete(name: string): this {
delete(nameOrOptions: string | { name: string; path?: string; domain?: string }): this {
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.

Nit: The PR description mentions this aligns with ResponseCookies.delete() behavior. However, looking at server.ts:464, NextResponse.cookies.delete() still uses Max-Age=0 (via this.set(name, "", { maxAge: 0, path: "/" })) rather than Expires=. This means the two cookie surfaces in vinext now use different deletion mechanisms.

Both are valid ways to expire a cookie per RFC 6265, but it's a divergence worth being aware of. Consider noting in a follow-up whether NextResponse.cookies.delete() should also switch to the Expires= pattern, or whether next/headers cookies().delete() should stay with Max-Age=0 for internal consistency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Left the NextResponse.cookies.delete() consistency question for follow-up. Both forms are valid expiry mechanisms, and this PR is scoped to the next/headers parity gap rather than normalizing both cookie surfaces at once.

Comment thread tests/shims.test.ts
const pending = getAndClearPendingCookies();
expect(pending).toHaveLength(1);
expect(pending[0]).toContain("token=implicit-path");
expect(pending[0]).toContain("Path=/");
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.

Good test — this is the key regression test for the path default behavior. Consider also adding a case that verifies an explicit path option still works on set() (i.e., c.set("token", "val", { path: "/api" }) produces Path=/api, not Path=/). This ensures the default doesn't accidentally override an explicit path.

Actually, looking more carefully at the existing test on line ~895 (cookies().set() with options in the action phase), it does test { httpOnly: true } but not an explicit path. So there's currently no test ensuring explicit path is respected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added an explicit-path regression test for cookies().set(). It now proves the default path logic only applies when the caller omits path.

Comment thread tests/shims.test.ts
const previousPhase = setHeadersAccessPhase("route-handler");
try {
const c = await cookies();
c.delete({ name: "session", path: "/account", domain: ".example.com" });
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.

Nice — this covers the full object form well. One additional edge case worth testing: delete({ name: "session" }) without path or domain should still emit Path=/ (the default). This verifies the nameOrOptions.path ?? "/" fallback in the object branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the object-form fallback case as well. delete({ name: "session" }) now has coverage for the implicit Path=/ branch.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 16, 2026

Review posted successfully. Here's a summary of the feedback:

Key findings:

  1. Stale JSDoc (line 790) — The doc comment still says "Delete a cookie by setting it with Max-Age=0" but the implementation now uses Expires=. Suggested fix provided.

  2. Minor optimization (line 806) — new Date(0) is created on every delete() call. Could be hoisted to a module-level constant since the value is always Thu, 01 Jan 1970 00:00:00 GMT.

  3. Internal consistency noteNextResponse.cookies.delete() in server.ts still uses Max-Age=0 while cookies().delete() from next/headers now uses Expires=. Both are valid per RFC 6265 but they diverge within vinext itself.

  4. Array support awareness — Next.js's RequestCookies.delete() also accepts string[]. Not a regression (wasn't supported before), but worth noting since the signature is being expanded.

  5. Test coverage gaps — Suggested two additional test cases:

    • Explicit path option on set() to ensure the default doesn't override it
    • delete({ name: "session" }) without path/domain to verify the fallback

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review: align next/headers cookie path semantics

This is a clean, well-scoped fix. The two behavioral changes — defaulting Path=/ on set() and accepting the object form on delete() — are both real gaps against Next.js semantics. The implementation is correct, the test coverage is thorough (including the explicit-path and object-form-fallback cases added after the first review round), and the integration test updates are appropriate.

The previous review covered this well and the author addressed all actionable feedback (hoisted constant, updated JSDoc, added edge case tests). I have one minor observation below but nothing blocking.

LGTM.


this._cookies.delete(name);
_getState().pendingSetCookies.push(`${name}=; Path=/; Max-Age=0`);
const parts = [`${name}=`, `Path=${path}`];
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.

Nit: the deleted cookie value is empty (session=) which is correct, but the formatting produces session=; Path=/; Expires=... — note the space after the empty value before the semicolon. This matches the set() method's parts.join("; ") pattern so it's consistent internally, but Next.js's ResponseCookies.delete() calls this.set(name, "", ...) which produces session=; Path=/; Max-Age=0 (also empty value). So both sides are consistent on the empty-value-then-attributes format. No issue here, just confirming I checked.

* Delete a cookie by emitting an expired Set-Cookie header.
*/
delete(name: string): this {
delete(nameOrOptions: string | { name: string; path?: string; domain?: string }): this {
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.

For the record: ResponseCookies.delete() in server.ts:464 still uses Max-Age=0 via this.set(name, "", { maxAge: 0, path: "/" }), while this method now uses Expires=Thu, 01 Jan 1970 00:00:00 GMT. Both are valid per RFC 6265, and the author explicitly noted this is a follow-up concern. Just flagging for visibility since these are the two cookie-deletion surfaces in vinext — if a future PR normalizes them, either direction would be fine.

The scope decision to keep this PR focused on next/headers parity is the right call.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 16, 2026

Review submitted. Here's a summary of my assessment:

Verdict: Approved

The PR correctly fixes two gaps in next/headers cookie semantics:

  1. cookies().set() now defaults Path=/ — Without this, cookies scoped to the current request path instead of /, breaking session visibility across routes. The implementation cleanly uses opts?.path ?? "/".

  2. cookies().delete() now accepts { name, path?, domain? } — This matches Next.js's ResponseCookies.delete() API shape and enables clearing cookies set on non-default paths/domains.

The code is clean, the EXPIRED_COOKIE_DATE constant is properly hoisted, the JSDoc is updated, and the test coverage is thorough with 5 new test cases covering: implicit path default, explicit path preservation, basic string delete, object-form delete with path+domain, and object-form fallback without path/domain.

I left two informational comments noting the Max-Age=0 vs Expires= divergence between next/headers and NextResponse.cookies deletion surfaces — both valid per RFC 6265, and correctly scoped as a follow-up concern.

github run

@james-elicx james-elicx merged commit 847260c into cloudflare:main Mar 16, 2026
27 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/cookies-path-default-delete-options branch March 18, 2026 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants