Skip to content

Tracing an Update

How SynState Solved the Glitch described the queue as buckets, one per depth. This page follows the code that implements them, field by field, through two updates of the graph from “Handling skipped updates”, and then through an update that a subscriber starts inside another. Each step shows what the code holds after it: the module state of propagation-state.mts, the private fields of the update’s UpdateQueue, the local variables of the function running, and three fields of every node.

combine and the map after it are two nodes, so the C of the previous page appears here as BD (the combine) and C (the map):

import { combine, createState, filter, map } from 'synstate';
const [A, setA] = createState(0);
const B = A.pipe(map((n) => n * 10)); // depth 1
const D = A.pipe(filter((n) => n % 2 === 0)); // depth 1
const BD = combine([B, D]); // depth 2
const C = BD.pipe(map(([b, d]) => b + d)); // depth 3
const E = D.pipe(map((n) => n * 10)); // depth 2
setA(1); // B = 10, BD = [10, 0], C = 10; D rejects 1, so E is not reached
setA(2); // B = 20, D = 2, BD = [20, 2], E = 20, C = 22
WhereFieldWhat it holds
propagation-state.mtspropagation.depth, propagation.outermostTokenHow many updates are propagating (a write that nothing observes opens none), and the token of the outermost one. A root written to while depth > 0 is written to from inside an update, and warns once.
queueState.levelHow many updates of this copy of SynState are open: 1 for the outermost, 2 for an update started inside it.
bucketSets[level - 1]The buckets of the update at that level. bucketSets[level - 1][index] is the bucket of depth base + index: nodes, an array of the nodes queued at that depth, and length, how many of its slots are in use.
UpdateQueue (the token)#baseThe depth of bucket 0: the depth of the node that started the update, plus one. A node goes in bucket node.depth - base.
#bucketsThe bucket set this update uses.
#sizeHow many buckets are in use. The drain loop runs up to it, and it grows while the loop runs.
#isOpenWhether the update is still running. A mark of an update that has closed means nothing, and is overwritten.
#queuedDespiteMarkThe nodes this update has queued while another open update’s mark was on them (see below). Made on first use, so it stays undefined in the two updates here.
every nodeupdateTokenThe token of the last update the node emitted in. Its children compare it with the token they are given.
snapshotThe node’s current value.
queuedForThe mark: the token of the last update that queued the node. Comparing it with the update’s own token is what queues a node only once per update.

The bucket sets are module state, kept from one update to the next. An update empties a bucket by setting its length back to 0, not by making a new array, and it sets each slot back to undefined when it takes the node out, so that a slot does not keep a node alive. The next update writes over those slots, so after the first few updates an update allocates nothing but its token. Neither is queuedFor cleared after the visit: the next update has a new token, so an old mark never equals it, and #isOpen tells a mark that has ended from one that is still in use.

In the drawing, a cyan tag on a node is a slot in use, [index][slot], holding it, and a pink tag is a local variable holding it: child in enqueueChildren and #push, and node in drain. A dashed yellow ring means the node carries the mark of an update that is still open. In the panel, a slot past its bucket’s length is drawn faded: it is not in use, whatever it holds. A field highlighted in pink changed in this step. The steps come from a model of the code below them; the tests of the samples on this page check that the library emits in the same order.

  • bucketSets[0] and each of its buckets are made the first time they are needed: this is the update that pays for them.
  • B and D go in bucket 0, at slots 0 and 1 of its nodes. #size grows from 2 to 3 while the loop is at index = 1, when BD queues C. The loop compares index with #size on every turn, so it reaches the new bucket.
  • D is visited, rejects 1, and returns without setNext: it keeps t0, and queues neither BD nor E. BD is visited anyway, because B queued it, and sees B.updateToken === t1 and D.updateToken === t0. E is never touched.
  • t2 takes the same bucketSets[0]. Every bucket’s length is 0, so it is empty, though their nodes arrays still have their slots; #push writes B and D over the undefined that update 1 left in them.
  • B.queuedFor is still t1, but t1 has closed, so the mark is overwritten with t2. BD gets queuedFor === t2 from B, so when D passes 2 and queues its children, BD is skipped: that one comparison keeps a node from being queued twice. E takes slot 1 of bucket 1, whose nodes grows by one.
  • When the update ends, every length is 0 and every slot undefined again; the marks stay, as t2’s.

A write from a subscriber (an anti-pattern)

Section titled “A write from a subscriber (an anti-pattern)”

A subscriber that writes to a root starts an update while the update that called it is still draining. Do not write one, even where it does not loop: the write is not an edge of the graph, so SynState cannot place it in the order of the update that called it, and can only run it as a second update. SynState lets the write through, and warns once per root, because the subscribers of the graph can then see intermediate states: in this graph, BDR emits twice for one setA. This section shows how the queue copes with such a write, so that it stays correct, not how to use one.

import { combine, createState, map } from 'synstate';
const [A, setA] = createState(0);
const [R, setR] = createState(0);
const B = A.pipe(map((n) => n * 10)); // depth 1
const D = A.pipe(map((n) => n * 100)); // depth 1
const BDR = combine([B, D, R]); // depth 2
// A write from a subscriber: an update nested in the one that reached D.
D.subscribe((d) => {
if (d > 0) {
setR(d);
}
});
const mut_received: (readonly number[])[] = [];
BDR.subscribe((values) => {
mut_received.push(values);
});
setA(1); // warns once: a root written to from a subscriber
// [0, 0, 0], then [10, 100, 100] twice: in setR(100), then in setA(1)
  • B queues BDR in t1 before D’s subscriber runs. When setR(100) starts t2, BDR waits in t1’s bucket 1 with queuedFor === t1.
  • t2 is the second update open in this copy (queueState.level is 2), so it takes bucketSets[1], a set of its own: it never writes into the buckets t1 is still draining.
  • R queues BDR in t2. BDR.queuedFor is t1, and t1.isOpen is true, so t2 leaves the mark alone: had it written t2 there, t1 would no longer see BDR as queued, and would queue it a second time if another parent of BDR emitted later in t1. t2 records BDR in its own #queuedDespiteMark instead, which keeps t2 itself from queuing it twice, and puts it in its own bucket 1.
  • t2 runs to the end inside D’s subscriber: drained and closed before t1 visits another node. t1’s buckets are as it left them, so t1 visits BDR again, and BDR emits [10, 100, 100] once per token. t1 cannot tell that t2 has already brought BDR up to date.
  • propagation.depth counts both updates, so outermostToken stays t1, and the work deferred with runAfterPropagation waits for t1’s endPropagation.

The correct way is to describe the connection with pipe. Deriving the value (const R = D.pipe(…)) makes D → R an edge of the graph, so SynState knows that BDR comes after R, and the one update reaches BDR once, with every parent settled: no warning, no second emission. The synstate/no-write-during-propagation rule of eslint-plugin-synstate reports a write from a subscriber, or from a projection inside pipe, where it is written.

The same rule covers two copies of SynState in one program. An update of one copy can start inside an update of the other, and a node can carry the mark of either; each copy has its own bucket sets, and #isOpen is read across copies, so neither update overwrites a mark the other still needs. If a tryUpdate throws, close empties the slots still in use, so the next update starts from empty buckets.