Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic APIs #60645

Merged
merged 4 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
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
1 change: 0 additions & 1 deletion docs/02-app/02-api-reference/04-functions/next-request.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ The following options are available:
| -------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `basePath` | `string` | The [base path](/docs/app/api-reference/next-config-js/basePath) of the URL. |
| `buildId` | `string` \| `undefined` | The build identifier of the Next.js application. Can be [customized](/docs/app/api-reference/next-config-js/generateBuildId). |
| `url` | `URL` | The URL object. |
| `pathname` | `string` | The pathname of the URL. |
| `searchParams` | `Object` | The search parameters of the URL. |

Expand Down
6 changes: 4 additions & 2 deletions packages/next/cache.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
export { revalidatePath } from 'next/dist/server/web/spec-extension/revalidate-path'
export { revalidateTag } from 'next/dist/server/web/spec-extension/revalidate-tag'
export {
revalidateTag,
revalidatePath,
} from 'next/dist/server/web/spec-extension/revalidate'
export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store'
4 changes: 2 additions & 2 deletions packages/next/cache.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const cacheExports = {
unstable_cache: require('next/dist/server/web/spec-extension/unstable-cache')
.unstable_cache,
revalidateTag: require('next/dist/server/web/spec-extension/revalidate-tag')
revalidateTag: require('next/dist/server/web/spec-extension/revalidate')
.revalidateTag,
revalidatePath: require('next/dist/server/web/spec-extension/revalidate-path')
revalidatePath: require('next/dist/server/web/spec-extension/revalidate')
.revalidatePath,
unstable_noStore:
require('next/dist/server/web/spec-extension/unstable-no-store')
Expand Down
11 changes: 2 additions & 9 deletions packages/next/src/build/templates/app-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,8 @@ const routeModule = new AppRouteRouteModule({
// Pull out the exports that we need to expose from the module. This should
// be eliminated when we've moved the other routes to the new format. These
// are used to hook into the route.
const {
requestAsyncStorage,
staticGenerationAsyncStorage,
serverHooks,
headerHooks,
staticGenerationBailout,
} = routeModule
const { requestAsyncStorage, staticGenerationAsyncStorage, serverHooks } =
routeModule

const originalPathname = 'VAR_ORIGINAL_PATHNAME'

Expand All @@ -51,8 +46,6 @@ export {
requestAsyncStorage,
staticGenerationAsyncStorage,
serverHooks,
headerHooks,
staticGenerationBailout,
originalPathname,
patchFetch,
}
10 changes: 8 additions & 2 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1580,7 +1580,13 @@ export async function isPageStatic({
const routeModule: RouteModule =
componentsResult.ComponentMod?.routeModule

let supportsPPR = false

if (pageType === 'app') {
if (ppr && routeModule.definition.kind === RouteKind.APP_PAGE) {
supportsPPR = true
}

const ComponentMod: AppPageModule = componentsResult.ComponentMod

isClientComponent = isClientReference(componentsResult.ComponentMod)
Expand Down Expand Up @@ -1650,7 +1656,7 @@ export async function isPageStatic({
// If force dynamic was set and we don't have PPR enabled, then set the
// revalidate to 0.
// TODO: (PPR) remove this once PPR is enabled by default
if (appConfig.dynamic === 'force-dynamic' && !ppr) {
if (appConfig.dynamic === 'force-dynamic' && !supportsPPR) {
appConfig.revalidate = 0
}

Expand Down Expand Up @@ -1748,7 +1754,7 @@ export async function isPageStatic({
// When PPR is enabled, any route may be completely static, so
// mark this route as static.
let isPPR = false
if (ppr && routeModule.definition.kind === RouteKind.APP_PAGE) {
if (supportsPPR) {
isPPR = true
isStatic = true
}
Expand Down
21 changes: 21 additions & 0 deletions packages/next/src/client/components/client-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client'
import { createDynamicallyTrackedSearchParams } from './search-params'

export function ClientPageRoot({
Component,
props,
}: {
Component: React.ComponentType<any>
props: { [props: string]: any }
}) {
// We expect to be passed searchParams but even if we aren't we can construct one from
// an empty object. We only do this if we are in a static generation as a performance
// optimization. Ideally we'd unconditionally construct the tracked params but since
// this creates a proxy which is slow and this would happen even for client navigations
// that are done entirely dynamically and we know there the dynamic tracking is a noop
// in this dynamic case we can safely elide it.
props.searchParams = createDynamicallyTrackedSearchParams(
props.searchParams || {}
)
return <Component {...props} />
}
17 changes: 12 additions & 5 deletions packages/next/src/client/components/draft-mode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DraftModeProvider } from '../../server/async-storage/draft-mode-provider'

import { staticGenerationBailout } from './static-generation-bailout'
import { staticGenerationAsyncStorage } from './static-generation-async-storage.external'
import { trackDynamicDataAccessed } from '../../server/app-render/dynamic-rendering'

export class DraftMode {
/**
Expand All @@ -15,14 +16,20 @@ export class DraftMode {
return this._provider.isEnabled
}
public enable() {
if (staticGenerationBailout('draftMode().enable()')) {
return
const store = staticGenerationAsyncStorage.getStore()
if (store) {
// We we have a store we want to track dynamic data access to ensure we
// don't statically generate routes that manipulate draft mode.
trackDynamicDataAccessed(store, 'draftMode().enable()')
}
return this._provider.enable()
}
public disable() {
if (staticGenerationBailout('draftMode().disable()')) {
return
const store = staticGenerationAsyncStorage.getStore()
if (store) {
// We we have a store we want to track dynamic data access to ensure we
// don't statically generate routes that manipulate draft mode.
trackDynamicDataAccessed(store, 'draftMode().disable()')
}
return this._provider.disable()
}
Expand Down
62 changes: 30 additions & 32 deletions packages/next/src/client/components/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,46 @@ import {
} 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 { requestAsyncStorage } from './request-async-storage.external'
import { actionAsyncStorage } from './action-async-storage.external'
import { staticGenerationBailout } from './static-generation-bailout'
import { DraftMode } from './draft-mode'
import { trackDynamicDataAccessed } from '../../server/app-render/dynamic-rendering'
import { staticGenerationAsyncStorage } from './static-generation-async-storage.external'
import { getExpectedRequestStore } from './request-async-storage.external'

export function headers() {
if (
staticGenerationBailout('headers', {
link: 'https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering',
})
) {
return HeadersAdapter.seal(new Headers({}))
}
const requestStore = requestAsyncStorage.getStore()
if (!requestStore) {
throw new Error(
`Invariant: headers() expects to have requestAsyncStorage, none available.`
)
const callingExpression = 'headers'
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 headers object
return HeadersAdapter.seal(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)
return requestStore.headers
}

export function cookies() {
if (
staticGenerationBailout('cookies', {
link: 'https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering',
})
) {
return RequestCookiesAdapter.seal(new RequestCookies(new Headers({})))
}
const callingExpression = 'cookies'
const staticGenerationStore = staticGenerationAsyncStorage.getStore()

const requestStore = requestAsyncStorage.getStore()
if (!requestStore) {
throw new Error(
`Invariant: cookies() expects to have requestAsyncStorage, none available.`
)
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 &&
Expand All @@ -57,11 +58,8 @@ export function cookies() {
}

export function draftMode() {
const requestStore = requestAsyncStorage.getStore()
if (!requestStore) {
throw new Error(
`Invariant: draftMode() expects to have requestAsyncStorage, none available.`
)
}
const callingExpression = 'draftMode'
const requestStore = getExpectedRequestStore(callingExpression)

return new DraftMode(requestStore.draftMode)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ export type RequestAsyncStorage = AsyncLocalStorage<RequestStore>

export const requestAsyncStorage: RequestAsyncStorage =
createAsyncLocalStorage()

export function getExpectedRequestStore(callingExpression: string) {
const store = requestAsyncStorage.getStore()
if (!store) {
throw new Error(
`Invariant: \`${callingExpression}\` expects to have requestAsyncStorage, none available.`
)
}
return store
}
73 changes: 73 additions & 0 deletions packages/next/src/client/components/search-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { ParsedUrlQuery } from 'querystring'

import { staticGenerationAsyncStorage } from './static-generation-async-storage.external'
import { trackDynamicDataAccessed } from '../../server/app-render/dynamic-rendering'
import { ReflectAdapter } from '../../server/web/spec-extension/adapters/reflect'

/**
* Takes a ParsedUrlQuery object and either returns it unmodified or returns an empty object
*
* Even though we do not track read access on the returned searchParams we need to
* return an empty object if we are doing a 'force-static' render. This is to ensure
* we don't encode the searchParams into the flight data.
*/
export function createUntrackedSearchParams(
searchParams: ParsedUrlQuery
): ParsedUrlQuery {
const store = staticGenerationAsyncStorage.getStore()
if (store && store.forceStatic) {
return {}
} else {
return searchParams
}
}

/**
* Takes a ParsedUrlQuery object and returns a Proxy that tracks read access to the object
*
* If running in the browser will always return the provided searchParams object.
* When running during SSR will return empty during a 'force-static' render and
* otherwise it returns a searchParams object which tracks reads to trigger dynamic rendering
* behavior if appropriate
*/
export function createDynamicallyTrackedSearchParams(
searchParams: ParsedUrlQuery
): ParsedUrlQuery {
const store = staticGenerationAsyncStorage.getStore()
if (!store) {
// we assume we are in a route handler or page render. just return the searchParams
return searchParams
} else if (store.forceStatic) {
// If we forced static we omit searchParams entirely. This is true both during SSR
// and browser render because we need there to be parity between these environments
return {}
} else if (!store.isStaticGeneration && !store.dynamicShouldError) {
// during dynamic renders we don't actually have to track anything so we just return
// the searchParams directly. However if dynamic data access should error then we
// still want to track access. This covers the case in Dev where all renders are dynamic
// but we still want to error if you use a dynamic data source because it will fail the build
// or revalidate if you do.
return searchParams
} else {
// We need to track dynamic access with a Proxy. We implement get, has, and ownKeys because
// these can all be used to exfiltrate information about searchParams.
return new Proxy({} as ParsedUrlQuery, {
get(target, prop, receiver) {
if (typeof prop === 'string') {
trackDynamicDataAccessed(store, `searchParams.${prop}`)
}
return ReflectAdapter.get(target, prop, receiver)
Copy link
Member

Choose a reason for hiding this comment

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

Is there a reason we use ReflectAdapter here but Reflect in has and ownKeys?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm it's a style choice. ReflectAdapter requires me to understand what ReflectAdapter is and why it is being used etc.... If I added ReflectAdapter for all the Reflect methods it adds some cognative overhead to later understanding of this code. The fact that only get has special handling in the Adapter to me is a sign that we should probably make a lint rule for binding function types in get traps and just drop the namespaced class.

If I implemented all of Reflect in ReflectAdapter for completeness sake then we'd be shipping more code and in some cases an extra function call that isn't optimized away for the appearence of abstraction (the ReflectAdapter is going to take care of things). If our goal is to make sure you don't forget something critical I'd prefer linting since there is no runtime overhead and the code will just real plainly showing what it does

},
has(target, prop) {
if (typeof prop === 'string') {
trackDynamicDataAccessed(store, `searchParams.${prop}`)
}
return Reflect.has(target, prop)
},
ownKeys(target) {
trackDynamicDataAccessed(store, 'searchParams')
return Reflect.ownKeys(target)
},
})
}
}
15 changes: 0 additions & 15 deletions packages/next/src/client/components/searchparams-bailout-proxy.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import type { Revalidate } from '../../server/lib/revalidate'

import { createAsyncLocalStorage } from './async-local-storage'

type PrerenderState = {
hasDynamic: boolean
}

export interface StaticGenerationStore {
readonly isStaticGeneration: boolean
readonly pagePath?: string
Expand All @@ -16,12 +20,8 @@ export interface StaticGenerationStore {
readonly isRevalidate?: boolean
readonly isUnstableCacheCallback?: boolean

/**
* If defined, this function when called will throw an error postponing
* rendering during the React render phase. This should not be invoked outside
* of the React render phase as it'll throw an error.
*/
readonly postpone: ((reason: string) => never) | undefined
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Generally we want to avoid these methods of passing code around. Even our ComponentMod technique for exposing RSC scoped functions is really not ideal. In the rewrite to dynamic APIs references to React's postpone API are referenced directly from the modules that need them (transitively through dynamic-rendering.ts). Crucially this allows the postpone to be the "right" one for RSC vs SSR whereas in the previous implementaiton we were using the RSC postpone to postpone in SSR in some cases (this happens to work by accident of the implementation but by no means guaranteed)

// When this exists (is not null) it means we are in a Prerender
prerenderState: null | PrerenderState
gnoff marked this conversation as resolved.
Show resolved Hide resolved

forceDynamic?: boolean
fetchCache?:
Expand All @@ -36,7 +36,6 @@ export interface StaticGenerationStore {
forceStatic?: boolean
dynamicShouldError?: boolean
pendingRevalidates?: Record<string, Promise<any>>
postponeWasTriggered?: boolean

dynamicUsageDescription?: string
dynamicUsageStack?: string
Expand All @@ -59,3 +58,13 @@ export type StaticGenerationAsyncStorage =

export const staticGenerationAsyncStorage: StaticGenerationAsyncStorage =
createAsyncLocalStorage()

export function getExpectedStaticGenerationStore(callingExpression: string) {
const store = staticGenerationAsyncStorage.getStore()
if (!store) {
throw new Error(
`Invariant: \`${callingExpression}\` expects to have staticGenerationAsyncStorage, none available.`
)
}
return store
}
Loading
Loading