Skip to content

fix(signals): a staged error reaches the memos and derived stores that derive from it - #4456

Merged
ryansolid merged 15 commits into
nextfrom
claude/project-thread-x1ore2-staged-err-derive
Oct 30, 2026
Merged

ryansolid merged 15 commits into
nextfrom
claude/project-thread-x1ore2-staged-err-derive

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Summary

Before: a held pass or flight that found an error staged it with the frame (D-41), but nothing deriving from it was told: no memo, no derived store. A memo over the errored source kept its old value for the whole hold. latest() of the source threw the proposed error, yet latest() of the memo returned the old value, and isPending() of the memo read false.

The async generator found two cases on next 5e68925 (E2, E3):

  • An action made m0 throw. latest(m0) read in a leaf showed [ROOT-E], but the same read through an identity memo or a derived store showed lm0:3.
  • Take an errored memo whose held re-run errors again. isPending of the memo read true, but isPending of an identity memo or derived store over it read false.

After: a derivation of a staged error carries that error, staged with the same frame (D-41). This holds through any depth of memos and through a derived store's readers. latest() of the derivation throws the proposed error, and isPending() of it is true until the hold commits, the same as for the source memo.

How

All the changes are in packages/signals/src/core/async.ts. A derivation is marked, not re-run.

  • stageError already runs the settle walk over the node's dependents. When that walk meets a derivation of a staged error, stageMark now gives the derivation the staging and queues it.
    • A derived store's family hears it too (fam?.st(STATUS_ERROR)).
    • The walk continues to the derivation's own readers.
    • Some nodes are skipped: boundary outputs, async memos, effects, boundary trees, and nodes already carrying a staged error. The commit's publish reaches those, as it does today.
    • Only the verdict engine marks, because only a verdict reads a derivation's staging before it commits. Plain-only bundles pay nothing.
  • propagateStatus re-derives a marked node on a pending mark. When the source asks again, the error the node carried is no longer the answer ([2.0 / next] A visible button's condition throws in its click handler during a held update #4109).

Marking adds no derivation runs. Re-running each derivation would add one run per derivation per staged error, and a whole chain would re-run. Ryan chose the mark for that reason. Catching an error with try/catch in a derivation is not a pattern Solid supports, so this PR doesn't try to make a catching memo answer differently during the hold.

Size vs next @ b773146 (after #4464 and #4472), measured locally (linux). Minified deltas are exact; brotli deltas are against the CI report for next and are approximate to ±5 B:

scenario brotli / minified vs next cap
page: live server components (frozen floor) ≈+49 / +116 unchanged at 38.85 KB (38,849 B measured); recorded minified 117,376 → 117,514 (Size-Exception)
signals: + isPending/latest ≈+43 / +115 11.12 → 11.17 KB
page: compiled live server components ≈+47 / +115 42.75 → 42.79 KB
page: live + router ≈+128 / +116 48.44 → 48.51 KB
hello world, core, simple-app, hydrating, base SC, server floors 0 / 0 unchanged

live + router's brotli delta is layout noise from the same +116 B of minified code (it was +30 brotli over next @ 131ceb8). Every brotli delta here is over the 20 B flag.

Size-Exception: the live server-components page carries the D-41 staged-error mark that lets a derivation of a staged error read like its source (+116 B minified, about +49 B brotli). Its cap is unchanged at 38.85 KB, and its recorded minified rises 117,376 → 117,514 so the floor gates later PRs on their own bytes. Correctness.

How did you test this change?

Found by the async-gen rerun on rc.20 (findings #6/#15, #8).


🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

…-41)

An error a held node's pass (or flight) found is staged with the frame
(CONFIG_STAGED_ERROR), but nothing told its dependents: a staged value
re-runs them (insertSubs), a staged error took the errored branch, which
leaves notification to a publish that only comes at the commit. A memo
over it kept its old value for the rest of the hold, so `latest()` of it
returned that value where `latest()` of the source threw the proposed
error (D-22), and `isPending()` of it read false (D-23 iii).

The settle walk stageError already runs now marks the derivations it
reaches as carrying the staging (`stageMark`), the staged twin of
propagateStatus's error mark: marked, not re-run (a re-run would replace
the frame a boundary's content owns, where a published error only marks),
queued so the seam holds them with the frame, and walked on so their own
verdict readers re-derive. A pending mark on a node carrying a staged
error re-derives it through propagateStatus's verdict branch, so a source
that re-asks in the same hold does not publish the old error (#4109).
Only verdicts read a derivation's staging before its commit, so all of it
runs with the verdict engine alone: the compiled floor is unchanged.

Found by the async-gen rerun on next 5e68925 (E2: latest and isPending
through an identity memo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
The marks run with the verdict engine alone: +109 B minified on the
isPending/latest bundles, 0 B on the floors. Raised the non-frozen caps
that crossed at measured + 10 B rounded up to 0.01 KB, recorded minified
= measured (isPending/latest, the compiled live page; the observe tier
with attribution is brotli layout only, +0 B minified). The live page's
frozen floor stays under its cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@changeset-bot

changeset-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 74f3771

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

This PR includes changesets to release 15 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
canvas-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

@github-actions

github-actions Bot commented Oct 30, 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.42 KB 0 B 0 B +15 B 7.43 KB ✅
signals: + createStore 14.96 KB 0 B 0 B +6 B 14.97 KB ✅
signals: + isPending/latest 11.15 KB +40 B (+0.4%) +115 B 0 B 11.17 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.05 KB 0 B 0 B +2 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.78 KB 0 B 0 B +15 B 9.78 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.75 KB 0 B 0 B 0 B 18.74 KB ⚠️ over by 12 B, 20 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.38 KB 0 B 0 B +13 B 32.48 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.45 KB 0 B 0 B +9 B 13.47 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.19 KB −14 B (−0.1%) 0 B +9 B 15.20 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.75 KB −30 B (−0.1%) 0 B +9 B 29.78 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.97 KB 0 B 0 B +2 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.33 KB 0 B 0 B +13 B 26.33 KB ⚠️ over by 3 B, 7 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.45 KB 0 B 0 B +7 B 32.54 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.77 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.67 KB 0 B 0 B +9 B 33.77 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.03 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 38.85 KB +46 B (+0.1%) +116 B 0 B 38.85 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.03 KB, wire.js 3.47 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.72 KB 0 B 0 B +9 B 35.72 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.01 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 42.78 KB +42 B (+0.1%) +115 B 0 B 42.79 KB ✅ eager (counted): web.js 22.81 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.07 KB, sc-comments.js 0.19 KB, trace.js 9.05 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.11 KB 0 B 0 B +7 B 41.25 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.03 KB, serverForms.js 3.57 KB, trace.js 9.03 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.50 KB +126 B (+0.3%) +116 B 0 B 48.51 KB ✅ eager (counted): client.js 28.02 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.30 KB, trace.js 9.03 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B 0 B 1.29 KB ✅
server: renderToString (the server-render floor) 22.79 KB 0 B 0 B 0 B 22.80 KB ✅

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

  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 12 B; minified 53,607 B vs 53,607 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 3 B; minified 80,048 B vs 80,035 B recorded with the cap (+13 B) — 7 B of the 20 B minified allowance left; +0 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 30, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41094895847

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage remained the same at 78.1%

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: 1362
Covered Lines: 1125
Line Coverage: 82.6%
Relevant Branches: 1090
Covered Branches: 790
Branch Coverage: 72.48%
Branches in Coverage %: Yes
Coverage Strength: 29.82 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 12.34%

2 warnings

⚠️ 2 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 2 benchmarks with a significant change ran on different hardware

Their base and head results were measured on machines with a different CPU, core count or memory, so the changes might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚡ 2 improved benchmarks
✅ 198 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ projection derive: write one NESTED field (reference) 2.4 ms 2.1 ms +13.44%
⚡ arrow in template 12.3 ms 11.1 ms +11.24%

Tip

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


Comparing claude/project-thread-x1ore2-staged-err-derive (74f3771) with next (e21c2ae)

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. ↩

Copy link
Copy Markdown
Member Author

I don't think CodSpeed's −13.8% on non-ascii-template "arrow in template" comes from this PR. The diff touches only packages/signals/src/core/async.ts, in the staged-error settle walk. This bench is a synchronous renderToString over a For of plain rows: it creates no async memo and never takes the settle path.

The base (next 7135a77) has this bench at a fast 10.8 ms. Other PRs on the same base show the same drop with unrelated diffs: #4453 is at −9.27%, and earlier bases showed −7..−9%. The rest of the suite is unchanged (199 untouched), including the ASCII twin of this template. I'm treating this as base-outlier noise and not chasing it.


Generated by Claude Code

A derived store over a memo whose error is staged with a hold now shows
the staging to its readers: latest() of the store throws the proposed
error and isPending() of it is true until the hold commits, as for a
plain memo (D-41). The derive is marked like any other derivation and
its family is told the error status, so the family's readers re-derive
from it (#4189).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@ryansolid ryansolid changed the title fix(signals): a staged error reaches the memos that derive from it fix(signals): a staged error reaches the memos and derived stores that derive from it Oct 30, 2026
The derived-store family wake of a staged error adds 6 B minified over
the memo marks; on the live server-component page it lands at 38,799 B
brotli, past the frozen floor. Raised the live page's frozen floor to
38.81 KB (recorded minified 117,343) and live + router to 48.45 KB
(recorded minified 142,747), at measured + 10 B rounded up to 0.01 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

Copy link
Copy Markdown
Member Author

On 4fce711, the "arrow in template" bench is back to unchanged. A different one is flagged now: projection-root-write "createStore setter: delete + set one root key" at −14%. This is the same bench file whose single-run cases flip ±13–15% with the store module's byte size alone. A V8 lazy sweep after the pre-run gc() either lands in the one measured run or misses it.

The new fam?.st call can't be the cause. It runs only on a staged error during a hold. This bench is a sync setter with no async or error path. I'm treating it as noise.


Generated by Claude Code

…-x1ore2-staged-err-derive

# Conflicts:
#	scripts/size/scenarios.js
…ing it

D-41: a derivation that reads a staged error throws it, as it would read
a staged value: its own pass errors and stages with the same frame. The
earlier change marked every derivation under a staged error with that
error without running it, so a memo or derived-store derive that catches
the error showed an error it never threw for the whole hold, and a
derived store's keys that never read the source threw it too (D-30). Read
only through latest(), both ended errored after the commit.

The settle walk now re-runs those derivations; a pass that catches
answers with its own value. A boundary's output and tree, async memos,
effects and nodes that own children (a boundary's content, #3945) are
left to the commit's publish as before. A projection's derive stages its
pass's error like a memo. A published error no longer marks a node that
holds its own pass's answer to the frame, which also fixes a catching
memo ending errored when the error's flush has no hold.

The silent-recovery pin counts the dependent's own errored pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
Over next @ 563b6b0: the settle walk's re-run of a staged error's
derivations is +60 B minified with the verdict engine (isPending/latest
11.10 -> 11.12 KB, recorded minified 30,656); the published-error check
is +2 B minified everywhere (createStore 14.95 -> 14.98 KB, recorded
minified 43,926, which next already measures at 43,924). Caps at
measured + 10 B rounded up to 0.01 KB. Every frozen floor stays under
its cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-staged-err-derive

# Conflicts:
#	scripts/size/scenarios.js
The marks run with the verdict engine alone: +115 B minified on the
isPending/latest bundles, 0 B on the floors. isPending/latest 11.12 ->
11.16 KB, the live server-component page's frozen floor 38.85 -> 38.90
KB, compiled live 42.73 -> 42.81 KB, each at measured + 10 B rounded up
to 0.01 KB with recorded minified = measured; live + router re-records
its minified only (still under its cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-staged-err-derive

# Conflicts:
#	scripts/size/scenarios.js
isPending/latest 11.12 -> 11.17 KB (30,771), compiled live 42.75 -> 42.79 KB
(125,655), live + router 48.44 -> 48.51 KB (142,918); the live
server-components floor keeps its 38.85 KB cap and re-records minified
117,376 -> 117,514 (Size-Exception in the PR body).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
ryansolid pushed a commit that referenced this pull request Oct 31, 2026
#4519)

* fix(signals): link a store reader to its derive's staged error (#4517)

A derive marked with a staged error (`stageMark`, #4456) carries no
STATUS_ERROR until the commit, so `pullFamily` took it for a settled
derive and pulled it untracked. The pull threw the staged error before
the reader read any leaf, so the reader subscribed to nothing in the
family: when the derive's source re-asked and landed, neither the wake
nor the settle reached it. An `<Errored>` mounted in the change that
errored its content stayed on its fallback over a keyed
`createProjection`, while the same read of the memo recovered.

A reader now links to a derive carrying a staged error as it does to a
pending or errored one, and re-runs at the landing. The render-effect
early return in that branch is for a derive with a flight up only: a
staged error is an answer the frame reader has to meet (D-41; the #3887
pin).

Pins: packages/signals/tests/errored-born-staged-error-projection-4517.test.ts,
packages/web/test/errored-born-staged-error-projection-4517.spec.tsx
(the issue's repro).

Size: +8 to +10 B minified on the store scenarios, every one under its
brotli cap; the three over-cap warnings are +0 B over next.

* fix: address review findings

The pullFamily comment block keeps its original lines, with one line on
the staged-error pull in place of the three that narrated the issue.
The web spec's header keeps only the jsxImportSource and
vitest-environment pragmas, as the neighbouring specs do. The changeset
states the behavior that changed in two sentences.
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