import { AvenxRouter } from './AvenxRouter.js';
import { AvenxError, AvenxErrorCodes, formatMessage } from './AvenxError.js';
import { initInspector } from '../tooling/inspect.js';
import { ProxyHandlerFactory } from '../reactive/proxyHandler.js';
import { isBridge } from './bridge.js';
import { getBuiltins } from './builtins.js';
import { AvenxComponent } from './AvenxComponent.js';
import { requireStringRenderer } from '../renderer/stringRenderer.js';
import { LruCache } from '../utils/LruCache.js';
import { logger } from './AvenxLogger.js';
/**
* The main application class for Avenx.
* Manages component registration, bridge registration, and mounting.
*/
export class AvenxApp {
/** @type {AvenxComponent[]} */
#activeComponents = [];
/** @type {Element|null} */
#target = null;
/** @type {Function[]} */
#errorHandlers = [];
/** @type {Function[]} */
#warnHandlers = [];
/** @type {Set<any>} */
#installedPlugins = new Set();
/** @type {LruCache|null} */
#pageCache = null;
/**
* @param {object} config - Application configuration.
* @param {string} config.target - Selector for the main application container.
*/
constructor(config) {
this.#target = document.querySelector(config.target);
if (!this.#target) {
throw new AvenxError(AvenxErrorCodes.MOUNT_TARGET_NOT_FOUND, config.target);
}
/** @type {Map<string, Function>} */
this.components = new Map();
// Built-ins are adopted from the registry rather than imported here. A
// direct import made every application carry the virtual list, the template
// renderer and the DOM patcher whether or not it wrote `<VirtualList>`; the
// compiler now adds the registering module when it sees the tag.
for (const [name, componentClass] of getBuiltins()) {
this.components.set(name, componentClass);
}
/** @type {Map<string, Function>} */
this.pages = new Map();
/** @type {Map<string, object>} */
this.directives = new Map();
/** @type {object} */
this.bridges = {};
/** @type {AvenxRouter|null} */
this.router = null;
this.updateAll = this.updateAll.bind(this);
if (config.logging) {
logger.configure(config.logging);
}
if (config.errorHandler) {
this.onError(config.errorHandler);
}
if (config.warnHandler) {
this.onWarn(config.warnHandler);
}
/** @type {boolean} */
this.enableProfiling = !!config.enableProfiling;
if (this.enableProfiling && typeof window !== 'undefined') {
window.__avenx_enable_profiling = true;
}
initInspector(this);
const keepAliveLimit = config.keepAliveLimit !== undefined ? config.keepAliveLimit : 5;
this.#pageCache = new LruCache(keepAliveLimit, (pageName, cachedVal) => {
if (cachedVal && cachedVal.pageInstance && typeof cachedVal.pageInstance.unmount === 'function') {
cachedVal.pageInstance.unmount();
}
});
}
/**
* Registers a plugin with the application. Supports synchronous plugins, async installer functions,
* dynamic import loader functions, or Promises.
* @param {object | Function | Promise} plugin - The plugin to install or async loader function.
* @param {object} [options] - Optional configurations for the plugin.
* @returns {AvenxApp | Promise<AvenxApp>} The app instance or a Promise resolving to the app instance.
*/
use(plugin, options = {}) {
if (this.#installedPlugins.has(plugin)) {
logger.warn('Plugin already installed.');
return this;
}
const installResolved = (resolvedPlugin) => {
let target = resolvedPlugin;
if (target && target.__esModule && target.default !== undefined) {
target = target.default;
} else if (
target &&
typeof target === 'object' &&
'default' in target &&
(typeof target.default === 'function' || (target.default && typeof target.default.install === 'function'))
) {
target = target.default;
}
if (!target) return this;
if (this.#installedPlugins.has(target)) {
logger.warn('Plugin already installed.');
return this;
}
this.#installedPlugins.add(target);
if (typeof target === 'function') {
return target(this, options);
} else if (target && typeof target.install === 'function') {
return target.install(this, options);
} else {
throw new Error('Plugin must be a function or an object with an install method.');
}
};
if (plugin && typeof plugin.then === 'function') {
this.#installedPlugins.add(plugin);
return plugin.then((res) => {
const out = installResolved(res);
return out && typeof out.then === 'function' ? out.then(() => this) : this;
});
}
if (typeof plugin === 'function') {
this.#installedPlugins.add(plugin);
// The plugin is invoked exactly once. Retrying it after a failure — which
// this previously did — replayed whatever side effects it had already
// performed (registered directives, components, bridges) and discarded
// the original error, leaving no way to debug a broken plugin.
const res = plugin(this, options);
if (res && typeof res.then === 'function') {
return res.then((resolved) => {
if (resolved !== undefined && resolved !== null) {
const out = installResolved(resolved);
return out && typeof out.then === 'function' ? out.then(() => this) : this;
}
return this;
});
}
return this;
}
if (plugin && typeof plugin.install === 'function') {
this.#installedPlugins.add(plugin);
const res = plugin.install(this, options);
if (res && typeof res.then === 'function') {
return res.then(() => this);
}
return this;
}
throw new Error('Plugin must be a function or an object with an install method.');
}
/**
* Registers a global mixin.
* @param {object} mixin - The mixin definition.
* @returns {AvenxApp} The app instance.
*/
mixin(mixin) {
AvenxComponent.mixin(mixin);
return this;
}
/**
* Registers an application-wide error handler.
* @param {function(Error, AvenxComponent, string): void} callback - The error callback.
* @returns {AvenxApp} The app instance.
*/
onError(callback) {
if (typeof callback === 'function') {
this.#errorHandlers.push(callback);
}
return this;
}
/**
* Invokes all registered error handlers safely, and reports what none claimed.
*
* This method used to be the loop alone. An application that registers no
* handler is the default -- `avenx init` writes no `onError` call and nothing
* requires one -- so the loop body never ran and the error was discarded.
* What the developer saw was a component that mounted and rendered nothing:
* no exception, no console output, no warning. A rendering path can break
* completely and look exactly like an application with nothing to show.
*
* So an error that reaches here unclaimed is reported. `logger.error` rather
* than a rethrow, deliberately: this is called from inside a `catch` that has
* already decided the component cannot render, and throwing again from there
* would tear down the rest of the flush -- turning one blank component into a
* blank page. Reporting keeps the failure local and visible.
*
* "Unclaimed" is the precise condition. An application that registered a
* handler has said it will deal with its own errors, and an
* `onErrorCaptured` hook that stopped propagation never reaches this method
* at all.
* @param {Error} error - The error that occurred.
* @param {AvenxComponent} component - The component instance where the error occurred.
* @param {string} origin - Description of the origin/lifecycle/event.
* @param {boolean} [logged] - Whether the call site already reported this
* error with a diagnostic of its own. A failing lifecycle hook is already
* AVX_R12 and a failing handler already AVX_R09; repeating them as AVX_R33
* would print the same failure twice under two codes.
* @private
*/
_handleError(error, component, origin, logged = false) {
if (this.#errorHandlers.length === 0 && !logged) {
logger.error(
formatMessage(
AvenxErrorCodes.COMPONENT_RENDER_ABORTED,
(component && component.constructor && component.constructor.name) || 'component',
origin || 'render',
(error && (error.stack || error.message)) || String(error),
),
);
return;
}
for (const handler of this.#errorHandlers) {
try {
handler(error, component, origin);
} catch (e) {
logger.error(`Error in global error handler: ${e.message || e}`);
}
}
}
/**
* Registers an application-wide warning handler.
* @param {function(string, AvenxComponent, string): void} callback - The warning callback.
* @returns {AvenxApp} The app instance.
*/
onWarn(callback) {
if (typeof callback === 'function') {
this.#warnHandlers.push(callback);
logger.configure({
warnHandler: (msg, context) => {
this._handleWarn(msg, context);
},
});
}
return this;
}
/**
* Invokes all registered warning handlers safely.
* @param {string} msg - The warning message.
* @param {object} context - Context metadata or component instance.
* @private
*/
_handleWarn(msg, context) {
const comp = context && (context.component || (context instanceof AvenxComponent ? context : null));
const codeMatch = msg && typeof msg === 'string' ? msg.match(/\[(AVX_[A-Z0-9]+)\]/) : null;
const code = codeMatch ? codeMatch[1] : undefined;
for (const handler of this.#warnHandlers) {
try {
handler(msg, comp, code);
} catch (e) {
logger.error(`Error in global warning handler: ${e.message || e}`);
}
}
}
/**
* Registers a component with the application.
* @param {string} name - The name of the component.
* @param {Function} compClass - The component class.
*/
register(name, compClass) {
if (typeof name !== 'string' || name.trim().length === 0) {
const msg = `Component name must be a non-empty string. received: "${JSON.stringify(name)}"`;
logger.warn(msg);
throw new Error(msg);
}
const isFunction = typeof compClass === 'function';
const extendsBase = isFunction && (compClass === AvenxComponent || compClass.prototype instanceof AvenxComponent);
if (!isFunction || !extendsBase) {
const receivedType = isFunction ? (compClass.name || 'anonymous function') : typeof compClass;
const msg = `Component "${name}" must be a class extending AvenxComponent. received: "${receivedType}"`;
logger.warn(msg);
throw new Error(msg);
}
if (this.components.has(name)) {
throw new AvenxError(AvenxErrorCodes.COMPILER_DUPLICATE_COMPONENT_NAME, ` "${name}"`);
}
this.components.set(name, compClass);
}
/**
* Registers a page with the application.
* @param {string} name - The name of the page.
* @param {Function} pageClass - The page class.
*/
registerPage(name, pageClass) {
if (typeof name !== 'string' || name.trim().length === 0) {
const msg = `Page name must be a non-empty string. received: "${JSON.stringify(name)}"`;
logger.warn(msg);
throw new Error(msg);
}
const isFunction = typeof pageClass === 'function';
const extendsBase = isFunction && (pageClass === AvenxComponent || pageClass.prototype instanceof AvenxComponent);
if (!isFunction || !extendsBase) {
const receivedType = isFunction ? (pageClass.name || 'anonymous function') : typeof pageClass;
const msg = `Page "${name}" must be a class extending AvenxComponent. received: "${receivedType}"`;
logger.warn(msg);
throw new Error(msg);
}
if (this.pages.has(name)) {
logger.warn(formatMessage(AvenxErrorCodes.PAGE_ALREADY_REGISTERED, name));
}
this.pages.set(name, pageClass);
}
/**
* Returns the names of all registered components.
* @returns {string[]} Registered component names.
*/
getRegisteredComponents() {
return Array.from(this.components.keys());
}
/**
* Returns the names of all registered pages.
* @returns {string[]} Registered page names.
*/
getRegisteredPages() {
return Array.from(this.pages.keys());
}
/**
* Retrieves the currently active page component instance.
* @returns {AvenxComponent|null}
*/
get activePage() {
return this.#activeComponents[this.#activeComponents.length - 1] || null;
}
/**
* Registers a custom directive with the application.
* @param {string} name - The name of the directive.
* @param {object} definition - The directive definition with lifecycle hooks.
* @returns {AvenxApp} The app instance.
*/
directive(name, definition) {
this.directives.set(name, definition);
return this;
}
/**
* Initializes the router for the application.
* @param {Object<string, string>} routes - Route mapping.
* @param {object} [options] - Router options.
* @returns {AvenxRouter} The router instance.
*/
initRouter(routes, options = {}) {
this.router = new AvenxRouter(this, routes, options);
this.router.start();
return this.router;
}
/**
* Registers a bridge with the application.
* Bridges provide shared state and logic across components.
* @param {string} name - The name of the bridge.
* @param {object} bridgeData - A bridge() instance or a plain reactive object.
*/
registerBridge(name, bridgeData) {
if (typeof name !== 'string' || name.trim().length === 0) {
const msg = `Bridge name must be a non-empty string. received: "${JSON.stringify(name)}"`;
logger.warn(msg);
throw new Error(msg);
}
if (Object.prototype.hasOwnProperty.call(this.bridges, name)) {
const availableBridges = Object.keys(this.bridges).join(',');
const suggestion = `Please use a unique name`;
throw new AvenxError(AvenxErrorCodes.BRIDGE_ALREADY_EXISTS, name, availableBridges || 'none', suggestion);
}
// A bridge() instance is already reactive and already read-only for
// consumers. Wrapping it again would defeat both, so it is indexed as-is;
// registration exists only so devtools can enumerate bridges.
if (isBridge(bridgeData)) {
this.bridges[name] = bridgeData;
return;
}
const handlerFactory = new ProxyHandlerFactory({
onChange: () => { },
});
const reactiveState = new Proxy(bridgeData, handlerFactory.create());
this.bridges[name] = reactiveState;
}
/**
* Updates all active components in the application.
*/
updateAll() {
this.#activeComponents.forEach((comp) => comp.update());
}
/**
* Mounts a page to the main application container.
* @param {string} name - The name of the page to mount.
* @param {object} [params] - Dynamic route parameters to inject.
* @param {object} [options] - Mount options, e.g., transition options.
*/
mountPage(name, params = {}, options = {}) {
const PageClass = this.pages.get(name);
if (!PageClass) {
throw new AvenxError(AvenxErrorCodes.PAGE_NOT_FOUND, name);
}
const LayoutClass = options.layout ? this.pages.get(options.layout) : null;
if (options.layout && !LayoutClass) {
throw new AvenxError(AvenxErrorCodes.PAGE_NOT_FOUND, options.layout);
}
if (this.#target) {
const activeLayout = this.#activeComponents[0];
const activeLayoutName = activeLayout ? activeLayout.$pageName : null;
const activeChild = this.#activeComponents[1];
const isSameLayout = activeLayout && LayoutClass && activeLayout instanceof LayoutClass;
const isSamePage = !options.layout && activeLayout && activeLayout instanceof PageClass;
const targetToUpdate = isSameLayout ? (activeChild && activeChild instanceof PageClass ? activeChild : null) : (isSamePage ? activeLayout : null);
if (targetToUpdate) {
targetToUpdate.$app = this;
let paramsChanged = false;
if (targetToUpdate.params) {
for (const key of Object.keys(targetToUpdate.params)) {
if (!(key in params)) {
delete targetToUpdate.state[key];
delete targetToUpdate.params[key];
paramsChanged = true;
}
}
} else {
targetToUpdate.params = {};
}
for (const [key, val] of Object.entries(params)) {
if (targetToUpdate.params[key] !== val) {
paramsChanged = true;
}
targetToUpdate.state[key] = val;
targetToUpdate.params[key] = val;
}
// A navigation that changed the route parameters re-renders the page it
// lands on, whether or not the page's template reads any of them: its
// lifecycle hooks are part of the contract, and onUpdate must run for
// the route now being shown. This used to happen implicitly, because
// every state write scheduled a render regardless of what observed it.
// Now that writes nothing depends on are skipped, the navigation has to
// say so -- and only when something actually changed, or a re-entry on
// identical parameters would start reporting an update that never used
// to happen.
if (paramsChanged && typeof targetToUpdate.scheduleUpdate === 'function') {
targetToUpdate.scheduleUpdate();
}
if (typeof targetToUpdate.onActivate === 'function') {
try {
targetToUpdate.onActivate(params);
} catch (err) {
logger.error('Error in onActivate hook:', err);
}
}
return;
}
const transitionName = options.transition;
let unmountPromise = null;
const toUnmount = [];
if (isSameLayout) {
if (activeChild) toUnmount.push(activeChild);
} else {
if (activeChild) toUnmount.push(activeChild);
if (activeLayout) toUnmount.push(activeLayout);
}
const unmountPromises = [];
for (const comp of toUnmount) {
if (comp === activeLayout && comp._isKeepAlive && this.#pageCache && activeLayoutName) {
const domFragment = document.createDocumentFragment();
const children = Array.from(this.#target.childNodes);
children.forEach((child) => domFragment.appendChild(child));
delete this.#target.__avenx_comp_instance;
comp.__setMountTarget(domFragment, true);
if (typeof comp.onDeactivate === 'function') {
try {
comp.onDeactivate();
} catch (err) {
logger.error('Error in onDeactivate hook:', err);
}
}
this.#pageCache.set(activeLayoutName, {
pageInstance: comp,
domFragment: domFragment
});
} else {
if (typeof comp.unmount === 'function') {
const res = comp.unmount();
if (res instanceof Promise) {
unmountPromises.push(res);
}
}
}
}
if (unmountPromises.length > 0) {
unmountPromise = Promise.all(unmountPromises);
}
const proceed = () => {
if (!isSameLayout) {
this.#activeComponents = [];
} else {
this.#activeComponents = [activeLayout];
}
let mountTarget = this.#target;
let wrapper = null;
if (isSameLayout) {
wrapper = activeLayout.$element ? activeLayout.$element.querySelector('[data-ax-router-view]') : null;
if (wrapper) {
mountTarget = wrapper;
} else {
// Fallback if slot wasn't rendered yet
wrapper = document.createElement('div');
wrapper.setAttribute('data-ax-router-view', 'true');
activeLayout.$element.appendChild(wrapper);
mountTarget = wrapper;
}
}
let exitWrapper = null;
if (transitionName && mountTarget.childNodes.length > 0 && mountTarget.parentNode) {
exitWrapper = document.createElement('div');
exitWrapper.className = 'ax-page-exit-wrapper';
const children = Array.from(mountTarget.childNodes);
children.forEach((child) => exitWrapper.appendChild(child));
mountTarget.parentNode.insertBefore(exitWrapper, mountTarget);
}
if (!isSameLayout) {
mountTarget.innerHTML = '';
}
const setupPage = (PClass, pName, pParams, mTarget, isTopLevel) => {
let inst;
const isCached = isTopLevel && this.#pageCache && this.#pageCache.has(pName);
if (isCached) {
const cached = this.#pageCache.get(pName);
this.#pageCache.delete(pName);
inst = cached.pageInstance;
const domFragment = cached.domFragment;
mTarget.appendChild(domFragment);
inst.__setMountTarget(mTarget, true);
} else {
inst = new PClass(this.bridges, this.components);
inst.$app = this;
inst.$pageName = pName;
inst._isKeepAlive = !!options.keepAlive;
inst.params = pParams;
for (const [key, val] of Object.entries(pParams)) {
inst.state[key] = val;
}
inst.mount(mTarget);
}
if (isCached) {
inst.params = pParams;
for (const [key, val] of Object.entries(pParams)) {
inst.state[key] = val;
}
}
if (typeof inst.onActivate === 'function') {
try {
inst.onActivate(pParams);
} catch (err) {
logger.error('Error in onActivate hook:', err);
}
}
this.#activeComponents.push(inst);
return inst;
};
if (LayoutClass && !isSameLayout) {
wrapper = document.createElement('div');
wrapper.setAttribute('data-ax-router-view', 'true');
this.#target.appendChild(wrapper);
const layoutInst = setupPage(LayoutClass, options.layout, params, this.#target, true);
// Re-find wrapper in case it moved to slot
wrapper = layoutInst.$element ? layoutInst.$element.querySelector('[data-ax-router-view]') : wrapper;
setupPage(PageClass, name, params, wrapper, false);
} else if (LayoutClass && isSameLayout) {
wrapper.innerHTML = '';
setupPage(PageClass, name, params, wrapper, false);
} else {
setupPage(PageClass, name, params, this.#target, true);
}
if (transitionName) {
// Route transitions are the router's only use of the patcher, and a
// transition is a template construct the IR does not lower yet -- so
// a build that has one has the string renderer anyway. Asking the
// registry keeps the router from being the reason an application
// that uses neither still carries 87 KB of it.
const patcher = new (requireStringRenderer().DomPatcher)();
if (exitWrapper) {
patcher.leave(exitWrapper, transitionName, () => {
if (exitWrapper.parentNode) {
exitWrapper.parentNode.removeChild(exitWrapper);
}
});
}
const targetForTransition = wrapper || this.#target;
const newPageChildren = Array.from(targetForTransition.childNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE,
);
newPageChildren.forEach((child) => {
patcher.enter(child, transitionName);
});
}
};
if (unmountPromise) {
unmountPromise.then(proceed);
} else {
proceed();
}
}
}
/**
* Mounts a component to a target element.
* @param {string} name - The name of the component to mount.
* @param {string|null} [targetSelector] - Optional selector for the mount target.
*/
mount(name, targetSelector = null) {
const Comp = this.components.get(name);
if (!Comp) {
const registeredList = Array.from(this.components.keys()).join(', ');
throw new AvenxError(AvenxErrorCodes.COMPONENT_NOT_FOUND, name, registeredList);
}
const target = targetSelector ? document.querySelector(targetSelector) : this.#target;
if (!target) {
throw new AvenxError(AvenxErrorCodes.MOUNT_TARGET_NOT_FOUND, targetSelector || 'default target');
}
const compInstance = new Comp(this.bridges);
compInstance.$app = this;
compInstance.mount(target);
this.#activeComponents.push(compInstance);
}
/**
* Programmatically clears cached KeepAlive component instances.
* If componentName is specified, invalidates only that cached instance.
* If omitted, purges all cached entries.
* Unmounts cached instances and destroys their cached DOM trees.
* @param {string} [componentName] - Optional name of component/page to clear from cache.
* @returns {boolean} True if cache entries were evicted, false otherwise.
*/
clearKeepAliveCache(componentName) {
if (!this.#pageCache) return false;
let evicted = false;
if (componentName !== undefined && componentName !== null && componentName !== '') {
let keyToRemove = null;
if (this.#pageCache.has(componentName)) {
keyToRemove = componentName;
} else {
for (const [key, cached] of this.#pageCache.cache.entries()) {
if (
cached &&
cached.pageInstance &&
(cached.pageInstance.$pageName === componentName ||
cached.pageInstance.constructor?.name === componentName)
) {
keyToRemove = key;
break;
}
}
}
if (keyToRemove) {
const cached = this.#pageCache.get(keyToRemove);
this.#pageCache.delete(keyToRemove);
if (cached && cached.pageInstance && typeof cached.pageInstance.unmount === 'function') {
cached.pageInstance.unmount();
}
evicted = true;
}
} else {
const entries = Array.from(this.#pageCache.cache.entries());
if (entries.length > 0) {
evicted = true;
}
for (const [key, cached] of entries) {
this.#pageCache.delete(key);
if (cached && cached.pageInstance && typeof cached.pageInstance.unmount === 'function') {
cached.pageInstance.unmount();
}
}
this.#pageCache.clear();
}
return evicted;
}
}