Skip to content

fix(signals): optimistic store guesses survive re-asks, answer latest() and stay per-action (#4329) - #4330

Merged
ryansolid merged 3 commits into
nextfrom
claude/project-thread-x1ore2-4329
Oct 28, 2026
Merged

ryansolid merged 3 commits into
nextfrom
claude/project-thread-x1ore2-4329

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Summary

Before: with createOptimisticStore, a per-key guess made inside a pending action (setStore(d => { d[1].assignee = "ann" }), then yield save.p) disappeared when a re-ask of the store (refresh(store)) landed before the action settled: the screen went from 1:null 3:ann to 1:null 3:null while save was still pending. That happened when the derive writes the list through its draft (d.length = 0; d.push(...rows) or d.issues = rows). Two related gaps showed up on both landing paths (draft writes and returned rows): latest(() => store[1].assignee) kept returning the guess when the truth equalled the value the guess covered, and one re-ask landing different truths on two rows, each with its own pending action, tied the two actions together, so a row kept its guess after its own action settled.

After: the screen keeps the guess until its action commits (RULES-DERIVED D-34), as createOptimistic already does. latest() answers the truth while the screen keeps the guess. Guesses from different actions stay independent (D-33): each row reverts or commits when its own action settles, and rows with no guess show their new truth at the landing.

How

  • store/store.ts notifyWrites: when the derive's draft writes an optimistic family, a new optimistic hook pair runs before the nodes are notified.
  • store/optimistic.ts pairRows: for each guessed row, it finds the container under the written target and the new object at its slot (by key in a keyed list) and runs reconcileState on it, so the draft path shares reconcile's keyed descent and leaf diff instead of a second routine.
  • store/reconcile.ts applyAdopt: a key under a guess is judged against the guess too (one line behind the existing optHooks check), so a truth equal to the covered value still answers it.
  • core/lanes.ts supersede: drops joinFuture(parent). The parent still holds the corrected node, and a pass that derives from the held truth joins the parent on its own (joinPass). The removed join made a whole landing join each guess's hold, which tied two rows' actions together. This also applies to createOptimistic signals; every S-33/S-34 and spec-async pin passes.

No new public exports, no new effects. The hooks are installed only by optimistic.ts.

How did you test this change?

packages/signals/tests/optimistic-key-guess-reask-4329.test.ts, 17 tests, run on both derive forms (draft writes and returned rows) where it applies: the issue repro (the row keeps its proxy), the committed value after save settles with no null frame, two rows with one or both actions pending, the nested d.issues shapes, latest() equal to the truth while the screen keeps the guess, independent commits for two actions answered by one landing, a no-guess row showing its truth beside a corrected guess, and createOptimistic parity. Against next's packages/signals/src, 14 of the 17 fail; all 17 pass on this branch.

packages/signals pnpm test: 355 files, 5646 passed (2 expected fail, 2 skipped). packages/solid pnpm test: 52 files, 897 passed. tsc -p tsconfig.build.json is clean. Store benches (write-floor, projection-root-write, reconcile-dbmon, reconcile-tree), 3 rounds alternating base and head: within run-to-run noise.

Size (vs next @ d2323b0, brotli / minified)

scenario brotli minified
app: hydrating + every store primitive family +123 B (31,064) +284 B
signals: + isPending/latest −7 B −5 B
page: live server components −35 B −5 B
page: compiled live server components −39 B −7 B
page: live + router +11 B −5 B
every other scenario 0 0

The store scenario's cap goes 30.96 → 31.08 KB (recorded minified 95,147 → 95,378) with a ledger line in scripts/size/scenarios.js.

Size-Exception: +123 B brotli / +284 B minified on app: hydrating + every store primitive family for the #4329 fix (correctness; only apps using createOptimisticStore pay), accepted by the maintainer 2026-10-27.

Fixes #4329

🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

…ile its action is pending (#4329)

A derive that writes a keyed list through its draft (`d.length = 0;
d.push(...rows)`, or `d.issues = rows`) replaced the guessed row with a
fresh object, so the guess, which lives on the old row's target, left the
screen before its action settled. The draft's new object at a guessed
row's slot (by key in a keyed list) is now adopted by that row's target,
as a reconcile landing already does: the guess stays until the commit
(D-34) and guesses on other rows stay independent (D-33).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 66deb9b

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 27, 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 −119 B 7.44 KB ✅
signals: + createStore 14.88 KB 0 B 0 B −1448 B 15.04 KB ✅
signals: + isPending/latest 10.37 KB −7 B (−0.1%) −5 B −13 B 10.38 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.05 KB 0 B 0 B −123 B 8.05 KB ✅
app: render + one signal (the simple-app floor) 9.78 KB 0 B 0 B −179 B 9.75 KB ⚠️ over by 26 B, 199 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.59 KB 0 B 0 B −252 B 18.49 KB ⚠️ over by 96 B, 272 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 31.06 KB +123 B (+0.4%) +284 B 0 B 31.08 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.36 KB 0 B 0 B −258 B 13.40 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.12 KB 0 B 0 B −264 B 15.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.66 KB 0 B 0 B −262 B 29.70 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 −178 B 9.93 KB ⚠️ over by 38 B, 198 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.16 KB 0 B 0 B −793 B 26.27 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.23 KB 0 B 0 B −721 B 32.25 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.50 KB 0 B 0 B −249 B 33.37 KB ⚠️ over by 131 B, 269 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 8.93 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 37.85 KB −35 B (−0.1%) −5 B −56 B 37.75 KB ⚠️ over by 99 B, 76 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 8.91 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.53 KB 0 B 0 B −232 B 35.43 KB ⚠️ over by 103 B, 252 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 8.94 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.75 KB −39 B (−0.1%) −7 B −62 B 41.56 KB ⚠️ over by 194 B, 82 B minified headroom eager (counted): web.js 22.35 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.19 KB, trace.js 8.94 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.95 KB 0 B 0 B −243 B 40.81 KB ⚠️ over by 141 B, 263 B minified headroom 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.58 KB, trace.js 8.98 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.41 KB +11 B (+0.0%) −5 B −66 B 47.16 KB ⚠️ over by 249 B, 86 B minified headroom eager (counted): client.js 27.50 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.32 KB, trace.js 8.96 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B −69 B 1.34 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 26 B; minified 26,850 B vs 27,029 B recorded with the cap (−179 B) — 199 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 96 B; minified 53,368 B vs 53,620 B recorded with the cap (−252 B) — 272 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 38 B; minified 27,382 B vs 27,560 B recorded with the cap (−178 B) — 198 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 131 B; minified 101,310 B vs 101,559 B recorded with the cap (−249 B) — 269 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 99 B; minified 115,161 B vs 115,217 B recorded with the cap (−56 B) — 76 B of the 20 B minified allowance left; −5 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 103 B; minified 107,686 B vs 107,918 B recorded with the cap (−232 B) — 252 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 194 B; minified 123,154 B vs 123,216 B recorded with the cap (−62 B) — 82 B of the 20 B minified allowance left; −7 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 141 B; minified 125,144 B vs 125,387 B recorded with the cap (−243 B) — 263 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 249 B; minified 140,414 B vs 140,480 B recorded with the cap (−66 B) — 86 B of the 20 B minified allowance left; −5 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).

Copy link
Copy Markdown
Member Author

The size check fails on one scenario: app: hydrating + every store primitive family is +122 B brotli, 103 B over its cap. The other 21 scenarios are unchanged. This is the expected result of this PR, not a flake, so I'm not re-running it.

It's waiting on a maintainer call: raise the cap with a Size-Exception, or take a reworked version first. A rework is in progress. It shares one pairing routine between the draft path and the reconcile path, and also fixes two related gaps in the same code: latest() returning the guess when the truth equals the committed value, and a re-ask with changed values tying two rows' actions together. I'll push it here with updated size numbers.


Generated by Claude Code

@coveralls

coveralls commented Oct 27, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 40595552182

Coverage remained the same at 78.727%

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: 1357
Covered Lines: 1127
Line Coverage: 83.05%
Relevant Branches: 1078
Covered Branches: 790
Branch Coverage: 73.28%
Branches in Coverage %: Yes
Coverage Strength: 28.39 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 13.23%

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 regressed benchmark
✅ 195 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) 29.7 ms 34.3 ms -13.23%

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-4329 (66deb9b) with next (d2323b0)

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

