Repository: vercel/next.js
Pull request: Prerender with streaming metadata during revalidation

During the export phase of `next build` we're hard-coding `serveStreamingMetadata` to `true`, so we need to do the same during revalidation.

Otherwise we're checking the user agent to decide if we should serve streaming metadata.

This fixes a hydration error that occurred when hydrating the resumed PPR shell. The error was not present for the initial resume of the prerendered shell, and only after the shell was revalidated once.

Changed files:
filename: packages/next/src/build/templates/app-page.ts
patch: @@ -233,16 +233,6 @@ export async function handler(
   const botType = getBotType(userAgent)
   const isHtmlBot = isHtmlBotRequest(req)
   const shouldWaitOnAllReady = isHtmlBot && isRoutePPREnabled
-  let serveStreamingMetadata = shouldServeStreamingMetadata(
-    userAgent,
-    // @ts-expect-error update for readonly
-    nextConfig.htmlLimitedBots
-  )
-
-  if (isHtmlBot && isRoutePPREnabled) {
-    isIsr = false
-    serveStreamingMetadata = false
-  }
 
   // If this is a dynamic route with PPR enabled and the default route
   // matches were set, then we should pass the fallback route params to
@@ -314,6 +304,24 @@ export async function handler(
   const isRevalidate =
     isIsr && !supportsDynamicResponse && !postponed && !isDynamicRSCRequest
 
+  let serveStreamingMetadata =
+    // During the export phase of `next build` we're hard-coding
+    // `serveStreamingMetadata` to `true`, so we need to do the same during
+    // revalidation.
+    isRevalidate ||
+    // Otherwise we're checking the user agent to decide if we should
+    // serve streaming metadata.
+    shouldServeStreamingMetadata(
+      userAgent,
+      // @ts-expect-error update for readonly
+      nextConfig.htmlLimitedBots
+    )
+
+  if (isHtmlBot && isRoutePPREnabled) {
+    isIsr = false
+    serveStreamingMetadata = false
+  }
+
   const method = req.method || 'GET'
   const tracer = getTracer()
   const activeSpan = tracer.getActiveScopeSpan()

filename: test/e2e/app-dir/ppr-metadata-streaming/app/partially-static/page.tsx
patch: @@ -0,0 +1,28 @@
+import { unstable_cacheLife as cacheLife } from 'next/cache'
+import { connection } from 'next/server'
+import { Suspense } from 'react'
+
+async function Dynamic() {
+  await connection()
+
+  return <p>Dynamic</p>
+}
+
+async function getCachedDate() {
+  'use cache'
+
+  cacheLife({ revalidate: 1, expire: 5 * 60 })
+
+  return new Date().toISOString()
+}
+
+export default async function Page() {
+  return (
+    <div>
+      <Suspense fallback={<p>Loading...</p>}>
+        <Dynamic />
+      </Suspense>
+      <p id="date">{await getCachedDate()}</p>
+    </div>
+  )
+}

filename: test/e2e/app-dir/ppr-metadata-streaming/next.config.js
patch: @@ -4,6 +4,7 @@
 const nextConfig = {
   experimental: {
     ppr: true,
+    useCache: true,
   },
 }
 

filename: test/e2e/app-dir/ppr-metadata-streaming/ppr-metadata-streaming.test.ts
patch: @@ -1,6 +1,6 @@
 import { nextTestSetup } from 'e2e-utils'
 import cheerio from 'cheerio'
-import { assertNoConsoleErrors } from 'next-test-utils'
+import { assertNoConsoleErrors, retry } from 'next-test-utils'
 
 function countSubstring(str: string, substr: string): number {
   return str.split(substr).length - 1
@@ -19,7 +19,9 @@ describe('ppr-metadata-streaming', () => {
       expect($(`${rootSelector} title`).text()).toBe('fully static')
       expect(countSubstring($.html(), '<title>')).toBe(1)
 
-      const browser = await next.browser('/fully-static')
+      const browser = await next.browser('/fully-static', {
+        pushErrorAsConsoleLog: true,
+      })
       expect(
         await browser.waitForElementByCss(`${rootSelector} title`).text()
       ).toBe('fully static')
@@ -31,7 +33,9 @@ describe('ppr-metadata-streaming', () => {
       expect($(`body title`).text()).toBe('dynamic page')
       expect(countSubstring($.html(), '<title>')).toBe(1)
 
-      const browser = await next.browser('/dynamic-page')
+      const browser = await next.browser('/dynamic-page', {
+        pushErrorAsConsoleLog: true,
+      })
       expect(await browser.waitForElementByCss('body title').text()).toBe(
         'dynamic page'
       )
@@ -46,7 +50,9 @@ describe('ppr-metadata-streaming', () => {
       expect($('body title').text()).toBe('fully dynamic')
       expect(countSubstring($.html(), '<title>')).toBe(1)
 
-      const browser = await next.browser('/fully-dynamic')
+      const browser = await next.browser('/fully-dynamic', {
+        pushErrorAsConsoleLog: true,
+      })
       expect(await browser.waitForElementByCss('body title').text()).toBe(
         'fully dynamic'
       )
@@ -72,7 +78,9 @@ describe('ppr-metadata-streaming', () => {
       expect($('body title').text()).toBe('dynamic-metadata - partial')
       expect(countSubstring($.html(), '<title>')).toBe(1)
 
-      const browser = await next.browser('/dynamic-metadata/partial')
+      const browser = await next.browser('/dynamic-metadata/partial', {
+        pushErrorAsConsoleLog: true,
+      })
       expect(await browser.waitForElementByCss('body title').text()).toBe(
         'dynamic-metadata - partial'
       )
@@ -85,12 +93,31 @@ describe('ppr-metadata-streaming', () => {
       expect($(`${rootSelector} title`).text()).toBe('dynamic-page - partial')
       expect(countSubstring($.html(), '<title>')).toBe(1)
 
-      const browser = await next.browser('/dynamic-page/partial')
+      const browser = await next.browser('/dynamic-page/partial', {
+        pushErrorAsConsoleLog: true,
+      })
       expect(
         await browser.waitForElementByCss(`${rootSelector} title`).text()
       ).toBe('dynamic-page - partial')
       await assertNoConsoleErrors(browser)
     })
+
+    it('should not yield hydration errors after revalidation', async () => {
+      const browser = await next.browser('/partially-static', {
+        pushErrorAsConsoleLog: true,
+      })
+
+      const initialDate = await browser.elementById('date').text()
+
+      // Wait for the background revalidation to complete.
+      await retry(async () => {
+        await browser.refresh()
+        expect(await browser.elementById('date').text()).not.toBe(initialDate)
+      })
+
+      // There should be no hydration errors after the revalidation.
+      await assertNoConsoleErrors(browser)
+    })
   })
 
   // Skip the deployment tests for html limited bots