Source: lib/core/renderer/program/blocks.js

/**
 * @file blocks.js
 * @description The runtime half of control flow: conditionals, lists and slots.
 *
 * ## What these replace
 *
 * Control flow used to live in the runtime as a search. `ListManager` ran
 * `root.querySelectorAll('template[data-ax-for]')` on every update, read the
 * list expression out of an attribute, unescaped the body markup, rendered it
 * to a string per item and diffed the result into the document. The runtime was
 * doing the compiler's job, once per update, with a string as the only record
 * of what the author had written.
 *
 * A compiled block arrives already understood. `{ k: 'for', x: 4, as: 'row',
 * key: 7, b: 2 }` says: evaluate expression 4, bind each element to `row`,
 * identify rows by expression 7, and render block 2 for each. Block 2 is a
 * skeleton parsed once for the life of the page and cloned per row, with its
 * own ops writing to its own nodes.
 *
 * ## Anchors, and why each binding owns a range
 *
 * Every construct here occupies a *range* of sibling nodes rather than one
 * node, and the range is empty for a false condition or an empty list. So the
 * compiler reserves a text anchor at the position, and the binding inserts
 * after it and remembers exactly what it inserted.
 *
 * Remembering matters: clearing by emptying the parent would take the siblings
 * that belong to other bindings, which is the bug that makes two adjacent
 * `<@if>` blocks erase each other.
 *
 * ## Reconciliation
 *
 * The list binding is keyed. Entries are matched by key, reused in place, moved
 * with the cursor as the new order is walked, and torn down when their key
 * disappears. An unkeyed list falls back to the item's position, which is the
 * documented behaviour and is why keys matter for a list that reorders.
 *
 * ## Why the instance factory is injected
 *
 * A block is rendered by a `TemplateInstance`, and a `TemplateInstance` creates
 * these bindings for its range ops. Importing the class here would make that
 * mutual, and the bundler refuses a cycle rather than relying on which half
 * happens to initialise first. The owner passes a factory instead, so the
 * dependency runs one way.
 * @module lib/core/renderer/program/blocks
 */

/**
 * Merges a parent block's local bindings with a child's.
 *
 * A loop inside a loop reads both bindings, so locals chain rather than
 * replace. The merge is a copy because a reused entry's values are written in
 * place, and sharing one object between entries would make every row show the
 * last row's values.
 * @param {object|null} parent - The enclosing block's locals.
 * @param {object|null} own - This block's own bindings.
 * @returns {object|null} The merged locals.
 */
function mergeLocals(parent, own) {
  if (!own) return parent;
  if (!parent) return own;
  return { ...parent, ...own };
}

/**
 * Collects the top-level nodes of a fragment before it is inserted.
 *
 * Read before insertion because a fragment is emptied by `insertBefore`, and
 * the binding needs the list to remove exactly these nodes later.
 * @param {DocumentFragment} fragment - The fragment about to be inserted.
 * @returns {Node[]} Its top-level nodes, in order.
 */
function fragmentNodes(fragment) {
  const nodes = [];
  for (let child = fragment.firstChild; child; child = child.nextSibling) {
    nodes.push(child);
  }
  return nodes;
}

/**
 * One rendered block: its instance and the nodes it put in the document.
 */
class BlockRange {
  /**
   * @param {object} block - The block program.
   * @param {function(object, (object|null)): object} createInstance - Builds a
   *   template instance for a block.
   * @param {object|null} locals - Local bindings for this block's expressions.
   */
  constructor(block, createInstance, locals) {
    /** @type {object} */
    this.instance = createInstance(block, locals);
    /** @type {Node[]} */
    this.nodes = [];
    /** @type {object|null} */
    this.locals = locals;
  }

  /**
   * Whether the block could be prepared in this host environment.
   * @returns {boolean} True when it can be mounted.
   */
  get usable() {
    return this.instance.usable;
  }

