perf(dom): List.each monotonic-append reconcile fast path (#171) - #181
Open
stefvw93 wants to merge 1 commit into
Open
perf(dom): List.each monotonic-append reconcile fast path (#171)#181stefvw93 wants to merge 1 commit into
stefvw93 wants to merge 1 commit into
Conversation
The most common list mutation is a pure append: the new snapshot is the previous order, unchanged and in order, plus new keys at the tail. The general reconcile path still pays the full O(n log n) diff+LIS walk over the whole list for it (the #168 residual after the Loom scheduler conflated queued snapshots). reconcileList now detects a pure suffix append (after projectKeys, so the KR1 duplicate guard still runs) and takes a fast path: reuse the prefix records in place, render only the appended tail, insert it in one pass before the region end marker, and extend prev.records with the tail. The post-reconcile ListState is identical to the general path, so every MR/KR/SC/ID/ER invariant holds and later reconciles are unaffected. Trigger: prev.order.length > 0 && keys.length > prev.order.length && prev.order.every((k, i) => Equal.equals(keys[i], k)). Guarded to a non-empty previous order (the empty/mount case stays on the general path). - Add appendListTail (private) + append branch in reconcileList. - Add list-append-fast-path.test.ts equivalence guards (AP1-AP6) plus a single-anchor insertion-signature guard that fails if the fast path is removed (the general path is DOM-op-identical for appends, so only the insertion anchor distinguishes them). - Extend perf-list-backlog.browser.test.ts with AP7: appending K rows to an N-row list inserts only the K tail ranges (one anchor), moves no existing row, under a generous cap. - Spec: list-append-fast-path.specs.md; list.specs.md amendment note. No public API change. All gates green: check, test (803), test:browser, test:types. Co-Authored-By: Claude Opus 4.8 (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 #171 (residual of #168).
What
The most common list mutation is a pure append: the new snapshot is the previous order, unchanged and in order, plus new keys at the tail. After the Loom scheduler (#170) conflated queued snapshots, each surviving reconcile still paid the full O(n log n) diff+LIS walk over the whole list for an append (the #168 residual).
reconcileListnow detects a pure suffix append and takes a fast path:projectKeys, so the KR1 duplicate guard still runs first):prev.order.length > 0 && keys.length > prev.order.length && prev.order.every((k, i) => Equal.equals(keys[i], k)).projectKeysproved every key globally unique, a matching prefix guarantees the tail keys are new — no extra membership check.appendListTailreuses the prefix records in place (untouched DOM + scopes), renders only the appended tail, inserts it in one pass before the region end marker, and extendsprev.records.prevIndexbuild, drop-set walk, and LIS.The returned
ListStateis identical to the general path, so every MR/KR/SC/ID/ER invariant holds and later reconciles are unaffected. Guarded to a non-empty previous order (the empty/mount case stays on the general path). No public API changed.Testing the optimization, not just correctness
The append fast path is DOM-op-identical to the general path (for a suffix append the general LIS keeps the whole prefix and inserts only the tail), so equivalence tests alone cannot detect the fast path being removed. A dedicated insertion-signature guard closes that gap: the fast path inserts every tail range before the single region end marker (one distinct anchor), while the general path anchors each new item to the next (K distinct anchors). Asserting a single distinct anchor is a genuine red→green signal — verified by disabling the fast path and watching exactly that assertion fail while the equivalence tests stay green.
Tests
list-append-fast-path.test.ts(jsdom): AP1/AP2 append core, AP3 record-state parity, AP4 non-append fallthrough (mid-insert / reorder / remove / prefix-mismatch), AP5 tail-dup →RenderError(KR1), AP6 subscription preservation, plus the single-anchor fast-path-engaged guard.perf-list-backlog.browser.test.ts(Chromium): AP7 — appending K=100 rows to N=5,000 in one emission inserts only the K tail ranges (≤ K×3, one anchor), moves no existing row, preserves prefix node identity, under a generous cap.Validation
All four gates green locally:
vp run check,vp run test(803),vp run test:browser(70),vp run test:types(149 tests / 251 assertions).Docs
list.specs.mdamendment + newlist-append-fast-path.specs.md(ACs AP1–AP7). No user-facing docs change (internal optimization, no public API).Note on Feature A
This branches off
mainindependently of the sibling first-emission mount fast path (#178, PR #180). The two are disjoint by design (this one'sprev.order.length > 0guard excludes the empty/mount case), so they compose cleanly at merge.🤖 Generated with Claude Code