Skip to content

fix(state): move row blocks with their values on element-write swaps (#4) - #285

Merged
mogera551 merged 7 commits into
mainfrom
fix/state-element-swap-identity
Sep 16, 2026
Merged

mogera551 merged 7 commits into
mainfrom
fix/state-element-swap-identity

Conversation

@mogera551

Copy link
Copy Markdown
Contributor

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:

  • pinned each written value in the write cache to the listIndex that used to sit at that position, and
  • never re-rendered the list itself.

The row blocks stayed where they were and only their bound contents were rewritten:

  • items.0.name read the other row.
  • A row's $1 disagreed with the text it showed.
  • A write through a row landed on the other row.
  • Unbound DOM state (text typed into an input) stayed at the old position.
  • A later array replacement left the page out of step with the array.

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, state 99).

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:

Fix

  • No write-cache pin for element writes. setByAddressCore skips commitWriteCache for swappable element writes, so the cache cannot hold a value under a listIndex that now belongs to another row.
  • Re-render the list when a swap completes (notifySwappedList in setByAddress.ts).
    • If the for last 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.
    • The list address is queued render-only.
    • Rows are handled one by one:
      • A row that did not move is skipped.
      • A row that moved with its value has its dependents invalidated and re-rendered, without landing.
      • A row with a new listIndex is notified as a write landing at its position, with its lists expanded in full, since everything under it is new. It also takes over the prev that the write recorded under the element path for the row it replaced; without that, $watch("items.*") lost prev, because the replaced row is retired by the diff.
  • Replaced rows keep their block. When the diff runs against a marked copy, applyChangeToFor pairs 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.
    • The content is still torn down exactly as unmount does, minus detaching its own nodes (Content.unmountInPlace): the contents owned by a nested for / if in the row are unmounted and the binding address ledgers are cleared. Leaving them alone duplicated the nested rows on every write and froze the if content on the row it was first rendered for.
    • Dropping the batch's applied marks for a reused content walks the nested structural contents too. 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 marking only the row's own bindings left them torn down and never rebuilt.
    • Contents owned by a nested for are dropped from that anchor's content ledger before the row is torn down, so repeated replacements do not grow it.
    • A component in the row is not detached either, so it never gets the connectedCallback that re-binds a pooled row's mount scope. remountScopesUnderContent runs the same rebind (remountScopeBindings) for every mount scope inside the reused content, right after it is activated for the new row.
  • A defect main shares, which only shows once a row is re-rendered.
    • for empties 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 sibling if anchor. A row shaped <div><template if><template for> then lost its inner list on every other replacement and reported Cannot get absolute state address for disconnected binding. The check now counts structural anchors.
  • Render-only queue in the updater. Addresses queued with enqueueRenderOnlyAddress are applied in the same drain. They are not passed to drain listeners ($scan, $watch, $streams) and are not counted by hasQueuedPath. A swap therefore does not fire $watch("items"); an address that is really written in the same batch still lands once.
  • Swap bookkeeping keyed by the array. The record of the order before the writes was keyed by the list's 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.
  • Pooled content in 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]] showed 5 while the state held 9. This is on main too, and the swap re-render hits the same path.

