Skip to content

fix(app-router): recover SSR shell render errors via __next_error__ document#1908

Open
NathanDrake2406 wants to merge 11 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/next-dynamic-css
Open

fix(app-router): recover SSR shell render errors via __next_error__ document#1908
NathanDrake2406 wants to merge 11 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/next-dynamic-css

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

What this changes

Scoped down from the original next/dynamic CSS PR after review (see comments): this branch now carries only the SSR shell-error recovery and the shim resolver fix, as two clean commits. The dynamic-CSS ordering mechanism was removed and will return redesigned (follow-up issue linked below).

  1. SSR shell-error recovery. When the HTML (Fizz) render rejects during SSR with an error that did not originate in the RSC render (no digest), and the app has no custom global-error.tsx, handleSsr serves the default __next_error__ error document carrying the original flight payload and bootstrap module. The browser detects the marker and re-renders the real tree with createRoot instead of hydrating — Next.js shell-error semantics. Local error.tsx boundaries still win: they ship inside the flight payload and catch re-thrown errors client-side.
  2. vinext/shims/* resolver fix. Package-subpath shim imports resolve to the same local shim files as next/* aliases in source checkouts, so request-scoped singleton state is not split between module copies. The recovery fixture exercises this.

Why

Previously a failed SSR shell rendered a server-side global-error page whose flight payload encodes the error tree. Without a custom global-error.tsx that meant the default error card with no path back to the real page — an SSR-phase-only client throw (the "throw to opt out of server rendering" pattern, used by the Next.js next-dynamic-css fixture) left the browser permanently on the card even though the client render would succeed.

Approach

fallbackToErrorDocumentOnShellError is threaded from the generated entry (which knows hasCustomGlobalError at build time) through dispatch/render options into handleSsr. Rejection semantics are preserved whenever:

  • the error carries an RSC digest (flight errors, redirect()/notFound(), server-component throws) — the existing rejection-driven boundary machinery in renderAppPageHtmlStreamWithRecovery is untouched, or
  • the app defines a custom global-error.tsx — the server-rendered boundary re-render path is kept.

Non-goals: dynamic CSS ordering (removed from this PR; follow-up issue), changing custom-global-error apps' behavior in any way.

Upstream References

Validation

  • New browser spec tests/e2e/app-router/nextjs-compat/ssr-error-shell-recovery.browser.spec.ts: recovery to real content for an SSR-only throw without a boundary; local error.tsx semantics for SSR-only (content renders, boundary absent) and unconditional (boundary renders) client throws; strict console-error checking apart from the intentional throws.
  • tests/nextjs-compat/global-error.test.ts (24 tests): all local/nested/global boundary semantics unchanged.
  • tests/entry-templates.test.ts, tests/build-optimization.test.ts, tests/deploy.test.ts green.

Risks / follow-ups

  • Apps with a custom global-error.tsx and an SSR-only client throw still get the server-rendered global-error (vinext's existing divergence from Next.js, which always serves the recovery shell). Deliberately unchanged here.
  • Dynamic CSS ordering returns in a follow-up redesigned on React stylesheet precedence instead of DOM insertion interception.

@pkg-pr-new

pkg-pr-new Bot commented Jun 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: a2f481b

@NathanDrake2406 NathanDrake2406 marked this pull request as draft June 11, 2026 07:11
Runtime helper modules embedded into generated entries import vinext's
own shims by package subpath (e.g. `vinext/shims/headers`), while source
checkouts alias userland `next/*` imports to the local shim files. The
two specifiers resolved to different module instances, so request-scoped
singleton state (navigation context, headers) split between the source
shim copy and the package export copy.

The violated invariant is that every shim module must be a per-request
singleton regardless of import specifier. Resolve `vinext/shims/*`
through the same plugin path as the `next/*` aliases so both forms land
on the local shim files.

Exercised by the SSR shell-error recovery browser spec, whose fixture
imports vinext from the source checkout and depends on shared
navigation state across both import forms.
…ocument

When the HTML (Fizz) render rejects during SSR, vinext re-rendered a
server-side global-error page whose flight payload encodes the error
tree. For an app without a custom global-error.tsx that meant the
default error card with no path back to the real page: an SSR-phase-only
throw (e.g. a client component using the "throw to opt out of server
rendering" pattern) left the browser stuck on the card even though the
client render would succeed.

The violated expectation is Next.js's shell-error semantics: a failed
HTML shell is served as the default `__next_error__` error document
carrying the ORIGINAL flight payload and the bootstrap module, and the
browser re-renders the real tree from that payload with createRoot
instead of hydrating. Local error.tsx boundaries still win — they ship
inside the flight payload and catch the re-thrown error client-side.

handleSsr now resolves to that recovery document instead of rejecting,
but only when both hold:
- the error did not originate in the RSC render (no string `digest`),
  so flight errors, redirect()/notFound(), and server-component throws
  keep driving the existing rejection-based boundary machinery, and
- the app has no custom global-error.tsx (the generated entry knows at
  build time and threads hasCustomGlobalError through dispatch/render
  options); apps with one keep the server-rendered boundary re-render.

The browser entry switches from hydrateRoot to createRoot when the
document root carries id="__next_error__", dropping the error-shell
styles first.

Covered by the new ssr-error-shell-recovery browser spec (recovery to
real content, local error.tsx for SSR-only and unconditional client
throws) and the existing tests/nextjs-compat/global-error.test.ts
boundary-semantics suite.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/next-dynamic-css branch from c7c5160 to 99789d7 Compare June 12, 2026 07:07
@NathanDrake2406 NathanDrake2406 changed the title fix(app-router): preserve next/dynamic CSS order fix(app-router): recover SSR shell render errors via __next_error__ document Jun 12, 2026
- Add static prerender no-boundary recovery regression test to
  ssr-error-shell-recovery.browser.spec.ts
- Document broad __next_error__ browser marker in app-browser-entry.ts
- Extract stripJsExtension to utils/path.ts and wire into all shim
  resolution sites (vinext/shims/* + react-server shims)

Non-functional: targeted regression coverage + code documentation
+ minor resolver hardening per reviewer feedback.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 12, 2026 07:48
@james-elicx

Copy link
Copy Markdown
Member

/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.

Reviewed the SSR shell-error recovery and the vinext/shims/* resolver fix. Overall this is a clean, well-scoped change with good test coverage. The core mechanism is correct: the error-document shell reuses the same rscEmbed/createTickBufferedTransform pipeline so the flight payload + nav metadata are still embedded, and createRoot(document) is valid in React 19 (isValidContainer accepts nodeType === 9). I ran tests/entry-templates.test.ts (25 pass), tests/nextjs-compat/global-error.test.ts (24 pass), and vp check on the changed server/util files (clean).

A few observations below — none are blockers, mostly worth confirming or noting.

Design note (non-blocking): When the fallback fires, renderAppPageHtmlStreamWithRecovery now resolves successfully, so the response takes the normal options.status (200) and is eligible for ISR cache writes (boundaryOutcome: { kind: "success" } in app-page-render.ts). This is intended for the "throw to opt out of SSR" pattern and matches Next.js shell-error semantics, and the /static-recovery test exercises the cached path deliberately. But note this also broadens behavior for genuine SSR-only crashes (no digest, no custom global-error): they now silently recover to a cached 200 instead of rejecting to a 500 error boundary. That's the documented Next.js behavior, so it's likely fine — just calling it out since it's a meaningful semantic shift, not only the dynamic-CSS case.

) {
throw error;
}
errorMetaRenderer.capture(error);

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.

errorMetaRenderer.capture(error) here is effectively a no-op for the shell-error case: the fallback only runs when the error has no string digest, and renderSsrErrorMetaTag returns "" unless getNextErrorDigest(error) yields a digest. So nothing is ever flushed into the head for these errors. Not a bug — but the call reads like it surfaces the error somewhere, when it doesn't. Consider a brief comment clarifying it's defensive/consistency-only, or dropping it.

}

function markErrorShellStyle(html: string): string {
return html.replace("<style>", '<style data-vinext-error-shell-style="">');

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.

markErrorShellStyle replaces only the first <style>. That's correct today because DefaultGlobalError renders exactly one <style dangerouslySetInnerHTML>, and any inline-CSS/server-inserted styles are added later by the transform (so they correctly stay unmarked and are preserved by the browser cleanup). This is fragile against future edits to DefaultGlobalError adding a second <style> — worth a one-line comment noting the single-style assumption, since the browser-side style[data-vinext-error-shell-style] removal depends on it.

// through this plugin so request-scoped singleton state is not split
// between source shims and the package export copy.
const vinextShimPrefix = "vinext/shims/";
if (cleanId.startsWith(vinextShimPrefix)) {

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.

This branch now intercepts every vinext/shims/* subpath before the _reactServerShims check below. That's correct for the current shim set (the react-server variants are keyed on userland next/* specifiers like next/navigation, not vinext/shims/navigation), so internal vinext/shims/navigation imports have always resolved to the plain shim. But if a future shim needs a .react-server variant when imported via its vinext/shims/* package subpath, this early return would silently shadow it. A short comment noting that this branch deliberately does not handle per-environment variants (and where to add that if needed) would prevent a subtle future regression.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my review of PR #1908:

Review Summary

The PR carries two clean changes: SSR shell-error recovery via the __next_error__ document, and a vinext/shims/* resolver fix. Overall it's well-scoped and well-tested.

Verification I did:

  • Traced the recovery flow: the error-document shell correctly reuses the rscEmbed + createTickBufferedTransform pipeline, so the flight payload and nav metadata are still embedded into the static error shell.
  • Confirmed createRoot(document) is valid in React 19 (isValidContainer accepts nodeType === 9) — this would have thrown in older React.
  • Verified the new vinext/shims/ resolver branch resolves to the same files the published exports map would, so no consumer regression; and that it doesn't shadow any current .react-server variant (those are keyed on next/* userland specifiers).
  • Ran tests/entry-templates.test.ts (25 pass), tests/nextjs-compat/global-error.test.ts (24 pass), and vp check on changed files (clean).

Findings posted (all non-blocking):

  1. Design note: the fallback resolves successfully → 200 + ISR cache eligible. Intended for the opt-out pattern, but also broadens behavior for genuine SSR-only crashes (200 cached instead of 500). Matches Next.js, just flagged the semantic shift.
  2. errorMetaRenderer.capture(error) is effectively a no-op for digest-less shell errors — suggest a clarifying comment or removal.
  3. markErrorShellStyle depends on DefaultGlobalError rendering exactly one <style> — fragile against future edits; suggest a comment.
  4. The new vinext/shims/* branch shadows the per-environment .react-server resolution for any future shim imported via its package subpath — suggest a comment noting this.

No correctness blockers found.

github run

@james-elicx

Copy link
Copy Markdown
Member

/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.

Reviewed the SSR shell-error recovery and the vinext/shims/* resolver fix. The core mechanism is sound and I verified the important pieces:

  • The error shell HTML is piped through the same createTickBufferedTransform(rscEmbed, getInsertedHTML, ...) pipeline, so the flight payload + nav metadata + modulepreload all land in the document (the transform finds </head> in DefaultGlobalError's static markup and injects there). No double bootstrap: buildHeadInjectionHtml only emits <link rel="modulepreload">, and renderToStaticMarkup doesn't inject a Fizz bootstrap, so the manual <script id="_R_"> is the only executing one.
  • The vinext/shims/* resolver branch aligns source-checkout resolution with the published-package exports map (./shims/* -> ./dist/shims/*.js, no react-server condition). _reactServerShims is keyed only on next/* userland specifiers, so this doesn't shadow any current variant, and it correctly stops splitting singleton state between the next/navigation-aliased source shim and the vinext/shims/navigation dist copy. Confirmed internal modules (app-route-handler-dispatch.ts, app-middleware.ts) import vinext/shims/navigation and rely on shared state.
  • markErrorShellStyle targets <style> which is what renderToStaticMarkup emits for the dangerouslySetInnerHTML style tag.

No correctness blockers. A few observations below, all non-blocking.

Dev-mode parity (worth confirming): hasCustomGlobalError threads through the generated RSC entry, which is the request handler in both dev and prod. So in dev, a digest-less SSR-only throw without a custom global-error now resolves to the recovery shell rather than surfacing the dev error overlay. The new browser __next_error__ branch in bootstrapHydration (createRoot, not reload) also runs in dev. The new spec only exercises production builds, so dev behavior for this path is untested. If the intent is to keep the dev overlay for genuine SSR-only crashes, that may be a gap.

ISR caches the recovery shell at 200 (restating the prior design note): when the fallback fires, renderAppPageHtmlStreamWithRecovery resolves with a stream and response: null, so the response keeps the normal status and is ISR-eligible. Intended for the opt-out pattern, but it also means a genuine SSR-only crash now caches a 200 error-shell instead of producing a 500. Matches Next.js shell-error semantics; flagging only because it's a meaningful surface for real bugs that manifest only during SSR.

if (options?.waitForAllReady === true) {
await renderedHtmlStream.allReady;
}
htmlStream = renderedHtmlStream;

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.

On the waitForAllReady (prerender) path, if renderedHtmlStream.allReady rejects, the already-created renderedHtmlStream is abandoned without being cancelled before we fall through to renderSsrErrorDocumentShell. Not a correctness bug, but it leaves an un-consumed/un-cancelled React stream behind. Consider void renderedHtmlStream.cancel().catch(() => {}) in the catch before building the error shell.

Comment thread packages/vinext/src/index.ts Outdated
const shimBase = _reactServerShims.get(id);
if (shimBase !== undefined) {
return resolveShimModulePath(shimsDir, shimBase);
return resolveShimModulePath(shimsDir, stripJsExtension(shimBase));

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.

stripJsExtension(shimBase) is a no-op here: shimBase comes from _reactServerShims.get(id), whose values are bare base names ("navigation") that never carry a .js extension. Only the userland specifier (the map key) can be extensionful. Harmless, but the wrapping reads like it's load-bearing — the id-side handling (line 2608 / 2594) is where stripJsExtension actually matters.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my review of PR #1908:

Review Summary

The PR carries two well-scoped changes: SSR shell-error recovery via the __next_error__ document, and a vinext/shims/* resolver fix. The core mechanism is correct and well-tested.

Verification I did:

  • Traced the recovery flow — the error shell HTML reuses the same createTickBufferedTransform(rscEmbed, getInsertedHTML, ...) pipeline, so the flight payload, nav metadata, and modulepreload all land in DefaultGlobalError's static markup (the transform finds </head> and injects there).
  • Confirmed no double bootstrap: buildHeadInjectionHtml only emits a <link rel="modulepreload">, and renderToStaticMarkup doesn't emit a Fizz bootstrap, so the manual <script id="_R_"> is the only executing one.
  • Verified the vinext/shims/* resolver branch aligns source-checkout resolution with the published-package exports map (./shims/*./dist/shims/*.js, no react-server condition). _reactServerShims is keyed only on next/* userland specifiers, so this doesn't shadow any current variant. Confirmed internal modules (app-route-handler-dispatch.ts, app-middleware.ts) import vinext/shims/navigation and rely on shared singleton state — so the fix correctly stops splitting state.
  • Verified markErrorShellStyle targets the right <style> tag.

Findings posted (all non-blocking):

  1. Dev-mode parity: hasCustomGlobalError threads through the generated RSC entry used in both dev and prod, so SSR-only throws in dev now recover via the error shell rather than surfacing the dev overlay — untested (the spec is production-only).
  2. ISR caches the recovery shell at 200 — restated the prior reviewer's design note; genuine SSR-only crashes now cache a 200 instead of producing a 500.
  3. Inline: abandoned renderedHtmlStream is not cancelled when allReady rejects on the prerender path (minor resource note).
  4. Inline: stripJsExtension(shimBase) at index.ts:2114 is a no-op since map values never carry .js.

No correctness blockers found.

github run

@james-elicx

Copy link
Copy Markdown
Member

/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.

Reviewed the SSR shell-error recovery and the vinext/shims/* resolver fix. The core mechanism is sound — I independently verified the recovery flow (error shell HTML piped through the same createTickBufferedTransform so rscEmbed flight chunks + getInsertedHTML nav metadata land in the static DefaultGlobalError markup; no double bootstrap since buildHeadInjectionHtml only emits a <link rel=modulepreload> and the manual <script id="_R_"> is the lone executing entry; createRoot(document) is valid in React 19). The vinext/shims/* branch correctly resolves nested subpaths (e.g. internal/app-router-context) via path.posix.join, and _shimsDir is anchored to __dirname so it works in both source and published-package layouts. The prior /bigbonk rounds already captured the design notes (200/ISR-eligible recovery, dev-overlay parity, single-<style> assumption, no-op stripJsExtension(shimBase)); I won't repeat those.

Two observations below, both non-blocking.

Background ISR revalidation does not recover (new finding). The fallback is threaded into the initial-render path via renderAppPageHtmlStream (fallbackToErrorDocumentOnShellError: options.hasCustomGlobalError === false), but the background revalidation renderFn in app-page-dispatch.ts (the revalidatedSsrEntry.handleSsr(...) call around line 653) does NOT pass it. So for a route using the throw-to-opt-out-of-SSR pattern this PR targets: the first request recovers and caches a 200 recovery shell, but every background regeneration of that same route throws and fails to refresh the entry — an internal inconsistency between first render and revalidation. The /static-recovery spec exercises the prerender-time path (which threads the flag) and the first runtime hit, not this background-regen renderFn, so the gap is untested. Consider threading hasCustomGlobalError into that handleSsr call too (the dispatch already has options.hasCustomGlobalError), or documenting that revalidation deliberately keeps reject semantics.

style.remove();
}
startTransition(() => {
const clientRoot = createRoot(document, hydrateRootOptions);

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.

hydrateRootOptions is built by createVinextHydrateRootOptions, which always includes formState (a hydrateRoot-only option). createRoot's RootOptions does not have formState — it's silently ignored at runtime, so this isn't a bug, but it's a semantic mismatch worth noting: in the recovery path there is no server-rendered form to hydrate, so the captured formState is meaningless here. The structural cast works only because HydrateRootOptions is a superset React tolerates. A short comment (e.g. "formState is inert for createRoot; we reuse the hydrate options object only for the error handlers") would prevent a future reader from assuming form-state replay happens on the recovery path.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my review of PR #1908:

Review Summary

The PR carries two well-scoped changes: SSR shell-error recovery via the __next_error__ document, and a vinext/shims/* resolver fix. The core mechanism is correct and well-tested.

Verification I did independently:

  • Traced the recovery flow end-to-end: the error shell HTML reuses the same createTickBufferedTransform(rscEmbed, getInsertedHTML, ...) pipeline, so the flight payload and nav metadata land in the static DefaultGlobalError markup (</head> injection point exists). No double bootstrap — buildHeadInjectionHtml only emits a <link rel="modulepreload">, and renderToStaticMarkup emits no Fizz bootstrap, so the manual <script id="_R_"> is the only executing one.
  • Confirmed DefaultGlobalError renders exactly one <style>, validating markErrorShellStyle.
  • Verified the vinext/shims/* resolver branch handles nested subpaths (internal/app-router-context) via path.posix.join, and _shimsDir is anchored to __dirname so it works in both source and published-package layouts. It doesn't shadow any current react-server variant (those are keyed on next/* userland specifiers).
  • Confirmed hasCustomGlobalError threads correctly: generated entry → dispatchAppPage → render lifecycle → renderAppPageHtmlStream.

Findings posted (both non-blocking):

  1. New finding — Background ISR revalidation does not recover. The fallback flag is threaded into the initial-render path but NOT into the background revalidation handleSsr call in app-page-dispatch.ts (~line 653). So a route using the throw-to-opt-out pattern recovers and caches a 200 on first render, but its background regeneration permanently rejects and never refreshes — an internal inconsistency untested by the new spec.
  2. createRoot(document, hydrateRootOptions) passes a formState that createRoot ignores — harmless but semantically misleading on the recovery path; suggested a clarifying comment.

I confirmed the prior /bigbonk rounds already covered the other observations (200/ISR design note, dev-overlay parity, single-style assumption, no-op stripJsExtension) and did not duplicate them. No correctness blockers found.

github run

@james-elicx

Copy link
Copy Markdown
Member

/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.

Re-reviewed PR #1908. The core mechanism is sound and I independently confirmed the key pieces:

  • The error-document shell is piped through the same createTickBufferedTransform(rscEmbed, getInsertedHTML, ...) pipeline, so the flight payload + nav metadata land in DefaultGlobalError's static <head>. No double bootstrap: buildHeadInjectionHtml emits only a <link rel=modulepreload> (app-ssr-entry.ts:280-283), and renderToStaticMarkup emits no Fizz bootstrap, so the manual <script id="_R_"> is the sole executing entry.
  • DefaultGlobalError renders exactly one <style dangerouslySetInnerHTML> (default-global-error.tsx:177), so markErrorShellStyle's single-<style> String.replace is correct today.
  • _reactServerShims is keyed on userland next/* specifiers with bare-name values ("navigation"), so the new vinext/shims/* branch doesn't shadow any current react-server variant and correctly stops splitting singleton state.

Two findings below. The first is a real behavioral gap for the exact use case this PR targets (worth fixing here); the second is a minor dead-code note.

Background ISR revalidation does not recover, unlike the initial render. The new flag is threaded into dispatchAppPageInner's render lifecycle (the hasCustomGlobalError: options.hasCustomGlobalError you added below at line 987, which renderAppPageHtmlStream turns into fallbackToErrorDocumentOnShellError). But the background-revalidation handleSsr call in this same file (app-page-dispatch.ts:653, the revalidatedSsrEntry.handleSsr(...) with waitForAllReady: true at line 666) does not pass it. options.hasCustomGlobalError is in scope there too.

Consequence for a route using the throw-to-opt-out-of-SSR pattern this PR targets (no custom global-error.tsx): the first request recovers and caches a 200 recovery shell, but every background regeneration of that same route rejects in handleSsr, so the renderFn rejects and the stale entry is never refreshed — first render and revalidation diverge for the same route. The new spec exercises the prerender-time path and the first runtime hit, not this background renderFn, so the gap is untested. Consider threading fallbackToErrorDocumentOnShellError: options.hasCustomGlobalError === false into that handleSsr options object (next to waitForAllReady: true), or documenting that revalidation deliberately keeps reject semantics.

return renderPageSpecialError(options, specialError);
},
renderToReadableStream: options.renderToReadableStream,
hasCustomGlobalError: options.hasCustomGlobalError,

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.

This threads the flag into the initial render lifecycle. The background-revalidation handleSsr call in this same file (~line 653, revalidatedSsrEntry.handleSsr(...) with waitForAllReady: true at line 666) does not get the equivalent fallbackToErrorDocumentOnShellError, so revalidation of a throw-to-opt-out route permanently rejects after the first render caches a 200 recovery shell. See the review summary for details — worth threading the flag there too or documenting the divergence.

Comment thread packages/vinext/src/index.ts Outdated
? `${reactServerShim}.react-server`
: reactServerShim;
return resolveShimModulePath(_shimsDir, shimName);
return resolveShimModulePath(_shimsDir, stripJsExtension(shimName));

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.

Minor: stripJsExtension(shimName) is a no-op here. _reactServerShims values are bare base names ("navigation", see index.ts:646-650) and ${reactServerShim}.react-server likewise never carries .js — only the map key (the userland specifier) can be extensionful, and that's already normalized via cleanId before the .get(cleanId) lookup. The new vinext/shims/* branch at line 2594 is where stripJsExtension actually matters (the subpath can be extensionful). Consider dropping it here so it doesn't imply the value side needs normalization:

Suggested change
return resolveShimModulePath(_shimsDir, stripJsExtension(shimName));
return resolveShimModulePath(_shimsDir, shimName);

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my review of PR #1908:

Review Summary

The PR carries two well-scoped changes: SSR shell-error recovery via the __next_error__ document, and a vinext/shims/* resolver fix. The core mechanism is correct and well-tested.

Verification I did independently:

  • The error-document shell reuses the same createTickBufferedTransform(rscEmbed, getInsertedHTML, ...) pipeline, so the flight payload + nav metadata land in DefaultGlobalError's static <head>.
  • No double bootstrap: buildHeadInjectionHtml emits only a <link rel=modulepreload> (app-ssr-entry.ts:280-283), renderToStaticMarkup emits no Fizz bootstrap, so the manual <script id="_R_"> is the only executing entry.
  • DefaultGlobalError renders exactly one <style> (default-global-error.tsx:177), validating markErrorShellStyle.
  • _reactServerShims is keyed on userland next/* specifiers with bare-name values, so the new branch doesn't shadow any current react-server variant.

Findings posted:

  1. Background ISR revalidation does not recover (behavioral gap, worth fixing in this PR). The flag is threaded into the initial render (line 987) but not into the background-revalidation handleSsr call (app-page-dispatch.ts:653/666). A throw-to-opt-out route caches a 200 on first render but its background regeneration permanently rejects — untested by the new spec. options.hasCustomGlobalError is already in scope there.
  2. Minor dead code: stripJsExtension(shimName) at index.ts:2608 is a no-op since _reactServerShims values are bare base names. Posted a suggestion to drop it.

No correctness blockers found for the happy path. Note: finding (1) was already raised in the prior /bigbonk round and remains unaddressed in the current commit — I re-confirmed it against the code and re-flagged it as the most actionable item.

github run

@james-elicx

Copy link
Copy Markdown
Member

/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.

Re-reviewed #1908 at 2a6292a. The two changes (SSR shell-error recovery via the __next_error__ document, and the vinext/shims/* resolver fix) are correct and well-tested. I independently re-verified the core mechanism and confirmed the prior /bigbonk findings are now addressed in the follow-up commits.

Verified independently:

  • Stream ordering / no double bootstrap. renderSsrErrorDocumentShell splices its <script id="_R_" async> before </body></html>; createTickBufferedTransform then strips </body></html> (stripDocumentCloseSuffix), emits the flight chunks via rscEmbed, and re-appends the close last. Final order is shell -> bootstrap -> flight -> </body></html>, and the async module executes after the embedded RSC payload is in the DOM. buildHeadInjectionHtml emits only a <link rel=modulepreload> (app-ssr-entry.ts:280), and the static-markup path runs no Fizz bootstrap, so <script id="_R_"> is the sole executing entry.
  • Head injection point exists. DefaultGlobalError renders <head>...</head> and exactly one <style> (default-global-error.tsx:177), so the transform's </head> injection and markErrorShellStyle's single-<style> String.replace are both valid. errorThemeCss contains no <style> substring, so the replace can't mis-target.
  • vinext/shims/* resolver. The new branch (index.ts:2591) codifies the previously-implicit resolution: vinext/shims/navigation already resolved to the plain navigation.ts before this PR (it was never intercepted by the resolveId hook and fell through node resolution to the source with no react-server condition), while next/navigation resolved to navigation.react-server.ts. So this is the correct fix to stop splitting singleton state, with no behavior change for the current shim set. _reactServerShims is keyed on next/* specifiers, so no shadowing.
  • Prior findings resolved. Background ISR regeneration now threads fallbackToErrorDocumentOnShellError (app-page-dispatch.ts:667, commit 2a6292a); abandoned prerender stream is cancelled (app-ssr-entry.ts:629); formState/createRoot and single-style assumptions are documented. tests/app-page-dispatch.test.ts (17) pass.

Two small residual notes below, both non-blocking. No correctness blockers.

// `<html id="__next_error__">`, so this branch also fires for legacy server-
// rendered global-error pages. That is upstream-compatible — the browser
// should always createRoot when the document is the default error shell.
if (document.documentElement.id === "__next_error__") {

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.

Behavior-change worth confirming: on main, bootstrapHydration always used hydrateRootInTransition (hydration). This branch now switches to createRoot (full client re-render, no hydration) for any document whose documentElement.id === "__next_error__", which — as your comment notes — also includes the pre-existing server-rendered default-error page (digest error, no custom global-error.tsx). That path previously hydrated the server markup; it now discards and re-renders client-side.

This is plausibly upstream-compatible (Next.js client-renders the recovery shell), and the embedded flight payload encodes the same error tree so the rendered content matches. But the new browser spec only exercises the SSR-only-throw recovery path, not the legacy server-digest-error path through this same branch. Worth a quick confirmation (or a test) that a genuine server-side digest error with no custom global-error still renders the default error card correctly via createRoot rather than relying on the now-removed hydration path.

Comment thread packages/vinext/src/index.ts Outdated
if (cleanId.startsWith(vinextShimPrefix)) {
return resolveShimModulePath(
_shimsDir,
stripJsExtension(cleanId.slice(vinextShimPrefix.length)),

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.

Minor robustness: cleanId here is only \0-stripped, not query-stripped (unlike line 2514's derivation, which also leaves the query). stripJsExtension won't strip a trailing query, so a specifier like vinext/shims/navigation?v=123 would resolve to a wrong base name. Internal package-subpath imports never carry a query, so this isn't reachable today — but if you want it to be defensive, run the subpath through stripViteModuleQuery before stripJsExtension.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my review of PR #1908:

Review Summary

The PR carries two well-scoped changes: SSR shell-error recovery via the __next_error__ document, and a vinext/shims/* resolver fix. The core mechanism is correct and well-tested.

Verified independently:

  • Stream ordering / no double bootstrap. Traced the full pipeline: renderSsrErrorDocumentShell splices <script id="_R_" async> before </body></html>; the transform strips that suffix, emits flight chunks, and re-appends the close last → shell → bootstrap → flight → </body></html>. The modulepreload from buildHeadInjectionHtml is a preload, not a duplicate executing script.
  • Head/style injection points exist. DefaultGlobalError renders <head> and exactly one <style>; both the </head> flight injection and markErrorShellStyle's single-<style> replace are valid.
  • Shim resolver. Confirmed against main that vinext/shims/navigation already resolved to the plain navigation.ts (never intercepted before), so the new branch codifies existing behavior and correctly stops splitting singleton state. No shadowing of _reactServerShims.
  • Prior /bigbonk findings are all resolved in follow-up commits: background ISR regeneration now threads the recovery flag (the previously-unaddressed gap), abandoned prerender streams are cancelled, and the documentation notes were added. tests/app-page-dispatch.test.ts (17) pass.

Findings posted (both non-blocking):

  1. Behavior-change to confirm: the __next_error__ branch in bootstrapHydration now createRoots (full client re-render) for any default-error document, including the legacy server-digest-error page that previously hydrated. Plausibly upstream-compatible, but only the SSR-only-throw path is tested, not the legacy digest-error path through this same branch.
  2. Minor robustness: cleanId in the new vinext/shims/* branch isn't query-stripped, so a query-bearing specifier would resolve to a wrong base name (not reachable today).

No correctness blockers found.

github run

@james-elicx

Copy link
Copy Markdown
Member

/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.

Independent re-review of #1908. The two changes (SSR shell-error recovery via the __next_error__ document, and the vinext/shims/* resolver fix) are correct, well-scoped, and well-tested. I verified the core mechanism from scratch rather than relying on the prior rounds, and confirmed the previously-flagged actionable items are addressed.

Verified independently:

  • Recovery stream pipeline. renderSsrErrorDocumentShell produces <!DOCTYPE html><html id="__next_error__"><head><style>…</style></head><body>…<script id="_R_" async></script></body></html>, then pipes through the same createTickBufferedTransform(rscEmbed, getInsertedHTML, …). DefaultGlobalError (default-global-error.tsx:175-201) renders the required </head> (transform flight-injection point at app-ssr-stream.ts:544) and exactly one <style> (line 177), so both the </head> injection and markErrorShellStyle's single-<style> replace are valid. errorThemeCss contains no <style> substring, so the replace can't mis-target. The </body></html> close is stripped by the transform and re-emitted last (app-ssr-stream.ts:610), so final order is shell → bootstrap <script> → flight → close.
  • error: null ⇒ client-error card. The shell renders with isServerError === false (no digest footer, "Reload to try again, or go back."), matching the spec's /page assertions.
  • vinext/shims/* resolver. Confirmed the resolveId filter (/(?:next\/|virtual:vinext-|…)/, index.ts:2507) matches vinext/shims/* only because vinext/ contains the substring next/ — so the new branch is actually reachable. The published-package exports map ./shims/*./dist/shims/*.js has no react-server condition, so vinext/shims/navigation always resolves to the plain shim in every environment. The new source-checkout branch (no environment check) matches that exactly, and _reactServerShims is keyed only on next/* specifiers (index.ts:646-650), so no current variant is shadowed. This correctly stops splitting singleton state between next/navigationnavigation.react-server.ts and the vinext/shims/navigation copy.
  • Prior actionable findings resolved. Background ISR revalidation now threads fallbackToErrorDocumentOnShellError (app-page-dispatch.ts:667); abandoned prerender stream is cancelled (app-ssr-entry.ts:629); formState/createRoot and single-<style> assumptions are documented.
  • Tests pass locally: tests/shims.test.ts (subpath test), tests/app-page-dispatch.test.ts (17).

No correctness blockers. Two small non-blocking notes below.

// one gains an environment-specific variant, handle it here before
// returning rather than relying on the userland `next/*` map below.
const vinextShimPrefix = "vinext/shims/";
if (cleanId.startsWith(vinextShimPrefix)) {

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.

The new vinext/shims/ branch is reachable only because the resolveId filter regex (/(?:next\/|…)/ at index.ts:2507) happens to match vinext/shims/*vinext/ contains the substring next/. This is load-bearing but entirely implicit: if the filter is ever tightened to anchor next/ (e.g. ^next\/ or a word boundary), this branch silently stops firing and the singleton-state split this PR fixes would quietly regress, with no test catching it (the new shims test calls the handler directly, bypassing the filter).

Consider either (a) adding an explicit vinext\/shims\/ alternative to the filter regex so the match is intentional, or (b) a one-line comment here noting the branch depends on the incidental next/-substring match in the filter above.

getSourceRoute(sourceRouteIndex) {
return routes[sourceRouteIndex];
},
hasCustomGlobalError: ${globalErrorVar ? `Boolean(${globalErrorVar}?.default)` : "false"},

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.

Boolean(${globalErrorVar}?.default) correctly treats a global-error file with no default export as "no custom global error" — so an app with a malformed global-error.tsx (file present, no default) would still get the __next_error__ recovery shell rather than the server-side boundary path. That's a reasonable choice, but worth confirming it matches Next.js, which I believe validates/throws on a global-error without a default export rather than silently falling back. Non-blocking, but a candidate for a follow-up parity check.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my independent review of PR #1908:

Review Summary

The PR carries two well-scoped changes: SSR shell-error recovery via the __next_error__ document, and a vinext/shims/* resolver fix. The core mechanism is correct and well-tested.

Verification I did from scratch:

  • Recovery stream pipeline. Traced renderSsrErrorDocumentShellcreateTickBufferedTransform. Confirmed DefaultGlobalError renders the </head> injection point and exactly one <style> that markErrorShellStyle targets; errorThemeCss has no <style> substring so the .replace can't mis-target; final stream order is shell → bootstrap script → flight → </body></html>.
  • error: null produces the client-error card (no digest footer), matching the spec assertions.
  • Resolver fix. Discovered the new vinext/shims/* branch is reachable only because vinext/ contains the substring next/, satisfying the resolveId filter regex incidentally. Confirmed the published-package exports map has no react-server condition, so the new branch (no env check) faithfully matches published behavior and stops the singleton-state split. _reactServerShims is keyed only on next/*, so nothing is shadowed.
  • Prior actionable findings resolved (background ISR revalidation threading, prerender stream cancellation, documentation).
  • Ran tests/shims.test.ts and tests/app-page-dispatch.test.ts — pass.

Findings posted (both non-blocking):

  1. The vinext/shims/* branch depends on the implicit next/-substring match in the filter regex — fragile if the filter is ever anchored. Suggested an explicit filter alternative or a comment.
  2. Boolean(globalErrorVar?.default) silently treats a malformed global-error.tsx (no default export) as "no custom global error" — worth a Next.js parity check (Next may throw on a default-less global-error).

No correctness blockers found.

github run

@james-elicx

Copy link
Copy Markdown
Member

/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.

Independent review of #1908. The two changes — SSR shell-error recovery via the __next_error__ document, and the vinext/shims/* resolver fix — are correct and well-tested. I verified the core mechanism from scratch and ran the relevant suites locally (tests/shims.test.ts subpath test, tests/app-page-dispatch.test.ts 17 pass).

Verified independently:

  • Flight payload survives the rejection. rscEmbed is built from a separate stream (sideStream, or the s2 tee branch at app-ssr-entry.ts:400), independent of the SSR render's ssrStream. So when renderToReadableStream rejects, the recovery shell still carries the complete flight payload. The recovery is sound.
  • No double bootstrap. buildHeadInjectionHtml emits only a <link rel=modulepreload> (via buildModulePreloadHtml, app-ssr-entry.ts:312), and the recovery shell's <script id="_R_" async> is the sole executing entry. The static-markup path runs no Fizz bootstrap.
  • Single <style> marker is valid. DefaultGlobalError renders exactly one <style dangerouslySetInnerHTML> (default-global-error.tsx:177); inline style={...} attributes serialize as style="..." (with a space) and can't match the <style> substring, and errorThemeCss contains no <style> literal — so markErrorShellStyle can't mis-target.
  • error: null ⇒ client-error card (error?.digestisServerError === false), matching the spec assertions.
  • Background ISR revalidation now recovers (app-page-dispatch.ts:667 threads the flag) — the gap from prior rounds is resolved.
  • stripJsExtension is load-bearing in the new branch (the subpath can be extensionful) — not dead code.

One finding worth confirming below (the resolveId filter narrowing), plus a minor parity note already raised in prior rounds. No correctness blockers.

Comment thread packages/vinext/src/index.ts Outdated
// re-imports from @vitejs/plugin-rsc.
filter: {
id: /(?:next\/|virtual:vinext-|^@vercel\/og(?:\.js)?$)/,
id: /(?:^next\/|^vinext\/shims\/|virtual:vinext-|^@vercel\/og(?:\.js)?$)/,

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.

This change does two things: it adds ^vinext\/shims\/ (intended), and it anchors the next/ alternative from unanchored next\/ to ^next\/ (a behavioral narrowing that comes along for the ride). The new tests/shims.test.ts case asserts unrelated-next/navigation no longer matches, so the anchoring is deliberate — but it interacts with this hook's own \0-prefix handling in a way worth confirming.

The handler strips a leading \0 (line 2515) precisely because the comment says @vitejs/plugin-rsc's generated browser entry re-imports modules with the already-resolved \0-prefixed id. With the new anchored filter, a \0-prefixed next/*, vinext/shims/*, or @vercel/og specifier no longer passes the filter, so the handler never runs for it:

\0virtual:vinext-app-browser-entry  → matches (virtual:vinext- is unanchored)
\0next/navigation                   → NO LONGER matches
\0vinext/shims/navigation           → NO LONGER matches
\0@vercel/og                        → NO LONGER matches

If the \0 re-import path only ever applies to virtual:vinext-* modules (which still match), this is safe and the \0-strip for the other branches is just dead defense. But if plugin-rsc can re-import a next/* or @vercel/og shim with a \0 prefix, this silently stops resolving it and would split singleton state — the exact failure mode this PR is trying to fix. Worth a quick confirmation that the \0 case is virtual-module-only, and/or anchoring with the \0 allowed (e.g. /(?:^\0?next\/|^\0?vinext\/shims\/|...)/) so the handler's \0-strip stays reachable for all branches it handles.

getSourceRoute(sourceRouteIndex) {
return routes[sourceRouteIndex];
},
hasCustomGlobalError: ${globalErrorVar ? `Boolean(${globalErrorVar}?.default)` : "false"},

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.

Boolean(${globalErrorVar}?.default) treats a global-error.tsx that exists but has no default export as "no custom global error", so a malformed global-error file would silently get the __next_error__ recovery shell instead of erroring. That's a reasonable default, but Next.js validates app-router special files and throws on a missing default export rather than silently falling back. Non-blocking — worth a follow-up parity check (also flagged in a prior round).

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my independent review of PR #1908:

Review Summary

The PR carries two well-scoped changes: SSR shell-error recovery via the __next_error__ document, and a vinext/shims/* resolver fix. The core mechanism is correct and well-tested. I ran tests/shims.test.ts (subpath test) and tests/app-page-dispatch.test.ts (17 pass).

Verified independently:

  • The flight payload (rscEmbed) is built from a separate stream from the SSR render's consumption, so it survives the render rejection and the recovery shell carries the full payload.
  • No double bootstrap — head injection emits only a modulepreload link; the recovery shell's <script id="_R_"> is the sole executing entry.
  • The single-<style> marker logic is valid (DefaultGlobalError renders exactly one <style>; inline style= attrs and errorThemeCss can't mis-match).
  • Background ISR revalidation now threads the flag (resolved from prior rounds).

New finding (worth confirming, non-blocking):
The filter regex change does two things — adds ^vinext\/shims\/ (intended) and anchors next\/^next\/. This narrowing means \0-prefixed next/*, vinext/shims/*, and @vercel/og specifiers no longer pass the filter, so the handler's own \0-strip logic (line 2515) is now unreachable for those branches. It's safe only if the plugin-rsc \0 re-import path applies exclusively to virtual:vinext-* modules (which still match). If it can ever apply to a next/*/@vercel/og/shim specifier, this would silently re-split singleton state — the exact bug this PR fixes. I flagged it for confirmation, with a suggested \0?-tolerant anchoring as an alternative.

