Scopes
Scopes contain a block of nodes which are fixed in place. You can add and remove whole scopes in one step for the
dynamic parts of the program, for example for conditional logic (Reactive.If) or array iteration
(Reactive.Each). The root scope is the first. Scopes nest inside
one another and form a tree.
The Scope Tree
For scopes to nest, each one records the node that owns it: a parentNode, the node object whose block
this scope is. An If owns its body scope. That is what the next
step builds. The parentNode from any scope climbs the tree towards the root.
{
nodes: [], // the nodes built into this scope
parentNode, // the node object that owns this scope
};
The root scope has no owning node. Its parentNode is undefined. A climb up the tree
stops the moment it reaches a parent that is not there. As a result, that undefined parent is where
every climb ends.
_scopeCreate
A new scope takes the owning node and stores it on the new scope. There are no ids and no map: the scope is just
an object, and _scopeCreate returns it directly. The ambient _currentScope is the scope
object itself.
In its final form the scope object carries more than nodes and parentNode. It holds a
currentEmissionsRef, the running emissions value for this
scope, seeded to the shared empty object before the callback runs so nodes land into a real stream. It holds
contexts for context lookups, and a destroyed
flag so a stale entry in a changed-scopes batch is skipped on teardown:
Reactive._scopeCreate = (parentNode, callback) => {
const scope = {
nodes: [],
parentNode,
currentEmissionsRef: undefined,
contexts: undefined,
destroyed: false,
};
const previousScope = Reactive._currentScope;
Reactive._currentScope = scope;
try {
scope.currentEmissionsRef = Reactive.Constant(Reactive._emitEmpty);
callback();
} finally {
Reactive._currentScope = previousScope;
}
return scope;
};
The emissions ref is seeded inside the try, after _currentScope is pointed at the new
scope, so the callback builds into an emissions stream that already exists.
Reactive.Root
You open the root scope like any other, so Reactive.Root gives it an undefined parent.
It has no owning node, which is what makes the climb stop there. The scope object it opens is kept as
root.rootScope:
Reactive.Root = (programCallback) => {
const previousRoot = Reactive._currentRoot;
const root = {};
Reactive._currentRoot = root;
try {
root.rootScope = Reactive._scopeCreate(undefined, programCallback);
return root;
} finally {
Reactive._currentRoot = previousRoot;
}
};
Running It
The program still builds into the root scope. But that scope now records its parent, the undefined
owner that roots the tree:
const root = Reactive.Root(() => {
const one = Reactive.Constant(1);
const two = Reactive.State(2);
const sum = Reactive.Calculate([one, two], (a, b) => a + b);
});
const rootScope = root.rootScope;
rootScope.parentNode; // undefined no owning node; the climb stops here
_scopeDestroy
Removing a scope removes everything it owns. A scope owned by an If, an
Each, or a transactions node may itself own further scopes, so teardown first collects the whole
subtree through a queue, with no recursion. Each node in a scope is checked for an if_, an
each, or a transactions payload, and any scopes those hold are pushed onto the queue:
Reactive._scopeDestroy = (scope) => {
// collect the whole subtree of scopes to remove (child scopes owned by if/each/transactions nodes).
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);
}
const eachPayload = node.payload.each;
if (eachPayload) {
Reactive._arrayToList(eachPayload.entries).forEach((entryLeaf) => {
queue.push(entryLeaf.value);
});
}
const transactionsPayload = node.payload.transactions;
if (transactionsPayload) {
Reactive._arrayToList(transactionsPayload.entries).forEach((transactionLeaf) => {
queue.push(transactionLeaf.value);
});
}
});
}
// sever the outward edges (remove each dying node from the listener set of any surviving input it
// reads) and flag each scope destroyed. Edges inside the subtree vanish with the nodes, so once the
// owning payload link is dropped the subtree is unreachable and collected — there is no registry.
removing.forEach((removeScope) => {
removeScope.nodes.forEach((node) => {
node.inputNodeRefs.forEach((inputNode) => {
if (!removing.has(inputNode.scope)) {
inputNode.listeners.delete(node);
}
});
});
removeScope.destroyed = true;
});
};
With the subtree gathered, the second pass severs only the outward edges. A node's inputs are always in its own
scope or an ancestor, so any input that lies outside the subtree is a surviving node, and one
listeners.delete per such node drops all of the dying scope's listeners on it. Edges inside the
subtree vanish with the nodes themselves. Because this severing runs before any scope is dropped and only touches
nodes outside the subtree, every scope it reaches still exists, so no existence check is needed. Each scope is
then marked destroyed.
Next Steps
With the scope tree in place, you can treat a section of the program as a unit and hang it off the node that owns it. Next, If builds a nested scope, owned by the If node, only when a condition holds.