Skip to content

fix(pages): apply Document renderPage enhancers - #2034

Merged
james-elicx merged 7 commits into
mainfrom
codex/fix-document-render-page-enhancers-20260615
Jun 15, 2026
Merged

fix(pages): apply Document renderPage enhancers#2034
james-elicx merged 7 commits into
mainfrom
codex/fix-document-render-page-enhancers-20260615

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • support object and legacy function forms of ctx.renderPage enhancers
  • make base Document.getInitialProps(ctx) delegate to ctx.defaultGetInitialProps(ctx) for canonical CSS-in-JS integrations
  • preserve custom Document HTML/styles when renderPage is intentionally skipped
  • propagate enhancer/document/style errors through the normal error-page pipeline
  • align dev and production error Document context and prevent fallback/double renders
  • update public Document typings

Next.js parity

Fixes the _document.getInitialProps().renderPage() enhancer parity failures and supports documented styled-components/emotion integration patterns.

Validation

  • 364 document/pages response/router tests passed
  • dev and production integration cases passed
  • full vp check and vinext build passed
  • repeated independent review; no actionable findings remain

Cache Components, PPR, and resume behavior are out of scope.

@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2034
npm i https://pkg.pr.new/vinext@2034

commit: d8b620c

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(pages): apply Document renderPage enhancers

Reviewed all 9 changed files against main. The renderPage enhancer support, the Document.getInitialProps -> ctx.defaultGetInitialProps delegation, the single-invocation contract (runDocumentRenderPage vs loadUserDocumentInitialProps), and the dev/prod parity wiring all look correct and well-tested. The 12 new enhancer/error tests pass and meaningfully cover the happy paths plus enhancer/page/invalid-html/style-serialization error routing.

One regression worth fixing before merge, plus a couple of minor notes.

Potential regression: dev error page can now write a partial response, then swallow a mid-stream error

In renderErrorPage (packages/vinext/src/server/dev-server.ts), the custom-_document branch now delegates to streamPageToResponse(...) inside the per-candidate try. streamPageToResponse calls res.writeHead() + res.write(prefix) and then pipes the body stream to res.

On main, the entire error HTML was buffered into a string and written exactly once at the very end of the try (a single res.writeHead/res.end). So any render error happened before a single byte was written, and the catch { continue; } could safely fall through to the next candidate or the default fallback.

With this change, if streamPageToResponse throws after the shell has been flushed — i.e. the error _document takes the skipped (streaming) path and the error element throws from a deferred/Suspense boundary mid-pipe (reader.read() at the body-stream loop) — the exception propagates to:

} catch {
  // This candidate doesn't exist, try next
  continue;
}

That catch silently swallows the streaming error and continues. Headers are already sent and a partial body is on the wire, so the next candidate (or the default fallback at the bottom, which calls res.writeHead(...) with no res.headersSent guard) throws ERR_HTTP_HEADERS_SENT. That re-throw escapes to the outer caller's fallback (res.end(...)), which also fails because the response is already partially written — net result is a corrupted/truncated error response and a confusing crash instead of a clean error page.

Note the rendered path is safe by construction (the body is rendered to a string in runDocumentRenderPage before any write), and getInitialProps/styles errors are likewise pre-write. The narrow-but-real window is the skipped+streaming path for an error _document whose body throws after the shell flush — which main handled safely via full buffering.

Suggested fix: after streamPageToResponse either guard the loop/default-fallback with if (res.headersSent || res.writableEnded) return;, and/or scope the swallowing catch so it only continues for the "candidate doesn't exist / module not found" case (which is what the comment claims it is for) and re-throws once a response write has begun. As written, the catch conflates "candidate not found" with "render failed after we started responding."

Minor

  • loadUserDocumentInitialProps supplies defaultGetInitialProps: async () => ({ html: "" }). Now that base Document.getInitialProps delegates to ctx.defaultGetInitialProps(ctx), any code path that reaches this compatibility helper with a user _document that overrides getInitialProps and calls await Document.getInitialProps(ctx) would render an empty body. In practice both dev and prod main-render paths always pass enhancePageElement, so the override always routes through runDocumentRenderPage (rendered) and this helper only returns non-null for... nothing reachable with an override (the identity check returns null for the un-overridden base). It's effectively dead for the override case today, but it's a latent foot-gun if a future caller invokes this helper without wiring enhancePageElement. A short comment or an assertion would help.

  • runDocumentRenderPage destructures docProps from docInitialProps ?? {} (line ~227) before the typeof docInitialProps.html !== "string" validation throws (line ~230). Functionally fine since the throw happens before docProps is used, but reordering the validation ahead of the destructure would read more clearly.

