コンテンツにスキップ

SynState がグリッチを解決した方法

リアクティブプログラミングにおけるグリッチとは、伝搬中に派生値が一時的に不整合な中間状態になる現象です。単一のソース値が変化したとき、それに依存する Observable が一度にすべて更新されるのではなく、一つずつ更新される場合があります。下流の Observable が共通の祖先を持つ複数の上流の値に依存している場合、一部の入力はすでに更新されているが他はまだ古いままという状態を観測してしまい、論理的には存在し得ない値が生成されます。

以下の Observable グラフを考えてみましょう:

SynState のコードでは:

import { collectToArray, counter, derive, map, take } from 'synstate';
const counterObservable = counter(1000 /* ms */);
// 0, 1, 2, 3, ...
const multipliedBy10 = counterObservable.pipe(map((count) => count * 10));
// 0, 10, 20, 30, ...
const multipliedBy1000 = counterObservable.pipe(map((count) => count * 1000));
// 0, 1000, 2000, 3000, ...
const sum = derive([multipliedBy10, multipliedBy1000], (a, b) => a + b);
// 0, 1010, 2020, 3030, ...
const resultPromise = collectToArray(sum.pipe(take(5)));
counterObservable.start();
const result = await resultPromise;
assert.deepStrictEqual(result, [0, 1010, 2020, 3030, 4040]);

multipliedBy10 と multipliedBy1000(derive への2つの入力)はどちらも同じソース(counterObservable)から派生しています。counterObservable が新しい値を発行するたびに、derive への両方の入力は同時に更新されるべきであり、sum は常に counter * 1010 に等しくなるはずです。

RxJS では、combineLatest はいずれか1つの入力が変化した時点で即座に新しい値を伝搬します。counterObservable が新しい値を発行すると、更新は深さ優先でグラフを伝搬します。combineLatest は入力を順番にsubscribeするため、カウンターが 0 から 1 に変化するときの典型的なシーケンスは次の通りです:

  1. counterObservable が 1 を発行
  2. combineLatest の multipliedBy10(最初の入力)への subscription が実行される — multipliedBy10 は 10 に再計算されるが、multipliedBy1000(2番目の入力)はまだ 0(古い値)
  3. combineLatest が [10, 0] を発行 → sum が 10 を発行 — グリッチ
  4. multipliedBy1000(同じく counterObservable をsubscribeしている)が 1000 に再計算
  5. combineLatest が multipliedBy1000 の 1000 への変化を検知
  6. combineLatest が [10, 1000] を発行 → sum が 1010 を発行 — 正しい値

このパターンはカウンターがインクリメントされるたびに繰り返されます:

counter: 0 1 2 3
│ │ │ │
sum (RxJS): 0 10, 1010 1020, 2020 2030, 3030 ...
↑ ↑ ↑
glitch glitch glitch

RxJS における sum の完全な出力シーケンスは:

0, 10, 1010, 1020, 2020, 2030, 3030, ...

10、1020、2030 などの値はグリッチです。これらは一方の派生 Observable はすでに更新されたが、もう一方はまだ再計算されていない状態を表しています。これらの値は論理的には存在し得ないはずですが(sum は常に 1010 の倍数であるべき)、subscriber に発行されてしまい、不正確な UI 描画、無効な API 呼び出し、あるいは見つけにくいバグの原因となる可能性があります。

注意: グリッチが現れる順序は、counterObservable が subscriber に通知する順序に依存します。上の例では combineLatest が multipliedBy10 → multipliedBy1000 の順に subscribe するため、counterObservable の subscriber リストも同じ順序になり、multipliedBy10 が先に更新されます。もし multipliedBy1000 が multipliedBy10 より先に counterObservable を subscribe していた場合、グリッチの値は 1000、2010、3020 などになります。いずれにしても、2つの派生 Observable がアトミックにではなく1つずつ更新されるためにグリッチが発生します。

以下の RxJS サンプルコードを実行することでこの動作を確認できます:

