zfb
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

クライアントサイドルーティングと View Transitions

ソフトスワップナビゲーション・View Transition アニメーション・リンクのプリフェッチで zfb を SPA に変える。

zfb のページはデフォルトで静的 HTML です。<ClientRouter /> は、それらを SPA スタイルのナビゲーションにオプトインします。同一オリジンのリンクをクリックすると、次のページをバックグラウンドで取得し、<body>(と変化した <head> ノード)だけを入れ替え、View Transition アニメーションを再生します。ブラウザの完全なリロードは発生しません。

<ClientRouter /> をマウントする

@takazudo/zfb-runtime からインポートし、コンポーネントをページの <head> 内に一度だけ配置します。

import { ClientRouter } from "@takazudo/zfb-runtime";

export default function Layout({ children }) {
  return (
    <html>
      <head>
        <meta charset="UTF-8" />
        <ClientRouter fallback="animate" />
      </head>
      <body>{children}</body>
    </html>
  );
}

zfb のプロジェクトでは、このコンポーネントをマウントするだけで十分です。インポート自体は何もしません。@takazudo/zfb-runtime から ClientRouter をインポートしても、リスナーは登録されませんし、履歴にも触れません。そのインポートをビルドが検知してクライアントルーターをブラウザへ届け、ルーターはそこで有効化されます。有効化は冪等なので、同じページで複数回マウントしても(あるいは HMR で再実行されても)安全です。

うまく動かないときのために、次の 2 つの役割は分けて捉えておくと役に立ちます。

  • コンポーネント は <head> へのレンダリングを担当します。ルーターが読み取るオプトイン用の <meta> タグと、オフスクリーンの ARIA ルートアナウンサー用のグローバルスタイルです。何も登録しませんし、何もナビゲートしません。

  • init() が、実行時に残りのすべてを担当します。このページの履歴エントリとスクロール位置を復元し、初回ロードで実行済みのスクリプトに印を付け、popstate / load / pageshow / スクロールのリスナーと、クリックおよびフォーム送信のインターセプトを登録します。これはブラウザが @takazudo/zfb-runtime/client-router を評価したときの副作用として走ります。このインポートは、ページが <ClientRouter /> に到達していることを zfb のアイランドスキャナーが検知した時点で、そのページのクライアントバンドルへ自動的に注入されます。"use client" のボイラープレートは必要ありません。検知のルールと手動のエスケープハッチについては、runtime README の「Enabling SPA soft-navigation」セクションを参照してください。

Props

プロパティ型デフォルト説明
fallback"none" | "animate" | "swap""animate"ブラウザがネイティブの View Transitions をサポートしていないときの挙動。
prefetchAllbooleanfalsetrue のとき、すべての同一オリジンリンクが自動的に "hover" プリフェッチ戦略にオプトインされます。
preserveHtmlAttrsstring[][]各 SPA スワップをまたいで ランタイム の値が維持される、追加の <html> 属性名。ランタイムの <html> 属性を保持する を参照。
traverseRefetchbooleanfalseこのページを同一ページ内の戻る/進むの高速パスからオプトアウトし、トラバーサルのたびに再フェッチを強制します。同一ページ内トラバーサル を参照。

フォールバックモード

fallback プロパティは、document.startViewTransition をサポートしないブラウザでの挙動を制御します。

  • "animate" — CSS アニメーションでトランジションをシミュレートします。zfb はスワップ前に <html> へ data-zfb-transition-fallback="old" を、スワップ後に "new" を設定します。ネイティブの View Transitions 向けに定義したフェードやスライドを再現するには、これらを CSS でターゲットにしてください。

  • "swap" — アニメーションなしで、即座に head と body を入れ替えます。

  • "none" — ルーターを完全にスキップします。同一オリジンのリンクは完全なページ遷移にフォールバックします。

{/* ネイティブ View Transitions がない場合は通常のページ遷移を使う */}
<ClientRouter fallback="none" />

fallback はネイティブアニメーション全体の切り替えではありません。ネイティブ対応ブラウザでは、fallback="none" や fallback="swap" でもネイティブの View Transitions は無効になりません。ネイティブスナップショットの CSS と、シミュレート経路の CSS は別の方針です。シミュレート経路は formData を伴うナビゲーションなどの特別な場合にも使われ、ネイティブのスナップショットツリーを再現するものではありません。

ランタイムの <html> 属性を保持する

SPA スワップのたびに、ルーターは新しく受信したサーバーレンダリング済みドキュメントの <html> 属性をライブなルート要素へコピーします。そのため、永続化された島が <html> に設定するランタイム属性(localStorage から駆動される data-theme や data-sidebar-hidden など)は、ナビゲーションのたびに失われます。これらの属性名を preserveHtmlAttrs に列挙すると、ルーターは各スワップの後にそれらの現在値を再適用します。

<ClientRouter preserveHtmlAttrs={["data-theme", "data-sidebar-hidden"]} />

このリストは <meta name="zfb-preserve-html-attrs"> タグとして出力され、スワップは 現在(遷移元)のページ からそれを読み取ります。そのため、SPA ナビゲーションに参加するすべてのページで、同じリストを指定して <ClientRouter /> をマウントしてください。エントリを省いたページは、そのページから離れるナビゲーションの際にその属性を失います。属性名は大文字・小文字を区別せずにマッチします(DOM の属性名は小文字化されます)。

静的な保持リストでは表現できない動的あるいは計算された属性値については、代わりに zfb:before-swap リスナー内で event.newDocument.documentElement を変更してください。ナビゲーションライフサイクルイベント を参照。

クライアントサイドのコード(島やイベントハンドラの中)から navigate() を呼び出すと、ソフトナビゲーションをトリガーできます。

import { navigate } from "@takazudo/zfb-runtime/client-router";