Minor parity note: Boolean(globalErrorVar?.default) silently treats a malformed global-error.tsx (no default export) as "no custom global error" — candidate for a Next.js parity follow-up.

No correctness blockers found.

github run

@james-elicx

Copy link
Copy Markdown
Member

/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.

Independent review of #1908. The core mechanism (SSR shell-error recovery via the __next_error__ document, plus the vinext/shims/* resolver fix) is correct and well-tested. I verified the recovery stream pipeline (flight payload from a separate stream survives the rejection; buildHeadInjectionHtml emits only a <link rel=modulepreload> so there is no double bootstrap; DefaultGlobalError renders exactly one <style> and a </head>, so markErrorShellStyle and the </head> flight injection are both valid; errorThemeCss has no <style> substring), and confirmed the prior-round gaps are resolved (background ISR revalidation threads the flag at app-page-dispatch.ts:667; abandoned prerender stream is cancelled). Ran tests/app-page-dispatch.test.ts + tests/shims.test.ts (1067 pass).

One new behavioral finding worth confirming (the unconditional-throw-with-no-boundary recovery path), plus a factual correction to a recurring claim from prior /bigbonk rounds about the filter regex. Neither is a hard blocker, but the first is a genuine regression surface the new spec does not cover.

Correction to prior rounds (re: the filter regex): several prior reviews stated this PR "anchors next/ -> ^next/" and thereby narrows the filter. That is not what changed. next\/ is unanchored in both the old and new regex. The actual diff is (a) adding vinext\/shims\/ (intended), and (b) removing the ^ anchor from @vercel/og (^@vercel\/og(?:\.js)?$ -> @vercel\/og(?:\.js)?$). That broadens the filter so a \0@vercel/og re-import now passes (it did not before) and reaches the handler's isVercelOgImport branch — which the new shims test asserts (hook.filter.id.test("\0@vercel/og") is now true). So the \0-strip concern raised in the last round is the opposite of the real change: the filter got more permissive, not less. No action needed on the regex; just flagging so the record is accurate.

