ランタイム実装ガイド
ランタイム実装者向けに、コンパイルパイプラインと実行モデルを定義する。
10.1 コンパイルパイプライン
8 つのフェーズがあり、それぞれが自分の担当するエラーを次へ渡さずに弾く:
| フェーズ | 生成物 | 弾くもの |
|---|---|---|
| project (selector) | kumiki source — CRDT graph store のテキスト断面 | — |
| parse | AST | — |
| 名前解決 | resolved AST | undef-ref、dangling 参照 |
| 型検査 | typed AST | type-mismatch、refinement 違反 |
| effect 解析 | effect-annotated AST | cap-missing、直接呼び出し |
| 純粋性検査 | verified AST | reducer-side-effect、tile-mutation |
| lower | IR (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-read | slot からの読み取り |
slot-write | slot への書き込み(reducer のみ) |
field-access, index | record/collection 要素アクセス |
op, call | 演算・関数呼び出し(fn 定義済み関数も含む) |
fn-body | fn レイヤの本体(純粋計算、引数のみ依存) |
match | union 分岐 |
if, when, for | 制御 |
emit | effect 放出 |
event-source | event の入口 |
dom-node | DOM 出力ノード |
dom-bind | DOM ノードへの slot 紐付け |
エッジは依存関係(dataflow)。
10.2.1 IR シリアライズ形式
JSON でデバッグ可能、本番は CBOR(バイナリ):
{
"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 操作 |
EventNode | DOM event | reducer 呼び出し |
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 ループは合法な値で終わり、途中で通過した非合法な値 — 下記のとおり後続のすべての文から読める — は一度も検査されない:
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 の後続文は構築途中のバッチを読むためだ:
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 の責任ではない。ガードは自分で書く:
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 でモデル化すること:
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+colsprop で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 の有無にかかわらず同じように振る舞う
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 配線):
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)の 下に置くこと。- マウント済みの子がどこに居るか は DOM から実測する。レンダラ所有の ラッパーの下にマウントされた子があれば pass は届かないので、reconciler は 辞退して
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
作者が書いた
{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 をたどる。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 が同じでも、 外側の反復が違う兄弟は区別される。ユーザタイル境界 は外側の暗黙 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がどれだけ入れ子でも、呼び出しが置かれたコンテナの子になる。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 する:
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。
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-change | oldCount, newCount | キーの無い兄弟リストの長さが変わったため親を再構築した。全ての子に key を付ける(§10.3.10)とこの制限は外れ、keyed matcher が insert / remove / reorder を越えて無関係な兄弟に触れずに済む。片側が空になる変化では出ない — その境界は key の有無に関係なく親を保つ(§10.3.10)。 |
child-hole | index | children 配列に空スロットがあった。Kumiki の codegen は nil を潰すので、これはホストが組んだタイルツリーからしか到達しない。兄弟を 1 件も適用する前に報告される(§10.3.10)ので、このレンダがしたことは親の再構築だけである。 |
child-unmapped | index, childKind | 旧 child がノード → 要素マップに存在しなかった。つまり親のレンダラが ctx.render を通さずに子を組んでいる。見つからないものは再利用できないので、その親は毎レンダ再構築される。child-hole と同じく、兄弟を 1 件も適用する前に報告される。 |
wrapped-children | index, childKind | 全ての子が key を持っていたが、親のレンダラが子を直下に置かず包んでいる(§10.3.10)ため、keyed matcher が辞退して構造 diff が走った。それ自体は何も再構築せず、失われるのは subtree ではなく「並べ替えを越えた要素同一性」である。 |
unplaceable-insert | index, 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-object | Date / Map / Set / RegExp / DOM ノード / クラスインスタンス、および別 realm のオブジェクト。状態が自身の列挙可能キーの外にあるため、2 つを等値にできるのは === だけ(§10.3.13)— 毎レンダ作り直される値が満たすことはない。 |
nan | NaN。定義上、自分自身と等しくない(§10.3.13)。 |
function-identity | identity が変わった関数。走査は履歴を持たないので、条件分岐が 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) 単位で同上 |
queue | FIFO で逐次実行 |
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 の構造
{
"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 する前に読んだ値。reducerstep と同じ形である。throw した reducer はreducerstep を残さないので、この 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
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/.errreducer である場合 — reducer ではなく SSR パスが開くssr.hydrateの bootstrap episode — その reducer は、それより前に記録された同じ effect・同じ結果のeffect-endのうち最後のものの上で走ったので、その値が reducer の$1になる。その記録済みの結果は消費され、同じ effect のfrom-logmock はその次から続く。- ログにそのような step がない場合 — 切り詰められた、あるいは手で編集されたログ — reducer は
$1なしで走り、それは推測せずに報告される:episode のトレース行に(no recorded result for <reducer>)が付き、実行の最後にentry results missing:の要約が出るので、その後の panic が reducer のバグに見えることはない。 - replay は episode ごとに入口の reducer を 1 つしか持たないので、SSR パスが複数の
app.initeffect を走らせた bootstrap は、最初の入口 reducer の連鎖しか replay されない。そのようなログに対するslots-equal: from-logのepisode-testは panic ではなく slot の不一致で失敗し、2 つのinitの連鎖が同じ effect を emit すると、入口が進めたfrom-logカーソルが再 emit にもう一方の連鎖の結果を渡しうる。この失敗は静かである:値は欠落ではなく交差する。
- ログにそのような step がない場合 — 切り詰められた、あるいは手で編集されたログ — reducer は
- 環境の読みはログから答える。環境からではない。 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に数え、その読みはライブにフォールバックする。- 記録された
panicstep はreducerstep と同じように replay される:nameが入口の reducer を指し、env-readsが同じ手順で据えられるので、記録されたクラッシュは再びクラッシュする。 - 記録済み
env-readsと replay 中の reducer は名前で対応付ける:replay はログの step を辿るのではなく reducer を再実行して連鎖を導くため、reducerfooの n 回目の実行が、記録された n 個目のfoostep の読みを取る。これは順序の仮定であって整合の保証ではない — replay は reducer の emit を宣言順に辿るが、記録側は.ok/.errstep を 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がそれらを保つためであり、サーバーはクライアントと同じ場所に行き着かなければならない。
- 初期 route とは、要求されたパスが行き着く先である:静的リダイレクト(ルーティング §3.10)は、トップレベルのものも、マッチした親の
- 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 時にクライアントが付ける。イベントハンドラ・フォーカス状態・解決済みのiconSVG も同様 — プレースホルダはレンダラが書くのと同じ要素・同じ属性だが、クライアントがパスを解決するまでは空でサイズも無いので、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.startreducer を発火(注意: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 開発サーバ
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 ビルド
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.json10.8.1 Vite プラグイン(@kumikijs/vite)
ビルド統合のエコシステム接点:既存の Vite プロジェクト(したがって Next / Astro / SvelteKit なども)に Kumiki を持ち込み、.kumiki ファイルを通常のモジュールと同じように import する。各ソースは、コンパイル済み AppShape を default export する ESM モジュールにコンパイルされる(codegen の exportApp 経由。自動 mount はしない — mount の責務は import 側にあり、mount または defineKumikiElement を使う)。
// vite.config.ts
import { kumiki } from "@kumikijs/vite";
export default { plugins: [kumiki()] };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 アプリを埋め込む場合:
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):
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 次
- 完全例 → examples/