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);
  });
};
A real value change marks every reader inputs-changed

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
  }
};
One reader marked; a path of contents-updated threaded up to the root
This upward thread is what lets an 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);
  }
};
Settle a node whose inputs changed, by kind

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);
};
Recompute a value with its pending, then store it through _updateNodeState
An emit keeps its stable 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);
};
A gather recomputes its array and pending from the emissions that reached it

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);
};
_updateIfInputs: raise, tear down, then take the body's emissions

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);
    }
  }
};
Settle a node whose contents changed: walk its body, re-read its value
An 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;
  });
};
_scopeDestroy: drop a whole subtree, unhooking its outward edges first

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);
};
_updateRefreshEachValue: combine the entry scopes' emissions, in order

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);
};
_updateEachInputs: keyed reconciliation, one O(N) sweep by element id

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);
};
_updateEachContents: walk only the changed entry scopes, re-read the value

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
};
_update: walk the root scope, which descends into every scope holding dirt
The walk descends only where 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)
A change flows through the graph in dependency order

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
The walk reaches a change wherever it nests

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
Toggling an If raises and tears down its block

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']
The root's driver streams are live

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.