feat: per-op targeted DOM mutation for range diff ops (#107)#108
Conversation
Bypasses full tree HTML reconstruction + morphdom diff for keyed-range diff ops (r/u/i/a/p/o). The targeted-apply path mutates the live DOM directly via [data-key="..."] queries, while a sentinel comment + data-lvt-targeted-skip marker tells morphdom to short-circuit the subtree on coexisting sibling updates. Pre-fix, single-row delete on a 10k-row table took 6-8 seconds in Chrome desktop because the client deep-cloned 10k items, rebuilt 5MB of HTML, and ran morphdom over the entire range. Post-fix, the same op completes in ~1.7s wall-clock (server compute + WS + targeted DOM mutation + post-render scans). The targeted-apply portion itself is sub-100ms; the residual cost is in post-morphdom side-effect rescans (handleScrollDirectives, changeAutoWirer.wireElements) which still walk the wrapper at O(N) — that's a follow-up. Architecture: - New state/range-dom-applier.ts: per-op DOM mutation module with findContainer + canApplyTargeted + apply (r/u/i/a/p/o) + cleanupMarkers - state/tree-renderer.ts: extracted renderRangeItem helper; applyUpdate now takes optional canApplyTargeted callback, mutates treeState in place for eligible keys, returns targetedOps + emits comment placeholder in result.html for skipped ranges - livetemplate-client.ts updateDOM: dispatches targeted ops before morphdom; post-processes placeholder comments; morphdom's onBeforeElUpdated returns false for marked subtrees; cleanup in try/finally - types.ts: TargetedRangeOp interface + optional UpdateResult.targetedOps field (additive, no breakage for downstream consumers) Includes 23 jsdom unit tests in tests/range-dom-applier.test.ts (per-op coverage, focus preservation, lifecycle hooks, skip mechanism with morphdom). Closes #107 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
E2E tests assert the targeted-apply path was actually taken (vs silently hitting the fallback). Cost: one integer increment per applied op.
…107) Profile of a 10k-row delete revealed ~360ms wasted in post-render directive scans (setupFxDOMEventTriggers, setupDOMEventTriggerDelegation, handleScrollDirectives, etc.) — each does qsa("*") on the wrapper, which contains ~80k descendants at N=10k. For a delete-only render, no new elements need wiring, so all wrapper-wide scans become pure waste. Track DOM additions via: - Existing morphdom onNodeAdded callback (now also increments counter) - New onNodeAdded callback in RangeDomApplier context, fired by i/a/p ops When `nodesAddedThisRender === 0` after both phases, skip the scans. changeAutoWirer.wireElements still runs (it has its own eviction loop for stale wirings on removed elements). Measured impact (10k-row LargeTable delete in headless Chrome): - updateDOM: 692ms → 447ms (-35%) - Browser-side wall-clock: ~1500ms → ~1170ms (-22%) Limitation: if the server adds new directive attributes to an existing element via attribute morph (e.g. adds lvt-fx:keydown to a button that didn't have it), the listener won't be wired until a render that DOES add a node fires the next scan. Rare in practice; opt out by adding data-lvt-force-update on the affected element.
|
Good architectural approach — the targeted-apply path is well-structured and the skip mechanism with morphdom is clever. A few issues worth addressing before merge: Bug:
A safe fix: tag each range container with the range path ( Bug: When Either call
CSS escape fallback is incomplete ( The fallback for environments without The |
There was a problem hiding this comment.
Pull request overview
This PR introduces a targeted DOM-mutation fast path for keyed range diff ops (r/u/i/a/p/o) so large ranges can be updated in-place without full HTML reconstruction + a full morphdom walk, and adds a skip mechanism to prevent morphdom from diffing already-mutated subtrees.
Changes:
- Add
RangeDomApplierfor per-op DOM mutations and lifecycle hook firing, plus marker cleanup. - Extend
TreeRenderer.applyUpdateto optionally emittargetedOpsand placeholder comments to drive morphdom subtree skipping. - Update
LiveTemplateClient.updateDOMto apply targeted ops pre-morphdom, convert placeholders into skip markers, and skip wrapper-wide directive scans when no nodes were added.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
types.ts |
Adds TargetedRangeOp and UpdateResult.targetedOps for the new targeted-apply pipeline. |
state/tree-renderer.ts |
Emits targeted ops + skip placeholders and exposes renderRangeItem for single-item rendering. |
state/range-dom-applier.ts |
New module implementing direct DOM mutations for range ops with lifecycle hook support. |
livetemplate-client.ts |
Wires targeted apply into updateDOM, adds subtree skip markers, and skips directive scans on delete-only renders. |
tests/tree-renderer.test.ts |
Tests targetedOps emission, placeholder behavior, and in-place mutation invariants. |
tests/range-dom-applier.test.ts |
Adds unit coverage for all targeted ops, lifecycle hooks, and morphdom skip behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Build morphdom options once so the applier's `u` op (which morphdoms | ||
| // a single row) uses the same callback set — focus skip, lvt-ignore, | ||
| // checkbox preservation, lifecycle hooks all stay consistent. | ||
| const morphdomOptions = { | ||
| childrenOnly: true, // Only update children, preserve the wrapper element itself | ||
| getNodeKey: (node: any) => { |
| // <!--lvt-targeted-skip:path--> placeholders that we now convert to | ||
| // data-lvt-targeted-skip markers on their parent elements so morphdom | ||
| // short-circuits those subtrees. | ||
| if (result.targetedOps && result.targetedOps.length > 0) { | ||
| for (const op of result.targetedOps) { | ||
| const container = this.rangeDomApplier.apply( | ||
| element, | ||
| op, | ||
| morphdomOptions | ||
| ); | ||
| if (container) { | ||
| container.setAttribute(TARGETED_APPLIED_ATTR, ""); | ||
| } | ||
| } | ||
| this.replaceTargetedSkipPlaceholders(tempWrapper); |
| while (cur && cur !== wrapper) { | ||
| if (cur.hasAttribute("lvt-ignore")) { | ||
| return { ok: false, reason: "lvt-ignore ancestor" }; | ||
| } |
Bug fixes from Claude + Copilot review on PR #108: 1. **findContainer no longer falls back to unscoped wrapper walk** (Claude #1) Removed `wrapper.querySelector('[data-key]')` fallback that could silently return a container belonging to a *different* keyed range when the wrapper has more than one. Returns null instead so the caller falls back to full rebuild. 2. **applyUpdateRow no-morphdom path now fires lifecycle hooks + notifies host** (Claude #2) The `row.replaceWith(newRow)` fallback never fired lvt-mounted/lvt-destroyed and didn't increment nodesAddedThisRender, so post-render directive scans were incorrectly skipped on rows containing newly-revealed children. 3. **Override childrenOnly:false for per-row morph** (Copilot #1) The main morphdom call uses childrenOnly:true (correct for the wrapper). For a single-row morph we must diff the row root too — class/style/aria attributes produced by statics+dynamics may have changed. 4. **Robust handling of failed targeted ops** (Copilot #2) When apply() returns null, the treeState was updated but the live DOM wasn't. Previously we'd still convert placeholders → either tell morphdom to skip (stale DOM) or leave an empty container (10k rows destroyed). Now we re-render full HTML from treeState (new `TreeRenderer.renderState()`), strip any partial success markers, and let morphdom sync. 5. **lvt-ignore check now includes wrapper itself** (Copilot #3) The walk previously stopped *before* checking the wrapper, allowing targeted-apply to mutate DOM inside an lvt-ignore'd wrapper. 6. **applyReorder logs a warning when newKeyOrder is shorter than children** (Claude #3) Documents the protocol assumption (server always sends full new order) and surfaces silent data-loss if the protocol changes. 7. **CSS escape fallback warns on unsupported chars** (Claude #4) Polyfill only handles `"` and `\`; warn (not silently miss) when keys contain `[]:.()#>~+*=^$|! ` whitespace etc. Tests: - 4 new range-dom-applier tests covering (1), (2), (3), (5) - Fixture pre-populates container cache via canApplyTargeted-equivalent call so a/p ops can locate the container (mirrors production flow now that the unscoped fallback is gone) - 510 unit tests pass (was 507 — net +3) - TestLargeTable + TestLargeTable_DeleteLatency_10k green at 1958 ms (ceiling 2500 ms), targeted-apply hits=1 - TestTodosE2E full suite green
|
Review: targeted range DOM applier Good overall architecture — the lvt-ignore ancestor check, fail-closed container resolution, and try/finally for marker cleanup are all solid defensive patterns. Bug: silent per-op skips in In Fix: have let anyOpFailed = false;
// inside the op loop:
case "u":
if (!this.applyUpdateRow(...)) anyOpFailed = true;
break;
// after the loop:
return anyOpFailed ? null : container;
A Minor: directive scan skip limitation is user-invisible The
|
Round-2 bot review (Claude) caught a correctness gap in the round-1
fixes: per-op methods (applyUpdateRow, applyInsertAfter, applyAppend,
applyPrepend, applyReorder) had several early-return paths for stale
state (row not found, item state unavailable, anchor missing, render
failed). These returned silently and apply() still reported the
container as a success — so updateDOM marked it with
TARGETED_APPLIED_ATTR, morphdom skipped the subtree, and the live DOM
stayed out of sync with treeState forever.
Changes:
1. Each per-op method now returns boolean. apply() collects the result
and returns null if any op silently no-op'd, triggering the
updateDOM full-rebuild fallback (added in round 1).
2. applyRemove still returns true on missing row (idempotent — common
when the same row got removed by an earlier op or previous render).
3. itemLookup moved from `wireItemLookup()` post-construction call into
the required RangeDomApplierContext field. Eliminates the silent
footgun where forgetting to wire it would silently no-op every `u`
op (treeState updated, DOM stale).
4. Added 3 jsdom unit tests covering the new contract:
- apply() returns null when u op finds row in DOM but item state
missing
- apply() returns null when i op anchor is missing
- r op apply() succeeds even when row already gone (idempotency)
Tests: 513 unit tests pass (+3 new). TestLargeTable + TestLargeTable
DeleteLatency_10k green at 1523ms with targeted-apply hits=1.
Notes for follow-up (not addressed in this PR):
- Claude flagged that data-lvt-force-update (the escape hatch for
attribute-only changes that skip directive scans when
nodesAddedThisRender===0) isn't in user-facing docs. The inline
comment in updateDOM documents it but the public docs in
livetemplate/docs/references/ should be updated. Out of client repo
scope; deferred.
Review: Targeted range DOM applierNice optimization — the targeted-apply path is well-structured with a solid fallback story. Three issues worth addressing before merging: 1.
|
Three Claude follow-up findings on commit 3c99b99: 1. **applyReorder fires lvt-destroyed on dropped children** When `newKeyOrder` is shorter than the existing children set, dropped children were silently discarded by `replaceChildren(fragment)`. Any user teardown registered via lvt-destroyed (timer cancellation, observer disconnect, etc.) never ran. Now: walk byKey for entries missing from the new order set, fire lvt-destroyed on each subtree before the replaceChildren. 2. **Directive-touched signal complements nodes-added signal** The `nodesAddedThisRender > 0` skip-scan optimization missed the case where the server adds a directive attribute to an existing element via attribute morph (e.g. `lvt-fx:keydown` on a button that didn't have it). Without scanning, the listener never wires and the user has to know about `data-lvt-force-update` as the escape hatch — which isn't publicly documented. Now morphdom.onBeforeElUpdated checks toEl for any `lvt-*` attribute and sets `directiveTouchedThisRender = true`. The scan gate becomes `nodesAdded > 0 || directiveTouched`, preserving the delete-only fast path while wiring listeners on attribute-only morphs that introduce new directives. Cost: per onBeforeElUpdated call, scan toEl.attributes for any name starting with "lvt-" (4-char prefix check via charCodeAt to avoid String.startsWith allocation overhead). At 80k descendants that's ~10ms — well below the post-render scans we'd otherwise run unnecessarily. 3. **__lvtTargetedHits gated behind explicit init** Previously incremented `window.__lvtTargetedHits` unconditionally, which polluted the global object in production. Now: increments only when the property is already present on `window` — tests initialize it via `window.__lvtTargetedHits = 0` before measuring; production never sets it so the increment branch is skipped. 4. **itemLookup O(N) acknowledged with comment** (Claude #3, low pri) Linear scan is bounded; building a Map<key,item> per render would amortize multi-u-op renders but adds caching complexity. Documented the trade-off in the lookup callback; revisit if profiling shows it on the hot path. Tests: 514 unit pass (+1 new applyReorder lvt-destroyed test). TestLargeTable + TestLargeTable_DeleteLatency_10k green at 1798ms, targeted-apply hits=1.
ReviewOverall this is a well-designed optimization with good test coverage and solid fallback handling. Three issues worth addressing before merge. Bug: unknown op type silently marks successIn Fix: set Bug:
|
Three Claude follow-up findings on commit 02c1755: 1. **Unknown op type now triggers fallback** (forward-compat) The switch's `default:` case left `opOK = true`, so any future unrecognised op type was silently treated as a success and the targeted-skip marker would have left the live DOM stale. Now sets `opOK = false` (and warns), forcing a full morphdom rebuild. 2. **Atomic insert: applyAppend/Prepend/InsertAfter bail before any DOM mutation on partial render failure** Previously, if `renderAndParse` failed for the second of two items, the first was already inserted with `lvt-mounted` fired. The fallback then ran a full morphdom rebuild — adding the same row again and firing `lvt-mounted` a second time. Refactored all three insert paths to render into a scratch DocumentFragment first, only splicing into the live DOM (and firing onNodeAdded + lvt-mounted) when ALL items rendered successfully. Extracted the common path into `renderItemsAtomic`. 3. **staticsContainKeyAttribute boundary check** (precision) `s.includes('data-key=')` had two false-positive classes: longer attribute names like `data-keystone=`, and `data-key=` appearing in text/attribute values. Tightened to a regex requiring the attr name be preceded by whitespace or `<` and followed by `\s*=` — eliminates the longer-name class. Quoted-value class is acknowledged as a known limitation in a code comment (false positives are SAFE: they just trigger the full-rebuild fallback without corrupting state). (4 minor: __lvtTargetedHits Symbol-key — declined; the existence-check guard is sufficient for the test-only use case and Symbol keys significantly complicate test setup. Documented in the existing comment.) Tests: 519 unit pass (+5 new). TestLargeTable + DeleteLatency_10k green at 1733ms with targeted-apply hits=1.
|
Review: Targeted Range DOM Applier. Good overall design. Three issues found: (1) BUG: applyReorder JSDoc says dropped rows fire no lvt-destroyed hook, but the code does fire it and the test asserts it - the comment is wrong. (2) Dead code: container param in renderItemsAtomic is never read (void container is the tell) - remove it from the signature and 3 call sites. (3) Minor perf: the two RegExps in staticsContainKeyAttribute are constant but constructed inside nested loops on every call - move to module-level constants. No security issues; HTML trust model unchanged. |
Three small Claude follow-up items on commit c26c003: 1. **applyReorder JSDoc**: said dropped rows fire no lvt-destroyed, but commit 02c1755 already fixed that to fire them. Comment was stale; updated to describe the correct (current) behavior. 2. **renderItemsAtomic dead `container` parameter**: leftover from the inline-helper refactor in c26c003. The splice callback closes over its container, so renderItemsAtomic doesn't need the param. Removed from signature + all 3 call sites. 3. **Pre-compile staticsContainKeyAttribute regexes**: hoisted the per-attr RegExp construction to module-level constants (KEY_ATTR_REGEXES). Previously rebuilt 2 RegExps per static segment per call. At initial render that's ~hundreds of allocations; now amortized to 2 at module load. No behavior change. 519 unit tests pass. TestLargeTable_DeleteLatency_10k: 1534ms wall-clock with hits=1.
Review: Targeted DOM Apply for Keyed RangesOverall: Solid performance optimization. The fallback mechanism (any op failure → full rebuild from authoritative Issues1. The shared The primary savings (skipping the structural morphdom diff on the entire range container) are still realized. But the scan-avoidance optimization is negated for 2. Partial DOM mutations not rolled back before fallback rebuild In 3.
4. Minor: The ops are discriminated union tuples ( |
Three Claude follow-up items on commit ff715fd: 1. **directiveTouchedThisRender now requires NEW lvt-* attribute** The previous check fired the flag whenever onBeforeElUpdated saw any lvt-* attribute on toEl. For high-frequency `u`-op renders on rows that ALREADY carry a directive (e.g. Todos rows with lvt-fx:animate persisting across renders), this triggered a wrapper-wide directive scan on every render — negating the scan-avoidance optimization. Now we only flag when the attribute is NEW: `toAttrs.includes(n) && !fromEl.hasAttribute(n)`. Catches the originally-intended case (server adds a new directive via attribute morph) without thrashing on stable directives. 2. **Comment on no-rollback safety in apply() failure path** When `allOpsSucceeded = false`, earlier ops that ran are NOT rolled back. The fallback rebuild from `treeRenderer.renderState()` produces the authoritative HTML and morphdom reconciles whatever partial state the live DOM is in. Documented in the early-return block. 3. **firstKnownKey now extracts from a/p item._k as a fallback** For batches containing only a/p ops on a cold cache (rare; canApplyTargeted normally warms it), apply would have failed container lookup. Now firstKnownKey samples the new items' `_k` so apply can find the container in the cold-cache case. (4 minor: TargetedRangeOp.ops discriminated union — declined; would touch the public type API. The existing `any[]` is permissive but the runtime switch + per-op return-bool already enforces correctness. Documented as a TODO in a future cleanup.) No behavior change for the LargeTable hot path. 519 unit tests pass; TestLargeTable_DeleteLatency_10k green at 1705ms with targeted-apply hits=1.
Code ReviewOverall: Well-architected performance optimization. The opt-in design, fail-closed semantics, and fallback paths are all sound. A few issues worth addressing: Bug:
|
The `nodesAddedThisRender > 0 || directiveTouchedThisRender` gate added in #108 was overly aggressive: it skipped ALL post-render directive scans when no new elements appeared and no new lvt-* attribute was introduced via attribute morph. But the "fire on change" directives — handleScrollDirectives, handleHighlightDirectives, handleAnimateDirectives, handleToastDirectives, setupScrollAway — detect VALUE changes on existing directive-bearing elements. They MUST run on every render to work as documented (e.g. `lvt-fx:highlight="flash"` should flash on every render where the underlying value changed). For a `lvt-fx:highlight` element whose value changes via a scalar update (no nodes added, no new directive attrs), the post-#108 client silently dropped the highlight. Repro: examples patterns TestHighlightOnChange/Increment_Flashes_Both_Highlight_Targets. Fix: split the scans into two classes: FIRE-ON-CHANGE (always run): handleScrollDirectives, handleHighlightDirectives, handleAnimateDirectives, handleToastDirectives, setupScrollAway. Each does a CSS attribute selector qsa for its specific directive — at 80k descendants on a LargeTable where rows DON'T carry these directives, the qsa returns empty in ~1-3ms total. Cheap. WIRE-IDEMPOTENT (skip when nothing new): setupFxDOMEventTriggers, setupDOMEventTriggerDelegation, uploadHandler.initializeFileInputs. These walk every descendant via qsa("*") — at 80k descendants they're ~150-200ms each. Still gated on nodesAdded || directiveTouched. Net: preserves the 360ms LargeTable-delete optimization (the expensive wire-idempotent scans still skip) while restoring correct fire-on-change semantics. Adds ~5-10ms to renders that previously skipped all scans — acceptable. Tests: 519 unit pass. Examples patterns TestHighlightOnChange now passes consistently against the new client.
Summary
Closes #107.
Bypasses full tree HTML reconstruction + morphdom diff for keyed-range diff ops (
r/u/i/o/a/p). The targeted-apply path mutates the live DOM directly via[data-key="..."]queries, while a sentinel comment +data-lvt-targeted-skipmarker tells morphdom to short-circuit the subtree on coexisting sibling updates.Plus: skip wrapper-wide directive scans when no nodes were added in the render (delete-only ops have nothing to wire).
Measured impact (10k-row LargeTable single-row delete)
updateDOMJS timeThe remaining ~1.17 s browser-side is server compute (~150 ms) +
updateDOMJS (~447 ms) + browser layout/paint of the 10k-row table (~570 ms). Sub-second at this scale needs table virtualization — tracked as a separate follow-up issue.Architecture
state/range-dom-applier.ts: per-op DOM mutation module —findContainer+canApplyTargeted+apply(r/u/i/a/p/o) +cleanupMarkers. Fireslvt-mounted/lvt-destroyedlifecycle hooks on inserted/removed subtrees.state/tree-renderer.ts: extractedrenderRangeItemhelper.applyUpdatenow takes optionalcanApplyTargetedcallback, mutatestreeStatein place for eligible keys, returnstargetedOps, emits comment placeholder inresult.htmlfor skipped ranges.livetemplate-client.ts updateDOM: dispatches targeted ops before morphdom; post-processes placeholder comments intodata-lvt-targeted-skipattributes; morphdom'sonBeforeElUpdatedreturnsfalsefor marked subtrees; cleanup intry/finally. TracksnodesAddedThisRenderto skip wrapper-wide directive scans when zero new elements.types.ts:TargetedRangeOpinterface + optionalUpdateResult.targetedOpsfield (additive — no breakage for downstream consumers).Tests
tests/range-dom-applier.test.ts: per-op coverage (r/u/i/a/p/o), focus preservation, lifecycle hooks fire on subtree, skip mechanism with morphdom (countgetNodeKeyinvocations).targetedOpsreturn shape + placeholder emission + in-place mutation invariant.Limitation noted
If the server adds new directive attributes via attribute morph to an existing element (e.g. adds
lvt-fx:keydownto a button that didn't have it), the listener won't be wired until the next render that adds a node. Rare in practice; opt out withdata-lvt-force-update.Observability hook
window.__lvtTargetedHitsincrements each time the applier runs. Used by E2E tests to assert the targeted-apply path was actually taken (vs silently hitting the fallback). Cost: one integer increment per applied op.Test plan
Delete_Targeted_Apply_Path_Takentargeted-apply hits: 1🤖 Generated with Claude Code