diff --git a/packages/next/src/client/components/segment-cache-impl/cache.ts b/packages/next/src/client/components/segment-cache-impl/cache.ts index 39373cba8da2e..16efe06352b5e 100644 --- a/packages/next/src/client/components/segment-cache-impl/cache.ts +++ b/packages/next/src/client/components/segment-cache-impl/cache.ts @@ -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 diff --git a/test/e2e/app-dir/segment-cache/incremental-opt-in/app/page.tsx b/test/e2e/app-dir/segment-cache/incremental-opt-in/app/page.tsx index 90db1c0f9c92f..1c3c2e2ac7744 100644 --- a/test/e2e/app-dir/segment-cache/incremental-opt-in/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/incremental-opt-in/app/page.tsx @@ -43,6 +43,14 @@ export default function Page() { +
  • + + Page with PPR disabled and a dynamic head, prefetch=true + +
  • ) diff --git a/test/e2e/app-dir/segment-cache/incremental-opt-in/app/ppr-disabled-dynamic-head/page.tsx b/test/e2e/app-dir/segment-cache/incremental-opt-in/app/ppr-disabled-dynamic-head/page.tsx new file mode 100644 index 0000000000000..3d3488f4b41d5 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/incremental-opt-in/app/ppr-disabled-dynamic-head/page.tsx @@ -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 { + const { foo } = await searchParams + return { + title: 'Dynamic Title: ' + (foo ?? '(empty)'), + } +} + +async function Content() { + await connection() + return
    Page content
    +} + +export default function PPRDisabledDynamicHead() { + return ( + + + + ) +} diff --git a/test/e2e/app-dir/segment-cache/incremental-opt-in/segment-cache-incremental-opt-in.test.ts b/test/e2e/app-dir/segment-cache/incremental-opt-in/segment-cache-incremental-opt-in.test.ts index de6705a824bc6..a7cad8ea2599b 100644 --- a/test/e2e/app-dir/segment-cache/incremental-opt-in/segment-cache-incremental-opt-in.test.ts +++ b/test/e2e/app-dir/segment-cache/incremental-opt-in/segment-cache-incremental-opt-in.test.ts @@ -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') + }) })