apprun
Version:
JavaScript library that has Elm inspired architecture, event pub-sub and components
389 lines (350 loc) • 12.4 kB
text/typescript
/**
* AppRun Component System Implementation
*
* This file provides the Component class which is the foundation for:
* 1. State Management
* - Maintains component state with history support
* - Handles state updates with async/iterator support
* - Supports state history navigation (prev/next)
* - Promise and async iterator state handling
*
* 2. View Rendering
* - Renders virtual DOM to real DOM with directives
* - Handles component lifecycle (mounted, rendered, unload)
* - Supports shadow DOM and web components
* - DOM change tracking with MutationObserver
* - View transition API support
*
* 3. Event Handling
* - Local and global event subscription management
* - Event handler registration via decorators
* - Action to state updates with error handling
* - Support for event options (delay, once, global)
*
* Features:
* - Component caching for debugging
* - Element tracking for cleanup
* - History navigation support
* - Global vs local event routing
* - Async state handling
* - Memory leak prevention
* - Component unmounting with cleanup
*
* Type Safety Improvements (v3.35.1):
* - Enhanced element access with null checks and warnings
* - Improved action validation and error handling
* - Better error reporting in component actions
* - Safer DOM element queries with fallback warnings
*
* Usage:
* ```ts
* class MyComponent extends Component {
* state = // Initial state
* view = state => // Return virtual DOM
* update = {
* 'event': (state, ...args) => // Return new state
* }
* }
*
* // Mount component
* new MyComponent().mount('element-id');
* ```
*/
import _app, { App } from './app';
import { Reflect } from './decorator'
import { State, View, Update, ActionDef, ActionOptions, MountOptions, EventOptions, IApp } from './types';
import directive from './directive';
import { safeQuerySelector, safeGetElementById } from './type-utils';
// const componentCache = new Map();
// if (!app.find('get-components')) app.on('get-components', o => o.components = componentCache);
export const REFRESH = state => state;
const app = _app as unknown as IApp;
export class Component<T = unknown, E = unknown> {
static __isAppRunComponent = true;
private _app = new App();
private _actions = [];
private _global_events = [];
private _state;
private _history = [];
private _history_idx = -1;
private enable_history;
private global_event;
public element;
public rendered;
public mounted;
public unload;
private tracking_id;
private observer;
private renderState(state: T, vdom = null) {
if (!this.view) return;
let html = vdom || this.view(state);
app['debug'] && app.run('debug', {
component: this,
_: html ? '.' : '-',
state,
vdom: html,
el: this.element
});
if (typeof document !== 'object') return;
const el = (typeof this.element === 'string' && this.element) ?
safeGetElementById(this.element) || safeQuerySelector(this.element) : this.element;
if (!el) {
console.warn(`Component element not found: ${this.element}`);
return;
}
const tracking_attr = '_c';
if (!this.unload) {
el.removeAttribute && el.removeAttribute(tracking_attr);
} else if (el['_component'] !== this || el.getAttribute(tracking_attr) !== this.tracking_id) {
this.tracking_id = new Date().valueOf().toString();
el.setAttribute(tracking_attr, this.tracking_id);
if (typeof MutationObserver !== 'undefined') {
if (!this.observer) this.observer = new MutationObserver(changes => {
if (changes[0].oldValue === this.tracking_id || !document.body.contains(el)) {
this.unload(this.state);
this.observer.disconnect();
this.observer = null;
}
});
this.observer.observe(document.body, {
childList: true, subtree: true,
attributes: true, attributeOldValue: true, attributeFilter: [tracking_attr]
});
}
}
el['_component'] = this;
if (!vdom && html) {
html = directive(html, this);
if (this.options.transition && document && document['startViewTransition']) {
document['startViewTransition'](() => app.render(el, html, this));
} else {
app.render(el, html, this);
}
}
this.rendered && this.rendered(this.state);
}
public setState(state: T, options: ActionOptions & EventOptions
= { render: true, history: false }) {
const handleAsyncIterator = async (iterator: AsyncIterator<T>) => {
try {
while (true) {
const { value, done } = await iterator.next();
if (done) break;
this.setState(value, options);
}
} catch (e) {
console.error('Error in async iterator:', e);
}
};
const result = state as any;
if (result?.[Symbol.asyncIterator]) {
// handleAsyncIterator(result[Symbol.asyncIterator]());
this.setState(handleAsyncIterator(result[Symbol.asyncIterator]()) as any, options);
return;
} else if (result?.[Symbol.iterator] && typeof result.next === "function") {
for (const value of result) {
this.setState(value, options);
}
return;
} else if (state && state instanceof Promise) {
// Promise will not be saved or rendered
// state will be saved and rendered when promise is resolved
Promise.resolve(state).then(v => {
this.setState(v, options);
this._state = state;
});
} else {
this._state = state;
if (state == null) return;
this.state = state;
if (options.render !== false) {
// before render state
if (options.transition && document && document['startViewTransition']) {
document['startViewTransition'](() => this.renderState(state));
} else {
this.renderState(state);
}
}
if (options.history !== false && this.enable_history) {
this._history = [...this._history, state];
this._history_idx = this._history.length - 1;
}
if (typeof options.callback === 'function') options.callback(this.state);
}
}
private _history_prev = () => {
this._history_idx--;
if (this._history_idx >= 0) {
this.setState(this._history[this._history_idx], { render: true, history: false });
}
else {
this._history_idx = 0;
}
};
private _history_next = () => {
this._history_idx++;
if (this._history_idx < this._history.length) {
this.setState(this._history[this._history_idx], { render: true, history: false });
}
else {
this._history_idx = this._history.length - 1;
}
};
constructor(
protected state?: State<T>,
protected view?: View<T>,
protected update?: Update<T, E>,
protected options?) {
}
start = (element = null, options?: MountOptions): Component<T, E> => {
this.mount(element, { render: true, ...options });
if (this.mounted && typeof this.mounted === 'function') {
const new_state = this.mounted({}, [], this.state);
(typeof new_state !== 'undefined') && this.setState(new_state);
}
return this;
}
public mount(element = null, options?: MountOptions): Component<T, E> {
console.assert(!this.element, 'Component already mounted.')
this.options = options = { ...this.options, ...options };
this.element = element;
this.global_event = options.global_event;
this.enable_history = !!options.history;
if (this.enable_history) {
this.on(options.history.prev || 'history-prev', this._history_prev);
this.on(options.history.next || 'history-next', this._history_next);
}
if (options.route) {
this.update = this.update || {};
if (!this.update[options.route]) this.update[options.route] = REFRESH;
}
this.add_actions();
this.state = this.state ?? this['model'] ?? {};
if (typeof this.state === 'function') this.state = (this.state as Function)();
this.setState(this.state as T, { render: !!options.render, history: true });
if (app['debug'] && app.find('debug-create-component')?.length) {
app.run('debug-create-component', this);
}
return this;
}
is_global_event(name: string): boolean {
return name && (
this.global_event ||
this._global_events.indexOf(name) >= 0 ||
name.startsWith('#') || name.startsWith('/') || name.startsWith('@'));
}
add_action(name: string, action, options: ActionOptions = {}) {
if (!action || typeof action !== 'function') {
console.warn(`Component action for '${name}' is not a valid function:`, action);
return;
}
if (options.global) this._global_events.push(name);
this.on(name as any, (...p) => {
app['debug'] && app.run('debug', {
component: this,
_: '>',
event: name, p,
current_state: this.state,
options
});
try {
const newState = action(this.state, ...p);
app['debug'] && app.run('debug', {
component: this,
_: '<',
event: name, p,
newState,
state: this.state,
options
});
this.setState(newState, options);
} catch (error) {
console.error(`Error in component action '${name}':`, error);
app['debug'] && app.run('debug', {
component: this,
_: '!',
event: name, p,
error,
state: this.state,
options
});
}
}, options);
}
add_actions() {
const actions = this.update || {};
Reflect.getMetadataKeys(this).forEach(key => {
if (key.startsWith('apprun-update:')) {
const meta = Reflect.getMetadata(key, this)
actions[meta.name] = [this[meta.key].bind(this), meta.options];
}
})
const all = {};
if (Array.isArray(actions)) {
actions.forEach(act => {
const [name, action, opts] = act as ActionDef<T, E>;
const names = name.toString();
names.split(',').forEach(n => all[n.trim()] = [action, opts])
})
} else {
Object.keys(actions).forEach(name => {
const action = actions[name];
if (typeof action === 'function' || Array.isArray(action)) {
name.split(',').forEach(n => all[n.trim()] = action)
}
})
}
if (!all['.']) all['.'] = REFRESH;
Object.keys(all).forEach(name => {
const action = all[name];
if (typeof action === 'function') {
this.add_action(name, action);
} else if (Array.isArray(action)) {
this.add_action(name, action[0], action[1]);
}
});
}
public run(event: E, ...args) {
if (this.state instanceof Promise) {
return Promise.resolve(this.state).then(state => {
this.state = state;
this.run(event, ...args)
});
} else {
const name = event.toString();
return this.is_global_event(name) ?
app.run(name, ...args) :
this._app.run(name, ...args);
}
}
public on(event: E, fn: (...args) => void, options?: any) {
const name = event.toString();
this._actions.push({ name, fn });
return this.is_global_event(name) ?
app.on(name, fn, options) :
this._app.on(name, fn, options);
}
public runAsync(event: E, ...args) {
const name = event.toString();
return this.is_global_event(name) ?
app.runAsync(name, ...args) :
this._app.runAsync(name, ...args);
}
// obsolete
/**
* @deprecated Use runAsync() instead. query() will be removed in a future version.
*/
public query(event: E, ...args) {
console.warn('component.query() is deprecated. Use component.runAsync() instead.');
return this.runAsync(event, ...args);
}
public unmount() {
this.observer?.disconnect();
this._actions.forEach(action => {
const { name, fn } = action;
this.is_global_event(name) ?
app.off(name, fn) :
this._app.off(name, fn);
});
}
}