Skip to content

Commit eca6c95

Browse files
acdlitealii
authored andcommitted
[Segment Cache] Support dynamic head prefetching (vercel#81677)
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.
1 parent 1d06153 commit eca6c95

File tree

4 files changed

+102
-8
lines changed

4 files changed

+102
-8
lines changed

packages/next/src/client/components/segment-cache-impl/cache.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,16 @@ function writeDynamicRenderResponseIntoCache(
15341534
// TODO: We should cache this, too, so that the MPA navigation is immediate.
15351535
return null
15361536
}
1537+
1538+
const staleTimeHeaderSeconds = response.headers.get(
1539+
NEXT_ROUTER_STALE_TIME_HEADER
1540+
)
1541+
const staleTimeMs =
1542+
staleTimeHeaderSeconds !== null
1543+
? parseInt(staleTimeHeaderSeconds, 10) * 1000
1544+
: STATIC_STALETIME_MS
1545+
const staleAt = now + staleTimeMs
1546+
15371547
for (const flightData of flightDatas) {
15381548
const seedData = flightData.seedData
15391549
if (seedData !== null) {
@@ -1555,24 +1565,36 @@ function writeDynamicRenderResponseIntoCache(
15551565
encodeSegment(segment)
15561566
)
15571567
}
1558-
const staleTimeHeaderSeconds = response.headers.get(
1559-
NEXT_ROUTER_STALE_TIME_HEADER
1560-
)
1561-
const staleTimeMs =
1562-
staleTimeHeaderSeconds !== null
1563-
? parseInt(staleTimeHeaderSeconds, 10) * 1000
1564-
: STATIC_STALETIME_MS
1568+
15651569
writeSeedDataIntoCache(
15661570
now,
15671571
task,
15681572
route,
1569-
now + staleTimeMs,
1573+
staleAt,
15701574
seedData,
15711575
isResponsePartial,
15721576
segmentKey,
15731577
spawnedEntries
15741578
)
15751579
}
1580+
1581+
// During a dynamic request, the server sends back new head data for the
1582+
// page. Overwrite the existing head with the new one. Note that we're
1583+
// intentionally not taking into account whether the existing head is
1584+
// already complete, even though the incoming head might not have finished
1585+
// streaming in yet. This is to prioritize consistency of the head with
1586+
// the segment data (though it's still not a guarantee, since some of the
1587+
// segment data may be reused from a previous request).
1588+
route.head = flightData.head
1589+
route.isHeadPartial = flightData.isHeadPartial
1590+
// TODO: Currently the stale time of the route tree represents the
1591+
// stale time of both the route tree *and* all the segment data. So we
1592+
// can't just overwrite this field; we have to use whichever value is
1593+
// lower. In the future, though, the plan is to track segment lifetimes
1594+
// separately from the route tree lifetime.
1595+
if (staleAt < route.staleAt) {
1596+
route.staleAt = staleAt
1597+
}
15761598
}
15771599
// Any entry that's still pending was intentionally not rendered by the
15781600
// server, because it was inside the loading boundary. Mark them as rejected

test/e2e/app-dir/segment-cache/incremental-opt-in/app/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ export default function Page() {
4343
</li>
4444
</ul>
4545
</li>
46+
<li>
47+
<LinkAccordion
48+
prefetch={true}
49+
href="/ppr-disabled-dynamic-head?foo=yay"
50+
>
51+
Page with PPR disabled and a dynamic head, prefetch=true
52+
</LinkAccordion>
53+
</li>
4654
</ul>
4755
</>
4856
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Suspense } from 'react'
2+
import { connection } from 'next/server'
3+
import { Metadata } from 'next'
4+
5+
export async function generateMetadata({
6+
searchParams,
7+
}: {
8+
searchParams: Promise<{ foo: string }>
9+
}): Promise<Metadata> {
10+
const { foo } = await searchParams
11+
return {
12+
title: 'Dynamic Title: ' + (foo ?? '(empty)'),
13+
}
14+
}
15+
16+
async function Content() {
17+
await connection()
18+
return <div id="page-content">Page content</div>
19+
}
20+
21+
export default function PPRDisabledDynamicHead() {
22+
return (
23+
<Suspense fallback="Loading...">
24+
<Content />
25+
</Suspense>
26+
)
27+
}

test/e2e/app-dir/segment-cache/incremental-opt-in/segment-cache-incremental-opt-in.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,4 +475,41 @@ describe('segment cache (incremental opt in)', () => {
475475
)
476476
}
477477
)
478+
479+
it('fully prefetch a page with a dynamic title', async () => {
480+
let act
481+
const browser = await next.browser('/', {
482+
beforePageLoad(p) {
483+
act = createRouterAct(p)
484+
},
485+
})
486+
487+
await act(
488+
async () => {
489+
const checkbox = await browser.elementByCss(
490+
'input[data-link-accordion="/ppr-disabled-dynamic-head?foo=yay"]'
491+
)
492+
await checkbox.click()
493+
},
494+
// Because the link is prefetched with prefetch=true, we should be able to
495+
// prefetch the title, even though it's dynamic.
496+
{
497+
includes: 'Dynamic Title: yay',
498+
}
499+
)
500+
501+
// When we navigate to the page, it should not make any additional
502+
// network requests, because both the segment data and the head were
503+
// fully prefetched.
504+
await act(async () => {
505+
const link = await browser.elementByCss(
506+
'a[href="/ppr-disabled-dynamic-head?foo=yay"]'
507+
)
508+
await link.click()
509+
const pageContent = await browser.elementById('page-content')
510+
expect(await pageContent.text()).toBe('Page content')
511+
const title = await browser.eval(() => document.title)
512+
expect(title).toBe('Dynamic Title: yay')
513+
}, 'no-requests')
514+
})
478515
})

0 commit comments

Comments
 (0)