diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 32e407f65842b..e319759869d53 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -279,11 +279,12 @@ function Router({ ? // Unlike the old implementation, the Segment Cache doesn't store its // data in the router reducer state; it writes into a global mutable // cache. So we don't need to dispatch an action. - (href) => + (href, options) => prefetchWithSegmentCache( href, actionQueue.state.nextUrl, - actionQueue.state.tree + actionQueue.state.tree, + options?.kind === PrefetchKind.FULL ) : (href, options) => { // Use the old prefetch implementation. diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index 6fd8585fc0e1c..c981a11b7b529 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -147,6 +147,7 @@ export type RouteCacheEntry = export const enum FetchStrategy { PPR, + Full, LoadingBoundary, } @@ -1005,9 +1006,10 @@ export async function fetchSegmentOnCacheMiss( } } -export async function fetchSegmentPrefetchesForPPRDisabledRoute( +export async function fetchSegmentPrefetchesUsingDynamicRequest( task: PrefetchTask, route: FulfilledRouteCacheEntry, + fetchStrategy: FetchStrategy, dynamicRequestTree: FlightRouterState, spawnedEntries: Map ): Promise | null> { @@ -1015,7 +1017,6 @@ export async function fetchSegmentPrefetchesForPPRDisabledRoute( const nextUrl = task.key.nextUrl const headers: RequestHeaders = { [RSC_HEADER]: '1', - [NEXT_ROUTER_PREFETCH_HEADER]: '1', [NEXT_ROUTER_STATE_TREE_HEADER]: encodeURIComponent( JSON.stringify(dynamicRequestTree) ), @@ -1023,6 +1024,13 @@ export async function fetchSegmentPrefetchesForPPRDisabledRoute( if (nextUrl !== null) { headers[NEXT_URL] = nextUrl } + // Only set the prefetch header if we're not doing a "full" prefetch. We + // omit the prefetch header from a full prefetch because it's essentially + // just a navigation request that happens ahead of time — it should include + // all the same data in the response. + if (fetchStrategy !== FetchStrategy.Full) { + headers[NEXT_ROUTER_PREFETCH_HEADER] = '1' + } try { const response = await fetchPrefetchResponse(href, headers) if (!response || !response.ok || !response.body) { diff --git a/packages/next/src/client/components/segment-cache/prefetch.ts b/packages/next/src/client/components/segment-cache/prefetch.ts index 83f03014b0f2d..7f7fa0c2607e3 100644 --- a/packages/next/src/client/components/segment-cache/prefetch.ts +++ b/packages/next/src/client/components/segment-cache/prefetch.ts @@ -7,11 +7,18 @@ import { schedulePrefetchTask } from './scheduler' * Entrypoint for prefetching a URL into the Segment Cache. * @param href - The URL to prefetch. Typically this will come from a , * or router.prefetch. It must be validated before we attempt to prefetch it. + * @param nextUrl - A special header used by the server for interception routes. + * Roughly corresponds to the current URL. + * @param treeAtTimeOfPrefetch - The FlightRouterState at the time the prefetch + * was requested. This is only used when PPR is disabled. + * @param includeDynamicData - Whether to prefetch dynamic data, in addition to + * static data. This is used by . */ export function prefetch( href: string, nextUrl: string | null, - treeAtTimeOfPrefetch: FlightRouterState + treeAtTimeOfPrefetch: FlightRouterState, + includeDynamicData: boolean ) { const url = createPrefetchURL(href) if (url === null) { @@ -19,5 +26,5 @@ export function prefetch( return } const cacheKey = createCacheKey(url.href, nextUrl) - schedulePrefetchTask(cacheKey, treeAtTimeOfPrefetch) + schedulePrefetchTask(cacheKey, treeAtTimeOfPrefetch, includeDynamicData) } diff --git a/packages/next/src/client/components/segment-cache/scheduler.ts b/packages/next/src/client/components/segment-cache/scheduler.ts index 866a3b643e71b..0ba95ac86f973 100644 --- a/packages/next/src/client/components/segment-cache/scheduler.ts +++ b/packages/next/src/client/components/segment-cache/scheduler.ts @@ -13,7 +13,7 @@ import { type RouteCacheEntry, type SegmentCacheEntry, type RouteTree, - fetchSegmentPrefetchesForPPRDisabledRoute, + fetchSegmentPrefetchesUsingDynamicRequest, type PendingSegmentCacheEntry, convertRouteTreeToFlightRouterState, FetchStrategy, @@ -21,6 +21,8 @@ import { upsertSegmentEntry, type FulfilledSegmentCacheEntry, upgradeToPendingSegment, + waitForSegmentCacheEntry, + resetRevalidatingSegmentEntry, } from './cache' import type { RouteCacheKey } from './cache-key' @@ -46,6 +48,12 @@ export type PrefetchTask = { */ treeAtTimeOfPrefetch: FlightRouterState + /** + * Whether to prefetch dynamic data, in addition to static data. This is + * used by . + */ + includeDynamicData: boolean + /** * sortId is an incrementing counter * @@ -158,10 +166,13 @@ let didScheduleMicrotask = false * * @param key The RouteCacheKey to prefetch. * @param treeAtTimeOfPrefetch The app's current FlightRouterState + * @param includeDynamicData Whether to prefetch dynamic data, in addition to + * static data. This is used by . */ export function schedulePrefetchTask( key: RouteCacheKey, - treeAtTimeOfPrefetch: FlightRouterState + treeAtTimeOfPrefetch: FlightRouterState, + includeDynamicData: boolean ): void { // Spawn a new prefetch task const task: PrefetchTask = { @@ -169,6 +180,7 @@ export function schedulePrefetchTask( treeAtTimeOfPrefetch, priority: PrefetchPriority.Default, hasBackgroundWork: false, + includeDynamicData, sortId: sortIdCounter++, isBlocked: false, _heapIndex: -1, @@ -388,64 +400,68 @@ function pingRootRouteTree( return PrefetchTaskExitStatus.InProgress } const tree = route.tree - if (route.isPPREnabled) { - return pingRouteTree(now, task, route, tree) - } else { - // When PPR is disabled, we can't prefetch per segment. We must fallback - // to the old prefetch behavior and send a dynamic request. - // - // Construct a tree (currently a FlightRouterState) that represents - // which segments need to be prefetched and which ones are already - // cached. If the tree is empty, then we can exit. Otherwise, we'll send - // the request tree to the server and use the response to populate the - // segment cache. - // - // Only routes that include a loading boundary can be prefetched in this - // way. The server will only render up to the first loading boundary - // inside new part of the tree. If there's no loading boundary, the - // server will never return any data. - // TODO: When we prefetch the route tree, the server should - // indicate whether there's a loading boundary so the client doesn't - // send a second request for no reason. - const spawnedEntries = new Map() - const dynamicRequestTree = pingRouteTreeForPPRDisabledRoute( - now, - route, - task.treeAtTimeOfPrefetch, - tree, - spawnedEntries - ) - const needsDynamicRequest = spawnedEntries.size > 0 - if (needsDynamicRequest) { - // Perform a dynamic prefetch request and populate the cache with - // the result - spawnPrefetchSubtask( - fetchSegmentPrefetchesForPPRDisabledRoute( - task, - route, - dynamicRequestTree, - spawnedEntries - ) + + // Determine which fetch strategy to use for this prefetch task. + const fetchStrategy = task.includeDynamicData + ? FetchStrategy.Full + : route.isPPREnabled + ? FetchStrategy.PPR + : FetchStrategy.LoadingBoundary + + switch (fetchStrategy) { + case FetchStrategy.PPR: + // Individually prefetch the static shell for each segment. This is + // the default prefetching behavior for static routes, or when PPR is + // enabled. It will not include any dynamic data. + return pingPPRRouteTree(now, task, route, tree) + case FetchStrategy.Full: + case FetchStrategy.LoadingBoundary: { + // Prefetch multiple segments using a single dynamic request. + const spawnedEntries = new Map() + const dynamicRequestTree = diffRouteTreeAgainstCurrent( + now, + route, + task.treeAtTimeOfPrefetch, + tree, + spawnedEntries, + fetchStrategy ) + const needsDynamicRequest = spawnedEntries.size > 0 + if (needsDynamicRequest) { + // Perform a dynamic prefetch request and populate the cache with + // the result + spawnPrefetchSubtask( + fetchSegmentPrefetchesUsingDynamicRequest( + task, + route, + fetchStrategy, + dynamicRequestTree, + spawnedEntries + ) + ) + } + return PrefetchTaskExitStatus.Done } - return PrefetchTaskExitStatus.Done + default: + fetchStrategy satisfies never } + break } default: { - const _exhaustiveCheck: never = route - return PrefetchTaskExitStatus.Done + route satisfies never } } + return PrefetchTaskExitStatus.Done } -function pingRouteTree( +function pingPPRRouteTree( now: number, task: PrefetchTask, route: FulfilledRouteCacheEntry, tree: RouteTree ): PrefetchTaskExitStatus.InProgress | PrefetchTaskExitStatus.Done { const segment = readOrCreateSegmentCacheEntry(now, route, tree.key) - pingSegment(now, task, route, segment, task.key, tree.key, tree.token) + pingPerSegment(now, task, route, segment, task.key, tree.key, tree.token) if (tree.slots !== null) { if (!hasNetworkBandwidth()) { // Stop prefetching segments until there's more bandwidth. @@ -454,7 +470,7 @@ function pingRouteTree( // Recursively ping the children. for (const parallelRouteKey in tree.slots) { const childTree = tree.slots[parallelRouteKey] - const childExitStatus = pingRouteTree(now, task, route, childTree) + const childExitStatus = pingPPRRouteTree(now, task, route, childTree) if (childExitStatus === PrefetchTaskExitStatus.InProgress) { // Child yielded without finishing. return PrefetchTaskExitStatus.InProgress @@ -465,12 +481,13 @@ function pingRouteTree( return PrefetchTaskExitStatus.Done } -function pingRouteTreeForPPRDisabledRoute( +function diffRouteTreeAgainstCurrent( now: number, route: FulfilledRouteCacheEntry, oldTree: FlightRouterState, newTree: RouteTree, - spawnedEntries: Map + spawnedEntries: Map, + fetchStrategy: FetchStrategy.Full | FetchStrategy.LoadingBoundary ): FlightRouterState { // This is a single recursive traversal that does multiple things: // - Finds the parts of the target route (newTree) that are not part of @@ -492,32 +509,83 @@ function pingRouteTreeForPPRDisabledRoute( oldTreeChildren[parallelRouteKey] const oldTreeChildSegment: FlightRouterStateSegment | void = oldTreeChild?.[0] - let requestTreeChild if ( oldTreeChildSegment !== undefined && matchSegment(newTreeChildSegment, oldTreeChildSegment) ) { // This segment is already part of the current route. Keep traversing. - requestTreeChild = pingRouteTreeForPPRDisabledRoute( + const requestTreeChild = diffRouteTreeAgainstCurrent( now, route, oldTreeChild, newTreeChild, - spawnedEntries + spawnedEntries, + fetchStrategy ) + requestTreeChildren[parallelRouteKey] = requestTreeChild } else { // This segment is not part of the current route. We're entering a // part of the tree that we need to prefetch (unless everything is // already cached). - requestTreeChild = createDynamicRequestTreeForPartiallyCachedSegments( - now, - route, - newTreeChild, - null, - spawnedEntries - ) + switch (fetchStrategy) { + case FetchStrategy.LoadingBoundary: { + // When PPR is disabled, we can't prefetch per segment. We must + // fallback to the old prefetch behavior and send a dynamic request. + // Only routes that include a loading boundary can be prefetched in + // this way. + // + // This is simlar to a "full" prefetch, but we're much more + // conservative about which segments to include in the request. + // + // The server will only render up to the first loading boundary + // inside new part of the tree. If there's no loading boundary, the + // server will never return any data. TODO: When we prefetch the + // route tree, the server should indicate whether there's a loading + // boundary so the client doesn't send a second request for no + // reason. + const requestTreeChild = + pingPPRDisabledRouteTreeUpToLoadingBoundary( + now, + route, + newTreeChild, + null, + spawnedEntries + ) + requestTreeChildren[parallelRouteKey] = requestTreeChild + break + } + case FetchStrategy.Full: { + // This is a "full" prefetch. Fetch all the data in the tree, both + // static and dynamic. We issue roughly the same request that we + // would during a real navigation. The goal is that once the + // navigation occurs, the router should not have to fetch any + // additional data. + // + // Although the response will include dynamic data, opting into a + // Full prefetch — via — implicitly + // instructs the cache to treat the response as "static", or non- + // dynamic, since the whole point is to cache it for + // future navigations. + // + // Construct a tree (currently a FlightRouterState) that represents + // which segments need to be prefetched and which ones are already + // cached. If the tree is empty, then we can exit. Otherwise, we'll + // send the request tree to the server and use the response to + // populate the segment cache. + const requestTreeChild = pingRouteTreeAndIncludeDynamicData( + now, + route, + newTreeChild, + false, + spawnedEntries + ) + requestTreeChildren[parallelRouteKey] = requestTreeChild + break + } + default: + fetchStrategy satisfies never + } } - requestTreeChildren[parallelRouteKey] = requestTreeChild } } const requestTree: FlightRouterState = [ @@ -530,24 +598,24 @@ function pingRouteTreeForPPRDisabledRoute( return requestTree } -function createDynamicRequestTreeForPartiallyCachedSegments( +function pingPPRDisabledRouteTreeUpToLoadingBoundary( now: number, route: FulfilledRouteCacheEntry, tree: RouteTree, refetchMarkerContext: 'refetch' | 'inside-shared-layout' | null, spawnedEntries: Map ): FlightRouterState { - // The tree we're constructing is the same shape as the tree we're navigating - // to — specifically, it's the subtree that isn't present in the previous - // route. But even though this is a "new" tree, some of the individual - // segments may be cached as a result of other route prefetches. - // - // So we need to find the first uncached segment along each path - // add an explicit "refetch" marker so the server knows where to start - // rendering. Once the server starts rendering along a path, it keeps - // rendering until it hits a loading boundary. We use `refetchMarkerContext` - // to represent the nearest parent marker. + // This function is similar to pingRouteTreeAndIncludeDynamicData, except the + // server is only going to return a minimal loading state — it will stop + // rendering at the first loading boundary. Whereas a Full prefetch is + // intentionally aggressive and tries to pretfetch all the data that will be + // needed for a navigation, a LoadingBoundary prefetch is much more + // conservative. For example, it will omit from the request tree any segment + // that is already cached, regardles of whether it's partial or full. By + // contrast, a Full prefetch will refetch partial segments. + // "inside-shared-layout" tells the server where to start looking for a + // loading boundary. let refetchMarker: 'refetch' | 'inside-shared-layout' | null = refetchMarkerContext === null ? 'inside-shared-layout' : null @@ -617,7 +685,7 @@ function createDynamicRequestTreeForPartiallyCachedSegments( for (const parallelRouteKey in tree.slots) { const childTree = tree.slots[parallelRouteKey] requestTreeChildren[parallelRouteKey] = - createDynamicRequestTreeForPartiallyCachedSegments( + pingPPRDisabledRouteTreeUpToLoadingBoundary( now, route, childTree, @@ -636,7 +704,87 @@ function createDynamicRequestTreeForPartiallyCachedSegments( return requestTree } -function pingSegment( +function pingRouteTreeAndIncludeDynamicData( + now: number, + route: FulfilledRouteCacheEntry, + tree: RouteTree, + isInsideRefetchingParent: boolean, + spawnedEntries: Map +): FlightRouterState { + // The tree we're constructing is the same shape as the tree we're navigating + // to. But even though this is a "new" tree, some of the individual segments + // may be cached as a result of other route prefetches. + // + // So we need to find the first uncached segment along each path add an + // explicit "refetch" marker so the server knows where to start rendering. + // Once the server starts rendering along a path, it keeps rendering the + // entire subtree. + const segment = readOrCreateSegmentCacheEntry(now, route, tree.key) + + let spawnedSegment: PendingSegmentCacheEntry | null = null + + switch (segment.status) { + case EntryStatus.Empty: { + // This segment is not cached. Include it in the request. + spawnedSegment = upgradeToPendingSegment(segment, FetchStrategy.Full) + break + } + case EntryStatus.Fulfilled: { + // The segment is already cached. + if (segment.isPartial) { + // The cached segment contians dynamic holes. Since this is a Full + // prefetch, we need to include it in the request. + spawnedSegment = pingFullSegmentRevalidation(now, segment, tree.key) + } + break + } + case EntryStatus.Pending: + case EntryStatus.Rejected: { + // There's either another prefetch currently in progress, or the previous + // attempt failed. If it wasn't a Full prefetch, fetch it again. + if (segment.fetchStrategy !== FetchStrategy.Full) { + spawnedSegment = pingFullSegmentRevalidation(now, segment, tree.key) + } + break + } + default: + segment satisfies never + } + const requestTreeChildren: Record = {} + if (tree.slots !== null) { + for (const parallelRouteKey in tree.slots) { + const childTree = tree.slots[parallelRouteKey] + requestTreeChildren[parallelRouteKey] = + pingRouteTreeAndIncludeDynamicData( + now, + route, + childTree, + isInsideRefetchingParent || spawnedSegment !== null, + spawnedEntries + ) + } + } + + if (spawnedSegment !== null) { + // Add the pending entry to the result map. + spawnedEntries.set(tree.key, spawnedSegment) + } + + // Don't bother to add a refetch marker if one is already present in a parent. + const refetchMarker = + !isInsideRefetchingParent && spawnedSegment !== null ? 'refetch' : null + + const requestTree: FlightRouterState = [ + tree.segment, + requestTreeChildren, + null, + refetchMarker, + tree.isRootLayout, + ] + return requestTree +} + +function pingPerSegment( now: number, task: PrefetchTask, route: FulfilledRouteCacheEntry, @@ -667,9 +815,10 @@ function pingSegment( break case EntryStatus.Pending: { // There's already a request in progress. Depending on what kind of - // request it is, we may want to + // request it is, we may want to revalidate it. switch (segment.fetchStrategy) { case FetchStrategy.PPR: + case FetchStrategy.Full: // There's already a request in progress. Don't do anything. break case FetchStrategy.LoadingBoundary: @@ -701,6 +850,7 @@ function pingSegment( // was originally fetched, we may or may not want to revalidate it. switch (segment.fetchStrategy) { case FetchStrategy.PPR: + case FetchStrategy.Full: // The previous attempt to fetch this entry failed. Don't attempt to // fetch it again until the entry expires. break @@ -783,6 +933,66 @@ function pingPPRSegmentRevalidation( } } +function pingFullSegmentRevalidation( + now: number, + currentSegment: SegmentCacheEntry, + segmentKey: string +): PendingSegmentCacheEntry | null { + const revalidatingSegment = readOrCreateRevalidatingSegmentEntry( + now, + currentSegment + ) + if (revalidatingSegment.status === EntryStatus.Empty) { + // During a Full prefetch, a single dynamic request is made for all the + // segments that we need. So we don't initiate a request here directly. By + // returning a pending entry from this function, it signals to the caller + // that this segment should be included in the request that's sent to + // the server. + const pendingSegment = upgradeToPendingSegment( + revalidatingSegment, + FetchStrategy.Full + ) + upsertSegmentOnCompletion( + segmentKey, + waitForSegmentCacheEntry(pendingSegment) + ) + return pendingSegment + } else { + // There's already a revalidation in progress. + const nonEmptyRevalidatingSegment = revalidatingSegment + if (nonEmptyRevalidatingSegment.fetchStrategy !== FetchStrategy.Full) { + // The existing revalidation was not fetched using the Full strategy. + // Reset it and start a new revalidation. + const emptySegment = resetRevalidatingSegmentEntry( + nonEmptyRevalidatingSegment + ) + const pendingSegment = upgradeToPendingSegment( + emptySegment, + FetchStrategy.Full + ) + upsertSegmentOnCompletion( + segmentKey, + waitForSegmentCacheEntry(pendingSegment) + ) + return pendingSegment + } + switch (nonEmptyRevalidatingSegment.status) { + case EntryStatus.Pending: + // There's already an in-progress prefetch that includes this segment. + return null + case EntryStatus.Fulfilled: + case EntryStatus.Rejected: + // A previous revalidation attempt finished, but we chose not to replace + // the existing entry in the cache. Don't try again until or unless the + // revalidation entry expires. + return null + default: + nonEmptyRevalidatingSegment satisfies never + return null + } + } +} + const noop = () => {} function upsertSegmentOnCompletion( diff --git a/test/e2e/app-dir/segment-cache/incremental-opt-in/app/mixed-fetch-strategies/page.tsx b/test/e2e/app-dir/segment-cache/incremental-opt-in/app/mixed-fetch-strategies/page.tsx index d36e7d1deb9ca..629599ce81da6 100644 --- a/test/e2e/app-dir/segment-cache/incremental-opt-in/app/mixed-fetch-strategies/page.tsx +++ b/test/e2e/app-dir/segment-cache/incremental-opt-in/app/mixed-fetch-strategies/page.tsx @@ -18,6 +18,17 @@ export default function MixedFetchStrategies() { > Link to PPR enabled page +
    +
  • + + Same link, but with prefetch=true + +
  • +
  • { }) } ) + + it( + 'when a link is prefetched with , no dynamic request ' + + 'is made on navigation', + async () => { + let act + const browser = await next.browser('/mixed-fetch-strategies', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + }, + }) + + await act( + async () => { + const checkbox = await browser.elementById( + 'ppr-enabled-prefetch-true' + ) + await checkbox.click() + }, + { + includes: 'Dynamic page content', + } + ) + + // Navigate to fully prefetched route + const link = await browser.elementByCss('#ppr-enabled-prefetch-true + a') + await act( + async () => { + await link.click() + + // We should be able to fully load the page content, including the + // dynamic data, before the server responds. + await browser.elementById('page-content') + }, + // Assert that no network requests are initiated within this block. + 'no-requests' + ) + } + ) + + it( + 'when prefetching with prefetch=true, refetches cache entries that only ' + + 'contain partial data', + async () => { + let act + const browser = await next.browser('/mixed-fetch-strategies', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + }, + }) + + // Prefetch a link with PPR + await act( + async () => { + const checkbox = await browser.elementById('ppr-enabled') + await checkbox.click() + }, + { includes: 'Loading (PPR shell of shared-layout)...' } + ) + + // Prefetch the same link again, this time with prefetch=true to include + // the dynamic data + await act( + async () => { + const checkbox = await browser.elementById( + 'ppr-enabled-prefetch-true' + ) + await checkbox.click() + }, + { + includes: 'Dynamic content in shared layout', + } + ) + + // Navigate to the PPR-enabled route + const link = await browser.elementByCss('#ppr-enabled-prefetch-true + a') + await act( + async () => { + await link.click() + + // If we prefetched all the segments correctly, we should be able to + // fully load the page content, including the dynamic data, before the + // server responds. + // + // If this fails, it likely means that the partial cache entry that + // resulted from prefetching the normal link () + // was not properly re-fetched when the full link () was prefetched. + await browser.elementById('page-content') + }, + // Assert that no network requests are initiated within this block. + 'no-requests' + ) + } + ) + + it( + 'when prefetching with prefetch=true, refetches partial cache entries ' + + "even if there's already a pending PPR request", + async () => { + // This test is hard to describe succinctly because it involves a fairly + // complex race condition between a non-PPR prefetch, a PPR prefetch, and + // a "full" prefetch. Despite the complexity of the scenario, it's worth + // testing because could easily happen in real world conditions. + + let act + const browser = await next.browser('/mixed-fetch-strategies', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + }, + }) + + // Initiate a prefetch for the PPR-disabled route first. This will not + // include the /shared-layout/ segment, because it's inside the + // loading boundary. + await act( + async () => { + const checkbox = await browser.elementById('ppr-disabled') + await checkbox.click() + }, + { includes: 'Loading (has-loading-boundary/loading.tsx)...' } + ) + + // Then initiate a prefetch for the PPR-enabled route. + await act(async () => { + // Create an inner act scope so we initate a prefetch but block it + // from responding. + await act( + async () => { + const checkbox = await browser.elementById('ppr-enabled') + await checkbox.click() + }, + { + // This prefetch should include the /shared-layout/ segment despite + // the presence of the loading boundary, and despite the earlier + // non-PPR attempt. + includes: 'Loading (PPR shell of shared-layout)...', + // We're going to block it from responding, so we can test what + // happens if another prefetch is initiated in the meantime. + block: true, + } + ) + + // Before the previous prefetch finishes, prefetch the same link again, + // this time with prefetch=true to include the dynamic data. + await act( + async () => { + const checkbox = await browser.elementById( + 'ppr-enabled-prefetch-true' + ) + await checkbox.click() + }, + // This prefetch should load the dynamic data in the shared layout + { + includes: 'Dynamic content in shared layout', + } + ) + }) + + // Navigate to the PPR-enabled route. + await act( + async () => { + const link = await browser.elementByCss( + '#ppr-enabled-prefetch-true + a' + ) + await link.click() + + // If we prefetched all the segments correctly, we should be able to + // fully load the page content, including the dynamic data, before the + // server responds. + // + // If this fails, it likely means that the pending cache entry that + // resulted from prefetching the normal link () + // was not properly re-fetched when the full link () was prefetched. + await browser.elementById('page-content') + }, + // Assert that no network requests are initiated within this block. + 'no-requests' + ) + } + ) })