Skip to content

Per-op range diff application: avoid full tree reconstruction on ["r"]/["u"]/["i"]/["o"] #107

Description

@adnaan

Summary

For range diff ops (["r"], ["u"], ["i"], ["o"]), the client currently does a full tree deep-clone + full HTML reconstruction + full innerHTML parse + DOM children replacement — even when the op only mutates one row. At N=10,000 rows this takes 6-8 seconds in Chrome desktop (and worse on mobile Safari) for a single-row delete that the server already represented as a 110-byte op.

The streaming-range work in livetemplate#365, #366, and the upcoming Phase 7+8 PR drives the server-side per-render cost down to ~75ms at N=10k and the wire payload to ~110 bytes per delete. None of that win reaches the user because the client redoes O(N) work regardless of op shape.

Reproduction

Server: livetemplate/examples patterns/lists/large-table with default LARGE_TABLE_SIZE=10000. Click any row's Delete button.

Server WS frames (verified):

  • Outgoing: {"action":"delete","data":{"value":"row-XXXXX"}} — 48 bytes, sent at T+0
  • Incoming: {"tree":{"2":"9999","3":"9999","8":[["r","row-XXXXX"]]},"meta":{...}} — 110 bytes, received at T+111ms

User-visible UI update completes ~6-8 seconds after the response arrives.

Root cause (file anchors)

In state/tree-renderer.ts:

applyUpdate(update: TreeNode): UpdateResult {
  // ...
  if (existingIsRange) {
    this.treeState[key] = deepClone(existing);  // <-- O(N) deep clone of all 10k items
    this.applyDifferentialOpsToRange(this.treeState[key], value, key);
  }
  // ...
  const html = this.reconstructFromTree(this.treeState, "");  // <-- O(N) full HTML rebuild (~5MB)
  return { html, changed };
}

(grep applyUpdate in state/tree-renderer.ts; deep-clone at the existingIsRange branch; reconstructFromTree returns the full HTML at the end of the function.)

Then in livetemplate-client.ts:

tempWrapper.innerHTML = result.html;  // <-- O(5MB) HTML parse

(grep tempWrapper.innerHTML = result.html in livetemplate-client.ts.)

Then morphdom-style replaceChildren reflows the table.

So per single-row delete on a 10k-row table: deep-clone 10k items → splice one out → walk tree to build ~5 MB HTML string → parse 5 MB HTML into DOM → diff/replace into live DOM. The op itself (["r", key]) is targeted; the application path is not.

Proposed approach

Apply each range op as a targeted DOM mutation against the live DOM, without going through the full tree → HTML → innerHTML pipeline:

Op Today Proposed
["r", key] full re-render container.querySelector(\[data-key="${key}"]`).remove()`
["u", key, dynamics] full re-render find row by key, update each dynamic position via textContent / attribute set
["i", afterKey, position, item] full re-render render the single new item's HTML, insertBefore at the right sibling
["o", newKeys] full re-render DocumentFragment-based DOM sibling reorder

The treeState deep clone can be eliminated when the apply is in-place targeted; no need to keep a full mirror in JS just to regenerate HTML.

For nested-range items (range-in-range), the current full-rebuild path remains as the fallback (also matches the spec's nested-range serialization rule).

Why it matters

  • The server side of streaming-range gets ~37,000× wire reduction at N=10k (110 B vs ~4.8 MB) and ~75 ms render. The client undoing that with 6-8 s of full-rebuild work makes the entire optimization invisible to users.
  • Affects every consumer of livetemplate with a large range — not specific to the LargeTable demo.
  • The proposed targeted-mutation path is sub-millisecond per op for typical workloads (browsers index [data-key] queries, single-element mutations are cheap).

Suggested scope

  1. PR 1: introduce per-op targeted application paths for ["r"], ["u"], ["i"], ["o"]. Keep current full-rebuild as the fallback (used when the range isn't found in DOM, when ops are interleaved with structural changes the targeted path can't express, etc.).
  2. PR 2: remove the deepClone of range items in applyUpdate once targeted apply doesn't need the JS mirror.
  3. PR 3: add a perf regression test asserting bounded per-op wall time at N=10k.

Tracking

Discovered while shipping streaming-range Phase 7+8 in livetemplate (per-render server cost 306 ms → 75 ms at N=10k). Server side numbers and design: livetemplate docs/proposals/build-latency-optimization-proposal.md (Phase 7+8 PR, pending). Server WS frame timestamps and the user-visible 6-8 s delay confirmed via the LargeTable patterns example with the user driving manually.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions