Skip to content

Commit 1380926

Browse files
authored
[Segment Bundling] [Scaffolding] Ensure inlining hint correctness (#91320)
Inlining hints (which segments to bundle together) are computed once at build time by measuring gzip sizes, then persisted to `prefetch-hints.json`. There are several scenarios where these hints may not be available at render time. This commit addresses each scenario to ensure the client always receives correct hints and never enters a bad state. ### Build-time static pages (stale hints) The initial RSC payload baked into the HTML is generated before hints are computed, since hint computation requires at least one completed render to measure sizes. The server marks these trees with `InliningHintsStale`. The client immediately expires the route cache entry so that the next prefetch re-fetches the correct tree from the `/_tree` response. The segment data doesn't need re-fetching since it was already cached from the initial HTML payload. ### ISR / revalidation Hints are always available from the manifest. A missing entry is an internal error — it means the build pipeline failed to produce hints for a route that needs them. ### Fully dynamic routes at runtime No hints exist and none will ever be computed. Every segment gets `PrefetchDisabled`, which tells the client not to attempt prefetching. This avoids an infinite re-fetch loop that would occur if the client kept trying to fetch "correct" hints. ### Unified response format Also simplifies the segment prefetch response format: every response is now a `SegmentPrefetchResponse` with a top-level `buildId` and a `data` array, treating a single-segment response as a bundle that happens to have length one. This unifies the format so that bundled multi-segment responses (added in a follow-up) require no additional client parsing logic.
1 parent cbb5879 commit 1380926

19 files changed

Lines changed: 425 additions & 78 deletions

File tree

packages/next/errors.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1140,5 +1140,9 @@
11401140
"1139": "`unstable_catchError` can only be used in Client Components.",
11411141
"1140": "Route %s used \\`import('next/root-params').%s()\\` inside \\`\"use cache\"\\` nested within \\`unstable_cache\\`. Root params are not available in this context.",
11421142
"1141": "Route %s used \\`import('next/root-params').%s()\\` inside \\`unstable_cache\\`. This is not supported. Use \\`\"use cache\"\\` instead.",
1143-
"1142": "Route %s used \\`import('next/root-params').%s()\\` inside \\`generateStaticParams\\`, but the \\`%s\\` parameter was not provided by a parent \\`generateStaticParams\\`. In \\`generateStaticParams\\`, root params are only available for segments nested below the segment that provides them."
1143+
"1142": "Route %s used \\`import('next/root-params').%s()\\` inside \\`generateStaticParams\\`, but the \\`%s\\` parameter was not provided by a parent \\`generateStaticParams\\`. In \\`generateStaticParams\\`, root params are only available for segments nested below the segment that provides them.",
1144+
"1143": "Prefetch inlining is enabled but no hint tree was provided during incremental static revalidation. The prefetch-hints.json manifest should contain an entry for this route.",
1145+
"1144": "Prefetch inlining is enabled but no hints were found for route \"%s\". This is a bug in the Next.js build pipeline — prefetch-hints.json should contain an entry for every route that produces segment data.",
1146+
"1145": "Internal Next.js Error: Prefetch inlining is enabled but no hints were found for route \"%s\". prefetch-hints.json should contain an entry for every route that produces segment data. This is a bug in Next.js.",
1147+
"1146": "Internal Next.js Error: Prefetch inlining is enabled but no hint tree was provided during incremental static revalidation. The prefetch-hints.json manifest should contain an entry for this route. This is a bug in Next.js."
11441148
}

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,12 @@ export function createInitialRouterState({
7575
// head is embedded into the CacheNode tree, but eventually we'll lift it out
7676
// and store it on the top-level state object.
7777
//
78-
// TODO: For statically-generated-at-build-time HTML pages, the
79-
// FlightRouterState baked into the initial RSC payload won't have the
80-
// correct segment inlining hints (ParentInlinedIntoSelf, InlinedIntoChild)
81-
// because those are computed after the pre-render. The client will need to
82-
// fetch the correct hints from the route tree prefetch (/_tree) response
83-
// before acting on inlining decisions.
78+
// For statically-generated-at-build-time HTML pages, the FlightRouterState
79+
// baked into the initial RSC payload won't have the correct segment inlining
80+
// hints because those are computed after the pre-render. The server marks
81+
// these trees with InliningHintsStale, which causes the route cache entry
82+
// to be immediately expired. The next prefetch will re-fetch the tree with
83+
// correct hints from the /_tree response.
8484
const acc = { metadataVaryPath: null }
8585
const initialRouteTree = convertRootFlightRouterStateToRouteTree(
8686
initialTree,

packages/next/src/client/components/segment-cache/cache.ts

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type {
22
TreePrefetch,
33
RootTreePrefetch,
4-
SegmentPrefetch,
4+
SegmentPrefetchResponse,
55
InlinedPrefetchResponse,
66
InlinedSegmentPrefetch,
77
} from '../../../server/app-render/collect-segment-data'
@@ -10,6 +10,7 @@ import type {
1010
FlightData,
1111
Segment as FlightRouterStateSegment,
1212
} from '../../../shared/lib/app-router-types'
13+
import { PrefetchHint } from '../../../shared/lib/app-router-types'
1314
import {
1415
readVaryParams,
1516
type VaryParams,
@@ -452,7 +453,7 @@ export function readRouteCacheEntry(
452453
// No cache hit. Attempt to construct from template using the new
453454
// optimistic routing mechanism (pattern-based matching).
454455
if (process.env.__NEXT_OPTIMISTIC_ROUTING) {
455-
return matchKnownRoute(key.pathname, key.search)
456+
return matchKnownRoute(now, key.pathname, key.search)
456457
}
457458

458459
return null
@@ -1070,7 +1071,17 @@ export function fulfillRouteCacheEntry(
10701071
// Always use the static stale time.
10711072
// NOTE: An exception is rewrites/redirects in middleware or proxy, which can
10721073
// change routes dynamically. We have other strategies for handling those.
1073-
fulfilledEntry.staleAt = now + STATIC_STALETIME_MS
1074+
//
1075+
// If the route tree has stale inlining hints (e.g. the initial RSC payload
1076+
// for a build-time static page, generated before collectPrefetchHints ran),
1077+
// immediately expire the entry so it gets re-fetched with correct hints.
1078+
// The segment data itself is still valid — only the route tree (which
1079+
// contains the hint bits) needs to be re-fetched.
1080+
if (tree.prefetchHints & PrefetchHint.InliningHintsStale) {
1081+
fulfilledEntry.staleAt = -1
1082+
} else {
1083+
fulfilledEntry.staleAt = now + STATIC_STALETIME_MS
1084+
}
10741085
fulfilledEntry.couldBeIntercepted = couldBeIntercepted
10751086
fulfilledEntry.canonicalUrl = canonicalUrl
10761087
fulfilledEntry.renderedSearch = renderedSearch
@@ -1924,42 +1935,57 @@ export async function fetchSegmentOnCacheMiss(
19241935
await createNonTaskyPrefetchResponseStream(response.body)
19251936
closed.resolve()
19261937
setSizeInCacheMap(segmentCacheEntry, responseSize)
1927-
const serverData = await createFromNextReadableStream<SegmentPrefetch>(
1928-
prefetchStream,
1929-
headers,
1930-
{ allowPartialStream: true }
1931-
)
1938+
const serverResponse =
1939+
await createFromNextReadableStream<SegmentPrefetchResponse>(
1940+
prefetchStream,
1941+
headers,
1942+
{ allowPartialStream: true }
1943+
)
19321944
if (
19331945
(response.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ??
1934-
serverData.buildId) !== getNavigationBuildId()
1946+
serverResponse.buildId) !== getNavigationBuildId()
19351947
) {
19361948
// The server build does not match the client. Treat as a 404. During
19371949
// an actual navigation, the router will trigger an MPA navigation.
19381950
rejectSegmentCacheEntry(segmentCacheEntry, Date.now() + 10 * 1000)
19391951
return null
19401952
}
1941-
const now = Date.now()
1942-
const staleAt = now + getStaleTimeMs(serverData.staleTime)
1943-
const fulfilledEntry = fulfillSegmentCacheEntry(
1944-
segmentCacheEntry,
1945-
serverData.rsc,
1946-
staleAt,
1947-
serverData.isPartial
1948-
)
1953+
// Iterate over the segment data in the response array. Currently each
1954+
// per-segment response contains a single entry, but the format supports
1955+
// bundled responses with multiple entries (used when segment inlining
1956+
// is enabled).
1957+
let fulfilledEntry: FulfilledSegmentCacheEntry | null = null
1958+
for (const serverData of serverResponse.data) {
1959+
if (serverData === null) {
1960+
// Null entries represent segments with static prefetch disabled
1961+
// (runtime prefetch or unstable_instant = false). Skip them.
1962+
continue
1963+
}
1964+
const now = Date.now()
1965+
const staleAt = now + getStaleTimeMs(serverData.staleTime)
1966+
fulfilledEntry = fulfillSegmentCacheEntry(
1967+
segmentCacheEntry,
1968+
serverData.rsc,
1969+
staleAt,
1970+
serverData.isPartial
1971+
)
19491972

1950-
// If the server tells us which params the segment varies by, we can re-key
1951-
// the entry to a more generic vary path. This allows the entry to be reused
1952-
// across different param values for params that the segment doesn't
1953-
// actually depend on.
1954-
const varyParams = serverData.varyParams
1955-
const fulfilledVaryPath =
1956-
process.env.__NEXT_VARY_PARAMS && varyParams !== null
1957-
? getFulfilledSegmentVaryPath(tree.varyPath, varyParams)
1958-
: getSegmentVaryPathForRequest(segmentCacheEntry.fetchStrategy, tree)
1959-
// Re-key and upsert the entry at the fulfilled vary path. This ensures
1960-
// the entry is stored at the most generic path possible based on which
1961-
// params the segment actually depends on.
1962-
upsertSegmentEntry(now, fulfilledVaryPath, fulfilledEntry)
1973+
// If the server tells us which params the segment varies by, we can
1974+
// re-key the entry to a more generic vary path. This allows the entry
1975+
// to be reused across different param values for params that the
1976+
// segment doesn't actually depend on.
1977+
const varyParams = serverData.varyParams
1978+
const fulfilledVaryPath =
1979+
process.env.__NEXT_VARY_PARAMS && varyParams !== null
1980+
? getFulfilledSegmentVaryPath(tree.varyPath, varyParams)
1981+
: getSegmentVaryPathForRequest(segmentCacheEntry.fetchStrategy, tree)
1982+
upsertSegmentEntry(now, fulfilledVaryPath, fulfilledEntry)
1983+
}
1984+
1985+
if (fulfilledEntry === null) {
1986+
rejectSegmentCacheEntry(segmentCacheEntry, Date.now() + 10 * 1000)
1987+
return null
1988+
}
19631989

19641990
return {
19651991
value: fulfilledEntry,
@@ -2037,7 +2063,7 @@ export async function fetchInlinedSegmentsOnCacheMiss(
20372063

20382064
if (
20392065
(response.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ??
2040-
serverData.tree.segment.buildId) !== getNavigationBuildId()
2066+
serverData.buildId) !== getNavigationBuildId()
20412067
) {
20422068
rejectSegmentEntriesIfStillPending(spawnedEntries, Date.now() + 10 * 1000)
20432069
return null

packages/next/src/client/components/segment-cache/optimistic-routes.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,11 @@ import {
4949
EntryStatus,
5050
writeRouteIntoCache,
5151
fulfillRouteCacheEntry,
52+
getCurrentRouteCacheVersion,
5253
type PendingRouteCacheEntry,
5354
createMetadataRouteTree,
5455
} from './cache'
56+
import { isValueExpired } from './cache-map'
5557
import { doesStaticSegmentAppearInURL } from '../../route-params'
5658
import type { NormalizedPathname, NormalizedSearch } from './cache-key'
5759
import {
@@ -137,6 +139,30 @@ type KnownRoutePart =
137139
*/
138140
type ResolvedParams = Map<string, string | string[]>
139141

142+
/**
143+
* Read the pattern from a KnownRoutePart, evicting it if expired.
144+
*
145+
* This prevents stale patterns (e.g. from InliningHintsStale route entries
146+
* with staleAt = -1) from being cloned into synthetic entries indefinitely.
147+
* Once evicted, the pattern slot can be repopulated by the next
148+
* discoverKnownRoute call with a fresh entry from a /_tree response.
149+
*/
150+
function readPattern(
151+
now: number,
152+
part: KnownRoutePart
153+
): FulfilledRouteCacheEntry | null {
154+
const pattern = part.pattern
155+
if (pattern === null) {
156+
return null
157+
}
158+
if (isValueExpired(now, getCurrentRouteCacheVersion(), pattern)) {
159+
// The pattern is expired. Null it out so the slot can be repopulated.
160+
part.pattern = null
161+
return null
162+
}
163+
return pattern
164+
}
165+
140166
function createEmptyPart(): KnownRoutePart {
141167
return {
142168
staticChildren: null,
@@ -442,12 +468,13 @@ function discoverKnownRoutePart(
442468

443469
// Reached a page node. Create/get the route cache entry and store as a
444470
// pattern. First, check if there's already a pattern for this route.
445-
if (knownRoutePart.pattern !== null) {
471+
const existingPattern = readPattern(now, knownRoutePart)
472+
if (existingPattern !== null) {
446473
// If this route has a dynamic rewrite, mark the existing pattern.
447474
if (hasDynamicRewrite) {
448-
knownRoutePart.pattern.hasDynamicRewrite = true
475+
existingPattern.hasDynamicRewrite = true
449476
}
450-
return knownRoutePart.pattern
477+
return existingPattern
451478
}
452479

453480
// Get or create the entry
@@ -486,12 +513,14 @@ function discoverKnownRoutePart(
486513
* pattern, or null if no match is found (fall back to server resolution).
487514
*/
488515
export function matchKnownRoute(
516+
now: number,
489517
pathname: string,
490518
search: NormalizedSearch
491519
): FulfilledRouteCacheEntry | null {
492520
const pathnameParts = pathname.split('/').filter((p) => p !== '')
493521
const resolvedParams: ResolvedParams = new Map()
494522
const match = matchKnownRoutePart(
523+
now,
495524
knownRouteTreeRoot,
496525
pathnameParts,
497526
0,
@@ -593,6 +622,7 @@ type KnownRouteMatch = {
593622
* Returns null if no match found (caller should fall back to server).
594623
*/
595624
function matchKnownRoutePart(
625+
now: number,
596626
part: KnownRoutePart,
597627
pathnameParts: string[],
598628
partIndex: number,
@@ -610,7 +640,7 @@ function matchKnownRoutePart(
610640
if (part.staticChildren === null) {
611641
// The only safe match is a direct pattern when no URL parts remain.
612642
if (urlPart === null) {
613-
const pattern = part.pattern
643+
const pattern = readPattern(now, part)
614644
if (pattern !== null && !pattern.hasDynamicRewrite) {
615645
return { part, pattern }
616646
}
@@ -636,6 +666,7 @@ function matchKnownRoutePart(
636666
return null
637667
}
638668
const match = matchKnownRoutePart(
669+
now,
639670
staticChild,
640671
pathnameParts,
641672
partIndex + 1,
@@ -658,7 +689,7 @@ function matchKnownRoutePart(
658689
const dynamicPart = part.dynamicChild
659690
const paramName = part.dynamicChildParamName
660691
const paramType = part.dynamicChildParamType
661-
const dynamicPattern = dynamicPart.pattern
692+
const dynamicPattern = readPattern(now, dynamicPart)
662693

663694
switch (paramType) {
664695
case 'c':
@@ -672,7 +703,7 @@ function matchKnownRoutePart(
672703
return { part: dynamicPart, pattern: dynamicPattern }
673704
}
674705
break
675-
case 'oc':
706+
case 'oc': {
676707
// Optional catch-all [[...param]]: consumes 0+ URL parts
677708
if (dynamicPattern !== null && !dynamicPattern.hasDynamicRewrite) {
678709
if (urlPart !== null) {
@@ -681,19 +712,22 @@ function matchKnownRoutePart(
681712
}
682713
// urlPart is null - can match with zero parts, but a direct pattern
683714
// (e.g., page.tsx alongside [[...param]]) takes precedence.
684-
if (part.pattern === null || part.pattern.hasDynamicRewrite) {
715+
const directPattern = readPattern(now, part)
716+
if (directPattern === null || directPattern.hasDynamicRewrite) {
685717
resolvedParams.set(paramName, [])
686718
return { part: dynamicPart, pattern: dynamicPattern }
687719
}
688720
}
689721
break
722+
}
690723
case 'd':
691724
// Regular dynamic [param]: consumes exactly 1 URL part.
692725
// Unlike catch-all which terminates here, regular dynamic must
693726
// continue recursing to find the leaf pattern.
694727
if (urlPart !== null) {
695728
resolvedParams.set(paramName, urlPart)
696729
return matchKnownRoutePart(
730+
now,
697731
dynamicPart,
698732
pathnameParts,
699733
partIndex + 1,
@@ -721,7 +755,7 @@ function matchKnownRoutePart(
721755
// No children matched. If we've consumed all URL parts, check for a direct
722756
// pattern at this node (the route terminates here).
723757
if (urlPart === null) {
724-
const pattern = part.pattern
758+
const pattern = readPattern(now, part)
725759
if (pattern !== null && !pattern.hasDynamicRewrite) {
726760
return { part, pattern }
727761
}

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
FlightDataPath,
1414
PrefetchHints,
1515
} from '../../shared/lib/app-router-types'
16+
import { PrefetchHint } from '../../shared/lib/app-router-types'
1617
import type { Readable } from 'node:stream'
1718
import {
1819
workAsyncStorage,
@@ -1733,9 +1734,15 @@ async function getRSCPayload(
17331734
} = ctx
17341735

17351736
const hints = ctx.renderOpts.prefetchHints?.[ctx.pagePath] ?? null
1737+
const prefetchInliningEnabled = Boolean(
1738+
ctx.renderOpts.experimental.prefetchInlining
1739+
)
17361740
const initialTree = await createFlightRouterStateFromLoaderTree(
17371741
tree,
17381742
hints,
1743+
prefetchInliningEnabled,
1744+
workStore.isStaticGeneration,
1745+
ctx.renderOpts.isBuildTimePrerendering ?? false,
17391746
getDynamicParamFromSegment,
17401747
query
17411748
)
@@ -1924,9 +1931,15 @@ async function getErrorRSCPayload(
19241931
)
19251932

19261933
const errorHints = ctx.renderOpts.prefetchHints?.[ctx.pagePath] ?? null
1934+
const errorPrefetchInliningEnabled = Boolean(
1935+
ctx.renderOpts.experimental.prefetchInlining
1936+
)
19271937
const initialTree = await createFlightRouterStateFromLoaderTree(
19281938
tree,
19291939
errorHints,
1940+
errorPrefetchInliningEnabled,
1941+
workStore.isStaticGeneration,
1942+
ctx.renderOpts.isBuildTimePrerendering ?? false,
19301943
getDynamicParamFromSegment,
19311944
query
19321945
)
@@ -7244,7 +7257,27 @@ async function collectSegmentData(
72447257
} else {
72457258
// Runtime: use hints from the manifest. Never compute fresh hints
72467259
// during ISR/revalidation.
7247-
hints = renderOpts.prefetchHints?.[pagePath] ?? null
7260+
const manifestHints = renderOpts.prefetchHints?.[pagePath]
7261+
if (manifestHints === undefined) {
7262+
// TODO(#91407): No hints found for this route. This currently
7263+
// happens for routes with `instant = false` at the root segment,
7264+
// which causes the prerender to run per-request and the hints
7265+
// manifest to be unavailable at runtime.
7266+
//
7267+
// Fall back to a hint tree that marks everything as unprefetchable.
7268+
// The root gets PrefetchDisabled, and children inherit null hints
7269+
// which triggers PrefetchDisabled in createFlightRouterStateFromLoaderTree.
7270+
//
7271+
// Once the instant:false bug is fixed, this should become an error —
7272+
// the manifest should always have an entry for every route that
7273+
// reaches collectSegmentData.
7274+
hints = {
7275+
hints: PrefetchHint.PrefetchDisabled,
7276+
slots: null,
7277+
}
7278+
} else {
7279+
hints = manifestHints
7280+
}
72487281
}
72497282

72507283
// Pass the resolved hints so collectSegmentData can union them into

0 commit comments

Comments
 (0)