Skip to content

Commit 713caf6

Browse files
authored
[Segment Cache] Add "client-only" option (#77655)
We're currently working through some issues surfaced by the new per-segment routes introduced by the Segment Cache experiment. Testing of this feature in production has been blocked while we figure that out. However, aside from per-segment prefetching, there are a bunch of client-only changes and improvements to the Segment Cache implementation that we can test in the meantime. So, I've added a "client-only" option the `clientSegmentCache` flag. When enabled, Next.js will not generate per-segment prefetch responses, but the client will switch to the new prefetching implementation. This includes: - De-duping of shared layouts across prefetch requests. - More resilience to race conditions. - Prefetch cancellation on Link viewport exit. - Re-prefetching stale data after revalidateTag/revalidatePath. etc. It's essentially a complete rewrite of the client-side prefetching implementation. Given the scope of the change, it may contain subtle regressions that I haven't yet discovered. That's why it's so urgent for me to start testing this in real apps. I initially hesitated to implement the "client-only" option because 1) there are already so many combinations of flags and forked implementations to juggle, and I didn't want to add another one, and 2) I thought it would take less time to fix the deployment issues that are blocking the wider rollout. But it turned out to be less net new complexity than I anticipated, given that I already had to support `ppr: "incremental"`, which works in a largely similar way.
1 parent 8631228 commit 713caf6

File tree

17 files changed

+325
-18
lines changed

17 files changed

+325
-18
lines changed

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

+58-12
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,7 @@ export async function fetchRouteOnCacheMiss(
10281028

10291029
writeDynamicTreeResponseIntoCache(
10301030
Date.now(),
1031+
task,
10311032
response,
10321033
serverData,
10331034
entry,
@@ -1246,6 +1247,10 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
12461247
prefetchStream
12471248
) as Promise<NavigationFlightResponse>)
12481249

1250+
// Since we did not set the prefetch header, the response from the server
1251+
// will never contain dynamic holes.
1252+
const isResponsePartial = false
1253+
12491254
// Aside from writing the data into the cache, this function also returns
12501255
// the entries that were fulfilled, so we can streamingly update their sizes
12511256
// in the LRU as more data comes in.
@@ -1254,6 +1259,7 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
12541259
task,
12551260
response,
12561261
serverData,
1262+
isResponsePartial,
12571263
route,
12581264
spawnedEntries
12591265
)
@@ -1269,6 +1275,7 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
12691275

