fix(state): move row blocks with their values on element-write swaps (#4) - #285
Merged
Merged
Conversation
Writing list elements to swap or replace rows (`this.$resolve("items.*",
[0], b)`, `this["items.0"] = c`) moved each row's identity with its value
in the ledger, but pinned the written value to the listIndex that used to
sit at that position and never re-rendered the list. The blocks stayed in
place with their contents rewritten, path reads and writes hit the other
row, `$1` disagreed with the text, and a later array replacement left the
page out of step with the array (#4). Replacing a row with a new object
did not refresh an unbound row getter.
- Element writes no longer pin the written value into the write cache.
- When a swap or replacement completes, the `for` baseline becomes a copy
of the order before the writes (with a copy of its ledger) if the `for`
last rendered this very array, and the list is queued render-only, so
the diff moves blocks by listIndex identity and re-creates replaced rows.
- A replaced row is notified as a write landing at its position; rows that
moved with their values are invalidated and re-rendered without landing.
- The updater gains a render-only queue: those addresses are applied but
are not passed to drain listeners ($scan, $watch, $streams) nor counted
by `hasQueuedPath`, so `$watch` on the list does not fire for a swap.
- `for` drops the applied mark of bindings in a pooled content it reuses:
a binding applied earlier in the same batch for the removed row was
otherwise skipped for the new row (e.g. a leaf write followed by
replacing that row showed the old value).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…e replaces it
Verification of the swap re-render found two regressions against main:
- Writing a value that was not in the list (typing into a primitive row
bound with `value: tags.*`, or an immutable row update from a handler)
re-created the row's block on every write. The input left the DOM and
lost focus on each keystroke.
- `$watch("tags.*")` received `undefined` as `prev` for such a write.
The write recorded `prev` under the replaced row, which the re-render
diff retires, and the landing moved to the new row.
A replacing value stays a new row, as #274 specifies for landings:
- When the `for` diffs against the swap baseline copy (now marked), it
pairs the row removed at a position with the row added at the same
position and reuses that content in place: no unmount, no fragment,
no clearing the parent. Array replacements still go through the pool.
- The new row takes over the `prev` recorded for the element path under
the row it replaced.
Giving the replacing value the displaced row's listIndex was tried and
dropped: the listIndex is the row's identity, so the new row inherited
the old row's `prev` (the #274 landing tests) and its caches for paths
only a getter reads (the stale unbound getter came back).
The record of the order before the writes is now keyed by the array
instead of the list's StateAddress, which every <wcs-state> with the
same path shares; an unfinished swap in one element was picked up by a
replacement in another.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Verification found three regressions against main in rows that hold more than text and inputs, all from reusing a replaced row's content without the teardown that unmounting does: - A nested `for` in the row rendered its rows again on every write, and the copies accumulated: the contents of the old row stayed mounted and registered under the listIndex the row no longer has. - An `if` in the row froze on the value it first rendered. - A component in the row froze the same way: it is never detached, so it never gets the connectedCallback that re-binds a pooled row's mount scope to the row it landed on. - `Content.unmountInPlace` does what `unmount` does — dispose the binding session, unmount the contents owned by nested structural bindings, clear the binding address ledgers — but leaves the row's own nodes in the DOM, which is the whole point of reusing the block (an input being edited in the row keeps focus). - `remountScopesUnderContent` runs the pooled-row rebind for every mount scope inside the reused content, right after it is activated for the new row. It is gated by `stateElementHasMounts`, so a tree with no mounted components pays one WeakMap read. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Verification found that a row reused in place lost everything one level deeper than its own bindings: - A nested `for` under an `if` in a replaced row rendered nothing at all, and never recovered. - `elseif` / `else` branches never rendered after a replacement. - The content ledger of a nested `for` anchor grew by one entry per inner row per replacement and was never reclaimed. The teardown unmounts the contents owned by nested structural bindings, but only the row's own bindings were dropped from the batch's applied set. An `if`'s inner `for` binding belongs to the if's content, and an `elseif` / `else` anchor sits inside the previous branch's fragment, so both kept their applied mark and were skipped when the row was activated for its new value — torn down and never rebuilt. - Dropping the applied mark now walks the contents of nested structural bindings, so the whole subtree is applied again for the new row. - Contents owned by a nested `for` go back to that `for`'s pool before the row is torn down, so the next render takes them instead of creating new ones and the anchor's content ledger stays flat (measured: 30 repeated replacements of a row with two inner rows keep it at 2, was 2 + 2n). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Verification found two regressions in rows whose wrapper holds only an
`if`, and in `$watch` on a nested element path. Both come from defects
that `main` also has, which only became visible once a row is re-rendered.
- A row shaped `<div><template if><template for>` lost its whole inner
list on every other replacement and reported "Cannot get absolute state
address for disconnected binding". Tracing the batch: the row's inner
`for` is applied first, sees its array replaced (every row deleted), and
takes the shortcut that empties the parent element. The check for "am I
the only thing in this parent" counts elements and non-blank text but
not comment nodes, so it wiped the sibling `if` anchor. `main` never
re-renders the row, so its rows simply kept the content the wipe left
behind. The check now counts structural anchors, so the shortcut is
skipped when the `for` shares its parent with one.
- `$watch("groups.*.items.*")` no longer fired for the replaced row.
The first write of the batch expands under the row being replaced, so
the inner list's ledger is built with that row as its parent; the row is
then retired by the re-render and the landing selection drops anything
parented to it. A replaced row now drops such ledgers before it is
notified, so the notification rebuilds them under the new row, and the
notification expands the row's lists in full (the inner rows are all new).
Contents owned by a nested `for` are dropped from the anchor's content
ledger instead of being returned to the pool: returning them made the next
render take the same shortcut above.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The previous commit dropped the ledger of any array inside a replaced row
that was still parented to the row it replaced, so that the landings for
its nested lists would be rebuilt under the new row. Verification showed
that this breaks addressing:
- `setListIndexesByList(list, null)` removes the ledger outright, and
`$resolve` / `$getAll` / `$setAll` resolve a nested list through it.
After the spelling this project recommends for an immutable row update
(`s["gs.0"] = { ...row, n: "G0" }`, which carries the inner array over),
`s.$resolve("gs.*.its.*", [0, 0], "A2")` threw `ListIndexes not found`
and the write was lost.
- Replacing the same row twice in one batch then writing to its nested
list threw as well, and that row's nested list stayed dead: no landing,
no `$scan` accumulation, no `$updatedCallback`.
Reparenting instead of clearing is not available at that moment: the
ledger repair (`resolveListIndexesByList`) only re-parents away from a
retired row, and the row being replaced is retired later, by the render.
So the ledger is left alone. What remains is that `$watch` / `$scan` on a
nested element path (`gs.*.its.*`) do not land for the row that was
replaced, because the batch's first write expands under the row it
replaces and the landing selection drops rows retired by the re-render.
The page and the state are correct; the test records the gap.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…p-identity # Conflicts: # CHANGELOG.md
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 #4.
What was wrong
Writing list elements to swap rows (
this.$resolve("items.*", [0], b),this["items.0"] = c) moved each row's identity with its value in the list-index ledger. The runtime, however:The row blocks stayed where they were and only their bound contents were rewritten:
items.0.nameread the other row.$1disagreed with the text it showed.Replacing a row with a different object the same way did not refresh an unbound row getter (a recursive total showed the old leaf plus the new children: DOM
97, state99).Contract
The maintainer chose the identity model: row blocks move with their values. After a completed swap, a row's block, its
$1, and state it holds outside bindings follow the value to its new position.A value that was not in the list replaces the row at that position:
$watch/$scanlandings.Fix
setByAddressCoreskipscommitWriteCachefor swappable element writes, so the cache cannot hold a value under a listIndex that now belongs to another row.notifySwappedListinsetByAddress.ts).forlast rendered this very array, its render baseline becomes a copy of the order before the writes, with a copy of that order's ledger. The copy is marked (swapBaselineList.ts). The list diff then moves blocks by listIndex identity.prevthat the write recorded under the element path for the row it replaced; without that,$watch("items.*")lostprev, because the replaced row is retired by the diff.applyChangeToForpairs each row removed at a position with the row added at the same position and reuses that content in place: its nodes are not detached, it does not go through the pool or the fragment path, and the "clear the parent" shortcut is skipped. A diff after an array replacement still goes through the pool as before.unmountdoes, minus detaching its own nodes (Content.unmountInPlace): the contents owned by a nestedfor/ifin the row are unmounted and the binding address ledgers are cleared. Leaving them alone duplicated the nested rows on every write and froze theifcontent on the row it was first rendered for.if's innerforbinding belongs to the if's content and anelseif/elseanchor sits inside the previous branch's fragment, so marking only the row's own bindings left them torn down and never rebuilt.forare dropped from that anchor's content ledger before the row is torn down, so repeated replacements do not grow it.connectedCallbackthat re-binds a pooled row's mount scope.remountScopesUnderContentruns the same rebind (remountScopeBindings) for every mount scope inside the reused content, right after it is activated for the new row.mainshares, which only shows once a row is re-rendered.forempties its parent element as a shortcut when every row is deleted. Its "am I the only thing in this parent" check counted elements and non-blank text, not comment nodes, so it wiped a siblingifanchor. A row shaped<div><template if><template for>then lost its inner list on every other replacement and reportedCannot get absolute state address for disconnected binding. The check now counts structural anchors.enqueueRenderOnlyAddressare applied in the same drain. They are not passed to drain listeners ($scan,$watch,$streams) and are not counted byhasQueuedPath. A swap therefore does not fire$watch("items"); an address that is really written in the same batch still lands once.StateAddress. That object is shared by every<wcs-state>with the same path, so an unfinished swap in one element (a list with duplicates) was picked up by a replacement in another. It is now keyed by the array itself, like the list-index ledger.for. When the list reuses a removed row's content for a new row, it drops that content's bindings from the batch's applied set. Before, a binding applied earlier in the same batch for the removed row was skipped for the new row:s["items.0.n"] = 5; s.items = [{ n: 9 }, s.items[1]]showed5while the state held9. This is onmaintoo, and the swap re-render hits the same path.Approaches that did not work
$watch/$scanon rows nobody wrote (integration.watch.rowLanding,integration.scan.from).activateContent. Re-activating anifthen applied its innerfortwice and duplicated rows (integration.ifRemountOrder).prev(breaking the @wcstack/state: row landings are lost or misattributed after a nested list replacement or a mid-list removal in the same job #274 landing tests) and its caches for paths only a getter reads, which brought back the stale unbound getter.foror anifthrough the pool instead of reusing their block. The row that had lost its inner list kept losing it — the cause was in the list-clearing shortcut, not in the reuse — and the fallback cost focus in every row holding a conditional.Tests
integration.elementSwapIdentity.test.ts.$1, writes, and a later array replacement.$1getter.$watch("items")not firing for a swap; an array replacement and a swap in one batch firing it once.$resolveexample with primitives.["100","4"]) and a recursive total (["99","8"]), both keeping the block.for(its rows are not duplicated), anif(its content follows the new value), and a child component bound withstate.row: groups.*(its shadow follows the new row).forunder anif, and a row whose wrapper holds only thatif: the inner rows are re-rendered on every replacement, no row wrapper is left behind, and nothing is reported.if/elseif/elsechain in the row: the branch that matches the new value renders.for: the anchor's content ledger stays at two entries.$watch("groups.*.items.*")not landing for the replaced row, with the reason — the gap listed under Verification.$watch("tags.*")receiving the value before the replacement asprev, also when the same position is replaced twice in one batch.<wcs-state>not leaking into another.["9","2"]).updater.updater.test.tsfor the render-only queue (not in the listener batch; an address both written and render-only still lands).proxy.setByAddress.test.tsfor the array-keyed swap record and the updater mock.Verification
Rounds by an adversarial agent, each measuring this branch and
mainon the same probes (about 50 cases in the first round, new cases in each later one). Every round found blocking regressions, and each was fixed by the commit that follows it:$watch("tags.*")lostprevfor that write.forin a replaced row duplicated its rows on every write, and anifor a component in the row froze on the value it first rendered.forunder anifrendered nothing,elseif/elsenever rendered, and the content ledger of a nestedforanchor grew by one entry per inner row per replacement.mainshares, which a re-rendered row makes visible. The shortcut that empties a parent when every row of a list is deleted wiped a siblingifanchor, so a row shaped<div><template if><template for>lost its inner list on every other replacement and reported a disconnected binding. And$watch("groups.*.items.*")stopped landing on the replaced row, because the batch's first write built the inner ledger under the row it replaces.{ ...row, n: v }, which carries the inner array over),$resolve("gs.*.its.*", [0,0], v)threwListIndexes not foundand the write was lost; replacing the same row twice in one batch left its nested list dead. That fix is reverted — reparenting instead of clearing is not possible at write time, because the ledger repair only moves away from a row the render has already retired. The round's other measurements are good news: the list-clearing shortcut costs the same or less thanmainat 2000 and 5000 rows, the content ledger stays flat over 30 replacements at every pool cap, and$listKeysrows, shared inner arrays and the stored cases of rounds 1-4 hold.Behavior differences against
mainthat remain, all measured:$watch/$scanon a nested element path (groups.*.items.*) does not land for a row replaced by an element write, at any depth. The batch's first write expands under the row it replaces, and the landing selection drops rows the re-render retires. The page and the state are correct, and a later write to that nested list lands normally. A test records this.mainis wrong here too, differently (it shows the first replacement's rows for the rest of the batch).$watch("<list>.*")once per written position with the moved value as bothcurandprev.mainfired with a stalecur.forof a replaced row is re-created and loses focus; only the row's own inputs keep it. Inner rows are new rows, andmainkept them only by not re-rendering at all — where it also left the typed text on the wrong inner row.wcBindable, say), a later element-write replacement also reportsContent not found for ListIndex: …, wheremainstays silent. The page is equally broken either way.[]in a row that also holds anifkeeps thatifworking. Onmaintheif's anchor is destroyed and it renders nothing for the rest of the page's life.On the probes where
mainthrowsContent not foundafter an element write — an unfinished swap in another<wcs-state>, a replacement after a swap, a hiddenfor, SSR hydration — this branch is consistent with the array and reports nothing.npm run test:coveragenpm run lint,npx tsc --noEmitInteraction with the companion PRs
integration.stateGenerationReset.test.ts(DOM97, state99). Merging fix(state): re-render on re-set, reject re-setting a loaded volume, re-check bound paths #281 into this branch conflicts only inCHANGELOG.md. On that merge, the pinned test is the only failure inintegration.stateGenerationReset,integration.stateResetReapplyandintegration.elementSwapIdentity(77 tests), and it receives["99","8"]. Whichever of the two merges second should flip that pin.CHANGELOG.mdentry, so a later merge needs a one-hunk rebase.🤖 Generated with Claude Code