From 75f0518397e268d34d492e626bcecab3c5e2481b Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 4 Dec 2024 12:30:12 -0500 Subject: [PATCH] [Segment Cache] Add isPartial to segment prefetch During a navigation, we should be able to skip the dynamic request if the prefetched data does not contain any dynamic holes. In the previous cache implementation, we only tracked this per route; in the Segment Cache, we must track this per segment. This updates the SegmentCacheEntry and CacheNodeSeedData types to include an `isPartial` field. The field is always false during a dynamic render, or when PPR is disabled. No behavior changes are included in this PR. --- .../fill-cache-with-new-subtree-data.test.tsx | 2 +- ...te-cache-below-flight-segmentpath.test.tsx | 2 +- .../client/components/segment-cache/cache.ts | 11 ++++++-- .../components/segment-cache/navigation.ts | 14 +++++++++- .../next/src/server/app-render/app-render.tsx | 1 + .../app-render/collect-segment-data.tsx | 26 +++++++++++++++++++ .../app-render/create-component-tree.tsx | 20 ++++++++++++++ packages/next/src/server/app-render/types.ts | 1 + 8 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.test.tsx b/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.test.tsx index 26e4bcf54cb25..39f1654977e3c 100644 --- a/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.test.tsx +++ b/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.test.tsx @@ -10,7 +10,7 @@ const getFlightData = (): NormalizedFlightData[] => { segmentPath: ['children', 'linking', 'children', 'about'], segment: 'about', tree: ['about', { children: ['', {}] }], - seedData: ['about',

SubTreeData Injected!

, {}, null], + seedData: ['about',

SubTreeData Injected!

, {}, null, false], head: 'Head Injected!', isRootRender: false, }, diff --git a/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.test.tsx b/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.test.tsx index f55e0d28746eb..e7be100bb5c84 100644 --- a/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.test.tsx +++ b/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.test.tsx @@ -11,7 +11,7 @@ const getFlightData = (): NormalizedFlightData[] => { segmentPath: ['children', 'linking', 'children', 'about'], segment: 'about', tree: ['about', { children: ['', {}] }], - seedData: ['about',

About Page!

, {}, null], + seedData: ['about',

About Page!

, {}, null, false], head: 'About page!', isRootRender: false, }, diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index 5f7316f425324..8be3535929fb1 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -117,6 +117,7 @@ type PendingSegmentCacheEntry = SegmentCacheEntryShared & { status: EntryStatus.Pending rsc: null loading: null + isPartial: true promise: null | PromiseWithResolvers } @@ -124,6 +125,7 @@ type RejectedSegmentCacheEntry = SegmentCacheEntryShared & { status: EntryStatus.Rejected rsc: null loading: null + isPartial: true promise: null } @@ -131,6 +133,7 @@ type FulfilledSegmentCacheEntry = SegmentCacheEntryShared & { status: EntryStatus.Fulfilled rsc: React.ReactNode | null loading: LoadingModuleData | Promise + isPartial: boolean promise: null } @@ -328,6 +331,7 @@ export function requestSegmentEntryFromCache( rsc: null, loading: null, staleAt: route.staleAt, + isPartial: true, promise: null, // LRU-related fields @@ -435,13 +439,15 @@ function fulfillSegmentCacheEntry( segmentCacheEntry: PendingSegmentCacheEntry, rsc: React.ReactNode, loading: LoadingModuleData | Promise, - staleAt: number + staleAt: number, + isPartial: boolean ) { const fulfilledEntry: FulfilledSegmentCacheEntry = segmentCacheEntry as any fulfilledEntry.status = EntryStatus.Fulfilled fulfilledEntry.rsc = rsc fulfilledEntry.loading = loading fulfilledEntry.staleAt = staleAt + fulfilledEntry.isPartial = isPartial // Resolve any listeners that were waiting for this data. if (segmentCacheEntry.promise !== null) { segmentCacheEntry.promise.resolve(fulfilledEntry) @@ -612,7 +618,8 @@ async function fetchSegmentEntryOnCacheMiss( serverData.loading, // TODO: The server does not currently provide per-segment stale time. // So we use the stale time of the route. - route.staleAt + route.staleAt, + serverData.isPartial ) } catch (error) { // Either the connection itself failed, or something bad happened while diff --git a/packages/next/src/client/components/segment-cache/navigation.ts b/packages/next/src/client/components/segment-cache/navigation.ts index 48d203a8e542c..39c2b2021df70 100644 --- a/packages/next/src/client/components/segment-cache/navigation.ts +++ b/packages/next/src/client/components/segment-cache/navigation.ts @@ -181,6 +181,7 @@ function readRenderSnapshotFromCache( let rsc: React.ReactNode | null = null let loading: LoadingModuleData | Promise = null + let isPartial: boolean = true const segmentEntry = readSegmentCacheEntry(now, tree.path) if (segmentEntry !== null) { @@ -189,6 +190,7 @@ function readRenderSnapshotFromCache( // Happy path: a cache hit rsc = segmentEntry.rsc loading = segmentEntry.loading + isPartial = segmentEntry.isPartial break } case EntryStatus.Pending: { @@ -202,6 +204,10 @@ function readRenderSnapshotFromCache( loading = promiseForFulfilledEntry.then((entry) => entry !== null ? entry.loading : null ) + // Since we don't know yet whether the segment is partial or fully + // static, we must assume it's partial; we can't skip the + // dynamic request. + isPartial = true break } case EntryStatus.Rejected: @@ -225,7 +231,13 @@ function readRenderSnapshotFromCache( null, isRootLayout, ], - seedData: [flightRouterStateSegment, rsc, childSeedDatas, loading], + seedData: [ + flightRouterStateSegment, + rsc, + childSeedDatas, + loading, + isPartial, + ], } } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 0a70918d33604..e829eb6efabcf 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -872,6 +872,7 @@ async function getErrorRSCPayload( , {}, null, + false, ] const globalErrorStyles = await getGlobalErrorStyles(tree, ctx) diff --git a/packages/next/src/server/app-render/collect-segment-data.tsx b/packages/next/src/server/app-render/collect-segment-data.tsx index 648ede3e9b1a3..a942588f5b1b9 100644 --- a/packages/next/src/server/app-render/collect-segment-data.tsx +++ b/packages/next/src/server/app-render/collect-segment-data.tsx @@ -59,6 +59,7 @@ export type SegmentPrefetch = { buildId: string rsc: React.ReactNode | null loading: LoadingModuleData | Promise + isPartial: boolean } export async function collectSegmentData( @@ -308,6 +309,7 @@ async function renderSegmentPrefetch( buildId, rsc, loading, + isPartial: await isPartialRSCData(rsc, clientModules), } // Since all we're doing is decoding and re-encoding a cached prerender, if // it takes longer than a microtask, it must because of hanging promises @@ -342,6 +344,30 @@ async function renderSegmentPrefetch( } } +async function isPartialRSCData( + rsc: React.ReactNode, + clientModules: ManifestNode +): Promise { + // We can determine if a segment contains only partial data if it takes longer + // than a task to encode, because dynamic data is encoded as an infinite + // promise. We must do this in a separate Flight prerender from the one that + // actually generates the prefetch stream because we need to include + // `isPartial` in the stream itself. + let isPartial = false + const abortController = new AbortController() + waitAtLeastOneReactRenderTask().then(() => { + // If we haven't yet finished the outer task, then it must be because we + // accessed dynamic data. + isPartial = true + abortController.abort() + }) + await prerender(rsc, clientModules, { + signal: abortController.signal, + onError() {}, + }) + return isPartial +} + // TODO: Consider updating or unifying this encoding logic for segments with // createRouterCacheKey on the client, perhaps by including it as part of // the FlightRouterState. Theoretically the client should never have to do its diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 392f6a9f1f1c9..eb1e6d27db5f7 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -322,6 +322,22 @@ async function createComponentTreeInternal({ const isStaticGeneration = workStore.isStaticGeneration + // Assume the segment we're rendering contains only partial data if PPR is + // enabled and this is a statically generated response. This is used by the + // client Segment Cache after a prefetch to determine if it can skip the + // second request to fill in the dynamic data. + // + // It's OK for this to be `true` when the data is actually fully static, but + // it's not OK for this to be `false` when the data possibly contains holes. + // Although the value here is overly pessimistic, for prefetches, it will be + // replaced by a more specific value when the data is later processed into + // per-segment responses (see collect-segment-data.tsx) + // + // For dynamic requests, this must always be `false` because dynamic responses + // are never partial. + const isPossiblyPartialResponse = + isStaticGeneration && experimental.isRoutePPREnabled === true + // If there's a dynamic usage error attached to the store, throw it. if (workStore.dynamicUsageErr) { throw workStore.dynamicUsageErr @@ -548,6 +564,7 @@ async function createComponentTreeInternal({ , parallelRouteCacheNodeSeedData, loadingData, + isPossiblyPartialResponse, ] } @@ -580,6 +597,7 @@ async function createComponentTreeInternal({ , parallelRouteCacheNodeSeedData, loadingData, + true, ] } @@ -650,6 +668,7 @@ async function createComponentTreeInternal({ , parallelRouteCacheNodeSeedData, loadingData, + isPossiblyPartialResponse, ] } else { const SegmentComponent = Component @@ -829,6 +848,7 @@ async function createComponentTreeInternal({ segmentNode, parallelRouteCacheNodeSeedData, loadingData, + isPossiblyPartialResponse, ] } } diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index d46ffccb0d1a5..750b52568b03a 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -97,6 +97,7 @@ export type CacheNodeSeedData = [ [parallelRouterKey: string]: CacheNodeSeedData | null }, loading: LoadingModuleData | Promise, + isPartial: boolean, ] export type FlightDataSegment = [