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
A page render that throws is a 500 with the nearest error boundary and an onError report, unless the route has a loading.{js,ts}. Then it is silent: HTTP 200, an empty page body, no server log line, and no onError call.
A loading.{js,ts} anywhere in the route chain makes renderChain wrap the page in Suspense({ fallback, children }) (packages/server/src/ssr.js:688-698). The page's own template is therefore NOT rendered during the shell pass; it is pushed into suspenseCtx.pending and rendered later inside streamingHtmlResponse, after the 200 and the shell have been flushed. A throw at that point lands in the per-boundary catch at packages/server/src/ssr.js:2102, which sets html = '' in production and returns.
Reproduced: app/todos/loading.ts plus an app/todos/page.ts whose render throws. The response is <webjs-boundary id="s1"><p>Loading…</p></webjs-boundary> at status 200, then the deferred render throws and the emitted chunk is <template data-webjs-resolve="s1"></template>. The loading fallback is swapped for nothing, so the visitor gets the layout chrome and an empty body. With JS off the swap never runs and the skeleton stays. The identical page WITHOUT the sibling loading.ts correctly gets a 500, the nearest error boundary, and an onError report.
The prod-silent HTML is deliberate and correct (#478: no internal detail reaches the client). The missing SERVER-side observability is not:
No console.error. Core's equivalent boundary loop DOES log (packages/core/src/render-server.js:1987, [webjs] Suspense boundary "${id}" rejected:), so this is a divergence between two implementations of the same policy rather than a considered choice.
No opts.onError. That hook is reached only from the ssrPage catch at packages/server/src/ssr.js:370, which cannot wrap work happening inside the stream's start(). So an APM sink wired through instrumentation.ts never sees it.
Net effect: adding a loading.ts to a route silently downgrades every render error on that route from "500, boundary, logged, reported" to "200, blank, invisible". Operators diagnose a blank page with nothing in the logs.
Design / approach
Keep the client-facing behaviour exactly as it is (prod emits nothing into the boundary, dev keeps its message). Add the server-side reporting the non-streamed path already has:
console.error in the catch at ssr.js:2102, matching core's wording at render-server.js:1987 so the two read the same in a log.
Route the error to opts.onError so APM sinks see it. The hook is currently only reachable from ssrPage's catch; the streaming path needs the same reference threaded into streamingHtmlResponse (or a small reporter closure passed down), since by then the response has begun and the existing catch is out of scope.
Open question worth deciding rather than defaulting: should a boundary that throws AFTER the 200 also be surfaced to the client somehow (a webjs:boundary-error event, the way the client router dispatches webjs:navigation-fallback for a degraded navigation, #1114)? An empty region with a 200 is indistinguishable from "the server had nothing to render", and the router precedent says a degradation should be observable in production rather than silent. Not required to close this issue, but decide it explicitly.
Implementation notes (for the implementing agent)
Where to edit:
packages/server/src/ssr.js:2095-2110, the per-boundary try/catch inside streamingHtmlResponse. This is the site that swallows.
packages/server/src/ssr.js:688-698, renderChain, which is what wraps a page in Suspense when a loading.{js,ts} is in the chain. Read it to understand when the deferred path is taken; do not change it.
packages/server/src/ssr.js:370, the only current opts.onError call site, inside ssrPage's catch. Whatever mechanism reaches onError from the streaming path should keep this one intact.
packages/core/src/render-server.js:1987 and :2007, core's equivalent boundary catch, which already logs. Use it as the reference for message wording.
The response has ALREADY been sent with status 200 by the time this catch runs. There is no way to convert it to a 500, so do not try. The fix is observability, not status.
opts.onError is author-supplied and may throw; the existing call site wraps it in a try/catch with the comment "a throwing sink must not affect the response". Preserve that.
The nonce/CSP handling in streamSuspenseBoundaries is load-bearing for the emitted script; do not disturb it while adding logging.
A failure in one boundary must not take down the others or the response.
Tests + docs surfaces:
Unit, packages/server/test/: a route with a loading.{js,ts} whose page throws must produce a console.error and exactly one onError call, while the emitted boundary HTML stays empty in prod and carries the message in dev. There are existing streaming-error tests in packages/server/test/dev/streaming-error-isolation.test.js to sit beside.
The counterfactual matters here: assert the onError call count, since the current behaviour is zero and a test that only checks the HTML passes today.
Docs: .agents/skills/webjs/references/muscle-memory-gotchas.md and website/app/docs/troubleshooting/page.ts both now carry a caveat saying this silence exists and is tracked; remove or update those once it is fixed.
Acceptance criteria
A page render that throws on a route with loading.{js,ts} writes a server log line naming the boundary
The same failure reaches opts.onError exactly once
The client-facing HTML is unchanged: empty in prod, message in dev
A counterfactual proves the test reds against today's behaviour (zero onError calls, no log)
The two boundary catches (packages/server/src/ssr.js and packages/core/src/render-server.js) report consistently
The caveats added to the troubleshooting page and the agent skill are updated once the silence is gone
Problem
A page render that throws is a 500 with the nearest
errorboundary and anonErrorreport, unless the route has aloading.{js,ts}. Then it is silent: HTTP 200, an empty page body, no server log line, and noonErrorcall.A
loading.{js,ts}anywhere in the route chain makesrenderChainwrap the page inSuspense({ fallback, children })(packages/server/src/ssr.js:688-698). The page's own template is therefore NOT rendered during the shell pass; it is pushed intosuspenseCtx.pendingand rendered later insidestreamingHtmlResponse, after the 200 and the shell have been flushed. A throw at that point lands in the per-boundary catch atpackages/server/src/ssr.js:2102, which setshtml = ''in production and returns.Reproduced:
app/todos/loading.tsplus anapp/todos/page.tswhose render throws. The response is<webjs-boundary id="s1"><p>Loading…</p></webjs-boundary>at status 200, then the deferred render throws and the emitted chunk is<template data-webjs-resolve="s1"></template>. The loading fallback is swapped for nothing, so the visitor gets the layout chrome and an empty body. With JS off the swap never runs and the skeleton stays. The identical page WITHOUT the siblingloading.tscorrectly gets a 500, the nearest error boundary, and anonErrorreport.The prod-silent HTML is deliberate and correct (#478: no internal detail reaches the client). The missing SERVER-side observability is not:
console.error. Core's equivalent boundary loop DOES log (packages/core/src/render-server.js:1987,[webjs] Suspense boundary "${id}" rejected:), so this is a divergence between two implementations of the same policy rather than a considered choice.opts.onError. That hook is reached only from thessrPagecatch atpackages/server/src/ssr.js:370, which cannot wrap work happening inside the stream'sstart(). So an APM sink wired throughinstrumentation.tsnever sees it.Net effect: adding a
loading.tsto a route silently downgrades every render error on that route from "500, boundary, logged, reported" to "200, blank, invisible". Operators diagnose a blank page with nothing in the logs.Design / approach
Keep the client-facing behaviour exactly as it is (prod emits nothing into the boundary, dev keeps its message). Add the server-side reporting the non-streamed path already has:
console.errorin the catch atssr.js:2102, matching core's wording atrender-server.js:1987so the two read the same in a log.opts.onErrorso APM sinks see it. The hook is currently only reachable fromssrPage's catch; the streaming path needs the same reference threaded intostreamingHtmlResponse(or a small reporter closure passed down), since by then the response has begun and the existing catch is out of scope.Open question worth deciding rather than defaulting: should a boundary that throws AFTER the 200 also be surfaced to the client somehow (a
webjs:boundary-errorevent, the way the client router dispatcheswebjs:navigation-fallbackfor a degraded navigation, #1114)? An empty region with a 200 is indistinguishable from "the server had nothing to render", and the router precedent says a degradation should be observable in production rather than silent. Not required to close this issue, but decide it explicitly.Implementation notes (for the implementing agent)
Where to edit:
packages/server/src/ssr.js:2095-2110, the per-boundarytry/catchinsidestreamingHtmlResponse. This is the site that swallows.packages/server/src/ssr.js:688-698,renderChain, which is what wraps a page inSuspensewhen aloading.{js,ts}is in the chain. Read it to understand when the deferred path is taken; do not change it.packages/server/src/ssr.js:370, the only currentopts.onErrorcall site, insidessrPage's catch. Whatever mechanism reachesonErrorfrom the streaming path should keep this one intact.packages/core/src/render-server.js:1987and:2007, core's equivalent boundary catch, which already logs. Use it as the reference for message wording.Landmines:
opts.onErroris author-supplied and may throw; the existing call site wraps it in a try/catch with the comment "a throwing sink must not affect the response". Preserve that.streamSuspenseBoundariesis load-bearing for the emitted script; do not disturb it while adding logging.Invariants to respect:
Tests + docs surfaces:
packages/server/test/: a route with aloading.{js,ts}whose page throws must produce aconsole.errorand exactly oneonErrorcall, while the emitted boundary HTML stays empty in prod and carries the message in dev. There are existing streaming-error tests inpackages/server/test/dev/streaming-error-isolation.test.jsto sit beside.onErrorcall count, since the current behaviour is zero and a test that only checks the HTML passes today..agents/skills/webjs/references/muscle-memory-gotchas.mdandwebsite/app/docs/troubleshooting/page.tsboth now carry a caveat saying this silence exists and is tracked; remove or update those once it is fixed.Acceptance criteria
loading.{js,ts}writes a server log line naming the boundaryopts.onErrorexactly onceonErrorcalls, no log)packages/server/src/ssr.jsandpackages/core/src/render-server.js) report consistently