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.
The graph
Section titled “The graph”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 = 22Where the queue lives
Section titled “Where the queue lives”| Where | Field | What it holds |
|---|---|---|
propagation-state.mts | propagation.depth, propagation.outermostToken | How 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.level | How 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) | #base | The depth of bucket 0: the depth of the node that started the update, plus one. A node goes in bucket node.depth - base. |
#buckets | The bucket set this update uses. | |
#size | How many buckets are in use. The drain loop runs up to it, and it grows while the loop runs. | |
#isOpen | Whether the update is still running. A mark of an update that has closed means nothing, and is overwritten. | |
#queuedDespiteMark | The 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 node | updateToken | The token of the last update the node emitted in. Its children compare it with the token they are given. |
snapshot | The node’s current value. | |
queuedFor | The 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.
Update 1: setA(1), an odd value
Section titled “Update 1: setA(1), an odd value”bucketSets[0]and each of its buckets are made the first time they are needed: this is the update that pays for them.BandDgo in bucket 0, at slots 0 and 1 of itsnodes.#sizegrows from 2 to 3 while the loop is atindex = 1, whenBDqueuesC. The loop comparesindexwith#sizeon every turn, so it reaches the new bucket.Dis visited, rejects 1, and returns withoutsetNext: it keepst0, and queues neitherBDnorE.BDis visited anyway, becauseBqueued it, and seesB.updateToken === t1andD.updateToken === t0.Eis never touched.
Update 2: setA(2), an even value
Section titled “Update 2: setA(2), an even value”t2takes the samebucketSets[0]. Every bucket’slengthis 0, so it is empty, though theirnodesarrays still have their slots;#pushwritesBandDover theundefinedthat update 1 left in them.B.queuedForis stillt1, butt1has closed, so the mark is overwritten witht2.BDgetsqueuedFor === t2fromB, so whenDpasses 2 and queues its children,BDis skipped: that one comparison keeps a node from being queued twice.Etakes slot 1 of bucket 1, whosenodesgrows by one.- When the update ends, every
lengthis 0 and every slotundefinedagain; the marks stay, ast2’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)BqueuesBDRint1beforeD’s subscriber runs. WhensetR(100)startst2,BDRwaits int1’s bucket 1 withqueuedFor === t1.t2is the second update open in this copy (queueState.levelis 2), so it takesbucketSets[1], a set of its own: it never writes into the bucketst1is still draining.RqueuesBDRint2.BDR.queuedForist1, andt1.isOpenis true, sot2leaves the mark alone: had it writtent2there,t1would no longer seeBDRas queued, and would queue it a second time if another parent ofBDRemitted later int1.t2recordsBDRin its own#queuedDespiteMarkinstead, which keepst2itself from queuing it twice, and puts it in its own bucket 1.t2runs to the end insideD’s subscriber: drained and closed beforet1visits another node.t1’s buckets are as it left them, sot1visitsBDRagain, andBDRemits[10, 100, 100]once per token.t1cannot tell thatt2has already broughtBDRup to date.propagation.depthcounts both updates, sooutermostTokenstayst1, and the work deferred withrunAfterPropagationwaits fort1’sendPropagation.
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.