Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4ee36d4
fix: render error boundaries inside their layout chain
vivek7405 Aug 14, 2026
5760cc3
test: prove a nav into a throwing page is a soft navigation
vivek7405 Aug 14, 2026
bf00e9a
test: raise the ssr barrel floor for the two boundary helpers
vivek7405 Aug 14, 2026
870d45e
test: narrow the error-boot assertion to what actually rendered
vivek7405 Aug 14, 2026
170310a
fix: never advertise a layout region the boundary did not render
vivek7405 Aug 14, 2026
f9b062c
chore: drop verification scratch apps swept in by git add -A
vivek7405 Aug 14, 2026
fc2fce3
fix: close three gaps opened by shipping the boundary boot set
vivek7405 Aug 14, 2026
7b26242
fix: do not report a control-flow throw as a crash, and stop losing t…
vivek7405 Aug 14, 2026
05991db
fix: report a boundary-path failure once, and never over the root cause
vivek7405 Aug 14, 2026
f72c16d
fix: dedupe boundary reports by cause, not by "one per request"
vivek7405 Aug 14, 2026
e2ac6d9
fix: key boundary-report dedup on the stage, and make the key total
vivek7405 Aug 14, 2026
7b24eec
fix: stop a boundary crash leaking its message, and formatting one fr…
vivek7405 Aug 14, 2026
1fe89dc
test: assert the 404 fallback body is dev-only
vivek7405 Aug 14, 2026
3afb2d7
fix: report a boundary module that throws or fails to load
vivek7405 Aug 14, 2026
0af5cf8
fix: report a boundary tree failure once, and wire the unrouted 404 s…
vivek7405 Aug 14, 2026
c0c978c
fix: record the boundary failure phase instead of inferring it
vivek7405 Aug 14, 2026
641dca1
fix: diagnose a boundary failure by the fallback, not the marker alone
vivek7405 Aug 14, 2026
fdf0e4c
fix: do not treat a failed prefetch as a recovery, or re-render a cha…
vivek7405 Aug 14, 2026
0ca2e52
fix: make the tree-failure fixtures runtime-agnostic, and skip a poin…
vivek7405 Aug 14, 2026
f6cf4a5
fix: wrap the DEFAULT boundary page, and use nearest-wins on the form…
vivek7405 Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .agents/skills/webjs/references/routing-and-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,18 @@ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere

- `error.ts` default-exports `({ error, ...ctx }) => TemplateResult`; catches sibling-page and deeper render errors, innermost wins (prod sends only `error.message`).
- `loading.ts` wraps the sibling page in `Suspense` with an immediately-flushed fallback.
- `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal.
- Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
- `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal, and receive the same ctx a page does (`params`, `searchParams`, `url`).
- **Every one of those boundaries renders INSIDE the layouts at and above its own segment** (#1298), so it carries the keyed `wj:children` pairs and a client-router navigation into a failing page stays a SOFT navigation with the surrounding chrome and its hydrated state intact. A layout deeper than the boundary is not rendered (it never rendered on the way in), and its module is not in the boundary's boot script; the boundary's own module IS. Since a boundary sits inside its own segment's layout, it cannot catch that layout, matching Next's `layout -> error -> page` hierarchy. What happens next differs by path, and the difference is worth knowing:

- On the **500 path**, a throwing layout is handled by the next `error.{js,ts}` OUT: the walk tries each boundary in the chain, innermost first, and a layout that throws fails every attempt whose wrapped set contains it, so control ends at `global-error` (or the default 500 page) when they are exhausted.
- On the **404 / 403 / 401 paths** there is NO outward walk. Each renders the one nearest boundary, so a throwing layout degrades that response to a chrome-less standalone render of the boundary with no boot script, keeping its status. A control-flow throw from a wrapped layout there (an auth-gate layout calling `redirect('/login')`) is discarded rather than honoured, deliberately: the status is already decided and the boundary page is the answer to that request.

A genuine layout crash is reported to `onError` (and to the dev overlay on the 404 / 403 / 401 paths, where nothing else claimed the frame) rather than being swallowed. Repeats of the SAME cause within one request collapse to a single report, because one shared layout can fail every boundary attempt (and, when the layout is what threw, arrives again as the error that produced the 500). The key is the STAGE plus the error's name, message and construction site: the stack below that site records how the throw was reached and differs on every re-render, so it cannot be part of the key, and the stage is what keeps `global-error`'s own crash from being swallowed by a boundary that failed through the same helper. Two DIFFERENT failures are both reported, and anything whose key cannot be derived safely (a non-Error throw) is always reported rather than risking a drop. A control-flow sentinel never is, since it is routing rather than a crash.

The BOUNDARY FILE's own crash (it throws, or fails to import at all) is reported the same way, and its response body follows the framework's standard rule for a thrown error: shown in dev, withheld in prod, where the page carries only its status. A thrown message is not author-controlled and may name a driver, a path or a connection string, so it does not reach the client; sanitizing the response never means losing the failure.

Two further consequences: 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 so its status is final before the first byte. A 404 for a URL that matched NO route has no chain to wrap in and stays a bare document.
- Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). That verbatim document is exactly why it is the one boundary left UNWRAPPED: a second shell would nest inside the root layout's, wrapping it would re-run the code that just threw, and with no boot script it could not soft-swap anyway. `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.

Metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `apple-icon.ts`, `opengraph-image.ts`, `twitter-image.ts`) live at app root or static segments and default-export a possibly-async function; `sitemap()` / `sitemapIndex()` from `@webjsdev/server` serialize spec-valid XML.

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ Default export receives `{ children, params, searchParams, url }`, must embed `c

### Error / loading / metadata routes

`error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<script>` in it must carry the nonce via `cspNonce()` (an inline `<style>` needs one only if you tighten `style-src`, since the default allows inline style outright). `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML. **`icon` / `apple-icon` are auto-LINKED** into the head (`<link rel="icon" href="/icon">`, `<link rel="apple-touch-icon" href="/apple-icon">`, base-path prefixed, no asserted `type` / `sizes` since the route picks its content type at request time), so writing the file is the whole wiring. A declared `metadata.icons` **suppresses** them rather than merging, matching Next's precedence for its static icon files, so an app that outgrows a placeholder route names its real icons instead of deleting it. `opengraph-image` / `twitter-image` are NOT auto-linked (a preview image is a per-page editorial choice): point `metadata.openGraph.images` / `metadata.twitter.images` at them. Declare a favicon through `metadata.icons` or a metadata route, never a hand-written `<link rel="icon">`, since only the root layout may write a shell at all (invariant 8). See `references/routing-and-pages.md`.
`error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). **A boundary renders INSIDE the layouts at and above its own segment** (#1298), so it carries the keyed `wj:children` pairs and navigating into a failing page is a SOFT navigation with the site chrome and its hydrated state intact; the same holds for `not-found` / `forbidden` / `unauthorized`, which now receive a page-shaped ctx too. A layout deeper than the boundary is not rendered and is not in the boot script, which ships the boundary module instead of the page's. A boundary cannot catch its OWN segment's layout (it renders inside it, Next's `layout -> error -> page` order). On the 500 path a throwing layout falls through to the next `error` boundary OUT, and to `global-error` when the chain is exhausted; the 404 / 403 / 401 paths have no outward walk, so there a throwing layout degrades that one response to a chrome-less standalone render of the boundary (status preserved, no boot script) and a control-flow throw from a wrapped layout is discarded rather than honoured, since the status is already decided. A real layout crash is reported to `onError` instead of vanishing (repeats of the SAME cause within one request collapse to one report, since a shared layout can fail several boundary attempts); a sentinel never is. A crash in the BOUNDARY FILE itself is reported too, and its body follows the standard thrown-error rule: shown in dev, withheld in prod (the page carries only its status). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). It is the sole boundary that is NOT wrapped in layouts: a second shell cannot nest inside the root layout's (invariant 8), wrapping it would re-run the code that just threw, and it ships no boot script anyway. Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<script>` in it must carry the nonce via `cspNonce()` (an inline `<style>` needs one only if you tighten `style-src`, since the default allows inline style outright). `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML. **`icon` / `apple-icon` are auto-LINKED** into the head (`<link rel="icon" href="/icon">`, `<link rel="apple-touch-icon" href="/apple-icon">`, base-path prefixed, no asserted `type` / `sizes` since the route picks its content type at request time), so writing the file is the whole wiring. A declared `metadata.icons` **suppresses** them rather than merging, matching Next's precedence for its static icon files, so an app that outgrows a placeholder route names its real icons instead of deleting it. `opengraph-image` / `twitter-image` are NOT auto-linked (a preview image is a per-page editorial choice): point `metadata.openGraph.images` / `metadata.twitter.images` at them. Declare a favicon through `metadata.icons` or a metadata route, never a hand-written `<link rel="icon">`, since only the root layout may write a shell at all (invariant 8). See `references/routing-and-pages.md`.

### Route handlers (`app/**/route.{js,ts}`)

Expand Down
11 changes: 11 additions & 0 deletions gallery/app/features/boundaries/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ export default function BoundariesExample() {
<code class="font-mono">not-found.ts</code> (404).
</li>
</ul>
<p class="text-muted-foreground text-sm">
Follow the first three links and notice what does NOT happen: the page
Comment thread
vivek7405 marked this conversation as resolved.
does not reload. A boundary renders inside the layouts at and above its
own segment, so the surrounding chrome survives and the navigation stays
a soft one. Two cases still reload, both for the same reason (there is no
shared shell to swap into): the fourth link, whose URL matches no route
at all, so there is no layout chain to render the
<code class="font-mono">not-found.ts</code> inside; and
<code class="font-mono">global-error.ts</code>, which owns its whole
document.
</p>
<p class="text-muted-foreground text-sm">
<code class="font-mono">forbidden()</code> is for an authenticated user who
lacks permission (403); <code class="font-mono">unauthorized()</code> is for
Expand Down
17 changes: 17 additions & 0 deletions packages/server/src/check/runner-support.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,30 @@ export async function checkServerImportInBrowserModule(appDir, violations) {
for (const page of routeTable.pages || []) {
for (const f of page.errors || []) alwaysShipRouteModules.set(f, 'error boundary');
for (const f of page.loadings || []) alwaysShipRouteModules.set(f, 'loading boundary');
// forbidden / unauthorized joined this set with #1298. They used to render
// with an empty boot set, so their modules never reached the browser and
// omitting them here was harmless. They now render inside their layout
// chain AND ship, so a `forbidden.ts` doing `import { auth } from
// '#lib/auth.server.ts'` (a natural thing on a 403: "signed in as X, but
// not permitted") is a real throw-at-load crash that takes every sibling
// registration in the same module script with it.
for (const f of page.forbiddens || []) alwaysShipRouteModules.set(f, 'forbidden boundary');
for (const f of page.unauthorizeds || []) alwaysShipRouteModules.set(f, 'unauthorized boundary');
}
if (routeTable.notFound) alwaysShipRouteModules.set(routeTable.notFound, 'not-found page');
if (routeTable.notFounds) {
for (const f of routeTable.notFounds.values()) {
alwaysShipRouteModules.set(f, 'not-found page');
}
}
// global-not-found ships for the same reason: a thrown notFound() with no
// nearer boundary renders it against the matched route, so it wraps and
// boots. global-ERROR is deliberately absent: it returns its own document
// verbatim with no importmap and no boot script, so nothing of it ever
// reaches the browser.
if (routeTable.globalNotFound) {
alwaysShipRouteModules.set(routeTable.globalNotFound, 'global-not-found page');
}

