Skip to content

Commit cbeba6a

Browse files
committed
Implement exotically async dynamic APIs
1 parent 728b140 commit cbeba6a

File tree

19 files changed

+532
-63
lines changed

19 files changed

+532
-63
lines changed

packages/next/headers.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './dist/client/components/headers'
2+
export * from './dist/server/request/cookies'

packages/next/headers.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
module.exports = require('./dist/client/components/headers')
2+
module.exports.cookies = require('./dist/server/request/cookies').cookies

packages/next/src/api/headers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from '../client/components/headers'
2+
export * from '../server/request/cookies'

packages/next/src/client/components/headers.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import {
2-
type ReadonlyRequestCookies,
3-
RequestCookiesAdapter,
4-
} from '../../server/web/spec-extension/adapters/request-cookies'
51
import { HeadersAdapter } from '../../server/web/spec-extension/adapters/headers'
6-
import { RequestCookies } from '../../server/web/spec-extension/cookies'
7-
import { actionAsyncStorage } from './action-async-storage.external'
82
import { DraftMode } from './draft-mode'
93
import { trackDynamicDataAccessed } from '../../server/app-render/dynamic-rendering'
104
import { staticGenerationAsyncStorage } from './static-generation-async-storage.external'
@@ -36,32 +30,6 @@ export function headers() {
3630
return getExpectedRequestStore(callingExpression).headers
3731
}
3832

