fix(ppr): gate fallback shells until request-time resume is supported - #1716
Conversation
commit: |
|
Replacing with stacked version |
a389392 to
f0ede3a
Compare
- normalizePregeneratedPathname: document the deliberate non-strict vs strict normalization choice so it isn't "fixed" into a build-time throw - addPregeneratedConcretePath: normalize the pathname internally as the single source of truth, removing the caller pre-normalize footgun - export PrerenderRouteParamsRouteMatch for cloudflare#1716 consumers - cover direct un-normalized recording with a test
The type is exported for cloudflare#1716's serving consumers but has no in-repo reference yet, so knip's no-unused-exports gate flags it. Knip excludes exports tagged @public from that report; this keeps the reviewer-requested export without failing CI.
…d tests (#1714) * feat(ppr): add fallback shell payload identity * refactor(ppr): address review feedback on payload identity helpers - normalizePregeneratedPathname: document the deliberate non-strict vs strict normalization choice so it isn't "fixed" into a build-time throw - addPregeneratedConcretePath: normalize the pathname internally as the single source of truth, removing the caller pre-normalize footgun - export PrerenderRouteParamsRouteMatch for #1716 consumers - cover direct un-normalized recording with a test * fix(ppr): mark PrerenderRouteParamsRouteMatch @public for knip The type is exported for #1716's serving consumers but has no in-repo reference yet, so knip's no-unused-exports gate flags it. Knip excludes exports tagged @public from that report; this keeps the reviewer-requested export without failing CI. * docs(ppr): document live-Set and dedup contracts on concrete-path registry Address bonk review notes: - getRenderedConcreteUrlPathsForRoute returns the live backing Set for allocation-free hot-path lookups; document that callers must not retain the reference across a re-seed (clear empties the map, stranding it). - parsePregeneratedConcretePaths intentionally does not dedupe repeated route patterns; document that they merge additively and value-dedup in addPregeneratedConcretePath makes the result equivalent. --------- Co-authored-by: James <james@eli.cx>
bcef56b to
bdc87a4
Compare
|
Filed follow-up #1861 for the remaining deploy-time concrete-path registry coupling. Treating that as non-blocking for this PR: #1716 now covers the runtime guard and module-init hydration contract, while #1861 tracks whether registry population should move into build/prerender finalization or be documented as vinext deploy-only behavior. |
|
Addressed the two small non-blocking clarity items in 77379e0: |
77379e0 to
276f2dc
Compare
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the full stack. The core safe-serving invariant is implemented correctly: exact cache lookup and static-param validation run ahead of fallback-shell probing, the known-pregenerated-route guard (renderedConcreteUrlPaths) prevents silently degrading a known route whose cache has a transient gap, and the search-param / RSC / non-GET guards keep shell reuse narrowly scoped. Behavior matches the documented Next.js execution order and the referenced ppr-root-param-fallback tests. The entry stays thin and delegates to typed helpers (app-page-cache.ts, app-ppr-fallback-shell-render.ts), consistent with the repo's layering guidance, and test coverage is thorough (exact HIT, unknown-child STALE regen, query-bearing skip, encoded/worker-registry known routes). Ran vp test run on the dispatch/cache/seed/deploy/prerender-route-params/ppr-fallback-shell suites (378 tests) and vp check on the touched server files — all green.
A few non-blocking observations below. None block merge.
Production key consistency confirmed: the prerender manifest route field is set to route.pattern (colon notation, e.g. /:locale/blog/:slug) at write time (build/prerender.ts), and the runtime guard looks up by route.pattern, so the registry key and lookup key match in production. The only mismatch is in test fixtures (see comment on prerender-manifest.ts).
|
Review submitted successfully. Summary of what I found: Review of PR #1716 —
|
|
/bigbonk review for issues |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review of #1716 — feat(ppr): safely serve cacheComponents fallback shells
Reviewed the full diff and traced the runtime path end-to-end. The core safe-serving invariant is implemented correctly and the prior Bonk review's three observations have all been addressed in commit b7c3803.
Verified correct:
- Ordering (
app-page-dispatch.ts:681-886): exact cache read → static-param validation → fallback-shell probing. A known route is never silently replaced by a placeholder shell. TherenderedConcreteUrlPathsguard (tryServePprFallbackShell, line 529-530) treats a missing exact cache for a known pregenerated route as a transient gap. - Scope guards (line 531-549): RSC, non-GET, search-param-bearing (unless force-static), draft-mode, and non-production requests all correctly skip shell reuse.
- Key parity: registry stores/looks up by
route.pattern(colon notation) in both the manifest writer (build/prerender.ts) and runtime (app-rsc-handler.ts:807).cleanPathnamenormalization (normalizePath ∘ normalizePathnameForRouteMatchStrict) agrees with the registry'snormalizePregeneratedPathnamefor all well-formed input (malformed encoding is rejected with 400 before dispatch). Encoded-known-route test confirms this. - Stale regen targets the shell key, not the concrete unknown URL (
app-page-cache.ts:555-578) — no concrete-path cache poisoning. - Suspense integration:
headers()/cookies()return a hanging suspense promise only when a fallback-shell state is active, returningnullotherwise so the ordinary path is unchanged. The_decorateSuspendingRequestApiPromiseproxy correctly distinguishesawait headers()(then/catch/finally) from synchronous member access (throws). server-onlyguard refactor (index.ts): the"use server"exemption now uses the lightweight directive-prologue scanner instead of a full AST parse; behavior preserved.- Idempotent HTML rewrite: re-running
rewriteAppPprFallbackShellHtmlNavigationappends a second metadata script; sincecreateNavigationRuntimeRscMetadataScriptemitsObject.assign(...,{params,nav})and scripts run in document order, last-wins correctly overrides the cached placeholder. Covered by the new test.
Resolved prior observations (commit b7c3803):
isFallbackShellArtifactPathnow checksroute.fallback === truefirst; the manifest reliably carries the flag (prerender.ts:1394).isReactDevelopmentRuntimenow gates onNODE_ENVbefore the function-source heuristic.- Test fixtures converted to colon notation, matching production manifest format.
Ran targeted suites locally — all green:
tests/app-page-dispatch.test.ts,tests/app-page-cache.test.ts,tests/app-ppr-fallback-shell.test.ts(64 tests)tests/deploy.test.ts,tests/seed-cache.test.ts,tests/pregenerated-concrete-paths.test.ts,tests/prerender-route-params.test.ts,tests/ppr-fallback-shell.test.ts(322 tests)
One minor, non-blocking follow-up note inline. Nothing blocks merge.
|
Review submitted successfully. SummaryI reviewed PR #1716 ( What's correct (verified):
Blocking issue found:
Behavioral gap surfaced by the dead code:
|
|
/bigbonk review for issues |
There was a problem hiding this comment.
Re-reviewed at 433cf14 (two commits past the 2f57bf6 the last Bonk review saw). The two blocking concerns from that review are now resolved:
- knip / dead code:
renderFreshPprFallbackShellForCacheand its private helpers/types were removed in91def17+433cf14. I reproducedkniplocally and it now exits clean (no unused exports). The CICheckjob should pass. - Stale-shell regeneration: removing the orphaned regen path makes the code, the test (
app-page-cache.test.ts:689— "serves stale static fallback shells without regenerating the shared shell key"), and the no-opscheduleRegeneration() {}(app-page-cache.ts:618) all internally consistent. No-regeneration is now the deliberate, tested design.
Verified correct (independent trace):
- Ordering in
dispatchAppPageInner: exact cache lookup (endsapp-page-dispatch.ts:830) → static-param validation (832–843) → fallback-shell probing (845–855). A known route is never silently replaced by a placeholder shell. classifyPprFallbackShellEligibility(app-page-dispatch.ts:510) puts therenderedConcreteUrlPathsknown-route guard as the first branch; a missing exact cache for a pregenerated path is treated as a transient gap, not an "unknown" signal.- Scope guards (RSC / non-GET / search-params / draft-mode / cache-disabled) keep shell reuse narrow.
isFallbackShellArtifactPath(prerender-manifest.ts:52) treatsroute.fallbackas authoritative and scopes the bracket heuristic to legacy manifests (fallback === undefined) with a dev warning — prior review's suggestion adopted.isReactDevelopmentRuntime(app-ssr-entry.ts:87) gates onNODE_ENVbefore the function-source fallback.
Ran locally with vp (the harness CI uses): tests/app-page-dispatch.test.ts, app-page-cache.test.ts, app-ppr-fallback-shell.test.ts (72), plus deploy/seed-cache/pregenerated-concrete-paths/prerender-route-params (334) — all green. vp check clean on touched server files.
One non-blocking documentation nit below. Nothing blocks merge.
|
Review submitted successfully. SummaryI reviewed PR #1716 ( The two blocking concerns from the last review are resolved:
Independently verified correct:
One non-blocking nit: the PR description's "What Changed" table still claims stale shells "schedule regeneration," which now contradicts the intentional no-op. Posted an inline comment asking for the description to be reconciled. Nothing blocks merge. |
|
/bigbonk review for issues |
# Conflicts: # .github/workflows/ci.yml # playwright.config.ts
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed at HEAD 2fe663d (one commit past the 433cf14 the previous Bonk review examined). That last commit, fix(ppr): safely gate fallback shell prerendering, is a meaningful behavioral change that the earlier reviews did not see, so I focused there.
What 2fe663d does (and it's the right call): fallback-shell generation is now gated behind an internal __VINEXT_EXPERIMENTAL_PPR_FALLBACK_SHELLS env flag, defaulting off. The comment explains why — these artifacts are partial HTML/RSC shells that need a request-time resume lifecycle vinext doesn't yet implement, and serving one as complete HTML drops hydration into the global error boundary. The new ppr-impact-demo e2e (tests/e2e/ppr-impact-demo/ppr-fallback-shell.spec.ts) runs with the flag off and asserts an unknown root-param route renders safely without a partial artifact, and it's wired into CI (ci.yml). The render-side prerender/abort wiring (prerenderToReadableStream, reactAbortController, beginPprFallbackShellFinalRender, skipProbes) is coherent and the new exports are all consumed.
Verified:
- Build-time generation is gated; runtime serving (
runtimeFallbackShells->probePprFallbackShellCache) is not gated, but that's safe: with generation off no shells are prerendered, so the runtime probe finds nothing and returns null (clean fallthrough). The warmup/prerender path (app-page-dispatch.ts:981) only fires forisPrerenderFallbackShellrequests, which only occur during fallback-shell prerendering — gated off. tests/ppr-fallback-shell.test.ts,tests/prerender.test.ts,tests/app-page-stream.test.tspass locally (117 tests).vp checkclean on the touched server files.- The default-off
prerender.test.tsnow asserts/en/blog/[slug]is not queued, and the opt-in variant asserts it is — good coverage of both paths.
One blocking issue (CI): see inline. vp run knip (ci.yml:28) fails on this branch.
Non-blocking: the PR description's "What Changed" table still says stale shells "schedule regeneration," which contradicts the deliberate scheduleRegeneration() {} no-op (app-page-cache.ts:618) — already noted in a prior review. The description's overall framing ("safely serve cacheComponents fallback shells") also no longer matches the shipped default behavior, which is to not serve them; worth a description update so reviewers/readers aren't misled.
|
Review submitted successfully. SummaryI reviewed PR #1716 at HEAD Key finding — the latest commit reverses the PR's posture: fallback-shell generation is now gated behind an internal Verified correct:
One blocking issue (CI): Non-blocking: the PR description's "What Changed" table (stale shells "schedule regeneration") and the overall "safely serve cacheComponents fallback shells" framing no longer match the shipped default (which is to not serve them) — worth a description update. |
|
/bigbonk review for issues |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed at HEAD e9159b9 (fix(ppr): keep fallback shell gate internal) — one commit past the 2fe663d the previous Bonk review examined.
The prior blocking issue is resolved. e9159b9 drops the export from isExperimentalPprFallbackShellGenerationEnabled (its only caller is in prerender.ts:1198). I reproduced locally: npx knip --no-progress now exits 0, so the CI Check job should pass.
Verified correct (independent trace):
- Build-time fallback-shell generation is gated behind
__VINEXT_EXPERIMENTAL_PPR_FALLBACK_SHELLSandcacheComponents === true, defaulting off (prerender.ts:1196-1216).prerender.test.tscovers all three paths: default-off does not queue/en/blog/[slug], opt-in does, and cacheComponents-disabled does not. - Runtime serving path is not gated but is safe: with generation off no shells are prerendered,
probePprFallbackShellCachefinds nothing and returns null (clean fallthrough).classifyPprFallbackShellEligibilityputs therenderedConcreteUrlPathsknown-route guard first, treating a missing exact cache for a pregenerated route as a transient gap. - Scope guards (RSC / non-GET / search-params / draft-mode / cache-disabled) keep reuse narrow.
skipProbesis correctly scoped topprFallbackShellSignal !== undefined(fallback prerender only) —app-page-render.ts:583.- The new shim plumbing (
markPprFallbackShellDynamicBoundary, suspending request-API proxy inheaders()/cookies()) early-returns when no fallback-shell state is present (fallbackParamNames.size === 0), so the ordinary path is unchanged. ppr-impact-demoe2e is wired into CI (ci.yml,playwright.config.ts) and asserts an unknown root-param route renders without the global error UI.
Ran locally with vp: tests/ppr-fallback-shell.test.ts, tests/prerender.test.ts, tests/app-page-dispatch.test.ts, tests/app-page-cache.test.ts (158), plus tests/shims.test.ts, tests/app-page-stream.test.ts, tests/app-ppr-fallback-shell.test.ts (1116) — all green. vp check clean on touched server files.
One non-blocking observation (latent safety gap): see inline. The serve-time defense-in-depth guard relies on a marker that production code never applies. Nothing blocks merge given generation is off by default.
Doc nit: the PR title/body now correctly reflect the gated-off posture, but the "What Changed" line about stale shells scheduling regeneration still contradicts the deliberate scheduleRegeneration() {} no-op (app-page-cache.ts:618) — already raised in prior reviews; worth reconciling.
|
Review submitted successfully. SummaryI reviewed PR #1716 at HEAD Prior blocking issue resolved: Verified correct (independent trace):
One new non-blocking finding (latent safety gap): the serve-time defense-in-depth guard at Doc nit: the "What Changed" stale-shell-regeneration line still contradicts the deliberate Nothing blocks merge. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed at HEAD 9a8353c (fix(ppr): mark generated dynamic fallback shells) — one commit past the e9159b9 the previous Bonk review examined.
The prior latent safety gap is resolved. The previous review flagged that the serve-time guard at app-page-cache.ts:600 (isAppPprDynamicFallbackShellHtml) depended on the markAppPprDynamicFallbackShellHtml marker, but that marker was only ever applied in tests — never in the production render path. 9a8353c wires it into the actual prerender path (build/prerender.ts:1341-1343), marking the HTML when isFallback === true. With the experimental flag enabled, generated partial shells now carry the marker, so the serve-time guard genuinely rejects them. The guard is no longer inert. prerender.test.ts now asserts the opt-in artifact contains the marker.
Verified correct (independent trace):
- Marker application is correctly scoped:
isFallbackis only set inside the experimental-flag +cacheComponents-gated block (prerender.ts:1199-1217), so default builds are unchanged. - The marker is appended to the end of the HTML, after the RSC bootstrap
<script>chunks.extractRscPayloadFromPrerenderedHtmlscans<script>tags, so the trailing comment does not interfere with RSC payload extraction. - The serve-time guard reads the raw cached HTML and rejects before
serveAppPageCachedHtml/rewriteHtmlruns, so the rewrite never sees a marked shell. headers()/cookies()shim integration is a no-op in the ordinary path (createPprFallbackShellSuspensePromisereturnsnullwhen no fallback-shell state exists).server-onlyguard refactor (index.ts) preserves behavior — the"use server"exemption now reusesgetLeadingReactDirective;tests/middleware-server-only.test.tspasses (incl. the App Router Server Actionsimport 'server-only'case).- knip exits clean;
vp checkclean on touched files.
Ran locally with vp: prerender.test.ts (fallback-shell suite), app-page-cache.test.ts, app-ppr-fallback-shell.test.ts, app-page-dispatch.test.ts, ppr-fallback-shell.test.ts, app-page-stream.test.ts, shims.test.ts, seed-cache.test.ts, pregenerated-concrete-paths.test.ts, prerender-route-params.test.ts, and the PPR-specific deploy.test.ts cases — all green.
Note (unrelated pre-existing failure): tests/deploy.test.ts > resolveWranglerBin > returns a clear fallback path when Wrangler is missing fails in this environment, but the test is unmodified by this PR and present unchanged on main — it resolves wrangler from the monorepo's hoisted node_modules, so the "missing wrangler" simulation doesn't hold. Not a regression from this PR.
Two non-blocking observations inline. Nothing blocks merge.
|
Review submitted successfully. SummaryI reviewed PR #1716 at HEAD The prior latent safety gap is resolved. The last review flagged that the serve-time guard ( Verified correct (independent trace):
Two non-blocking observations posted inline:
Note: one failing test ( Nothing blocks merge. |
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed at HEAD 7a7ee0a (test(ppr): align fallback fixture and search params) — one commit past the 9a8353c the previous Bonk review examined. This commit addresses the two non-blocking observations from that review, and I found nothing blocking.
Prior observations both resolved:
-
useSearchParamsdynamic-boundary inconsistency — the server branch now callsmarkPprFallbackShellDynamicBoundary()(navigation.ts:1507), matchingusePathname/useParams/useSelectedLayoutSegments. A newshims.test.tsread-hook entry exercisesuseSearchParams()alongside the others. The marker is a no-op in the ordinary path —markPprFallbackShellDynamicBoundary()early-returns whenstate === null || fallbackParamNames.size === 0(ppr-fallback-shell.ts:236), so normal server renders are unaffected and only the gated-off fallback-shell generation path observes the boundary. -
examples/ppr-impact-demoplaced underexamples/— moved totests/fixtures/ppr-impact-demoper AGENTS.md guidance. I confirmed:examples/ppr-impact-demois fully removed (clean rename, no stale dir).playwright.config.tscwd/testDirand the README's relative CLI paths all updated to the new depth (../../../packages/...).pnpm-lock.yamlworkspace link updated;tests/fixtures/*is inpnpm-workspace.yaml, so the fixture is registered.- It is correctly absent from the deploy matrix and
scripts/smoke-test.sh(it's a CI-only fixture), and remains wired into the e2e matrix inci.yml:337and theppr-impact-demoPlaywright project.
Independently verified (still correct after the move):
npx knip --no-progressexits 0 — no unused exports; the CI Check job should pass.- The serve-time guard (
app-page-cache.ts:600) is backed by a real production marker:markAppPprDynamicFallbackShellHtmlis applied in the prerender path (build/prerender.ts:1341-1343) only whenisFallback === true, which is scoped inside the experimental-flag +cacheComponentsgate. Default builds are unmarked and unchanged. - Dispatch ordering invariant holds:
classifyPprFallbackShellEligibilityputs therenderedConcreteUrlPathsknown-pregenerated-route guard as the first branch (app-page-dispatch.ts:529-533), treating a missing exact cache for a known route as a transient gap rather than an unknown signal. - Targeted suites pass locally via
vp:tests/shims.test.ts,tests/ppr-fallback-shell.test.ts,tests/app-ppr-fallback-shell.test.ts(1109), plustests/app-page-dispatch.test.ts,tests/app-page-cache.test.ts,tests/prerender.test.ts(143).
One non-blocking doc nit (carried over, still unaddressed): the PR description's "What Changed"/posture framing has been updated to the gated-off stance, but earlier reviews noted the stale-shell-regeneration line contradicting the deliberate scheduleRegeneration() {} no-op (app-page-cache.ts:618). The current body no longer contains that table row, so this appears resolved as well.
Nothing blocks merge.
|
Review submitted successfully. SummaryI reviewed PR #1716 at HEAD Both prior observations resolved:
Independently verified still-correct after the move: knip exits 0 (CI Check should pass), the serve-time guard is backed by the production marker in the gated prerender path, the dispatch ordering invariant holds (known-pregenerated-route guard first), and targeted suites pass locally (1109 + 143 tests). The carried-over doc nit about stale-shell regeneration also appears resolved — that table row is no longer in the description. Nothing blocks merge. |
Overview
This PR builds the fallback-shell generation and serving prerequisites needed for Next.js
cacheComponentsparity, but keeps fallback-shell artifact generation disabled by default until vinext implements request-time resume.Partial HTML/RSC fallback artifacts cannot safely be served as complete documents. Without resume, React hydration falls into the global error boundary. The internal
__VINEXT_EXPERIMENTAL_PPR_FALLBACK_SHELLS=1opt-in exists only for focused generation tests and development of the follow-up resume implementation.__VINEXT_EXPERIMENTAL_PPR_FALLBACK_SHELLS=1enables artifact generation for focused testing only.Why
Next.js PPR fallback shells require two coordinated halves:
This PR establishes and tests the build/render prerequisites, including the RSC-to-SSR signal handoff, abort lifecycle, static Flight prerendering, and cache-task coordination. Since vinext does not yet have step 2, generation is gated off by default rather than exposing a broken hydration path.
What Changed
Safe Default
With ordinary configuration,
cacheComponents: truedoes not queue a bracket-param fallback artifact. For example,/en/blog/knownmay be prerendered, but/en/blog/[slug]is not emitted. A request for/en/blog/new-posttherefore performs a normal concrete render and hydrates successfully.The internal opt-in is intentionally not a supported public feature flag. It allows artifact generation to remain testable while the request-time resume follow-up is developed separately.
Validation
vp run knipvp test run tests/ppr-fallback-shell.test.ts tests/app-page-stream.test.ts tests/app-page-dispatch.test.ts tests/prerender.test.tsvp test run tests/prerender.test.ts -t \"cacheComponents PPR fallback-shell artifacts\"PLAYWRIGHT_PROJECT=ppr-impact-demo vp exec playwright testvp checkon all touched PPR runtime, test, example, and Playwright files/en/blog/new-post: concrete blog/comments content rendered, no global error UIFollow-up Required
A separate PR should implement and validate request-time resume before enabling fallback-shell generation by default. Worker prerender-cache population should be designed separately rather than added implicitly here.
Non-goals
References
Refs #1359