Skip to content

fix(state): land every row of a nested list replacement (#274) - #276

Merged
mogera551 merged 3 commits into
mainfrom
fix/state-row-landing
Sep 14, 2026
Merged

mogera551 merged 3 commits into
mainfrom
fix/state-row-landing

Conversation

@mogera551

Copy link
Copy Markdown
Contributor

Closes #274.

K1 — replacing a nested list with a longer array

state.$resolve("groups.*.items", [0], [a, b]) over a one-row list landed only 0.0. $watch and $scan never saw 0.1, while the DOM rendered it.

  • All three write paths in setByAddress.ts run the dependency walk (notifyWrite) before commitWriteCache.
  • groups.*.items contains a wildcard, so it is cacheable. The walk read the pre-write array from the cache, the diff against the baseline said "unchanged", and only the old rows were expanded.

Fix, as proposed in the issue. notifyWrite dirties the written address's own cache entry before the walk, for cacheable writes only. $postUpdate already works in that order. commitWriteCache stores the value again right after.

Side effect. The walk now builds the new array's ledger, so a cold $resolve into that nested list right after a structural write no longer throws ListIndexes not found. That characterization test in integration.recursionKnownDefects.test.ts is flipped to a fixed contract.

K2 — a row written and then removed in the same job

Given [a, b, c], writing b and then assigning items = [a, c] folded c's unchanged value with b's prev.

Not taken: dropping every replaced row, as the issue suggested. An element write into list.* (the swap path) puts a row without a value into the ledger without a diff, and the replaced row is not retired. Dropping every replaced row would lose that position's only landing.

Taken: only rows that a list diff retired (isRetiredListIndex) become gone.

$watch gets the same narrowing

$watch had no narrowing at all. Measured before this change:

Shape $watch before
K2 fired [3, 2, 1] for the unchanged row
write a row, then shorten or empty the list reported an evaluation error while reading the removed row
write a row, then replace the list with new rows position 0 fired twice
a moved row and a removed row on one position position 1 fired twice
  • The narrowing moved from scanRuntime.ts to watch/rowLanding.ts, and $scan and $watch share it.
  • To keep plain row-value batches cheap, $watch looks positions up only for an entry that has a hit on a retired row, or two hits on the same position. Hits are sorted, so those two are adjacent.
  • If reading a position throws, that entry's hits do not fire and are reported once as evaluate. The other watches still run.

Tests

  • scan.from.test.ts: both DEFECT(#274) tests are flipped (K1 → ["0.0:5", "0.1:6"] and two $watch calls; K2 → []). Two tests are added for element writes into list.*.
  • New watch.rowLanding.test.ts has 9 tests. They cover K2, shortening one row or several, emptying, replacing, a moved row next to a removed row, an element write, an element write next to a row write, and a throwing position lookup.
  • integration.recursionKnownDefects.test.ts: case (ii) is flipped.
  • The state: replacing row objects while keeping their children arrays leaves that row's aggregate stale (X2) #256 risk tests the issue listed pass unchanged: integration.sharedListRowRevival, divergentLedgerReplacement, recursionKnownDefects and bindComponentNestedFor.

Performance

A/B was measured in one process, alternating runs, on happy-dom:

  • The synchronous write path for 2,000 row writes went from 1.876 ms to 1.936 ms.
  • Measured end to end with the drain, row writes and a 200-row append (with and without $watch) differed within noise, and not in a consistent direction.
  • The browser js-framework-benchmark was not run.

Verification

Gate Result
state vitest run --coverage 297 files / 3555 tests, exit 0 — 99.61 / 98.75 / 100 / 99.77. rowLanding.ts and scanRuntime.ts are at 100%. watchRuntime.ts branches rose from 98.18 to 98.52; the one uncovered line is the existing re-check in the firing loop.
state eslint src, npx tsc --noEmit clean

e2e runs in CI. This PR edits the same CHANGELOG.md spot as #273, so whichever merges second needs a trivial rebase.

🤖 Generated with Claude Code

mogera551 and others added 3 commits September 15, 2026 05:42
The write's dependency walk read the written list path from the cache
before commitWriteCache stored the new value. A wildcard list path such
as groups.*.items is cacheable, so the walk saw the pre-write array,
diffed it as unchanged and expanded only the old rows: rows past the old
length never reached $watch or $scan. Dirty the written address's own
cache entry before the walk, as $postUpdate already does. This also lets
a cold $resolve into that nested list succeed right after a structural
write.

Row landings are now narrowed the same way for $watch and $scan (moved to
watch/rowLanding.ts). The address of a row the list diff retired is
dropped, so a row written and then removed, replaced or cut off in the
same job no longer fires or folds. A row replaced by an element write
into list.* is not retired and still lands. $watch had no narrowing at
all: it reported evaluation errors after shortening a list and fired a
position twice after a replacement. It looks positions up only for
entries with a retired row or two hits on one position.

Closes #274

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mogera551
mogera551 merged commit b4e19ec into main Sep 14, 2026
6 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.

@wcstack/state: row landings are lost or misattributed after a nested list replacement or a mid-list removal in the same job

1 participant