Skip to content

ルーティング

Strand のルーティングは SPA を前提にしている。ハッシュルーティングではなく History API ベース。サーバから静的に同じ HTML を返し、クライアントランタイムがルートを解決する。


3.1 ルートの宣言

approutes フィールドで宣言する。

strand
app TodoApp
    caps   = [nav.push, nav.replace, nav.back]
    routes = {
        "/"                -> Home,
        "/todos"           -> TodoList,
        "/todos/:id"       -> TodoDetail,
        "/todos/:id/edit"  -> TodoEdit,
        "/settings/*"      -> Settings,
        "/404"             -> NotFound
    }
    init   = []

3.1.1 パスセグメントの種類

構文意味
/static静的セグメント
/:nameパラメータ(1 セグメント)
/*ワイルドカード(残り全部)
/?query※ クエリは別途。パスには書かない

3.1.2 マッチ順序

  1. より具体的なルートが優先(静的 > パラメータ > ワイルドカード)
  2. 同じ具体度なら 定義順(並列開発で挙動が変わらないように)

3.1.3 /404 は予約

/404どのルートにもマッチしなかった場合のフォールバック。app.routes/404 -> X を含めるのは必須(未指定はコンパイルエラー)。


3.2 現在のルート状態

ランタイムは標準 slot route を提供する:

strand
slot route : Route = Route.empty       ; ランタイムが管理

Route 型は標準提供

strand
type Route = {
    path: Text,                ; "/todos/abc-123"
    pattern: Text,             ; "/todos/:id"
    params: Map(Text, Text),   ; {"id": "abc-123"}
    query: Map(Text, Text),    ; ?foo=bar&baz=1 → {"foo":"bar","baz":"1"}
    hash: Option(Text)         ; #section
}

tile から参照:

strand
tile TodoDetail = column(
                    heading("Todo " + route.params.get-or("id", "?")),
                    ...)

3.3 ルート遷移

strand
tile Nav = row(
             link(to="/")        {text: "Home"},
             link(to="/todos")   {text: "Todos"},
             link(to="/settings"){text: "Settings"})

link は自動的に nav.push capability を使う(暗黙)。<a href> と異なりフルリロードしない。

3.3.2 effect として書く

reducer から遷移するには effect を emit:

strand
reducer save  on=ui.click(SaveBtn)
              do= emit persist(todos)
                  emit navigate({path: "/todos", params: {}})

ビルトイン effect:

strand
effect navigate         cap=nav.push     in={path: Text, params: Map(Text, Text)}    out=Unit
effect navigate-replace cap=nav.replace  in={path: Text, params: Map(Text, Text)}    out=Unit
effect navigate-back    cap=nav.back     in=Unit                                     out=Unit

3.3.3 動的パス構築

strand
emit navigate({path: "/todos/{id}", params: {"id": todo.id.show}})

{name} は params で置換される。未指定の {name} はコンパイル時警告。


3.4 ルートライフサイクル

ルート切替時に発火するイベント:

イベントタイミング
route.leave(pattern)旧ルートを離れる直前
route.enter(pattern)新ルートに入った直後
strand
reducer loadTodoOnEnter
    on=route.enter("/todos/:id")
    do= todos[$route.params.get-or("id", "")] := Loading
        emit loadTodo($route.params.get-or("id", ""))

reducer cleanupOnLeave
    on=route.leave("/todos/:id")
    do= editing := None

$route は新(または旧)ルートを表す bind。


3.5 ガード

ルート遷移を阻止したいケース(未保存変更、未ログインなど)。

3.5.1 enter ガード

route.enter(pattern) の reducer 中で emit navigate-replace(...) を出すと、リダイレクトとして扱われる。

strand
reducer requireAuth
    on=route.enter("/admin/*")
    do= if session.is-none
        then emit navigate-replace({path: "/login", params: {}})
        else ()

3.5.2 leave ガード

未保存変更があるなら遷移を止めたい場合:

strand
slot dirty : Bool = false

reducer guardEdit
    on=route.leave("/todos/:id/edit")
    do= if dirty
        then emit confirm({title: "破棄してよい?", onYes: continueLeave, onNo: stayHere})
        else ()

confirm は標準 effect(→ ./stdlib.md)で、回答を別 reducer に届ける。詳細は ./lifecycle.md


3.6 ネステッドルート

/* をパターンに使うと、サブルートを別 tile に委譲できる。

3.6.1 親ルート

strand
app App
    caps   = [nav.push]
    routes = {
        "/settings/*" -> SettingsLayout,
        "/404"        -> NotFound
    }

3.6.2 子ルートマップ

子ルートマップは tile 定義に sub-routes で書く:

strand
tile SettingsLayout
    sub-routes = {
        "/settings/account" -> AccountSettings,
        "/settings/billing" -> BillingSettings,
        "/settings"         -> SettingsHome
    }
    = page(
        heading("Settings"),
        row(
          column(
            link(to="/settings/account") {text: "Account"},
            link(to="/settings/billing") {text: "Billing"}),
          route-outlet()))           ; 子ルートがここに描画される

route-outlet() は親ルート tile 内で子の描画位置を指定するプリミティブ。

3.6.3 マッチング規則

  • 子ルートは親パターン /settings/* の中で再マッチング
  • 子ルートにマッチしなければ親の /settings (デフォルト) を使う
  • それも無ければグローバル /404

3.7 クエリパラメータ

クエリは route.query から読む。書き込みは navigateparams には含まれず、別フィールド query で渡す。

strand
emit navigate({
    path: "/search",
    params: {},
    query: {"q": searchTerm, "page": "1"}
})

navigate effect の in 型はこれを許す拡張版:

strand
effect navigate cap=nav.push
                in={path: Text, params: Map(Text, Text), query: Map(Text, Text)}
                out=Unit

paramsquery は未指定なら {}


3.8 プリフェッチ

リンクがビューポートに入ったときに先にデータを取りたい:

strand
link(to="/todos/abc-123") {
    text: "Todo abc-123",
    prefetch: loadTodo,           ; emit する reducer 名
    prefetch-args: {"id": "abc-123"}
}

prefetchIntersectionObserver を経由してビューポート進入時に発火する標準機能。reducer は route.enter のときと同じ引数バインドで呼ばれる。


3.9 スクロール復元

履歴を戻ったときにスクロール位置を復元する。デフォルトで有効。

無効化したい tile:

strand
tile Chat
    scroll-restoration = false
    = scroll(...)

特定ルート進入時にトップへ:

strand
reducer scrollTop on=route.enter("/*") do= emit scroll-to({x: 0, y: 0})

scroll-to は標準 effect。


3.10 リダイレクト(静的)

strand
app App
    routes = {
        "/old-path"  ->> "/new-path",     ; ->> はリダイレクト
        "/new-path"  -> NewPage,
        "/404"       -> NotFound
    }

->>静的リダイレクト。マッチした瞬間に navigate-replace 相当を実行。


3.11 例: 認証付きルーティング

strand
type SessionId = nominal Text

slot session : Option(SessionId) = None
slot loginRedirect : Option(Text) = None

effect loadSession cap=storage.read in=Unit out=Option(SessionId) policy=once

reducer boot
    on=app.start
    do= emit loadSession()

reducer sessionLoaded
    on=loadSession.ok($s, _)
    do= session := $s

reducer requireAuth
    on=route.enter("/app/*")
    do= if session.is-none
        then let _ = (loginRedirect := Some(route.path))
             in emit navigate-replace({path: "/login", params: {}, query: {}})
        else ()

reducer afterLogin
    on=ui.submit(LoginForm)
    do= session := Some(SessionId.fresh())
        let back = loginRedirect.get-or("/app")
        emit navigate-replace({path: back, params: {}, query: {}})
        loginRedirect := None

app SecureApp
    caps   = [storage.read, nav.push, nav.replace]
    routes = {
        "/"        -> Landing,
        "/login"   -> LoginPage,
        "/app/*"   -> AppShell,
        "/404"     -> NotFound
    }
    init   = []

3.12 設計上の判断記録

判断理由
/404 を必須にした404 未指定で本番に出るバグを構造で防ぐ
マッチ順は具体度→定義順並列開発で hash 順だと挙動が変動する
link を要素にした「nav.push を emit するボタン」と毎回書かせるのはトークンの無駄
クエリを path に書かないパスとクエリの混同を構造で防ぐ
ネステッドルートを tile に書くルート構造とビュー階層を一致させる
prefetch を link prop にしたreducer に書くと意図が散る
ガードを reducer で書く専用 DSL を増やさない(学習対象を最小化)

3.13 次