/**
* Handles patching the DOM with new HTML content using a simple diffing algorithm.
* This approach is more efficient than innerHTML as it preserves existing DOM nodes.
*/
export class DomPatcher {
/**
* Patches the target element with the provided HTML.
* @param {Element} target - The element to patch.
* @param {string} html - The new HTML content.
*/
patch(target, html) {
const parser = new DOMParser();
const newDoc = parser.parseFromString(html, 'text/html');
const newRoot = newDoc.body;
this.#patchNode(target, newRoot, true, true);
}
/**
* Patches an existing element with a new element structure in-place.
* @param {Element} oldElement - The existing element.
* @param {Element} newElement - The new element structure.
*/
patchElement(oldElement, newElement) {
this.#patchNode(oldElement, newElement, false, true);
}
/**
* Recursively diffs and patches two nodes.
* @param {Node} oldNode - The existing DOM node.
* @param {Node} newNode - The new node structure.
* @param {boolean} [isBodyWrapper=false] - Whether the new node is a temporary body wrapper.
* @param {boolean} [isPatchRoot=false] - Whether this is the root node of the patching operation.
* @private
*/
#patchNode(oldNode, newNode, isBodyWrapper = false, isPatchRoot = false) {
if (!isPatchRoot && oldNode.nodeType === Node.ELEMENT_NODE && oldNode.nodeName === 'SLOT' && oldNode.hasAttribute('data-avenx-transcluded')) {
if (newNode.nodeType === Node.ELEMENT_NODE) {
this.#patchAttributes(oldNode, newNode);
oldNode.setAttribute('data-avenx-transcluded', 'true');
}
return;
}
if (!isPatchRoot && oldNode.nodeType === Node.ELEMENT_NODE && oldNode.hasAttribute('data-avenx-comp')) {
if (newNode.nodeType === Node.ELEMENT_NODE) {
this.#patchAttributes(oldNode, newNode);
const compInstance = oldNode.__avenx_comp_instance;
if (compInstance && typeof compInstance.__updateTranscludedContent === 'function') {
compInstance.__updateTranscludedContent(newNode.childNodes);
}
}
return;
}
// 1. Update attributes if it's an element (skip if it is the temporary body wrapper)
if (!isBodyWrapper && oldNode.nodeType === Node.ELEMENT_NODE && newNode.nodeType === Node.ELEMENT_NODE) {
this.#patchAttributes(oldNode, newNode);
}
// 2. Diff children
const oldChildren = Array.from(oldNode.childNodes);
const newChildren = Array.from(newNode.childNodes);
let oldIndex = 0;
let newIndex = 0;
while (newIndex < newChildren.length) {
const newChild = newChildren[newIndex];
let oldChild = oldChildren[oldIndex];
// Skip items managed by ListManager in the old DOM
while (oldChild && oldChild.nodeType === Node.ELEMENT_NODE && oldChild.hasAttribute('data-ax-list-item')) {
oldIndex++;
oldChild = oldChildren[oldIndex];
}
if (!oldChild) {
// Add remaining new children
oldNode.appendChild(newChild.cloneNode(true));
} else if (this.#isSameNodeType(oldChild, newChild)) {
// Nodes are same type, patch them
if (oldChild.nodeType === Node.TEXT_NODE) {
if (oldChild.textContent !== newChild.textContent) {
oldChild.textContent = newChild.textContent;
}
} else {
this.#patchNode(oldChild, newChild);
}
oldIndex++;
} else {
// Nodes are different, replace
oldNode.replaceChild(newChild.cloneNode(true), oldChild);
oldIndex++;
}
newIndex++;
}
// Remove remaining old children (that are not managed by ListManager)
while (oldIndex < oldChildren.length) {
const oldChild = oldChildren[oldIndex];
if (!(oldChild.nodeType === Node.ELEMENT_NODE && oldChild.hasAttribute('data-ax-list-item'))) {
oldNode.removeChild(oldChild);
}
oldIndex++;
}
}
/**
* Checks if two nodes are of the same type and name.
* @private
*/
#isSameNodeType(nodeA, nodeB) {
return nodeA.nodeType === nodeB.nodeType && nodeA.nodeName === nodeB.nodeName;
}
/**
* Syncs attributes from newNode to oldNode.
* @private
*/
#patchAttributes(oldNode, newNode) {
const oldAttrs = oldNode.attributes;
const newAttrs = newNode.attributes;
// Remove old attributes that are gone
for (let i = oldAttrs.length - 1; i >= 0; i--) {
const attr = oldAttrs[i];
if (!newNode.hasAttribute(attr.name)) {
oldNode.removeAttribute(attr.name);
if (attr.name === 'value' && ['INPUT', 'TEXTAREA', 'SELECT'].includes(oldNode.nodeName)) {
oldNode.value = '';
}
}
}
// Add or update attributes
for (let i = 0; i < newAttrs.length; i++) {
const attr = newAttrs[i];
if (oldNode.getAttribute(attr.name) !== attr.value) {
oldNode.setAttribute(attr.name, attr.value);
}
if (attr.name === 'value' && ['INPUT', 'TEXTAREA', 'SELECT'].includes(oldNode.nodeName)) {
if (oldNode.value !== attr.value) {
oldNode.value = attr.value;
}
}
}
}
}