diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index b551145b90bd8b..9417c81dcd6df0 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -43,6 +43,7 @@ import { fromNodeOutgoingHttpHeaders } from '../web/utils' import { selectWorkerForForwarding } from './action-utils' import { isNodeNextRequest, isWebNextRequest } from '../base-http/helpers' import { RedirectStatusCode } from '../../client/components/redirect-status-code' +import { synchronizeMutableCookies } from '../async-storage/request-store' function formDataFromSearchQueryString(query: string) { const searchParams = new URLSearchParams(query) @@ -459,6 +460,15 @@ export async function handleAction({ ) } + const finalizeAndGenerateFlight: GenerateFlight = (...args) => { + // When we switch to the render phase, cookies() will return + // `workUnitStore.cookies` instead of `workUnitStore.userspaceMutableCookies`. + // We want the render to see any cookie writes that we performed during the action, + // so we need to update the immutable cookies to reflect the changes. + synchronizeMutableCookies(requestStore) + return generateFlight(...args) + } + requestStore.phase = 'action' // When running actions the default is no-store, you can still `cache: 'force-cache'` @@ -548,7 +558,7 @@ export async function handleAction({ return { type: 'done', - result: await generateFlight(req, ctx, { + result: await finalizeAndGenerateFlight(req, ctx, { actionResult: promise, // if the page was not revalidated, we can skip the rendering the flight tree skipFlight: !workStore.pathWasRevalidated, @@ -841,7 +851,7 @@ export async function handleAction({ requestStore, }) - actionResult = await generateFlight(req, ctx, { + actionResult = await finalizeAndGenerateFlight(req, ctx, { actionResult: Promise.resolve(returnVal), // if the page was not revalidated, or if the action was forwarded from another worker, we can skip the rendering the flight tree skipFlight: !workStore.pathWasRevalidated || actionWasForwarded, @@ -916,7 +926,7 @@ export async function handleAction({ } return { type: 'done', - result: await generateFlight(req, ctx, { + result: await finalizeAndGenerateFlight(req, ctx, { skipFlight: false, actionResult: promise, }), diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index 9b7e93525ffdb1..1cde5d9743ca9e 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -38,8 +38,11 @@ export type RequestStore = { } readonly headers: ReadonlyHeaders - readonly cookies: ReadonlyRequestCookies + // This is mutable because we need to reassign it when transitioning from the action phase to the render phase. + // The cookie object itself is deliberately read only and thus can't be updated. + cookies: ReadonlyRequestCookies readonly mutableCookies: ResponseCookies + readonly userspaceMutableCookies: ResponseCookies readonly draftMode: DraftModeProvider readonly isHmrRefresh?: boolean readonly serverComponentsHmrCache?: ServerComponentsHmrCache diff --git a/packages/next/src/server/async-storage/request-store.ts b/packages/next/src/server/async-storage/request-store.ts index 94cea77271f2c6..5e1dfdcb99a44b 100644 --- a/packages/next/src/server/async-storage/request-store.ts +++ b/packages/next/src/server/async-storage/request-store.ts @@ -13,6 +13,8 @@ import { import { MutableRequestCookiesAdapter, RequestCookiesAdapter, + responseCookiesToRequestCookies, + wrapWithMutableAccessCheck, type ReadonlyRequestCookies, } from '../web/spec-extension/adapters/request-cookies' import { ResponseCookies, RequestCookies } from '../web/spec-extension/cookies' @@ -165,6 +167,7 @@ function createRequestStoreImpl( headers?: ReadonlyHeaders cookies?: ReadonlyRequestCookies mutableCookies?: ResponseCookies + userspaceMutableCookies?: ResponseCookies draftMode?: DraftModeProvider } = {} @@ -202,6 +205,9 @@ function createRequestStoreImpl( return cache.cookies }, + set cookies(value: ReadonlyRequestCookies) { + cache.cookies = value + }, get mutableCookies() { if (!cache.mutableCookies) { const mutableCookies = getMutableCookies( @@ -215,6 +221,15 @@ function createRequestStoreImpl( } return cache.mutableCookies }, + get userspaceMutableCookies() { + if (!cache.userspaceMutableCookies) { + const userspaceMutableCookies = wrapWithMutableAccessCheck( + this.mutableCookies + ) + cache.userspaceMutableCookies = userspaceMutableCookies + } + return cache.userspaceMutableCookies + }, get draftMode() { if (!cache.draftMode) { cache.draftMode = new DraftModeProvider( @@ -234,3 +249,10 @@ function createRequestStoreImpl( (globalThis as any).__serverComponentsHmrCache, } } + +export function synchronizeMutableCookies(store: RequestStore) { + // TODO: does this need to update headers as well? + store.cookies = RequestCookiesAdapter.seal( + responseCookiesToRequestCookies(store.mutableCookies) + ) +} diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index 436b7f735b0078..658fa1c5ca0f57 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -1,6 +1,7 @@ import { type ReadonlyRequestCookies, type ResponseCookies, + areCookiesMutableInCurrentPhase, RequestCookiesAdapter, } from '../web/spec-extension/adapters/request-cookies' import { RequestCookies } from '../web/spec-extension/cookies' @@ -16,7 +17,6 @@ import { trackDynamicDataInDynamicRender, } from '../app-render/dynamic-rendering' import { getExpectedRequestStore } from '../app-render/work-unit-async-storage.external' -import { actionAsyncStorage } from '../app-render/action-async-storage.external' import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' import { makeResolvedReactPromise } from './utils' import { makeHangingPromise } from '../dynamic-rendering-utils' @@ -113,22 +113,16 @@ export function cookies(): Promise { } // cookies is being called in a dynamic context - const actionStore = actionAsyncStorage.getStore() const requestStore = getExpectedRequestStore(callingExpression) let underlyingCookies: ReadonlyRequestCookies - // The current implementation of cookies will return Response cookies - // for a server action during the render phase of a server action. - // This is not correct b/c the type of cookies during render is ReadOnlyRequestCookies - // where as the type of cookies during action is ResponseCookies - // This was found because RequestCookies is iterable and ResponseCookies is not - if (actionStore?.isAction || actionStore?.isAppRoute) { + if (areCookiesMutableInCurrentPhase(requestStore)) { // We can't conditionally return different types here based on the context. // To avoid confusion, we always return the readonly type here. underlyingCookies = - requestStore.mutableCookies as unknown as ReadonlyRequestCookies + requestStore.userspaceMutableCookies as unknown as ReadonlyRequestCookies } else { underlyingCookies = requestStore.cookies } diff --git a/packages/next/src/server/web/spec-extension/adapters/request-cookies.test.ts b/packages/next/src/server/web/spec-extension/adapters/request-cookies.test.ts index b654bdbfe52970..0322f3cbc8d290 100644 --- a/packages/next/src/server/web/spec-extension/adapters/request-cookies.test.ts +++ b/packages/next/src/server/web/spec-extension/adapters/request-cookies.test.ts @@ -1,7 +1,13 @@ -import { RequestCookies } from '../cookies' +import { + workUnitAsyncStorage, + type RequestStore, +} from '../../../app-render/work-unit-async-storage.external' +import { RequestCookies, ResponseCookies } from '../cookies' import { ReadonlyRequestCookiesError, RequestCookiesAdapter, + MutableRequestCookiesAdapter, + wrapWithMutableAccessCheck, } from './request-cookies' describe('RequestCookiesAdapter', () => { @@ -52,3 +58,97 @@ describe('RequestCookiesAdapter', () => { expect(sealed.get('bar')).toEqual(undefined) }) }) + +describe('MutableRequestCookiesAdapter', () => { + it('supports chained set calls and preserves wrapping', () => { + const headers = new Headers({}) + const underlyingCookies = new RequestCookies(headers) + const onUpdateCookies = jest.fn() + + const wrappedCookies = MutableRequestCookiesAdapter.wrap( + underlyingCookies, + onUpdateCookies + ) + + const returned = wrappedCookies.set('foo', '1').set('bar', '2') + + expect(returned).toBe(wrappedCookies) + expect(onUpdateCookies).toHaveBeenCalledWith([ + expect.stringContaining('foo=1'), + ]) + expect(onUpdateCookies).toHaveBeenCalledWith([ + expect.stringContaining('foo=1'), + expect.stringContaining('bar=2'), + ]) + }) + + it('supports chained delete calls and preserves wrapping', () => { + const headers = new Headers({}) + const underlyingCookies = new RequestCookies(headers) + underlyingCookies.set('foo', '1').set('bar', '2') + + const onUpdateCookies = jest.fn() + const wrappedCookies = MutableRequestCookiesAdapter.wrap( + underlyingCookies, + onUpdateCookies + ) + + const returned = wrappedCookies.delete('foo').delete('bar') + + expect(returned).toBe(wrappedCookies) + expect(onUpdateCookies).toHaveBeenCalledWith([ + expect.stringContaining('foo=;'), + ]) + expect(onUpdateCookies).toHaveBeenCalledWith([ + expect.stringContaining('foo=;'), + expect.stringContaining('bar=;'), + ]) + }) +}) + +describe('wrapWithMutableAccessCheck', () => { + const createMockRequestStore = (phase: RequestStore['phase']) => + ({ type: 'request', phase }) as RequestStore + + it('prevents setting cookies in the render phase', () => { + const requestStore = createMockRequestStore('action') + workUnitAsyncStorage.run(requestStore, () => { + const headers = new Headers({}) + const underlyingCookies = new ResponseCookies(headers) + const wrappedCookies = wrapWithMutableAccessCheck(underlyingCookies) + + // simulate changing phases + requestStore.phase = 'render' + + const EXPECTED_ERROR = + /Cookies can only be modified in a Server Action or Route Handler\./ + + expect(() => { + wrappedCookies.set('foo', '1') + }).toThrow(EXPECTED_ERROR) + + expect(wrappedCookies.get('foo')).toBe(undefined) + }) + }) + + it('prevents deleting cookies in the render phase', () => { + const requestStore = createMockRequestStore('action') + workUnitAsyncStorage.run(requestStore, () => { + const headers = new Headers({}) + const underlyingCookies = new ResponseCookies(headers) + const wrappedCookies = wrapWithMutableAccessCheck(underlyingCookies) + wrappedCookies.set('foo', '1') + + // simulate changing phases + requestStore.phase = 'render' + + const EXPECTED_ERROR = + /Cookies can only be modified in a Server Action or Route Handler\./ + + expect(() => { + wrappedCookies.delete('foo') + }).toThrow(EXPECTED_ERROR) + expect(wrappedCookies.get('foo')?.value).toEqual('1') + }) + }) +}) diff --git a/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts b/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts index fea02788828d17..fb3176933a3f0b 100644 --- a/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts +++ b/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts @@ -1,8 +1,12 @@ -import type { RequestCookies } from '../cookies' +import { RequestCookies } from '../cookies' import { ResponseCookies } from '../cookies' import { ReflectAdapter } from './reflect' import { workAsyncStorage } from '../../../app-render/work-async-storage.external' +import { + getExpectedRequestStore, + type RequestStore, +} from '../../../app-render/work-unit-async-storage.external' /** * @internal @@ -63,6 +67,10 @@ export function getModifiedCookieValues( return modified } +type SetCookieArgs = + | [key: string, value: string, cookie?: Partial] + | [options: ResponseCookie] + export function appendMutableCookies( headers: Headers, mutableCookies: ResponseCookies @@ -128,7 +136,7 @@ export class MutableRequestCookiesAdapter { } } - return new Proxy(responseCookies, { + const wrappedCookies = new Proxy(responseCookies, { get(target, prop, receiver) { switch (prop) { // A special symbol to get the modified cookie values @@ -144,29 +152,86 @@ export class MutableRequestCookiesAdapter { ) try { target.delete(...args) + return wrappedCookies } finally { updateResponseCookies() } } case 'set': - return function ( - ...args: - | [key: string, value: string, cookie?: Partial] - | [options: ResponseCookie] - ) { + return function (...args: SetCookieArgs) { modifiedCookies.add( typeof args[0] === 'string' ? args[0] : args[0].name ) try { - return target.set(...args) + target.set(...args) + return wrappedCookies } finally { updateResponseCookies() } } + default: return ReflectAdapter.get(target, prop, receiver) } }, }) + + return wrappedCookies + } +} + +export function wrapWithMutableAccessCheck( + responseCookies: ResponseCookies +): ResponseCookies { + const wrappedCookies = new Proxy(responseCookies, { + get(target, prop, receiver) { + switch (prop) { + case 'delete': + return function (...args: [string] | [ResponseCookie]) { + ensureCookiesAreStillMutable('cookies().delete') + target.delete(...args) + return wrappedCookies + } + case 'set': + return function (...args: SetCookieArgs) { + ensureCookiesAreStillMutable('cookies().set') + target.set(...args) + return wrappedCookies + } + + default: + return ReflectAdapter.get(target, prop, receiver) + } + }, + }) + 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(callingExpression: string) { + const requestStore = getExpectedRequestStore(callingExpression) + if (!areCookiesMutableInCurrentPhase(requestStore)) { + // TODO: maybe we can give a more precise error message based on callingExpression? + throw new ReadonlyRequestCookiesError() + } +} + +export function responseCookiesToRequestCookies( + responseCookies: ResponseCookies +): RequestCookies { + const requestCookies = new RequestCookies(new Headers()) + for (const cookie of responseCookies.getAll()) { + requestCookies.set(cookie) } + return requestCookies } diff --git a/test/e2e/app-dir/next-after-app/index.test.ts b/test/e2e/app-dir/next-after-app/index.test.ts index efcba7aaa0c1d7..52664e6fb6151a 100644 --- a/test/e2e/app-dir/next-after-app/index.test.ts +++ b/test/e2e/app-dir/next-after-app/index.test.ts @@ -248,15 +248,16 @@ describe.each(runtimes)('unstable_after() in %s runtime', (runtimeValue) => { const cookie1 = await browser.elementById('cookie').text() expect(cookie1).toEqual('Cookie: null') + const cliOutputIndex = next.cliOutput.length try { await browser.elementByCss('button[type="submit"]').click() await retry(async () => { const cookie1 = await browser.elementById('cookie').text() expect(cookie1).toEqual('Cookie: "action"') - // const newLogs = next.cliOutput.slice(cliOutputIndex) + const newLogs = next.cliOutput.slice(cliOutputIndex) // // after() from action - // expect(newLogs).toContain(EXPECTED_ERROR) + expect(newLogs).toMatch(EXPECTED_ERROR) }) } finally { await browser.eval('document.cookie = "testCookie=;path=/;max-age=-1"') diff --git a/test/e2e/app-dir/phase-changes/app/cookies/action-to-after/page.tsx b/test/e2e/app-dir/phase-changes/app/cookies/action-to-after/page.tsx new file mode 100644 index 00000000000000..d95cf7c777e29c --- /dev/null +++ b/test/e2e/app-dir/phase-changes/app/cookies/action-to-after/page.tsx @@ -0,0 +1,23 @@ +import { unstable_after as after } from 'next/server' +import { cookies } from 'next/headers' +import * as React from 'react' + +export const dynamic = 'force-dynamic' + +async function action() { + 'use server' + after(async () => { + const cookieStore = await cookies() + cookieStore.set('illegalCookie', 'too-late-for-that') + }) +} + +export default async function Page() { + return ( + <> +
+ +
+ + ) +} diff --git a/test/e2e/app-dir/phase-changes/app/cookies/action-to-after/via-closure/page.tsx b/test/e2e/app-dir/phase-changes/app/cookies/action-to-after/via-closure/page.tsx new file mode 100644 index 00000000000000..3208047baffba4 --- /dev/null +++ b/test/e2e/app-dir/phase-changes/app/cookies/action-to-after/via-closure/page.tsx @@ -0,0 +1,23 @@ +import { unstable_after as after } from 'next/server' +import { cookies } from 'next/headers' +import * as React from 'react' + +export const dynamic = 'force-dynamic' + +async function action() { + 'use server' + const cookieStore = await cookies() + after(async () => { + cookieStore.set('illegalCookie', 'too-late-for-that') + }) +} + +export default async function Page() { + return ( + <> +
+ +
+ + ) +} diff --git a/test/e2e/app-dir/phase-changes/app/cookies/action-to-render/page.tsx b/test/e2e/app-dir/phase-changes/app/cookies/action-to-render/page.tsx new file mode 100644 index 00000000000000..c8be1b20863960 --- /dev/null +++ b/test/e2e/app-dir/phase-changes/app/cookies/action-to-render/page.tsx @@ -0,0 +1,34 @@ +import { cookies } from 'next/headers' +import * as React from 'react' + +export const dynamic = 'force-dynamic' + +async function action() { + 'use server' + // make sure we return an updated version of the page in the response + ;(await cookies()).set('pleaseRenderThePage', Date.now() + '') +} + +export default async function Page() { + const timestamp = Date.now() + const cookieStore = await cookies() + const canSetCookies = (() => { + try { + cookieStore.set('illegalCookie', 'i-love-side-effects-in-render') + return true + } catch (err) { + // we want assert on the error message, so don't swallow the error + console.error(err) + return false + } + })() + return ( + <> +
{timestamp}
+
{JSON.stringify(canSetCookies)}
+
+ +
+ + ) +} diff --git a/test/e2e/app-dir/phase-changes/app/cookies/middleware-to-after/page.tsx b/test/e2e/app-dir/phase-changes/app/cookies/middleware-to-after/page.tsx new file mode 100644 index 00000000000000..1b337d4338281f --- /dev/null +++ b/test/e2e/app-dir/phase-changes/app/cookies/middleware-to-after/page.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export default function Page() { + return <>This page does nothing, everything happens in middleware +} diff --git a/test/e2e/app-dir/phase-changes/app/cookies/middleware-to-after/via-closure/page.tsx b/test/e2e/app-dir/phase-changes/app/cookies/middleware-to-after/via-closure/page.tsx new file mode 100644 index 00000000000000..1b337d4338281f --- /dev/null +++ b/test/e2e/app-dir/phase-changes/app/cookies/middleware-to-after/via-closure/page.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export default function Page() { + return <>This page does nothing, everything happens in middleware +} diff --git a/test/e2e/app-dir/phase-changes/app/cookies/redirect-target/page.tsx b/test/e2e/app-dir/phase-changes/app/cookies/redirect-target/page.tsx new file mode 100644 index 00000000000000..48aa57228463ca --- /dev/null +++ b/test/e2e/app-dir/phase-changes/app/cookies/redirect-target/page.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export default async function Page() { + return
Redirect target
+} diff --git a/test/e2e/app-dir/phase-changes/app/cookies/route-handler-to-after/route.ts b/test/e2e/app-dir/phase-changes/app/cookies/route-handler-to-after/route.ts new file mode 100644 index 00000000000000..a9bdbb0a2def56 --- /dev/null +++ b/test/e2e/app-dir/phase-changes/app/cookies/route-handler-to-after/route.ts @@ -0,0 +1,11 @@ +import { cookies } from 'next/headers' +import { unstable_after as after } from 'next/server' + +export async function POST() { + after(async () => { + const cookieStore = await cookies() + cookieStore.set('illegalCookie', 'too-late-for-that') + }) + + return new Response('') +} diff --git a/test/e2e/app-dir/phase-changes/app/cookies/route-handler-to-after/via-closure/route.ts b/test/e2e/app-dir/phase-changes/app/cookies/route-handler-to-after/via-closure/route.ts new file mode 100644 index 00000000000000..4d9ebf559b9055 --- /dev/null +++ b/test/e2e/app-dir/phase-changes/app/cookies/route-handler-to-after/via-closure/route.ts @@ -0,0 +1,10 @@ +import { cookies } from 'next/headers' +import { unstable_after as after } from 'next/server' + +export async function POST() { + const cookieStore = await cookies() + after(async () => { + cookieStore.set('illegalCookie', 'too-late-for-that') + }) + return new Response('') +} diff --git a/test/e2e/app-dir/phase-changes/app/layout.tsx b/test/e2e/app-dir/phase-changes/app/layout.tsx new file mode 100644 index 00000000000000..fe18a75a94f1fe --- /dev/null +++ b/test/e2e/app-dir/phase-changes/app/layout.tsx @@ -0,0 +1,10 @@ +import * as React from 'react' + +export default function AppLayout({ children }) { + return ( + + + {children} + + ) +} diff --git a/test/e2e/app-dir/phase-changes/cookies.test.ts b/test/e2e/app-dir/phase-changes/cookies.test.ts new file mode 100644 index 00000000000000..8c586f4a8920a9 --- /dev/null +++ b/test/e2e/app-dir/phase-changes/cookies.test.ts @@ -0,0 +1,147 @@ +/* eslint-env jest */ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('setting cookies', () => { + const { next, isNextDeploy, skipped } = nextTestSetup({ + files: __dirname, + }) + + if (skipped) return + + let currentCliOutputIndex = 0 + beforeEach(() => { + resetCliOutput() + }) + + const getCliOutput = () => { + if (next.cliOutput.length < currentCliOutputIndex) { + // cliOutput shrank since we started the test, so something (like a `sandbox`) reset the logs + currentCliOutputIndex = 0 + } + return next.cliOutput.slice(currentCliOutputIndex) + } + + const resetCliOutput = () => { + currentCliOutputIndex = next.cliOutput.length + } + + const EXPECTED_ERROR = + /Cookies can only be modified in a Server Action or Route Handler\./ + + const EXPECTED_ERROR_IN_AFTER = + /An error occurred in a function passed to `unstable_after\(\)`: .+?: Cookies can only be modified in a Server Action or Route Handler\./ + + describe('stops cookie mutations when changing phases', () => { + it('from an action to a page render', async () => { + const path = '/cookies/action-to-render' + const session = await next.browser(path) + + const timestamp1 = await session.elementById('timestamp').text() + // .set() should throw during render + expect(await session.elementById('canSetCookies').text()).toEqual('false') + if (!isNextDeploy) { + expect(getCliOutput()).toMatch(EXPECTED_ERROR) + } + // no cookie should be set + expect(await session.eval('document.cookie')).not.toInclude( + 'illegalCookie' + ) + + resetCliOutput() + // trigger an action + await session.elementByCss('[type="submit"]').click() + // wait for page to update as a result + await retry(async () => { + const timestamp2 = await session.elementById('timestamp').text() + expect(timestamp2).not.toEqual(timestamp1) + }) + + // .set() should throw during render + expect(await session.elementById('canSetCookies').text()).toEqual('false') + if (!isNextDeploy) { + expect(getCliOutput()).toMatch(EXPECTED_ERROR) + } + + // no cookie should be set + expect(await session.eval('document.cookie')).not.toInclude( + 'illegalCookie' + ) + }) + + // these tests inspect CLI logs to see what happened in unstable_after, + // so they won't work in deploy mode + if (!isNextDeploy) { + it.each([ + { + title: 'from an action to unstable_after', + path: '/cookies/action-to-after', + }, + { + title: 'from an action to unstable_after via closure', + path: '/cookies/action-to-after/via-closure', + }, + ])('$title', async ({ path }) => { + const session = await next.browser(path) + + // trigger an action + await session.elementByCss('[type="submit"]').click() + await retry(async () => { + // the .set() in unstable_after should error + expect(getCliOutput()).toMatch(EXPECTED_ERROR_IN_AFTER) + }) + + // no cookie should be set + expect(await session.eval('document.cookie')).not.toInclude( + 'illegalCookie' + ) + }) + + it.each([ + { + title: 'from a route handler to unstable_after', + path: '/cookies/route-handler-to-after', + }, + { + title: 'from a route handler to unstable_after via closure', + path: '/cookies/route-handler-to-after/via-closure', + }, + ])('$title', async ({ path }) => { + const response = await next.fetch(path, { method: 'POST' }) + await response.text() + expect(response.status).toBe(200) + + // no cookie should be set + expect(response.headers.get('set-cookie')).toBe(null) + + // the .set() in unstable_after should error + await retry(async () => { + expect(getCliOutput()).toMatch(EXPECTED_ERROR_IN_AFTER) + }) + }) + + it.each([ + { + title: 'from middleware to unstable_after', + path: '/cookies/middleware-to-after', + }, + { + title: 'from middleware to unstable_after via closure', + path: '/cookies/middleware-to-after/via-closure', + }, + ])('$title', async ({ path }) => { + const response = await next.fetch(path) + await response.text() + expect(response.status).toBe(200) + + // no cookie should be set + expect(response.headers.get('set-cookie')).toBe(null) + + // the .set() in unstable_after should error + await retry(async () => { + expect(getCliOutput()).toMatch(EXPECTED_ERROR_IN_AFTER) + }) + }) + } + }) +}) diff --git a/test/e2e/app-dir/phase-changes/middleware.ts b/test/e2e/app-dir/phase-changes/middleware.ts new file mode 100644 index 00000000000000..9809868c2cade5 --- /dev/null +++ b/test/e2e/app-dir/phase-changes/middleware.ts @@ -0,0 +1,22 @@ +import { cookies } from 'next/headers' +import { unstable_after as after, type NextRequest } from 'next/server' + +export async function middleware(request: NextRequest) { + const url = new URL(request.url) + + if (url.pathname === '/cookies/middleware-to-after') { + after(async () => { + const cookieStore = await cookies() + cookieStore.set('illegalCookie', 'too-late-for-that') + }) + } else if (url.pathname === '/cookies/middleware-to-after/via-closure') { + const cookieStore = await cookies() + after(async () => { + cookieStore.set('illegalCookie', 'too-late-for-that') + }) + } +} + +export const config = { + matcher: ['/cookies/middleware-to-after/:path*'], +} diff --git a/test/e2e/app-dir/phase-changes/next.config.js b/test/e2e/app-dir/phase-changes/next.config.js new file mode 100644 index 00000000000000..ec0f3bcc9dad4b --- /dev/null +++ b/test/e2e/app-dir/phase-changes/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + experimental: { + after: true, + }, +}