Skip to content

fix(signals): createOptimisticStore(fn) passes an upstream memo's error to its readers (#4437) - #4457

Merged
ryansolid merged 5 commits into
nextfrom
claude/project-thread-x1ore2-4437
Oct 30, 2026
Merged

ryansolid merged 5 commits into
nextfrom
claude/project-thread-x1ore2-4437

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Summary

Before: createOptimisticStore(fn) built its derived-store family by hand and left out the projection's status hook (st: familyStatus, node.fam). When the derive read a memo that threw, the error never reached the store's readers. They kept showing the old value, and no Errored caught the error (F-12).

Separately, a derived store, createStore(fn) included, could deliver one error to an effect twice. It happened when an async derive was read beside isPending, and, once the fix above is in, when an optimistic store's derive failed while an action's guess was in flight.

After: the optimistic store builds its family through createProjectionInternal, the same path createStore(fn) uses. An upstream memo's error or pending state now reaches its readers the same way it does for a plain derived store. Each reader gets an error once, at the reveal (D-41).

How

  • createProjectionInternal is exported and takes an opt flag. It also accepts a null fn, for an optimistic store that isn't derived. createOptimisticStore calls it instead of duplicating the family setup.
  • errorFamily(fam, error?) skips a reader that is already errored with this exact error. This is the same check core's propagateStatus makes. The derive's own throw and its async rejection pass the error in.
    • Before the change, a derive that failed twice on the same error re-queued every reader each time. One failure came from the refresh() waiter's pull after the commit published the staged error; the other from a retried async pass rejecting again.
    • Readers showing a different error, or none, are still re-queued, including a derive that rejects with undefined.

Size vs next @ 5f07d04 (brotli / minified):

scenario brotli minified
compiled floor (hello world) and every frozen floor 0 B 0 B
app: hydrating + every store primitive family −57 B −343 B
signals: + createStore +8 B +16 B
app: compiled CSR −10 B +16 B
app: compiled hydrating −79 B +16 B

No caps change.

How did you test this change?

  • New packages/signals/tests/optimistic-derive-memo-error-4437.test.ts. It fails on next.

  • New packages/signals/tests/derived-store-error-delivered-once.test.ts covers three cases, each expecting the error once:

    • an optimistic store over a failing memo with an action's guess in flight;
    • an optimistic store's async derive read beside isPending;
    • a createStore(fn) async derive read beside isPending.

    It also has four guard cases: a sync or an async derive rejecting with undefined still reaches its reader, for both store kinds. The first three cases fail without the fix.

  • Suites: signals 6,021 passed, web 195 files passed. Hydrate passes except c09-no-phantom, which also fails on next.

Fixes #4437


Generated by Claude Code