import {
combineLatest,
interval,
lastValueFrom,
map,
take,
toArray,
} from 'rxjs';
const counterObservable = interval(100);
// 0, 1, 2, 3, ...
const multipliedBy10 = counterObservable.pipe(map((count) => count * 10));
// 0, 10, 20, 30, ...
const multipliedBy1000 = counterObservable.pipe(map((count) => count * 1000));
// 0, 1000, 2000, 3000, ...
const sum = combineLatest([multipliedBy10, multipliedBy1000]).pipe(
map(([a, b]) => a + b),
);
// 0, 10, 1010, 1020, 2020, 2030, 3030, ...
const result = await lastValueFrom(sum.pipe(take(7), toArray()));
assert.deepStrictEqual(result, [0, 10, 1010, 1020, 2020, 2030, 3030]);
ステップcounterObservablemultipliedBy10multipliedBy1000sum (multipliedBy10 + multipliedBy1000)整合性?
10000 ✓Yes
2110010 ✗グリッチ
311010001010 ✓Yes
422010001020 ✗グリッチ
522020002020 ✓Yes
633020002030 ✗グリッチ
733030003030 ✓Yes

これは作為的な例に過ぎないのか?

Section titled “これは作為的な例に過ぎないのか?”

上記の counter × 10 + counter × 1000 という計算式は明らかに人工的ですが、その背後にあるパターン — ダイヤモンド依存関係 — は日常的な UI コードで驚くほど一般的に発生します。2つ以上の派生ステートが共通の祖先を共有している場合、それはダイヤモンドです。

実世界の例: フィルター付きデータテーブル

Section titled “実世界の例: フィルター付きデータテーブル”

以下のインタラクティブなテーブルは、典型的なデータ閲覧 UI です。レコードの一覧に列ごとのテキストフィルター(デバウンス付き)とページネーションコントロールがあります。フィルター入力欄に文字を入力してみてください。キーストロークごとに派生状態の更新チェーンが発生します:

TableSliced への3つの入力 — ItemsPerPage、TableFiltered、CurrentPage — は複数の重なり合うダイヤモンドを形成しています。例えば、TableFiltered は TableSliced への直接の入力であると同時に、CurrentPage の間接的な祖先でもあります(PageLength 経由)。同様に、ItemsPerPage は PageLength(CurrentPage に影響する)と TableSliced の両方に直接入力されます。フィルター入力または ItemsPerPageInput が変化するたびに、TableSliced の複数の入力が共通の祖先を共有しており、TableSliced を整合的に評価する前にすべてが解決される必要があります。

RxJS のようなプッシュ型ライブラリでは、combineLatest が一部の入力はすでに更新されているが他はまだ古い値を保持している中間状態を発行してしまいます。これが目に見えるバグを引き起こすかどうかはアプリケーションによりますが、不整合は常に存在し、グラフの拡大に伴って悪化します。combineLatest への NN 個の入力がある場合、単一のソース更新で 1 回ではなく N+1N+1 回の発行が生じ、ディープチェーンデモで示されるように O(N2)O(N^2) の急激な性能劣化を引き起こします。

グリッチをどのように排除できるか?

Section titled “グリッチをどのように排除できるか?”

グリッチの根本原因は、子 Observable がすべての祖先の更新が完了する前に評価される可能性があることです。すべての Observable がその祖先のすべてが更新された後にのみ更新されることを保証できれば、グリッチは不可能になります。

よく知られた解決策は、各 Observable にレベル(または深さ)— ルートソースからその Observable までの最長パスの長さ — を割り当て、優先度キューを使用してレベルの昇順で更新を処理することです。

このアプローチは、例えば論文 Deprecating the Observer Pattern with Scala.React(Maier & Odersky, 2012)で詳しく解説されています。アルゴリズムは以下のように動作します:

  1. ソース Observable が変化しても、子にすぐには通知しません。代わりに、無効化されたすべてのノードがレベル順の優先度キューに入れられます。
  2. 伝搬ループは最低レベルのノードをデキューし、検証(評価)して、その依存ノード(より高いレベルにある)をキューに追加します。
  3. キューが空になるまでこれを続けます。

キューは常に低レベルのノードを先に処理するため、derive(または任意のマルチ親ノード)がデキューされるまでに、そのすべての親はこのサイクルで既に更新されていることが保証されます。一方の親は更新されたがもう一方はまだという中間状態は、下流のノードからは決して観測されません。

ダイヤモンドの例を使って優先度キューアプローチをステップごとにトレースしてみましょう。各ノードにはグラフ内の位置に基づくレベルがあります:

counter → level 0
multipliedBy10 → level 1
multipliedBy1000 → level 1
sum (derive) → level 2

