vercel/next.js · #97440
Keep metadata rendering stable across streaming modes
packages/next/src/build/templates/app-page-runtime.ts4 + / 3 −
@@ -529,9 +529,10 @@ export function createAppPageEntrypoint({ ? true : shouldServeStreamingMetadata(userAgent, nextConfig.htmlLimitedBots) - // PPR shells are generated for streaming metadata. Requests that require- // blocking metadata must bypass the shell so the prerender and dynamic- // render use the same metadata tree.+ // A PPR shell has already closed its head before the dynamic render resumes.+ // Blocking metadata resolved during the resume would therefore be emitted+ // after the head, where HTML-limited bots cannot observe it. Bypass the+ // shell so blocking metadata is included in the initial document head. const shouldForceDynamicPPRRender = isRoutePPREnabled && !serveStreamingMetadata packages/next/src/lib/metadata/metadata-parallel.tsx25 + / 20 −
@@ -98,8 +98,10 @@ export function createMetadataComponents({ ) } - async function Metadata() {- const tags = await getResolvedParallelMetadata(+ // Metadata resolution must start while rendering so it observes the current+ // work unit store.+ function getSelectedMetadata() {+ return getResolvedParallelMetadata( tree, pathnameForMetadata, searchParams,@@ -110,30 +112,35 @@ export function createMetadataComponents({ // We're going to throw the error from the metadata outlet so we just render null here instead return null })+ } - return tags+ async function Metadata() {+ return await getSelectedMetadata() } Metadata.displayName = 'Next.Metadata' + function MetadataBlocker() {+ return serveStreamingMetadata+ ? null+ : getSelectedMetadata().then(() => null)+ }+ function MetadataWrapper() {- // TODO: We shouldn't change what we render based on whether we are streaming or not.- // If we aren't streaming we should just block the response until we have resolved the- // metadata.- if (!serveStreamingMetadata) {- return (- <MetadataBoundary>- <Metadata />- </MetadataBoundary>- )- }+ // Keep the same component structure in streaming and blocking renders.+ // The blocker only holds the shell open when metadata must not stream.+ // React requires top-level suspenseful metadata to be nested under a host+ // element. Otherwise it becomes part of the document preamble and blocks+ // shell flushing instead of streaming. Metadata tags are hoisted out, so+ // this hidden wrapper remains empty. return (- <div hidden>- <MetadataBoundary>+ <MetadataBoundary>+ <div hidden> <Suspense name="Next.Metadata"> <Metadata /> </Suspense>- </MetadataBoundary>- </div>+ </div>+ <MetadataBlocker />+ </MetadataBoundary> ) } @@ -150,12 +157,10 @@ export function createMetadataComponents({ (resolution) => resolution.outlets.get(outletTree) ?? null ) - // TODO: We shouldn't change what we render based on whether we are streaming or not.- // If we aren't streaming we should just block the response until we have resolved the- // metadata. if (!serveStreamingMetadata) { return <OutletBoundary>{pendingOutlet}</OutletBoundary> }+ return ( <OutletBoundary> <Suspense name="Next.MetadataOutlet">{pendingOutlet}</Suspense>packages/next/src/lib/metadata/metadata.tsx25 + / 20 −
@@ -84,8 +84,10 @@ export function createMetadataComponents({ ) } - async function Metadata() {- const tags = await getResolvedMetadata(+ // Metadata resolution must start while rendering so it observes the current+ // work unit store.+ function getSelectedMetadata() {+ return getResolvedMetadata( tree, pathnameForMetadata, searchParams,@@ -105,30 +107,35 @@ export function createMetadataComponents({ // We're going to throw the error from the metadata outlet so we just render null here instead return null })+ } - return tags+ async function Metadata() {+ return await getSelectedMetadata() } Metadata.displayName = 'Next.Metadata' + function MetadataBlocker() {+ return serveStreamingMetadata+ ? null+ : getSelectedMetadata().then(() => null)+ }+ function MetadataWrapper() {- // TODO: We shouldn't change what we render based on whether we are streaming or not.- // If we aren't streaming we should just block the response until we have resolved the- // metadata.- if (!serveStreamingMetadata) {- return (- <MetadataBoundary>- <Metadata />- </MetadataBoundary>- )- }+ // Keep the same component structure in streaming and blocking renders.+ // The blocker only holds the shell open when metadata must not stream.+ // React requires top-level suspenseful metadata to be nested under a host+ // element. Otherwise it becomes part of the document preamble and blocks+ // shell flushing instead of streaming. Metadata tags are hoisted out, so+ // this hidden wrapper remains empty. return (- <div hidden>- <MetadataBoundary>+ <MetadataBoundary>+ <div hidden> <Suspense name="Next.Metadata"> <Metadata /> </Suspense>- </MetadataBoundary>- </div>+ </div>+ <MetadataBlocker />+ </MetadataBoundary> ) } @@ -145,12 +152,10 @@ export function createMetadataComponents({ getResolvedViewport(tree, searchParams, interpolatedParams, errorType), ]).then(() => null) - // TODO: We shouldn't change what we render based on whether we are streaming or not.- // If we aren't streaming we should just block the response until we have resolved the- // metadata. if (!serveStreamingMetadata) { return <OutletBoundary>{pendingOutlet}</OutletBoundary> }+ return ( <OutletBoundary> <Suspense name="Next.MetadataOutlet">{pendingOutlet}</Suspense>test/e2e/app-dir/metadata-streaming-cache-components/metadata-streaming-cache-components-custom-bots.test.ts35 + / 2 −
@@ -1,10 +1,22 @@ import { isNextDev, nextTestSetup } from 'e2e-utils' import cheerio from 'cheerio'+import { assertNoConsoleErrors } from 'next-test-utils' const describeCacheComponents = isNextDev ? describe.skip : describe -describeCacheComponents('metadata streaming with a custom bot list', () => {+function runCustomBotTests(parallelRouteMetadata: boolean) { const { next, isNextDeploy } = nextTestSetup({ files: __dirname,+ overrideFiles: {+ 'next.config.js': `+ module.exports = {+ cacheComponents: true,+ htmlLimitedBots: /MyBot/i,+ experimental: {+ parallelRouteMetadata: ${parallelRouteMetadata},+ },+ }+ `,+ }, }) it('should serve a fully dynamic render with blocking metadata to a configured HTML-limited bot', async () => {@@ -56,6 +68,20 @@ describeCacheComponents('metadata streaming with a custom bot list', () => { expect($('#dynamic-content').text()).toBe('dynamic content') }) + it('should hydrate blocking metadata without errors', async () => {+ const browser = await next.browser('/partial', {+ userAgent: 'MyBot',+ pushErrorAsConsoleLog: true,+ })++ expect(+ await browser+ .waitForElementByCss('head title', { state: 'attached' })+ .text()+ ).toBe('dynamic title')+ await assertNoConsoleErrors(browser)+ })+ it('should continue streaming the body after blocking metadata for a configured HTML-limited bot', async () => { const abortController = new AbortController() let body:@@ -135,7 +161,14 @@ describeCacheComponents('metadata streaming with a custom bot list', () => { expect($('body title').text()).toBe('dynamic title') expect($('#dynamic-content').text()).toBe('dynamic content') })-})+}++describeCacheComponents.each([false, true])(+ 'metadata streaming with a custom bot list (parallelRouteMetadata: %s)',+ (parallelRouteMetadata) => {+ runCustomBotTests(parallelRouteMetadata)+ }+) ;(isNextDev ? describe.skip : describe)( 'metadata streaming with Cache Components and a built-in bot in the custom pattern', () => {