Skip to content

Commit c0c978c

Browse files
committed
fix: record the boundary failure phase instead of inferring it
Inferring the phase by re-rendering is unsound when BOTH a wrapped layout and the boundary tree are broken: the standalone attempt throws too, so the tree error was discarded and the LAYOUT error was reported under the boundary label. Two failures, one report, wrong name, in the code whose whole purpose is that a boundary crash is never lost. The phase is known exactly where it happens, so renderBoundaryInChain marks it and the catch reads it. Both errors are reported now, each under its own label, and the dev body shows the tree error, which is the one that defeated the fallback. The unrouted 404's dev frame also stamped a bare pathname. The overlay gate compares against location.pathname + location.search, so the frame was refused (and then dropped) for any url carrying a query, and for every url on a base-path deploy, where the ingress strip removed the prefix from url while the browser still has it. It stamps what the matched-page branch stamps. That hook is also dropped for a speculative prefetch now, per the same #1047 rule: hovering a link to a broken url must not raise an overlay on the page the user is actually on.
1 parent 0af5cf8 commit c0c978c

4 files changed

Lines changed: 154 additions & 22 deletions

File tree

packages/server/src/dev/serve.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,26 @@ export async function handleCore(req, ctx) {
509509
req,
510510
url,
511511
onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
512-
onDevError: dev ? (e) => reportDevError(e, { kind: 'render', url: url.pathname }) : undefined,
512+
// The frame's url must be stamped exactly as the matched-page branch
513+
// stamps it, or the overlay's scope gate refuses it: the browser compares
514+
// against `location.pathname + location.search`, so dropping the query
515+
// suppresses the overlay on any unrouted url that carries one, and
516+
// dropping the base path suppresses it on EVERY url of a sub-path deploy
517+
// (the ingress strip already removed the prefix from `url`, while the
518+
// browser's location still has it). A refused frame goes to the pending
519+
// slot and is then discarded, so the overlay simply never paints.
520+
//
521+
// Dropped for a speculative prefetch, on the same #1047 rule the
522+
// matched-page branch follows: hovering a link to a broken page must not
523+
// raise an overlay on the page you are actually on, nor become
524+
// `state.lastDevError` for the SSE to replay into a fresh tab. Prefetch is
525+
// on by default and fetches unrouted hrefs too, so this is reachable.
526+
onDevError: dev && req.headers.get('x-webjs-prefetch') !== '1'
527+
? (e) => reportDevError(e, {
528+
kind: 'render',
529+
url: withBasePath(url.pathname, basePath()) + url.search,
530+
})
531+
: undefined,
513532
});
514533
}
515534

packages/server/src/ssr/render.js

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,18 @@ async function renderBoundaryInChain(tree, route, boundaryFile, ctx, dev) {
339339
const pageSeg = pageSegmentPath(route.file);
340340
const layoutSegs = new Set((route.layouts || []).map(layoutSegmentPath));
341341
if (!layoutSegs.has(pageSeg)) tree = wrapWithChildrenMarker(tree, pageSeg, params);
342-
const chain = await wrapLayoutChain(tree, wrapLayouts, ctx, dev, params, null);
342+
// MARK a layout-phase failure. The caller has to tell "a wrapped layout
343+
// threw" apart from "the boundary's own tree is broken", and it cannot infer
344+
// it by re-rendering: when BOTH are broken the standalone attempt throws too,
345+
// and an inference would report the layout error under the boundary's label
346+
// and lose the tree's entirely. The phase is known exactly here, so it is
347+
// recorded rather than guessed.
348+
let chain;
349+
try {
350+
chain = await wrapLayoutChain(tree, wrapLayouts, ctx, dev, params, null);
351+
} catch (e) {
352+
throw markLayoutPhase(e);
353+
}
343354
return renderToString(chain.tree, { ssr: true, dev });
344355
}
345356

@@ -414,6 +425,28 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
414425
return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: chain.reduced };
415426
}
416427

