vercel/next.js · #97440

Keep metadata rendering stable across streaming modes

gnoff · merged Sep 17, 20264 files · 89 + / 45
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',   () => {