@zedux/atoms
Version:
A Molecular State Engine for React
617 lines (616 loc) • 25.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Ecosystem = void 0;
const core_1 = require("@zedux/core");
const index_1 = require("../store/index");
const index_2 = require("../utils/index");
const plugin_actions_1 = require("../utils/plugin-actions");
const EvaluationStack_1 = require("./EvaluationStack");
const Graph_1 = require("./Graph");
const IdGenerator_1 = require("./IdGenerator");
const AtomInstanceBase_1 = require("./instances/AtomInstanceBase");
const Scheduler_1 = require("./Scheduler");
const Selectors_1 = require("./Selectors");
const AtomTemplate_1 = require("./templates/AtomTemplate");
const defaultMods = Object.keys(plugin_actions_1.pluginActions).reduce((map, mod) => {
map[mod] = 0;
return map;
}, {});
const mapOverrides = (overrides) => overrides.reduce((map, atom) => {
map[atom.key] = atom;
return map;
}, {});
class Ecosystem {
constructor(config) {
var _a;
this.modBus = (0, core_1.createStore)(); // use an empty store as a message bus
this.overrides = {};
this.selectors = new Selectors_1.Selectors(this);
this._graph = new Graph_1.Graph(this);
// define this after _graph so it can access _graph immediately
this._evaluationStack = new EvaluationStack_1.EvaluationStack(this);
this._idGenerator = new IdGenerator_1.IdGenerator();
this._instances = {};
this._mods = Object.assign({}, defaultMods);
this._refCount = 0;
this._scheduler = new Scheduler_1.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 = index_1.internalStore.getState()[this.id];
if (!ecosystem)
return;
this.plugins.forEach(({ cleanup }) => cleanup());
this.plugins = [];
index_1.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 ((0, core_1.is)(atom, AtomInstanceBase_1.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 || (!(0, core_1.is)(atom, AtomInstanceBase_1.AtomInstanceBase) && !(0, core_1.is)(atom, AtomTemplate_1.AtomTemplate))) {
throw new TypeError(`Zedux: Expected an atom template or atom instance. Received ${(0, core_1.detailedTypeof)(atom)}`);
}
if (typeof params !== 'undefined' && !Array.isArray(params)) {
throw new TypeError(`Zedux: Expected atom params to be an array. Received ${(0, core_1.detailedTypeof)(params)}`);
}
}
if ((0, core_1.is)(atom, AtomInstanceBase_1.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(plugin_actions_1.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 (!(0, core_1.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 ((0, core_1.is)(selectable, Selectors_1.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 & index_2.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(plugin_actions_1.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;
}
}
exports.Ecosystem = Ecosystem;