Source: lib/core/renderer/domPatch.js

import { AvenxErrorCodes, formatMessage } from '../runtime/AvenxError.js';
import { sanitizeUrlAttribute, isUrlAttribute } from '../security/urlPolicy.js';
import { logger } from '../runtime/AvenxLogger.js';
import { HtmlEscaper, SafeHtml } from '../security/escapeHtml.js';
import { isBooleanAttribute } from './constants.js';
import { profile, getComponentProfilingInfo } from '../utils/profiler.js';
import { tracer } from '../trace/tracer.js';
import { traceDomOp, clampDomValue } from '../trace/dom.js';
import { findScope } from './bindingScope.js';

const escaper = new HtmlEscaper();

/**
 * Compares two class attribute strings as unordered token sets, ignoring extra
 * whitespace and token order, so redundant DOM mutations are skipped when the
 * effective class list is unchanged.
 * @param {string|null} a
 * @param {string|null} b
 * @returns {boolean}
 */
function classTokensEqual(a, b) {
  const tokensOf = (value) => {
    if (value == null) return [];
    return Array.from(new Set(String(value).trim().split(/\s+/).filter(Boolean))).sort();
  };
  const aTokens = tokensOf(a);
  const bTokens = tokensOf(b);
  if (aTokens.length !== bTokens.length) {
    return false;
  }
  return aTokens.every((token, index) => token === bTokens[index]);
}

/**
 * Helper to compute transition/animation duration from element computed styles.
 * @param {Element} el
 * @returns {number} duration in ms
 */
function getTransitionDuration(el) {
  if (!el || typeof window === 'undefined' || !window.getComputedStyle) return 0;
  const styles = window.getComputedStyle(el);
  const transitionDelay = styles.transitionDelay || '';
  const transitionDuration = styles.transitionDuration || '';
  const animationDelay = styles.animationDelay || '';
  const animationDuration = styles.animationDuration || '';

  const parseTime = (timeStr) => {
    if (!timeStr) return 0;
    const times = timeStr.split(',').map((t) => {
      const match = t.trim().match(/^([0-9.]+)(s|ms)$/i);
      if (!match) return 0;
      const val = parseFloat(match[1]);
      const unit = match[2].toLowerCase();
      return unit === 'ms' ? val : val * 1000;
    });
    return Math.max(...times, 0);
  };

  const tDuration = parseTime(transitionDuration);
  const tDelay = parseTime(transitionDelay);
  const aDuration = parseTime(animationDuration);
  const aDelay = parseTime(animationDelay);

  return Math.max(tDuration + tDelay, aDuration + aDelay);
}


/**
 * The DOMParser used for every patch.
 *
 * One instance rather than one per patch. Constructing a DOMParser is not free,
 * and the update path runs it on every component on every flush.
 * @type {DOMParser|null}
 */
let parserInstance = null;

/**
 * Returns the shared DOMParser, creating it on first use.
 * @returns {DOMParser} The parser.
 */
function sharedParser() {
  if (!parserInstance) {
    parserInstance = new DOMParser();
  }
  return parserInstance;
}

/**
 * Applies the URL policy to every URL-bearing attribute in a parsed tree.
 * @param {Element} root - The root of the freshly parsed render output.
 */
function sanitizeUrlsIn(root) {
  if (!root || typeof root.querySelectorAll !== 'function') {
    return;
  }
  const elements = [root, ...Array.from(root.querySelectorAll('*'))];
  for (const el of elements) {
    if (!el || !el.attributes || typeof el.setAttribute !== 'function') continue;
    // Copied first: sanitising mutates the collection being iterated.
    const attrs = Array.from(el.attributes);
    for (const attr of attrs) {
      if (!isUrlAttribute(attr.name)) continue;
      const safe = sanitizeUrlAttribute(attr.name, attr.value);
      if (safe !== attr.value) {
        el.setAttribute(attr.name, safe);
      }
    }
  }
}

