zfb

Type to search...

to open search from anywhere

dev モードのライフサイクル

作成2026年6月1日Takeshi Takatsudo

.tsx ファイルを保存してからブラウザで変更が見えるまでに何が起こるか — ウォッチャー、リバンドル、SSR リフレッシュ、そして 3 つの SSE イベント種別。

ℹ️ このページで扱う内容

zfb dev の「保存からピクセルまで」のループ。ウォッチャーがどのように変更を検出するか、 オーケストレーターが何をリビルドするかをどう決めるか、そして 3 つの SSE イベント種別が どのように、不要なフルページリロードなしにブラウザを反応させるか。ビルドステップ全体の 順序については Build pipeline を、依存グラフがどのように リビルドを影響を受けるページに限定するかについては Incremental rebuild を参照してください。

このページは SSG / 静的 HTML の dev ループのみ を説明します。prerender = false の SSR ルートについては、zfb dev セッション中のソース編集は ホットリロードされません — V8 SSR バンドルは起動時の JS バンドルにバインドされており、再起動でのみ反映されます。下記の ティックごとのリビルドは SSR レンダラを リロードしません。それはレンダラがブートストラップ された際の SSG 出力を再レンダリングします。SSR ガイドの No live reload of the SSR bundle を参照してください。

.tsx ファイルを保存する。それから?

保存した瞬間、オペレーティングシステムがそのファイルのパスに対してファイルシステムイベントを 発火します。crates/zfb-watcher は関連するすべてのディレクトリ — pages/components/content/layouts/styles/data/public/、そして 2 つの設定ファイル zfb.config.jsonzfb.config.ts — を <code>notify</code> クレート経由で監視しています。(正規のリストは crates/zfb/src/commands/dev.rsDEFAULT_WATCH_ROOTS を参照してください。)

エディタの保存が単一のきれいなイベントであることはまれです。vim はスワップファイルを元の ファイルにリネームし、VS Code はメタデータ→データのイベントを立て続けに複数発行し、 git checkout は一度に数百を発火します。ウォッチャーのデバウンサーは、50ms の静寂ウィンドウ 内のすべてを単一の Change { path, kind } 値に統合します。kind フィールドは ChangeKind::CreatedChangeKind::ModifiedChangeKind::Removed のいずれかです。 OS が分類できないイベント種別はすべて Modified に折りたたまれ、実際の変更が静かに 取りこぼされることが決してないようにします。

バーストが収まりデバウンスウィンドウが閉じると、ビルドオーケストレーター(crates/zfb-build)が 変更を受け取ります。変更されたパスを携えて crates/zfb-graph を呼び出し、DirtySet を 受け取ります。Allzfb.config.ts のようなグローバルファイル)か、変更されたファイルを 実際にインポートしているページの Specific(set_of_page_ids) のどちらかです。ダーティセットの 外のページは触られません。(依存追跡の完全なストーリーは Incremental rebuild にあります。)

オーケストレーターはダーティセットから RebuildPlan を組み立てます。変更されたパスが アイランドルート(例: components/)の中にあれば、プランの rerun_islands フラグが 立てられます。CSS ソースが変更されていれば rerun_css が立てられます。その後、 DevAssetPipeline::apply() メソッドが次の順序で実行されます。

  1. ページの再レンダリング。 オーケストレーターはダーティセット内の各ページについてレンダラを 呼び出し、zfb dev 起動時にブートされた組み込み V8 ホストを通して SSG 出力を駆動します。 レンダリングされた各 RenderedPage.html は、直近の既知の出力とバイト単位で比較されます。 バイトが同一なら(セマンティックな HTML の変化を生まない純粋なリファクタリング)、そのページ についてはファイルが書き込まれず、リロードシグナルも送られません。V8 ホスト自体はティック ごとに リロードされません — 今日の dev では BuildContext::reload_rendererNone であり(crates/zfb/src/commands/dev.rs を参照)、レンダラはセッション全体を通して 起動時のバンドルにバインドされたままです。
  2. CSS パイプライン。 rerun_css が true のとき、Tailwind v4 + PostCSS が実行されます。 CSS 出力が前のティックとバイト単位で同一なら、Css イベントは発行されません。
  3. アイランドの再バンドル。 rerun_islands が true のとき、esbuild の Go バイナリの サブプロセスが呼び出されます。すべての "use client" コンポーネントをバンドルし、単一の 結合モジュールを 安定したファイル名dist/assets/islands.jscrates/zfb-types/src/asset_urls.rsSTABLE_ISLANDS_FILENAME 定数で、再エクスポートされてアイランドバンドラに消費されます) — に書き込みます。ファイル名にコンテンツハッシュはありません。なぜ dev ではファイル名が安定したままなのか を参照してください。
  4. ビルド結果のブロードキャスト。 パイプラインは BuildOutcome 構造体を返します。 crates/zfb-server/src/livereload.rsoutcome_to_events() がその結果を検査し、 /__zfb/reload の SSE チャネル経由でブロードキャストされる ReloadEvent 値にマッピング します。あなたのサイトを開いているすべてのブラウザタブはそのチャネルを購読しており、即座に 反応します。

3 つの SSE イベント種別

