Skip to content

fix(server): dedupe server error hook reports per request, not per process - #4130

Merged
ryansolid merged 4 commits into
nextfrom
fix/server-error-dedup-per-request
Oct 17, 2026
Merged

ryansolid merged 4 commits into
nextfrom
fix/server-error-dedup-per-request

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Summary

Before: the server error hook (configureServerErrors, a render's onError) kept one process-wide "already heard this error" ledger keyed by the error object. If two requests met the same Error object (a module-level constant, a cached rejection, a memoized failing fetch), only the first request's hook heard about it. The second request got no report and was also handed the first request's mapped wire value. An unrelated request's failure was suppressed because a different request had failed earlier with the same object.

After: the hook hears each error object once per kind per request, or per render when no request is in scope, and never once per process. A direct server-function call that throws during SSR inside an <Errored> or <Loading> is heard twice: as server-function (the call failed), then as render (the render fell back). So a render's onError seeing kind: "render" is enough to know that render failed, which is what #4125 asks for. Every road still carries one wire value, the first one a hook returned, and each request decides its own, so request B never gets request A's mapping.

Refs #4125. With this, #4126's hasErrors flag isn't needed.

When is there no request in scope?

The request scope is the Symbol.for("solid.RequestContext") store (AsyncLocalStorage, installed by @solidjs/web/storage's provideRequestEvent or by a framework). A render has no request when:

  • renderToString / renderToStream / renderToFrameStream is called without provideRequestEvent around it. That covers static generation, prerender scripts, tests, and a bare Node script.
  • @solidjs/web/storage was never imported, so no store exists at all.
  • the platform has no AsyncLocalStorage (or only a sync polyfill) and the code runs after an await. The store reads empty there even when the render started inside a request.

In all of these cases the render is now the scope. Server functions always have a request: HTTP dispatch has its event, and a direct call throws "Cannot call server function outside of a request". A report with neither a request nor a render in hand (a bare reportServerError funnel, which has no call site that lacks both) is not deduplicated at all.

How

  • verdicts in packages/solid/src/server/signals.ts is now WeakMap<scope, WeakMap<error, Verdict>>. The scope is picked in this order:

    1. the render's scope, when the caller has it. This is a new internal _errorScope on the render context, set by renderToString/renderToStream: the request event the render started under, otherwise a fresh per-render object. It is inherited by the derived contexts (Object.create(ctx)) of <Loading> and frames.
    2. otherwise site.event.
    3. otherwise the request store.
    4. otherwise none (a fresh verdict nobody keeps).
      An event counts by its request. The derived {...event} that a direct SSR-time server-function call runs under shares that request with the render that made it, which is what keeps that case at one verdict.
  • Within a verdict, heard records the kinds the hook has heard. A new kind is reported; a repeat of a kind is not. A hook's answer, once given, stays the wire (mapped), and a later kind's return is ignored. The default policy's value does not bind a later hook answer, as before. kind: "request" (reportRequestFailure) is the catch-all: it's heard only when no other kind was, so passing a render's own throw to it still adds nothing.

  • reportServerError / ssrSanitizeError take an optional trailing scope. This is internal ABI only; there are no new exports or options. The boundary sites (<Errored>, <Loading>), the renderer's own sites (failRender, teardown, first-pass throw), the fragment and channel funnels, and the live-hole error chunks pass their render's scope. Server-function sites keep reading the request.

  • Golf to fit the renderToString floor: a verdict is always an object, so the !== undefined guards are gone. The request-store and ambient-hook reads use ?..

  • Docs (RFC 10/12, the ServerErrorHook / configureServerErrors / reportRequestFailure doc comments) now say "once per error object per kind per request".

  • throwers (where an error was thrown, used for ownerPath) was also a process-global WeakMap<error, Owner>. With a reused module-level Error, every later request reported the first request's ownerPath, and that request's owner tree stayed reachable for as long as the error lived. The thrower now lives on the error's verdict in the same scope: it is stamped under the request in scope, else the running render's _errorScope, and read back under the same scope. It is collected with its request or render, and each request stamps and reads its own. The separate throwers map is gone.

How did you test this change?

New tests in packages/web/test/server/server-error-hook.spec.tsx, under "once per request, never once per process (#4125)":

  1. The Expose per-render failure state for buffered SSR consumers #4125 repro: one Error reused by two requests' <Loading> fragments. Both hooks fire, each with its own event, and each document carries its own request's mapping.
  2. Two concurrent renderToStreams with no request in scope share one Error. Each render's onError hears it once, and each document carries only its own mapping.
  3. Within one request, a direct server-function failure that escapes into <Loading> (the Expose per-render failure state for buffered SSR consumers #4125 server-function repro): a server-function/thrown call, then a render/client call. The hook returns a different value the second time, and the source's rejection and _fr both carry only the first, and the original never appears (this tier's default is fidelity, so a road that missed the verdict would leak it).
  4. With no request in scope, two <Errored>s in one render meet the same Error: one call, and both fallbacks render the mapping. The next render hears it again.
  5. One Error thrown by <First> in request 1 and by <Second> in request 2, and the same again in two renders with no request: each report's ownerPath names its own thrower. This fails with the old global throwers.

The existing direct-call tests (<Errored> around a throwing call, and the two in concurrent-render-error-policy.spec.tsx) now expect the render sight after the server-function one, with the function's mapping still on the wire. I also added a reportRequestFailure case (the same error object for two requests is heard twice) and updated the existing "once per error object" tests so the handler reports with the same event the render ran under. Tests 1, 2 and 4 and the new reportRequestFailure case fail on next.

Size (node scripts/size/size.mjs vs next @ 7010268, gate.mjs)

Only one scenario moved:

scenario brotli minified
server: renderToString (the server-render floor) 20,573 → 20,624 B (+51) 72,154 → 72,131 B (−23)

The gate passes with a WARN: 34 B over the brotli cap, but minified is 23 B under the size recorded with the cap. check-floor-caps reports no cap raised, so no Size-Exception is needed.

https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1


Generated by Claude Code

…ocess

The server error hook's once-per-error ledger (`verdicts`) was one
process-global WeakMap keyed by the error object, so an error object
reused across requests (a module-level constant, a cached rejection) was
heard only by the first request's hook, and later requests were handed
the first request's mapped wire value (#4125).

The ledger is now keyed by scope, then error: the request a render
started under (keyed by `event.request`, which the derived event of a
direct SSR-time server-function call shares), else the render itself (a
per-render object @solidjs/web puts on the render context as the
internal `_errorScope`), else no dedup at all. Every road within one
request still reads the same verdict, so a server-function failure that
escapes into a <Loading> is reported once and carries one mapped value.

Refs #4125

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
@changeset-bot

changeset-bot Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 267af39

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
solid-js Patch
@solidjs/web Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal Patch
board-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/signals Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.24 KB 0 B 0 B 0 B 7.25 KB ✅
signals: + createStore 14.77 KB 0 B 0 B 0 B 14.79 KB ✅
signals: + isPending/latest 9.95 KB 0 B 0 B +4 B 9.96 KB ✅
signals: + until/refresh (truth readers, no optimism) 7.85 KB 0 B 0 B 0 B 7.86 KB ✅
app: render + one signal (the simple-app floor) 9.55 KB 0 B 0 B 0 B 9.56 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.17 KB 0 B 0 B 0 B 18.19 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.09 KB 0 B 0 B +4 B 30.09 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.12 KB 0 B 0 B 0 B 13.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.87 KB 0 B 0 B 0 B 14.91 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.44 KB 0 B 0 B 0 B 29.48 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.75 KB 0 B 0 B +2 B 9.77 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.94 KB 0 B 0 B 0 B 25.95 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.95 KB 0 B 0 B 0 B 31.96 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.22 KB 0 B 0 B 0 B 12.23 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.42 KB 0 B 0 B 0 B 33.44 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.99 KB, wire.js 1.33 KB
page: live server components (base + live/GET + action + isPending/latest) 37.51 KB 0 B 0 B 0 B 37.52 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.98 KB, wire.js 1.33 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.29 KB 0 B 0 B 0 B 35.30 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 9.02 KB, wire.js 1.33 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.46 KB 0 B 0 B 0 B 41.48 KB ✅ eager (counted): web.js 21.61 KB; assets.js 0.78 KB, decode.js 6.25 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 9.02 KB, wire.js 1.33 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.88 KB 0 B 0 B 0 B 40.90 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.60 KB, trace.js 9.03 KB, wire.js 1.33 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.00 KB 0 B 0 B 0 B 47.02 KB ✅ eager (counted): client.js 26.70 KB; assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 9.03 KB, wire.js 1.34 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.62 KB +51 B (+0.2%) −23 B −23 B 20.59 KB ⚠️ over by 34 B, 43 B minified headroom

⚠️ Over the brotli cap within the minified allowance (passes)

  • server: renderToString (the server-render floor): over brotli cap by 34 B; minified 72,131 B vs 72,154 B recorded with the cap (−23 B) — 43 B of the 20 B minified allowance left; −23 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Oct 17, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39018278347

Coverage remained the same at 77.564%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1339
Covered Lines: 1097
Line Coverage: 81.93%
Relevant Branches: 1050
Covered Branches: 756
Branch Coverage: 72.0%
Branches in Coverage %: Yes
Coverage Strength: 27.83 hits per line

💛 - Coveralls

…r process

`throwers` was a process-global WeakMap from error object to the owner it
was first thrown under. A reused error object (a module-level constant)
kept the first request's owner, so later requests' hook reports and
findings named the first request's `ownerPath`, and that request's owner
tree stayed reachable for as long as the error lived.

The thrower now lives on the error's verdict in the same scope the hook
ledger uses (the request in scope, else the running render's
`_errorScope`), so it is collected with that scope and a later request
stamps and reads its own.

Refs #4125

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
…p-per-request

# Conflicts:
#	packages/web/frames/src/frame-sink.ts
@codspeed

codspeed Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 8.87%

⚠️ 3 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚡ 1 improved benchmark
✅ 195 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ createStore setter: delete + set one root key (#3044 overlay) 1.9 ms 1.8 ms +8.87%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/server-error-dedup-per-request (267af39) with next (7010268)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

… failure always reaches it

A direct server-function call that throws during SSR was heard once, as
`kind: "server-function"`; the <Errored> or <Loading> that then contained
it was deduped away, so a render's `onError` could not tell that the
render failed (#4125). The per-scope verdict now records the kinds it has
been heard as: the render's sight is reported too, while the wire stays
the first answer a hook gave. `kind: "request"` stays the catch-all, heard
only when no other kind was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
@ryansolid
ryansolid marked this pull request as ready for review October 17, 2026 09:05
@ryansolid
ryansolid merged commit 9ac1481 into next Oct 17, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants