/** * 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(); } } // Create singleton instance export const domPatcher = new DomPatcher(); export default domPatcher;