UNPKG

@zedux/atoms

Version:

A Molecular State Engine for React

298 lines (297 loc) 11.4 kB
import { is } from '@zedux/core'; import { Explicit, External, prefix } from '../utils/index.js'; import { pluginActions } from '../utils/plugin-actions.js'; const defaultResultsComparator = (a, b) => a === b; export class SelectorCache { constructor(id, selectorRef, args) { this.id = id; this.selectorRef = selectorRef; this.args = args; this.nextReasons = []; } } SelectorCache.$$typeof = Symbol.for(`${prefix}/SelectorCache`); /** * Since AtomSelectors are meant to feel lightweight, they don't have to be * instances of a class - they'll often be standalone or even inline * functions. This class handles all the logic that AtomSelectors would handle * themselves if they were classes - creation, cache management, and * destruction. */ export class Selectors { constructor(ecosystem) { this.ecosystem = ecosystem; /** * Map selectorKey + params id strings to the SelectorCache for the selector */ this._items = {}; /** * Map selectors (or selector config objects) to a base selectorKey that can * be used to predictably create selectorKey+params ids to look up the cache * in `this._items` */ this._refBaseKeys = new WeakMap(); } addDependent(cacheItem, { callback, operation = 'addDependent', } = {}) { const { _graph, _idGenerator } = this.ecosystem; const id = _idGenerator.generateNodeId(); _graph.addEdge(id, cacheItem.id, operation, Explicit | External, callback); return () => _graph.removeEdge(id, cacheItem.id); } /** * Get an object mapping all ids in this selectorCache to their current * values. * * Pass a selector to only return caches of that selector. * * Pass a partial SelectorCache id string to only return caches whose id * contains the passed key (case-insensitive). * * IMPORTANT: Don't use this for SSR. SelectorCaches are not designed to be * shared across environments. Selectors should be simple derivations that * will be predictably recreated from rehydrated atom instances. * * In other words, `ecosystem.dehydrate()` is all you need for SSR. Don't * worry about selectors. This method is solely an inspection/debugging util. */ dehydrate(selectableOrName) { const hash = this.findAll(selectableOrName); // We just created the object. Just mutate it. Object.keys(hash).forEach(id => { hash[id] = hash[id].result; }); return hash; } /** * Destroys the cache for the given selector + args combo (if it exists). * * Destruction bails out by default if the selector's ref count is > 0. Pass * `true` as the 3rd param to force destruction. */ destroyCache(selectable, args, force) { const id = is(selectable, SelectorCache) ? selectable.id : this.getCacheId(selectable, args); const cache = is(selectable, SelectorCache) ? selectable : this._items[id]; if (!cache || cache.isDestroyed) return; const node = this.ecosystem._graph.nodes[id]; if (force || !node.refCount) { this._destroySelector(id); } } find(selectable, args) { if (is(selectable, SelectorCache)) { return selectable; } if (typeof selectable === 'string') { return Object.values(this.findAll(selectable))[0]; } const id = this.getCacheId(selectable, args, true); return id && this._items[id]; } /** * Get an object of all currently-cached AtomSelectors. * * Pass a selector reference or string to filter by caches whose id * weakly matches the passed selector name. */ findAll(selectableOrName = '') { const hash = {}; const filterKey = typeof selectableOrName === 'string' ? selectableOrName.toLowerCase() : is(selectableOrName, SelectorCache) ? selectableOrName.id : this.getBaseKey(selectableOrName, true) || this._getIdealCacheId(selectableOrName); Object.values(this._items) .sort((a, b) => a.id.localeCompare(b.id)) .forEach(item => { if (!filterKey || item.id.toLowerCase().includes(filterKey)) { hash[item.id] = item; } }); return hash; } /** * Get the cached args and result for the given AtomSelector (or * AtomSelectorConfig). Runs the selector, sets up the graph, and caches the * initial value if this selector hasn't been cached before. */ getCache(selectable, args = []) { if (is(selectable, SelectorCache)) { return selectable; } const selectorOrConfig = selectable; const id = this.getCacheId(selectorOrConfig, args); let cache = this._items[id]; if (cache) return cache; // create the cache; it doesn't exist yet cache = new SelectorCache(id, selectorOrConfig, args); this._items[id] = cache; this.ecosystem._graph.addNode(id, true); this.runSelector(id, args, true); return cache; } /** * Get the fully qualified id for the given selector+params combo */ getCacheId(selectorOrConfig, args, weak) { const { complexParams, _idGenerator } = this.ecosystem; const paramsHash = (args === null || args === void 0 ? void 0 : args.length) ? _idGenerator.hashParams(args, complexParams) : ''; const baseKey = this.getBaseKey(selectorOrConfig, weak); return paramsHash ? `${baseKey}-${paramsHash}` : baseKey; } /** * Should only be used internally. Removes the selector from the cache and * the graph */ _destroySelector(id) { const cache = this._items[id]; if (!cache) return; // shouldn't happen const { _graph, _scheduler, _mods, modBus } = this.ecosystem; if (cache.nextReasons.length && cache.task) { _scheduler.unschedule(cache.task); } _graph.removeDependencies(id); _graph.removeNode(id); delete this._items[id]; cache.isDestroyed = true; // don't delete the ref from this._refBaseKeys; this selector cache isn't // necessarily the only one using it (if the selector takes params). Just // let the WeakMap clean itself up. if (_mods.statusChanged) { modBus.dispatch(pluginActions.statusChanged({ newStatus: 'Destroyed', node: cache, oldStatus: 'Active', })); } } /** * Get the string key we would ideally use as the id of the given * AtomSelector function or AtomSelectorConfig object - doesn't necessarily * mean we end up caching using this key. */ _getIdealCacheId(selectorOrConfig) { var _a; const idealKey = selectorOrConfig.name || ((_a = selectorOrConfig.selector) === null || _a === void 0 ? void 0 : _a.name); // 'selector' is too generic (it's the key in AtomSelectorConfig objects) return (idealKey !== 'selector' && idealKey) || undefined; } /** * Should only be used internally */ _scheduleEvaluation(id, reason, shouldSetTimeout) { const cache = this._items[id]; cache.nextReasons.push(reason); if (cache.nextReasons.length > 1) return; // job already scheduled const task = () => { cache.task = undefined; this.runSelector(id, cache.args); }; cache.task = task; this.ecosystem._scheduler.schedule({ id: id, task, type: 2, // EvaluateGraphNode (2) }, shouldSetTimeout); } /** * Should only be used internally */ _swapRefs(oldCache, newRef, args = []) { const baseKey = this._refBaseKeys.get(oldCache.selectorRef); if (!baseKey) return; this._refBaseKeys.set(newRef, baseKey); this._refBaseKeys.delete(oldCache.selectorRef); oldCache.selectorRef = newRef; this.runSelector(oldCache.id, args, false, true); } /** * Destroy all cached selectors. Should probably only be used internally. * Prefer `ecosystem.reset()`. */ _wipe() { Object.keys(this._items).forEach(id => { this._destroySelector(id); }); this._refBaseKeys = new WeakMap(); } /** * Get a base key that can be used to generate consistent ids for the given * selector */ getBaseKey(selectorOrConfig, weak) { const existingId = this._refBaseKeys.get(selectorOrConfig); if (existingId || weak) return existingId; const selectorName = this._getIdealCacheId(selectorOrConfig) || 'unnamed'; const key = this.ecosystem._idGenerator.generateId(`@@selector-${selectorName}`); this._refBaseKeys.set(selectorOrConfig, key); return key; } /** * Run an AtomSelector and, depending on the selector's resultsComparator, * update its cached result. Updates the graph efficiently (using * `.bufferUpdates()`) */ runSelector(id, args, isInitializing, skipNotifyingDependents) { const { _evaluationStack, _graph, _mods, modBus } = this.ecosystem; _graph.bufferUpdates(id); const cache = this._items[id]; const selector = typeof cache.selectorRef === 'function' ? cache.selectorRef : cache.selectorRef.selector; const resultsComparator = (typeof cache.selectorRef !== 'function' && cache.selectorRef.resultsComparator) || defaultResultsComparator; _evaluationStack.start(cache); try { const result = selector(_evaluationStack.atomGetters, ...args); if (!isInitializing && !resultsComparator(result, cache.result)) { if (!skipNotifyingDependents) { _graph.scheduleDependents(id, cache.nextReasons, result, cache.result); } if (_mods.stateChanged) { modBus.dispatch(pluginActions.stateChanged({ cache: cache, newState: result, oldState: cache.result, reasons: cache.nextReasons, })); } cache.result = result; } else if (isInitializing) { cache.result = result; if (_mods.statusChanged) { modBus.dispatch(pluginActions.statusChanged({ newStatus: 'Active', node: cache, oldStatus: 'Initializing', })); } } } catch (err) { _graph.destroyBuffer(); console.error(`Zedux encountered an error while running selector with id "${id}":`, err); throw err; } finally { _evaluationStack.finish(); cache.prevReasons = cache.nextReasons; cache.nextReasons = []; } _graph.flushUpdates(); } }