アプリページに client:only を使わない:静的シェルと明示的なマウント
ゲームとツールのページは「シェル + createApp」で、client:only ではありません。読み込み・成功・失敗の三状態を自分で持ち、再試行ボタンも本当に動きます。
ゲームとツールのページには共通点があります。どちらも [slug] の動的ルートで、コンポーネントが URL によって変わります。まさに <Component client:only> が役に立たなくなる場面です。
client:only が与えてくれないもの
client:only がやることは一つ、SSR を飛ばしてブラウザで描画すること。読み込み状態も、失敗状態も、再試行も与えてくれません。
結果として三つを自分で作ることになります。chunk の取得中はページが空白で、読み込み中か壊れているか分かりません。マウントが例外を投げてもコンソールに行くだけで画面は空のままです。再試行したければ、結局は自前の状態機械を書くことになります。
しかも動的ルートではコンポーネントは変数で、ハイドレーション指令はコンパイル時に確定します。逆らうほうが手間です。
シェルの形
AppStage.astro は状態を持つコンテナを一つ出します。
<div data-app-stage data-state="loading">
<div data-app-root data-app-kind="games" data-app-slug="snake" data-app-locale="zh"></div>
</div>
マウンターは data-* 属性を読むだけで、個々のアプリを知りません。
const loader = loaders[loaderKey(kind, slug)];
const mod = await loader();
createApp(mod.default, { locale }).mount(root);
三状態は CSS が持つ
data-state が変われば、どのブロックを表示するかはスタイルが決めます。読み込み中の文言もエラーパネルも再試行ボタンも静的な HTML として出荷され、JS に依存しません。スクリプトが落ちても、ユーザーは起動できなかったこととボタンを見られます。
各アプリは独立した chunk のまま
レジストリはリテラルの相対パスで import.meta.glob を使います。
const loaders = {
...import.meta.glob('../games/*/App.vue'),
...import.meta.glob('../tools/*/App.vue'),
};
Vite はビルド時にこれを「ファイルパス → 動的 import」のマップへ展開し、各 App.vue が個別の chunk になります。開いたアプリだけがダウンロードされます。ここでエイリアスは使えません。Vite はリテラルの相対パスしか受け付けません。
代償
アプリページは内容ページのようなゼロ JS ではありません。マウンター本体と最低一回の import() を運びます。内容ページは Vue ランタイムを一切読み込みません。線は「このページはアプリか」で引かれています。
読み込み中と失敗は、フレームワークが与えるものではなく、そのページがもともと持っている二つの状態です。手放すことは、設計からその二つを消すことと同じです。

コメント
…