Skip to content

Commit

Permalink
Allow prefetches to skip already cached segments
Browse files Browse the repository at this point in the history
During a non-PPR prefetch, where we render up to the nearest loading
boundary, we should be able to skip segments that are already cached on
the client. To do this, we need to be able to tell the server that
a segment is already cached while also indicating where the "new" part
of the navigation starts — that is, the part inside the shared layout.

To do this, I added a new kind of marker to FlightRouterState called
"inside-shared-layout". It's similar to the "refresh" marker but has
different semantics. I tried my best to explain how it's used in the
code comments.

This implements the marker but does not yet change any behavior on the
client. I'm submitting this as its own PR to confirm that none of the
existing behavior regresses. I'll follow up with the client changes
in a separate PR.

As another follow-up, we're overdue for a redesign of the protocol for
sending dynamic requests. I'm not sure it makes sense to use the
FlightRouterState type, given that it's overloaded with so many
other concerns.
  • Loading branch information
acdlite committed Dec 16, 2024
1 parent 8994d31 commit 8d76dc7
Show file tree
Hide file tree
Showing 2 changed files with 115 additions and 52 deletions.
41 changes: 34 additions & 7 deletions packages/next/src/server/app-render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,15 @@ export const flightRouterStateSchema: s.Describe<any> = s.tuple([
s.lazy(() => flightRouterStateSchema)
),
s.optional(s.nullable(s.string())),
s.optional(s.nullable(s.union([s.literal('refetch'), s.literal('refresh')]))),
s.optional(
s.nullable(
s.union([
s.literal('refetch'),
s.literal('refresh'),
s.literal('inside-shared-layout'),
])
)
),
s.optional(s.boolean()),
])

