Astro Server Islands の動的フォームが予想外に複雑だった話
Server Islands で問い合わせフォームを実装したら、Actions が動かない・URL が取れない・状態が共有できないと三重苦だった記録
フォームを Server Island にしたら何も動かなかった
問い合わせフォームをログイン状態によって出し分けたくて、Server Islands に載せた。server:defer を付ければコンポーネント単位でオンデマンドレンダリングできるから、ページ全体を SSR にしなくて済む。理屈は合っている。
<ContactForm server:defer>
<FormSkeleton slot="fallback" />
</ContactForm>
フォーム自体はすぐ表示された。が、送信ボタンを押しても何も起きない。Astro Actions で定義した submitContact が呼ばれていない。ブラウザの Network タブを見ても POST リクエストが飛んでいない。
原因を調べて、これが「仕様」だとわかるまでにけっこう時間を使った。
Actions が Server Island の中で動かない理由
Server Islands は初回のページリクエストとは別の HTTP リクエストで描画される。ブラウザが /_server-islands/ContactForm に GET(props が大きければ POST)を投げて、返ってきた HTML でフォールバックを差し替える仕組みになっている。
つまり、フォームを action 属性で送信しても、それはページの POST ではなく Island エンドポイントへのリクエストになる。Astro.getActionResult() は初回の POST リクエストに対して結果を返す関数だから、別リクエストで動いている Server Island の中では結果を取得できない。これは GitHub Issue #12371 で「仕様通り」としてクローズされている。
さらに、Issue #12736 では、Server Island 内のフォームから Actions 自体が発火しないケースも報告されている。ページが prerender されていると、フォーム送信が Actions のハンドラに到達しない。
結局やったのは、フォームの送信処理を Server Island の外に出すことだった。フォームの表示(ログイン判定・フィールドの出し分け)だけを server:defer で動的にし、送信は素の <form> の action か、クライアントサイドの fetch で Actions を叩く構成に変えた。
---
// ContactForm.astro(Server Island)
// ここではフォームのUIを組み立てるだけ
const session = Astro.cookies.get("session");
const user = session ? await getUser(session.value) : null;
---
{user ? (
<form method="POST" action="/api/contact">
<input type="hidden" name="userId" value={user.id} />
<textarea name="message" required></textarea>
<button type="submit">送信</button>
</form>
) : (
<p>ログインしてください</p>
)}
Actions を使いたいなら、フォームの action を Server Island の外側のページに向けるか、JavaScript で actions.submitContact() を呼ぶ。Server Island はあくまで「動的な HTML の組み立て」に徹させるのが、いまのところ無難な線だと感じた。
Astro.url が自分のページを知らない
次にぶつかったのが Astro.url の問題。フォームの送信先を現在のページパスに応じて切り替えたかったが、Server Island の中で Astro.url を参照すると /_server-islands/ContactForm が返ってくる。ページの URL ではない。
公式ドキュメントでは Referer ヘッダーから元ページの URL を取る方法が案内されている。
---
const referer = Astro.request.headers.get("Referer");
if (!referer) throw new Error("Referer header is missing");
const url = new URL(referer);
const lang = url.pathname.startsWith("/en") ? "en" : "ja";
---
これで一応動く。ただし View Transitions(ClientRouter)を使っていると、navigate() でページ遷移したとき Referer が前のページの URLになる。SPA 的に DOM を差し替えているだけで、ブラウザの認識する「現在のページ」がまだ更新されていないからだ。Issue #12879 に報告があり、navigate() の前に history.pushState() を呼ぶワークアラウンドが示されている。
// navigate()の前にブラウザの履歴を更新する
history.pushState(history.state, '', url.href);
Referer ヘッダー自体、ブラウザのプライバシー設定やリファラーポリシーで送られない場合がある。本番で使うなら if (!referer) のフォールバックは必須。
props のサイズ上限と暗号化キー
Server Islands に渡す props は暗号化されて URL のクエリ文字列に載る。ここにもう一つ罠がある。ブラウザの URL の上限はおおよそ 2048 バイトで、それを超えると Astro が自動的に GET から POST にフォールバックする。POST になるとブラウザキャッシュが効かなくなるから、props にフォームの初期値を大量に突っ込むと、毎回サーバーにリクエストが飛ぶことになる。
対策はシンプルで、props には ID だけ渡して、コンポーネントの中でデータを取得する。
<!-- NG: 大きなオブジェクトを丸ごと渡す -->
<ContactForm server:defer formConfig={largeConfigObject} />
<!-- OK: IDだけ渡して中でfetchする -->
<ContactForm server:defer formId="contact-ja" />
もう一つ、暗号化キーの問題。Astro はビルドごとにランダムなキーで props を暗号化する。ローリングデプロイやマルチリージョン CDN を使っていると、古いビルドの HTML が新しいビルドのサーバーにリクエストを投げて復号に失敗する。astro create-key で生成した固定キーを ASTRO_KEY 環境変数にセットしておけば防げるが、デプロイパイプラインに一手間増える。
「Island に何を載せるか」の判断が一番難しい
技術的なハマりポイントを並べたが、一番時間を使ったのは設計判断だった。
Server Islands はリクエストごとに独立している。Island 同士で状態を共有する仕組みは、クライアント側で Nano Stores などを使わない限りない。フォームのバリデーション結果を別の Island に反映させたい、送信後にヘッダーのユーザー名を更新したい、といった「ページ内の連携」は Island だけでは解決しない。
最終的に落ち着いた方針はこうなった。
- Server Island に載せるもの: ログイン状態の判定、ユーザー情報に依存する表示の出し分け、パーソナライズされた UI
- Server Island に載せないもの: フォームの送信処理、送信後のフィードバック表示、Island 間で共有が必要な状態
フォームの「見た目」は Server Island、フォームの「振る舞い」はページ側か JavaScript。この切り分けに辿り着くまでに、Actions を Island の中に入れては動かず、Referer を参照しては古い URL が返り、props を詰め込んではキャッシュが切れ、という試行錯誤があった。
Server Islands は静的サイトに動的パーツを埋め込む仕組みとしてはよくできている。ただし「動的パーツ」がフォームのようにユーザーの入力を受けてサーバーとやり取りするものになると、Island の独立したリクエストモデルが制約として効いてくる。ドキュメントを読んだだけだと気づきにくい部分なので、書き残しておく。
参考リンク
- Astro 公式ドキュメント — Server Islands
- Astro 公式ドキュメント — Actions
- GitHub Issue #12371 — server:defer と Astro.getActionResult の非互換
- GitHub Issue #12736 — Server Island 内のフォームから Actions が発火しない問題
- GitHub Issue #12879 — ClientRouter 使用時の Referer ヘッダー問題
- Building a view counter with Astro’s Server Islands and Actions — Thomas Ledoux