counter が 0 から 1 に変化すると、伝搬は以下のように進みます:

ステップアクション優先度キュー(実行後)
1counter が 1 に更新。依存先の multipliedBy10 と multipliedBy1000 がキューに追加される。[multipliedBy10 (L1), multipliedBy1000 (L1)]
2multipliedBy10(レベル1)をデキュー。評価: 1 * 10 = 10。依存先の sum がキューに追加される。[multipliedBy1000 (L1), sum (L2)]
3multipliedBy1000(レベル1)をデキュー。評価: 1 * 1000 = 1000。依存先の sum は既にキューにある — スキップ。[sum (L2)]
4sum(レベル2)をデキュー。評価: 10 + 1000 = 1010。両方の入力が最新。依存先はなし。[](空)

キューが空になり、伝搬が完了します。sum は 1010 を発行します。これは正しい値です。sum は両方の親が更新された後にのみ評価されたため、グリッチは発生しませんでした。

reactivelib/reactive は、Scala.React の論文に直接触発された、このトポロジカル順序の伝搬を実装する TypeScript ライブラリです。

優先度キューアプローチはグリッチを正しく排除しますが、更新のたびにオーバーヘッドが発生します。ソースが発行するたびに、すべての子孫を優先度キューに追加・取り出しする必要があり、各操作のコストは O(log⁡n)O(\log n)(nn はグラフ内の Observable の数)です。nn 個の子孫を持つグラフの場合、ソース発行あたりの合計伝搬コストは O(nlog⁡n)O(n \log n) になります。

RxJS の深さ優先伝搬はこのオーバーヘッドを回避します — 各ノードは関数呼び出しだけで訪問されます。しかし、RxJS は依存関係グラフを DAG(有向非巡回グラフ)ではなくツリーとして扱います。ダイヤモンド依存関係が存在する場合、RxJS は両方のブランチを独立にトラバースします。共有された子孫(例: combine)は、それに到達する各ブランチごとに1回ずつ評価されます。

コストはダイヤモンドのトポロジーに依存します:

  • ワイドダイヤモンド — nn 本のブランチが1つの combineLatest に合流する場合。ソース発行ごとに n+1n+1 回の combineLatest 評価が発生し、各評価が nn 個の入力を処理 → ソース発行あたり O(n2)O(n^2)。
  • カスケードダイヤモンド — nn 個のダイヤモンドが直列に連なる場合。各ダイヤモンドのファンアウト(分岐数)を DD とすると、各レベルで emission 数が DD 倍になります:レベル 1 は DD 回、レベル 2 は D2D^2 回、…、レベル nn は DnD^n 回 → ソース発行あたり O(Dn)O(D^n)。二分岐(D=2D = 2)の場合は O(2n)O(2^n) です。
アプローチダイヤモンドなし(ツリー)ダイヤモンドあり(最悪ケース)
RxJS(深さ優先)O(n)O(n)O(Dn)O(D^n)
優先度キュー(Scala.React)O(nlog⁡n)O(n \log n)O(nlog⁡n)O(n \log n)

(DD = ダイヤモンド1段あたりのファンアウト)

ダイヤモンド依存関係のないグラフでは、RxJS は log⁡n\log n 倍高速です。しかしダイヤモンドが存在すると、RxJS の最悪ケースのコストは指数関数的に増大(カスケードダイヤモンドの場合)しますが、優先度キューアプローチは O(nlog⁡n)O(n \log n) のままです。どちらも O(n)O(n) でのグリッチフリー伝搬という理想を達成していません。

これは mousemove や pointermove イベント、アニメーションフレーム、または毎秒数十回から数百回更新が発生する高頻度ストリームなど、パフォーマンスに敏感なシナリオで重要になります。優先度キューアプローチの繰り返されるヒープ操作は蓄積され、ダイヤモンドグラフにおける RxJS の冗長な評価はさらに悪化します。

SynState は両方の問題を解決します。O(n)O(n) でのグリッチフリー伝搬を実現します。これは RxJS のツリートラバーサルと同じ漸近的コストですが、グリッチがなく、指数的な最悪ケースもありません(DD = ダイヤモンド1段あたりのファンアウト)。ディープ依存関係チェーンデモでは、N+1N+1 個の出力が1つの combineLatest に合流するワイドダイヤモンドでの冗長な発行を観察できます。マウス移動1回につき、RxJS の Total updates は N+1N+1、SynState は1増え、RxJS の更新はそれぞれ N+1N+1 点すべてを描き直します。

SynState で起こること(グリッチフリー、O(n))

Section titled “SynState で起こること(グリッチフリー、O(n))”

SynState の重要な洞察は、更新順序におけるノードの位置が、ノードを作った時点で決まることです。それがノードの深さで、小さな整数です。そのため、優先度キューアプローチのキューを深さごとのバケットにでき、ノードを入れる操作も取り出す操作も O(log⁡n)O(\log n) ではなく O(1)O(1) になります。

深さごとのバケットに分けたキュー

Section titled “深さごとのバケットに分けたキュー”

Observable が .pipe() またはコンビネータ(combine、derive、merge、zip)を介して作成されると、SynState はその深さ — 任意のルートソースからの最長パスの長さ — を計算します:

  • ルート Observable: depth = 0.
  • 子 Observable: depth = 1 + max(parent depths)。createScope(parent, { after }) で作ったスコープの中で作られた子は、after より後に更新されるよう、スコープの下限まで引き上げられます。

更新はマネージャー Observable(後述)から始まります。マネージャーはその更新のキューを開き、自身より下の深さごとにバケットを 1 つずつ持たせます。更新の中で値を発行したノードは、マネージャーを先頭に、自分の子をそれぞれの深さのバケットに入れます。子の親がいくつ発行しても、子が入るのは 1 回の更新につき 1 回です。マネージャーはバケットを浅い順に取り出し、中のノードの tryUpdate を呼びます。発行したノードはその場で自分の子をより深いバケットに入れていき、最後のバケットが空になれば更新は終わりです:

