Skip to content

fix(signals): a derived store's readers settle with its derive, not by a re-run - #4493

Merged
ryansolid merged 13 commits into
nextfrom
claude/project-thread-x1ore2-store-refetch-error
Oct 30, 2026
Merged

ryansolid merged 13 commits into
nextfrom
claude/project-thread-x1ore2-store-refetch-error

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Before: take a derived store createStore(d => { d.v = m0() }) over an async memo m0. The derive goes pending on m0's flight, and the flight then settles without the derive re-running. That happens when a rejection is staged in the flight's transaction, or when a re-ask returns to the committed question. The settle walk retired m0 from the derive, but the store's readers wait on the derive itself (wakeFamily), and nothing retired that. So isPending of the store stayed true forever (F-9), and the staged rejection's transaction never landed, so the error never showed (F-12). The memo form, m0() read directly, settles in both cases.

After: the store reads like the memo. A re-ask that nets to the committed question ends isPending, and a refetch's rejection reaches the store's readers, once.

How:

  • In settlePendingSource (core/async.ts), when the walk fully settles a node that has no blocked pass to re-run, it settles a derived store's family (fam?.st()). That is how a memo's readers settle through its dependents (F-9, A9). It runs only when a settle walk fully settles a node, so there is no per-pass cost.
  • That arm is gated on storeParkHook, which only the store module sets, so store-free bundles fold it away and pay 0 B.
  • In familyStatus (store/projection.ts), no status means "settle the readers" (settleFamily). The _blocked mark wakeFamily used to set is gone.
  • The derive is not re-run. A re-run would ask the question again: an async derive's new flight is a second wait, and it would run ahead of the staged rejection's publish, which reaches the derive at the commit (D-41). The error-once contract from fix(signals): a derived store's propagated error reaches its readers once #4463 (derived-store-error-delivered-once.test.ts) holds.

Size (client, vs next 643a7a7, brotli / minified; no ledger edits in this PR):

Scenario Brotli Minified
signals: + createStore 14,960 → 14,957 (−3) +26
app: hydrating + every store primitive family 32,470 → 32,465 (−5) +26
app: compiled CSR 26,267 → 26,306 (+39) +26
app: compiled hydrating 32,481 → 32,475 (−6) +26
page: base server components 33,735 → 33,744 (+9) +15
page: live server components 38,969 → 38,950 (−19) +15
page: compiled base server components 35,662 → 35,725 (+63) +6
page: compiled live server components 42,931 → 42,916 (−15) +15
page: base + router 41,132 → 41,221 (+89) +15
page: live + router 48,569 → 48,594 (+25) +15

Every store-free scenario is unchanged, hello world included (9,973 B). The ungated form put hello world at 9,993 B, over its 9.98 KB cap, from −3 B minified.

How did you test this change?

  • New packages/signals/tests/derived-store-derive-settle.test.ts and packages/web/test/async-gen/store-derive-settle-e3.spec.ts.
  • After merging next @ 643a7a7: signals 6,082 passed / 17 expected fail; web 1,581 passed / 7 expected fail; web hydrate 716 passed / 10 expected fail.

