Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/next/src/build/define-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ export function getDefineEnv({
'process.env.__NEXT_DYNAMIC_ON_HOVER': Boolean(
config.experimental.dynamicOnHover
),
'process.env.__NEXT_PREFETCH_INLINING': Boolean(
config.experimental.prefetchInlining
),
'process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE':
config.experimental.optimisticClientCache ?? true,
'process.env.__NEXT_MIDDLEWARE_PREFETCH':
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/build/templates/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@ export async function handler(
nextConfig.experimental.optimisticRouting
),
inlineCss: Boolean(nextConfig.experimental.inlineCss),
prefetchInlining: Boolean(nextConfig.experimental.prefetchInlining),
authInterrupts: Boolean(nextConfig.experimental.authInterrupts),
clientTraceMetadata:
nextConfig.experimental.clientTraceMetadata || ([] as any),
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/build/templates/edge-ssr-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ async function requestHandler(
dynamicOnHover: Boolean(nextConfig.experimental.dynamicOnHover),
optimisticRouting: Boolean(nextConfig.experimental.optimisticRouting),
inlineCss: Boolean(nextConfig.experimental.inlineCss),
prefetchInlining: Boolean(nextConfig.experimental.prefetchInlining),
authInterrupts: Boolean(nextConfig.experimental.authInterrupts),
clientTraceMetadata:
nextConfig.experimental.clientTraceMetadata || ([] as any),
Expand Down
172 changes: 172 additions & 0 deletions packages/next/src/client/components/segment-cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {
TreePrefetch,
RootTreePrefetch,
SegmentPrefetch,
InlinedPrefetchResponse,
InlinedSegmentPrefetch,
} from '../../../server/app-render/collect-segment-data'
import type {
CacheNodeSeedData,
Expand Down Expand Up @@ -1983,6 +1985,176 @@ export async function fetchSegmentOnCacheMiss(
}
}

// TODO: The inlined prefetch flow below is temporary. Eventually, inlining
// will be the default behavior controlled by a size heuristic rather than a
// boolean flag. At that point, the per-segment and inlined fetch paths will
// merge, and these separate functions will be removed.
//
// The call site in the scheduler is guarded by
// process.env.__NEXT_PREFETCH_INLINING, so these functions are
// dead-code-eliminated from the client bundle when the feature is disabled.

export async function fetchInlinedSegmentsOnCacheMiss(
route: FulfilledRouteCacheEntry,
routeKey: RouteCacheKey,
tree: RouteTree,
spawnedEntries: Map<SegmentRequestKey, PendingSegmentCacheEntry>
): Promise<PrefetchSubtaskResult<null> | null> {
// When prefetch inlining is enabled, all segment data for a route is bundled
// into a single /_inlined response instead of individual per-segment
// requests. This function fetches that response and walks the tree to fill
// all segment cache entries at once.
const url = new URL(route.canonicalUrl, location.origin)
const nextUrl = routeKey.nextUrl

const headers: RequestHeaders = {
[RSC_HEADER]: '1',
[NEXT_ROUTER_PREFETCH_HEADER]: '1',
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: '/_inlined',
}
if (nextUrl !== null) {
headers[NEXT_URL] = nextUrl
}
addInstantPrefetchHeaderIfLocked(headers)

try {
const response = await fetchPrefetchResponse(url, headers)
if (
!response ||
!response.ok ||
response.status === 204 ||
(response.headers.get(NEXT_DID_POSTPONE_HEADER) !== '2' &&
!isOutputExportMode) ||
!response.body
) {
rejectSegmentEntriesIfStillPending(spawnedEntries, Date.now() + 10 * 1000)
return null
}

const closed = createPromiseWithResolvers<void>()

const prefetchStream = createPrefetchResponseStream(
response.body,
closed.resolve,
function onResponseSizeUpdate() {
// For inlined responses, size tracking per segment is approximate.
// We don't track individual sizes since they're all in one response.
}
)
const serverData =
await createFromNextReadableStream<InlinedPrefetchResponse>(
prefetchStream,
headers,
{ allowPartialStream: true }
)

if (
(response.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ??
serverData.tree.segment.buildId) !== getNavigationBuildId()
) {
rejectSegmentEntriesIfStillPending(spawnedEntries, Date.now() + 10 * 1000)
return null
}

const now = Date.now()

// Walk the inlined tree in parallel with the RouteTree and fill
// segment cache entries.
fillInlinedSegmentEntries(now, route, tree, serverData.tree, spawnedEntries)

// Fill the head entry.
const headStaleAt = now + getStaleTimeMs(serverData.head.staleTime)
const headKey = route.metadata.requestKey
const ownedHeadEntry = spawnedEntries.get(headKey)
if (ownedHeadEntry !== undefined) {
fulfillSegmentCacheEntry(
ownedHeadEntry,
serverData.head.rsc,
headStaleAt,
serverData.head.isPartial
)
} else {
// The head was already cached. Try to upsert if the entry is empty.
const existingEntry = readOrCreateSegmentCacheEntry(
now,
FetchStrategy.PPR,
route.metadata
)
if (existingEntry.status === EntryStatus.Empty) {
fulfillSegmentCacheEntry(
upgradeToPendingSegment(existingEntry, FetchStrategy.PPR),
serverData.head.rsc,
headStaleAt,
serverData.head.isPartial
)
}
}

// Reject any remaining entries that were not fulfilled by the response.
rejectSegmentEntriesIfStillPending(spawnedEntries, Date.now() + 10 * 1000)

return { value: null, closed: closed.promise }
} catch (error) {
rejectSegmentEntriesIfStillPending(spawnedEntries, Date.now() + 10 * 1000)
return null
}
}

function fillInlinedSegmentEntries(
now: number,
route: FulfilledRouteCacheEntry,
tree: RouteTree,
inlinedNode: InlinedSegmentPrefetch,
spawnedEntries: Map<SegmentRequestKey, PendingSegmentCacheEntry>
): void {
// Check if the spawned entries map has an entry for this segment's key.
const segment = inlinedNode.segment
const staleAt = now + getStaleTimeMs(segment.staleTime)
const ownedEntry = spawnedEntries.get(tree.requestKey)
if (ownedEntry !== undefined) {
// We own this entry. Fulfill it directly.
fulfillSegmentCacheEntry(
ownedEntry,
segment.rsc,
staleAt,
segment.isPartial
)
} else {
// Not owned by us — this is extra data from the inlined response for a
// segment that was already cached. Try to upsert if the entry is empty.
const existingEntry = readOrCreateSegmentCacheEntry(
now,
FetchStrategy.PPR,
tree
)
if (existingEntry.status === EntryStatus.Empty) {
fulfillSegmentCacheEntry(
upgradeToPendingSegment(existingEntry, FetchStrategy.PPR),
segment.rsc,
staleAt,
segment.isPartial
)
}
}

// Recurse into children.
if (tree.slots !== null && inlinedNode.slots !== null) {
for (const parallelRouteKey in tree.slots) {
const childTree = tree.slots[parallelRouteKey]
const childInlinedNode = inlinedNode.slots[parallelRouteKey]
if (childInlinedNode !== undefined) {
fillInlinedSegmentEntries(
now,
route,
childTree,
childInlinedNode,
spawnedEntries
)
}
}
}
}

export async function fetchSegmentPrefetchesUsingDynamicRequest(
task: PrefetchTask,
route: FulfilledRouteCacheEntry,
Expand Down
66 changes: 66 additions & 0 deletions packages/next/src/client/components/segment-cache/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
readOrCreateSegmentCacheEntry,
fetchRouteOnCacheMiss,
fetchSegmentOnCacheMiss,
fetchInlinedSegmentsOnCacheMiss,
EntryStatus,
type FulfilledRouteCacheEntry,
type RouteCacheEntry,
Expand Down Expand Up @@ -793,6 +794,7 @@ function pingRootRouteTree(
)
}
}

return PrefetchTaskExitStatus.Done
}
case FetchStrategy.Full:
Expand Down Expand Up @@ -1482,6 +1484,25 @@ function pingStaticSegmentData(
): void {
switch (segment.status) {
case EntryStatus.Empty:
if (process.env.__NEXT_PREFETCH_INLINING) {
// All segment data for this route is bundled into a single
// /_inlined response. Walk the full route tree to collect all
// Empty segments, upgrade them to Pending, and spawn one
// bundled request. Subsequent calls for other segments in the
// same tree will see them as Pending and skip.
const inlinedEntries = collectInlinedEntries(now, route)
if (inlinedEntries.size > 0) {
spawnPrefetchSubtask(
fetchInlinedSegmentsOnCacheMiss(
route,
routeKey,
route.tree,
inlinedEntries
)
)
}
break
}
// Upgrade to Pending so we know there's already a request in progress
spawnPrefetchSubtask(
fetchSegmentOnCacheMiss(
Expand Down Expand Up @@ -1557,6 +1578,51 @@ function pingStaticSegmentData(
// entry, which is handled by `fetchSegmentOnCacheMiss`).
}

/**
* Walks the RouteTree (including the head metadata) and collects any segments
* that are still Empty into a Map, upgrading them to Pending. These entries
* will be fulfilled by the inlined prefetch response.
*/
function collectInlinedEntries(
now: number,
route: FulfilledRouteCacheEntry
): Map<SegmentRequestKey, PendingSegmentCacheEntry> {
const entries = new Map<SegmentRequestKey, PendingSegmentCacheEntry>()
collectInlinedEntriesImpl(now, route.tree, entries)
// Also collect the head/metadata entry.
const headEntry = readOrCreateSegmentCacheEntry(
now,
FetchStrategy.PPR,
route.metadata
)
if (headEntry.status === EntryStatus.Empty) {
entries.set(
route.metadata.requestKey,
upgradeToPendingSegment(headEntry, FetchStrategy.PPR)
)
}
return entries
}

function collectInlinedEntriesImpl(
now: number,
tree: RouteTree,
entries: Map<SegmentRequestKey, PendingSegmentCacheEntry>
): void {
const entry = readOrCreateSegmentCacheEntry(now, FetchStrategy.PPR, tree)
if (entry.status === EntryStatus.Empty) {
entries.set(
tree.requestKey,
upgradeToPendingSegment(entry, FetchStrategy.PPR)
)
}
if (tree.slots !== null) {
for (const parallelRouteKey in tree.slots) {
collectInlinedEntriesImpl(now, tree.slots[parallelRouteKey], entries)
}
}
}

function pingPPRSegmentRevalidation(
now: number,
route: FulfilledRouteCacheEntry,
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ async function exportAppImpl(
dynamicOnHover: nextConfig.experimental.dynamicOnHover ?? false,
optimisticRouting: nextConfig.experimental.optimisticRouting ?? false,
inlineCss: nextConfig.experimental.inlineCss ?? false,
prefetchInlining: nextConfig.experimental.prefetchInlining ?? false,
authInterrupts: !!nextConfig.experimental.authInterrupts,
maxPostponedStateSizeBytes: parseMaxPostponedStateSize(
nextConfig.experimental.maxPostponedStateSize
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6114,7 +6114,8 @@ async function collectSegmentData(
fullPageDataBuffer,
staleTime,
clientModules,
serverConsumerManifest
serverConsumerManifest,
renderOpts.experimental.prefetchInlining
)
}

Expand Down
Loading
Loading