Skip to content

ランタイム実装ガイド ​

ランタイム実装者向けに、コンパイルパイプラインと実行モデルを定義する。

10.1 コンパイルパイプライン ​

8 つのフェーズがあり、それぞれが自分の担当するエラーを次へ渡さずに弾く:

フェーズ生成物弾くもの
project (selector)kumiki source — CRDT graph store のテキスト断面—
parseAST—
名前解決resolved ASTundef-ref、dangling 参照
型検査typed ASTtype-mismatch、refinement 違反
effect 解析effect-annotated ASTcap-missing、直接呼び出し
純粋性検査verified ASTreducer-side-effect、tile-mutation
lowerIR (Kumiki Intermediate Representation)—
codegenランタイム成果物—

codegen が出力する成果物は 4 つ: signal graph(JS または WASM)、effect dispatcher テーブル、episode logger、dev-tool trace UI。エラーは AI 編集 の構造化エラーで返す。


10.2 IR ​

中間表現は Typed Dataflow Graph。ノードは次のいずれか:

ノード種役割
slot-readslot からの読み取り
slot-writeslot への書き込み(reducer のみ)
field-access, indexrecord/collection 要素アクセス
op, call演算・関数呼び出し(fn 定義済み関数も含む)
fn-bodyfn レイヤの本体(純粋計算、引数のみ依存)
matchunion 分岐
if, when, for制御
emiteffect 放出
event-sourceevent の入口
dom-nodeDOM 出力ノード
dom-bindDOM ノードへの slot 紐付け

エッジは依存関係(dataflow)。

10.2.1 IR シリアライズ形式 ​

JSON でデバッグ可能、本番は CBOR(バイナリ):

json
{
  "version": "0.1",
  "slots": [
    {"name": "todos", "type": "...", "init": "...", "hash": "..."},
    {"name": "draft", "type": "Text", "init": {"text": ""}, "hash": "..."}
  ],
  "effects": [
    {"name": "persist", "cap": "storage.write", "in": "...", "out": "Unit", "policy": "debounce:300"}
  ],
  "reducers": [
    {
      "name": "addTodo",
      "on": {"kind": "ui.submit", "selector": {"tile": "NewTodoForm"}},
      "do": [
        {"op": "let", "name": "id", "value": {"op": "call", "fn": "TodoId.fresh"}},
        {"op": "slot-write", "lhs": {"slot": "todos", "key": {"var": "id"}}, "rhs": "..."},
        {"op": "slot-write", "lhs": {"slot": "draft"}, "rhs": {"text": ""}},
        {"op": "emit", "name": "persist", "args": [{"slot-read": "todos"}]}
      ]
    }
  ],
  "tiles": [
    {
      "name": "App",
      "body": {"kind": "page", "children": [...]},
      "deps": ["slot:todos", "slot:draft", "tile:TodoList", "fn:matchFilter"]
    }
  ],
  "fns": [
    {
      "name": "matchFilter",
      "params": [{"name": "t", "type": "Todo"}, {"name": "f", "type": "Filter"}],
      "ret": "Bool",
      "body": {"op": "match", ...},
      "hash": "..."
    }
  ],
  "app": {
    "name": "TodoApp",
    "caps": ["storage.read", "storage.write"],
    "routes": {"/": "App", "/404": "NotFound"},
    "init": [{"emit": "loadTodos", "args": []}],
    "theme": "DefaultTheme"
  }
}

10.3 Signal Graph ​

ランタイムは IR から 静的 signal graph を生成する。Solid 風の fine-grained reactivity だが、Kumiki ではコンパイル時にグラフ構造が完全に決まる(実行時にシグナル追跡しない)。

10.3.1 ノード種 ​

ノード入力出力
SlotNode–slot 値
ComputeNode上流ノードの値派生値
BindNode上流ノードの値DOM 操作
EventNodeDOM eventreducer 呼び出し

10.3.2 更新アルゴリズム ​

on reducer execution:
    collect modified slots into Set<SlotId>
    for each modified slot:
        for each downstream ComputeNode/BindNode (precomputed):
            mark dirty
    process dirty queue in topological order:
        recompute ComputeNode
        apply BindNode → DOM mutation

依存関係はコンパイル時に静的に解析されているので、実行時の追跡コストは 0。

10.3.3 batching ​

1 つの reducer 実行内のすべての slot 変更は 1 つのバッチとして扱う。for ループ内の連続変更も同一バッチ。バッチ確定後に signal graph を 1 度だけ更新する。

バッチは全部通るか全部通らないかのどちらか ​

書き込みごとに、対象 slot の refinement(登録済み refinement 述語)に照らして検査する。バッチ最終値だけではない。検査に使う refinement は、slot の型が表す連鎖 — 別名・nominal ラッパー・where・型引数をそのまま返すジェネリック(type NonEmpty(T) = T where nonempty のもとでの NonEmpty(Text)) — に沿って持たれているものであり、標準ライブラリ自身の宣言も含まれる:Email は標準ライブラリが宣言する refinement 付き nominal(ドメイン型)なので、slot e : Email は slot e : Text where email とまったく同じように email で検査される。型の内側に書かれた refinement も、それが書かれたパスで検査される(§1.3.3):slot form : {email: Text where email} のもとで form.email := "nope" も form := {email: "nope"} も拒否され、自身の述語を満たさない union のペイロードやコンテナの要素も同様である。述語を複数持つ型はそのすべてに照らして検査され、拒否が名指すのは、§1.3.1 が与える順で値が最初に失敗した述語である(§1.3.1)。いずれか 1 つの書き込みでも拒否された場合、その reducer 適用は丸ごと破棄される。slot は 1 つも書かれず、emit は 1 つも発行されず、stop-timer も走らず、再レンダリングも起きない。

バッチ単位ではなく書き込み単位なのは、バッチが map であり各 slot について最後に代入された値しか覚えていないからである。slot の範囲から出て戻ってくる for ループは合法な値で終わり、途中で通過した非合法な値 — 下記のとおり後続のすべての文から読める — は一度も検査されない:

kumiki
reducer drift on=ui.click(Btn)
    do= for d in [1, 1, 1, 1, -1, -1, -1, -1] { count  := count + d    # 4 に到達する
                                                mirror := mirror + count }

これは panic ではない — アプリは操作可能なまま、slot は無傷で、app.error も発火しない — が、決して沈黙しない。runtime は

[kumiki] reducer "bump" was rejected: slot "count" cannot hold 4 (between(0, 3)). No slot was written and no effect was emitted.

を console.error に報告する(slot の型の内側に書かれた述語なら、失敗した場所へのパスを添えて slot "form" cannot hold {"email":"nope"} (email at .email) と報告する)。未処理の effect エラー(標準 capability)と同じ経路・同じ契約であり、検証ティア(smoke / runScenario / e2e)がすべて拾う。

このルールがあるのは、もう一方の選択肢 — 拒否された slot だけを飛ばして残りを書く — が reducer を半分だけ適用し、さらに slot が一度も取らなかった値を隣の slot へ逃がしてしまうからである。body の後続文は構築途中のバッチを読むためだ:

kumiki
type Small = nominal Int where between(0, 3)
slot count : Small = 0
slot mirror : Int  = 0

reducer bump on=ui.click(Btn)
    do= count  := count + 1      # 上限では、この値は拒否される
        mirror := count          # ...そしてここから読めてはならない

到達しうる境界はプログラム側の責任であって runtime の責任ではない。ガードは自分で書く:

kumiki
reducer bump on=ui.click(Btn)
    do= if count < 3 then count := count + 1

プログラムの外から来る値は、reducer に届く前に検査される。Decoder.Json(T) が decode した値を T の述語が拒否するレスポンスや保存値は、effect の .err になり(§6.1.4、§6.7.2)、プログラムの .err reducer が扱う。そうでなければ、その書き込みがこの規則で破棄される .ok になり、reducer が行った他のすべて(ロード状態を終えることなど)も一緒に破棄される。

refinement が門番をしないものが 2 つある:

  • 宣言時の初期値。slot email : Text where email = "" は自身の refinement が拒否する値を最初から保持する。これこそが、手つかずのフォームで error(field=email) にメッセージを出させている仕組みである(エラー表示)。
  • 双方向 bind。入力の拒否はフィールド単位で、報告も出ない(refinement の扱い)。入力途中の値は欠陥ではなく想定内だからである。slot は以前の値を保ち、フィールドは入力されたものを表示し続け、error(field=…) はフィールドが表示しているものについてメッセージを出す。

この 2 つは組み合わさると罠になる。宣言時の初期値が自身の refinement に違反している slot は、reducer からその初期値にリセットできない。Text where nonempty の slot に対する name := "" は他と変わらない書き込みなので、バッチを破棄する。slot の型を広げて境界で refine するか、空のケースを Option でモデル化すること:

kumiki
slot name : Text where nonempty = ""    # 不正な値で始まる — 許される
reducer clear on=ui.click(Btn) do= name := ""    # 拒否される — 許されない

