Skip to content

fix(signals): an optimistic guess's replaced row reaches <For> - #4360

Merged
ryansolid merged 1 commit into
nextfrom
claude/project-thread-x1ore2-optimistic-adopt
Oct 28, 2026
Merged

ryansolid merged 1 commit into
nextfrom
claude/project-thread-x1ore2-optimistic-adopt

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #4344. This supersedes #4358: same issue, a broader rule, fewer bytes. The two PRs edit the same lines of rowSame, so only one of them should land.

Summary

Before: a guess on a createOptimisticStore can put a different row object at an index that the container's arrangement comparator still calls "the same row". When that happens, the arrangement guess is dropped. <For> (keyed and non-keyed) keeps the old row's proxy, while direct reads serve the new row, so an edit of that row shows in direct reads but not in the DOM. Each case below does that swap or return, then d[0].n = 2 inside an action:

case at the guess on rc.14 on rc.19 / next
A. keyless rows with identical contents, swapped in the draft direct 2,1, screen 1,2 ok broken (since #3937)
B. a fresh row with the same id returned from the setter direct 2,1, screen 1,1 broken broken
C. a fresh keyless row with the same contents returned direct 2,1, screen 1,1 ok broken (since #3937)
#4344: a returned reorder of keyless rows direct c,b,a, screen a,b,c ok broken (since #3937)

Plain createStore and createProjection agree with direct reads in every case on rc.19 and next. Only the optimistic guess path has this bug.

Cause: rowSame (the comparator behind arrangement → arrangementEqual) matched keyed rows by key and keyless rows by position. A landing's object is adopted into the row it pairs with, so its proxy survives. A guess's fresh object instead wraps a new proxy, and a draft swap moves two different proxies. In both cases "same key" or "same position" no longer means "same proxy", but the comparator still said "same". So optimisticWrite dropped the arrangement guess and <For> never re-ran. The revert at the settle had the same problem.

After: a row the store already serves is its proxy's row. lookupTarget(raw, fam) is checked on both sides. If either side has a target, the rows are the same row only when the targets are equal. A landing's object has already been adopted by the time the comparator runs, so a server echo still confirms (#3898 passes unchanged). Rows that no proxy serves yet still pair by key, and keyless rows by position. The keyless special case now goes through sameKey, since undefined is the same key. That also makes the comparator smaller.

I did not reuse reconcile's adopt path (reconcileState / pairRows) for the setter's returned array. That would keep proxies only for keyed matches. It would not cover A (no new objects, just two different proxies moved), and keyless rows would need a new positional fallback. The comparator change covers every case without new code paths.

How did you test this change?

  • New packages/signals/tests/optimistic-replaced-row-identity.test.ts:
    • Cases A, B and C, keyed and non-keyed mapArray, exact frames at the guess and after the settle.
    • A derived store whose truth the action writes, checking the landing and a later action's edit (guess, settle, second guess, second settle).
    • createStore and createProjection controls.
  • The [2.0 rc.15 – rc.19 / next] createOptimisticStore: a reorder inside an action is visible to direct reads but not to <For> when rows have no id #4344 tests from fix(signals): an optimistic reorder of keyless rows reaches <For> (#4344) #4358: packages/signals/tests/optimistic-reorder-keyless-4344.test.ts and packages/web/test/optimistic-reorder-keyless-for-4344.spec.tsx.
  • New packages/web/test/optimistic-replaced-row-for.spec.tsx: cases A, B and C under <For> keyed and keyed={false}, exact innerHTML at the guess and after the settle.
  • On next, all optimistic cases fail (the 8 web ones included) and every control passes. With this change, all pass.
  • Full suites:
    • signals: 5697 tests pass. solid: 897 pass. tsc -p tsconfig.build.json is clean.
    • web, client, server and hydrate: the only failures also fail on next in this environment (uncommitted-input-overwritten, server-functions-adapter-request "keeps a rebuild failure's own error…", lone-spread-hydration-key-4240 with a locally stale native compiler).
  • scripts/size, measured against next @ 38e0c49 on the same machine: only app: hydrating + every store primitive family changes, by −27 B minified (95,595 → 95,568) and −33 B brotli. No cap changes; it is now under its cap.

Generated by Claude Code

The container's arrangement comparator judged rows by key, keyless rows by
position. A guess that put a different object at an index with the same
key (a fresh `{ id }` row returned from the setter), or any keyless row
(identical rows swapped in the draft, a fresh row with the same contents,
a reorder: #4344), was "the same row": the arrangement guess was dropped
and `<For>` kept the old row's proxy while direct reads served the new
one, so an edit of that row never reached the screen.

A row the store already serves is now its proxy's row: another object at
the index is another row. A landing's object is adopted by the row it
pairs with before the comparator runs, so an echo still confirms (#3898).
Rows no proxy serves yet still pair by key, keyless rows by position.

The comparator also folds its keyless special case into `sameKey`
(`undefined` is the same key), so the store scenario gets smaller.

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 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3f79fb2

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

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.92 KB 0 B 0 B −1359 B 15.04 KB ✅
signals: + isPending/latest 10.37 KB 0 B 0 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.61 KB 0 B 0 B −244 B 18.49 KB ⚠️ over by 116 B, 264 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 31.14 KB −33 B (−0.1%) −27 B −27 B 31.19 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.38 KB 0 B 0 B −250 B 13.40 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.11 KB 0 B 0 B −256 B 15.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.69 KB 0 B 0 B −254 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.15 KB 0 B 0 B −573 B 26.27 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.34 KB 0 B 0 B −504 B 32.25 KB ⚠️ over by 91 B, 524 B minified headroom 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.59 KB 0 B 0 B −182 B 33.37 KB ⚠️ over by 222 B, 202 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.01 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 37.94 KB 0 B 0 B +6 B 37.75 KB ⚠️ over by 194 B, 14 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.98 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.50 KB 0 B 0 B −170 B 35.43 KB ⚠️ over by 69 B, 190 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.95 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.81 KB 0 B 0 B +5 B 41.56 KB ⚠️ over by 246 B, 15 B minified headroom eager (counted): web.js 22.37 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.03 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.99 KB 0 B 0 B −181 B 40.81 KB ⚠️ over by 179 B, 201 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.59 KB, trace.js 8.98 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.40 KB 0 B 0 B −15 B 47.16 KB ⚠️ over by 239 B, 35 B minified headroom eager (counted): client.js 27.49 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.08 KB, server.js 1.03 KB, serverForms.js 3.30 KB, trace.js 8.99 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 116 B; minified 53,376 B vs 53,620 B recorded with the cap (−244 B) — 264 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
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 91 B; minified 100,540 B vs 101,044 B recorded with the cap (−504 B) — 524 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 222 B; minified 101,377 B vs 101,559 B recorded with the cap (−182 B) — 202 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 194 B; minified 115,223 B vs 115,217 B recorded with the cap (+6 B) — 14 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 69 B; minified 107,748 B vs 107,918 B recorded with the cap (−170 B) — 190 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 246 B; minified 123,221 B vs 123,216 B recorded with the cap (+5 B) — 15 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 179 B; minified 125,206 B vs 125,387 B recorded with the cap (−181 B) — 201 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 239 B; minified 140,465 B vs 140,480 B recorded with the cap (−15 B) — 35 B of the 20 B minified allowance left; +0 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

Copy link
Copy Markdown

Coverage Report for CI Build 40677694999

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

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 28, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 4.59%

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

⚠️ 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
❌ memo + sync render effect only (reference) 29.4 ms 34.1 ms -13.8%
⚡ build 139.6 µs 132.2 µs +5.6%

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-optimistic-adopt (3f79fb2) with next (38e0c49)

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 flagged mount-rows "memo + sync render effect only (reference)" on 3f79fb2 as a regression. This is runner noise, not this PR. The PR only changes rowSame in store/optimistic.ts, and a plain signals mount never reaches the optimistic store. The same benchmark dropped 10% on #4357, which has no signals code, and CodSpeed warns that base and head ran on different runner types here too. Nothing to fix here.


Generated by Claude Code

@ryansolid
ryansolid merged commit 1e115b8 into next Oct 28, 2026
8 of 9 checks passed
ryansolid pushed a commit that referenced this pull request Oct 28, 2026
Brings in #4347, #4350-#4353, #4356, #4357, #4359 and #4360. The one
conflict was in the store-app entry of scripts/size/scenarios.js. Both
ledgers are kept: next's two lines, then this branch's two. The cap is
re-measured after the merge.

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