import { AvenxComponent } from './AvenxComponent.js';
import { logger } from './AvenxLogger.js';
import { AvenxErrorCodes, formatMessage } from './AvenxError.js';
import { queueFlushCallback } from '../reactive/scheduler.js';
/**
* AvenxPage is a specialized component that can host child components.
* It automatically mounts child components defined in its template via [data-avenx-comp].
*/
export class AvenxPage extends AvenxComponent {
/** @type {Map<string, Function>} */
#componentRegistry;
/** @type {Map<Element, AvenxComponent>} */
#childComponents = new Map();
/**
* Set while a child-prop reconciliation is already queued for this flush.
* @type {boolean}
*/
#childPropsQueued = false;
/**
* @param {object} initialState - Initial state.
* @param {object} computed - Computed properties.
* @param {object} bridges - Shared bridges.
* @param {string} template - HTML template.
* @param {object} methods - Component methods.
* @param {Map<string, Function>} componentRegistry - Registry of available components.
* @param {object} props - Component properties.
* @param {object} styles - Component CSS variables.
* @param {object} [resources] - Reactive resources.
* @param {object} [options] - Component options and compiler contracts.
*/
constructor(
initialState = {},
computed = {},
bridges = {},
template = '',
methods = {},
componentRegistry = new Map(),
props = {},
styles = {},
resources = {},
options = {},
) {
super(initialState, computed, bridges, template, methods, props, styles, resources, options);
this.#componentRegistry = componentRegistry;
}
/**
* Updates the page and then mounts/updates child components.
*
* The child pass runs inside {@link AvenxPage#runUpdate}, not here, so that
* the state a `data-props-*` expression reads is collected by the page's
* render watcher. Mounting children after `super.update()` returned put
* those reads outside the watcher, leaving the page with no dependency on
* the state it passes down -- which only worked while every write scheduled
* a re-render regardless of what read it.
*/
update() {
super.update();
this.#mountChildComponents();
}
/**
* Renders the page, and registers the state its children's props depend on.
*
* Mounting children stays outside the render watcher, in {@link
* AvenxPage#update}: it *writes* to each child's props, and doing that inside
* the watcher made the page depend on its children's props, so a child
* setting its own prop re-ran the parent and overwrote the value.
*
* The reads still have to be inside the watcher, though, or the page has no
* dependency on the state it passes down. `data-props-*` expressions are
* therefore evaluated here for their dependencies and the results discarded.
* That only mattered once unobserved writes stopped scheduling a render;
* before, every write re-rendered every component regardless.
* @override
*/
runUpdate() {
super.runUpdate();
this.#trackChildPropDependencies();
}
/**
* Evaluates child `data-props-*` expressions so the page's render watcher
* records what they read. Results are discarded.
* @private
*/
#trackChildPropDependencies() {
const root = this.$element;
if (!root || typeof root.querySelectorAll !== 'function') {
return;
}
// Queried separately rather than with a comma selector: this runs against
// whatever DOM the host provides, and a selector list is one of the first
// things a minimal implementation leaves out.
const elements = [
...Array.from(root.querySelectorAll('[data-avenx-comp]')),
...Array.from(root.querySelectorAll('[data-avenx-comp-dynamic]')),
];
if (root.hasAttribute && (root.hasAttribute('data-avenx-comp') || root.hasAttribute('data-avenx-comp-dynamic'))) {
elements.unshift(root);
}
for (const el of elements) {
if (!el.attributes) continue;
// A compiled mount point has no prop attributes left to read, and needs
// none: each prop is already a tracked effect of its own, which is a
// strictly finer dependency than the page-wide one this pass records.
if (el.__axProps) continue;
for (const attr of el.attributes) {
if (!attr.name.startsWith('data-props-') && attr.name !== 'data-avenx-comp-dynamic') {
continue;
}
try {
this._evaluate(attr.value);
} catch {
// A prop expression that throws is reported when it is used for
// real, in #mountChildComponents. This pass exists only to collect
// dependencies and must never change what the page reports.
}
}
}
}
/**
* Pushes changed compiled props into already-mounted children.
*
* On the compiled path each `data-props-*` expression is its own reactive
* effect, so a prop that changed says so directly instead of the page
* re-evaluating every prop of every child whenever anything changed.
*
* Coalesced to once per flush: several props of several children can change
* in one tick, and the mount pass reconciles all of them in one go.
* @override
*/
__onChildPropsChanged() {
if (this.#childPropsQueued) return;
this.#childPropsQueued = true;
queueFlushCallback(() => {
this.#childPropsQueued = false;
if (this.$isUnmounted) return;
this.#mountChildComponents();
});
}
/**
* Unmounts the page and all child components.
*/
unmount() {
const promises = [];
for (const compInstance of this.#childComponents.values()) {
if (typeof compInstance.unmount === 'function') {
const res = compInstance.unmount();
if (res instanceof Promise) {
promises.push(res);
}
}
}
this.#childComponents.clear();
const superRes = super.unmount();
if (superRes instanceof Promise) {
promises.push(superRes);
}
if (promises.length > 0) {
return Promise.all(promises).then(() => {});
}
}
/**
* Finds all mount points for child components and initializes or updates them.
* @private
*/
#mountChildComponents() {
const root = this._getElement();
if (!root) return;
const mountPoints = [
...Array.from(root.querySelectorAll('[data-avenx-comp]')),
...Array.from(root.querySelectorAll('[data-avenx-comp-dynamic]')),
];
const currentElements = new Set(mountPoints);
// 1. Clean up/unmount child components whose elements are no longer in the DOM/page
for (const [el, compInstance] of this.#childComponents.entries()) {
if (!currentElements.has(el) || !root.contains(el)) {
if (typeof compInstance.unmount === 'function') {
compInstance.unmount();
}
this.#childComponents.delete(el);
}
}
// 2. Instantiate new components or update existing ones
const registry = this.#getRegistry();
mountPoints.forEach((el) => {
let CompClass = null;
let compName = null;
if (el.hasAttribute('data-avenx-comp')) {
compName = el.getAttribute('data-avenx-comp');
CompClass = registry.get(compName);
} else if (el.hasAttribute('data-avenx-comp-dynamic')) {
const dynamicExpr = el.getAttribute('data-avenx-comp-dynamic');
try {
let resolvedVal = this._evaluate(dynamicExpr);
if (resolvedVal && resolvedVal[Symbol.for('rawTarget')]) {
resolvedVal = resolvedVal[Symbol.for('rawTarget')];
}
if (typeof resolvedVal === 'string') {
compName = resolvedVal;
CompClass = registry.get(resolvedVal);
} else if (typeof resolvedVal === 'function') {
CompClass = resolvedVal;
compName = resolvedVal.name;
}
} catch (e) {
logger.warn(
formatMessage(AvenxErrorCodes.PAGE_PROP_EVALUATION_FAILED, dynamicExpr, e.message || e)
);
}
}
if (CompClass) {
// Props come from whichever mechanism owns them.
//
// On the compiled path the parent's program evaluated each
// `data-props-*` expression as its own reactive effect and left the
// result on the element, so the attributes are gone and re-evaluating
// them here would find nothing. On the string path they are still
// attributes holding expression source, evaluated per render.
//
// Checked per element rather than per page: a page can carry a program
// while a child mount point added by other means does not.
const props = el.__axProps ? { ...el.__axProps } : {};
if (!el.__axProps) {
for (const attr of el.attributes) {
if (attr.name.startsWith('data-props-')) {
const propName = attr.name.slice('data-props-'.length);
try {
props[propName] = this._evaluate(attr.value);
} catch (e) {
logger.warn(
formatMessage(AvenxErrorCodes.PAGE_PROP_EVALUATION_FAILED, attr.value, e.message || e)
);
}
}
}
}
if (this.#childComponents.has(el)) {
const compInstance = this.#childComponents.get(el);
if (compInstance.constructor === CompClass) {
if (typeof compInstance.setProps === 'function') {
compInstance.setProps(props);
} else if (typeof compInstance.update === 'function') {
compInstance.update();
}
} else {
// Component class changed! Unmount the old one and mount the new one
if (typeof compInstance.unmount === 'function') {
compInstance.unmount();
}
// Clear content of el before mounting new one to avoid merge issues
el.innerHTML = '';
const newInstance = new CompClass(this._getBridges(), props);
newInstance.$parent = this;
newInstance.mount(el);
this.#childComponents.set(el, newInstance);
}
} else {
const compInstance = new CompClass(this._getBridges(), props);
compInstance.$parent = this;
compInstance.mount(el);
this.#childComponents.set(el, compInstance);
}
} else {
// If it was dynamic and is now null/undefined/unresolved, we should unmount any existing component
if (this.#childComponents.has(el)) {
const compInstance = this.#childComponents.get(el);
if (typeof compInstance.unmount === 'function') {
compInstance.unmount();
}
this.#childComponents.delete(el);
el.innerHTML = '';
}
if (compName) {
logger.warn(formatMessage(AvenxErrorCodes.PAGE_COMPONENT_NOT_REGISTERED, compName));
}
}
});
}
/**
* Retrieves the component registry.
* @returns {Map<string, Function>}
* @protected
*/
_getComponentRegistry() {
return this.#componentRegistry;
}
/**
* Resolves the component registry dynamically by traversing parent page instances.
* @returns {Map<string, Function>}
* @private
*/
#getRegistry() {
if (this.#componentRegistry instanceof Map) {
return this.#componentRegistry;
}
const root = this._getElement();
if (root) {
let parentEl = root.parentNode;
while (parentEl) {
if (
parentEl.__avenx_comp_instance &&
typeof parentEl.__avenx_comp_instance._getComponentRegistry === 'function'
) {
const reg = parentEl.__avenx_comp_instance._getComponentRegistry();
if (reg instanceof Map) {
return reg;
}
}
parentEl = parentEl.parentNode;
}
}
return new Map();
}
}