Skip to content

Commit 9da891d

Browse files
committed
[Segment Cache] Add "client-only" option
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 9da891d

File tree

17 files changed

+316
-18
lines changed

17 files changed

+316
-18
lines changed

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

Lines changed: 49 additions & 12 deletions
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,34 @@ 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 completeley 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+
writeDynamicRenderResponseIntoCache(
1341+
now,
1342+
task,
1343+
response,
1344+
serverData,
1345+
isResponsePartial,
1346+
fulfilledEntry,
1347+
null
1348+
)
13241349
}
13251350

13261351
function rejectSegmentEntriesIfStillPending(
@@ -1343,16 +1368,19 @@ function writeDynamicRenderResponseIntoCache(
13431368
task: PrefetchTask,
13441369
response: Response,
13451370
serverData: NavigationFlightResponse,
1371+
isResponsePartial: boolean,
13461372
route: FulfilledRouteCacheEntry,
1347-
spawnedEntries: Map<string, PendingSegmentCacheEntry>
1373+
spawnedEntries: Map<string, PendingSegmentCacheEntry> | null
13481374
): Array<FulfilledSegmentCacheEntry> | null {
13491375
if (serverData.b !== getAppBuildId()) {
13501376
// The server build does not match the client. Treat as a 404. During
13511377
// an actual navigation, the router will trigger an MPA navigation.
13521378
// TODO: Consider moving the build ID to a response header so we can check
13531379
// it before decoding the response, and so there's one way of checking
13541380
// across all response types.
1355-
rejectSegmentEntriesIfStillPending(spawnedEntries, now + 10 * 1000)
1381+
if (spawnedEntries !== null) {
1382+
rejectSegmentEntriesIfStillPending(spawnedEntries, now + 10 * 1000)
1383+
}
13561384
return null
13571385
}
13581386
const flightDatas = normalizeFlightData(serverData.f)
@@ -1395,6 +1423,7 @@ function writeDynamicRenderResponseIntoCache(
13951423
route,
13961424
now + staleTimeMs,
13971425
seedData,
1426+
isResponsePartial,
13981427
segmentKey,
13991428
spawnedEntries
14001429
)
@@ -1408,11 +1437,14 @@ function writeDynamicRenderResponseIntoCache(
14081437
// segments we're marking as rejected here. We should mark on the segment
14091438
// somehow that the reason for the rejection is because of a non-PPR prefetch.
14101439
// 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
1440+
if (spawnedEntries !== null) {
1441+
const fulfilledEntries = rejectSegmentEntriesIfStillPending(
1442+
spawnedEntries,
1443+
now + 10 * 1000
1444+
)
1445+
return fulfilledEntries
1446+
}
1447+
return null
14161448
}
14171449

14181450
function writeSeedDataIntoCache(
@@ -1421,8 +1453,9 @@ function writeSeedDataIntoCache(
14211453
route: FulfilledRouteCacheEntry,
14221454
staleAt: number,
14231455
seedData: CacheNodeSeedData,
1456+
isResponsePartial: boolean,
14241457
key: string,
1425-
entriesOwnedByCurrentTask: Map<string, PendingSegmentCacheEntry>
1458+
entriesOwnedByCurrentTask: Map<string, PendingSegmentCacheEntry> | null
14261459
) {
14271460
// This function is used to write the result of a dynamic server request
14281461
// (CacheNodeSeedData) into the prefetch cache. It's used in cases where we
@@ -1431,12 +1464,15 @@ function writeSeedDataIntoCache(
14311464
// dynamic data into being static) and when prefetching a PPR-disabled route
14321465
const rsc = seedData[1]
14331466
const loading = seedData[3]
1434-
const isPartial = rsc === null
1467+
const isPartial = rsc === null || isResponsePartial
14351468

14361469
// We should only write into cache entries that are owned by us. Or create
14371470
// a new one and write into that. We must never write over an entry that was
14381471
// created by a different task, because that causes data races.
1439-
const ownedEntry = entriesOwnedByCurrentTask.get(key)
1472+
const ownedEntry =
1473+
entriesOwnedByCurrentTask !== null
1474+
? entriesOwnedByCurrentTask.get(key)
1475+
: undefined
14401476
if (ownedEntry !== undefined) {
14411477
fulfillSegmentCacheEntry(ownedEntry, rsc, loading, staleAt, isPartial)
14421478
} else {
@@ -1481,6 +1517,7 @@ function writeSeedDataIntoCache(
14811517
route,
14821518
staleAt,
14831519
childSeedData,
1520+
isResponsePartial,
14841521
encodeChildSegmentKey(
14851522
key,
14861523
parallelRouteKey,

packages/next/src/export/index.ts

Lines changed: 4 additions & 1 deletion
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

Lines changed: 11 additions & 1 deletion
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 3 additions & 1 deletion
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

Lines changed: 3 additions & 1 deletion
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

Lines changed: 1 addition & 1 deletion
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 */
Lines changed: 3 additions & 0 deletions
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+
}
Lines changed: 8 additions & 0 deletions
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+
}
Lines changed: 3 additions & 0 deletions
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+
}
Lines changed: 6 additions & 0 deletions
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+
}
Lines changed: 11 additions & 0 deletions
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+
}
Lines changed: 35 additions & 0 deletions
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+
}
Lines changed: 3 additions & 0 deletions
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)