Reverse
Everything so far is the forward direction. A change enters at a state and flows out to everything that reads it, recomputed in order (see updates). The reverse direction is the other half, how a change flows back to state. Application code never writes a value directly. Instead a reverse declares its target references and returns their new values, and the runtime applies them itself. Because you name the targets before the write runs, the framework knows exactly what a write will set before it sets it.
Reverse and Binding
A Reverse is a write-only lens over a declared set of targets. Setting it runs
onChange(incoming) once, as a builder: it composes one new reactive value per
target, in target order, and returns them. It takes a single argument, the incoming value. Anything else it needs
it reads through its closure, and those reads are its inputs: values the write depends on but does not
itself target. Inputs are gates only. They decide whether the reverse can run at all (a pending
input locks it), never data the callback branches on. A reverse carries no forward value.
Reactive.Reverse(inputs, targetRefs, onChange) // onChange(incoming) → one composed value per target
Reactive.Binding(source, reverse) // a two-way view: read source, write through the reverse
Reverse resolves its inputs and appends its targets as edges, so a change to an input's
pending or a target's lock reruns it. It takes a fresh reverseId from the root, and is born
write-locked when any input is pending or any target is already held. It carries no forward value.
Reactive.Reverse = (inputs, targetRefs, onChange) => {
const inputRefs = Reactive._resolveInputs(inputs);
const inputNodeRefs = inputRefs.concat(targetRefs);
const reverseId = Reactive._currentRoot.nextReverseId ++;
const payload = { reverse: { inputRefs, targetRefs, onChange, reverseId } };
const anyInputsPending = Reactive._anyPending(inputRefs);
const anyTargetsLocked = Reactive._anyWriteLocked(targetRefs);
const writeLocked = anyInputsPending || anyTargetsLocked;
return Reactive._nodeCreate(payload, undefined, inputNodeRefs, false, writeLocked);
};
Binding pairs a source with a reverse. Its forward value is a passthrough of the source, so
reading it follows every change to source. It reads pending from its source and write-locked from its
reverse, both wired as edges so each is kept current. Setting the binding runs that reverse.
Reactive.Binding = (source, reverseRef) => {
const sourceRef = Reactive._resolveInput(source);
const value = Reactive._value(sourceRef);
const reverseId = Reactive._currentRoot.nextReverseId ++;
const payload = { binding: { sourceRef, reverseRef, reverseId } };
const inputNodeRefs = [sourceRef, reverseRef];
const pending = sourceRef.pending;
const writeLocked = reverseRef.writeLocked;
return Reactive._nodeCreate(payload, value, inputNodeRefs, pending, writeLocked);
};
A Binding is the two-way view. Its forward value tracks source, a plain passthrough, so
reading it follows every change to the source. Setting it runs the given reverse. This is the controlled-input,
derived-field shape: read a source, then write it back transformed onto real state.
Temperature is the small example. celsius is the real state. A reverse over [celsius]
turns a fahrenheit figure back into celsius, and fahrenheit is a binding whose forward value derives
fahrenheit from celsius and whose write side is that reverse. Reading fahrenheit tracks
celsius; setting it writes celsius back.
const celsius = Reactive.State(0);
const asFahrenheit = Reactive.Calculate([celsius], (c) => c * 9 / 5 + 32);
const toCelsius = Reactive.Reverse([], [celsius], (incoming) => {
const nextCelsius = Reactive.Calculate([incoming], (f) => (f - 32) * 5 / 9);
return [nextCelsius];
});
const fahrenheit = Reactive.Binding(asFahrenheit, toCelsius);
The value onChange returns is a composed reactive value, not a plain number. That is what makes the
reverse side a living graph: the returned value can read the incoming, read frozen inputs, and
read a Pending the callback creates, and it re-settles as those resolve. The whole write is built
once and then follows its inputs, exactly like the forward side.
Because the write-lock flag is kept current on every node, a caller can ask whether a reference can be written
right now straight from its stored flag. isWriteLocked reads it under the given root, and
_anyWriteLocked is the helper the constructors use to test a set of targets.
Reactive.isWriteLocked = (root, reference) => {
const previousRoot = Reactive._currentRoot;
Reactive._currentRoot = root;
try {
return reference.writeLocked;
} finally {
Reactive._currentRoot = previousRoot;
}
};
Reactive._anyWriteLocked = (references) => {
return references.some((reference) => {
return reference.writeLocked;
});
};
One Write Path
There is a single internal write path, _write. It is not a public call. Application code never writes
directly. A write starts only from a driver, given a set bound to the root (see the
drivers page). Each write is one transaction: it opens a
reverse process to hold the working state, builds the reverse graph and the note that finishes it
inside a fresh scope, settles, then runs the drivers.
For any of this to work the root needs two more things. It mints a nextReverseId per reverse node, so
a lens always outranks the targets it writes. And it holds a transactionsHostRef, the one node that
owns the live transactions. So the root now runs the program inside a scope that first creates that host, making it
the first program node the update walk reaches: a held reverse transaction settles before the forward view.
Reactive.Root = (programCallback, drivers) => {
const previousRoot = Reactive._currentRoot;
const root = {
drivers: drivers || {},
rootScope: undefined,
// the next reverse id, minted per reverse node (Reverse or Binding) as it is built. A reverse only
// writes targets that already existed, so a setter always has a higher id than everything it sets:
// following the setting tree, ids strictly decrease. That makes the id a valid reverse-topological
// key — the reverse sweep drains highest id first (see _afterInWriteOrder), no path comparison.
nextReverseId: 1,
transactionsHostRef: undefined,
};
root.set = (reference, value) => {
return Reactive._write(root, reference, value);
};
Reactive._currentRoot = root;
try {
// the root scope has no owning node (parentNode undefined), which is where a tree climb stops.
root.rootScope = Reactive._scopeCreate(undefined, () => {
// the transactions host is the first program node in the root scope, so the update walk reaches
// it before the rest of the program: a held reverse transaction settles before the forward view.
Reactive._transactionsHostCreate();
programCallback();
});
Reactive._dispatchDrivers(root);
} finally {
Reactive._currentRoot = previousRoot;
}
return root;
};
Two things happen up front. If the target is already write-locked the write throws: setting
something an in-flight transaction holds, or a lens gated on a pending input, is a caller error, refused before any
transaction opens. Otherwise the process is created. It carries the working store the build reads and writes, and a
pendingCount, the number of its own async values still unresolved, which decides when it finishes.
Reactive._write = (root, reference, value) => {
const previousRoot = Reactive._currentRoot;
const previousProcess = Reactive._currentProcess;
Reactive._currentRoot = root;
const isTopLevel = previousProcess === undefined;
// A write to something write-locked is blocked by construction, not attempted-then-rolled-back. The
// lock lives on the plain-state leaves an in-flight transaction holds; it propagates up through every
// reverse command that targets them, so the whole reverse a competing write would drive reads as
// write-locked. Setting a write-locked value or lens is a caller error: throw before any transaction
// opens, so no buffer, no commit, no rollback, and the mistake is loud rather than a silent no-op.
if (isTopLevel && reference.writeLocked) {
Reactive._currentRoot = previousRoot;
throw new Error('Sprites.Reactive2: cannot set a write-locked value or lens (it is held by an in-flight transaction or gated on a pending input).');
}
if (!isTopLevel) {
// a write cannot nest inside another: a reverse composes values, it never calls set, and drivers run
// only after the transaction closes. Reaching here is a bug in the caller, not a case to handle.
Reactive._currentRoot = previousRoot;
throw new Error('Sprites.Reactive2: a write cannot nest inside another write.');
}
const process = {
// the working store, keyed by target NODE: target -> the latest reactive value written to it. A read
// of a target during onChange (through _resolveInput) resolves to its latest, so writers chain.
latest: new Map(),
writtenStateRefs: new Map(),
inputRefs: [],
inputKeys: new Set(),
redirecting: false,
// how many of this process's own Pendings are still unresolved. It rises as onChange creates them
// and falls as drivers fill them; at zero the transaction has no async left and finalises.
pendingCount: 0,
transactionScope: undefined,
};
Reactive._currentProcess = process;
const hostNode = root.transactionsHostRef;
let committed = false;
try {
// the transaction scope is owned by the host; the build runs in it, composing the reverse DAG of
// values from the top write (a raw write becomes a Constant), then the commit note that finishes it.
Reactive._scopeCreate(hostNode, () => {
// capture the scope up front, before anything can throw: the scope is not returned to us on a build
// error (the throw escapes _scopeCreate before its return), so the process is what holds it for the
// abort path below.
process.transactionScope = Reactive._currentScope;
Reactive._buildReverseDag(reference, Reactive._resolveInput(value));
Reactive._buildCommit(root, process);
});
hostNode.payload.transactions.entries.push({ id: Reactive._mintEntryId(), value: process.transactionScope });
Reactive._update(root);
committed = true;
} catch (error) {
// an error while building the DAG (a bug in a lens, or an uncounted pending write rejected in
// _buildCommit) leaves a half-built transaction scope: destroy it, touching no destination, and let the
// error propagate. The scope is captured on the process even when the throw escapes _scopeCreate before
// its return; on this path it is never in the host entries (the entry is pushed only after a clean build).
if (process.transactionScope !== undefined && !process.transactionScope.destroyed) {
Reactive._scopeDestroy(process.transactionScope);
}
throw error;
} finally {
Reactive._currentProcess = previousProcess;
Reactive._currentRoot = previousRoot;
}
// drivers run after the transaction closes, reading through the current root, so a driver's own write
// opens a fresh transaction. Then prune any transaction that completed this walk — after the drivers
// have gathered its emissions, so even a synchronous transaction's requests reach them before it goes.
if (committed) {
const priorRoot = Reactive._currentRoot;
Reactive._currentRoot = root;
try {
Reactive._dispatchDrivers(root);
Reactive._pruneCompletedTransactions(root);
} finally {
Reactive._currentRoot = priorRoot;
}
}
};
Every transaction lives in its own scope on the transactions host, one node near the top of the root scope that owns the live transactions exactly as an each owns its entry scopes. Because a transaction is a scope on that host, its work is walked by the update just like any other section, and a completed one is dropped from the host the same way. Keeping each transaction in a scope is what lets an async write hold across many walks and still land as one.
The build reads a target's reverse lens through one helper. _reverseInfo returns the reverse
payload of a lens node, unwrapping a binding to the reverse underneath, or undefined for a plain
state, so the walk knows whether a target routes on or terminates.
Reactive._reverseInfo = (node) => {
if (node.payload.reverse) {
return node.payload.reverse;
}
if (node.payload.binding) {
const reverseNode = node.payload.binding.reverseRef;
return reverseNode.payload.reverse;
}
return undefined;
};
The Living Graph
_buildReverseDag runs onChange once as a builder and wires each value it returns to its
target. A target that is itself a lens (a Binding's reverse, or a lens over a lens) is queued and its
own onChange run in turn, so the write threads through nested lenses to the plain states underneath. A
target that is a plain state is recorded in writtenStateRefs: those are what the transaction lands.
Reactive._buildReverseDag = (topReverseRef, topIncomingRef) => {
const process = Reactive._currentProcess;
const queue = [];
const queued = new Set();
const processed = new Set();
const wire = (targetRef, valueRef) => {
const key = targetRef;
// overwriting the latest is the merge: a later writer already read the previous one's value.
process.latest.set(key, valueRef);
const targetNode = targetRef;
const reverseInfo = Reactive._reverseInfo(targetNode);
if (reverseInfo !== undefined) {
if (!queued.has(key) && !processed.has(key)) {
queued.add(key);
queue.push(targetRef);
}
return;
}
if (targetNode.payload.state) {
process.writtenStateRefs.set(key, targetRef);
return;
}
throw new Error('Sprites.Reactive2: cannot write a read-only value (a Constant or a Calculate result). Only a State, a Pending, or a lens (Reverse, Binding) is settable.');
};
wire(topReverseRef, topIncomingRef);
while (queue.length > 0) {
let bestAt = 0;
for (let at = 1; at < queue.length; at ++) {
if (Reactive._afterInWriteOrder(queue[at], queue[bestAt])) {
bestAt = at;
}
}
const reference = queue.splice(bestAt, 1)[0];
const key = reference;
queued.delete(key);
processed.add(key);
const reverseInfo = Reactive._reverseInfo(reference);
const incomingRef = process.latest.get(key);
// collect this reverse's declared inputs so the transaction can freeze them if it holds: a value a
// reverse process read must not change while it is still in flight. Its own input keys also drive
// the snapshotting in _resolveInput while its onChange runs (a read of a frozen input is a snapshot).
const currentInputKeys = new Set();
reverseInfo.inputRefs.forEach((inputRef) => {
const inputKey = inputRef;
currentInputKeys.add(inputKey);
if (!process.inputKeys.has(inputKey)) {
process.inputKeys.add(inputKey);
process.inputRefs.push(inputRef);
}
});
// run onChange once as a builder; reads of a target it makes are redirected to the working store so
// writers chain. Wire each returned value to its target, in the same order they were declared.
process.redirecting = true;
process.currentInputKeys = currentInputKeys;
const updatedValues = reverseInfo.onChange(incomingRef);
process.currentInputKeys = undefined;
process.redirecting = false;
reverseInfo.targetRefs.forEach((targetRef, index) => {
wire(targetRef, Reactive._resolveInput(updatedValues[index]));
});
}
};
Two reads inside onChange are special, so a lens that reads and writes the same value (a field lens
over an object) does not fight itself. A read of a target already written this transaction
resolves to its latest value in the working store, so writers chain and two field edits merge. A read of a declared
input resolves to a stable snapshot, so a self-referential read-modify-write does not wait on the
very value it is about to set. The queue drains highest reverseId first, so a lens always runs before
the targets it writes.
Those two special reads live in _resolveInput, the same resolver a calculation
uses. It reads the ambient in-flight process, which is undefined outside a write:
Reactive._currentProcess = undefined;
When a process is redirecting, a reactive input that names a target written this transaction resolves to that target's latest value node (writers chain), and a declared, frozen input resolves to a stable snapshot of its committed value. Outside the drain, or for an unwritten reference, it passes through as before:
Reactive._resolveInput = (input) => {
if (Reactive._isReactive(input)) {
// inside a reverse callback (redirecting), a read of a target written earlier this transaction
// resolves to that target's latest value node instead of its committed ref, so writers chain and
// the whole reverse process is one live DAG. Outside the drain, or for an unwritten ref, pass through.
const process = Reactive._currentProcess;
if (process !== undefined && process.redirecting) {
const key = input;
const latest = process.latest.get(key);
if (latest !== undefined) {
// a target written earlier this transaction: chain onto its latest value node.
return latest;
}
if (process.currentInputKeys !== undefined && process.currentInputKeys.has(key)) {
// a declared (frozen) input this reverse reads: it cannot change while the transaction is in
// flight, so read a stable SNAPSHOT of its committed value. This decouples a read-modify-write
// (a value that is both this reverse's input and its target) from the destination's held-pending
// node, which would otherwise make the destination's own value node wait on itself forever.
return Reactive.Constant(input.value);
}
}
return input;
}
return Reactive.Constant(input);
};
That ordering is decided by two small helpers. _reverseIdOf reads a lens node's reverse id from its
payload, and _afterInWriteOrder compares two, so the drain always runs a lens before the targets it
writes, which carry lower ids.
Reactive._reverseIdOf = (node) => {
if (node.payload.reverse !== undefined) {
return node.payload.reverse.reverseId;
}
return node.payload.binding.reverseId;
};
Reactive._afterInWriteOrder = (a, b) => {
return Reactive._reverseIdOf(a) > Reactive._reverseIdOf(b);
};
Write-Locking
A target can be held mid-write, and that fact must reach the top. So a node gains a second propagated flag,
writeLocked, stored beside pending. A state target locks while an in-flight async write
holds it. A reverse is locked when any of its inputs is pending or any target it writes is locked. A binding takes
its reverse's lock. Like pending, the flag propagates. _updateNodeWriteLocked stores it and, when it
moves, marks the node's readers, so the lock threads state → reverse → binding → whatever reads it. It mirrors
_updateNodeState exactly.
Reactive._updateNodeWriteLocked = (node, nextWriteLocked) => {
if (nextWriteLocked !== node.writeLocked) {
node.writeLocked = nextWriteLocked;
Reactive._updateMarkListenersChanged(node);
}
};
The update dispatch gains two branches for the new node kinds. A reverse recomputes its lock, a binding recomputes its passthrough value and its lock. Every other kind is unchanged.
Reactive._updateNodeInputs = (node) => {
if (!node.inputsChanged) {
return;
}
node.inputsChanged = false;
const payload = node.payload;
if (payload.calculate) {
Reactive._updateCalculateNodeInputs(node);
} else if (payload.reverse) {
Reactive._updateReverseNodeInputs(node);
} else if (payload.binding) {
Reactive._updateBindingNodeInputs(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 plain state is never marked inputs-changed: it reads nothing. A commit node reads its outputs only
// so that a change to one marks its scope into the drain; the landing itself is done by the drain loop
// (_finaliseIfSettled), gated on the process's pending counter, not by settling the node here.
};
A reverse recomputes its own lock from its edges. Both its inputs and its targets are wired as node inputs, so a change to an input's pending or a target's lock reruns it. It is locked when any input it reads is pending, or when any target it writes is already held. The write cannot be composed while a value it reads is loading.
Reactive._updateReverseNodeInputs = (node) => {
const reversePayload = node.payload.reverse;
const writeLocked = Reactive._anyPending(reversePayload.inputRefs) || Reactive._anyWriteLocked(reversePayload.targetRefs);
Reactive._updateNodeWriteLocked(node, writeLocked);
};
Reactive._updateBindingNodeInputs = (node) => {
const bindingPayload = node.payload.binding;
const value = Reactive._value(bindingPayload.sourceRef);
const pending = bindingPayload.sourceRef.pending;
Reactive._updateNodeState(node, value, pending);
const writeLocked = bindingPayload.reverseRef.writeLocked;
Reactive._updateNodeWriteLocked(node, writeLocked);
};
_anyWriteLocked reads each node's stored flag without recursing, because the flag is kept current on
every node. Because the lock threads all the way up, a UI can ask Reactive.isWriteLocked(root, ref)
of whatever it renders, and know whether that thing can be written right now, direct from the stored flag.
Finishing: The Commit
After the graph is built, _buildCommit reads the working store for each written state, and builds the
commit note that finishes the transaction. Every destination is locked for writing. A destination
whose composed value is already ready is set now; one whose value is still pending is held at its prior value. The
note carries the destinations, their value nodes, the frozen inputs, and the process whose counter decides when to
land. It does not settle itself: the drain loop lands it when the process is done (below).
The gate is what keeps this simple. A reverse cannot even run while a value it reads is pending, because a pending
input write-locks the lens and the write throws before it starts. So onChange never
sees a loading value. The only thing that can still be pending inside a running transaction is a
Pending the callback creates itself, as an async output for a driver to fill.
Every live transaction needs a home. The transactions host is one node near the top of the root scope that owns them, each in its own child scope, exactly as an each owns its entry scopes. Its value is the merge of its transaction scopes' emissions, appended into the root emissions, so a driver request a reverse callback makes reaches the drivers like any other emission. It starts empty; a held write adds a scope and its commit removes it once it lands.
Reactive._transactionsHostCreate = () => {
const currentScope = Reactive._currentScope;
const entries = [];
const value = Reactive._emitCombineEntries(entries);
const payload = { transactions: { entries, optionalContentsUpdatedScopes: undefined } };
const hostNode = Reactive._nodeCreate(payload, value, [], false, false);
currentScope.currentEmissionsRef = Reactive._emitAppend(currentScope.currentEmissionsRef, hostNode);
Reactive._currentRoot.transactionsHostRef = hostNode;
};
Because the host owns child scopes exactly as an each does, the two update
dispatchers that handle scope-owning nodes each gain a transactions branch, the same shape as the each
branch. Marking climbs first: a change inside a transaction scope threads contentsUpdated up to the
root, recording which child scope moved.
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;
}
payload.if_.contentsUpdated = true;
} else if (payload.each) {
const changedScopes = payload.each.optionalContentsUpdatedScopes || new Set();
payload.each.optionalContentsUpdatedScopes = changedScopes;
if (changedScopes.has(currentScope)) {
return;
}
changedScopes.add(currentScope);
} else if (payload.transactions) {
const changedScopes = payload.transactions.optionalContentsUpdatedScopes || new Set();
payload.transactions.optionalContentsUpdatedScopes = changedScopes;
if (changedScopes.has(currentScope)) {
return;
}
changedScopes.add(currentScope);
}
currentScope = owner.scope;
}
};
Settling then descends. _updateNodeContents reads and clears the host's set of changed transaction
scopes and drains them through _updateTransactionsContents, exactly as the each branch drains its
changed entry scopes.
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);
}
} else if (payload.transactions) {
const changedScopeIds = payload.transactions.optionalContentsUpdatedScopes;
if (changedScopeIds !== undefined) {
payload.transactions.optionalContentsUpdatedScopes = undefined;
Reactive._updateTransactionsContents(node, changedScopeIds);
}
}
};
After the graph is built, _buildCommit reads the working store for each written state and builds the
destinations. Before locking anything it rejects an uncounted pending write: if the counter is zero yet a
destination value is still pending, the caller wrote a value pending for a reason nothing on this process will
resolve (a plain write of an already-pending value, or a lens folding in an undeclared pending). It throws here,
before any target is locked or held, so _write's catch tears the scope down and the target is left
exactly as it was. Otherwise it freezes every input the involved reverses read and locks every destination: a
destination whose composed value is already ready is set now; one still pending is held at its prior value. It ends
by creating the commit node over that store.
Reactive._buildCommit = (root, process) => {
// the destinations: each written plain state, with the final reactive value node written to it (the
// last writer's output, which read the previous writers' outputs, so it holds the merged result).
const destinations = [];
process.writtenStateRefs.forEach((stateRef, key) => {
const valueRef = process.latest.get(key);
destinations.push({ reference: stateRef, node: stateRef, valueRef });
});
const valueRefs = destinations.map((destination) => {
return destination.valueRef;
});
// reject an uncounted pending write before locking anything. Async only ever enters through a Pending a
// reverse creates inside onChange, which is counted on this process. If the counter is zero yet a
// destination value is still pending, the caller wrote a value pending for a reason nothing on this
// process will resolve. Throw here, before any target is locked: _write's catch tears the scope down.
if (process.pendingCount === 0) {
const anyPending = valueRefs.some((valueRef) => {
return valueRef.pending;
});
if (anyPending) {
throw new Error('Sprites.Reactive2: cannot write a still-pending value into a target (async enters only through a Pending created inside a reverse onChange, counted on its process).');
}
}
// freeze every input the involved reverses read, and lock every destination held pending at its value
// node's pending, so nothing the transaction depends on or writes changes under it while it is in
// flight. A synchronous transaction lands on the very next walk and releases these at once.
const frozenInputRefs = process.inputRefs;
frozenInputRefs.forEach((inputRef) => {
Reactive._takeTargetLock(inputRef, inputRef);
});
destinations.forEach((destination) => {
Reactive._takeTargetLock(destination.reference, destination.node);
const valueNode = destination.valueRef;
if (valueNode.pending) {
// value not ready: hold the destination pending at its prior value; its commit lands it later.
Reactive._updateNodeState(destination.node, destination.node.value, true);
} else {
// value ready: set the destination now, so no reader (including another transaction's commit)
// sees it settled-but-stale in the window before the commit runs. The commit only unlocks it.
Reactive._updateNodeState(destination.node, valueNode.value, false);
}
});
Reactive._commitNodeCreate(destinations, valueRefs, frozenInputRefs, process);
};
_commitNodeCreate makes the note itself, in the current transaction scope. It reads the output value
refs so a change to any of them marks its scope to drain, and it is marked inputs-changed so the next walk reaches
it once, letting a synchronous transaction finalise then. It carries no value; the landing is the drain loop's job.
Reactive._commitNodeCreate = (destinations, valueRefs, frozenInputRefs, process) => {
const transactionScope = Reactive._currentScope;
const payload = { commit: { destinations, valueRefs, frozenInputRefs, transactionScope, process, completed: false } };
const commitNodeRef = Reactive._nodeCreate(payload, undefined, valueRefs, false, false);
Reactive._updateMarkInputsChanged(commitNodeRef);
};
When the counter reaches zero, _finaliseTransaction lands it: it applies the whole store together,
releases the locks, and marks the transaction completed for pruning. It is reached only once no output is still
pending (an uncounted pending write is rejected earlier, in _buildCommit, before any lock), so every
destination reads a settled value.
Reactive._finaliseTransaction = (commitNode) => {
const commitPayload = commitNode.payload.commit;
commitPayload.destinations.forEach((destination) => {
const finalValue = destination.valueRef.value;
Reactive._updateNodeState(destination.node, finalValue, false);
});
Reactive._releaseTransactionLocks(commitPayload);
commitPayload.completed = true;
};
Landing a transaction releases every lock it holds, its destinations and its frozen inputs.
Reactive._releaseTransactionLocks = (commitPayload) => {
commitPayload.destinations.forEach((destination) => {
Reactive._releaseTargetLock(destination.reference, destination.node);
});
commitPayload.frozenInputRefs.forEach((inputRef) => {
Reactive._releaseTargetLock(inputRef, inputRef);
});
};
A handful of small helpers round it out. _transactionCommitNode finds a scope's one commit node,
_heldTransactionCount counts the live transactions on the host, and _takeTargetLock and
_releaseTargetLock set and clear a node's write-lock flag.
Reactive._transactionCommitNode = (transactionScope) => {
let commitNode = undefined;
transactionScope.nodes.forEach((node) => {
if (node.payload.commit) {
commitNode = node;
}
});
return commitNode;
};
Reactive._heldTransactionCount = (root) => {
return root.transactionsHostRef.payload.transactions.entries.length;
};
Reactive._takeTargetLock = (reference, node) => {
Reactive._updateNodeWriteLocked(node, true);
};
Reactive._releaseTargetLock = (reference, node) => {
if (!node.writeLocked) {
return;
}
Reactive._updateNodeWriteLocked(node, false);
};
The host refreshes as the walk reaches it. _updateTransactionsContents drains its changed transaction
scopes in rounds, since a transaction that lands may write a source another waits on, then rebuilds the host's
merged emissions. Each round completes at least one, so it terminates.
Reactive._updateTransactionsContents = (hostNode, changedScopeIds) => {
const root = Reactive._currentRoot;
// drain in rounds: a transaction that lands this round may write a source another transaction waits
// on, marking its scope for the next round. Each round completes at least one, so this terminates.
// Completed scopes are not pruned here — they stay so the drivers can gather their emissions; the
// prune happens after the drivers run (see _pruneCompletedTransactions).
let batch = changedScopeIds;
while (batch !== undefined) {
hostNode.payload.transactions.optionalContentsUpdatedScopes = undefined;
batch.forEach((transactionScope) => {
if (!transactionScope.destroyed) {
const commitNode = Reactive._transactionCommitNode(transactionScope);
// process the scope under its own process, so any Pending a deferred If/Each body creates while
// this scope settles is counted against the right transaction, not whichever write is on the stack.
const previousProcess = Reactive._currentProcess;
Reactive._currentProcess = commitNode === undefined ? previousProcess : commitNode.payload.commit.process;
try {
Reactive._processScope(transactionScope);
} finally {
Reactive._currentProcess = previousProcess;
}
Reactive._finaliseIfSettled(transactionScope);
}
});
batch = hostNode.payload.transactions.optionalContentsUpdatedScopes;
}
const hostEntries = hostNode.payload.transactions.entries;
const value = Reactive._emitCombineEntries(hostEntries);
Reactive._updateNodeState(hostNode, value, Reactive._emitEntriesPending(hostEntries));
};
After the drivers have gathered a walk's emissions, _pruneCompletedTransactions drops every landed
transaction: it removes the host entry, destroys the scope, and refreshes the host's emissions so a one-shot
request a completed transaction emitted is not seen again. A held transaction stays until its walk.
Reactive._pruneCompletedTransactions = (root) => {
const hostNode = root.transactionsHostRef;
const remaining = [];
const completedScopeIds = [];
hostNode.payload.transactions.entries.forEach((entry) => {
const commitNode = Reactive._transactionCommitNode(entry.value);
if (commitNode !== undefined && commitNode.payload.commit.completed) {
completedScopeIds.push(entry.value);
} else {
remaining.push(entry);
}
});
if (completedScopeIds.length === 0) {
return;
}
hostNode.payload.transactions.entries = remaining;
completedScopeIds.forEach((transactionScope) => {
Reactive._scopeDestroy(transactionScope);
});
const value = Reactive._emitCombineEntries(remaining);
Reactive._updateNodeState(hostNode, value, Reactive._emitEntriesPending(remaining));
};
The Pending Counter
An async write is a reverse whose output is a Pending it makes: a placeholder a driver will fill (a
saved id, a fetched body). Because a Pending created while a process is building belongs to that process, its
constructor counts it on the process and stamps the process onto the state, so the fill can find
the counter later. This is the whole of async: pendingCount is the number of the transaction's own
placeholders still open.
Reactive.Pending = (initialValue) => {
// if a reverse process is building right now, this Pending is its own async output (a driver-response
// placeholder), stamped onto the state as optionalCurrentProcess and counted, so a driver later filling
// it (from OUTSIDE this transaction) can find and decrement the right counter. A Pending in the plain
// program has no current process, so optionalCurrentProcess is undefined and nothing holds on it.
const optionalCurrentProcess = Reactive._currentProcess;
if (optionalCurrentProcess !== undefined) {
optionalCurrentProcess.pendingCount ++;
}
const payload = { state: { optionalCurrentProcess } };
const value = Reactive._value(initialValue);
return Reactive._nodeCreate(payload, value, Reactive._emptyArray, true, false);
};
When a driver fills that placeholder, the state goes from pending to ready. _updateNodeState, the one
place a node's pending flag ever moves, notices a stamped state resolving, decrements the counter, and marks the
transaction's scope so the drain re-checks it. A placeholder that feeds no output has no readers to mark otherwise,
so this explicit mark is what still wakes the transaction.
Reactive._updateNodeState = (node, nextValue, nextPending) => {
if (nextValue !== node.value || nextPending !== node.pending) {
if (node.pending === true && nextPending === false) {
const stateInfo = node.payload.state;
if (stateInfo !== undefined && stateInfo.optionalCurrentProcess !== undefined) {
const process = stateInfo.optionalCurrentProcess;
process.pendingCount --;
stateInfo.optionalCurrentProcess = undefined;
// a stamped Pending whose process was aborted (its scope torn down) has nothing live to wake: skip
// the mark rather than climb a destroyed or absent scope. Its decrement above is harmless.
if (process.transactionScope !== undefined && !process.transactionScope.destroyed) {
Reactive._updateMarkScopeChanged(process.transactionScope);
}
}
}
node.value = nextValue;
node.pending = nextPending;
Reactive._updateMarkListenersChanged(node);
}
};
The transactions host walks its changed transaction scopes in the update. Right after a scope is settled, it lands
the transaction if the counter has reached zero. An uncounted pending write was already refused up front in
_buildCommit, so at zero every output is settled: it sets every destination to its final value, releases
the locks, and marks the transaction completed, to be pruned after the drivers run.
Reactive._finaliseIfSettled = (transactionScope) => {
const commitNode = Reactive._transactionCommitNode(transactionScope);
if (commitNode === undefined) {
return;
}
const commitPayload = commitNode.payload.commit;
if (commitPayload.completed) {
return;
}
if (commitPayload.process.pendingCount > 0) {
return;
}
Reactive._finaliseTransaction(commitNode);
};
A synchronous write creates no Pendings, so its counter is zero from the start and it lands on the first walk. An async write holds, its targets locked, until the last of its placeholders is filled, and then lands them all together. One transaction, one atomic landing, however many walks it waited across.
Running It
Setting the fahrenheit binding runs its reverse onto celsius, and the forward value re-derives from the settled celsius. The write went back to real state:
// celsius 0, fahrenheit reads 32
root.set(celsius, 100); // fahrenheit -> 212 the forward side follows its source
root.set(fahrenheit, 32); // celsius -> 0 the reverse runs onto real state
// fahrenheit -> 32 re-derived from the settled celsius
An async write is a reverse whose output is a Pending it creates. Setting it runs
onChange, which makes the placeholder and returns it: the target is held at its prior value and locked,
and the transaction holds on its one open placeholder. The lock threads up, so a competing write to the held target
throws. When a driver fills the placeholder, the counter reaches zero, the write lands, and the
lock releases.
let source;
const target = Reactive.State('old');
const saver = Reactive.Reverse([], [target], (incoming) => {
source = Reactive.Pending(''); // an async output a driver will fill
return [source];
});
root.set(saver, 'go'); // onChange makes source; target held + locked, transaction holds
Reactive.isWriteLocked(root, target); // true the lock threads up through saver too
root.set(target, 'clobber'); // THROWS a held target refuses a competing write
root.set(source, 'saved'); // target -> 'saved' filling the placeholder lands the write
Reactive.isWriteLocked(root, target); // false the counter hit zero, the lock released
Next Steps
That completes the framework. The forward graph reacts. A change enters at a state and flows to everything that reads it, in dependency order, each node touched at most once. Drivers bridge the outside, reading the root's leftover streams onto the screen and feeding external events back in. The reverse direction writes back. A lens declares its targets and returns their new values, and the runtime applies them, through one write path, inside one process.
Two flags thread through both halves. pending carries whether a value is still loading, forward from
a resource to everything that reads it. writeLocked carries whether a value can be written, up from a
held target through every reverse and binding to whatever a UI reads. A whole reactive program, a running graph
with both directions live, is built from exactly these pieces.