You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Every client-router navigation into a page whose render throws degrades to a full document load, even when the segment has a proper error.{js,ts} boundary. The same holds for not-found, forbidden, and unauthorized.
The cause is that an error render is served without its layout chain. ssrPage's catch renders the nearest boundary module standalone and hands the result straight to wrapInDocument (packages/server/src/ssr.js:383-410); the layout chain is never re-run. Since the keyed <!--wj:children:…--> boundary comments are emitted by the layout wrapping in renderChain (ssr.js:974-975), the error response carries none of them. The client router scans both DOMs, finds no shared boundary, reports webjs:navigation-fallback with cause no-shared-boundary (packages/core/src/router-client.js:3223), and hard-navigates.
Verified against a fixture that HAS an error.ts boundary: the response contains zero wj:children markers and the click produces a real document load, losing the SPA's client state, scroll, and any hydrated component state on the surrounding layout.
Two user-visible consequences:
An error boundary does not behave like a boundary. In Next.js, error.tsx renders inside its parent layouts, so only the failing segment is replaced and the shell (nav, sidebar, header) stays live. In WebJs the whole document is replaced, so an error in a leaf segment blows away the entire page.
The layout is lost from the error page itself. The user lands on a bare error document with no site chrome, no nav to escape with.
Found while building the e2e for #1047 (PR #1294), which needed a soft navigation into a throwing page and could not get one. Nothing in #1047 changes this behaviour; the overlay fix works either way.
Design / approach
The direction is to render a boundary through the SAME layout chain the happy path uses, so the response carries the keyed boundary comments and the router can soft-swap it.
Concretely: instead of renderToString(boundaryTree) followed by a bare wrapInDocument, the catch should re-enter the layout wrapping with the boundary's tree in the page position, so renderChain's per-layout comment emission runs unchanged. The natural shape is to factor the layout-wrapping half of renderChain (ssr.js:676) out from the page-loading half, so both the happy path and the boundary paths can call it.
Open questions the implementer should settle and record on the PR:
Which layouts wrap a boundary. Next renders error.tsx inside layouts ABOVE its own segment, not its own segment's layout (an error in the layout itself must not be re-run). WebJs's route.layouts is the full chain, so the boundary's own segment layout has to be excluded, and the exclusion has to be derived from where the boundary file sits.
What happens when a LAYOUT is what threw. This is the case the current standalone render is implicitly safe for. Re-running the chain would re-run the throwing layout. The fallback has to stay the current bare document rather than looping, which suggests wrapping only the layouts strictly above the failure point, with the existing standalone render kept as the last resort.
Whether global-error stays exempt. It already renders its own full document verbatim (ssr.js:417-430) precisely because a root-layout failure is when it fires, so it should stay as it is.
Applies equally to ssrForbidden / ssrUnauthorized (ssr.js:467-481) and ssrNotFoundHtml (ssr.js:657), which all go through ssrBoundaryHtml (ssr.js:638-655) and share the same bare wrapInDocument.
Not in scope: changing the client router. It behaves correctly today, since degrading on a genuinely disjoint scan is the #1015 contract.
Implementation notes (for the implementing agent)
Line numbers verified against origin/main at 74dd3ada.
Where to edit:
packages/server/src/ssr.js:383-410, the error.{js,ts} loop inside ssrPage's catch. This is the site: it renders mod.default({ ...ctx, error: err }), calls renderToString, builds errModuleUrls, and calls wrapInDocument with no layout wrapping.
packages/server/src/ssr.js:676, renderChain(route, ctx, dev, suspenseCtx, have, pageModule), the happy path. Its layout loop is what emits the boundary comments; splitting the layout-wrapping half out of the page-loading half is the enabling refactor.
packages/server/src/ssr.js:949-975, the keyed comment pair itself (<!--wj:children:<segment>:<route-key>--> / <!--/wj:children:<segment>-->, Rebuild the client-router swap on route-keyed comment boundaries #1015). Whatever path renders a boundary must produce these with the SAME segment paths and route-key the happy path would, or the router's scan will find a mismatched pair and degrade anyway (which is worse than today, because it would look fixed in a diff and still hard-load).
packages/server/src/ssr.js:638-655, ssrBoundaryHtml, shared by 403 / 401 / 404. Same treatment, and note it renders mod.default({}) with an EMPTY context, so a layout chain around it needs a real ctx.
packages/server/src/ssr.js:417-430, global-error: leave alone, returned verbatim by design.
Landmines:
The inert / import-only boot substitution. The error path already applies the dogfood: import-only elision rejects reactive utils reachable only through shipping components #963 rule when building errModuleUrls (ssr.js:395-408), because an import-only page with a bare .server.* import would otherwise load whole and crash the error page's boot on the throw-at-load stub. Any refactor MUST keep that substitution, and once layouts are in the picture their modules join the same set.
A boundary that itself throws already falls through to the next outer boundary (ssr.js:411-413). Wrapping in layouts adds a second way to throw (a layout re-running), and the fallthrough must not become an infinite loop.
Re-running a layout has side effects. Layouts are async functions that may fetch. The happy path already ran them once for the request that threw, so a naive re-render doubles that work and can double-fire whatever they do. Worth measuring before committing to a shape.
Suspense / streaming.renderChain takes a suspenseCtx; a streamed page that throws mid-stream has already flushed its shell, so the error path there is not the same as a pre-flush throw. Check what a boundary render means once bytes are out.
The HTML cache and X-Webjs-Have.renderChain takes have and participates in the reduced-fragment path. A 500 must never be stored or reduced; confirm the new path cannot leak into either.
This changes what the browser downloads on an error page (layout modules now ship), so the elision / preload tests are in the blast radius.
Invariants to respect:
AGENTS.md invariant 8: only the root layout may write the document shell. A boundary rendered inside layouts must not gain one.
The Rebuild the client-router swap on route-keyed comment boundaries #1015 boundary contract: comment pairs must be keyed, matched, and non-duplicated, or the router poisons the scan and degrades. That is the whole point of the change, so it is also the easiest thing to get subtly wrong.
packages/ stays plain .js with JSDoc, no .ts, no build step.
Tests + docs surfaces:
Unit / integration: packages/server/test/ SSR + boundary coverage, asserting the 500 / 403 / 401 / 404 responses now carry matched keyed boundary comments for the expected segments.
Browser or e2e: the headline is browser-observable, so assert a client-router click into a throwing page is a SOFT navigation (the document is not reloaded) and the outer layout's DOM identity is preserved. test/e2e/dev-overlay-nav.test.mjs (added in PR fix: scope the dev error overlay to the URL that produced it #1294) already has a fixture with a throwing page plus an interactive layout and documents this exact limitation in its comments; its /crash route is a ready-made case, and its comments will need correcting once this lands.
Bun parity: SSR dispatch is runtime-sensitive, so a test/bun/* assertion is required.
Docs: website/app/docs/error-handling/page.ts and the routing docs describe boundary behaviour; AGENTS.md's error/loading/metadata section and .agents/skills/webjs/references/routing-and-pages.md both describe what an error.{js,ts} renders.
Acceptance criteria
A client-router navigation into a page whose render throws is a SOFT navigation when the segment has an error.{js,ts} boundary (no document reload, no webjs:navigation-fallback)
The error response carries matched, correctly keyed <!--wj:children:…--> pairs for the layouts above the boundary
The surrounding layout's DOM identity and hydrated component state survive the swap
The same holds for not-found, forbidden, and unauthorized
A throwing LAYOUT still degrades safely instead of looping, and global-error still returns its own document verbatim
Problem
Every client-router navigation into a page whose render throws degrades to a full document load, even when the segment has a proper
error.{js,ts}boundary. The same holds fornot-found,forbidden, andunauthorized.The cause is that an error render is served without its layout chain.
ssrPage's catch renders the nearest boundary module standalone and hands the result straight towrapInDocument(packages/server/src/ssr.js:383-410); the layout chain is never re-run. Since the keyed<!--wj:children:…-->boundary comments are emitted by the layout wrapping inrenderChain(ssr.js:974-975), the error response carries none of them. The client router scans both DOMs, finds no shared boundary, reportswebjs:navigation-fallbackwith causeno-shared-boundary(packages/core/src/router-client.js:3223), and hard-navigates.Verified against a fixture that HAS an
error.tsboundary: the response contains zerowj:childrenmarkers and the click produces a real document load, losing the SPA's client state, scroll, and any hydrated component state on the surrounding layout.Two user-visible consequences:
error.tsxrenders inside its parent layouts, so only the failing segment is replaced and the shell (nav, sidebar, header) stays live. In WebJs the whole document is replaced, so an error in a leaf segment blows away the entire page.Found while building the e2e for #1047 (PR #1294), which needed a soft navigation into a throwing page and could not get one. Nothing in #1047 changes this behaviour; the overlay fix works either way.
Design / approach
The direction is to render a boundary through the SAME layout chain the happy path uses, so the response carries the keyed boundary comments and the router can soft-swap it.
Concretely: instead of
renderToString(boundaryTree)followed by a barewrapInDocument, the catch should re-enter the layout wrapping with the boundary's tree in the page position, sorenderChain's per-layout comment emission runs unchanged. The natural shape is to factor the layout-wrapping half ofrenderChain(ssr.js:676) out from the page-loading half, so both the happy path and the boundary paths can call it.Open questions the implementer should settle and record on the PR:
error.tsxinside layouts ABOVE its own segment, not its own segment's layout (an error in the layout itself must not be re-run). WebJs'sroute.layoutsis the full chain, so the boundary's own segment layout has to be excluded, and the exclusion has to be derived from where the boundary file sits.global-errorstays exempt. It already renders its own full document verbatim (ssr.js:417-430) precisely because a root-layout failure is when it fires, so it should stay as it is.Applies equally to
ssrForbidden/ssrUnauthorized(ssr.js:467-481) andssrNotFoundHtml(ssr.js:657), which all go throughssrBoundaryHtml(ssr.js:638-655) and share the same barewrapInDocument.Not in scope: changing the client router. It behaves correctly today, since degrading on a genuinely disjoint scan is the #1015 contract.
Implementation notes (for the implementing agent)
Line numbers verified against
origin/mainat74dd3ada.packages/server/src/ssr.js:383-410, theerror.{js,ts}loop insidessrPage's catch. This is the site: it rendersmod.default({ ...ctx, error: err }), callsrenderToString, buildserrModuleUrls, and callswrapInDocumentwith no layout wrapping.packages/server/src/ssr.js:676,renderChain(route, ctx, dev, suspenseCtx, have, pageModule), the happy path. Its layout loop is what emits the boundary comments; splitting the layout-wrapping half out of the page-loading half is the enabling refactor.packages/server/src/ssr.js:949-975, the keyed comment pair itself (<!--wj:children:<segment>:<route-key>-->/<!--/wj:children:<segment>-->, Rebuild the client-router swap on route-keyed comment boundaries #1015). Whatever path renders a boundary must produce these with the SAME segment paths and route-key the happy path would, or the router's scan will find a mismatched pair and degrade anyway (which is worse than today, because it would look fixed in a diff and still hard-load).packages/server/src/ssr.js:638-655,ssrBoundaryHtml, shared by 403 / 401 / 404. Same treatment, and note it rendersmod.default({})with an EMPTY context, so a layout chain around it needs a realctx.packages/server/src/ssr.js:417-430,global-error: leave alone, returned verbatim by design.errModuleUrls(ssr.js:395-408), because an import-only page with a bare.server.*import would otherwise load whole and crash the error page's boot on the throw-at-load stub. Any refactor MUST keep that substitution, and once layouts are in the picture their modules join the same set.ssr.js:411-413). Wrapping in layouts adds a second way to throw (a layout re-running), and the fallthrough must not become an infinite loop.renderChaintakes asuspenseCtx; a streamed page that throws mid-stream has already flushed its shell, so the error path there is not the same as a pre-flush throw. Check what a boundary render means once bytes are out.X-Webjs-Have.renderChaintakeshaveand participates in the reduced-fragment path. A 500 must never be stored or reduced; confirm the new path cannot leak into either.packages/stays plain.jswith JSDoc, no.ts, no build step.packages/server/test/SSR + boundary coverage, asserting the 500 / 403 / 401 / 404 responses now carry matched keyed boundary comments for the expected segments.test/e2e/dev-overlay-nav.test.mjs(added in PR fix: scope the dev error overlay to the URL that produced it #1294) already has a fixture with a throwing page plus an interactive layout and documents this exact limitation in its comments; its/crashroute is a ready-made case, and its comments will need correcting once this lands.test/bun/*assertion is required.website/app/docs/error-handling/page.tsand the routing docs describe boundary behaviour;AGENTS.md's error/loading/metadata section and.agents/skills/webjs/references/routing-and-pages.mdboth describe what anerror.{js,ts}renders.Acceptance criteria
error.{js,ts}boundary (no document reload, nowebjs:navigation-fallback)<!--wj:children:…-->pairs for the layouts above the boundarynot-found,forbidden, andunauthorizedglobal-errorstill returns its own document verbatimtest/bun/*cross-runtime assertionAGENTS.md, and the skill'sreferences/routing-and-pages.mdtest/e2e/dev-overlay-nav.test.mjs's comments, which currently document this as a hard limitation, are corrected