UNPKG

@symbiotejs/symbiote

Version:

Symbiote.js - zero-dependency close-to-platform frontend library to build super-powered web components

414 lines (363 loc) 10.7 kB
import { DICT } from './dictionary.js'; import { warnMsg, devState } from './warn.js'; // structuredClone() is limited by supported types, so we use custom cloning: function cloneObj(obj) { let clone = (o) => { for (let prop in o) { if (o[prop]?.constructor === Object) { o[prop] = clone(o[prop]); } } return { ...o }; }; return clone(obj); } /** @template {Record<string, unknown>} T */ export class PubSub { /** @type {String | Symbol} */ #uid; #proxy; /** @type {Boolean} */ #storeIsProxy; /** * Local dependency map for computed props. * Key = computed prop name, Value = Set of local prop names it depends on. * @type {Object<string, Set<string>>} */ #localDeps = {}; /** * External dependency subscriptions for cross-context computed props. * Key = computed prop name, Value = array of subscription removers. * @type {Object<string, Array<{remove: Function}>>} */ #externalSubs = {}; /** * Tracks which computed prop is currently being executed, * so read() can record local dependencies. * @type {string | null} */ #trackingTarget = null; /** * Pending microtask flag to batch computed recalculations. * @type {boolean} */ #pendingRecalc = false; /** * Set of local props that changed and need computed recalc. * @type {Set<string>} */ #dirtyProps = new Set(); /** @param {T} schema */ constructor(schema) { if (schema.constructor === Object) { this.store = cloneObj(schema); } else { // For Proxy support: this.#storeIsProxy = true; this.store = schema; } /** @type {Record<keyof T, Set<(val:unknown) => void>>} */ this.callbackMap = Object.create(null); } /** * @param {String} actionName * @param {*} prop */ static #warn(actionName, prop, ctx) { warnMsg(1, String(ctx?.uid || 'local'), actionName, prop); } /** * Execute a computed function while tracking local reads. * @param {string} compProp - computed property name * @returns {unknown} computed result */ #executeTracked(compProp) { let compEntry = this.store[compProp]; let fn = typeof compEntry === 'function' ? compEntry : compEntry?.fn; if (fn?.constructor !== Function) { PubSub.#warn('compute', compProp); return; } this.#trackingTarget = compProp; this.#localDeps[compProp] = new Set(); let val = fn(); this.#trackingTarget = null; return val; } /** * Set up external dependency subscriptions for a computed prop declared * with object syntax: { deps: ['CTX/prop', ...], fn: () => ... } * @param {string} compProp * @param {string[]} deps */ #setupExternalDeps(compProp, deps) { if (this.#externalSubs[compProp]) { this.#externalSubs[compProp].forEach((s) => s.remove()); } this.#externalSubs[compProp] = []; for (let depKey of deps) { let slashIdx = depKey.indexOf('/'); if (slashIdx === -1) continue; let ctxName = depKey.slice(0, slashIdx); let propName = depKey.slice(slashIdx + 1); let extCtx = PubSub.getCtx(ctxName, false); if (!extCtx) { // Defer: will resolve when the context is registered if (!PubSub.pendingDeps.has(ctxName)) { PubSub.pendingDeps.set(ctxName, []); } PubSub.pendingDeps.get(ctxName).push(() => { let resolvedCtx = PubSub.getCtx(ctxName, false); if (!resolvedCtx) return; let sub = resolvedCtx.sub(propName, () => { this.#recalcComputed(compProp); }, false); if (sub) { if (!this.#externalSubs[compProp]) { this.#externalSubs[compProp] = []; } this.#externalSubs[compProp].push(sub); } this.#recalcComputed(compProp); }); continue; } let sub = extCtx.sub(propName, () => { this.#recalcComputed(compProp); }, false); if (sub) { this.#externalSubs[compProp].push(sub); } } } /** * Recalculate a single computed prop and notify if changed. * @param {string} compProp */ #recalcComputed(compProp) { if (!this.__computedMap) return; let newVal = this.#executeTracked(compProp); if (newVal !== this.__computedMap[compProp]) { this.__computedMap[compProp] = newVal; this.notify(compProp); } } /** * Schedule batched recalculation of computed props affected by dirty local props. */ #scheduleBatchRecalc() { if (this.#pendingRecalc) return; this.#pendingRecalc = true; queueMicrotask(() => { this.#pendingRecalc = false; if (!this.__computedMap) return; let dirtySnapshot = new Set(this.#dirtyProps); this.#dirtyProps.clear(); for (let compProp of Object.keys(this.__computedMap)) { let deps = this.#localDeps[compProp]; if (!deps) continue; let affected = false; for (let dp of dirtySnapshot) { if (deps.has(dp)) { affected = true; break; } } if (affected) { this.#recalcComputed(compProp); } } }); } /** @param {keyof T} prop */ read(prop) { if (!this.#storeIsProxy && !(prop in this.store)) { PubSub.#warn('read', prop); return null; } if (typeof prop === 'string' && prop.startsWith(DICT.COMPUTED_PX)) { if (!this.__computedMap) { /** * @private * @type {Object<keyof T, *>} */ this.__computedMap = {}; } // Already initialized — return cached value (recalc happens in #recalcComputed) if (prop in this.__computedMap) { return this.__computedMap[prop]; } // First read — initialize: execute, cache, setup deps let currentVal = this.#executeTracked(prop); this.__computedMap[prop] = currentVal; let entry = this.store[prop]; if (entry?.constructor === Object && Array.isArray(entry.deps)) { this.#setupExternalDeps(prop, entry.deps); } this.notify(prop); return currentVal; } else { if (this.#trackingTarget && typeof prop === 'string') { this.#localDeps[this.#trackingTarget].add(prop); } return this.store[prop]; } } /** @param {String} prop */ has(prop) { return this.#storeIsProxy ? this.store[prop] !== undefined : this.store.hasOwnProperty(prop); } /** * @param {String} prop * @param {unknown} val * @param {Boolean} [rewrite] */ add(prop, val, rewrite = false) { if (!rewrite && (prop in this.store)) { return; } this.store[prop] = val; this.notify(prop, val); } /** * @param {keyof T} prop * @param {unknown} val */ pub(prop, val) { if (!this.#storeIsProxy && !(prop in this.store)) { PubSub.#warn('publish', prop, this); return; } // @ts-expect-error if (prop?.startsWith(DICT.COMPUTED_PX) && val.constructor !== Function) { PubSub.#warn('publish computed (value must be a Function)', prop, this); return; } if (devState.devMode && !(this.store[prop] === null || val === null) && typeof this.store[prop] !== typeof val) { warnMsg(2, String(this.uid || 'local'), String(prop), typeof this.store[prop], typeof val, JSON.stringify(this.store[prop]), JSON.stringify(val)); } this.store[prop] = val; this.notify(prop, val); } /** @returns {T} */ get proxy() { if (!this.#proxy) { let o = Object.create(null); this.#proxy = new Proxy(o, { set: (obj, /** @type {String} */ prop, val) => { this.pub(prop, val); return true; }, get: (obj, /** @type {String} */ prop) => { return this.read(prop); }, }); } return this.#proxy; } /** @param {T} updObj */ multiPub(updObj) { for (let prop in updObj) { this.pub(prop, updObj[prop]); } } /** @param {keyof T} prop */ notify(prop, val) { // @ts-expect-error let isComputed = prop?.startsWith(DICT.COMPUTED_PX); if (this.callbackMap[prop]) { if (val === undefined) { val = this.read(prop); } this.callbackMap[prop].forEach((callback) => { callback(val); }); if (isComputed) { this.__computedMap[prop] = val; } } if (!isComputed && this.__computedMap) { this.#dirtyProps.add(/** @type {string} */ (prop)); this.#scheduleBatchRecalc(); } } /** * @param {keyof T} prop * @param {(val: unknown) => void} callback * @param {Boolean} [init] */ sub(prop, callback, init = true) { if (!this.#storeIsProxy && !(prop in this.store)) { PubSub.#warn('subscribe', prop); return null; } if (!this.callbackMap[prop]) { this.callbackMap[prop] = new Set(); } this.callbackMap[prop].add(callback); if (init) { callback(this.read(prop)); } return { remove: () => { this.callbackMap[prop].delete(callback); if (!this.callbackMap[prop].size) { delete this.callbackMap[prop]; } }, callback, }; } /** * @param {String | Symbol} uid */ set uid(uid) { !this.#uid && (this.#uid = uid); } get uid() { return this.#uid; } /** * @template {Record<string, unknown>} S * @param {S} schema * @param {String | Symbol} [uid] * @returns {PubSub<S>} */ static registerCtx(schema, uid = Symbol()) { /** @type {PubSub} */ let data = PubSub.globalStore.get(uid); if (data) { warnMsg(3, uid); } else { data = new PubSub(schema); data.uid = uid; PubSub.globalStore.set(uid, data); // Resolve deferred external deps waiting for this context: let pending = PubSub.pendingDeps.get(uid); if (pending) { PubSub.pendingDeps.delete(uid); for (let resolve of pending) { resolve(); } } } return data; } /** @param {String | Symbol} uid */ static deleteCtx(uid) { PubSub.globalStore.delete(uid); } /** * @param {String | Symbol} uid * @param {Boolean} [notify] * @returns {PubSub} */ static getCtx(uid, notify = true) { return PubSub.globalStore.get(uid) || (notify && warnMsg(4, String(uid), [...PubSub.globalStore.keys()].map(String).join(', ')), null); } } /** @type {Map<String | Symbol, PubSub>} */ PubSub.globalStore = globalThis.__SYMBIOTE_PUBSUB_STORE || (globalThis.__SYMBIOTE_PUBSUB_STORE = new Map()); /** @type {Map<String | Symbol, Array<Function>>} */ PubSub.pendingDeps = new Map(); export default PubSub;