import { DomPatcher } from './domPatch.js';
import { deriveScope } from '../reactive/scopeProxy.js';
import { stampScope, clearScope, findScope } from './bindingScope.js';
import { logger } from '../runtime/AvenxLogger.js';
import { AvenxErrorCodes, formatMessage } from '../runtime/AvenxError.js';
import { unescapeTemplateMarkers } from '../utils/templateUtils.js';
/**
* Handles efficient rendering of lists by managing DOM fragments and performing keyed diffing.
*/
export class ListManager {
/**
* The last list rendered per template, used to skip a pass that cannot have
* changed anything. Only populated meaningfully for lists of primitives --
* see the note in #updateList.
* @type {WeakMap<Element, {listRef: any, items: any[], primitives: boolean}>}
*/
#listCache = new WeakMap();
/** @type {WeakMap<HTMLTemplateElement, {listRef: Array, items: Array}>} */
/** @type {WeakMap<HTMLTemplateElement, Array<Element>>} */
#nodePool = new WeakMap();
/**
* @param {DynamicEvaluator} evaluator - The expression evaluator.
* @param {TemplateRenderer} renderer - The template renderer.
* @param {EventBinder} [eventBinder] - The event binder to unbind removed elements.
* @param {string} [componentName] - The component name.
*/
constructor(evaluator, renderer, eventBinder, componentName) {
this.evaluator = evaluator;
this.renderer = renderer;
this.eventBinder = eventBinder;
this.componentName = componentName;
this.patcher = new DomPatcher();
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
this.parserDiv = document.createElement('div');
}
}
/**
* Processes all template-based lists within a root element.
* @param {Element} root - The root element to search in.
* @param {object} scope - The evaluation scope.
* @param {object} state - The component state.
* @param {object} [app] - The application context.
*/
process(root, scope, state, app) {
const templates = root.querySelectorAll('template[data-ax-for]');
templates.forEach((template) => {
let parent = template.parentNode;
let insideSlot = false;
while (parent) {
if (parent.nodeName === 'SLOT' && parent.hasAttribute && parent.hasAttribute('data-avenx-transcluded')) {
insideSlot = true;
break;
}
parent = parent.parentNode;
}
if (insideSlot) {
return;
}
// A list nested inside another list's row belongs to that row, not to the
// component: `<@for row in group.rows>` can only be evaluated in a scope
// that binds `group`. The row that owns it drives it, with its own scope,
// as part of building itself.
//
// Compared by identity against the scope this pass was given, not merely
// tested for presence: when the owning row *is* this pass, the template is
// exactly the one to process, and skipping on presence alone meant a row
// refused to render the list it owned.
const owning = findScope(template.parentNode);
if (owning && owning !== scope) {
return;
}
this.#updateList(template, scope, state, app);
});
}
/**
* Updates a specific list based on its template and current state.
* @param {HTMLTemplateElement} template - The list template.
* @param {object} scope - The evaluation scope.
* @param {object} state - The component state.
* @param {object} [app] - The application context.
* @private
*/
#updateList(template, scope, state, app) {
const listExpr = template.getAttribute('data-ax-for');
const itemVar = template.getAttribute('data-ax-as');
const keyExpr = template.getAttribute('data-ax-key');
let list;
try {
list = this.evaluator.evaluateExpression(listExpr, scope, state);
} catch (e) {
logger.warn(
formatMessage(AvenxErrorCodes.RENDER_LIST_EVALUATION_FAILED, listExpr, e.message || e, this.componentName || 'AnonymousComponent')
);
return;
}
let normalizedList = [];
if (list != null) {
if (Array.isArray(list)) {
normalizedList = list;
} else if (typeof list === 'number') {
if (list > 0 && Number.isInteger(list)) {
normalizedList = new Array(list).fill(0).map((_, i) => i);
}
} else if (list instanceof Map) {
normalizedList = Array.from(list.entries());
} else if (list instanceof Set) {
normalizedList = Array.from(list.values());
} else if (typeof list[Symbol.iterator] === 'function') {
normalizedList = Array.from(list);
} else if (typeof list === 'object') {
normalizedList = Object.entries(list);
} else {
logger.warn(formatMessage(AvenxErrorCodes.RENDER_LIST_INVALID_SOURCE, this.componentName || 'AnonymousComponent', listExpr));
}
}
list = normalizedList;
// Identity memo, applied only where identity actually decides the answer.
//
// Comparing element references is sound for a list of primitives: if the
// array, its length and every value are unchanged, nothing a template can
// read has changed. It is not sound for a list of objects, because
// reactive state is mutated in place -- `items[1].active = true` leaves
// every reference intact while changing what the row renders. The memo
// used to apply to both, so a directive bound to a mutated item's own
// property silently stopped updating. It went unnoticed because the
// previous evaluator handed back a freshly wrapped array each time, so the
// reference check rarely matched at all.
const cached = this.#listCache.get(template);
if (
cached &&
cached.primitives &&
cached.listRef === list &&
cached.items.length === list.length &&
cached.items.every((item, i) => item === list[i])
) {
return;
}
const destructureMatch = itemVar.match(/^\[\s*(\w+)(?:\s*,\s*(\w+))?\s*\]$/);
const rawItems = list.map((item, index) => {
// Derived rather than spread: spreading a scope reads every name it can
// enumerate, which for a reactive scope means evaluating every state key
// and computed value once per list item.
const bindings = { index };
if (destructureMatch) {
if (destructureMatch[1]) bindings[destructureMatch[1]] = item[0];
if (destructureMatch[2]) bindings[destructureMatch[2]] = item[1];
} else {
bindings[itemVar] = item;
}
const itemScope = deriveScope(scope, bindings);
let key = index;
if (keyExpr) {
try {
key = this.evaluator.evaluateExpression(keyExpr, itemScope, state);
} catch (e) {
logger.warn(
`[AVX_W19] Failed to evaluate key expression "${keyExpr}" in component <${this.componentName || 'AnonymousComponent'}>: ${e.message || e}`
);
}
}
return { item, key: String(key), itemScope, index };
});
if (rawItems.length === 0) {
let emptyTemplate = template._emptyTemplate;
if (emptyTemplate === undefined) {
emptyTemplate = null;
let next = template.nextElementSibling;
while (next) {
if (next.nodeName === 'TEMPLATE' && next.hasAttribute('data-ax-empty')) {
emptyTemplate = next;
break;
}
if (next.hasAttribute('data-ax-list-item')) {
next = next.nextElementSibling;
continue;
}
break;
}
template._emptyTemplate = emptyTemplate;
}
if (emptyTemplate) {
rawItems.push({
item: null,
key: '__AVENX_EMPTY__',
itemScope: scope,
index: 0,
isEmptyBlock: true,
customTemplate: unescapeTemplateMarkers(emptyTemplate.innerHTML)
});
}
}
const keyCounts = {};
for (const entry of rawItems) {
keyCounts[entry.key] = (keyCounts[entry.key] || 0) + 1;
}
const warnedKeys = new Set();
const nextItems = rawItems.map((entry) => {
let finalKey = entry.key;
if (keyCounts[entry.key] > 1) {
if (!warnedKeys.has(entry.key)) {
logger.warn(
formatMessage(AvenxErrorCodes.RENDER_LIST_DUPLICATE_KEY, entry.key, listExpr)
);
warnedKeys.add(entry.key);
}
finalKey = `${entry.key}_${entry.index}`;
}
return { item: entry.item, key: finalKey, itemScope: entry.itemScope, isEmptyBlock: entry.isEmptyBlock, customTemplate: entry.customTemplate };
});
// 1. Double-ended list diffing: common prefix and common suffix matching
const currentItemsMap = this.#getCurrentItems(template);
const oldChildren = Array.from(currentItemsMap.values());
const itemTemplate = unescapeTemplateMarkers(template.innerHTML);
let i = 0;
let e1 = oldChildren.length - 1;
let e2 = nextItems.length - 1;
// 1.1 Sync Head (Common Prefix)
while (i <= e1 && i <= e2) {
const oldChild = oldChildren[i];
const nextItem = nextItems[i];
const oldKey = oldChild.getAttribute('data-ax-key-val');
if (oldKey === nextItem.key) {
this.#createOrPatchItem(nextItem, oldChild, itemTemplate, scope, state, app, template);
i++;
} else {
break;
}
}
// 1.2 Sync Tail (Common Suffix)
while (i <= e1 && i <= e2) {
const oldChild = oldChildren[e1];
const nextItem = nextItems[e2];
const oldKey = oldChild.getAttribute('data-ax-key-val');
if (oldKey === nextItem.key) {
this.#createOrPatchItem(nextItem, oldChild, itemTemplate, scope, state, app, template);
e1--;
e2--;
} else {
break;
}
}
// 1.3 Additions only (common prefix/suffix covered all old items)
if (i > e1) {
if (i <= e2) {
const anchor = e2 + 1 < nextItems.length ? currentItemsMap.get(nextItems[e2 + 1].key) : null;
let lastEl = i > 0 ? currentItemsMap.get(nextItems[i - 1].key) : template;
for (let k = i; k <= e2; k++) {
const newEl = this.#createOrPatchItem(nextItems[k], null, itemTemplate, scope, state, app, template);
if (anchor) {
this.#insertNodeBefore(newEl, anchor, lastEl);
} else {
this.#insertNodeAfter(newEl, lastEl);
}
lastEl = newEl;
}
}
}
// 1.4 Deletions only (common prefix/suffix covered all new items)
else if (i > e2) {
while (i <= e1) {
this.#removeItem(oldChildren[i], template, app);
i++;
}
}
// 1.5 General case (unknown sequence in middle): use LIS algorithm to minimize moves
else {
const s1 = i;
const s2 = i;
const toBePatched = e2 - s2 + 1;
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
const keyToNewIndexMap = new Map();
for (let k = s2; k <= e2; k++) {
keyToNewIndexMap.set(nextItems[k].key, k);
}
let patchedCount = 0;
let moved = false;
let maxNewIndexSoFar = 0;
const patchedElements = new Map();
for (let k = s1; k <= e1; k++) {
const prevChild = oldChildren[k];
const prevKey = prevChild.getAttribute('data-ax-key-val');
if (patchedCount >= toBePatched) {
this.#removeItem(prevChild, template, app);
continue;
}
const newIndex = keyToNewIndexMap.get(prevKey);
if (newIndex === undefined) {
this.#removeItem(prevChild, template, app);
} else {
newIndexToOldIndexMap[newIndex - s2] = k + 1;
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
moved = true;
}
const nextItem = nextItems[newIndex];
const patchedEl = this.#createOrPatchItem(
nextItem,
prevChild,
itemTemplate,
scope,
state,
app,
template
);
patchedElements.set(nextItem.key, patchedEl);
patchedCount++;
}
}
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
let j = increasingNewIndexSequence.length - 1;
for (let k = toBePatched - 1; k >= 0; k--) {
const nextIndex = s2 + k;
const nextItem = nextItems[nextIndex];
const anchor =
nextIndex + 1 < nextItems.length
? currentItemsMap.get(nextItems[nextIndex + 1].key) || patchedElements.get(nextItems[nextIndex + 1].key)
: null;
const lastEl =
nextIndex > 0
? currentItemsMap.get(nextItems[nextIndex - 1].key) || patchedElements.get(nextItems[nextIndex - 1].key)
: template;
if (newIndexToOldIndexMap[k] === 0) {
const newEl = this.#createOrPatchItem(nextItem, null, itemTemplate, scope, state, app, template);
patchedElements.set(nextItem.key, newEl);
if (anchor) {
this.#insertNodeBefore(newEl, anchor, lastEl);
} else {
this.#insertNodeAfter(newEl, lastEl);
}
} else if (moved) {
if (j < 0 || k !== increasingNewIndexSequence[j]) {
const el = patchedElements.get(nextItem.key);
if (anchor) {
this.#insertNodeBefore(el, anchor, lastEl);
} else {
this.#insertNodeAfter(el, lastEl);
}
} else {
j--;
}
}
}
}
this.#listCache.set(template, {
listRef: list,
items: [...list],
primitives: list.every((item) => item === null || (typeof item !== 'object' && typeof item !== 'function')),
});
}
/**
* Helper to create a new item element or patch an existing element in-place.
* @param {object} nextItem - Next item metadata object.
* @param {Element|null} existingElement - Existing DOM element to patch.
* @param {string} itemTemplate - Rendered template HTML string.
* @param {object} scope - Evaluation scope.
* @param {object} state - Component state.
* @param {object} [app] - Application context.
* @param {HTMLTemplateElement} template - List template.
* @returns {Element} The created or patched element.
* @private
*/
#createOrPatchItem(nextItem, existingElement, itemTemplate, scope, state, app, template) {
const { key, itemScope, isEmptyBlock, customTemplate } = nextItem;
const resolver = (expr) => this.evaluator.evaluateExpression(expr, itemScope, state);
const targetTemplate = isEmptyBlock ? customTemplate : itemTemplate;
const html = this.renderer.render(targetTemplate, resolver).trim();
let newElement = null;
if (this.parserDiv) {
this.parserDiv.innerHTML = html;
newElement = this.parserDiv.firstElementChild;
} else if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
const temp = document.createElement('div');
temp.innerHTML = html;
newElement = temp.firstElementChild;
}
if (newElement) {
newElement = this.patcher.cleanElement(newElement);
newElement.setAttribute('data-ax-list-item', '');
newElement.setAttribute('data-ax-key-val', key);
}
let element = existingElement;
if (element) {
if (newElement) {
let needsPatch = element.outerHTML !== newElement.outerHTML;
if (!needsPatch && hasDirectivesHelper(element)) {
needsPatch = true;
}
if (needsPatch) {
this.patcher.patchElement(element, newElement, resolver, app);
}
}
} else {
const pool = this.#nodePool.get(template);
const recycledElement = pool ? pool.pop() : null;
if (recycledElement && newElement) {
this.patcher.patchElement(recycledElement, newElement, resolver, app);
element = recycledElement;
this.patcher.triggerEnter(element, resolver);
} else if (newElement) {
element = newElement;
this.patcher.applyDirectives(element, resolver, app);
this.patcher.triggerEnter(element, resolver);
}
}
if (this.parserDiv) {
this.parserDiv.innerHTML = '';
}
// The row remembers the scope it was rendered under, so a handler attached
// to it resolves `item` and `index` when it fires rather than against
// whatever the component scope happens to hold. Stamped here, after every
// branch, because `element` may be a recycled node that held a different
// item a moment ago -- the answer has to be the item it holds now.
stampScope(element, itemScope);
// A list inside this row is this row's to drive, because only this scope
// binds the loop variable its expression names. Recursing here rather than
// re-scanning from the top is also what makes the depth work out: each
// level is processed by the level that created it, in the scope it created.
if (element && typeof element.querySelector === 'function' && element.querySelector('template[data-ax-for]')) {
this.process(element, itemScope, state, app);
}
return element;
}
/**
* Helper to remove a list item element and recycle it into the node pool.
* @param {Element} element - Element to remove.
* @param {HTMLTemplateElement} template - List template.
* @param {object} [app] - Application context.
* @private
*/
#removeItem(element, template, app) {
if (this.eventBinder) {
this.eventBinder.unbind(element);
}
this.patcher.triggerLeave(element, null, () => {
// Cleared on the way into the pool. A pooled node is re-stamped before it
// is used again, but until then it must not be able to hand a stale item
// to anything that walks up to it.
clearScope(element);
this.#resetNodeState(element);
element.remove();
let pool = this.#nodePool.get(template);
if (!pool) {
pool = [];
this.#nodePool.set(template, pool);
}
pool.push(element);
}, app);
}
/**
* Helper to insert a DOM node after a target element.
* @param {Element} node - Node to insert.
* @param {Element} target - Target element to insert after.
* @private
*/
#insertNodeAfter(node, target) {
if (!node || !target) return;
if (typeof target.after === 'function') {
target.after(node);
} else if (target.parentNode) {
if (target.nextSibling && typeof target.parentNode.insertBefore === 'function') {
target.parentNode.insertBefore(node, target.nextSibling);
} else if (typeof target.parentNode.appendChild === 'function') {
target.parentNode.appendChild(node);
}
}
}
/**
* Helper to insert a DOM node before an anchor node, or after a fallback element.
* @param {Element} node - Node to insert.
* @param {Element|null} anchor - Anchor node to insert before.
* @param {Element} fallbackLast - Fallback element to insert after if anchor is missing.
* @private
*/
#insertNodeBefore(node, anchor, fallbackLast) {
if (!node) return;
if (anchor && anchor.parentNode) {
if (typeof anchor.parentNode.insertBefore === 'function') {
anchor.parentNode.insertBefore(node, anchor);
return;
}
if (typeof anchor.before === 'function') {
anchor.before(node);
return;
}
if (anchor.previousElementSibling && typeof anchor.previousElementSibling.after === 'function') {
anchor.previousElementSibling.after(node);
return;
}
}
if (fallbackLast && typeof fallbackLast.after === 'function') {
fallbackLast.after(node);
}
}
/**
* Resets element state like focus, selection, and inputs.
* @param {Element} element - The element to reset.
* @private
*/
#resetNodeState(element) {
if (typeof document !== 'undefined' && document.activeElement &&
(element === document.activeElement || element.contains(document.activeElement))) {
if (typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
}
if (typeof window !== 'undefined' && window.getSelection) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
try {
const range = selection.getRangeAt(0);
if (element.contains(range.commonAncestorContainer)) {
selection.removeAllRanges();
}
} catch {
// Ignore
}
}
}
const inputs = [];
['input', 'textarea', 'select'].forEach((tag) => {
const found = element.querySelectorAll(tag);
if (found && found.forEach) {
found.forEach((el) => inputs.push(el));
}
});
inputs.forEach((input) => {
if (input.tagName === 'INPUT') {
const type = input.getAttribute('type');
if (type === 'checkbox' || type === 'radio') {
input.checked = false;
} else {
input.value = '';
if (typeof input.setSelectionRange === 'function') {
try {
input.setSelectionRange(0, 0);
} catch {
// Ignore
}
}
}
} else if (input.tagName === 'TEXTAREA') {
input.value = '';
if (typeof input.setSelectionRange === 'function') {
try {
input.setSelectionRange(0, 0);
} catch {
// Ignore
}
}
} else if (input.tagName === 'SELECT') {
input.selectedIndex = -1;
}
});
}
/**
* Retrieves currently rendered items for a template by scanning subsequent siblings.
* @param {HTMLTemplateElement} template - The template.
* @returns {Map<string, Element>}
* @private
*/
#getCurrentItems(template) {
const items = new Map();
let current = template.nextElementSibling;
while (current && current.hasAttribute('data-ax-list-item')) {
if (!current._isLeaving) {
const key = current.getAttribute('data-ax-key-val');
items.set(key, current);
}
current = current.nextElementSibling;
}
return items;
}
}
/**
* Helper to check if an element or its descendants have custom directives.
* @param {Element} el
* @returns {boolean}
*/
function hasDirectivesHelper(el) {
if (!el || el.nodeType !== 1) return false;
const checkAttrs = (node) => {
if (!node.attributes) return false;
for (const attr of node.attributes) {
const name = attr.name;
if (
name.startsWith('data-ax-') &&
name !== 'data-ax-static' &&
name !== 'data-ax-list-item' &&
name !== 'data-ax-key-val'
) {
return true;
}
}
return false;
};
if (checkAttrs(el)) return true;
if (typeof el.querySelectorAll === 'function') {
const descendants = el.querySelectorAll('*');
for (const desc of descendants) {
if (checkAttrs(desc)) return true;
}
}
return false;
}
/**
* Calculates the Longest Increasing Subsequence (LIS) of an array of numbers.
* Returns an array of indices of the LIS in `arr`.
* Uses binary search + parent tracking for O(N log N) complexity.
* @param {number[]} arr
* @returns {number[]} Array of indices in arr that form the LIS.
*/
function getSequence(arr) {
const p = arr.slice();
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}