…rror (#4437)

A derived optimistic store built its family by hand and left out the two
links `createProjection` sets: the family's status hook (`fam.st`) and the
derive's back-pointer (`node.fam`). A failed memo the derive reads reaches
the derive by status propagation, not by a re-run of its pass, so without
them the leaves' readers never learned the error (F-12). The store now
builds its family through `createProjectionInternal`, with an `opt` flag
and an optional derive, so both forms share one constructor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
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: 2146f5c

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 +13 B 7.43 KB ✅
signals: + createStore 14.94 KB +8 B (+0.1%) +16 B +16 B 14.95 KB ✅
signals: + isPending/latest 11.08 KB −13 B (−0.1%) −10 B +3 B 11.10 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.04 KB 0 B 0 B +13 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.78 KB 0 B 0 B +13 B 9.78 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.75 KB 0 B 0 B +13 B 18.74 KB ⚠️ over by 5 B, 7 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.37 KB −57 B (−0.2%) −343 B −324 B 32.45 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.45 KB 0 B 0 B 0 B 13.47 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.20 KB 0 B 0 B +13 B 15.20 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.76 KB 0 B 0 B 0 B 29.78 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.96 KB 0 B 0 B +13 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.28 KB −10 B (−0.0%) +16 B +16 B 26.31 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.43 KB −79 B (−0.2%) +16 B +16 B 32.52 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.70 KB 0 B 0 B +13 B 33.66 KB ⚠️ over by 45 B, 7 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.02 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 38.75 KB −6 B (−0.0%) −8 B +80 B 38.76 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.02 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.63 KB 0 B 0 B +13 B 35.66 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 8.99 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 42.67 KB −16 B (−0.0%) −9 B +4 B 42.71 KB ✅ eager (counted): web.js 22.75 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.06 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.20 KB 0 B 0 B +13 B 41.23 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.02 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.31 KB −11 B (−0.0%) −8 B +5 B 48.44 KB ✅ eager (counted): client.js 27.86 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.01 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 5 B; minified 53,598 B vs 53,585 B recorded with the cap (+13 B) — 7 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 45 B; minified 101,618 B vs 101,605 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 41056290847

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.81 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

2 warnings

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

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

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 196 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ memo + sync render effect only (reference) 30.3 ms 34.9 ms -13.3%
⚡ build 228.5 µs 176.1 µs +29.73%
⚡ build + consume 393.2 µs 344.1 µs +14.26%
⚡ build + consume 744.9 µs 697.9 µs +6.73%

Tip

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


Comparing claude/project-thread-x1ore2-4437 (2146f5c) with next (9c4e4a8)

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 either of CodSpeed's two regressions comes from this PR.

  • mount-rows "memo + sync render effect only (reference)" (−12.9%): this bench uses no store. It is a known runner-type swing of ±10–20%, and CodSpeed itself warns that 5 changed benchmarks ran on different hardware for base and head.
  • projection-root-write "write one NESTED field (reference)" (−12.8%): this bench flips ±13–15% on the store module's byte size alone. A V8 lazy sweep after the pre-run gc() either lands in the single measured run or misses it. We reproduced that on next earlier with a 200 B comment-only change. This PR changes only how createOptimisticStore builds its projection, not the root-write path this bench measures.

The three props-chain improvements are the same noise in the other direction. I'm not chasing any of these.


Generated by Claude Code

…-x1ore2-4437

# Conflicts:
#	scripts/size/scenarios.js
… once

errorFamily re-ran every live reader of the family on each errored pass of
the derive. A second pass meeting the same upstream error object - a held
error's commit dirtying the derive under an optimistic store's action guess
(the regression #4457 review found), or a reader's pull retrying an errored
async derive beside an isPending probe (pre-existing in createStore(fn)) -
re-ran readers already showing that error, so an effect's error arm ran
twice (D-41: the arm runs once, at the reveal). A reader already errored
with the very error being handed off is now skipped, as core
propagateStatus skips one.

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

Copy link
Copy Markdown
Member Author

CodSpeed's one regression, mount-rows "memo + sync render effect only (reference)" (−13.3%), is runner noise. This PR only changes errorFamily in projection.ts, and that bench mounts plain memos and render effects with no store or projection. That reference bench swings 10–20% between runner types, and CodSpeed flags different hardware for 4 of the changed benches here.


Generated by Claude Code

@ryansolid
ryansolid merged commit 5315127 into next Oct 30, 2026
8 of 9 checks passed
ryansolid added a commit that referenced this pull request Oct 30, 2026
createStore and the compiled CSR app were over their caps on next after #4454
and #4457 landed together; raise both by the cap rule and re-record the
minified of every non-frozen scenario that moved.


Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

Co-authored-by: Claude <noreply@anthropic.com>
ryansolid added a commit that referenced this pull request Oct 30, 2026
…once (#4463)

* fix(signals): a derived store's propagated error reaches its readers once

propagateStatus handed the derive's family a status without its error, so
errorFamily's same-error skip could not apply and a sync derive over a sync
memo re-ran its reader's error arm twice in one flush (#4457 review).

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

* chore(size): re-measure over next @ 131ceb8; type the family status error

Raise the base + router and compiled hydrating caps (brotli layout from
+2 B minified) and give StoreFamily.st its error parameter.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
ryansolid added a commit that referenced this pull request Oct 30, 2026
…ad; seat rows D1''/L1''/V3'' (#4472)

* fix(signals): a netted write read in a strict untrack is no staged read; seat rows D1''/L1''/V3''

#4452 made a signal's staging equal to its committed value (writes that
netted, D-8) a read of the screen in `read`'s fast block. The reader-seat
table still raised Fl on that read (rows D1 / L1 / D7, and V3 for a
verdict reader), so the shadow's seat parted from the flags and every
later read of the pass reported too ("world after the read", "verdict
seat after the read", B2 "born held"): 8 INV-seat-3 classes (x59) in the
signals suite, 7 in web, and 60 in the generator (37 / 35 / 43 at 2,000
cases x seeds 3289 / 91501 / 7, every pair). Bisected over every merge
7135a77..131ceb8: 0 before #4452 (66c6f56), 60 at it, none added by
#4453, #4458, #4460, #4454, #4457 or #4455. The runtime follows the rule
(D-8: a tick whose writes net to the committed value made no proposal),
so the table was stale: rows D1''/L1'' (D7'' in a lane) and V3'' serve P
and raise nothing. Shadow: 0 classes in signals, web and the generator.

With the rows in, the shadow found the path #4452 missed: in dev a
labelled `untrack` read (`strictRead`) takes the slow path, where `serve`
still called `stagedRead`, so dev held the errored re-run that prod (fast
block) does not. `serve` now skips it for a netted signal in dev; prod
folds the test away (0 B on every scenario).

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

* fix(signals): gate serve's netted skip on the strict read, not on dev

Only the strict untrack read skips the fast block in dev; a hydration
snapshot or a promoted write reaches serve in dev and prod alike and keeps
the staged-read mark. 0 B in prod.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
@ryansolid
ryansolid deleted the claude/project-thread-x1ore2-4437 branch November 5, 2026 17:34
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