Skip to content

Commit 01622a8

Browse files
committed
[Prefetch Inlining] Generate size-based hints on server
Part 1 of 2. This commit adds the server-side infrastructure for size-based segment bundling but does not change any observable behavior. The client-side changes that actually consume bundled responses are in the next commit. At build time, a measurement pass renders each segment's prefetch response, measures its gzip size, and decides which segments should be bundled together vs fetched separately. The decisions are persisted to a manifest and embedded into the route tree prefetch response so the client can act on them. The decisions are computed once at build and remain fixed for the lifetime of the deployment. They are not recomputed during ISR/revalidation — if they could change, the client would need to re-fetch the route tree after every revalidation, defeating the purpose of caching it independently. Refer to the next commit for a full description of the design and motivation. ## Config experimental.prefetchInlining accepts either a boolean or an object with threshold overrides (maxSize, maxBundleSize). When true, the default thresholds are used (2KB per-segment, 10KB total budget). Eventually this will become the default; the flag exists only for incremental rollout.
1 parent d1fcd20 commit 01622a8

File tree

44 files changed

+1425
-39
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1425
-39
lines changed

packages/next/src/build/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
import type { MiddlewareManifest } from './webpack/plugins/middleware-plugin'
88
import type { ActionManifest } from './webpack/plugins/flight-client-entry-plugin'
99
import type { CacheControl, Revalidate } from '../server/lib/cache-control'
10+
import type { PrefetchHints } from '../shared/lib/app-router-types'
1011

1112
import '../lib/setup-exception-listeners'
1213

@@ -61,6 +62,7 @@ import {
6162
IMAGES_MANIFEST,
6263
PAGES_MANIFEST,
6364
PHASE_PRODUCTION_BUILD,
65+
PREFETCH_HINTS,
6466
PRERENDER_MANIFEST,
6567
REACT_LOADABLE_MANIFEST,
6668
ROUTES_MANIFEST,
@@ -2739,6 +2741,11 @@ export default async function build(
27392741
preview: previewProps,
27402742
}
27412743

2744+
// Accumulate per-route segment inlining decisions for
2745+
// prefetch-hints.json. First-writer-wins: if multiple param
2746+
// combinations exist for the same route pattern, use the first one.
2747+
const prefetchHints: Record<string, PrefetchHints> = {}
2748+
27422749
const tbdPrerenderRoutes: string[] = []
27432750

27442751
const { i18n } = config
@@ -3194,6 +3201,11 @@ export default async function build(
31943201
initialCacheControl: cacheControl,
31953202
})
31963203

3204+
// Collect prefetch hints (first-writer-wins per page)
3205+
if (metadata.prefetchHints && !(page in prefetchHints)) {
3206+
prefetchHints[page] = metadata.prefetchHints
3207+
}
3208+
31973209
if (cacheControl.revalidate !== 0) {
31983210
const normalizedRoute = normalizePagePath(route.pathname)
31993211

@@ -3369,6 +3381,11 @@ export default async function build(
33693381
builtSegmentDataRoute
33703382
)
33713383
}
3384+
3385+
// Collect prefetch hints (first-writer-wins per page)
3386+
if (metadata?.prefetchHints && !(page in prefetchHints)) {
3387+
prefetchHints[page] = metadata.prefetchHints
3388+
}
33723389
}
33733390

