Skip to content

fix(signals): a held error waits for its frame (D-41) - #4085

Merged
ryansolid merged 8 commits into
nextfrom
fix/held-error-frame
Oct 15, 2026
Merged

ryansolid merged 8 commits into
nextfrom
fix/held-error-frame

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Requested by Ryan · project thread

Summary

Before: when a transaction held a node's frame and that node errored, the error was published at once. A value in the same spot would have waited for the frame. As a result, the effect's error arm ran early, <Errored> flipped beside the previous frame's content (F-1's torn screen), an untracked read threw, isPending read false, and resolve/until/refresh rejected early. A held recovery dropped the shown error ahead of its frame. A recovery equal to the last good value never reached the effect, held or not, because the equality gate swallowed it.

After: an error is an answer (D-41, ruled 2026-10-15 as Q-6 B). It ends the wait like a value, and it shows when a value would. A held rejection or throw is staged and published by the frame's commit. Until then:

  • the screen, untracked reads, the error arm and <Errored> keep the previous frame
  • isPending is true
  • latest throws the proposed error, including for a first load born in the hold
  • resolve/until/refresh reject at the landing

A held recovery keeps the shown error until its own commit. A recovery to the last good value reaches effects.

How: the error is staged in _pendingValue (CONFIG_STAGED_ERROR), and commitPendingNode publishes it through the ordinary notifyStatus path. This replaces the prototype's separate publish path (publishError, stageErrorOn, the staged flag through propagateStatus). _shownError on the node extension keeps the displayed error through a retry. watch() defers its rejection to the landing the way it defers a value.

Rules: #3995's proposed D-40 is renumbered D-41 and marked ruled. Its scenarios S-41/S-42 become S-42/S-43, and Q-6 is removed from OPEN-QUESTIONS.md. The new numbers avoid the D-40/S-41 that #4067 uses. Q-7 (loadingValue after a shown failure) stays open, with today's behaviour unchanged.

Open deviation, pinned it.fails: an untracked read of a derived store's property (createStore(fn), projections) still throws a held rejection before its frame. The store's effect and the screen do wait.

Not in this PR: the standalone bugs from #3886's audit, which have no rule of their own:

  • writing to a failed writable memo
  • error-wrapper formatting
  • comparator release
  • snapshot capture

tests/watch-error-outcomes.test.ts is @GabbeV's, from #3886.

Size: the core floor is +437 B minified / +167 B brotli against next, after a trim pass. Every client scenario grows about the same. Ryan accepted the cost on 2026-10-15 (golf first, then raise by what is left), so the caps that fail the gate are raised to measured + 10 B, each with a dated ledger line. That includes the five frozen floors. The two compiled server-component pages stay as they are: they are over brotli but within their recorded minified sizes, so the gate only warns.

Size-Exception: a held error waits for its frame (D-41), core error-path behaviour ruled for 2.0; maintainer accepted the floor cost on 2026-10-15 after a trim pass.

How did you test this change?

  • pnpm exec vitest run in packages/signals: 5257 passed, 3 expected fail.
  • packages/web: client 1400 passed, hydrate 507 passed. Server: 1693 passed and 1 failed (server-functions-adapter-request), which fails the same way on next locally. The first CI run here crashed the web job: a held error committed at the seam looked stale to the next cycle's readers, so a boundary re-deriving over it retried the failed memo forever. Fixed by stamping the commit with the cycle that shows it; errored-show-retry-3991.spec.tsx covers it. The S-42/S-43 pins are plain it, and the derived-store pin is it.fails.
  • packages/solid: 863 passed.
  • fix(signals): publish value and error outcomes consistently #3886's held-error suites run against this branch: 56 of 69 pass. The rest are the derived-store deviation, Q-7 and fix(signals): publish value and error outcomes consistently #3886's other contract changes.
  • Sizes: scripts/size/size.mjs against next at 3fc5551.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EBzTHZM41ipGacdiFva1ju


Generated by Claude Code


Generated by Claude Code

