Skip to content

fix: optimistic release without torn frames (sink slot records, keyed guess fold) - #4210

Merged
ryansolid merged 10 commits into
nextfrom
claude/project-thread-f38ibp
Oct 22, 2026
Merged

ryansolid merged 10 commits into
nextfrom
claude/project-thread-f38ibp

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Requested by Ryan · project thread

Summary

This PR fixes two places where releasing an optimistic value could show a frame that is torn or can't be reconciled.

1. @solidjs/web: a sweep's slot re-emissions join the sweep's unit

A live server component could send a torn write. Each arg binding of a slot occurrence re-sends the whole slot record as it sweeps. frame-sink.ts slot() called put directly, so the first copy went out in its own message. That copy carried the other args' previous values, and it went out before the message carrying the holes from the same commit.

In the chat example, one commit went out as slot:composer { seen: <new>, busy: false } on its own, then slot:composer { …, busy: true } + hole:lh:2. The client applies each message in its own flush, so the pending bubble left the outbox before the thread showed the server's row. In Chromium the two landed 0.4 to 7 ms apart, sometimes in separate tasks.

Inside a sweep, slot() now writes into the sweep's unit (swept.r), as hole() and attr records already do (C13: one sweep, one frame). Outside a sweep, nothing changes.

2. @solidjs/signals: a shown keyed arrangement guess folds a correction instead of voiding it

Two overlapping actions push onto one keyed optimistic store, and the stream echoes the first write while the second is open. The correction dissolved the lane. For that window the store read torn: a hole at the first row's index, length taken from the guess, and a raw StoreTarget served for a row. At the commit, the confirmed row remounted twice. In the board example this destroyed an open title editor and its draft.

