Updates
Everything so far builds a program and computes it once. This step makes it react. A change enters at one node and flows to everything that reads it, settled in dependency order. As a result a rerun costs what the change costs and no more. The order comes from two things already in place. Within a scope, a node's inputs were built before it, so index order is dependency order. Scopes also nest into a tree. The walk descends that tree and loops each scope's nodes in order. Two flags on a node carry the change, with no heap and no queue.
Two Flags Carry a Change
A node gains two flags. inputsChanged means the node's own inputs moved, so its value must be
recomputed. contentsUpdated means the node owns a scope whose insides moved, so that scope must be
walked. The first says what changed; the second says where to look. Together they let the walk
start at the root scope and descend only into the scopes that hold changed work.
What a Node Gains
The flags live on the node, so _nodeCreate seeds them both to false. Two new lines:
Reactive._nodeCreate = (payload, value, inputNodeRefs) => {
const scope = Reactive._currentScope;
const node = {
[Reactive._reactive]: true,
scope,
payload,
value,
inputNodeRefs,
inputsChanged: false,
contentsUpdated: false,
listeners: new Set(),
};
scope.nodes.push(node);
inputNodeRefs.forEach((inputNode) => {
inputNode.listeners.add(node);
});
return node;
};
Nothing else changes. The scope tree from before, each scope's
parentNode, is the path the walk goes up to mark contentsUpdated. The root scope's
undefined parent is where that climb stops.
Marking, Up the Scope Tree
A node's value is stored through _updateNodeValue, which only reacts when the value actually
changed. When it does, every node that reads it, grouped by scope, is marked inputsChanged:
Reactive._updateNodeValue = (node, nextValue) => {
if (nextValue !== node.value) {
node.value = nextValue;
Reactive._updateMarkListenersChanged(node);
}
};
Reactive._updateMarkListenersChanged = (node) => {
node.listeners.forEach((listenerNode) => {
Reactive._updateMarkInputsChanged(listenerNode);
});
};
Marking a node inputsChanged then threads a path of contentsUpdated up the scope tree,
so the walk can reach it from the root. The climb follows each scope's parentNode and marks each
owning node. The climb stops the moment it meets an owner already marked, because that chain was threaded before.
The climb also stops when it reaches the root scope, whose parent node is undefined:
Reactive._updateMarkInputsChanged = (node) => {
if (!node.inputsChanged) {
node.inputsChanged = true;
Reactive._updateMarkScopeChanged(node.scope);
}
};
Reactive._updateMarkScopeChanged = (scope) => {
let currentScope = scope;
while (currentScope.parentNode !== undefined) {
const owner = currentScope.parentNode;
if (owner.contentsUpdated) {
return; // this owner's chain up was threaded before
}
owner.contentsUpdated = true;
currentScope = owner.scope; // climb by the owner's own scope object
}
};
If read only its condition again. A change
inside its body needs no edge back to the if node. Marking a body node threads contentsUpdated
through the if node, and the walk starts from there.Settling a Node
A node marked inputsChanged is settled by _updateNodeInputs, which dispatches on its
kind. A calculation and an emit recompute their value. An if reacts to its condition:
Reactive._updateNodeInputs = (node) => {
const payload = node.payload;
if (payload.calculate) {
Reactive._updateCalculateNodeInputs(node);
} else if (payload.emit) {
Reactive._updateEmitNodeInputs(node);
} else if (payload.if_) {
Reactive._updateIfInputs(node);
}
};
A calculation reruns its callback over its inputs' current values, and carries its inputs' pending with it. An
emit recomputes its stream with _emitCompute, reappending (or withholding) its own stable leaf. Each
stores the result through _updateNodeState, so the value and its pending flag flow on only if
something actually moved:
Reactive._updateCalculateNodeInputs = (node) => {
const inputValues = Reactive._values(node.inputNodeRefs);
const calculatedValue = node.payload.calculate(...inputValues);
const pending = Reactive._anyPending(node.inputNodeRefs);
Reactive._updateNodeState(node, calculatedValue, pending);
};
Reactive._updateEmitNodeInputs = (node) => {
const emissions = Reactive._emitCompute(node.payload.emit, node.inputNodeRefs);
Reactive._updateNodeState(node, emissions, false);
};
leafId, so a changed value updates the leaf
in place. The array diff reports it as an updated, not a remove and an add. And a value that
resolves from pending makes its withheld entry appear, in its leaf-id order.An If Reacts
An if with a changed condition settles differently. By whether a body already stands, the if raises a fresh body, removes one, or leaves it. Then it takes its value from the body. The value is the body scope's emissions when one stands, or the shared empty object when none does. The if reads its body across scopes, with no listener edge, so that value is refreshed by hand:
Reactive._updateIfInputs = (ifNode) => {
const ifPayload = ifNode.payload.if_;
const conditionNode = ifNode.inputNodeRefs[0];
const conditionIsPresent = conditionNode.value;
const hadBody = ifPayload.optionalScope !== undefined;
if (conditionIsPresent && !hadBody) {
ifPayload.optionalScope = Reactive._scopeCreate(ifNode, ifPayload.callback);
} else if (!conditionIsPresent && hadBody) {
Reactive._scopeDestroy(ifPayload.optionalScope);
ifPayload.optionalScope = undefined;
}
Reactive._refreshIfValue(ifNode);
};
Reactive._refreshIfValue = (ifNode) => {
const conditionPending = Reactive._anyPending(ifNode.inputNodeRefs);
const ifValue = Reactive._emitIfValue(conditionPending, ifNode.payload.if_.optionalScope);
Reactive._updateNodeValue(ifNode, ifValue);
};
When it is the if's contents that changed, the walk processes its body scope so its own changed nodes
settle. Then the body's fresh emissions are re-read as the if's value. _updateNodeContents dispatches
this, the mirror of _updateNodeInputs for the contentsUpdated flag:
Reactive._updateIfContents = (ifNode) => {
const ifPayload = ifNode.payload.if_;
if (ifPayload.optionalScope !== undefined) {
Reactive._processScope(ifPayload.optionalScope);
}
Reactive._refreshIfValue(ifNode);
};
Reactive._updateNodeContents = (node) => {
if (node.payload.if_) {
Reactive._updateIfContents(node);
}
};
If owns one body, so one flag says "walk it". A structure that owns
many child scopes, a list (Each) or the reverse transactions
host, keeps the marker on its own payload and records the set of child scopes that moved, so the walk
descends into just those. _updateNodeContents gains a branch per such kind; the shape is the same.To remove a body is _scopeDestroy. It collects the whole subtree of scopes. It unhooks each of their
nodes from the listener sets of the surviving nodes they read. Then it drops the scopes, so nothing left standing
points at a node that is gone:
Reactive._scopeDestroy = (scope) => {
const removing = new Set();
const queue = [scope];
while (queue.length > 0) {
const removeScope = queue.pop();
removing.add(removeScope);
removeScope.nodes.forEach((node) => {
const ifPayload = node.payload.if_;
if (ifPayload && ifPayload.optionalScope !== undefined) {
queue.push(ifPayload.optionalScope);
}
});
}
removing.forEach((removeScope) => {
removeScope.nodes.forEach((node) => {
node.inputNodeRefs.forEach((inputNode) => {
if (!removing.has(inputNode.scope)) {
inputNode.listeners.delete(node);
}
});
});
});
removing.forEach((removeScope) => {
removeScope.destroyed = true;
});
};
The Walk
The walk is a plain in-order loop over a scope's nodes, with no heap and no queue. Each node whose inputs changed is settled. Then the walk processes the body of each node whose contents changed, both in index order, which is dependency order. An if can be both, when its condition and its body have moved in the one pass. New readers marked at a larger index during the pass are reached later in the same loop:
Reactive._processScope = (scope) => {
const nodes = scope.nodes;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex = nodeIndex + 1) {
const node = nodes[nodeIndex];
if (node.inputsChanged) {
node.inputsChanged = false;
Reactive._updateNodeInputs(node);
}
if (node.contentsUpdated) {
node.contentsUpdated = false;
Reactive._updateNodeContents(node);
}
}
};
Reactive._update = (root) => {
Reactive._processScope(root.rootScope); // walk from the root scope object
};
contentsUpdated was threaded. This is what lets an
If read only its condition. A change inside its body threads contentsUpdated through the
if node. The walk processes its body, then re-reads the if's value from the settled body.Set
Set is the one way in, and the one function that names root. It establishes the ambient
_currentRoot that every update step reads. It writes the state's new value through
_updateNodeValue, which marks its readers only if it actually changed. Then it runs the walk. The
program is data, and this is a mutation on that data.
Reactive.Set = (root, reference, value) => {
const previousRoot = Reactive._currentRoot;
Reactive._currentRoot = root; // ambient for the whole update
try {
const node = reference;
Reactive._updateNodeValue(node, Reactive._value(value));
Reactive._update(root);
} finally {
Reactive._currentRoot = previousRoot;
}
};
Running It
A calculation follows a Set to its state, whatever the length of the chain. A diamond joins both
updated paths, and each node is recomputed once:
const root = Reactive.Root(() => {
n = Reactive.State(1);
const doubled = Reactive.Calculate([n], (x) => x * 2);
const incremented = Reactive.Calculate([n], (x) => x + 1);
sum = Reactive.Calculate([doubled, incremented], (a, b) => a + b);
}, {});
// sum -> 4
Reactive.Set(root, n, 10); // sum -> 31 (10*2 + 10+1)
An emitted value reflows its gather and keeps its stable id. It is a value change, not an add and a remove. A
change inside a present If, even a state declared in the body or two scopes deep, reflows the same
way, in document order, because the walk goes up the scope tree to reach it:
// Emit 1, If(true){ Emit 2, If(true){ Emit s (a State = 5) }, Emit 8 }, Emit 9, Gather('n')
// -> [1, 2, 5, 8, 9]
Reactive.Set(root, s, 6); // -> [1, 2, 6, 8, 9] a change two scopes deep, still in order
A toggle of an If raises or drops its whole block. It keeps document order and cleans up its
nodes:
const root = Reactive.Root(() => {
open = Reactive.State(false);
Reactive.Emit('n', 1);
Reactive.If(open, () => { Reactive.Emit('n', 2); });
Reactive.Emit('n', 3);
g = Reactive.Gather('n');
}, {});
// g -> [1, 3]
Reactive.Set(root, open, true); // g -> [1, 2, 3] the body is raised, in order
Reactive.Set(root, open, false); // g -> [1, 3] the body is torn down, its nodes gone
A driver's leftover stream at the root is a reactive value like every other value. A Set reflows it
too, and this is how the outside world sees a change:
const root = Reactive.Root(() => {
s = Reactive.State('a');
Reactive.Emit('dom', s);
}, { dom: {} });
// the root scope's 'dom' stream -> ['a']
Reactive.Set(root, s, 'z'); // the 'dom' stream -> ['z']
Next Steps
With the walk in place the graph is a program that runs and reacts. A state changes, and the change travels exactly as far as it is read. It travels down the scope tree to the branch that holds it, along each scope in index order, each node touched at most once. Calculations rerun, emissions reflow their gathers, and a section appears or disappears as its condition flips.
That is the forward direction complete. A change flows from a state out to everything that reads it. The last piece is the reverse direction. A Reverse lens declares the state it writes and returns the new value. A set of it flows a change back onto real state, held by a write lock that keeps an async write from landing until a driver's result arrives.