11 min read

サーバーレス時代のバグハント: Astro View Transitionsとフォーム二重送信の怪 - Vol.9

サーバーレス時代のバグハント: Astro View Transitionsとフォーム二重送信の怪 - Vol.9

🚨 緊急事態発生:予約ができない!?

某・海鮮テイクアウト専門店の予約サイトをリリースして数日後、クライアントから緊急の連絡が入りました。

「お客様から『合計金額が0円のまま動かない』と言われています!あと、たまに同じ予約が2回届きます!」

リリース前のテストでは完璧に動作していたはずのシステム。なぜ本番環境でのみ、しかも特定のタイミングで不具合が起きるのか? 調査の結果、「モダンなWebフレームワークの落とし穴」「ブラウザの基本仕様」 が絡み合った、非常に興味深いバグであることが判明しました。

今回は、このトラブルシューティングの記録を技術的な知見として共有します。


🔍 Case 1: 合計金額が計算されない

現象

ページを開いて数量を変更しても、合計金額が 0円 のままピクリとも動かない。 しかし、スーパーリロード(キャッシュ削除して再読み込み)すると直る。

原因:初期化トリガーの消失

このサイトでは Astro を採用していますが、パフォーマンス向上のために将来的に View Transitions(ページ遷移時のSPA風挙動)を導入する準備をしていました。

その過程で、スクリプトの初期化処理を以下のように書き換えていました。

// ❌ 修正前のコード
document.addEventListener('astro:page-load', () => {
    // 初期化処理 (init)
});

astro:page-load は、AstroのView Transitionsが有効なページ遷移完了時に発火するイベントです。 しかし、View Transitions がまだ有効になっていない(<ClientRouter />がない)ページでは、初回ロード時にこのイベントは発火しません。 さらに、DOMContentLoaded での初期化コードを誤って削除してしまっていたため、「スクリプトは読み込まれるが、初期化関数が誰からも呼ばれない」 状態になっていました。

スーパーリロードで直っていたのは、ブラウザがキャッシュを無視してスクリプトを強制再評価した際に、タイミングの妙でたまたま動いていた(あるいは古いキャッシュがクリアされた)ためと考えられます。

解決策:ハイブリッド初期化パターン

どのような状況でも確実にJavaScriptを実行させるため、3段構えの初期化パターン を実装しました。

// ✅ 修正後のコード (Hybrid Initialization Pattern)

// 1. 初回ロード & リロード用 (DOMContentLoaded / 即時実行)
//    View Transitions無効時や、通常のページアクセス用
if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
} else {
    init(); // 既にDOMができているなら即時実行
}

// 2. Astro View Transitions用 (ページ遷移後)
//    将来的にView Transitionsを有効にした際のため
document.addEventListener("astro:page-load", () => {
    isInitialized = false; // フラグ・クリーンアップ
    init();
});

// 3. bfcache復元用 (戻る/進むボタン)
//    ブラウザの「戻る」ボタンで戻った際はスクリプトが再実行されないため
window.addEventListener("pageshow", (event) => {
    if (event.persisted) {
        isInitialized = false;
        init();
    }
});

これで、「普通に開く」「リンクから飛ぶ」「戻るボタンで戻る」の全てに対応できました。


🔍 Case 2: フォーム二重送信の怪

現象

予約フォームの送信ボタンを1回クリックしただけなのに、Google Sheetsには 全く同じデータが2行 記録され、お客様にもメールが2通届く。 タイムスタンプは 3秒 ほどのズレがある。

原因A:物理的な連打

送信処理中はローディング画面を出していましたが、ボタン自体は押せる状態(disabledになっていない) でした。 回線が遅い場合やレスポンス待ちの間に、ユーザーが不安になって「連打」してしまうことが可能でした。

原因B:論理的な重複

Case 1の修正過程で、スクリプトが複数回実行される可能性が出ました。その際、addEventListener が重複して実行され、「1回のクリックで2回関数が呼ばれる」 状態になっていました。

解決策:物理ロック & 論理ロック

ここでも二段構えの対策を行いました。

1. ボタンの即時無効化 (Physical Lock)

クリックされた瞬間にボタンを無効化します。