// Push a new history entry (default)
await navigate("/about");

// Replace the current history entry instead of pushing
await navigate("/search?q=zfb", { history: "replace" });

`<ClientRouter />` は「現在の」ページにマウントされていなければならない

navigate() は、それが呼び出されるページに <ClientRouter /> がマウントされているときにのみソフトナビゲーションを行います。内部的には <ClientRouter /> が <head> にレンダリングする <meta name="zfb-view-transitions-enabled"> タグをチェックし、そのタグが存在しない場合は素の location.href への代入にフォールバックします。これはソフトナビゲーションではなく完全なページロードであり、エラーも警告も出ません。

ルートの @takazudo/zfb-runtime バレルから navigate(や syncHistoryEntry)をインポートするだけでは 不十分 です。このバレルは副作用を持たず、init() も意図的にエクスポートしていません。つまり、そこからインポートしてもインターセプト用のリスナーは 1 つも登録されず、ルーターがリンクのクリックやフォーム送信を見張り始めることはありません。それぞれのヘルパーは、直接呼び出したときの挙動をドキュメントどおりに保ちます(navigate() は変わらずナビゲートしますし、syncHistoryEntry() は変わらず履歴エントリを書き込みます)。手に入らないのは、自動的なインターセプトのほうです。

ソフトナビゲーション可能にしたいすべてのページで、レイアウトの <head> に <ClientRouter /> をマウントしてください。これは必要なメタタグをレンダリングすると同時に、zfb のアイランドスキャナーにルーターをブラウザへ届けさせるきっかけにもなります。ブラウザ側では import "@takazudo/zfb-runtime/client-router" が init() を呼び出してくれます。代わりに自分で init() を呼び出したい場合は、同じサブパスからインポートしてください。ただし、そのサブパスはインポートした時点ですでにルーターを有効化することに注意してください。

navigate() はオプションの Options オブジェクトを受け取ります。

type Options = {
  history?: "auto" | "push" | "replace";
  info?: any;    // passed to before-preparation event as event.info
  state?: any;   // merged into the new history.state entry
  formData?: FormData;
};
  • history: "auto"(オプションを省略したときのデフォルト) — pushState で新しいブラウザ履歴エントリを作成します。明示的に "push" を渡しても同じ動作です。

  • history: "replace" — history.replaceState を呼び出すので、戻るボタン用のエントリは作成されません。

navigate() は SSR 中は何もせず、サーバー上で呼び出された場合はコンソール警告を出します。

個々の <a> タグを、JavaScript なしで replaceState にオプトインできます。

<a href="/terms" data-zfb-history="replace">Terms</a>

任意の <a> または <form> に data-zfb-reload を付けると、そのナビゲーションで完全なブラウザリロードを強制できます。

<a href="/admin" data-zfb-reload>Admin (full reload)</a>

フォーム送信

<ClientRouter /> は同一オリジンの <form> 送信もインターセプトし、リンククリックと同じ navigate() の経路でソフトナビゲーションに変換します。完全なページの往復は発生しません。

  • GET フォーム — フォームのフィールドは遷移先 URL のクエリ文字列にシリアライズされ、通常の GET ソフトナビゲーションになります。リクエストボディは送信されません。

  • POST(またはそれ以外の GET 以外のメソッド)フォーム — 送信された FormData は navigate() の formData オプションを通じて渡され、実際の POST リクエストのボディとして送信されます。フォームの enctype が要求する場合は application/x-www-form-urlencoded として、そうでなければマルチパートの FormData としてエンコードされます。

  • 送信者の formaction / formmethod が優先される — 送信をトリガーした要素の <button formaction="..."> / formmethod="..." は、<form> 自体の action / method を上書きします。これは標準の HTML フォームのセマンティクスと同じです。

  • 送信済みのリクエストが二度送られることはありません — formData を伴うナビゲーションは、zfb:before-swap で最終 URL が確定した後、古いページを破棄する前に履歴エントリを一度だけ書き込みます。そのため、ネイティブの View Transition ではなく、常にシミュレートされたトランジション(ページの zfb-view-transitions-fallback 設定)を通ります。ブラウザがその履歴の書き込みを拒否した場合、ナビゲーションは zfb:navigation-aborted で終了し、現在のページはそのまま残ります。ルーターがフォームを再送信したり、レスポンスの URL を再読み込みしたりすることはありません。

ソフトなフォーム送信からオプトアウトする

以下のいずれかに該当する場合、送信は通常の完全なブラウザフォーム送信にフォールバックします。

  • <form> が data-zfb-reload を持っている(リンクと同じオプトアウト属性。上の リンクをルーターからオプトアウトする を参照)

  • method="dialog" — <dialog> 要素内で使われる特別な HTML キーワード

  • 解決されたアクション URL がクロスオリジンである

  • 送信者への修飾キー(Cmd/Ctrl/Shift/Alt)クリック、またはプライマリでないマウスボタンによって送信がトリガーされた

  • 別のハンドラがすでに submit イベントで event.preventDefault() を呼び出している

zfb:before-preparation の event.formData は、フォームによってトリガーされたナビゲーションの場合にのみ設定されます(リンククリックや、渡さなかった場合のプログラムによる navigate() 呼び出しでは undefined です)。送信が送られる前に検査したり反応したりするには、ここをリッスンしてください。

syncHistoryEntry() で一時的な UI 状態をディープリンクする

モーダル・ダイアログ・ビューアは、独自の URL を持ちたいことがよくあります。そうすれば「戻る」で閉じられ、状態が共有・ブックマーク可能になります。しかも実際のページ遷移は行いません。このために生の history.pushState() / history.replaceState() に手を伸ばさないでください。ルーターは戻る/進むの方向や同一ページ内トラバーサルを検出するために、自身の履歴インデックスとナビゲーション 元 の URL を追跡しており(後述の 同一ページ内トラバーサル を参照)、手作りの履歴エントリはその管理情報を狂わせます。