Approaches that did not work

  • Notify every row of the list (full expansion). It fired $watch / $scan on rows nobody wrote (integration.watch.rowLanding, integration.scan.from).
  • Drop the applied mark in activateContent. Re-activating an if then applied its inner for twice and duplicated rows (integration.ifRemountOrder).
  • Give a replacing value the listIndex of the row it displaces. It kept the block, but the listIndex is the row's identity. The new row inherited the old row's 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.
  • Send rows that contain a for or an if through 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

  • New: integration.elementSwapIdentity.test.ts.
    • The steps from リスト要素スワップ時、ループブロックを入れ替えていない #4: DOM, reads, $1, writes, and a later array replacement.
    • Unbound input state moving with its row; a $1 getter.
    • A swap split across two batches; two swaps in one batch.
    • $watch("items") not firing for a swap; an array replacement and a swap in one batch firing it once.
    • The README $resolve example with primitives.
    • Row replacement:
      • An unbound row getter (["100","4"]) and a recursive total (["99","8"]), both keeping the block.
      • Typing into a primitive row, including a one-row list: the input is never detached and keeps focus.
      • An immutable row update from a row handler: the block and unbound input state stay.
      • A row containing a nested for (its rows are not duplicated), an if (its content follows the new value), and a child component bound with state.row: groups.* (its shadow follows the new row).
      • A nested for under an if, and a row whose wrapper holds only that if: the inner rows are re-rendered on every replacement, no row wrapper is left behind, and nothing is reported.
      • An if / elseif / else chain in the row: the branch that matches the new value renders.
      • Repeated replacements of a row with a nested 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 as prev, also when the same position is replaced twice in one batch.
    • A replacement in the middle of a swap whose displaced value moves elsewhere.
    • An unfinished swap in one <wcs-state> not leaking into another.
    • A leaf write and a replacement of that row in one batch (["9","2"]).
  • Updated:
    • updater.updater.test.ts for the render-only queue (not in the listener batch; an address both written and render-only still lands).
    • proxy.setByAddress.test.ts for the array-keyed swap record and the updater mock.

Verification

Rounds by an adversarial agent, each measuring this branch and main on 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:

  • Round 1. A write of a value that was not in the list re-created the row's block, so an input bound to a primitive row lost focus on every keystroke, and $watch("tags.*") lost prev for that write.
  • Round 2. Reusing a row's block skipped the teardown an unmount does: a nested for in a replaced row duplicated its rows on every write, and an if or a component in the row froze on the value it first rendered.
  • Round 3. The teardown ran but the re-apply stopped at the row's own bindings: a nested for under an if rendered nothing, elseif / else never rendered, and the content ledger of a nested for anchor grew by one entry per inner row per replacement.
  • Round 4. Two defects main shares, which a re-rendered row makes visible. The shortcut that empties a parent when every row of a list is deleted wiped a sibling if anchor, 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.
  • Round 5. The fix for round 4's landing problem was itself blocking: clearing the ledger of a nested list broke addressing. After the spelling this project recommends for an immutable row update ({ ...row, n: v }, which carries the inner array over), $resolve("gs.*.its.*", [0,0], v) threw ListIndexes not found and 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 than main at 2000 and 5000 rows, the content ledger stays flat over 30 replacements at every pool cap, and $listKeys rows, shared inner arrays and the stored cases of rounds 1-4 hold.

Behavior differences against main that remain, all measured:

  • $watch / $scan on 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.
  • Replacing the same row twice in one batch and then replacing its inner array leaves the previous inner rows in the page. main is wrong here too, differently (it shows the first replacement's rows for the rest of the batch).
  • In a list of primitives, equal values cannot be told apart: writes that end in a reordering of the same values count as a swap. The README says so.
  • Swapping primitive rows fires $watch("<list>.*") once per written position with the moved value as both cur and prev. main fired with a stale cur.
  • An input inside a nested for of a replaced row is re-created and loses focus; only the row's own inputs keep it. Inner rows are new rows, and main kept them only by not re-rendering at all — where it also left the typed text on the wrong inner row.
  • When a row's binding already throws during the first render (a misconfigured wcBindable, say), a later element-write replacement also reports Content not found for ListIndex: …, where main stays silent. The page is equally broken either way.
  • The anchor-counting fix changes one case that has nothing to do with element writes: clearing a nested list to [] in a row that also holds an if keeps that if working. On main the if's anchor is destroyed and it renders nothing for the rest of the page's life.

On the probes where main throws Content not found after an element write — an unfinished swap in another <wcs-state>, a replacement after a swap, a hidden for, SSR hydration — this branch is consistent with the array and reports nothing.

Gate Result
state npm run test:coverage 298 files / 3589 tests, exit 0 — 99.60 / 98.72 / 100 / 99.76
state npm run lint, npx tsc --noEmit clean

Interaction with the companion PRs

🤖 Generated with Claude Code

mogera551 and others added 7 commits September 16, 2026 06:47
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]>
@mogera551
mogera551 merged commit f002310 into main Sep 16, 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.

リスト要素スワップ時、ループブロックを入れ替えていない

1 participant