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 a boundary render is served without its layout chain. ssrPage's catch renders the nearest boundary module standalone and hands the result straight to wrapInDocument, so the layout chain is never re-run. Since the keyed <!--wj:children:…--> boundary comments are emitted by the layout wrapping inside renderChain, the boundary 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/swap.js:347-351), and hard-navigates.
Two user-visible consequences:
An error boundary does not behave like a boundary. In Next.js, error.tsx renders inside its layouts, so only the failing region 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 boundary page itself. The user lands on a bare error document with no site chrome and 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 premise, re-measured at HEAD
All line anchors below are dated to 105372de ("refactor(framework): overhaul WebJs framework architecture following SOLID, KISS, and DRY principles", #1376), which sits on top of the #1365 barrel split. Every ssr.js:NNN and router-client.js:NNN anchor in the previous version of this body was verified at 74dd3ada and is now stale: packages/server/src/ssr.js is a 33-line barrel re-exporting packages/server/src/ssr/, and packages/core/src/router-client.js is a barrel over packages/core/src/router-client/. Every construct has been re-located and is cited by its real path below.
The #1376 overhaul did not change the boundary render path. Measured, not assumed. A probe app (root layout with #root-chrome, app/docs/layout.js with #docs-chrome, app/docs/error.js, app/docs/not-found.js, app/docs/gated/forbidden.js, plus a throwing page, a forbidden() page, and a notFound() page) driven through the real createRequestHandler(...).handle() in prod mode gives:
The premise holds exactly as stated. Two further facts the probe settled, both of which change the plan:
A throwing LAYOUT today renders the error boundary at its OWN segment./badlayout (whose layout.js throws) rendered app/badlayout/error.js. That is safe today only because no layout re-runs, and it is not what Next.js does. See decision 2.
The error path already emits every layout module in the boot script.errModuleUrls is built from [route.file, ...route.layouts] at packages/server/src/ssr/render.js:800-813, so "layout modules now join that set" is already true. What is NOT in that set is the boundary module itself, which is the one module that actually rendered. See step 6.
packages/server/src/router.js:170-180, chainOf at 215-221
HTML-cache storability guard
packages/server/src/html-cache.js:232-238
cacheEligible (excludes X-Webjs-Have)
packages/server/src/ssr/render.js:414-418
form-action boundary call sites
packages/server/src/form-dispatch.js:565, 569-570
unmatched-URL 404 call site
packages/server/src/dev/serve.js:498
The code as it stands today, at packages/server/src/ssr/render.js:787-815:
// Try nearest error.js (innermost → outermost).for(leti=route.errors.length-1;i>=0;i--){try{constmod=awaitloadModule(route.errors[i],opts.dev);if(!mod.default)continue;consttree=awaitmod.default({ ...ctx,error: err});constbody=awaitrenderToString(tree,{ssr: true,dev: opts.dev});
...
consthtml=wrapInDocument(body,{ metadata,moduleUrls: errModuleUrls,dev: opts.dev,nonce: errNonce});returnhtmlResponse(html,500,opts.req,url);}catch(nested){// fall through to next error boundary}}
renderToString straight into wrapInDocument. No layouts, therefore no markers, therefore a hard load.
Design / approach
Render every boundary through the SAME layout-wrapping code the happy path uses, so the response carries the identical keyed boundary comments and the router can soft-swap it. The boundary tree takes the PAGE's position in the tree, and nothing else about the shape of the response changes.
The enabling refactor is to lift the layout-wrapping half of renderChain out of the page-loading half into one wrapLayoutChain helper both paths call. Sharing the code rather than copying the loop is not a style preference here: the #1015 contract says the segment ids and route-keys have to match the happy path byte for byte or the router finds a mismatched pair and degrades anyway, which looks fixed in a diff and still hard-loads. One emitter cannot drift from itself.
Decision 1: which layouts wrap a boundary
A boundary file at segment E renders wrapped by exactly the layouts whose segment path is E or an ancestor of E. The boundary's own segment layout IS included.
The previous draft of this issue assumed the opposite ("layouts ABOVE its own segment, not its own segment's layout"). That is wrong, and Next.js says so outright. From ~/Documents/Projects/frameworks/next.js/docs/01-app/03-api-reference/03-file-conventions/error.mdx:96:
In the component hierarchy, error.js wraps loading.js, not-found.js, page.js, and nested layout.js files in a React error boundary. It does not wrap the layout.js or template.js above it in the same segment. To handle errors in the root layout, use global-error.js.
And the hierarchy itself, from docs/01-app/01-getting-started/02-project-structure.mdx:194-201, is layout.js then template.js then error.js then loading.js then not-found.js then page.js. The layout at a segment is OUTSIDE the error boundary at that same segment. That is precisely why an error thrown in a layout is not caught by its own segment's boundary: the boundary is nested inside the thing that threw.
How WebJs derives it.route.layouts is the full outermost-to-innermost chain (built at packages/server/src/router.js:173 from chainOf(page.routeDir)), and .filter(Boolean) drops the directory association, so the exclusion cannot be an index. It is derived from the FILE PATH, exactly the way layoutSegmentPath already derives a layout's own segment.
Two new functions in packages/server/src/ssr/document.js, next to the existing segment-path helpers:
boundarySegmentPath(file) strips the boundary filename from the app-relative path, mirroring layoutSegmentPath. app/docs/error.ts gives /docs, app/error.ts gives /, app/(marketing)/about/not-found.ts gives /(marketing)/about. Route groups are KEPT, for the same reason layoutSegmentPath keeps them (document.js:12-15): two routes at one URL prefix served by different (group) layouts must not be mistaken for a shared layout.
layoutsForBoundary(layouts, boundarySeg) returns the sub-array of layouts whose layoutSegmentPath is boundarySeg or a prefix of it on a segment break. The prefix test is seg === boundarySeg || boundarySeg.startsWith(seg === '/' ? '/' : seg + '/'), so /doc never matches /docs.
The variable that holds the answer at the call site is wrapLayouts.
Decision 2: what happens when a LAYOUT is what threw
The bounded fallthrough already answers this, and no failure-point tracking is needed.
The tempting design is to record which layout threw during the happy path and use it to pre-filter the eligible boundaries. Reject it: a throw can also come out of renderToString when a layout's template hole holds a rejected promise, and at that point the throw is not attributable to any one layout, so the recorded failure point would be absent exactly when a layout is most likely to have caused it. A mechanism that is right most of the time and silently absent the rest is worse than no mechanism.
The correct design is to let the existing loop discover it. The boundary attempt is made INSIDE the existing try at render.js:788, so a layout that throws while wrapping the boundary is caught by the existing catch (nested) at :816 and the loop moves one boundary outward. Because a boundary at segment E wraps only the ancestor-or-self chain of E, moving outward strictly shrinks the wrapped set, so the walk converges on a boundary whose whole chain renders. If the throwing layout is above every boundary (the root layout), every attempt fails and control reaches global-error, which is exactly where Next.js sends a root-layout failure.
Control flow that makes an infinite fallthrough impossible:
for (let i = route.errors.length - 1; i >= 0; i--) is a bounded descending index. Each iteration is one try / catch and never re-enters ssrPage.
wrapLayoutChain walks a finite array with no recursion and no retry.
Past the loop, global-error gets ONE attempt, unwrapped (decision 3).
Past that, the default 500 page loads no module and wraps nothing.
Total module evaluations on the error path are bounded by |route.errors| * (1 + |wrapLayouts|) + 1. There is no path that re-enters the ladder from inside itself.
The 404 / 403 / 401 paths are not loops (they render one nearest boundary), so they get a two-attempt ladder instead: wrapped, then unwrapped on a throw, then the default heading. Also bounded, also non-recursive.
Note the behaviour change this brings, and it is a fix rather than a regression: today a throwing layout renders the error boundary at its own segment (measured above on /badlayout), which is a boundary nested inside the failure. After this change that attempt throws, and the next boundary OUT renders, matching Next.
Decision 3: global-error stays exempt
Unchanged, returned verbatim at render.js:820-836. Three reasons, any one sufficient:
It renders its own <!doctype><html><body>. AGENTS.md invariant 8 says only the root layout may write a shell, and a global-error inside the root layout would produce two nested shells.
It fires only after every nested error boundary is exhausted, which per decision 2 is the state a root-layout failure lands in. Wrapping it in the root layout would re-run the exact code that threw.
It ships no importmap and no boot script by design (AGENTS.md, "Error / loading / metadata routes"), so it could not participate in a soft swap even if it were wrapped. A hard load into global-error is correct.
global-not-found stays exempt for the same reason plus decision 5.
Decision 4: double-running layouts is accepted, not optimised away
The happy path already ran the layout chain for the request that threw. The boundary path re-runs the ancestor-or-self chain. Accept the cost.
Measured on the probe app (warm, 300 iterations each, prod mode): a /docs render of the root layout plus the docs layout plus the page is 0.913ms, and the /docs/crash boundary render with no chain at all is 0.702ms. The entire two-layout chain costs about 0.21ms. This is a response that is already a failure, on a path that by definition is not hot.
Reuse was considered and rejected. renderChain builds ONE nested tree in which the children hole holds the page tree, so there is no per-layout HTML lying around to splice a boundary into. Producing one would mean rendering each layout separately against a placeholder and re-joining the strings, which moves the happy path's byte output and every ETag with it, for a payoff of 0.2ms on the error path. Next.js re-renders from the boundary's parent on the error path too, so this is also the prior-art behaviour.
The consequence to write down rather than hide: a layout that fetches will fetch twice on a boundary response. Documented in the docs-site error-handling page (see Docs).
Decision 5: the ctx a wrapped layout receives
A layout receives the same ctx shape as on the happy path, { params, searchParams, url, actionData, children }, built by one new boundaryCtx(params, url, actionData) helper in render.js using the already-imported makeThenable. Where it comes from, per entry point:
Entry point
ctx source
ssrPage catch (500, and the notFound() / forbidden() / unauthorized() branches at render.js:756-767)
the ctx already in scope at render.js:445-458. Pass it down.
ssrForbidden / ssrUnauthorized from form-dispatch.js:569-570
runFormAction(route, params, url, req, ssrOpts, deps) holds params and url. Add them to the opts object it already spreads.
ssrNotFound from form-dispatch.js:565
same, plus route, which that call site currently does not pass at all.
ssrNotFound from dev/serve.js:498
no route and no params exist. The URL matched nothing, so there is no layout chain to wrap in. This path stays exactly as it is today: bare wrapInDocument, no markers. A hard load into a 404 for an unrouted URL is correct, because there is no shared shell to swap into.
ssrBoundaryHtml currently renders mod.default({}) (render.js:353) and ssrNotFoundHtml the same (:372). Since a real ctx has to be built anyway for the layouts, pass it to the boundary module as well, so error, not-found, forbidden, and unauthorized all receive one uniform ctx. Strictly additive (nothing today reads a prop that disappears), and WebJs has no users, so a shim for the empty-object shape would be dead weight.
Decision 6: streaming
Out of scope, and unreachable from this code path.streamingHtmlResponse is constructed at render.js:694, INSIDE the try. Once it returns, the catch at :746 can no longer fire, so every throw this catch sees is pre-flush. A rejection inside a deferred Suspense boundary after the shell flushed is handled by the streaming machinery, never here.
The boundary render itself stays buffered and passes NO suspenseCtx, exactly as it does today. Per packages/core/src/render-server/stream.js:13-18, a Suspense boundary with no ctx emits its fallback and drops the promise, so a <webjs-suspense> inside a wrapped layout renders its fallback on a boundary response and never resolves. That is a known, bounded limitation, written into the docs rather than papered over. Streaming a boundary would mean the status and headers were committed before the boundary was known to render, which is a worse trade on a 500.
Decision 7: the HTML cache and X-Webjs-Have cannot be reached
Neither can leak onto the new path, and each is gated by code that already exists:
The response cache.isCacheableResponse (packages/server/src/html-cache.js:232-238) opens with if (res.status !== 200) return false;, so a 500 / 404 / 403 / 401 can never be stored. Independently, the HTML_CACHE_MARKER that flags a response for the funnel is set at render.js:742-744, inside the try, after the successful response was built. The catch never reaches it, and every boundary response is built by htmlResponse(...) which does not set it.
X-Webjs-Have. The reduced-fragment short-circuit lives only in the layout loop's have branch (render.js:317-327). wrapLayoutChain is called from the boundary path with have passed as null, which is structural rather than conventional: with no map there is no branch to take. A boundary response therefore can never be reduced, never gets Vary: X-Webjs-Have, and never calls privateFragment. Belt and braces, cacheEligible at render.js:414-418 already excludes any request carrying the header from the cache entirely.
What is deliberately not changed
The client router. It behaves correctly today: degrading on a genuinely disjoint scan is the #1015 contract, and the fix is to stop producing disjoint responses.
Implementation plan
Ordered. Every step names the file, the function, and the line anchor at 105372de. packages/ is plain .js with JSDoc, so no .ts file is added anywhere.
Step 1: two segment-path helpers in packages/server/src/ssr/document.js
Add after pageSegmentPath (which ends at :49), so the three derivations sit together, and export both from the ssr.js barrel's document.js block alongside _pageSegmentPath.
/** * Derive a BOUNDARY file's own segment path, the same way layoutSegmentPath * does for a layout. Route groups are KEPT, for the reason stated on * layoutSegmentPath: two routes at one URL prefix served by different * `(group)` layouts must not look like a shared layout. * * app/error.ts -> '/' * app/docs/error.ts -> '/docs' * app/(marketing)/about/not-found.ts -> '/(marketing)/about' * * @param {string} file Absolute path to an error / not-found / forbidden / * unauthorized source file. * @returns {string} */exportfunctionboundarySegmentPath(file){constp=file.replace(/^.*\/app\//,'').replace(/\/?(?:error|not-found|forbidden|unauthorized)\.[jt]sx?$/,'');returnp==='' ? '/' : '/'+p;}/** * The layouts that wrap a boundary at `boundarySeg`: every layout whose own * segment is that segment or an ancestor of it, in the chain's original * outermost-first order. A layout DEEPER than the boundary never rendered, so * it is excluded; the boundary's OWN segment layout is INCLUDED, which is what * Next does (error.js sits inside its segment's layout, which is why a * throwing layout is not caught by its own segment's boundary). * * The prefix test breaks on a segment, so '/doc' never matches '/docs'. * * @param {string[]} layouts route.layouts, outermost first. * @param {string} boundarySeg * @returns {string[]} */exportfunctionlayoutsForBoundary(layouts,boundarySeg){return(layouts||[]).filter((f)=>{constseg=layoutSegmentPath(f);returnseg===boundarySeg||boundarySeg.startsWith(seg==='/' ? '/' : seg+'/');});}
Step 2: lift the layout loop out of renderChain (render.js:312-336)
This is a MOVE, not a rewrite. Today the loop's short-circuit branch and its fall-through end do the identical two lines (renderToString, then + loadingTemplates), differing only in the reduced flag, so the branch collapses to a break and the wrapping becomes reusable with no behaviour change. Today:
After, as a new module-level function placed directly above renderChain:
/** * Wrap a tree in a layout chain, emitting the KEYED boundary comment pair * (#1015) around each layout's `${children}`. The ONE emitter: the happy path * and every boundary render call it, so the segment ids and route-keys they * produce cannot drift. A drift there is the worst possible outcome, because * the router's scan finds a mismatched pair and hard-loads anyway, which looks * fixed in a diff. * * `have` is the X-Webjs-Have short-circuit and is null on every boundary path, * so a boundary response is structurally incapable of being reduced. * * @param {unknown} tree * @param {string[]} layouts outermost first * @param {Record<string,unknown>} ctx * @param {boolean} dev * @param {Record<string,string>} params * @param {Map<string,string> | null} have * @returns {Promise<{ tree: unknown, reduced: boolean }>} */asyncfunctionwrapLayoutChain(tree,layouts,ctx,dev,params,have){for(leti=layouts.length-1;i>=0;i--){constsegmentPath=layoutSegmentPath(layouts[i]);// Short-circuit ONLY when the client's copy of this layout was rendered// for the SAME route-key: a param change at a dynamic layout must// re-render the layout's own markup (#1015).if(have&&have.get(segmentPath)===regionRouteKey(segmentPath,params)){return{tree: wrapWithChildrenMarker(tree,segmentPath,params),reduced: true};}constmod=awaitloadModule(layouts[i],dev);if(!mod.default)continue;tree=awaitmod.default({
...ctx,children: wrapWithChildrenMarker(tree,segmentPath,params),});}return{ tree,reduced: false};}
Keep the whole explanatory comment block at render.js:296-311 on renderChain at the call site; move only the per-branch comments with their code. The happy path's bytes must be unchanged, which is what the differential and the existing keyed-boundary tests assert.
Step 3: the boundary renderer, in render.js
New function beside wrapLayoutChain. It is the whole fix.
/** * Render a boundary tree in the PAGE's position, wrapped in the layouts that * wrap that boundary, so the response carries the same keyed boundary comments * a successful render of this URL would and the client router can soft-swap it * (#1298). Buffered: no suspenseCtx, so a <webjs-suspense> inside a wrapped * layout emits its fallback (render-server/stream.js), which is the trade for a * boundary response whose status and headers are final before the first byte. * * A throw from a wrapped layout propagates to the CALLER, which is what makes * a throwing layout fall through to the next boundary out instead of looping. * * @param {unknown} tree the boundary module's rendered tree * @param {import('../router.js').PageRoute} route * @param {string} boundaryFile * @param {Record<string,unknown>} ctx * @param {boolean} dev * @returns {Promise<string>} */asyncfunctionrenderBoundaryInChain(tree,route,boundaryFile,ctx,dev){constparams={ ...(/** @type {Record<string,string>} */(ctx.params)||{})};constwrapLayouts=layoutsForBoundary(route.layouts,boundarySegmentPath(boundaryFile));// The boundary occupies the PAGE's region, so it gets the page's own keyed// pair under the SAME skip rule renderChain uses (render.js:288-294), read// against the innermost WRAPPED layout rather than the innermost layout of// the route. That keeps the emitted segment-id SET identical to the happy// path's in every shape: where a deeper layout would have owned the innermost// region, the page region takes its place under the same id.constpageSeg=pageSegmentPath(route.file);constinnermostSeg=wrapLayouts.length
? layoutSegmentPath(wrapLayouts[wrapLayouts.length-1])
: null;if(pageSeg!==innermostSeg)tree=wrapWithChildrenMarker(tree,pageSeg,params);constchain=awaitwrapLayoutChain(tree,wrapLayouts,ctx,dev,params,null);returnrenderToString(chain.tree,{ssr: true, dev });}
Worked example, the shape most likely to be got wrong. Layouts / and /docs/crash, no /docs layout, boundary at app/docs/error.ts. Happy path regions are / and /docs/crash (the page marker is skipped, since pageSeg === '/docs/crash' equals the innermost layout's segment). Boundary path wraps only /, so innermostSeg is /, the page marker IS emitted, and the regions are / and /docs/crash. Same ids, same keys.
Step 4: use it in the error.{js,ts} loop (render.js:787-819)
consttree=awaitmod.default({ ...ctx,error: err});// Wrap in the layouts that wrap THIS boundary (#1298). Inside the// existing try on purpose: a layout that throws here is caught by the// `catch (nested)` below and the loop moves one boundary OUT, whose// wrapped set is strictly smaller. That bounded walk is how a throwing// layout resolves, and why no failure-point tracking is needed.constbody=awaitrenderBoundaryInChain(tree,route,route.errors[i],ctx,opts.dev);
Nothing else in the loop moves. The catch (nested) at :816-818 keeps its comment and gains one sentence noting it now also absorbs a throwing wrapped layout.
Step 5: use it in the 404 / 403 / 401 renderers (render.js:348-384)
ssrBoundaryHtml and ssrNotFoundHtml are the same shape, so both take the same two-attempt ladder. ssrBoundaryHtml today:
asyncfunctionssrBoundaryHtml(file,heading,opts){letbody=`<h1>${heading}</h1>`;if(file){constctx=boundaryCtx(opts.params,opts.url,undefined);try{constmod=awaitloadModule(file,opts.dev);if(mod.default){consttree=awaitmod.default(ctx);// Wrap in this boundary's layouts when the caller supplied the matched// route (#1298), so a 403 / 401 soft-swaps like any other response.// Two bounded attempts, never a loop: a throwing layout degrades to the// standalone render this has always produced.try{body=opts.route
? awaitrenderBoundaryInChain(tree,opts.route,file,ctx,opts.dev)
: awaitrenderToString(tree,{ssr: true,dev: opts.dev});}catch{body=awaitrenderToString(tree,{ssr: true,dev: opts.dev});}}}catch(e){body=`<h1>${heading}</h1><pre>${escapeHtml(String(e))}</pre>`;}}
ssrNotFoundHtml takes the identical edit against its '<h1>404: Not found</h1>' default.
Add the ctx builder above them:
/** * The ctx a boundary module and its wrapped layouts receive. Same shape as the * page render's (render.js:445-458), so a layout cannot tell the two apart. * @param {Record<string,string> | undefined} params * @param {URL | undefined} url * @param {unknown} actionData */functionboundaryCtx(params,url,actionData){return{params: makeThenable(params||{}),searchParams: makeThenable(url ? Object.fromEntries(url.searchParams.entries()) : {}),url: url ? url.toString() : '',
actionData,};}
Then thread route + params at the four call sites:
render.js:760, the notFound() branch of ssrPage's catch. ssrNotFoundHtml(nearest(route.notFounds) || opts.globalNotFound || null, opts) becomes ssrNotFoundHtml(..., { ...opts, route, ctx }), and both renderers prefer a supplied opts.ctx over building one, so the page render's ctx (with its real actionData) is reused. Note opts.globalNotFound is a ROOT-only boundary whose boundarySegmentPath is /, so it wraps in the root layout only, which is right.
render.js:766-767, ssrForbidden(route, { ...opts, url }) / ssrUnauthorized(...) become { ...opts, url, route, ctx }. Both exported wrappers at :871-885 pass opts straight through to ssrBoundaryHtml, so no signature changes there.
form-dispatch.js:565, ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url }) gains route, params the same way.
dev/serve.js:498 is left alone. No route matched, so opts.route is undefined and the ternary in step 5 renders standalone, byte-identical to today.
Step 6: make the boot module set match what rendered (render.js:800-813)
Today errModuleUrls is built from [route.file, ...route.layouts], so a boundary response ships the PAGE module (which never rendered) and every layout (including ones deeper than the boundary, which also never rendered), and does NOT ship the boundary module itself (the one thing that did render), so a component the boundary renders never upgrades.
Change the source list to [boundaryFile, ...wrapLayouts], keeping the #963 substitution loop exactly as it is:
The substitution must stay, for the reason its comment at :793-799 already gives. It applies to the boundary file harmlessly: collectRouteModules (packages/server/src/dev/helpers.js:268) feeds only page and layout files to the elision analysis, so an error.{js,ts} is never inert and never import-only, gets no verdict, and is pushed as-is. That is the documented contract (server AGENTS.md, package invariant 7), not an accident.
Compute wrapLayouts ONCE per iteration and share it with the renderBoundaryInChain call from step 4, rather than deriving it twice.
The same treatment is NOT applied to ssrBoundaryHtml / ssrNotFoundHtml, which pass moduleUrls: [] today (render.js:361, :380). Those pages ship no JS at all, and giving them a boot script is a separate behaviour change with its own blast radius. Left as is, and named under Out of scope.
Step 7: global-error and the default 500 page are untouched
render.js:820-836 and :837-849 keep their current code verbatim, per decision 3. Add one sentence to the global-error comment recording that the exemption is deliberate now that everything around it wraps.
Tests
npm test runs none of the browser, e2e, or Bun layers. Run them yourself and report the result. Rebuild packages/core/dist before e2e or Bun, or a packages/core/src edit is invisible to them and a counterfactual passes vacuously.
packages/server/test/routing/forbidden-unauthorized.test.js (the 403 / 401 pipeline, makeApp plus createRequestHandler). Add: both statuses carry matched pairs; the nearest-wins selection is unchanged; a forbidden.ts now receives a ctx with params populated.
test/ssr/ssr.test.js, the ssrPage: redirect / notFound / error boundaries block at line 1574 (cases at 1617, 1627, 1650, 1677, 1699). This is the primary surface. Add:
a 500 through app/docs/error.ts emits <!--wj:children:/:/--> and <!--wj:children:/docs:/docs--> with their matched closers, and the page-region pair for /docs/crash;
the segment-id set on the 500 equals the set on a 200 for the same URL, which is the single strongest assertion available and catches every skip-rule mistake in step 3;
a layout DEEPER than the boundary is absent from the 500;
a throwing LAYOUT falls through to the next boundary out and does not render the boundary at its own segment (the /badlayout shape measured above), and the response is a 500 with no hang;
a throwing root layout with only a root error.ts reaches global-error, whose document is still returned verbatim with no markers and no boot script;
the boot script on a 500 contains the boundary module and does not contain the page module (step 6);
the dogfood: import-only elision rejects reactive utils reachable only through shipping components #963 substitution still applies: an import-only layout in the wrapped set is replaced by its component URLs.
Note the load-bearing comment already at test/ssr/ssr.test.js:1035-1036 ("The shell wraps the boundary, not the other way around"), which describes the layout's own markup sitting outside its children range. Assertions here must not contradict it.
packages/server/test/ssr/region-route-key.test.js for boundarySegmentPath and layoutsForBoundary as pure functions: root, nested, a (group) segment kept, and the /doc vs /docs prefix trap.
Counterfactual
Revert step 4's one line (renderBoundaryInChain back to renderToString) and assertions 1, 3, and 7 above must fail, plus the e2e soft-navigation assertion below. That is the specific check that the test fires on the mechanism rather than on an incidental. Assertion 5 is its own counterfactual for decision 2: with wrapping reverted it passes vacuously, which is why it is stated as "renders the boundary OUTSIDE the throwing layout's segment" rather than merely "returns 500".
Browser
packages/core/test/routing/browser/ already owns the swap side. No change is needed there: the router is not being modified, and the browser suite's boundary-scan coverage (nav-guard.test.js:205, refresh-page.test.js:276) asserts the degradation path, which must keep working for genuinely disjoint responses. Run the suite to prove it is unaffected; do not add a case.
E2E (the headline is browser-observable, so this layer is mandatory)
test/e2e/dev-overlay-nav.test.mjs (added in PR #1294, 250 lines, still present at HEAD). Correction to the previous version of this issue: that file does not contain a comment documenting this hard-load limitation. What it has is a /crash test at lines 145-147 that reaches the page with a plain page.goto (a hard document load) and no comment saying why, and a hover test at 217-224 that only PREFETCHES /crash so the user never leaves /good. The suite never soft-navigates into /crash at all, and that gap is silent. So the work here is to close the gap and write down the reason it existed, not to correct a wrong comment.
Its fixture at test/e2e/fixtures/dev-overlay-app/ has a throwing page and an interactive layout but no error.ts, so add app/error.ts (or app/crash/error.ts, which exercises the nested case) to the fixture. Then add a case that:
lands on /good with the client router live (the file's existing helper at lines 106-111 is the idiom);
clicks a link to /crash;
asserts the navigation was SOFT, using the window.__wjCtx survival technique the file already uses at 169-171 and 181-184;
asserts no webjs:navigation-fallback fired, via the shared test/browser-nav-guard.js helper;
asserts the layout's DOM identity survived (the same node, not a re-created one) and its hydrated component state is intact;
asserts the boundary's content is on screen and the status was 500.
Also relevant and worth reading for the assertion style: test/e2e/nested-layout-partial-swap.test.mjs (sidenav DOM identity plus scroll preserved) and test/e2e/e2e.test.mjs:320-358 (the webjs:navigation-fallback cause assertion).
Bun parity (mandatory)
SSR dispatch is a runtime-sensitive surface, so this is part of the task, not an afterthought. Two files:
test/bun/routing-boundaries.mjs, the existing cross-runtime buildRouteTable proof for Close Next.js 16 file-routing parity gaps (async params, forbidden/unauthorized, global-error/not-found, instrumentation) #848. Extend it with the pure boundarySegmentPath / layoutsForBoundary derivation, since it already owns the boundary-chain shape. It currently has no .test.mjs wrapper, so npm test never runs it on Node. Add test/bun/routing-boundaries.test.mjs following the sibling pattern documented in test/bun/keyed-boundaries.test.mjs:1-9, or the extension is invisible on Node.
test/bun/ssr-boundary-chain.mjs plus test/bun/ssr-boundary-chain.test.mjs (new, named after its sibling test/bun/keyed-boundaries.mjs, which proves the happy-path wj:children emission cross-runtime). It renders a 500 and a 403 through the real handler on both runtimes and asserts the markers match and pair. This is the assertion that would catch a runtime-specific divergence in the boundary path, which is exactly what keyed-boundaries.mjs does for the happy path.
Run with node scripts/run-bun-tests.js plus the touched files under bun.
Smoke
test/examples/*/smoke/* does not apply: no example app is being changed, and both examples/blog and website already ship app/error.ts plus app/not-found.ts, so they are covered incidentally by CI booting them. The elision and preload suites (packages/server/test/elision/, packages/server/test/ssr/dropped-page-app-preload.test.js) ARE in the blast radius because step 6 changes the boot module set on a boundary response. Run them and expect any failure there to be a real signal, not a fixture update.
Convention gates
Run webjs check from inside each in-repo app (( cd gallery && npx webjs check ), and the same for examples/blog and website), plus webjs doctor for all three, since the required conventions CI job runs doctor over them and a clean check alone does not predict it (#1257). gallery/app/features/boundaries/ already carries error.ts, not-found.ts, gated/forbidden.ts, and private/unauthorized.ts, so the gallery is a live end-to-end check of all four boundary kinds against a real layout chain.
Docs
Every surface below exists and was verified at 105372de. The gap is uniform: not one of them says whether a rendered boundary is wrapped by its layouts, so each needs the same fact added, not a correction.
website/app/docs/error-handling/page.ts. Line 30 currently reads that the nearest error.ts "is rendered instead", which is silent on layout wrapping. Add: the boundary renders INSIDE the layouts at and above its own segment, so the site chrome stays on screen and a client-router navigation into a failing page stays a soft navigation. Add the two consequences from decisions 4 and 6: a layout that fetches runs its fetch again on a boundary response, and a <webjs-suspense> inside a wrapped layout shows its fallback because a boundary response is buffered. Line 92's global-error paragraph gains the reason it is exempt (its own document, and it fires when the root layout failed).
website/app/docs/routing/page.ts, the Error Boundaries (error.ts) section at lines 417-425 and the context note at 449-454. Same sentence about layout wrapping, plus the Next-parity rule that a boundary does not catch its own segment's layout, so an error in a layout is handled by the next boundary out.
.agents/skills/webjs/references/routing-and-pages.md, lines 212-215. Extend the error.ts line and the not-found / forbidden / unauthorized line with the wrapping rule and the ctx they now receive. Line 215's global-error sentence already states the verbatim-document behaviour; add that this is why it alone is unwrapped.
AGENTS.md, the Error / loading / metadata routes section at line 341-343. One sentence: a boundary renders inside the layouts at and above its own segment and therefore carries the keyed wj:children pairs, so navigating into one is a soft navigation; global-error is the sole exception because it returns its own document.
Scaffold. There is nopackages/cli/templates/.agents/skills/webjs/references/routing-and-pages.md. Correction to the previous version of this issue: find packages -name routing-and-pages.md returns nothing, and packages/cli/templates/ holds only AGENTS.md, CLAUDE.md, CONVENTIONS.md, compose.yaml, Dockerfile, gitignore, instrumentation.ts, partials/, public/, and scripts/. The scaffold-side edit therefore lands in packages/cli/templates/AGENTS.md (and partials/agents-playbook-fullstack.md if it discusses boundaries), mirroring the root AGENTS.md sentence.
gallery/app/features/boundaries/. The demo's own explanatory copy should state that the surrounding chrome survives, since the gallery is read as reference. A one-line change in the demo page's prose, folded into this PR.
The doc gate (.claude/hooks/require-docs-with-src.sh) is satisfied by the above. WEBJS_NO_DOC_GATE=1 is NOT applicable here: this is a user-visible behaviour change on a documented surface.
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 and no webjs:navigation-fallback
The boundary response carries matched, correctly keyed <!--wj:children:…--> pairs, and its segment-id set equals the set a 200 for the same URL emits
Every open has exactly one matching close and no segment id is duplicated
The surrounding layout's DOM identity and its hydrated component state survive the swap
The same holds for not-found, forbidden, and unauthorized when a route matched
A 404 for a URL that matched no route is still a bare document, since there is no chain to wrap in
A layout deeper than the boundary is not rendered and its module is not in the boot script
A throwing LAYOUT renders the next boundary OUT rather than the one at its own segment, terminates, and cannot loop
A throwing root layout still reaches global-error, which still returns its own document verbatim with no importmap and no boot script
A boundary response is never stored in the HTML cache and never served as a reduced X-Webjs-Have fragment
The happy path's bytes are unchanged (the differential elision test and the keyed-boundary tests stay green)
Reverting the one-line wrapping call reds the soft-navigation e2e assertion and the segment-id-set unit assertion
Tests ship at every layer the change touches, including a test/bun/* cross-runtime assertion, and test/bun/routing-boundaries.test.mjs exists so the Bun proof also runs on Node
webjs check and webjs doctor are clean in gallery, examples/blog, and website
Docs updated on all six surfaces listed above
test/e2e/dev-overlay-nav.test.mjs gains a soft-navigation-into-/crash case and its fixture gains an error.ts
global-error and global-not-found. Decided exempt (decision 3). Do not wrap them.
Giving the 404 / 403 / 401 pages a boot script. They pass moduleUrls: [] today and keep doing so. Making those pages interactive is a separate behaviour change with its own elision and preload blast radius.
Streaming a boundary response. Unreachable from this catch and deliberately buffered (decision 6). Do not thread a suspenseCtx into the boundary path.
Caching or memoising the happy path's layout output to avoid the second run. Rejected on measurement (decision 4); it moves the happy path's bytes for 0.2ms on a failure path.
Reworking layoutSegmentPath / pageSegmentPath / loadingSegmentPath into one generalised helper. Tempting on DRY grounds and not worth the byte risk on the happy path; add boundarySegmentPath beside them instead.
Filing follow-up issues for anything this turns up. Fold a small same-file tweak into this PR and report the rest in the PR description.
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 a boundary render is served without its layout chain.
ssrPage's catch renders the nearest boundary module standalone and hands the result straight towrapInDocument, so the layout chain is never re-run. Since the keyed<!--wj:children:…-->boundary comments are emitted by the layout wrapping insiderenderChain, the boundary 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/swap.js:347-351), and hard-navigates.Two user-visible consequences:
error.tsxrenders inside its layouts, so only the failing region 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 premise, re-measured at HEAD
All line anchors below are dated to
105372de("refactor(framework): overhaul WebJs framework architecture following SOLID, KISS, and DRY principles", #1376), which sits on top of the #1365 barrel split. Everyssr.js:NNNandrouter-client.js:NNNanchor in the previous version of this body was verified at74dd3adaand is now stale:packages/server/src/ssr.jsis a 33-line barrel re-exportingpackages/server/src/ssr/, andpackages/core/src/router-client.jsis a barrel overpackages/core/src/router-client/. Every construct has been re-located and is cited by its real path below.The #1376 overhaul did not change the boundary render path. Measured, not assumed. A probe app (root layout with
#root-chrome,app/docs/layout.jswith#docs-chrome,app/docs/error.js,app/docs/not-found.js,app/docs/gated/forbidden.js, plus a throwing page, aforbidden()page, and anotFound()page) driven through the realcreateRequestHandler(...).handle()in prod mode gives:wj:childrenmarkers/docs(happy path)<!--wj:children:/:/-->,<!--wj:children:/docs:/docs-->,<!--/wj:children:/docs-->,<!--/wj:children:/-->/docs/crash(page throws)/docs/gated(forbidden())/docs/missing(notFound())/badlayout(layout throws)The premise holds exactly as stated. Two further facts the probe settled, both of which change the plan:
/badlayout(whoselayout.jsthrows) renderedapp/badlayout/error.js. That is safe today only because no layout re-runs, and it is not what Next.js does. See decision 2.errModuleUrlsis built from[route.file, ...route.layouts]atpackages/server/src/ssr/render.js:800-813, so "layout modules now join that set" is already true. What is NOT in that set is the boundary module itself, which is the one module that actually rendered. See step 6.Where each construct lives now
105372de)ssrPagecatch, theerror.{js,ts}looppackages/server/src/ssr/render.js:787-819errModuleUrlsbuild (the #963 substitution)packages/server/src/ssr/render.js:800-813renderChainpackages/server/src/ssr/render.js:248-337X-Webjs-Haveshort-circuitpackages/server/src/ssr/render.js:312-334packages/server/src/ssr/render.js:288-294ssrBoundaryHtml(403 / 401)packages/server/src/ssr/render.js:348-365ssrNotFoundHtml(404)packages/server/src/ssr/render.js:367-384global-errorverbatim returnpackages/server/src/ssr/render.js:820-836packages/server/src/ssr/render.js:837-849ssrNotFound/ssrForbidden/ssrUnauthorizedexportspackages/server/src/ssr/render.js:858-885packages/server/src/ssr/document.js:157-167layoutSegmentPath/pageSegmentPath/regionRouteKeypackages/server/src/ssr/document.js:20-25/44-49/92-128wrapInDocumentpackages/server/src/ssr/document.js:299-302no-shared-boundaryfallback causepackages/core/src/router-client/swap.js:337-351packages/server/src/router.js:170-180,chainOfat215-221packages/server/src/html-cache.js:232-238cacheEligible(excludesX-Webjs-Have)packages/server/src/ssr/render.js:414-418packages/server/src/form-dispatch.js:565,569-570packages/server/src/dev/serve.js:498The code as it stands today, at
packages/server/src/ssr/render.js:787-815:renderToStringstraight intowrapInDocument. No layouts, therefore no markers, therefore a hard load.Design / approach
Render every boundary through the SAME layout-wrapping code the happy path uses, so the response carries the identical keyed boundary comments and the router can soft-swap it. The boundary tree takes the PAGE's position in the tree, and nothing else about the shape of the response changes.
The enabling refactor is to lift the layout-wrapping half of
renderChainout of the page-loading half into onewrapLayoutChainhelper both paths call. Sharing the code rather than copying the loop is not a style preference here: the #1015 contract says the segment ids and route-keys have to match the happy path byte for byte or the router finds a mismatched pair and degrades anyway, which looks fixed in a diff and still hard-loads. One emitter cannot drift from itself.Decision 1: which layouts wrap a boundary
A boundary file at segment
Erenders wrapped by exactly the layouts whose segment path isEor an ancestor ofE. The boundary's own segment layout IS included.The previous draft of this issue assumed the opposite ("layouts ABOVE its own segment, not its own segment's layout"). That is wrong, and Next.js says so outright. From
~/Documents/Projects/frameworks/next.js/docs/01-app/03-api-reference/03-file-conventions/error.mdx:96:And the hierarchy itself, from
docs/01-app/01-getting-started/02-project-structure.mdx:194-201, islayout.jsthentemplate.jsthenerror.jsthenloading.jsthennot-found.jsthenpage.js. The layout at a segment is OUTSIDE the error boundary at that same segment. That is precisely why an error thrown in a layout is not caught by its own segment's boundary: the boundary is nested inside the thing that threw.How WebJs derives it.
route.layoutsis the full outermost-to-innermost chain (built atpackages/server/src/router.js:173fromchainOf(page.routeDir)), and.filter(Boolean)drops the directory association, so the exclusion cannot be an index. It is derived from the FILE PATH, exactly the waylayoutSegmentPathalready derives a layout's own segment.Two new functions in
packages/server/src/ssr/document.js, next to the existing segment-path helpers:boundarySegmentPath(file)strips the boundary filename from the app-relative path, mirroringlayoutSegmentPath.app/docs/error.tsgives/docs,app/error.tsgives/,app/(marketing)/about/not-found.tsgives/(marketing)/about. Route groups are KEPT, for the same reasonlayoutSegmentPathkeeps them (document.js:12-15): two routes at one URL prefix served by different(group)layouts must not be mistaken for a shared layout.layoutsForBoundary(layouts, boundarySeg)returns the sub-array oflayoutswhoselayoutSegmentPathisboundarySegor a prefix of it on a segment break. The prefix test isseg === boundarySeg || boundarySeg.startsWith(seg === '/' ? '/' : seg + '/'), so/docnever matches/docs.The variable that holds the answer at the call site is
wrapLayouts.Decision 2: what happens when a LAYOUT is what threw
The bounded fallthrough already answers this, and no failure-point tracking is needed.
The tempting design is to record which layout threw during the happy path and use it to pre-filter the eligible boundaries. Reject it: a throw can also come out of
renderToStringwhen a layout's template hole holds a rejected promise, and at that point the throw is not attributable to any one layout, so the recorded failure point would be absent exactly when a layout is most likely to have caused it. A mechanism that is right most of the time and silently absent the rest is worse than no mechanism.The correct design is to let the existing loop discover it. The boundary attempt is made INSIDE the existing
tryatrender.js:788, so a layout that throws while wrapping the boundary is caught by the existingcatch (nested)at:816and the loop moves one boundary outward. Because a boundary at segmentEwraps only the ancestor-or-self chain ofE, moving outward strictly shrinks the wrapped set, so the walk converges on a boundary whose whole chain renders. If the throwing layout is above every boundary (the root layout), every attempt fails and control reachesglobal-error, which is exactly where Next.js sends a root-layout failure.Control flow that makes an infinite fallthrough impossible:
for (let i = route.errors.length - 1; i >= 0; i--)is a bounded descending index. Each iteration is onetry/catchand never re-entersssrPage.wrapLayoutChainwalks a finite array with no recursion and no retry.global-errorgets ONE attempt, unwrapped (decision 3).Total module evaluations on the error path are bounded by
|route.errors| * (1 + |wrapLayouts|) + 1. There is no path that re-enters the ladder from inside itself.The 404 / 403 / 401 paths are not loops (they render one nearest boundary), so they get a two-attempt ladder instead: wrapped, then unwrapped on a throw, then the default heading. Also bounded, also non-recursive.
Note the behaviour change this brings, and it is a fix rather than a regression: today a throwing layout renders the error boundary at its own segment (measured above on
/badlayout), which is a boundary nested inside the failure. After this change that attempt throws, and the next boundary OUT renders, matching Next.Decision 3:
global-errorstays exemptUnchanged, returned verbatim at
render.js:820-836. Three reasons, any one sufficient:<!doctype><html><body>. AGENTS.md invariant 8 says only the root layout may write a shell, and aglobal-errorinside the root layout would produce two nested shells.errorboundary is exhausted, which per decision 2 is the state a root-layout failure lands in. Wrapping it in the root layout would re-run the exact code that threw.global-erroris correct.global-not-foundstays exempt for the same reason plus decision 5.Decision 4: double-running layouts is accepted, not optimised away
The happy path already ran the layout chain for the request that threw. The boundary path re-runs the ancestor-or-self chain. Accept the cost.
Measured on the probe app (warm, 300 iterations each, prod mode): a
/docsrender of the root layout plus thedocslayout plus the page is 0.913ms, and the/docs/crashboundary render with no chain at all is 0.702ms. The entire two-layout chain costs about 0.21ms. This is a response that is already a failure, on a path that by definition is not hot.Reuse was considered and rejected.
renderChainbuilds ONE nested tree in which the children hole holds the page tree, so there is no per-layout HTML lying around to splice a boundary into. Producing one would mean rendering each layout separately against a placeholder and re-joining the strings, which moves the happy path's byte output and every ETag with it, for a payoff of 0.2ms on the error path. Next.js re-renders from the boundary's parent on the error path too, so this is also the prior-art behaviour.The consequence to write down rather than hide: a layout that fetches will fetch twice on a boundary response. Documented in the docs-site error-handling page (see Docs).
Decision 5: the ctx a wrapped layout receives
A layout receives the same ctx shape as on the happy path,
{ params, searchParams, url, actionData, children }, built by one newboundaryCtx(params, url, actionData)helper inrender.jsusing the already-importedmakeThenable. Where it comes from, per entry point:ssrPagecatch (500, and thenotFound()/forbidden()/unauthorized()branches atrender.js:756-767)ctxalready in scope atrender.js:445-458. Pass it down.ssrForbidden/ssrUnauthorizedfromform-dispatch.js:569-570runFormAction(route, params, url, req, ssrOpts, deps)holdsparamsandurl. Add them to the opts object it already spreads.ssrNotFoundfromform-dispatch.js:565route, which that call site currently does not pass at all.ssrNotFoundfromdev/serve.js:498wrapInDocument, no markers. A hard load into a 404 for an unrouted URL is correct, because there is no shared shell to swap into.ssrBoundaryHtmlcurrently rendersmod.default({})(render.js:353) andssrNotFoundHtmlthe same (:372). Since a real ctx has to be built anyway for the layouts, pass it to the boundary module as well, soerror,not-found,forbidden, andunauthorizedall receive one uniform ctx. Strictly additive (nothing today reads a prop that disappears), and WebJs has no users, so a shim for the empty-object shape would be dead weight.Decision 6: streaming
Out of scope, and unreachable from this code path.
streamingHtmlResponseis constructed atrender.js:694, INSIDE thetry. Once it returns, the catch at:746can no longer fire, so every throw this catch sees is pre-flush. A rejection inside a deferred Suspense boundary after the shell flushed is handled by the streaming machinery, never here.The boundary render itself stays buffered and passes NO
suspenseCtx, exactly as it does today. Perpackages/core/src/render-server/stream.js:13-18, aSuspenseboundary with no ctx emits its fallback and drops the promise, so a<webjs-suspense>inside a wrapped layout renders its fallback on a boundary response and never resolves. That is a known, bounded limitation, written into the docs rather than papered over. Streaming a boundary would mean the status and headers were committed before the boundary was known to render, which is a worse trade on a 500.Decision 7: the HTML cache and
X-Webjs-Havecannot be reachedNeither can leak onto the new path, and each is gated by code that already exists:
isCacheableResponse(packages/server/src/html-cache.js:232-238) opens withif (res.status !== 200) return false;, so a 500 / 404 / 403 / 401 can never be stored. Independently, theHTML_CACHE_MARKERthat flags a response for the funnel is set atrender.js:742-744, inside thetry, after the successful response was built. The catch never reaches it, and every boundary response is built byhtmlResponse(...)which does not set it.X-Webjs-Have. The reduced-fragment short-circuit lives only in the layout loop'shavebranch (render.js:317-327).wrapLayoutChainis called from the boundary path withhavepassed asnull, which is structural rather than conventional: with no map there is no branch to take. A boundary response therefore can never be reduced, never getsVary: X-Webjs-Have, and never callsprivateFragment. Belt and braces,cacheEligibleatrender.js:414-418already excludes any request carrying the header from the cache entirely.What is deliberately not changed
The client router. It behaves correctly today: degrading on a genuinely disjoint scan is the #1015 contract, and the fix is to stop producing disjoint responses.
Implementation plan
Ordered. Every step names the file, the function, and the line anchor at
105372de.packages/is plain.jswith JSDoc, so no.tsfile is added anywhere.Step 1: two segment-path helpers in
packages/server/src/ssr/document.jsAdd after
pageSegmentPath(which ends at:49), so the three derivations sit together, and export both from thessr.jsbarrel'sdocument.jsblock alongside_pageSegmentPath.Step 2: lift the layout loop out of
renderChain(render.js:312-336)This is a MOVE, not a rewrite. Today the loop's short-circuit branch and its fall-through end do the identical two lines (
renderToString, then+ loadingTemplates), differing only in thereducedflag, so the branch collapses to abreakand the wrapping becomes reusable with no behaviour change. Today:After, as a new module-level function placed directly above
renderChain:and
renderChain's tail becomes:Keep the whole explanatory comment block at
render.js:296-311onrenderChainat the call site; move only the per-branch comments with their code. The happy path's bytes must be unchanged, which is what the differential and the existing keyed-boundary tests assert.Step 3: the boundary renderer, in
render.jsNew function beside
wrapLayoutChain. It is the whole fix.Worked example, the shape most likely to be got wrong. Layouts
/and/docs/crash, no/docslayout, boundary atapp/docs/error.ts. Happy path regions are/and/docs/crash(the page marker is skipped, sincepageSeg === '/docs/crash'equals the innermost layout's segment). Boundary path wraps only/, soinnermostSegis/, the page marker IS emitted, and the regions are/and/docs/crash. Same ids, same keys.Step 4: use it in the
error.{js,ts}loop (render.js:787-819)One line changes inside the existing
try. Before:After:
Nothing else in the loop moves. The
catch (nested)at:816-818keeps its comment and gains one sentence noting it now also absorbs a throwing wrapped layout.Step 5: use it in the 404 / 403 / 401 renderers (
render.js:348-384)ssrBoundaryHtmlandssrNotFoundHtmlare the same shape, so both take the same two-attempt ladder.ssrBoundaryHtmltoday:After:
ssrNotFoundHtmltakes the identical edit against its'<h1>404: Not found</h1>'default.Add the ctx builder above them:
Then thread
route+paramsat the four call sites:render.js:760, thenotFound()branch ofssrPage's catch.ssrNotFoundHtml(nearest(route.notFounds) || opts.globalNotFound || null, opts)becomesssrNotFoundHtml(..., { ...opts, route, ctx }), and both renderers prefer a suppliedopts.ctxover building one, so the page render's ctx (with its realactionData) is reused. Noteopts.globalNotFoundis a ROOT-only boundary whoseboundarySegmentPathis/, so it wraps in the root layout only, which is right.render.js:766-767,ssrForbidden(route, { ...opts, url })/ssrUnauthorized(...)become{ ...opts, url, route, ctx }. Both exported wrappers at:871-885passoptsstraight through tossrBoundaryHtml, so no signature changes there.form-dispatch.js:569-570, addrouteandparams:ssrForbidden(route, { ...ssrOpts, req, url, route, params }).runFormAction's signature (form-dispatch.js:418) already carriesparams.form-dispatch.js:565,ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url })gainsroute, paramsthe same way.dev/serve.js:498is left alone. No route matched, soopts.routeis undefined and the ternary in step 5 renders standalone, byte-identical to today.Step 6: make the boot module set match what rendered (
render.js:800-813)Today
errModuleUrlsis built from[route.file, ...route.layouts], so a boundary response ships the PAGE module (which never rendered) and every layout (including ones deeper than the boundary, which also never rendered), and does NOT ship the boundary module itself (the one thing that did render), so a component the boundary renders never upgrades.Change the source list to
[boundaryFile, ...wrapLayouts], keeping the #963 substitution loop exactly as it is:The substitution must stay, for the reason its comment at
:793-799already gives. It applies to the boundary file harmlessly:collectRouteModules(packages/server/src/dev/helpers.js:268) feeds only page and layout files to the elision analysis, so anerror.{js,ts}is never inert and never import-only, gets no verdict, and is pushed as-is. That is the documented contract (serverAGENTS.md, package invariant 7), not an accident.Compute
wrapLayoutsONCE per iteration and share it with therenderBoundaryInChaincall from step 4, rather than deriving it twice.The same treatment is NOT applied to
ssrBoundaryHtml/ssrNotFoundHtml, which passmoduleUrls: []today (render.js:361,:380). Those pages ship no JS at all, and giving them a boot script is a separate behaviour change with its own blast radius. Left as is, and named under Out of scope.Step 7:
global-errorand the default 500 page are untouchedrender.js:820-836and:837-849keep their current code verbatim, per decision 3. Add one sentence to theglobal-errorcomment recording that the exemption is deliberate now that everything around it wraps.Tests
npm testruns none of the browser, e2e, or Bun layers. Run them yourself and report the result. Rebuildpackages/core/distbefore e2e or Bun, or apackages/core/srcedit is invisible to them and a counterfactual passes vacuously.Unit and integration (extend, do not create)
packages/server/test/routing/global-boundaries.test.js(Close Next.js 16 file-routing parity gaps (async params, forbidden/unauthorized, global-error/not-found, instrumentation) #848 nestednot-foundnearest-wins plus the root-only boundaries). Add: a 404 from anotFound()throw carries matched keyed pairs for the layouts at and above thenot-foundfile's segment, and the layout's own markup is in the body. Add the root-only case: aglobal-not-foundwraps in the root layout only.packages/server/test/routing/forbidden-unauthorized.test.js(the 403 / 401 pipeline,makeApppluscreateRequestHandler). Add: both statuses carry matched pairs; the nearest-wins selection is unchanged; aforbidden.tsnow receives a ctx withparamspopulated.test/ssr/ssr.test.js, thessrPage: redirect / notFound / error boundariesblock at line 1574 (cases at 1617, 1627, 1650, 1677, 1699). This is the primary surface. Add:app/docs/error.tsemits<!--wj:children:/:/-->and<!--wj:children:/docs:/docs-->with their matched closers, and the page-region pair for/docs/crash;/badlayoutshape measured above), and the response is a 500 with no hang;error.tsreachesglobal-error, whose document is still returned verbatim with no markers and no boot script;Note the load-bearing comment already at
test/ssr/ssr.test.js:1035-1036("The shell wraps the boundary, not the other way around"), which describes the layout's own markup sitting outside its children range. Assertions here must not contradict it.packages/server/test/ssr/region-route-key.test.jsforboundarySegmentPathandlayoutsForBoundaryas pure functions: root, nested, a(group)segment kept, and the/docvs/docsprefix trap.Counterfactual
Revert step 4's one line (
renderBoundaryInChainback torenderToString) and assertions 1, 3, and 7 above must fail, plus the e2e soft-navigation assertion below. That is the specific check that the test fires on the mechanism rather than on an incidental. Assertion 5 is its own counterfactual for decision 2: with wrapping reverted it passes vacuously, which is why it is stated as "renders the boundary OUTSIDE the throwing layout's segment" rather than merely "returns 500".Browser
packages/core/test/routing/browser/already owns the swap side. No change is needed there: the router is not being modified, and the browser suite's boundary-scan coverage (nav-guard.test.js:205,refresh-page.test.js:276) asserts the degradation path, which must keep working for genuinely disjoint responses. Run the suite to prove it is unaffected; do not add a case.E2E (the headline is browser-observable, so this layer is mandatory)
test/e2e/dev-overlay-nav.test.mjs(added in PR #1294, 250 lines, still present at HEAD). Correction to the previous version of this issue: that file does not contain a comment documenting this hard-load limitation. What it has is a/crashtest at lines 145-147 that reaches the page with a plainpage.goto(a hard document load) and no comment saying why, and a hover test at 217-224 that only PREFETCHES/crashso the user never leaves/good. The suite never soft-navigates into/crashat all, and that gap is silent. So the work here is to close the gap and write down the reason it existed, not to correct a wrong comment.Its fixture at
test/e2e/fixtures/dev-overlay-app/has a throwing page and an interactive layout but noerror.ts, so addapp/error.ts(orapp/crash/error.ts, which exercises the nested case) to the fixture. Then add a case that:/goodwith the client router live (the file's existing helper at lines 106-111 is the idiom);/crash;window.__wjCtxsurvival technique the file already uses at 169-171 and 181-184;webjs:navigation-fallbackfired, via the sharedtest/browser-nav-guard.jshelper;Also relevant and worth reading for the assertion style:
test/e2e/nested-layout-partial-swap.test.mjs(sidenav DOM identity plus scroll preserved) andtest/e2e/e2e.test.mjs:320-358(thewebjs:navigation-fallbackcause assertion).Bun parity (mandatory)
SSR dispatch is a runtime-sensitive surface, so this is part of the task, not an afterthought. Two files:
test/bun/routing-boundaries.mjs, the existing cross-runtimebuildRouteTableproof for Close Next.js 16 file-routing parity gaps (async params, forbidden/unauthorized, global-error/not-found, instrumentation) #848. Extend it with the pureboundarySegmentPath/layoutsForBoundaryderivation, since it already owns the boundary-chain shape. It currently has no.test.mjswrapper, sonpm testnever runs it on Node. Addtest/bun/routing-boundaries.test.mjsfollowing the sibling pattern documented intest/bun/keyed-boundaries.test.mjs:1-9, or the extension is invisible on Node.test/bun/ssr-boundary-chain.mjsplustest/bun/ssr-boundary-chain.test.mjs(new, named after its siblingtest/bun/keyed-boundaries.mjs, which proves the happy-pathwj:childrenemission cross-runtime). It renders a 500 and a 403 through the real handler on both runtimes and asserts the markers match and pair. This is the assertion that would catch a runtime-specific divergence in the boundary path, which is exactly whatkeyed-boundaries.mjsdoes for the happy path.Run with
node scripts/run-bun-tests.jsplus the touched files underbun.Smoke
test/examples/*/smoke/*does not apply: no example app is being changed, and bothexamples/blogandwebsitealready shipapp/error.tsplusapp/not-found.ts, so they are covered incidentally by CI booting them. The elision and preload suites (packages/server/test/elision/,packages/server/test/ssr/dropped-page-app-preload.test.js) ARE in the blast radius because step 6 changes the boot module set on a boundary response. Run them and expect any failure there to be a real signal, not a fixture update.Convention gates
Run
webjs checkfrom inside each in-repo app (( cd gallery && npx webjs check ), and the same forexamples/blogandwebsite), pluswebjs doctorfor all three, since the requiredconventionsCI job runs doctor over them and a clean check alone does not predict it (#1257).gallery/app/features/boundaries/already carrieserror.ts,not-found.ts,gated/forbidden.ts, andprivate/unauthorized.ts, so the gallery is a live end-to-end check of all four boundary kinds against a real layout chain.Docs
Every surface below exists and was verified at
105372de. The gap is uniform: not one of them says whether a rendered boundary is wrapped by its layouts, so each needs the same fact added, not a correction.website/app/docs/error-handling/page.ts. Line 30 currently reads that the nearesterror.ts"is rendered instead", which is silent on layout wrapping. Add: the boundary renders INSIDE the layouts at and above its own segment, so the site chrome stays on screen and a client-router navigation into a failing page stays a soft navigation. Add the two consequences from decisions 4 and 6: a layout that fetches runs its fetch again on a boundary response, and a<webjs-suspense>inside a wrapped layout shows its fallback because a boundary response is buffered. Line 92'sglobal-errorparagraph gains the reason it is exempt (its own document, and it fires when the root layout failed).website/app/docs/routing/page.ts, theError Boundaries (error.ts)section at lines 417-425 and the context note at 449-454. Same sentence about layout wrapping, plus the Next-parity rule that a boundary does not catch its own segment's layout, so an error in a layout is handled by the next boundary out..agents/skills/webjs/references/routing-and-pages.md, lines 212-215. Extend theerror.tsline and thenot-found/forbidden/unauthorizedline with the wrapping rule and the ctx they now receive. Line 215'sglobal-errorsentence already states the verbatim-document behaviour; add that this is why it alone is unwrapped.AGENTS.md, theError / loading / metadata routessection at line 341-343. One sentence: a boundary renders inside the layouts at and above its own segment and therefore carries the keyedwj:childrenpairs, so navigating into one is a soft navigation;global-erroris the sole exception because it returns its own document.packages/cli/templates/.agents/skills/webjs/references/routing-and-pages.md. Correction to the previous version of this issue:find packages -name routing-and-pages.mdreturns nothing, andpackages/cli/templates/holds onlyAGENTS.md,CLAUDE.md,CONVENTIONS.md,compose.yaml,Dockerfile,gitignore,instrumentation.ts,partials/,public/, andscripts/. The scaffold-side edit therefore lands inpackages/cli/templates/AGENTS.md(andpartials/agents-playbook-fullstack.mdif it discusses boundaries), mirroring the rootAGENTS.mdsentence.gallery/app/features/boundaries/. The demo's own explanatory copy should state that the surrounding chrome survives, since the gallery is read as reference. A one-line change in the demo page's prose, folded into this PR.The doc gate (
.claude/hooks/require-docs-with-src.sh) is satisfied by the above.WEBJS_NO_DOC_GATE=1is NOT applicable here: this is a user-visible behaviour change on a documented surface.Acceptance criteria
error.{js,ts}boundary: no document reload and nowebjs:navigation-fallback<!--wj:children:…-->pairs, and its segment-id set equals the set a 200 for the same URL emitsnot-found,forbidden, andunauthorizedwhen a route matchedglobal-error, which still returns its own document verbatim with no importmap and no boot scriptX-Webjs-Havefragmenttest/bun/*cross-runtime assertion, andtest/bun/routing-boundaries.test.mjsexists so the Bun proof also runs on Nodewebjs checkandwebjs doctorare clean ingallery,examples/blog, andwebsitetest/e2e/dev-overlay-nav.test.mjsgains a soft-navigation-into-/crashcase and its fixture gains anerror.tsOut of scope
packages/core/src/router-client/.global-errorandglobal-not-found. Decided exempt (decision 3). Do not wrap them.moduleUrls: []today and keep doing so. Making those pages interactive is a separate behaviour change with its own elision and preload blast radius.suspenseCtxinto the boundary path.layoutSegmentPath/pageSegmentPath/loadingSegmentPathinto one generalised helper. Tempting on DRY grounds and not worth the byte risk on the happy path; addboundarySegmentPathbeside them instead.