syncHistoryEntry() は、ナビゲーションせずにルーター管理の履歴エントリを書き込みます。フェッチも、スワップも、DOM の変更もなく、ビューポートをスクロールすることも決してありません(ただしエントリには現在のスクロール位置がスタンプされます。後述を参照)。

import { syncHistoryEntry } from "@takazudo/zfb-runtime/client-router";

function openModal(id: string) {
  syncHistoryEntry(`#photo-${id}`);
}
function syncHistoryEntry(url: string | URL, options?: SyncHistoryEntryOptions): void;

type SyncHistoryEntryOptions = {
  replace?: boolean; // use replaceState instead of pushState (no new Back entry)
  state?: any;       // merged into history.state; the router's own keys win on collision
};
  • デフォルトは push — 新しい履歴エントリを作成するので、「戻る」はユーザーがその前に見ていたものへ戻ります。代わりに現在のエントリを上書きするには { replace: true } を渡してください(新しい「戻る」の停止点は作られません)。

  • state はマージされ、ルーターのキーが勝つ — あなた自身のキーは残りますが、あなたの state オブジェクトがたまたまルーターの管理キー(index・scrollX・scrollY)と同じ名前を再利用していた場合は、常にルーターのキーが優先されます。

  • クロスオリジン URL では例外を投げます — 完全なページロードへ黙ってフォールバックすることは決してありません。誤ったクロスオリジン呼び出しは、すぐに気づきたいバグです。

  • 拒否された History の書き込みは再スローされます — ブラウザが URL の書き込みを拒否した場合(例: WebKit の History 書き込み回数制限)、ブラウザ自身のエラーがそのまま再スローされます。ルーターが追跡する履歴インデックスと「遷移元」URL は現在のエントリのまま変わらず、ナビゲーション・フェッチ・スクロールは一切起こりません。about:srcdoc ドキュメント内ではブラウザの History 制限は許容され、書き込みを行わずにそのまま戻ります。

  • SSR 中は何もしません。サーバー上で navigate() を呼び出したときと同じ一度きりのコンソール警告を出します。

  • ビューポートをスクロールしたり DOM / document.title に触れたりすることは決してありません — これは純粋な履歴管理です。現在の URL に基づいてモーダル/ダイアログ自体をレンダリングするのは、あなたのコンポーネントの責務です。

  • エントリには (0, 0) ではなく「現在の」スクロール位置がスタンプされます。 push(または replace)されたエントリは、ページ先頭ではなく まさに今この瞬間の scrollX/scrollY を記録します。これはトラバーサルの高速パスにとって重要です。ユーザーが後で「進む」でこのエントリに戻ったとき(後述の 同一ページ内トラバーサル を参照)、ページは syncHistoryEntry() を呼び出した時点で実際にあったスクロール位置へ復元されます。再び開いたダイアログの下で先頭へ不快にスナップすることはありません。

例: ハッシュベースのモーダル

import { computed, getScope, signal } from "@takazudo/zfb/zudo-react";
import { syncHistoryEntry } from "@takazudo/zfb-runtime/client-router";

function PhotoModal({ id }: { id: string }) {
  const open = signal(false);
  const hidden = computed(() => !open.value);
  getScope().onActivate(() => {
    const syncFromUrl = () => { open.value = location.hash === `#photo-${id}`; };
    window.addEventListener("popstate", syncFromUrl);
    window.addEventListener("hashchange", syncFromUrl);
    syncFromUrl();
    return () => {
      window.removeEventListener("popstate", syncFromUrl);
      window.removeEventListener("hashchange", syncFromUrl);
    };
  });

  return <div class="modal" hidden={hidden}>{/* photo content */}</div>;
}

function openPhoto(id: string) {
  syncHistoryEntry(`${location.pathname}#photo-${id}`);
}

push されたエントリは現在の pathname と search を共有する(変わるのはハッシュだけ)ため、その後に「戻る」を押すと同一ページ内トラバーサルの高速パス(後述)で処理されます。フェッチも、スワップも、完全なページの再マウントもありません。ただしブラウザは通常どおり popstate イベントを発火します。syncHistoryEntry() があなたの代わりにモーダルを閉じることはありません。モーダルは自身で popstate(または hashchange)をリッスンし、ハッシュがもうマッチしなくなったら閉じます。そのリスナーがなければ、URL は変わってもモーダルは開いたままになります。

例: 独自の pathname を持つダイアログ

一部のビューアは、ハッシュではなく実際のパスセグメントにディープリンクします。たとえば共有可能なリンクから到達できる /photos/<slug>/ のフォトライトボックスなどです。

import { syncHistoryEntry } from "@takazudo/zfb-runtime/client-router";

function openPhotoLightbox(slug: string) {
  syncHistoryEntry(`/photos/${slug}/`);
}

これは pathname を変えるため、push されたエントリはユーザーが来たページともうマッチしません。「戻る」を押すと、高速パスではなく通常のページ間トラバーサル(前のページのフェッチ + スワップ)に落ちます。syncHistoryEntry() は URL バーとルーターの管理情報を編集するだけで、それ自体は何もフェッチもレンダリングもしません。そのため、URL が運ぶライトボックスの内容を実際に表示するのは、location.pathname を読み取る島です。

Raw History エントリと所有権