12701276
function writeDynamicTreeResponseIntoCache(
12711277
now: number,
1278+
task: PrefetchTask,
12721279
response: Response,
12731280
serverData: NavigationFlightResponse,
12741281
entry: PendingRouteCacheEntry,
@@ -1311,16 +1318,43 @@ function writeDynamicTreeResponseIntoCache(
13111318
staleTimeHeaderSeconds !== null
13121319
? parseInt(staleTimeHeaderSeconds, 10) * 1000
13131320
: STATIC_STALETIME_MS
1314-
fulfillRouteCacheEntry(
1321+
1322+
// If the response contains dynamic holes, then we must conservatively assume
1323+
// that any individual segment might contain dynamic holes, and also the
1324+
// head. If it did not contain dynamic holes, then we can assume every segment
1325+
// and the head is completely static.
1326+
const isResponsePartial =
1327+
response.headers.get(NEXT_DID_POSTPONE_HEADER) === '1'
1328+
1329+
const fulfilledEntry = fulfillRouteCacheEntry(
13151330
entry,
13161331
convertRootFlightRouterStateToRouteTree(flightRouterState),
13171332
flightData.head,
1318-
flightData.isHeadPartial,
1333+
isResponsePartial,
13191334
now + staleTimeMs,
13201335
couldBeIntercepted,
13211336
canonicalUrl,
13221337
routeIsPPREnabled
13231338
)
1339+
1340+
// If the server sent segment data as part of the response, we should write
1341+
// it into the cache to prevent a second, redundant prefetch request.
1342+
//
1343+
// TODO: When `clientSegmentCache` is enabled, the server does not include
1344+
// segment data when responding to a route tree prefetch request. However,
1345+
// when `clientSegmentCache` is set to "client-only", and PPR is enabled (or
1346+
// the page is fully static), the normal check is bypassed and the server
1347+
// responds with the full page. This is a temporary situation until we can
1348+
// remove the "client-only" option. Then, we can delete this function call.
1349+
writeDynamicRenderResponseIntoCache(
1350+
now,
1351+
task,
1352+
response,
1353+
serverData,
1354+
isResponsePartial,
1355+
fulfilledEntry,
1356+
null
1357+
)
13241358
}
13251359

13261360
function rejectSegmentEntriesIfStillPending(
@@ -1343,16 +1377,19 @@ function writeDynamicRenderResponseIntoCache(
13431377
task: PrefetchTask,
13441378
response: Response,
13451379
serverData: NavigationFlightResponse,
1380+
isResponsePartial: boolean,
13461381
route: FulfilledRouteCacheEntry,
1347-
spawnedEntries: Map<string, PendingSegmentCacheEntry>
1382+
spawnedEntries: Map<string, PendingSegmentCacheEntry> | null
13481383
): Array<FulfilledSegmentCacheEntry> | null {
13491384
if (serverData.b !== getAppBuildId()) {
13501385
// The server build does not match the client. Treat as a 404. During
13511386
// an actual navigation, the router will trigger an MPA navigation.
13521387
// TODO: Consider moving the build ID to a response header so we can check
13531388
// it before decoding the response, and so there's one way of checking
13541389
// across all response types.
1355-
rejectSegmentEntriesIfStillPending(spawnedEntries, now + 10 * 1000)
1390+
if (spawnedEntries !== null) {
1391+
rejectSegmentEntriesIfStillPending(spawnedEntries, now + 10 * 1000)
1392+
}
13561393
return null
13571394
}
13581395
const flightDatas = normalizeFlightData(serverData.f)
@@ -1395,6 +1432,7 @@ function writeDynamicRenderResponseIntoCache(
13951432
route,
13961433
now + staleTimeMs,
13971434
seedData,
1435+
isResponsePartial,
13981436
segmentKey,
13991437
spawnedEntries
14001438
)
@@ -1408,11 +1446,14 @@ function writeDynamicRenderResponseIntoCache(
14081446
// segments we're marking as rejected here. We should mark on the segment
14091447
// somehow that the reason for the rejection is because of a non-PPR prefetch.
14101448
// That way a per-segment prefetch knows to disregard the rejection.
1411-
const fulfilledEntries = rejectSegmentEntriesIfStillPending(
1412-
spawnedEntries,
1413-
now + 10 * 1000
1414-
)
1415-
return fulfilledEntries
1449+
if (spawnedEntries !== null) {
1450+
const fulfilledEntries = rejectSegmentEntriesIfStillPending(
1451+
spawnedEntries,
1452+
now + 10 * 1000
1453+
)
1454+
return fulfilledEntries
1455+
}
1456+
return null
14161457
}
14171458

14181459
function writeSeedDataIntoCache(
@@ -1421,8 +1462,9 @@ function writeSeedDataIntoCache(
14211462
route: FulfilledRouteCacheEntry,
14221463
staleAt: number,
14231464
seedData: CacheNodeSeedData,
1465+
isResponsePartial: boolean,
14241466
key: string,
1425-
entriesOwnedByCurrentTask: Map<string, PendingSegmentCacheEntry>
1467+
entriesOwnedByCurrentTask: Map<string, PendingSegmentCacheEntry> | null
14261468
) {
14271469
// This function is used to write the result of a dynamic server request
14281470
// (CacheNodeSeedData) into the prefetch cache. It's used in cases where we
@@ -1431,12 +1473,15 @@ function writeSeedDataIntoCache(
14311473
// dynamic data into being static) and when prefetching a PPR-disabled route
14321474
const rsc = seedData[1]
14331475
const loading = seedData[3]
1434-
const isPartial = rsc === null
1476+
const isPartial = rsc === null || isResponsePartial
14351477

14361478
// We should only write into cache entries that are owned by us. Or create
14371479
// a new one and write into that. We must never write over an entry that was
14381480
// created by a different task, because that causes data races.
1439-
const ownedEntry = entriesOwnedByCurrentTask.get(key)
1481+
const ownedEntry =
1482+
entriesOwnedByCurrentTask !== null
1483+
? entriesOwnedByCurrentTask.get(key)
1484+
: undefined
14401485
if (ownedEntry !== undefined) {
14411486
fulfillSegmentCacheEntry(ownedEntry, rsc, loading, staleAt, isPartial)
14421487
} else {
@@ -1481,6 +1526,7 @@ function writeSeedDataIntoCache(
14811526
route,
14821527
staleAt,
14831528
childSeedData,
1529+
isResponsePartial,
14841530
encodeChildSegmentKey(
14851531
key,
14861532
parallelRouteKey,

packages/next/src/export/index.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,10 @@ async function exportAppImpl(
391391
clientTraceMetadata: nextConfig.experimental.clientTraceMetadata,
392392
expireTime: nextConfig.expireTime,
393393
dynamicIO: nextConfig.experimental.dynamicIO ?? false,
394-
clientSegmentCache: nextConfig.experimental.clientSegmentCache ?? false,
394+
clientSegmentCache:
395+
nextConfig.experimental.clientSegmentCache === 'client-only'
396+
? 'client-only'
397+
: Boolean(nextConfig.experimental.clientSegmentCache),
395398
inlineCss: nextConfig.experimental.inlineCss ?? false,
396399
authInterrupts: !!nextConfig.experimental.authInterrupts,
397400
},

packages/next/src/server/app-render/app-render.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -4212,7 +4212,17 @@ async function collectSegmentData(
42124212
// decomposed into a separate stream per segment.
42134213

42144214
const clientReferenceManifest = renderOpts.clientReferenceManifest
4215-
if (!clientReferenceManifest || !renderOpts.experimental.clientSegmentCache) {
4215+
if (
4216+
!clientReferenceManifest ||
4217+
// Do not generate per-segment data unless the experimental Segment Cache
4218+
// flag is enabled.
4219+
//
4220+
// We also skip generating segment data if flag is set to "client-only",
4221+
// rather than true. (The "client-only" option only affects the behavior of
4222+
// the client-side implementation; per-segment prefetches are intentionally
4223+
// disabled in that configuration).
4224+
renderOpts.experimental.clientSegmentCache !== true
4225+
) {
42164226
return
42174227
}
42184228

packages/next/src/server/app-render/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ export interface RenderOptsPartial {
213213
expireTime: number | undefined
214214
clientTraceMetadata: string[] | undefined
215215
dynamicIO: boolean
216-
clientSegmentCache: boolean
216+
clientSegmentCache: boolean | 'client-only'
217217
inlineCss: boolean
218218
authInterrupts: boolean
219219
}

packages/next/src/server/base-server.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,9 @@ export default abstract class Server<
605605
clientTraceMetadata: this.nextConfig.experimental.clientTraceMetadata,
606606
dynamicIO: this.nextConfig.experimental.dynamicIO ?? false,
607607
clientSegmentCache:
608-
this.nextConfig.experimental.clientSegmentCache ?? false,
608+
this.nextConfig.experimental.clientSegmentCache === 'client-only'
609+
? 'client-only'
610+
: Boolean(this.nextConfig.experimental.clientSegmentCache),
609611
inlineCss: this.nextConfig.experimental.inlineCss ?? false,
610612
authInterrupts: !!this.nextConfig.experimental.authInterrupts,
611613
},

packages/next/src/server/config-schema.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,9 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
299299
memoryBasedWorkersCount: z.boolean().optional(),
300300
craCompat: z.boolean().optional(),
301301
caseSensitiveRoutes: z.boolean().optional(),
302-
clientSegmentCache: z.boolean().optional(),
302+
clientSegmentCache: z
303+
.union([z.boolean(), z.literal('client-only')])
304+
.optional(),
303305
disableOptimizedLoading: z.boolean().optional(),
304306
disablePostcssPresetEnv: z.boolean().optional(),
305307
dynamicIO: z.boolean().optional(),

packages/next/src/server/config-shared.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ export interface ExperimentalConfig {
269269
prerenderEarlyExit?: boolean
270270
linkNoTouchStart?: boolean
271271
caseSensitiveRoutes?: boolean
272-
clientSegmentCache?: boolean
272+
clientSegmentCache?: boolean | 'client-only'
273273
appDocumentPreloading?: boolean
274274
preloadEntriesOnStart?: boolean
275275
/** @default true */
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Loading() {
2+
return <div>Loading dynamic content...</div>
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { connection } from 'next/server'
2+
3+
export const experimental_ppr = true
4+
5+
export default async function DynamicPage() {
6+
await connection()
7+
return <div id="dynamic-content">Dynamic Content</div>
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Loading() {
2+
return <div>Loading dynamic content...</div>
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { connection } from 'next/server'
2+
3+
export default async function DynamicPage() {
4+
await connection()
5+
return <div id="dynamic-content">Dynamic Content</div>
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function RootLayout({
2+
children,
3+
}: {
4+
children: React.ReactNode
5+
}) {
6+
return (
7+
<html lang="en">
8+
<body>{children}</body>
9+
</html>
10+
)
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { LinkAccordion } from '../components/link-accordion'
2+
3+
export default function Page() {
4+
return (
5+
<div>
6+
<h1>
7+
<code>clientSegmentCache: 'client-only'</code>
8+
</h1>
9+
<p>
10+
This page demonstrates the behavior when the experimental Segment Cache
11+
flag is set to <code>'client-only'</code> instead of <code>true</code>.
12+
The new prefetching implementation is enabled on the client, but the
13+
server will not generate any per-segment prefetching. This is useful
14+
because the client implementation has many other improvements that are
15+
unrelated to per-segment prefetching, like improved scheduling and
16+
dynamic request deduping.
17+
</p>
18+
<ul>
19+
<li>
20+
<LinkAccordion href="/static">Static page</LinkAccordion>
21+
</li>
22+
<li>
23+
<LinkAccordion href="/dynamic-with-ppr">
24+
Dynamic page (with PPR enabled)
25+
</LinkAccordion>
26+
</li>
27+
<li>
28+
<LinkAccordion href="/dynamic-without-ppr">
29+
Dynamic page (without PPR enabled)
30+
</LinkAccordion>
31+
</li>
32+
</ul>
33+
</div>
34+
)
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function StaticPage() {
2+
return <div id="static-content">Static Content</div>
3+
}

0 commit comments

Comments
 (0)