10.3.4 DOM レンダリングの不変条件 ​

  • null/undefined 子ノードは skip される。when(false, X) のような偽分岐は null を子に渡すが、renderTile はそれを無視して兄弟だけを描画する
  • column / row / card / box / panel / stack / region / scroll / fieldset はすべて <div> ベースのコンテナ。stack は column 相当(vertical stack)
  • grid は display: grid + cols prop で grid-template-columns: repeat(N, 1fr) (数値)または直接 CSS 値(文字列)
  • divider は <hr> 単独要素(children なし)
  • timer reducer は setInterval で発火、app の dispose 時に clearInterval で停止

10.3.5 input/textarea/select の bind path ​

bind=draft.title のように nested lvalue path に bind できる。ランタイムは:

  • 表示: _live[root][...path] を辿って初期値を読む
  • 変更: 入力イベントで _setPath を使い root slot を immutable に更新
  • focus 復元: data-kumiki-bind 属性に full path 文字列 ("draft.title") を入れて識別

10.3.6 動的 theme switching ​

app theme = themeName のように slot 名で theme を指定できる。ランタイムは:

  • app.themeName が app.themes に存在しなければ、_live[app.themeName] を読んで theme 名を解決
  • 各 render() の冒頭で applyThemeDefaults を再実行 → slot 値の変更が body スタイルと注入済みのベーススタイルシートに反映
  • 解決された theme が、マウント済みツリーを塗ったときの theme と違えば、ページを塗り直す。トークン prop(bg, color, pad, gap, radius, shadow、タイポグラフィ等)は tile の描画時にリテラル値へ解決されるため、自身の prop が変わらない tile は放っておくと旧 theme の値のままになる。その描画パスは差分を取らず、ツリーを作り直す。切替をまたいで要素の同一性は保たれない:フォーカス中のコントロールは他の再構築と同じ方法でフォーカスと選択範囲を取り戻し、どの slot も持たない DOM 状態は初期状態に戻る:スクロール位置、制御されていない <details>、bind されていない input の入力中テキスト、file input の選択、再生中のメディア、そして bind が拒否したテキスト(作り直されたコントロールは slot が保持している値を表示し、そのフィールドの error(field=…) のメッセージも拒否されたテキストとともに消える)。切替は入場アニメーションを再生し直さない:作り直された要素のうち transition や motion を持つものは、一回きりのアニメーションなら最終フレームをすぐに表示し、繰り返すアニメーションは動き続ける。アプリのどのビューも、hydration の有無にかかわらず同じように振る舞う
kumiki
slot themeName : Text = "Light"
theme Light = { colors: {bg: "#fff", fg: "#222"}, ... }
theme Dark  = { colors: {bg: "#222", fg: "#eee"}, ... }
reducer toggle on=ui.click(ThemeBtn) do= themeName := if themeName == "Light" then "Dark" else "Light"
app App ... theme = themeName    # ← slot 名を渡す

10.3.7 polymorphic collection methods ​

.filter / .map / .get-or などはランタイムで型 dispatch:

  • .filter(pred): Array なら Array.prototype.filter、Option なら Some の値、それ以外のオブジェクト — Map、または値が true の Set — なら各エントリを 1 つの [key, value] ペアとして渡す(_s.filter)
  • .map(fn): Array なら要素 map、Option/Result なら Some/Ok の中身に map (mapOver)
  • .flat-map(fn): Option/Result の Some/Ok を f に渡し、None/Err は素通り (flatMapOption)
  • .get-or(default) (Option) / .get-or(key, default) (Map): 引数数で判別
  • m.entries は [[k, v], ...] で返る。後続の list ops の lambda が $1 / $2 をどう束縛するかはここではなく、受信側の型から型検査器が決める(標準ライブラリ §2.2.3)— Tuple(K, V) の要素は $1=k, $2=v に分解される

10.3.8 select の値マッチング ​

select(value=v, options=[...]) は option の選択状態を 構造的キーで判定する:

  • variant は _tag + payload を再帰的にシリアライズしてキー化する。Some(Backlog) と Some(InProgress) は別キーになる(フラットな _tag 比較だと両者が "Some" で衝突するため、payload まで含めることが必須)
  • Option(Status) のような「variant でラップした variant」を option 値にできる

10.3.9 focus 復元 ​

#190(要素同一性を保った reconciliation、§10.3.11)以降、runtime は同じ kind の タイルを data prop 変更時に「その場で patch」するので、mount 中の <input> / <textarea> / <select> は自然に browser focus を保つ。以下の snapshot / restore 層は、丸ごと DOM を swap するパス(reconcile bailout、panic recovery、 focus 対象要素そのものを動かす keyed reorder)専用の fallback として残す。 並べ替えで動かなかった focus 中の子はそもそもこの fallback に届かない。keyed pass は動かす必要のある子だけを配置するので(§10.3.10)、カーソルはその要素を 離れない。

再レンダリング後も入力中の input/textarea の focus とカーソル位置を維持する:

  • bind= がある要素: data-kumiki-bind 属性(nested path は full path 文字列)で再特定
  • id= がある要素: id で再特定
  • どちらもない(value= のみの検索ボックス等): DOM child-index path で位置ベースに再特定
  • <select> も snapshot 対象に含まれるので、reorder / bailout パスでもピッカーへの focus は復元される(open dropdown の状態は復元不能で、それは §10.3.11 の patch パス側の保証)

10.3.10 安定タイル identity ​

compiler↔runtime のタイル木コントラクトが keyed reconcile 用のインスタンス identity フィールドを持つ(#187 で diff カーネルを、#188 で end-to-end 配線):

ts
type TileNode = (/* … kind variants … */) & { readonly key?: string };

コントラクト

  • key は additive で optional。key を持たないタイルも合法な TileNode で、key を含まない旧コンパイル出力は新 runtime でそのまま mount し、旧 runtime はこのフィールドを無視する。これはフィールドについての記述に限られる。 コンパイラが key を計算するために呼ぶ stdlib ヘルパ(後述の暗黙 key の _s.loopKeys)は前方互換ではなく、新コンパイラの出力にはそれ以上に新しい runtime が要る(Migration を参照)。

  • reconciler は 親ごとに all-or-nothing で keyed matching を判断する: ある レベルの全子が key を持つときのみ key で pairing し、reorder/insert/remove を親サブツリー再構築なしで乗り越える。1 つでも key を欠く子があれば、 #187 以前と同じ構造 diff(位置 + kind + データプロップ等価、length 変化 → 再構築)にフォールバックする。

  • 構造 diff も親ごとに all-or-nothing である。 残る 2 つの離脱条件 — 子リストの穴と、要素マッピングを持たない旧子 — は、子を 1 件も適用する前に リスト全体について決着させる。したがって親は「全ての子を reconcile する」か 「1 件も触らずに再構築する」かのどちらかで、再構築が捨てるだけの中途半端な 適用を残さない。これが買うのは報告の正直さである: binds-updated(§10.3.11) も診断(§10.3.12)も実際に起きたレンダを記述し、放棄されたサブツリーの newMap エントリは書かれない。

  • keyed pass は「要素マッピングの有無」を診断ではなく panic で答える。 要素マッピングを持たない旧子は keyed pass も止める。しかもリスト内の全ての子に 対して同じ扱いである: reconcile / mount / remove を 1 件も行う前に旧子それぞれの 要素を解決し、欠けていれば throw する。これは location: "reconcile" の panic として記録され、ツリーは丸ごと再構築される — onDiagnostic を opt-in して いないホストにも聞こえる。下の配置チェックのうち「実測」の側は未マップの子を 「拒否する」のではなく意図的に「見送る」(未マップは配置スタイルではなく壊れた 不変条件だから)ので、それを報告するのはこの panic だけである。だからこの panic は 実測が問題なしと答えた直後、「宣言」側のチェックより前に上がる — そうしないと 宣言側が先に降りて、不変条件違反が報告されないまま構造 diff へ流れてしまう。 その子がこの後どうなる予定だったか — 再利用か削除か — も、報告されるかどうかを 左右しない。

  • 片側だけが空の子リストは、key を見る前に決着する。 旧側が空なら 対応付けるものが存在しない(新しい子は全てフレッシュマウント、古い子は 全て退場)ので、key には照合する相手が無く、上の規則には言うことが無い。 残るのは「新しい子をどこに置くか」だけで、それを知っているのは親の レンダラだけである。したがって runtime はそのレンダラを再入して内側を 作り直し、マウント済みの要素へ移す。親は自分の要素(とそこに載っている ブラウザ所有の状態)を保ち、child-count-change も wrapped-children も 報告されない。これは keyed でも unkeyed でも同じである。結果として 2 つ: 子を包むレンダラでもこの経路は正しい(配置したのはレンダラ自身だから)、 そしてレンダラの「子以外の内側」(details の <summary>、surface の content ラッパーとタイトル)は子と一緒に作り直される。 これが「空状態 → 最初の 1 件」の遷移(空の todo リスト、初回クエリ前の 結果セット、空のカート)である。ただしこの経路に入るのは、container の子が ループだけの場合に限る。静的な兄弟と同居する for はその親の子リストを 空にしない。

  • keyed matching はさらに、親のレンダラが子を自分の要素の直下に配置している ことを要求する。key で対応付けるのは半分でしかなく、その後 reconciler は 生存した子の移動・消えた子の削除・新入りのマウントを親要素に対して行う — これは親要素が保持しているスロットにしか働かない。判断は 2 つの独立した 検査からなる。問うている内容が違うからである:

    • マウント済みの子がどこに居るか は DOM から実測する。レンダラ所有の ラッパーの下にマウントされた子があれば pass は届かないので、reconciler は 辞退して wrapped-children(§10.3.12)を報告する。
    • 新入りがどこへ行くか はレンダラの宣言された配置から読む。まだ存在 しないスロットは実測できないからである。overlay は最初の子を直下に置き 残りを包むので、1 レイヤーの overlay は「全て直下」と実測される — 成長する その瞬間までは。そのまま keyed pass に入れば 2 番目のレイヤーは裸で append されてしまう。こうした親の下で keyed リストが要素を得たとき、 reconciler は辞退して unplaceable-insert(§10.3.12)を報告する。 メンバーが変わらないレンダは置くものが無いので keyed パスのままである。

    現行の組み込みでは overlay が 2 番目以降の子を配置用レイヤーで包み、 modal / drawer / popover は全ての子を content div で包む。ホスト製 レンダラも「返した要素以外に子を append する」と同じ扱いになるが、宣言集合が カバーするのは組み込みだけなので、未知の kind は宣言どおり「直下に置く」と みなされる — ホストレンダラの子は root 要素の直下に置くこと。並べ替えたり 伸ばしたりする keyed リストは素の container(column / row / list)の 下に置くこと。

  • 1 つの親について 2 つの diagnostic が同一レンダで出ることがある。包む親の keyed 子リストの長さも変わった場合、配置の辞退(wrapped-children または unplaceable-insert。key が使われなかった)に続いて child-count-change (結局構造 diff が親を再構築した)が出る。両者は別の事実を述べており、 前者を直せば後者は問題にならなくなる。

  • keyed な並べ替えは最小しか動かさない。 key による対応付けが答えるのは 「どの古い要素がどの新しい子のものか」だけで、「新しい並びを作るのに何個 触る必要があるか」は答えない。reconciler は、古い位置が既に昇順に並んで いる生存子をその場に残し(残りが最小になるよう最長の並びを取る)、それ 以外だけを後続要素の手前に挿入する。具体的には、並び順が変わらないレンダは DOM 配置を 1 回も行わず、1 要素の移動は 1 回、最悪ケース(どの 2 つも 相対順を保たない)でも N−1 回である。 これはスループットだけでなく正しさの保証でもある。ノードを付け直すと blur するので、動く理由の無かった子は focus・キャレット・開いている <select> のドロップダウン・変換中の IME を失う — keyed matching が守る ために存在する状態そのものである。したがって並べ替えで動かなかった focus 中の子は、§10.3.9 の snapshot / restore fallback に頼らずネイティブに それらを保つ。配置は必ず既存ノードの手前に対して行う。アンカーは 新しい並びでその子の次に来る子であり、次が無い最後の子については 「マウント済み子リスト全体の直後にある兄弟」(無ければ親の末尾)である。 したがって子の後ろに自前の内容を置くレンダラでも、その内容は後ろに残る。

コンパイラの emit

  1. 作者が書いた {key: <expr>} はタイル呼び出しのプロップから剥がされ、 emit される TileNode のトップレベル key に置かれる。値は _s.show(...) で文字列化される。props.el には流れない。兄弟の中で一意であること、 つまり 1 つのループの中でも、1 つの親の下のループどうしや他の key 付きの子 との間でも一意であることは作者が約束する。そのレベルの全子が key を持つとき (上述の all-or-nothing 規則)、同じ key を持つ 2 つの兄弟は、どちらを指すかを runtime が判断できないため、次のレンダで location: "reconcile" の panic になる。 このとき {key: …} の値が衝突するループ(for s in [7, 3, 7] text(s.show) {key: s.show}) はプログラムの誤りであり、runtime は位置による対応に退避せず、 [kumiki] error in reconcile: reconcile: duplicate TileNode.key "7" among sibling tiles — keys must be unique within a parent's children list を報告してツリー全体を再構築し、ページ上のすべての要素を置き換える (ライフサイクル §7.2.2)。 そのレベルに key を持たない兄弟があるときは keyed pass が走らないため、 衝突した key は使われず、親は構造 diff をたどる。

  2. for 反復の内側 で {key: ...} を書いていないタイル呼び出しには、 次の 3 つの部分をこの順に持つ暗黙 key を合成する。

    • ループ: レンダをまたいで安定し、ソース上のループごとに異なる識別子。
    • 出現回数: この要素までに同じ表示値を持つ要素がいくつあるか(最初の 7 は 1、2 つ目は 2)。
    • 表示値 _s.show(<loopVar>)。

    参考(非規範): 実装はループを、それが書かれたタイル定義の名前と、その定義の ループのうちソース順で何番目か(App_0、App_1、…)で名付ける。したがって 空行の追加やその定義の外の編集ではどの key も変わらない。各部分は <loop>|<occurrence>|<shown> と連結され、ループの評価ごとに 1 回呼ばれる _s.loopKeys(<list>, "<loop>") の i 番目の要素が反復 i に渡される。 ループ名と出現回数は | を含まず、出現回数は数字の並びなので、3 つの部分は 一意に読み戻せ、どの表示値も別の要素の key を綴れない。本体のすべての タイル呼び出しが {key: …} を持つループは暗黙 key を読まないため、実装は それを計算しない。

    したがってリストの要素は相異なる必要はない。[7, 3, 7] も、1 つの親の下で 値を共有する 2 つのループも、すべての子を別の key にする。None だけの リストや表示値が空の要素も同様に区別される。出現回数が key に含まれる前は、 前者は重複 key の panic、後者は _wk が拒否する空 key だった。唯一の例外は、 ソース上の 1 つのループが 1 つの親の子リストに 2 回以上寄与する場合である。 tile Items = for x in xs text(x.show) を column(Items, Items) と使うと 1 つのループが 2 回展開され、両方の展開が同じ key を持ち、親は 1. の重複 key の panic になる。それぞれの使用箇所に別のコンテナを与える (column(row(Items), row(Items)))。

    表示値が相異なる要素の並べ替えでは、どの要素もその表示値の最初の出現 なので key は変わらず、上述の再利用の保証はそのまま成り立つ。出現回数から 次の 2 つの留保が生じる。

    • 繰り返される値より前への挿入や削除は、その値の後の出現の番号を振り直す ため、等しい値の要素どうしが入れ替わることがあり、そのための移動が隣の 行を巻き込むこともある。
    • 要素の型について show が単射でないとき、暗黙 key は位置に退化する。 レコードはどれも同じ表示([object Object])になり、ヴァリアントはタグ だけを表示するので Done(1) と Done(2) は同じ表示値を持つ。このとき 出現回数は要素の位置そのものであり、並べ替え・挿入・削除は行を移動せず その場でパッチする。上述の保証(最小の移動、<input> のフォーカスと キャレット、開いた <select>、IME composition)はそのようなリストには 成り立たない。並べ替えるレコードのリストには明示 key {key: t.id} を書く。

    明示 key が常に優先。ネストした for は内側のループの key で上書きされ、 for i in inner 配下のタイルは外側の for o in outer の影響を受けず i で key 付けされる。 内側の for(外側の body、またはそこにある分岐の腕)は外側の 反復ごとにリストを描くので、そのノードは項目 3 のリストを描く呼び出しと 同じく、外側の key の下のリストとして key が付く。内側の key が同じでも、 外側の反復が違う兄弟は区別される。

  3. ユーザタイル境界 は外側の暗黙 key を body に持ち込まない。_wk は 境界ノードそのものに巻かれ、body 側の identity は body が反復すれば body 自身で組む。 body がリストを描く(body が for)とき、呼び出し側の key — 明示でも 暗黙でも — は 1 つのノードではなくリストを名指す。全ノードに同じ key を 付ければ 1 つの identity に潰れてしまう。そこで各ノードは、呼び出し側の key と自身の key — どれだけ深く入れ子でも、そのノード自身の for が付けた key — (無ければ平坦化したリスト内の位置)の組を JSON 配列 [callKey, nodeKey] として 符号化した key を持つ。ノードは互いに区別され、2 つの組が同じ文字列になる ことはない。外側のリストを並べ替えると各ノードの要素が移動し、リスト内の 並べ替えはノード自身の key で移動する。リストのノードは、それを生んだ for がどれだけ入れ子でも、呼び出しが置かれたコンテナの子になる。

  4. TileWhen / TileIf / TileMatch は透過。タイルを emit する分岐に 暗黙 key を素通しで伝える。

runtime の消費 packages/runtime/src/core.ts の reconciler が oldNode.key と newNode.key を子リストレベルで参照する。key は TILE_SKIP_TOP に含まれ、key の変化だけでは親の replaceWithFreshTile を 起こさない — key は「どの旧子がどの新子と対応するか」を決めるだけで、 タイル自体が再構築されるかどうかを決めるものではない。

Migration runtime とコンパイラは key コントラクトを matched pair として 出荷する。コントラクトの変更は、その changeset が指定するレベルで両パッケージを 同じリリースで bump する。@kumikijs/compiler は @kumikijs/runtime に依存し、 runtime のリリースはコンパイラ側の依存範囲を引き上げるので、レジストリから 入れたコンパイラはそれ以上に新しい runtime を伴う。旧コンパイラの出力は新 runtime で動く。新コンパイラの出力にはそれ以上に新しい runtime が要る。出力は _s.loopKeys を呼ぶが、それより古い runtime はこれを持たず、最初のレンダで _s.loopKeys is not a function で失敗する。<select> value / <input> focus と caret / event listener が insert/remove/reorder をまたいで保持されるという保証は 両方が揃って初めて成立する。

10.3.11 要素同一性を保った reconciliation (#190) ​

Keyed diff(§10.3.10)は「データ prop が変わらなかった」タイルの DOM 同一性を 保つ。#190 はさらに「データ prop が変わったが in-place で更新できる」タイル まで保証範囲を広げ、browser 固有の状態(<select> の open dropdown、<video> の再生位置、<details> の open、contenteditable のキャレット/IME composition) がインタラクション中の再レンダーを生き残るようにした。

コントラクト すべての tile-renderer モジュールは TileRenderers(create) と TilePatchers(update)の 2 つを export する:

ts
export type TilePatcher<K> = (
  el: HTMLElement,
  oldNode: TileNode & { kind: K },
  newNode: TileNode & { kind: K },
  ctx: TileCtx,
) => void;

reconcile が oldNode.kind === newNode.kind かつ自分自身のデータ prop に差分を 検出すると、その kind の patcher を引いて mount 中の要素を in-place で mutate し、その後 children を通常どおり walk する(container タイルは属性変更と子 変更が同じ render で同居し得るため)。patcher がない kind は #190 以前と同じ subtree rebuild にフォールバックするので、この機能は 段階的に採用可能で、 runtime の全書き換えは要求しない。

Handler-slot パターン 再利用される要素にリスナーを多重登録してはならず (runtime は addEventListener を使い、listener 参照を保持していない)、create 時に登録した listener はその時点の node クロージャに束縛される。bind / onChange / onClose / to が render 間で変わり得るコントロールでは、各 renderer が WeakMap<HTMLElement, Handlers> に現在の handler を格納し、create 時に張った native listener はその slot 越しに dispatch する。patch は slot を 新しい node の handler で上書きするだけ。適用対象: input, textarea, select, check, radio, switch, slider, editable, form, button, link, modal, drawer, popover。details は動的 handler を持たず、 toggle は browser native なので slot 不要(意図的除外)。全 tile に対して applyUiEventHandlers が張る onKeyDown / onMouseEnter / onFocus / onBlur の 4 種は共通の UI_HANDLER_STATE slot を経由し、reconcile が patch のたびに refresh するので、tile kind に関係なくクロージャ変更が次のイベントに 届く。native listener を張るのは 4 種のいずれかが slot に最初に入ったレンダ である — 最初から持つ tile なら create 時、後の分岐が足す場合はその patch 時。 一度も持たない tile には何も張らない。

value 書き込みのガード テキスト入力(input, textarea, editable)は .value === newNode.value(editable は textContent)のときは代入を skip (typing 中のキャレット reset を回避)。加えて compositionstart から compositionend の間は書き込み自体を skip する — JP/CN/KR IME で候補ウィンドウ 表示中に上書きすると変換候補が中断され失われるため。slider は activeElement === el のときは skip(ドラッグ中ガード)。reducer が明示的に フィールドをクリアするケースは、外側の snapshot 層(§10.3.9)が patch 実行前に selection range を捕捉するので、そこで復元される。

episode log binds-updated への影響 patch パスも subtree rebuild と同じ ように tileTouchedId(newNode) を touched セットに push するため、 「slot X が変わった → タイル/bind A, B が patched」という因果は rebuild/patch のどちらを通っても signal-update.binds-updated(#189)に載る。

載るのはそのレンダを生き延びたものだけ。 親を諦めて再構築したレンダは その親だけを名指しし、配下は 1 つも載せない。walk は子を 1 件も適用する前に 親の運命を決めるので(§10.3.10)、自分が記述する作業より長生きする識別子を 残す「中途半端な適用」がそもそも存在しない。

10.3.12 reconcile の診断 ​

keyed diff(§10.3.10)も in-place patch(§10.3.11)も、静かに劣化する。 subtree の identity を保てないと判断したら reconciler は再構築するか、より弱い マッチング戦略に降りる — 常に正しく、例外も投げない。つまりアプリが毎レンダで 全ツリーを再マウントしていても、外から見る限りは健全に見える。 MountOptions.onDiagnostic はその判断を観測する opt-in。

ts
mount(app, root, { onDiagnostic: (d) => console.warn(d) });

契約 sink を渡さないのが既定で、コストは fallback ごとの optional call 一段のみ。 何も計算せず何も出力しない。ビルド時フラグは存在しない — 本番マウントが無音なのは バンドラが削ったからではなく、opt-in していないからである。

観測は観測対象を変えない throw する sink は握り潰される。それを呼ぶ走査自体も 同様である — ホストタイルのフィールドを読む行為は Object.keys / プロパティ getter / Object.getPrototypeOf をホスト所有の値に対して走らせることであり、等値カーネルは 最初の差分で短絡するので、カーネルが到達しなかった箇所で Proxy トラップや アクセサが throw しうる。これらはすべて reconcile の bailout の内側で起きるため、 throw が漏れれば location: "reconcile" の panic として記録されツリー全体が 再構築される — このチャネルが報告するはずの identity 喪失を、報告自体が引き起こす ことになる。走査が throw したタイルは診断されないまま、sink が無かった場合と同じ ように描画される。sink 自身の失敗に気付くのはホストの責務である。

報告される fallback

全ての diagnostic は identity 保証を失ったタイルを特定する情報(tileKind、作者定義 タイル名があれば tile、および episode log と同じ id)を持つ。加えて reason ごとに、 その reason だけが知っている証拠を載せる — ソースを読み直さなくても対処できるように。

reason証拠失われたもの
no-patcher—タイルの data prop が変わったが、その kind に patcher が登録されていないため subtree ごと再構築した。その要素の focus / キャレット / <select> の open 状態 / <video> の再生位置が失われる。
child-count-changeoldCount, newCountキーの無い兄弟リストの長さが変わったため親を再構築した。全ての子に key を付ける(§10.3.10)とこの制限は外れ、keyed matcher が insert / remove / reorder を越えて無関係な兄弟に触れずに済む。片側が空になる変化では出ない — その境界は key の有無に関係なく親を保つ(§10.3.10)。
child-holeindexchildren 配列に空スロットがあった。Kumiki の codegen は nil を潰すので、これはホストが組んだタイルツリーからしか到達しない。兄弟を 1 件も適用する前に報告される(§10.3.10)ので、このレンダがしたことは親の再構築だけである。
child-unmappedindex, childKind旧 child がノード → 要素マップに存在しなかった。つまり親のレンダラが ctx.render を通さずに子を組んでいる。見つからないものは再利用できないので、その親は毎レンダ再構築される。child-hole と同じく、兄弟を 1 件も適用する前に報告される。
wrapped-childrenindex, childKind全ての子が key を持っていたが、親のレンダラが子を直下に置かず包んでいる(§10.3.10)ため、keyed matcher が辞退して構造 diff が走った。それ自体は何も再構築せず、失われるのは subtree ではなく「並べ替えを越えた要素同一性」である。
unplaceable-insertindex, childKind全ての子が key を持ち、マウント済みの子は全て親の直下に居たが、index の新しい子は新入りで、この親のレンダラは全ての子を直下に置かない(§10.3.10)。keyed matcher が新入りをマウントできるスロットが無い。短いリストは成長するその瞬間まで「配置可能」と実測されるので、これは DOM ではなくレンダラ側から読む。それ自体は何も再構築せず、続く構造 diff が長さの変化を見れば child-count-change を出す。

再構築パスのうち 2 つは意図的に報告しない。kind の変化はその位置に別のものが 来たという意味なので、保つべき identity が無い。patcher が PatchRequiresRebuild (§10.3.11)で in-place を辞退するのも正常で期待された結果であり、そのセンチネルは まさにログを汚さないために存在する。

再構築された親は自分の分しか報告しない。 親を再構築する 4 つの理由 — no-patcher / child-count-change / child-hole / child-unmapped — はいずれも 子を 1 件も reconcile する前に決まる(§10.3.10)ので、配下は検査されず、したがって そのレンダで配下から報告されるものは何も無い(reconcile-fallback も never-equal-prop も)。binds-updated(§10.3.11)と 同じ規則である — そのレンダが捨てたものは記述しない。(wrapped-children と unplaceable-insert は 何も再構築しないので、続く構造 diff は通常どおり報告する。)

代償は把握しておくこと。no-patcher は設定の事実であり — その kind に patcher が 登録されていない、そして次のレンダでも登録されていない — 毎レンダ再構築される親の 配下にある子は、その状態が続く限り見えないままになる。先に読むべきは親自身の理由で ある。再構築を直せば、その subtree の診断は届き始める。

ハンドラは identity で比較する かつて等値カーネルは任意の 2 つの関数を等値と して扱っていた。codegen が毎レンダ新しいクロージャを作っていたため、identity で 比較すると全タイルが永久に「変化した」と判定されてしまうからである。しかしそれが 安全なのは両方のクロージャが同じ reducer へ dispatch している間だけだった。ハンドラ だけが異なる 2 つのインラインタイルを条件分岐で入れ替えると等値と判定され、要素は そのまま再利用され、作成時の reducer へ dispatch し続けた — 無言で。

そこで codegen は reducer リストごとに 1 つのクロージャをアプリインスタンス単位で memo 化し(§10.3.13)、カーネルは関数を他の値と同じように比較する。変わっていない ハンドラは同一参照なので再利用パスをそのまま通り、変わったハンドラは差分になるので patcher が走って §10.3.11 の要素ごとのハンドラスロットを更新する。

毎レンダ inline でハンドラを作り続けるホストレンダラは、自分自身とも等値にならない。 それは無言で再利用されるのではなく、後述の function-identity として報告される。

決して等値になり得ない prop data prop が毎レンダ 必ず不等値になるタイルは、その kind に patcher が登録されていれば walker から見て identity を保った成功パスそのものになる — patcher が走り、要素は生き残り、何も degrade しない。したがって fallback は 1 件も報告されず、アプリは同じ属性を永久に 再適用し続けながら完全に健全に見える。不等値判断を読んで「2 つのレンダがどれだけ 同一でも等値になり得なかった値」を never-equal-prop として報告するのがこちらである。

causeぶつかっている規則
non-plain-objectDate / Map / Set / RegExp / DOM ノード / クラスインスタンス、および別 realm のオブジェクト。状態が自身の列挙可能キーの外にあるため、2 つを等値にできるのは === だけ(§10.3.13)— 毎レンダ作り直される値が満たすことはない。
nanNaN。定義上、自分自身と等しくない(§10.3.13)。
function-identityidentity が変わった関数。走査は履歴を持たないので、条件分岐が memo 済みハンドラ同士を 1 回入れ替えた場合も含め、異なるクロージャ 2 つに対して発火する。常に言えるのは「この 2 つは等値になり得なかった」ことだけで、繰り返すかどうかはホストが毎レンダ作り直しているかによる。直す価値があるのはその場合で、memo 化すれば止まる。

対象範囲は MountOptions.hostTileKinds で決まり、パッケージエントリの mount は tiles の上書きマップから導出する — built-in kind の上書きも含む。built-in の位置に ホストのレンダラが入れば、ホスト自身の prop 慣習も一緒に持ち込まれるからである。 独自レンダラで mountCore を直接呼ぶホストは自分で渡す。走査対象はノード自身の フィールドと props の 1 階層で、これは built-in レンダラが全て従っている慣習 (props.onClick, props.onChange)である。それより深くハンドラを埋めているホスト タイル(props.handlers.onClick)は検査されない。どの cause も codegen からは出ない ので、報告は常にホストが組んだツリーを指す。patcher の有無に関わらず 発火する — patcher があればタイルが churn していることを伝える唯一の信号であり、 無ければ再構築は既に no-patcher として報告されているので、こちらはその reason が 名指しできないフィールドを埋める。両方が出る場合の順序は「原因 → 結果」。

報告されるのは両側が同じ never-equal な形になってからである。plain な bag が Date になった、数値が NaN になった、というのはそのレンダにおいては通常の変化で あり、報告は次のレンダで出る。同一インスタンスを二度渡した場合は === で等値に なるので決して報告されない。

2 つの diagnostic kind が異なるのは severity ではなくコストである。 reconcile-fallback はパフォーマンスとブラウザ所有の要素状態を失い、 never-equal-prop は毎レンダ分の diff と patch を払う。どちらもアプリは正しいまま なので、kumiki dev は両方を console.warn に振り分ける。

episode log との関係 episode は「アプリが何をしたか」という作者向けの因果記録で、 subtree が再レンダされたこと自体は既に signal-update.binds-updated(§10.3.11)に 載っている。diagnostic が伝えるのは再利用ではなく再構築を選んだ理由 — フレームワーク 内部の情報であり、アプリやホスト統合のチューニングには有用だが挙動トレースの中では ノイズになる。互いに補完的なチャネルなので、新しい episode step kind にはしていない。

consumer smoke() は非致命の SmokeReport.diagnostics に集める。各要素は SmokeIssue と同じ phase / trigger を伴うので、どの操作が引き起こしたかを特定できる (必要以上に再構築していること自体は失敗ではない。SmokeOptions.diagnosticsAsIssues = kumiki smoke --diagnostics-as-issues で失敗扱いにできる)。runScenario は 再レンダを引き起こしたアクションのステップに紐付け、kumiki run はそのステップの下に 出力する。kumiki smoke は reason ごとの要約を出力する。

10.3.13 data prop の等値判定 ​

§10.3.10 も §10.3.11 も「タイルの data prop が変わっていない」を起点にしている。 それを決めるのがこの規則であり、ランタイム唯一の再利用述語である。false positive は 症状の出ないまま古い要素をマウントし続け、false negative は変わっていない subtree を 再構築する。

範囲 比較は TileNode 自身のフィールドのうち kind / children / key を 除いたものを走査する。kind は判別子であり、述語が走る前に決着している (§10.3.12)。children は walker 自身の担当で、子はそれぞれ独立に reconcile される ので、孫の変化が全ての祖先を再構築してはならない。key は keyed child matcher が 消費する identity メタデータ(§10.3.10)で、ペアが述語に届く時点で既に「同じ インスタンス」だと確定している。

値 2 つの値が等値なのは次のいずれかのとき:

  • 同一の値である(===)
  • 両方が同じ長さの配列で、要素同士が等値である
  • 両方が plain な data bag(prototype が Object.prototype または null)で、 own キーの和集合が等値な値に対応している

明記しておくべき帰結:

  • キーが無いことと明示的な undefined は等値である。 {a: 1} と {a: 1, b: undefined} は同じタイルである。codegen は省略可能なフィールドを 出力しないし、条件付き spread は同じ作者定義タイルからどちらの形も生む。

  • null は undefined ではなく、0 は false ではなく、"" は 0 ではない。 比較は常に === ベースであり、== は使わない。

  • NaN は NaN と等値ではない。 それを持つタイルは毎レンダ再構築される。 prop の中の NaN は既に計算が失敗しているという意味であり、再構築が安全側で、 churn は凍り付いたタイルより見える症状である — ホストタイルであれば §10.3.12 の never-equal-prop がフィールドを名指しする。patcher が登録されて いれば churn は他のどのチャネルからも見えないからである。

  • plain でないオブジェクトは自分自身以外の何とも等値にならない。 Date / Map / Set / RegExp / DOM ノード / クラスインスタンスは状態を自身の列挙可能 キーの外に持つので、キー単位の比較では変わった値を「変わっていない」と報告して しまう。Kumiki の codegen は plain なデータしか出力しないため .kumiki ソース からは到達不能であり、ホストレンダラが渡した場合は静かな再利用ではなく再構築に なる。同一インスタンスを二度渡した場合は === で等値のままである。「plain」の 判定は prototype 同一性で行われ、これは realm ローカルである — 別 realm (<iframe>、vm コンテキスト)で作られたオブジェクトは exotic として扱われ 再構築される。安全側であり、toString タグ検査に緩めない理由でもある。毎レンダ 同じタイルに渡し続けるホストは、決して勝てない diff の代償を払う。それを報告する のが never-equal-prop(§10.3.12)である。

  • 関数は自分自身とのみ等値である。 ハンドラもここでは他の値と同じ値である。 codegen は dispatch する reducer リストをキーにしたインスタンス単位の memo を通して 全ハンドラを出力するので、同じ配線なら毎レンダ同一参照になり、変わっていないタイル は再利用パスをそのまま通る。一方、ハンドラだけが異なる 2 つのインラインタイルを 条件分岐で入れ替えたときのように本当に変わった場合は、walker が扱うべき差分になる。 毎レンダ inline でハンドラを作るホストレンダラは自分自身とも等値にならず、毎レンダ patch(patcher が無ければ再構築)を払う。それを never-equal-prop(§10.3.12)が function-identity として報告する。

循環は契約の外である。構造的に循環していて互いに別物の bag 2 つは、スタックが尽きる まで再帰する。その throw は reconcile の bailout に落ち、ツリー全体を再構築して location: "reconcile" の panic を記録する。サポート外だが、封じ込められていて可視で ある — codegen は循環を作れないし、visited セットは 1 つの循環を防ぐために毎レンダ コストを払うことになる。


10.4 Effect Dispatcher ​

reducer から emit された effect を実行する責務。

10.4.1 受付 ​

reducer が完了すると、emit された effect 集合がディスパッチャに渡される:

[{name: "persist", args: {...}, key: <derived>, policy: "debounce:300"}, ...]

10.4.2 capability check ​

各 effect の cap が app.caps に含まれるか検査。違反は実行せず app.error に通知。

cap が空の effect は標準の表示系 effect であり、ゲートを通さない。

通知の中身は lifecycle.md §7.2.3 の PanicInfo であり、category は "capability"、location は拒否された effect を名指す。併せて、検証ティアが読むチャネルである console.error にも出力し、その emit を囲む episode が開いていればその episode に panic step としても記録する。throw は発生していないので stack も cause も持たない。

報告は live・SSR の両経路が同じ言葉で行う。異なるのは報告先である。renderToString には発火すべき app.error が無い — そのパスでは reducer の panic も panic step だけで終わり、拒否もその同じ規則に従わせる — ので、サーバ側ではコンソールと episode がすべてである。

step が付く episode は、いま開いている episode ではなく、その emit を所有する episode である。default policy では dispatcher は発火元の episode が開いたまま拒否するので、step はそこに落ちる。遅延 policy(§10.4.3)では launch はタイマーやキューの末尾から、その episode が閉じたずっと後に発火する。それでも step は effect-start を確保した episode に落ちなければならず、episode-id もそれを名指さなければならない — 「episode 無し」に落ちると、その episode には effect-start と effect-cancel だけが残り、置き換えられた debounce タイマーと見分けがつかなくなる。拒否が panic step と effect-cancel の両方を生む場合、panic step が先に来る。cancel は episode を確定させるので、読み手は結果より先に理由に出会う。

名指せる episode が無い唯一の emit は app.init からのものである。最初の episode が開く前に dispatch されるため、コンソールと app.error には episode-id: None を伴って報告される。

10.4.3 policy 処理 ​

policy実装
並列 (default)即時 dispatch
latest同名の走行中 effect を cancel、新規を開始
latest-per-key(k)(effect-name, key) 単位で同上
queueFIFO で逐次実行
debounce(d)同名の呼び出しを d ms 待って最後だけ実行
throttle(d)同名で d ms 以内の追加呼び出しを破棄
once同 in の呼び出しを破棄

10.4.4 retry ​

retry=... 指定がある場合、Err 結果かつ 5xx/network エラーで再試行。指数バックオフは jitter ±20% を加える。

10.4.5 結果の配送 ​

effect 完了時、結果を <effect-name>.ok($value, $key) / <effect-name>.err($error, $key) イベントとしてランタイムに通知。マッチする reducer が実行される。

10.4.6 標準 capability の実装 ​

capability実装
http.*fetch()
storage.*window.localStorage
session.*window.sessionStorage
indexed.*IndexedDB API
nav.*History API
clipboard.*Clipboard API
notification.show組み込み tile (toast/confirm/modal)
analytics.*hook (アプリ起動時に app.analytics で実装注入)
log.*console.* + 任意 hook
crypto.*Web Crypto API
media.*MediaDevices API
geo.*Geolocation API
socket.*WebSocket

10.5 Episode Loop ​

1 つのトリガから派生する因果列を 1 つの episode として記録する。

10.5.1 episode の構造 ​

json
{
  "id": "ep_01JC...",
  "trigger": {"kind": "ui.click", "target": "AddBtn", "payload": {...}, "ts": ...},
  "steps": [
    {"kind": "reducer", "name": "addTodo", "slot-diffs": [...], "emits": ["persist"],
     "env-reads": [{"kind": "now", "value": 1717900000000}], "ts": ...},
    {"kind": "effect-start", "name": "persist", "args": {...}, "ts": ...},
    {"kind": "effect-end", "name": "persist", "result": "ok", "value": "()", "ts": ...},
    {"kind": "signal-update", "dirty-slots": ["todos"], "binds-updated": ["TodoList.row.0", ...], "ts": ...}
  ],
  "status": "completed" | "panic" | "cancelled" | "ongoing"
}

reducer step は次も持つ:

  • env-reads: reducer 本体が実行中に環境から読んだ値を、読んだ順に並べたもの。各要素は {kind, value} で、kind は now / random / fresh-id / prefers-dark のいずれか — 答えがプログラムの外から来るビルトイン、すなわち slot の値からは決まらないものである。本体が何も読まなかった場合(大半の reducer がそうである)このフィールドは省略される。refinement がバッチを棄却した reducer(§10.3.3)でも読みは記録する:本体は走ったのであり、それを再実行する replay が同じ答えを見なければ、そもそも棄却しないかもしれない。

    記録されるスコープは reducer 本体 であり、それだけである。これらのビルトインは式が書ける場所ならどこでも呼べる(§10.10)が、tile の式の中の now や描画中の prefers-dark() のように reducer 本体の外で読まれた値は記録されず、再現もされない。描画を replay するものは無いので、それらの読みには再現すべき対象が無い。

    記録されるのは読みが返した値であって、その値を後で何を使って整形したかではない。now.format(...) と Time.parse("yyyy-MM-dd") はホストのローカルタイムゾーンで解決される(stdlib.md §2.4.2)。タイムゾーンは env-reads の kind ではないので、あるゾーンで記録した episode を別のゾーンで replay すると、記録された時刻そのものは再現されるが整形結果は変わる。以下の保証は 4 つの kind についてのものであり、そこから導かれるあらゆる値についてではない。

    env-reads があるからこそ replay は同じ実行になる。reducer が書いた結果だけを記録した episode は replay できない:replay は本体を再実行し、環境を読む本体はもう一度環境を読む — 新しいサイコロの目、後の時刻 — ので、replay された slot-diffs は記録されたものとは別の実行のものになる。env-reads は記録された実行の環境そのものであり、あとで返せるように保持される(§10.5.3)。

    このフィールドは前方互換のため省略可能である:古いランタイムが書いた episode ログは env-reads を持たず、それらは今も変わらず parse でき replay できなければならない。

panic step は次も持つ:

  • name: 本体が throw した reducer(throw が reducer 由来のとき)。location は散文であり、replay には鍵が要る。render / hydrate の panic では無い。
  • env-reads: その本体が throw する前に読んだ値。reducer step と同じ形である。throw した reducer は reducer step を残さないので、この 2 つが無ければ、ユーザがバグ報告に添える episode — つまりクラッシュしたもの — こそが replay で再現できないものになる:環境を読み直し、別の分岐を通り、最後まで完走してしまう。

遅延 policy effect の帰属。 policy=debounce(d) で emit された effect は、トリガとなった reducer の episode が一旦閉じた 後 に setTimeout が満了する。そのため dispatcher は effect-start step (とその episode トークン) を launch 時 ではなく dispatch 時 に確保し、満了後の effect-end および .ok / .err reducer 連鎖が元 episode 上に着地するようにする — 因果連鎖は一本に保たれる。debounce timer が発火前に置換された場合、元 episode に effect-cancel step (targetId = <effect-name>) を残し、その episode は effect-end なしで status="completed" として commit する。policy=throttle(d) は先頭呼び出しを同期 launch するため (通常の同期パスで effect-start が attach される)、window 内の後続 dispatch は黙って抑制される — 元 reducer の emits には抑制された effect 名が残るが、続く effect-start は出ない。

10.5.2 episode store ​

  • メモリに直近 N 件(デフォルト 100)
  • localStorage に直近 M 件(デフォルト 20、サイズ上限 5MB)
  • 開発時は --episode-log /path/to/log.jsonl でファイル書き出し

10.5.3 replay ​

bash
kumiki replay <episode-id>                  # signal graph を初期状態から再生
kumiki replay --from-log <file>             # ファイルから読み込んで再生
kumiki replay --mock 'loadUser: from-log'   # effect mock 指定
kumiki replay --until-step 5                # 途中まで
  • 入口の reducer は、記録時に受け取った payload で走る。 episode の trigger.payload はライブの runtime が最初の reducer に渡した payload そのもの — UI イベントなら {$el, $event}、effect の結果なら {$1, $2} — であり、replay はそれを加工せずに渡す。したがって $el.idx や $event.value はライブと同じ値を読む。payload を持たない trigger の最初の reducer が .ok / .err reducer である場合 — reducer ではなく SSR パスが開く ssr.hydrate の bootstrap episode — その reducer は、それより前に記録された同じ effect・同じ結果の effect-end のうち最後のものの上で走ったので、その値が reducer の $1 になる。その記録済みの結果は消費され、同じ effect の from-log mock はその次から続く。
    • ログにそのような step がない場合 — 切り詰められた、あるいは手で編集されたログ — reducer は $1 なしで走り、それは推測せずに報告される:episode のトレース行に (no recorded result for <reducer>) が付き、実行の最後に entry results missing: の要約が出るので、その後の panic が reducer のバグに見えることはない。
    • replay は episode ごとに入口の reducer を 1 つしか持たないので、SSR パスが複数の app.init effect を走らせた bootstrap は、最初の入口 reducer の連鎖しか replay されない。そのようなログに対する slots-equal: from-log の episode-test は panic ではなく slot の不一致で失敗し、2 つの init の連鎖が同じ effect を emit すると、入口が進めた from-log カーソルが再 emit にもう一方の連鎖の結果を渡しうる。この失敗は静かである:値は欠落ではなく交差する。
  • 環境の読みはログから答える。環境からではない。 reducer 本体を走らせる前に、replay はその reducer の記録済み env-reads(§10.5.1)を据える。以後 now / random() / <T>.fresh() / prefers-dark() は、時計・乱数源・ID 生成器・OS の設定を読み直すのではなく、記録時に返したのと同じ値を返す。したがって環境を読んだ reducer の episode を replay すると、記録された slot-diffs が毎回そのまま再現される。
    • 読みと記録済みの答えは kind で 対応付け、同じ kind の中では記録順に返す。あるビルトインの余分な読みが、別のビルトインの答えをずらすことはない。
    • 対応する答えが尽きた読み — 古いログ、あるいは記録時より多く読んだ本体 — は、replay を失敗させるのではなくライブの値にフォールバックする。その読みだけが replay に再現できないものなので、推測ではなく報告される:その step のトレース行に (env: N read live) が付き、実行の最後に environment reads: の要約が出る。同じ行が、replay 側の本体が使わなかった記録済みの答え(N recorded unused)と、ログが持っていた不正な要素(N malformed)も報告する。3 つとも無い step は何も言わない。
    • value が欠けている、あるいは kind が要求する型でない(now / random は数値、fresh-id は文字列、prefers-dark は真偽値)要素は、本体に渡す前にスコープを開く時点で棄却される。不正な要素をそのまま消費すると undefined が返り、now.show は "undefined" を描画し算術は NaN になるが、理由を告げる throw は起きない。棄却された要素は malformed に数え、その読みはライブにフォールバックする。
    • 記録された panic step は reducer step と同じように replay される:name が入口の reducer を指し、env-reads が同じ手順で据えられるので、記録されたクラッシュは再びクラッシュする。
    • 記録済み env-reads と replay 中の reducer は名前で対応付ける:replay はログの step を辿るのではなく reducer を再実行して連鎖を導くため、reducer foo の n 回目の実行が、記録された n 個目の foo step の読みを取る。これは順序の仮定であって整合の保証ではない — replay は reducer の emit を宣言順に辿るが、記録側は .ok / .err step を effect の完了順に並べるので、宣言順と違う順で完了した 2 つの effect から同じ reducer に 2 回到達すると、記録された 2 つの読みの組は入れ替わる。この失敗は静かである:値は欠落ではなく交差するので、live の数には現れない。

10.6 SSR / Edge / Client 分割 ​

10.6.1 SSR ​

  • HTML 生成は server-side で初期 route の tile を 1 回描画
    • 初期 route とは、要求されたパスが行き着く先である:静的リダイレクト(ルーティング §3.10)は、トップレベルのものも、マッチした親の sub-routes 内のものも、mount と同じ解決で先に処理され、その行き先が描画される。描画中の tile が読む route も、スナップショットの route も、ブートストラップエピソードの trigger.target(§10.5.1)も、行き先を指す。
    • 要求されたパスはクエリとハッシュを含んでよい(リクエストの URL をそのまま渡してよい)。クライアントのルーターがロケーションを読むのと同じ方法で分割され、pathname は書かれたとおりに照合される://foo や /a/../b は正規化されない。ブラウザの location.pathname がそれらを保つためであり、サーバーはクライアントと同じ場所に行き着かなければならない。
  • slot 初期値は app.init で emit した effect の結果を含めても良い(hydration 時に再実行しない)
  • 配信される HTML は、クライアントが塗るのと同じインラインスタイルを持つ:tile の要素と、その kind 自身のレイアウト(column の flex 軸、card のボックス寸法、grid のトラック)、および prop が対応付けるプロパティ(gap / align / justify / pad / max-w / bg / radius / style、テキスト tile では color / size / weight / strike)。これが無いと初期描画ではすべてのコンテナがブロックとして並び、hydration が終わった瞬間にページがリフローする — SSR が取り除くはずのレイアウトシフトそのものである。
    • レスポンシブ値({base, sm, md, …})は base に畳まれる:ブレークポイントはビューポートについての問いであり、サーバにビューポートは無い。
    • 配信されないのは、インライン宣言では運べないもの:transition と hover: / focus: / active: ブロック、motion 層は注入された CSS に紐づくクラスであり、hydration 時にクライアントが付ける。イベントハンドラ・フォーカス状態・解決済みの icon SVG も同様 — プレースホルダはレンダラが書くのと同じ要素・同じ属性だが、クライアントがパスを解決するまでは空でサイズも無いので、icon の到着は後続を動かす。テーマのスタイルシートが塗るものも同じ理由で配信されない:card の面・境界・影、button / input / link のリングはクライアントがマウント時に注入する規則であり、サーバはそれらを欠いた箱を配信する。
  • 配信される要素ツリーはクライアントが組み立てるものと同じであり、外側の要素だけが一致するのではない:レンダラが自前の要素を入れ子にする tile は、同じ入れ子で配信される。初期描画のレイアウトを決めるのは要素ツリーであり(平坦なツリーは入れ子のツリーと違う並び方をする)、クローラが読むのもそれである。
    • markdown はレンダラが解釈するのと同じ段落(空行ごとに 1 つ、white-space: pre-wrap)として配信される。生のソースを 1 つのテキストノードに入れるのではない。
    • overlay は 2 つめ以降の子を、レンダラが組むのと同じ絶対配置のレイヤーで包み、align で配置する。
    • 閉じた modal / drawer / popover は、空文字列ではなく、レンダラがマウントするのと同じ「存在するが隠れた」ホスト(コンテンツ箱を包む display: none)として配信される。サーフェスの開閉は両方の経路でスタイルの切り替えであり、閉じたサーフェスの中身はクローラから読める。(理由は hydration ではない:hydration はどちらの形を配信しても、配信された DOM を丸ごと置き換える — §10.6.2。)
    • check / switch / radio はコントロールを包む <label> として、error はメッセージが入る <span> として配信される。
  • 配信された属性が持ち、マウント済み要素が持たないものがフォーム状態である。ただしプロパティが属性へ反映されない場合に限る:value / checked / selected はクライアントで DOM プロパティとして設定されマークアップには現れないため、配信されたページがそれらを運ぶ手段は属性しかない。disabled / readonly / <details open> は反映される(プロパティ代入が属性を書く)ので、両方の経路で属性として現れ、一致しなければならない。
  • レスポンス bundle 構成:
    • HTML(初期 tile 描画結果)
    • JSON(初期 slot snapshot)
    • JS(signal graph + effect dispatcher)

10.6.2 Hydration ​

  • クライアント JS が起動
  • 初期 slot snapshot を読み込んで signal graph に反映
  • event handler を DOM に attach
  • app.start reducer を発火(注意:SSR 中は実行しない、hydration 後のみ)

10.6.3 Edge ​

Cloudflare Workers / Vercel Edge 等での SSR:

  • effect dispatcher の一部(http.*, storage.kv.*)を edge 側で実行
  • 残りはクライアントに deferred
  • bundle サイズ予算:runtime 30KB + app code(ターゲット)

10.7 開発サーバ ​

bash
kumiki dev                          # 開発サーバ起動
kumiki dev --port 5173
kumiki dev --episode-log ./eps.log
kumiki dev --strict-a11y

機能:

  • ホットリロード(コード変更時、slot は維持)
  • error overlay(panic 時に詳細表示)
  • episode timeline panel(最近の episode を視覚化)
  • inspector(slot 値、tile ツリー、依存グラフ)

10.8 ビルド ​

bash
kumiki build                        # 本番ビルド
kumiki build --target=spa           # SPA only
kumiki build --target=ssr           # Node.js SSR
kumiki build --target=edge          # Edge runtime
kumiki build --target=static        # 静的サイト
kumiki build --analyze              # bundle 分析
kumiki build --minify               # 生成されたアプリモジュールを minify
kumiki build --bundle               # アプリとランタイムを1ファイルに結合

--bundle は、生成モジュールとそれが import するランタイムモジュールを 1 つの minify 済み app.js に結合し、runtime/ を出力しない。--minify に含めず独立し たフラグにしてあるのは、2 つが逆方向を最適化するからである。モジュール構成は runtime/core.js にアプリの変更で変わらない URL を与えるので、再訪者は app.js だけを取り直せばよい。バンドルは初回訪問者にリクエスト 1 回と、ペイロード全体に対 する圧縮ストリーム 1 本を与える — gzip も brotli も辞書をレスポンスごとに構築する ので、小さなモジュール 20 個は同じバイト列を結合したものより明確に圧縮率が悪い — さらに、モジュール境界が隠していた範囲を越えて tree-shake できる。サンプルアプリ では圧縮後のペイロードが 19〜28% 減る。

既定にしていない理由は --minify と同じである — これは minify を含意し、読める app.js こそデバッグループ(§10.5)がスタックトレースを読み取る対象だからである。 取り除かれる runtime/ の構成はキャッシュ上の関心事にすぎず、他に読み手はいない。

--minify はオプトインであり、読める出力が既定であることには理由がある — 生成モ ジュールはスタックトレースが指す先そのものなので、求められてもいないのに minify す るビルドはデバッグループ(§10.5)から最も直接的な証拠を奪う。ビルドが併せてコピー するランタイムモジュールはどちらの場合も minify 済みである — ランタイム自身のビル ドがそう出力している。

出力構成:

dist/
├── index.html
├── assets/
│   ├── app-<hash>.js
│   ├── app-<hash>.css         ← reset + theme トークン展開のみ
│   └── icons-<hash>.svg
├── server/                    ← SSR/Edge 時のみ
│   └── entry.js
└── manifest.json

10.8.1 Vite プラグイン(@kumikijs/vite) ​

ビルド統合のエコシステム接点:既存の Vite プロジェクト(したがって Next / Astro / SvelteKit なども)に Kumiki を持ち込み、.kumiki ファイルを通常のモジュールと同じように import する。各ソースは、コンパイル済み AppShape を default export する ESM モジュールにコンパイルされる(codegen の exportApp 経由。自動 mount はしない — mount の責務は import 側にあり、mount または defineKumikiElement を使う)。

ts
// vite.config.ts
import { kumiki } from "@kumikijs/vite";
export default { plugins: [kumiki()] };
ts
import App from "./app.kumiki";
import { mount } from "@kumikijs/runtime";
mount(App, document.getElementById("root"));

モジュールは createApp() ファクトリも export する(import App, { createApp } from "./app.kumiki")。独立したインスタンスを複数立ち上げるためのもので、createApp() はそれぞれ自前の状態を持つ AppShape を返す。

  • ランタイムは共有され、複製されない — コンパイル済みモジュールは import "@kumikijs/runtime" をそのまま保ち、バンドラが 1 つだけ載せる。上の例がまさにそれに依存している(mount は同じパッケージから来る)。bundle: true は代わりにランタイムをモジュールへインライン展開する — 単体で完結させたいモジュール向け。そうすると、他にランタイムを import するものがあれば 2 つ目のコピーが載り(counter で 82 kB に対し 129 kB、.kumiki を 1 つ import するごとにさらに 1 コピー)、コピー同士はランタイムのモジュールレベル状態を共有しない。プラグインは、プロジェクト側で解決できるならそちらを、できなければ自身の依存を解決するので、@kumikijs/vite だけを入れたプロジェクトでもビルドは通り、いずれの場合もコピーは 1 つ。

  • オプション — bundle(デフォルト false、上記)。types(デフォルト false:型付き provider 記述のために KumikiSlots / KumikiProviders ヘルパを持つ兄弟ファイル <name>.kumiki.gen.ts を出力する。内容が変わったときだけ書き込む — プログラム自身の Slots / Providers 型と衝突しないよう接頭辞を付けている)。

  • capability — kumiki.caps.json は CLI と同じ規則で自動解決される:ソースファイルのディレクトリから、最も近い package.json を持つディレクトリまで遡る。Vite の root では区切らない — kumiki dev は静的な import App を解決するために .kumiki ファイル自身のディレクトリを root にするため、kumiki check が読むマニフェストと dev サーバが読むマニフェストが食い違ってしまう。カスタム capability の登録 を参照。

  • 失敗は位置を持つ — 型エラーもパースエラーも字句エラーも、ファイル・行・列を持つ診断として Vite のオーバレイに届くので、該当行へ飛べる。

  • import に型を付ける — 同梱の ambient 型を 1 度参照すれば import App from "./x.kumiki" は AppShape として型付けされる:

    ts
    /// <reference types="@kumikijs/vite/client" />

検証:packages/vite/test/plugin.test.ts(コンパイル)、runtime-dedupe.test.ts(実際の vite build でランタイムが 1 つ)、diagnostics.test.ts(位置付き失敗・マニフェスト探索)。型ヘルパ生成器(generateDts)は packages/compiler/test/dts.test.ts と dts-compiles.test.ts。


10.9 ランタイム API(埋め込み用) ​

ホストアプリから Kumiki アプリを埋め込む場合:

javascript
import { mount } from "kumiki/runtime"

const app = mount({
  target: document.getElementById("app"),
  bundle: "/assets/app.js",
  initialSlots: { /* ... */ },
  effectHandlers: {
    "analytics.send": (event, props) => myAnalytics.track(event, props)
  }
})

app.dispatch({ kind: "ui.click", target: "AddBtn", payload: {} })
app.slots.todos                       // read-only
app.episodes                          // 最近の episode
app.unmount()

1 つの AppShape を複数の場所にマウントする ​

コンパイル済みモジュールのデフォルトエクスポート(AppShape)はアプリ 1 つであり、いくつのホストにマウントしても 1 つである(mount を 2 回呼んでも、defineKumikiElement に factory ではなく AppShape を渡して要素を複数置いても同じ)。各ホストはビューであり、同じ slot を映し、片方のクリックは全部を再描画し、どの要素への setSlot も唯一の状態への書き込みになる。

アプリが一度だけ持つもの — app.init、app.start、タイマー、ルータ、effect dispatcher — は最初のマウントに属する。2 つ目のビューを足しても初期化は再実行されず、タイマーが二重に刻むこともない。差分照合の状態はビューごとに持つので、DOM の同一性保持はホスト単位で働く。

破棄もビュー単位である。1 つのビューを dispose するとそのホストは空になり登録も外れるが、他のビューは動き続ける。アプリ自体が畳まれるのは最後のビューが消えたときで、その後に同じ shape を再度マウントすれば app.init / app.start / タイマー / ルータはやり直しになる。ただし状態はリセットされない:app.live は shape のものであり、アプリが書き込んだ slot の値は次のマウントに持ち越される。宣言時の既定値から始まる shape が欲しいなら createApp() を使う。ビューの追加に hydrate は使えない — サーバの snapshot はまっさらな状態に重ねるものであり、このアプリの状態は既に生きているからである。

独立したインスタンスが欲しい場合は、モジュールの createApp factory を使う(createApp() ごとに固有の状態を持つ AppShape が返る)。

reducer 本体を自前で走らせるホスト — 独自のテストハーネスや自前の replayer — は、ランタイムが使うのと同じジャーナルでその環境読みを囲める(§10.5.1):

ts
import { withEnvRecord, withEnvReplay } from "@kumikijs/runtime";

const run = withEnvRecord(() => reducer.apply(live, payload));
run.env.reads;                                  // ログに書く `env-reads`
const again = withEnvReplay(run.env.reads, () => reducer.apply(live, payload));
again.env.live;                                 // その一覧では答えられなかった読み

どちらも本体をコールバックで受け取る。スコープは開いている間プロセス全体に効くため、閉じ忘れたフレームはそれ以降のあらゆる読みを捕まえてしまう。釣り合いを保つ唯一の開き方が「本体を渡すこと」である。生の beginEnvRecord / beginEnvReplay / endEnvScope は、コールバックでは跨げない境界を挟む必要があるホストのために公開してあり、その場合の釣り合いは呼び出し側の責任になる。


10.10 標準ライブラリの実装責務 ​

標準ライブラリ で列挙したビルトインは、ランタイム実装が次の挙動を保証する:

機能保証
Map, Set, List純粋(in-place mutation なし)
Option, Resultパターンマッチ網羅検査
now, random()式が書ける場所ならどこでも呼べる。reducer 本体の中で読んだ値は、その episode の step に env-reads として記録される(§10.5.1)ため、replay は新しい値を引かずに同じ値を再現する。それ以外の場所(tile の式、描画)での読みは記録されず、それらを replay するものも無い
*.fresh()UUIDv7 を生成。now / random() と同じ reducer 本体スコープで env-reads として記録されるため、replay した episode は実行が実際に刻んだ ID を刻む
panic(message)episode を panic 状態にして slot をロールバック

10.11 パフォーマンス予算 ​

項目予算
ランタイム本体~30KB gzip
1 reducer 実行時間< 1ms (typical)
signal graph 更新< 16ms (60fps)
effect dispatch overhead< 0.1ms
episode log 書き込み< 0.5ms (memory)

これらを満たすため、ランタイムは Rust → WASM(オプション)または手書き JS(デフォルト)。


10.12 設計上の判断記録 ​

判断理由
signal graph は静的実行時依存追跡を排除、性能と予測可能性
バッチ更新連続変更で 60fps を超えないよう
effect は dispatcher 経由capability ガードとログを構造で担保
episode = trigger 単位デバッグ・テスト・audit を一つの単位に統合
SSR と CSR は同じ IR を食うターゲット差は dispatcher の実装差のみ
ランタイム 30KB 目標モバイル / Edge での実用性

10.13 次 ​