全体像
view!構文をTautoの式へ展開Tree<K, P>番号付きノードと PatchK と属性 P は描画先が選ぶ。bikkuri が反応性と描画先に依存しない木を持つ。bikkuri-html はHTML用の語彙、マクロ、HTMLライター、ブラウザのDOMホストを提供する。bikkuri-web がこれらをルート、静的生成、サーバー配信、RPCにつなぐ。
bikkuri-html/src/html-view.taut · 510–521pub macro fn view(v: view::View): SynExpr ! {macro_error, Div, overflow} { let d := HtmlView::html() match v.tree_input { Some(input) => View::child(d, input, v.child) // The tree is taken from `ui` once, so the closures the view makes (of `@if`, `@for`, // events) hold the tree and do not perform `ui` themselves. None => { let build := HtmlView::from_ui(d, v.child, v.span) quote { view_in($build) } } }}HTML側のマクロは共通の view::View を受け取る。入力に木が明示されていればその木を渡し、省略時は view_in を使う式を生成する。
Signalと所有
get(signal)実行中のwatcherに読み取りを登録set(signal, value)値を更新し、読んだwatcherをキューへflushキューを処理して購読を更新peek は購読せずに読む。set_eq は値が等しければ更新を省く。bikkuri/src/signal.taut · 374–389// Writes the signal and runs again, once each, the watchers that read it.pub fn set<T: Shared>(s: bikkuri::src::signal::Signal(T), v: T): Unit ! {rt, Div} { let r := perform access_runtime() Ref::set(s.cell, v) queue(r, live_readers(r, s.id)) if Ref::get(r.flushing) { #() } else { Ref::set(r.flushing, true) flush(r) end_flush(r) }}// Writes only when the value changes, without subscribing the current watcher.pub fn set_eq<T: Shared>(s: bikkuri::src::signal::Signal(T), v: T): Unit ! {rt, Div} where DecEq(T) { if peek(s) == v { #() } else { set(s, v) }}reactive が実行単位ごとのRuntimeを作り、scope がその中にownerを作る。dispose は子を先に終了し、cleanupを実行して購読を解除する。条件分岐やキー付きの行の寿命もこの仕組みで管理する。
要素の木と差分
text_dyn は値をメモ化し、初回は CreateText を出す。最初のwatcher実行は購読だけを行い、その後の変更で SetText を出す。レンダラーはその番号のノードを更新する。
bikkuri/src/tree.taut · 342–354// Text that follows the signals `f` reads.pub fn text_dyn<K: Shared, P: Shared>(t: Tree(K, P), f: () -> Text ! {rt, Div}): Node ! {rt, Div, overflow} { let n := node_of(t) let v := memo_eq(() => f()) emit(t, CreateText(n, peek(v))) // The first run only subscribes: the text was created with the value. let first := Ref::new(true) watch(() => { let s := get(v) if Ref::get(first) { Ref::set(first, false) } else { emit(t, SetText(n, s)) } }) n}キー付きの並べ替え
keyed_each は既存の行をキーで再利用し、消えた行を終了する。残る行の中で順序を維持できる部分を求め、必要な移動だけを発行する。行をwatcherとは別のownerに置くので、一覧の再計算をまたいで行の状態を保てる。
bikkuri/src/tree.taut · 678–707): Node ! {rt, Div, overflow} { let g := node_of(t) emit(t, CreateGroup(g)) let rows := Ref::new(Nil) // The rows belong to the owner `keyed_each` was made in, not to the watcher, so a change of // `items` keeps the rows it keeps. let home := scope(() => #()) watch(() => { let xs := items() let old := Ref::get(rows) var keys: HashMap(u64, u64) := HashMap::new() keys_of(&mut keys, xs, key) end_missing(t, old, &keys) let fresh := reverse_rows(in_owner(home.owner, () => { var by_key: HashMap(u64, KeyedRow) := HashMap::new() rows_by_key(&mut by_key, old) var seen: HashMap(u64, u64) := HashMap::new() rows_for(&by_key, xs, key, row, t.duplicate_keys, &mut seen, Nil) }), Nil) Ref::set(rows, fresh) match old { Nil => append_rows(t, g, fresh) Cons(o, more) => { let keep := staying(old, fresh) place_rows(t, g, reverse_indexed(fresh, 0, Nil), &keep, None) } } }) on_cleanup(() => dispose(home.owner)) gHTMLとDOM
Patch同じ差分の語彙bikkuri-html/src/write.taut · 269–275// Whatever the tree holds, `html_safe` accepts what is written.theorem write_safe(t: HTree): html_safe(write(t)) = true { Eq::congr((s: Scan) => Scan::done(s), write_keeps_text(t))}// The tree as trusted HTML.fn written(t: HTree): TrustedHtml { TrustedHtml::Mk(write(t), write_safe(t)) }write_safe の結論はライターの出力を html_safe が受理すること。HTTPヘッダーやDOMホストの振る舞いは別の検証対象になる。
bikkuri-html/dom/host/dom.mjs · 188–190export const setText = (d, n, s) => { node(d, n).data = s;};DOMホストでは SetText に対応してTextノードの data を置き換える。アンマウント時の release はリスナーとタイマーを外し、進行中のロードを中断する。
ResourceとSuspense
resource は Loading のSignalとリクエストを作る。レンダラーが take_wants から取り出し、結果を resolve または reject で返す。Suspense境界は配下の待機数を持ち、最初はfallbackを表示し、待機数がゼロになってから本文を差し込む。
bikkuri/src/resource.taut · 58–74// while another of its resources still loads.fn settle(w: Want, l: Load): Unit ! {rt, Div} { if Ref::get(w.settled) { #() } else { Ref::set(w.settled, true) batch(() => { set(w.state, l) match w.boundary { None => #() Some(c) => update(c, (n: u64) => handle { n - 1 } with { overflow() => 0 }) } }) }}pub fn resolve(w: Want, v: Text): Unit ! {rt, Div} { settle(w, Loaded(v)) }pub fn reject(w: Want, why: Text): Unit ! {rt, Div} { settle(w, LoadFailed(why)) }同じリクエストの完了は一度だけ処理される。reload は前のリクエストを先に完了扱いにするため、遅い古い応答で新しい値が上書きされるのを防ぐ。
ルーティングとHTTP
Router(R)パスを型付きのルートへPageタイトル、head、viewbikkuri-web/src/app.taut · 42–47pub fn page_at<R: Shared>(router: Router(R), target: Text): Option(Page) ! {overflow, Div} { match route_of(router, target) { None => None Some(r) => Some((router.page)(r)) }}ResourceのGETとサーバー関数のPOST
ブラウザ側のloaderは一時的な通信切断や一部のプロキシ応答でGETを再試行する。POSTはサーバー側で変更が完了してから応答だけ失われる場合があるため自動再試行しない。RPCのスキーマハッシュでフレームの互換性を見て、業務ルールや認可はアプリ側で扱う。
bikkuri-web/host/loader.mjs · 40–51 let request; try { request = requestOf(key); } catch { return failure('decode'); } if (request.failed) return request.failed; let delay = retryDelayMs; for (let attempt = 0; ; attempt++) { const { result, retry } = await once(request, signal); if (request.rpc || !retry || attempt >= retries || signal?.aborted) return result; await pause(delay, signal); if (signal?.aborted) return failure('cancelled'); delay *= 2; } };本番運用の判定
コアとHTMLの型検査は通った。現行環境でコアのテストが検査段階で失敗し、Webの型検査は依存解決で止まった。対象211ファイルの全行レビューは未完了。
| 確認項目 | 今回の結果 |
|---|---|
| コアとHTML | tauto check は両方成功。コアの tauto test は既存 show 宣言の診断でファイル9件が失敗。 |
| Webの型検査 | http が参照するローカルの url/Tauto.eure が見つからず、型検査まで到達しなかった。 |
| JavaScriptの独立テスト | loader 11件とTodoのWALアダプター4件が成功。共有Todoのサーバーテストは生成済みモジュールがなく起動できなかった。 |
| Rustと実ブラウザ | 今回は未検証。既存READMEは serve_answers・serve_with_state の動作実績と、通常の serve のRust生成コードがコンパイルを通らなかった記録を分けている。 |
再確認が必要な運用上の論点
- 長寿命のRuntimeについて、画面の開閉や一覧の差し替えを繰り返した後のメモリを確認する。
dispose_ownerはwatcherの関数を外すが、watcherのキーはNoneとして表に残す。購読の組もdrop_readerで状態を書き換える実装なので、保有件数の推移を計測する。signal.taut serve_streamedの接続上限は512。リソース処理は別タスクに分かれ、期限を超えた処理は待機を終了してもタスク側で続く。接続数とバックグラウンド処理数をそれぞれ計測する必要がある。stream.taut- Todo例のWALはappendごとに全ファイルを読み直してから書く。ログが伸びる環境では書き込み遅延と再起動時のリプレイ時間を測る必要がある。ledger-disk.mjs
verify.mjs --expect fixedは1/13一致。旧showを使うケースが宣言エラーとなり、waitの公開関数はビルドできたがテストは42と比較して失敗した。Node向けの直接呼び出しではbigint 42nを返した。verify.mjs
Tautoの診断と最小再現
コアのテストから check.taut だけを抜き出した、依存のないtoteでも再現した。旧テストの show 宣言を現行の言語仕様で受理するべきかは未確認。既存のマクロ名前解決の報告と同じ原因かどうかも未確認。
Tauto.eure の全文
package {
name = "tests"
version = "0.1.0"
}
src/check.taut の全文
pub fn check(name: Text, ok: Bool): Unit ! {∀ A. show(A)} {
if ok { perform show(name, "ok") } else { perform show(name, "FAILED") }
}
上記のtoteルートで tauto check . を実行する。期待は旧テストが前提としていた宣言の検査成功。今回の終了コードは1で、診断全文は以下。
error: `∀ A. show(A)`: every instance of `show` binds each type argument in order: `∀ . show()`
--> src/check.taut:1:1
|
1 | pub fn check(name: Text, ok: Bool): Unit ! {∀ A. show(A)} {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2 | if ok { perform show(name, "ok") } else { perform show(name, "FAILED") }
3 | }
| ^ in `macro`
実行ファイルは /Users/ryo/.cargo/bin/tauto。バージョン表示は tauto 0.1.0。SHA-256は ec2ebd921ab3f0b113dbff96a0d223b961f954cbc3acb4472b5826498ef0dd58。
Tauto 0.1.0 · Node v25.2.1 · ソース 25d3af9f019f。引用の行番号はこのソース時点。