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 b6c2da8b9488aa..085f8132e7524c 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/router-reducer/ppr-navigations.ts @@ -797,9 +797,6 @@ function finishPendingCacheNode( // a pending promise that needs to be resolved with the dynamic head from // the server. const head = cacheNode.head - // TODO: change head back to ReactNode when metadata - // is stably rendered in body - // Handle head[0] - viewport if (isDeferredRsc(head)) { head.resolve(dynamicHead) } diff --git a/packages/next/src/export/types.ts b/packages/next/src/export/types.ts index abb640299e8b13..d2602bc6e7632f 100644 --- a/packages/next/src/export/types.ts +++ b/packages/next/src/export/types.ts @@ -62,6 +62,7 @@ export interface ExportPageInput { nextConfigOutput?: NextConfigComplete['output'] enableExperimentalReact?: boolean sriEnabled: boolean + streamingMetadata: boolean | undefined } export type ExportRouteResult = diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 1b813f683f2cfb..2499a772d11cec 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -253,6 +253,13 @@ async function exportPageImpl( ) } + // During the export phase in next build, if it's using PPR we can serve streaming metadata + // when it's available. When we're building the PPR rendering result, we don't need to rely + // on the user agent. The result can be determined to serve streaming on infrastructure level. + const serveStreamingMetadata = Boolean( + isRoutePPREnabled && input.streamingMetadata + ) + const renderOpts: WorkerRenderOpts = { ...components, ...input.renderOpts, @@ -262,6 +269,7 @@ async function exportPageImpl( disableOptimizedLoading, locale, supportsDynamicResponse: false, + serveStreamingMetadata, experimental: { ...input.renderOpts.experimental, isRoutePPREnabled, @@ -416,6 +424,7 @@ export async function exportPages( debugOutput: options.debugOutput, enableExperimentalReact: needsExperimentalReact(nextConfig), sriEnabled: Boolean(nextConfig.experimental.sri?.algorithm), + streamingMetadata: nextConfig.experimental.streamingMetadata, buildId: input.buildId, }), // If exporting the page takes longer than the timeout, reject the promise. diff --git a/packages/next/src/lib/metadata/async-metadata.tsx b/packages/next/src/lib/metadata/async-metadata.tsx index f92c1d33a78a2e..f97287e55efe64 100644 --- a/packages/next/src/lib/metadata/async-metadata.tsx +++ b/packages/next/src/lib/metadata/async-metadata.tsx @@ -1,21 +1,13 @@ 'use client' -import { use } from 'react' -import { useServerInsertedHTML } from '../../client/components/navigation' +import { use, type JSX } from 'react' +import { useServerInsertedMetadata } from '../../server/app-render/metadata-insertion/use-server-inserted-metadata' -// We need to wait for metadata on server once it's resolved, and insert into -// the HTML through `useServerInsertedHTML`. It will suspense in during SSR. -function ServerInsertMetadata({ promise }: { promise: Promise }) { - let metadataToFlush: React.ReactNode = use(promise) - - useServerInsertedHTML(() => { - if (metadataToFlush) { - const flushing = metadataToFlush - // reset to null to ensure we only flush it once - metadataToFlush = null - return flushing - } - }) +function ServerInsertMetadata({ promise }: { promise: Promise }) { + // Apply use() to the metadata promise to suspend the rendering in SSR. + const metadata = use(promise) + // Insert metadata into the HTML stream through the `useServerInsertedMetadata` + useServerInsertedMetadata(() => metadata) return null } diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index 2cbeebec8254d7..ff5ac93e5af74e 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -3,7 +3,7 @@ import type { GetDynamicParamFromSegment } from '../../server/app-render/app-ren import type { LoaderTree } from '../../server/lib/app-dir-module' import type { CreateServerParamsForMetadata } from '../../server/request/params' -import { cache, cloneElement } from 'react' +import { Suspense, cache, cloneElement } from 'react' import { AppleWebAppMeta, FormatDetectionMeta, @@ -38,6 +38,7 @@ import { VIEWPORT_BOUNDARY_NAME, } from './metadata-constants' import { AsyncMetadata } from './async-metadata' +import { isPostpone } from '../../server/lib/router-utils/is-postpone' // Use a promise to share the status of the metadata resolving, // returning two components `MetadataTree` and `MetadataOutlet` @@ -147,8 +148,8 @@ export function createMetadataComponents({ async function resolveFinalMetadata() { try { return await metadata() - } catch (error) { - if (!errorType && isHTTPAccessFallbackError(error)) { + } catch (metadataErr) { + if (!errorType && isHTTPAccessFallbackError(metadataErr)) { try { return await getNotFoundMetadata( tree, @@ -158,7 +159,18 @@ export function createMetadataComponents({ createServerParamsForMetadata, workStore ) - } catch {} + } catch (notFoundMetadataErr) { + // In PPR rendering we still need to throw the postpone error. + // If metadata is postponed, React needs to be aware of the location of error. + if (isPostpone(notFoundMetadataErr)) { + throw notFoundMetadataErr + } + } + } + // In PPR rendering we still need to throw the postpone error. + // If metadata is postponed, React needs to be aware of the location of error. + if (isPostpone(metadataErr)) { + throw metadataErr } // We don't actually want to error in this component. We will // also error in the MetadataOutlet which causes the error to @@ -168,10 +180,15 @@ export function createMetadataComponents({ } } async function Metadata() { + const promise = resolveFinalMetadata() if (serveStreamingMetadata) { - return + return ( + + + + ) } - return await resolveFinalMetadata() + return await promise } Metadata.displayName = METADATA_BOUNDARY_NAME diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 6b0697e44dc284..947caf2fdc1904 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -185,6 +185,7 @@ import { import type { MetadataErrorType } from '../../lib/metadata/resolve-metadata' import isError from '../../lib/is-error' import { isUseCacheTimeoutError } from '../use-cache/use-cache-errors' +import { createServerInsertedMetadata } from './metadata-insertion/create-server-inserted-metadata' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -322,13 +323,11 @@ function createDivergedMetadataComponents( StaticMetadata: React.ComponentType<{}> StreamingMetadata: React.ComponentType<{}> } { - const EmptyMetadata = () => null + function EmptyMetadata() { + return null + } const StreamingMetadata: React.ComponentType<{}> = serveStreamingMetadata - ? () => ( - - - - ) + ? Metadata : EmptyMetadata const StaticMetadata: React.ComponentType<{}> = serveStreamingMetadata @@ -1017,11 +1016,13 @@ function App({ clientReferenceManifest, nonce, ServerInsertedHTMLProvider, + ServerInsertedMetadataProvider, }: { reactServerStream: BinaryStreamOf preinitScripts: () => void clientReferenceManifest: NonNullable ServerInsertedHTMLProvider: React.ComponentType<{ children: JSX.Element }> + ServerInsertedMetadataProvider: React.ComponentType<{ children: JSX.Element }> nonce?: string }): JSX.Element { preinitScripts() @@ -1057,13 +1058,15 @@ function App({ nonce, }} > - - - + + + + + ) } @@ -1677,6 +1680,8 @@ async function renderToStream( const { ServerInsertedHTMLProvider, renderServerInsertedHTML } = createServerInsertedHTML() + const { ServerInsertedMetadataProvider, getServerInsertedMetadata } = + createServerInsertedMetadata() const tracingMetadata = getTracedMetadata( getTracer().getTracePropagationData(), @@ -1874,6 +1879,7 @@ async function renderToStream( preinitScripts={preinitScripts} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={ctx.nonce} />, postponed, @@ -1897,6 +1903,7 @@ async function renderToStream( formState ), getServerInsertedHTML, + getServerInsertedMetadata, }) } } @@ -1913,6 +1920,7 @@ async function renderToStream( preinitScripts={preinitScripts} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={ctx.nonce} />, { @@ -1950,10 +1958,17 @@ async function renderToStream( * or throw an error. It is the sole responsibility of the caller to * ensure they aren't e.g. requesting dynamic HTML for an AMP page. * + * 3.) If `shouldWaitOnAllReady` is true, which indicates we need to + * resolve all suspenses and generate a full HTML. e.g. when it's a + * html limited bot requests, we produce the full HTML content. + * * These rules help ensure that other existing features like request caching, * coalescing, and ISR continue working as intended. */ - const generateStaticHTML = renderOpts.supportsDynamicResponse !== true + const generateStaticHTML = + renderOpts.supportsDynamicResponse !== true || + !!renderOpts.shouldWaitOnAllReady + const validateRootLayout = renderOpts.dev return await continueFizzStream(htmlStream, { inlinedDataStream: createInlinedDataReadableStream( @@ -1963,6 +1978,7 @@ async function renderToStream( ), isStaticGeneration: generateStaticHTML, getServerInsertedHTML, + getServerInsertedMetadata, validateRootLayout, }) } catch (err) { @@ -2085,11 +2101,16 @@ async function renderToStream( * 2.) If dynamic HTML support is requested, we must honor that request * or throw an error. It is the sole responsibility of the caller to * ensure they aren't e.g. requesting dynamic HTML for an AMP page. + * 3.) If `shouldWaitOnAllReady` is true, which indicates we need to + * resolve all suspenses and generate a full HTML. e.g. when it's a + * html limited bot requests, we produce the full HTML content. * * These rules help ensure that other existing features like request caching, * coalescing, and ISR continue working as intended. */ - const generateStaticHTML = renderOpts.supportsDynamicResponse !== true + const generateStaticHTML = + renderOpts.supportsDynamicResponse !== true || + !!renderOpts.shouldWaitOnAllReady const validateRootLayout = renderOpts.dev return await continueFizzStream(fizzStream, { inlinedDataStream: createInlinedDataReadableStream( @@ -2108,6 +2129,7 @@ async function renderToStream( basePath: renderOpts.basePath, tracingMetadata: tracingMetadata, }), + getServerInsertedMetadata, validateRootLayout, }) } catch (finalErr: any) { @@ -2246,6 +2268,7 @@ async function spawnDynamicValidationInDev( } const { ServerInsertedHTMLProvider } = createServerInsertedHTML() + const { ServerInsertedMetadataProvider } = createServerInsertedMetadata() const nonce = '1' if (initialServerStream) { @@ -2265,6 +2288,7 @@ async function spawnDynamicValidationInDev( preinitScripts={() => {}} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={nonce} />, { @@ -2404,6 +2428,7 @@ async function spawnDynamicValidationInDev( preinitScripts={() => {}} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={ctx.nonce} />, { @@ -2516,6 +2541,8 @@ async function prerenderToStream( const { ServerInsertedHTMLProvider, renderServerInsertedHTML } = createServerInsertedHTML() + const { ServerInsertedMetadataProvider, getServerInsertedMetadata } = + createServerInsertedMetadata() const tracingMetadata = getTracedMetadata( getTracer().getTracePropagationData(), @@ -2770,6 +2797,9 @@ async function prerenderToStream( preinitScripts={preinitScripts} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ + ServerInsertedMetadataProvider + } nonce={ctx.nonce} />, { @@ -2927,6 +2957,7 @@ async function prerenderToStream( preinitScripts={preinitScripts} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={ctx.nonce} />, { @@ -3018,6 +3049,7 @@ async function prerenderToStream( ssrErrors: allCapturedErrors, stream: await continueDynamicPrerender(prelude, { getServerInsertedHTML, + getServerInsertedMetadata, }), dynamicAccess: consumeDynamicAccess( serverDynamicTracking, @@ -3054,6 +3086,7 @@ async function prerenderToStream( preinitScripts={() => {}} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={ctx.nonce} />, JSON.parse(JSON.stringify(postponed)), @@ -3078,6 +3111,7 @@ async function prerenderToStream( formState ), getServerInsertedHTML, + getServerInsertedMetadata, }), dynamicAccess: consumeDynamicAccess( serverDynamicTracking, @@ -3239,6 +3273,7 @@ async function prerenderToStream( preinitScripts={preinitScripts} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={ctx.nonce} />, { @@ -3391,6 +3426,9 @@ async function prerenderToStream( preinitScripts={preinitScripts} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ + ServerInsertedMetadataProvider + } nonce={ctx.nonce} />, { @@ -3497,6 +3535,7 @@ async function prerenderToStream( ), isStaticGeneration: true, getServerInsertedHTML, + getServerInsertedMetadata, validateRootLayout, }), dynamicAccess: consumeDynamicAccess( @@ -3570,6 +3609,7 @@ async function prerenderToStream( preinitScripts={preinitScripts} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={ctx.nonce} />, { @@ -3650,6 +3690,7 @@ async function prerenderToStream( ssrErrors: allCapturedErrors, stream: await continueDynamicPrerender(prelude, { getServerInsertedHTML, + getServerInsertedMetadata, }), dynamicAccess: dynamicTracking.dynamicAccesses, // TODO: Should this include the SSR pass? @@ -3669,6 +3710,7 @@ async function prerenderToStream( ssrErrors: allCapturedErrors, stream: await continueDynamicPrerender(prelude, { getServerInsertedHTML, + getServerInsertedMetadata, }), dynamicAccess: dynamicTracking.dynamicAccesses, // TODO: Should this include the SSR pass? @@ -3703,6 +3745,7 @@ async function prerenderToStream( preinitScripts={() => {}} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={ctx.nonce} />, JSON.parse(JSON.stringify(postponed)), @@ -3727,6 +3770,7 @@ async function prerenderToStream( formState ), getServerInsertedHTML, + getServerInsertedMetadata, }), dynamicAccess: dynamicTracking.dynamicAccesses, // TODO: Should this include the SSR pass? @@ -3779,6 +3823,7 @@ async function prerenderToStream( preinitScripts={preinitScripts} clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} + ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} nonce={ctx.nonce} />, { @@ -3822,6 +3867,7 @@ async function prerenderToStream( ), isStaticGeneration: true, getServerInsertedHTML, + getServerInsertedMetadata, }), // TODO: Should this include the SSR pass? collectedRevalidate: prerenderLegacyStore.revalidate, @@ -3996,6 +4042,7 @@ async function prerenderToStream( basePath: renderOpts.basePath, tracingMetadata: tracingMetadata, }), + getServerInsertedMetadata, validateRootLayout, }), dynamicAccess: null, diff --git a/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.tsx b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.tsx new file mode 100644 index 00000000000000..7c4f76a6e3fe51 --- /dev/null +++ b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.tsx @@ -0,0 +1,43 @@ +import React, { type JSX } from 'react' +import { renderToReadableStream } from 'react-dom/server.edge' +import { + ServerInsertedMetadataContext, + type MetadataResolver, +} from '../../../shared/lib/server-inserted-metadata.shared-runtime' +import { renderToString } from '../render-to-string' + +export function createServerInsertedMetadata() { + let metadataResolver: MetadataResolver | null = null + let metadataToFlush: JSX.Element | null = null + const setMetadataResolver = (resolver: MetadataResolver): void => { + metadataResolver = resolver + } + + return { + ServerInsertedMetadataProvider: ({ + children, + }: { + children: React.ReactNode + }) => { + return ( + + {children} + + ) + }, + + async getServerInsertedMetadata(): Promise { + if (!metadataResolver || metadataToFlush) { + return '' + } + + metadataToFlush = metadataResolver() + const html = await renderToString({ + renderToReadableStream, + element: <>{metadataToFlush}, + }) + + return html + }, + } +} diff --git a/packages/next/src/server/app-render/metadata-insertion/use-server-inserted-metadata.ts b/packages/next/src/server/app-render/metadata-insertion/use-server-inserted-metadata.ts new file mode 100644 index 00000000000000..de48498901b28c --- /dev/null +++ b/packages/next/src/server/app-render/metadata-insertion/use-server-inserted-metadata.ts @@ -0,0 +1,19 @@ +'use client' + +import { useContext } from 'react' +import { + type MetadataResolver, + ServerInsertedMetadataContext, +} from '../../../shared/lib/server-inserted-metadata.shared-runtime' + +// Receives a metadata resolver setter from the context, and will pass the metadata resolving promise to +// the context where we gonna use it to resolve the metadata, and render as string to append in . +export const useServerInsertedMetadata = ( + metadataResolver: MetadataResolver +) => { + const setMetadataResolver = useContext(ServerInsertedMetadataContext) + + if (setMetadataResolver) { + setMetadataResolver(metadataResolver) + } +} diff --git a/packages/next/src/server/app-render/render-to-string.tsx b/packages/next/src/server/app-render/render-to-string.tsx index 05f594abd61bd7..87487f08b8a105 100644 --- a/packages/next/src/server/app-render/render-to-string.tsx +++ b/packages/next/src/server/app-render/render-to-string.tsx @@ -1,17 +1,15 @@ import { streamToString } from '../stream-utils/node-web-streams-helper' -import { AppRenderSpan } from '../lib/trace/constants' -import { getTracer } from '../lib/trace/tracer' export async function renderToString({ - ReactDOMServer, + renderToReadableStream, element, }: { - ReactDOMServer: typeof import('react-dom/server.edge') + // `renderToReadableStream()` method could come from different react-dom/server implementations + // such as `react-dom/server.edge` or `react-dom/server.node`, etc. + renderToReadableStream: typeof import('react-dom/server.edge').renderToReadableStream element: React.ReactElement -}) { - return getTracer().trace(AppRenderSpan.renderToString, async () => { - const renderStream = await ReactDOMServer.renderToReadableStream(element) - await renderStream.allReady - return streamToString(renderStream) - }) +}): Promise { + const renderStream = await renderToReadableStream(element) + await renderStream.allReady + return streamToString(renderStream) } diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 221c471c30a0a8..3c7361e6333ccc 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -177,6 +177,7 @@ export interface RenderOptsPartial { assetPrefix?: string crossOrigin?: '' | 'anonymous' | 'use-credentials' | undefined nextFontManifest?: DeepReadonly + botType?: 'dom' | 'html' | undefined serveStreamingMetadata?: boolean incrementalCache?: import('../lib/incremental-cache').IncrementalCache cacheLifeProfiles?: { @@ -220,6 +221,12 @@ export interface RenderOptsPartial { } postponed?: string + /** + * Should wait for react stream allReady to resolve all suspense boundaries, + * in order to perform a full page render. + */ + shouldWaitOnAllReady?: boolean + /** * The resume data cache that was generated for this partially prerendered * page during dev warmup. diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index e4d713cdf5c541..5ce5e71ec9a308 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -83,7 +83,7 @@ import { } from './lib/revalidate' import { execOnce } from '../shared/lib/utils' import { isBlockedPage } from './utils' -import { isBot } from '../shared/lib/router/utils/is-bot' +import { getBotType, isBot } from '../shared/lib/router/utils/is-bot' import RenderResult from './render-result' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' @@ -175,8 +175,11 @@ import type { RouteModule } from './route-modules/route-module' import { FallbackMode, parseFallbackField } from '../lib/fallback' import { toResponseCacheEntry } from './response-cache/utils' import { scheduleOnNextTick } from '../lib/scheduler' -import { shouldServeStreamingMetadata } from './lib/streaming-metadata' import { SegmentPrefixRSCPathnameNormalizer } from './normalizers/request/segment-prefix-rsc' +import { + shouldServeStreamingMetadata, + isHtmlBotRequestStreamingMetadata, +} from './lib/streaming-metadata' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -1761,6 +1764,7 @@ export default abstract class Server< renderOpts: { ...this.renderOpts, supportsDynamicResponse: !isBotRequest, + botType: getBotType(ua), serveStreamingMetadata: shouldServeStreamingMetadata( ua, this.renderOpts.experimental @@ -2064,6 +2068,14 @@ export default abstract class Server< } } + const isHtmlBotRequest = isHtmlBotRequestStreamingMetadata( + req, + this.renderOpts.experimental.streamingMetadata + ) + if (isHtmlBotRequest) { + this.renderOpts.serveStreamingMetadata = false + } + if ( hasFallback || staticPaths?.includes(resolvedUrlPathname) || @@ -2072,6 +2084,10 @@ export default abstract class Server< req.headers['x-now-route-matches'] ) { isSSG = true + } else if (isHtmlBotRequest) { + // When it's html limited bots request, disable SSG + // and perform the full blocking rendering. + isSSG = false } else if (!this.renderOpts.dev) { isSSG ||= !!prerenderManifest.routes[toRoute(pathname)] } @@ -2474,6 +2490,8 @@ export default abstract class Server< // make sure to only add query values from original URL query: origQuery, }) + + const shouldWaitOnAllReady = !supportsDynamicResponse || isHtmlBotRequest const renderOpts: LoadedRenderOpts = { ...components, ...opts, @@ -2511,6 +2529,7 @@ export default abstract class Server< isRoutePPREnabled, }, supportsDynamicResponse, + shouldWaitOnAllReady, isOnDemandRevalidate, isDraftMode: isPreviewMode, isServerAction, @@ -3527,7 +3546,7 @@ export default abstract class Server< } // Mark that the request did postpone. - if (didPostpone) { + if (didPostpone && !isHtmlBotRequest) { res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') } diff --git a/packages/next/src/server/lib/streaming-metadata.ts b/packages/next/src/server/lib/streaming-metadata.ts index 3948b6c59a878c..1d83dcf33be9bf 100644 --- a/packages/next/src/server/lib/streaming-metadata.ts +++ b/packages/next/src/server/lib/streaming-metadata.ts @@ -1,4 +1,8 @@ -import { HTML_LIMITED_BOT_UA_RE_STRING } from '../../shared/lib/router/utils/is-bot' +import { + getBotType, + HTML_LIMITED_BOT_UA_RE_STRING, +} from '../../shared/lib/router/utils/is-bot' +import type { BaseNextRequest } from '../base-http' export function shouldServeStreamingMetadata( userAgent: string, @@ -23,3 +27,15 @@ export function shouldServeStreamingMetadata( !!userAgent && !blockingMetadataUARegex.test(userAgent) ) } + +// When streaming metadata is enabled and request UA is a html-limited bot, we should do a dynamic render. +// In this case, postpone state is not sent. +export function isHtmlBotRequestStreamingMetadata( + req: BaseNextRequest, + streamingMetadata: boolean +): boolean { + const ua = req.headers['user-agent'] || '' + const botType = getBotType(ua) + + return botType === 'html' && streamingMetadata +} diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 658139db7ae766..0eb8e1c0229cc2 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -266,6 +266,7 @@ export type RenderOptsPartial = { domainLocales?: readonly DomainLocale[] disableOptimizedLoading?: boolean supportsDynamicResponse: boolean + botType?: 'dom' | 'html' | undefined serveStreamingMetadata?: boolean runtime?: ServerRuntime serverComponents?: boolean diff --git a/packages/next/src/server/route-modules/app-page/vendored/contexts/entrypoints.ts b/packages/next/src/server/route-modules/app-page/vendored/contexts/entrypoints.ts index 00a3e07163c6c4..896acde50c99e0 100644 --- a/packages/next/src/server/route-modules/app-page/vendored/contexts/entrypoints.ts +++ b/packages/next/src/server/route-modules/app-page/vendored/contexts/entrypoints.ts @@ -1,5 +1,6 @@ export * as HeadManagerContext from '../../../../../shared/lib/head-manager-context.shared-runtime' export * as ServerInsertedHtml from '../../../../../shared/lib/server-inserted-html.shared-runtime' +export * as ServerInsertedMetadata from '../../../../../shared/lib/server-inserted-metadata.shared-runtime' export * as AppRouterContext from '../../../../../shared/lib/app-router-context.shared-runtime' export * as HooksClientContext from '../../../../../shared/lib/hooks-client-context.shared-runtime' export * as RouterContext from '../../../../../shared/lib/router-context.shared-runtime' diff --git a/packages/next/src/server/route-modules/app-page/vendored/contexts/server-inserted-metadata.ts b/packages/next/src/server/route-modules/app-page/vendored/contexts/server-inserted-metadata.ts new file mode 100644 index 00000000000000..8f6341eef1a585 --- /dev/null +++ b/packages/next/src/server/route-modules/app-page/vendored/contexts/server-inserted-metadata.ts @@ -0,0 +1,3 @@ +module.exports = require('../../module.compiled').vendored[ + 'contexts' +].ServerInsertedMetadata diff --git a/packages/next/src/server/stream-utils/node-web-streams-helper.ts b/packages/next/src/server/stream-utils/node-web-streams-helper.ts index d4814ca2f98f6d..fe27f0f12eb399 100644 --- a/packages/next/src/server/stream-utils/node-web-streams-helper.ts +++ b/packages/next/src/server/stream-utils/node-web-streams-helper.ts @@ -523,6 +523,7 @@ export type ContinueStreamOptions = { inlinedDataStream: ReadableStream | undefined isStaticGeneration: boolean getServerInsertedHTML: () => Promise + getServerInsertedMetadata: () => Promise validateRootLayout?: boolean /** * Suffix to inject after the buffered data, but before the close tags. @@ -537,6 +538,7 @@ export async function continueFizzStream( inlinedDataStream, isStaticGeneration, getServerInsertedHTML, + getServerInsertedMetadata, validateRootLayout, }: ContinueStreamOptions ): Promise> { @@ -553,6 +555,9 @@ export async function continueFizzStream( // Buffer everything to avoid flushing too frequently createBufferedTransformStream(), + // Insert generated metadata + createHeadInsertionTransformStream(getServerInsertedMetadata), + // Insert suffix content suffixUnclosed != null && suffixUnclosed.length > 0 ? createDeferredSuffixStream(suffixUnclosed) @@ -576,11 +581,15 @@ export async function continueFizzStream( type ContinueDynamicPrerenderOptions = { getServerInsertedHTML: () => Promise + getServerInsertedMetadata: () => Promise } export async function continueDynamicPrerender( prerenderStream: ReadableStream, - { getServerInsertedHTML }: ContinueDynamicPrerenderOptions + { + getServerInsertedHTML, + getServerInsertedMetadata, + }: ContinueDynamicPrerenderOptions ) { return ( prerenderStream @@ -589,17 +598,26 @@ export async function continueDynamicPrerender( .pipeThrough(createStripDocumentClosingTagsTransform()) // Insert generated tags to head .pipeThrough(createHeadInsertionTransformStream(getServerInsertedHTML)) + // Insert generated metadata + .pipeThrough( + createHeadInsertionTransformStream(getServerInsertedMetadata) + ) ) } type ContinueStaticPrerenderOptions = { inlinedDataStream: ReadableStream getServerInsertedHTML: () => Promise + getServerInsertedMetadata: () => Promise } export async function continueStaticPrerender( prerenderStream: ReadableStream, - { inlinedDataStream, getServerInsertedHTML }: ContinueStaticPrerenderOptions + { + inlinedDataStream, + getServerInsertedHTML, + getServerInsertedMetadata, + }: ContinueStaticPrerenderOptions ) { return ( prerenderStream @@ -607,6 +625,10 @@ export async function continueStaticPrerender( .pipeThrough(createBufferedTransformStream()) // Insert generated tags to head .pipeThrough(createHeadInsertionTransformStream(getServerInsertedHTML)) + // Insert generated metadata to head + .pipeThrough( + createHeadInsertionTransformStream(getServerInsertedMetadata) + ) // Insert the inlined data (Flight data, form state, etc.) stream into the HTML .pipeThrough(createMergedTransformStream(inlinedDataStream)) // Close tags should always be deferred to the end @@ -617,11 +639,16 @@ export async function continueStaticPrerender( type ContinueResumeOptions = { inlinedDataStream: ReadableStream getServerInsertedHTML: () => Promise + getServerInsertedMetadata: () => Promise } export async function continueDynamicHTMLResume( renderStream: ReadableStream, - { inlinedDataStream, getServerInsertedHTML }: ContinueResumeOptions + { + inlinedDataStream, + getServerInsertedHTML, + getServerInsertedMetadata, + }: ContinueResumeOptions ) { return ( renderStream @@ -629,6 +656,10 @@ export async function continueDynamicHTMLResume( .pipeThrough(createBufferedTransformStream()) // Insert generated tags to head .pipeThrough(createHeadInsertionTransformStream(getServerInsertedHTML)) + // Insert generated metadata to body + .pipeThrough( + createHeadInsertionTransformStream(getServerInsertedMetadata) + ) // Insert the inlined data (Flight data, form state, etc.) stream into the HTML .pipeThrough(createMergedTransformStream(inlinedDataStream)) // Close tags should always be deferred to the end diff --git a/packages/next/src/shared/lib/router/utils/is-bot.ts b/packages/next/src/shared/lib/router/utils/is-bot.ts index 019a09f90024ca..73a8a4cc6a0f9e 100644 --- a/packages/next/src/shared/lib/router/utils/is-bot.ts +++ b/packages/next/src/shared/lib/router/utils/is-bot.ts @@ -12,7 +12,7 @@ export const HTML_LIMITED_BOT_UA_RE = new RegExp( 'i' ) -function isHeadlessBrowserBotUA(userAgent: string) { +function isDomBotUA(userAgent: string) { return HEADLESS_BROWSER_BOT_UA_RE.test(userAgent) } @@ -21,5 +21,15 @@ function isHtmlLimitedBotUA(userAgent: string) { } export function isBot(userAgent: string): boolean { - return isHeadlessBrowserBotUA(userAgent) || isHtmlLimitedBotUA(userAgent) + return isDomBotUA(userAgent) || isHtmlLimitedBotUA(userAgent) +} + +export function getBotType(userAgent: string): 'dom' | 'html' | undefined { + if (isDomBotUA(userAgent)) { + return 'dom' + } + if (isHtmlLimitedBotUA(userAgent)) { + return 'html' + } + return undefined } diff --git a/packages/next/src/shared/lib/server-inserted-metadata.shared-runtime.ts b/packages/next/src/shared/lib/server-inserted-metadata.shared-runtime.ts new file mode 100644 index 00000000000000..07c88f8015b852 --- /dev/null +++ b/packages/next/src/shared/lib/server-inserted-metadata.shared-runtime.ts @@ -0,0 +1,9 @@ +'use client' + +import { type JSX, createContext } from 'react' + +export type MetadataResolver = () => JSX.Element +type MetadataResolverSetter = (m: MetadataResolver) => void + +export const ServerInsertedMetadataContext = + createContext(null) diff --git a/test/e2e/app-dir/metadata-static-generation/metadata-static-generation.test.ts b/test/e2e/app-dir/metadata-static-generation/metadata-static-generation.test.ts index 441be1007ee5c0..85af160aa6d77b 100644 --- a/test/e2e/app-dir/metadata-static-generation/metadata-static-generation.test.ts +++ b/test/e2e/app-dir/metadata-static-generation/metadata-static-generation.test.ts @@ -7,15 +7,10 @@ describe('app-dir - metadata-static-generation', () => { files: __dirname, }) - // /suspenseful/dynamic will behave differently when PPR is enabled. - // We'll visit PPR tests in the new test suite. - if (isPPREnabled) { - it('skip ppr test', () => {}) - return - } - - if (isNextStart) { - // Precondition for the following tests in build mode + if (isNextStart && !isPPREnabled) { + // Precondition for the following tests in build mode. + // This test is only useful for non-PPR mode as in PPR mode those routes + // are all listed in the prerender manifest. it('should generate all pages static', async () => { const prerenderManifest = JSON.parse( await next.readFile('.next/prerender-manifest.json') diff --git a/test/e2e/app-dir/metadata-streaming-static-generation/app/layout.tsx b/test/e2e/app-dir/metadata-streaming-static-generation/app/layout.tsx index 7504a0d9c4199c..91f6cb85de5a6f 100644 --- a/test/e2e/app-dir/metadata-streaming-static-generation/app/layout.tsx +++ b/test/e2e/app-dir/metadata-streaming-static-generation/app/layout.tsx @@ -1,26 +1,27 @@ import Link from 'next/link' import { ReactNode } from 'react' +const hrefs = [ + '/slow/dynamic', + '/slow/static', + '/', + '/suspenseful/dynamic', + '/suspenseful/static', +] + export default function Root({ children }: { children: ReactNode }) { return (
- - {`to /slow`} - -
- - {`to /`} - -
- - {`to /suspenseful/dynamic`} - -
- - {`to /suspenseful/static`} - + {hrefs.map((href) => ( +
+ + {`to ${href}`} + +
+
+ ))}
{children} diff --git a/test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts b/test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts index 641eafee53d83f..8b885f4ceacf14 100644 --- a/test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts +++ b/test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts @@ -7,15 +7,10 @@ describe('app-dir - metadata-streaming-static-generation', () => { files: __dirname, }) - // /suspenseful/dynamic will behave differently when PPR is enabled. - // We'll visit PPR tests in the new test suite. - if (isPPREnabled) { - it('skip ppr test', () => {}) - return - } - - if (isNextStart) { - // Precondition for the following tests in build mode + if (isNextStart && !isPPREnabled) { + // Precondition for the following tests in build mode. + // This test is only useful for non-PPR mode as in PPR mode those routes + // are all listed in the prerender manifest. it('should generate all pages static', async () => { const prerenderManifest = JSON.parse( await next.readFile('.next/prerender-manifest.json') diff --git a/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts b/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts index 8be119906ef55f..9e8d12b9313fac 100644 --- a/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts +++ b/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts @@ -73,7 +73,7 @@ describe('app-dir - metadata-streaming', () => { }) }) - it('should not insert metadata twice or inject into body', async () => { + it('should only insert metadata once into head or body', async () => { const browser = await next.browser('/slow') // each metadata should be inserted only once diff --git a/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-metadata/page.tsx b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-metadata/page.tsx new file mode 100644 index 00000000000000..423a16b8875f76 --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-metadata/page.tsx @@ -0,0 +1,18 @@ +import { connection } from 'next/server' + +export default function Home() { + return ( +
+

Dynamic Metadata

+
+ ) +} + +export async function generateMetadata() { + await connection() + await new Promise((resolve) => setTimeout(resolve, 2 * 1000)) + return { + title: `dynamic metadata`, + description: `dynamic metadata - ${Math.random()}`, + } +} diff --git a/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-metadata/partial/layout.tsx b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-metadata/partial/layout.tsx new file mode 100644 index 00000000000000..30a6595a3afa5c --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-metadata/partial/layout.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react' + +export default function Layout({ children }) { + return ( +
+

