Skip to content

Commit e2ac6d9

Browse files
committed
fix: key boundary-report dedup on the stage, and make the key total
The fingerprint was wrong on three counts and unsafe on a fourth. It never saw the original error, so the commonest layout crash of all was reported twice: when a layout is what threw, it is both the error that produced the 500 and the thing re-run around every boundary it wraps. The set is seeded with it now. Keying on the whole stack cannot work, and neither can keying on the first frame alone. The frames below the construction site record how the throw was reached, so the same layout re-rendered around a different boundary never matches itself; the construction site alone, meanwhile, is shared by two genuinely different failures that go through one helper. Those two shapes are indistinguishable by stack, so the key carries the STAGE instead. One layout failing repeatedly inside the walk collapses to one report, while a shared helper failing in both the walk and global-error reports twice. The key could also throw. String(Object.create(null)) throws, and this runs inside the catch that keeps the response alive, ahead of the guarded sinks, so a null-prototype throw escaped ssrPage and took the 500 page with it. It is total now and returns null when no safe key exists, which means report rather than risk dropping. That also covers a non-Error throw, which used to collapse every plain object to one key and silently drop the second.
1 parent f72c16d commit e2ac6d9

3 files changed

Lines changed: 123 additions & 15 deletions

File tree

.agents/skills/webjs/references/routing-and-pages.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere
217217
- 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.
218218
- 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.
219219

220-
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, keyed on the error's name, message and throw site, because one shared layout can fail several boundary attempts; two DIFFERENT failures are both reported, including `global-error`'s own. A control-flow sentinel never is, since it is routing rather than a crash.
220+
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.
221221

222222
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.
223223
- 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.

packages/server/src/ssr/render.js

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -416,21 +416,49 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
416416