claude and others added 4 commits October 15, 2026 02:55
Prototype for the proposed D-40: an error a held pass finds is staged in
_pendingValue (CONFIG_STAGED_ERROR), propagated to dependents as a staging
of their own, and published by commitPendingNode when the frame lands. A
held recovery keeps the shown error until its landing; a re-ask of the
source whose error a node shows re-runs it on the settle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q7JD9PktsxtBJJbWmtuwr
… and pins

An error settles the async its node asked for, so a hold never waits on
it; but settled is not shown. A value landing on a held node is staged
until the frame reveals, an error publishes at once. F-1 and F-2 already
decide this; D-40 states it as a narrowing of F-12, S-41/S-42 are the
screens, Q-6 puts the two columns to the maintainer, Q-7 the loadingValue
contract question #3886 raised beside it. Pinned it.fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q7JD9PktsxtBJJbWmtuwr
…tus error

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q7JD9PktsxtBJJbWmtuwr
An error a held node's flight or pass finds is staged like a value and
published by the frame's commit through the ordinary error path; the
separate staged-error publish path is gone. A held recovery keeps the
shown error until its own commit, a recovery equal to the last good value
reaches effects, latest() throws a staged error (born held too), and
watch() (resolve/until/refresh) rejects at the landing.

Rules: D-40 (#3995, proposed) renumbered D-41 and ruled (Q-6 B);
S-41/S-42 are S-42/S-43; Q-6 removed from OPEN-QUESTIONS. The derived-store
untracked read is the open deviation, pinned it.fails.

watch-error-outcomes.test.ts is from #3886.

Co-authored-by: Gabriel Valfridsson <gabriel.valfridsson@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBzTHZM41ipGacdiFva1ju
@changeset-bot

changeset-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 116b4ef

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

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

@ryansolid ryansolid self-assigned this Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 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.65 KB +169 B (+2.3%) +440 B +3 B 7.67 KB ✅
signals: + createStore 15.16 KB +166 B (+1.1%) +445 B +3 B 15.16 KB ✅
signals: + isPending/latest 9.96 KB +184 B (+1.9%) +511 B +4 B 9.98 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.28 KB +183 B (+2.3%) +464 B +3 B 8.29 KB ✅
app: render + one signal (the simple-app floor) 10.16 KB +162 B (+1.6%) +440 B +3 B 10.17 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.31 KB +219 B (+1.2%) +448 B +3 B 18.29 KB ⚠️ over by 20 B, 17 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.91 KB +185 B (+0.6%) +447 B +3 B 29.91 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.35 KB +159 B (+1.2%) +442 B +3 B 13.36 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.94 KB +169 B (+1.1%) +442 B +3 B 14.95 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.55 KB +209 B (+0.7%) +446 B +3 B 29.56 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.35 KB +172 B (+1.7%) +441 B +3 B 10.36 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.09 KB +155 B (+0.6%) +446 B +3 B 26.12 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.03 KB +144 B (+0.5%) +443 B +3 B 32.03 KB ⚠️ over by 5 B, 17 B minified headroom stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.87 KB 0 B 0 B 0 B 11.87 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.85 KB +104 B (+0.3%) +435 B +3 B 34.89 KB ✅ assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.03 KB, wire.js 1.35 KB
page: live server components (base + live/GET + action + isPending/latest) 38.73 KB +112 B (+0.3%) +507 B +4 B 38.70 KB ⚠️ over by 32 B, 16 B minified headroom assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.02 KB, wire.js 1.35 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.75 KB +165 B (+0.5%) +435 B −545 B 35.66 KB ⚠️ over by 92 B, 565 B minified headroom assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.23 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.97 KB, wire.js 1.35 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.44 KB +238 B (+0.6%) +520 B −447 B 41.21 KB ⚠️ over by 228 B, 467 B minified headroom eager (counted): web.js 21.87 KB; assets.js 0.78 KB, bind.js 2.55 KB, decode.js 6.23 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 9.02 KB, wire.js 1.35 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 42.27 KB +167 B (+0.4%) +437 B +3 B 42.22 KB ⚠️ over by 47 B, 17 B minified headroom assets.js 0.78 KB, bind.js 2.56 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.58 KB, trace.js 9.03 KB, wire.js 1.36 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.00 KB +145 B (+0.3%) +522 B +3 B 48.05 KB ✅ eager (counted): client.js 28.05 KB; assets.js 0.78 KB, bind.js 2.56 KB, decode.js 6.23 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.00 KB, wire.js 1.36 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.57 KB 0 B 0 B +8 B 20.58 KB ✅

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

  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 20 B; minified 53,834 B vs 53,831 B recorded with the cap (+3 B) — 17 B of the 20 B minified allowance left; +448 B minified over this PR's base
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 5 B; minified 101,836 B vs 101,833 B recorded with the cap (+3 B) — 17 B of the 20 B minified allowance left; +443 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 32 B; minified 119,544 B vs 119,540 B recorded with the cap (+4 B) — 16 B of the 20 B minified allowance left; +507 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 92 B; minified 110,172 B vs 110,717 B recorded with the cap (−545 B) — 565 B of the 20 B minified allowance left; +435 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 228 B; minified 123,986 B vs 124,433 B recorded with the cap (−447 B) — 467 B of the 20 B minified allowance left; +520 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 47 B; minified 131,132 B vs 131,129 B recorded with the cap (+3 B) — 17 B of the 20 B minified allowance left; +437 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).

