Skip to content

ライフサイクル・エラー境界・サスペンス ​

ランタイムが reducer に制御を渡す瞬間——起動・画面遷移・タイマー——と、失敗したときにアプリが何をするか。反応できるイベントの一覧が §7.1、§7.3 以降が失敗側(tile 単位の境界・loading 表示・404 ページ)である。

7.1 ライフサイクルイベント一覧 ​

イベントタイミング
app.startアプリ起動直後(初期 slot 値が確定し、ランタイムがマウントされた時点)
app.stopアプリ終了直前(ブラウザクローズ・タブ閉じる前)
app.error未捕捉エラー発生時
app.http-401HTTP 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 された後に届く。

kumiki
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 を発火したタイミング。短時間で完了する処理のみ実行可能(ブラウザ仕様)。

kumiki
reducer cleanup
    on=app.stop
    do= emit persist(todos)         # 同期 storage.write のみ実用的

7.1.3 app.visible / app.hidden ​

visibilitychange イベントに対応。タブ切り替えで状態をポーズしたい場合:

kumiki
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 ​

kumiki
reducer onlineSync   on=app.online   do= emit retryQueued()
reducer showOffline  on=app.offline  do= emit toast({kind: "warn", text: "Offline"})

7.1.5 timer ​

kumiki
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 から明示的に停止できる:

kumiki
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 される。
kumiki
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 に現れた / 消えたタイミング。

kumiki
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 で Err
  • panic(msg) の明示呼び出し

これらは panic と呼ばれる例外。panic は episode log に記録され、現在の reducer は中断される。slot の変更は トランザクション的にロールバックされる。

7.2.3 app.error reducer ​

kumiki
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 の型:

kumiki
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 を出す:

kumiki
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.error reducer(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) 型を使うことを推奨する:

kumiki
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       ::= identifier

network コードはほぼ常に match で書く。これは Kumiki における loading/error の正規パターン。


7.5 404 と error ページ ​

7.5.1 404 ​

/404 への到達は通常のルートと同じ。ルートマッチに失敗するとランタイムが nav.replace で /404 に飛ばす。

kumiki
tile NotFound = page(
                  heading("404"),
                  text("Page not found"),
                  link(to="/") {text: "Home"})

7.5.2 ルート単位のエラー fallback ​

kumiki
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 として提供する:

kumiki
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 トースト ​

kumiki
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-labelE0701、--strict-a11y 時
image には必ず altE0702、--strict-a11y 時
link には必ず内側テキストか aria-labelE0703、--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 で破棄)
kumiki
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 階層を一致させる

7.11 次 ​