  /**
   * Creates the block's DOM and inserts it after `after`.
   * @param {Node} parent - The parent node.
   * @param {Node} after - The node to insert after.
   * @returns {Node} The last node inserted, for chaining.
   */
  mount(parent, after) {
    const fragment = this.instance.create();
    if (!fragment) return after;
    this.nodes = fragmentNodes(fragment);
    parent.insertBefore(fragment, after.nextSibling);
    return this.nodes.length > 0 ? this.nodes[this.nodes.length - 1] : after;
  }

  /**
   * Moves this block's nodes so they follow `after`, preserving their order.
   * @param {Node} parent - The parent node.
   * @param {Node} after - The node to follow.
   * @returns {Node} The last node of this range.
   */
  moveAfter(parent, after) {
    let cursor = after;
    for (const node of this.nodes) {
      if (node.previousSibling !== cursor) {
        parent.insertBefore(node, cursor.nextSibling);
      }
      cursor = node;
    }
    return cursor;
  }

  /**
   * Removes this block's nodes and releases its effects.
   */
  dispose() {
    for (const node of this.nodes) {
      if (node.parentNode) node.parentNode.removeChild(node);
    }
    this.nodes = [];
    this.instance.dispose();
  }
}

/**
 * The state one range op keeps between evaluations.
 *
 * Held on the binding rather than in a map keyed by op, so it is released with
 * the instance and cannot outlive the DOM it refers to.
 */
export class RangeBinding {
  /**
   * @param {Text} anchor - The text node the compiler reserved for this op.
   * @param {function(object, (object|null)): object} createInstance - Builds a
   *   template instance for a block.
   * @param {object[]} blocks - The program's block table.
   * @param {object|null} locals - The enclosing block's local bindings.
   * @param {function(): void|null} [onBlockMounted] - Announces a mounted block.
   */
  constructor(anchor, createInstance, blocks, locals, onBlockMounted = null) {
    this.anchor = anchor;
    this.createInstance = createInstance;
    this.blocks = blocks;
    this.locals = locals;
    /**
     * Called after a block's nodes enter the document.
     *
     * A block can contain a child-component mount point, and the pass that
     * instantiates those scans the subtree -- so it has to be told that the
     * subtree grew. Without this a component inside `<@for>`, `<@if>` or
     * `<@defer>` renders its placeholder and never mounts, which is exactly
     * the defect the E2E suite pinned for `<@defer>`.
     * @type {function(): void|null}
     */
    this.onBlockMounted = onBlockMounted;
  }

  /**
   * Mounts a range and announces it.
   * @param {BlockRange} range - The range to mount.
   * @param {Node} parent - The parent node.
   * @param {Node} after - The node to insert after.
   * @returns {Node} The last node inserted.
   */
  mountRange(range, parent, after) {
    const last = range.mount(parent, after);
    if (this.onBlockMounted) this.onBlockMounted();
    return last;
  }

  /**
   * The node new content is inserted into.
   * @returns {Node|null} The anchor's parent.
   */
  get parent() {
    return this.anchor ? this.anchor.parentNode : null;
  }

  /**
   * Builds a range for one block index.
   * @param {number} index - The block index.
   * @param {object|null} extra - Local bindings this block adds.
   * @returns {BlockRange|null} The range, or null when the index is absent.
   */
  createRange(index, extra = null) {
    const block = this.blocks[index];
    if (!block) return null;
    const range = new BlockRange(block, this.createInstance, mergeLocals(this.locals, extra));
    return range.usable ? range : null;
  }
}

/**
 * Renders the first arm whose test is truthy.
 */
export class IfBinding extends RangeBinding {
  /**
   * @param {Text} anchor - The op's anchor.
   * @param {function(object, (object|null)): object} createInstance - Builds a
   *   template instance for a block.
   * @param {object[]} blocks - The program's block table.
   * @param {object|null} locals - The enclosing block's local bindings.
   * @param {function(): void|null} [onBlockMounted] - Announces a mounted block.
   */
  constructor(anchor, createInstance, blocks, locals, onBlockMounted) {
    super(anchor, createInstance, blocks, locals, onBlockMounted);
    /** @type {number} */
    this.activeArm = -1;
    /** @type {BlockRange|null} */
    this.range = null;
  }

