/** * Lightweight DOM Patcher for LiveComponent Fragments * * Efficiently patches specific DOM fragments without full re-render. * Optimized for LiveComponent use case with minimal overhead. * * Features: * - Smart element matching by tag and data-lc-fragment attribute * - Attribute diffing and patching * - Text content updates * - Child node reconciliation * - Preserves focus and scroll position where possible * * Philosophy: * - Keep it simple and focused * - No external dependencies * - Framework-compliant modern JavaScript */ export class DomPatcher { /** * Patch a specific fragment within a container * * @param {HTMLElement} container - Container element to search in * @param {string} fragmentName - Fragment name (data-lc-fragment value) * @param {string} newHtml - New HTML for the fragment * @returns {boolean} - True if fragment was patched, false if not found */ patchFragment(container, fragmentName, newHtml) { // Find existing fragment element const existingElement = container.querySelector(`[data-lc-fragment="${fragmentName}"]`); if (!existingElement) { console.warn(`[DomPatcher] Fragment not found: ${fragmentName}`); return false; } // Parse new HTML into element const temp = document.createElement('div'); temp.innerHTML = newHtml; const newElement = temp.firstElementChild; if (!newElement) { console.warn(`[DomPatcher] Invalid HTML for fragment: ${fragmentName}`); return false; } // Verify fragment name matches if (newElement.getAttribute('data-lc-fragment') !== fragmentName) { console.warn(`[DomPatcher] Fragment name mismatch: expected ${fragmentName}, got ${newElement.getAttribute('data-lc-fragment')}`); return false; } // Patch the element in place this.patchElement(existingElement, newElement); return true; } /** * Patch multiple fragments at once * * @param {HTMLElement} container - Container element * @param {Object.} fragments - Map of fragment names to HTML * @returns {Object.} - Map of fragment names to success status */ patchFragments(container, fragments) { const results = {}; for (const [fragmentName, html] of Object.entries(fragments)) { results[fragmentName] = this.patchFragment(container, fragmentName, html); } return results; } /** * Patch an element with new content * * Core patching logic that efficiently updates only what changed. * * @param {HTMLElement} oldElement - Existing element to patch * @param {HTMLElement} newElement - New element with updated content */ patchElement(oldElement, newElement) { // 1. Patch attributes this.patchAttributes(oldElement, newElement); // 2. Patch child nodes this.patchChildren(oldElement, newElement); } /** * Patch element attributes * * Only updates attributes that actually changed. * * @param {HTMLElement} oldElement - Existing element * @param {HTMLElement} newElement - New element */ patchAttributes(oldElement, newElement) { // Get all attributes from both elements const oldAttrs = new Map(); const newAttrs = new Map(); for (const attr of oldElement.attributes) { oldAttrs.set(attr.name, attr.value); } for (const attr of newElement.attributes) { newAttrs.set(attr.name, attr.value); } // Remove attributes that no longer exist for (const [name, value] of oldAttrs) { if (!newAttrs.has(name)) { oldElement.removeAttribute(name); } } // Add or update attributes for (const [name, value] of newAttrs) { if (oldAttrs.get(name) !== value) { oldElement.setAttribute(name, value); } } } /** * Patch child nodes * * Reconciles child nodes between old and new elements. * Uses simple key-based matching for efficiency. * * @param {HTMLElement} oldElement - Existing element * @param {HTMLElement} newElement - New element */ patchChildren(oldElement, newElement) { const oldChildren = Array.from(oldElement.childNodes); const newChildren = Array.from(newElement.childNodes); const maxLength = Math.max(oldChildren.length, newChildren.length); for (let i = 0; i < maxLength; i++) { const oldChild = oldChildren[i]; const newChild = newChildren[i]; if (!oldChild && newChild) { // New child added - append it oldElement.appendChild(newChild.cloneNode(true)); } else if (oldChild && !newChild) { // Child removed - remove it oldElement.removeChild(oldChild); } else if (oldChild && newChild) { // Both exist - patch or replace if (this.shouldPatch(oldChild, newChild)) { if (oldChild.nodeType === Node.ELEMENT_NODE) { this.patchElement(oldChild, newChild); } else if (oldChild.nodeType === Node.TEXT_NODE) { if (oldChild.nodeValue !== newChild.nodeValue) { oldChild.nodeValue = newChild.nodeValue; } } } else { // Different node types or tags - replace oldElement.replaceChild(newChild.cloneNode(true), oldChild); } } } } /** * Determine if two nodes should be patched or replaced * * Nodes should be patched if they are compatible (same type and tag). * * @param {Node} oldNode - Existing node * @param {Node} newNode - New node * @returns {boolean} - True if nodes should be patched */ shouldPatch(oldNode, newNode) { // Different node types - replace if (oldNode.nodeType !== newNode.nodeType) { return false; } // Text nodes can always be patched if (oldNode.nodeType === Node.TEXT_NODE) { return true; } // Element nodes - check if same tag if (oldNode.nodeType === Node.ELEMENT_NODE) { if (oldNode.tagName !== newNode.tagName) { return false; } // Check for special keys that indicate identity const oldKey = oldNode.getAttribute('data-lc-key') || oldNode.getAttribute('id'); const newKey = newNode.getAttribute('data-lc-key') || newNode.getAttribute('id'); // If both have keys, they must match if (oldKey && newKey) { return oldKey === newKey; } // Otherwise, assume they match (same tag is enough) return true; } // Other node types - replace return false; } /** * Preserve focus state before patching * * Returns a function to restore focus after patching. * * @param {HTMLElement} container - Container being patched * @returns {Function} - Restore function */ preserveFocus(container) { const activeElement = document.activeElement; // Check if focused element is within container if (!activeElement || !container.contains(activeElement)) { return () => {}; // No-op restore } // Get selector for focused element const selector = this.getElementSelector(activeElement); const selectionStart = activeElement.selectionStart; const selectionEnd = activeElement.selectionEnd; // Return restore function return () => { try { if (selector) { const element = container.querySelector(selector); if (element && element.focus) { element.focus(); // Restore selection for input/textarea if (element.setSelectionRange && typeof selectionStart === 'number' && typeof selectionEnd === 'number') { element.setSelectionRange(selectionStart, selectionEnd); } } } } catch (e) { // Focus restoration failed - not critical console.debug('[DomPatcher] Could not restore focus:', e); } }; } /** * Get a selector for an element * * Tries to create a unique selector using ID, name, or data attributes. * * @param {HTMLElement} element - Element to get selector for * @returns {string|null} - CSS selector or null */ getElementSelector(element) { if (element.id) { return `#${element.id}`; } if (element.name) { return `[name="${element.name}"]`; } const lcKey = element.getAttribute('data-lc-key'); if (lcKey) { return `[data-lc-key="${lcKey}"]`; } // Fallback - not guaranteed to be unique return element.tagName.toLowerCase(); } /** * Swap element content using different strategies * * Supports multiple swap strategies similar to htmx: * - innerHTML: Replace inner content (default) * - outerHTML: Replace element itself * - beforebegin: Insert before element * - afterbegin: Insert at start of element * - afterend: Insert after element * - beforeend: Insert at end of element * - none: No DOM update (only events/state) * * @param {HTMLElement} target - Target element to swap * @param {string} html - HTML content to swap * @param {string} strategy - Swap strategy (default: 'innerHTML') * @param {string} transition - Optional transition type (fade, slide, none) * @param {Object} scrollOptions - Optional scroll options { enabled, target, behavior } * @returns {boolean} - True if swap was successful */ swapElement(target, html, strategy = 'innerHTML', transition = null, scrollOptions = null) { if (!target || !target.parentNode) { console.warn('[DomPatcher] Invalid target element for swap'); return false; } // Preserve focus state before swap const restoreFocus = this.preserveFocus(target); // Apply transition if specified if (transition && transition !== 'none') { this.applyTransition(target, transition, strategy); } // Parse HTML into document fragment const temp = document.createElement('div'); temp.innerHTML = html; const newContent = temp.firstElementChild || temp; try { switch (strategy) { case 'innerHTML': // Replace inner content (default behavior) target.innerHTML = html; break; case 'outerHTML': // Replace element itself if (newContent.nodeType === Node.ELEMENT_NODE) { target.parentNode.replaceChild(newContent.cloneNode(true), target); } else { // If HTML doesn't have a single root element, wrap it const wrapper = document.createElement('div'); wrapper.innerHTML = html; while (wrapper.firstChild) { target.parentNode.insertBefore(wrapper.firstChild, target); } target.parentNode.removeChild(target); } break; case 'beforebegin': // Insert before target element if (newContent.nodeType === Node.ELEMENT_NODE) { target.parentNode.insertBefore(newContent.cloneNode(true), target); } else { // Multiple nodes - insert all const wrapper = document.createElement('div'); wrapper.innerHTML = html; while (wrapper.firstChild) { target.parentNode.insertBefore(wrapper.firstChild, target); } } break; case 'afterbegin': // Insert at start of target element if (newContent.nodeType === Node.ELEMENT_NODE) { target.insertBefore(newContent.cloneNode(true), target.firstChild); } else { // Multiple nodes - insert all at start const wrapper = document.createElement('div'); wrapper.innerHTML = html; while (wrapper.firstChild) { target.insertBefore(wrapper.firstChild, target.firstChild); } } break; case 'afterend': // Insert after target element if (newContent.nodeType === Node.ELEMENT_NODE) { target.parentNode.insertBefore(newContent.cloneNode(true), target.nextSibling); } else { // Multiple nodes - insert all after const wrapper = document.createElement('div'); wrapper.innerHTML = html; const nextSibling = target.nextSibling; while (wrapper.firstChild) { target.parentNode.insertBefore(wrapper.firstChild, nextSibling); } } break; case 'beforeend': // Insert at end of target element if (newContent.nodeType === Node.ELEMENT_NODE) { target.appendChild(newContent.cloneNode(true)); } else { // Multiple nodes - append all const wrapper = document.createElement('div'); wrapper.innerHTML = html; while (wrapper.firstChild) { target.appendChild(wrapper.firstChild); } } break; case 'none': // No DOM update - only events/state will be handled // This is handled by the caller, so we just return success return true; default: console.warn(`[DomPatcher] Unknown swap strategy: ${strategy}, falling back to innerHTML`); target.innerHTML = html; break; } // Restore focus after swap (only for strategies that don't remove the target) if (strategy !== 'outerHTML' && strategy !== 'none') { restoreFocus(); } // Handle scroll behavior if specified if (scrollOptions && scrollOptions.enabled) { this.scrollToTarget(scrollOptions.target || target, scrollOptions.behavior || 'smooth'); } return true; } catch (error) { console.error('[DomPatcher] Error during swap:', error); return false; } } /** * Apply CSS transition to element * * @param {HTMLElement} element - Element to apply transition to * @param {string} transition - Transition type (fade, slide) * @param {string} strategy - Swap strategy */ applyTransition(element, transition, strategy) { // Add transition class const transitionClass = `lc-transition-${transition}`; // For fade/slide transitions, we need to apply the transition class // and then trigger the transition by adding an active class element.classList.add(transitionClass); // Force reflow to ensure class is applied void element.offsetWidth; // Add active class to trigger transition requestAnimationFrame(() => { element.classList.add('lc-transition-active'); }); // Remove transition classes after animation completes const handleTransitionEnd = (e) => { // Only handle transition end for this element if (e.target === element) { element.classList.remove(transitionClass); element.classList.remove('lc-transition-active'); element.removeEventListener('transitionend', handleTransitionEnd); } }; element.addEventListener('transitionend', handleTransitionEnd, { once: true }); } /** * Scroll to target element * * @param {HTMLElement|string} target - Target element or selector * @param {string} behavior - Scroll behavior (smooth, instant) */ scrollToTarget(target, behavior = 'smooth') { let targetElement = target; // If target is a string, try to find element if (typeof target === 'string') { targetElement = document.querySelector(target); } if (!targetElement || !(targetElement instanceof HTMLElement)) { console.warn('[DomPatcher] Scroll target not found:', target); return; } // Scroll to element targetElement.scrollIntoView({ behavior: behavior === 'smooth' ? 'smooth' : 'auto', block: 'start', inline: 'nearest' }); } } // Create singleton instance export const domPatcher = new DomPatcher(); export default domPatcher;