428+
/**
429+
* Marker for a throw that came from the LAYOUT-wrapping phase of a boundary
430+
* render, as opposed to the boundary's own tree (#1298). Non-enumerable, so it
431+
* never shows up in a serialized error, and best-effort: a frozen or primitive
432+
* throw simply goes unmarked and is treated as a tree failure, which reports it
433+
* once under the boundary label rather than dropping it.
434+
*/
435+
const LAYOUT_PHASE = Symbol('webjs.boundaryLayoutPhase');
436+
437+
/** @param {unknown} err @returns {unknown} */
438+
function markLayoutPhase(err) {
439+
if (err && typeof err === 'object') {
440+
try { Object.defineProperty(err, LAYOUT_PHASE, { value: true, enumerable: false }); } catch { /* frozen */ }
441+
}
442+
return err;
443+
}
444+
445+
/** @param {unknown} err @returns {boolean} */
446+
function isLayoutPhase(err) {
447+
return !!(err && typeof err === 'object' && /** @type {any} */ (err)[LAYOUT_PHASE]);
448+
}
449+
417450
/**
418451
* Render a thrown value as text for a DEV error page, safely.
419452
*
@@ -668,25 +701,26 @@ async function ssrBoundaryHtml(file, heading, opts) {
668701
body = await renderToString(tree, { ssr: true, dev: opts.dev });
669702
}
670703
} catch (layoutErr) {
671-
// Degrade to the standalone render this has always produced, and to
672-
// its empty boot set with it.
673-
//
674-
// The standalone attempt is also what tells us WHAT failed, so it
675-
// runs before anything is reported. If it succeeds, the fault was in
676-
// the layout chain: report it, because these paths execute layout
677-
// modules for the first time since #1298 and a genuine layout crash
678-
// would otherwise vanish, leaving a developer looking at a
679-
// chrome-less boundary page with nothing saying why. If it throws
680-
// too, the boundary's OWN tree is what is broken, so rethrow and let
681-
// the outer catch report it once, under the right label. Reporting
682-
// here first would report a tree failure twice and call it a layout.
704+
// A TREE failure is not this catch's business: the standalone
705+
// fallback would re-render the same tree and fail the same way, so
706+
// rethrow and let the outer catch report it ONCE under the boundary
707+
// label. Reporting here as well would double-report it and call it a
708+
// layout crash.
709+
if (!isLayoutPhase(layoutErr)) throw layoutErr;
710+
// A wrapped layout threw. Degrade to the standalone render this has
711+
// always produced, and to its empty boot set with it. Report it
712+
// either way: these paths execute layout modules for the first time
713+
// since #1298, so a genuine layout crash would otherwise vanish.
714+
reportBoundaryLayoutError(layoutErr, opts, { overlay: true });
683715
try {
684716
body = await renderToString(tree, { ssr: true, dev: opts.dev });
685717
moduleUrls = [];
686-
} catch {
687-
throw layoutErr;
718+
} catch (treeErr) {
719+
// BOTH are broken. The layout crash is already reported above, so
720+
// hand the outer catch the TREE error: it is a second, distinct
721+
// failure, and it is the one whose text the dev body should show.
722+
throw treeErr;
688723
}
689-
reportBoundaryLayoutError(layoutErr, opts, { overlay: true });
690724
}
691725
}
692726
} catch (e) {
@@ -747,15 +781,15 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
747781
body = await renderToString(tree, { ssr: true, dev: opts.dev });
748782
}
749783
} catch (layoutErr) {
750-
// Same degradation, and the same report-only-if-the-fault-was-the-
751-
// chain rule, as ssrBoundaryHtml above.
784+
// Same phase rule as ssrBoundaryHtml above.
785+
if (!isLayoutPhase(layoutErr)) throw layoutErr;
786+
reportBoundaryLayoutError(layoutErr, opts, { overlay: true });
752787
try {
753788
body = await renderToString(tree, { ssr: true, dev: opts.dev });
754789
moduleUrls = [];
755-
} catch {
756-
throw layoutErr;
790+
} catch (treeErr) {
791+
throw treeErr;
757792
}
758-
reportBoundaryLayoutError(layoutErr, opts, { overlay: true });
759793
}
760794
}
761795
} catch (e) {

packages/server/test/routing/global-boundaries.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,52 @@ test('an unrouted 404 whose not-found THROWS reaches the onError sink', async ()
138138
assert.equal(captured.length, 1, 'the crash reached the APM sink through the real path');
139139
assert.match(String(captured[0].message), /ROOT_NF_BOOM/);
140140
});
141+
142+
test('an unrouted 404 stamps its dev frame the way the overlay gate expects', async () => {
143+
// The browser compares the frame url against `location.pathname +
144+
// location.search`. A stamp missing the query is refused for any unrouted
145+
// url carrying one, and the frame is then dropped, so the overlay never
146+
// paints. This asserts the stamp, which is the half a render test cannot see.
147+
const appDir = makeApp({
148+
'package.json': pkg,
149+
'app/page.js': page('export default function H() { return html`<main>home</main>`; }'),
150+
'app/not-found.js':
151+
`export default function NF() { throw new Error('NF_FRAME_BOOM'); }\n`,
152+
});
153+
const frames = [];
154+
const app = await createRequestHandler({
155+
appDir, dev: true, onDevError: (frame) => frames.push(frame),
156+
});
157+
const prev = console.error;
158+
console.error = () => {};
159+
try {
160+
await app.handle(new Request('http://x/missing?page=2'));
161+
} finally { console.error = prev; }
162+
assert.equal(frames.length, 1, 'a frame was pushed');
163+
assert.equal(frames[0].url, '/missing?page=2', 'and it carries the search, as the gate requires');
164+
});
165+
166+
test('a PREFETCH of an unrouted url raises no dev overlay frame (#1047)', async () => {
167+
// Hovering a link to a broken url must not raise an overlay on the page the
168+
// user is actually looking at, nor become the frame the SSE replays.
169+
const appDir = makeApp({
170+
'package.json': pkg,
171+
'app/page.js': page('export default function H() { return html`<main>home</main>`; }'),
172+
'app/not-found.js':
173+
`export default function NF() { throw new Error('NF_PREFETCH_BOOM'); }\n`,
174+
});
175+
const frames = [];
176+
const errors = [];
177+
const app = await createRequestHandler({
178+
appDir, dev: true,
179+
onDevError: (frame) => frames.push(frame),
180+
onError: (e) => errors.push(e),
181+
});
182+
const prev = console.error;
183+
console.error = () => {};
184+
try {
185+
await app.handle(new Request('http://x/missing', { headers: { 'x-webjs-prefetch': '1' } }));
186+
} finally { console.error = prev; }
187+
assert.equal(frames.length, 0, 'no overlay frame from a speculative fetch');
188+
assert.equal(errors.length, 1, 'but the APM sink still hears about it, since the render really threw');
189+
});

test/ssr/ssr.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2495,6 +2495,36 @@ test('boundary: a boundary whose own TREE fails is reported ONCE, and not as a l
24952495
assert.equal(hits.length, 1, 'reported once, not once per render attempt');
24962496
});
24972497

2498+
test('boundary: when the layout AND the tree are broken, BOTH are reported', async () => {
2499+
// The previous shape inferred the phase by re-rendering, so with both broken
2500+
// the standalone attempt threw too, the tree error was discarded, and the
2501+
// LAYOUT error was reported under the boundary label. Two failures, one
2502+
// report, wrong name. The phase is recorded at its source now.
2503+
const { route, appDir } = makeBoundaryApp({
2504+
files: {
2505+
'layout.js': `export default function Root() { throw new Error('LAYOUT_HALF_BOOM'); }\n`,
2506+
'admin/forbidden.js': HTML_IMPORT + `export default function F() {
2507+
return html\`<p>\${Promise.reject(new Error('TREE_HALF_BOOM'))}</p>\`;
2508+
}\n`,
2509+
'admin/page.js': `import { forbidden } from ${JSON.stringify(WEBJS_MODULE_URL)};\nexport default function Page() { forbidden(); }\n`,
2510+
},
2511+
page: 'admin/page.js',
2512+
layouts: ['layout.js'],
2513+
forbiddens: ['admin/forbidden.js'],
2514+
});
2515+
const seen = [];
2516+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/admin'), {
2517+
dev: true, appDir, onError: (e) => seen.push(e),
2518+
}));
2519+
assert.equal(resp.status, 403, 'it still answers with the boundary status');
2520+
const messages = seen.map((e) => String(e && e.message));
2521+
assert.ok(messages.includes('LAYOUT_HALF_BOOM'), 'the layout crash is reported');
2522+
assert.ok(messages.includes('TREE_HALF_BOOM'), 'and the tree crash is not lost behind it');
2523+
// The dev body shows the TREE error, since that is the one that defeated the
2524+
// fallback and left the page with nothing to render.
2525+
assert.match(await resp.text(), /TREE_HALF_BOOM/);
2526+
});
2527+
24982528
test('boundary: a boundary response is never storable and never reduced', async () => {
24992529
// Two independent guarantees. The HTML cache refuses a non-200 outright, and
25002530
// the reduced X-Webjs-Have path is structurally unreachable: the boundary

0 commit comments

Comments
 (0)