結果のトリガーイベントブラウザの振る舞い
pages_written.len() > 0Page完全な location.reload()
css_changedCssすべての <link rel="stylesheet"> をホットスワップ — ドキュメントをリロードせずにブラウザキャッシュを破棄するため ?v=<timestamp> を付加
islands_bundle.is_some()Islands { component, bundle_url }新しいバンドル URL の動的 import()(キャッシュ破棄の ?v=<timestamp> 付き)。新しくインポートされたモジュールがハイドレーションを実行し、デフォルトでは現在のページ上の すべての [data-zfb-island] 要素を再マウント — ドキュメントのリロードなし

複数のイベントが同じティックで発火すると、サーバーは該当するイベントをすべて発行し、 接続中のすべてのタブが同じ SSE チャネルを購読しており、それらすべてを受け取ります。 ブラウザはイベントを到着順に処理します。Page イベントを受け取ると、各タブは location.reload() を呼び出し、現在のドキュメントを破棄します — これにより、同じティックで 発行された CssIslands イベントは、そのタブにとって無意味になります。「このタブだけが フルリロードを受け取る」というパターンはここには存在しません。インプレースの CssIslands のスワップは、そのティックでアクティブなタブだけでなく、購読しているすべてのタブに とって無意味です。

Islands について 1 つの詳細: 今日の dev モードでは、BuildContext::run_islands は ビルド側のペイロードが現状アイランドごとの名前を提供しないため、outcome_to_events()components: Vec::new() を報告します。そのためサーバーは component: "" と安定した バンドル URL(/assets/islands.js)を持つ 単一の Islands イベントを発行します。 /__zfb/livereload.js のクライアントスクリプトは bundleUrl だけを読み、新鮮な タイムスタンプ(?v=<timestamp>)を付加し、その結果を動的インポートします。インポートされた バンドルのトップレベルのハイドレーションコードが実行され、ページ上のすべての [data-zfb-island] を巡回します。つまり、単一のアイランドティックは、デフォルトでは単一の コンポーネントではなくページ全体を再ハイドレートします。

ターゲットを絞った再ハイドレーションはオプトインです。 クライアントがユーザー提供の window.__zfbIslandsReload(component, swapUrl) 関数を検出すると、プレーンな動的インポートを 行う代わりに、そのフックにインポートを委譲します。アイランドのホットスワップを通してスクロール 位置やコンポーネントの状態を保持したいアプリケーションは、このフックをインストールし、どの コンポーネントを再マウントするかを自分自身で決めます。フックがなければ、デフォルトのページ全体の 再ハイドレーションが実行されます。

なぜ dev ではファイル名が安定したままなのか

本番ビルドはコンテンツハッシュ付きのアセット URL(/assets/islands-abc12345.js)を使います。 これにより、デプロイされた CDN レスポンスを無期限にキャッシュでき、新しいデプロイで変更された アセットは新鮮な URL を得ます。ハッシュはファイルの内容によって決まり、バンドルが変わるたびに 変化します。

dev モードは意図的にコンテンツハッシュをスキップします。出力は dist/assets/islands.js に 配置されます — 毎ティック同じ URL です。これが、SSE 駆動のホットスワップを機能させる URL コントラクトの保証です。ブラウザが Islands イベントを受け取ったとき、新しいバンドルが 常に同じベース URL で到達可能だと分かっています。リビルドのたびに URL を変えると、ブラウザに スワップ元のキャッシュ参照がなくなるため、フルページリロードが強制されてしまいます。

コンテンツハッシュは本番パイプラインの責務です。dev では安定した名前が設計上正しいのであって、 見落としではありません。

実際にはどういう意味になるのか

典型的な 3 つの編集シナリオと、どのイベントが発火するか。

.tsx アイランドコンポーネントの本体のみを編集する。 ウォッチャーがコンポーネントファイルで 発火します。依存グラフはそれを消費するページをダーティとしてマークし、オーケストレーターは まず影響を受けるページを再レンダリングし、その後アイランドを再バンドルします。レンダリングされた HTML が変わっていれば、Page イベントが発火し、ブラウザがリロードします。HTML がバイト単位で 同一なら(例: サーバーレンダリングに決して到達しないクライアント側の状態ロジックだけを変えた 場合)、Islands イベントだけが発火します — 新しいバンドルが動的インポートされ、その ハイドレーションが実行され、ページ上のすべてのアイランドを再マウントします。そのスワップを通して スクロール位置やコンポーネントごとの状態を保持するには、window.__zfbIslandsReload フックを インストールしてください(3 つの SSE イベント種別を参照)。

アイランドを消費するページファイルを編集する。 ページとアイランドの両方がダーティです。 オーケストレーターはページを再レンダリングし、HTML はほぼ確実に変わり、Page イベントが 発火します。ブラウザは最新のサーバーレンダリング済み HTML で新鮮なフルページロードを得ます。 そのタブの同時並行の Islands イベントは、リロードによって無意味になります。

CSS ファイルのみを編集する。 ページはダーティにならず、アイランドの再バンドルもありません。 CSS パイプラインが実行され、出力が変わっていれば Css イベントが発火します。ブラウザは スタイルシートをインプレースでスワップします — ドキュメントのリロードはなく、スクロール位置と クライアント側の状態はすべて保持されます。

関連

  • Build pipeline — CLI から dist/ までの完全なパイプラインと、dev モードがその上にどう乗るか
  • Incremental rebuild — リビルドを影響を受けるページに限定する依存グラフと DirtySet
  • Islands"use client" がどのようにコンポーネントをアイランドバンドルにオプトインさせるか
  • SSR and Cloudflare Bindings — dev における prerender = false ルートの再起動のみという制約を含む

Revision History