UNPKG

mancha

Version:

Javscript HTML rendering engine

352 lines 12.6 kB
import * as expressions from "./expressions/index.js"; class IDebouncer { timeouts = new Map(); debounce(millis, callback) { return new Promise((resolve, reject) => { const timeout = this.timeouts.get(callback); if (timeout) clearTimeout(timeout); this.timeouts.set(callback, setTimeout(() => { try { resolve(callback()); this.timeouts.delete(callback); } catch (exc) { reject(exc); } }, millis)); }); } } /** Default debouncer time in millis. */ export const REACTIVE_DEBOUNCE_MILLIS = 10; /** Shared AST factory. */ const AST_FACTORY = new expressions.EvalAstFactory(); /** Symbol used to identify proxified objects. */ const PROXY_MARKER = "__is_proxy__"; function isProxified(object) { return object instanceof SignalStore || object[PROXY_MARKER] === true; } export function getAncestorValue(store, key) { const map = store?._store; if (map?.has(key)) { return map.get(key); } else if (map?.has("$parent")) { return getAncestorValue(map.get("$parent"), key); } else { return undefined; } } export function getAncestorKeyStore(store, key) { const map = store?._store; if (map?.has(key)) { return store; } else if (map?.has("$parent")) { return getAncestorKeyStore(map.get("$parent"), key); } else { return null; } } export function setAncestorValue(store, key, value) { const ancestor = getAncestorKeyStore(store, key); if (ancestor) { ancestor._store.set(key, value); } else { store._store.set(key, value); } } export function setNestedProperty(obj, path, value) { const keys = path.split("."); let current = obj; for (let i = 0; i < keys.length - 1; i++) { if (!(keys[i] in current)) current[keys[i]] = {}; current = current[keys[i]]; } current[keys[keys.length - 1]] = value; } export class SignalStore extends IDebouncer { evalkeys = ["$elem", "$event"]; expressionCache = new Map(); observers = new Map(); keyHandlers = new Map(); _observer = null; _store = new Map(); _lock = Promise.resolve(); constructor(data) { super(); for (const [key, value] of Object.entries(data || {})) { // Use our set method to ensure that callbacks and wrappers are appropriately set, but ignore // the return value since we know that no observers will be triggered. this.set(key, value); } } // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type wrapFunction(fn) { return (...args) => fn.call(this.$, ...args); } wrapObject(obj, callback) { // If this object is already a proxy or not a plain object (or array), return it as-is. if (obj == null || isProxified(obj) || (obj.constructor !== Object && !Array.isArray(obj))) { return obj; } return new Proxy(obj, { deleteProperty: (target, property) => { if (typeof property === "string" && property in target) { delete target[property]; callback(); return true; } return false; }, set: (target, prop, value, receiver) => { if (typeof value === "object" && value !== null) { value = this.wrapObject(value, callback); } const ret = Reflect.set(target, prop, value, receiver); callback(); return ret; }, get: (target, prop, receiver) => { if (prop === PROXY_MARKER) return true; return Reflect.get(target, prop, receiver); }, }); } watch(key, observer) { const owner = getAncestorKeyStore(this, key); if (!owner) { throw new Error(`Cannot watch key "${key}" as it does not exist in the store.`); } if (!owner.observers.has(key)) { owner.observers.set(key, new Set()); } if (!owner.observers.get(key)?.has(observer)) { owner.observers.get(key)?.add(observer); } } addKeyHandler(pattern, handler) { if (!this.keyHandlers.has(pattern)) { this.keyHandlers.set(pattern, new Set()); } this.keyHandlers.get(pattern)?.add(handler); } async notify(key, debounceMillis = REACTIVE_DEBOUNCE_MILLIS) { const owner = getAncestorKeyStore(this, key); const observers = Array.from(owner?.observers.get(key) || []); await this.debounce(debounceMillis, () => Promise.all(observers.map((observer) => observer.call(this.proxify(observer))))); } get(key, observer) { if (observer) this.watch(key, observer); return getAncestorValue(this, key); } async set(key, value) { // Early return if the key exists in this store and has the same value. if (this._store.has(key) && value === this._store.get(key)) return; const callback = () => this.notify(key); if (value && typeof value === "function") { value = this.wrapFunction(value); } if (value && typeof value === "object") { value = this.wrapObject(value, callback); } setAncestorValue(this, key, value); // Invoke any key handlers. for (const [pattern, handlers] of this.keyHandlers.entries()) { if (pattern.test(key)) { for (const handler of handlers) { await Promise.resolve(handler.call(this.$, key, value)); } } } // Invoke the callback to notify observers. await callback(); } async del(key) { // By setting to null, we trigger observers before deletion. await this.set(key, null); this._store.delete(key); this.observers.delete(key); } keys() { return Array.from(this._store.keys()); } /** * Checks if a key exists in THIS store only (not ancestors). * Use `get(key) !== null` to check if a key exists anywhere in the chain. */ has(key) { return this._store.has(key); } effect(observer) { return observer.call(this.proxify(observer)); } proxify(observer) { const keys = Array.from(this._store.entries()).map(([key]) => key); const keyval = Object.fromEntries(keys.map((key) => [key, null])); return new Proxy(keyval, { has: (_, prop) => { if (typeof prop === "string") { if (getAncestorKeyStore(this, prop)) return true; // Check if property exists on the SignalStore instance (e.g. methods like $resolve) if (Reflect.has(this, prop)) return true; } return Reflect.has(keyval, prop); }, get: (_, prop, receiver) => { if (typeof prop === "string") { if (getAncestorKeyStore(this, prop)) { return this.get(prop, observer); } // If the property is not found, but we are observing, we assume it's a // state variable that hasn't been initialized yet. We initialize it to // undefined so that we can watch it. if (observer && prop !== PROXY_MARKER && !Reflect.has(this, prop)) { this.set(prop, undefined); return this.get(prop, observer); } } if (prop === "$") { return this.proxify(observer); } else { return Reflect.get(this, prop, receiver); } }, set: (_, prop, value, receiver) => { if (typeof prop !== "string" || prop in this) { Reflect.set(this, prop, value, receiver); } else { this.set(prop, value); } return true; }, }); } get $() { return this.proxify(); } /** * Creates an evaluation function for the provided expression. * @param expr The expression to be evaluated. * @returns The evaluation function. */ makeEvalFunction(expr) { return (thisArg, args) => { const ast = expressions.parse(expr, AST_FACTORY); const scope = new Proxy(args, { has(target, prop) { return prop in target || prop in thisArg || prop in globalThis; }, get(target, prop) { if (typeof prop !== "string") return undefined; if (prop in target) return target[prop]; if (prop in thisArg) return thisArg[prop]; if (prop in globalThis) return globalThis[prop]; return thisArg[prop]; }, set(target, prop, value) { if (typeof prop !== "string") return false; if (prop in target) { target[prop] = value; return true; } thisArg[prop] = value; return true; }, }); return ast?.evaluate(scope); }; } /** * Retrieves or creates a cached expression function for the provided expression. * @param expr - The expression to retrieve or create a cached function for. * @returns The cached expression function. */ cachedExpressionFunction(expr) { expr = expr.trim(); if (!this.expressionCache.has(expr)) { this.expressionCache.set(expr, this.makeEvalFunction(expr)); } const fn = this.expressionCache.get(expr); if (!fn) { throw new Error(`Failed to retrieve cached expression: ${expr}`); } return fn; } eval(expr, args = {}) { // Determine whether we have already been proxified to avoid doing it again. const thisArg = this._observer ? this : this.$; if (this._store.has(expr)) { // Shortcut: if the expression is just an item from the value store, use that directly. return thisArg[expr]; } else { // Otherwise, perform the expression evaluation. const fn = this.cachedExpressionFunction(expr); try { return fn(thisArg, args); } catch (exc) { console.error(`Failed to evaluate expression: ${expr}`); console.error(exc); return null; } } } /** * Executes an async function and returns a reactive state object that tracks the result. * * @param fn - The async function to execute. * @param options - Optional arguments to pass to the function. * @returns A reactive state object with $pending, $result, and $error properties. * * @example * // In :data attribute - executes on mount * :data="{ users: $resolve(api.listUsers) }" * * // With options * :data="{ user: $resolve(api.getUser, { path: { id: userId } }) }" * * // In :on:click - executes on click * :on:click="result = $resolve(api.deleteUser, { path: { id } })" */ $resolve(fn, options) { // Create the state object. const state = { $pending: true, $result: null, $error: null, }; // Execute the function immediately, wrapping in Promise.resolve to handle sync throws. Promise.resolve() .then(() => fn(options)) .then((data) => { state.$result = data; }) .catch((err) => { state.$error = err instanceof Error ? err : new Error(String(err)); }) .finally(() => { state.$pending = false; }); return state; } } //# sourceMappingURL=store.js.map