…ctions independent (#4329)

- The draft path's pairing reuses reconcile: the container above a
  guessed row reconciles the new object at its slot, so both landing
  paths share one keyed pairing and one leaf diff.
- reconcile judges a key under a guess against the guess too, so a
  truth equal to the covered value still answers it: latest() reads
  the truth, the screen keeps the guess until the commit (D-34).
- supersede no longer joins the landing's tick to the parent hold; the
  parent takes the node, and passes deriving from the held truth join
  on their own. One re-ask answering two actions' guesses no longer
  ties them together (D-33).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@ryansolid ryansolid changed the title fix(signals): a per-key optimistic guess survives a re-ask landing while its action is pending (#4329) fix(signals): optimistic store guesses survive re-asks, answer latest() and stay per-action (#4329) Oct 27, 2026

Copy link
Copy Markdown
Member Author

CodSpeed flags 3 benches: mount-rows "memo + sync render effect only (reference)" (−9.1%), props-chain "build" (−5.5%) and reactivity "createComputations:create0to1" (−5.2%). It also warns that base and head ran on different runner types.

None of the three runs code this PR changes. Every change is on an optimistic path: the pair hook runs only for fam.opt families, the applyAdopt line runs only under a guess (prevView !== old), and supersede runs only when a guess is corrected or confirmed. None of those three benches makes an optimistic store or a guess. The same store.ts gate change was in 83a9b87, and CodSpeed was clean on it against the same base. Locally, the store benches came out within noise over 3 alternating rounds.

I'm re-running CodSpeed once to confirm.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

On the re-run, CodSpeed still flags mount-rows "memo + sync render effect only (reference)" (29.7 → 34.3 ms, again with the different-runner-types warning). The other two benches came back clean. I measured this one locally, using the built dist of next @ d2323b0 against this head:

  • Walltime (vitest bench, 4 alternating rounds), mean ms at 1000 / 4000 rows:

    • base: 0.611/2.599, 0.631/2.613, 0.625/2.605, 0.617/2.557
    • head: 0.612/2.609, 0.617/2.610, 0.614/2.598, 0.628/2.623
  • Callgrind instruction counts (the bench's mount(4000) in node, marginal instructions per mount, from runs of 1/6 and 2/8 mounts):

    • base: 210M, 234M, 213M
    • head: 238M, 228M, 234M

    Both builds land in the same 210–238M range. The spread comes from JIT/GC run to run, not from the build.

The bench makes no store and no guess, so none of this PR's changed lines run in it. I'm treating it as runner noise and not changing the code. If a maintainer wants, the regression can be acknowledged in CodSpeed.


Generated by Claude Code

@ryansolid
ryansolid merged commit ac28157 into next Oct 28, 2026
9 of 10 checks passed
ryansolid pushed a commit to solidjs/reactivescript that referenced this pull request Oct 28, 2026
… lingering

- ref/SOLID_NEXT and the crate's solidjs-compiler rev move to ac28157a
  (rc.19 plus solidjs/solid#4330). All ref suites pass with zero
  divergences.
- eval builds against that checkout's packages. Round 3's four #4329
  failures pass on it, as its README now records.
- Round 4 overlap tests: when two adds or removes on one list are out,
  a declined one may stay until the other settles (one change, H-11;
  Ryan 2026-10-28: desired). The one still out must not go.
- .rx references for close and comments use the plain Solid pattern:
  a whole-list guess, then refresh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0117BoWKEv4MghW8Ns9LeUUX
@ryansolid
ryansolid deleted the claude/project-thread-x1ore2-4329 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