refactor(server): replace prerender HTML buffering with metadataReady contract#1809
Conversation
commit: |
…ansform when waitForAllReady is true
…ataReady promise resolution
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: replace prerender HTML buffering with metadataReady contract
Reviewed the diff, traced all handleSsr / renderAppPageHtmlStream callers, and ran the relevant suites locally — tests/app-page-render.test.ts (46), tests/app-page-stream.test.ts (16), and tests/isr-cache.test.ts (64) all pass, and vp check is clean on the four changed source files.
This is a solid change. Removing readStreamAsText + createBufferedHtmlStream from the prerender path means the HTML body is no longer fully buffered inside the lifecycle; it's returned as a live stream and consumed by the prerender driver (build/prerender.ts:781 does await response.text()). The cache-life/metadata mechanism is preserved because:
handleSsrstill awaitshtmlStream.allReadywhenwaitForAllReadyis set (prerender), so the RSC tree is fully rendered andrscErrorTrackeris populated before thehasLoadingBoundaryspecial-error check runs.settleCapturedRscRenderForCacheMetadata(htmlRender.capturedRscData)drives the captured RSC buffer to completion beforeapplyRequestCacheLife, same as the oldcapturedRscDataRef.valueawait.
The allReady-await reordering in app-ssr-entry.ts (moved from before the CSS/font setup block to immediately before pipeThrough) is behavior-preserving — the intervening code is synchronous closure/string setup that never pulls from htmlStream.
The type guard isAppSsrRenderResult correctly distinguishes a raw ReadableStream (no htmlStream/metadataReady props) from the new result object, and the normalize* helpers keep the union contained. No callers outside the App Router page path consume handleSsr, so the contract change is well-scoped.
A few minor / non-blocking observations below.
|
|
||
| return { | ||
| htmlStream: finalStream, | ||
| metadataReady: Promise.resolve(), |
There was a problem hiding this comment.
The real handleSsr always returns metadataReady: Promise.resolve() — the actual prerender blocking happens via await htmlStream.allReady a few lines up. So await htmlRender.metadataReady in renderAppPageLifecycle (app-page-render.ts:850) is effectively a no-op for the production path; the only thing that exercises a pending metadataReady is the test mock.
That's fine as a contract (it keeps the lifecycle agnostic to where the blocking happens), but it's worth a short comment here explaining that metadataReady resolves eagerly precisely because allReady was already awaited above — otherwise a future reader may assume metadataReady is load-bearing in prod and try to defer the allReady await onto it.
| basePath: options.basePath, | ||
| clientTraceMetadata: options.clientTraceMetadata, | ||
| rootParams: options.rootParams, | ||
| waitForAllReady: true, |
There was a problem hiding this comment.
Good catch adding waitForAllReady: true to the ISR revalidation render — previously this path did not wait for allReady, so Suspense fallback content could leak into the regenerated cache entry. This is a real correctness improvement and brings revalidation in line with the prerender path.
However, it's a behavioral change that isn't covered by a dedicated test. The new tests in app-page-render.test.ts cover the prerender lifecycle, but not the dispatch revalidation branch. Consider adding a focused test (or extending an isr-cache test) that asserts a revalidated entry does not contain fallback content / that allReady is awaited before the cache write, so this guarantee doesn't silently regress.
| } from "./client-reuse-manifest.js"; | ||
| import { NO_STORE_CACHE_CONTROL } from "./cache-control.js"; | ||
| import { readStreamAsText } from "../utils/text-stream.js"; | ||
|
|
There was a problem hiding this comment.
Stray blank line left behind where the readStreamAsText import was removed. Minor, but worth cleaning up:
| import { NO_STORE_CACHE_CONTROL } from "./cache-control.js"; | |
| import { |
| loadSsrHandler: async () => ({ | ||
| async handleSsr(_rscStream, _navContext, _fontData, _options) { | ||
| // Trigger the captured onError callback representing a component throw | ||
| if (capturedOnError) { |
There was a problem hiding this comment.
let capturedOnError: any and _options use any/loose typing. Minor nit for consistency with the rest of the file's typed mocks — consider typing capturedOnError as ((error: unknown, ...args: unknown[]) => void) | null. Not blocking.
|
Review submitted successfully. Here's a summary of my review of PR #1809. SummaryThe PR replaces full HTML stream buffering during prerendering with an explicit Verdict: solid, well-scoped change. No blocking issues found. What I verified
Feedback posted (all non-blocking)
|
…nits - Add focused test asserting stale HTML cache regeneration passes waitForAllReady: true to handleSsr so Suspense fallbacks never leak into the cache entry - Document why metadataReady resolves eagerly (allReady is already awaited for the prerender path) in app-ssr-entry.ts - Remove stray blank line left by the readStreamAsText import removal - Type capturedOnError in the prerender special-error test instead of any
|
/bigbonk review for issues |
The dts bundle build (vp pack) failed because pages-page-handler.ts imports PagesPageModule from pages-page-data.js, but the type was not exported, so rolldown-plugin-dts could not resolve it.
|
/bigbonk review for issues |
Triage / Issue Context
This PR resolves Issue #1795.
Previously,
renderAppPageLifecycle()implicitly buffered the entire HTML stream usingreadStreamAsText(htmlStream)during prerendering in order to discover metadata (such as cache life revalidation and headers) generated during component rendering. While this worked, it forced full stream buffering of the body.Proposed Changes
We introduce an explicit
metadataReadypromise contract between the SSR handler (app-ssr-entry.ts) and the app-page rendering lifecycle (app-page-render.ts).AppSsrRenderResult:Introduced a unified return result for
handleSsr()underapp-page-stream.ts:SSR Entry Contract (
handleSsr):handleSsr()to returnAppSsrRenderResult.metadataReady: Promise.resolve(), ensuring fully unbuffered progressive streaming.waitForAllReady: true): It blocks and awaitshtmlStream.allReadyinsidehandleSsr()before piping the HTML stream through the tick-buffered transform pipeline. This guarantees that Fizz HTML is not pulled or transformed before the React rendering tree is fully ready, preventing suspense fallback leaks. The returnedmetadataReadypromise is set toPromise.resolve()sinceallReadyis already resolved.Lifecycle Integration:
Modified
renderAppPageLifecycle()to:htmlRender.metadataReadyandsettleCapturedRscRenderForCacheMetadata(htmlRender.capturedRscData)whenoptions.isPrerender === true.readStreamAsTextandcreateBufferedHtmlStreamlogic from the rendering flow.ReadableStream.redirect()/notFound()thrown during the full SSR pass) after awaitingmetadataReadyto ensure those errors are correctly captured and processed before the response is returned.ISR Revalidation:
app-page-dispatch.tsto passwaitForAllReady: truewhen rendering fresh cache output to block stream transformations until React is fully ready, preserving correct cache state.Refactored Compatibility Glue:
normalizeAppSsrRenderResulthelper and a sharedresolvedMetadataReadyconstant inapp-page-stream.tsto normalize return results cleanly, avoiding duplicate compatibility code inrenderAppPageHtmlStreamandrenderAppPageHtmlStreamWithRecovery.Verification Plan
"streams runtime HTML responses progressively without buffering the body"verifying that runtime requests stream chunks progressively."waits for metadataReady to resolve before returning the response in prerender mode"."captures special errors thrown during the full prerender SSR pass and converts them to 307/404 response".vp check(passed with zero lint/formatting/type/knip errors).pnpm run build(successful).Next.js Reference: matches Next.js static rendering flow where
allReadyis awaited asynchronously for static/ISR targets: Next.js app-render.tsx