  /**
   * Selects and renders the matching arm.
   * @param {Array<{x: number|null, b: number}>} arms - The op's arms, in order.
   * @param {function(number): any} evaluate - Evaluates an expression index.
   */
  update(arms, evaluate) {
    let selected = -1;
    for (let i = 0; i < arms.length; i++) {
      // A null test is the `<@else>`, which the compiler guarantees is last.
      if (arms[i].x === null || evaluate(arms[i].x)) {
        selected = i;
        break;
      }
    }

    // Re-rendering an arm that is already showing would tear down live DOM --
    // losing focus, selection and scroll position -- for a condition that did
    // not change. The arm index is the whole comparison: within an arm the
    // block's own bindings keep themselves current.
    if (selected === this.activeArm) return;

    this.activeArm = selected;
    if (this.range) {
      this.range.dispose();
      this.range = null;
    }
    if (selected === -1) return;

    const parent = this.parent;
    if (!parent) return;

    this.range = this.createRange(arms[selected].b);
    if (this.range) {
      this.mountRange(this.range, parent, this.anchor);
    }
  }

  /**
   * Releases the rendered arm.
   */
  dispose() {
    if (this.range) {
      this.range.dispose();
      this.range = null;
    }
    this.activeArm = -1;
  }
}

/**
 * Renders one copy of a block per list element, reconciled by key.
 */
export class ForBinding extends RangeBinding {
  /**
   * @param {Text} anchor - The op's anchor.
   * @param {function(object, (object|null)): object} createInstance - Builds a
   *   template instance for a block.
   * @param {object[]} blocks - The program's block table.
   * @param {object|null} locals - The enclosing block's local bindings.
   * @param {function(): void|null} [onBlockMounted] - Announces a mounted block.
   */
  constructor(anchor, createInstance, blocks, locals, onBlockMounted) {
    super(anchor, createInstance, blocks, locals, onBlockMounted);
    /** @type {Array<{key: any, range: BlockRange}>} */
    this.entries = [];
    /** @type {BlockRange|null} */
    this.emptyRange = null;
  }

  /**
   * Renders the list.
   * @param {object} op - The `for` op.
   * @param {function(number, (object|null)): any} evaluate - Evaluates an
   *   expression index against optional local bindings.
   */
  update(op, evaluate) {
    const parent = this.parent;
    if (!parent) return;

    const raw = evaluate(op.x, this.locals);
    const items = normaliseList(raw);

    if (items.length === 0) {
      this.#clearEntries();
      this.#showEmpty(op, parent);
      return;
    }

    this.#hideEmpty();

    /** @type {Map<any, {key: any, range: BlockRange}>} */
    const previous = new Map();
    for (const entry of this.entries) {
      // A duplicate key means two rows claim one identity. The first wins and
      // the second is rebuilt, which is what keeps the DOM consistent with the
      // list; the compiler reports duplicate keys separately.
      if (!previous.has(entry.key)) previous.set(entry.key, entry);
    }

    /** @type {Array<{key: any, range: BlockRange}>} */
    const next = [];
    let cursor = this.anchor;

    for (let i = 0; i < items.length; i++) {
      const item = items[i];
      // `index` is bound for every loop, named or not, because that is how the
      // loop index has always been reached and applications depend on it.
      const locals = { index: i };
      if (op.ds) {
        // `[a, b] in pairs` destructures the element itself. An element that is
        // not indexable binds undefined rather than throwing: a list that has
        // not loaded its shape yet is a normal state of a list.
        for (let slot = 0; slot < op.ds.length; slot++) {
          locals[op.ds[slot]] = item ? item[slot] : undefined;
        }
      } else {
        locals[op.as] = item;
      }

      const key = op.key === undefined ? i : evaluate(op.key, mergeLocals(this.locals, locals));

      const reused = previous.get(key);
      if (reused) {
        previous.delete(key);
        // The row kept its identity but may have a new value or a new position,
        // so the locals are written before the nodes move and the instance is
        // refreshed only when something it reads actually changed.
        const changed = applyLocals(reused.range.instance, locals);
        cursor = reused.range.moveAfter(parent, cursor);
        if (changed) reused.range.instance.refresh();
        next.push(reused);
        continue;
      }

      const range = this.createRange(op.b, locals);
      if (!range) continue;
      cursor = this.mountRange(range, parent, cursor);
      next.push({ key, range });
    }

    for (const stale of previous.values()) {
      stale.range.dispose();
    }
    this.entries = next;
  }

