Skip to content

Commit

Permalink
[Breaking] Update Dynamic APIs to be async (vercel#68812)
Browse files Browse the repository at this point in the history
Next.js has a number of dynamic APIs that aren't available during
prerendering. What happens when you access these APIs might differ
depending on the mode you are using, for instance if you have PPR turned
on or the newly introduced `dynamicIO` experimental mode. But regardless
of the mode the underlying API represents accessing something that might
only be available at render time (dynamic rendering) rather than
prerender time (build and revalidate rendering)

Unfortunately our current dynamic APIs make certain kinds of modeling
tricky because they are all synchronous. For instance if we wanted to
add a feature to Next.js where we started a dynamic render before a
Request even hits the server it would be interesting to be able to start
working on everything that does not rely on any dynamic data and then
once a real Request arrives we can continue the render and provide the
associated Request context through our dynamic APIs.

If our dynamic APIs were all async we could build something like this
because they represnt a value that will eventually resolve to some
Request value. This PR updates most existing dynamic APIs to be async
rather than sync. This is a breaking change and will need to be paired
with codemods to realistically adopt. Additionally since this change is
so invasive I have implemented it in a way to maximize backward
compatibility by still allowing most synchronous access. The combination
of codemods, typescript updates, and backward compat functionality
should make it possible for projects to upgrade to the latest version
with minimal effort and then follow up with a complete conversion over
time.

#### `cookies()`
`cookies()` now returns `Promise<ReadonlyRequestCookies>`. Synchronous
access to the underlying RequestCookies object is still supported to
facilitate migration.
```tsx
// ------------ preferred usage

// async Server Component
const token = (await cookies()).get('token')

// sync Server Component
import { use } from 'react'
//...
const token = use(cookies()).get('token')

// ------------ temporarily allowed usage

// javascript, dev warning at runtime
const token = cookies().get('token')

// typescript, dev warning at runtime
import { type UnsafeUnwrappedCookies } from 'next/headers'
// ...
const token = (cookies() as unknown as UnsafeUnwrappedCookies).get('token')
```

#### `headers()`
`headers()` now returns `Promise<ReadonlyHeaders>`. Synchronous access
to the underlying Headers object is still supported to facilitate
migration.
```tsx
// ------------ preferred usage

// async Server Component
const header = (await headers()).get('x-foo')

// sync Server Component
import { use } from 'react'
//...
const header = use(headers()).get('x-foo')

// ------------ temporarily allowed usage

// javascript, dev warning at runtime
const header = headers().get('x-foo')

// typescript, dev warning at runtime
import { type UnsafeUnwrappedHeaders } from 'next/headers'
// ...
const header = (headers() as unknown as UnsafeUnwrappedHeaders).get('x-foo')
```


#### `draftMode()`
`draftMode()` now returns `Promise<DraftMode>`. Synchronous access to
the underlying DraftMode object is still supported to facilitate
migration.
```tsx
// ------------ preferred usage

// async Server Component
if ((await draftMode()).isEnabled) { ... }

// sync Server Component
import { use } from 'react'
//...
if (use(draftMode()).isEnabled) { ... }

// ------------ temporarily allowed usage

// javascript, dev warning at runtime
if (draftMode().isEnabled) { ... }

// typescript, dev warning at runtime
import { type UnsafeUnwrappedDraftMode} from 'next/headers'
// ...
if ((draftMode() as unknown as UnsafeUnwrappedDraftMode).isEnabled) { ... }
```

#### `searchParams`
`searchParams` is now a `Promise<{...}>`. Synchronous access to the
underlying search params is still supported to facilitate migration.
```tsx
// ------------ preferred usage

// async Page Component
export default async function Page({
  searchParams
}: {
  searchParams: Promise<{ foo: string }>
}) {
  const fooSearchParam = (await searchParams).foo
}

// sync Page Component
import { use } from 'react'
export default function Page({
  searchParams
}: {
  searchParams: Promise<{ foo: string }>
}) {
  const fooSearchParam = use(searchParams).foo
}

// ------------ temporarily allowed usage

// javascript, dev warning at runtime
export default async function Page({ searchParams}) {
  const fooSearchParam = searchParams.foo
}

// typescript, dev warning at runtime
import { type UnsafeUnwrappedSearchParams } from 'next/server'
export default async function Page({
  searchParams
}: {
  searchParams: Promise<{ foo: string }>
}) {
  const syncSearchParams = (searchParams as unknown as UnsafeUnwrappedSearchParams<typeof searchParams>)
  const fooSearchParam = syncSearchParams.foo
}
```



#### `params`
`params` is now a `Promise<{...}>`. Synchronous access to the underlying
params is still supported to facilitate migration. It should be noted
that while params are not usually dynamic there are certain modes where
they can be such as fallback prerenders for PPR.
```tsx
// ------------ preferred usage

// async Segment Component
export default async function Layout({
  params
}: {
  params: Promise<{ foo: string }>
}) {
  const fooParam = (await params).foo
}

// sync Segment Component
import { use } from 'react'
export default function Layout({
  params
}: {
  params: Promise<{ foo: string }>
}) {
  const fooParam = use(params).foo
}

// ------------ temporarily allowed usage

// javascript, dev warning at runtime
export default async function Layout({ params}) {
  const fooParam = params.foo
}

// typescript, dev warning at runtime
import { type UnsafeUnwrappedParams } from 'next/headers'
export default async function Layout({
  params
}: {
  params: Promise<{ foo: string }>
}) {
  const syncParams = (params as unknown as UnsafeUnwrappedParams<typeof params>)
  const fooSearchParam = syncParams.foo
}
```

### Typescript Changes
When using typescript with Next.js currently it is up to you to author
types for Pages, Layouts and other Segment components that recieve props
like `params` and `searchParams`.

Next comes with some build-time type checking to ensure you have not
improperly typed various top level module exports however the current
type assertions for `params` and `searchParams` is `any`. This isn't
very helpful because it allows you to erroneously type these props.

`searchParams` is tricky because while the default type is a dictionary
object parsed using node.js url parsing it is possible to customize when
running a custom Next.js server. However we can ensure that you
correctly type the prop as a Promise so with this change the validated
type for `searchParams` will be `Promise<any>`.

In the long run we will look at updating the `searchParams` underlying
type to be URLSearchParams so we can move away from supporting
customized parsing during rendering and we can get even more explicit
about valid types.

`params` is more straight forward because the framework controls the
actual `params` prop implementation and no customization is possible. In
the long run we want to enforce you are only typing params that are
valid for the Layout level your file is located in but for now we are
updating the allowed type to be `Promise<{[key: string]: string |
string[] | undefined }>`.

These new type restrictions may also require fixes before being able to
successfully build a project that updates to include these breaking
changes. These changes will also not always be codemodable because it is
valid to type the entire component using an opaque type like `Props`
which our codemods may not have an ability to introspect or modify.
  • Loading branch information
gnoff authored and abhi12299 committed Sep 29, 2024
1 parent 69188a9 commit f6303cc
Show file tree
Hide file tree
Showing 347 changed files with 11,746 additions and 1,014 deletions.
15 changes: 15 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,29 @@
"codemod",
"codemods",
"Destructuring",
"buildtime",
"callsites",
"codemod",
"datastream",
"deduped",
"draftmode",
"Entrypoints",
"jscodeshift",
"napi",
"navigations",
"nextjs",
"opentelemetry",
"Preinit",
"prerendered",
"prerendering",
"proxied",
"renderable",
"revalidates",
"subresource",
"thenables",
"Threadsafe",
"Turbopack",
"unproxied",
"zipkin"
],
"grammarly.selectors": [
Expand Down
2 changes: 2 additions & 0 deletions packages/next/headers.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './dist/server/request/cookies'
export * from './dist/server/request/headers'
export * from './dist/server/request/draft-mode'
4 changes: 3 additions & 1 deletion packages/next/headers.js
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
module.exports = require('./dist/server/request/headers')
module.exports.cookies = require('./dist/server/request/cookies').cookies
module.exports.headers = require('./dist/server/request/headers').headers
module.exports.draftMode = require('./dist/server/request/draft-mode').draftMode
2 changes: 2 additions & 0 deletions packages/next/server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url'
export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response'
export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types'
export { unstable_after } from 'next/dist/server/after'
export type { UnsafeUnwrappedSearchParams } from 'next/dist/server/request/search-params'
export type { UnsafeUnwrappedParams } from 'next/dist/server/request/params'
2 changes: 2 additions & 0 deletions packages/next/src/api/headers.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from '../server/request/cookies'
export * from '../server/request/headers'
export * from '../server/request/draft-mode'
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ checkFields<Diff<{
}
}, TEntry, ''>>()
${options.type === 'route' ? `type RouteContext = { params: Promise<SegmentParams> }` : ''}
${
options.type === 'route'
? HTTP_METHODS.map(
Expand All @@ -103,7 +104,7 @@ if ('${method}' in entry) {
>()
checkFields<
Diff<
ParamCheck<PageParams>,
ParamCheck<RouteContext>,
{
__tag__: '${method}'
__param_position__: 'second'
Expand Down Expand Up @@ -158,14 +159,14 @@ if ('generateViewport' in entry) {
}
// Check the arguments and return type of the generateStaticParams function
if ('generateStaticParams' in entry) {
checkFields<Diff<{ params: PageParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
checkFields<Diff<{ params: SegmentParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
}
type PageParams = any
type SegmentParams = {[param: string]: string | string[] | undefined}
export interface PageProps {
params?: any
searchParams?: any
params?: Promise<SegmentParams>
searchParams?: Promise<any>
}
export interface LayoutProps {
children?: React.ReactNode
Expand All @@ -174,7 +175,7 @@ ${
? options.slots.map((slot) => ` ${slot}: React.ReactNode`).join('\n')
: ''
}
params?: any
params?: Promise<SegmentParams>
}
// =============
Expand Down
86 changes: 66 additions & 20 deletions packages/next/src/client/components/client-page.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,76 @@
'use client'

import type { ParsedUrlQuery } from 'querystring'
import { InvariantError } from '../../shared/lib/invariant-error'

import type { Params } from '../../server/request/params'

/**
* When the Page is a client component we send the params and searchParams to this client wrapper
* where they are turned into dynamically tracked values before being passed to the actual Page component.
*
* additionally we may send promises representing the params and searchParams. We don't ever use these passed
* values but it can be necessary for the sender to send a Promise that doesn't resolve in certain situations.
* It is up to the caller to decide if the promises are needed.
*/
export function ClientPageRoot({
Component,
props,
searchParams,
params,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
promises,
}: {
Component: React.ComponentType<any>
props: { [props: string]: any }
searchParams: ParsedUrlQuery
params: Params
promises?: Array<Promise<any>>
}) {
if (typeof window === 'undefined') {
const { createDynamicallyTrackedParams } =
require('../../server/request/fallback-params') as typeof import('../../server/request/fallback-params')
const { createDynamicallyTrackedSearchParams } =
require('../../server/request/search-params') as typeof import('../../server/request/search-params')

// 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 || {}
)
props.params = props.params
? createDynamicallyTrackedParams(props.params)
: {}
const { staticGenerationAsyncStorage } =
require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external')

let clientSearchParams: Promise<ParsedUrlQuery>
let clientParams: Promise<Params>
// We are going to instrument the searchParams prop with tracking for the
// appropriate context. We wrap differently in prerendering vs rendering
const store = staticGenerationAsyncStorage.getStore()
if (!store) {
throw new InvariantError(
'Expected staticGenerationStore to exist when handling searchParams in a client Page.'
)
}

if (store.isStaticGeneration) {
// We are in a prerender context
const { createPrerenderSearchParamsFromClient } =
require('../../server/request/search-params') as typeof import('../../server/request/search-params')
clientSearchParams = createPrerenderSearchParamsFromClient(store)

const { createPrerenderParamsFromClient } =
require('../../server/request/params') as typeof import('../../server/request/params')

clientParams = createPrerenderParamsFromClient(params, store)
} else {
const { createRenderSearchParamsFromClient } =
require('../../server/request/search-params') as typeof import('../../server/request/search-params')
clientSearchParams = createRenderSearchParamsFromClient(
searchParams,
store
)
const { createRenderParamsFromClient } =
require('../../server/request/params') as typeof import('../../server/request/params')
clientParams = createRenderParamsFromClient(params, store)
}

return <Component params={clientParams} searchParams={clientSearchParams} />
} else {
const { createRenderSearchParamsFromClient } =
require('../../server/request/search-params.browser') as typeof import('../../server/request/search-params.browser')
const clientSearchParams = createRenderSearchParamsFromClient(searchParams)
const { createRenderParamsFromClient } =
require('../../server/request/params.browser') as typeof import('../../server/request/params.browser')
const clientParams = createRenderParamsFromClient(params)

return <Component params={clientParams} searchParams={clientSearchParams} />
}
return <Component {...props} />
}
61 changes: 49 additions & 12 deletions packages/next/src/client/components/client-segment.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,58 @@
'use client'

type ClientSegmentRootProps = {
Component: React.ComponentType
props: { [props: string]: any }
}
import { InvariantError } from '../../shared/lib/invariant-error'

import type { Params } from '../../server/request/params'

/**
* When the Page is a client component we send the params to this client wrapper
* where they are turned into dynamically tracked values before being passed to the actual Segment component.
*
* additionally we may send a promise representing params. We don't ever use this passed
* value but it can be necessary for the sender to send a Promise that doesn't resolve in certain situations
* such as when dynamicIO is enabled. It is up to the caller to decide if the promises are needed.
*/
export function ClientSegmentRoot({
Component,
props,
}: ClientSegmentRootProps) {
slots,
params,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
promise,
}: {
Component: React.ComponentType<any>
slots: { [key: string]: React.ReactNode }
params: Params
promise?: Promise<any>
}) {
if (typeof window === 'undefined') {
const { createDynamicallyTrackedParams } =
require('../../server/request/fallback-params') as typeof import('../../server/request/fallback-params')
const { staticGenerationAsyncStorage } =
require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external')

let clientParams: Promise<Params>
// We are going to instrument the searchParams prop with tracking for the
// appropriate context. We wrap differently in prerendering vs rendering
const store = staticGenerationAsyncStorage.getStore()
if (!store) {
throw new InvariantError(
'Expected staticGenerationStore to exist when handling params in a client segment such as a Layout or Template.'
)
}

const { createPrerenderParamsFromClient } =
require('../../server/request/params') as typeof import('../../server/request/params')

props.params = props.params
? createDynamicallyTrackedParams(props.params)
: {}
if (store.isStaticGeneration) {
clientParams = createPrerenderParamsFromClient(params, store)
} else {
const { createRenderParamsFromClient } =
require('../../server/request/params') as typeof import('../../server/request/params')
clientParams = createRenderParamsFromClient(params, store)
}
return <Component {...slots} params={clientParams} />
} else {
const { createRenderParamsFromClient } =
require('../../server/request/params.browser') as typeof import('../../server/request/params.browser')
const clientParams = createRenderParamsFromClient(params)
return <Component {...slots} params={clientParams} />
}
return <Component {...props} />
}
35 changes: 6 additions & 29 deletions packages/next/src/client/components/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { getSegmentValue } from './router-reducer/reducers/get-segment-value'
import { PAGE_SEGMENT_KEY, DEFAULT_SEGMENT_KEY } from '../../shared/lib/segment'
import { ReadonlyURLSearchParams } from './navigation.react-server'
import { trackFallbackParamAccessed } from '../../server/app-render/dynamic-rendering'
import { useDynamicRouteParams } from '../../server/app-render/dynamic-rendering'

/**
* A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook
Expand Down Expand Up @@ -65,27 +65,6 @@ export function useSearchParams(): ReadonlyURLSearchParams {
return readonlySearchParams
}

function trackParamsAccessed(expression: string) {
if (typeof window === 'undefined') {
// AsyncLocalStorage should not be included in the client bundle.
const { staticGenerationAsyncStorage } =
require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external')

const staticGenerationStore = staticGenerationAsyncStorage.getStore()

if (
staticGenerationStore &&
staticGenerationStore.isStaticGeneration &&
staticGenerationStore.fallbackRouteParams &&
staticGenerationStore.fallbackRouteParams.size > 0
) {
// There are fallback route params, we should track these as dynamic
// accesses.
trackFallbackParamAccessed(staticGenerationStore, expression)
}
}
}

/**
* A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook
* that lets you read the current URL's pathname.
Expand All @@ -105,7 +84,7 @@ function trackParamsAccessed(expression: string) {
*/
// Client components API
export function usePathname(): string {
trackParamsAccessed('usePathname()')
useDynamicRouteParams('usePathname()')

// In the case where this is `null`, the compat types added in `next-env.d.ts`
// will add a new overload that changes the return type to include `null`.
Expand Down Expand Up @@ -165,21 +144,19 @@ export function useRouter(): AppRouterInstance {
*/
// Client components API
export function useParams<T extends Params = Params>(): T {
trackParamsAccessed('useParams()')
useDynamicRouteParams('useParams()')

return useContext(PathParamsContext) as T
}

/** Get the canonical parameters from the current level to the leaf node. */
// Client components API
export function getSelectedLayoutSegmentPath(
function getSelectedLayoutSegmentPath(
tree: FlightRouterState,
parallelRouteKey: string,
first = true,
segmentPath: string[] = []
): string[] {
trackParamsAccessed('getSelectedLayoutSegmentPath()')

let node: FlightRouterState
if (first) {
// Use the provided parallel route key on the first parallel route
Expand Down Expand Up @@ -238,7 +215,7 @@ export function getSelectedLayoutSegmentPath(
export function useSelectedLayoutSegments(
parallelRouteKey: string = 'children'
): string[] {
trackParamsAccessed('useSelectedLayoutSegments()')
useDynamicRouteParams('useSelectedLayoutSegments()')

const context = useContext(LayoutRouterContext)
// @ts-expect-error This only happens in `pages`. Type is overwritten in navigation.d.ts
Expand Down Expand Up @@ -269,7 +246,7 @@ export function useSelectedLayoutSegments(
export function useSelectedLayoutSegment(
parallelRouteKey: string = 'children'
): string | null {
trackParamsAccessed('useSelectedLayoutSegment()')
useDynamicRouteParams('useSelectedLayoutSegment()')

const selectedLayoutSegments = useSelectedLayoutSegments(parallelRouteKey)

Expand Down
Loading

0 comments on commit f6303cc

Please sign in to comment.