Skip to content

fix(signals): lane pass over a superseded override reads the displayed override (#3548) - #3558

Open
ryansolid wants to merge 2 commits into
nextfrom
fix/optimistic-move-duplicate-3548
Open

ryansolid wants to merge 2 commits into
nextfrom
fix/optimistic-move-duplicate-3548

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #3548.

Symptom

Two overlapping optimistic moves in a kanban-style UI — three keyed <For> lists filtered from one createOptimisticStore over an async stream — rendered the same card in two lanes while the store itself held it once. Reproduced exactly from the reported playground (ported to a signals-level test, red on next):

lanes: [[], [0, 1], []]   store (tracked, same effect): [1, 1]
untracked view.cards.map(c => c.lane) outside the effect: [2, 1]

Cause

Not the provenance guard. The stream's landing runs mainline (origin = 0), so A18 correctly treats it as a fresh answer and supersedes the override on card0.lane; the lane memos re-derive as held transaction work and no frame publishes.

The tear comes one step later. A fresh action's write to card1.order creates a new lane that reaches lane 1's mapArray memo — a reader shared between card0.lane and card1.order. That memo now runs as a lane pass, and overrideRead had no lane arm: it fell through to the superseded branch and was served the staged truth (lane = 1). The lane pass published via laneOverride and runEffect(LANE_RUN) applied it immediately. Lane 2's memo was not on the new lane, its plain recompute stayed held, and it kept displaying the override. Two readers, two paths, one torn frame.

Fix

overrideRead (packages/signals/src/core/optimistic.ts): a lane pass reading a superseded override gets the displayed override — A18 (c), the applied screen keeps the override until the owning transaction commits — and the reader is recorded in the owner's _gatedSubs for replay at that commit, under the same contract as laneReadsCommitted / gatedRead. The replay is load-bearing: resolveOptimisticNodes deliberately does not notify a superseded node's subscribers at the drop, so without it the lane-pass reader would never see the reveal. The test pins the post-settle reveal ([[1],[0],[]]) as well as the whole frame at every step.

A17's "lane recomputes see the arrived truth" is read here as the demoted pass at supersession time, not a later lane's pass whose frame is applied pre-commit. The alternative — demoting such a pass off the lane entirely — also removes the tear but hides the newest action's optimistic write until the merged transaction commits; this fix keeps the newest write optimistic and the screen whole.

Verification

  • New packages/signals/tests/optimistic-move-duplicate-3548.test.ts (red on next, green here); a temporary web-level <For> spec reproduced the report's exact DOM on next and passed with the fix.
  • @solidjs/signals: 205 files, 3465 passed, 1 skipped. @solidjs/web jsdom suite: 96 files / 834 passed against rebuilt dists.
  • Size budget: all scenarios within caps, no cap bumps (a redundant activeTransition ?? fallback was trimmed to stay under).
  • RULES-INDEX.md regenerated (A18 citation count).

Changeset: .changeset/fix-optimistic-move-duplicate-3548.md (@solidjs/signals: patch).


Diagnosed and implemented by Claude via Cursor.

…yed override (#3548)

A18 supersession hands tracked readers the staged truth while the screen
keeps the override until the owning transaction commits. A LATER action's
write put a shared reader (a keyed list filtered on two rows' fields) back
on a lane, and that lane pass — whose frame applies ahead of the commit —
was served the truth too: one list re-derived from the landed placement
while its neighbour still displayed the override, rendering the same card
in two lanes.

overrideRead: a lane pass reads what is on screen (the override) and is
recorded in the owner's _gatedSubs for replay at the commit, under
laneReadsCommitted's contract — the superseded drop notifies nobody.

Pinned by tests/optimistic-move-duplicate-3548.test.ts (the report's
five-step sequence over an async-iterable optimistic store, plus the
post-settle reveal through the replay).
@changeset-bot

changeset-bot Bot commented Sep 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 739404d

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

This PR includes changesets to release 11 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

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

@codspeed

codspeed Bot commented Sep 20, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing fix/optimistic-move-duplicate-3548 (739404d) with next (be46a04)

Open in CodSpeed

Footnotes

  1. 3 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.

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 35503195443

Coverage remained the same at 72.722%

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: 1070
Covered Lines: 823
Line Coverage: 76.92%
Relevant Branches: 818
Covered Branches: 550
Branch Coverage: 67.24%
Branches in Coverage %: Yes
Coverage Strength: 17.43 hits per line

💛 - Coveralls

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.

2 participants