// The elision flag mirrors `dev.js`: respect `webjs.elide === false` and the
// WEBJS_ELIDE override. When elision is OFF, the build ships EVERY component
Expand Down
54 changes: 53 additions & 1 deletion packages/server/src/dev/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,59 @@ export async function handleCore(req, ctx) {
}
// Unmatched anywhere: prefer the root not-found.{js,ts}, then a
// global-not-found.{js,ts} (#848), else the default 404 page.
return ssrNotFound(state.routeTable.notFound || state.routeTable.globalNotFound, { dev, appDir, req, url });
// The APM / overlay sinks ride along here too: a root not-found that throws
// or fails to load must reach them, not only the console (#1298). Built
// inline rather than reused, because the ssrOpts above is scoped to the
// matched-page branch, which this path did not take.
//
// No `route` is passed on purpose: nothing matched, so there is no layout
// chain to render the boundary inside, and the response stays a bare
// document with no boot script.
// Same supersede rule the matched-page branch follows (#1047 / #893): a
// LATER successful render of this same url clears the frame it retained, so
// an intermittently-failing not-found (a query behind it, not a source edit,
// which the rebuild already clears) cannot leave a frame that paints over a
// page that has since recovered. Scoped to this url, so a good render here
// never erases an error still current for a page the user is looking at.
const devErrorUrl = withBasePath(url.pathname, basePath()) + url.search;
// Gated on !isPrefetch as well as dev, exactly as the matched-page branch
// gates its whole supersede closure. The clear below infers "this render
// succeeded" from "the retained frame is still the one I captured", and that
// inference is only valid when a sink was installed to write a new one. A
// prefetch installs none, so a prefetch of a url that throws AGAIN would
// look like a recovery and wipe a frame that is still current, which is the
// #893 gap the retention exists to close.
const isPrefetch = req.headers.get('x-webjs-prefetch') === '1';
const before = dev && !isPrefetch ? state.lastDevError : null;
const resp = await ssrNotFound(state.routeTable.notFound || state.routeTable.globalNotFound, {
dev,
appDir,
req,
url,
onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
// The frame's url must be stamped exactly as the matched-page branch
// stamps it, or the overlay's scope gate refuses it: the browser compares
// against `location.pathname + location.search`, so dropping the query
// suppresses the overlay on any unrouted url that carries one, and
// dropping the base path suppresses it on EVERY url of a sub-path deploy
// (the ingress strip already removed the prefix from `url`, while the
// browser's location still has it). A refused frame goes to the pending
// slot and is then discarded, so the overlay simply never paints.
//
// Dropped for a speculative prefetch, on the same #1047 rule the
// matched-page branch follows: hovering a link to a broken page must not
// raise an overlay on the page you are actually on, nor become
// `state.lastDevError` for the SSE to replay into a fresh tab. Prefetch is
// on by default and fetches unrouted hrefs too, so this is reachable.
onDevError: dev && !isPrefetch
? (e) => reportDevError(e, { kind: 'render', url: devErrorUrl })
: undefined,
});
if (
before && state.lastDevError === before
&& before.kind === 'render' && before.url === devErrorUrl
) state.lastDevError = null;
return resp;
}

