UNPKG

mancha

Version:

Javscript HTML rendering engine

632 lines 26 kB
import * as expressions from "./expressions/index.js"; /** Symbol used to identify computed value markers. */ const COMPUTED_MARKER = Symbol("__computed__"); /** Type guard to check if a value is a computed marker. */ function isComputedMarker(value) { return (value !== null && typeof value === "object" && COMPUTED_MARKER in value && value[COMPUTED_MARKER] === true); } /** Default notification debounce 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 { evalkeys = ["$elem", "$event"]; expressionCache = new Map(); observers = new Map(); keyHandlers = new Map(); _store = new Map(); _lock = Promise.resolve(); /** * Notification state per key. Value is a pending timeout, or "executing" * when observers are running. Used to debounce and prevent infinite loops. */ _notify = new Map(); /** * Tracks nested computed evaluation depth. When > 0, we're inside a computed * function and writes to reactive properties should trigger a warning. */ _computedDepth = 0; constructor(data) { 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); } } wrapObject(obj, callback) { // Skip nulls and already-proxified objects. if (obj == null || isProxified(obj)) return obj; // Skip frozen/sealed objects - they can't be modified and proxying them would // violate JS invariants (get trap must return actual value for non-configurable props). if (Object.isFrozen(obj) || Object.isSealed(obj)) return obj; // Only wrap plain objects and arrays. Custom class instances are skipped because // deep reactivity on arbitrary classes can cause unexpected behavior and performance // issues (e.g., classes that modify internal state when methods are called). const proto = Object.getPrototypeOf(obj); const isPlainObject = proto === Object.prototype || proto === null; const isArray = Array.isArray(obj); if (!isPlainObject && !isArray) { 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) => { // Skip if the value is unchanged. if (Reflect.get(target, prop, receiver) === value) return true; 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; const result = Reflect.get(target, prop, receiver); // Lazily wrap nested objects for deep reactivity. // This ensures that modifications like items[0].visible = true trigger notifications. if (result !== null && typeof result === "object" && !isProxified(result)) { const wrapped = this.wrapObject(result, callback); // If wrapObject returned a different object (a proxy), store it back for identity. if (wrapped !== result) { Reflect.set(target, prop, wrapped, receiver); return wrapped; } } return result; }, }); } 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()); } // Check if this observer is already registered (avoid duplicates). const existing = Array.from(owner.observers.get(key) || []); if (!existing.some((entry) => entry.observer === observer)) { // Store the observer along with the store context that registered it. owner.observers.get(key)?.add({ observer, store: this }); } } addKeyHandler(pattern, handler) { if (!this.keyHandlers.has(pattern)) { this.keyHandlers.set(pattern, new Set()); } this.keyHandlers.get(pattern)?.add(handler); } /** * Tags all observer entries matching the given observer function with a computed key. * Called after effect runs to mark which observers belong to which computed. */ tagObserversForComputed(observer, computedKey) { // Check this store's observers. for (const entries of this.observers.values()) { for (const entry of entries) { if (entry.observer === observer && entry.store === this) { entry.computedKey = computedKey; } } } // Also check ancestor stores (for inherited dependencies). let ancestor = this._store.get("$parent"); while (ancestor) { for (const entries of ancestor.observers.values()) { for (const entry of entries) { if (entry.observer === observer && entry.store === this) { entry.computedKey = computedKey; } } } ancestor = ancestor._store.get("$parent"); } } /** * Synchronously marks all computeds that depend on this key as dirty. * Uses the computedKey field on observer entries for O(1) key lookup. * Cascades through computed chains (if A depends on B, and B is marked dirty, * then A is also marked dirty). */ markDependentComputedsDirty(key) { const owner = getAncestorKeyStore(this, key); const entries = owner?.observers.get(key); if (!entries) return; for (const entry of entries) { if (entry.computedKey) { const stored = entry.store._store.get(entry.computedKey); if (isComputedMarker(stored) && !stored.dirty) { stored.dirty = true; // Cascade: mark computeds that depend on THIS computed. entry.store.markDependentComputedsDirty(entry.computedKey); } } } } async notify(key, debounceMillis = REACTIVE_DEBOUNCE_MILLIS) { // Capture observers NOW (at call time). This ensures constructor calls // don't trigger effects registered later. const owner = getAncestorKeyStore(this, key); const entries = Array.from(owner?.observers.get(key) || []); const current = this._notify.get(key); // Skip if observers are already executing for this key (prevents infinite loops). if (current === "executing") return; // Clear any pending notification (debounce). if (current) clearTimeout(current); // Schedule the notification. return new Promise((resolve) => { this._notify.set(key, setTimeout(async () => { this._notify.set(key, "executing"); try { await Promise.all(entries.map((entry) => entry.observer.call(entry.store.proxify(entry.observer)))); } finally { this._notify.delete(key); } // Lazy cleanup: remove observers whose store's $rootNode is disconnected. // This handles memory leaks from removed :for items, replaced :html content, etc. // Only applies to subrenderers (stores with $parent) - root renderer observers persist. const observerSet = owner?.observers.get(key); if (observerSet) { for (const entry of entries) { const hasParent = entry.store._store.has("$parent"); const rootNode = entry.store._store.get("$rootNode"); if (hasParent && rootNode && !rootNode.isConnected) { observerSet.delete(entry); } } } resolve(); }, debounceMillis)); }); } get(key, observer) { if (observer) this.watch(key, observer); const stored = getAncestorValue(this, key); // Handle computed values: recompute if dirty, return the cached value. if (isComputedMarker(stored)) { if (stored.dirty) { this._computedDepth++; try { // Use the effect function as observer to register new dependencies. // This handles conditional dependencies that change based on execution path. const proxy = this.proxify(stored.effectFn); stored.value = stored.fn.call(proxy, proxy); stored.dirty = false; // Tag any new observers with the computed key. if (stored.effectFn) { this.tagObserversForComputed(stored.effectFn, key); } // Mark dependents of this computed as dirty (cascading). this.markDependentComputedsDirty(key); } finally { this._computedDepth--; } } return stored.value; } return stored; } setupComputed(key, computedFn) { const store = this; // Create the marker with dirty: true for initial computation. const marker = { [COMPUTED_MARKER]: true, fn: computedFn, dirty: true, }; this._store.set(key, marker); // Define the effect function that will update the marker. const effectFn = function () { // Track computed depth for write guard warnings. store._computedDepth++; try { // Pass `this` as both the context and first argument, so arrow functions // can receive the reactive proxy as `$` parameter. const result = computedFn.call(this, this); const oldValue = marker.value; // Only notify if value actually changed. if (oldValue !== result) { marker.value = result; // Synchronously invoke observers of the computed key to ensure // cascading computed values update in the same tick. const owner = getAncestorKeyStore(store, key); const entries = Array.from(owner?.observers.get(key) || []); for (const entry of entries) { entry.observer.call(entry.store.proxify(entry.observer)); } } marker.dirty = false; } finally { store._computedDepth--; } }; // Store the effect function in the marker for use during lazy recomputation. marker.effectFn = effectFn; // Run the effect to register observers and compute initial value. this.effect(effectFn, { directive: "computed", id: key }); // Tag all observers created by this effect with the computed key. this.tagObserversForComputed(effectFn, key); } /** * Sets a value in the store. * @param key - The key to set. * @param value - The value to set (can be a computed marker). * @param local - If true, sets directly on this store bypassing ancestor lookup. * Use for creating local scope variables that shadow ancestors. */ async set(key, value, local) { if (isComputedMarker(value)) { this.setupComputed(key, value.fn); return; } // 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.markDependentComputedsDirty(key); return this.notify(key); }; // Note: Functions are NOT wrapped here. They are wrapped dynamically at access // time in proxify() to ensure the correct observer context is used. if (value && typeof value === "object") { value = this.wrapObject(value, callback); } if (local) { // Set directly on this store, not on ancestors. this._store.set(key, value); } else { setAncestorValue(this, key, value); } // Invoke any key handlers (only for non-local sets). if (!local) { 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); } /** * Disposes this store by clearing all observers. * Call this when the store is no longer needed to prevent memory leaks. * Also removes any observers this store registered on ancestor stores. */ dispose() { // Clear local observers. for (const observerSet of this.observers.values()) { observerSet.clear(); } this.observers.clear(); // Remove observers registered on ancestors (for inherited keys). let ancestor = this._store.get("$parent"); while (ancestor) { for (const observerSet of ancestor.observers.values()) { for (const entry of observerSet) { if (entry.store === this) { observerSet.delete(entry); } } } ancestor = ancestor._store.get("$parent"); } } 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); } /** * Returns observer statistics for performance reporting. */ getObserverStats() { const byKey = {}; let totalObservers = 0; for (const [key, observers] of this.observers) { byKey[key] = observers.size; totalObservers += observers.size; } return { totalKeys: this.observers.size, totalObservers, byKey, }; } effect(observer, _meta) { // Base implementation ignores metadata; IRenderer overrides to add performance tracking. return observer.call(this.proxify(observer)); } /** * Creates a computed property that automatically updates when its dependencies change. * The function is evaluated in a reactive effect, and the result is stored. When any * reactive property accessed within the function changes, it re-evaluates and updates. * * **Important:** This method returns a marker object at runtime, but is typed as * returning `R` to enable ergonomic property assignment without type casts. The return * value must be assigned to a store property (via `set()` or `$.prop =`) - do not use * it directly as a value. * * @example * // Using function() to access reactive `this`: * store.set('double', store.$computed(function() { return this.count * 2 })); * * // Using arrow function with $ parameter (for templates): * store.set('double', store.$computed(($) => $.count * 2)); * * // Direct property assignment (ergonomic typing): * store.$.doubled = store.$computed(($) => $.count * 2); */ $computed(fn) { // Returns a marker object that signals to set() this is a computed property. // The return type is R (not ComputedMarker<R>) to allow ergonomic assignment // like `$.prop = $computed(fn)` without requiring type casts. return { [COMPUTED_MARKER]: true, fn: fn, dirty: true }; } proxify(observer) { const keys = Array.from(this._store.entries()).map(([key]) => key); const keyval = Object.fromEntries(keys.map((key) => [key, null])); // Wraps a function to use the receiver proxy as `this`, ensuring proper // context and dependency tracking when the function accesses reactive properties. // Skips "constructor" as it needs to be callable with `new`. const wrapMaybeFunction = (value, prop, receiver) => { if (typeof value === "function" && prop !== "constructor") { return (...args) => value.call(receiver, ...args); } return value; }; 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)) { const value = this.get(prop, observer); // If the value is a SignalStore (e.g., $parent) and we have an // observer, return it as a proxy for proper dependency tracking. if (observer && value instanceof SignalStore) { return value.proxify(observer); } return wrapMaybeFunction(value, prop, receiver); } // 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 { const value = Reflect.get(this, prop, receiver); return wrapMaybeFunction(value, prop, receiver); } }, set: (_, prop, value, receiver) => { if (typeof prop !== "string" || prop in this) { Reflect.set(this, prop, value, receiver); } else { // Warn if writing to reactive property inside a computed. if (this._computedDepth > 0) { console.warn(`[mancha] Computed wrote to '${prop}'. Computeds should be pure; use $effect for side effects.`); } 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 = {}) { // Use this.$ which returns a proxy. When called through an effect's proxy, // this.$ inherits the observer for proper dependency tracking. const thisArg = 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