From 86abd1c9695e53dbc784e9155fc580048cbd3756 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 30 Sep 2024 08:12:45 -0700 Subject: [PATCH 1/2] Add `connection()` as a new dynamic API (#69949) In https://github.com/vercel/next.js/pull/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`. It is tempting to call the API `request()` and return a `Promise` 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 {rand} } ``` --- packages/next/server.d.ts | 1 + packages/next/server.js | 2 + .../next/src/server/request/connection.ts | 72 +++++++++++++++++ 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, 337 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 e3ba1fa950e07..2b94d798ff84e 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 589a789dfe66b..ff224a2bb5a93 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 0000000000000..8ea2af93ac9c4 --- /dev/null +++ b/packages/next/src/server/request/connection.ts @@ -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 { + 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) +} diff --git a/packages/next/src/server/web/exports/index.ts b/packages/next/src/server/web/exports/index.ts index 942bc5a9caf9a..fc5fc2bbb4883 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 9b627b2480234..8ba6ec4a9a7f2 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 0000000000000..d15afc39b167a --- /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 6cf7cbf960760..cfee26df62328 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/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

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 1137df1923ddd..6974704ef9b89 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 73e446eb15aa4..a99598bd4dfa5 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 0000000000000..c45ca779f1200 --- /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 0000000000000..187ac9a0cb7b1 --- /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 0000000000000..84a13e6876f6c --- /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 0000000000000..f5ad949a2e3d3 --- /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 0000000000000..b859bf8eedf41 --- /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').length).toBe(0) + 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') + } + }) + } +}) From 7fddec2d169634df4b2b73209b9440ce4af0cb74 Mon Sep 17 00:00:00 2001 From: jaredhan Date: Mon, 30 Sep 2024 23:29:43 +0800 Subject: [PATCH 2/2] Fix: Set busboy defParamCharset to utf8 (#70348) ### Fixing a bug Fix https://github.com/vercel/next.js/issues/70335 ### What? see https://github.com/vercel/next.js/issues/70335 ### Why? ### How? As for fetch standard, I think it should make filename decode with utf-8 by default https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-form-data-set Co-authored-by: JJ Kasper --- packages/next/src/server/app-render/action-handler.ts | 1 + test/e2e/app-dir/actions/app-action.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index b2e4f86df9c6e..68423cea8b924 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -725,6 +725,7 @@ export async function handleAction({ if (isMultipartAction) { if (isFetchAction) { const busboy = (require('busboy') as typeof import('busboy'))({ + defParamCharset: 'utf8', headers: req.headers, limits: { fieldSize: bodySizeLimitBytes }, }) diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index 58d1c2dcf363b..016dc365bc597 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -336,7 +336,7 @@ describe('app-dir action handling', () => { // Fake a file to upload await browser.eval(` - const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }); + const file = new File(['hello'], 'hello你好テスト.txt', { type: 'text/plain' }); const list = new DataTransfer(); list.items.add(file); document.getElementById('file').files = list.files; @@ -347,7 +347,9 @@ describe('app-dir action handling', () => { // we don't have access to runtime logs on deploy if (!isNextDeploy) { await check(() => { - return logs.some((log) => log.includes('File name: hello.txt size: 5')) + return logs.some((log) => + log.includes('File name: hello你好テスト.txt size: 5') + ) ? 'yes' : '' }, 'yes')