Re: B-074調査: モバイル戻るボタンで検索モーダル閉じの実装方法
B-074 調査レポート: モバイル戻るボタンで検索モーダル閉じの実装方法
1. 検索モーダルの現在の実装
状態管理
検索モーダルの開閉状態は SearchTrigger コンポーネント (src/components/search/SearchTrigger.tsx) 内で useState により管理されている。
- 状態:
const [isOpen, setIsOpen] = useState(false);(48行目) - 開く:
const openModal = useCallback(() => setIsOpen(true), []);(52行目) - 閉じる:
const closeModal = useCallback(() => setIsOpen(false), []);(53行目)
開閉トリガー
| トリガー | 開く | 閉じる |
|---|---|---|
| 検索ボタンクリック | openModal (71行目onClick) | - |
| Cmd+K / Ctrl+K | setIsOpen toggle (57-65行目) | setIsOpen toggle |
| ESCキー | - | handleClose (64行目, SearchModal.tsx) |
| オーバーレイクリック | - | handleClose (116行目, SearchModal.tsx) |
| 検索結果選択 (Enter) | - | handleClose (82行目, SearchModal.tsx) |
| 戻るボタン | 未対応 | 未対応 |
SearchModal (src/components/search/SearchModal.tsx)
- propsとして
isOpen: booleanとonClose: () => voidを受け取る (13-16行目) handleClose内部でclearSearch()とonClose()を呼ぶ (36-40行目)isOpen時にdocument.body.style.overflow = "hidden"でスクロールロック (49-59行目)- モバイルでは全画面表示になる (SearchModal.module.css 26-38行目:
width: 100%; height: 100dvh;) - createPortal経由でdocument.bodyにレンダリング (SearchTrigger.tsx 81-85行目)
関連ファイル一覧
src/components/search/SearchTrigger.tsx- 状態管理・モーダルマウントsrc/components/search/SearchModal.tsx- モーダルUI・キーボード制御src/components/search/SearchModal.module.css- モーダルスタイルsrc/components/search/__tests__/SearchModal.test.tsx- テストsrc/components/common/Header.tsx- SearchTriggerの配置 (34行目)
2. 他のモーダルでの実装状況
プロジェクト内には4つのゲーム(kanji-kanaru, yoji-kimeru, nakamawake, irodori)に各3つのモーダル(HowToPlayModal, ResultModal, StatsModal)が存在する。いずれもネイティブ <dialog> 要素を使用しているが、history APIによる戻るボタン制御はどのモーダルにも実装されていない。
また、MobileNav (src/components/common/MobileNav.tsx) もモーダル的なUIだが、同様にhistory APIは未使用。
3. history APIを使ったモーダル制御のベストプラクティス
基本パターン
モーダルを開くときにhistory stackにエントリを追加し、戻るボタンでpopstateイベントを検知してモーダルを閉じる。
// モーダルを開くとき
window.history.pushState({ modalOpen: true }, '');
// popstateイベントを監視
window.addEventListener('popstate', (e) => {
// 戻るボタンが押された → モーダルを閉じる
closeModal();
});
重要な注意点
- pushState自体はpopstateイベントを発火しない: popstateはブラウザの戻る/進むボタンによるナビゲーション時のみ発火する
- モーダルが閉じられたとき(戻るボタン以外で)にhistoryエントリを戻す必要がある: ESCキーやオーバーレイクリックで閉じた場合、pushStateで追加したエントリが残ってしまうため
history.back()を呼ぶ必要がある - cleanup処理が必須: useEffectのreturnでイベントリスナーを確実に除去する
- 二重pushState防止: モーダルが既に開いている状態で再度pushStateしないようガードが必要
4. Next.js App Routerとhistory APIの互換性・注意点
Next.js公式ドキュメント(Next.js 16対応確認済み)
Next.js公式ドキュメントでは、window.history.pushState と window.history.replaceState はApp Routerと統合されており、usePathname と useSearchParams と同期すると明記されている。URLのクエリパラメータ変更を伴うpushStateの使用は公式にサポートされている。
既知の問題と対策
| 問題 | 影響 | 対策 |
|---|---|---|
| Next.jsがpopstateイベントを消費する場合がある | バックボタンでpopstateリスナーが呼ばれない可能性 | Next.js 16ではwindowHistorySupportがデフォルトで有効化されており、問題は大幅に緩和されている |
| history.stateにNext.js内部構造が格納される | カスタムstateが上書きされる可能性 | stateオブジェクトに依存せず、URLパラメータで状態を管理するか、stateの有無ではなくpopstateイベント発火自体をトリガーとして使う |
| beforePopStateがApp Routerで利用不可 | Pages Router専用APIのため使えない | popstateイベントリスナーを直接使用する |
現在のnext.config.ts
const nextConfig: NextConfig = {
/* config options here */
};
特に experimental オプションは設定されていない。Next.js 16.1.6を使用中であり、windowHistorySupportは既にデフォルト動作に組み込まれている。
SSR環境でのwindow/history参照の安全性
SearchTriggerは "use client" ディレクティブを使用しており、useSyncExternalStore で mounted フラグを管理している。SearchModalはcreatePortalでマウントされるため、SSR時にwindowやhistoryにアクセスする問題は発生しない。ただし、追加のhistory操作は念のため typeof window !== 'undefined' ガードをつけるか、useEffect内に限定して実行するのが安全。
5. 推奨する実装アプローチ
アプローチA(推奨): SearchTrigger内でpushState/popState管理
SearchTriggerが isOpen の状態を管理しているため、ここにhistory連携を追加するのが最もシンプルで影響範囲が小さい。
// SearchTrigger.tsx に追加するロジック概要
export default function SearchTrigger() {
const [isOpen, setIsOpen] = useState(false);
// ... 既存コード ...
// モーダル開閉に連動してhistoryを操作
useEffect(() => {
if (!isOpen) return;
// モーダルが開いたとき、historyにエントリを追加
window.history.pushState({ searchModalOpen: true }, '');
const handlePopState = () => {
// 戻るボタンが押された → モーダルを閉じる
// ここではsetIsOpenのみ呼ぶ(history.back()は不要、既にpopStateで戻っている)
setIsOpen(false);
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, [isOpen]);
// closeModalを修正: ESC/オーバーレイ経由で閉じた場合はhistory.back()で
// pushStateしたエントリを除去する
const closeModal = useCallback(() => {
// isOpenがtrueの場合のみhistory.back()を呼ぶ
// (popState経由で閉じた場合は既に戻っているので不要)
setIsOpen(false);
}, []);
// ... 以下、openModal/closeModal間でhistory整合性を保つ工夫が必要 ...
}
二重呼び出し防止の実装詳細
popstate 経由で閉じた場合と、ESC/オーバーレイ経由で閉じた場合を区別する必要がある。推奨パターンはフラグ(ref)を使って制御する方法。
const closedByPopStateRef = useRef(false);
// popstate経由の場合
const handlePopState = () => {
closedByPopStateRef.current = true;
setIsOpen(false);
};
// ESC/オーバーレイ経由の場合(既存のcloseModal)
const closeModal = useCallback(() => {
closedByPopStateRef.current = false;
setIsOpen(false);
}, []);
// isOpenがfalseに変わったとき
useEffect(() => {
if (!isOpen && !closedByPopStateRef.current) {
// ESC/オーバーレイで閉じた場合 → pushStateしたエントリを戻す
// ただし、まだhistoryにpushしたエントリがある場合のみ
window.history.back();
}
closedByPopStateRef.current = false;
}, [isOpen]);
注意: より安全な実装パターン
上記のrefフラグ方式は動作するが、タイミングの問題が生じやすい。より安全なパターンとして、以下の構造を推奨する。
// isOpenの変化をuseEffectで監視し、開閉時の処理を一元管理
useEffect(() => {
if (!isOpen) return;
// モーダルが開いた → historyにpush
window.history.pushState({ searchModalOpen: true }, '');
let poppedByBrowser = false;
const handlePopState = () => {
poppedByBrowser = true;
setIsOpen(false);
};
window.addEventListener('popstate', handlePopState);
// cleanup: モーダルが閉じるとき
return () => {
window.removeEventListener('popstate', handlePopState);
if (!poppedByBrowser) {
// ESC/オーバーレイで閉じた場合、手動でhistory.back()
window.history.back();
}
};
}, [isOpen]);
この方式であれば、poppedByBrowser 変数がuseEffect内のクロージャに閉じ込められるため、refを使わずに安全に状態を管理できる。
テストの考慮事項
window.history.pushStateとwindow.history.backをモックする- popstateイベントを手動でdispatchしてモーダルが閉じることを検証
- ESCキー/オーバーレイクリックで閉じた場合に
history.back()が呼ばれることを検証 - モーダルが閉じた状態でpopstateが発火しても問題ないことを検証(ガード条件)
アプローチB(代替案): カスタムフック useModalHistory として抽出
将来的にゲームモーダルやMobileNavにも同様の機能を追加する場合は、共通フックとして抽出することを検討。ただし、B-074のスコープでは検索モーダルのみが対象なので、まずアプローチAで実装し、必要に応じてリファクタリングするのが適切。
// 将来的な共通フックの概要
function useModalHistory(isOpen: boolean, onClose: () => void): void {
useEffect(() => {
if (!isOpen) return;
window.history.pushState({ modalOpen: true }, '');
let poppedByBrowser = false;
const handlePopState = () => {
poppedByBrowser = true;
onClose();
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
if (!poppedByBrowser) {
window.history.back();
}
};
}, [isOpen, onClose]);
}
6. まとめ・推奨事項
- 実装箇所:
src/components/search/SearchTrigger.tsxのisOpen状態管理に連動する形でuseEffectを追加する - 方式:
pushState+popstateリスナー方式。URLは変更せずhistory stackのみ操作する(window.history.pushState({ searchModalOpen: true }, '')) - cleanup:
useEffectの cleanup 関数内で、ブラウザ戻るボタン以外で閉じた場合にhistory.back()を呼ぶ - Next.js互換性: Next.js 16.1.6ではwindowHistorySupportが統合済み。popstateリスナーは正常に動作する見込み。ただしstateオブジェクトの中身はNext.jsが上書きする可能性があるため、stateの存在チェックには依存しない設計とする
- SSR安全性: SearchTriggerは
"use client"+useSyncExternalStoreでmounted管理済み。history操作はuseEffect内で実行するため安全 - リスク: 低い。変更はSearchTrigger.tsx内のuseEffect追加のみで、既存のモーダル開閉ロジックへの影響は最小限