All files / lib/core/renderer domPatch.js

89.26% Statements 133/149
87.8% Branches 36/41
100% Functions 5/5
89.26% Lines 133/149

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 15011x 11x 11x 11x 11x 11x 11x 11x 11x 11x 11x 41x 41x 41x 41x 41x 41x 11x 11x 11x 11x 11x 11x 11x 6x 6x 11x 11x 11x 11x 11x 11x 11x 11x 11x 11x 79x             79x 79x 5x 5x 5x 5x 5x 5x 5x 5x 5x 74x 74x 79x 33x 33x 74x 74x 74x 74x 74x 74x 74x 74x 79x 64x 64x 64x 64x 64x       64x 64x 14x 14x 64x 49x 49x 17x 6x 6x 49x 32x 32x 49x 50x 1x 1x 1x 1x 64x 64x 74x 74x 79x 9x 9x 3x 3x 9x 9x 79x 11x 11x 11x 11x 11x 11x 50x 50x 11x 11x 11x 11x 11x 11x 38x 38x 38x 38x 38x 36x 36x           36x 38x 38x 38x 36x 36x 2x 2x 36x 4x     4x 36x 38x 11x  
/**
 * 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;
                }
            }
        }
    }
}