Everything else (typings tightening in next-shims.d.ts, err instanceof Error narrowing in pages-page-handler.ts, the query threading for DocumentContext, the prod resolvedDocProps reuse to avoid double-invocation) looks correct.

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2034.

Summary

I reviewed all 9 changed files in PR #2034 against main, ran the new test suite (12 tests pass), and ran vp check on the changed files (clean).

The core feature work is solid and well-tested: renderPage enhancer support (object + legacy function forms), the Document.getInitialProps → ctx.defaultGetInitialProps delegation, the single-invocation contract between runDocumentRenderPage and loadUserDocumentInitialProps, dev/prod parity, and error-routing for enhancer/page/invalid-html/style-serialization failures.

I flagged one regression and two minor notes:

Regression (worth fixing before merge): In renderErrorPage (dev-server.ts), the custom-_document error branch now delegates to streamPageToResponse, which writes headers + prefix and then pipes the body — inside the per-candidate try. On main, error HTML was fully buffered and written once at the end, so render errors happened before any write. Now, in the skipped+streaming path, if the error page's body throws after the shell flush, the exception is swallowed by the catch { continue; } (which is documented as only for "candidate doesn't exist"), leaving a partial response on the wire and causing ERR_HTTP_HEADERS_SENT in the next candidate / default fallback (no res.headersSent guard). Suggested fix: guard with res.headersSent/writableEnded and/or scope the catch so post-write failures re-throw instead of continuing.

Minor:

  • loadUserDocumentInitialProps's defaultGetInitialProps: () => ({ html: "" }) stub is a latent foot-gun now that base getInitialProps delegates to it — currently unreachable for the override case but worth a comment/assert.
  • runDocumentRenderPage destructures docProps before the html validation throws; functionally fine, reordering would read more clearly.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(pages): apply Document renderPage enhancers

Re-reviewed the 9 changed files against main and ran the document/response suites (tests/document.test.ts, tests/pages-page-response.test.ts — 59 tests pass). The core feature work is correct and well-covered: the renderPage enhancer support (object + legacy function forms), the Document.getInitialProps → ctx.defaultGetInitialProps delegation, the single-invocation contract between runDocumentRenderPage and loadUserDocumentInitialProps, the prod resolvedDocProps reuse, the query threading into DocumentContext, and the typings tightening in next-shims.d.ts / document.tsx.

One confirmed regression remains, plus the two minor notes from the prior round.

Confirmed regression: dev error page can flush a partial response, then swallow a mid-stream throw

In renderErrorPage (dev-server.ts), the custom-_document branch now delegates to streamPageToResponse(...) inside the per-candidate try (line ~1952). streamPageToResponse calls res.writeHead() (line 349) + res.write(prefix) (line 352), then pipes the body stream in the reader.read() loop (lines 357–364).

On main, the error HTML was fully buffered to a string and written exactly once at the end (res.writeHead/res.end), so any render error happened before the first byte and catch { continue; } could safely fall through.

The narrow-but-real window: when the error _document does not override getInitialProps/renderPage (or the enhancer isn't routed), runDocumentRenderPage returns skipped, so bodyStream = await renderToReadableStream(element) (line 256) is the live React stream. If the error element throws from a deferred/Suspense boundary mid-pipe, the exception escapes streamPageToResponse after the shell has been flushed, and lands in:

} catch {
  // This candidate doesn't exist, try next
  continue;
}

Headers + prefix are already on the wire, so:

  • if another candidate remains (e.g. 500 failed → tries _error), the next streamPageToResponse call hits res.writeHead again → ERR_HTTP_HEADERS_SENT; or
  • the loop ends and the default fallback at the bottom calls res.writeHead(...) (lines ~2019 / ~2023) with no res.headersSent/res.writableEnded guard → same throw.

