Skip to content

Commit

Permalink
Implement exotically async dynamic APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
gnoff committed Aug 14, 2024
1 parent 728b140 commit cbeba6a
Show file tree
Hide file tree
Showing 19 changed files with 532 additions and 63 deletions.
1 change: 1 addition & 0 deletions packages/next/headers.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './dist/client/components/headers'
export * from './dist/server/request/cookies'
1 change: 1 addition & 0 deletions packages/next/headers.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
module.exports = require('./dist/client/components/headers')
module.exports.cookies = require('./dist/server/request/cookies').cookies
1 change: 1 addition & 0 deletions packages/next/src/api/headers.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from '../client/components/headers'
export * from '../server/request/cookies'
32 changes: 0 additions & 32 deletions packages/next/src/client/components/headers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
type ReadonlyRequestCookies,
RequestCookiesAdapter,
} from '../../server/web/spec-extension/adapters/request-cookies'
import { HeadersAdapter } from '../../server/web/spec-extension/adapters/headers'
import { RequestCookies } from '../../server/web/spec-extension/cookies'
import { actionAsyncStorage } from './action-async-storage.external'
import { DraftMode } from './draft-mode'
import { trackDynamicDataAccessed } from '../../server/app-render/dynamic-rendering'
import { staticGenerationAsyncStorage } from './static-generation-async-storage.external'
Expand Down Expand Up @@ -36,32 +30,6 @@ export function headers() {
return getExpectedRequestStore(callingExpression).headers
}

export function cookies() {
const callingExpression = 'cookies'
const staticGenerationStore = staticGenerationAsyncStorage.getStore()

if (staticGenerationStore) {
if (staticGenerationStore.forceStatic) {
// When we are forcing static we don't mark this as a Dynamic read and we return an empty cookies object
return RequestCookiesAdapter.seal(new RequestCookies(new Headers({})))
} else {
// We will return a real headers object below so we mark this call as reading from a dynamic data source
trackDynamicDataAccessed(staticGenerationStore, callingExpression)
}
}

const requestStore = getExpectedRequestStore(callingExpression)

const asyncActionStore = actionAsyncStorage.getStore()
if (asyncActionStore?.isAction || asyncActionStore?.isAppRoute) {
// We can't conditionally return different types here based on the context.
// To avoid confusion, we always return the readonly type here.
return requestStore.mutableCookies as unknown as ReadonlyRequestCookies
}

return requestStore.cookies
}