form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const submitBtn = form.querySelector('button[type="submit"]');

    // 🚀 クリック直後に無効化!
    if (submitBtn) submitBtn.disabled = true;

    try {
        await sendData(); // 送信処理
        // ...
    } catch (err) {
        // ❌ エラー時のみ再度押せるように戻す
        if (submitBtn) submitBtn.disabled = false;
    }
});

2. イベントリスナーのシングルトン管理 (Logical Lock)

リスナーを登録する前に、必ず「以前のリスナー」を削除するようにします。これにより、スクリプトが何度再実行されても、イベントリスナーは常に1つだけになります。

// グローバル変数(windowオブジェクト)でハンドラを管理
if (window._ehomakiSubmitHandler) {
    // 🗑️ 既存のハンドラがあれば削除
    form.removeEventListener("submit", window._ehomakiSubmitHandler);
}

const handler = async (e) => { ... };

window._ehomakiSubmitHandler = handler;
form.addEventListener("submit", handler);

💡 まだ終わらない:Chromeキャッシュの罠

修正したコードをデプロイし、Safariで動作確認。「よし、直った!」と安心したのも束の間、「Chromeだと直ってないです」 という報告が。

原因:強固なブラウザキャッシュ

Chromeはパフォーマンス向上のため、JavaScriptファイルのキャッシュを非常に積極的に利用します。 ファイルの中身を変えても、ファイル名(URL)が同じなら、ブラウザはサーバーに問い合わせずローカルの古いファイルを使い続けます。

解決策:Cache Busting (キャッシュバスター)

スクリプトを読み込むURLに、パラメータとしてバージョン番号(日付)を付与しました。

<!-- Before -->
<script src="/js/ehomaki.js?v=20260110"></script>

<!-- After: 日付を更新 -->
<script src="/js/ehomaki.js?v=20260113"></script>

URLが ...js?v=20260113 に変わることで、ブラウザは「お、新しいファイルだな?」と認識し、確実に最新のコードを取得してくれます。


Summaries

フレームワークやライブラリがいかに進化しても、最終的に動くのはブラウザです。

  1. ライフサイクルを知る: フレームワークのイベントだけでなく、ブラウザの標準イベント (DOMContentLoaded, pageshow) も理解する。
  2. ユーザーの行動を予測する: 「ボタンは連打されるもの」として設計する。
  3. キャッシュを制御する: 新しいコードを届けるための仕組み (Cache Busting) を忘れない。

今回のバグハントを通じて、より堅牢な予約システムへと進化させることができました。

Webブラウザの仕組みを知る

Browser Tech Lab

Recommend: レンダリングエンジンの挙動からキャッシュ仕様まで、フロントエンドエンジニアなら知っておきたい低レイヤーの知識。

🤖 Antigravity Prompt

このバグハントと修正をAIエージェントと行うために使用できる、具体的な指示プロンプトの例です。

# 依頼: スクリプト初期化バグの原因分析と恒久修正

## 現象
リリースした予約サイトで、以下の不具合が発生しています。
1.  **計算が動かない**: 初回ロード時に合計金額が計算されない。スーパーリロードすると直る。
2.  **二重送信**: 予約フォームから送信すると、同じデータが2回送信されることがある(3秒差など)。

## 技術的背景
- Astro 5 を使用。
- View Transitions を将来的に導入予定だが、現在は `<ClientRouter>` は未使用。
- `ehomaki.js` で初期化処理を行っている。

## 指示
プロのWebエンジニアとして、以下のステップで修正案を提示してください。

1.  **原因分析**:
    - `astro:page-load` イベントが、View Transitions 無効時に発火するか確認してください。
    - ブラウザの「戻る」ボタン使用時(bfcache)のスクリプト挙動を考慮してください。
2.  **修正実装**:
    - 「初回ロード」「Astro遷移」「bfcache復元」のすべてに対応する**ハイブリッドな初期化パターン**を実装してください。
    - フォーム送信ボタンは、クリック直後に `disabled` になるよう物理的にロックしてください。
    - イベントリスナーは `window` オブジェクト等を利用して、重複登録されないようシングルトン管理してください。
3.  **キャッシュ対策**:
    - コード修正後もChromeでキャッシュが残る問題を防ぐため、スクリプト読み込みタグにバージョン番号 (`?v=YYYYMMDD`) を付与してください。
Share:

Related Posts