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