export function draftMode() {
const callingExpression = 'draftMode'
const requestStore = getExpectedRequestStore(callingExpression)
Expand Down
198 changes: 198 additions & 0 deletions packages/next/src/server/request/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import {
type ReadonlyRequestCookies,
RequestCookiesAdapter,
} from '../../server/web/spec-extension/adapters/request-cookies'
import { RequestCookies } from '../../server/web/spec-extension/cookies'
import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external'
import { prerenderAsyncStorage } from '../app-render/prerender-async-storage.external'
import { trackDynamicDataAccessed } from '../../server/app-render/dynamic-rendering'
import { getExpectedRequestStore } from '../../client/components/request-async-storage.external'
import { actionAsyncStorage } from '../../client/components/action-async-storage.external'
import { ReflectAdapter } from '../web/spec-extension/adapters/reflect'

type Cookies = ReadonlyRequestCookies | RequestCookies

type ExoticCookies<T extends Cookies> = Promise<T> & T

export function cookies(): ExoticCookies<ReadonlyRequestCookies> {
const callingExpression = 'cookies'
const requestStore = getExpectedRequestStore(callingExpression)
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
const prerenderStore = prerenderAsyncStorage.getStore()

if (staticGenerationStore) {
if (staticGenerationStore.forceStatic) {
// When using forceStatic we override all other logic and always just return an empty
// cookies object without tracking
const underlyingCookies = createEmptyCookies()
return makeExoticCookies(
makeResolved(underlyingCookies),
underlyingCookies
)
}

if (prerenderStore) {
// We are in PPR and/or dynamicIO mode and prerendering

if (prerenderStore.controller || prerenderStore.cacheSignal) {
// We use the controller and cacheSignal as an indication we are in dynamicIO mode.
// When resolving cookies for a prerender with dynamic IO we return a forever promise
// along with property access tracked synchronous cookies.

// We don't track dynamic access here because access will be tracked when you access
// one of the properties of the cookies object.
const underlyingCookies = createDynamicallyTrackedEmptyCookies()
const promiseOfCookies = makeForeverPromise<ReadonlyRequestCookies>()
return makeExoticCookies(promiseOfCookies, underlyingCookies)
} else {
// We are prerendering with PPR. We need track dynamic access here eagerly
// to keep continuity with how cookies has worked in PPR without dynamicIO.
// TODO consider switching the semantic to throw on property access intead
trackDynamicDataAccessed(staticGenerationStore, 'cookies')
const underlyingCookies = createEmptyCookies()
const promiseOfCookies = makeResolved(underlyingCookies)
return makeExoticCookies(promiseOfCookies, underlyingCookies)
}
} else if (staticGenerationStore.isStaticGeneration) {
// We are in a legacy static generation mode while prerendering
// We track dynamic access here so we don't need to wrap the cookies in
// individual property access tracking.
trackDynamicDataAccessed(staticGenerationStore, 'cookies')
const underlyingCookies = createEmptyCookies()
const promiseOfCookies = makeResolved(underlyingCookies)
return makeExoticCookies(promiseOfCookies, underlyingCookies)
}
// We fall through to the dynamic context below but we still track dynamic access
// because in dev we can still error for things like using cookies inside a cache context
trackDynamicDataAccessed(staticGenerationStore, 'cookies')
}

// cookies is being called in a dynamic context
const asyncActionStore = actionAsyncStorage.getStore()

let underlyingCookies: ReadonlyRequestCookies

// The current implmenetation 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 (asyncActionStore?.isAction || asyncActionStore?.isAppRoute) {
// 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
} else {
underlyingCookies = requestStore.cookies
}

const promiseOfCookies = makeResolved(underlyingCookies)
return makeExoticCookies(promiseOfCookies, underlyingCookies)
}

function createEmptyCookies(): ReadonlyRequestCookies {
return RequestCookiesAdapter.seal(new RequestCookies(new Headers({})))
}

function createDynamicallyTrackedEmptyCookies(): ReadonlyRequestCookies {
return new Proxy(createEmptyCookies(), dynamicTrackingHandler)
}

const dynamicTrackingHandler: ProxyHandler<ReadonlyRequestCookies> = {
get(target, prop, receiver) {
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
if (staticGenerationStore) {
// We need to use the slow String(prop) form because Symbols cannot be case with +
trackDynamicDataAccessed(staticGenerationStore, getPropExpression(prop))
}
return ReflectAdapter.get(target, prop, receiver)
},
has(target, prop) {
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
if (staticGenerationStore) {
// We need to use the slow String(prop) form because Symbols cannot be case with +
trackDynamicDataAccessed(staticGenerationStore, getPropExpression(prop))
}
return Reflect.has(target, prop)
},
}

/**
* The wrapped promise is the primary type so for most method means of interacting with the object we want the Promise
* mode to be in control. For synchronous access to cookies methods we support get and has traps for the
* enumerated types of RequestCookies
*/

function makeExoticCookies<T extends Cookies>(
promiseOfUnderlying: Promise<T>,
underlying: T
): ExoticCookies<T> {
const handler: ProxyHandler<Promise<Cookies>> = {
get(target, prop, receiver) {
switch (prop) {
case 'size':
case 'get':
case 'getAll':
case 'has':
case 'set':
case 'delete':
case 'clear':
case 'toString':
case Symbol.iterator:
return ReflectAdapter.get(underlying, prop, underlying)
default:
// @TODO consider warning in dev when developing next directly if it looks like we
// missed a property. We need to keep this in sync with the RequestCookies class
return ReflectAdapter.get(target, prop, receiver)
}
},
has(target, prop) {
switch (prop) {
case 'size':
case 'get':
case 'getAll':
case 'has':
case 'set':
case 'delete':
case 'clear':
case 'toString':
case Symbol.iterator:
return Reflect.has(underlying, prop)
default:
return Reflect.has(target, prop)
}
},
}

return new Proxy(promiseOfUnderlying, handler) as ExoticCookies<T>
}