「戻る」/「進む」の押下が到達するエントリは、ルーターかアプリのどちらかが所有しています。ルーターはエントリの URL を、アドレスバーではなく DOM が実際に表示しているドキュメントと比較して判断します。アドレスバーは syncHistoryEntry() によって、何もレンダリングされていない URL を指すことがあるからです。

  • アプリが所有する同一ドキュメントのエントリには syncHistoryEntry() を使います。 ルーターの管理情報が同期されたままになるため、ハッシュや同じ search のエントリからの「戻る」は高速パスで処理されます。別の pathname を持つ syncHistoryEntry() のエントリからの「戻る」は、上で説明したページ間スワップです。そのようなエントリを越えて、DOM にまだ表示されているページのフラグメントエントリへ戻る場合、そのページは再フェッチされません。

  • レンダリング中のページのフラグメントエントリはブラウザのものです。 ルーターが処理しないハッシュリンクや location.hash への書き込みは、history.state が null のエントリを作ります。それらのエントリ間の「戻る」/「進む」はスクロールするだけで、フェッチもスワップもライフサイクルイベントもありません。

  • レンダリング中の pathname 上での raw な history.pushState(null, "", "?q=…") はアプリのものです。 ルーターはこうしたフィルターや検索のエントリをアプリ自身の popstate ハンドラーに任せ、フェッチもスワップもしません。

  • ルーターがレンダリングしたドキュメントは、null のエントリを通してもルーターのものです。 ルーターが /list?page=1#item から /list?page=2 へソフトナビゲーションした後、#item のエントリへ「戻る」と、ページ 1 がフェッチされてスワップで戻ります。これにより、レンダリングされたページは常に URL と一致します。

  • 別の pathname 上の raw な null 状態のエントリは、ルーターが所有するドキュメントナビゲーションです。 history.pushState(null, "", "/app/item/2") は DOM が表示していないドキュメントを指すため、そこへの「戻る」/「進む」は他のページと同様にフェッチとスワップを行い、島は再マウントされます。別の pathname 上で独自の非 null 状態を持つ raw なエントリは、以前からこのように扱われています。オプトアウトはありません。pathname 間のルーティングを自前で行う島は、エントリを syncHistoryEntry() で書き込み、「戻る」ではページ間スワップになることを前提にしてください。

一つだけ残る例外があります。ルーターは pathname 上でレンダリングしたクエリを記憶しているため、まさにそのクエリを書き込む raw な push — たとえばルーターが ?page=1 を ?page=2 にスワップした後の ?page=1 — はそのドキュメントとして扱われ、「戻る」/「進む」で再フェッチされます。これを避けるには、アプリが所有するエントリに syncHistoryEntry() を使ってください。

プリフェッチ戦略

ルーターは、ユーザーがリンクをクリックする前にブラウザのキャッシュをウォームアップできます。各リンクは data-zfb-prefetch 属性でオプトインします。

リンクごとの属性

<!-- Prefetch when the link enters the viewport -->
<a href="/docs/api" data-zfb-prefetch="viewport">API docs</a>

<!-- Prefetch on pointer hover (default when prefetchAll is true) -->
<a href="/blog" data-zfb-prefetch="hover">Blog</a>

<!-- Prefetch on touchstart / mousedown (just before the click) -->
<a href="/pricing" data-zfb-prefetch="tap">Pricing</a>

<!-- Prefetch immediately after DOMContentLoaded (idle callback) -->
<a href="/contact" data-zfb-prefetch="load">Contact</a>

<!-- Opt this link out even when prefetchAll is true -->
<a href="/heavy-page" data-zfb-prefetch="false">Heavy page</a>

利用可能な戦略

戦略トリガー
"hover"pointerenter / focusin(キーボードフォーカス) — いずれもアイドルコールバックの遅延あり。pointerleave / focusout でキャンセル
"viewport"IntersectionObserver — リンクがビューに入ったときに発火
"tap"touchstart / mousedown — クリックイベントの直前に発火
"load"DOMContentLoaded の後の requestIdleCallback

キーボードフォーカスによるプリフェッチはホバーと同じ挙動をします。"hover" 戦略のリンクへ Tab キーでフォーカスを移すと、ポインタでホバーしたときと同じアイドルコールバックのプリフェッチがキューに入り、フォーカスを外すと発火前にキャンセルされます。そのため、キーボードのみのナビゲーションでもポインタホバーと同じプリフェッチの恩恵を受けられます。別途 data-zfb-prefetch の値を用意する必要はありません。

プリフェッチモジュールはサポートされている場合は <link rel="prefetch"> を使い、そうでなければ priority: "low" の fetch() にフォールバックします。プリフェッチは URL 単位で重複排除されます。低速な接続(Save-Data ヘッダ、2G/slow-2G)は、呼び出し側が ignoreSlowConnection: true を渡さない限りスキップされます。

prefetchAll

<ClientRouter /> に prefetchAll を設定すると、data-zfb-prefetch="false" を持たないすべての同一オリジンリンクを "hover" 戦略にオプトインします。

<ClientRouter prefetchAll />

これは、すべてのページのすべてのリンクに data-zfb-prefetch="hover" を追加するのと同等です。

サイト全体でプリフェッチを無効化する

zfb.config.ts で prefetch.disabled を設定すると、サイト全体のプリフェッチ配線を抑制できます。

// zfb.config.ts
import { defineConfig } from "zfb/config";

export default defineConfig({
  prefetch: { disabled: true },
});

このフラグが設定されると、バンドラはすべてのページに <meta name="zfb-prefetch-disabled" content="true"> タグを出力し、プリフェッチモジュールはランタイムで何もしなくなります。設定の完全なリファレンスは defineConfig を参照してください。

命令的なプリフェッチ API

data-zfb-prefetch では表現できないトリガー — リンクをプログラムから挿入する直前にウォームアップしたい場合や、カスタムイベントからプリフェッチを発火させたい場合など — には、prefetch() を直接呼び出します。

import { prefetch } from "@takazudo/zfb-runtime/client-router";

