Skip to content

Commit 7b26242

Browse files
committed
fix: do not report a control-flow throw as a crash, and stop losing the 500 one
The new boundary-layout reporter treated every throw as an error, so a layout throwing redirect() while wrapping a 403 reached the app's APM sink and painted a false dev error overlay over the page the user was looking at. That is the classification the page path already makes before it reports, and it matters more here because of what the overlay lands on top of. Sentinels are filtered out now. They are still not HONOURED, which is deliberate and written down: the status is decided by the time a boundary renders and the boundary page is the answer to the request, so a layout redirect cannot replace a 403 the app asked for with a bounce it did not. The 500 path kept swallowing the same throw the commit before this one exists to stop losing. Its fallthrough to the next boundary out is right, but the sinks only ever heard the original page error, so a boundary or layout that crashed while handling it vanished. It reports on the way past now. The non-empty guard on the instrumentation prepend was unreachable and its comment described a state the function cannot be in: the boundary file is always pushed, since only pages and layouts are fed to the elision analysis.
1 parent fc2fce3 commit 7b26242

2 files changed

Lines changed: 92 additions & 9 deletions

File tree

packages/server/src/ssr/render.js

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -415,22 +415,39 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
415415
}
416416

417417
/**
418-
* Report a throw from a layout wrapped around a 403 / 401 / 404 boundary
419-
* (#1298) to the same sinks a page-render error reaches, then let the caller
420-
* degrade to the standalone render. Best-effort on both sinks: a throwing sink
421-
* must never affect the response.
418+
* Report a throw from a layout wrapped around a boundary page (#1298) to the
419+
* same sinks a page-render error reaches, then let the caller degrade to the
420+
* standalone render. Best-effort on both sinks: a throwing sink must never
421+
* affect the response.
422+
*
423+
* A CONTROL-FLOW sentinel is not an error and is never reported. This is the
424+
* same classification the page path makes before it calls `onError` (a
425+
* `redirect()` / `notFound()` / `forbidden()` / `unauthorized()` is a routing
426+
* decision, not a crash), and it matters more here: reporting one would fire
427+
* the app's APM sink and paint a false dev error overlay OVER the 403 the user
428+
* is looking at.
429+
*
430+
* The sentinel is not HONOURED either, and that is deliberate. The status is
431+
* already decided by the time a boundary renders, and the boundary page IS the
432+
* answer to this request; letting a layout redirect out of it would replace a
433+
* 403 the app asked for with a bounce the app did not, on a path where the
434+
* redirect target could itself be forbidden. So the render degrades to the
435+
* standalone boundary body, exactly as it does for a real layout crash, and
436+
* the status the boundary carries is preserved.
422437
*
423438
* @param {unknown} err
424439
* @param {{ onError?: (e: unknown) => void, onDevError?: (e: unknown) => void }} opts
440+
* @param {string} what Where the throw came from, for the log line.
425441
*/
426-
function reportBoundaryLayoutError(err, opts) {
442+
function reportBoundaryLayoutError(err, opts, what = 'a layout threw while wrapping a boundary page') {
443+
if (isRedirect(err) || isNotFound(err) || isForbidden(err) || isUnauthorized(err)) return;
427444
if (typeof opts.onError === 'function') {
428445
try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
429446
}
430447
if (typeof opts.onDevError === 'function') {
431448
try { opts.onDevError(err); } catch { /* a throwing sink must not affect the response */ }
432449
}
433-
console.error('[webjs] a layout threw while wrapping a boundary page:', err);
450+
console.error(`[webjs] ${what}:`, err);
434451
}
435452

436453
/**
@@ -475,9 +492,11 @@ function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
475492
// it has on the happy path: it runs before app modules so the app's client
476493
// error reporting is installed before anything can throw. A boundary page is
477494
// where that matters most, so it must not be the one place it is missing.
478-
// Only when the set is non-empty: an empty set means no boot script at all,
479-
// and instrumentation alone is not a reason to start emitting one.
480-
if (opts.instrumentationClient && urls.length) {
495+
// The set is never empty by the time we get here (the boundary file is
496+
// always pushed above, since only pages and layouts are fed to the elision
497+
// analysis, so a boundary is never inert and never import-only), which is
498+
// why this needs no non-empty guard.
499+
if (opts.instrumentationClient) {
481500
const u = toUrlPath(opts.instrumentationClient, opts.appDir);
482501
const i = urls.indexOf(u);
483502
if (i !== -1) urls.splice(i, 1);
@@ -1070,6 +1089,15 @@ export async function ssrPage(route, params, url, opts) {
10701089
// absorbs a throw from a WRAPPED LAYOUT, which is the case that makes a
10711090
// throwing layout render the boundary outside it (Next parity: a
10721091
// boundary sits inside its own segment's layout, so it cannot catch it).
1092+
//
1093+
// Report it on the way past. The fallthrough is the right BEHAVIOUR,
1094+
// but it is not a reason to lose the error: whatever this response ends
1095+
// up being, the sinks only ever hear about the ORIGINAL page error
1096+
// (reported above), so a boundary or a layout that crashed while
1097+
// handling it would vanish completely. A control-flow sentinel is
1098+
// filtered out by the reporter, since a boundary throwing notFound() is
1099+
// a routing decision rather than a crash.
1100+
reportBoundaryLayoutError(nested, opts, 'an error boundary or its layout threw while handling a render error');
10731101
}
10741102
}
10751103
// Root global-error.{js,ts} (#848): the app-wide catch-all, tried after the

test/ssr/ssr.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2127,6 +2127,61 @@ test('boundary: a layout that throws while wrapping a 403 is REPORTED, not swall
21272127
assert.match(String(seen[0].message), /layout-boom-403/);
21282128
});
21292129

2130+
test('boundary: a layout control-flow throw is NOT reported as an error', async () => {
2131+
// A redirect() from a layout is a routing decision, not a crash. Reporting it
2132+
// would fire the app's APM sink and paint a false dev error overlay OVER the
2133+
// 403 the user is looking at. It is not honoured either: the status is
2134+
// already decided and the boundary page IS the answer to this request.
2135+
const { route, appDir } = makeBoundaryApp({
2136+
files: {
2137+
'layout.js': `import { redirect } from ${JSON.stringify(WEBJS_MODULE_URL)};\nexport default function Root() { redirect('/login'); }\n`,
2138+
'admin/forbidden.js': HTML_IMPORT + `export default function F() { return html\`<p id="fb">no</p>\`; }\n`,
2139+
'admin/page.js': `import { forbidden } from ${JSON.stringify(WEBJS_MODULE_URL)};\nexport default function Page() { forbidden(); }\n`,
2140+
},
2141+
page: 'admin/page.js',
2142+
layouts: ['layout.js'],
2143+
forbiddens: ['admin/forbidden.js'],
2144+
});
2145+
const seen = [];
2146+
const devSeen = [];
2147+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/admin'), {
2148+
dev: true, appDir, onError: (e) => seen.push(e), onDevError: (e) => devSeen.push(e),
2149+
}));
2150+
assert.equal(resp.status, 403, 'the boundary still answers, and its status is preserved');
2151+
const body = await resp.text();
2152+
assert.ok(body.includes('id="fb"'), 'the boundary rendered');
2153+
assert.deepEqual(seen, [], 'a control-flow sentinel never reaches the APM sink');
2154+
assert.deepEqual(devSeen, [], 'nor the dev error overlay');
2155+
});
2156+
2157+
test('boundary: a layout that throws on the 500 path is reported, not lost', async () => {
2158+
// The fallthrough to the next boundary out is the right behaviour, but the
2159+
// sinks otherwise only ever hear the ORIGINAL page error, so a boundary or
2160+
// layout that crashed while handling it would vanish completely.
2161+
const { route, appDir } = makeBoundaryApp({
2162+
files: {
2163+
'layout.js': HTML_IMPORT + `export default function Root({ children }) { return html\`<div id="root-chrome">\${children}</div>\`; }\n`,
2164+
'error.js': HTML_IMPORT + `export default function Err() { return html\`<p id="root-boundary">outer</p>\`; }\n`,
2165+
'docs/layout.js': `export default function Docs() { throw new Error('layout-boom-500'); }\n`,
2166+
'docs/error.js': HTML_IMPORT + `export default function Err() { return html\`<p id="docs-boundary">inner</p>\`; }\n`,
2167+
'docs/page.js': `export default function Page() { throw new Error('page-boom'); }\n`,
2168+
},
2169+
page: 'docs/page.js',
2170+
layouts: ['layout.js', 'docs/layout.js'],
2171+
errors: ['error.js', 'docs/error.js'],
2172+
});
2173+
const seen = [];
2174+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/docs'), {
2175+
dev: false, appDir, onError: (e) => seen.push(e),
2176+
}));
2177+
assert.equal(resp.status, 500);
2178+
const body = await resp.text();
2179+
assert.ok(body.includes('id="root-boundary"'), 'it still falls through to the boundary outside the throwing layout');
2180+
const messages = seen.map((e) => String(e && e.message));
2181+
assert.ok(messages.includes('page-boom'), 'the original page error is still reported');
2182+
assert.ok(messages.includes('layout-boom-500'), 'and so is the layout crash that used to vanish');
2183+
});
2184+
21302185
test('boundary: a boundary response is never storable and never reduced', async () => {
21312186
// Two independent guarantees. The HTML cache refuses a non-200 outright, and
21322187
// the reduced X-Webjs-Have path is structurally unreachable: the boundary

0 commit comments

Comments
 (0)