That second throw escapes to the outer caller's fallback (res.end(...) at line ~1837), which also operates on an already-partially-written response. Net result: corrupted/truncated error response plus a confusing secondary crash instead of a clean error page.

Note: the rendered path (and getInitialProps/styles/invalid-html errors) are safe by construction — the body is rendered to a string in runDocumentRenderPage before any write, which is why the new error-routing tests pass. They exercise the rendered path only; the skipped+streaming error-document body throw is untested. This is dev-only (prod buffers via renderPagesPageResponse), but it's a behavioral regression vs main.

Suggested fix: guard the loop iterations and the default fallback with if (res.headersSent || res.writableEnded) return; after any write may have begun, and/or scope the swallowing catch so it only continues for the "candidate doesn't exist / module not found" case (which is what the comment claims) and re-throws once a response write has started. As written, the catch conflates "candidate not found" with "render failed after we started responding." (The existing res.headersSent guard at line ~1902 is pre-stream only and doesn't cover this.)

Minor

  • loadUserDocumentInitialProps supplies defaultGetInitialProps: async () => ({ html: "" }) (line 76). Now that base Document.getInitialProps delegates to ctx.defaultGetInitialProps(ctx), any future caller that reaches this compatibility helper with a user _document that overrides getInitialProps and calls await Document.getInitialProps(ctx) would render an empty body. Today this is unreachable for the override case — every main render path wires enhancePageElement, so overrides always route through runDocumentRenderPage (rendered), and the identity check returns null for the un-overridden base. It's effectively dead for the override case, but a latent foot-gun; a short comment or assertion that this path must only be hit for non-renderPage props would help.

  • runDocumentRenderPage destructures docProps from docInitialProps ?? {} (line 227) before the typeof docInitialProps.html !== "string" validation throws (line 230). Functionally fine since docProps isn't used before the throw, but moving the validation ahead of the destructure would read more clearly.

Everything else (the err instanceof Error narrowing in pages-page-handler.ts, the query/err threading, and the comment cleanup dropping the stale consumed status) looks correct.

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2034.

Summary

I re-reviewed all 9 changed files against main, ran the document/response test suites (59 tests pass), and verified the prior round's findings against the actual code.

Confirmed one regression (worth fixing before merge): In renderErrorPage (dev-server.ts), the custom-_document error branch now streams via streamPageToResponse inside the per-candidate try. When the error _document doesn't override getInitialProps/renderPage, the skipped path uses the live React stream — if it throws mid-pipe after the shell flush, the exception is swallowed by catch { continue; }, leaving a partial response on the wire. The next candidate or the default fallback then calls res.writeHead with no res.headersSent/res.writableEnded guard → ERR_HTTP_HEADERS_SENT and a corrupted error response. On main, error HTML was fully buffered and written once, so this couldn't happen. Dev-only, untested (the new error tests only exercise the buffered rendered path).

I also corrected the prior review's claim: there is a res.headersSent guard at line ~1902, but it's pre-stream and doesn't cover the post-write case.

Two minor notes (carried from the prior round, both verified): the defaultGetInitialProps: () => ({ html: "" }) stub in loadUserDocumentInitialProps is a latent foot-gun, and the docProps destructure ordering before the html validation in runDocumentRenderPage reads less clearly than necessary.

Everything else — the enhancer support, the delegation, single-invocation contract, prod resolvedDocProps reuse, query/err threading, and typings — looks correct.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(pages): apply Document renderPage enhancers

Re-reviewed against main after commit d8b620c ("buffer custom dev error documents"). Ran tests/document.test.ts + tests/pages-page-response.test.ts (59 pass) and vp check on the changed source files (clean). The core feature work — renderPage enhancer support (object + legacy function forms), the Document.getInitialProps -> ctx.defaultGetInitialProps delegation, the single-invocation contract between runDocumentRenderPage and loadUserDocumentInitialProps, prod resolvedDocProps reuse, query/err threading, and the typings tightening — all look correct.

Prior-round regression: now mitigated (not a blocker)

The dev error-page streaming regression flagged in earlier rounds is addressed by d8b620c, but worth confirming the shape of the fix since it's partial:

  • The main render path (dev-server.ts:1771) now passes bufferBodyBeforeHeaders: true, so the body is fully buffered (await new Response(bodyStream).text() at line 334) before res.writeHead() at line 353. A mid-render throw there now happens pre-write. Good.
  • The error-page path (renderErrorPage, dev-server.ts:1962) does not pass bufferBodyBeforeHeaders, so it still streams. But the new guard at line 2013 — if (res.headersSent || res.writableEnded) return; inside the per-candidate catch — prevents the previously-reported ERR_HTTP_HEADERS_SENT cascade: a mid-stream throw after the shell flush now returns cleanly instead of continueing to the next candidate / default fallback (lines ~2030/2034) and re-calling res.writeHead.

Net: the crash is fixed. The error response can still be truncated in the narrow skipped+streaming-throw case (error _document that doesn't override getInitialProps/renderPage, with a body that throws from a deferred/Suspense boundary), since that path isn't buffered. That's dev-only and inherent to streaming, so it's acceptable — but if you want full parity with the old buffered behavior, passing bufferBodyBeforeHeaders: true on the renderErrorPage streamPageToResponse call too would close it entirely (error pages are small; buffering them is cheap). Not a merge blocker.

Minor (carried, both verified)

  • loadUserDocumentInitialProps html: "" stub is a latent foot-gun. pages-document-initial-props.ts:76 supplies defaultGetInitialProps: async () => ({ html: "" }). Now that base Document.getInitialProps delegates to ctx.defaultGetInitialProps(ctx), a user _document that overrides getInitialProps and calls await Document.getInitialProps(ctx) through this helper renders an empty body. tests/document.test.ts:165 now pins this to expect(props!.html).toBe(""). It's unreachable for the override case today — every main render path wires enhancePageElement, so overrides always route through runDocumentRenderPage (rendered), and the identity check returns null for the un-overridden base — but a future caller that invokes this helper without wiring enhancePageElement would silently get an empty body. A short comment or assertion that this path must only be reached for non-renderPage props would guard against that.

  • runDocumentRenderPage destructures docProps before the html validation. pages-document-initial-props.ts:227 destructures { html, head, styles, ...docProps } = docInitialProps ?? {} before the typeof docInitialProps.html !== "string" throw at line 230. Functionally fine (the throw precedes any use of docProps), but moving the validation ahead of the destructure reads more clearly.

Everything else (the err instanceof Error narrowing in pages-page-handler.ts, the query ?? params fallback in pages-page-response.ts:524, and the stale-consumed comment cleanup) looks correct.

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2034.

Summary

Re-reviewed all 9 changed files against main after the latest commit d8b620c ("buffer custom dev error documents"). Ran the document/response suites (59 tests pass) and vp check on the changed source files (clean).

The prior-round regression is now mitigated (not a blocker). Commit d8b620c does two things:

  • Adds bufferBodyBeforeHeaders: true to the main render path, buffering the body before res.writeHead() so a render throw happens pre-write.
  • Adds a if (res.headersSent || res.writableEnded) return; guard to the per-candidate catch in renderErrorPage, which stops the previously-reported ERR_HTTP_HEADERS_SENT cascade.

The error-page path itself still streams (it does not pass bufferBodyBeforeHeaders), so the response can still be truncated in the narrow skipped+streaming-throw case — but the crash is gone, and that's dev-only and inherent to streaming. I noted that passing bufferBodyBeforeHeaders: true on the renderErrorPage call too would close the gap entirely if full parity is wanted.

Two minor notes (both verified, carried from prior rounds):

  • loadUserDocumentInitialProps's defaultGetInitialProps: () => ({ html: "" }) stub is a latent foot-gun now that base getInitialProps delegates to it — unreachable for the override case today (all main paths wire enhancePageElement), but tests/document.test.ts:165 now pins html: "". A comment/assertion would help.
  • runDocumentRenderPage destructures docProps before the html validation throws — functionally fine, reordering reads more clearly.

Everything else (the err instanceof Error narrowing, the query ?? params fallback, the typings tightening, stale-comment cleanup) looks correct.

github run

@james-elicx
james-elicx marked this pull request as ready for review June 15, 2026 10:26
@james-elicx
james-elicx merged commit 0fb9bd3 into main Jun 15, 2026
55 checks passed
@james-elicx
james-elicx deleted the codex/fix-document-render-page-enhancers-20260615 branch June 15, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant