更新を 1 ステップずつ追う
SynState がグリッチを解決した方法では、キューを深さごとのバケットとして説明しました。このページでは、それを実装しているコードを、「スキップされた更新の処理」のグラフの 2 回の更新と、subscriber が別の更新の中で始める更新で、フィールド単位に追います。各ステップには、そのステップを実行した後の値が表示されます。propagation-state.mts のモジュール状態、更新の UpdateQueue の private フィールド、実行中の関数のローカル変数、そして各ノードの 3 つのフィールドです。
combine とその後の map は別々のノードなので、前のページの C は、ここでは BD(combine)と C(map)の 2 つになります。
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キューの置き場所
Section titled “キューの置き場所”| 場所 | フィールド | 持っているもの |
|---|---|---|
propagation-state.mts | propagation.depth、propagation.outermostToken | 伝搬中の更新の数(誰も観測していない書き込みは更新を開かないので数えない)と、最も外側の更新のトークンです。depth > 0 の間にルートへ書き込むと、更新の中からの書き込みとして一度だけ警告が出ます。 |
queueState.level | この SynState のコピーで開いている更新の数です。最も外側の更新で 1、その中で始まった更新で 2 です。 | |
bucketSets[level - 1] | その段の更新が使うバケットです。bucketSets[level - 1][index] が深さ base + index のバケットで、nodes はその深さでキューに入ったノードの配列、length はそのうち使っているスロットの数です。 | |
UpdateQueue(=トークン) | #base | バケット 0 の深さです。更新を始めたノードの深さ + 1 で、ノードはバケット node.depth - base に入ります。 |
#buckets | この更新が使うバケットの組です。 | |
#size | 使っているバケットの数です。drain のループはここまで回り、ループ中にも増えます。 | |
#isOpen | 更新がまだ動いているかどうかです。閉じた更新の印には意味がないので、上書きされます。 | |
#queuedDespiteMark | 開いている別の更新の印が付いたまま、この更新がキューに入れたノードの集合です(後述)。初めて使うときに作るので、ここでの 2 回の更新では undefined のままです。 | |
| 各ノード | updateToken | そのノードが最後に発行した更新のトークンです。子は、渡されたトークンとこれを比べます。 |
snapshot | ノードの現在の値です。 | |
queuedFor | 印:最後にこのノードをキューに入れた更新のトークンです。これを更新自身のトークンと比べることで、1 回の更新でノードがキューに入るのは 1 回だけになります。 |
バケットの組はモジュール状態で、更新から更新へ使い回します。更新はバケットを、新しい配列を作るのではなく長さを 0 に戻すことで空にし、ノードを取り出すときに各スロットを undefined に戻します。スロットがノードを生かし続けないためです。次の更新はそのスロットに上書きするので、最初の何回かが済めば、更新ごとに確保するのはトークンだけです。queuedFor も訪問の後に消しません。次の更新は新しいトークンを持つので古い印と一致することはなく、終わった更新の印か、まだ使われている印かは #isOpen で分かります。
図では、ノードに付いた水色のタグはそのノードを持っている使用中のスロット [index][slot]、ピンクのタグはそのノードを持っているローカル変数です。enqueueChildren と #push の child、drain の node があります。黄色の点線の輪は、ノードにまだ開いている更新の印が付いていることを表します。パネルでは、バケットの長さより後ろのスロットを薄く表示します。何が入っていても使われていないスロットです。ピンクで強調されたフィールドは、そのステップで変わったものです。ステップは下に表示したコードのモデルから生成しています。このページのサンプルのテストで、ライブラリが同じ順序で発行することを確かめています。
更新 1:setA(1)(奇数)
Section titled “更新 1:setA(1)(奇数)”bucketSets[0]とその中のバケットは、初めて必要になったときに作ります。その分を払うのはこの更新です。BとDはバケット 0 のnodesのスロット 0 と 1 に入ります。ループがindex = 1にいる間に、BDがCをキューに入れて#sizeが 2 から 3 に増えます。ループは毎回indexと#sizeを比べるので、新しいバケットにも届きます。Dは訪問されますが 1 を通さず、setNextせずに戻ります。t0のままで、BDもEもキューに入れません。BDはBがキューに入れたので訪問され、B.updateToken === t1とD.updateToken === t0を見ます。Eには一切触れません。
更新 2:setA(2)(偶数)
Section titled “更新 2:setA(2)(偶数)”t2は同じbucketSets[0]を使います。どのバケットもlengthが 0 なので、nodes配列にスロットが残っていても空です。#pushは更新 1 が残したundefinedの上にBとDを書きます。B.queuedForはまだt1ですが、t1は閉じているのでt2で上書きします。BDはBによってqueuedFor === t2になるので、Dが 2 を通して子をキューに入れるとき、BDは飛ばします。ノードが 2 回キューに入らないのは、この比較 1 つのおかげです。Eはバケット 1 のスロット 1 に入り、そのnodesが 1 つ伸びます。- 更新が終わると、
lengthはすべて 0、スロットはすべてundefinedに戻ります。印はt2のものとして残ります。
subscriber からの書き込み(非推奨パターン)
Section titled “subscriber からの書き込み(非推奨パターン)”ルートに書き込む subscriber は、呼び出し元の更新がまだ drain している間に新しい更新を始めます。無限ループにならない場合でも、このような書き込みは避けてください。書き込みはグラフの辺ではないので、SynState はそれを呼び出し元の更新の順序に組み込めず、2 つ目の更新として動かすしかありません。SynState はこの書き込みを通し、ルートごとに一度だけ警告します。グラフの subscriber が途中の状態を見ることになるからです。このグラフでは、1 回の setA で BDR が 2 回発行します。この節で示すのは、そうした書き込みがあってもキューが正しく動き続ける仕組みであって、使い方ではありません。
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は、Dの subscriber が動く前にBDRをt1に入れます。setR(100)がt2を始めたとき、BDRはqueuedFor === t1でt1のバケット 1 にあります。t2はこのコピーで 2 つ目に開いた更新(queueState.levelが 2)なので、自分用のbucketSets[1]を使います。t1がまだ drain しているバケットには書き込みません。RがBDRをt2に入れます。BDR.queuedForはt1で、t1.isOpenは true なので、t2は印を書き換えません。t2と書き込むと、t1はBDRを入れ済みだと分からなくなり、BDRの別の親が後でt1で発行したときに、もう一度入れてしまうからです。代わりにt2は自分の#queuedDespiteMarkにBDRを記録して自身の重複を防ぎ、自分のバケット 1 に入れます。t2はDの subscriber の中で最後まで動きます。t1が次のノードを訪問する前に drain され、閉じられます。t1のバケットはそのままなので、t1はもう一度BDRを訪問し、BDRはトークンごとに 1 回ずつ[10, 100, 100]を発行します。t1には、t2がすでにBDRを最新にしたことが分かりません。propagation.depthは両方の更新を数えるので、outermostTokenはt1のままで、runAfterPropagationで後回しにした処理はt1のendPropagationまで待ちます。
正しいやり方は、Observable の接続関係を pipe で記述することです。値を導出にすれば(const R = D.pipe(…))、D → R がグラフの辺になり、SynState は BDR が R の後だと分かります。1 回の更新が、親がすべて確定した状態で BDR に 1 回だけ届き、警告も 2 回目の発行も起きません。eslint-plugin-synstate の synstate/no-write-during-propagation ルールは、subscriber や pipe 内の射影関数からの書き込みを、書かれた場所で報告します。
同じ決まりは、1 つのプログラムに SynState のコピーが 2 つある場合にも働きます。一方のコピーの更新が他方の更新の中で始まることがあり、ノードにはどちらの印も付きえます。バケットの組はコピーごとに別で、#isOpen はコピーをまたいで読めるので、どちらの更新も、相手がまだ必要とする印を上書きしません。tryUpdate が例外を投げた場合は、close が使用中のスロットを空にするので、次の更新は空のバケットから始まります。