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
- 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.).
- PR 2: remove the
deepClone of range items in applyUpdate once targeted apply doesn't need the JS mirror.
- 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
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/examplespatterns/lists/large-tablewith defaultLARGE_TABLE_SIZE=10000. Click any row's Delete button.Server WS frames (verified):
{"action":"delete","data":{"value":"row-XXXXX"}}— 48 bytes, sent at T+0{"tree":{"2":"9999","3":"9999","8":[["r","row-XXXXX"]]},"meta":{...}}— 110 bytes, received at T+111msUser-visible UI update completes ~6-8 seconds after the response arrives.
Root cause (file anchors)
In
state/tree-renderer.ts:(grep
applyUpdateinstate/tree-renderer.ts; deep-clone at theexistingIsRangebranch;reconstructFromTreereturns the full HTML at the end of the function.)Then in
livetemplate-client.ts:(grep
tempWrapper.innerHTML = result.htmlinlivetemplate-client.ts.)Then morphdom-style
replaceChildrenreflows 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:
["r", key]container.querySelector(\[data-key="${key}"]`).remove()`["u", key, dynamics]textContent/ attribute set["i", afterKey, position, item]insertBeforeat the right sibling["o", newKeys]The
treeStatedeep 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
[data-key]queries, single-element mutations are cheap).Suggested scope
["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.).deepCloneof range items inapplyUpdateonce targeted apply doesn't need the JS mirror.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: livetemplatedocs/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