はじめに
React Portalで実装したモーダルをモバイル端末で閉じたとき、同じ位置にある背後の設定ボタンまで反応してしまう問題に遭遇しました。
一見するとイベントバブリングの問題に見えます。しかし、stopPropagation()を追加しても解決しませんでした。原因は、同じイベントが伝播したことではなく、1回のタッチ操作が完了する前にPortalをアンマウントしていたことでした。
この記事では、実際に発生したイベント順序、最小限の修正、PlaywrightとChrome DevTools Protocolを使った回帰テストを紹介します。対象プロジェクトは、ブラウザ上で動作するオープンソース動画編集アプリ「Timeline Studio」です。
- GitHub: https://github.com/MartinDelophy/ai-video-editor
- デモ: https://video-editor.ai-creator.top
- 修正PR: https://github.com/MartinDelophy/ai-video-editor/pull/34
- Release v0.6.0: https://github.com/MartinDelophy/ai-video-editor/releases/tag/v0.6.0
発生した問題
モバイル版では、プレビューを大きなキャンバスとして開く編集モードをReact Portalで表示しています。
そのモーダルの閉じるボタンと、背後にある設定ボタンが画面上のほぼ同じ位置にありました。ユーザーが閉じるボタンを1回タップすると、大きなキャンバスが閉じた直後に設定パネルまで開きます。
ユーザーから見ると、1回のタップで無関係な操作が2つ実行されたように見えます。タッチUIの空間的な予測可能性が崩れ、編集画面への信頼を損なう不具合でした。
修正前のコード
閉じるボタンは、pointerdownの時点でReact stateを更新していました。
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
setIsFocusPreviewOpen(false);
}}
onClick={(event) => event.stopPropagation()}
preventDefault()とstopPropagation()があるため、一見安全そうです。しかし、ここで問題になったのは通常のバブリングではありません。
タップは単一イベントではない
タップは複数のイベントから構成されます。今回の状況を単純化すると、次の順序でした。
touchstart / pointerdown
↓
閉じるstateを更新
↓
Portalがアンマウントされる
↓
touchend / pointerup / synthesized click
↓
ブラウザが同じ画面座標を再びヒットテスト
↓
背後の設定ボタンが後続イベントを受け取る
最初のpointerdownで最前面の要素を消したため、ジェスチャーが完了する前に背後の操作可能な要素が露出しました。
stopPropagation()が制御できるのは、現在のDOMツリー内を1つのイベントがどのように伝播するかです。すでに対象ノードがアンマウントされた後に発生する別のイベントについて、同じターゲットを保証するものではありません。
つまり、これは「伝播」の問題というより、イベントターゲットとUIライフサイクルのタイミングの問題でした。
修正内容
Portalを閉じるタイミングをpointerdownからclickへ移しました。
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsFocusPreviewOpen(false);
}}
これにより、モーダルはpointerdown中もマウントされたままです。クリック全体が閉じるボタン上で解決した後に、初めてReact stateを更新します。
タイマー、デバウンス、透明なブロッカー、座標に依存した回避策は必要ありません。UIのライフサイクルを、ユーザーのジェスチャーのライフサイクルに合わせただけです。
実際のタッチシーケンスをテストする
マウスのclick()だけを使うテストでは、この修正の重要な性質を十分に検証できません。そこで、412×915のモバイルviewportで、PlaywrightからChrome DevTools Protocolのタッチイベントを送る回帰テストを追加しました。
テストの流れは次のとおりです。
- 大きなキャンバス編集モードを開く
- 閉じるボタン上へ
touchStartを送る - この時点ではダイアログがまだ表示されていることを確認する
- 同じ座標で
touchEndを送る - ダイアログが閉じたことを確認する
- 背後の設定パネルが開いていないことを確認する
中心となる検証を簡略化すると、次のようになります。
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x, y }],
});
await expect(focusDialog).toBeVisible();
await client.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await expect(focusDialog).toBeHidden();
await expect(page.locator(".settings-panel")).toHaveCount(0);
特に重要なのは、touchStartとtouchEndの間にあるアサーションです。修正前はPortalが早すぎるタイミングで消えるため失敗し、修正後はジェスチャーが完了するまでダイアログが維持されます。
単に最終状態を確認するのではなく、イベントシーケンス途中のUI状態まで仕様として固定したことがポイントです。
今回の修正で意識したこと
症状ではなくイベント境界を直す
背後の設定ボタンを一時的に無効化しても、別のボタン配置で同じ問題が再発します。今回は、ジェスチャー途中で最前面の操作面を削除するという根本原因を修正しました。
実際の入力デバイスに近いテストを使う
CDPのInput.dispatchTouchEventを使い、touchStartとtouchEndを分離することで、障害が発生した境界を直接テストできました。デスクトップでの閉じる操作、Escapeキー、別の終了ボタンも維持されていることを確認しています。
学んだこと
今回の重要な学びは、イベント伝播とイベントターゲットは別の問題だということです。
- 伝播: 1つのイベントが現在のDOMツリー内をどう移動するか
- ターゲット: その時点の画面とDOMに対して、どの要素がイベントを受け取るか
React stateの更新によってpointerdownとpointerupの間にPortalを削除すると、後続イベントのターゲットになり得る要素自体が変わります。
背後に操作可能な要素がある場合、複数イベントからなるジェスチャーの最初の段階で、最前面の操作面を同期的に削除しない。
状況に応じてclickまたはpointerupで閉じる、モーダル表示中は背景をinertにする、そしてタッチシーケンス全体をテストすることで、この種類の不具合を防ぎやすくなります。
小さな差分でしたが、タッチインターフェースの信頼性に与える影響は大きい修正でした。
英語版の記事もDEV Communityに公開しています。