/**
* Makes a resolved Promise from the value which was be synchronously unwrapped by React
*/
function makeResolved<T>(value: T): Promise<T> {
const p = Promise.resolve(value)
;(p as any).status = 'fulfilled'
;(p as any).value = value
return p
}

function neverResolve(): void {}
function makeForeverPromise<T>(): Promise<T> {
return new Promise<T>(neverResolve)
}

function getPropExpression(prop: string | symbol): string {
if (typeof prop === 'string') {
return `cookies().${prop}`
} else {
switch (prop) {
// For expected symbol accesses let's provide the more readable form
case Symbol.iterator:
return 'cookies()[Symbol.iterator]'
// For less common symbol access let the platform print something useful
default:
return `cookies()[${String(prop)}]`
}
}
}
2 changes: 1 addition & 1 deletion test/e2e/app-dir/actions/app/redirect-target/page.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cookies } from 'next/dist/client/components/headers'
import { cookies } from 'next/headers'

export default function Page() {
const redirectCookie = cookies().get('redirect')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Suspense } from 'react'
import { cookies } from 'next/headers'

/**
* This test case is constructed to demonstrate how using the async form of cookies can lead to a better
* prerender with dynamic IO when PPR is on. There is no difference when PPR is off. When PPR is on the second component
* can finish rendering before the prerender completes and so we can produce a static shell where the Fallback closest
* to Cookies access is read
*/
export default async function Page() {
return (
<>
<Suspense fallback="loading...">
<Component />
</Suspense>
<ComponentTwo />
</>
)
}

async function Component() {
const cookie = (await cookies()).get('x-sentinel')
if (cookie && cookie.value) {
return (
<div>
cookie <span id="x-sentinel">{cookie.value}</span>
</div>
)
} else {
return <div>no cookie found</div>
}
}

function ComponentTwo() {
return <p>footer</p>
}
18 changes: 18 additions & 0 deletions test/e2e/app-dir/io-is-dynamic-ppr/app/cookies/async_root/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cookies } from 'next/headers'

export default async function Page() {
return <Component />
}

async function Component() {
const cookie = (await cookies()).get('x-sentinel')
if (cookie && cookie.value) {
return (
<div>
cookie <span id="x-sentinel">{cookie.value}</span>
</div>
)
} else {
return <div>no cookie found</div>
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Suspense } from 'react'
import { cookies } from 'next/headers'

/**
* This test case is constructed to demonstrate the deopting behavior of synchronously
* accesing dynamic data like cookies. <ComponentTwo /> won't be able to render before we abort
* to it will bubble up to the root and mark the whoe page as dynamic when PPR is one. There
* is no real change in behavior when PPR is off.
*/
export default async function Page() {
return (
<>
<Suspense fallback="loading...">
<Component />
</Suspense>
<ComponentTwo />
</>
)
}

// @TODO convert these back to sync functions once https://github.com/facebook/react/pull/30683 is integrated
async function Component() {
await 1
const cookie = cookies().get('x-sentinel')
if (cookie && cookie.value) {
return (
<div>
cookie <span id="x-sentinel">{cookie.value}</span>
</div>
)
} else {
return <div>no cookie found</div>
}
}

function ComponentTwo() {
return <p>footer</p>
}
20 changes: 20 additions & 0 deletions test/e2e/app-dir/io-is-dynamic-ppr/app/cookies/sync_root/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { cookies } from 'next/headers'

export default async function Page() {
return <Component />
}

// @TODO convert these back to sync functions once https://github.com/facebook/react/pull/30683 is integrated
async function Component() {
await 1
const cookie = cookies().get('x-sentinel')
if (cookie && cookie.value) {
return (
<div>
cookie <span id="x-sentinel">{cookie.value}</span>
</div>
)
} else {
return <div>no cookie found</div>
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default async function Page() {

async function ComponentOne() {
try {
cookies()
cookies().get('test')
} catch (e) {
// swallow any throw. We should still not be static
}
Expand Down
Loading

0 comments on commit cbeba6a

Please sign in to comment.