Skip to content

feat: per-op targeted DOM mutation for range diff ops (#107)#108

Merged
adnaan merged 9 commits into
mainfrom
range-targeted-dom-apply
May 2, 2026
Merged

feat: per-op targeted DOM mutation for range diff ops (#107)#108
adnaan merged 9 commits into
mainfrom
range-targeted-dom-apply

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented May 2, 2026

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-skip marker 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)

Metric Pre-fix After this PR
chromedp wall-clock 6-8 s ~1.5 s
Browser-side wall-clock ~1.17 s
updateDOM JS time ~447 ms
Targeted-apply hits 1 (verified)

The remaining ~1.17 s browser-side is server compute (~150 ms) + updateDOM JS (~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

  • New state/range-dom-applier.ts: per-op DOM mutation module — findContainer + canApplyTargeted + apply (r/u/i/a/p/o) + cleanupMarkers. Fires lvt-mounted / lvt-destroyed lifecycle hooks on inserted/removed subtrees.
  • 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 into data-lvt-targeted-skip attributes; morphdom's onBeforeElUpdated returns false for marked subtrees; cleanup in try/finally. Tracks nodesAddedThisRender to skip wrapper-wide directive scans when zero new elements.
  • types.ts: TargetedRangeOp interface + optional UpdateResult.targetedOps field (additive — no breakage for downstream consumers).

Tests

  • 23 jsdom unit tests in 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 (count getNodeKey invocations).
  • 4 new tree-renderer tests covering targetedOps return shape + placeholder emission + in-place mutation invariant.
  • All 507 existing tests pass.

Limitation noted

If the server adds new directive attributes via attribute morph to an existing element (e.g. adds lvt-fx:keydown to 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 with data-lvt-force-update.

Observability hook

window.__lvtTargetedHits increments 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

  • All 507 client unit tests green
  • TypeScript build clean
  • TestLargeTable (200 rows, all subtests) green incl. new Delete_Targeted_Apply_Path_Taken
  • TestLargeTable_DeleteLatency_10k green at 1.5 s (ceiling 2.5 s) with targeted-apply hits: 1
  • TestTodos full suite green (focus-preservation regression check)
  • Manual Chrome verification on 10k LargeTable: delete/append/sort/update all work
  • iPhone manual UX verification — improvement confirmed; sub-second floor needs virtualization (follow-up issue filed)

🤖 Generated with Claude Code

adnaan and others added 3 commits May 2, 2026 13:30
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.
@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

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: findContainer is path-agnostic during cache miss

state/range-dom-applier.ts — when anyKnownItemKey doesn't find a match (e.g. the first op is r for a key that's already gone), the fallback does wrapper.querySelector('[data-key]') with no path constraint. If the wrapper contains two keyed ranges, this can return a container belonging to a different range and cache it under the wrong path. Subsequent ops then mutate the wrong container silently.

A safe fix: tag each range container with the range path (data-lvt-range-path) during reconstructFromTree and use that as the primary lookup. Alternatively, only use the unscoped querySelector as a last resort and don't cache the result.


Bug: applyUpdateRow without morphdomOptions skips lifecycle hooks

When morphdomOptions is absent, applyUpdateRow does row.replaceWith(newRow) directly. Unlike the i/a/p paths, this never calls this.ctx.onNodeAdded?.(newRow) or fireHookOnSubtree(newRow, 'lvt-mounted'). If the updated row gains new elements with lvt-mounted (e.g. a conditional render reveals a new child), those hooks silently don't fire and nodesAddedThisRender stays at 0, so the post-render directive scans are also skipped.

Either call fireHookOnSubtree(newRow, 'lvt-mounted') + onNodeAdded on the replace path, or require morphdomOptions for u ops (morphdom handles this naturally via its onNodeAdded callback already being wired).


applyReorder silently drops DOM children not in newKeyOrder

container.replaceChildren(fragment) removes any existing children whose keys aren't in newKeyOrder. The test explicitly verifies this ("ignores keys not present in the container"), but in the reverse direction — existing rows absent from the new order list are deleted without a lvt-destroyed hook and without a log warning. Is this intentional protocol behaviour (server always sends the full new order)? If so, the comment on the test should say so explicitly. If not, this is a data-loss bug under partial reorder ops.


CSS escape fallback is incomplete (findItemByKey, line ~750)

The fallback for environments without CSS.escape only escapes " and \, but CSS attribute selectors treat ], [, :, (, ., #, etc. as special characters too. The fallback would produce a malformed or wrong-matching selector for any key containing these characters. Given this falls back gracefully to null rather than matching wrong nodes, the blast radius is limited — but it'd be more defensive to throw or warn so callers know the lookup failed cleanly.


The window.__lvtTargetedHits observability hook is fine for a mutable global, but worth noting that it's cumulative across renders (not reset per test/per page), so E2E assertions should compare deltas rather than absolute values.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 RangeDomApplier for per-op DOM mutations and lifecycle hook firing, plus marker cleanup.
  • Extend TreeRenderer.applyUpdate to optionally emit targetedOps and placeholder comments to drive morphdom subtree skipping.
  • Update LiveTemplateClient.updateDOM to 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.

Comment thread livetemplate-client.ts
Comment on lines +1318 to 1323
// 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) => {
Comment thread livetemplate-client.ts Outdated
Comment on lines +1527 to +1541
// <!--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);
Comment thread state/range-dom-applier.ts Outdated
Comment on lines +149 to +152
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
@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026


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 apply() do not trigger fallback

In state/range-dom-applier.ts, when lookupCurrentItem returns null (or findItemByKey misses the row), applyUpdateRow logs and returns silently. apply() then returns the container as "success." The caller marks it with TARGETED_APPLIED_ATTR and morphdom skips the subtree — so the stale live-DOM row is never corrected. anyFailed is only set when apply() returns null, which only happens on a thrown exception, not a silent skip.

Fix: have applyUpdateRow return a boolean indicating success, and return null from apply() if any op fails silently:

let anyOpFailed = false;
// inside the op loop:
case "u":
  if (!this.applyUpdateRow(...)) anyOpFailed = true;
  break;
// after the loop:
return anyOpFailed ? null : container;

wireItemLookup is a silent footgun

A RangeDomApplier without a wired item lookup silently no-ops all u ops (lookupCurrentItem returns null, logs, returns). Since the lookup is required for u ops to work, consider requiring it in the constructor or asserting its presence before processing any u op.

Minor: directive scan skip limitation is user-invisible

The nodesAddedThisRender > 0 optimization skips post-render directive scans for attribute-only changes (e.g. server adding lvt-fx:keydown to an existing element). The only escape hatch is data-lvt-force-update, which is not publicly documented. Worth adding to user-facing docs before shipping, otherwise this shows up as mysterious missing event listeners.

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.
@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

Review: Targeted range DOM applier

Nice optimization — the targeted-apply path is well-structured with a solid fallback story. Three issues worth addressing before merging:


1. applyReorder drops elements without firing lvt-destroyed (bug)

In state/range-dom-applier.ts, when newKeyOrder.length < byKey.size, children not present in newKeyOrder are silently discarded via container.replaceChildren(fragment) without calling fireHookOnSubtree(el, 'lvt-destroyed'). Any user code in those handlers (timer teardown, observer cleanup) never runs.

Fix: before container.replaceChildren(fragment), iterate byKey and fire lvt-destroyed on any entry whose key is absent from newKeyOrder:

for (const [key, el] of byKey) {
  if (!newKeyOrder.includes(key)) {
    this.fireHookOnSubtree(el, 'lvt-destroyed');
  }
}
container.replaceChildren(fragment);

2. Directive-scan skip breaks attribute-only updates (silent regression)

The if (this.nodesAddedThisRender > 0) guard in livetemplate-client.ts skips setupFxDOMEventTriggers, setupDOMEventTriggerDelegation, setupScrollAway, etc. for any render that only mutates/removes existing elements. The comment acknowledges this: directives added to existing elements via attribute morph won't be wired until the next node-adding render, and the only escape hatch (data-lvt-force-update) requires server template changes to know about this client-side optimization.

A lower-risk approach: track attribute mutations separately. Set a hasAttributeChanges flag from morphdom's onElUpdated callback (fires on attribute diffs) and run the directive scans when either nodes were added OR attributes changed. That preserves the delete-only fast path while preventing the silent breakage on attribute-morph-only renders.


3. itemLookup is O(N) per u op

lookupCurrentItem does a linear scan through range.d for every u op. With 10k rows and multiple updates per render this becomes O(N×K). The cost is acknowledged as acceptable, but building a Map<key, item> once at the start of apply() would reduce this to O(K) for free. Low priority given the targeted path's overall gains, but easy to add.


Minor: (window as any).__lvtTargetedHits increments in the production code path. Consider gating it behind a debug/dev flag to avoid polluting the window object in production.

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.
@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

Review

Overall 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 success

In state/range-dom-applier.ts, the apply() method's switch default: case leaves opOK = true, so an unrecognised op type is treated as a success. apply() returns the container, TARGETED_APPLIED_ATTR is set, and morphdom skips the subtree — leaving the live DOM stale while treeState reflects the server's new state. This is a forward-compatibility problem: any future op type will silently corrupt the view.

Fix: set opOK = false in the default: case.


Bug: applyPrepend always inserts the fragment even on partial failure

When renderAndParse fails for any item, the successfully-parsed siblings are still inserted via container.insertBefore(fragment, ...) before return allRendered propagates the failure. apply() returns null and the caller does a full morphdom rebuild — so the final DOM is correct, but lvt-mounted fires twice on the successfully-prepended rows. applyInsertAfter has the same double-fire issue via its per-item cursor approach.

Fix for prepend: bail before insertBefore when !allRendered. For applyInsertAfter, collect all items into a scratch fragment first and only splice it in when all items rendered.


Correctness concern: staticsContainKeyAttribute uses naive string-includes

s.includes('data-key=') can match data-key= appearing in text content or a title attribute value, causing canApplyTargeted to return ok: true for a range that has no keyed root elements. The targeted path then fails to find a container and falls back — safe but wasteful. Consider tightening the check or documenting the known false-positive.


Minor: __lvtTargetedHits window property

The test-observability counter is guarded by an existence check, but production code accidentally setting this on window would silently activate it. A Symbol key or a dedicated test-harness injection point would be safer.

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.
@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

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.
@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

Review: Targeted DOM Apply for Keyed Ranges

Overall: Solid performance optimization. The fallback mechanism (any op failure → full rebuild from authoritative treeState) is the right design. The lvt-ignore ancestor check and container cache validation (isConnected + wrapper.contains) are good defensive additions.

Issues

1. directiveTouchedThisRender set on every targeted u op that touches lvt-* elements

The shared morphdomOptions is passed into applyUpdateRow's inner morphdom call. The onBeforeElUpdated callback scans attribute names and sets directiveTouchedThisRender = true whenever it sees a lvt- attribute on any morphed element. For high-frequency update-only renders (e.g., live price feeds updating table cells), this triggers full wrapper-wide directive scans (setupFxDOMEventTriggers, setupDOMEventTriggerDelegation, etc.) after every render even when no new bindings were added.

The primary savings (skipping the structural morphdom diff on the entire range container) are still realized. But the scan-avoidance optimization is negated for u-heavy workloads. If the inner morphdom used a trimmed options object (same structural callbacks, minus the directive tracking), directiveTouchedThisRender would only fire for the outer wrapper pass — which is the intended semantic.

2. Partial DOM mutations not rolled back before fallback rebuild

In apply, when allOpsSucceeded = false after processing the full ops array, earlier ops that succeeded (e.g., an i that already inserted a row) are NOT rolled back. The caller then does a full morphdom rebuild from treeState. This is safe because treeState was fully updated in applyDifferentialOpsToRange before apply ran, so morphdom reconciles toward the correct end state. But it's non-obvious — worth a comment at the allOpsSucceeded = false return site explaining why rollback is not needed.

3. firstKnownKey skips a/p item arrays

firstKnownKey only extracts string keys from r/u/i/o ops; for a and p it returns undefined. A batch containing only a/p ops on a cold cache would fail container lookup in apply and silently fall back. In practice this is harmless because canApplyTargeted warms the cache using existing.d[0] before apply runs. But the degradation path is not obvious — a comment or extracting a key from op[1][0] for array ops would clarify intent.

4. Minor: TargetedRangeOp.ops: any[] and morphdomOptions?: any

The ops are discriminated union tuples (["r", string], ["u", string], etc.). Even a loose [string, ...unknown[]] type would make the switch exhaustiveness checkable and catch malformed ops in future callers.

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.
@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

Code Review

Overall: Well-architected performance optimization. The opt-in design, fail-closed semantics, and fallback paths are all sound. A few issues worth addressing:

Bug: findItemByKey warning fires unconditionally

In state/range-dom-applier.ts, the CSS.escape fallback branch emits a warning for every key, even plain ones like row-123 that need no escaping:

} else {
  this.ctx.logger.warn(
    `... key "${key}" contains characters that need escaping. Lookup may miss the row.`
  );
  escaped = key.replace(/(["\\])/g, "\\$1");
}

The message says "contains characters that need escaping" but the condition for entering this branch is just "CSS.escape is unavailable" — not that the key actually has special chars. This will spam warnings in any environment where CSS.escape is missing. The warning should only fire when the key actually contains " or \.

Design note: apply() re-resolves the container canApplyTargeted already found

canApplyTargeted resolves and caches the container, then apply() calls findContainer again (cache hit, so it works). But for a/p ops the firstKnownKey inside apply() returns a new item key that isn't in the DOM yet — if the cache were cold at that point the lookup would fall through to a DOM query that returns null. The cache from canApplyTargeted covers this, but the dependency is implicit. Passing the pre-resolved container from CanApplyResult directly into apply() (or storing it on the TargetedRangeOp) would make this explicit and remove the latent footgun.

Minor: partial-op no-rollback semantics

When an early op in a batch succeeds and a later one fails, apply() returns null and the live DOM is left with partial mutations. The fallback full-rebuild from treeState will reconcile it correctly — just noting it's worth a comment in the caller's fallback path for future maintainers.

Looks good

  • The lvt-ignore ancestor walk in canApplyTargeted correctly blocks targeted mutations inside ignored subtrees.
  • Atomic insert/prepend/append via DocumentFragment correctly avoids partial DOM mutations and double-firing lvt-mounted.
  • applyReorder correctly fires lvt-destroyed on dropped children before replaceChildren.
  • The TARGETED_SKIP_ATTR / morphdom short-circuit mechanism is clever; the test asserting 0 onBeforeElUpdated calls on the skipped subtree is a solid correctness proof.

@adnaan adnaan merged commit 8f34384 into main May 2, 2026
6 checks passed
adnaan added a commit that referenced this pull request May 2, 2026
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.
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.

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

2 participants