// Simplified from ManagerObservableClass
protected startUpdate(nextValue: A): void {
// No child and no subscriber: store the value, open no update
if (this.setNextIfUnobserved(nextValue)) return;
// The token of the update is also its queue
const updateToken = openUpdate(this.depth);
this.setNext(nextValue, updateToken); // queues this node's children
drainUpdate(updateToken); // visits the buckets in ascending depth
}
// Simplified from ObservableBaseClass, which every observable extends
protected setNext(nextValue: A, updateToken: UpdateToken): void {
this.#updateToken = updateToken;
this.#snapshot = Optional.some(nextValue);
// Into the bucket of each child's depth, once per update
enqueueChildren(this.#children, updateToken);
// ...then notify the subscribers
}

これは RxJS との重要なアーキテクチャの違いです。RxJS では、各 Observable が発行したその場で子に伝搬します(深さ優先)。SynState では、発行したノードは子をキューに入れるだけで、いつ訪れるかは更新を始めたマネージャー Observable が単一のループで決めます。この中央のループがあるから、キューで深さ順に並べられます。

SynState の Observable 型は RootObservable、SyncChildObservable、AsyncChildObservable です。このうち、RootObservable と AsyncChildObservable がマネージャーとして機能します。RootObservable はグラフのエントリーポイントであり、AsyncChildObservable は debounce のような時間シフト演算子によって作成されます。その発行は非同期であり上流のルートによって調整できないため、自身の下流サブグラフのマネージャーとして引き継ぎます。

親は子より必ず浅く、バケットは浅い順に処理されるので、ノードを処理する時点で親の値はすべて確定しています。これは優先度キューアプローチと同じ保証です。更新が届いたノードはそれぞれバケットに 1 回入って 1 回出るだけで、どちらも O(1)O(1) です。そのため更新のコストは、届いたノードの数について O(n)O(n) です。値を通さなかった filter の下のノードなど、更新が届かないノードには一切触れません。バケットは深さごとの配列で、更新から更新へ使い回します。空にするのは長さを 0 に戻すだけなので、更新ごとに確保するのはトークンだけです。switchMap が追っていない分岐のような、子も購読者もないノードへの書き込みは、更新を開くことさえしません。届ける相手がいないので、自分のトークンを付けて値を保存し、そのまま戻ります。

このアニメーションはダイヤモンドの1回の更新をたどります。チップはバケットで待っているノードです。D は B によってキューに入り、C からは二度目は入りません。

これをダイヤモンドの例に適用すると:

depth 0: counter ← source emits
depth 1: multipliedBy10 ← updated first (depth 1)
depth 1: multipliedBy1000 ← updated next (depth 1)
depth 2: sum (derive) ← updated last (depth 2); both inputs are up-to-date

SynState における sum の出力は:

0, 1010, 2020, 3030, 4040, ...

中間状態はありません。グリッチはありません。subscriber に発行されるすべての値が整合的です。

ステップcounterObservablemultipliedBy10multipliedBy1000sum (multipliedBy10 + multipliedBy1000)整合性?
10000 ✓Yes
211010001010 ✓Yes
322020002020 ✓Yes
433030003030 ✓Yes

スキップされた更新の処理: 更新トークン

Section titled “スキップされた更新の処理: 更新トークン”

深さ順に訪れることで、すべてのノードが更新される場合のグリッチはなくなります。しかし、すべてのノードが毎サイクル更新されるわけではありません — filter や skipIfNoChange のような演算子は発行をスキップすることができます。これにより微妙な問題が生じます: 下流のノードは、親が現在のサイクルで意図的にスキップされたのか、まだ到達していないだけなのかをどう判断すればよいのでしょうか?

D が filter を適用する以下のグラフを考えてみましょう:

const A = counter(1000);
// 0, 1, 2, 3, 4, ...
const B = A.pipe(map((n) => n * 10));
// 0, 10, 20, 30, 40, ...
const D = A.pipe(filter((n) => n % 2 === 0));
// 0, 2, 4, ... (odd values are skipped)
const C = derive([B, D], (b, d) => b + d);
// sum of B and D: 0, 10, 22, 32, 44, 54, 66, ...
const E = D.pipe(map((n) => n * 10));
// 0, 20, 40, ...
const resultC = collectToArray(C.pipe(take(7)));
const resultE = collectToArray(E.pipe(take(4)));

A が偶数の値(例: 2)を発行すると、すべてのノードが更新されます: B は 20 に、D は 2 を通過し、C は 22 を発行、E は 20 になります。しかし A が奇数の値(例: 1)を発行すると、D は述語を通過しません。そのため D とその子孫 E は更新されるべきではありませんが、B は 10 に更新され、C は B が変化したため再評価されるべきです。C は B が発行した時点でキューに入るので、訪れられたときに、このサイクルで自身の親のどれが更新され、どれがスキップされたのかを自力で判断する必要があります。

SynState はこれを、各更新サイクルに一意のアイデンティティ — 更新トークン — を与えることで解決します。トークンは各サイクルの開始時に openUpdate() で作成される小さなオブジェクトで、同じオブジェクトがそのサイクルのキューを持ちます。(誰も観測していない書き込みはサイクルを開かず、キューを持たない自分専用のトークンを受け取るので、updateToken はやはり変わります。)トークンは以下のようにグラフを流れます:

  1. マネージャーが新しいトークンを作成し、setNext(value, token) で自身にスタンプを押します。
  2. 同じトークンが、キューから取り出されたすべてのノードの tryUpdate(token) に渡されます。
  3. 子が正常に更新されると、setNext(newValue, token) を呼び出し、現在のサイクルのトークンで自身にスタンプを押します。
  4. 子がスキップした場合(例: filter の述語が false を返す)、setNext を呼び出しません — そのため updateToken は前のサイクルのトークンのままで、子もキューに入りません。

これによりシンプルな不変条件が成立します: ノードの updateToken が現在のサイクルのトークンと等しいのは、そのノードがこのサイクルで更新された場合かつその場合に限ります。 各ノードは O(1)O(1) の単一の参照比較で親のこれをチェックし、更新するかスキップするかを決定します:

  • 単一親演算子(例: map、scan): parent.updateToken === token をチェック。親のトークンが一致しない場合、親はスキップされたので子もスキップします。
  • 複数親演算子(例: combine): 少なくとも1つの親のトークンが一致するかをチェック。すべての親がスキップされた場合、combine もスキップします。少なくとも1つが更新された場合、combine はすべての親(このサイクルで更新されたかどうかにかかわらず)の最新のスナップショットを使用して再評価します。

各演算子は SyncChildObservableClass または AsyncChildObservableClass のサブクラスで、マネージャーがトークンを渡して呼ぶ tryUpdate をオーバーライドします:

// Simplified from DeriveObservableClass, which `combine` is built on
override tryUpdate(updateToken: UpdateToken): void {
let updated = false;
for (const [i, parent] of this.parents.entries()) {
this.values[i] = parent.getSnapshot().value;
if (parent.updateToken === updateToken) {
updated = true;
}
}
// Skip only if ALL parents were skipped in this cycle
if (!updated) {
return;
}
// ... this.setNext(this.project(...this.values), updateToken) ...
}
// Simplified from FilterObservableClass
override tryUpdate(updateToken: UpdateToken): void {
const par = this.parents[0];
if (par.updateToken !== updateToken) {
return; // parent was skipped → skip this node too
}
const sn = par.getSnapshot();
if (Optional.isSome(sn) && this.#predicate(sn.value)) {
this.setNext(sn.value, updateToken); // stamp with token → "I updated"
}
// If predicate fails: no setNext → updateToken stays stale → descendants know to skip
}

各ノードのスキップ/更新の判断が定数時間のトークン比較で済むため、単一の更新サイクルの全体コストは O(n)O(n)(nn は更新が届くノードの数)のままです。各ノードのコストはサイクルあたり償却 O(1)O(1) です。

このアニメーションは上のグラフを A = 1、続いて A = 2 で動かします。奇数の値では D が発行しないので E は一度もキューに入りません。C は B によってキューに入り、B だけが発行したことをトークンから知ります。

アプローチダイヤモンドなし(ツリー)ダイヤモンドあり(最悪ケース)グリッチフリー?
RxJS(深さ優先)O(n)O(n)O(Dn)O(D^n)No
優先度キュー(Scala.React)O(nlog⁡n)O(n \log n)O(nlog⁡n)O(n \log n)Yes
SynStateO(n)O(n)O(n)O(n)Yes

SynState は、依存関係グラフの2つの性質を活用して O(n)O(n) でのグリッチフリー伝搬を実現しています:

  1. 整数の深さ — ノードの深さは構築時に固定されるため、キューを深さごとのバケットにでき、ヒープの O(log⁡n)O(\log n) ではなく O(1)O(1) で出し入れできます。
  2. 更新トークン — サイクルごとの一意のトークンにより、各ノードが O(1)O(1) で自身の親が現在のサイクルに参加したかどうかを判断でき、ある親がキューに入れたノードについて、別の親がスキップされていても正しいスキップ/更新の判断が可能になります。

SynState の伝搬モデルは、依存関係グラフが DAG(有向非巡回グラフ)であることを前提としています。これは公開 API の作りによって保証されています。新しい Observable の親はそれより先に存在し、後から変えられないので、どの Observable も自分自身の祖先にはなれません。そのうえで SynState は、開発環境で構築時に循環チェックを実行してこれを確かめます。.pipe() またはコンビネータ(combine、derive、merge、zip)を介して新しい Observable が作成されるたびに、祖先チェーンの深さ優先探索により、自分自身が祖先に含まれていないことを検証します。循環が検出された場合、即座にエラーがスローされます:

Circular dependency detected in observable graph:
a child observable cannot be its own ancestor.

このチェックは開発環境でオン、本番環境でオフです。新しい Observable の祖先をすべてたどるので作成のたびにコストがかかり、本番ではそれを払いません。process.env.NODE_ENV が 'production' のときと、ブラウザ向けバンドルのように process が存在しない環境ではオフです。公開 API だけでは循環を作れないので、このチェックが見つけるのは Observable の parents を書き換えるコードで、そうしたコードは本番に届く前に開発中やテストで動きます。

これにより、グラフが構築された後は、すべての後続の更新サイクルが DAG 上で動作することが保証されます — 無限ループはなく、どのノードにも深さが定まります。

他のライブラリは循環を異なる方法で処理します:

  • RxJS は循環検出を行いません。実際には、pipe() と combineLatest() は前方のみの構築パターンに従います — 既に存在する Observable のみを参照できるため、通常の使用では循環はほとんど発生しません。しかし、SynState の構築時 DFS チェックとは異なり、RxJS はセーフティネットとしての明示的な検証を提供しません。
  • Jotai は循環を検出しません。循環的な atom 定義(例: atomA が atomB を読み、atomB が atomA を読む)は定義時にエラーなく受け入れられます。循環は atom が読まれたときにのみ顕在化し、明確なエラーメッセージではなく Maximum call stack size exceeded(スタックオーバーフロー)でクラッシュします。

これらの動作は循環依存の比較テストで検証されています。