Skip to content

perf(dom): List.each monotonic-append reconcile fast path (#171) - #181

Open
stefvw93 wants to merge 1 commit into
mainfrom
feat/list-append-fast-path
Open

perf(dom): List.each monotonic-append reconcile fast path (#171)#181
stefvw93 wants to merge 1 commit into
mainfrom
feat/list-append-fast-path

Conversation

@stefvw93

Copy link
Copy Markdown
Owner

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).

reconcileList now detects a pure suffix append and takes a fast path:

  • Trigger (after 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)).
  • Because projectKeys proved every key globally unique, a matching prefix guarantees the tail keys are new — no extra membership check.
  • appendListTail reuses the prefix records in place (untouched DOM + scopes), renders only the appended tail, inserts it in one pass before the region end marker, and extends prev.records.
  • Skips the prevIndex build, drop-set walk, and LIS.

The returned ListState is 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.md amendment + new list-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 main independently of the sibling first-emission mount fast path (#178, PR #180). The two are disjoint by design (this one's prev.order.length > 0 guard excludes the empty/mount case), so they compose cleanly at merge.

🤖 Generated with Claude Code

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

perf(dom): List.each LIS append fast path — skip key-diff + LIS on monotonic append

1 participant