Skip to content

Commit 1a295a6

Browse files
authored
Align fallback parameter staging with shell validation (#98512)
`next dev` reported missing Suspense boundaries in layouts that the build accepted. For `/[top]/items/[bottom]`, `generateStaticParams` returned `[{ top: 't1' }]`, and the page wrapped its `bottom` access in Suspense. A request for `/t2/items/b2` still reported the layout's access to `top` as an error. Development treated both parameters as unresolved because the requested `top` value was not generated, although the required static shell only needed to defer `bottom`. Production Cached Navigations used the same overly broad parameter set and omitted eligible static content from repeat visits. Resumes also reconstructed that set from the original build manifest, even after an on-demand prerender had produced a more complete shell. This replaces the alternative proposed in #98460. That proposal fixes the development error by selecting a separate fallback parameter set for validation while keeping response staging unchanged. The two sets are not intended to differ for the same shell target. Correcting only validation would preserve the incorrect staging decision and leave production Cached Navigations without the eligible static content. Staging and static-shell validation now use one `stagedFallbackParams` set for each selected shell target. Required partial shells retain their unresolved parameters, even when a later request can complete them. Prerenders record their parameter set in postponed state, and resumes use that recorded set rather than reconstructing it from the generic source. Dynamic RSC requests now read and revalidate the completed-shell cache key, so they find partial artifacts that the fully resolved pathname lookup missed. Request metadata and `RequestStore` both expose the set as `stagedFallbackParams`. Action-only fallback detection checks actual unresolved parameters instead of treating deferred values as missing.
1 parent 79daac3 commit 1a295a6

50 files changed

Lines changed: 1424 additions & 297 deletions

File tree

Some content is hidden

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

‎packages/next/src/build/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,11 @@ export interface DynamicPrerenderManifestRoute
337337
*/
338338
remainingPrerenderableParams?: readonly FallbackRouteParam[]
339339

340+
/**
341+
* Whether this candidate must produce a nonempty static shell.
342+
*/
343+
throwOnEmptyStaticShell?: boolean
344+
340345
/**
341346
* When defined, it describes the revalidation configuration for the fallback
342347
* route.
@@ -3888,6 +3893,8 @@ export default async function build(
38883893
experimentalPPR: isRoutePPREnabled,
38893894
remainingPrerenderableParams:
38903895
route.remainingPrerenderableParams,
3896+
throwOnEmptyStaticShell:
3897+
prerenderCandidate?.throwOnEmptyStaticShell,
38913898
renderingMode: isAppPPREnabled
38923899
? isRoutePPREnabled
38933900
? RenderingMode.PARTIALLY_STATIC

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

Lines changed: 101 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { UNDERSCORE_NOT_FOUND_ROUTE } from '../../shared/lib/entry-constants' wi
3434
import {
3535
getFallbackRouteParams,
3636
getPlaceholderFallbackRouteParams,
37+
getStagedFallbackParams,
3738
buildDynamicSegmentPlaceholder,
3839
createOpaqueFallbackRouteParams,
3940
type OpaqueFallbackRouteParams,
@@ -585,6 +586,42 @@ export function createAppPageEntrypoint({
585586
prerenderInfo?.fallback === null &&
586587
(prerenderInfo.fallbackRootParams?.length ?? 0) > 0
587588

589+
// SSG writes and navigation RDC reads use the same completed-shell key.
590+
// Completion uses the matched shell rather than the fully resolved
591+
// pathname. A request for `/prefix/c/foo` can complete
592+
// `/prefix/[one]/[two]` to `/prefix/c/[two]`. This avoids creating an entry
593+
// for every value of `two`.
594+
//
595+
// The completed-shell key also applies when unresolved root params require
596+
// a blocking render. A source shell cannot be shared across root branches.
597+
// Completion resolves the params into the key as follows:
598+
// - Root params and other prerenderable params use concrete values.
599+
// - Never-prerenderable params remain placeholders.
600+
// The values of those placeholder params must not partition the cache.
601+
const fallbackPathname = prerenderMatch
602+
? typeof prerenderInfo?.fallback === 'string'
603+
? prerenderInfo.fallback
604+
: prerenderMatch.source
605+
: null
606+
let completedShellCacheKey: string | null = null
607+
if (
608+
nextConfig.partialPrefetching &&
609+
fallbackPathname &&
610+
prerenderInfo?.fallbackRouteParams?.length &&
611+
remainingPrerenderableParams.length > 0
612+
) {
613+
const cacheKey = buildCompletedShellCacheKey(
614+
fallbackPathname,
615+
remainingPrerenderableParams,
616+
params
617+
)
618+
619+
// Only a more complete shell gets a separate cache entry.
620+
if (cacheKey !== fallbackPathname) {
621+
completedShellCacheKey = cacheKey
622+
}
623+
}
624+
588625
let ssgCacheKey: string | null = null
589626
let usesCompletedShellCacheKey = false
590627
if (
@@ -595,22 +632,6 @@ export function createAppPageEntrypoint({
595632
!hasPostponedState &&
596633
!isDynamicRSCRequest
597634
) {
598-
// For normal SSG routes we cache by the fully resolved pathname. For
599-
// partial fallbacks we instead derive the cache key from the shell
600-
// that matched this request so `/prefix/[one]/[two]` can specialize into
601-
// `/prefix/c/[two]` without promoting all the way to `/prefix/c/foo`.
602-
// This includes entries with unresolved ROOT params: those requests are
603-
// served blocking (no shell can be shared across root branches), but
604-
// the entry they produce is still keyed by the completed shell — root
605-
// params and any other prerenderable params resolve into the key while
606-
// params that `generateStaticParams` can never provide stay as
607-
// placeholders and must not partition the cache.
608-
const fallbackPathname = prerenderMatch
609-
? typeof prerenderInfo?.fallback === 'string'
610-
? prerenderInfo.fallback
611-
: prerenderMatch.source
612-
: null
613-
614635
if (
615636
// Partial fallback shells are only specialized per request when Partial
616637
// Prefetching is enabled, mirroring the `partialFallback` flag the
@@ -622,21 +643,8 @@ export function createAppPageEntrypoint({
622643
fallbackPathname &&
623644
prerenderInfo?.fallbackRouteParams?.length
624645
) {
625-
if (remainingPrerenderableParams.length > 0) {
626-
const completedShellCacheKey = buildCompletedShellCacheKey(
627-
fallbackPathname,
628-
remainingPrerenderableParams,
629-
params
630-
)
631-
632-
// If applying the current request params doesn't make the shell any
633-
// more complete, then this shell is already at its most complete
634-
// form and should remain shared rather than creating a new cache entry.
635-
if (completedShellCacheKey !== fallbackPathname) {
636-
ssgCacheKey = completedShellCacheKey
637-
usesCompletedShellCacheKey = true
638-
}
639-
}
646+
ssgCacheKey = completedShellCacheKey
647+
usesCompletedShellCacheKey = completedShellCacheKey !== null
640648
} else {
641649
ssgCacheKey = resolvedPathname
642650
}
@@ -1204,43 +1212,28 @@ export function createAppPageEntrypoint({
12041212
fallbackRouteParams = null
12051213
}
12061214
} else {
1207-
// In dev the prerender manifest isn't populated for ad-hoc
1208-
// prefetches (`fallbackMode` is undefined for not-fully-generated
1209-
// routes, so the on-demand manifest write is skipped, and
1210-
// `getPrerenderManifest` is cached regardless). So
1211-
// `prerenderInfo` is unavailable here. Instead base-server
1212-
// derives the per-URL fallback set from the dev `getStaticPaths`
1213-
// result and threads it via the `fallbackParams` request meta —
1214-
// the most-specific prerendered route matching this URL, so
1215-
// `generateStaticParams`-covered params resolve in the shell and
1216-
// only the uncovered ones are deferred, matching what a
1217-
// production build serves. `isDebugFallbackShell` (the explicit
1218-
// fallback-shell debug flow) still forces the worst case.
1215+
// Dev selects the source for static-shell debug renders from
1216+
// the per-URL `getStaticPaths` result. The dev prerender
1217+
// manifest does not contain these ad-hoc paths. `base-server`
1218+
// passes the source's fallback params in request metadata.
1219+
// These renders need the source set, not the completed target's
1220+
// potentially smaller `stagedFallbackParams`.
12191221
if (isDebugFallbackShell) {
12201222
fallbackRouteParams = getFallbackRouteParams(
12211223
normalizedSrcPage,
12221224
routeModule
12231225
)
12241226
} else if (isDebugStaticShell) {
1225-
// base-server threads the per-URL fallback set via the
1226-
// `fallbackParams` meta for every dev Cache Components dynamic
1227-
// request, so reuse it as the fallback route params for this
1228-
// shell render. It only sets the meta for routes that still
1229-
// have uncovered params, so an absent meta means this URL is
1230-
// fully covered by `generateStaticParams` and there is nothing
1231-
// to defer (`null`).
12321227
fallbackRouteParams =
1233-
getRequestMeta(req, 'fallbackParams') ?? null
1228+
getRequestMeta(req, 'fallbackRouteParams') ?? null
12341229
} else {
12351230
fallbackRouteParams = null
12361231
}
12371232
}
12381233

1239-
// When rendering a debug static shell, override the fallback
1240-
// params on the request so that the staged rendering correctly
1241-
// defers params that are not statically known.
1242-
if (isDebugStaticShell && fallbackRouteParams) {
1243-
addRequestMeta(req, 'fallbackParams', fallbackRouteParams)
1234+
// Debug rendering stages the params of the selected source shell.
1235+
if (isDebugStaticShell) {
1236+
addRequestMeta(req, 'stagedFallbackParams', fallbackRouteParams)
12441237
}
12451238

12461239
// We use the response cache here to handle the revalidation and
@@ -1355,6 +1348,7 @@ export function createAppPageEntrypoint({
13551348
!isOnDemandRevalidate && !isRevalidating && minimalPostponed
13561349
? minimalPostponed
13571350
: undefined
1351+
let hasFullyStaticCacheEntry = false
13581352

13591353
if (
13601354
// If this is a dynamic RSC request or a server action request, we should
@@ -1377,8 +1371,10 @@ export function createAppPageEntrypoint({
13771371
// from entering an infinite loop of revalidations.
13781372
!forceStaticRender
13791373
) {
1374+
const incrementalCacheKey =
1375+
completedShellCacheKey ?? resolvedPathname
13801376
const incrementalCacheEntry = await incrementalCache.get(
1381-
resolvedPathname,
1377+
incrementalCacheKey,
13821378
{
13831379
kind: IncrementalCacheKind.APP_PAGE,
13841380
isRoutePPREnabled: true,
@@ -1396,6 +1392,7 @@ export function createAppPageEntrypoint({
13961392
// CRITICAL: we're assigning the postponed data from the cache entry
13971393
// here as we're using the RDC to resume the render.
13981394
postponed = incrementalCacheEntry.value.postponed
1395+
hasFullyStaticCacheEntry = postponed === undefined
13991396

14001397
// If the cache entry is stale, we should trigger a background
14011398
// revalidation so that subsequent requests will get a fresh response.
@@ -1414,7 +1411,7 @@ export function createAppPageEntrypoint({
14141411

14151412
try {
14161413
await responseCache.revalidate(
1417-
resolvedPathname,
1414+
incrementalCacheKey,
14181415
incrementalCache,
14191416
isRoutePPREnabled,
14201417
false,
@@ -1530,17 +1527,20 @@ export function createAppPageEntrypoint({
15301527
effectiveFallbackRouteParams.length <
15311528
(prerenderInfo?.fallbackRouteParams?.length ?? 0)
15321529
? createOpaqueFallbackRouteParams(effectiveFallbackRouteParams)
1533-
: // A render cached under a completed shell cache key must keep
1534-
// deferring the params the key leaves as placeholders (the
1535-
// ones `generateStaticParams` can never provide) so they
1536-
// resume per request instead of baking into the shared entry.
1537-
// This is the blocking analog of the background shell
1538-
// upgrade above and is likewise self-hosted only: in minimal
1539-
// mode the platform proxy owns this contract by stripping
1540-
// never-prerenderable params from the request, which defers
1541-
// them through the placeholder handling instead.
1530+
: // This render defers the params left unresolved in its key.
1531+
// The render must not store their concrete values in the
1532+
// shared entry. Per-request resumes resolve those params
1533+
// instead. Blocking renders and RSC-triggered revalidations
1534+
// both need this restriction.
1535+
//
1536+
// The serving modes obtain the unresolved params differently:
1537+
// - `next start` defers them in this branch.
1538+
// - Minimal mode uses the platform's placeholders above.
1539+
// The platform strips never-prerenderable param values before
1540+
// calling the origin.
15421541
!isMinimalMode &&
1543-
usesCompletedShellCacheKey &&
1542+
(usesCompletedShellCacheKey ||
1543+
(forceStaticRender && completedShellCacheKey !== null)) &&
15441544
remainingFallbackRouteParams.length > 0
15451545
? createOpaqueFallbackRouteParams(
15461546
remainingFallbackRouteParams
@@ -1549,24 +1549,35 @@ export function createAppPageEntrypoint({
15491549
? getFallbackRouteParams(normalizedSrcPage, routeModule)
15501550
: null
15511551

1552-
// For staged dynamic rendering (Cached Navigations) and debug static
1553-
// shell rendering, pass the fallback params via request meta so the
1554-
// RequestStore knows which params to defer. We don't pass them as
1555-
// fallbackRouteParams because that would replace actual param values
1556-
// with opaque placeholders during segment resolution.
1552+
// Staging defers params without replacing their values with opaque
1553+
// placeholders. A resumed render reads the selected artifact's mask
1554+
// from its postponed state.
15571555
if (
15581556
(isProduction || isDebugStaticShell) &&
1559-
nextConfig.cacheComponents &&
1560-
!isPrerendered &&
1561-
prerenderInfo?.fallbackRouteParams
1557+
nextConfig.cacheComponents
15621558
) {
1563-
const fallbackParams = createOpaqueFallbackRouteParams(
1564-
fallbackRouteParamsForRender ?? prerenderInfo.fallbackRouteParams
1565-
)
1566-
1567-
if (fallbackParams) {
1568-
addRequestMeta(req, 'fallbackParams', fallbackParams)
1559+
let stagedFallbackParams: OpaqueFallbackRouteParams | null
1560+
if (hasPlaceholderFallbackRouteParams) {
1561+
stagedFallbackParams = createOpaqueFallbackRouteParams(
1562+
placeholderFallbackRouteParams
1563+
)
1564+
} else if (isDebugStaticShell) {
1565+
stagedFallbackParams = fallbackRouteParams
1566+
} else if (hasFullyStaticCacheEntry || isPrerendered) {
1567+
stagedFallbackParams = null
1568+
} else if (postponed && prerenderInfo?.fallbackRouteParams) {
1569+
// App-render uses this source-shell set only when the supplied
1570+
// state does not record its own fallback params.
1571+
stagedFallbackParams = createOpaqueFallbackRouteParams(
1572+
prerenderInfo.fallbackRouteParams
1573+
)
1574+
} else if (prerenderInfo) {
1575+
stagedFallbackParams = getStagedFallbackParams(prerenderInfo)
1576+
} else {
1577+
stagedFallbackParams = null
15691578
}
1579+
1580+
addRequestMeta(req, 'stagedFallbackParams', stagedFallbackParams)
15701581
}
15711582

15721583
// Forced static and debug renders are prerenders even when the
@@ -2094,19 +2105,14 @@ export function createAppPageEntrypoint({
20942105
const transformer = new TransformStream<Uint8Array, Uint8Array>()
20952106
body.push(transformer.readable)
20962107

2097-
// Plumb fallback params via request meta so the RequestStore created
2098-
// downstream in app-render.tsx knows which params to defer during the
2099-
// resume. We don't pass them as `fallbackRouteParams` because that
2100-
// would replace actual param values with opaque placeholders during
2101-
// segment resolution; the resolved values are baked into the URL and
2102-
// already interpolated into the postponed state.
2108+
// This metadata supplies fallback params when the saved state does not
2109+
// record them. App-render uses the saved state's own set when present.
21032110
if (nextConfig.cacheComponents && prerenderInfo?.fallbackRouteParams) {
2104-
const fallbackParams = createOpaqueFallbackRouteParams(
2105-
prerenderInfo.fallbackRouteParams
2111+
addRequestMeta(
2112+
req,
2113+
'stagedFallbackParams',
2114+
createOpaqueFallbackRouteParams(prerenderInfo.fallbackRouteParams)
21062115
)
2107-
if (fallbackParams) {
2108-
addRequestMeta(req, 'fallbackParams', fallbackParams)
2109-
}
21102116
}
21112117

21122118
// Perform the render again, but this time, provide the postponed state.
@@ -2115,8 +2121,8 @@ export function createAppPageEntrypoint({
21152121
doRender({
21162122
span,
21172123
postponed: cachedData.postponed,
2118-
// This is a resume render, not a fallback render. Fallback params
2119-
// (for cacheComponents routes) are plumbed via request meta above.
2124+
// The resume retains concrete param values; staging only defers
2125+
// access.
21202126
fallbackRouteParams: null,
21212127
renderOperation: 'render',
21222128
})

‎packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -452,9 +452,9 @@ export function processMessage(
452452
}
453453
case HMR_MESSAGE_SENT_TO_BROWSER.STATIC_PARAMS_CHANGED: {
454454
// Re-fetch the current router tree so the render picks up the new set of
455-
// statically-known params (and thus the fresh `fallbackParams`). Unlike
456-
// `SERVER_COMPONENT_CHANGES` this does not store an HMR refresh hash, so
457-
// it doesn't invalidate `"use cache"` entries.
455+
// statically-known params (and thus the fresh `stagedFallbackParams`).
456+
// Unlike `SERVER_COMPONENT_CHANGES` this does not store an HMR refresh
457+
// hash, so it doesn't invalidate `"use cache"` entries.
458458
if (
459459
RuntimeErrorHandler.hadRuntimeError ||
460460
document.documentElement.id === '__next_error__'

‎packages/next/src/server/app-render/action-handler.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,7 @@ export async function handleAction({
784784
// which to resume the destination page.
785785
const isActionOnlyFallbackRequest =
786786
isFetchAction &&
787-
requestStore.fallbackParams != null &&
787+
ctx.fallbackRouteParams != null &&
788788
typeof ctx.renderOpts.postponed === 'string'
789789
const shouldSkipPageRendering =
790790
actionWasForwarded || isActionOnlyFallbackRequest

0 commit comments

Comments
 (0)