// Legacy server-rendered default-error pages also use `__next_error__`, but
// keep their existing hydration path so the serialized server error digest
// remains available to DefaultGlobalError.
if (document.documentElement.id === "__next_error__" && errorShellStyles.length > 0) {

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.

Behavioral regression worth confirming: unconditional client throw + no boundary + no custom global-error now leaves a blank page.

This createRoot recovery path runs on the initial bootstrap, where pendingNavigationRecoveryHref is still null (it's only set during an in-flight navigation, app-browser-entry.ts:304/573). So the prod onUncaughtError (createOnUncaughtError(() => pendingNavigationRecoveryHref), line 1593) returns early without navigating — it only console.errors (app-browser-error.ts:24-27).

Consider a client component that throws on both server and client (not the SSR-only typeof window === undefined pattern), with no local error.tsx and no custom global-error.tsx:

  • On main: the SSR shell rejects, handleSsr rejects, and renderAppPageHtmlStreamWithRecovery falls to renderErrorBoundaryResponse (app-page-stream.ts:366), serving the server-rendered default error card which then hydrates and stays visible.
  • With this PR: the recovery flag is set (no custom global-error), so handleSsr resolves with the error-document shell. The browser enters this createRoot branch, tears down the error card, re-renders — the component throws again, onUncaughtError has no recovery href, so the user is left on a blank, torn-down document instead of the error card.

The new spec only exercises the SSR-only throw (recovers) and the local-error.tsx cases (boundary catches). The unconditional-throw-no-boundary case is the one that regresses, and it's untested. Worth either (a) confirming this matches Next.js shell-error semantics for a genuinely always-failing component, or (b) adding a fixture route that throws unconditionally with no boundary and asserting the resulting UX, so the regression (if it is one) is caught.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Checked this on the current PR head and it does not leave a blank page. The shell-recovery path already lands on the default This page couldn’t load card when there is no local boundary. I pushed a2f481b9 to lock that behavior in with a dedicated no-boundary regression case in ssr-error-shell-recovery.browser.spec.ts, then re-ran PLAYWRIGHT_PROJECT=app-router-chrome-browser-specific pnpm run test:e2e -- tests/e2e/app-router/nextjs-compat/ssr-error-shell-recovery.browser.spec.ts.

// re-imports from @vitejs/plugin-rsc.
filter: {
id: /(?:next\/|virtual:vinext-|^@vercel\/og(?:\.js)?$)/,
id: /(?:next\/|vinext\/shims\/|virtual:vinext-|@vercel\/og(?:\.js)?$)/,

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.

The meaningful change here besides adding vinext\/shims\/ is dropping the ^ anchor from the @vercel/og alternative (^@vercel\/og(?:\.js)?$ -> @vercel\/og(?:\.js)?$). That's a deliberate broadening so \0@vercel/og re-imports pass the filter (the new shims test asserts this), and it's correct — just flagging because prior review rounds described this diff as narrowing next/, which it does not. A one-line note in the filter comment that the @vercel/og alternative is intentionally unanchored (to admit \0-prefixed re-imports) would prevent someone re-adding ^ and silently breaking \0@vercel/og resolution.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my independent review of PR #1908:

Review Summary

The two changes — SSR shell-error recovery via the __next_error__ document, and the vinext/shims/* resolver fix — are correct and well-tested. I verified the core mechanism from scratch and confirmed the prior-round gaps are resolved.

Verified independently:

  • Flight payload comes from a separate stream, so it survives the SSR render rejection.
  • No double bootstrap — buildHeadInjectionHtml emits only a <link rel=modulepreload>; the manual <script id="_R_"> is the sole executing entry.
  • DefaultGlobalError renders exactly one <style> and a </head>, so both markErrorShellStyle and the </head> flight injection are valid; errorThemeCss has no <style> substring.
  • Background ISR revalidation threads the flag (app-page-dispatch.ts:667); abandoned prerender stream is cancelled.
  • Ran tests/app-page-dispatch.test.ts + tests/shims.test.ts (1067 pass).

Findings posted (2, both non-blocking):

  1. New behavioral finding — unconditional throw + no boundary + no custom global-error now leaves a blank page. The createRoot recovery branch runs on initial bootstrap, where pendingNavigationRecoveryHref is null, so onUncaughtError only logs and does not navigate. A component that throws on both server and client (not the SSR-only pattern) previously served the hydrated server-rendered error card; now the error card is torn down by createRoot, the re-render throws again, and the user is left on a blank document. The new spec doesn't cover this case. Worth confirming against Next.js parity or adding a fixture.

  2. Factual correction to prior rounds re: the filter regex. Several prior /bigbonk reviews claimed this PR "anchors next/^next/" and narrows the filter. That is incorrect — next/ is unanchored in both versions. The real change is removing the ^ anchor from @vercel/og, which broadens the filter so \0@vercel/og re-imports now pass (asserted by the new shims test). The \0-strip concern from the last round is the opposite of the actual change. Suggested a comment so nobody re-adds the ^.

No correctness blockers found.

github run

An unconditional client throw during SSR shell recovery without a local error boundary must still land on the default global-error card. Without a regression test, a future change could tear down the recovery shell and leave a blank document.\n\nAdd a production browser case that exercises the no-boundary route and asserts the default error UI after the client re-render throws again.
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.

2 participants