prefetch("/docs/api", { ignoreSlowConnection: true });
function prefetch(url: string, opts?: PrefetchOptions): void;

type PrefetchOptions = {
  ignoreSlowConnection?: boolean; // bypass the Save-Data / 2G skip described above
  with?: "link" | "fetch";        // force a transport instead of feature-detecting <link rel="prefetch"> support
};

prefetch() は url を現在のオリジンに対して解決し(クロスオリジンの href は黙ってスキップされます)、URL 単位で冪等です。すでにプリフェッチ済み、あるいは進行中の href に対する 2 回目の呼び出しは no-op になります。

<ClientRouter /> をマウントする代わりに自分でルーターの init() を呼び出す場合(上の警告を参照)、対応する prefetchInit() でプリフェッチのリスナーを自分で配線してください。ルーターの init とは異なり、こちらは /client-router サブパスだけでなく、ルートの @takazudo/zfb-runtime バレルからも再エクスポートされています。

import { prefetchInit } from "@takazudo/zfb-runtime";

prefetchInit({ defaultStrategy: "viewport" });
function prefetchInit(options?: PrefetchInitOptions): void;

type PrefetchInitOptions = {
  prefetchAll?: boolean;              // same effect as <ClientRouter prefetchAll />
  defaultStrategy?: PrefetchStrategy; // strategy prefetchAll opts links into; defaults to "hover"
};

ナビゲーションライフサイクルイベント

ルーターは各ナビゲーションの間に、6 つのカスタムイベントを document 上でディスパッチします。document.addEventListener でリッスンします。

document.addEventListener("zfb:before-preparation", (e) => {
  // e is a TransitionBeforePreparationEvent
  console.log("navigating from", e.from.href, "to", e.to.href);
});

例外が 1 つあります。これら 6 つのイベントは、同一ページ内の戻る/進むトラバーサルでは 1 つも発火しません。後述の 同一ページ内トラバーサル を参照。

イベントリファレンス

イベント名キャンセル可能発火するタイミング
zfb:before-preparationあり次のページがフェッチされる前。キャンセルするとナビゲーションを中止し、完全なブラウザロードにフォールバックします。
zfb:after-preparationなし次のページがフェッチされ、event.newDocument にパースされた後。
zfb:before-swapなし<head> と <body> が入れ替わる直前。
zfb:after-swapなしDOM スワップの直後。新しい body はライブですが、スクリプトはまだ再実行されていません。
zfb:page-loadなし新しいページのスクリプトが再実行され、島が再マウントされた後。受信ページにとっての DOMContentLoaded に相当します。
zfb:navigation-abortedなしナビゲーションがキャンセルされた(例: zfb:before-preparation での e.preventDefault()、あるいは進行中のナビゲーションが新しいものに取って代わられた)。

型付きのイベントアクセス

上記の各イベント名は TRANSITION_* という文字列定数としてもエクスポートされています。また、追加のプロパティを持つ 2 つのイベント(zfb:before-preparation、zfb:before-swap)は、具体的なクラスと型ガードによって裏付けられています。そのため、手動でのキャストなしにリスナー内で Event を型付きイベントへ絞り込めます。

import {
  TRANSITION_BEFORE_PREPARATION,
  isTransitionBeforePreparationEvent,
} from "@takazudo/zfb-runtime/client-router";

document.addEventListener(TRANSITION_BEFORE_PREPARATION, (e) => {
  if (!isTransitionBeforePreparationEvent(e)) return;
  console.log("navigating from", e.from.href, "to", e.to.href); // fully typed
});

利用可能なエクスポート: 上の表の各行に対応する 6 つの TRANSITION_* 定数、TransitionBeforePreparationEvent と TransitionBeforeSwapEvent の各クラス、そしてそれに対応する isTransitionBeforePreparationEvent / isTransitionBeforeSwapEvent 型ガードです。

zfb:before-preparation イベントのプロパティ

TransitionBeforePreparationEvent は Event を拡張し、次を持ちます。

event.from            // URL  — current page URL
event.to              // URL  — destination URL (writable)
event.direction       // "forward" | "back"
event.navigationType  // "push" | "replace" | "traverse"
event.sourceElement   // Element | undefined — the <a> or <form> that triggered navigation
event.info            // any  — value passed to navigate() options.info
event.newDocument     // Document  — starts as the current page; overwritten with the fetched document after event.loader() resolves (writable)
event.signal          // AbortSignal — aborted if a newer navigation supersedes this one
event.formData        // FormData | undefined — set for POST form submissions
event.loader          // () => Promise<void> — call to execute the default fetch; replace to use a custom loader

event.preventDefault() を呼び出すとナビゲーションを停止します。その後ルーターは、遷移先への完全なブラウザロードをトリガーします。

zfb:before-swap イベントのプロパティ

TransitionBeforeSwapEvent は同じ基底を拡張し、さらに次を公開します。

event.viewTransition  // ViewTransition — the active View Transition object
event.swap            // () => void — call to execute the default head/body swap; replace to implement a custom swap

例: swapFunctions からカスタムスワップを組み立てる

event.swap のデフォルトはルーター自身の swap() です。これは、5 つの細かいステップを順番に呼び出しているだけです。swap と、それらのステップ(deselectScripts・swapRootAttributes・swapHeadElements・swapBodyElement・saveFocus)をまとめた swapFunctions オブジェクトはどちらもインポート可能です。そのため、event.swap を、同じステップの一部を再利用するバリアントに差し替えられます。

import { swapFunctions } from "@takazudo/zfb-runtime/client-router";

