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