39-
export function cookies() {
40-
const callingExpression = 'cookies'
41-
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
42-
43-
if (staticGenerationStore) {
44-
if (staticGenerationStore.forceStatic) {
45-
// When we are forcing static we don't mark this as a Dynamic read and we return an empty cookies object
46-
return RequestCookiesAdapter.seal(new RequestCookies(new Headers({})))
47-
} else {
48-
// We will return a real headers object below so we mark this call as reading from a dynamic data source
49-
trackDynamicDataAccessed(staticGenerationStore, callingExpression)
50-
}
51-
}
52-
53-
const requestStore = getExpectedRequestStore(callingExpression)
54-
55-
const asyncActionStore = actionAsyncStorage.getStore()
56-
if (asyncActionStore?.isAction || asyncActionStore?.isAppRoute) {
57-
// We can't conditionally return different types here based on the context.
58-
// To avoid confusion, we always return the readonly type here.
59-
return requestStore.mutableCookies as unknown as ReadonlyRequestCookies
60-
}
61-
62-
return requestStore.cookies
63-
}
64-
6533
export function draftMode() {
6634
const callingExpression = 'draftMode'
6735
const requestStore = getExpectedRequestStore(callingExpression)
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import {
2+
type ReadonlyRequestCookies,
3+
RequestCookiesAdapter,
4+
} from '../../server/web/spec-extension/adapters/request-cookies'
5+
import { RequestCookies } from '../../server/web/spec-extension/cookies'
6+
import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external'
7+
import { prerenderAsyncStorage } from '../app-render/prerender-async-storage.external'
8+
import { trackDynamicDataAccessed } from '../../server/app-render/dynamic-rendering'
9+
import { getExpectedRequestStore } from '../../client/components/request-async-storage.external'
10+
import { actionAsyncStorage } from '../../client/components/action-async-storage.external'
11+
import { ReflectAdapter } from '../web/spec-extension/adapters/reflect'
12+
13+
type Cookies = ReadonlyRequestCookies | RequestCookies
14+
15+
type ExoticCookies<T extends Cookies> = Promise<T> & T
16+
17+
export function cookies(): ExoticCookies<ReadonlyRequestCookies> {
18+
const callingExpression = 'cookies'
19+
const requestStore = getExpectedRequestStore(callingExpression)
20+
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
21+
const prerenderStore = prerenderAsyncStorage.getStore()
22+
23+
if (staticGenerationStore) {
24+
if (staticGenerationStore.forceStatic) {
25+
// When using forceStatic we override all other logic and always just return an empty
26+
// cookies object without tracking
27+
const underlyingCookies = createEmptyCookies()
28+
return makeExoticCookies(
29+
makeResolved(underlyingCookies),
30+
underlyingCookies
31+
)
32+
}
33+
34+
if (prerenderStore) {
35+
// We are in PPR and/or dynamicIO mode and prerendering
36+
37+
if (prerenderStore.controller || prerenderStore.cacheSignal) {
38+
// We use the controller and cacheSignal as an indication we are in dynamicIO mode.
39+
// When resolving cookies for a prerender with dynamic IO we return a forever promise
40+
// along with property access tracked synchronous cookies.
41+
42+
// We don't track dynamic access here because access will be tracked when you access
43+
// one of the properties of the cookies object.
44+
const underlyingCookies = createDynamicallyTrackedEmptyCookies()
45+
const promiseOfCookies = makeForeverPromise<ReadonlyRequestCookies>()
46+
return makeExoticCookies(promiseOfCookies, underlyingCookies)
47+
} else {
48+
// We are prerendering with PPR. We need track dynamic access here eagerly
49+
// to keep continuity with how cookies has worked in PPR without dynamicIO.
50+
// TODO consider switching the semantic to throw on property access intead
51+
trackDynamicDataAccessed(staticGenerationStore, 'cookies')
52+
const underlyingCookies = createEmptyCookies()
53+
const promiseOfCookies = makeResolved(underlyingCookies)
54+
return makeExoticCookies(promiseOfCookies, underlyingCookies)
55+
}
56+
} else if (staticGenerationStore.isStaticGeneration) {
57+
// We are in a legacy static generation mode while prerendering
58+
// We track dynamic access here so we don't need to wrap the cookies in
59+
// individual property access tracking.
60+
trackDynamicDataAccessed(staticGenerationStore, 'cookies')
61+
const underlyingCookies = createEmptyCookies()
62+
const promiseOfCookies = makeResolved(underlyingCookies)
63+
return makeExoticCookies(promiseOfCookies, underlyingCookies)
64+
}
65+
// We fall through to the dynamic context below but we still track dynamic access
66+
// because in dev we can still error for things like using cookies inside a cache context
67+
trackDynamicDataAccessed(staticGenerationStore, 'cookies')
68+
}
69+
70+
// cookies is being called in a dynamic context
71+
const asyncActionStore = actionAsyncStorage.getStore()
72+
73+
let underlyingCookies: ReadonlyRequestCookies
74+
75+
// The current implmenetation of cookies will return Response cookies
76+
// for a server action during the render phase of a server action.
77+
// This is not correct b/c the type of cookies during render is ReadOnlyRequestCookies
78+
// where as the type of cookies during action is ResponseCookies
79+
// This was found because RequestCookies is iterable and ResponseCookies is not
80+
if (asyncActionStore?.isAction || asyncActionStore?.isAppRoute) {
81+
// We can't conditionally return different types here based on the context.
82+
// To avoid confusion, we always return the readonly type here.
83+
underlyingCookies =
84+
requestStore.mutableCookies as unknown as ReadonlyRequestCookies
85+
} else {
86+
underlyingCookies = requestStore.cookies
87+
}
88+
89+
const promiseOfCookies = makeResolved(underlyingCookies)
90+
return makeExoticCookies(promiseOfCookies, underlyingCookies)
91+
}
92+
93+
function createEmptyCookies(): ReadonlyRequestCookies {
94+
return RequestCookiesAdapter.seal(new RequestCookies(new Headers({})))
95+
}
96+
97+
function createDynamicallyTrackedEmptyCookies(): ReadonlyRequestCookies {
98+
return new Proxy(createEmptyCookies(), dynamicTrackingHandler)
99+
}
100+
101+
const dynamicTrackingHandler: ProxyHandler<ReadonlyRequestCookies> = {
102+
get(target, prop, receiver) {
103+
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
104+
if (staticGenerationStore) {
105+
// We need to use the slow String(prop) form because Symbols cannot be case with +
106+
trackDynamicDataAccessed(staticGenerationStore, getPropExpression(prop))
107+
}
108+
return ReflectAdapter.get(target, prop, receiver)
109+
},
110+
has(target, prop) {
111+
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
112+
if (staticGenerationStore) {
113+
// We need to use the slow String(prop) form because Symbols cannot be case with +
114+
trackDynamicDataAccessed(staticGenerationStore, getPropExpression(prop))
115+
}
116+
return Reflect.has(target, prop)
117+
},
118+
}
119+
120+
/**
121+
* The wrapped promise is the primary type so for most method means of interacting with the object we want the Promise
122+
* mode to be in control. For synchronous access to cookies methods we support get and has traps for the
123+
* enumerated types of RequestCookies
124+
*/
125+
126+
function makeExoticCookies<T extends Cookies>(
127+
promiseOfUnderlying: Promise<T>,
128+
underlying: T
129+
): ExoticCookies<T> {
130+
const handler: ProxyHandler<Promise<Cookies>> = {
131+
get(target, prop, receiver) {
132+
switch (prop) {
133+
case 'size':
134+
case 'get':
135+
case 'getAll':
136+
case 'has':
137+
case 'set':
138+
case 'delete':
139+
case 'clear':
140+
case 'toString':
141+
case Symbol.iterator:
142+
return ReflectAdapter.get(underlying, prop, underlying)
143+
default:
144+
// @TODO consider warning in dev when developing next directly if it looks like we
145+
// missed a property. We need to keep this in sync with the RequestCookies class
146+
return ReflectAdapter.get(target, prop, receiver)
147+
}
148+
},
149+
has(target, prop) {
150+
switch (prop) {
151+
case 'size':
152+
case 'get':
153+
case 'getAll':
154+
case 'has':
155+
case 'set':
156+
case 'delete':
157+
case 'clear':
158+
case 'toString':
159+
case Symbol.iterator:
160+
return Reflect.has(underlying, prop)
161+
default:
162+
return Reflect.has(target, prop)
163+
}
164+
},
165+
}
166+
167+
return new Proxy(promiseOfUnderlying, handler) as ExoticCookies<T>
168+
}
169+
170+
/**
171+
* Makes a resolved Promise from the value which was be synchronously unwrapped by React
172+
*/
173+
function makeResolved<T>(value: T): Promise<T> {
174+
const p = Promise.resolve(value)
175+
;(p as any).status = 'fulfilled'
176+
;(p as any).value = value
177+
return p
178+
}
179+
180+
function neverResolve(): void {}
181+
function makeForeverPromise<T>(): Promise<T> {
182+
return new Promise<T>(neverResolve)
183+
}
184+
185+
function getPropExpression(prop: string | symbol): string {
186+
if (typeof prop === 'string') {
187+
return `cookies().${prop}`
188+
} else {
189+
switch (prop) {
190+
// For expected symbol accesses let's provide the more readable form
191+
case Symbol.iterator:
192+
return 'cookies()[Symbol.iterator]'
193+
// For less common symbol access let the platform print something useful
194+
default:
195+
return `cookies()[${String(prop)}]`
196+
}
197+
}
198+
}

test/e2e/app-dir/actions/app/redirect-target/page.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cookies } from 'next/dist/client/components/headers'
1+
import { cookies } from 'next/headers'
22

