import { ComputedRegistry } from '../reactive/createComputed.js';
import { styleMountManager } from './StyleMountManager.js';
import { EventBinder } from '../events/bindEvents.js';
import { EventExecutor, buildEventNode } from '../events/eventExecutor.js';
import { DynamicEvaluator } from '../security/evaluator.js';
import { LifecycleManager } from './lifecycle.js';
import { ComponentScope } from './ComponentScope.js';
import { stampScope } from '../renderer/bindingScope.js';
import { DeadlockManager } from '../renderer/deadlockManager.js';
import { AvenxError, AvenxErrorCodes, formatMessage } from './AvenxError.js';
import { logger } from './AvenxLogger.js';
import { queueJob, queueFlushCallback, nextTick as schedulerNextTick } from '../reactive/scheduler.js';
import { TemplateInstance } from '../renderer/program/TemplateInstance.js';
import { isProgram } from '../../compiler/render/program.js';
import { AvenxWatcher, activeWatcher } from '../reactive/watcher.js';
import { DisposalScope, runInScope } from '../reactive/scope.js';
import { Resource } from '../reactive/Resource.js';
import { ProxyHandlerFactory, RAW_SYMBOL, toRaw } from '../reactive/proxyHandler.js';
import { processBindDirectives } from '../utils/templateUtils.js';
import { profile } from '../utils/profiler.js';
import { parseValidationRules, validateValue, getFieldName, updateValidationState } from '../validation/validator.js';
import { serializeSafe } from '../tooling/inspect.js';
import { requireStringRenderer, hasStringRenderer } from '../renderer/stringRenderer.js';
import { tracer } from '../trace/tracer.js';
import { TraceNodeType } from '../trace/schema.js';
import { traceDerived } from '../trace/derived.js';
export const RESERVED_INSTANCE_KEYS = [
'mount',
'unmount',
'update',
'destroy',
'scheduleUpdate',
'onBeforeMount',
'onMount',
'onBeforeUpdate',
'onUpdate',
'onUnmount',
'onActivate',
'onDeactivate',
'onErrorCaptured',
];
const globalMixins = [];
let componentUid = 0;
/**
* Base class for all Avenx components.
* Manages state, reactivity, rendering, and lifecycle.
*/
export class AvenxComponent {
/** @type {Element|null} */
#element = null;
/** @type {string} */
#template = '';
/** @type {object} */
#methods = {};
/** @type {object} */
#bridges = {};
/**
* Values the component's module imported, keyed by their local name.
*
* Template expressions and action bodies are evaluated against a declared
* scope rather than by the JavaScript engine, so a module-level `import` is
* not visible to them by default. Before the bundler that was moot -- the
* compiler deleted every non-runtime import, so there was nothing to see.
* Now that an import resolves and ships, it has to be reachable from the code
* that asked for it, or the import is decoration.
*
* They sit at the bottom of the scope, below bridges, so nothing imported can
* shadow a `<state>` key, a computed value, an action or a bridge.
* @type {object}
*/
#imports = {};
/**
* Whether {@link AvenxComponent##imports} holds anything.
*
* Kept as a flag so building a scope costs no property enumeration, and so a
* component that imports nothing gets exactly the layers it had before.
* @type {boolean}
*/
#hasImports = false;
/** @type {ComputedRegistry} */
#computed;
/** @type {TemplateRenderer|null} */
#rendererInstance = null;
/** @type {DomPatcher|null} */
#patcherInstance = null;
/** @type {ListManager|null} */
#listManagerInstance = null;
/** @type {DeferManager|null} */
#deferManagerInstance = null;
/** @type {DeadlockManager|null} */
#deadlockManagerInstance = null;
/** @type {EventBinder} */
#eventBinder;
/** @type {EventExecutor} */
#eventExecutor;
/** @type {DynamicEvaluator} */
#evaluator;
/** @type {LifecycleManager} */
#lifecycle;
/** @type {boolean} */
#isMounted = false;
/** @type {boolean} */
#isUpdating = false;
/** @type {Set<string>} */
#evaluating = new Set();
/**
* Whether a render has completed, and therefore whether the render watcher's
* dependency set can be trusted to say what this component observes.
* @type {boolean}
*/
#hasRenderedOnce = false;
/** @type {object | null} */
#transcludedGroups = null;
/** @type {boolean} */
#updateQueued = false;
/**
* The names this component's state layer binds, cached.
*
* Invalidated by the state proxy whenever a root key is added or removed.
* Null means "not computed yet".
* @type {string[]|null}
*/
#scope = new ComponentScope(this);
/**
* The compiled render program, when the compiler produced one.
*
* Its presence is what selects the fine-grained renderer. A component whose
* template contains something the program runtime does not implement has no
* program and renders through the string path, unchanged.
* @type {object|null}
*/
#program = null;
/**
* This component's mounted copy of the program.
* @type {TemplateInstance|null}
*/
#templateInstance = null;
/**
* Set once the post-render notification is queued for this flush, so a
* component with 500 bindings fires `onUpdate` once rather than 500 times.
* @type {boolean}
*/
#postRenderQueued = false;
/**
* Set once `onBeforeUpdate` has fired for the flush in progress. Cleared when
* that flush's post-render notification runs.
* @type {boolean}
*/
#beforeRenderFired = false;
/** @type {Function} */
#updateJob = () => {
this.#updateQueued = false;
this.update();
};
/** @type {boolean} */
#isUnmounting = false;
/** @type {Promise<void>|null} */
#unmountPromise = null;
/** @type {boolean} */
#isUnmounted = false;
/** @type {Record<string, Element>} */
#refsCache = {};
/** @type {boolean} */
#refsDirty = false;
/** @type {boolean} */
#isBeforeMounting = false;
/** @type {Object<string, object>} */
#resources = {};
/** @type {Error|null} */
#componentError = null;
/** @type {Set<string>} */
#contracts = new Set();
/**
* @param {object} [initialState] - The initial state of the component.
* @param {object} [computed] - Computed properties definitions.
* @param {object} [bridges] - External bridges accessible to the component.
* @param {string} [template] - The HTML template string.
* @param {object} [methods] - Component methods.
* @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 = {}, props = {}, styles = {}, resources = {}, options = {}) {
this.uid = componentUid++;
this.#updateJob.id = this.uid;
if (options && options.contracts) {
this.#contracts = new Set(options.contracts);
} else if (Array.isArray(options)) {
this.#contracts = new Set(options);
} else if (options instanceof Set) {
this.#contracts = options;
}
// Actions the compiler saw declared `atomic`. Absent for every component
// that declares none, which is why the generated constructor omits the
// options argument entirely in that case.
const optionsObject = options && !Array.isArray(options) && !(options instanceof Set) ? options : null;
if (optionsObject && optionsObject.imports && typeof optionsObject.imports === 'object') {
this.#imports = optionsObject.imports;
this.#hasImports = true;
}
const atomicActions = options && !Array.isArray(options) && !(options instanceof Set) ? options.atomic : null;
// The render program travels in the same options object. Validating the
// shape rather than trusting it keeps a program produced by a different
// build -- restored from a cache, replayed from a trace fixture -- from
// reaching a renderer that would misread it; an unrecognised program means
// the string path, which is slower and always correct.
const compiledProgram =
options && !Array.isArray(options) && !(options instanceof Set) ? options.program : null;
if (isProgram(compiledProgram)) {
this.#program = compiledProgram;
}
/** @type {AvenxComponent|null} */
this.$parent = null;
// Merge global mixins options
const mergedState = {};
const mergedComputed = {};
const mergedMethods = {};
const mergedProps = {};
const mergedStyles = {};
for (const mixin of globalMixins) {
const mixinState = typeof mixin.state === 'function' ? mixin.state.call(this) : mixin.state;
if (mixinState && typeof mixinState === 'object') {
Object.assign(mergedState, mixinState);
}
const mixinData = typeof mixin.data === 'function' ? mixin.data.call(this) : mixin.data;
if (mixinData && typeof mixinData === 'object') {
Object.assign(mergedState, mixinData);
}
if (mixin.computed && typeof mixin.computed === 'object') {
Object.assign(mergedComputed, mixin.computed);
}
if (mixin.methods && typeof mixin.methods === 'object') {
Object.assign(mergedMethods, mixin.methods);
}
if (mixin.props && typeof mixin.props === 'object') {
Object.assign(mergedProps, mixin.props);
}
if (mixin.styles && typeof mixin.styles === 'object') {
Object.assign(mergedStyles, mixin.styles);
}
}
Object.assign(mergedState, initialState);
Object.assign(mergedComputed, computed);
Object.assign(mergedMethods, methods);
Object.assign(mergedProps, props);
Object.assign(mergedStyles, styles);
this._mixinProps = {};
const reservedKeys = ['state', 'data', 'methods', 'computed', 'props', 'styles', 'onBeforeMount', 'onMount', 'onBeforeUpdate', 'onUpdate', 'onUnmount', 'onActivate', 'onDeactivate', 'onErrorCaptured'];
for (const mixin of globalMixins) {
for (const key of Object.keys(mixin)) {
if (!reservedKeys.includes(key)) {
let value = mixin[key];
if (typeof value === 'function') {
value = value.bind(this);
}
if (!(key in this)) {
this[key] = value;
}
this._mixinProps[key] = value;
}
}
}
const isIsolated = this.#contracts.has('isolated');
this.#template = processBindDirectives(template);
this.#bridges = isIsolated ? {} : bridges;
this.#computed = new ComputedRegistry(mergedComputed);
this.#eventBinder = new EventBinder();
// The compiler attaches the closures it generated for this class. Read
// from the constructor rather than passed positionally, because a class
// compiled before expression generation existed simply has neither static
// and gets an evaluator with no table -- which behaves exactly as it did.
this.#evaluator = new DynamicEvaluator({
expressions: this.constructor.__axExprs,
statements: this.constructor.__axStmts,
indexedExpressions: this.constructor.__axProgramExprs,
indexedStatements: this.constructor.__axProgramStmts,
});
this.#lifecycle = new LifecycleManager();
/** @type {AvenxWatcher[]} */
this._watchers = [];
/**
* Owns teardown callbacks created while this component runs its own code
* (lifecycle hooks, actions, event handlers). Bridge subscriptions register
* here and are released in __performTeardown().
* @type {DisposalScope}
*/
this._scope = new DisposalScope(this.constructor.name);
this._stateHandler = new ProxyHandlerFactory({
computedKeys: this.#computed.keys(),
onChange: () => this.scheduleUpdate(),
// The scope's state layer needs the set of names state binds. Computing
// it is `Object.keys()` over the whole state object, and a scope is built
// per expression evaluated -- so on a component with many state keys it
// was the largest single cost in an update, and it grew with the number
// of keys rather than with the change. Cached, and invalidated here,
// which is the only place the answer can change.
onKeysChanged: () => {
this.#scope.invalidateKeys();
},
// A component's onChange means "schedule a render", so it must not fire
// for a write nothing reads. Without this the per-key dependency graph is
// computed and then discarded: changing a state key no expression
// mentions re-rendered the whole component.
//
// Gated on having rendered at least once. Before the first render no
// expression has been evaluated, so every key would look unobserved and
// nothing would ever schedule.
onlyNotifyObserved: () => this.#hasRenderedOnce,
instance: this,
getComputedValue: (key) => {
if (this.#evaluating.has(key)) {
logger.warn(formatMessage(AvenxErrorCodes.COMPUTED_CIRCULAR_DEPENDENCY, key), this.$logContext);
return undefined;
}
this.#evaluating.add(key);
const definition = this.#computed.get(key);
try {
const value =
typeof definition === 'function'
? definition.call(this)
: this.#evaluator.evaluateExpression(definition, this.#createScope(), this.state);
if (tracer.on) {
// Reached only when the computed's watcher is dirty, so this is
// already a re-evaluation rather than a cached read. The watcher
// still holds the value it cached before going dirty, which is the
// honest "from" side of the change — and is what makes the first
// change after a recording starts reportable rather than silently
// absorbed as a baseline.
const watcher = this._stateHandler.computedWatchers.get(key);
traceDerived(
this,
{
name: key,
kind: 'computed',
owner: this.constructor.name,
expression: typeof definition === 'string' ? definition : undefined,
contracts: this.#contracts.size > 0 ? Array.from(this.#contracts) : undefined,
},
value,
watcher && watcher.value !== undefined ? { has: true, value: watcher.value } : undefined,
);
}
return value;
} catch (error) {
if (error && error.code === AvenxErrorCodes.STATE_MUTATION_IN_UPDATE) {
throw error;
}
logger.warn(formatMessage(AvenxErrorCodes.COMPUTED_EVALUTION_FAILED, key, definition, error), this.$logContext);
return undefined;
} finally {
this.#evaluating.delete(key);
}
},
});
this.state = new Proxy(mergedState, this._stateHandler.create());
this._propsHandler = new ProxyHandlerFactory({
onChange: () => this.scheduleUpdate(),
});
this.props = new Proxy(mergedProps, this._propsHandler.create());
this._stylesHandler = new ProxyHandlerFactory({
onChange: () => this.scheduleUpdate(),
});
this.styles = new Proxy(mergedStyles, this._stylesHandler.create());
this.#methods = this.#evaluator.createMethodMap(
mergedMethods,
(executableMethods) => this.#createScope(executableMethods),
() => this.state,
{
owner: this.constructor.name,
kind: 'action',
contracts: Array.from(this.#contracts),
compiled: this.constructor.__axActions,
...(atomicActions ? { atomic: atomicActions } : {}),
},
);
for (const [name, fn] of Object.entries(this.#methods)) {
if (RESERVED_INSTANCE_KEYS.includes(name)) {
logger.warn(
formatMessage(
AvenxErrorCodes.COMPONENT_METHOD_RESERVED_KEY_COLLISION,
name,
this.constructor && this.constructor.name !== 'AvenxComponent' && this.constructor.name !== 'Object'
? this.constructor.name
: 'Component'
),
this.$logContext
);
}
if (!(name in this)) {
this[name] = fn;
}
}
const activeResources = typeof resources === 'object' && resources !== null ? resources : {};
// Ensure $resources object is initialized and available on the instance
this.$resources = this.$resources || {};
for (const [name, resDef] of Object.entries(activeResources)) {
const handler = typeof resDef === 'object' && resDef !== null ? resDef.handler : resDef;
const pollInterval = typeof resDef === 'object' && resDef !== null ? resDef.pollInterval : 0;
let handlerFn = typeof handler === 'function' ? handler : null;
if (!handlerFn) {
const cleanHandler = typeof handler === 'string' && handler.trim().startsWith('return') ? handler : `return ${handler}`;
handlerFn = this.#evaluator.createMethodMap(
{ [name]: cleanHandler },
() => ({
...this.#createScope(),
setTimeout: typeof setTimeout !== 'undefined' ? setTimeout : undefined,
clearTimeout: typeof clearTimeout !== 'undefined' ? clearTimeout : undefined,
setInterval: typeof setInterval !== 'undefined' ? setInterval : undefined,
clearInterval: typeof clearInterval !== 'undefined' ? clearInterval : undefined,
fetch: typeof fetch !== 'undefined' ? fetch : undefined,
Promise: typeof Promise !== 'undefined' ? Promise : undefined,
}),
() => this.state,
{
owner: this.constructor.name,
kind: 'resource',
contracts: Array.from(this.#contracts),
compiled: this.constructor.__axResources,
},
)[name];
}
const resInstance = new Resource(name, handlerFn, this, { pollInterval });
this.#resources[name] = resInstance;
this.$resources[name] = resInstance; // Expose imperative controls and status accessors
Object.defineProperty(this, name, {
get: () => this.#resources[name].read(),
enumerable: true,
configurable: true
});
}
// Process declarative watch options from mixins and initialState options
const mergedWatch = {};
for (const mixin of globalMixins) {
if (mixin.watch && typeof mixin.watch === 'object') {
Object.assign(mergedWatch, mixin.watch);
}
}
const optionWatch =
(initialState && typeof initialState === 'object' && initialState.watch && typeof initialState.watch === 'object'
? initialState.watch
: null) ||
(options && typeof options === 'object' && options.watch && typeof options.watch === 'object'
? options.watch
: null);
if (optionWatch) {
Object.assign(mergedWatch, optionWatch);
}
for (const [key, handler] of Object.entries(mergedWatch)) {
if (typeof handler === 'function') {
this.$watch(key, handler);
} else if (typeof handler === 'object' && handler !== null && typeof handler.handler === 'function') {
const { handler: fn, ...watchOpts } = handler;
this.$watch(key, fn, watchOpts);
} else if (typeof handler === 'string' && typeof this[handler] === 'function') {
this.$watch(key, this[handler]);
}
}
/**
* @type {boolean}
* @private
*/
this._isFirstRender = true;
this.#eventExecutor = new EventExecutor((source, event, slotScope) => this.#runEventHandler(source, event, slotScope));
this.#eventExecutor.validate = (el) => this.$validateElement(el);
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production') {
let initialized = false;
const originalProto = Object.getPrototypeOf(this);
const protoProxy = new Proxy(originalProto, {
getPrototypeOf(target) {
return target;
},
set(target, key, value, receiver) {
if (initialized && typeof key === 'string' && !key.startsWith('$') && !key.startsWith('_') && key !== 'renderWatcher') {
const hasKey = (key in receiver);
if (!hasKey) {
const compName = receiver.constructor?.name || 'Component';
console.warn(
`[Avenx Warning] Direct assignment to "this.${key} = ${value}" on component <${compName}> is non-reactive and will not trigger DOM updates. Declare "${key}" in <state> instead.`
);
}
}
return Reflect.set(target, key, value, receiver);
}
});
Object.setPrototypeOf(this, protoProxy);
Promise.resolve().then(() => {
initialized = true;
});
}
}
/**
* Evaluates validation rules for an element and updates this.state.$validation.
* @param {Element} el - The element to validate.
* @returns {string[]} Validation errors for the field.
*/
$validateElement(el) {
if (!el || typeof el.getAttribute !== 'function' || !el.hasAttribute('data-ax-validate')) {
return [];
}
const ruleStr = el.getAttribute('data-ax-validate');
const rules = parseValidationRules(ruleStr);
const fieldName = getFieldName(el);
let value = el.value;
if (el.type === 'checkbox') {
value = el.checked;
}
let customMessages = {};
if (el.hasAttribute('data-ax-validate-messages')) {
try {
customMessages = JSON.parse(el.getAttribute('data-ax-validate-messages'));
} catch {
// Ignored
}
}
const errors = validateValue(value, rules, { state: this.state, customMessages });
updateValidationState(this.state, fieldName, errors);
return errors;
}
/**
* Programmatically registers a watcher on a reactive expression/function.
* @param {Function} getter - Evaluation function returning the value to watch.
* @param {Function} callback - Triggered when the value changes.
* @param {object} [options] - Config options.
* @returns {AvenxWatcher}
*/
watch(getter, callback, options = {}) {
const watcher = new AvenxWatcher(getter, callback, options);
if (this.#isUnmounted) {
watcher.teardown();
return watcher;
}
this._watchers.push(watcher);
return watcher;
}
/**
* Trips a named deadlock boundary inside this component's DOM tree, rendering its fallback template.
* @param {string|null} [boundaryName] - Name of the deadlock boundary (or null for first boundary).
* @param {Error|object} [error] - Error context to pass to fallback template.
*/
$tripDeadlockBoundary(boundaryName = null, error = {}) {
if (!this.#element) return;
const boundaries = this.#deadlockManager.findBoundaries(this.#element);
for (const b of boundaries) {
const name = b.getAttribute('data-ax-deadlock-name');
if (!boundaryName || name === boundaryName) {
this.#deadlockManager.trip(b, error, this.#createScope());
return;
}
}
}
/**
* Resets a named deadlock boundary inside this component's DOM tree.
* @param {string|null} [boundaryName]
*/
$resetDeadlockBoundary(boundaryName = null) {
if (!this.#element) return;
const boundaries = this.#deadlockManager.findBoundaries(this.#element);
for (const b of boundaries) {
const name = b.getAttribute('data-ax-deadlock-name');
if (!boundaryName || name === boundaryName) {
this.#deadlockManager.reset(b);
return;
}
}
}
/**
* Returns component context metadata for diagnostic logging.
* @returns {{ componentName: string, fileName: string|null, component: AvenxComponent }}
*/
get $logContext() {
return {
componentName: this.constructor && this.constructor.name !== 'AvenxComponent' && this.constructor.name !== 'Object' ? this.constructor.name : (this.name || 'Component'),
fileName: this.__filename || (this.options && this.options.__filename) || null,
component: this,
};
}
/**
* Whether the component is currently mounted in the DOM.
* @returns {boolean}
*/
get $isMounted() {
return this.#isMounted;
}
/**
* Whether the component has been unmounted.
* @returns {boolean}
*/
get $isUnmounted() {
return this.#isUnmounted;
}
/**
* Getter for the component's root element.
* @returns {Element|null}
*/
get $element() {
return this.#element;
}
/**
* Getter for component reference elements marked with data-ax-ref.
* Lazily resolves references if marked dirty.
* @returns {Record<string, Element>}
*/
get $refs() {
if (this.#refsDirty) {
this.#collectRefs();
}
return this.#refsCache;
}
/**
* Slot helpers for checking whether the parent provided content for a slot.
* Available after mount target setup populates `#transcludedGroups`.
* @returns {object}
*/
get $slots() {
const groups = this.#transcludedGroups;
return {
/**
* @param {string} [slotName] Named slot, or `default` / empty for the default slot.
* @returns {boolean}
*/
has(slotName = 'default') {
if (!groups) return false;
const name = !slotName || slotName === 'default' ? null : slotName;
if (!name) {
return Array.isArray(groups.default) && groups.default.length > 0;
}
const nodes = groups.named?.[name];
return Array.isArray(nodes) && nodes.length > 0;
},
};
}
/**
* Returns a diagnostic snapshot of the component for runtime debugging.
* Props and state are sanitized clones (safe to log, serialize, or diff);
* `element` is intentionally the live root element so it stays inspectable
* in browser devtools. Computed properties are listed by key only, so
* inspecting never evaluates them.
* @returns {{ componentName: string, props: object, state: object, computed: string[], slots: string[], element: Element|null }}
*/
$inspect() {
const groups = this.#transcludedGroups;
const slots = [
...(Array.isArray(groups?.default) && groups.default.length > 0 ? ['default'] : []),
...Object.keys(groups?.named || {}),
];
// Serialize the raw targets: enumerating the reactive proxies would
// evaluate computed properties as a side effect.
return {
componentName: this.$logContext.componentName,
props: serializeSafe(toRaw(this.props)),
state: serializeSafe(toRaw(this.state)),
computed: this.#computed.keys(),
slots,
element: this.$element,
};
}
/**
* KeepAlive invalidation API for clearing cached components.
* @returns {object}
*/
get $keepAlive() {
return {
clear: (componentName) => {
if (this.$app && typeof this.$app.clearKeepAliveCache === 'function') {
return this.$app.clearKeepAliveCache(componentName);
}
return false;
},
};
}
/**
* Helper method to clear cached KeepAlive component instances.
* @param {string} [pageName] - Optional component or page name to clear from cache.
* @returns {boolean} True if cache entries were evicted, false otherwise.
*/
clearKeepAliveCache(pageName) {
if (this.$app && typeof this.$app.clearKeepAliveCache === 'function') {
return this.$app.clearKeepAliveCache(pageName);
}
return false;
}
/**
* Emits a custom event up to parent components with unified bubble options.
* @param {string} eventName - Name of the event to emit.
* @param {object} [detail] - Event details payload.
* @param {object} [options] - Optional custom event options.
*/
emit(eventName, detail = {}, options = {}) {
if (typeof CustomEvent === 'undefined') {
return;
}
const event = new CustomEvent(eventName, {
detail,
bubbles: options.bubbles !== undefined ? options.bubbles : true,
cancelable: options.cancelable !== undefined ? options.cancelable : true,
...options
});
if (this.$element) {
this.$element.dispatchEvent(event);
}
}
/**
* Emits a custom event to the parent component.
* @param {string} eventName - Name of the event to emit.
* @param {object} [detail] - Event details.
*/
$emit(eventName, detail = {}) {
this.emit(eventName, detail, { composed: true });
}
/**
* Reactively listens to changes in specific state values or getters.
* @param {string|Function|Array<string|Function>} source - State property key string, getter function, or array of sources.
* @param {Function} callback - Triggered when the value changes.
* @param {object} [options] - Config options.
* @param {boolean} [options.immediate] - Run callback immediately on watcher creation.
* @param {boolean} [options.deep] - Deeply watch nested properties.
* @returns {AvenxWatcher}
*/
$watch(source, callback, options = {}) {
const resolveSource = (src) => {
if (typeof src === 'string') {
return () => {
const segments = src.split('.');
let val = this.state;
for (const seg of segments) {
if (val === null || val === undefined) return undefined;
val = val[seg];
}
return val;
};
} else if (typeof src === 'function') {
return () => src.call(this);
} else {
throw new Error('source element must be a string or a function');
}
};
let getter;
if (Array.isArray(source)) {
getter = source.map((src) => resolveSource(src));
} else if (typeof source === 'string' || typeof source === 'function') {
getter = resolveSource(source);
} else {
throw new Error('source must be a string, function, or array of sources');
}
const watcher = new AvenxWatcher(getter, callback, options);
if (this.#isUnmounted) {
watcher.teardown();
return watcher;
}
this._watchers.push(watcher);
return watcher;
}
/**
* Reactively runs an immediate effect hook that automatically tracks dependencies and re-runs on state mutation.
* Automatically registers the watcher in this._watchers and tears it down when the component unmounts.
* @param {Function} effect - Effect function to run immediately and track.
* @param {object} [options] - Config options.
* @returns {Function} Stop handle function () => watcher.teardown().
*/
$watchEffect(effect, options = {}) {
if (typeof effect !== 'function') {
throw new Error('effect must be a function');
}
const getter = () => effect.call(this);
const watcher = new AvenxWatcher(getter, null, options);
if (this.#isUnmounted) {
watcher.teardown();
const stop = () => watcher.teardown();
stop.watcher = watcher;
return stop;
}
this._watchers.push(watcher);
const stop = () => {
watcher.teardown();
const idx = this._watchers.indexOf(watcher);
if (idx !== -1) {
this._watchers.splice(idx, 1);
}
};
stop.watcher = watcher;
return stop;
}
/**
* Creates a scope object for expression evaluation.
* @param {object} [methods] - Methods to include in the scope.
* @param {object} [extras] - Additional variables to include.
* @returns {object} The combined scope.
* @private
*/
#createScope(methods = this.#methods, extras = null) {
return this.#scope.resolve(methods, extras && Object.keys(extras).length > 0 ? extras : null);
}
/**
* The names the component's computed values bind.
*
* Read by {@link ComponentScope}; a component's computed registry is private
* and the scope has no business reaching into it.
* @returns {string[]} Computed names.
*/
__computedKeys() {
return this.#computed.keys();
}
/**
* The component's executable actions.
* @returns {object} The action map.
*/
__methods() {
return this.#methods;
}
/**
* Whether the component declared the `isolated` contract.
* @returns {boolean} True when bridges are kept out of scope.
*/
__isIsolated() {
return this.#contracts.has('isolated');
}
/**
* The bridges this component can see.
* @returns {object} Bridges by name.
*/
__bridgeValues() {
return this.#bridges;
}
/**
* Whether the component's module bound any imports.
* @returns {boolean} True when there are imports in scope.
*/
__hasImports() {
return this.#hasImports;
}
/**
* The values the component's module imported.
* @returns {object} Imported values by local name.
*/
__importValues() {
return this.#imports;
}
/**
* The names of the component's declared resources.
* @returns {string[]} Resource names.
*/
__resourceNames() {
return this.#resources ? Object.keys(this.#resources) : [];
}
/**
* Reads a resource, suspending if it is still in flight.
* @param {string} name - The resource name.
* @returns {any} The resource's value.
*/
__readResource(name) {
return this.#resources[name].read();
}
/**
* Resolves an expression within the template.
* @param {string} expression - The expression to evaluate.
* @param {object} [extraScope] - Additional scope variables.
* @returns {any} The result of the evaluation.
* @private
*/
#resolveTemplateExpression(expression, extraScope = {}) {
return this.#evaluator.evaluateExpression(expression, this.#createScope(this.#methods, extraScope), this.state);
}
/**
* Evaluates one of the program's expressions by index.
*
* The scope is built the same way every other template expression's is, so a
* compiled binding sees the same names, the same bridges and the same tracer
* substitution point. Only the addressing changed.
* @param {number} index - The expression index the op carries.
* @param {object|null} [locals] - A block's local bindings, such as a loop item.
* @returns {any} The value.
* @private
*/
#evaluateProgram(index, locals = null) {
const scope = this.#createScope(this.#methods, locals || undefined);
// MIGRATION: a program emitted by the pre-IR compiler carries expression
// *source* on its ops rather than an index. Both shapes are executed here
// while the two compiler paths coexist. Remove this branch with the legacy
// renderer -- see lib/compiler/render/compileTemplate.js.
if (typeof index === 'string') {
return this.#evaluator.evaluateExpression(index, scope, this.state);
}
return this.#evaluator.evaluateIndexed(index, scope, this.state);
}
/**
* Runs one of the program's event handlers by index.
* @param {number} index - The statement index the op carries.
* @param {Event} event - The DOM event.
* @param {object|null} [locals] - A block's local bindings.
* @returns {any} Whatever the handler returned.
* @private
*/
#runProgramStatement(index, event, locals = null) {
// A DOM event is the one thing in an Avenx application that genuinely
// starts a causal chain, so it opens the outermost trace node -- the same
// node EventExecutor opens for the string path, built by the same
// function, so a trace does not read differently depending on which
// renderer produced it.
//
// Everything replay needs (the event type, the target selector, a typed
// value, a checked flag, a key) comes from the event. Only the `handler`
// label comes from the source, which a development build supplies through
// __axProgramStmtSrc and a production build does not carry.
const label = this.constructor.__axProgramStmtSrc?.[index] ?? `handler #${index}`;
try {
const token = tracer.on ? tracer.enter(TraceNodeType.EVENT, buildEventNode(label, event)) : -1;
try {
// Subscriptions opened by a handler belong to this component, so the
// handler runs inside the component's disposal scope -- the same rule
// the string path's handler follows.
return runInScope(this._scope, () =>
this.#evaluator.executeIndexed(
index,
this.#createScope(this.#methods, { event, ...(locals || {}) }),
this.state,
),
);
} finally {
if (token >= 0) {
tracer.leave(token);
}
}
} catch (error) {
logger.error(formatMessage(AvenxErrorCodes.EVENT_HANDLER_ERROR, label, error), this.$logContext);
this.#reportError(error, label, true);
return undefined;
}
}
/**
* Runs an event handler.
*
* The handler arrives as source. It used to be able to arrive as a function
* too -- `EventExecutor` pre-compiled every handler with `new Function` --
* and that branch ran the closure against `AvenxSandbox.createProxy`, the
* source-text sandbox the AST evaluator replaced. Removing the branch closes
* the last route from a template to that sandbox: every inline handler now
* goes through `executeStatement`, which parses first and only reaches for
* `new Function` when the body is genuinely a statement.
* @param {string} source - The handler body, as written in the template.
* @param {Event} event - The event object.
* @param {object|null} [slotScope] - Optional slot scope variables.
* @returns {any} The result of the execution.
* @private
*/
#runEventHandler(source, event, slotScope = null) {
try {
// Subscriptions opened by a handler belong to this component, so the
// handler runs inside the component's disposal scope.
return runInScope(this._scope, () =>
this.#evaluator.executeStatement(
source,
this.#createScope(this.#methods, { event, ...slotScope }),
this.state,
),
);
} catch (error) {
logger.error(formatMessage(AvenxErrorCodes.EVENT_HANDLER_ERROR, source, error), this.$logContext);
this.#reportError(error, source, true);
return undefined;
}
}
/**
* The compiled template this component renders.
*
* An internal seam, in the same family as `__setMountTarget`. Benchmarks and
* renderer tests need the exact string the compiler produced, and reaching it
* by recompiling the source would measure a reconstruction rather than what
* the component actually holds.
* @returns {string} The template source.
*/
__getTemplate() {
return this.#template;
}
/**
* Evaluates one template expression against this component's scope.
*
* Exposed for renderer benchmarks and tests, which need to price expression
* evaluation on its own rather than inferring it from a whole update.
* @param {string} expression - The expression source.
* @param {object} [extraScope] - Additional scope bindings.
* @returns {any} The value.
*/
__evaluate(expression, extraScope = {}) {
return this.#resolveTemplateExpression(expression, extraScope);
}
/**
* Builds one evaluation scope, without evaluating anything in it.
*
* Scope construction used to happen once per interpolation per render, which
* made it a hot path nobody could see. It is measurable from here.
* @returns {object} A fresh scope.
*/
__buildScope() {
return this.#createScope();
}
/**
* Renders the component template with current state.
* @returns {string} The rendered HTML string.
*/
render() {
const enableProfiling = !!(this.$app?.enableProfiling || (typeof window !== 'undefined' && window.__avenx_enable_profiling));
return profile(enableProfiling, this.constructor.name, 'render', () => {
return this.#renderer.render(this.#template, (expression) => this.#resolveTemplateExpression(expression));
});
}
/**
* The string renderer and its satellites, built on first use.
*
* A component with a render program never touches any of them, and in a
* compiled application most components have one. Constructing five objects
* per instance -- one of which allocates a 500-entry LRU cache -- to serve a
* path that will not run is the kind of cost that is invisible per component
* and material per page.
*
* Exposed as getters under the original names, so every call site reads the
* same as it did and only the moment of construction moved.
* @returns {TemplateRenderer} The template renderer.
*/
get #renderer() {
if (!this.#rendererInstance) {
this.#rendererInstance = new (requireStringRenderer(this.constructor.name).TemplateRenderer)();
}
return this.#rendererInstance;
}
/**
* @returns {DomPatcher} The DOM patcher.
*/
get #patcher() {
if (!this.#patcherInstance) {
this.#patcherInstance = new (requireStringRenderer(this.constructor.name).DomPatcher)();
}
return this.#patcherInstance;
}
/**
* @returns {ListManager} The list manager.
*/
get #listManager() {
if (!this.#listManagerInstance) {
this.#listManagerInstance = new (requireStringRenderer(this.constructor.name).ListManager)(
this.#evaluator,
this.#renderer,
this.#eventBinder,
this.constructor.name,
);
}
return this.#listManagerInstance;
}
/**
* @returns {DeferManager} The defer manager.
*/
get #deferManager() {
if (!this.#deferManagerInstance) {
this.#deferManagerInstance = new (requireStringRenderer(this.constructor.name).DeferManager)(
this.#evaluator,
this.#renderer,
this.#eventBinder,
this.constructor.name,
);
}
return this.#deferManagerInstance;
}
/**
* @returns {DeadlockManager} The deadlock manager.
*/
get #deadlockManager() {
if (!this.#deadlockManagerInstance) {
this.#deadlockManagerInstance = new DeadlockManager(
this.#evaluator,
this.#renderer,
this.#eventBinder,
this.constructor.name,
);
}
return this.#deadlockManagerInstance;
}
/**
* Renders the component.
*
* On the compiled path this builds the DOM once and hands ownership to the
* per-binding effects; afterwards it is only reached when something outside
* the reactive graph asks for a re-read. On the string path it evaluates the
* render watcher, which re-renders and re-patches the whole template.
*/
update() {
if (this.#program) {
this.#renderProgram();
return;
}
if (!this.renderWatcher) {
this.renderWatcher = new AvenxWatcher(
() => this.runUpdate(),
() => this.scheduleUpdate(),
{ lazy: true, name: `${this.constructor.name}#render` },
);
}
this.renderWatcher.evaluate();
}
/**
* Whether this component renders through a compiled program.
*
* Exposed for tests and diagnostics: "did this component take the fast path"
* is otherwise unanswerable from outside, and a performance property nobody
* can observe is a performance property nobody can defend.
* @returns {boolean} True when a render program is driving the DOM.
*/
get $compiled() {
return this.#program !== null;
}
/**
* Renders through the compiled program.
*
* On the first call this builds the DOM from the program's skeleton and
* creates one reactive effect per binding. After that the effects own the
* DOM: a state write wakes the bindings that read it and nothing else, and
* this method is only reached again when something *outside* the reactive
* graph asks for a re-read -- a settled resource, a forced update.
* @private
*/
#renderProgram() {
if (!this.#element) return;
if (this.#componentError) {
this.#handleComponentError(this.#componentError);
return;
}
this.#isUpdating = true;
try {
if (this.#templateInstance) {
if (this.#isMounted) {
this.#triggerLifecycle('onBeforeUpdate');
}
this.#templateInstance.refresh();
this.#afterProgramRender();
return;
}
const instance = new TemplateInstance(this.#program, {
program: this.#program,
evaluate: (index, locals) => this.#evaluateProgram(index, locals),
runStatement: (index, event, locals) => this.#runProgramStatement(index, event, locals),
describeExpression: (index) => this.constructor.__axProgramExprSrc?.[index],
onSuspend: (promise) => this.#suspend(promise),
jobId: this.uid,
onRendered: () => this.#scheduleProgramPostRender(),
onChildProps: () => this.__onChildPropsChanged(),
onBeforeRender: () => this.#notifyProgramBeforeRender(),
});
if (!instance.usable) {
// The skeleton could not be prepared in this environment -- a DOM
// without <template>, or a parser that reshaped the tree past the
// compiler's markers. Drop to the string renderer permanently rather
// than binding against a tree that does not match the program.
logger.warn(
formatMessage(
AvenxErrorCodes.TEMPLATE_RENDER_ERROR,
this.constructor.name,
'compiled template could not be prepared; falling back to the string renderer',
),
this.$logContext,
);
this.#program = null;
this.#isUpdating = false;
this.update();
return;
}
if (this.#isMounted) {
this.#triggerLifecycle('onBeforeUpdate');
}
// Bindings run while the fragment is still detached, so the document
// never sees the skeleton with its values missing.
const fragment = instance.create();
this.#templateInstance = instance;
this.#element.innerHTML = '';
this.#element.appendChild(fragment);
// A `<select value="a">` written literally in the template is never
// touched by an op, and the HTML parser does not apply a `value`
// attribute to a select. Once, on creation, is enough.
this.#syncSelectElements();
// Slots compile now, so the compiled path has to transclude too. The
// fallback children are already in the tree at this point -- they came
// out of the skeleton -- so they are captured here, before `#fillSlots`
// empties the outlet to insert what the parent passed down. The string
// path recovers them by re-rendering the template and parsing the result;
// a compiled component has the nodes in hand and needs neither.
this.#captureSlotFallbacks();
this.#fillSlots();
// Set once rather than per update. `data-ax-ref` makes a template refuse
// to compile, so a compiled component has no refs to find -- but `$refs`
// collects lazily and this keeps the flag honest for anything that reads
// it, at the cost of one scan the first time somebody does.
this.#refsDirty = true;
// Events are delegated from this element and read `data-ax-event` off the
// DOM when one fires, so binding once at mount covers every handler the
// template declares. The string path re-binds on every update because it
// replaces nodes; a program never does.
this.#eventBinder.bind(this.#element, this.#eventExecutor);
this.#hasRenderedOnce = true;
this.#afterProgramRender();
} catch (error) {
if (error instanceof Promise) {
this.#suspend(error);
return;
}
if (error && error.code === AvenxErrorCodes.STATE_MUTATION_IN_UPDATE) {
throw error;
}
this.#handleComponentError(error);
} finally {
this.#isUpdating = false;
}
}
/**
* Runs the bookkeeping that follows any render on the program path.
* @private
*/
#afterProgramRender() {
this.#beforeRenderFired = false;
// Deliberately narrower than the string path's equivalent, because the
// compiler has already ruled several things out.
//
// `#validateFormElements` and `#collectRefs` each run a querySelectorAll
// over the whole component subtree. On the string path that is one scan per
// render and the render was already O(template). Here it would be the only
// O(template) work left in an otherwise O(1) update -- and it would find
// nothing, because `data-ax-validate` and `data-ax-ref` both make a
// template refuse to compile. A component reaching this method cannot
// contain either.
//
// `<select value>` is likewise handled at the point of the write:
// `syncValueProperty` sets the property alongside the attribute, so there
// is nothing left to re-sync afterwards. The one case it does not cover --
// a *static* `value` on a select, which no op ever applies -- is synced
// once when the tree is created, not on every update.
if (this.#isMounted && typeof CustomEvent !== 'undefined' && this.#element?.dispatchEvent) {
this.#element.dispatchEvent(new CustomEvent('avenx:update'));
}
if (this.#isMounted) {
this.#triggerLifecycle('onUpdate');
}
this.__notifyInjectingChildren();
}
/**
* Called when a compiled prop for a child component changed value.
*
* A plain component does not mount children, so this is a no-op here.
* {@link AvenxPage} overrides it: pages own the child mount points, and a
* changed prop is the one thing that has to reach an already-mounted child.
*/
__onChildPropsChanged() {}
/**
* Fires `onBeforeUpdate` once, before the first binding of a flush applies.
*
* On the string path there was one render per update, so the hook had an
* obvious place to sit. With per-binding effects there is no single render,
* and a component with 500 bindings must not fire the hook 500 times. The
* flag is cleared by the post-render callback, which runs after every job in
* the flush has drained.
* @private
*/
#notifyProgramBeforeRender() {
if (this.#beforeRenderFired || !this.#isMounted || this.#isUnmounted) return;
this.#beforeRenderFired = true;
this.#triggerLifecycle('onBeforeUpdate');
}
/**
* Queues the once-per-flush notification for a fine-grained update.
*
* Individual bindings write to the DOM independently, so there is no single
* moment that is "the update" any more. `onUpdate`, the `avenx:update` event
* and ref resolution still have to happen exactly once per flush, or a
* component with many bindings would fire them many times per tick and every
* lifecycle test in every application would start failing.
* @private
*/
#scheduleProgramPostRender() {
if (this.#postRenderQueued || this.#isUnmounted) return;
this.#postRenderQueued = true;
queueFlushCallback(() => {
this.#postRenderQueued = false;
if (this.#isUnmounted || !this.#element) {
this.#beforeRenderFired = false;
return;
}
this.#afterProgramRender();
});
}
/**
* Performs the actual update/render of the component.
*/
runUpdate() {
if (!this.#element) return;
if (this.#componentError) {
this.#handleComponentError(this.#componentError);
return;
}
this.#isUpdating = true;
try {
if (this.#isMounted) {
this.#triggerLifecycle('onBeforeUpdate');
}
this.__updateProvidedState();
this.#patcher.patch(this.#element, this.render(), (expression, slotScope) => this.#resolveTemplateExpression(expression, slotScope), this.$app);
// Fill slots with transcluded content.
this.#fillSlots();
this.#listManager.process(this.#element, this.#createScope(), this.state, this.$app);
this.#deferManager.process(this.#element, this.#createScope(), this.state, this.$app);
this.#eventBinder.bind(this.#element, this.#eventExecutor);
this.#refsDirty = true;
this.#syncSelectElements();
if (this.#isMounted && typeof CustomEvent !== 'undefined' && this.#element?.dispatchEvent) {
this.#element.dispatchEvent(new CustomEvent('avenx:update'));
}
if (this.#isMounted) {
this.#resolveRefs();
this.#triggerLifecycle('onUpdate');
}
this.__notifyInjectingChildren();
// Only a render that ran under the render watcher collected
// dependencies. runUpdate() can also be called directly, and after such
// a call the dependency set says nothing about what this component
// observes -- so filtering must stay off until a tracked render happens.
if (activeWatcher && activeWatcher === this.renderWatcher) {
this.#hasRenderedOnce = true;
}
} catch (error) {
if (error instanceof Promise) {
this.#suspend(error);
return;
}
if (error && error.code === AvenxErrorCodes.STATE_MUTATION_IN_UPDATE) {
throw error;
}
this.#handleComponentError(error);
} finally {
this.#isUpdating = false;
this.#validateFormElements();
}
}
/**
* Validates all form input elements in the component that have data-ax-validate.
* @private
*/
#validateFormElements() {
if (!this.$element || typeof this.$element.querySelectorAll !== 'function') return;
const elements = Array.from(this.$element.querySelectorAll('[data-ax-validate]'));
if (this.$element.hasAttribute && this.$element.hasAttribute('data-ax-validate')) {
elements.unshift(this.$element);
}
for (const el of elements) {
this.$validateElement(el);
}
}
/**
* Executes a callback (or resolves a Promise) after the current reactive
* update cycle has finished flushing pending DOM updates.
* @param {Function} [callback] - Optional callback to run after the flush.
* @returns {Promise<void>|void} A promise resolving after the flush, if no callback was given.
*/
$nextTick(callback) {
return schedulerNextTick(callback);
}
/**
* Alias for {@link AvenxComponent#$nextTick}.
* @param {Function} [callback] - Optional callback to run after the flush.
* @returns {Promise<void>|void}
*/
nextTick(callback) {
return this.$nextTick(callback);
}
/**
* Schedules an update to run asynchronously in a microtask.
*/
scheduleUpdate() {
if (this.#isUpdating) {
throw new AvenxError(AvenxErrorCodes.STATE_MUTATION_IN_UPDATE);
}
if (this.renderWatcher) {
this.renderWatcher.dirty = true;
}
if (this.#isBeforeMounting) {
return;
}
// On the program path there is no component-level render to schedule. The
// write that reached here has already woken the bindings that read it,
// through the same dependency graph this call was about to bypass, and
// queueing a whole-component update on top would undo the change: every
// write would re-evaluate every binding, which is what the string renderer
// did. The lifecycle notification is still owed, and is queued instead.
if (this.#templateInstance) {
this.#scheduleProgramPostRender();
return;
}
if (this.#updateQueued) return;
this.#updateQueued = true;
queueJob(this.#updateJob);
}
/**
* Internal method to set the mount target.
* @param {Element} target - The target element.
* @param {boolean} [isRestore] - Whether the component is being restored from keep-alive.
* @private
*/
__setMountTarget(target, isRestore = false) {
this.#element = target;
this.#isUnmounted = false;
// A component instance may be mounted again after teardown. Its disposal
// scope is single-use, so give the new lifetime a fresh one.
if (this._scope && this._scope.disposed) {
this._scope = new DisposalScope(this.constructor.name);
}
if (target) {
target.__avenx_comp_instance = this;
this.__initProvide();
this.__initInjection();
if (isRestore) {
return;
}
const children = Array.from(target.childNodes);
this.#transcludedGroups = {
default: [],
named: {},
};
children.forEach((child) => {
if (child.nodeType === 1 && child.hasAttribute('slot')) {
const name = child.getAttribute('slot');
if (!this.#transcludedGroups.named[name]) {
this.#transcludedGroups.named[name] = [];
}
this.#transcludedGroups.named[name].push(child);
} else {
this.#transcludedGroups.default.push(child);
}
});
target.innerHTML = '';
}
}
/**
* Resolves the current name of a slot element.
* Dynamic slot names are evaluated during template rendering, so the
* rendered name attribute contains the value used for transclusion.
* @param {Element} slotEl - The slot element.
* @returns {string|null} The resolved slot name.
* @private
*/
#resolveSlotName(slotEl) {
const name = slotEl.getAttribute('name');
return name && name.trim() ? name.trim() : null;
}
/**
* Records each slot's declared fallback children before anything fills it.
*
* Called once, from the compiled render path, while the skeleton's own
* children are still in the outlet. After `#fillSlots` has run, the outlet
* holds whatever the parent projected and the fallback is unrecoverable from
* the DOM -- which is why the string path has to re-render the template and
* parse the result to find it again.
* @private
*/
#captureSlotFallbacks() {
if (!this.#element) return;
for (const slotEl of this.#getOwnSlots()) {
if (slotEl.__avenxFallback) continue;
slotEl.__avenxFallback = Array.from(slotEl.childNodes).map((child) => child.cloneNode(true));
}
}
/**
* Retrieves the fallback children declared for a slot.
* @param {string|null} name - The name of the slot.
* @returns {Node[]} Cloned child nodes.
* @private
*/
#getDefaultSlotChildren(name) {
// A compiled component captured its fallbacks from the skeleton at mount,
// so the answer is already a list of nodes. The round trip below exists
// only for the string path, which has no other record of them.
const captured = this.#getOwnSlots().find((slotEl) => {
const slotName = this.#resolveSlotName(slotEl);
return (name ? slotName === name : !slotName) && slotEl.__avenxFallback;
});
if (captured) {
return captured.__avenxFallback.map((child) => child.cloneNode(true));
}
try {
const parser = new DOMParser();
const doc = parser.parseFromString(this.render(), 'text/html');
const rootDoc = doc.body || doc;
const defaultSlot = Array.from(rootDoc.querySelectorAll('slot')).find((s) => {
const sName = this.#resolveSlotName(s);
return name ? sName === name : !sName;
});
if (defaultSlot) {
return Array.from(defaultSlot.childNodes).map((child) => child.cloneNode(true));
}
} catch (e) {
logger.warn(
formatMessage(AvenxErrorCodes.COMPONENT_RESTORE_SLOT_CONTENT_FAILED, e.message || e),
this.$logContext
);
}
return [];
}
/**
* Fills <slot> elements with transcluded child nodes.
* Supports both static and dynamically rendered slot names.
* @private
*/
#fillSlots() {
if (!this.#element || !this.#transcludedGroups) return;
const slots = this.#getOwnSlots();
slots.forEach((slotEl) => {
const name = this.#resolveSlotName(slotEl);
const nodes = name ? this.#transcludedGroups.named[name] || [] : this.#transcludedGroups.default || [];
const hasContent = nodes.some((node) => {
if (node.nodeType === 1 && node.nodeName !== '!--' && node.nodeName !== '#comment') return true;
if (node.nodeType === 3 && node.textContent.trim().length > 0) return true;
return false;
});
if (hasContent) {
slotEl.innerHTML = '';
const templateEl = nodes.find(
(node) => node.nodeType === 1 && node.tagName.toLowerCase() === 'template' && node.hasAttribute('data-slot-props')
);
if (templateEl) {
const renderedNodes = this.#renderScopedSlot(slotEl, templateEl);
renderedNodes.forEach((node) => {
slotEl.appendChild(node);
});
} else {
nodes.forEach((node) => {
slotEl.appendChild(node);
});
}
slotEl.setAttribute('data-avenx-transcluded', 'true');
} else if (slotEl.hasAttribute('data-avenx-transcluded')) {
// Only when the outlet actually held projected content. Rebuilding it
// unconditionally replaced the fallback with *clones* of itself, and on
// the compiled path the bindings still pointed at the originals -- so a
// slot showing its fallback rendered once and then went stale, writing
// every later value into a detached node.
//
// When nothing was ever projected, the fallback in the outlet is the
// one the renderer produced and is already live. Leaving it alone is
// both cheaper and the only correct thing to do.
slotEl.removeAttribute('data-avenx-transcluded');
slotEl.innerHTML = '';
this.#getDefaultSlotChildren(name).forEach((child) => {
slotEl.appendChild(child);
});
}
});
}
/**
* Retrieves slot elements belonging to this component.
* @returns {Element[]}
* @private
*/
#getOwnSlots() {
if (!this.#element) return [];
const slots = this.#element.querySelectorAll('slot');
const root = this.#element;
return Array.from(slots).filter((slot) => {
let parent = slot.parentNode;
while (parent && parent !== root) {
if (
parent.hasAttribute &&
(parent.hasAttribute('data-avenx-comp') || parent.hasAttribute('data-avenx-comp-dynamic'))
) {
return false;
}
parent = parent.parentNode;
}
return true;
});
}
/**
* Resolves reference elements if marked dirty.
* @private
*/
#resolveRefs() {
if (this.#refsDirty) {
this.#collectRefs();
}
}
/**
* Collects elements marked with data-ax-ref that belong to this component.
* Elements inside nested component boundaries are excluded.
* @private
*/
#collectRefs() {
this.#refsCache = {};
this.#refsDirty = false;
if (!this.#element) return;
const refElements = this.#element.querySelectorAll('[data-ax-ref]');
const root = this.#element;
Array.from(refElements).forEach((element) => {
let parent = element.parentNode;
while (parent && parent !== root) {
if (
parent.hasAttribute &&
(parent.hasAttribute('data-avenx-comp') || parent.hasAttribute('data-avenx-comp-dynamic'))
) {
return;
}
parent = parent.parentNode;
}
const refName = element.getAttribute('data-ax-ref');
if (refName && refName.trim()) {
// Prefer the mounted component instance when present (Vue-like $refs behavior)
this.#refsCache[refName.trim()] = element.__avenx_comp_instance || element;
}
});
}
/**
* Renders the content of a scoped slot template using child-exposed properties
* merged into the parent component's evaluation scope.
* @param {Element} slotEl - The slot element.
* @param {Element} templateEl - The transcluded template element.
* @returns {Node[]} Array of rendered and tagged child nodes.
* @private
*/
#renderScopedSlot(slotEl, templateEl) {
const slotPropsName = templateEl.getAttribute('data-slot-props');
if (!slotPropsName) return [];
const exposedProps = {};
for (const attr of slotEl.attributes) {
if (attr.name.startsWith(':')) {
const propName = attr.name.slice(1);
exposedProps[propName] = this._evaluate(attr.value);
} else if (attr.name.startsWith('data-props-')) {
const propName = attr.name.slice('data-props-'.length);
exposedProps[propName] = this._evaluate(attr.value);
}
}
const extraScope = { [slotPropsName]: exposedProps };
let templateHtml = templateEl.innerHTML;
templateHtml = templateHtml
.replace(/_AX_LBRACE3_/g, '{{{')
.replace(/_AX_RBRACE3_/g, '}}}')
.replace(/_AX_LBRACE_/g, '{{')
.replace(/_AX_RBRACE_/g, '}}');
const parentComp = this.$parent || this;
const renderedHtml = this.#renderer.render(templateHtml, (expr) => {
return parentComp._evaluate(expr, extraScope);
});
const parser = new DOMParser();
const doc = parser.parseFromString(renderedHtml, 'text/html');
const nodes = Array.from((doc.body || doc).childNodes);
// Only the roots need stamping: resolution walks up from wherever a binding
// sits, so marking every descendant was redundant work proportional to the
// size of the slot's content.
nodes.forEach((node) => stampScope(node, extraScope));
nodes.forEach((node) => {
if (node.nodeType === 1) {
this.#patcher.applyDirectives(node, (expr) => {
return parentComp._evaluate(expr, extraScope);
}, this.$app);
}
});
return nodes;
}
/**
* Updates the transcluded content when the parent template updates.
* @param {NodeList|Array} virtualChildNodes - The new virtual transcluded nodes from parent.
* @private
*/
__updateTranscludedContent(virtualChildNodes) {
const grouped = {
default: [],
named: {},
};
Array.from(virtualChildNodes || []).forEach((node) => {
if (node.nodeType === 1 && node.hasAttribute('slot')) {
const name = node.getAttribute('slot');
if (!grouped.named[name]) {
grouped.named[name] = [];
}
grouped.named[name].push(node);
} else {
grouped.default.push(node);
}
});
this.#transcludedGroups = grouped;
if (this.#element) {
const slots = this.#getOwnSlots();
slots.forEach((slotEl) => {
const name = this.#resolveSlotName(slotEl);
const newChildren = name ? grouped.named[name] || [] : grouped.default || [];
const newSlotWrapper = slotEl.cloneNode(false);
const templateEl = newChildren.find(
(node) => node.nodeType === 1 && node.tagName.toLowerCase() === 'template' && node.hasAttribute('data-slot-props')
);
let finalChildren = newChildren;
let isScoped = false;
if (templateEl) {
finalChildren = this.#renderScopedSlot(slotEl, templateEl);
isScoped = true;
}
if (isScoped) {
finalChildren.forEach((child) => {
newSlotWrapper.appendChild(child);
});
} else {
finalChildren.forEach((child) => {
newSlotWrapper.appendChild(child.cloneNode(true));
});
}
const hasContent = finalChildren.some((node) => {
if (node.nodeType === 1 && node.nodeName !== '!--' && node.nodeName !== '#comment') return true;
if (node.nodeType === 3 && node.textContent.trim().length > 0) return true;
return false;
});
if (hasContent) {
newSlotWrapper.setAttribute('data-avenx-transcluded', 'true');
} else {
newSlotWrapper.removeAttribute('data-avenx-transcluded');
this.#getDefaultSlotChildren(name).forEach((child) => {
newSlotWrapper.appendChild(child);
});
}
const slotEvaluator = (expr) => {
if (isScoped && templateEl) {
const slotPropsName = templateEl.getAttribute('data-slot-props');
const exposedProps = {};
for (const attr of slotEl.attributes) {
if (attr.name.startsWith(':')) {
const propName = attr.name.slice(1);
exposedProps[propName] = this._evaluate(attr.value);
} else if (attr.name.startsWith('data-props-')) {
const propName = attr.name.slice('data-props-'.length);
exposedProps[propName] = this._evaluate(attr.value);
}
}
const extraScope = { [slotPropsName]: exposedProps };
const parentComp = this.$parent || this;
return parentComp._evaluate(expr, extraScope);
}
return this.#resolveTemplateExpression(expr);
};
this.#patcher.patchElement(slotEl, newSlotWrapper, slotEvaluator, this.$app);
});
}
}
/**
* Internal method called before the component is mounted to the DOM.
* @private
*/
__beforeMount() {
this.#isBeforeMounting = true;
try {
this.#triggerLifecycle('onBeforeMount');
} catch (error) {
this.#reportError(error, '__beforeMount', true);
} finally {
this.#isBeforeMounting = false;
}
}
/**
* Internal method called after the component is mounted to the DOM.
* @private
*/
__afterMount() {
this.#isMounted = true;
try {
this.#resolveRefs();
if (typeof CustomEvent !== 'undefined' && this.#element?.dispatchEvent) {
this.#element.dispatchEvent(new CustomEvent('avenx:mount'));
}
this.#triggerLifecycle('onMount');
} catch (error) {
this.#reportError(error, '__afterMount', true);
}
}
/**
* Unmounts the component and triggers cleanup.
* @returns {Promise<void>|void}
*/
unmount() {
if (this.#isUnmounted) return;
if (this.#isUnmounting) return this.#unmountPromise;
this.#isUnmounting = true;
const res = this.#lifecycle.unmount(this);
if (res instanceof Promise) {
this.#unmountPromise = res;
return res;
}
}
/**
* Destroys/unmounts the component and performs teardown of watchers and resources.
* Alias for unmount().
* @returns {Promise<void>|void}
*/
destroy() {
return this.unmount();
}
/**
* Destroys/unmounts the component and performs teardown of watchers and resources.
* Alias for unmount().
* @returns {Promise<void>|void}
*/
$destroy() {
return this.unmount();
}
/**
* Performs the actual synchronous teardown and DOM clearing of the component.
* @private
*/
__performTeardown() {
try {
this.#eventBinder.unbind(this.#element);
this.#eventBinder.teardown();
this.#eventExecutor.teardown();
if (typeof CustomEvent !== 'undefined' && this.#element?.dispatchEvent) {
this.#element.dispatchEvent(new CustomEvent('avenx:unmount'));
}
this.#triggerLifecycle('onUnmount');
if (this.renderWatcher) {
this.renderWatcher.teardown();
}
// Every binding holds a watcher that sits in the dependency set of the
// state it read. Left in place they keep this component, its DOM and its
// scope reachable for as long as that state exists -- a leak proportional
// to how finely the template was bound, which is exactly the wrong way
// round.
if (this.#templateInstance) {
this.#templateInstance.dispose();
this.#templateInstance = null;
}
this.#postRenderQueued = false;
this.#beforeRenderFired = false;
if (this._injectedAncestors) {
for (const { ancestor, key } of this._injectedAncestors) {
ancestor.__unregisterInjectingChild(key, this);
}
this._injectedAncestors = [];
}
if (this._watchers) {
for (const watcher of this._watchers) {
watcher.teardown();
}
this._watchers = [];
}
// Releases bridge subscriptions (and anything else scope-owned) opened
// by this component's hooks and handlers.
if (this._scope) {
this._scope.dispose();
}
if (this._stateHandler) {
this._stateHandler.teardown();
}
if (this._propsHandler) {
this._propsHandler.teardown();
}
if (this.#resources) {
for (const res of Object.values(this.#resources)) {
if (res && typeof res.teardown === 'function') {
res.teardown();
}
}
}
this.#refsCache = {};
this.#refsDirty = false;
if (this.#element) {
// Custom-directive `unmounted` hooks, and only those. The metadata they
// read -- `__avenx_directives` -- is written in exactly one place, the
// string renderer's attribute patch, so a bundle without that renderer
// has nothing here to trigger.
//
// Reaching for `#patcher` unconditionally therefore threw on every
// teardown in a fully compiled application, where the renderer is
// correctly absent. The throw was caught below and, until unhandled
// errors were reported, discarded -- so the rest of this block never
// ran: the instance stayed on the element, its content stayed in the
// document, and nothing said why. Skipping the call when there is
// provably nothing to call is both the fix and the honest description
// of the condition.
if (hasStringRenderer()) {
this.#patcher.triggerUnmounted(this.#element, this.$app);
}
delete this.#element.__avenx_comp_instance;
this.#element.innerHTML = '';
this.#element = null;
}
} catch (error) {
this.#reportError(error, '__performTeardown');
}
// Decrement runtime style reference count for this component class
styleMountManager.unmount(this.constructor);
this.#isMounted = false;
this.#isUnmounting = false;
this.#isUnmounted = true;
this.#unmountPromise = null;
}
/**
* Called before the component leaves the DOM.
* Can return a Promise to postpone DOM removal.
* @returns {Promise<void>|void}
*/
onBeforeLeave() { }
/**
* Called when the component is mounted to the DOM and enters.
*/
onEnter() { }
/**
* Called when the component is unmounted and leaves the DOM.
*/
onLeave() { }
/**
* Updates the component's props and triggers an update if they changed.
* @param {object} newProps - The new props to apply.
*/
setProps(newProps) {
const currentProps = this.props;
for (const key of Object.keys(newProps)) {
if (currentProps[key] !== newProps[key]) {
currentProps[key] = newProps[key];
}
}
for (const key of Object.keys(currentProps)) {
if (!(key in newProps)) {
delete currentProps[key];
}
}
}
/**
* The current active route metadata.
* @returns {{hash: string, path: string, page: string, params: Record<string, any>, query: Record<string, string | boolean | number>}} The route details.
*/
get $route() {
if (typeof window !== 'undefined' && window.__avenx_routers) {
for (const router of window.__avenx_routers) {
if (router.currentRoute && router.currentRoute.hash) {
return router.currentRoute;
}
}
}
return { hash: '', path: '', page: '', params: {}, query: {}, meta: {} };
}
/**
* Evaluates an expression in the component's scope.
* @param {string} expression - The expression to evaluate.
* @param {object} [extraScope] - Additional scope variables.
* @returns {any} The result of the evaluation.
* @protected
*/
_evaluate(expression, extraScope = {}) {
return this.#evaluator.evaluateExpression(expression, this.#createScope(this.#methods, extraScope), this.state);
}
/**
* @returns {Element|null} The component's root element.
* @protected
*/
_getElement() {
return this.#element;
}
/**
* @returns {object} The bridges accessible to the component.
* @protected
*/
_getBridges() {
return this.#bridges;
}
/**
* Returns the set of active compiler contracts for this component.
* @returns {Set<string>}
*/
get contracts() {
return this.#contracts;
}
/**
* Retrieves the transcluded groups for this component.
* @returns {object} The transcluded groups.
* @protected
*/
_getTranscludedGroups() {
return this.#transcludedGroups;
}
/**
* Mounts the component to a target element.
* @param {Element|string} target - The target element or selector.
*/
mount(target) {
this.#lifecycle.mount(this, target);
}
/**
* Internal method to initialize injected properties from ancestors.
* @private
*/
__initInjection() {
const injectOption =
this.inject ||
(typeof this.constructor.inject === 'function' ? this.constructor.inject() : this.constructor.inject);
if (!injectOption) return;
let injectMap = {};
const resolvedOption = typeof injectOption === 'function' ? injectOption.call(this) : injectOption;
if (Array.isArray(resolvedOption)) {
for (const key of resolvedOption) {
injectMap[key] = key;
}
} else if (resolvedOption && typeof resolvedOption === 'object') {
injectMap = resolvedOption;
}
for (const [localKey, injectDef] of Object.entries(injectMap)) {
let provideKey = injectDef;
let defaultValue;
let hasDefault = false;
if (injectDef && typeof injectDef === 'object' && !Array.isArray(injectDef)) {
provideKey = injectDef.from != null ? injectDef.from : localKey;
if (Object.prototype.hasOwnProperty.call(injectDef, 'default')) {
hasDefault = true;
defaultValue = injectDef.default;
}
}
Object.defineProperty(this, localKey, {
get: () => {
const ancestor = this.#findAncestorProviding(provideKey);
if (!ancestor) {
if (hasDefault) {
return typeof defaultValue === 'function' ? defaultValue.call(this) : defaultValue;
}
logger.warn(
formatMessage(AvenxErrorCodes.COMPONENT_INJECT_KEY_NOT_FOUND, provideKey),
this.$logContext
);
return undefined;
}
if (!this._injectedAncestors) {
this._injectedAncestors = [];
}
const alreadyRegistered = this._injectedAncestors.some(
(reg) => reg.ancestor === ancestor && reg.key === provideKey
);
if (!alreadyRegistered) {
ancestor.__registerInjectingChild(provideKey, this);
this._injectedAncestors.push({ ancestor, key: provideKey });
}
return this.#getAncestorProvidedValue(ancestor, provideKey);
},
enumerable: true,
configurable: true,
});
}
}
/**
* Finds the nearest ancestor component that provides the specified key.
* @param {string} key - The provided key to search for.
* @returns {AvenxComponent|null} The ancestor component instance, or null.
* @private
*/
#findAncestorProviding(key) {
const el = this._getElement();
if (!el) return null;
let parentEl = el.parentNode;
while (parentEl) {
if (parentEl.__avenx_comp_instance) {
const comp = parentEl.__avenx_comp_instance;
if (this.#componentProvides(comp, key)) {
return comp;
}
}
parentEl = parentEl.parentNode;
}
return null;
}
/**
* Checks if a component provides the specified key.
* @param {AvenxComponent} comp - The component to check.
* @param {string} key - The provided key.
* @returns {boolean} True if the component provides the key.
* @private
*/
#componentProvides(comp, key) {
const provideOption =
comp.provide ||
(typeof comp.constructor.provide === 'function' ? comp.constructor.provide() : comp.constructor.provide);
if (!provideOption) return false;
const resolved = typeof provideOption === 'function' ? provideOption.call(comp) : provideOption;
if (Array.isArray(resolved)) {
return resolved.includes(key);
} else if (resolved && typeof resolved === 'object') {
return key in resolved;
}
return false;
}
/**
* Retrieves the provided value for a key from an ancestor component.
* @param {AvenxComponent} comp - The ancestor component instance.
* @param {string} key - The provided key.
* @returns {any} The value.
* @private
*/
#getAncestorProvidedValue(comp, key) {
if (comp._providedState && key in comp._providedState) {
const val = comp._providedState[key];
if (typeof val === 'function') {
return val;
}
return val;
}
const provideOption =
comp.provide ||
(typeof comp.constructor.provide === 'function' ? comp.constructor.provide() : comp.constructor.provide);
if (!provideOption) return undefined;
const resolved = typeof provideOption === 'function' ? provideOption.call(comp) : provideOption;
if (Array.isArray(resolved)) {
return comp._getScopeValue(key);
} else if (resolved && typeof resolved === 'object') {
const val = resolved[key];
if (typeof val === 'function') {
return val.bind(comp);
}
return val;
}
return undefined;
}
/**
* Internal method to initialize provided properties as a reactive proxy.
* @private
*/
__initProvide() {
const provideOption =
this.provide ||
(typeof this.constructor.provide === 'function' ? this.constructor.provide() : this.constructor.provide);
if (!provideOption) return;
const resolved = typeof provideOption === 'function' ? provideOption.call(this) : provideOption;
if (resolved && typeof resolved === 'object' && !Array.isArray(resolved)) {
// Create a reactive proxy of the provided object
const handlerFactory = new ProxyHandlerFactory({
onChange: () => {
this.__notifyInjectingChildren();
},
});
this._providedState = new Proxy(resolved, handlerFactory.create());
}
}
/**
* Registers a child component that is injecting a provided key.
* @param {string} key - The provided key.
* @param {AvenxComponent} child - The child component.
*/
__registerInjectingChild(key, child) {
if (!this._injectingChildren) {
this._injectingChildren = new Map();
}
let children = this._injectingChildren.get(key);
if (!children) {
children = new Set();
this._injectingChildren.set(key, children);
}
children.add(child);
}
/**
* Unregisters an injecting child component.
* @param {string} key - The provided key.
* @param {AvenxComponent} child - The child component.
*/
__unregisterInjectingChild(key, child) {
if (this._injectingChildren) {
const children = this._injectingChildren.get(key);
if (children) {
children.delete(child);
}
}
}
/**
* Notifies all registered injecting child components that a provided value has shifted.
*/
__notifyInjectingChildren() {
if (this._injectingChildren) {
for (const children of this._injectingChildren.values()) {
for (const child of children) {
child.scheduleUpdate();
}
}
}
}
/**
* Dynamically re-evaluates the provide option and updates providedState.
*/
__updateProvidedState() {
if (!this._providedState) return;
const provideOption =
this.provide ||
(typeof this.constructor.provide === 'function' ? this.constructor.provide() : this.constructor.provide);
if (!provideOption) return;
const resolved = typeof provideOption === 'function' ? provideOption.call(this) : provideOption;
if (resolved && typeof resolved === 'object' && !Array.isArray(resolved)) {
const rawProvided = this._providedState[RAW_SYMBOL] || this._providedState;
for (const [k, v] of Object.entries(resolved)) {
if (rawProvided[k] !== v) {
this._providedState[k] = v;
}
}
}
}
/**
* Retrieves a property or method from the component's scope.
* Used for array-based provide to resolve keys dynamically.
* @param {string} key - The key to retrieve.
* @returns {any} The value.
* @protected
*/
_getScopeValue(key) {
if (this.state && key in this.state) {
return this.state[key];
}
if (this.props && key in this.props) {
return this.props[key];
}
if (this.#methods && key in this.#methods) {
return this.#methods[key];
}
if (this.#bridges && key in this.#bridges) {
return this.#bridges[key];
}
if (key in this) {
return this[key];
}
return undefined;
}
/**
* Resolves the app instance associated with this component.
* @returns {AvenxApp|null}
*/
get $app() {
if (this._app) {
return this._app;
}
if (this.$parent) {
return this.$parent.$app;
}
const el = this.$element;
if (el) {
let parentEl = el.parentNode;
while (parentEl) {
if (parentEl.__avenx_comp_instance) {
const comp = parentEl.__avenx_comp_instance;
if (comp._app) return comp._app;
if (comp.$parent) {
const app = comp.$parent.$app;
if (app) return app;
}
}
parentEl = parentEl.parentNode;
}
}
return null;
}
/**
* Sets the app instance associated with this component.
* @param {AvenxApp} app - The app instance.
*/
set $app(app) {
this._app = app;
}
/**
* Invokes all registered lifecycle hooks for the specified event.
* @param {string} hookName - The name of the lifecycle hook.
* @private
*/
#triggerLifecycle(hookName) {
const hooks = [];
for (const mixin of globalMixins) {
if (mixin[hookName] && typeof mixin[hookName] === 'function') {
hooks.push(mixin[hookName].bind(this));
} else if (mixin.methods && mixin.methods[hookName] && typeof mixin.methods[hookName] === 'function') {
hooks.push(mixin.methods[hookName].bind(this));
}
}
const selfHook = this.#methods[hookName] || (typeof this[hookName] === 'function' ? this[hookName].bind(this) : null);
if (selfHook) {
hooks.push(selfHook);
}
for (const hook of hooks) {
try {
const enableProfiling = !!(this.$app?.enableProfiling || (typeof window !== 'undefined' && window.__avenx_enable_profiling));
profile(enableProfiling, this.constructor.name, hookName, () => {
runInScope(this._scope, hook);
});
} catch (error) {
if (error && error.code === AvenxErrorCodes.STATE_MUTATION_IN_UPDATE) {
throw error;
}
logger.error(formatMessage(AvenxErrorCodes.LIFECYCLE_HOOK_ERROR, this.constructor.name, hookName, error), this.$logContext);
this.#reportError(error, hookName, true);
}
}
}
/**
* Reports an error to the global application error handler, if registered.
* If a parent component defines an onErrorCaptured hook, it is invoked first.
* If onErrorCaptured returns false, the error propagation is stopped.
*
* `logged` says whether the call site has already written the error to the
* log with a diagnostic of its own. Several have: a failing lifecycle hook is
* reported as AVX_R12 and a failing handler as AVX_R09, each naming the hook
* or the statement, which is more specific than anything this method knows.
* Those keep their message and are not reported a second time.
*
* The call sites that pass nothing are the ones where this method is the only
* thing standing between an error and silence -- a failed render above all.
* @param {Error} error - The error that occurred.
* @param {string} origin - Description of where the error occurred.
* @param {boolean} [logged] - Whether the caller already logged this error.
* @private
*/
#reportError(error, origin, logged = false) {
let current = this;
let currentError = error;
while (current) {
const hooks = [];
for (const mixin of globalMixins) {
if (mixin['onErrorCaptured'] && typeof mixin['onErrorCaptured'] === 'function') {
hooks.push(mixin['onErrorCaptured'].bind(current));
} else if (mixin.methods && mixin.methods['onErrorCaptured'] && typeof mixin.methods['onErrorCaptured'] === 'function') {
hooks.push(mixin.methods['onErrorCaptured'].bind(current));
}
}
const selfHook = current.#methods['onErrorCaptured'] || (typeof current['onErrorCaptured'] === 'function' ? current['onErrorCaptured'].bind(current) : null);
if (selfHook) {
hooks.push(selfHook);
}
if (hooks.length > 0) {
let stopPropagation = false;
for (const hook of hooks) {
try {
const result = hook(currentError, this, origin);
if (result === false) {
stopPropagation = true;
}
} catch (hookError) {
currentError = hookError;
origin = 'onErrorCaptured hook';
}
}
if (stopPropagation) {
return;
}
}
current = current.$parent;
}
const app = this.$app;
if (app && typeof app._handleError === 'function') {
app._handleError(currentError, this, origin, logged);
return;
}
if (logged) {
// The call site already said what happened, with a diagnostic more
// specific than this one.
return;
}
// No application is reachable -- a component mounted on its own, or one
// whose element is not in the document yet. `$app` walks the parent chain
// and then the DOM, so this is uncommon, but it used to be the second way
// an error could disappear: the chain above found no `onErrorCaptured`,
// there was nothing to hand the error to, and the method simply ended.
// Silence has to cost something, so it costs a report here as well.
logger.error(
formatMessage(
AvenxErrorCodes.COMPONENT_RENDER_ABORTED,
this.constructor?.name || 'component',
origin || 'render',
(currentError && (currentError.stack || currentError.message)) || String(currentError),
),
);
}
/**
* Syncs the value property of select elements to match their value attribute
* after child nodes and lists are fully patched/rendered.
* @private
*/
#syncSelectElements() {
if (!this.#element || typeof this.#element.querySelectorAll !== 'function') return;
const selects = this.#element.querySelectorAll('select') || [];
const isSelect = typeof this.#element.getAttribute === 'function' && this.#element.tagName === 'SELECT';
const allSelects = isSelect ? [this.#element, ...selects] : selects;
for (const select of allSelects) {
if (typeof select.getAttribute === 'function') {
const valueAttr = select.getAttribute('value');
if (valueAttr !== null) {
if (select.value !== valueAttr) {
select.value = valueAttr;
}
}
}
}
}
/**
* Suspends the component rendering and shows the fallback UI if available.
* @param {Promise<any>} promise
* @private
*/
#suspend(promise) {
if (!this.#element) return;
const fallbackMatch = this.#template.match(/<template data-ax-fallback[^>]*>([\s\S]*?)<\/template>/i);
if (!fallbackMatch) {
if (this.$parent) {
this.$parent.#suspend(promise);
}
return;
}
const fallbackHtml = fallbackMatch[1].replace(/\{%/g, '{{').replace(/%\}/g, '}}');
try {
const renderedFallback = this.#renderer.render(fallbackHtml, (expression) => this.#resolveTemplateExpression(expression));
this.#patcher.patch(this.#element, renderedFallback, (expression, slotScope) => this.#resolveTemplateExpression(expression, slotScope), this.$app);
this.#fillSlots();
this.#listManager.process(this.#element, this.#createScope(), this.state, this.$app);
this.#eventBinder.bind(this.#element, this.#eventExecutor);
} catch (error) {
if (error instanceof Promise) {
if (this.$parent) {
this.$parent.#suspend(error);
}
} else {
this.#handleComponentError(error);
}
}
promise.finally(() => {
this.update();
});
}
/**
* Handles component rendering errors using error boundaries.
* @param {Error} error
* @private
*/
#handleComponentError(error) {
if (!this.#element) return;
this.#componentError = error;
let errorAs = 'error';
const asMatch = this.#template.match(/data-ax-error-as="([^"]*)"/i);
if (asMatch) {
errorAs = asMatch[1];
}
const fallbackMatch = this.#template.match(/<template data-ax-error-fallback[^>]*>([\s\S]*?)<\/template>/i);
if (!fallbackMatch) {
if (this.$parent) {
this.$parent.#handleComponentError(error);
} else {
this.#reportError(error, 'runUpdate');
}
return;
}
const fallbackHtml = fallbackMatch[1].replace(/\{%/g, '{{').replace(/%\}/g, '}}');
const extraScope = { [errorAs]: error };
try {
const renderedFallback = this.#renderer.render(fallbackHtml, (expression) => this.#resolveTemplateExpression(expression, extraScope));
this.#patcher.patch(this.#element, renderedFallback, (expression, slotScope) => this.#resolveTemplateExpression(expression, { ...extraScope, ...slotScope }), this.$app);
this.#fillSlots();
this.#listManager.process(this.#element, this.#createScope(this.#methods, extraScope), this.state, this.$app);
this.#eventBinder.bind(this.#element, this.#eventExecutor);
} catch (e) {
if (this.$parent) {
this.$parent.#handleComponentError(e);
} else {
this.#reportError(e, 'ErrorBoundary');
}
}
}
/**
* Registers a global mixin.
* @param {object} mixin - The mixin definition.
*/
static mixin(mixin) {
if (mixin && typeof mixin === 'object') {
globalMixins.push(mixin);
}
}
/**
* Resets/clears the global mixins list.
* Useful for testing environments.
*/
static clearMixins() {
globalMixins.length = 0;
}
/**
* Helper method to programmatically create component subclasses without ES class boilerplate.
* @param {object} [options] - Component definition options.
* @returns {Function} The generated component subclass.
*/
static extend(options = {}) {
const SuperClass = this;
const parentOptions = SuperClass.__options || {};
const componentName =
options.name ||
(SuperClass.name && SuperClass.name !== 'AvenxComponent'
? `${SuperClass.name}Extended`
: 'ExtendedComponent');
const reservedKeys = new Set([
'name',
'state',
'data',
'computed',
'template',
'methods',
'props',
'styles',
'resources',
'contracts',
'watch',
'provide',
'inject',
'options',
]);
const lifecycleHookNames = new Set([
'onBeforeMount',
'onMount',
'onBeforeUpdate',
'onUpdate',
'onUnmount',
'onActivate',
'onDeactivate',
'onErrorCaptured',
'onEnter',
'onLeave',
'onBeforeLeave',
]);
// Merge methods
const explicitMethods = {
...(parentOptions.methods || {}),
...(options.methods || {}),
};
// Extract top-level methods & lifecycle hooks
for (const [key, value] of Object.entries(options)) {
if (!reservedKeys.has(key) && typeof value === 'function') {
explicitMethods[key] = value;
}
}
// Separate regular methods (for #methods/super) and lifecycle hooks
const regularMethods = {};
for (const [key, fn] of Object.entries(explicitMethods)) {
if (!lifecycleHookNames.has(key)) {
regularMethods[key] = fn;
}
}
const finalComputed = {
...(parentOptions.computed || {}),
...(options.computed || {}),
};
const finalTemplate =
options.template !== undefined
? options.template
: (parentOptions.template !== undefined ? parentOptions.template : '');
const finalProps = {
...(parentOptions.props || {}),
...(options.props || {}),
};
const finalStyles = {
...(parentOptions.styles || {}),
...(options.styles || {}),
};
const finalResources = {
...(parentOptions.resources || {}),
...(options.resources || {}),
};
const finalWatch = {
...(parentOptions.watch || {}),
...(options.watch || {}),
};
const parentContracts = Array.isArray(parentOptions.contracts)
? parentOptions.contracts
: (parentOptions.contracts instanceof Set ? Array.from(parentOptions.contracts) : []);
const childContracts = Array.isArray(options.contracts)
? options.contracts
: (options.contracts instanceof Set ? Array.from(options.contracts) : []);
const finalContracts = Array.from(new Set([...parentContracts, ...childContracts]));
const finalOptions = {
...(parentOptions.options || {}),
...(options.options || {}),
contracts: finalContracts,
watch: finalWatch,
};
/**
* Resolves and clones initial state from a state/data definition.
* @param {object|Function} stateDef - The state definition.
* @returns {object} The resolved state object.
*/
function resolveState(stateDef) {
if (typeof stateDef === 'function') {
return stateDef();
}
if (stateDef && typeof stateDef === 'object') {
try {
if (typeof globalThis !== 'undefined' && typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(stateDef);
}
return JSON.parse(JSON.stringify(stateDef));
} catch {
return { ...stateDef };
}
}
return {};
}
const ExtendedComponent = {
[componentName]: class extends SuperClass {
/**
* @param {...any} args - Constructor arguments.
*/
constructor(...args) {
let runtimeBridges = {};
let runtimeProps = {};
if (args.length >= 8) {
const [cState, cComputed, cBridges, cTemplate, cMethods, cProps, cStyles, cResources, cOptions] = args;
const parentDefState = resolveState(parentOptions.state || parentOptions.data);
const childDefState = resolveState(options.state || options.data);
const mergedState = { ...parentDefState, ...childDefState, ...(cState || {}) };
const mergedComputed = { ...finalComputed, ...(cComputed || {}) };
const mergedTemplate = cTemplate !== undefined ? cTemplate : finalTemplate;
const mergedMethods = { ...regularMethods, ...(cMethods || {}) };
const mergedProps = { ...finalProps, ...(cProps || {}) };
const mergedStyles = { ...finalStyles, ...(cStyles || {}) };
const mergedResources = { ...finalResources, ...(cResources || {}) };
const mergedOptions = { ...finalOptions, ...(cOptions || {}) };
super(
mergedState,
mergedComputed,
cBridges || {},
mergedTemplate,
mergedMethods,
mergedProps,
mergedStyles,
mergedResources,
mergedOptions
);
return;
}
if (args.length === 1) {
if (args[0] && typeof args[0] === 'object') {
runtimeBridges = args[0];
runtimeProps = args[0];
}
} else if (args.length >= 2) {
runtimeBridges = args[0] || {};
runtimeProps = args[1] || {};
}
const parentDefState = resolveState(parentOptions.state || parentOptions.data);
const childDefState = resolveState(options.state || options.data);
const mergedState = { ...parentDefState, ...childDefState };
const mergedProps = { ...finalProps, ...runtimeProps };
super(
mergedState,
finalComputed,
runtimeBridges,
finalTemplate,
regularMethods,
mergedProps,
finalStyles,
finalResources,
finalOptions
);
}
},
}[componentName];
Object.defineProperty(ExtendedComponent, 'name', {
value: componentName,
configurable: true,
});
ExtendedComponent.__isExtendedComponent = true;
ExtendedComponent.__options = {
name: componentName,
state: options.state || options.data || parentOptions.state || parentOptions.data,
computed: finalComputed,
methods: explicitMethods,
template: finalTemplate,
props: finalProps,
styles: finalStyles,
resources: finalResources,
watch: finalWatch,
contracts: finalContracts,
options: finalOptions,
provide: options.provide || parentOptions.provide,
inject: options.inject || parentOptions.inject,
};
// Attach all methods & lifecycle hooks to ExtendedComponent.prototype
for (const [name, fn] of Object.entries(explicitMethods)) {
if (typeof fn === 'function') {
ExtendedComponent.prototype[name] = fn;
}
}
// Attach computed getters to ExtendedComponent.prototype
for (const name of Object.keys(finalComputed)) {
if (!(name in ExtendedComponent.prototype)) {
Object.defineProperty(ExtendedComponent.prototype, name, {
get() {
return this.state ? this.state[name] : undefined;
},
configurable: true,
enumerable: true,
});
}
}
// Attach provide & inject
if (options.provide || parentOptions.provide) {
ExtendedComponent.prototype.provide = options.provide || parentOptions.provide;
}
if (options.inject || parentOptions.inject) {
ExtendedComponent.prototype.inject = options.inject || parentOptions.inject;
}
// Copy any custom non-function / extra properties from options to prototype
for (const [key, value] of Object.entries(options)) {
if (!reservedKeys.has(key) && typeof value !== 'function') {
ExtendedComponent.prototype[key] = value;
}
}
return ExtendedComponent;
}
}