Expand All @@ -57,13 +65,32 @@ export type FlightRouterState = [
segment: Segment,
parallelRoutes: { [parallelRouterKey: string]: FlightRouterState },
url?: string | null,
/*
/* "refresh" and "refetch", despite being similarly named, have different semantics.
* - "refetch" is a server indicator which informs where rendering should start from.
* - "refresh" is a client router indicator that it should re-fetch the data from the server for the current segment.
* It uses the "url" property above to determine where to fetch from.
/**
* "refresh" and "refetch", despite being similarly named, have different
* semantics:
* - "refetch" is used during a requets to inform the server where rendering
* should start from.
*
* - "refresh" is used by the client to mark that a segmentshould re-fetch the
* data from the server for the current segment. It uses the "url" property
* above to determine where to fetch from.
*
* - "inside-shared-layout" is used during a prefetch request to inform the
* server that even if the segment matches, it should be treated as if it's
* within the "new" part of a navigation — inside the shared layout. If
* the segment doesn't match, then it has no effect, since it would be
* treated as new regardless. If it does match, though, the server does not
* need to render it, because the client already has it.
*
* A bit confusing, but that's because it has only one extremely narrow use
* case — during a non-PPR prefetch, the server uses it to find the first
* loading boundary beneath a shared layout.
*
* TODO: We should rethink the protocol for dynamic requests. It might not
* make for the client to send a FlightRouterState, since this type is
* overloaded with concerns.
*/
refresh?: 'refetch' | 'refresh' | null,
refresh?: 'refetch' | 'refresh' | 'inside-shared-layout' | null,
isRootLayout?: boolean,
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export async function walkTreeWithFlightRouterState({
loaderTreeToFilter,
parentParams,
flightRouterState,
parentIsInsideSharedLayout,
rscPayloadHead,
injectedCSS,
injectedJS,
Expand All @@ -41,6 +42,7 @@ export async function walkTreeWithFlightRouterState({
loaderTreeToFilter: LoaderTree
parentParams: { [key: string]: string | string[] }
flightRouterState?: FlightRouterState
parentIsInsideSharedLayout?: boolean
rscPayloadHead: React.ReactNode
injectedCSS: Set<string>
injectedJS: Set<string>
Expand Down Expand Up @@ -107,7 +109,24 @@ export async function walkTreeWithFlightRouterState({
// to ensure prefetches are quick and inexpensive. If there's no `loading` component anywhere in the tree being rendered,
// the prefetch will be short-circuited to avoid requesting a potentially very expensive subtree. If there's a `loading`
// somewhere in the tree, we'll recursively render the component tree up until we encounter that loading component, and then stop.
const shouldSkipComponentTree =

// Check if we're inside the "new" part of the navigation — inside the
// shared layout. In the case of a prefetch, this can be true even if the
// segment matches, because the client might send a matching segment to
// indicate that it already has the data in its cache. But in order to find
// the correct loading boundary, we still need to track where the shared
// layout begins.
//
// TODO: We should rethink the protocol for dynamic requests. It might not
// make sense for the client to send a FlightRouterState, since that type is
// overloaded with other concerns.
const isInsideSharedLayout =
renderComponentsOnThisLevel ||
parentIsInsideSharedLayout ||
flightRouterState[3] === 'inside-shared-layout'

if (
isInsideSharedLayout &&
!experimental.isRoutePPREnabled &&
// If PPR is disabled, and this is a request for the route tree, then we
// never render any components. Only send the router state.
Expand All @@ -116,10 +135,44 @@ export async function walkTreeWithFlightRouterState({
(isPrefetch &&
!Boolean(modules.loading) &&
!hasLoadingComponentInTree(loaderTreeToFilter)))
) {
// Send only the router state.
// TODO: Even for a dynamic route, we should cache these responses,
// because they do not contain any render data (neither segment data nor
// the head). They can be made even more cacheable once we move the route
// params into a separate data structure.
const overriddenSegment =
flightRouterState &&
// TODO: Why does canSegmentBeOverridden exist? Why don't we always just
// use `actualSegment`? Is it to avoid overwriting some state that's
// tracked by the client? Dig deeper to see if we can simplify this.
canSegmentBeOverridden(actualSegment, flightRouterState[0])
? flightRouterState[0]
: actualSegment

const routerState = createFlightRouterStateFromLoaderTree(
// Create router state using the slice of the loaderTree
loaderTreeToFilter,
getDynamicParamFromSegment,
query
)
return [
[
overriddenSegment,
routerState,
null,
null,
false,
] satisfies FlightDataSegment,
]
}

if (renderComponentsOnThisLevel) {
const overriddenSegment =
flightRouterState &&
// TODO: Why does canSegmentBeOverridden exist? Why don't we always just
// use `actualSegment`? Is it to avoid overwriting some state that's
// tracked by the client? Dig deeper to see if we can simplify this.
canSegmentBeOverridden(actualSegment, flightRouterState[0])
? flightRouterState[0]
: actualSegment
Expand All @@ -130,51 +183,33 @@ export async function walkTreeWithFlightRouterState({
getDynamicParamFromSegment,
query
)
// Create component tree using the slice of the loaderTree
const seedData = await createComponentTree(
// This ensures flightRouterPath is valid and filters down the tree
{
ctx,
loaderTree: loaderTreeToFilter,
parentParams: currentParams,
injectedCSS,
injectedJS,
injectedFontPreloadTags,
// This is intentionally not "rootLayoutIncludedAtThisLevelOrAbove" as createComponentTree starts at the current level and does a check for "rootLayoutAtThisLevel" too.
rootLayoutIncluded,
getMetadataReady,
preloadCallbacks,
authInterrupts: experimental.authInterrupts,
}
)

if (shouldSkipComponentTree) {
// Send only the router state.
// TODO: Even for a dynamic route, we should cache these responses,
// because they do not contain any render data (neither segment data nor
// the head). They can be made even more cacheable once we move the route
// params into a separate data structure.
return [
[
overriddenSegment,
routerState,
null,
null,
false,
] satisfies FlightDataSegment,
]
} else {
// Create component tree using the slice of the loaderTree
const seedData = await createComponentTree(
// This ensures flightRouterPath is valid and filters down the tree
{
ctx,
loaderTree: loaderTreeToFilter,
parentParams: currentParams,
injectedCSS,
injectedJS,
injectedFontPreloadTags,
// This is intentionally not "rootLayoutIncludedAtThisLevelOrAbove" as createComponentTree starts at the current level and does a check for "rootLayoutAtThisLevel" too.
rootLayoutIncluded,
getMetadataReady,
preloadCallbacks,
authInterrupts: experimental.authInterrupts,
}
)

return [
[
overriddenSegment,
routerState,
seedData,
rscPayloadHead,
false,
] satisfies FlightDataSegment,
]
}
return [
[
overriddenSegment,
routerState,
seedData,
rscPayloadHead,
false,
] satisfies FlightDataSegment,
]
}

// If we are not rendering on this level we need to check if the current
Expand Down Expand Up @@ -213,6 +248,7 @@ export async function walkTreeWithFlightRouterState({
parentParams: currentParams,
flightRouterState:
flightRouterState && flightRouterState[1][parallelRouteKey],
parentIsInsideSharedLayout: isInsideSharedLayout,
rscPayloadHead,
injectedCSS: injectedCSSWithCurrentLayout,
injectedJS: injectedJSWithCurrentLayout,
Expand Down

0 comments on commit 8d76dc7

Please # to comment.