Repository: vercel/next.js
Pull request: [Segment Cache] Support dynamic head prefetching

Fixes an issue where opting into dynamic prefetching with prefetch={true} would not apply to head data (like the title), only the page data. Although the head was being sent by the server as part of the prefetch response, it wasn't being transferred correctly to the prefetch cache.

The net result is that you can now fully prefetch a page with a dynamic title without any additional network requests on navigation.

Changed files:
filename: packages/next/src/client/components/segment-cache-impl/cache.ts
patch: @@ -1534,6 +1534,16 @@ function writeDynamicRenderResponseIntoCache(
     // TODO: We should cache this, too, so that the MPA navigation is immediate.
     return null
   }
+
+  const staleTimeHeaderSeconds = response.headers.get(
+    NEXT_ROUTER_STALE_TIME_HEADER
+  )
+  const staleTimeMs =
+    staleTimeHeaderSeconds !== null
+      ? parseInt(staleTimeHeaderSeconds, 10) * 1000
+      : STATIC_STALETIME_MS
+  const staleAt = now + staleTimeMs
+
   for (const flightData of flightDatas) {
     const seedData = flightData.seedData
     if (seedData !== null) {
@@ -1555,24 +1565,36 @@ function writeDynamicRenderResponseIntoCache(
           encodeSegment(segment)
         )
       }
-      const staleTimeHeaderSeconds = response.headers.get(
-        NEXT_ROUTER_STALE_TIME_HEADER
-      )
-      const staleTimeMs =
-        staleTimeHeaderSeconds !== null
-          ? parseInt(staleTimeHeaderSeconds, 10) * 1000
-          : STATIC_STALETIME_MS
+
       writeSeedDataIntoCache(
         now,
         task,
         route,
-        now + staleTimeMs,
+        staleAt,
         seedData,
         isResponsePartial,
         segmentKey,
         spawnedEntries
       )
     }
+
+    // During a dynamic request, the server sends back new head data for the
+    // page. Overwrite the existing head with the new one. Note that we're
+    // intentionally not taking into account whether the existing head is
+    // already complete, even though the incoming head might not have finished
+    // streaming in yet. This is to prioritize consistency of the head with
+    // the segment data (though it's still not a guarantee, since some of the
+    // segment data may be reused from a previous request).
+    route.head = flightData.head
+    route.isHeadPartial = flightData.isHeadPartial
+    // TODO: Currently the stale time of the route tree represents the
+    // stale time of both the route tree *and* all the segment data. So we
+    // can't just overwrite this field; we have to use whichever value is
+    // lower. In the future, though, the plan is to track segment lifetimes
+    // separately from the route tree lifetime.
+    if (staleAt < route.staleAt) {
+      route.staleAt = staleAt
+    }
   }
   // Any entry that's still pending was intentionally not rendered by the
   // server, because it was inside the loading boundary. Mark them as rejected

filename: test/e2e/app-dir/segment-cache/incremental-opt-in/app/page.tsx
patch: @@ -43,6 +43,14 @@ export default function Page() {
             </li>
           </ul>
         </li>
+        <li>
+          <LinkAccordion
+            prefetch={true}
+            href="/ppr-disabled-dynamic-head?foo=yay"
+          >
+            Page with PPR disabled and a dynamic head, prefetch=true
+          </LinkAccordion>
+        </li>
       </ul>
     </>
   )

filename: test/e2e/app-dir/segment-cache/incremental-opt-in/app/ppr-disabled-dynamic-head/page.tsx
patch: @@ -0,0 +1,27 @@
+import { Suspense } from 'react'
+import { connection } from 'next/server'
+import { Metadata } from 'next'
+
+export async function generateMetadata({
+  searchParams,
+}: {
+  searchParams: Promise<{ foo: string }>
+}): Promise<Metadata> {
+  const { foo } = await searchParams
+  return {
+    title: 'Dynamic Title: ' + (foo ?? '(empty)'),
+  }
+}
+
+async function Content() {
+  await connection()
+  return <div id="page-content">Page content</div>
+}
+
+export default function PPRDisabledDynamicHead() {
+  return (
+    <Suspense fallback="Loading...">
+      <Content />
+    </Suspense>
+  )
+}

filename: test/e2e/app-dir/segment-cache/incremental-opt-in/segment-cache-incremental-opt-in.test.ts
patch: @@ -475,4 +475,41 @@ describe('segment cache (incremental opt in)', () => {
       )
     }
   )
+
+  it('fully prefetch a page with a dynamic title', async () => {
+    let act
+    const browser = await next.browser('/', {
+      beforePageLoad(p) {
+        act = createRouterAct(p)
+      },
+    })
+
+    await act(
+      async () => {
+        const checkbox = await browser.elementByCss(
+          'input[data-link-accordion="/ppr-disabled-dynamic-head?foo=yay"]'
+        )
+        await checkbox.click()
+      },
+      // Because the link is prefetched with prefetch=true, we should be able to
+      // prefetch the title, even though it's dynamic.
+      {
+        includes: 'Dynamic Title: yay',
+      }
+    )
+
+    // When we navigate to the page, it should not make any additional
+    // network requests, because both the segment data and the head were
+    // fully prefetched.
+    await act(async () => {
+      const link = await browser.elementByCss(
+        'a[href="/ppr-disabled-dynamic-head?foo=yay"]'
+      )
+      await link.click()
+      const pageContent = await browser.elementById('page-content')
+      expect(await pageContent.text()).toBe('Page content')
+      const title = await browser.eval(() => document.title)
+      expect(title).toBe('Dynamic Title: yay')
+    }, 'no-requests')
+  })
 })