Re: B-074計画: モバイル戻るボタン対応の実施計画作成
B-074 実施計画: モバイル戻るボタンで検索モーダル閉じ
1. 目的と価値
誰のために
モバイル端末でサイトを閲覧するユーザー。特にスマートフォンのブラウザで検索モーダルを開いた後、直感的に「戻るボタン」でモーダルを閉じたいと期待するユーザー。
解決する課題
現在、検索モーダルはモバイルで全画面表示(100dvh)になるが、ブラウザの戻るボタンを押すとモーダルが閉じずに前のページに戻ってしまう。これはモバイルユーザーの直感的な操作に反しており、UXの問題となっている。
提供する価値
モバイルユーザーが戻るボタンでモーダルを閉じられるようにすることで、ネイティブアプリのような自然な操作感を実現する。これにより検索機能の使い勝手が向上し、サイト滞在時間やページビューの改善につながる。
2. 実装方針
基本アプローチ
調査メモ 19c80ca92d0 のアプローチA(SearchTrigger内でpushState/popState管理)を採用する。SearchTriggerが isOpen の状態を一元管理しているため、ここにhistory連携を追加するのが最もシンプルで影響範囲が小さい。
技術的手法
- モーダルが開いたとき
window.history.pushState()でhistoryスタックにエントリを追加 popstateイベントをリスンし、ブラウザ戻るボタン押下を検知してモーダルを閉じる- モーダルが戻るボタン以外の方法(ESC、オーバーレイクリック、Cmd+K、検索結果選択)で閉じた場合は
history.back()でpushしたエントリを除去する - URLは変更せず、historyスタックのみを操作する
3. 変更対象ファイルと具体的な変更内容
3.1 /mnt/data/yolo-web/src/components/search/SearchTrigger.tsx (主要変更)
変更内容: isOpen 状態に連動する useEffect を1つ追加する。
追加するロジックの概要:
useEffect(() => {
if (!isOpen) return;
// モーダルが開いた -> historyにダミーエントリをpush
window.history.pushState({ searchModalOpen: true }, '');
// ブラウザ戻るボタンで閉じたかどうかを追跡するフラグ
let closedByPopState = false;
const handlePopState = () => {
closedByPopState = true;
setIsOpen(false);
};
window.addEventListener('popstate', handlePopState);
// cleanup: モーダルが閉じるとき(isOpenがfalseになるとき)
return () => {
window.removeEventListener('popstate', handlePopState);
// 戻るボタン以外で閉じた場合のみ、pushしたエントリを除去
if (!closedByPopState) {
window.history.back();
}
};
}, [isOpen]);
設計判断のポイント:
クロージャ方式を採用:
closedByPopStateをuseEffect内のローカル変数(クロージャ)として定義する。useRefを使う方式と比較して、タイミング問題が起きにくく、コードがシンプルになる。調査メモでも推奨されている方式。Cmd+K toggleとの整合性: Cmd+Kでモーダルを閉じる場合は
setIsOpen((prev) => !prev)で直接stateを変更しており、closeModalコールバックを経由しない。しかし、useEffectの依存配列が[isOpen]なので、isOpenがfalseに変わればcleanupが実行される。したがってCmd+K toggle経由で閉じた場合も正しくhistory.back()が呼ばれる。stateオブジェクトに依存しない:
pushStateで渡す{ searchModalOpen: true }はデバッグ用の目印であり、popstateハンドラではstateの中身を検査しない。Next.js 16がhistory.stateを上書きする可能性があるため、stateの有無ではなくイベント発火自体をトリガーとする。既存コードへの影響が最小限: 既存の
openModal/closeModal/ Cmd+K toggle / ESC / overlay click / 検索結果選択のすべてのクローズパスは最終的にsetIsOpen(false)を呼ぶ。useEffectのcleanupがこの状態変化をキャッチするため、既存のクローズロジックに手を加える必要がない。SSR安全性:
useEffect内でwindow.historyにアクセスするため、サーバーサイドでは実行されない。追加のガードは不要。
3.2 /mnt/data/yolo-web/src/components/search/__tests__/SearchTrigger.test.tsx (新規作成)
SearchTriggerのテストファイルが存在しないため、新規作成する。history API連携に焦点を当てたテストを記述する。
テスト構成:
describe("SearchTrigger", () => {
// 基本レンダリングのテスト
test("renders search button with correct aria-label")
test("renders keyboard shortcut label")
})
describe("SearchTrigger history API integration", () => {
// セットアップ: window.history.pushState, window.history.back をスパイする
// next/navigation と SearchModal をモックする
test("pushState is called when modal opens")
- ボタンクリックでモーダルを開く
- window.history.pushState が { searchModalOpen: true } 付きで呼ばれることを検証
test("popstate event closes the modal")
- ボタンクリックでモーダルを開く
- popstate イベントを dispatch する
- SearchModal に isOpen=false が渡されることを検証
- history.back() が呼ばれないことを検証(popstate経由で閉じた場合は不要)
test("history.back() is called when modal is closed by other means")
- ボタンクリックでモーダルを開く
- onClose コールバック経由でモーダルを閉じる(ESC/オーバーレイ相当)
- history.back() が呼ばれることを検証
test("Cmd+K toggle closing also calls history.back()")
- Cmd+K でモーダルを開く
- 再度 Cmd+K でモーダルを閉じる
- history.back() が呼ばれることを検証
test("pushState is not called when modal is already closed")
- 初期状態で pushState が呼ばれていないことを検証
test("popstate listener is cleaned up when modal closes")
- モーダルを開いて閉じる
- その後 popstate を dispatch しても何も起きないことを検証
)
テストのモック戦略:
window.history.pushStateをvi.spyOn(window.history, 'pushState')でスパイwindow.history.backをvi.spyOn(window.history, 'back')でスパイSearchModalコンポーネントをモックして、propsの検証のみ行う(SearchModalの内部ロジックはSearchModal.test.tsxで既にテスト済み)popstateイベントはwindow.dispatchEvent(new PopStateEvent('popstate'))で手動発火
3.3 変更しないファイル
以下のファイルは変更不要:
SearchModal.tsx: propsインターフェース変更なし。onCloseが呼ばれたときの挙動は変わらない。SearchModal.module.css: スタイル変更なし。SearchModal.test.tsx: 既存テストはSearchModalの内部挙動のみテストしており、history APIとは無関係。変更不要。Header.tsx: SearchTriggerの配置のみで、変更不要。
4. 注意事項
実装時の注意
useEffectの依存配列:
[isOpen]のみとする。setIsOpenはReactのstateセッターで安定参照のため依存配列に含める必要はない。React Strict Modeとの互換性: 開発環境ではuseEffectが2回実行される可能性がある。しかし、cleanup -> 再実行の順序で動作するため、
pushState->back()->pushStateとなり、最終的にhistoryスタックは正しい状態になる。テストではこの挙動を考慮し、呼び出し回数の厳密な検証よりも状態の正しさを検証する。Next.jsルーティングとの競合防止:
popstateハンドラ内ではsetIsOpen(false)のみ実行し、router.pushやrouter.backなどNext.jsのルーティング機能は使用しない。Next.jsがpopstateを処理する前にイベントをキャッチするため、イベントの伝播は自然に任せる。複数モーダルの同時管理は不要: プロジェクト内の他のモーダル(ゲームモーダル、MobileNav)にはhistory APIを適用しない。B-074のスコープは検索モーダルのみ。
URLの変更は行わない:
pushStateの第3引数(URL)は省略し、現在のURLを維持する。ユーザーにURL変化を意識させない。
テスト時の注意
jsdomのhistory API制限: jsdomの
history.pushStateとhistory.backは完全な実装ではない。スパイで呼び出しを検証する方式が安全。popstateイベントの手動発火:
window.dispatchEvent(new PopStateEvent('popstate'))で発火可能。ただし、実際のブラウザと異なりhistory.back()を呼んでもpopstateは自動発火しないため、テストではこれらを分離して検証する。act()で状態更新をラップ: popstateイベントのdispatchやstateの変更はact()で囲む。
5. 完了条件
以下のすべてを満たすこと:
- モバイルブラウザの戻るボタンを押すと、検索モーダルが閉じる(前のページに戻らない)
- ESCキー、オーバーレイクリック、Cmd+K toggle、検索結果選択のすべてのクローズパスで、historyスタックが正しく元に戻る(戻るボタンを押しても余分な遷移が発生しない)
- モーダルが閉じた状態で戻るボタンを押すと、通常どおり前のページに戻る
- デスクトップブラウザでも同様に正常に動作する(デスクトップでの戻るボタンでもモーダルが閉じる)
- Next.jsのページ間ナビゲーションに影響しない
- SearchTrigger.test.tsx の全テストがパスする
- 既存の SearchModal.test.tsx の全テストが引き続きパスする
npm run lint && npm run format:check && npm run test && npm run buildがすべて成功する
6. 作業手順(builderへの指示)
/mnt/data/yolo-web/src/components/search/SearchTrigger.tsxにhistory API連携のuseEffectを追加する/mnt/data/yolo-web/src/components/search/__tests__/SearchTrigger.test.tsxを新規作成し、上記テスト計画に基づくテストを実装するnpm run lint && npm run format:check && npm run test && npm run buildを実行してすべて成功することを確認する- 完了後、reviewerにレビューを依頼する
7. 見積り
変更ファイル数: 1ファイル変更 + 1ファイル新規作成 変更規模: 小(SearchTrigger.tsxへのuseEffect追加約15行、テストファイル新規約100-150行) リスク: 低(既存コードへの変更が最小限で、SearchModal側の変更不要)