Skip to content

Commit 86abd1c

Browse files
authored
Add connection() as a new dynamic API (#69949)
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> } ```
1 parent ee3211e commit 86abd1c

File tree

14 files changed

+337
-6
lines changed

14 files changed

+337
-6
lines changed

packages/next/server.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url'
1414
export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response'
1515
export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types'
1616
export { unstable_after } from 'next/dist/server/after'
17+
export { connection } from 'next/dist/server/request/connection'
1718
export type { UnsafeUnwrappedSearchParams } from 'next/dist/server/request/search-params'
1819
export type { UnsafeUnwrappedParams } from 'next/dist/server/request/params'

packages/next/server.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const serverExports = {
1212
URLPattern: require('next/dist/server/web/spec-extension/url-pattern')
1313
.URLPattern,
1414
unstable_after: require('next/dist/server/after').unstable_after,
15+
connection: require('next/dist/server/request/connection').connection,
1516
}
1617

1718
// https://nodejs.org/api/esm.html#commonjs-namespaces
@@ -26,3 +27,4 @@ exports.userAgentFromString = serverExports.userAgentFromString
2627
exports.userAgent = serverExports.userAgent
2728
exports.URLPattern = serverExports.URLPattern
2829
exports.unstable_after = serverExports.unstable_after
30+
exports.connection = serverExports.connection
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external'
2+
import {
3+
isDynamicIOPrerender,
4+
prerenderAsyncStorage,
5+
} from '../app-render/prerender-async-storage.external'
6+
import {
7+
postponeWithTracking,
8+
throwToInterruptStaticGeneration,
9+
trackDynamicDataInDynamicRender,
10+
} from '../app-render/dynamic-rendering'
11+
import { StaticGenBailoutError } from '../../client/components/static-generation-bailout'
12+
import { makeHangingPromise } from '../dynamic-rendering-utils'
13+
14+
/**
15+
* This function allows you to indicate that you require an actual user Request before continuing.
16+
*
17+
* During prerendering it will never resolve and during rendering it resolves immediately.
18+
*/
19+
export function connection(): Promise<void> {
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+
// headers object without tracking
27+
return Promise.resolve(undefined)
28+
}
29+
30+
if (staticGenerationStore.isUnstableCacheCallback) {
31+
throw new Error(
32+
`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`
33+
)
34+
} else if (staticGenerationStore.dynamicShouldError) {
35+
throw new StaticGenBailoutError(
36+
`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`
37+
)
38+
}
39+
40+
if (prerenderStore) {
41+
// We are in PPR and/or dynamicIO mode and prerendering
42+
43+
if (isDynamicIOPrerender(prerenderStore)) {
44+
// We use the controller and cacheSignal as an indication we are in dynamicIO mode.
45+
// When resolving headers for a prerender with dynamic IO we return a forever promise
46+
// along with property access tracked synchronous headers.
47+
48+
// We don't track dynamic access here because access will be tracked when you access
49+
// one of the properties of the headers object.
50+
return makeHangingPromise()
51+
} else {
52+
// We are prerendering with PPR. We need track dynamic access here eagerly
53+
// to keep continuity with how headers has worked in PPR without dynamicIO.
54+
// TODO consider switching the semantic to throw on property access intead
55+
postponeWithTracking(
56+
staticGenerationStore.route,
57+
'connection',
58+
prerenderStore.dynamicTracking
59+
)
60+
}
61+
} else if (staticGenerationStore.isStaticGeneration) {
62+
// We are in a legacy static generation mode while prerendering
63+
// We treat this function call as a bailout of static generation
64+
throwToInterruptStaticGeneration('connection', staticGenerationStore)
65+
}
66+
// We fall through to the dynamic context below but we still track dynamic access
67+
// because in dev we can still error for things like using headers inside a cache context
68+
trackDynamicDataInDynamicRender(staticGenerationStore)
69+
}
70+
71+
return Promise.resolve(undefined)
72+
}

packages/next/src/server/web/exports/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { NextResponse } from '../spec-extension/response'
66
export { userAgent, userAgentFromString } from '../spec-extension/user-agent'
77
export { URLPattern } from '../spec-extension/url-pattern'
88
export { unstable_after } from '../../after'
9+
export { connection } from '../../request/connection'

test/e2e/app-dir/dynamic-data/dynamic-data.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,16 @@ describe('dynamic-data with dynamic = "error"', () => {
199199
await browser.close()
200200
}
201201

202+
browser = await next.browser('/connection')
203+
try {
204+
await assertHasRedbox(browser)
205+
expect(await getRedboxHeader(browser)).toMatch(
206+
'Error: Route /connection with `dynamic = "error"` couldn\'t be rendered statically because it used `connection`'
207+
)
208+
} finally {
209+
await browser.close()
210+
}
211+
202212
browser = await next.browser('/headers?foo=foosearch')
203213
try {
204214
await assertHasRedbox(browser)
@@ -230,6 +240,9 @@ describe('dynamic-data with dynamic = "error"', () => {
230240
expect(next.cliOutput).toMatch(
231241
'Error: Route /cookies with `dynamic = "error"` couldn\'t be rendered statically because it used `cookies`'
232242
)
243+
expect(next.cliOutput).toMatch(
244+
'Error: Route /connection with `dynamic = "error"` couldn\'t be rendered statically because it used `connection`'
245+
)
233246
expect(next.cliOutput).toMatch(
234247
'Error: Route /headers with `dynamic = "error"` couldn\'t be rendered statically because it used `headers`'
235248
)
@@ -277,6 +290,16 @@ describe('dynamic-data inside cache scope', () => {
277290
await browser.close()
278291
}
279292

293+
browser = await next.browser('/connection')
294+
try {
295+
await assertHasRedbox(browser)
296+
expect(await getRedboxHeader(browser)).toMatch(
297+
'Error: Route /connection used "connection" inside a function cached with "unstable_cache(...)".'
298+
)
299+
} finally {
300+
await browser.close()
301+
}
302+
280303
browser = await next.browser('/headers')
281304
try {
282305
await assertHasRedbox(browser)
@@ -297,6 +320,9 @@ describe('dynamic-data inside cache scope', () => {
297320
expect(next.cliOutput).toMatch(
298321
'Error: Route /cookies used "cookies" inside a function cached with "unstable_cache(...)".'
299322
)
323+
expect(next.cliOutput).toMatch(
324+
'Error: Route /connection used "connection" inside a function cached with "unstable_cache(...)".'
325+
)
300326
expect(next.cliOutput).toMatch(
301327
'Error: Route /headers used "headers" inside a function cached with "unstable_cache(...)".'
302328
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { connection } from 'next/server'
2+
import { unstable_cache as cache } from 'next/cache'
3+
4+
const cachedConnection = cache(async () => connection())
5+
6+
export default async function Page({ searchParams }) {
7+
await cachedConnection()
8+
return (
9+
<div>
10+
<section>
11+
This example uses `connection()` inside `unstable_cache` which should
12+
cause the build to fail
13+
</section>
14+
</div>
15+
)
16+
}

test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { headers, cookies } from 'next/headers'
2+
import { connection } from 'next/server'
23

34
import { PageSentinel } from '../getSentinelValue'
45

56
export const dynamic = 'force-dynamic'
67

78
export default async function Page({ searchParams }) {
9+
await connection()
810
return (
911
<div>
1012
<PageSentinel />
1113
<section>
12-
This example uses headers/cookies/searchParams directly in a Page
13-
configured with `dynamic = 'force-dynamic'`. This should cause the page
14-
to always render dynamically regardless of dynamic APIs used
14+
This example uses headers/cookies/connection/searchParams directly in a
15+
Page configured with `dynamic = 'force-dynamic'`. This should cause the
16+
page to always render dynamically regardless of dynamic APIs used
1517
</section>
1618
<section id="headers">
1719
<h3>headers</h3>

test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { headers, cookies } from 'next/headers'
2+
import { connection } from 'next/server'
23

34
import { PageSentinel } from '../getSentinelValue'
45

56
export const dynamic = 'force-static'
67

78
export default async function Page({ searchParams }) {
9+
await connection()
810
return (
911
<div>
1012
<PageSentinel />
1113
<section>
12-
This example uses headers/cookies/searchParams directly in a Page
13-
configured with `dynamic = 'force-static'`. This should cause the page
14-
to always statically render but without exposing dynamic data
14+
This example uses headers/cookies/connection/searchParams directly in a
15+
Page configured with `dynamic = 'force-static'`. This should cause the
16+
page to always statically render but without exposing dynamic data
1517
</section>
1618
<section id="headers">
1719
<h3>headers</h3>

test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { headers, cookies } from 'next/headers'
2+
import { connection } from 'next/server'
23

34
import { PageSentinel } from '../getSentinelValue'
45

56
export default async function Page({ searchParams }) {
7+
await connection()
68
return (
79
<div>
810
<PageSentinel />
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Server, { connection } from 'next/server'
2+
3+
console.log('Server', Server)
4+
5+
export const dynamic = 'error'
6+
7+
export default async function Page({ searchParams }) {
8+
await connection()
9+
return (
10+
<div>
11+
<section>
12+
This example uses `connection()` but is configured with `dynamic =
13+
'error'` which should cause the page to fail to build
14+
</section>
15+
</div>
16+
)
17+
}

0 commit comments

Comments
 (0)