diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 6677415..d1b938d 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -28,6 +28,11 @@ import { setupInvokerPolyfill } from "./dom/invoker-polyfill"; import { setupHashLink, teardownHashLink, openFromHash, safeMatchesPopoverOpen } from "./dom/hash-link"; import { setupScrollAway, teardownScrollAway } from "./dom/scroll-away"; import { TreeRenderer } from "./state/tree-renderer"; +import { + RangeDomApplier, + TARGETED_APPLIED_ATTR, + TARGETED_SKIP_ATTR, +} from "./state/range-dom-applier"; import { FormLifecycleManager } from "./state/form-lifecycle-manager"; import { ChangeAutoWirer } from "./state/change-auto-wirer"; import { WebSocketManager } from "./transport/websocket"; @@ -49,6 +54,9 @@ export { setupReactiveAttributeListeners } from "./dom/reactive-attributes"; export class LiveTemplateClient { private readonly treeRenderer: TreeRenderer; + private readonly rangeDomApplier: RangeDomApplier; + private nodesAddedThisRender: number = 0; + private directiveTouchedThisRender: boolean = false; private readonly focusManager: FocusManager; private readonly logger: Logger; private lvtId: string | null = null; @@ -146,6 +154,38 @@ export class LiveTemplateClient { }; this.treeRenderer = new TreeRenderer(this.logger.child("TreeRenderer")); + this.rangeDomApplier = new RangeDomApplier({ + logger: this.logger.child("RangeDomApplier"), + renderItem: (item, idx, statics, sm, sp) => + this.treeRenderer.renderRangeItem(item, idx, statics, sm, sp), + executeLifecycleHook: (el, hook) => this.executeLifecycleHook(el, hook), + itemLookup: (rangePath, key) => { + // O(N) linear scan over range.d. For one `u` op per render this is + // ~50µs at N=10k — acceptable. For a render with many u ops on + // the same range, this becomes O(N×K); building a Map + // once at apply() start would amortize, but the gain is small + // (whole `u` op cost is dominated by morphdom on the row anyway). + // Revisit if profiling shows this on the hot path. + const range = this.treeRenderer.getTreeState()[rangePath]; + if (!range || !Array.isArray(range.d)) return null; + const idKey = range.m?.idKey; + for (const item of range.d) { + if (!item || typeof item !== "object") continue; + if (item._k === key) return item; + if ( + idKey && + item[idKey] !== undefined && + String(item[idKey]) === key + ) { + return item; + } + } + return null; + }, + onNodeAdded: () => { + this.nodesAddedThisRender++; + }, + }); this.focusManager = new FocusManager(this.logger.child("FocusManager")); this.formLifecycleManager = new FormLifecycleManager(); @@ -499,6 +539,7 @@ export class LiveTemplateClient { // intent — tests can observe the init transition a second time. private resetSessionState(): void { this.treeRenderer.reset(); + this.rangeDomApplier.invalidate(); this.focusManager.reset(); this.observerManager.teardown(); this.changeAutoWirer.teardown(); @@ -1135,8 +1176,33 @@ export class LiveTemplateClient { * @param meta - Optional metadata about the update (action, success, errors) */ updateDOM(element: Element, update: TreeNode, meta?: ResponseMetadata): void { - // Apply update to internal state and get reconstructed HTML - const result = this.applyUpdate(update); + // Reset per-render counters before applying the update. + // - nodesAddedThisRender: incremented by morphdom.onNodeAdded and the + // applier's onNodeAdded callback for i/a/p ops. + // - directiveTouchedThisRender: set by morphdom.onBeforeElUpdated when + // it processes an element carrying a directive attribute (lvt-fx:*, + // lvt-on:*, lvt-el:*) — covers attribute-only morphs that don't add + // nodes but do change directive bindings, so the post-render scans + // still need to wire them. + // Either signal triggers the wrapper-wide directive scans below. + this.nodesAddedThisRender = 0; + this.directiveTouchedThisRender = false; + + // Apply update to internal state and get reconstructed HTML. + // Pass canApplyTargeted so eligible top-level range diff ops mutate + // treeState in place and are emitted as targetedOps for direct DOM + // mutation (skipping the full HTML rebuild + morphdom diff for that + // subtree). + const result = this.treeRenderer.applyUpdate(update, { + canApplyTargeted: (rangeStructure, rangePath) => { + const r = this.rangeDomApplier.canApplyTargeted( + element, + rangeStructure, + rangePath + ); + return r.ok; + }, + }); // Helper to recursively check if there are any statics in the tree const hasStaticsInTree = (node: any): boolean => { @@ -1267,8 +1333,10 @@ export class LiveTemplateClient { return; } - // Use morphdom to efficiently update the element - morphdom(element, tempWrapper, { + // 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) => { // Use data-key or data-lvt-key for efficient reconciliation @@ -1280,7 +1348,49 @@ export class LiveTemplateClient { ); } }, - onBeforeElUpdated: (fromEl, toEl) => { + onBeforeElUpdated: (fromEl: any, toEl: any) => { + // Targeted-apply skip: the live container's children were already + // mutated directly by RangeDomApplier, and the rebuilt tempWrapper + // has the container empty + tagged with data-lvt-targeted-skip. + // Returning false short-circuits the entire subtree update — + // morphdom skips both the diff walk AND the children-replacement. + if ( + toEl.nodeType === Node.ELEMENT_NODE && + (toEl as Element).hasAttribute(TARGETED_SKIP_ATTR) + ) { + return false; + } + + // Track newly-introduced directive attributes so the post-render + // scan can wire any new lvt-fx:/lvt-on:/lvt-el: bindings even on + // renders that wouldn't otherwise trigger a wrapper-wide scan. + // Only flag when the directive attribute is NEW on toEl (not + // already present on fromEl) — otherwise high-frequency `u` ops + // on rows that ALREADY carry a directive (e.g. Todos rows with + // `lvt-fx:animate`) would trigger a wrapper-wide scan on every + // render even though no new binding needs wiring. + if ( + toEl.nodeType === Node.ELEMENT_NODE && + fromEl.nodeType === Node.ELEMENT_NODE + ) { + const toAttrs = (toEl as Element).attributes; + const fromElement = fromEl as Element; + for (let i = 0; i < toAttrs.length; i++) { + const n = toAttrs[i].name; + if ( + n.length > 4 && + n.charCodeAt(0) === 0x6c /* l */ && + n.charCodeAt(1) === 0x76 /* v */ && + n.charCodeAt(2) === 0x74 /* t */ && + n.charCodeAt(3) === 0x2d /* - */ && + !fromElement.hasAttribute(n) + ) { + this.directiveTouchedThisRender = true; + break; + } + } + } + // lvt-ignore: morphdom skips this element and its entire subtree. // Equivalent to Phoenix LiveView's phx-update="ignore". // Checked on fromEl (live DOM) so both server templates and @@ -1422,7 +1532,7 @@ export class LiveTemplateClient { this.executeLifecycleHook(fromEl, "lvt-updated"); return true; }, - onElUpdated: (el) => { + onElUpdated: (el: any) => { // Textarea-specific: morphdom patches child text nodes but browsers // ignore textContent changes to "dirty" textareas (ones the user // has typed in), so we explicitly set .value. Inputs don't need @@ -1437,7 +1547,7 @@ export class LiveTemplateClient { el.removeAttribute("data-lvt-force-update"); } }, - onNodeAdded: (node) => { + onNodeAdded: (node: any) => { // Sync textarea value for newly inserted textarea elements if (node instanceof HTMLTextAreaElement) { node.value = node.textContent ?? ""; @@ -1448,49 +1558,103 @@ export class LiveTemplateClient { // Execute lvt-mounted lifecycle hook if (node.nodeType === Node.ELEMENT_NODE) { this.executeLifecycleHook(node as Element, "lvt-mounted"); + this.nodesAddedThisRender++; } }, - onBeforeNodeDiscarded: (node) => { + onBeforeNodeDiscarded: (node: any) => { // Execute lvt-destroyed lifecycle hook if (node.nodeType === Node.ELEMENT_NODE) { this.executeLifecycleHook(node as Element, "lvt-destroyed"); } return true; }, - }); - - // Restore focus to previously focused element - this.focusManager.restoreFocusedElement(); - - // Handle scroll directives (implicit trigger only) - handleScrollDirectives(element); - - // Handle highlight directives (implicit trigger only) - handleHighlightDirectives(element); - - // Handle animate directives (implicit trigger only) - handleAnimateDirectives(element); + }; - // Set up DOM event triggers for lvt-fx: attributes with :on:{event} - // Registry always lives on wrapperElement so teardown can find all entries - setupFxDOMEventTriggers(element, this.wrapperElement || undefined); + // Apply per-op targeted DOM mutations BEFORE morphdom. The applier + // mutates the live DOM in place; tempWrapper has corresponding + // placeholders that we now convert to + // data-lvt-targeted-skip markers on their parent elements so morphdom + // short-circuits those subtrees. + // + // Robustness: if any targeted op fails (apply returns null — e.g. + // container couldn't be located, or an op threw), the treeState was + // updated but the live DOM wasn't, so leaving the placeholder in + // place would either (a) tell morphdom to skip → live DOM stays + // stale, or (b) leave an empty container in tempWrapper → morphdom + // would empty the live container. Both are wrong. We re-render the + // full HTML from treeState (which is authoritative) and let morphdom + // sync from there. + if (result.targetedOps && result.targetedOps.length > 0) { + const successContainers: Element[] = []; + let anyFailed = false; + for (const op of result.targetedOps) { + const container = this.rangeDomApplier.apply( + element, + op, + morphdomOptions + ); + if (container) { + container.setAttribute(TARGETED_APPLIED_ATTR, ""); + successContainers.push(container); + } else { + anyFailed = true; + } + } - // Re-scan updated subtree for lvt-el:*:on:{event} DOM triggers - this.eventDelegator.setupDOMEventTriggerDelegation(element); + if (anyFailed) { + this.logger.warn( + "[updateDOM] one or more targeted DOM ops failed; rebuilding tempWrapper from treeState for a full morphdom sync" + ); + // Strip success markers — we're going to do a full diff now. + for (const c of successContainers) { + c.removeAttribute(TARGETED_APPLIED_ATTR); + } + // Re-render full HTML (no skip placeholders) and reset tempWrapper. + const fullHtml = this.treeRenderer.renderState(); + tempWrapper.innerHTML = fullHtml; + } else { + this.replaceTargetedSkipPlaceholders(tempWrapper); + } + } - // Set up scroll-away visibility toggles - setupScrollAway(element); + try { + // Use morphdom to efficiently update the element + morphdom(element, tempWrapper, morphdomOptions); + } finally { + // Strip lifecycle markers regardless of whether morphdom threw, + // preventing leaked attributes on the live DOM. + this.rangeDomApplier.cleanupMarkers(element); + } - // Handle toast trigger directives (ephemeral client-side toasts) - handleToastDirectives(element); + // Restore focus to previously focused element + this.focusManager.restoreFocusedElement(); - // Initialize upload file inputs - this.uploadHandler.initializeFileInputs(element); + // Wrapper-wide directive scans walk every descendant of `element`. + // For a delete-only render against a large keyed range (10k+ rows), + // that's ~80k descendants × 9 scans = ~360ms wasted on a tree where + // no new elements need wiring. Skip them when neither: + // - any new node was added (morphdom.onNodeAdded / applier i/a/p), nor + // - any morphed element carried an lvt-* directive attribute that + // might newly need wiring (morphdom.onBeforeElUpdated check above). + // + // The directive-touched signal handles the attribute-morph case: + // server adds `lvt-fx:keydown` to an existing button → onBeforeElUpdated + // sees the attribute on toEl → flag set → scans run → listener wired. + if (this.nodesAddedThisRender > 0 || this.directiveTouchedThisRender) { + handleScrollDirectives(element); + handleHighlightDirectives(element); + handleAnimateDirectives(element); + setupFxDOMEventTriggers(element, this.wrapperElement || undefined); + this.eventDelegator.setupDOMEventTriggerDelegation(element); + setupScrollAway(element); + handleToastDirectives(element); + this.uploadHandler.initializeFileInputs(element); + } - // Auto-wire change listeners for bound form fields + // changeAutoWirer always runs: its eviction loop must process + // wirings on removed elements too, regardless of additions. this.changeAutoWirer.wireElements(); - // Handle form lifecycle if metadata is present if (meta) { this.formLifecycleManager.handleResponse(meta); } @@ -1503,6 +1667,38 @@ export class LiveTemplateClient { this.uploadHandler.handleUploadStartResponse(response); } + /** + * Walk tempWrapper for `` comments left by + * `reconstructFromTree` and convert each into a `data-lvt-targeted-skip` + * attribute on its parent element. The marker tells morphdom (via its + * onBeforeElUpdated callback) to short-circuit the subtree, leaving the + * live container's existing children — already updated by the applier — + * untouched. + */ + private replaceTargetedSkipPlaceholders(tempWrapper: Element): void { + const walker = document.createTreeWalker( + tempWrapper, + NodeFilter.SHOW_COMMENT + ); + const toReplace: Comment[] = []; + let node: Node | null; + while ((node = walker.nextNode())) { + const c = node as Comment; + if (c.nodeValue && /^lvt-targeted-skip:.+$/.test(c.nodeValue)) { + toReplace.push(c); + } + } + for (const c of toReplace) { + const match = c.nodeValue!.match(/^lvt-targeted-skip:(.+)$/); + const path = match ? match[1] : ""; + const parent = c.parentElement; + if (parent) { + parent.setAttribute(TARGETED_SKIP_ATTR, path); + } + c.remove(); + } + } + /** * Execute lifecycle hook on an element * @param element - Element with lifecycle hook attribute diff --git a/state/range-dom-applier.ts b/state/range-dom-applier.ts new file mode 100644 index 0000000..8427f6e --- /dev/null +++ b/state/range-dom-applier.ts @@ -0,0 +1,713 @@ +import morphdom from "morphdom"; +import type { Logger } from "../utils/logger"; +import type { TargetedRangeOp } from "../types"; + +const KEY_ATTRIBUTES = ["data-key", "data-lvt-key"] as const; +// Pre-compile attribute-presence regexes once at module load — these were +// previously rebuilt on every staticsContainKeyAttribute call (per static +// segment, per attribute) which showed up in profiling on initial render. +const KEY_ATTR_REGEXES: RegExp[] = KEY_ATTRIBUTES.map( + (attr) => new RegExp(`(?:^|[\\s<])${attr}\\s*=`) +); +export const TARGETED_APPLIED_ATTR = "data-lvt-targeted-applied"; +export const TARGETED_SKIP_ATTR = "data-lvt-targeted-skip"; + +type RenderItemFn = ( + item: any, + itemIdx: number, + statics: string[], + staticsMap?: Record, + statePath?: string +) => string; + +type LifecycleHookFn = (el: Element, hookName: string) => void; +type NodeAddedFn = (el: Element) => void; +type ItemLookupFn = (rangePath: string, key: string) => any; + +export interface RangeDomApplierContext { + logger: Logger; + renderItem: RenderItemFn; + executeLifecycleHook: LifecycleHookFn; + /** + * Look up the current item state for the given range path + key. Used by + * the `u` op to render the FULL post-merge item (treeState is mutated in + * place by `applyDifferentialOpsToRange` before the applier runs). + * + * Required: an applier without an `itemLookup` would silently no-op every + * `u` op, leaving the live DOM stale while morphdom's skip marker + * prevents the fallback diff from running. The constructor enforces this. + */ + itemLookup: ItemLookupFn; + /** + * Notification that the applier inserted a new element into the live DOM + * (i/a/p ops). Lets the caller track per-render DOM additions so it can + * decide whether the post-render directive scans need to walk the wrapper. + */ + onNodeAdded?: NodeAddedFn; +} + +export interface CanApplyResult { + ok: boolean; + reason?: string; + container?: Element; + containerKey?: string; +} + +/** + * Applies range diff ops directly to the live DOM, bypassing full HTML + * reconstruction + morphdom diff. Designed to handle the common case where + * a 10k-row range receives a single-row mutation; the targeted path turns + * what would be a 5+ second morphdom walk into a sub-millisecond DOM op. + * + * The applier is opt-in per range: `canApplyTargeted` checks that the + * range has data-key emission, no nested-range items, and a resolvable + * container element. When any check fails, the caller falls back to the + * existing applyUpdate → reconstructFromTree → morphdom path. + */ +export class RangeDomApplier { + private containerCache = new Map(); + + constructor(private readonly ctx: RangeDomApplierContext) {} + + invalidate(): void { + this.containerCache.clear(); + } + + invalidatePath(rangePath: string): void { + this.containerCache.delete(rangePath); + } + + /** + * Locate the live container element for a range path. The container is + * the parent element of items rendered with data-key. Cached per path; + * cache invalidated automatically when a cached element becomes detached. + * + * Resolution order: + * 1. Cached container (if still connected to the wrapper). + * 2. `wrapper.querySelector('[data-key="anyKnownItemKey"]').parentElement`. + * + * The original implementation also fell back to an unscoped + * `wrapper.querySelector('[data-key]')` walk, but that could return a + * container belonging to a *different* keyed range when the wrapper has + * more than one — silently mutating the wrong DOM subtree on subsequent + * ops. We now prefer to fail closed (return null → caller falls back to + * full rebuild) over mutating an unrelated container. + */ + findContainer( + wrapper: Element, + rangePath: string, + anyKnownItemKey?: string + ): Element | null { + const cached = this.containerCache.get(rangePath); + if (cached && cached.isConnected && wrapper.contains(cached)) { + return cached; + } + if (cached) { + this.containerCache.delete(rangePath); + } + + if (anyKnownItemKey === undefined) { + return null; + } + const sample = this.findItemByKey(wrapper, anyKnownItemKey); + if (!sample || !sample.parentElement) { + return null; + } + + const container = sample.parentElement; + this.containerCache.set(rangePath, container); + return container; + } + + /** + * Decide whether a range update can take the targeted-apply path. + * Returns the resolved container in the success case so the caller + * can pass it to `apply` without re-resolving. + */ + canApplyTargeted( + wrapper: Element, + rangeStructure: any, + rangePath: string + ): CanApplyResult { + if (!rangeStructure || typeof rangeStructure !== "object") { + return { ok: false, reason: "no range structure" }; + } + if (!Array.isArray(rangeStructure.s) || rangeStructure.s.length === 0) { + return { ok: false, reason: "no statics" }; + } + + const allStatics: string[][] = [rangeStructure.s]; + if (rangeStructure.sm && typeof rangeStructure.sm === "object") { + for (const sm of Object.values(rangeStructure.sm)) { + if (Array.isArray(sm)) { + allStatics.push(sm as string[]); + } + } + } + + const hasKeyInStatics = allStatics.some((arr) => + this.staticsContainKeyAttribute(arr) + ); + if (!hasKeyInStatics) { + return { ok: false, reason: "no data-key attribute in statics" }; + } + + const items = rangeStructure.d; + if (Array.isArray(items)) { + for (const item of items) { + if (this.itemHasNestedRange(item)) { + return { ok: false, reason: "nested-range item" }; + } + } + } + + const sampleKey = this.extractItemKey(items?.[0], rangeStructure); + const container = this.findContainer(wrapper, rangePath, sampleKey); + if (!container) { + return { ok: false, reason: "container not found in DOM" }; + } + + // Walk up from container through wrapper (inclusive) — if any element + // on the path is lvt-ignore'd, the targeted-apply path would mutate + // DOM inside an ignored subtree while morphdom would have skipped it, + // violating the lvt-ignore contract. + let cur: Element | null = container; + while (cur) { + if (cur.hasAttribute("lvt-ignore")) { + return { ok: false, reason: "lvt-ignore ancestor" }; + } + if (cur === wrapper) break; + cur = cur.parentElement; + } + + return { ok: true, container, containerKey: sampleKey }; + } + + /** + * Apply a single targeted op to the live DOM. Returns the affected + * container element so the caller can mark it for the morphdom skip + * mechanism. Returns null if the op could not be applied (caller + * should fall back to full-rebuild for the next render). + */ + apply( + wrapper: Element, + targetedOp: TargetedRangeOp, + morphdomOptions?: any + ): Element | null { + const { rangePath, ops, statics, staticsMap } = targetedOp; + const sampleKey = this.firstKnownKey(ops); + const container = this.findContainer(wrapper, rangePath, sampleKey); + if (!container) { + this.ctx.logger.debug( + `[RangeDomApplier] container not found for range ${rangePath}; cannot apply` + ); + return null; + } + + let allOpsSucceeded = true; + for (const op of ops) { + if (!Array.isArray(op) || op.length < 1) continue; + const opType = op[0]; + try { + let opOK = true; + switch (opType) { + case "r": + opOK = this.applyRemove(container, op[1] as string); + break; + case "u": + opOK = this.applyUpdateRow( + container, + op[1] as string, + statics, + staticsMap, + rangePath, + morphdomOptions + ); + break; + case "i": + opOK = this.applyInsertAfter( + container, + op[1] as string, + op[2], + statics, + staticsMap, + rangePath + ); + break; + case "a": + opOK = this.applyAppend( + container, + op[1], + statics, + staticsMap, + rangePath + ); + break; + case "p": + opOK = this.applyPrepend( + container, + op[1], + statics, + staticsMap, + rangePath + ); + break; + case "o": + opOK = this.applyReorder(container, op[1] as string[]); + break; + default: + // Forward-compat: an unrecognised op type means we can't + // reason about the DOM mutation. Treat as failure so the + // caller falls back to a full morphdom rebuild from + // treeState (which the server-emitted unknown op type + // presumably already mutated correctly). + this.ctx.logger.warn( + `[RangeDomApplier] unknown op type ${opType}; falling back` + ); + opOK = false; + } + if (!opOK) { + allOpsSucceeded = false; + } + } catch (err) { + this.ctx.logger.error( + `[RangeDomApplier] op ${opType} failed for range ${rangePath}`, + err + ); + return null; + } + } + + // If any per-op method silently no-op'd because of stale state + // (e.g. `u` for a row that's no longer in the DOM, `i` with a + // missing anchor), we MUST signal failure so the caller falls back + // to a full rebuild — otherwise the live DOM stays out of sync with + // treeState and morphdom would skip the subtree (TARGETED_APPLIED + // marker tells it to). + // + // Note: earlier ops in this batch that succeeded are NOT rolled back. + // No rollback is needed because `treeState` was already mutated to + // its complete post-op state by `applyDifferentialOpsToRange` BEFORE + // this method ran. The caller's fallback path re-renders from + // `treeState.renderState()` and runs morphdom over the full HTML — + // morphdom reconciles whatever partial DOM mutations we made toward + // the authoritative end state. + if (!allOpsSucceeded) { + return null; + } + + // Observability hook: increment a global counter so E2E tests can + // assert the targeted-apply path was actually taken (vs silently + // hitting the fallback). Opt-in: tests must initialize the property + // first (e.g. `window.__lvtTargetedHits = 0`); production never sets + // it so the increment is skipped and we don't pollute the window + // object outside of test environments. + if ( + typeof window !== "undefined" && + "__lvtTargetedHits" in (window as any) + ) { + (window as any).__lvtTargetedHits++; + } + return container; + } + + cleanupMarkers(wrapper: Element): void { + const applied = wrapper.querySelectorAll(`[${TARGETED_APPLIED_ATTR}]`); + applied.forEach((el) => el.removeAttribute(TARGETED_APPLIED_ATTR)); + if (wrapper.hasAttribute(TARGETED_APPLIED_ATTR)) { + wrapper.removeAttribute(TARGETED_APPLIED_ATTR); + } + const skip = wrapper.querySelectorAll(`[${TARGETED_SKIP_ATTR}]`); + skip.forEach((el) => el.removeAttribute(TARGETED_SKIP_ATTR)); + if (wrapper.hasAttribute(TARGETED_SKIP_ATTR)) { + wrapper.removeAttribute(TARGETED_SKIP_ATTR); + } + } + + // --- per-op implementations ----------------------------------------------- + // + // Each per-op method returns `boolean`: + // true → the live DOM is now consistent with the new treeState + // false → silent no-op (e.g. row not found, item state unavailable); + // the caller should invalidate the targeted-apply marker and + // fall back to a full rebuild + + private applyRemove(container: Element, key: string): boolean { + const row = this.findItemByKey(container, key); + if (!row) { + // r is idempotent: if the row is already gone, treeState's post-op + // view (also without the row) matches the DOM. No fallback needed. + this.ctx.logger.debug( + `[RangeDomApplier] r: row with key ${key} not found (idempotent no-op)` + ); + return true; + } + this.fireHookOnSubtree(row, "lvt-destroyed"); + row.remove(); + return true; + } + + private applyUpdateRow( + container: Element, + key: string, + statics: string[], + staticsMap: Record | undefined, + rangePath: string, + morphdomOptions?: any + ): boolean { + const row = this.findItemByKey(container, key); + if (!row) { + this.ctx.logger.debug( + `[RangeDomApplier] u: row with key ${key} not found in DOM; falling back` + ); + return false; + } + const itemIdx = this.indexOfChild(container, row); + const item = this.lookupCurrentItem(rangePath, key); + if (!item) { + this.ctx.logger.debug( + `[RangeDomApplier] u: item state for key ${key} not available; falling back` + ); + return false; + } + const newHtml = this.ctx.renderItem( + item, + itemIdx, + statics, + staticsMap, + rangePath + ); + const newRow = this.parseSingleRow(newHtml); + if (!newRow) { + this.ctx.logger.warn( + `[RangeDomApplier] u: failed to parse rendered row HTML; falling back` + ); + return false; + } + if (morphdomOptions) { + // Override childrenOnly: the main morphdom call uses childrenOnly:true + // because it's diffing the wrapper's children. For a single-row morph + // we MUST diff the row element itself too (its attributes — class, + // style, aria, etc. — are produced by statics+dynamics and may have + // changed). Reuse the same callbacks for behavioral consistency. + morphdom(row, newRow, { ...morphdomOptions, childrenOnly: false }); + } else { + // No morphdom options provided — fall back to wholesale replacement. + // morphdom's onNodeAdded / onBeforeNodeDiscarded callbacks would + // normally fire lvt-mounted/lvt-destroyed hooks for us; here we have + // to fire them manually on both sides AND notify the host so its + // nodesAddedThisRender counter sees the new subtree (otherwise the + // post-render directive scans would skip wiring listeners on it). + this.fireHookOnSubtree(row, "lvt-destroyed"); + row.replaceWith(newRow); + this.ctx.onNodeAdded?.(newRow); + this.fireHookOnSubtree(newRow, "lvt-mounted"); + } + return true; + } + + private applyInsertAfter( + container: Element, + afterKey: string, + items: any | any[], + statics: string[], + staticsMap: Record | undefined, + rangePath: string + ): boolean { + const anchor = this.findItemByKey(container, afterKey); + if (!anchor) { + this.ctx.logger.debug( + `[RangeDomApplier] i: anchor key ${afterKey} not found; falling back` + ); + return false; + } + return this.renderItemsAtomic( + items, + statics, + staticsMap, + rangePath, + this.indexOfChild(container, anchor) + 1, + (frag) => container.insertBefore(frag, anchor.nextSibling) + ); + } + + private applyAppend( + container: Element, + items: any | any[], + statics: string[], + staticsMap: Record | undefined, + rangePath: string + ): boolean { + return this.renderItemsAtomic( + items, + statics, + staticsMap, + rangePath, + container.children.length, + (frag) => container.appendChild(frag) + ); + } + + private applyPrepend( + container: Element, + items: any | any[], + statics: string[], + staticsMap: Record | undefined, + rangePath: string + ): boolean { + return this.renderItemsAtomic( + items, + statics, + staticsMap, + rangePath, + 0, + (frag) => container.insertBefore(frag, container.firstChild) + ); + } + + /** + * Render N items into a scratch DocumentFragment, splicing them into the + * live DOM only if ALL renders succeeded. On partial failure no DOM + * mutation happens and the caller falls back to a full rebuild — this + * avoids `lvt-mounted` firing on items that morphdom is then about to + * re-add (which would double-fire the hook). + */ + private renderItemsAtomic( + items: any | any[], + statics: string[], + staticsMap: Record | undefined, + rangePath: string, + baseIdx: number, + splice: (frag: DocumentFragment) => void + ): boolean { + const list = Array.isArray(items) ? items : [items]; + const scratch = document.createDocumentFragment(); + const newRows: Element[] = []; + for (let i = 0; i < list.length; i++) { + const newRow = this.renderAndParse( + list[i], + baseIdx + i, + statics, + staticsMap, + rangePath + ); + if (!newRow) { + return false; + } + scratch.appendChild(newRow); + newRows.push(newRow); + } + splice(scratch); + for (const row of newRows) { + this.ctx.onNodeAdded?.(row); + this.fireHookOnSubtree(row, "lvt-mounted"); + } + return true; + } + + /** + * Reorder existing children to match `newKeyOrder`. Protocol assumption: + * the server emits the *full* new key order (mirrors the assumption in + * `applyDifferentialOpsToRange`'s "o" case in tree-renderer). When the + * new order is shorter than the current child set, we treat the missing + * keys as removals and fire `lvt-destroyed` on each dropped subtree + * (so user teardown — timer cancellation, observer disconnect, etc. — + * still runs) plus log a warning surfacing the protocol mismatch. + */ + private applyReorder(container: Element, newKeyOrder: string[]): boolean { + if (!Array.isArray(newKeyOrder)) return false; + const byKey = new Map(); + Array.from(container.children).forEach((child) => { + for (const attr of KEY_ATTRIBUTES) { + const k = child.getAttribute(attr); + if (k !== null) { + byKey.set(k, child); + break; + } + } + }); + + const fragment = document.createDocumentFragment(); + const newKeySet = new Set(newKeyOrder); + for (const key of newKeyOrder) { + const el = byKey.get(key); + if (el) { + fragment.appendChild(el); + } + } + + // Fire lvt-destroyed on children that aren't in the new order. The + // protocol normally sends the FULL key order, but if a partial reorder + // ever lands here, user-defined teardown (timer cancellation, observer + // disconnect, etc.) must still run. + if (newKeySet.size < byKey.size) { + this.ctx.logger.warn( + `[RangeDomApplier] o: newKeyOrder (${newKeySet.size}) shorter than existing children (${byKey.size}); ${byKey.size - newKeySet.size} children will be dropped` + ); + for (const [k, el] of byKey) { + if (!newKeySet.has(k)) { + this.fireHookOnSubtree(el, "lvt-destroyed"); + } + } + } + + container.replaceChildren(fragment); + return true; + } + + // --- helpers -------------------------------------------------------------- + + private renderAndParse( + item: any, + itemIdx: number, + statics: string[], + staticsMap: Record | undefined, + rangePath: string + ): Element | null { + const html = this.ctx.renderItem( + item, + itemIdx, + statics, + staticsMap, + rangePath + ); + return this.parseSingleRow(html); + } + + /** + * Parse a string of HTML containing a single root element and return it. + * Uses