@zedux/atoms
Version:
A Molecular State Engine for React
336 lines (335 loc) • 14.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Graph = void 0;
const index_1 = require("../utils/index");
const plugin_actions_1 = require("../utils/plugin-actions");
/**
* When a node's refCount hits 0, schedule destruction of that node.
*/
const scheduleNodeDestruction = ({ ecosystem, nodes }, nodeId) => {
const node = nodes[nodeId];
if (node && !node.refCount) {
if (node.isSelector) {
ecosystem.selectors._destroySelector(nodeId);
}
else {
ecosystem._instances[nodeId]._scheduleDestruction();
}
}
};
/**
* When a node's refCount hits 0, we schedule destruction of that node. If
* that destruction is still pending and the refCount goes back up to 1,
* cancel the scheduled destruction.
*/
const unscheduleNodeDestruction = ({ ecosystem, nodes }, nodeId) => {
var _a, _b;
const dependency = nodes[nodeId];
if (!dependency.isSelector) {
(_b = (_a = ecosystem._instances[nodeId])._cancelDestruction) === null || _b === void 0 ? void 0 : _b.call(_a);
}
};
class Graph {
constructor(ecosystem) {
this.ecosystem = ecosystem;
this.nodes = {};
this.updateStack = [];
}
/**
* Draw a new edge between two nodes in the graph. This is how dependencies
* are created between atoms, selectors, and external nodes like React
* components.
*/
addEdge(dependentKey, dependencyKey, operation, flags, callback) {
const { ecosystem, updateStack } = this;
const newEdge = {
callback,
createdAt: ecosystem._idGenerator.now(),
flags,
operation,
};
if (!updateStack.length) {
return this.finishAddingEdge(dependentKey, dependencyKey, newEdge);
}
// We're buffering updates! Buffer this one
const { dependencies, key } = updateStack[updateStack.length - 1];
if (true /* DEV */ && key !== dependentKey) {
throw new Error("Zedux: Tried updating dependencies of a graph node that isn't currently evaluating. This means that either you tried updating the graph manually or there is a bug in Zedux. If it's a bug, please report it!");
}
const dependency = dependencies.get(dependencyKey);
// Don't overwrite a higher-prio edge with a lower one. Also ignore same-prio
if (!dependency || dependency.flags > flags) {
dependencies.set(dependencyKey, newEdge);
return newEdge;
}
// if this edge was ignored, return the existing buffered edge
return dependency;
}
// Should only be used internally
addNode(nodeId, isSelector) {
if (this.nodes[nodeId])
return; // already added
this.nodes[nodeId] = {
dependencies: new Map(),
dependents: new Map(),
isSelector,
refCount: 0,
weight: 1, // this node doesn't have dependencies yet; its weight is 1
};
}
/**
* Prevent new graph edges from being added immediately. Instead, buffer them
* so we can prevent duplicates or unnecessary edges. Call `.flushUpdates()`
* to finish buffering.
*
* This is used during atom and AtomSelector evaluation to make the graph as
* efficient as possible.
*/
bufferUpdates(key) {
this.updateStack.push({ key, dependencies: new Map() });
}
/**
* If an atom instance or AtomSelector errors during evaluation, we need to
* destroy any instances or AtomSelectors created during that evaluation that
* now have no dependents.
*/
destroyBuffer() {
const { dependencies, key } = this.updateStack[this.updateStack.length - 1];
const edges = this.nodes[key].dependencies;
for (const dependencyKey of dependencies.keys()) {
// the edge wasn't created during the evaluation that errored; keep it
if (edges.get(dependencyKey))
continue;
scheduleNodeDestruction(this, dependencyKey);
}
this.updateStack.pop();
}
/**
* Stop buffering updates for the node passed to `.bufferUpdates()` and add
* the buffered edges to the graph.
*/
flushUpdates() {
const { nodes, updateStack } = this;
const { dependencies, key } = updateStack[updateStack.length - 1];
const edges = nodes[key].dependencies;
// remove any edges that were not recreated while buffering. Don't remove
// anything but implicit-internal edges (those are the only kind we
// auto-create during evaluation - other types may have been added manually
// by the user and we don't want to touch them here)
for (const dependencyKey of edges.keys()) {
// this cast should be fine
const existingEdge = nodes[dependencyKey].dependents.get(key);
if (existingEdge.flags & (index_1.Explicit | index_1.External))
continue;
const edgeToAdd = dependencies.get(dependencyKey);
// if edge still exists, keep it
if (!edgeToAdd || edgeToAdd.flags !== existingEdge.flags) {
this.removeEdge(key, dependencyKey);
}
}
// add new edges that we tracked while buffering
for (const [dependencyKey, edgeToAdd] of dependencies.entries()) {
const existingEdge = edges.get(dependencyKey);
// if edge already exists, keep it
if (!existingEdge) {
this.finishAddingEdge(key, dependencyKey, edgeToAdd);
}
}
updateStack.pop();
}
removeDependencies(dependentKey) {
const node = this.nodes[dependentKey];
if (!node)
return; // node already destroyed
const edges = node.dependencies;
if (!edges)
return; // TODO: delete
for (const dependencyKey of edges.keys()) {
this.removeEdge(dependentKey, dependencyKey);
}
}
/**
* Should only be used internally. Remove the graph edge between two nodes.
* The dependent may not exist as a node in the graph if it's external, e.g. a
* React component
*
* For some reason in React 18+, React destroys parents before children. This
* means a parent EcosystemProvider may have already unmounted and wiped the
* whole graph; this edge may already be destroyed.
*/
removeEdge(dependentKey, dependencyKey) {
const dependency = this.nodes[dependencyKey];
const dependent = this.nodes[dependentKey]; // won't exist if external
// erase graph edge between dependent and dependency
if (dependent) {
dependent.dependencies.delete(dependencyKey);
}
// hmm could maybe happen when a dependency was force-destroyed if a child
// tries to destroy its edge before recreating it (I don't think we ever do
// that though)
if (!dependency)
return;
const dependentEdge = dependency.dependents.get(dependentKey);
// happens in React 18+ (see this method's jsdoc above)
if (!dependentEdge) {
// useAtomSelector can delete edges that were never drawn 'cause React
// StrictMode double-renders can create an entirely new SelectorCache
// before the old one received dependents. Delete the old one in this case
if (dependency.isSelector)
scheduleNodeDestruction(this, dependencyKey);
return;
}
dependency.dependents.delete(dependentKey);
dependency.refCount--;
// static dependencies don't change a node's weight
if (!(dependentEdge.flags & index_1.Static)) {
this.recalculateNodeWeight(dependentKey, -dependency.weight);
}
const { _instances, _mods, _scheduler, modBus, selectors } = this.ecosystem;
if (dependentEdge.task) {
_scheduler.unschedule(dependentEdge.task);
}
if (_mods.edgeRemoved) {
modBus.dispatch(plugin_actions_1.pluginActions.edgeRemoved({
dependency: _instances[dependencyKey] || selectors._items[dependencyKey],
dependent: _instances[dependentKey] ||
selectors._items[dependentKey] ||
dependentKey,
edge: dependentEdge,
}));
}
scheduleNodeDestruction(this, dependencyKey);
}
// Should only be used internally
removeNode(nodeId) {
const node = this.nodes[nodeId];
if (!node)
return; // already removed
// We don't need to remove this dependent from its dependencies here - the
// atom instance/AtomSelector will have removed all its deps before calling
// this function as part of its destruction process
// if an atom instance is force-destroyed, it could still have dependents.
// Inform them of the destruction
this.scheduleDependents(nodeId, [], undefined, undefined, true, 'node destroyed', 'Destroyed', true);
// Remove this dependency from all its dependents and recalculate all
// weights recursively
for (const [dependentKey, dependentEdge] of node.dependents.entries()) {
if (!(dependentEdge.flags & index_1.Static)) {
this.recalculateNodeWeight(dependentKey, -node.weight);
}
const dependentNode = this.nodes[dependentKey];
if (dependentNode)
dependentNode.dependencies.delete(nodeId);
// we _probably_ don't need to send edgeRemoved mod events to plugins for
// these - it's better that they receive the duplicate edgeCreated event
// when the dependency is recreated by its dependent(s) so they can infer
// that the edge was "moved"
}
delete this.nodes[nodeId];
}
/**
* Schedules a job to update all dependents of a node. This is called e.g.
* when an atom instance or AtomSelector updates, when an atom instance is
* force-destroyed, or when an atom instance's promise changes.
*/
scheduleDependents(nodeId, reasons, newState, oldState, shouldSetTimeout, type = 'state changed', signal = 'Updated', scheduleStaticDeps = false) {
const { _instances, _scheduler, selectors } = this.ecosystem;
const instance = _instances[nodeId];
const cache = selectors._items[nodeId];
const node = this.nodes[nodeId];
for (const [dependentKey, dependentEdge] of node.dependents.entries()) {
// if edge.task exists, this edge has already been scheduled
if (dependentEdge.task) {
if (signal !== 'Destroyed')
continue;
// destruction jobs supersede update jobs; cancel the existing job so we
// can create a new one for the destruction
_scheduler.unschedule(dependentEdge.task);
}
// Static deps don't update on state change. Dynamic deps don't update on
// promise change. Both types update on instance force-destruction
const isStatic = dependentEdge.flags & index_1.Static;
if (isStatic && !scheduleStaticDeps)
continue;
const reason = {
newState,
oldState,
operation: dependentEdge.operation,
reasons,
sourceId: nodeId,
sourceType: node.isSelector ? 'AtomSelector' : 'Atom',
type,
};
// let internal dependents (other atoms and AtomSelectors) schedule their
// own jobs
if (!(dependentEdge.flags & index_1.External)) {
if (this.nodes[dependentKey].isSelector) {
selectors._scheduleEvaluation(dependentKey, reason, shouldSetTimeout);
continue;
}
_instances[dependentKey]._scheduleEvaluation(reason, shouldSetTimeout);
continue;
}
// schedule external dependents
const task = () => {
var _a;
dependentEdge.task = undefined;
(_a = dependentEdge.callback) === null || _a === void 0 ? void 0 : _a.call(dependentEdge, signal, instance ? instance.store.getState() : cache.result, // don't use the snapshotted newState above
reason);
};
_scheduler.schedule({
flags: dependentEdge.flags,
task,
type: 3, // UpdateExternalDependent (3)
}, shouldSetTimeout);
// mutate the edge; give it the scheduled task so it can be cleaned up
dependentEdge.task = task;
}
}
/**
* Actually add an edge to the graph. When we buffer graph updates, we're
* really just deferring the calling of this method.
*/
finishAddingEdge(dependentKey, dependencyKey, newEdge) {
const dependency = this.nodes[dependencyKey];
if (!dependency)
return; // happened once for some reason
// draw graph edge between dependent and dependency
if (!(newEdge.flags & index_1.External)) {
this.nodes[dependentKey].dependencies.set(dependencyKey, true);
}
dependency.dependents.set(dependentKey, newEdge);
dependency.refCount++;
unscheduleNodeDestruction(this, dependencyKey);
// static dependencies don't change a node's weight
if (!(newEdge.flags & index_1.Static)) {
this.recalculateNodeWeight(dependentKey, dependency.weight);
}
const { _instances, _mods, modBus, selectors } = this.ecosystem;
if (_mods.edgeCreated) {
modBus.dispatch(plugin_actions_1.pluginActions.edgeCreated({
dependency: _instances[dependencyKey] || selectors._items[dependencyKey],
dependent: _instances[dependentKey] ||
selectors._items[dependentKey] ||
dependentKey,
edge: newEdge,
}));
}
return newEdge;
}
/**
* When a non-static edge is added or removed, every node below that edge (the
* dependent, its dependents, etc) in the graph needs to have its weight
* recalculated.
*/
recalculateNodeWeight(nodeId, weightDiff) {
const node = this.nodes[nodeId];
if (!node)
return; // happens when node is external
node.weight += weightDiff;
for (const dependentKey of node.dependents.keys()) {
this.recalculateNodeWeight(dependentKey, weightDiff);
}
}
}
exports.Graph = Graph;