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 Marks Carry a Change
Two marks carry the change. inputsChanged is a node flag: the node's own inputs moved, so its
value must be recomputed. contentsUpdated is kept on the owning payload: 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 update walk needs to know which nodes have changed inputs. Every node already carries
inputsChanged, seeded false when it is created, as the values
page shows in _nodeCreate. Nothing is added to the node here. The scan sets it when a reader's
inputs move, and clears it once the reader has settled.
The per-scope "contents changed" record is kept on the payload, not the node. An if keeps a single
contentsUpdated boolean on payload.if_; an each keeps the set of its changed child
scopes on payload.each. This is what _updateMarkScopeChanged writes, below.
Nothing else changes. The scope tree from before, each scope's
parentNode, is the path the walk goes up to record contents-changed on each owning payload. 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 contents-updated marks up the scope tree,
so the walk can reach it from the root. The climb follows each scope's parentNode and records the
change on each owning node's payload: an if keeps a single contentsUpdated boolean, an each the set
of its changed child scopes. 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;
const payload = owner.payload;
if (payload.if_) {
if (payload.if_.contentsUpdated) {
return; // this owner's chain up was threaded before
}
payload.if_.contentsUpdated = true;
} else if (payload.each) {
const changedScopes = payload.each.optionalContentsUpdatedScopes || new Set();
payload.each.optionalContentsUpdatedScopes = changedScopes;
if (changedScopes.has(currentScope)) {
return; // this child scope was recorded before
}
changedScopes.add(currentScope);
}
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) => {
if (!node.inputsChanged) {
return;
}
node.inputsChanged = false;
const payload = node.payload;
if (payload.calculate) {
Reactive._updateCalculateNodeInputs(node);
} else if (payload.emit) {
Reactive._updateEmitNodeInputs(node);
} else if (payload.gather) {
Reactive._updateGatherNodeInputs(node);
} else if (payload.if_) {
Reactive._updateIfInputs(node);
} else if (payload.each) {
Reactive._updateEachInputs(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, and
carries the previous emissions node's global forward as its own pending. 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, node.inputNodeRefs[0].pending);
};
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.A gather is the mirror of an emit. When its emissions input moves, it takes its name's array back out of the emissions, and takes its own node pending from that name's per-name flag. This is the point where the pending a value carried along becomes a node flag again, for the readers below the gather:
Reactive._updateGatherNodeInputs = (node) => {
const emissionsRef = node.inputNodeRefs[0];
const gatheredArray = Reactive._emitGatherArray(node.payload.gather, emissionsRef);
const gatheredPending = Reactive._emitGatherPending(node.payload.gather, emissionsRef);
Reactive._updateNodeState(node, gatheredArray, gatheredPending);
};
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 refreshes both its value and its global pending from the
body. The value is the body scope's emissions when one stands, or the shared empty object when none does; the global
pending is true when the condition is pending, or when the standing body is itself globally pending. The if reads its
body across scopes, with no listener edge, so both are refreshed by hand through _updateNodeState:
Reactive._updateIfInputs = (ifNode) => {
const ifPayload = ifNode.payload.if_;
const conditionIsPresent = Reactive._value(ifNode.inputNodeRefs[0]);
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._updateRefreshIfValue(ifNode);
};
Reactive._updateRefreshIfValue = (ifNode) => {
const conditionPending = Reactive._anyPending(ifNode.inputNodeRefs);
const optionalScope = ifNode.payload.if_.optionalScope;
const ifValue = Reactive._emitIfValue(conditionPending, optionalScope);
const ifPending = Reactive._emitIfPending(conditionPending, optionalScope);
Reactive._updateNodeState(ifNode, ifValue, ifPending);
};
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 and global pending are re-read as the if's value and pending.
_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._updateRefreshIfValue(ifNode);
};
Reactive._updateNodeContents = (node) => {
const payload = node.payload;
if (payload.if_) {
if (payload.if_.contentsUpdated) {
payload.if_.contentsUpdated = false;
Reactive._updateIfContents(node);
}
} else if (payload.each) {
const changedScopeIds = payload.each.optionalContentsUpdatedScopes;
if (changedScopeIds !== undefined) {
payload.each.optionalContentsUpdatedScopes = undefined;
Reactive._updateEachContents(node, changedScopeIds);
}
}
};
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;
});
};
An Each Reconciles
An Each owns one scope per element. Like an if, it reads those scopes across scope boundaries with no
listener edge, so it refreshes its value by hand. Its value is its entry scopes' emissions combined in order, and
its pending is true when its array is pending or any entry is. This mirrors the if's value refresh:
Reactive._updateRefreshEachValue = (eachNode) => {
const arrayPending = Reactive._anyPending(eachNode.inputNodeRefs);
const entries = eachNode.payload.each.entries;
const eachValue = Reactive._emitEachValue(arrayPending, entries);
const eachPending = Reactive._emitEachPending(arrayPending, entries);
Reactive._updateNodeState(eachNode, eachValue, eachPending);
};
When the each's input array changes, it reconciles by element id in one O(N) sweep. An element gone from the new array has its entry scope destroyed. A new id gets a fresh scope. An id whose value changed has its scope rebuilt. An unchanged id keeps its scope, so a reorder just reuses the scopes in the new order. The entries list, whose leaf id is the element id and whose value is the entry scope id, is rebuilt in the new order, and the combined value refreshed:
Reactive._updateEachInputs = (eachNode) => {
const eachPayload = eachNode.payload.each;
const newArray = Reactive._value(eachNode.inputNodeRefs[0]);
const oldArray = eachPayload.array;
const oldEntries = eachPayload.entries;
const scopeByElementId = new Map();
oldEntries.forEach((entryLeaf) => {
scopeByElementId.set(entryLeaf.id, entryLeaf.value);
});
const oldValueById = new Map();
oldArray.forEach((element) => {
oldValueById.set(element.id, element.value);
});
const newIds = new Set();
newArray.forEach((element) => {
newIds.add(element.id);
});
// destroy the entry scopes of elements gone from the new array.
oldEntries.forEach((entryLeaf) => {
if (!newIds.has(entryLeaf.id)) {
Reactive._scopeDestroy(entryLeaf.value);
scopeByElementId.delete(entryLeaf.id);
}
});
// reuse unchanged scopes, rebuild value-changed ones, build new ones — in the new order.
const entries = [];
newArray.forEach((element) => {
const hadScope = scopeByElementId.has(element.id);
const valueUnchanged = hadScope && oldValueById.get(element.id) === element.value;
let entryScopeId;
if (valueUnchanged) {
entryScopeId = scopeByElementId.get(element.id);
} else {
if (hadScope) {
Reactive._scopeDestroy(scopeByElementId.get(element.id));
}
entryScopeId = Reactive._eachEntryCreate(eachNode, eachPayload.callback, element);
scopeByElementId.set(element.id, entryScopeId);
}
entries.push({ id: element.id, value: entryScopeId });
});
eachPayload.entries = entries;
eachPayload.array = newArray;
Reactive._updateRefreshEachValue(eachNode);
};
When it is the each's contents that changed, the walk processes only the entry scopes recorded as changed.
A scope rebuilt or removed by _updateEachInputs in the same pass is gone, so any that no longer exists is
skipped. Then the combined value is refreshed:
Reactive._updateEachContents = (eachNode, changedScopes) => {
changedScopes.forEach((entryScope) => {
if (!entryScope.destroyed) {
Reactive._processScope(entryScope);
}
});
Reactive._updateRefreshEachValue(eachNode);
};
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 ++) {
const node = nodes[nodeIndex];
Reactive._updateNodeInputs(node);
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.The Way In
A write is the one way in. It goes through root.set, the bound wrapper each root carries, which
calls the framework's one internal write path. That path establishes the ambient root every update step reads,
writes the state's new value through _updateNodeValue, which marks its readers only if it actually
changed, then runs the walk. The full write path, with its reverse lenses and write locks, is built on the
reverse page. Here it is enough that a write settles the state and walks
the tree.
Running It
A calculation follows a write 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
root.set(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]
root.set(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]
root.set(open, true); // g -> [1, 2, 3] the body is raised, in order
root.set(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 write 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']
root.set(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.