417417
/**
418418
* A per-request dedup key for a secondary boundary failure: the error's name,
419-
* message and throw site. Identity cannot be used, because a layout that
419+
* message and FULL stack. Identity cannot be used, because a layout that
420420
* constructs its error yields a fresh object each time it is re-run, and the
421421
* whole point is to collapse exactly those repeats while letting a genuinely
422-
* different failure through. The first stack frame is the throw site, so two
423-
* different layouts raising the same message still read as distinct causes.
422+
* different failure through.
423+
*
424+
* The CONSTRUCTION SITE (the first stack frame), not the whole stack. The
425+
* frames below it record how the throw was reached, and the same layout
426+
* re-rendered around a different boundary is reached differently every time,
427+
* so a full-stack key would never collapse the repeats it exists for.
428+
*
429+
* That leaves the construction site shared by two genuinely different
430+
* failures that go through one helper, which is why the caller's STAGE is part
431+
* of the key rather than the error alone: the boundary walk and the
432+
* `global-error` attempt are different stages, so a shared helper failing in
433+
* both is reported twice, while one layout failing repeatedly INSIDE the walk
434+
* is reported once. The stage is what distinguishes them; the stack cannot.
435+
*
436+
* Returns null when no safe key can be derived, which means DO NOT DEDUPE.
437+
* Failing open is the only acceptable direction here: a duplicate report is
438+
* noise, a dropped one is a crash nobody hears about. That covers a non-Error
439+
* throw (two unrelated plain objects would otherwise share `[object Object]`)
440+
* and anything whose property access or stringification throws, which is a
441+
* real shape: `String(Object.create(null))` throws, and this runs INSIDE the
442+
* catch that is supposed to keep the response alive, so a throw here would
443+
* escape `ssrPage` and take the 500 page with it.
424444
*
425445
* @param {unknown} err
426-
* @returns {string}
446+
* @returns {string | null}
427447
*/
428-
function boundaryErrorKey(err) {
429-
if (!(err instanceof Error)) return `raw:${String(err)}`;
430-
const site = String(err.stack || '').split('\n')[1] || '';
431-
return `${err.name}:${err.message}:${site.trim()}`;
448+
function boundaryErrorKey(err, stage) {
449+
if (!(err instanceof Error)) return null;
450+
try {
451+
const site = String(err.stack || '').split('\n')[1] || '';
452+
return `${stage}\u0000${String(err.name)}:${String(err.message)}:${site.trim()}`;
453+
} catch {
454+
return null;
455+
}
432456
}
433457

458+
/** Stage labels, which are part of the dedup key (see boundaryErrorKey). */
459+
const STAGE_WALK = 'an error boundary or its layout threw while handling a render error';
460+
const STAGE_GLOBAL_ERROR = 'global-error threw while handling a render error';
461+
434462
/**
435463
* Report a throw from a layout wrapped around a boundary page (#1298) to the
436464
* same sinks a page-render error reaches, then let the caller degrade to the
@@ -484,9 +512,13 @@ function boundaryErrorKey(err) {
484512
function reportBoundaryLayoutError(err, opts, cfg = {}) {
485513
if (isRedirect(err) || isNotFound(err) || isForbidden(err) || isUnauthorized(err)) return;
486514
if (cfg.seen) {
487-
const key = boundaryErrorKey(err);
488-
if (cfg.seen.has(key)) return;
489-
cfg.seen.add(key);
515+
const key = boundaryErrorKey(err, cfg.what || '');
516+
// A null key means no safe key could be derived, so report rather than
517+
// risk dropping. See boundaryErrorKey.
518+
if (key !== null) {
519+
if (cfg.seen.has(key)) return;
520+
cfg.seen.add(key);
521+
}
490522
}
491523
const what = cfg.what || 'a layout threw while wrapping a boundary page';
492524
if (typeof opts.onError === 'function') {
@@ -1099,7 +1131,17 @@ export async function ssrPage(route, params, url, opts) {
10991131
// global-error attempt after it collapse REPEATS of the same cause (a
11001132
// shared layout that fails every attempt) while still reporting a
11011133
// genuinely different failure, including global-error's own.
1134+
//
1135+
// SEEDED with the original error, which was just reported above. When a
1136+
// LAYOUT is what threw, the walk re-runs that same layout around each
1137+
// boundary it wraps (the root layout wraps every boundary), so the cause
1138+
// that produced this 500 is about to arrive again as a secondary failure.
1139+
// Without the seed the commonest layout crash of all is reported twice.
11021140
const secondary = new Set();
1141+
{
1142+
const originalKey = boundaryErrorKey(err, STAGE_WALK);
1143+
if (originalKey !== null) secondary.add(originalKey);
1144+
}
11031145
// Try nearest error.js (innermost → outermost).
11041146
for (let i = route.errors.length - 1; i >= 0; i--) {
11051147
try {
@@ -1151,7 +1193,7 @@ export async function ssrPage(route, params, url, opts) {
11511193
// filtered out by the reporter, since a boundary throwing notFound() is
11521194
// a routing decision rather than a crash.
11531195
reportBoundaryLayoutError(nested, opts, {
1154-
what: 'an error boundary or its layout threw while handling a render error',
1196+
what: STAGE_WALK,
11551197
seen: secondary,
11561198
overlay: false,
11571199
});
@@ -1182,7 +1224,7 @@ export async function ssrPage(route, params, url, opts) {
11821224
// the original error, with nothing naming the boundary as the thing
11831225
// that failed.
11841226
reportBoundaryLayoutError(nested, opts, {
1185-
what: 'global-error threw while handling a render error',
1227+
what: STAGE_GLOBAL_ERROR,
11861228
seen: secondary,
11871229
overlay: false,
11881230
});

test/ssr/ssr.test.js

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2186,7 +2186,7 @@ test('boundary: one throwing layout is reported ONCE, not once per boundary', as
21862186
// The walk tries each boundary in the chain, and layoutsForBoundary selects
21872187
// by segment ancestry rather than boundary depth, so a root layout that
21882188
// throws fails EVERY attempt. Identity dedup would not help: a layout that
2189-
// constructs its error yields a fresh object per attempt. Hence the latch.
2189+
// constructs its error yields a fresh object per attempt, so the key is its name, message and stack.
21902190
const { route, appDir } = makeBoundaryApp({
21912191
files: {
21922192
'layout.js': `export default function Root() { throw new Error('root-layout-boom'); }\n`,
@@ -2291,6 +2291,72 @@ test('boundary: dedup collapses REPEATS of one cause, not distinct failures', as
22912291
assert.ok(messages.includes('shared-layout-boom'), 'and the LATER, unrelated layout crash is not swallowed by it');
22922292
});
22932293

2294+
test('boundary: a LAYOUT crash that produced the 500 is reported once, not twice', async () => {
2295+
// The commonest shape of all, and the one the dedup set has to be SEEDED for.
2296+
// When the layout is what threw, it becomes the original error AND is re-run
2297+
// around every boundary it wraps, so the same cause arrives twice.
2298+
const { route, appDir } = makeBoundaryApp({
2299+
files: {
2300+
'layout.js': `export default function Root() { throw new Error('only-layout-boom'); }\n`,
2301+
'error.js': HTML_IMPORT + `export default function Err() { return html\`<p id="b">boundary</p>\`; }\n`,
2302+
// The page renders fine: the LAYOUT is the sole failure.
2303+
'page.js': HTML_IMPORT + `export default function Page() { return html\`<p>ok</p>\`; }\n`,
2304+
},
2305+
page: 'page.js',
2306+
layouts: ['layout.js'],
2307+
errors: ['error.js'],
2308+
});
2309+
const seen = [];
2310+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/'), {
2311+
dev: false, appDir, onError: (e) => seen.push(e),
2312+
}));
2313+
assert.equal(resp.status, 500);
2314+
const hits = seen.filter((e) => /only-layout-boom/.test(String(e && e.message)));
2315+
assert.equal(hits.length, 1, 'reported once, not once as the original and again as a secondary');
2316+
});
2317+
2318+
test('boundary: a throw whose value cannot be stringified still degrades, never escapes', async () => {
2319+
// The dedup key runs INSIDE the catch that keeps the response alive, so it
2320+
// must be total. `String(Object.create(null))` throws, and a key computation
2321+
// that throws would take ssrPage's 500 page down with it.
2322+
const { route, appDir } = makeBoundaryApp({
2323+
files: {
2324+
'error.js': `export default function Err() { throw Object.create(null); }\n`,
2325+
'page.js': `export default function Page() { throw Object.create(null); }\n`,
2326+
},
2327+
page: 'page.js',
2328+
layouts: [],
2329+
errors: ['error.js'],
2330+
});
2331+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir }));
2332+
assert.equal(resp.status, 500, 'it degraded to the default 500 rather than throwing out of ssrPage');
2333+
assert.ok((await resp.text()).includes('Something went wrong'));
2334+
});
2335+
2336+
test('boundary: two boundaries failing through ONE shared helper are both reported', async () => {
2337+
// The key is the whole stack, not just the frame where the Error was
2338+
// constructed. Keying on that one frame would make these two collide, and
2339+
// global-error's own crash would be swallowed by the earlier boundary's.
2340+
const { route, appDir } = makeBoundaryApp({
2341+
files: {
2342+
'boom.js': `export function boom() { throw new Error('shared-helper-boom'); }\n`,
2343+
'error.js': `import { boom } from './boom.js';\nexport default function Err() { boom(); }\n`,
2344+
'global-error.js': `import { boom } from './boom.js';\nexport default function GE() { boom(); }\n`,
2345+
'page.js': `export default function Page() { throw new Error('page-boom'); }\n`,
2346+
},
2347+
page: 'page.js',
2348+
layouts: [],
2349+
errors: ['error.js'],
2350+
});
2351+
const seen = [];
2352+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/'), {
2353+
dev: false, appDir, globalError: join(appDir, 'global-error.js'), onError: (e) => seen.push(e),
2354+
}));
2355+
assert.equal(resp.status, 500);
2356+
const hits = seen.filter((e) => /shared-helper-boom/.test(String(e && e.message)));
2357+
assert.equal(hits.length, 2, 'the boundary and global-error each report, despite one construction site');
2358+
});
2359+
22942360
test('boundary: a boundary response is never storable and never reduced', async () => {
22952361
// Two independent guarantees. The HTML cache refuses a non-200 outright, and
22962362
// the reduced X-Webjs-Have path is structurally unreachable: the boundary

0 commit comments

Comments
 (0)