Skip to content

Commit

Permalink
Add connection() as a new dynamic API (#69949)
Browse files Browse the repository at this point in the history
In #68812 I updated most dynamic
APIs to be async. One API that was not udpated was `unstable_noStore()`.
This API is marked as unstable and doesn't quite fit the semantics we're
exploring with dynamicIO and partial prerendering and so rather than
converting it to be async we're going to deprecate it and replace it
with an entirely new API.

This PR doesn't actually deprecate anything yet but it does introduce
`connection()`.

The idea with `connection` is that you are waiting until there is a real
user Request before proceeding. In the context of prerendering no
Request will ever happen so the page cannot produce a static result.
(this is similar to how `unstable_noStore()` works today).

In a PPR context the currently rendering component won't resolve but a
parent Suspense boundary can still statically render a fallback.

`connect()` returns a `Promise<void>`. It is tempting to call the API
`request()` and return a `Promise<Request>` however we have to guard
access to the underlying Request carefully to ensure we can maximally
prerender pages and to avoid confusion and maybe some frustration we are
naming it `connection` since this doesn't imply a specific data set that
might be returned.

```
import { connection } from 'next/server'

async function MyServerComponent() {
  await connection()
  // everthing after this point will be excluded from prerendering
  const rand = Math.random()
  return <span>{rand}</span>
}
```
  • Loading branch information
gnoff authored Sep 30, 2024
1 parent ee3211e commit 86abd1c
Show file tree
Hide file tree
Showing 14 changed files with 337 additions and 6 deletions.
1 change: 1 addition & 0 deletions packages/next/server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ 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 { connection } from 'next/dist/server/request/connection'
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/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const serverExports = {
URLPattern: require('next/dist/server/web/spec-extension/url-pattern')
.URLPattern,
unstable_after: require('next/dist/server/after').unstable_after,
connection: require('next/dist/server/request/connection').connection,
}

// https://nodejs.org/api/esm.html#commonjs-namespaces
Expand All @@ -26,3 +27,4 @@ exports.userAgentFromString = serverExports.userAgentFromString
exports.userAgent = serverExports.userAgent
exports.URLPattern = serverExports.URLPattern
exports.unstable_after = serverExports.unstable_after
exports.connection = serverExports.connection
72 changes: 72 additions & 0 deletions packages/next/src/server/request/connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external'
import {
isDynamicIOPrerender,
prerenderAsyncStorage,
} from '../app-render/prerender-async-storage.external'
import {
postponeWithTracking,
throwToInterruptStaticGeneration,
trackDynamicDataInDynamicRender,
} from '../app-render/dynamic-rendering'
import { StaticGenBailoutError } from '../../client/components/static-generation-bailout'
import { makeHangingPromise } from '../dynamic-rendering-utils'

/**
* This function allows you to indicate that you require an actual user Request before continuing.
*
* During prerendering it will never resolve and during rendering it resolves immediately.
*/
export function connection(): Promise<void> {
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
// headers object without tracking
return Promise.resolve(undefined)
}

if (staticGenerationStore.isUnstableCacheCallback) {
throw new Error(
`Route ${staticGenerationStore.route} used "connection" inside a function cached with "unstable_cache(...)". The \`connection()\` function is used to indicate the subsequent code must only run when there is an actual Request, but caches must be able to be produced before a Request so this function is not allowed in this scope. See more info here: https://nextjs.org/docs/app/api-reference/functions/unstable_cache`
)
} else if (staticGenerationStore.dynamicShouldError) {
throw new StaticGenBailoutError(
`Route ${staticGenerationStore.route} with \`dynamic = "error"\` couldn't be rendered statically because it used \`connection\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`
)
}

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

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

// We don't track dynamic access here because access will be tracked when you access
// one of the properties of the headers object.
return makeHangingPromise()
} else {
// We are prerendering with PPR. We need track dynamic access here eagerly
// to keep continuity with how headers has worked in PPR without dynamicIO.
// TODO consider switching the semantic to throw on property access intead
postponeWithTracking(
staticGenerationStore.route,
'connection',
prerenderStore.dynamicTracking
)
}
} else if (staticGenerationStore.isStaticGeneration) {
// We are in a legacy static generation mode while prerendering
// We treat this function call as a bailout of static generation
throwToInterruptStaticGeneration('connection', staticGenerationStore)
}
// 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 headers inside a cache context
trackDynamicDataInDynamicRender(staticGenerationStore)
}