33
export default function Page() {
44
const redirectCookie = cookies().get('redirect')
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Suspense } from 'react'
2+
import { cookies } from 'next/headers'
3+
4+
/**
5+
* This test case is constructed to demonstrate how using the async form of cookies can lead to a better
6+
* prerender with dynamic IO when PPR is on. There is no difference when PPR is off. When PPR is on the second component
7+
* can finish rendering before the prerender completes and so we can produce a static shell where the Fallback closest
8+
* to Cookies access is read
9+
*/
10+
export default async function Page() {
11+
return (
12+
<>
13+
<Suspense fallback="loading...">
14+
<Component />
15+
</Suspense>
16+
<ComponentTwo />
17+
</>
18+
)
19+
}
20+
21+
async function Component() {
22+
const cookie = (await cookies()).get('x-sentinel')
23+
if (cookie && cookie.value) {
24+
return (
25+
<div>
26+
cookie <span id="x-sentinel">{cookie.value}</span>
27+
</div>
28+
)
29+
} else {
30+
return <div>no cookie found</div>
31+
}
32+
}
33+
34+
function ComponentTwo() {
35+
return <p>footer</p>
36+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { cookies } from 'next/headers'
2+
3+
export default async function Page() {
4+
return <Component />
5+
}
6+
7+
async function Component() {
8+
const cookie = (await cookies()).get('x-sentinel')
9+
if (cookie && cookie.value) {
10+
return (
11+
<div>
12+
cookie <span id="x-sentinel">{cookie.value}</span>
13+
</div>
14+
)
15+
} else {
16+
return <div>no cookie found</div>
17+
}
18+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Suspense } from 'react'
2+
import { cookies } from 'next/headers'
3+
4+
/**
5+
* This test case is constructed to demonstrate the deopting behavior of synchronously
6+
* accesing dynamic data like cookies. <ComponentTwo /> won't be able to render before we abort
7+
* to it will bubble up to the root and mark the whoe page as dynamic when PPR is one. There
8+
* is no real change in behavior when PPR is off.
9+
*/
10+
export default async function Page() {
11+
return (
12+
<>
13+
<Suspense fallback="loading...">
14+
<Component />
15+
</Suspense>
16+
<ComponentTwo />
17+
</>
18+
)
19+
}
20+
21+
// @TODO convert these back to sync functions once https://github.com/facebook/react/pull/30683 is integrated
22+
async function Component() {
23+
await 1
24+
const cookie = cookies().get('x-sentinel')
25+
if (cookie && cookie.value) {
26+
return (
27+
<div>
28+
cookie <span id="x-sentinel">{cookie.value}</span>
29+
</div>
30+
)
31+
} else {
32+
return <div>no cookie found</div>
33+
}
34+
}
35+
36+
function ComponentTwo() {
37+
return <p>footer</p>
38+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { cookies } from 'next/headers'
2+
3+
export default async function Page() {
4+
return <Component />
5+
}
6+
7+
// @TODO convert these back to sync functions once https://github.com/facebook/react/pull/30683 is integrated
8+
async function Component() {
9+
await 1
10+
const cookie = cookies().get('x-sentinel')
11+
if (cookie && cookie.value) {
12+
return (
13+
<div>
14+
cookie <span id="x-sentinel">{cookie.value}</span>
15+
</div>
16+
)
17+
} else {
18+
return <div>no cookie found</div>
19+
}
20+
}

0 commit comments

Comments
 (0)