document.addEventListener("zfb:before-swap", (e) => {
  // e is a TransitionBeforeSwapEvent — reuse every default step except the head swap.
  e.swap = () => {
    swapFunctions.deselectScripts(e.newDocument);
    swapFunctions.swapRootAttributes(e.newDocument);
    const restoreFocus = swapFunctions.saveFocus();
    swapFunctions.swapBodyElement(e.newDocument.body, document.body);
    restoreFocus();
  };
});

例: すべての SPA ナビゲーションの後にコードを実行する

document.addEventListener("zfb:page-load", () => {
  // Re-initialise analytics, syntax highlighters, etc.
  initHighlighter();
});

例: ナビゲーションをインターセプトしてリダイレクトする

document.addEventListener("zfb:before-preparation", (e) => {
  if (e.to.pathname.startsWith("/beta/")) {
    e.to = new URL(e.to.href.replace("/beta/", "/stable/"));
  }
});

同一ページ内トラバーサル

現在のページの pathname と search を共有する履歴エントリ(変わるのはハッシュかルーター追跡状態だけ)に着地する「戻る」や「進む」の操作は、完全にライブな DOM から処理されます。ルーターはフェッチせず、<head>/<body> を入れ替えず、何も再マウントしません。エントリの追跡済みスクロール位置を復元し、ページをそのままの状態に保ちます。島/クライアントの状態(開いているドロップダウン、フォーム入力、動画の再生など)はそのまま保たれます。

