fix(state): land every row of a nested list replacement (#274) - #276
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #274.
K1 — replacing a nested list with a longer array
state.$resolve("groups.*.items", [0], [a, b])over a one-row list landed only0.0.$watchand$scannever saw0.1, while the DOM rendered it.setByAddress.tsrun the dependency walk (notifyWrite) beforecommitWriteCache.groups.*.itemscontains 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.
notifyWritedirties the written address's own cache entry before the walk, for cacheable writes only.$postUpdatealready works in that order.commitWriteCachestores the value again right after.Side effect. The walk now builds the new array's ledger, so a cold
$resolveinto that nested list right after a structural write no longer throwsListIndexes not found. That characterization test inintegration.recursionKnownDefects.test.tsis flipped to a fixed contract.K2 — a row written and then removed in the same job
Given
[a, b, c], writingband then assigningitems = [a, c]foldedc's unchanged value withb'sprev.Not taken: dropping every
replacedrow, as the issue suggested. An element write intolist.*(the swap path) puts a row without a value into the ledger without a diff, and the replaced row is not retired. Dropping everyreplacedrow would lose that position's only landing.Taken: only rows that a list diff retired (
isRetiredListIndex) becomegone.$watchgets the same narrowing$watchhad no narrowing at all. Measured before this change:$watchbefore[3, 2, 1]for the unchanged rowscanRuntime.tstowatch/rowLanding.ts, and$scanand$watchshare it.$watchlooks 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.evaluate. The other watches still run.Tests
scan.from.test.ts: bothDEFECT(#274)tests are flipped (K1 →["0.0:5", "0.1:6"]and two$watchcalls; K2 →[]). Two tests are added for element writes intolist.*.watch.rowLanding.test.tshas 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.integration.sharedListRowRevival,divergentLedgerReplacement,recursionKnownDefectsandbindComponentNestedFor.Performance
A/B was measured in one process, alternating runs, on happy-dom:
$watch) differed within noise, and not in a consistent direction.Verification
vitest run --coveragerowLanding.tsandscanRuntime.tsare at 100%.watchRuntime.tsbranches rose from 98.18 to 98.52; the one uncovered line is the existing re-check in the firing loop.eslint src,npx tsc --noEmite2e runs in CI. This PR edits the same
CHANGELOG.mdspot as #273, so whichever merges second needs a trivial rebase.🤖 Generated with Claude Code