Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import { RequestCookies } from '../cookies'

import { ResponseCookies } from '../cookies'
import { ReflectAdapter } from './reflect'
import { workAsyncStorage } from '../../../app-render/work-async-storage.external'
import type { RequestStore } from '../../../app-render/work-unit-async-storage.external'
import { ActionDidRevalidateStaticAndDynamic } from '../../../../shared/lib/action-revalidation-kind'

/**
* @internal
*/
export class ReadonlyRequestCookiesError extends Error {
constructor() {
super(
Expand All @@ -21,12 +17,8 @@ export class ReadonlyRequestCookiesError extends Error {
}
}

// We use this to type some APIs but we don't construct instances directly
export type { ResponseCookies }

// The `cookies()` API is a mix of request and response cookies. For `.get()` methods,
// we want to return the request cookie if it exists. For mutative methods like `.set()`,
// we want to return the response cookie.
export type ReadonlyRequestCookies = Omit<
RequestCookies,
'set' | 'clear' | 'delete'
Expand Down Expand Up @@ -69,34 +61,6 @@ type SetCookieArgs =
| [key: string, value: string, cookie?: Partial<ResponseCookie>]
| [options: ResponseCookie]

export function appendMutableCookies(
headers: Headers,
mutableCookies: ResponseCookies
): boolean {
const modifiedCookieValues = getModifiedCookieValues(mutableCookies)
if (modifiedCookieValues.length === 0) {
return false
}

// Return a new response that extends the response with
// the modified cookies as fallbacks. `res` cookies
// will still take precedence.
const resCookies = new ResponseCookies(headers)
const returnedCookies = resCookies.getAll()

// Set the modified cookies as fallbacks.
for (const cookie of modifiedCookieValues) {
resCookies.set(cookie)
}

// Set the original cookies as the final values.
for (const cookie of returnedCookies) {
resCookies.set(cookie)
}

return true
}

type ResponseCookie = NonNullable<
ReturnType<InstanceType<typeof ResponseCookies>['get']>
>
Expand All @@ -107,23 +71,26 @@ export class MutableRequestCookiesAdapter {
onUpdateCookies?: (cookies: string[]) => void
): ResponseCookies {
const responseCookies = new ResponseCookies(new Headers())

for (const cookie of cookies.getAll()) {
responseCookies.set(cookie)
}

let modifiedValues: ResponseCookie[] = []
const modifiedCookies = new Set<string>()

const updateResponseCookies = () => {
// TODO-APP: change method of getting workStore
const workStore = workAsyncStorage.getStore()
if (workStore) {
workStore.pathWasRevalidated = ActionDidRevalidateStaticAndDynamic
}

const allCookies = responseCookies.getAll()
modifiedValues = allCookies.filter((c) => modifiedCookies.has(c.name))

if (onUpdateCookies) {
const serializedCookies: string[] = []

for (const cookie of modifiedValues) {
const tempCookies = new ResponseCookies(new Headers())
tempCookies.set(cookie)
Expand All @@ -137,29 +104,45 @@ export class MutableRequestCookiesAdapter {
const wrappedCookies = new Proxy(responseCookies, {
get(target, prop, receiver) {
switch (prop) {
// A special symbol to get the modified cookie values
case SYMBOL_MODIFY_COOKIE_VALUES:
return modifiedValues

// TODO: Throw error if trying to set a cookie after the response
// headers have been set.
case 'delete':
return function (...args: [string] | [ResponseCookie]) {
const requestStore = workAsyncStorage.getStore() as
Copy link
Contributor

Choose a reason for hiding this comment

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

MutableRequestCookiesAdapter.wrap incorrectly uses workAsyncStorage.getStore() (which returns a WorkStore) and casts it to RequestStore to check phase, but WorkStore has no phase property, causing ensureCookiesAreStillMutable to always throw ReadonlyRequestCookiesError.

Fix on Vercel

| RequestStore
| undefined

if (requestStore) {
ensureCookiesAreStillMutable(requestStore, 'cookies().delete')
}
Comment on lines +112 to +118
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

workAsyncStorage.getStore() is typed to WorkStore (app-render work store), not RequestStore. Casting it to RequestStore and passing it into ensureCookiesAreStillMutable will treat the work store as if it has a phase field, causing cookies().set/delete to throw unexpectedly whenever a WorkStore is present (because phase will be undefined). This should read from workUnitAsyncStorage.getStore() (and then narrow to type === 'request') or otherwise obtain the actual RequestStore that owns the phase transitions.

Copilot uses AI. Check for mistakes.

modifiedCookies.add(
typeof args[0] === 'string' ? args[0] : args[0].name
)

try {
target.delete(...args)
return wrappedCookies
} finally {
updateResponseCookies()
}
}

case 'set':
return function (...args: SetCookieArgs) {
const requestStore = workAsyncStorage.getStore() as
| RequestStore
| undefined

if (requestStore) {
ensureCookiesAreStillMutable(requestStore, 'cookies().set')
}
Comment on lines 132 to +140
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

This change introduces phase-dependent mutation checks inside MutableRequestCookiesAdapter.wrap, but there are no unit tests covering the new behavior (e.g., that mutations are allowed in phase === 'action' and rejected after transitioning to render/after). Consider adding tests similar to the existing wrapWithMutableAccessCheck suite to prevent regressions once the correct async storage is used.

Copilot uses AI. Check for mistakes.

modifiedCookies.add(
typeof args[0] === 'string' ? args[0] : args[0].name
)

try {
target.set(...args)
return wrappedCookies
Expand Down Expand Up @@ -190,6 +173,7 @@ export function createCookiesWithMutableAccessCheck(
target.delete(...args)
return wrappedCookies
}

case 'set':
return function (...args: SetCookieArgs) {
ensureCookiesAreStillMutable(requestStore, 'cookies().set')
Expand All @@ -202,26 +186,19 @@ export function createCookiesWithMutableAccessCheck(
}
},
})

return wrappedCookies
}

export function areCookiesMutableInCurrentPhase(requestStore: RequestStore) {
return requestStore.phase === 'action'
}

/** Ensure that cookies() starts throwing on mutation
* if we changed phases and can no longer mutate.
*
* This can happen when going:
* 'render' -> 'after'
* 'action' -> 'render'
* */
function ensureCookiesAreStillMutable(
requestStore: RequestStore,
_callingExpression: string
) {
if (!areCookiesMutableInCurrentPhase(requestStore)) {
// TODO: maybe we can give a more precise error message based on callingExpression?
throw new ReadonlyRequestCookiesError()
}
}
Expand All @@ -230,8 +207,34 @@ export function responseCookiesToRequestCookies(
responseCookies: ResponseCookies
): RequestCookies {
const requestCookies = new RequestCookies(new Headers())

for (const cookie of responseCookies.getAll()) {
requestCookies.set(cookie)
}

return requestCookies
}

export function appendMutableCookies(
headers: Headers,
mutableCookies: ResponseCookies
): boolean {
const modifiedCookieValues = getModifiedCookieValues(mutableCookies)

if (modifiedCookieValues.length === 0) {
return false
}

const resCookies = new ResponseCookies(headers)
const returnedCookies = resCookies.getAll()

for (const cookie of modifiedCookieValues) {
resCookies.set(cookie)
}

for (const cookie of returnedCookies) {
resCookies.set(cookie)
}

return true
}
Loading