これが上のハッシュモーダルの例の背後にある仕組みです。syncHistoryEntry() の後に「戻る」を押しても、サーバーへの往復はなく、ページの他の部分も乱れません。モーダルコンポーネント自体は、閉じるために URL の変化に(popstate/hashchange を通じて)反応する必要があります。(ルーターはトラバーサルが関わる以前から、単純な同一ページ内の #anchor リンクではすでにフェッチをスキップしていました。この高速パスは、同じ考え方を戻る/進むに拡張したものです。)

ライフサイクルイベントとルートアナウンサーはスキップされる

何もフェッチもスワップもされないため、6 つの zfb:* ライフサイクルイベント(zfb:before-preparation から zfb:page-load まで)は、同一ページ内トラバーサルでは 1 つも発火しません。ARIA ルートアナウンサー(すべての SPA ナビゲーションの後に新しいページのタイトルをスクリーンリーダーへ読み上げる、画面外の .zfb-route-announcer 要素)も更新されません。これはフェッチ/スワップのフローの一部としてのみ書き込まれるためです。

ナビゲーションごとの副作用(アナリティクス、ウィジェットの再初期化など)を実行するためにライフサイクルイベントをフックしている場合、それらのフックは 2 つの同一ページ履歴エントリ間の戻る/進む操作では実行されません。

traverseRefetch で再びオプトインする

静的なページや事前レンダリングされたページは、これらを一切気にする必要がありません。それらのコンテンツは訪問の間に変わり得ないため、ライブな DOM から高速パスを提供するのは常に正しいのです。リクエストごとの SSR ページ(export const prerender = false)は事情が異なります。そのサーバーレンダリング出力は、同じ URL への 2 回の訪問の間で正当に異なり得ます(セッション依存のマークアップ、CSRF トークン、ライブデータなど)。そのため、以後のトラバーサルのたびに初回レンダリングの内容を固定するのは誤りになります。

そのようなページを traverseRefetch プロパティで再びフェッチにオプトインします。

<ClientRouter traverseRefetch />

これは <meta name="zfb-traverse-refetch" content="true"> を出力し、ルーターは高速パスを取る前に現在(対象)のページでこれをチェックします。存在する場合、同一ページ内トラバーサルは高速パスではなく通常のフェッチとスワップに落ちます。メタは現在ライブなページから読み取られるため、SPA ナビゲーションに参加するすべてのページで、同じ traverseRefetch の値を指定して <ClientRouter /> をマウントしてください。

bfcache をまたぐ戻る/進む

全体像をまとめると、「戻る」や「進む」の押下は次の 3 通りのいずれかで処理されます。

  1. 同一ページの高速パス — 上で説明したとおりです。遷移先が現在のページと pathname/search を共有しているため、ルーターは完全にライブな DOM から処理します。フェッチもスワップも再マウントもなく、島/クライアントの状態はそのまま保たれます。

  2. SPA のページ間スワップ — 遷移先が別のページである場合(あるいは現在のページが traverseRefetch でオプトアウトしている場合)、ルーターはリンククリックと同様にフェッチとスワップを行います。<head>/<body> が入れ替わり、島が再マウントされます。別の pathname を持つ syncHistoryEntry() のエントリからの「戻る」や、別のドキュメントを指す raw な null 状態のエントリもこれに含まれます(Raw History エントリと所有権を参照)。アプリ自身による同じ pathname 上の raw なクエリのエントリはアプリに任されます。

  3. ブラウザの bfcache 復元 — Safari をはじめとする WebKit ベースのブラウザは、このルーターを含むページ上の JavaScript を一切再実行せずに、ブラウザ自身の back/forward キャッシュから直接「戻る」/「進む」のナビゲーションを提供することがあります。この復元では、標準の pageshow イベントが event.persisted: true で発火します。ルーターはこれをリッスンし、復元された history.state から内部の管理情報 — 追跡している履歴インデックス、「遷移元」の URL、スクロール位置 — を再同期します。これにより、次の「戻る」/「進む」の押下も正しく計算されます。これはルーターの仕組みではなくブラウザレベルの復元であるため、ケース 1 と同様に、6 つの zfb:* ライフサイクルイベントは 1 つも発火しません。

View Transitions

ブラウザが document.startViewTransition をサポートしている場合、対象となるページ間スワップではネイティブの View Transition を使います。名前を付けるとスナップショットを個別にスタイル指定できますが、view-transition-name だけではブラウザ標準のフェードは消えません。すべてのナビゲーションがこの経路を通るわけではありません。同一ページ内トラバーサルではスワップ自体を省略でき、ブラウザ自身が視覚的な遷移を提供する場合はシミュレート経路でスワップします。formData を伴うナビゲーションも履歴の安全性のためシミュレート経路を使います。

独立した三つの制御

選択仕組み制御するもの
視覚的な同一性CSS の view-transition-nameどの旧・新スナップショットを同じ領域として扱うか。各ページ内で名前は一意である必要があります。
DOM と島の永続化data-zfb-transition-persist と <Island persist>ノードや島のインスタンスを維持するか。props が変わった場合は既存の再マウント契約に従います。
アニメーション方針ネイティブ疑似要素への CSSブラウザ標準、利用側が定義した動き、または明示的な静止。

静的要素の置換や島の再マウントが起きても、その領域を視覚的に静止させられます。逆に、DOM ノードの永続化だけではアニメーションするスナップショットから分離されず、動きも抑制されません。受信した内容は新しく保ってください。persistProps はアニメーションの修正手段ではありません。

一部の領域を静止させて本文を動かす

次は利用側が所有する CSS の例です。遷移に参加する全ページに適用し、領域のレイアウトと寸法をページ間で安定させます。

header { view-transition-name: global-region; }
main { view-transition-name: content-region; }

/* グループの位置・寸法の補間と、両画像のアニメーションを止める */
::view-transition-group(global-region),
::view-transition-old(global-region),
::view-transition-new(global-region) {
  animation: none;
  mix-blend-mode: normal;
}

/* 利用側の動きの例。zfb のモーションプリセットではない */
::view-transition-old(content-region) {
  animation: content-out 300ms ease both;
}
::view-transition-new(content-region) {
  animation: content-in 300ms ease both;
}
@keyframes content-out {
  to { opacity: 0; transform: translateX(24px); }
}
@keyframes content-in {
  from { opacity: 0; transform: translateX(-24px); }
}

通常、グループは寸法と位置を補間します。old/new のフェードだけを止めても、この補間は止まりません。old/new の規則は片側だけ存在する登場・退場にも適用されます。この例は領域の背景が不透明であることを前提にしています。両画像がある場合、通常の合成では新しい不透明なピクセルが古い画像を即座に覆いますが、透明な部分には古い内容が透けて見えることがあります。透明な領域を使う場合は、利用側でスナップショットの合成を設計し、別途検証してください。古い画像だけがある場合は、トランジション終了まで静止したまま残り、その後消えます。

view-transition-name: none は「独立した名前付きスナップショットを作らない」という意味で、「アニメーションしない」ではありません。その内容は祖先やルートのスナップショットに含まれ、その動きに従います。静止させる領域には独立した名前を付け、利用側が定義した祖先・グループの動きやネストしたトランジショングループも考慮してください。補間の抑制は、遷移先でレイアウトが変わっても画面上の座標が固定されるという保証ではありません。

コンテキストごとに名前を変える補助領域

同じコンテキストの定義は利用側が決めます。たとえば、同じセクション内では両ページに view-transition-name: context-alpha を出力し、別セクションでは context-beta にします。同名なら旧・新の画像が対になり、名前が変われば片側だけの退場・登場になります。対になった画像は静止させ、片側だけの場合に明示的な動きを付けられます。

::view-transition-group(context-alpha),
::view-transition-group(context-beta),
::view-transition-old(context-alpha),
::view-transition-new(context-alpha),
::view-transition-old(context-beta),
::view-transition-new(context-beta) {
  animation: none;
  mix-blend-mode: normal;
}
::view-transition-old(context-alpha):only-child,
::view-transition-old(context-beta):only-child {
  animation: content-out 300ms ease both;
}
::view-transition-new(context-alpha):only-child,
::view-transition-new(context-beta):only-child {
  animation: content-in 300ms ease both;
}

名前は例であり、zfb が予約する領域名やカテゴリ規則ではありません。DOM の永続化キーは別の選択です。名前を生成する場合は、一意で有効な CSS 識別子を使ってください。

利用側のアニメーション方針では、動きを減らす設定も尊重します。より詳細度の高い名前付きセレクタの規則にも適用してください。

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

実行中のトランジションを観測する

既存の zfb:before-swap イベントの viewTransition を使います。非同期の観測処理を登録したら、その場で戻ってください。ネイティブの更新コールバックが完了するまで ready は完了できません。差し替えた event.swap や、更新コールバックが待つ処理の中で ready を await しないでください。

import { isTransitionBeforeSwapEvent } from "@takazudo/zfb-runtime/client-router";

document.addEventListener("zfb:before-swap", (event) => {
  if (!isTransitionBeforeSwapEvent(event)) return;
  const transition = event.viewTransition;
  void transition.ready.then(
    () => console.log("スナップショットのアニメーション準備完了"),
    (error) => console.log("視覚的な遷移がスキップされたか利用できない", error),
  );
  void transition.updateCallbackDone.then(
    () => console.log("DOM 更新完了"),
    (error) => console.error("DOM 更新失敗", error),
  );
  void transition.finished.then(
    () => console.log("トランジション完了"),
    (error) => console.error("トランジション失敗", error),
  );
});

ネイティブの ready が reject されただけでは、DOM 更新失敗とは判断できません。アニメーションをスキップしても updateCallbackDone と finished は fulfill できます。シミュレート経路も似た Promise を公開しますが、ネイティブのスナップショットツリーはなく、タイミングも異なります。その ready は DOM 更新に続きます。zfb:page-load はネイティブアニメーションの終了を証明しません。

回帰テスト用の tests/router-chromium/build-native-regions-fixture.mjs は、静的要素の置換、永続化された祖先内の島、公開 API の <Island persist> と新しい props を扱います。ブラウザの spec は実在する group/old/new レイヤーを調べ、アニメーションを許可した実行中のフレームを撮影します。動く本文を正の対照とし、保護規則を意図的に削除する負の対照も含みます。check-native-regions-packed.mjs は隔離環境のローカル tarball と公開エクスポートで同じ検証を行います。新しいランタイム API なしでこの手法を検証するものであり、過去のバージョン間の回帰や、利用側アプリケーション全体の正しさを証明するものではありません。

機能検出

ルーターは 2 つのヘルパーを公開しています。

import {
  supportsViewTransitions,
  transitionEnabledOnThisPage,
} from "@takazudo/zfb-runtime/client-router";

// true if the browser has document.startViewTransition
console.log(supportsViewTransitions);

// true if the current page has <ClientRouter /> mounted
console.log(transitionEnabledOnThisPage());

supportsViewTransitions が false のとき、fallback プロパティが劣化した体験を制御します(フォールバックモード を参照)。

ナビゲーションをまたいで要素を永続化する

ソフトナビゲーションをまたいで破棄・再生成せずに生かし続けたい要素には、data-zfb-transition-persist="<id>" を追加します。古い body と新しい body の両方が、同じ id を持つその属性を備えていなければなりません。

<!-- Keeps this video player alive across page navigations -->
<video data-zfb-transition-persist="promo-video" src="/intro.mp4" autoplay />

ルーターは、body スワップの前に永続化された要素を <html> へ持ち上げ(Chrome 133+ では切り離しゼロの moveBefore() API を、そうでなければフォールバックの appendChild を使用)、新しい body 内の対応するターゲットへ再アタッチします。これは、Astro が WebGL コンテキストや再生状態を失わずに <canvas> や <video> を生かし続けるために使っているのと同じ仕組みです。

<Island> で島を永続化する

島には SDK の persist プロパティを使います。SDK は、クライアントランタイムが検証するコンポーネント、ビルド、transport、protocol、props のメタデータとともに、所有するラッパーへ永続化キーを出力します。

// レイアウトコンポーネント — サイドバーのツリーは開閉状態と
// スクロール位置を SPA ナビゲーションをまたいで維持します。
<Island persist="sidebar-tree">
  <SidebarTree {...props} />
</Island>

島を存続させたい各ページで、同じ空でない persist キーを使います。キーは各ドキュメント内で一意にしてください。重複すると、DOM のスワップ前にナビゲーションが失敗します。キーが一致すると、ラッパーとライブなコンポーネントインスタンスが維持されるため、メニューの開閉やフォーカス中の入力などの状態はリセットされません。

デフォルトでは、ナビゲーション時に受信ページの props へ更新します。props が同じなら現在のインスタンスを維持します。異なる場合、古いインスタンスを一度破棄し、受信 props で新しくマウントするため、前のページの古いデータを表示し続けません。この再マウントでは、古いインスタンスの内部状態は失われます。

一致するキーの間、現在の props と状態を維持するには persistProps を設定します。

<Island persist="sidebar-tree" persistProps>
  <SidebarTree {...props} />
</Island>

persistProps は、サイト共通のサイドバーのようにページ間で props が変わらない島に使います。boolean の SDK オプションで、デフォルトは false です。persistProps を有効にしても、コンポーネントまたはビルド識別子が変わる場合はルートを作り直します。ページ固有の目次や「最終更新」ウィジェットで古い props を維持すると、前のページのデータが表示されることがあります。

永続化された要素の内側にある島

data-zfb-transition-persist を持つ要素の内側なら、どの深さにある島でも、受信ページのその要素の内側に同じ島があれば、ナビゲーションをまたいでライブなコンポーネントインスタンスと内部状態を保ちます。同じ島とみなされるには、コンポーネント、ルートの種類、トランスポート、プロトコル、ビルドが一致する必要があります。data-zfb-transition-persist-props によって props のリフレッシュをオプトアウトしていない限り、data-props も同一でなければなりません。

これらの値のいずれかが異なる場合、古い島はクリーンアップされ、render モードで新しいインスタンスが作られます。props のリフレッシュが有効なら受信ページの props を使い、data-zfb-transition-persist-props でオプトアウトしていれば古い props を保ちます。その際、内部状態は失われます。受信ページの永続化された要素の内側に対応する島がない場合、古い島はクリーンアップされて取り除かれます。受信ページにだけ存在する島は追加されません。永続化された要素は受信ページのマークアップを取り込まず、既存の DOM をそのまま保持するためです。

同じコンポーネントの島が 1 つの永続化された要素の内側に複数ある場合、それらは文書順に対応付けられます。そのため順序を変えると、古いインスタンスが別の受信側の島に対応付けられることがあります。別の永続化された要素の内側にある永続化された要素は、親とともに移動します。

<ViewTransitions /> — 非推奨

@takazudo/zfb-runtime は <ViewTransitions /> もエクスポートしています。このコンポーネントは型付きの no-op であり、後方互換性のためだけに残されています。何もレンダリングせず、何も登録しません。

// Old code — compiles but does nothing
import { ViewTransitions } from "@takazudo/zfb-runtime";
<ViewTransitions />

// Use this instead
import { ClientRouter } from "@takazudo/zfb-runtime";
<ClientRouter fallback="animate" />

クロスドキュメント(MPA)の View Transitions — リンクをクリックすると、JavaScript ルーターなしでネイティブのブラウザアニメーションがトリガーされるもの — は、コンポーネントではなく、両方のページでの CSS @view-transition at-rule によって有効化されます。

/* global.css — applies to every page */
@view-transition {
  navigation: auto;
}

ブラウザのサポート状況とアニメーションのカスタマイズオプションについては MDN: @view-transition を参照してください。

Revision History

作成更新