diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 14b90f4544b83..1f77c5faa1777 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -1073,11 +1073,6 @@ impl NextConfig { Vc::cell(self.experimental.taint.unwrap_or(false)) } - #[turbo_tasks::function] - pub fn enable_dynamic_io(&self) -> Vc { - Vc::cell(self.experimental.dynamic_io.unwrap_or(false)) - } - #[turbo_tasks::function] pub fn use_swc_css(&self) -> Vc { Vc::cell( diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index d7c4739868396..20204cd84852e 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -117,14 +117,12 @@ pub async fn get_next_client_import_map( match ty.into_value() { ClientContextType::Pages { .. } => {} ClientContextType::App { app_dir } => { - let react_flavor = if *next_config.enable_ppr().await? - || *next_config.enable_taint().await? - || *next_config.enable_dynamic_io().await? - { - "-experimental" - } else { - "" - }; + let react_flavor = + if *next_config.enable_ppr().await? || *next_config.enable_taint().await? { + "-experimental" + } else { + "" + }; import_map.insert_exact_alias( "react", @@ -686,12 +684,7 @@ async fn rsc_aliases( ) -> Result<()> { let ppr = *next_config.enable_ppr().await?; let taint = *next_config.enable_taint().await?; - let dynamic_io = *next_config.enable_dynamic_io().await?; - let react_channel = if ppr || taint || dynamic_io { - "-experimental" - } else { - "" - }; + let react_channel = if ppr || taint { "-experimental" } else { "" }; let react_client_package = get_react_client_package(&next_config).await?; let mut alias = IndexMap::new(); 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/client/components/router-reducer/aliased-prefetch-navigations.ts b/packages/next/src/client/components/router-reducer/aliased-prefetch-navigations.ts index 71e44eaf53d2f..10e9dd3f8cc74 100644 --- a/packages/next/src/client/components/router-reducer/aliased-prefetch-navigations.ts +++ b/packages/next/src/client/components/router-reducer/aliased-prefetch-navigations.ts @@ -223,7 +223,6 @@ export function addSearchParamsToPageSegments( // If it's a page segment, modify the segment by adding search params if (segment.includes(PAGE_SEGMENT_KEY)) { const newSegment = addSearchParamsIfPageSegment(segment, searchParams) - console.log({ existingSegment: segment, newSegment }) return [newSegment, parallelRoutes, ...rest] } diff --git a/packages/next/src/client/components/router-reducer/ppr-navigations.ts b/packages/next/src/client/components/router-reducer/ppr-navigations.ts index e07e8097c3602..19572b8a2a024 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/router-reducer/ppr-navigations.ts @@ -9,10 +9,7 @@ import type { ChildSegmentMap, ReadyCacheNode, } from '../../../shared/lib/app-router-context.shared-runtime' -import { - DEFAULT_SEGMENT_KEY, - PAGE_SEGMENT_KEY, -} from '../../../shared/lib/segment' +import { DEFAULT_SEGMENT_KEY } from '../../../shared/lib/segment' import { matchSegment } from '../match-segments' import { createRouterCacheKey } from './create-router-cache-key' import type { FetchServerResponseResult } from './fetch-server-response' @@ -124,27 +121,13 @@ export function updateCacheNodeOnNavigation( const oldSegmentChild = oldRouterStateChild !== undefined ? oldRouterStateChild[0] : undefined - // A dynamic segment will be an array, and doesn't correspond with a page segment. - const isPageSegment = Array.isArray(newSegmentChild) - ? false - : // A page segment might contain search parameters, so we verify that it starts with the page segment key. - newSegmentChild.startsWith(PAGE_SEGMENT_KEY) - const oldCacheNodeChild = oldSegmentMapChild !== undefined ? oldSegmentMapChild.get(newSegmentKeyChild) : undefined let taskChild: Task | null - if (isPageSegment) { - // This is a leaf segment — a page, not a shared layout. We always apply - // its data. - taskChild = spawnPendingTask( - newRouterStateChild, - prefetchDataChild !== undefined ? prefetchDataChild : null, - prefetchHead - ) - } else if (newSegmentChild === DEFAULT_SEGMENT_KEY) { + if (newSegmentChild === DEFAULT_SEGMENT_KEY) { // This is another kind of leaf segment — a default route. // // Default routes have special behavior. When there's no matching segment diff --git a/packages/next/src/lib/needs-experimental-react.ts b/packages/next/src/lib/needs-experimental-react.ts index b8d2b05cdfc0e..373d4d61b146e 100644 --- a/packages/next/src/lib/needs-experimental-react.ts +++ b/packages/next/src/lib/needs-experimental-react.ts @@ -1,9 +1,5 @@ import type { NextConfig } from '../server/config-shared' export function needsExperimentalReact(config: NextConfig) { - return Boolean( - config.experimental?.ppr || - config.experimental?.taint || - config.experimental?.dynamicIO - ) + return Boolean(config.experimental?.ppr || config.experimental?.taint) } 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/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index b94812fe6a244..a585628f72dd1 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -2126,21 +2126,12 @@ async function prerenderToStream( const PRERENDER_COMPLETE = 'NEXT_PRERENDER_COMPLETE' const abortReason = new Error(PRERENDER_COMPLETE) + ;(abortReason as any).digest = PRERENDER_COMPLETE // We need to scope the dynamic IO state per render because we don't want to leak // details between the prospective render and the final render let flightController = new AbortController() - let reactServerIsDynamic = false - function onError(err: unknown) { - if (err === abortReason || isPrerenderInterruptedError(err)) { - reactServerIsDynamic = true - return - } - - return serverComponentsErrorHandler(err) - } - dynamicTracking = createDynamicTrackingState( renderOpts.isDebugDynamicAccesses ) @@ -2162,57 +2153,37 @@ async function prerenderToStream( ctx, res.statusCode === 404 ) - // We're not going to use the result of this render because the only time it could be used - // is if it completes in a microtask and that's likely very rare for any non-trivial app - ;( - prerenderAsyncStorage.run( - // The store to scope - prospectiveRenderPrerenderStore, - // The function to run - ComponentMod.prerender, - // ... the arguments for the function to run - firstAttemptRSCPayload, - clientReferenceManifest.clientModules, - { - onError, - signal: flightController.signal, - } - ) as Promise - ).catch(() => {}) + + const prospectiveStream = prerenderAsyncStorage.run( + // The store to scope + prospectiveRenderPrerenderStore, + // The function to run + ComponentMod.renderToReadableStream, + // ... the arguments for the function to run + firstAttemptRSCPayload, + clientReferenceManifest.clientModules, + { + onError: () => {}, + signal: flightController.signal, + } + ) as ReadableStream // When this resolves the cache has no inflight reads and we can ascertain the dynamic outcome await cacheSignal.cacheReady() - if (reactServerIsDynamic) { - // During a prospective render the only dynamic thing that can happen is a synchronous dynamic - // API access. We expect to have a tracked expression to use for our dynamic error but we fall back - // to a generic error if we don't. - const dynamicReason = getFirstDynamicReason(dynamicTracking) - if (dynamicReason) { - throw new DynamicServerError( - `Route ${staticGenerationStore.route} couldn't be rendered statically because it used \`${dynamicReason}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error` - ) - } else { - console.error( - 'Expected Next.js to keep track of reason for opting out of static rendering but one was not found. This is a bug in Next.js' - ) - throw new DynamicServerError( - `Route ${staticGenerationStore.route} couldn't be rendered statically because it used a dynamic API. See more info here: https://nextjs.org/docs/messages/dynamic-server-error` - ) - } - } else { - // The render didn't explicitly use any Dynamic APIs but it might have IO so we need to retry - // the render. We abort the current render here to avoid doing unecessary work. - // Keep in mind that while the render is aborted, inflight async ServerComponents can still continue - // and might call dynamic APIs. - flightController.abort(abortReason) - } + // Even though we could detect whether a sync dynamic API was used we still need to render SSR to + // do error validation so we just abort and re-render. + flightController.abort(abortReason) + + await warmFlightResponse(prospectiveStream, clientReferenceManifest) // Reset the prerenderState because we are going to retry the render flightController = new AbortController() dynamicTracking = createDynamicTrackingState( renderOpts.isDebugDynamicAccesses ) - reactServerIsDynamic = false + let reactServerIsDynamic = false + let reactServerIsSynchronouslyDynamic = false + let SSRIsDynamic = false const finalRenderPrerenderStore: PrerenderStore = { // During the final prerender we don't need to track cache access so we omit the signal @@ -2221,6 +2192,18 @@ async function prerenderToStream( dynamicTracking, } + const SSRController = new AbortController() + const ssrPrerenderStore: PrerenderStore = { + // For HTML Generation we don't need to track cache reads (RSC only) + cacheSignal: null, + // We expect the SSR render to complete in a single Task and need to be able to synchronously abort + // When you use APIs that are considered dynamic or synchronous IO. + controller: SSRController, + // We do track dynamic access because searchParams and certain hooks can still be + // dynamic during SSR + dynamicTracking, + } + const finalAttemptRSCPayload = await prerenderAsyncStorage.run( finalRenderPrerenderStore, getRSCPayload, @@ -2229,15 +2212,41 @@ async function prerenderToStream( res.statusCode === 404 ) - const reactServerResult = (reactServerPrerenderResult = - await createReactServerPrerenderResult( - prerenderAndAbortInSequentialTasks( - () => + function onError(err: unknown) { + if (err === abortReason) { + reactServerIsDynamic = true + return PRERENDER_COMPLETE + } else if (isPrerenderInterruptedError(err)) { + reactServerIsSynchronouslyDynamic = true + return err.digest + } + + return serverComponentsErrorHandler(err) + } + + function SSROnError(err: unknown, errorInfo?: ErrorInfo) { + if (err === abortReason) { + SSRIsDynamic = true + return PRERENDER_COMPLETE + } else if (isPrerenderInterruptedError(err)) { + SSRIsDynamic = true + return err.digest + } + + return htmlRendererErrorHandler(err, errorInfo) + } + + let reactServerStream: ReadableStream + let htmlStream + try { + htmlStream = await prerenderAndAbortInSequentialTasks( + () => { + const teedStream = ( prerenderAsyncStorage.run( // The store to scope finalRenderPrerenderStore, // The function to run - ComponentMod.prerender, + ComponentMod.renderToReadableStream, // ... the arguments for the function to run finalAttemptRSCPayload, clientReferenceManifest.clientModules, @@ -2245,66 +2254,19 @@ async function prerenderToStream( onError, signal: flightController.signal, } - ), - () => { - flightController.abort(abortReason) - } - ) - )) - - if (reactServerIsDynamic) { - // There was unfinished work after we aborted after the first render Task. This means there is some IO - // that is not covered by a cache and we need to bail out of static generation. - const err = new DynamicServerError( - `Route ${staticGenerationStore.route} couldn't be rendered statically because it used IO that was not cached in a Server Component. See more info here: https://nextjs.org/docs/messages/dynamic-io` - ) - serverComponentsErrorHandler(err) - throw err - } - - await warmFlightResponse( - reactServerResult.asStream(), - clientReferenceManifest - ) - - const SSRController = new AbortController() - const ssrPrerenderStore: PrerenderStore = { - // For HTML Generation we don't need to track cache reads (RSC only) - cacheSignal: null, - // We expect the SSR render to complete in a single Task and need to be able to synchronously abort - // When you use APIs that are considered dynamic or synchronous IO. - controller: SSRController, - // We do track dynamic access because searchParams and certain hooks can still be - // dynamic during SSR - dynamicTracking, - } - let SSRIsDynamic = false - function SSROnError(err: unknown) { - if (err === abortReason || isPrerenderInterruptedError(err)) { - SSRIsDynamic = true - return - } + ) as ReadableStream + ).tee() - return htmlRendererErrorHandler(err) - } - function SSROnPostpone(_: string) { - // We don't really support postponing when PPR is off but since experimental react - // has this API enabled we need to account for it. For now we'll just treat any postpone - // as dynamic. - SSRIsDynamic = true - return - } + reactServerStream = teedStream[0] + const rscForSSRStream = teedStream[1] - const prerender = require('react-dom/static.edge') - .prerender as (typeof import('react-dom/static.edge'))['prerender'] - const { prelude: htmlStream } = - await prerenderAndAbortInSequentialTasks( - () => - prerenderAsyncStorage.run( + const renderToReadableStream = require('react-dom/server.edge') + .renderToReadableStream as (typeof import('react-dom/server.edge'))['renderToReadableStream'] + const pendingHTMLStream = prerenderAsyncStorage.run( ssrPrerenderStore, - prerender, + renderToReadableStream, {}) + return pendingHTMLStream + }, () => { SSRController.abort(abortReason) + flightController.abort(abortReason) } ) + } catch (err) { + if (err === abortReason || isPrerenderInterruptedError(err)) { + // We aborted with an incomplete shell. We'll handle this below with the handling + // for dynamic. + } else { + // We have some other kind of shell error, we want to bubble this up to be handled + throw err + } + } if (SSRIsDynamic) { // Something dynamic happened in the SSR phase of the render. This could be IO or it could be @@ -2339,8 +2313,33 @@ async function prerenderToStream( `Route ${staticGenerationStore.route} couldn't be rendered statically because it used IO that was not cached in a Client Component. See more info here: https://nextjs.org/docs/messages/dynamic-io` ) } + } else if (reactServerIsSynchronouslyDynamic) { + const dynamicReason = getFirstDynamicReason(dynamicTracking) + if (dynamicReason) { + throw new DynamicServerError( + `Route ${staticGenerationStore.route} couldn't be rendered statically because it used \`${dynamicReason}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error` + ) + } else { + console.error( + 'Expected Next.js to keep track of reason for opting out of static rendering but one was not found. This is a bug in Next.js' + ) + throw new DynamicServerError( + `Route ${staticGenerationStore.route} couldn't be rendered statically because it used a dynamic API. See more info here: https://nextjs.org/docs/messages/dynamic-server-error` + ) + } + } else if (reactServerIsDynamic) { + // There was unfinished work after we aborted after the first render Task. This means there is some IO + // that is not covered by a cache and we need to bail out of static generation. + const err = new DynamicServerError( + `Route ${staticGenerationStore.route} couldn't be rendered statically because it used IO that was not cached in a Server Component. See more info here: https://nextjs.org/docs/messages/dynamic-io` + ) + serverComponentsErrorHandler(err) + throw err } + const reactServerResult = + await createReactServerPrerenderResultFromRender(reactServerStream!) + metadata.flightData = await streamToBuffer(reactServerResult.asStream()) const getServerInsertedHTML = makeGetServerInsertedHTML({ @@ -2354,7 +2353,7 @@ async function prerenderToStream( return { digestErrorsMap: reactServerErrorsByDigest, ssrErrors: allCapturedErrors, - stream: await continueFizzStream(htmlStream, { + stream: await continueFizzStream(htmlStream!, { inlinedDataStream: createInlinedDataReadableStream( reactServerResult.consumeAsStream(), ctx.nonce, @@ -2794,7 +2793,7 @@ function trackChunkLoading(load: Promise) { } export async function warmFlightResponse( - flightStream: BinaryStreamOf, + flightStream: ReadableStream, clientReferenceManifest: DeepReadonly ) { let createFromReadableStream diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index c0ac2cebd6ee3..efaba5105472b 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -405,7 +405,13 @@ function createPrerenderInterruptedError(message: string): Error { return error } -export function isPrerenderInterruptedError(error: unknown) { +type DigestError = Error & { + digest: string +} + +export function isPrerenderInterruptedError( + error: unknown +): error is DigestError { return ( typeof error === 'object' && error !== null && 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/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') 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') + } + }) + } +}) diff --git a/test/e2e/favicon-short-circuit/app/layout.js b/test/e2e/favicon-short-circuit/app/layout.js new file mode 100644 index 0000000000000..803f17d863c8a --- /dev/null +++ b/test/e2e/favicon-short-circuit/app/layout.js @@ -0,0 +1,7 @@ +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/integration/styled-jsx-plugin/test/index.test.js b/test/integration/styled-jsx-plugin/test/index.test.js index 4dc864ea1f670..421947697118e 100644 --- a/test/integration/styled-jsx-plugin/test/index.test.js +++ b/test/integration/styled-jsx-plugin/test/index.test.js @@ -20,25 +20,29 @@ function runTests() { }) } -describe('styled-jsx using in node_modules', () => { - ;(process.env.TURBOPACK_DEV ? describe.skip : describe)( - 'production mode', - () => { - beforeAll(async () => { - const output = await nextBuild(appDir, undefined, { - stdout: true, - stderr: true, - cwd: appDir, - }) +// This test is skipped in Turbopack because it uses a custom babelrc. +;(process.env.TURBOPACK ? describe.skip : describe)( + 'styled-jsx using in node_modules', + () => { + ;(process.env.TURBOPACK_DEV ? describe.skip : describe)( + 'production mode', + () => { + beforeAll(async () => { + const output = await nextBuild(appDir, undefined, { + stdout: true, + stderr: true, + cwd: appDir, + }) - console.log(output.stdout, output.stderr) + console.log(output.stdout, output.stderr) - appPort = await findPort() - app = await nextStart(appDir, appPort) - }) - afterAll(() => killApp(app)) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(() => killApp(app)) - runTests() - } - ) -}) + runTests() + } + ) + } +) diff --git a/test/turbopack-build-tests-manifest.json b/test/turbopack-build-tests-manifest.json index 1b766dfa481b4..e75b1b19d5539 100644 --- a/test/turbopack-build-tests-manifest.json +++ b/test/turbopack-build-tests-manifest.json @@ -4732,10 +4732,10 @@ "runtimeError": false }, "test/e2e/favicon-short-circuit/favicon-short-circuit.test.ts": { - "passed": [], - "failed": [ + "passed": [ "favicon-short-circuit should not short circuit the favicon in production" ], + "failed": [], "pending": [], "flakey": [], "runtimeError": false @@ -14958,10 +14958,10 @@ }, "test/integration/styled-jsx-plugin/test/index.test.js": { "passed": [], - "failed": [ + "failed": [], + "pending": [ "styled-jsx using in node_modules production mode should serve a page correctly" ], - "pending": [], "flakey": [], "runtimeError": false }, @@ -15856,6 +15856,26 @@ "flakey": [], "runtimeError": false }, + "test/production/app-dir/server-action-period-hash/server-action-period-hash.test.ts": { + "passed": [], + "failed": [ + "app-dir - server-action-period-hash should have same manifest between continuous two builds", + "app-dir - server-action-period-hash should have different manifest between two builds with period hash" + ], + "pending": [], + "flakey": [], + "runtimeError": false + }, + "test/production/app-dir/server-action-period-hash/server-action-period-hash-custom-key.test.ts": { + "passed": [], + "failed": [ + "app-dir - server-action-period-hash-custom-key should have different manifest if the encryption key from process env is changed", + "app-dir - server-action-period-hash-custom-key should have different manifest if the encryption key from process env is same" + ], + "pending": [], + "flakey": [], + "runtimeError": false + }, "test/production/app-dir/ssg-single-pass/ssg-single-pass.test.ts": { "passed": [ "ssg-single-pass should only render the page once during an ISR revalidation",