/** @param {Request} req @param {string} path */
Expand Down
15 changes: 12 additions & 3 deletions packages/server/src/form-dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -561,13 +561,22 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
// the SUCCESS-result path below.
return new Response(null, { status: e.status || 307, headers: { location: e.url } });
}
// `route` and `params` ride along so the boundary renders inside its
// layouts, exactly as it does on the page-render path (#1298).
if (isNotFound(err)) {
return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
// NEAREST-wins, like the page-render path (#848). `ssrOpts.notFoundFile`
// is the ROOT one only, so a nested form action throwing notFound()
// rendered the root boundary while the same throw from the page render
// rendered the nested one. Two paths, one url, different 404s.
const nearestNotFound = route.notFounds && route.notFounds.length
? route.notFounds[route.notFounds.length - 1]
: (ssrOpts.notFoundFile ?? null);
return ssrNotFound(nearestNotFound, { ...ssrOpts, req, url, route, params });
}
// forbidden()/unauthorized() from a form action render the same 403/401
// boundary as the page-render path (#848), not a generic 500.
if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url });
if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url });
if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url, route, params });
if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url, route, params });
if (typeof onError === 'function') onError(err);
return await formActionErrorResponse(err, ssrOpts.dev);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/ssr.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export { publicEnvShim } from './ssr/env-shim.js';
export {
_layoutSegmentPath,
_pageSegmentPath,
_boundarySegmentPath,
_layoutsForBoundary,
_regionRouteKey,
_wrapWithChildrenMarker,
_extractUserShell,
Expand Down
Loading
Loading