/**
 * 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.
   * @param {function(string): any} [resolveExpression] - Function to evaluate expressions.
   * @param {object} [app] - The application context.
   */
  patch(target, html, resolveExpression, app) {
    const { enableProfiling, componentName } = getComponentProfilingInfo(target);

    profile(enableProfiling, componentName, 'patch', () => {
      const newDoc = sharedParser().parseFromString(html, 'text/html');

      const parserError =
        newDoc && typeof newDoc.querySelector === 'function' ? newDoc.querySelector('parsererror') : null;
      if (parserError) {
        const errorMsg = parserError.textContent ? parserError.textContent.trim() : 'Unknown parsing error';
        logger.warn(formatMessage(AvenxErrorCodes.DOM_PARSING_FAILED, errorMsg, html));
        return;
      }

      const newRoot = newDoc.body;
      // Sanitised here, on the whole parsed tree, because this is the one point
      // every rendered node passes through. Doing it only in the attribute-sync
      // path missed the first render, where nodes are inserted wholesale rather
      // than attribute by attribute -- so a javascript: href was live until
      // something else caused that attribute to be re-set.
      sanitizeUrlsIn(newRoot);
      this.flattenTransitionTags(newRoot);

      const prevSession = this.sessionElements;
      const prevRoot = this.patchRoot;
      this.sessionElements = new Set();
      this.patchRoot = target;

      try {
        this.#patchNode(target, newRoot, true, true, resolveExpression, app);
        this.flushLifecycleHooks(app);
      } finally {
        this.sessionElements = prevSession;
        this.patchRoot = prevRoot;
      }
    });
  }

  /**
   * Patches an existing element with a new element structure in-place.
   * @param {Element} oldElement - The existing element.
   * @param {Element} newElement - The new element structure.
   * @param {function(string): any} [resolveExpression] - Function to evaluate expressions.
   * @param {object} [app] - The application context.
   */
  patchElement(oldElement, newElement, resolveExpression, app) {
    const { enableProfiling, componentName } = getComponentProfilingInfo(oldElement);
    profile(enableProfiling, componentName, 'patch', () => {
      this.flattenTransitionTags(newElement);

      const prevSession = this.sessionElements;
      const prevRoot = this.patchRoot;
      this.sessionElements = new Set();
      this.patchRoot = oldElement;

      try {
        this.#patchNode(oldElement, newElement, false, true, resolveExpression, app);
        this.flushLifecycleHooks(app);
      } finally {
        this.sessionElements = prevSession;
        this.patchRoot = prevRoot;
      }
    });
  }

  /**
   * Recursively diffs and patches two nodes.
   * @param {Node} oldNode - The existing DOM node.
   * @param {Node} newNode - The new node structure.
   * @param {boolean} [isBodyWrapper] - Whether the new node is a temporary body wrapper.
   * @param {boolean} [isPatchRoot] - Whether this is the root node of the patching operation.
   * @param {function(string): any} [resolveExpression] - Function to evaluate expressions.
   * @param {object} [app] - The application context.
   * @private
   */
  #patchNode(oldNode, newNode, isBodyWrapper = false, isPatchRoot = false, resolveExpression, app) {
    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');
        if (resolveExpression) {
          this.#applyDirectives(oldNode, resolveExpression, app);
        }
      }
      return;
    }

    if (
      !isPatchRoot &&
      oldNode.nodeType === Node.ELEMENT_NODE &&
      (oldNode.hasAttribute('data-avenx-comp') || oldNode.hasAttribute('data-avenx-comp-dynamic'))
    ) {
      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);
        }
        if (resolveExpression) {
          this.#applyDirectives(oldNode, resolveExpression, app);
        }
      }
      return;
    }

    if (
      !isPatchRoot &&
      oldNode.nodeType === Node.ELEMENT_NODE &&
      (oldNode.tagName.toLowerCase() === 'template' || oldNode.tagName.toLowerCase() === '@for')
    ) {
      if (newNode.nodeType === Node.ELEMENT_NODE) {
        this.#patchAttributes(oldNode, newNode);
      }
      return;
    }

    if (!isPatchRoot && oldNode.nodeType === Node.ELEMENT_NODE && oldNode.hasAttribute('data-ax-static')) {
      return;
    }

    if (
      !isPatchRoot &&
      oldNode.nodeType === Node.ELEMENT_NODE &&
      newNode.nodeType === Node.ELEMENT_NODE &&
      oldNode.hasAttribute('data-ax-memo') &&
      typeof oldNode.isEqualNode === 'function' &&
      oldNode.isEqualNode(newNode)
    ) {
      return;
    }

    if (
      !isPatchRoot &&
      oldNode.nodeType === Node.ELEMENT_NODE &&
      oldNode.hasAttribute('data-ax-deadlock') &&
      typeof oldNode.querySelector === 'function' &&
      oldNode.querySelector('.ax-deadlock-fallback')
    ) {
      return;
    }

    // 1. Update attributes if it's an element (skip if it is the temporary body wrapper)
    let skipChildren = false;
    if (!isBodyWrapper && oldNode.nodeType === Node.ELEMENT_NODE && newNode.nodeType === Node.ELEMENT_NODE) {
      this.#patchAttributes(oldNode, newNode);
      if (resolveExpression) {
        skipChildren = this.#applyDirectives(oldNode, resolveExpression, app);
      }
    }

    if (skipChildren) {
      return;
    }

    // 2. Diff children
    const oldChildrenRaw = Array.from(oldNode.childNodes).filter((child) => !child._isLeaving);
    const newChildrenRaw = Array.from(newNode.childNodes);

    const oldChildren = [];
    for (const child of oldChildrenRaw) {
      if (child.nodeType === Node.TEXT_NODE) {
        const last = oldChildren[oldChildren.length - 1];
        if (last && last.nodeType === Node.TEXT_NODE) {
          last.textContent += child.textContent;
          if (child.parentNode) {
            child.parentNode.removeChild(child);
          }
        } else {
          oldChildren.push(child);
        }
      } else {
        oldChildren.push(child);
      }
    }

    const newChildren = [];
    for (const child of newChildrenRaw) {
      if (child.nodeType === Node.TEXT_NODE) {
        const last = newChildren[newChildren.length - 1];
        if (last && last.nodeType === Node.TEXT_NODE) {
          last.textContent += child.textContent;
          if (child.parentNode) {
            child.parentNode.removeChild(child);
          }
        } else {
          newChildren.push(child);
        }
      } else {
        newChildren.push(child);
      }
    }

    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
        const isParentSvg =
          oldNode &&
          oldNode.nodeType === Node.ELEMENT_NODE &&
          (oldNode.namespaceURI === 'http://www.w3.org/2000/svg' || oldNode.tagName.toLowerCase() === 'svg');
        const prepared = this.#prepareNode(newChild, isParentSvg, resolveExpression, app);
        oldNode.appendChild(prepared);
        if (tracer.on) {
          traceDomOp('insert', prepared, { into: oldNode.tagName && oldNode.tagName.toLowerCase() });
        }
        this.triggerEnter(prepared, resolveExpression);
      } else if (this.#isSameNodeType(oldChild, newChild)) {
        // Nodes are same type, patch them
        if (oldChild.nodeType === Node.TEXT_NODE) {
          if (oldChild.textContent !== newChild.textContent) {
            const previousText = oldChild.textContent;
            oldChild.textContent = newChild.textContent;
            if (tracer.on) {
              traceDomOp('text', oldChild, {
                from: clampDomValue(previousText),
                to: clampDomValue(newChild.textContent),
              });
            }
          }
        } else {
          this.#patchNode(oldChild, newChild, false, false, resolveExpression, app);
        }
        oldIndex++;
      } else {
        // Nodes are different, replace
        const isParentSvg =
          oldNode &&
          oldNode.nodeType === Node.ELEMENT_NODE &&
          (oldNode.namespaceURI === 'http://www.w3.org/2000/svg' || oldNode.tagName.toLowerCase() === 'svg');
        const prepared = this.#prepareNode(newChild, isParentSvg, resolveExpression, app);

        const transitionName = this.getTransitionName(oldChild, resolveExpression);
        if (transitionName) {
          oldNode.insertBefore(prepared, oldChild);
          this.triggerLeave(oldChild, resolveExpression, () => {
            if (oldChild.parentNode === oldNode) {
              if (tracer.on) {
                traceDomOp('remove', oldChild);
              }
              oldNode.removeChild(oldChild);
            }
          }, app);
        } else {
          this.triggerUnmounted(oldChild, app);
          if (tracer.on) {
            traceDomOp('replace', oldChild, { with: prepared.nodeName && prepared.nodeName.toLowerCase() });
          }
          oldNode.replaceChild(prepared, oldChild);
        }
        this.triggerEnter(prepared, resolveExpression);
        oldIndex++;
      }
      newIndex++;
    }

    // Re-sync select value after dynamic options are patched.
    if (
      oldNode.nodeType === Node.ELEMENT_NODE &&
      oldNode.nodeName === 'SELECT' &&
      newNode.nodeType === Node.ELEMENT_NODE
    ) {
      const valueAttr = newNode.getAttribute('value');

      if (valueAttr !== null && oldNode.value !== valueAttr) {
        oldNode.value = valueAttr;
      }
    }

    // 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'))) {
        this.triggerLeave(oldChild, resolveExpression, () => {
          if (oldChild.parentNode === oldNode) {
            if (tracer.on) {
              traceDomOp('remove', oldChild);
            }
            oldNode.removeChild(oldChild);
          }
        }, app);
      }
      oldIndex++;
    }
  }

  /**
   * Checks if two nodes are of the same type and name.
   * @param {Node} nodeA
   * @param {Node} nodeB
   * @private
   */
  #isSameNodeType(nodeA, nodeB) {
    return nodeA.nodeType === nodeB.nodeType && nodeA.nodeName === nodeB.nodeName;
  }

  /**
   * Syncs attributes from newNode to oldNode.
   * @param {Element} oldNode
   * @param {Element} newNode
   * @private
   */
  #patchAttributes(oldNode, newNode) {
    const oldAttrs = oldNode.attributes;
    const newAttrs = newNode.attributes;

    // Track dynamic attribute names for clean attribute removal when dynamic attribute names change
    const newDynAttrStr = newNode.getAttribute ? newNode.getAttribute('data-ax-dyn-attrs') : null;
    const newDynAttrs = newDynAttrStr !== null ? newDynAttrStr.split(',').filter(Boolean) : null;

    let prevDynAttrs = oldNode.__avenxDynAttrs || [];
    if (oldNode.getAttribute && oldNode.hasAttribute('data-ax-dyn-attrs')) {
      const attrVal = oldNode.getAttribute('data-ax-dyn-attrs');
      const fromAttr = attrVal ? attrVal.split(',').filter(Boolean) : [];
      prevDynAttrs = Array.from(new Set([...prevDynAttrs, ...fromAttr]));
    }

    if (newDynAttrs !== null) {
      for (const oldDynName of prevDynAttrs) {
        if (!newDynAttrs.includes(oldDynName)) {
          if (oldNode.hasAttribute(oldDynName)) {
            oldNode.removeAttribute(oldDynName);
            if (isBooleanAttribute(oldDynName)) {
              oldNode[oldDynName] = false;
            }
          }
        }
      }
      oldNode.__avenxDynAttrs = newDynAttrs;
    }

    // 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 (tracer.on) {
          traceDomOp('remove-attr', oldNode, { name: attr.name, from: clampDomValue(attr.value) });
        }
        if (isBooleanAttribute(attr.name)) {
          oldNode[attr.name] = false;
        }
        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];
      const isBoolean = isBooleanAttribute(attr.name);

      if (isBoolean) {
        const isFalsy = attr.value === 'false' || attr.value === null || attr.value === undefined;
        if (isFalsy) {
          if (oldNode.hasAttribute(attr.name)) {
            oldNode.removeAttribute(attr.name);
          }
          oldNode[attr.name] = false;
        } else {
          if (oldNode.getAttribute(attr.name) !== attr.value) {
            const previous = oldNode.getAttribute(attr.name);
            oldNode.setAttribute(attr.name, sanitizeUrlAttribute(attr.name, attr.value));
            if (tracer.on) {
              traceDomOp('attr', oldNode, {
                name: attr.name,
                from: clampDomValue(previous),
                to: clampDomValue(attr.value),
              });
            }
          }
          oldNode[attr.name] = true;
        }
      } else {
        const oldValue = oldNode.getAttribute(attr.name);
        const shouldUpdate =
          attr.name === 'class' ? !classTokensEqual(oldValue, attr.value) : oldValue !== attr.value;
        if (shouldUpdate) {
          // Escaping made the value safe *as an attribute*; only the scheme
          // check makes it safe as a URL. See lib/core/security/urlPolicy.js.
          oldNode.setAttribute(attr.name, sanitizeUrlAttribute(attr.name, attr.value));
          if (tracer.on) {
            traceDomOp('attr', oldNode, {
              name: attr.name,
              from: clampDomValue(oldValue),
              to: clampDomValue(attr.value),
            });
          }
        }
        if (attr.name === 'value' && ['INPUT', 'TEXTAREA', 'SELECT'].includes(oldNode.nodeName)) {
          if (oldNode.value !== attr.value) {
            oldNode.value = attr.value;
          }
        }
      }
    }
  }

  /**
   * Cleans an element by removing boolean attributes that evaluate to false.
   * @param {Element} element - The element to clean.
   * @returns {Element} The cleaned element.
   */
  cleanElement(element) {
    if (element && element.nodeType === Node.ELEMENT_NODE) {
      this.flattenTransitionTags(element);
      this.#cleanBooleanAttributes(element);
    }
    return element;
  }

  /**
   * Recursively finds and cleans boolean attributes that evaluate to false in a subtree.
   * @param {Element} element - The root element to clean.
   * @private
   */
  #cleanBooleanAttributes(element) {
    const elements = [element, ...element.querySelectorAll('*')];
    for (const el of elements) {
      const attrs = Array.from(el.attributes);
      for (const attr of attrs) {
        if (isBooleanAttribute(attr.name)) {
          const isFalsy = attr.value === 'false' || attr.value === null || attr.value === undefined;
          if (isFalsy) {
            el.removeAttribute(attr.name);
            el[attr.name] = false;
          } else {
            el[attr.name] = true;
          }
        }
      }
    }
  }

  /**
   * Cleans boolean attributes of a single element in-place.
   * @param {Element} el
   * @private
   */
  #cleanBooleanAttributesForNode(el) {
    const attrs = Array.from(el.attributes);
    for (const attr of attrs) {
      if (isBooleanAttribute(attr.name)) {
        const isFalsy = attr.value === 'false' || attr.value === null || attr.value === undefined;
        if (isFalsy) {
          el.removeAttribute(attr.name);
          el[attr.name] = false;
        } else {
          el[attr.name] = true;
        }
      }
    }
  }

  /**
   * Prepares a node for insertion into the DOM by cleaning its boolean attributes
   * and ensuring correct namespaces for SVG elements.
   * If a node already has the correct namespace, it is prepared in-place without cloning.
   * @param {Node} node - The node to prepare.
   * @param {boolean} [isSvg] - Whether the node is within an SVG context.
   * @param {function(string): any} [resolveExpression] - Function to evaluate expressions.
   * @param {object} [app] - The application context.
   * @returns {Node} The prepared node.
   * @private
   */
  #prepareNode(node, isSvg = false, resolveExpression, app) {
    if (node.nodeType === Node.ELEMENT_NODE) {
      const tagName = node.tagName.toLowerCase();
      if (tagName === 'template' || tagName === '@for') {
        return node;
      }
      const currentIsSvg = isSvg || tagName === 'svg';

      let skipChildren = false;
      if (resolveExpression) {
        skipChildren = this.#applyDirectives(node, resolveExpression, app);
      }

      if (currentIsSvg) {
        if (node.namespaceURI === 'http://www.w3.org/2000/svg') {
          this.#cleanBooleanAttributesForNode(node);
          if (!skipChildren) {
            const children = Array.from(node.childNodes);
            for (const child of children) {
              this.#prepareNode(child, currentIsSvg, resolveExpression, app);
            }
          }
          return node;
        } else {
          const svgElement = document.createElementNS('http://www.w3.org/2000/svg', tagName);
          const attrs = node.attributes;
          if (attrs) {
            for (let i = 0; i < attrs.length; i++) {
              const attr = attrs[i];
              const isBoolean = isBooleanAttribute(attr.name);
              const isFalsy = attr.value === 'false' || attr.value === null || attr.value === undefined;
              if (isBoolean && isFalsy) {
                svgElement[attr.name] = false;
              } else {
                svgElement.setAttribute(attr.name, sanitizeUrlAttribute(attr.name, attr.value));
                if (isBoolean) {
                  svgElement[attr.name] = true;
                }
              }
            }
          }
          if (resolveExpression) {
            this.#applyDirectives(svgElement, resolveExpression, app);
          }
          if (!skipChildren) {
            const children = Array.from(node.childNodes);
            for (const child of children) {
              svgElement.appendChild(this.#prepareNode(child, currentIsSvg, resolveExpression, app));
            }
          }
          return svgElement;
        }
      } else {
        this.#cleanBooleanAttributesForNode(node);
        if (!skipChildren) {
          const children = Array.from(node.childNodes);
          for (const child of children) {
            this.#prepareNode(child, false, resolveExpression, app);
          }
        }
        return node;
      }
    }
    return node;
  }

  /**
   * Resolves the scope an element's bindings belong to.
   *
   * A scoped slot was the first thing to need this; a `<@for>` row needs
   * exactly the same answer, so the walk lives in one module now and both stamp
   * through it.
   * @param {Element} el - The element a binding is attached to.
   * @returns {object|null} The nearest enclosing derived scope, or null.
   * @private
   */
  #getSlotScope(el) {
    return findScope(el);
  }

  /**
   * Evaluates and applies directives on a single element.
   * @param {Element} el - The element to evaluate directives on.
   * @param {function(string): any} resolveExpression - The expression evaluator.
   * @param {object} [app] - The application context.
   * @returns {boolean} Whether children evaluation/diffing should be skipped.
   * @private
   */
  #applyDirectives(el, resolveExpression, app) {
    if (!resolveExpression || el.nodeType !== Node.ELEMENT_NODE) {
      return false;
    }
    const slotScope = this.#getSlotScope(el);
    const localResolve = slotScope ? (expr) => resolveExpression(expr, slotScope) : resolveExpression;
    let skipChildren = false;

    // 1. data-ax-html
    if (el.hasAttribute('data-ax-html')) {
      const expr = el.getAttribute('data-ax-html');
      try {
        const value = localResolve(expr);
        let resolvedHtml = '';
        if (value instanceof SafeHtml) {
          resolvedHtml = value.toString();
        } else if (value == null) {
          resolvedHtml = '';
        } else {
          resolvedHtml = escaper.escape(value);
        }
        if (el.innerHTML !== resolvedHtml) {
          el.innerHTML = resolvedHtml;
        }
        skipChildren = true;
      } catch (err) {
        logger.warn(
          formatMessage(AvenxErrorCodes.DIRECTIVE_HTML_EVALUATION_FAILED, expr, err.message || err)
        );
      }
    }

    // 2. data-ax-show
    if (el.hasAttribute('data-ax-show')) {
      const expr = el.getAttribute('data-ax-show');
      try {
        const value = !!localResolve(expr);
        const hasOriginal = typeof el.__originalDisplay !== 'undefined';
        if (!hasOriginal) {
          el.__originalDisplay = el.style.display || '';
        }

        const isCurrentlyVisible = el.style.display !== 'none';

        if (!el.axShowInitialized) {
          el.style.display = value ? el.__originalDisplay : 'none';
          el.axShowInitialized = true;
        } else if (value !== isCurrentlyVisible) {
          const transitionName = this.getTransitionName(el, resolveExpression);
          if (transitionName) {
            if (value) {
              el.style.display = el.__originalDisplay;
              this.enter(el, transitionName);
            } else {
              this.leave(el, transitionName, () => {
                el.style.display = 'none';
              });
            }
          } else {
            el.style.display = value ? el.__originalDisplay : 'none';
          }
        }
      } catch (err) {
        logger.warn(
          formatMessage(AvenxErrorCodes.DIRECTIVE_SHOW_EVALUATION_FAILED, expr, err.message || err)
        );
      }
    }

    // 3. data-ax-class
    if (el.hasAttribute('data-ax-class')) {
      const expr = el.getAttribute('data-ax-class');
      try {
        const value = localResolve(expr);
        // Clean up classes added by previous data-ax-class evaluation
        if (el.__lastAxClasses) {
          for (const cls of el.__lastAxClasses) {
            el.classList.remove(cls);
          }
        }

        const newClasses = [];
        if (typeof value === 'string') {
          newClasses.push(...value.split(/\s+/).filter(Boolean));
        } else if (value && typeof value === 'object') {
          for (const [cls, enabled] of Object.entries(value)) {
            if (enabled) {
              newClasses.push(cls);
            }
          }
        }

        for (const cls of newClasses) {
          el.classList.add(cls);
        }
        el.__lastAxClasses = newClasses;
      } catch (err) {
        logger.warn(
          formatMessage(AvenxErrorCodes.DIRECTIVE_CLASS_EVALUATION_FAILED, expr, err.message || err)
        );
      }
    }

    // 4. Dynamic Attribute Name Binding (:[attrName]="attrValue")
    const elAttrs = Array.from(el.attributes || []);
    const currentDynAttrs = [];
    for (const attr of elAttrs) {
      const match = attr.name.match(/^:\[(.*)\]$/);
      if (match) {
        const nameExpr = match[1];
        const valExpr = attr.value;
        let resolvedName = null;
        try {
          resolvedName = localResolve(nameExpr);
        } catch (err) {
          logger.warn(`Failed to evaluate dynamic attribute name expression "${nameExpr}": ${err.message || err}`);
        }

        if (resolvedName != null && String(resolvedName).trim() !== '') {
          const attrName = String(resolvedName).trim();
          let resolvedVal = null;
          if (valExpr) {
            try {
              resolvedVal = localResolve(valExpr);
            } catch (err) {
              logger.warn(`Failed to evaluate dynamic attribute value expression "${valExpr}": ${err.message || err}`);
            }
          }

          if (resolvedVal !== false && resolvedVal != null) {
            currentDynAttrs.push(attrName);
            if (resolvedVal === true) {
              el.setAttribute(attrName, 'true');
              if (isBooleanAttribute(attrName)) {
                el[attrName] = true;
              }
            } else {
              el.setAttribute(attrName, sanitizeUrlAttribute(attrName, String(resolvedVal)));
            }
          } else {
            currentDynAttrs.push(attrName);
            if (el.hasAttribute(attrName)) {
              el.removeAttribute(attrName);
              if (isBooleanAttribute(attrName)) {
                el[attrName] = false;
              }
            }
          }
        }
        el.removeAttribute(attr.name);
      }
    }

    const prevDynAttrs = el.__avenxDynAttrs || [];
    for (const oldName of prevDynAttrs) {
      if (!currentDynAttrs.includes(oldName)) {
        if (el.hasAttribute(oldName)) {
          el.removeAttribute(oldName);
          if (isBooleanAttribute(oldName)) {
            el[oldName] = false;
          }
        }
      }
    }
    if (currentDynAttrs.length > 0 || prevDynAttrs.length > 0) {
      el.__avenxDynAttrs = currentDynAttrs;
    }

    // 5. Custom Directives
    if (app && app.directives) {
      const attrs = Array.from(el.attributes || []);
      for (const attr of attrs) {
        if (attr.name.startsWith('data-ax-')) {
          // Split dot-notation modifiers off the directive name, mirroring the
          // event modifier convention in core/events/bindEvents.js.
          const attrKey = attr.name.slice(8);
          const parts = attrKey.split('.');
          const dirName = parts[0];
          // Ignore built-in core directives
          if (
            dirName === 'show' ||
            dirName === 'class' ||
            dirName === 'html' ||
            dirName === 'static' ||
            dirName === 'transition' ||
            dirName === 'for' ||
            dirName === 'as' ||
            dirName === 'key' ||
            dirName === 'list-item' ||
            dirName === 'key-val' ||
            dirName === 'validate' ||
            dirName === 'validate-messages'
          ) {
            continue;
          }
          if (app.directives.has(dirName)) {
            const expr = attr.value;
            let value;
            if (expr) {
              try {
                value = localResolve(expr);
              } catch (err) {
                logger.warn(
                  `Failed to evaluate custom directive ${dirName} expression "${expr}": ${err.message || err}`
                );
              }
            }

            if (!el.__avenx_directives) {
              el.__avenx_directives = new Map();
            }

            // Keyed by the full attribute suffix so that two modifier variants of
            // the same directive on one element do not overwrite each other.
            const oldInfo = el.__avenx_directives.get(attrKey);

            if (!oldInfo) {
              const modifiers = {};
              for (let i = 1; i < parts.length; i++) {
                if (parts[i]) modifiers[parts[i]] = true;
              }

              el.__avenx_directives.set(attrKey, {
                name: dirName,
                value,
                oldValue: undefined,
                expression: expr,
                modifiers,
                mountedCalled: false,
                valueChanged: false,
              });
            } else {
              const oldValue = oldInfo.value;

              if (value !== oldValue) {
                oldInfo.oldValue = oldValue;
                oldInfo.value = value;
                oldInfo.expression = expr;
                oldInfo.valueChanged = true;
              } else {
                // The directive value has not changed.
                // Make sure a previous update flag cannot trigger
                // another updated() lifecycle call.
                oldInfo.valueChanged = false;
              }
            }

            if (this.sessionElements) {
              this.sessionElements.add(el);
            }
          }
        }
      }
    }

    return skipChildren;
  }

  /**
   * Recursively applies custom directives to an element and its children.
   * @param {Element} element - The element tree root.
   * @param {function(string): any} resolveExpression - The expression evaluator.
   * @param {object} [app] - The application context.
   */
  applyDirectives(element, resolveExpression, app) {
    const prevSession = this.sessionElements;
    const prevRoot = this.patchRoot;
    this.sessionElements = new Set();
    this.patchRoot = element;

    try {
      this.applyDirectivesInternal(element, resolveExpression, app);
      this.flushLifecycleHooks(app);
    } finally {
      this.sessionElements = prevSession;
      this.patchRoot = prevRoot;
    }
  }

  /**
   * Recursively applies directives to an element and its children.
   * @param {Element} element - The target element.
   * @param {Function} resolveExpression - Expression resolver callback.
   * @param {object} [app] - The application context.
   */
  applyDirectivesInternal(element, resolveExpression, app) {
    const skip = this.#applyDirectives(element, resolveExpression, app);
    if (!skip) {
      const children = Array.from(element.childNodes);
      for (const child of children) {
        if (child.nodeType === Node.ELEMENT_NODE) {
          this.applyDirectivesInternal(child, resolveExpression, app);
        }
      }
    }
  }

  /**
   * Applies the enter transition classes and triggers animation/transition.
   * @param {Element} el - The element to animate.
   * @param {string} transitionName - The transition name (e.g. 'fade').
   */
  enter(el, transitionName) {
    if (el.nodeType !== Node.ELEMENT_NODE) return;
    const name = transitionName || 'ax';
    const enterClass = `${name}-enter`;
    const enterActiveClass = `${name}-enter-active`;
    const enterToClass = `${name}-enter-to`;

    if (el._cleanupTransition) {
      el._cleanupTransition();
    }

    el.classList.add(enterClass);
    el.classList.add(enterActiveClass);

    let resolved = false;
    let timeoutId = null;

    const done = () => {
      if (resolved) return;
      resolved = true;
      el.classList.remove(enterActiveClass);
      el.classList.remove(enterToClass);
      el.removeEventListener('transitionend', done);
      el.removeEventListener('animationend', done);
      if (timeoutId) clearTimeout(timeoutId);
      delete el._cleanupTransition;
    };

    el._cleanupTransition = done;

    el.addEventListener('transitionend', done);
    el.addEventListener('animationend', done);

    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        if (resolved) return;
        el.classList.remove(enterClass);
        el.classList.add(enterToClass);

        const duration = getTransitionDuration(el);
        if (duration === 0) {
          done();
        } else {
          timeoutId = setTimeout(done, duration + 50);
        }
      });
    });
  }

  /**
   * Applies the leave transition classes, triggers animation, and cleans up when complete.
   * @param {Element} el - The element to animate.
   * @param {string} transitionName - The transition name (e.g. 'fade').
   * @param {function(): void} removeCallback - Callback invoked when the leave transition completes.
   */
  leave(el, transitionName, removeCallback) {
    if (el.nodeType !== Node.ELEMENT_NODE) {
      if (removeCallback) removeCallback();
      return;
    }
    const name = transitionName || 'ax';
    const leaveClass = `${name}-leave`;
    const leaveActiveClass = `${name}-leave-active`;
    const leaveToClass = `${name}-leave-to`;

    if (el._cleanupTransition) {
      el._cleanupTransition();
    }

    el._isLeaving = true;

    el.classList.add(leaveClass);
    el.classList.add(leaveActiveClass);

    let resolved = false;
    let timeoutId = null;

    const done = () => {
      if (resolved) return;
      resolved = true;
      el.classList.remove(leaveActiveClass);
      el.classList.remove(leaveToClass);
      el.removeEventListener('transitionend', done);
      el.removeEventListener('animationend', done);
      if (timeoutId) clearTimeout(timeoutId);
      delete el._cleanupTransition;
      delete el._isLeaving;
      if (removeCallback) removeCallback();
    };

    el._cleanupTransition = done;

    el.addEventListener('transitionend', done);
    el.addEventListener('animationend', done);

    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        if (resolved) return;
        el.classList.remove(leaveClass);
        el.classList.add(leaveToClass);

        const duration = getTransitionDuration(el);
        if (duration === 0) {
          done();
        } else {
          timeoutId = setTimeout(done, duration + 50);
        }
      });
    });
  }

  /**
   * Resolves the transition name for an element.
   * @param {Element} el - The element.
   * @param {function(string): any} [resolveExpression] - The expression evaluator.
   * @returns {string|null} The resolved transition name, or null.
   */
  getTransitionName(el, resolveExpression) {
    if (!el || el.nodeType !== Node.ELEMENT_NODE) return null;
    if (!el.hasAttribute('data-ax-transition')) return null;
    const expr = el.getAttribute('data-ax-transition');
    if (!expr) return 'ax';
    if (!resolveExpression) return expr;
    const slotScope = this.#getSlotScope(el);
    const localResolve = slotScope ? (e) => resolveExpression(e, slotScope) : resolveExpression;
    try {
      const val = localResolve(expr);
      return typeof val === 'string' ? val : expr;
    } catch {
      return expr;
    }
  }

  /**
   * Triggers the enter transition if transition configurations are present.
   * @param {Element} el - The element.
   * @param {function(string): any} [resolveExpression] - The expression evaluator.
   */
  triggerEnter(el, resolveExpression) {
    if (el.nodeType !== Node.ELEMENT_NODE) return;
    const transitionName = this.getTransitionName(el, resolveExpression);
    if (transitionName) {
      el._transitionName = transitionName;
      this.enter(el, transitionName);
    }
  }

  /**
   * Triggers the leave transition or asynchronous unmounting of an element.
   * @param {Element} el - The element leaving.
   * @param {Function} resolveExpression - Expression evaluator callback.
   * @param {Function} removeCallback - Callback to remove element from DOM.
   * @param {object} [app] - The application context.
   */
  triggerLeave(el, resolveExpression, removeCallback, app) {
    if (el.nodeType !== Node.ELEMENT_NODE) {
      if (removeCallback) removeCallback();
      return;
    }

    const compInstance = el.__avenx_comp_instance;
    const proceedWithRemoval = () => {
      const done = () => {
        this.triggerUnmounted(el, app);
        if (removeCallback) removeCallback();
      };
      const transitionName = el._transitionName || this.getTransitionName(el, resolveExpression);
      if (transitionName) {
        this.leave(el, transitionName, done);
      } else {
        done();
      }
    };

    if (compInstance && typeof compInstance.unmount === 'function') {
      const unmountResult = compInstance.unmount();
      if (unmountResult instanceof Promise) {
        unmountResult.then(proceedWithRemoval);
        return;
      }
    }
    proceedWithRemoval();
  }

  /**
   * Flattens <transition> elements inside a root element.
   * @param {Element} node - The root element.
   */
  flattenTransitionTags(node) {
    if (!node || node.nodeType !== Node.ELEMENT_NODE) return;
    const transitions = Array.from(node.querySelectorAll('transition'));
    for (const trans of transitions) {
      const nameAttr = trans.getAttribute('name');
      let transitionValue = 'ax';
      if (nameAttr) {
        if (nameAttr.startsWith('{{') && nameAttr.endsWith('}}')) {
          transitionValue = nameAttr.slice(2, -2).trim();
        } else {
          transitionValue = nameAttr;
        }
      }
      const children = Array.from(trans.childNodes);
      for (const child of children) {
        if (child.nodeType === Node.ELEMENT_NODE) {
          child.setAttribute('data-ax-transition', transitionValue);
        }
        trans.parentNode.insertBefore(child, trans);
      }
      trans.parentNode.removeChild(trans);
    }
  }

  /**
   * Checks if a node is in the currently patched DOM tree.
   * @param {Element} el
   * @returns {boolean}
   */
  isNodeInPatchTree(el) {
    if (!this.patchRoot) return false;
    let curr = el;
    while (curr) {
      if (curr === this.patchRoot) return true;
      curr = curr.parentNode || (curr.host && curr.host.nodeType === 1 ? curr.host : null);
    }
    return false;
  }

  /**
   * Triggers the unmounted hook recursively on a node and its descendants.
   * @param {Node} node
   * @param {object} app
   */
  triggerUnmounted(node, app) {
    if (!node || node.nodeType !== Node.ELEMENT_NODE) return;

    const descendants = Array.from(node.querySelectorAll('*'));
    for (const desc of descendants) {
      this.triggerUnmountedForElement(desc, app);
    }
    this.triggerUnmountedForElement(node, app);
  }

  /**
   * Triggers the unmounted hook on a single element.
   * @param {Element} el
   * @param {object} app
   */
  triggerUnmountedForElement(el, app) {
    if (el.__avenx_directives) {
      for (const [dirName, info] of el.__avenx_directives.entries()) {
        if (info.mountedCalled) {
          const dirDef = app && app.directives && app.directives.get(info.name);
          if (dirDef && typeof dirDef.unmounted === 'function') {
            try {
              dirDef.unmounted(el, {
                value: info.value,
                oldValue: info.value,
                expression: info.expression,
                modifiers: info.modifiers,
              });
            } catch (err) {
              logger.warn(`Error in unmounted hook of directive ${dirName}: ${err.message || err}`);
            }
          }
          info.mountedCalled = false;
        }
      }
    }
  }

  /**
   * Flushes all lifecycle hooks collected during the patch session.
   * @param {object} app
   */
  flushLifecycleHooks(app) {
    if (!this.sessionElements || !app) return;
    for (const el of this.sessionElements) {
      if (!el.__avenx_directives) continue;

      const isConnected = this.isNodeInPatchTree(el);
      if (isConnected) {
        for (const [dirName, info] of el.__avenx_directives.entries()) {
          const dirDef = app.directives.get(info.name);
          if (!dirDef) continue;

          if (!info.mountedCalled) {
            if (typeof dirDef.mounted === 'function') {
              try {
                dirDef.mounted(el, {
                  value: info.value,
                  expression: info.expression,
                  modifiers: info.modifiers,
                });
              } catch (err) {
                logger.error(`Error in mounted hook of directive ${dirName}: ${err.message || err}`);
              }
            }
            info.mountedCalled = true;
          } else if (info.valueChanged) {
            if (typeof dirDef.updated === 'function') {
              try {
                dirDef.updated(el, {
                  value: info.value,
                  oldValue: info.oldValue,
                  expression: info.expression,
                  modifiers: info.modifiers,
                });
              } catch (err) {
                logger.error(`Error in updated hook of directive ${dirName}: ${err.message || err}`);
              }
            }
            info.valueChanged = false;
          }
        }
      } else {
        this.triggerUnmounted(el, app);
      }
    }
  }
}