  /**
   * Renders the `<@empty>` block, if the loop declared one.
   * @param {object} op - The `for` op.
   * @param {Node} parent - The anchor's parent.
   * @private
   */
  #showEmpty(op, parent) {
    if (op.emp === undefined || this.emptyRange) return;
    this.emptyRange = this.createRange(op.emp);
    if (this.emptyRange) {
      this.mountRange(this.emptyRange, parent, this.anchor);
    }
  }

  /**
   * Removes the `<@empty>` block.
   * @private
   */
  #hideEmpty() {
    if (!this.emptyRange) return;
    this.emptyRange.dispose();
    this.emptyRange = null;
  }

  /**
   * Removes every rendered row.
   * @private
   */
  #clearEntries() {
    for (const entry of this.entries) {
      entry.range.dispose();
    }
    this.entries = [];
  }

  /**
   * Releases every row and the empty block.
   */
  dispose() {
    this.#clearEntries();
    this.#hideEmpty();
  }
}

/**
 * Renders transcluded content, or the slot's fallback block.
 *
 * Transclusion itself is still owned by the component mounter, which knows what
 * the parent passed down. This binding owns the position and the fallback, and
 * reports the anchor so the mounter can place content at it.
 */
export class SlotBinding extends RangeBinding {
  /**
   * @param {Text} anchor - The op's anchor.
   * @param {function(object, (object|null)): object} createInstance - Builds a
   *   template instance for a block.
   * @param {object[]} blocks - The program's block table.
   * @param {object|null} locals - The enclosing block's local bindings.
   * @param {function(): void|null} [onBlockMounted] - Announces a mounted block.
   */
  constructor(anchor, createInstance, blocks, locals, onBlockMounted) {
    super(anchor, createInstance, blocks, locals, onBlockMounted);
    /** @type {BlockRange|null} */
    this.fallbackRange = null;
    /** @type {boolean} */
    this.filled = false;
  }

  /**
   * Renders the fallback when nothing has been transcluded into this slot.
   * @param {object} op - The `slot` op.
   */
  update(op) {
    const parent = this.parent;
    if (!parent) return;

    if (this.filled || op.b === undefined) {
      if (this.fallbackRange) {
        this.fallbackRange.dispose();
        this.fallbackRange = null;
      }
      return;
    }

    if (this.fallbackRange) return;
    this.fallbackRange = this.createRange(op.b);
    if (this.fallbackRange) {
      this.mountRange(this.fallbackRange, parent, this.anchor);
    }
  }

  /**
   * Releases the fallback.
   */
  dispose() {
    if (this.fallbackRange) {
      this.fallbackRange.dispose();
      this.fallbackRange = null;
    }
  }
}

/**
 * Writes new local bindings into a mounted instance.
 * @param {object} instance - The instance to update.
 * @param {object} locals - The new bindings.
 * @returns {boolean} True when any bound value changed.
 */
function applyLocals(instance, locals) {
  const target = instance.locals;
  if (!target) return false;

  let changed = false;
  for (const [name, value] of Object.entries(locals)) {
    if (target[name] !== value) {
      target[name] = value;
      changed = true;
    }
  }
  return changed;
}

