Skip to content

fix(signals): a held filter change commits whole past an optimistic refresh (#4576, #4577) - #4585

Open
ryansolid wants to merge 7 commits into
nextfrom
claude/project-thread-c5xes4-4576
Open

ryansolid wants to merge 7 commits into
nextfrom
claude/project-thread-c5xes4-4576

Conversation

@ryansolid

@ryansolid ryansolid commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Requested by Ryan · project thread

Summary

A filter change held with an optimistic action never committed when a second async memo's answer to the new filter came in after the action's refresh had answered (#4576, the shared, memo probe variant; the other bindings already settled on next after #4564).

Root cause

The list derivation (createMemo over the store) counts as lane work because it reads the guess. While the action's refresh(issues) was running, that derivation got the committed rows through frameRead, so laneServed dropped the refresh from its own pending sources (#4397). The render effect that the refresh's pending had already reached through the derivation kept the mark. When the refresh landed, its settle walk stopped at the derivation, which no longer carried that source, so it never reached the effect. The filter's transaction stayed blocked on a reader pending on a flight that had already landed, with nothing left to wake it.

Fix

A lane re-based over a transaction's answer (#4536) reads that transaction's world in every flush, including when the transaction has merged since. Before, it only did so in that transaction's own flush (core.ts frameRead, which compares resolveTx(passLane.over) with the transaction). Lane passes run in other flushes: the action's refresh, the refetch landing, or another change merged into the frame. There they read the screen, so the landing committed values derived over the old inputs.

The first commit on this PR retired the carried pending in laneServed. That turned out to treat a symptom of the same cause, and it has been reverted: laneServed is back to its next form.

The laneSeams re-derive also skips lane work re-based over the parked frame (liveTx(l.over) === t), because its rows already belong to that frame's answer.

Rules

  • F-2: a held change commits once the async it needs has answered.
  • D-11: a hold lasts only while an on-screen reader observes a flight it waits on.
  • D-9: what shares an on-screen async derivation settles as one.
  • F-1: every frame on the way is whole.

Tests

  • New optimistic-refresh-late-shared-memo-4576.test.ts covers shared/split × inline/memo bindings. It checks the final frame (closed: [#2] | O-closed), that no ask is left, and that every recorded frame is whole. The failing pin is shared, memo: it fails on next and passes here. The other three already pass on next and stay as regression pins.
  • New optimistic-guess-second-async-memo-4577.test.ts is the [2.0] Filter change never commits when a second async memo reads the optimistically guessed list #4577 shape. Every frame must be whole: one memo's two readers agree, the shared memo's filter matches the screen's, and other matches the filter. It passes on next, which stays stuck on the old but whole screen. It failed on this PR's first commit at closed|[#2]|[#2]|open:[#2]|O-open, and passes now.
  • New optimistic-guess-entangled-async-4577.test.ts: review's mid-hold setTag repro plus a 12-case matrix (when setTag runs × whether other reads the list × shared/split). The repro and the 6 list-reading cases fail at closed: [#2] | O-open on 4844d77 and pass now.
  • Full signals run (npx vitest run): 6216 passed. The only failures are dist-artifacts.test.ts, which needs a built dist/.
  • Targeted vitest on source over the optimistic/lane/async/transition/action/verdict/store/boundary files: 3403 passed, 5 expected fail, 2 skipped. Full suites come from CI.

#4577 (the second memo reads the guessed list)

It has the same cause. A second async memo reads the guessed list, the lane's passes outside the filter's flush re-asked other:open, and the landing showed open:[#2] and O-open beside closed. It also tore when another async change (setTag) merged into the frame mid-hold.

Following the batch process, this PR leaves scripts/size alone.

Fixes #4576
Fixes #4577


Generated by Claude Code

…t carried (#4576)

A derivation that is lane work (it reads an optimistic guess) and is served
the committed value of a refreshing store drops that refresh from its own
pending sources (`laneServed`, #4397). The render effect the refresh's
pending had propagated onto through it kept the mark: the refresh's landing
walk stopped at the derivation, which no longer carried the source, so the
effect stayed pending on a flight that had already landed. A filter change
held with it never committed once the second async memo answered last.

The drop now retires the source from the dependents that carried it through
the derivation (`settlePendingSource`, which keeps it where another path
still reaches it). A hold lasts only while an on-screen reader observes a
flight it waits on (D-11), so the change commits once every ask has
answered (F-2), settling as one with what shares the screen (D-9) and whole
on the way (F-1). Lanes engine only; no core-path change.

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

codspeed Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 5.77%

1 warning

⚠️ 1 benchmark is 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 improved benchmark
✅ 199 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 32 ms 30.2 ms +5.77%

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-c5xes4-4576 (ff4eeaa) with next (9034647)

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

CodSpeed's two flagged benches aren't from this change:

  • arrow in template (server renderToString) and memo + sync render effect only (reference) (mount-rows) are the two benches known to swing between runner types. CodSpeed says both ran on different hardware for base and head, and fix(signals): a chain's join keeps the staged-error mark mid-hold, like a diamond's (#4562) #4582 and the cut PRs showed the same pair at ±6–10% this morning.
  • This diff only changes laneServed in the lanes engine (installed by installLaneHooks). Neither bench installs that engine or holds a lane.

No perf fix is needed. createRenderEffects:create1to1 and the other render benches are untouched.


Generated by Claude Code

@changeset-bot

changeset-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ff4eeaa

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

@ryansolid ryansolid self-assigned this Nov 1, 2026
@coveralls

coveralls commented Nov 1, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41298035558

Coverage remained the same at 78.134%

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: 1367
Covered Lines: 1129
Line Coverage: 82.59%
Relevant Branches: 1098
Covered Branches: 797
Branch Coverage: 72.59%
Branches in Coverage %: Yes
Coverage Strength: 30.03 hits per line

💛 - Coveralls
@github-actions

github-actions Bot commented Nov 1, 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.43 KB 0 B 0 B −108 B 7.43 KB ✅
signals: + createStore 15.00 KB 0 B 0 B 0 B 15.01 KB ✅
signals: + isPending/latest 11.47 KB +37 B (+0.3%) +39 B +70 B 11.44 KB ❌ over by 34 B
signals: + until/refresh (truth readers, no optimism) 8.05 KB 0 B 0 B −101 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.80 KB 0 B 0 B −167 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.80 KB 0 B 0 B −203 B 18.80 KB ⚠️ over by 2 B, 223 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.64 KB −12 B (−0.0%) +40 B −27 B 32.68 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.50 KB 0 B 0 B 0 B 13.52 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.25 KB 0 B 0 B 0 B 15.26 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.84 KB 0 B 0 B −157 B 29.88 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 −204 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 0 B 0 B −215 B 26.36 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.56 KB 0 B 0 B −244 B 32.61 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.84 KB 0 B 0 B −211 B 33.81 KB ⚠️ over by 25 B, 231 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.21 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.25 KB +18 B (+0.0%) +40 B −23 B 39.10 KB ⚠️ over by 150 B, 43 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.18 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.74 KB 0 B 0 B −240 B 35.74 KB ⚠️ over by 3 B, 260 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.18 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 43.22 KB +20 B (+0.0%) +39 B +38 B 43.19 KB ❌ over by 26 B eager (counted): web.js 23.08 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.24 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.16 KB 0 B 0 B −11 B 41.33 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.58 KB, trace.js 9.14 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.84 KB +73 B (+0.1%) +46 B +99 B 48.82 KB ❌ over by 17 B eager (counted): client.js 28.23 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.14 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.85 KB 0 B 0 B 0 B 22.87 KB ✅

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

  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 2 B; minified 53,414 B vs 53,617 B recorded with the cap (−203 B) — 223 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 25 B; minified 101,537 B vs 101,748 B recorded with the cap (−211 B) — 231 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 150 B; minified 118,117 B vs 118,140 B recorded with the cap (−23 B) — 43 B of the 20 B minified allowance left; +40 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 3 B; minified 107,876 B vs 108,116 B recorded with the cap (−240 B) — 260 B of the 20 B minified allowance left; +0 B minified over this PR's base

❌ Fails the gate

  • signals: + isPending/latest: over brotli cap by 34 B; minified 31,397 B vs 31,327 B recorded with the cap (+70 B) — 50 B past the 20 B minified allowance; +39 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 26 B; minified 126,279 B vs 126,241 B recorded with the cap (+38 B) — 18 B past the 20 B minified allowance; +39 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 17 B; minified 143,612 B vs 143,513 B recorded with the cap (+99 B) — 79 B past the 20 B minified allowance; +46 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)

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

…d on the committed world (#4577)

Re-derived at the seam, a re-based lane pass paired the frame's new rows
with the old filter, and the landing then showed that beside the new
filter (F-1, D-34 ¶3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
@ryansolid ryansolid changed the title fix(signals): a lane-served read retires the refresh from readers it reached through the memo (#4576) Nov 1, 2026

Copy link
Copy Markdown
Member Author

The red check job on 4844d77 is the size gate on "signals: + isPending/latest", which is 13 B over its brotli cap. Most of that is drift already on next: the row is +31 B minified since #4582. This PR's lane change adds a few bytes. Per the batch process, the cap is raised in the ledger re-record after the batch merges, not in this PR. No other row fails.


Generated by Claude Code

… through merges (#4577)

A second async change merged into the parked filter frame mid-hold; the
lane's later passes then read the screen and re-asked over the old
filter, and the landing committed that beside the new one (F-1, D-9).
This also covers #4576, so laneServed is back to its next form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
Without it a nested action's refresh answers were lost (the store ended
[2, 5] instead of [5]).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants