メインコンテンツへスキップ
#Web制作 約9分で読めます

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 #SSR
シェア