Skip to content

fix(signals): a memo waiting on a held flight is the hold's work at its landing (#4408, #4399) - #4510

Merged
ryansolid merged 8 commits into
nextfrom
claude/project-thread-x1ore2-4408
Oct 31, 2026
Merged

ryansolid merged 8 commits into
nextfrom
claude/project-thread-x1ore2-4408

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Summary

Before:

After: both pins in latest-two-readers-one-ask-4393 (the "beside" row) and latest-first-one-pass-4402 (#4399's split/value rows) pass as plain it, with their expectations unchanged. The #4408 trace now reads v1122, then c1101, then v1123, which is the shape #4393 had before #4397 merged.

How

Each decision is made at the event that makes the pass wrong, not by a per-pass check. Neither touches recompute's hot path, and both sit behind holdWorkHook or the lane engine, so bundles without them pay 0 B.

#4408 (verdict.ts, holdWork): m1 is an async memo over a verdict memo, with no isPending/latest window of its own. When m0 goes pending, holdWork makes m1 wait on m0's flight as the hold's work, but m1 kept CONFIG_VERDICT. At the park, m0's verdict-lane re-derive (#4393) then re-derived m1 as a verdict reader into the s lane early. The lane was unshown when m1:22 landed, so nothing held the frame m0b:1100 landed into.

holdWork now clears CONFIG_VERDICT when it says "wait" (D-25: the hold's work reads plainly; the pass's own reads stamp the posture again). recompute's head can't make this call: the landing settles the node before the pass runs, and a lane's node is never held.

#4399 (lanes.ts, enterLane): m1 is held by the frame m0b:20 landed into. Its pass re-entered that hold at its head, then read m0 (A's verdict lane) first and was moved into A's lane. There its read of its own hold's landing was the screen value (D-26), so it asked m1:15 again. In the other read order (m0b first), creatorReads already keeps the pass as its hold's work.

The guard gains one more case, in one mask test: a pass that is held, has no verdict posture, is not a frame, lagging or lane node, and whose hold is not l's parent stays its hold's work (D-25: either read order). Its own hold's verdict lane is its display-ahead read, and it still enters that one (#4050), which keeps the #4393 rows green.

Size (client, vs next d8ca095, brotli / minified, measured locally; no ledger edits in this PR):

Scenario Brotli Minified
signals: + isPending/latest +28 +63
app: hydrating + every store primitive family −22 +38
page: live server components +4 +63
page: compiled live server components +30 +85
page: live + router +28 +104

Hello world and every floor are unchanged (9,971 B). Bundles without the verdict engine are +0. The size check is expected to fail on isPending/latest, compiled live and live + router; the batch ledger PR re-records them.

Perf (callgrind on the prod dist, instructions per iteration): every changed bench moves by +0.005% or less (propagation:diamond +149, propagation:avoidable +118, create1to1 +447). None of those benches runs the changed code.

How did you test this change?

The issues' pins in tests/latest-two-readers-one-ask-4393.test.ts and tests/latest-first-one-pass-4402.test.ts flip from it.fails to it, with their expectations unchanged.

The async-gen differential was recorded on next with seeds 3289 and 91501 (2,000 cases each, all eight equivalence pairs) and replayed on the fix. 0 cases differ, and the finding signatures match next.

After merging next @ d8ca095:

  • signals: 6,109 passed, 12 expected fail, 2 skipped;
  • web: 1,581 passed, 7 expected fail, 1 skipped.

Closes #4408
Closes #4399



Generated by Claude Code

…ts landing (#4408, #4399)

#4408 (F-6 amended 2026-10-28, D-26): `latest(m2)` showed `c1101` while
`m1:1122` of the same lane was in flight. `m1` (an async memo over a
verdict memo, no window of its own) was waiting on `m0`'s flight as the
hold's work (verdict.ts `holdWork`), but kept CONFIG_VERDICT, so `m0`'s
verdict-lane re-derive at the park re-derived it into the s lane early;
the lane was unshown when `m1:22` landed and nothing held the frame
`m0b:1100` landed into. `holdWork` now drops the verdict posture at the
wait: the memo is the hold's work until a pass of it reads a verdict
again (D-25: re-run by the write its hold holds, it reads plainly). The
recompute head could not tell: the landing settles the node before the
pass, and a lane's node is not held.

#4399 (F-9, D-25): two actions, each holding a verdict memo's write,
asked `m1:15` twice. `m1`'s pass over `m0b`'s landing re-entered its hold
at its head, then read `m0` (A's verdict lane) first and was moved into
that lane, where its read of its own hold's landing was the screen
(D-26), so it asked `m1:15` again. In `enterLane`, a pass that re-entered
the hold holding its node at its head stays that hold's work when the
verdict lane is another hold's, as it already did when it read its
hold's staging first (D-25: either read order). Its own hold's verdict
lane is its display-ahead read and it still enters that one (#4050).

Both decisions sit at the event (the wait's start in `propagateStatus`,
a verdict-lane read in `enterLane`), behind `holdWorkHook` and the lane
engine: nothing in recompute's hot path, 0 B in bundles without them.
The pins in latest-two-readers-one-ask-4393 (beside) and
latest-first-one-pass-4402 (#4399 split/value) flip from it.fails to it.

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: 3565aa5

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 −3 B 7.43 KB ✅
signals: + createStore 14.97 KB 0 B 0 B 0 B 14.99 KB ✅
signals: + isPending/latest 11.38 KB +41 B (+0.4%) +71 B 0 B 11.40 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.04 KB 0 B 0 B −1 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.79 KB 0 B 0 B −3 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.78 KB 0 B 0 B 0 B 18.80 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.49 KB +64 B (+0.2%) +38 B 0 B 32.51 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.43 KB 0 B 0 B +13 B 13.47 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.18 KB 0 B 0 B +13 B 15.20 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.78 KB 0 B 0 B 0 B 29.80 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.98 KB 0 B 0 B −1 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.30 KB 0 B 0 B 0 B 26.36 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.50 KB 0 B 0 B 0 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.80 KB 0 B 0 B 0 B 33.81 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.19 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.07 KB −15 B (−0.0%) +71 B 0 B 39.10 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.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.69 KB 0 B 0 B +4 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.15 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 43.04 KB +13 B (+0.0%) +93 B 0 B 43.06 KB ✅ eager (counted): web.js 23.02 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.18 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.10 KB 0 B 0 B −53 B 41.25 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.13 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.68 KB +48 B (+0.1%) +112 B 0 B 48.69 KB ✅ eager (counted): client.js 28.16 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 ✅

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

Copy link
Copy Markdown
Member Author

The check job fails at the size gate. Three scenarios are over their brotli caps by more than the 20 B minified allowance:

Scenario Over cap Minified vs recorded
signals: + isPending/latest 16 B +63 B
page: compiled live server components 16 B +85 B
page: live + router 18 B +104 B

This is the fix's own code: the guard in enterLane and the drop in holdWork, both in the verdict and lane engines. Hello world and every bundle without isPending/latest are +0.

Per the batch process, this PR doesn't edit scripts/size. The batch ledger PR re-records these caps after merge. The growth has been flagged to the maintainer.


Generated by Claude Code

@coveralls

coveralls commented Oct 30, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41169306542

Coverage remained the same at 78.108%

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: 1363
Covered Lines: 1126
Line Coverage: 82.61%
Relevant Branches: 1090
Covered Branches: 790
Branch Coverage: 72.48%
Branches in Coverage %: Yes
Coverage Strength: 29.91 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 5.12%

2 warnings

⚠️ 3 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 benchmark with a significant change ran on different hardware

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

Learn more about Macro Runners

See affected benchmarks

⚡ 1 improved benchmark
✅ 199 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ polymorphic-chain mount+clear 1000 rows: compiled 427.8 ms 407 ms +5.12%

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-4408 (3565aa5) with next (730c9ef)

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

…ct read (#4408 after #4504; D-25)

After #4504, `m2 = m0b() + 1` (m0b a verdict memo) re-ran at the t landing
while m0b was pending, threw, and got no posture (#4504: a pending read
hands on none). Its next run, at m0b:1100's landing into the frame that
m1:22's landing opened, was plain: it staged 1101 there, and `latest(m2)`
showed it through that frame's verdict lane while m1:1122 was in flight
(c1101 before v1122).

#4504's rule rests on D-25's "the hold's own work ... reads plainly". It now
applies only to such a pass: one born in the hold's world (uninitialized,
D-27) or one over a staged pass of its own. A memo with a committed value
and nothing staged is no hold's work. Its posture is what it reads, and it
lasts "until one completes having asked nothing" (D-25, ruled 2026-10-28);
a pass that threw on a pending read did not complete. The line stays behind
observeFlightHook.

holdWork returns from the wait branch instead of keeping a local
(-10 B minified).

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

Copy link
Copy Markdown
Member Author

I've re-fixed this after #4504 merged. That merge broke the beside row in latest-two-readers-one-ask-4393.test.ts, and 16f33ea fixes it.

The posture that went missing was m2's (m2 = m0b() + 1), not m1's. When T3 lands, m2 re-runs while m0b is still pending. Under #4504 that read stamps no posture, so m2's next pass is plain, and latest(m2) shows 1101 through T7's lane while m1:1122 is still in flight.

The fix limits #4504's no-stamp rule to passes that are the hold's own work, per D-25 "born in its world … or re-run by the write it holds". Those are:

  • an uninitialized node (D-27);
  • a node whose earlier pass is still staged for the commit.

A memo with a committed value and nothing staged keeps its posture. D-25's "a memo's posture is what it reads" applies to it (#4050), and so does the 2026-10-28 ruling that a posture lasts until a pass completes having asked nothing. A pass that threw on a pending read did not complete. Each narrower form fails pins: uninitialized-only fails 5 of #4504's pins, and staged-only fails the 3 mid-flight pins.

I also took one size cut from review. holdWork returns (n._config &= ~CONFIG_VERDICT) from the if, which is −10 B minified. The txOf(c) !== hold guard in enterLane is load-bearing: without it, 18 pins fail.

Tests after merging next:

  • signals: 6,130 passed / 12 expected fail / 2 skipped;
  • web: 1,583 passed / 7 expected fail;
  • web hydrate config: 716 passed / 10 expected fail;
  • web test-types passes.

The one web server failure (server-functions-adapter-request.spec.tsx) also fails on next @ 71a6a75 in this environment.

Callgrind over prod/index.js (diamond, avoidable, update1to1, update1to1000, create1to1, effects) is within ±0.002%.

Size vs next @ 71a6a75 for the whole PR, brotli / minified:

Scenario brotli minified
isPending/latest +36 +71
live server components +34 +71
compiled live server components +43 +93
live + router +32 +112
hydrating + every store −22 +38

Hello world is unchanged. These rows are over their caps. The maintainer's size call is pending, and the caps move in the batch ledger.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

The job check failed on one signals test: tests/treeshake.test.ts › "core floor sheds every optional feature module" hit vitest's 5000 ms timeout after 5295 ms. It made no assertion failure.

I don't think this PR causes it:

  • This test is the first cold Rolldown bundle in that file, and it has timed out the same way on other PRs today under full-suite load, then passed when re-run.
  • The diff doesn't change what that bundle contains, only a condition inside read and holdWork.
  • Locally, the full signals suite passes on 5429aa0: 6,133 passed, 11 expected fail, 2 skipped.

I've re-run the failed job once. The size gate is still red on its own growth, which is waiting on the maintainer's size call (see above).


Generated by Claude Code

Copy link
Copy Markdown
Member Author

CodSpeed flags two regressions on 5429aa0. I read both as noise:

  • arrow in template (−12%): a server renderToString bench on the known-noise list. next recorded a fast outlier there.
  • polymorphic-chain mount+clear 1000 rows: compiled (−5.2%): the same −5% showed up on fix(signals): four optimistic store bugs (#4480, #4489, #4482, #4484) #4511, whose diff never touched read. CodSpeed also reports that 3 of the changed benches ran on different hardware for base and head.

This PR's change to read sits inside the observeFlightHook branch, which needs the verdict engine to be installed. Callgrind with CodSpeed's flags over prod/index.js (diamond, avoidable, update1to1, update1to1000, create1to1, effects) is within ±0.002% of next.


Generated by Claude Code

ryansolid added a commit that referenced this pull request Oct 30, 2026
…nners (#4516)

* test(signals): give the tree-shaking suite a 30 s timeout

Every test in treeshake.test.ts runs a full Vite build of a fixture. The
first one pays the bundler's cold start, and under the whole suite's load
on CI it has run past vitest's 5 s default (5,295 ms on #4510, and on
other PRs earlier today) without any assertion failing.

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

* test(signals): widen the attribution self-time gap to 40 ms

"measures self-time and aggregates costs by scope and root write"
ranks a 10 ms memo above a cheap effect by wall-clock self-time. On this
PR's CI run a scheduling pause inside the effect measured 17.9 ms against
the memo's 10.1 ms. A 40 ms spin keeps the ranking meaningful under load.

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 31, 2026
Re-records the rows #4511 left over cap or more than 20 B minified past
the ledger on next @ 6bde511, and records #4510's growth (accepted by the
maintainer, 2026-10-31) so it merges green once next is merged into it.


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

Co-authored-by: Claude <noreply@anthropic.com>
@ryansolid
ryansolid merged commit 2ba10bd into next Oct 31, 2026
8 checks passed
@ryansolid
ryansolid deleted the claude/project-thread-x1ore2-4408 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