Suspenseful Layout

+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-metadata/partial/page.tsx b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-metadata/partial/page.tsx new file mode 100644 index 00000000000000..1714667ebf4f07 --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-metadata/partial/page.tsx @@ -0,0 +1,36 @@ +import { connection } from 'next/server' + +// Page is suspended and being caught by the layout Suspense boundary +export default function Page() { + return ( +
+ +
+ ) +} + +async function SuspendedComponent() { + await connection() + await new Promise((resolve) => setTimeout(resolve, 500)) + return ( +
+
suspended component
+ +
+ ) +} + +async function NestedSuspendedComponent() { + await connection() + await new Promise((resolve) => setTimeout(resolve, 500)) + return
nested suspended component
+} + +export async function generateMetadata() { + await connection() + await new Promise((resolve) => setTimeout(resolve, 3 * 1000)) + return { + title: 'dynamic-metadata - partial', + description: `dynamic metadata - ${Math.random()}`, + } +} diff --git a/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-page/page.tsx b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-page/page.tsx new file mode 100644 index 00000000000000..c500e5bb3caf2e --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-page/page.tsx @@ -0,0 +1,24 @@ +import { headers } from 'next/headers' + +// Dynamic usage in page, wrapped with Suspense boundary +export default function Page() { + return ( +
+

Dynamic Page

+ +
+ ) +} + +async function SubComponent() { + await headers() + return
Dynamic Headers
+} + +export async function generateMetadata() { + // Slow but static metadata + await new Promise((resolve) => setTimeout(resolve, 2 * 1000)) + return { + title: `dynamic page`, + } +} diff --git a/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-page/partial/layout.tsx b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-page/partial/layout.tsx new file mode 100644 index 00000000000000..f54b7084902e68 --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-page/partial/layout.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react' + +export default function Layout({ children }) { + return ( +
+

Suspenseful Layout

+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-page/partial/page.tsx b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-page/partial/page.tsx new file mode 100644 index 00000000000000..b131c7ad1eb5c6 --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/app/dynamic-page/partial/page.tsx @@ -0,0 +1,35 @@ +import { connection } from 'next/server' + +/// Page is suspended and being caught by the layout Suspense boundary +export default function Page() { + return ( +
+ +
+ ) +} + +async function SuspendedComponent() { + await connection() + await new Promise((resolve) => setTimeout(resolve, 500)) + return ( +
+
suspended component
+ +
+ ) +} + +async function NestedSuspendedComponent() { + await connection() + await new Promise((resolve) => setTimeout(resolve, 500)) + return
nested suspended component
+} + +export async function generateMetadata() { + // Slow but static metadata + await new Promise((resolve) => setTimeout(resolve, 2 * 1000)) + return { + title: 'dynamic-page - partial', + } +} diff --git a/test/e2e/app-dir/ppr-metadata-streaming/app/fully-dynamic/page.tsx b/test/e2e/app-dir/ppr-metadata-streaming/app/fully-dynamic/page.tsx new file mode 100644 index 00000000000000..79a1352c33516b --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/app/fully-dynamic/page.tsx @@ -0,0 +1,30 @@ +import { cookies } from 'next/headers' +import { connection } from 'next/server' +import { Suspense } from 'react' + +export default function Home() { + return ( +
+

Fully Dynamic

+ + + +
+ ) +} + +async function SubComponent() { + const cookieStore = await cookies() + await new Promise((resolve) => setTimeout(resolve, 500)) + const cookie = await cookieStore.get('test') + return
Cookie: {cookie?.value}
+} + +export async function generateMetadata() { + await connection() + await new Promise((resolve) => setTimeout(resolve, 2 * 1000)) + return { + title: `fully dynamic`, + description: `fully dynamic - ${Math.random()}`, + } +} diff --git a/test/e2e/app-dir/ppr-metadata-streaming/app/fully-static/page.tsx b/test/e2e/app-dir/ppr-metadata-streaming/app/fully-static/page.tsx new file mode 100644 index 00000000000000..cc804edaf0a591 --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/app/fully-static/page.tsx @@ -0,0 +1,11 @@ +export default function Page() { + return

slow

+} + +export async function generateMetadata() { + await new Promise((resolve) => setTimeout(resolve, 2 * 1000)) + return { + title: 'fully static', + description: 'fully static', + } +} diff --git a/test/e2e/app-dir/ppr-metadata-streaming/app/layout.tsx b/test/e2e/app-dir/ppr-metadata-streaming/app/layout.tsx new file mode 100644 index 00000000000000..3bbc3d2ae2d1b8 --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/app/layout.tsx @@ -0,0 +1,30 @@ +import Link from 'next/link' +import { ReactNode } from 'react' + +const hrefs = [ + '/dynamic-metadata', + '/dynamic-metadata/partial', + '/dynamic-page', + '/dynamic-page/partial', + '/fully-dynamic', + '/fully-static', +] + +export default function Root({ children }: { children: ReactNode }) { + return ( + + +
+ {hrefs.map((href) => ( +
+ + {`to ${href}`} + +
+ ))} +
+ {children} + + + ) +} diff --git a/test/e2e/app-dir/ppr-metadata-streaming/next.config.js b/test/e2e/app-dir/ppr-metadata-streaming/next.config.js new file mode 100644 index 00000000000000..100ade01c081a6 --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: true, + streamingMetadata: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/ppr-metadata-streaming/ppr-metadata-streaming.test.ts b/test/e2e/app-dir/ppr-metadata-streaming/ppr-metadata-streaming.test.ts new file mode 100644 index 00000000000000..4c71024eae0a13 --- /dev/null +++ b/test/e2e/app-dir/ppr-metadata-streaming/ppr-metadata-streaming.test.ts @@ -0,0 +1,150 @@ +import { nextTestSetup } from 'e2e-utils' +import cheerio from 'cheerio' +import { assertNoConsoleErrors } from 'next-test-utils' + +function countSubstring(str: string, substr: string): number { + return str.split(substr).length - 1 +} + +describe('ppr-metadata-streaming', () => { + const { next, isNextDev, isNextStart, isNextDeploy } = nextTestSetup({ + files: __dirname, + }) + + // No dynamic APIs used in metadata + describe('static metadata', () => { + it('should generate metadata in head when page is fully static', async () => { + const rootSelector = isNextDev ? 'body' : 'head' + const $ = await next.render$('/fully-static') + expect($(`${rootSelector} title`).text()).toBe('fully static') + expect(countSubstring($.html(), '')).toBe(1) + + const browser = await next.browser('/fully-static') + expect( + await browser.waitForElementByCss(`${rootSelector} title`).text() + ).toBe('fully static') + await assertNoConsoleErrors(browser) + }) + + it('should insert metadata in body when page is dynamic page content', async () => { + const $ = await next.render$('/dynamic-page') + expect($(`body title`).text()).toBe('dynamic page') + expect(countSubstring($.html(), '<title>')).toBe(1) + + const browser = await next.browser('/dynamic-page') + expect(await browser.waitForElementByCss('body title').text()).toBe( + 'dynamic page' + ) + await assertNoConsoleErrors(browser) + }) + }) + + // Dynamic APIs used in metadata, metadata should be suspended and inserted into body + describe('dynamic metadata', () => { + it('should generate metadata in body when page is fully dynamic', async () => { + const $ = await next.render$('/fully-dynamic') + expect($('body title').text()).toBe('fully dynamic') + expect(countSubstring($.html(), '<title>')).toBe(1) + + const browser = await next.browser('/fully-dynamic') + expect(await browser.waitForElementByCss('body title').text()).toBe( + 'fully dynamic' + ) + await assertNoConsoleErrors(browser) + }) + + it('should generate metadata in body when page content is static', async () => { + const $ = await next.render$('/dynamic-metadata') + expect($('body title').text()).toBe('dynamic metadata') + expect(countSubstring($.html(), '<title>')).toBe(1) + + const browser = await next.browser('/dynamic-metadata') + expect(await browser.waitForElementByCss('body title').text()).toBe( + 'dynamic metadata' + ) + await assertNoConsoleErrors(browser) + }) + }) + + describe('partial shell', () => { + it('should insert metadata into body with dynamic metadata and wrapped under layout Suspense boundary', async () => { + const $ = await next.render$('/dynamic-metadata/partial') + expect($('body title').text()).toBe('dynamic-metadata - partial') + expect(countSubstring($.html(), '<title>')).toBe(1) + + const browser = await next.browser('/dynamic-metadata/partial') + expect(await browser.waitForElementByCss('body title').text()).toBe( + 'dynamic-metadata - partial' + ) + await assertNoConsoleErrors(browser) + }) + + it('should insert metadata into head with dynamic metadata and dynamic page wrapped under layout Suspense boundary', async () => { + const rootSelector = isNextDev ? 'body' : 'head' + const $ = await next.render$('/dynamic-page/partial') + expect($(`${rootSelector} title`).text()).toBe('dynamic-page - partial') + expect(countSubstring($.html(), '<title>')).toBe(1) + + const browser = await next.browser('/dynamic-page/partial') + expect( + await browser.waitForElementByCss(`${rootSelector} title`).text() + ).toBe('dynamic-page - partial') + await assertNoConsoleErrors(browser) + }) + }) + + // Disable deployment until we support it on infra + if (isNextStart && !isNextDeploy) { + // This test is only relevant in production mode, as it's testing PPR results + describe('html limited bots', () => { + it('should serve partial static shell when normal UA requests the page', async () => { + const res1 = await next.fetch('/dynamic-page/partial') + const res2 = await next.fetch('/dynamic-page/partial') + + const $1 = cheerio.load(await res1.text()) + const $2 = cheerio.load(await res2.text()) + + const attribute1 = parseInt($1('[data-date]').attr('data-date')) + const attribute2 = parseInt($2('[data-date]').attr('data-date')) + + // Normal UA should still get the partial static shell produced by PPR + expect(attribute1).toBe(attribute2) + expect(attribute1).toBeTruthy() + + const headers = res1.headers + + // Static render should have postponed header + expect(headers.get('x-nextjs-postponed')).toBe('1') + }) + + it('should not serve partial static shell when html limited bots requests the page', async () => { + const htmlLimitedBotUA = 'Discordbot' + const res1 = await next.fetch('/dynamic-page/partial', { + headers: { + 'User-Agent': htmlLimitedBotUA, + }, + }) + + const res2 = await next.fetch('/dynamic-page/partial', { + headers: { + 'User-Agent': htmlLimitedBotUA, + }, + }) + + // Dynamic render should not have postponed header + const headers = res1.headers + expect(headers.get('x-nextjs-postponed')).toBe(null) + + const $1 = cheerio.load(await res1.text()) + const $2 = cheerio.load(await res2.text()) + + const attribute1 = parseInt($1('[data-date]').attr('data-date')) + const attribute2 = parseInt($2('[data-date]').attr('data-date')) + + // Two requests are dynamic and should not have the same data-date attribute + expect(attribute2).toBeGreaterThan(attribute1) + expect(attribute1).toBeTruthy() + }) + }) + } +})