UNPKG

@zedux/atoms

Version:

A Molecular State Engine for React

613 lines (612 loc) 25.1 kB
import { createStore, detailedTypeof, is, isPlainObject } from '@zedux/core'; import { internalStore } from '../store/index.js'; import { External } from '../utils/index.js'; import { pluginActions } from '../utils/plugin-actions.js'; import { EvaluationStack } from './EvaluationStack.js'; import { Graph } from './Graph.js'; import { IdGenerator } from './IdGenerator.js'; import { AtomInstanceBase } from './instances/AtomInstanceBase.js'; import { Scheduler } from './Scheduler.js'; import { SelectorCache, Selectors } from './Selectors.js'; import { AtomTemplate } from './templates/AtomTemplate.js'; const defaultMods = Object.keys(pluginActions).reduce((map, mod) => { map[mod] = 0; return map; }, {}); const mapOverrides = (overrides) => overrides.reduce((map, atom) => { map[atom.key] = atom; return map; }, {}); export class Ecosystem { constructor(config) { var _a; this.modBus = createStore(); // use an empty store as a message bus this.overrides = {}; this.selectors = new Selectors(this); this._graph = new Graph(this); // define this after _graph so it can access _graph immediately this._evaluationStack = new EvaluationStack(this); this._idGenerator = new IdGenerator(); this._instances = {}; this._mods = Object.assign({}, defaultMods); this._refCount = 0; this._scheduler = new Scheduler(this); /** * Only for use by internal addon packages - lets us attach anything we want * to the ecosystem. For example, the React package uses this to store React * Context objects */ this._storage = {}; this.isInitialized = false; this.plugins = []; if (true /* DEV */) { if (config.flags && !Array.isArray(config.flags)) { throw new TypeError("Zedux: The Ecosystem's `flags` property must be an array of strings"); } if (config.overrides && !Array.isArray(config.overrides)) { throw new TypeError("Zedux: The Ecosystem's `overrides` property must be an array of atom template objects"); } } Object.assign(this, config); this.id || (this.id = this._idGenerator.generateId('es')); if (config.overrides) { this.setOverrides(config.overrides); } this.context = this.context; this.isInitialized = true; this.cleanup = (_a = config.onReady) === null || _a === void 0 ? void 0 : _a.call(config, this); } /** * Merge the passed atom overrides into the ecosystem's current list of * overrides. Force-destroys all atom instances currently in the ecosystem * that should now be overridden. * * This can't be used to remove overrides. Use `.setOverrides()` or * `.removeOverrides()` for that. */ addOverrides(overrides) { this.overrides = Object.assign(Object.assign({}, this.overrides), mapOverrides(overrides)); overrides.forEach(override => { const instances = this.findAll(override); Object.values(instances).forEach(instance => instance.destroy(true)); }); } /** * Batch all state updates that happen synchronously during the passed * callback's execution. Flush all updates when the passed callback completes. * * Has no effect if the scheduler is already running - updates are always * batched when the scheduler is running. */ batch(callback) { const scheduler = this._scheduler; const prevIsRunning = scheduler._isRunning; scheduler._isRunning = true; const result = callback(); scheduler._isRunning = prevIsRunning; scheduler.flush(); return result; } /** * Retrieve an object mapping atom instance ids to their current values. * * Calls the `dehydrate` atom config option (on atoms that have one) to * transform state to a serializable form. Pass `transform: false` to prevent * this. * * Atoms can be excluded from dehydration by passing `exclude` and/or * `excludeFlags` options: * * ```ts * myEcosystem.dehydrate({ * exclude: [myAtom, 'my-fuzzy-search-string'], * excludeFlags: ['no-ssr'] * }) * ``` * * An atom passed to `exclude` will exclude all instances of that atom. A * string passed to `exclude` will exclude all instances whose id contains the * string (case-insensitive) * * You can dehydrate only a subset of all atoms by passing `include` and/or * `includeFlags` options: * * ```ts * myEcosystem.dehydrate({ * include: [myAtom, 'my-fuzzy-search-string'], * includeFlags: ['ssr'] * }) * ``` * * An atom passed to `include` will include all instances of that atom. A * string passed to `include` will include all instances whose id contains the * string (case-insensitive) * * Excludes takes precedence over includes. * * By default, dehydration will call any configured `dehydrate` atom config * options to transform atom instance state. Pass `{ transform: false }` to * prevent this. */ dehydrate({ exclude, excludeFlags, include, includeFlags, transform = true, } = {}) { const instances = Object.values(this._instances).filter(({ id, template }) => { if (exclude && exclude.some(atomOrKey => typeof atomOrKey === 'string' ? id.toLowerCase().includes(atomOrKey.toLowerCase()) : template.key === atomOrKey.key)) { return false; } if (excludeFlags && excludeFlags.some(flag => { var _a; return (_a = template.flags) === null || _a === void 0 ? void 0 : _a.includes(flag); })) { return false; } if (!include && !includeFlags) return true; if (include && include.some(atomOrKey => typeof atomOrKey === 'string' ? id.toLowerCase().includes(atomOrKey.toLowerCase()) : template.key === atomOrKey.key)) { return true; } if (includeFlags && includeFlags.some(flag => { var _a; return (_a = template.flags) === null || _a === void 0 ? void 0 : _a.includes(flag); })) { return true; } return false; }); return instances.reduce((obj, { id, store, template }) => { const state = store.getState(); obj[id] = transform && template.dehydrate ? template.dehydrate(state) : state; return obj; }, {}); } /** * Destroy this ecosystem - destroy all this ecosystem's atom instances, * remove and clean up all plugins, and remove this ecosystem from the * internal store. * * Destruction will bail out by default if this ecosystem is still being * provided via an <EcosystemProvider>. Pass `true` as the first parameter to * force destruction anyway. */ destroy(force) { if (!force && this._refCount > 0) return; this.wipe(); // Check if this ecosystem has been destroyed already const ecosystem = internalStore.getState()[this.id]; if (!ecosystem) return; this.plugins.forEach(({ cleanup }) => cleanup()); this.plugins = []; internalStore.setState(state => { const newState = Object.assign({}, state); delete newState[this.id]; return newState; }); } find(template, params) { const isString = typeof template === 'string'; if (!isString) { const id = template.getInstanceId(this, params); // try to find an existing instance const instance = this._instances[id]; if (instance) return instance; } // if params are passed, don't fuzzy search if (params) { return this._instances[isString ? template : `${template.key}-${this._idGenerator.hashParams(params, this.complexParams)}`]; } const matches = this.findAll(template); return ((isString && matches[template]) || Object.values(matches)[0]); } /** * Get an object of all atom instances in this ecosystem keyed by their id. * * Pass an atom template to only find instances of that atom. Pass an atom key * string to only return instances whose id weakly matches the passed key. */ findAll(template) { var _a, _b; const isAtom = (_a = template) === null || _a === void 0 ? void 0 : _a.key; const filterKey = isAtom || ((_b = template) === null || _b === void 0 ? void 0 : _b.toLowerCase()); const hash = {}; Object.values(this._instances) .filter(instance => !filterKey || (isAtom ? instance.template.key === template.key : instance.id.toLowerCase().includes(filterKey))) .sort((a, b) => a.id.localeCompare(b.id)) .forEach(instance => { hash[instance.id] = instance; }); return hash; } /** * Returns an atom instance's value. Creates the atom instance if it doesn't * exist yet. Doesn't register any graph dependencies. */ get(atom, params) { if (is(atom, AtomInstanceBase)) { return atom.store.getState(); } const instance = this.getInstance(atom, params); return instance.store.getState(); } /** * Returns an atom instance. Creates the atom instance if it doesn't exist * yet. Doesn't register any graph dependencies. */ getInstance(atom, params) { if (true /* DEV */) { if (!atom || (!is(atom, AtomInstanceBase) && !is(atom, AtomTemplate))) { throw new TypeError(`Zedux: Expected an atom template or atom instance. Received ${detailedTypeof(atom)}`); } if (typeof params !== 'undefined' && !Array.isArray(params)) { throw new TypeError(`Zedux: Expected atom params to be an array. Received ${detailedTypeof(params)}`); } } if (is(atom, AtomInstanceBase)) { // if the passed atom instance is Destroyed, get(/create) the // non-Destroyed instance return atom.status === 'Destroyed' ? this.getInstance(atom.template, atom.params) : atom; } const id = atom.getInstanceId(this, params); // try to find an existing instance const instance = this._instances[id]; if (instance) { if (this._mods.instanceReused) { this.modBus.dispatch(pluginActions.instanceReused({ instance, template: atom })); } return instance; } // create a new instance const resolvedAtom = this.resolveAtom(atom); this._graph.addNode(id); const newInstance = resolvedAtom._createInstance(this, id, (params || [])); this._instances[id] = newInstance; newInstance._init(); return newInstance; } /** * Hydrate the state of atoms in this ecosystem with an object mapping atom * instance ids to their hydrated state. This object will usually be the * result of a call to `ecosystem.dehydrate()`. * * This is the key to SSR. The ecosystem's initial state can be dehydrated on * the server, sent to the client in serialized form, deserialized, and passed * to `ecosystem.hydrate()`. Every atom instance that evaluates after this * hydration can use the `hydrate` injectStore config option to retrieve its * hydrated state. * * Pass `retroactive: false` to prevent this call from updating the state of * all atom instances that have already been initialized with this new * hydration. Hydration is retroactive by default. * * ```ts * ecosystem.hydrate(dehydratedState, { retroactive: false }) * ``` */ hydrate(dehydratedState, config) { if (true /* DEV */) { if (!isPlainObject(dehydratedState)) { throw new TypeError('Zedux: ecosystem.hydrate() - first parameter must be a plain object'); } } this.hydration = Object.assign(Object.assign({}, this.hydration), dehydratedState); if ((config === null || config === void 0 ? void 0 : config.retroactive) === false) return; Object.entries(dehydratedState).forEach(([key, val]) => { const instance = this._instances[key]; if (!instance) return; instance.setState(instance.template.hydrate ? instance.template.hydrate(val) : val); // we know hydration is defined at this point delete this.hydration[key]; }); } /** * Add a ZeduxPlugin to this ecosystem. This ecosystem will subscribe to the * plugin's modStore, whose state can be changed to reactively update the mods * of this ecosystem. * * This method will also call the passed plugin's `.registerEcosystem` method, * allowing the plugin to subscribe to this ecosystem's modBus * * The plugin will remain part of this ecosystem until it is unregistered or * this ecosystem is destroyed. `.wipe()` and `.reset()` don't remove plugins. * However, a plugin _can_ set the `ecosystemWiped` mod and react to those * events. */ registerPlugin(plugin) { if (this.plugins.some(descriptor => descriptor.plugin === plugin)) return; const subscription = plugin.modStore.subscribe((newState, oldState) => { this.recalculateMods(newState, oldState); }); const cleanupRegistration = plugin.registerEcosystem(this); const cleanup = () => { subscription.unsubscribe(); if (cleanupRegistration) cleanupRegistration(); }; this.plugins.push({ cleanup, plugin }); this.recalculateMods(plugin.modStore.getState()); } /** * Remove all passed atoms from this ecosystem's list of atom overrides. Does * nothing for passed atoms that aren't currently in the overrides list. * * Force destroys all instances of all removed atoms. This forced destruction * will cause dependents of those instances to recreate their dependency atom * instance without using an override. */ removeOverrides(overrides) { this.overrides = mapOverrides(Object.values(this.overrides).filter(template => overrides.every(override => { const key = typeof override === 'string' ? override : override.key; return key !== template.key; }))); overrides.forEach(override => { const instances = this.findAll(override); Object.values(instances).forEach(instance => instance.destroy(true)); }); } /** * Destroys all atom instances in this ecosystem, runs the cleanup function * returned from `onReady` (if any), and calls `onReady` again to reinitialize * the ecosystem. * * Note that this doesn't remove overrides or plugins but _does_ remove * hydrations. This is because you can remove overrides/plugins yourself if * needed, but there isn't currently a way to remove hydrations. */ reset(newContext) { var _a; this.wipe(); const prevContext = this.context; if (typeof newContext !== 'undefined') this.context = newContext; this.cleanup = (_a = this.onReady) === null || _a === void 0 ? void 0 : _a.call(this, this, prevContext); } /** * Runs an AtomSelector statically - without registering any dependencies or * updating any caches. If we've already cached this exact selector + args * combo, returns the cached value without running the selector again */ select(selectable, ...args) { if (is(selectable, SelectorCache)) { return selectable.result; } const atomSelector = selectable; const cache = this.selectors.find(atomSelector, args); if (cache) return cache.result; const resolvedSelector = typeof atomSelector === 'function' ? atomSelector : atomSelector.selector; return resolvedSelector({ ecosystem: this, get: this.get.bind(this), getInstance: this.getInstance.bind(this), select: this.select.bind(this), }, ...args); } /** * Completely replace this ecosystem's current list of atom overrides with a * new list. * * Force destroys all instances of all previously- and newly-overridden atoms. * This forced destruction will cause dependents of those instances to * recreate their dependency atom instance. */ setOverrides(newOverrides) { const oldOverrides = this.overrides; this.overrides = mapOverrides(newOverrides); if (!this.isInitialized) return; newOverrides.forEach(atom => { const instances = this.findAll(atom); Object.values(instances).forEach(instance => { instance.destroy(true); }); }); Object.values(oldOverrides).forEach(atom => { const instances = this.findAll(atom); Object.values(instances).forEach(instance => { instance.destroy(true); }); }); } /** * Unregister a plugin registered in this ecosystem via `.registerPlugin()` */ unregisterPlugin(plugin) { const index = this.plugins.findIndex(descriptor => descriptor.plugin === plugin); if (index === -1) return; this.plugins[index].cleanup(); this.plugins.splice(index, 1); this.recalculateMods(undefined, plugin.modStore.getState()); } /** * Get the current graph of this ecosystem. There are 3 views: * * Flat (default). Returns an object with all graph nodes on the top layer, * each node pointing to its dependencies and dependents. No nesting. * * Bottom-Up. Returns an object containing all the leaf nodes of the graph * (nodes that have no internal dependents), each node containing an object of * its parent nodes, recursively. * * Top-Down. Returns an object containing all the root nodes of the graph * (nodes that have no dependencies), each node containing an object of its * child nodes, recursively. */ viewGraph(view) { const { nodes } = this._graph; if (view !== 'top-down' && view !== 'bottom-up') { const hash = {}; Object.keys(nodes).forEach(id => { const node = nodes[id]; hash[id] = { dependencies: [...node.dependencies.keys()].map(key => ({ key, operation: nodes[key].dependents.get(id) .operation, })), dependents: [...node.dependents.keys()].map(key => ({ key, operation: node.dependents.get(key).operation, })), weight: node.weight, }; }); return hash; } const hash = {}; Object.keys(nodes).forEach(key => { const node = nodes[key]; const isTopLevel = view === 'bottom-up' ? [...node.dependents.values()].every(dependent => dependent.flags & External) : !node.dependencies.size; if (isTopLevel) { hash[key] = {}; } }); const recurse = (node) => { if (!node) return; const map = view === 'bottom-up' ? node.dependencies : node.dependents; const children = {}; for (const key of map.keys()) { const child = recurse(nodes[key]); if (child) children[key] = child; } return children; }; Object.keys(hash).forEach(key => { const node = nodes[key]; const children = recurse(node); if (children) hash[key] = children; }); return hash; } /** * Returns the list of reasons detailing why the current atom instance or * selector is evaluating. * * Returns undefined if nothing is currently evaluating. Returns an empty * array if this is the first evaluation of the instance or selector. */ why() { var _a; return (_a = this._evaluationStack.read()) === null || _a === void 0 ? void 0 : _a.node.nextReasons; } /** * Destroy all atom instances in this ecosystem. Also run the cleanup function * returned from the onReady callback (if any). Don't remove plugins or re-run * the onReady callback. * * Also don't remove overrides. This may usually be wanted, but it's easy * enough to add a `.setOverrides([])` call when you need it. * * Important! This method is mostly for internal use. You won't typically want * to call this method. Prefer `.reset()` which re-runs the onReady callback * after wiping the ecosystem, allowing onReady to re-initialize the ecosystem * - preloading atoms, registering plugins, configuring context, etc */ wipe() { var _a; const { _instances, _mods, _scheduler, modBus, selectors } = this; // call cleanup function first so it can configure the ecosystem for cleanup (_a = this.cleanup) === null || _a === void 0 ? void 0 : _a.call(this); // TODO: Delete nodes in an optimal order, starting with nodes with no // internal dependents. This is different from highest-weighted nodes since // static dependents don't affect weight. This should make sure no internal // nodes schedule unnecessary reevaaluations to recreate force-destroyed // instances Object.values(_instances).forEach(instance => { instance.destroy(true); }); this.hydration = undefined; selectors._wipe(); _scheduler.wipe(); _scheduler.flush(); if (_mods.ecosystemWiped) { modBus.dispatch(pluginActions.ecosystemWiped({ ecosystem: this })); } } /** * Should only be used internally */ _consumeHydration(instance) { var _a; const hydratedValue = (_a = this.hydration) === null || _a === void 0 ? void 0 : _a[instance.id]; if (typeof hydratedValue === 'undefined') return; // hydration must exist here. This cast is fine: delete this.hydration[instance.id]; return instance.template.hydrate ? instance.template.hydrate(hydratedValue) : hydratedValue; } /** * Should only be used internally */ _decrementRefCount() { this._refCount--; if (!this.destroyOnUnmount) return; this.destroy(); // only destroys if _refCount === 0 } /** * Should only be used internally */ _destroyAtomInstance(id) { // try to destroy instance (if not destroyed - this fn is called as part of // that destruction process too) this._graph.removeNode(id); // mods have already been notified of the instance's status changing to // Destroyed by this point. No need to notify anything of this mutation. delete this._instances[id]; } /** * Should only be used internally */ _incrementRefCount() { this._refCount++; } recalculateMods(newState, oldState) { if (oldState) { oldState.forEach(key => { this._mods[key]--; // fun fact, undefined-- is fine }); } if (newState) { newState.forEach(key => { this._mods[key]++; }); } } resolveAtom(template) { var _a; const { flags, overrides } = this; const override = overrides[template.key]; const maybeOverriddenAtom = (override || template); // to turn off flag checking, just don't pass a `flags` prop if (flags) { const badFlag = (_a = maybeOverriddenAtom.flags) === null || _a === void 0 ? void 0 : _a.find(flag => !flags.includes(flag)); if (true /* DEV */ && badFlag) { console.error(`Zedux: encountered unsafe atom template "${template.key}" with flag "${badFlag}". This atom template should be overridden in the current environment.`); } } return maybeOverriddenAtom; } }