From eaa80b5cd30e49d7926612abcd25e76b1a8e43af Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 10 Sep 2024 19:12:36 -0700 Subject: [PATCH] Implements new `connection` api --- packages/next/server.d.ts | 1 + packages/next/server.js | 2 + .../next/src/server/request/connection.ts | 70 ++++++++++++++++ packages/next/src/server/web/exports/index.ts | 1 + .../app-dir/dynamic-data/dynamic-data.test.ts | 26 ++++++ .../cache-scoped/app/connection/page.js | 16 ++++ .../fixtures/main/app/force-dynamic/page.js | 8 +- .../fixtures/main/app/force-static/page.js | 8 +- .../fixtures/main/app/top-level/page.js | 2 + .../require-static/app/connection/page.js | 17 ++++ .../static-behavior/boundary/page.tsx | 34 ++++++++ .../static-behavior/pass-deeply/page.tsx | 46 +++++++++++ .../connection/static-behavior/root/page.tsx | 31 ++++++++ .../dynamic-io/dynamic-io.connection.test.ts | 79 +++++++++++++++++++ 14 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 packages/next/src/server/request/connection.ts create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/connection/page.js create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/require-static/app/connection/page.js create mode 100644 test/e2e/app-dir/dynamic-io/app/connection/static-behavior/boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/connection/static-behavior/pass-deeply/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/connection/static-behavior/root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/dynamic-io.connection.test.ts diff --git a/packages/next/server.d.ts b/packages/next/server.d.ts index e3ba1fa950e076..2b94d798ff84ef 100644 --- a/packages/next/server.d.ts +++ b/packages/next/server.d.ts @@ -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' diff --git a/packages/next/server.js b/packages/next/server.js index 589a789dfe66b1..ff224a2bb5a93e 100644 --- a/packages/next/server.js +++ b/packages/next/server.js @@ -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 @@ -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 diff --git a/packages/next/src/server/request/connection.ts b/packages/next/src/server/request/connection.ts new file mode 100644 index 00000000000000..355a0680d26765 --- /dev/null +++ b/packages/next/src/server/request/connection.ts @@ -0,0 +1,70 @@ +import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' +import { prerenderAsyncStorage } from '../app-render/prerender-async-storage.external' +import { + postponeWithTracking, + interruptStaticGeneration, + trackDynamicDataInDynamicRender, +} from '../app-render/dynamic-rendering' +import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' + +/** + * 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 { + 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 wait 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 (prerenderStore.controller || prerenderStore.cacheSignal) { + // 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 new Promise(hangForever) + } 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 + interruptStaticGeneration('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) +} + +function hangForever() {} diff --git a/packages/next/src/server/web/exports/index.ts b/packages/next/src/server/web/exports/index.ts index 942bc5a9caf9ac..fc5fc2bbb48831 100644 --- a/packages/next/src/server/web/exports/index.ts +++ b/packages/next/src/server/web/exports/index.ts @@ -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' diff --git a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts index d637aab1eb6a49..04c4762df0082f 100644 --- a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts +++ b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts @@ -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) @@ -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`' ) @@ -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) @@ -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(...)".' ) diff --git a/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/connection/page.js b/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/connection/page.js new file mode 100644 index 00000000000000..d15afc39b167ae --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/cache-scoped/app/connection/page.js @@ -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 ( +
+
+ This example uses `connection()` inside `unstable_cache` which should + cause the build to fail +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js index 6cf7cbf9607607..2bb44a5ed0fd03 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js @@ -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 (
- 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/conneciton/searchParams directly in a + Page configured with `dynamic = 'force-dynamic'`. This should cause the + page to always render dynamically regardless of dynamic APIs used

headers

diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js index 1137df1923dddf..6974704ef9b895 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js @@ -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 (
- 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

headers

diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js index 73e446eb15aa48..a99598bd4dfa50 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js @@ -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 (
diff --git a/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/connection/page.js b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/connection/page.js new file mode 100644 index 00000000000000..c45ca779f1200b --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/connection/page.js @@ -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 ( +
+
+ This example uses `connection()` but is configured with `dynamic = + 'error'` which should cause the page to fail to build +
+
+ ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/connection/static-behavior/boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/connection/static-behavior/boundary/page.tsx new file mode 100644 index 00000000000000..187ac9a0cb7b17 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/connection/static-behavior/boundary/page.tsx @@ -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 ( + <> + + + + +
{getSentinelValue()}
+ + ) +} + +async function Component() { + await connection() + return ( +
+ cookie foo +
+ ) +} + +function ComponentTwo() { + return

footer

+} diff --git a/test/e2e/app-dir/dynamic-io/app/connection/static-behavior/pass-deeply/page.tsx b/test/e2e/app-dir/dynamic-io/app/connection/static-behavior/pass-deeply/page.tsx new file mode 100644 index 00000000000000..84a13e6876f6cd --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/connection/static-behavior/pass-deeply/page.tsx @@ -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 ( +
+

Deep Connection Reader

+

+ This component was passed the connection promise returned by + `connection()`. It is rendered inside a Suspense boundary. +

+

+ 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 +

+ +

loading connection...

+
{getSentinelValue()}
+ + } + > + +
+
+ ) +} + +async function DeepConnectionReader({ + pendingConnection, +}: { + pendingConnection: ReturnType +}) { + await pendingConnection + return ( + <> +

The connection was awaited

+
{getSentinelValue()}
+ + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/connection/static-behavior/root/page.tsx b/test/e2e/app-dir/dynamic-io/app/connection/static-behavior/root/page.tsx new file mode 100644 index 00000000000000..f5ad949a2e3d3c --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/connection/static-behavior/root/page.tsx @@ -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 ( + <> + + +
{getSentinelValue()}
+ + ) +} + +async function Component() { + await connection() + return ( +
+ cookie foo +
+ ) +} + +function ComponentTwo() { + return

footer

+} diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.connection.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.connection.test.ts new file mode 100644 index 00000000000000..dba74e2d005706 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.connection.test.ts @@ -0,0 +1,79 @@ +import { nextTestSetup } from 'e2e-utils' + +const WITH_PPR = !!process.env.__NEXT_EXPERIMENTAL_PPR + +describe('dynamic-io', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + if (WITH_PPR) { + it('should partially prerender pages that use connection', async () => { + let $ = await next.render$('/connection/static-behavior/boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#foo').text()).toBe('foo') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#foo').text()).toBe('foo') + } + + $ = await next.render$('/connection/static-behavior/root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#foo').text()).toBe('foo') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#foo').text()).toBe('foo') + } + }) + } else { + it('should produce dynamic pages when using connection', async () => { + let $ = await next.render$('/connection/static-behavior/boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#foo').text()).toBe('foo') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#foo').text()).toBe('foo') + } + + $ = await next.render$('/connection/static-behavior/root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#foo').text()).toBe('foo') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#foo').text()).toBe('foo') + } + }) + } + + if (WITH_PPR) { + it('should be able to pass connection as a promise to another component and trigger an intermediate Suspense boundary', async () => { + const $ = await next.render$('/connection/static-behavior/pass-deeply') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#fallback').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#fallback').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at runtime') + } + }) + } +})