Skip to content

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

@adnaan

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions