ライフサイクル・エラー境界・サスペンス
ランタイムが reducer に制御を渡す瞬間——起動・画面遷移・タイマー——と、失敗したときにアプリが何をするか。反応できるイベントの一覧が §7.1、§7.3 以降が失敗側(tile 単位の境界・loading 表示・404 ページ)である。
7.1 ライフサイクルイベント一覧
| イベント | タイミング |
|---|---|
app.start | アプリ起動直後(初期 slot 値が確定し、ランタイムがマウントされた時点) |
app.stop | アプリ終了直前(ブラウザクローズ・タブ閉じる前) |
app.error | 未捕捉エラー発生時 |
app.http-401 | HTTP 401 を受信したとき(app.http.on-401 経由で来る) |
app.http-403 | 同 403 |
app.http-5xx | 同 5xx |
app.visible | タブが表示状態になったとき |
app.hidden | タブが非表示状態になったとき |
app.online | ネットワーク復旧 |
app.offline | ネットワーク切断 |
route.enter(pattern) | ルート進入直後 |
route.leave(pattern) | ルート離脱直前 |
route.error(pattern) | 当該ルートの tile 描画中にエラー |
tile.mount(name) | 特定 tile の初回マウント |
tile.unmount(name) | 特定 tile のアンマウント |
timer(duration) | 指定間隔で繰り返し |
7.1.1 app.start
アプリ起動時に 1 回だけ発火。app.init = [...] で宣言した effect 列が emit された後に届く。
reducer boot
on=app.start
do= emit loadSession()
emit loadTodos()
emit identify(currentUser())app.start reducer の中で emit した effect は synchronous に dispatcher へ渡される(reducer の戻り値として)。dispatcher は capability を check し、policy に従って実行する。
7.1.2 app.stop
ブラウザが beforeunload を発火したタイミング。短時間で完了する処理のみ実行可能(ブラウザ仕様)。
reducer cleanup
on=app.stop
do= emit persist(todos) # 同期 storage.write のみ実用的7.1.3 app.visible / app.hidden
visibilitychange イベントに対応。タブ切り替えで状態をポーズしたい場合:
reducer pause on=app.hidden do= timerPaused := true
reducer resume on=app.visible do= timerPaused := false
emit syncFromServer()7.1.4 app.online / app.offline
reducer onlineSync on=app.online do= emit retryQueued()
reducer showOffline on=app.offline do= emit toast({kind: "warn", text: "Offline"})7.1.5 timer
reducer poll
on=timer(5s)
do= emit fetchUpdates()timer(d) は app の mount 時から d 間隔で繰り返し発火する。ランタイム実装は setInterval ベースで、app の dispose 時に自動 clear される。
duration リテラル: 1ms, 500ms, 1s, 30s, 5m のように整数 + 単位 (ms / s / m) で書ける。
名前付きタイマーと stop-timer
タイマーに名前を付けると、reducer から明示的に停止できる:
slot remaining : Int = 10
reducer tick on=timer(1s, name=countdown) do= remaining := remaining - 1
reducer stop on=ui.click(StopBtn) do= stop-timer(countdown)timer(d, name=N)は interval を識別子Nの下に登録する。タイマー名は単一のネームスペースを共有し、アプリ内で一意でなければならない(重複は E0002)。stop-timer(N)は reducer の文で、Nという名前の interval を clear する。実行後、そのタイマーは発火しなくなる。未宣言のタイマー名を参照するとコンパイルエラー(E0106)。stop-timerは純粋な制御文である — slot の読み書きも effect の emit もしないので、reducer は純粋なまま。runtime は reducer の結果を適用するときに interval を clear する。- 停止したタイマーは自動では再開しない。再開は再マウント時のみ。
appの dispose 時に、全タイマー(稼働中・停止中問わず)が clear される。
reducer tick on=timer(1s) do= elapsed := elapsed + 1
reducer poll on=timer(30s) do= emit fetchUpdates()
reducer fast on=timer(100ms) do= emit syncCursor()7.1.6 tile.mount / tile.unmount
特定の tile が DOM に現れた / 消えたタイミング。
reducer trackPageView
on=tile.mount(SettingsPage)
do= emit track({event: "settings_view", props: {}})複数の tile を一度に対象にしたい場合は同名の reducer を複数定義する(定義順で実行)。
7.2 エラー処理
Kumiki では try/catch を許可しない。エラーは次の経路で扱う:
7.2.1 期待されるエラー
Result(T, E) 型で表現する。effect の戻り値が Result.Err の場合は effect-name.err($e, $k) reducer に届く。
7.2.2 想定外のエラー(panic)
- reducer 内での
Option.getで None を取った - インデックス
xs[i](読み取りでも書き込みでも)がListのどの要素も指さない(末尾より先、または負) - 読み取り
m[k]がMapの持たないキーを指した Result.getで Errpanic(msg)の明示呼び出し
これらは panic と呼ばれる例外。panic は episode log に記録され、現在の reducer は中断される。slot の変更は トランザクション的にロールバックされる。
7.2.3 app.error reducer
slot lastError : Option(PanicInfo) = None
reducer onPanic
on=app.error
do= lastError := Some($event)
emit log({level: "error", message: $event.message, data: {}})
emit toast({kind: "error", text: "Something went wrong"})PanicInfo の型:
type PanicInfo = {
message: Text,
location: Text, # 例: `reducer "foo"` / `render`
episode-id: Option(Text),
cause: Option(Text),
category: Text # "reducer" / "effect" / "capability" / "tile-render" / "hydrate" / "unknown"
}category は、runtime 側のどこでその失敗を捕捉したかを名指す。現時点で自前の category を出すのは reducer / tile-render / hydrate の経路と、capability チェックが拒否した effect を指す capability(runtime.md §10.4.2)である —— capability だけは、何も throw されていない出来事を報告する値である。effect と unknown は予約値であり、今後 callsite が配線されていっても、アプリ側のコードが fallthrough なしに網羅的に match できるようにするためにある。
episode-id は panic が起きた episode を名指す(runtime.md §10.5)—— ユーザーが見た panic と、kumiki replay / kumiki_episode_tail が読み戻すものとを繋ぐ結合キーである。Option(Text) なのは、episode が常に開いているとは限らないからだ:episode logger を接続していないホストには名指すべき episode が無く、それに対する答えが None である。どの dispatch にも属さない場所で発生した panic —— 例えば初回描画で捕捉された描画 panic —— も同じである。
cause は、throw が Error.cause を伴っていた場合のその最も近いリンクの message であり、そうでなければ None である。message が空の cause も None として扱う:.get-or を通すと Some("") は理由が入るべき場所を空白にしてしまい、「理由が無い」ではなく「理由が空である」と読めてしまうからだ。その背後のチェーンと、それに付随するスタックは episode log に留まる —— そこへ辿り着く手段が episode-id である。
panic がプログラムに届くすべての経路 —— app.error reducer、route.error reducer、error-boundary の fallback —— に対して、すべてのフィールドが供給される:一度だけ組み立てられた同じレコードが渡される。route.error だけはその上に、マッチした pattern が乗る。episode-id は、名指すべき開いた episode が無い箇所ではすべて None である —— 境界経路ではどの dispatch の内側でもない描画が、サーバー側ではすべての描画がそれに当たる(renderToString は描画より前に bootstrap episode を commit する)。本仕様の旧版では location の例に "reducer:foo:line:42" という形を使っていたが、runtime が出すのは reducer "foo" / render である。
開発ツール向けのフィールドである stack(JS の Error.stack)と機械可読な Error.cause チェーンは episode log(docs/spec/runtime.md §10.5.1)に記録されるが、ユーザー向けの $event には意図的に公開しない —— 生のスタックを本番 UI に漏らすのは footgun だからである。参照するには kumiki replay / kumiki_episode_tail を使う。
7.3 エラー境界(タイル単位)
特定の tile 配下の描画エラーを捕捉して fallback を出す:
tile UserPage
error-boundary = ErrorFallback
= page(
UserHeader,
UserStats,
UserActivity)
tile ErrorFallback
in=PanicInfo
= column(
heading("Something went wrong"),
text($1.message) {color: "danger"},
button(text="Retry", onClick=retryUserPage))error-boundary = X を tile 定義に書くと、その tile 配下の描画中 panic は X tile を in=PanicInfo で呼び出して fallback 表示する。
したがって $1 を読む fallback は in=PanicInfo(または PanicInfo が代入可能な型、たとえばその別名)を宣言しなければならない。何を宣言していても $1 は panic なので、別の in= を宣言した fallback、あるいは in= を宣言せずに $1 を読む fallback は E0220 であり、error-boundary 句の位置に報告される。$1 を読まない fallback は in= を宣言しなくてよく、そうすれば同じ tile を route や sub-routes のターゲットにもできる。
境界が属するのは tile であって、それが書かれた場所ではない。したがってその tile が描画されるあらゆる位置で有効であり、route がターゲットとして名指した場合も、sub-routes のエントリが名指した場合も含む。
境界はその tile の配下で描画されるすべてを覆い、sub-routes のエントリがその tile の route-outlet(ルーティング §3.6)に注入する子もその配下にある。したがってシェルに宣言した境界は、子を含むセクション全体に対する 1 つの fallback になる。勝つのは最も近い境界である —— 子が自身の境界を宣言していれば、子は outlet の中に自身の fallback を表示し、シェルはそのまま残る。fallback に渡る PanicInfo の location は、runtime が組み立てていた route のターゲット(とりわけ outlet の子)を名指し、それ以外は境界を宣言した tile を名指す。ターゲットが自身の body 内で描画する tile は、ターゲットと区別されない。
境界が受け取るのは panic —— §7.2.2 が定義する制御されたシグナル —— である。fallback が受け取るのは PanicInfo の全体であり、§7.2.3 の app.error reducer が受け取るものと同じ payload である —— 同じコードが組み立てるので、両者が食い違うことはない。それ以外のものが境界に到達した場合、それはプログラムのエラーではなく生成コードまたは runtime の欠陥であり、トップレベル表示のために再送出される:fallback がそれを吸収すると失敗が描画済みページに置き換わり、smoke / scenario はエラーチャネルで検証するため、見るべきものが何も残らなくなる。
実装ステータス. live runtime は エラー処理 の panic モデルを実装する:reducer ディスパッチ中の panic はその episode の
slot変更を rollback し(部分書き込みなし)、検証 tier(smoke/ scenario)へ surface し、app.errorreducer(app.error reducer)をPanicInfoを$eventとして発火する。描画中の panic は、それを囲む最も近いerror-boundaryが捕捉する。これには route ターゲットの境界も、sub-routesを持つ親のroute-outlet内の子に対する親の境界も含まれる。上に境界がない描画 panic は、イベントハンドラを未捕捉で突き抜ける代わりに組み込みのトップレベル panic 表示にフォールバックする。panic(message)と多相な.get(None/Errで panic、.get-errと整合)はこの同じ制御されたシグナルを送出する。
7.4 サスペンス(loading 表示)
非同期 effect の結果待ちで loading 表示したい場合。Kumiki は 明示的に LoadResult(T) 型を使うことを推奨する:
type LoadResult(T) = Idle | Loading | Loaded(T) | Failed(HttpError)
slot user : LoadResult(User) = Idle
tile UserView = match user with
| Idle -> button(text="Load", onClick=fetchUser)
| Loading -> spinner() {size: "lg"}
| Loaded(u) -> UserCard(u)
| Failed(e) -> ErrorView(e)専用の <Suspense> 機構は持たない(Reactで起こった「どこから何が suspend するか追跡困難」を避けるため)。
7.4.1 match 式
match-expr ::= 'match' expr 'with' match-arm+
match-arm ::= '|' pattern '->' expr
pattern ::= identifier ; variant 名
| identifier '(' bind (',' bind)* ')' ; variant + 束縛
| '_' ; ワイルドカード
bind ::= identifiernetwork コードはほぼ常に match で書く。これは Kumiki における loading/error の正規パターン。
7.5 404 と error ページ
7.5.1 404
/404 への到達は通常のルートと同じ。ルートマッチに失敗するとランタイムが nav.replace で /404 に飛ばす。
tile NotFound = page(
heading("404"),
text("Page not found"),
link(to="/") {text: "Home"})7.5.2 ルート単位のエラー fallback
reducer onRouteErr
on=route.error("/todos/:id")
do= toastError := Some("Failed to load todo")
emit navigate-replace({path: "/todos", params: {}, query: {}})boundary に捕まらない render の panic は、対応する route.error reducer を 1 回だけ発火する。reducer の書き込みはそれ自体では render を始めない(描こうとするページはたった今 panic したページそのものだから)。reducer が返った後に runtime がもう 1 回だけ render し、書き込み(navigation を含む)はその render で反映される。その render も panic した場合は組み込みの panic 表示を出し、それに対して route.error を再び発火することはない。
7.6 確認ダイアログ
Kumiki は window.confirm 相当を effect として提供する:
effect confirm cap=notification.show
in={title: Text, message: Text, onYes: ReducerRef, onNo: ReducerRef}
out=Unit
reducer askDelete
on=ui.click(DeleteBtn)
do= emit confirm({
title: "削除しますか?",
message: "この操作は取り消せません",
onYes: doDelete,
onNo: noop
})
reducer doDelete on=ui.click(_) do= ... # ※ 実装上は別名 reducer を作る方が綺麗
reducer noop on=ui.click(_) do= ()ランタイム実装ではこれは モーダルダイアログ tile として描画される(ネイティブ confirm ではない)。これにより UI スタイルが揃い、テストも容易になる。
7.7 トースト
effect toast cap=notification.show
in={kind: Text, text: Text, duration: Option(Duration)}
out=Unit
reducer notifySave
on=persist.ok(_, _)
do= emit toast({kind: "success", text: "Saved", duration: Some(Duration.s(3))})kind は info / success / warn / error のいずれかで、DOM には data-level として載る — ランタイムは見た目を与えない。duration 未指定なら kind 別のデフォルト(info 3s, success 3s, warn 5s, error 0 = 閉じるまで残る)。Some(Duration.ms(0)) は同じことを明示的に頼む書き方。
ランタイムは画面右下にトーストスタックを管理する組み込み tile を持つ。
7.8 アクセシビリティの最小規約
| 規約 | 適用 |
|---|---|
button には必ず text または aria-label | E0701、--strict-a11y 時 |
image には必ず alt | E0702、--strict-a11y 時 |
link には必ず内側テキストか aria-label | E0703、--strict-a11y 時 |
label {for: "x"} は存在する id="x" を指すこと | E0705、--strict-a11y 時 |
| キーボード操作 (Tab/Enter/Esc) はランタイムが自動 | ランタイム保証 |
フォーカス管理: modal は trap focus | ランタイム保証 |
aria-live 領域: toast(role="status"、polite)と error(role="alert"、assertive)で自動 — tile も toast effect のバナーも、クライアント/サーバとも | ランタイム保証 |
検査される行はデフォルト off、on のときはエラー。--strict-a11y を付けなければコンパイラはこれらを警告として報告するのではなく完全に除外するため、ビルドは何も言わない。フラグを付けるとビルドが落ちる。ランタイム保証はどちらでも成立する。
7.9 ホットリロード時の状態
開発時のホットリロードで slot 値を保持するか破棄するか:
| slot 修飾子 | reload 時 |
|---|---|
| なし | 維持 |
transient | 破棄(初期値に戻る) |
volatile | 永続化対象から外す(log にも書かれない、reload で破棄) |
slot draft : Text = "" # reload で維持
slot toast : Option(Toast) transient = None # reload で破棄
slot password : Text volatile = "" # episode log にも書かれない7.10 設計上の判断記録
| 判断 | 理由 |
|---|---|
| try/catch を許さない | エラー伝播が暗黙になる、Result で明示 |
| panic 時に slot をロールバック | 中途半端な状態を残さない |
| Suspense 専用機構を持たない | LoadResult 型で明示する方が AI に追跡しやすい |
| confirm を effect として提供 | UI 一貫性とテスト容易性 |
| a11y を警告レベルで強制 | 機械的にチェックできる項目は構造で守る |
| エラー境界を tile 属性に | tile 階層と error 階層を一致させる |