/**
 * Coerces whatever a list expression returned into an array of elements.
 *
 * Arrays pass through. A `Map` or `Set` iterates. A plain object iterates its
 * own values, which is what `<@for value in someObject>` has always meant.
 * Anything else -- null, a number, a string that was not meant to be iterated
 * -- renders nothing rather than throwing, because a list that has not loaded
 * yet is the normal state of a list, not an error.
 * @param {any} value - The evaluated list expression.
 * @returns {any[]} The elements to render.
 */
function normaliseList(value) {
  if (Array.isArray(value)) return value;
  if (value instanceof Map) return Array.from(value.values());
  if (value instanceof Set) return Array.from(value);
  if (value && typeof value === 'object') return Object.values(value);
  return [];
}

/**
 * Renders a block once its trigger fires.
 *
 * `<@defer>` had two pinned defects, and both were consequences of it living in
 * markup. The deferred content was a `<div data-ax-defer>` the runtime searched
 * for on every update: a component inside it rendered a placeholder that
 * nothing ever mounted, because the mounting pass ran before the content
 * existed and was never told it appeared; and any unrelated state change
 * re-rendered the enclosing template, which replaced the container and threw
 * away the trigger that had been attached to it.
 *
 * A block has neither problem by construction. It is owned by its binding
 * rather than by a diff, so an unrelated update cannot touch it, and mounting
 * is announced through the same hook every other block uses.
 */
export class DeferBinding extends RangeBinding {
  /**
   * @param {Text} anchor - The op's anchor.
   * @param {function(object, (object|null)): object} createInstance - Builds a
   *   template instance for a block.
   * @param {object[]} blocks - The program's block table.
   * @param {object|null} locals - The enclosing block's local bindings.
   * @param {function(): void|null} [onBlockMounted] - Announces a mounted block.
   */
  constructor(anchor, createInstance, blocks, locals, onBlockMounted) {
    super(anchor, createInstance, blocks, locals, onBlockMounted);
    /** @type {BlockRange|null} */
    this.range = null;
    /** @type {BlockRange|null} */
    this.placeholderRange = null;
    /** @type {boolean} */
    this.armed = false;
    /** @type {Array<function(): void>} */
    this.teardowns = [];
  }

  /**
   * Arms the trigger, or renders immediately when an expression trigger is true.
   * @param {object} op - The `defer` op.
   * @param {function(number, (object|null)): any} evaluate - Evaluates an expression index.
   */
  update(op, evaluate) {
    // An expression trigger is the one kind that is reactive: it is evaluated
    // here, inside the op's own effect, so it re-runs when what it reads
    // changes. Every other trigger is a one-time subscription to something
    // outside the reactive graph and is armed exactly once.
    this.#showPlaceholder(op);

    if (op.x !== undefined) {
      if (!this.range && evaluate(op.x, this.locals)) {
        this.#reveal(op);
      }
      return;
    }

    if (this.armed) return;
    this.armed = true;
    this.#arm(op);
  }

  /**
   * Subscribes to the declared trigger.
   * @param {object} op - The `defer` op.
   * @private
   */
  #arm(op) {
    const when = String(op.when || 'idle').toLowerCase();
    const reveal = () => this.#reveal(op);

    // `timer(1500)` and `1500ms` are both documented spellings of the same
    // trigger, and both read their delay from the first number in the value.
    if (when.startsWith('timer(') || when.endsWith('ms')) {
      const digits = when.match(/(\d+)/);
      const handle = setTimeout(reveal, digits ? Number(digits[1]) : 1000);
      this.teardowns.push(() => clearTimeout(handle));
      return;
    }

    if (when === 'visible') {
      this.#armVisible(reveal);
      return;
    }

    if (when === 'interaction' || when === 'hover' || when === 'click') {
      this.#armInteraction(when, reveal);
      return;
    }

