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
};
A scope now knows its owner, so scopes form a tree

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 final scope carries its emissions, contexts, and destroyed flag

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;
  }
};
The root scope is opened with no owning node

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
The root scope sits at the top of the tree, with no owning node

_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;
  });
};
A scope and its whole subtree are torn down in one step

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.