33743391
pageInfos.set(route.pathname, {
@@ -3958,6 +3975,12 @@ export default async function build(
39583975
config.experimental.allowedRevalidateHeaderKeys
39593976

39603977
await writePrerenderManifest(distDir, prerenderManifest)
3978+
if (Object.keys(prefetchHints).length > 0) {
3979+
await writeManifest(
3980+
path.join(distDir, SERVER_DIRECTORY, PREFETCH_HINTS),
3981+
prefetchHints
3982+
)
3983+
}
39613984
await writeClientSsgManifest(prerenderManifest, {
39623985
distDir,
39633986
buildId,

packages/next/src/build/templates/app-page.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,8 @@ export async function handler(
735735
nextConfig.experimental.optimisticRouting
736736
),
737737
inlineCss: Boolean(nextConfig.experimental.inlineCss),
738-
prefetchInlining: Boolean(nextConfig.experimental.prefetchInlining),
738+
prefetchInlining:
739+
nextConfig.experimental.prefetchInlining ?? false,
739740
authInterrupts: Boolean(nextConfig.experimental.authInterrupts),
740741
clientTraceMetadata:
741742
nextConfig.experimental.clientTraceMetadata || ([] as any),

packages/next/src/build/templates/edge-ssr-app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ async function requestHandler(
163163
dynamicOnHover: Boolean(nextConfig.experimental.dynamicOnHover),
164164
optimisticRouting: Boolean(nextConfig.experimental.optimisticRouting),
165165
inlineCss: Boolean(nextConfig.experimental.inlineCss),
166-
prefetchInlining: Boolean(nextConfig.experimental.prefetchInlining),
166+
prefetchInlining: nextConfig.experimental.prefetchInlining ?? false,
167167
authInterrupts: Boolean(nextConfig.experimental.authInterrupts),
168168
clientTraceMetadata:
169169
nextConfig.experimental.clientTraceMetadata || ([] as any),

packages/next/src/client/components/router-reducer/create-initial-router-state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ export function createInitialRouterState({
6565
// NOTE: The metadataVaryPath isn't used for anything currently because the
6666
// head is embedded into the CacheNode tree, but eventually we'll lift it out
6767
// and store it on the top-level state object.
68+
//
69+
// TODO: For statically-generated-at-build-time HTML pages, the
70+
// FlightRouterState baked into the initial RSC payload won't have the
71+
// correct segment inlining hints (ParentInlinedIntoSelf, InlinedIntoChild)
72+
// because those are computed after the pre-render. The client will need to
73+
// fetch the correct hints from the route tree prefetch (/_tree) response
74+
// before acting on inlining decisions.
6875
const acc = { metadataVaryPath: null }
6976
const initialRouteTree = convertRootFlightRouterStateToRouteTree(
7077
initialTree,

packages/next/src/export/routes/app-page.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export async function exportAppPage(
100100
fetchTags,
101101
fetchMetrics,
102102
segmentData,
103+
prefetchHints,
103104
renderResumeDataCache,
104105
} = metadata
105106

@@ -218,6 +219,7 @@ export async function exportAppPage(
218219
headers,
219220
postponed,
220221
segmentPaths,
222+
prefetchHints,
221223
}
222224

223225
fileWriter.append(
@@ -231,6 +233,7 @@ export async function exportAppPage(
231233
? meta
232234
: {
233235
segmentPaths: meta.segmentPaths,
236+
prefetchHints: meta.prefetchHints,
234237
},
235238
hasEmptyStaticShell: Boolean(postponed) && html === '',
236239
hasPostponed: Boolean(postponed),
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { OutgoingHttpHeaders } from 'node:http'
2+
import type { PrefetchHints } from '../../shared/lib/app-router-types'
23

34
export type RouteMetadata = {
45
status: number | undefined
56
headers: OutgoingHttpHeaders | undefined
67
postponed: string | undefined
78
segmentPaths: Array<string> | undefined
9+
prefetchHints: PrefetchHints | undefined
810
}

packages/next/src/server/app-render/app-render.tsx

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
FlightData,
1212
InitialRSCPayload,
1313
FlightDataPath,
14+
PrefetchHints,
1415
} from '../../shared/lib/app-router-types'
1516
import type { Readable } from 'node:stream'
1617
import {
@@ -605,6 +606,7 @@ async function generateDynamicRSCPayload(
605606
rootLayoutIncluded: false,
606607
preloadCallbacks,
607608
MetadataOutlet,
609+
hintTree: ctx.renderOpts.prefetchHints?.[ctx.pagePath] ?? null,
608610
})
609611
).map((path) => path.slice(1)) // remove the '' (root) segment
610612
}
@@ -1635,8 +1637,10 @@ async function getRSCPayload(
16351637
workStore,
16361638
} = ctx
16371639

1640+
const hints = ctx.renderOpts.prefetchHints?.[ctx.pagePath] ?? null
16381641
const initialTree = await createFlightRouterStateFromLoaderTree(
16391642
tree,
1643+
hints,
16401644
getDynamicParamFromSegment,
16411645
query
16421646
)
@@ -1815,8 +1819,10 @@ async function getErrorRSCPayload(
18151819
createElement(Metadata, null)
18161820
)
18171821

1822+
const errorHints = ctx.renderOpts.prefetchHints?.[ctx.pagePath] ?? null
18181823
const initialTree = await createFlightRouterStateFromLoaderTree(
18191824
tree,
1825+
errorHints,
18201826
getDynamicParamFromSegment,
18211827
query
18221828
)
@@ -5534,11 +5540,13 @@ async function prerenderToStream(
55345540

55355541
// collectSegmentData needs the raw flight data without the marker byte.
55365542
const flightData = metadata.flightData.subarray(1)
5537-
metadata.segmentData = await collectSegmentData(
5543+
await collectSegmentData(
55385544
flightData,
55395545
finalServerPrerenderStore,
55405546
ComponentMod,
5541-
renderOpts
5547+
renderOpts,
5548+
ctx.pagePath,
5549+
metadata
55425550
)
55435551

55445552
if (serverIsDynamic) {
@@ -5788,11 +5796,13 @@ async function prerenderToStream(
57885796

57895797
if (shouldGenerateStaticFlightData(workStore)) {
57905798
metadata.flightData = flightData
5791-
metadata.segmentData = await collectSegmentData(
5799+
await collectSegmentData(
57925800
flightData,
57935801
ssrPrerenderStore,
57945802
ComponentMod,
5795-
renderOpts
5803+
renderOpts,
5804+
ctx.pagePath,
5805+
metadata
57965806
)
57975807
}
57985808

@@ -5997,11 +6007,13 @@ async function prerenderToStream(
59976007
if (shouldGenerateStaticFlightData(workStore)) {
59986008
const flightData = await streamToBuffer(reactServerResult.asStream())
59996009
metadata.flightData = flightData
6000-
metadata.segmentData = await collectSegmentData(
6010+
await collectSegmentData(
60016011
flightData,
60026012
prerenderLegacyStore,
60036013
ComponentMod,
6004-
renderOpts
6014+
renderOpts,
6015+
ctx.pagePath,
6016+
metadata
60056017
)
60066018
}
60076019

@@ -6165,11 +6177,13 @@ async function prerenderToStream(
61656177
reactServerPrerenderResult.asStream()
61666178
)
61676179
metadata.flightData = flightData
6168-
metadata.segmentData = await collectSegmentData(
6180+
await collectSegmentData(
61696181
flightData,
61706182
prerenderLegacyStore,
61716183
ComponentMod,
6172-
renderOpts
6184+
renderOpts,
6185+
ctx.pagePath,
6186+
metadata
61736187
)
61746188
}
61756189

@@ -6289,8 +6303,10 @@ async function collectSegmentData(
62896303
fullPageDataBuffer: Buffer,
62906304
prerenderStore: PrerenderStore,
62916305
ComponentMod: AppPageModule,
6292-
renderOpts: RenderOpts
6293-
): Promise<Map<string, Buffer> | undefined> {
6306+
renderOpts: RenderOpts,
6307+
pagePath: string,
6308+
metadata: AppPageRenderResultMetadata
6309+
): Promise<void> {
62946310
// Per-segment prefetch data
62956311
//
62966312
// All of the segments for a page are generated simultaneously, including
@@ -6321,13 +6337,51 @@ async function collectSegmentData(
63216337

63226338
const selectStaleTime = createSelectStaleTime(renderOpts.experimental)
63236339
const staleTime = selectStaleTime(prerenderStore.stale)
6324-
return await ComponentMod.collectSegmentData(
6340+
6341+
// Resolve prefetch hints. At runtime (next start / ISR), the precomputed
6342+
// hints are already loaded from the prefetch-hints.json manifest. During
6343+
// build, compute them by measuring segment gzip sizes and write them to
6344+
// metadata so the build pipeline can persist them to the manifest.
6345+
let hints: PrefetchHints | null
6346+
if (renderOpts.isBuildTimePrerendering) {
6347+
// Build time: compute fresh hints and store in metadata for the manifest.
6348+
const prefetchInlining = renderOpts.experimental.prefetchInlining
6349+
const maxSize =
6350+
typeof prefetchInlining === 'object'
6351+
? (prefetchInlining.maxSize ?? 2_048)
6352+
: 2_048
6353+
const maxBundleSize =
6354+
typeof prefetchInlining === 'object'
6355+
? (prefetchInlining.maxBundleSize ?? 10_240)
6356+
: 10_240
6357+
hints = await ComponentMod.collectPrefetchHints(
6358+
fullPageDataBuffer,
6359+
staleTime,
6360+
clientModules,
6361+
serverConsumerManifest,
6362+
maxSize,
6363+
maxBundleSize
6364+
)
6365+
metadata.prefetchHints = hints
6366+
} else {
6367+
// Runtime: use hints from the manifest. Never compute fresh hints
6368+
// during ISR/revalidation.
6369+
hints = renderOpts.prefetchHints?.[pagePath] ?? null
6370+
}
6371+
6372+
// Pass the resolved hints so collectSegmentData can union them into
6373+
// the TreePrefetch. During the initial build the FlightRouterState in
6374+
// the buffer doesn't have inlining hints yet (they were just computed
6375+
// above), so we need to merge them in here. At runtime/ISR the hints
6376+
// are already embedded in the FlightRouterState, so this is null.
6377+
metadata.segmentData = await ComponentMod.collectSegmentData(
63256378
renderOpts.cacheComponents,
63266379
fullPageDataBuffer,
63276380
staleTime,
63286381
clientModules,
63296382
serverConsumerManifest,
6330-
renderOpts.experimental.prefetchInlining
6383+
Boolean(renderOpts.experimental.prefetchInlining),
6384+
hints
63316385
)
63326386
}
63336387

0 commit comments

Comments
 (0)