A keyed arrangement guess is rows by key. So when a correction differs from a guess the screen has shown, it now folds into the hold beneath the guess. RULES-DERIVED D-34 is amended to say so, and F-11 still holds: the screen keeps what it showed until the hold commits.

  • The guess stays the lane's value and the screen's until the commit. At the commit, rows match by key.
  • A guess the screen never showed is void at the correction, as D-34 says for any guess.
  • Deriving passes outside the lane read the hold's world (each guess's truth, else what it covered) and join the hold, as for any held truth (2.0.0-rc until doesnt entangle the update resolving it with the action update #3164). latest() shows that world.
  • until and resolve keep their D-31 postures. In laneRead, the fold's arm comes before the authoritative arm on purpose. It serves an until outside the lane the same truth _truth would, and also joins it to the hold, which is its posture over a foreign hold. With the arms reversed, three pinned D-31 posture tests fail, and a new test in optimistic-keyed-fold.test.ts pins the order.
  • Keys born under a folded guess are held with it.
  • A folded lane's list pass in a frame that already has a transaction reads that frame's stagings as the screen, as a verdict reader does. Without this, a list pass could pair the truth's length with the guess's rows and hand a mapper undefined. The check is gated on the lane engine, so the floors don't carry it.

The fold lives in store/optimistic.ts. The lane engine reaches it through the existing _laneRebase hook, which now takes an older flag and handles both the re-base and the fold. A keyed arrangement guess is recognized by its view's removed-keys set, which the setter now always gives a keyed array's view. A folded lane carries the store's reader of it as Transaction.folded. That property is deliberately not _-prefixed, because a new mangled name shifts every later one in the dist.

No existing test changed.

Size

Size-Exception: the maintainer accepted the keyed-fold size cost (2026-10-22). After merging next @ db8241c (#4213 re-recorded the floors), the live server components frozen floor carries the lane engine's fold hooks (+110 B, as + isPending/latest).

The other floors are +0 B minified. Following review, the fold was trimmed from the first version: + isPending/latest went from +142 to +110 B, and the all-stores app from +694 to +569 B. Every cap below was re-measured over next @ db8241c, has a ledger entry, and was accepted by the maintainer:

scenario minified vs next new cap
+ createStore +44 B 15.04 KB
+ isPending/latest +110 B 10.30 KB
hydrating + every store primitive family (carries the fold itself) +569 B 30.66 KB
compiled hydrating (todo app, store) +44 B 32.23 KB
page: live server components (frozen floor, floor-caps.json) +125 B 37.64 KB
page: compiled live server components +110 B 41.47 KB
page: live + router +110 B 47.01 KB

CSR is +0 B minified and compiled CSR is +44 B; both stay under next's caps. All numbers were measured locally on Linux; earlier rounds matched CI exactly.

How did you test this change?

  • test/server/frame-sweep-ops.spec.tsx: one commit produces exactly one batch, [slot, hole], with busy: true. It fails on next.
  • tests/optimistic-keyed-fold.test.ts has 9 cases:
    • overlapping pushes, flat and nested;
    • another tab's row landing mid-write (it folds until settle and keeps identity);
    • overlap plus an unrelated change;
    • the second push timing out;
    • a mainline until over a folded lane, which delivers at the commit (D-31);
    • a keyed guess the screen never showed, which is void at the correction;
    • a truth longer than a reorder guess, in the action and on mainline.
  • Test runs:
    • signals: 5460 passed after merging next.
    • solid: 882/882.
    • web client: 1407/1407.
    • web hydrate: 520 passed.
    • web server: 1763 passed and 1 failed. The failure is the server-functions-adapter-request "rebuild failure" test, which fails only on Node 22.22 here.
  • The size gate passes locally.
  • CodSpeed flagged "memo + sync render effect only" and "props chain depth 1". Neither reproduces: Callgrind on the built packages counts fewer instructions on this branch than on next for both. CodSpeed also warns that base and head ran on different runner types.
  • Chromium:
    • Chat, 5 of 5 runs: one wire write.
    • Board: overlapping creates, the server applying writes after 300 ms, and O1's title editor open with a draft. Before this change, O1's <li> went #5 → #7 → #8 and focus ended on <body>. Now it keeps its identity and the editor keeps focus and its draft.

🤖 Generated with Claude Code

https://claude.ai/code/session_015SnZadCVdDSeDzxBUhcMU9

Each arg binding of a slot occurrence re-sends the whole record as it
sweeps, so the first copy carried the other args' previous values and
went out alone, ahead of the holes the same commit moved. The chat's
outbox dropped a pending message before the thread showed its row.
Inside a sweep the record now joins the sweep's unit like hole and attr
records (C13): the pass's last re-emission stands and leaves with them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015SnZadCVdDSeDzxBUhcMU9
@ryansolid ryansolid self-assigned this Oct 21, 2026
@changeset-bot

changeset-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3cd7d4e

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

This PR includes changesets to release 14 packages
Name Type
@solidjs/signals Patch
@solidjs/web Patch
test-integration Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
board-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/compiler Patch
solid-js Patch
@solidjs/universal 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 21, 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 −8 B (−0.1%) 0 B 0 B 7.44 KB ✅
signals: + createStore 15.03 KB +33 B (+0.2%) +44 B 0 B 15.04 KB ✅
signals: + isPending/latest 10.28 KB +45 B (+0.4%) +110 B 0 B 10.30 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.04 KB 0 B 0 B 0 B 8.05 KB ✅
app: render + one signal (the simple-app floor) 9.73 KB −10 B (−0.1%) 0 B 0 B 9.75 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.37 KB +2 B (+0.0%) 0 B 0 B 18.38 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.64 KB +239 B (+0.8%) +569 B 0 B 30.66 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.32 KB +4 B (+0.0%) 0 B 0 B 13.33 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.06 KB 0 B 0 B 0 B 15.08 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.62 KB 0 B 0 B 0 B 29.64 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.92 KB −2 B (−0.0%) 0 B 0 B 9.93 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.22 KB +2 B (+0.0%) +44 B 0 B 26.23 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.22 KB +41 B (+0.1%) +44 B 0 B 32.23 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.76 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.22 KB −35 B (−0.1%) +8 B +8 B 33.27 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.10 KB, wire.js 2.78 KB
page: live server components (base + live/GET + action + isPending/latest) 37.63 KB +115 B (+0.3%) +125 B 0 B 37.64 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.09 KB, wire.js 2.78 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.38 KB +113 B (+0.3%) +8 B +8 B 35.30 KB ⚠️ over by 84 B, 12 B minified headroom assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.09 KB, wire.js 2.77 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.45 KB +49 B (+0.1%) +110 B 0 B 41.47 KB ✅ eager (counted): web.js 22.05 KB; assets.js 0.78 KB, decode.js 6.25 KB, regions.js 1.06 KB, sc-comments.js 0.19 KB, trace.js 9.13 KB, wire.js 2.77 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.74 KB −27 B (−0.1%) 0 B 0 B 40.78 KB ✅ assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.02 KB, serverForms.js 3.58 KB, trace.js 9.14 KB, wire.js 2.77 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.99 KB +51 B (+0.1%) +110 B 0 B 47.01 KB ✅ eager (counted): client.js 27.17 KB; assets.js 0.78 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.02 KB, serverForms.js 3.32 KB, trace.js 9.14 KB, wire.js 2.80 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.63 KB 0 B 0 B 0 B 20.65 KB ✅

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

  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 84 B; minified 107,884 B vs 107,876 B recorded with the cap (+8 B) — 12 B of the 20 B minified allowance left; +8 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 21, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39704959958

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 77.662%

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: 1349
Covered Lines: 1105
Line Coverage: 81.91%
Relevant Branches: 1055
Covered Branches: 762
Branch Coverage: 72.23%
Branches in Coverage %: Yes
Coverage Strength: 27.53 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 24.38%

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

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

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

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ build + consume 289.3 µs 596.6 µs -51.5%
⚡ memo + sync render effect only (reference) 33.9 ms 28.7 ms +17.9%

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-f38ibp (3cd7d4e) with next (db8241c)

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

… voiding it

Two overlapping actions pushing onto one keyed optimistic store: when the
stream echoed the first write while the second was open, the correction
dissolved the lane. For that window the store read torn (a hole at the
first row's index, `length` from the guess, a raw target served for a row),
and at the commit the confirmed row remounted twice, which destroyed an
open editor and its draft in the board example.

A keyed arrangement guess is rows by key, so a current truth that differs
now folds into the hold beneath it (D-34, amended): the guess stays the
lane's value and the screen's until the hold commits, and rows match by key
there. Deriving passes outside the lane read the hold's world (each guess's
truth, else what it covered) and join the hold, as for any held truth
(#3164); `latest()` shows it; `until`/`resolve` keep their postures. Keys
born under a folded guess are held with it. Lane work in a frame that
already has a transaction reads that frame's stagings as the screen, as a
verdict reader does, so a list pass never pairs the truth's length with the
guess's rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015SnZadCVdDSeDzxBUhcMU9
@ryansolid ryansolid changed the title fix(web): a sweep's slot re-emissions join the sweep's unit fix: optimistic release without torn frames (sink slot records, keyed guess fold) Oct 21, 2026
The fold of a correction under a keyed arrangement guess moves into
store/optimistic.ts behind GlobalQueue hooks (`fold`, `foldedRead`,
`foldedHold`), so the lane engine and plain stores carry only the calls.
The hooks and `Transaction.folded` are not `_`-prefixed: a new mangled name
shifts every later one in the dist and costs the floors bytes. The lane
staged-screen read is gated on the lane engine, so the core floor is
unchanged.

Caps raised with ledger entries, pending the maintainer's acceptance:
+ createStore (+35 B minified), + isPending/latest (+142 B), hydrating +
every store primitive family (+694 B: it carries the fold itself), and CSR
(+0 B minified; brotli layout noise from the shifted mangled names on a
scenario next already had at the allowance).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015SnZadCVdDSeDzxBUhcMU9

Copy link
Copy Markdown
Member Author

Review of 98f31fb. Merge after changes. Part 1 (frame-sink slot() joining the sweep's unit) is correct and safe for broadcast retain and replay. Part 2 fixes a real bug: 5 of the 7 new signals tests and the new frame-sweep-ops case fail on next. Suites: signals 5446 passed; web server 1763 passed, with only the known Node 22 server-functions-adapter-request failure.

  1. Size. The gate fails on 10 scenarios. Minified: isPending/latest +420 B (138 B over its brotli cap), hydrating + every store primitive +704 B, live server components +499 B, createStore +138 B. The compiled floor is 3 B over its brotli cap (+10 B minified). Either cut the lane code or get the maintainer's OK on the raises, with Size-Exception: for the frozen floors.
  2. Scope of the read change. core.ts:1814-1818 makes every lane pass read the screen once the frame has a transaction. That covers createOptimistic and non-keyed stores too, and it adds the +10 B on every floor. D-34 describes it as a list-pass rule. Gating it on passLane !== null && passLane._folded keeps all 7 new tests and the full suite passing. With the change removed, only the two "truth longer than reorder" tests fail.
  3. until order. In lanes.ts:584-587, foldedRead runs before the CONFIG_AUTHORITATIVE check, so until readers join the hold and read covered() instead of _truth. Swapping the order also passes everything, so no test pins D-31 here. Either exclude authoritative reads or add a test that pins the intended behavior.
  4. Unshown guesses. The keyed branch of laneWrite (lanes.ts:768-787) never checks l._shown, but D-34's title says a guess the screen never showed is void. Handle that case, or amend D-34 to say it isn't voided.
  5. Minor. stale() is evaluated twice (lanes.ts:768-769), and lanes.ts reaches into (el as any)._host?.fam?.node. A store hook would be cleaner.

#4210 and #4213 merge cleanly together, and the signals suite passes on the merged tree.

— Claude via Claude Code


Generated by Claude Code

…ntil's posture

- read(): a lane pass reads the frame's stagings as the screen only over a
  folded lane, gated on the lane engine (floors +0 B minified).
- One store hook for the re-base and the fold (_laneRebase), and a folded
  lane carries the store's reader (Transaction.folded): fewer unmangled
  names in core.
- D-34: a keyed guess the screen never showed is void at a correction, as
  any guess's; the fold applies to a shown one only.
- laneRead: the fold's arm stays before the authoritative one so an until
  outside the lane joins the hold (D-31); pinned by a new test.
- Size caps re-recorded on the golfed numbers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015SnZadCVdDSeDzxBUhcMU9

Copy link
Copy Markdown
Member Author

CodSpeed on f143b9c flags "props chain depth 1: build + consume" at −51%. This PR doesn't change anything that benchmark runs: it only calls merge/omit, and the diff never touches them. It also doesn't reproduce. I ran callgrind on the built dist/prod with the same chain run 20,000 times (the bench's chain(1) + consume), and this branch counts fewer instructions than next (2.968B vs 3.020B). CodSpeed also warns that base and head ran on different runner types.

Earlier flags on this PR show the same pattern:

  • "memo + sync render effect only (reference)": 2.91B on this branch vs 3.08B on next over 12 mounts of 4000 rows.
  • "projection derive: write one NESTED field": gone on the next head.

I don't have the means to re-run the CodSpeed check from here.


Generated by Claude Code

… raises

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015SnZadCVdDSeDzxBUhcMU9
…-f38ibp

# Conflicts:
#	scripts/size/scenarios.js
Raises the live server components frozen floor (Size-Exception in the PR).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015SnZadCVdDSeDzxBUhcMU9
The keyed fold's +44 B was left above the recorded minified, which leaves
next 44 B past it and only 20 B of allowance for later PRs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
…ved-keys set

fold's guard was re-deriving "keyed container with a LaneView of rows"
on every landing; the setter already knows, and now always gives a keyed
array's view a removed-keys set (empty or not). -59 B minified where
createOptimisticStore is used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015SnZadCVdDSeDzxBUhcMU9
@ryansolid
ryansolid merged commit ee4963a into next Oct 22, 2026
7 checks passed
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