mancha
Version:
Javscript HTML rendering engine
632 lines • 26 kB
JavaScript
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