return Promise.resolve(undefined)
}
1 change: 1 addition & 0 deletions packages/next/src/server/web/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { NextResponse } from '../spec-extension/response'
export { userAgent, userAgentFromString } from '../spec-extension/user-agent'
export { URLPattern } from '../spec-extension/url-pattern'
export { unstable_after } from '../../after'
export { connection } from '../../request/connection'
26 changes: 26 additions & 0 deletions test/e2e/app-dir/dynamic-data/dynamic-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,16 @@ describe('dynamic-data with dynamic = "error"', () => {
await browser.close()
}

browser = await next.browser('/connection')
try {
await assertHasRedbox(browser)
expect(await getRedboxHeader(browser)).toMatch(
'Error: Route /connection with `dynamic = "error"` couldn\'t be rendered statically because it used `connection`'
)
} finally {
await browser.close()
}

browser = await next.browser('/headers?foo=foosearch')
try {
await assertHasRedbox(browser)
Expand Down Expand Up @@ -230,6 +240,9 @@ describe('dynamic-data with dynamic = "error"', () => {
expect(next.cliOutput).toMatch(
'Error: Route /cookies with `dynamic = "error"` couldn\'t be rendered statically because it used `cookies`'
)
expect(next.cliOutput).toMatch(
'Error: Route /connection with `dynamic = "error"` couldn\'t be rendered statically because it used `connection`'
)
expect(next.cliOutput).toMatch(
'Error: Route /headers with `dynamic = "error"` couldn\'t be rendered statically because it used `headers`'
)
Expand Down Expand Up @@ -277,6 +290,16 @@ describe('dynamic-data inside cache scope', () => {
await browser.close()
}

browser = await next.browser('/connection')
try {
await assertHasRedbox(browser)
expect(await getRedboxHeader(browser)).toMatch(
'Error: Route /connection used "connection" inside a function cached with "unstable_cache(...)".'
)
} finally {
await browser.close()
}

browser = await next.browser('/headers')
try {
await assertHasRedbox(browser)
Expand All @@ -297,6 +320,9 @@ describe('dynamic-data inside cache scope', () => {
expect(next.cliOutput).toMatch(
'Error: Route /cookies used "cookies" inside a function cached with "unstable_cache(...)".'
)
expect(next.cliOutput).toMatch(
'Error: Route /connection used "connection" inside a function cached with "unstable_cache(...)".'
)
expect(next.cliOutput).toMatch(
'Error: Route /headers used "headers" inside a function cached with "unstable_cache(...)".'
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { connection } from 'next/server'
import { unstable_cache as cache } from 'next/cache'

const cachedConnection = cache(async () => connection())

export default async function Page({ searchParams }) {
await cachedConnection()
return (
<div>
<section>
This example uses `connection()` inside `unstable_cache` which should
cause the build to fail
</section>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { headers, cookies } from 'next/headers'
import { connection } from 'next/server'

import { PageSentinel } from '../getSentinelValue'

export const dynamic = 'force-dynamic'

export default async function Page({ searchParams }) {
await connection()
return (
<div>
<PageSentinel />
<section>
This example uses headers/cookies/searchParams directly in a Page
configured with `dynamic = 'force-dynamic'`. This should cause the page
to always render dynamically regardless of dynamic APIs used
This example uses headers/cookies/connection/searchParams directly in a
Page configured with `dynamic = 'force-dynamic'`. This should cause the
page to always render dynamically regardless of dynamic APIs used
</section>
<section id="headers">
<h3>headers</h3>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { headers, cookies } from 'next/headers'
import { connection } from 'next/server'

import { PageSentinel } from '../getSentinelValue'

export const dynamic = 'force-static'

export default async function Page({ searchParams }) {
await connection()
return (
<div>
<PageSentinel />
<section>
This example uses headers/cookies/searchParams directly in a Page
configured with `dynamic = 'force-static'`. This should cause the page
to always statically render but without exposing dynamic data
This example uses headers/cookies/connection/searchParams directly in a
Page configured with `dynamic = 'force-static'`. This should cause the
page to always statically render but without exposing dynamic data
</section>
<section id="headers">
<h3>headers</h3>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { headers, cookies } from 'next/headers'
import { connection } from 'next/server'

import { PageSentinel } from '../getSentinelValue'

export default async function Page({ searchParams }) {
await connection()
return (
<div>
<PageSentinel />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Server, { connection } from 'next/server'

console.log('Server', Server)

export const dynamic = 'error'

export default async function Page({ searchParams }) {
await connection()
return (
<div>
<section>
This example uses `connection()` but is configured with `dynamic =
'error'` which should cause the page to fail to build
</section>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Suspense } from 'react'
import { connection } from 'next/server'

import { getSentinelValue } from '../../../getSentinelValue'
/**
* 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 />
<div id="page">{getSentinelValue()}</div>
</>
)
}

async function Component() {
await connection()
return (
<div>
cookie <span id="foo">foo</span>
</div>
)
}

function ComponentTwo() {
return <p>footer</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Suspense } from 'react'
import { connection } from 'next/server'

import { getSentinelValue } from '../../../getSentinelValue'

export default async function Page() {
const pendingConnection = connection()
return (
<section>
<h1>Deep Connection Reader</h1>
<p>
This component was passed the connection promise returned by
`connection()`. It is rendered inside a Suspense boundary.
</p>
<p>
If dynamicIO is turned off the `connection()` call would trigger a
dynamic point at the callsite and the suspense boundary would also be
blocked for over one second
</p>
<Suspense
fallback={
<>
<p>loading connection...</p>
<div id="fallback">{getSentinelValue()}</div>
</>
}
>
<DeepConnectionReader pendingConnection={pendingConnection} />
</Suspense>
</section>
)
}

async function DeepConnectionReader({
pendingConnection,
}: {
pendingConnection: ReturnType<typeof connection>
}) {
await pendingConnection
return (
<>
<p>The connection was awaited</p>
<div id="page">{getSentinelValue()}</div>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { connection } from 'next/server'

import { getSentinelValue } from '../../../getSentinelValue'
/**
* 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 (
<>
<Component />
<ComponentTwo />
<div id="page">{getSentinelValue()}</div>
</>
)
}

async function Component() {
await connection()
return (
<div>
cookie <span id="foo">foo</span>
</div>
)
}

function ComponentTwo() {
return <p>footer</p>
}
Loading

0 comments on commit 86abd1c

Please sign in to comment.