Skip to content

perf(nanoviews): wake only the rows whose value changed - #202

Merged
dangreen merged 1 commit into
mainfrom
fix/nanoviews-writes-during-swap
Aug 19, 2026
Merged

perf(nanoviews): wake only the rows whose value changed#202
dangreen merged 1 commit into
mainfrom
fix/nanoviews-writes-during-swap

Conversation

@dangreen

@dangreen dangreen commented Aug 19, 2026

Copy link
Copy Markdown
Member

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:

interface LoopItem extends LoopLink {
  k: unknown                            // tracking key
  i: WritableSignal<number>             // index
  v: WritableSignal<unknown>            // value - what the reconcile writes
  a: WritableSignal<unknown[]> | undefined
  
}

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:

let rawValue: unknown
const raw = () => rawValue

The reconcile stops allocating

matched and stashed were 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, and move loses its emptiness guard.

All four size pins come down:

before after
all publics (gzip) 7668 7585
all publics (brotli) 6849 6762
average usage (gzip) 4359 4253
average usage (brotli) 4001 3899

109 B off the average-usage bundle, mostly atIndex and batch, 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_ and switch_, 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:

periodScope(() => effect(() => {
  $items()
  startRows()
}))

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 from itemsList.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.

decide carries one on the condition:

effect(() => void $condition(), true)

The batch around 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.

@dangreen
dangreen force-pushed the fix/nanoviews-writes-during-swap branch from b03d0f3 to 361cba9 Compare August 19, 2026 12:29
@dangreen dangreen changed the title fix(nanoviews): keep a write made while a block swaps perf(nanoviews): wake only the rows whose value changed Aug 19, 2026
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.29%. Comparing base (ac6b35f) to head (e6d1775).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dangreen
dangreen force-pushed the fix/nanoviews-writes-during-swap branch 3 times, most recently from 686c5e7 to 1fbf651 Compare August 19, 2026 12:39
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
dangreen force-pushed the fix/nanoviews-writes-during-swap branch from 1fbf651 to e6d1775 Compare August 19, 2026 16:38
@dangreen
dangreen merged commit c71cc9c into main Aug 19, 2026
10 checks passed
@dangreen
dangreen deleted the fix/nanoviews-writes-during-swap branch August 19, 2026 19:29
@github-actions github-actions Bot mentioned this pull request Aug 19, 2026
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