    // `idle`, and anything unrecognised. Falling back to idle rather than
    // refusing keeps a typo from leaving content that never appears at all.
    // Read off globalThis rather than named directly: requestIdleCallback is
    // absent in Node, in happy-dom and in Safari before 17, and a bare
    // reference to a missing global is a ReferenceError rather than undefined.
    const idle = globalThis.requestIdleCallback;
    if (typeof idle === 'function') {
      const handle = idle(reveal);
      this.teardowns.push(() => {
        const cancel = globalThis.cancelIdleCallback;
        if (typeof cancel === 'function') cancel(handle);
      });
      return;
    }
    const handle = setTimeout(reveal, 0);
    this.teardowns.push(() => clearTimeout(handle));
  }

  /**
   * Reveals when the anchor's position scrolls into view.
   * @param {function(): void} reveal - Renders the block.
   * @private
   */
  #armVisible(reveal) {
    // The placeholder marks where the content will be, so that is what is
    // watched. Watching the anchor's parent would watch whatever element
    // happens to enclose the directive -- often the page root, which is
    // intersecting from the first frame, and the content would appear
    // immediately however far down the page it sat.
    const target = this.#observableTarget();
    if (!target || typeof IntersectionObserver !== 'function') {
      // Without an observer the honest choice is to show the content rather
      // than hide it behind a capability the host does not have.
      reveal();
      return;
    }
    const observer = new IntersectionObserver((entries) => {
      if (entries.some((entry) => entry.isIntersecting)) {
        observer.disconnect();
        reveal();
      }
    });
    observer.observe(target);
    this.teardowns.push(() => observer.disconnect());
  }

  /**
   * The element whose position stands for the deferred content's position.
   * @returns {Element|null} The placeholder's first element, or the anchor's parent.
   * @private
   */
  #observableTarget() {
    if (this.placeholderRange) {
      const element = this.placeholderRange.nodes.find((node) => node.nodeType === 1);
      if (element) return element;
    }
    return this.parent;
  }

  /**
   * Reveals on the first pointer interaction with the surrounding element.
   * @param {string} when - The trigger name.
   * @param {function(): void} reveal - Renders the block.
   * @private
   */
  #armInteraction(when, reveal) {
    // The placeholder is what the user sees and therefore what they interact
    // with; the anchor's parent is only the fallback when none was declared.
    const parent = this.#observableTarget();
    if (!parent || typeof parent.addEventListener !== 'function') {
      reveal();
      return;
    }
    const events = when === 'hover' ? ['mouseenter'] : when === 'click' ? ['click'] : ['click', 'mouseenter'];
    const listener = () => {
      for (const name of events) parent.removeEventListener(name, listener);
      reveal();
    };
    for (const name of events) parent.addEventListener(name, listener);
    this.teardowns.push(() => {
      for (const name of events) parent.removeEventListener(name, listener);
    });
  }

  /**
   * Renders the deferred block, once.
   * @param {object} op - The `defer` op.
   * @private
   */
  #reveal(op) {
    if (this.range) return;
    const parent = this.parent;
    if (!parent) return;

    // The placeholder goes first, so the deferred content lands at the anchor
    // rather than after whatever the placeholder left behind.
    if (this.placeholderRange) {
      this.placeholderRange.dispose();
      this.placeholderRange = null;
    }

    this.range = this.createRange(op.b);
    if (this.range) {
      this.mountRange(this.range, parent, this.anchor);
    }
  }

  /**
   * Renders the `<@placeholder>` block, if the directive declared one.
   * @param {object} op - The `defer` op.
   * @private
   */
  #showPlaceholder(op) {
    if (this.range || this.placeholderRange || op.ph === undefined) return;
    const parent = this.parent;
    if (!parent) return;
    this.placeholderRange = this.createRange(op.ph);
    if (this.placeholderRange) {
      this.mountRange(this.placeholderRange, parent, this.anchor);
    }
  }

  /**
   * Releases the trigger and the rendered block.
   */
  dispose() {
    for (const teardown of this.teardowns) teardown();
    this.teardowns = [];
    if (this.placeholderRange) {
      this.placeholderRange.dispose();
      this.placeholderRange = null;
    }
    if (this.range) {
      this.range.dispose();
      this.range = null;
    }
  }
}