Found by the async-gen rerun on rc.20 (pair E3, rows #1 and #9).


🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

… on settles

`createStore(fn)` over an async memo. The derive went pending on the memo's
flight by propagation (`familyStatus` -> `wakeFamily`), and the store's
readers were parked on the derive itself. When the flight then settled
without the derive re-running (a rejection staged in its transaction, or a
re-ask that returned to the committed question), the settle walk retired
the memo from the derive but never reached the readers parked on it:
`isPending` stayed true forever (F-9), and the staged rejection's
transaction never landed, so the error never showed (F-12, D-41). The wake
now marks the derive blocked, as a pass that threw is, so the settle
re-runs it and its commit settles the family.

Found by the async-gen rerun on rc.20 (pair E3, rows #1 and #9).

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

The derive pending by propagation (familyStatus -> wakeFamily) was marked
blocked so its source's settle re-ran it. For an async derive that re-run
is a new flight: when the source's rejection was staged, the walk in
stageError re-ran the derive in the same flush, ahead of the commit that
publishes the error to it. The new flight woke the family again, so a
reader beside isPending showed a second pending frame before the error
(#4463's error-once test, D-41).

The settle walk now settles the family instead (familyStatus with no
status -> settleFamily) when it retires a derive's pending without a pass
to re-run, as a memo's readers settle through its dependents (F-9, A9).
The error still reaches the derive at the commit (F-12, D-41), and the
re-ask that nets to the committed question ends isPending (F-9).

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: 39c2377

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.41 KB 0 B 0 B +15 B 7.43 KB ✅
signals: + createStore 14.96 KB −3 B (−0.0%) +26 B +32 B 14.97 KB ✅
signals: + isPending/latest 11.27 KB 0 B 0 B 0 B 11.29 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.79 KB 0 B 0 B +15 B 9.78 KB ⚠️ over by 8 B, 5 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.72 KB 0 B 0 B 0 B 18.74 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.47 KB −5 B (−0.0%) +26 B +26 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 0 B 0 B +9 B 15.20 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.76 KB 0 B 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.31 KB +39 B (+0.1%) +26 B +39 B 26.33 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.48 KB −6 B (−0.0%) +26 B +33 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.74 KB +9 B (+0.0%) +15 B +15 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.11 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 38.95 KB −19 B (−0.0%) +15 B +15 B 38.98 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.10 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.73 KB +63 B (+0.2%) +6 B +6 B 35.72 KB ⚠️ over by 5 B, 14 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.05 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 42.92 KB −15 B (−0.0%) +15 B +15 B 42.95 KB ✅ eager (counted): web.js 22.93 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.08 KB, sc-comments.js 0.19 KB, trace.js 9.10 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.22 KB +89 B (+0.2%) +15 B +15 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.59 KB, trace.js 9.07 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.59 KB +25 B (+0.1%) +15 B +15 B 48.58 KB ⚠️ over by 14 B, 5 B minified headroom eager (counted): client.js 28.09 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: render + one signal (the simple-app floor): over brotli cap by 8 B; minified 26,754 B vs 26,739 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 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 5 B; minified 108,054 B vs 108,048 B recorded with the cap (+6 B) — 14 B of the 20 B minified allowance left; +6 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 14 B; minified 143,284 B vs 143,269 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +15 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 41118336775

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

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 12.41%

1 warning

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

❌ 1 regressed benchmark
✅ 199 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ projection derive: write one NESTED field (reference) 2.1 ms 2.4 ms -12.41%

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-store-refetch-error (39c2377) with next (14a8190)

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

…tch PR)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
On next @ 643a7a7 the ungated `else (node as any).fam?.st()` laid out
+20 B brotli on hello world (9,993 B, over its 9.98 KB cap) from -3 B
minified. A derive's family exists only when the store module is loaded,
which is what sets `storeParkHook`, so the arm is gated on it and folds
away in store-free bundles: every one is 0 B vs next, hello world
included (9,973 B). The settle walk's `_blocked` clear is back to next's
form.

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

ryansolid commented Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

Two things on this PR's checks:

Size gate (check). It isn't this PR's. next @ 643a7a7 was already over the caps of isPending/latest, live server components, compiled live server components and live + router after the 2026-10-30 batch. #4501 re-records the ledger, and this branch now carries it. The current head gates the family settle on the store module, so store-free bundles, hello world included, are 0 B vs next.

CodSpeed. Its flags are three reference benches that move without code on their path:

  • arrow in template swings with runner type.
  • memo + sync render effect only (reference) swings with runner type, and CodSpeed flags both runs as on different hardware.
  • projection derive: write one NESTED field (reference) swings ±13–15% with module byte size alone: a V8 lazy sweep after the pre-run gc() either lands in the single measured run or misses it.

This PR's settle arm runs only when a settle walk fully settles a node, not on a write pass.


Generated by Claude Code

@ryansolid
ryansolid merged commit 20c115c into next Oct 30, 2026
8 of 9 checks passed
ryansolid added a commit that referenced this pull request Oct 30, 2026
…4499, #4500, #4493, #4488, #4498) (#4507)

next @ b5077ea is over six caps after the batch merged back to back (each
fix PR carried no ledger edits, per the batch process). Re-records,
measured over next @ b5077ea (linux):

- signals: + createStore: 14.97 -> 14.99 KB, minified 43,924 -> 43,956
- signals: + isPending/latest: 11.29 -> 11.35 KB, minified 31,101 -> 31,245
- app: compiled CSR: 26.33 -> 26.36 KB, minified 80,035 -> 80,074
- page: live server components (frozen floor): 38.98 -> 39.10 KB,
  minified 117,858 -> 118,033
- page: compiled live server components: 42.95 -> 43.01 KB,
  minified 126,018 -> 126,181
- page: live + router: 48.58 -> 48.65 KB, minified 143,269 -> 143,439

and the recorded minified only (caps unchanged) where the batch drifted
more than 20 B: hydrating + every store (98,535 -> 98,561), compiled
hydrating (100,803 -> 100,836), base server components (frozen,
101,675 -> 101,706), compiled base server components (108,048 ->
108,081), base + router (125,503 -> 125,539).

Hello world is 9,971 B (cap 9.98 KB). The build leaves the pinned
mangle-props.json unchanged.


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

Co-authored-by: Claude <noreply@anthropic.com>
@Peyton-Spencer

Copy link
Copy Markdown

Confirmed this failure in an app on rc.19, with a standalone reproduction and a tested rc.19 runtime backport: https://gist.github.com/Peyton-Spencer/edae55128c740f01357d116844e3f7bd

The reproduction refetches an async memo, resolves it to the same value, and checks a memo that reads an unchanged projection leaf. After draining microtasks and scheduler flushes, isPending(status) stays true.

Results on macOS / Node 26.8.2:

Runtime Production Development Observe
Published @solidjs/signals@2.0.0-rc.19 fails fails fails
rc.19 with the attached backport passes passes passes
Published @solidjs/signals@2.0.0-rc.20 fails fails fails
Current next, d9b5a79e8c85b43e173696a570a0ea75fc687626 passes passes passes

For next, I bundled the source with esbuild using each tier's __DEV__, __OBSERVE__, and __TEST__=false settings. That row checks source behavior, not release packaging. The reproduction uses createProjection on rc.19 and the derived createStore overload when createProjection is absent.

The gist includes repro.mjs, commands to reproduce and apply the patch, and rc19-backport.patch for all three published runtime tiers. The app-level symptom was completed async work leaving replies hidden or a responding state stuck. The backport restored that flow in our app.

Your merged source fix passes this reproduction on current next. The rc.20 package was published on October 29, before this PR merged on October 30. This records an independent reproduction and a backport for users on the published RCs.

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.

4 participants