perf(nanoviews): wake only the rows whose value changed - #202
Merged
Conversation
dangreen
force-pushed
the
fix/nanoviews-writes-during-swap
branch
from
August 19, 2026 12:29
b03d0f3 to
361cba9
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #202 +/- ##
==========================================
+ Coverage 84.99% 85.29% +0.29%
==========================================
Files 140 140
Lines 3159 3168 +9
Branches 596 593 -3
==========================================
+ Hits 2685 2702 +17
+ Misses 338 335 -3
+ Partials 136 131 -5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
dangreen
force-pushed
the
fix/nanoviews-writes-during-swap
branch
3 times, most recently
from
August 19, 2026 12:39
686c5e7 to
1fbf651
Compare
The rows of a loop shared one subscription to the items array through `atIndex`, so a write to any element woke every row on screen. A row now owns its value signal and the reconcile pushes into it: a write reaches only the rows whose value actually moved, and the face in front of the row carries a write back to its own place in the array, found by the row's key and index instead of by a search. A read-only array costs no face at all. The reconcile walks the list with counters and pointers instead of building `matched` and `stashed` arrays for every run, deletes a removed suffix in one crossing rather than item by item, and rewinds only within a budget of one pass. Every row now holds at least one DOM node, so a row that rendered nothing still has a place to be moved to and inserted before, which takes the emptiness guard out of `move`. All four `nanoviews` size pins come down. It also closes a hole the rows made reachable. Content may write the very signal its block swaps on - a row that normalises the value it was handed, a branch that refuses to be shown. The write is made while the swap runs, and a running effect cannot be re-queued by its own propagation, so the block kept content that was already contradicted and the corrective content was rendered but never started. Waking it takes a second reader that is idle at that moment: the loop starts its rows from an effect of its own over the same array the swap reads, which serves as that reader and also keeps the rows off the swap's own stack, and `decide` carries one on the condition. The `batch` around the reconcile goes with them - the swap already runs from the flush, so the batch added nothing but a trailing flush of its own, and that flush drained the queue onto the one stack where the write is lost.
dangreen
force-pushed
the
fix/nanoviews-writes-during-swap
branch
from
August 19, 2026 16:38
1fbf651 to
e6d1775
Compare
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.
The rows of a loop shared one subscription to the items array.
atIndex($items, $index)is a computed over the whole array, so a write to any single element marked every row's computed dirty and woke every row on screen to discover that its own element had not changed.A row owns its value
The reconcile now pushes the value into the row instead of the row pulling it out of the array:
A write to the array wakes only the rows whose value actually moved. The face handed to
each_sits in front of the item, so a write back into the array finds its place from the row's own key and index — no search, no per-row closure over the array. A read-only items array has nothing to write back to, so its rows are the bare value signal and cost no face at all; the signal is marked non-writable so a child of it is never handed a setter that writes nowhere.The write-back travels through one slot for the whole pass, because a signal write is a reducer when it is a function and a row value that is a function must be stored, not called:
The reconcile stops allocating
matchedandstashedwere arrays rebuilt on every run; they are now two counters plus two pointers into the list, and the rewind is bounded by a budget of one pass over it. The list itself is a chain headed by the list object, so a splice is always the same two writes with no head to special-case. A removed suffix is cut with one splice and one DOM crossing instead of item by item. Every row now holds at least one DOM node — a row that rendered nothing gets a text node — so it always has a place to be moved to, inserted before and removed with, andmoveloses its emptiness guard.All four size pins come down:
109 B off the average-usage bundle, mostly
atIndexandbatch, which the loop no longer pulls in.The hole the rows made reachable
Once a row can write back into the array, it will: a row that normalises the value it was handed does it from its own render body, or from an effect the update started. That write is made while the swap runs, and a running effect cannot be re-queued by its own propagation — so the block kept showing content that was already contradicted, and the corrective content was rendered but never started, its DOM in place while its effects never ran. The same hole is reachable from
if_andswitch_, where a branch writes its own condition.Waking the parked swap takes a second reader of the same signal, idle at the moment of the write: its read settles the signal and re-queues the swap for the corrective pass. Each block already had somewhere to put one.
The loop starts its rows from an effect of its own over the array the swap reads:
That
$items()read is the second reader. Starting from here also keeps the rows a reconcile created off the swap's own stack, and the walk goes fromitemsList.c— the first row the reconcile made — instead of the array of created rows the old code built. It subscribes after the swap on purpose: a signal notifies its readers in subscription order, and the rows have to exist before anything starts them.decidecarries one on the condition:The
batcharound the reconcile goes away with them. The swap runs from the flush and from nowhere else, so its writes were already deferred; the batch added nothing but a trailing flush of its own, and that flush drained the queue onto the one stack where a write back is lost.Tests
for_gains the row write-back cases — from a removed row, from a row whose key was reused by a new one, from a row created by an update, from one that survived it, from a row writing during its own render, and from a row the placeholder gave way to (the period swap, a different path into the same running swapper). Each checks the DOM and the array agree. Then a fuzzer: four seeds, 150 random steps each, checking after every step that the rows stand in the array's order and that every row on screen has had its effect run. That last invariant is the one this change is about — a row rendered but never started keeps the right DOM and silently answers nothing.if_gains the write-during-swap cases, each asserting through an effect of the brought-back branch that it is live and not merely rendered.