Truthiness checks for _shownError (always a status error when set),
flushTransaction and passLane.

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

Copy link
Copy Markdown
Member Author

The red check is the size gate (gate.mjs). This PR adds the held-error mechanism, about +430 B minified / +150–230 B brotli on every client scenario, and that is over every cap. It's real growth, not noise. Raising the caps (and the frozen floors, which need a Size-Exception: line) is waiting on @ryansolid's decision. I'll push the raise, or a smaller form, once that's settled. The trim in 7541c58 brought the floor to +428 B minified.


Generated by Claude Code

@codspeed

codspeed Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ 2 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

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 193 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ reconcile: deep tree, 10 of ~12k paths subscribed 3.1 ms 3.2 ms -5.28%
⚡ memo + sync render effect only (reference) 31.2 ms 27 ms +15.36%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/held-error-frame (116b4ef) with next (4a1ed17)

Open in CodSpeed

Copy link
Copy Markdown
Member Author

CodSpeed's one regression is reconcile: deep tree, 10 of ~12k paths subscribed (−5.2%), a store reconcile path this PR doesn't change. CodSpeed also flags that the two runs used different runtime environments, and the same report shows two unrelated benchmarks improving (+66.6%, +5.4%). Earlier CodSpeed reds in this repo were judged runner noise, so I'm treating this one the same way rather than changing code for it. The size gate above is still the real blocker.


Generated by Claude Code

…hows it

commitPendingNode publishes a staged error at the seam, before the clock
advances. Readers re-derived by it (a boundary over the failed memo) run on
the next cycle, where read() took the error for an old one and retried the
async source; the retry rejected again and staged again, forever (web's
errored-show-retry-3991 outside variant ran out of memory). Stamp the
commit with the cycle that shows it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBzTHZM41ipGacdiFva1ju
Size-Exception: the held-error path costs +437 B minified / +167 B brotli on
the core floor after a trim pass; each raised cap is measured + 10 B with a
dated ledger line. The two compiled server-component pages stay (within
their recorded minified).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBzTHZM41ipGacdiFva1ju
Conflicts: D-41/S-42/S-43 (errors) and next's D-40/S-41 (createDeferred,
#4067) both sit after D-39/S-40; kept both, D-41 in section E and the new
section F after it. Size caps re-raised from next's caps against next @
4a1ed17 (same Size-Exception). CONFIG_STAGED_ERROR moves to 1 << 21:
createDeferred's CONFIG_LAGS took 1 << 19, and a lagging node read as a
staged error.

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

Copy link
Copy Markdown

Coverage Report for CI Build 38700193946

Coverage remained the same at 77.548%

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: 1328
Covered Lines: 1086
Line Coverage: 81.78%
Relevant Branches: 1046
Covered Branches: 755
Branch Coverage: 72.18%
Branches in Coverage %: Yes
Coverage Strength: 27.86 hits per line

💛 - Coveralls

@ryansolid
ryansolid marked this pull request as ready for review October 15, 2026 07:58
@ryansolid
ryansolid merged commit 0bda987 into next Oct 15, 2026
7 of 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