- BBC UKウェブサイトの Moreボタン は、特定の在宅勤務環境でのみクリック処理に失敗しており、一見ありふれたUIバグが実際にはマルチモニターの座標系の問題だった
- 外部モニターがメインモニターの 上側・左側 に置かれていると、ChromeとFirefoxの
click イベントで screenX, screenY が負の値になることがあった
- 既存コードはポインタークリックを
event.screenX > 0 || event.screenY > 0 で判定していたため、負の座標でのクリック をマウスクリックとして認識できなかった
- 修正は、
screenX, screenY が0より大きいかではなく 0ではないか を確認するだけの単純なもので、event.type === 'click' && (event.screenX!== 0 || event.screenY!== 0) という形になった
- 単体テスト・Puppeteer・手動テスト・支援技術テストを経ていても、UI Events仕様の曖昧さと マルチモニター座標への仮定 によって、このようなバグは残り得る
特定環境でのみ再現したBBCナビゲーションのバグ
- BBC UKウェブサイトのナビゲーションバーは、ユーザーが Moreボタン を有効化するとメニューを開く動作を行う
- このボタンは
click イベントを使っており、このイベントはマウスだけでなく、タッチ、キーボードの Enter, Space でも発生し得る
- あるチームメンバーは、自宅で業務用ノートPCを使うときにだけ問題を経験し、同じノートPCをオフィスで使うと正常に動作した
- 自宅でも、ブラウザーウィンドウが外部モニター上にあるときだけ失敗し、ノートPCの画面上ではボタンは正常に動作した
- 問題が発生すると、JavaScriptハンドラーがメニューを開く代わりに、no-JavaScript fallback の動作でメニューが開いた
- Safariでは同じ問題は発生しなかった
再現条件はモニターの位置
- チームは自宅環境でどの要素が問題を引き起こしているのかを確認しながら、再現条件を絞り込んでいった
- 外部モニターがノートPC画面の 上側 に配置されており、OS設定でこの配置を変えると問題は止まった
- 別のチームメンバーも同じようにOSのモニター配置を合わせると、バグを再現できた
- 調査初期に確認された条件は2つだった
- Safariでは問題が発生しない
- 外部モニターがメインモニターの 上側かつ左側 にあると問題が発生する
screenX, screenY の負の座標
More ボタンの click イベントを console.log で確認すると、ChromeとFirefoxで screenX, screenY の値が 負 になっていた
click イベントはどの入力で発生したとしても PointerEvent の一種なので、イベントオブジェクトにはクリックを発生させたマウスやタッチポインターの情報が含まれる
screenX, screenY は、画面上でクリックされた位置の座標をピクセル単位で表す
- DOM UI Events spec には、このプロパティが負の値になり得るかどうかについての具体的な情報は見当たらなかった
- SafariとChrome・Firefoxの違いは、マルチモニター構成においてブラウザーごとに 画面座標の表現方法 が異なる可能性を示していた
- この相互運用性の問題はWebKitチームに報告された
ブラウザーごとのマルチモニター座標方式の違い
- マルチモニター構成では、ブラウザーの画面座標系は複数のモニターを1つの大きな画面のように扱う
- 横並びに配置した800pxモニター2台であれば、x座標の範囲は0から1600までになり得る
- Safariでは、座標範囲は常に最も左上のモニターを起点とする 正の範囲 のように見える
- ChromeとFirefoxでは、座標がメインモニターを基準に計算されているようで、メインモニターより上や左にある画面では負の座標になり得る
- 今回のバグは
screenX, screenY が負の値のときにだけ発生した
実際の問題コードと修正
- 問題のコードの
isInvokedByMouse は、click イベントがマウスやタッチポインターによって発生したかを確認しようとして、screenX, screenY が正の値かどうかを検査していた
const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0;
const isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);
// ...
const toggleMenu = event => {
// ...
if (isInvokedByMouse(event) || isInvokedByKeyboard(event)) {
event.preventDefault();
// Do stuff to open the menu and move the focus...
}
};
- このコードは、ポインターによって発生した
click イベントの screenX, screenY が 正の値 になることを前提にしていた
- ユーザーが負の画面座標を持つモニター上で
More ボタンをクリックすると、イベントハンドラーはそのクリックを認識せず、More リンクのデフォルト動作にフォールバックしていた
- 修正は、
screenX, screenY が0より大きいかを見る代わりに、0ではないこと を確認する方式だった
const isInvokedByMouse = event =>
event.type === 'click' && (event.screenX !== 0 || event.screenY !== 0);
- この変更により、珍しいマルチモニター配置を使うユーザーでもBBCウェブサイトのナビゲーションバーを利用できるようになった
残る設計上の問題と後続のリファクタリング
- 修正自体は簡単だったが、コードには依然として不自然な部分が残っている
click がマウスで発生したかキーボードで発生したかを確認する必要はなく、イベントハンドラーが keydown イベントまで処理しているため複雑になっている
- APIの動作にどんな前提を置くかには注意が必要であり、
screenX, screenY に負の値が入り得るかどうか仕様が明確でなかったことも、問題を見えにくくしていた
- このコードは単体テスト、Puppeteerテスト、複数ブラウザー・デバイス・支援技術ツールを使った手動テストを経ていたが、バグは発見されなかった
- 2024年11月19日の修正事項として、ナビゲーションコンポーネントはその後リファクタリングされ、
menu ボタンのイベントハンドラーも大きく変更された
- 後続記事では、リファクタリングの方法とよく寄せられた質問への回答が説明されている: How I refactored the BBC navigation bar and a follow-up FAQ
1件のコメント
Hacker News のコメント
WebKit のバグレポートまでクリックしていない人のために補足すると、WebKit の開発者が BBC に対し、キーボード由来のイベントかどうかを検知できると、なぜ有用なのかと尋ね、投稿者はアクセシビリティ関連のユースケースのため相互運用性が必要だと答えている。
BBC の英国向け Web サイトのナビゲーションバーのメニューボタンは、ポインターで開く場合とキーボードで開く場合で挙動が少し異なる。クリックイベントは常にメニューを開くが、ポインターで開くとフォーカスはメニューコンテナへ移動し、キーボードで開くとメニューを開くアニメーションなしに、メニュー内の最初のリンクへフォーカスが移動する。
clickイベントはデバイス非依存なので、キーボードのユーザー体験を作るうえで都合がよく、キーボードでは Space または Enter でのみ呼び出される。keydownを使う場合は、Space/Enter かどうかを自分で確認する必要がある。出典: https://bugs.webkit.org/show_bug.cgi?id=281430
const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0;とconst isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);で、表面的にはイベントをマウスかキーボードのどちらかに分類しようとしているように見える。実際には、マウスでありキーボードではない、キーボードでありマウスではない、両方である、どちらでもないの4つのカテゴリが生まれる。元のバグのように「どちらでもない」が不適切に処理され、「両方である」も正しく動作するのか疑問が残る。コードは、キーボードかどうかとマウスかどうかが別々のブール値である点を意図的に扱うか、
eventSourceが"keyboard"、"mouse"、"not sure"のような相互排他的なカテゴリを返すように構成すべきだ。デフォルトの挙動に合わせ、両方のユースケースで動くコンポーネントを設計するほうがよい。アクセシビリティでは、賢く振る舞おうとしてはいけない。結局ハックに近い解決策になっており、そういう方法は必然的に壊れるか副作用を生む。アクセシビリティの文脈で別扱いするための良いハンドルが少ないのは、そもそも別扱いすることを意図した領域ではないからだ。
同時に、1つのイベントだけにバインドできる利便性も求めている。
clickはこれを可能にするが、そのイベントがマウスクリックで発生したのかキーボード入力で発生したのかを知る方法がないため、Chrome ではマウス位置がscreenX=0,screenY=0なら原点クリックまたはキーボードトリガーだとみなす不安定なヒューリスティックを使っている。アクセシビリティのプロジェクトを経験した立場からすると、かなり悪いアイデアで、PR で見たら書き直しを求めただろう。ブラウザー間で同じ挙動になるのが理想ではあるが、本当の問題は、キーボードで発生したclickにおけるscreenXとscreenYにはほとんど意味がない点に見える。理想的には
MouseEventを発火せず、キーボードとマウスの両方に適用できる、たとえば"trigger"のようなより一般的なイベントがあり、トリガー元の情報を提供すべきだ。現在の仕様にないため今すぐ解決策が必要なら、keydownにもバインドしたうえで、同じ要素でkeydownとともにclickが発生した場合はキーボード入力とみなすほうが、はるかに安定しており、ハック度も低い。screenXとscreenYを必要としているのは理解できるが、なぜscreenXがレンダラー内部の位置やレンダリングされたページ上の位置であるlayerX,layerYではなく、実際の画面座標を返さなければならないのかは、依然として疑問だ。投稿者の必要はレンダラー上の位置でも満たせるし、訪問したすべての Web サイトにブラウザーウィンドウの位置を漏らさずに済む。
don’tは、意図と逆の意味にしてしまっているタイプミスではないかと思う。「
isInvokedByMouseがscreenXとscreenYが 0 より大きいかを確認していたのを、0 ではないかを確認するように変えるだけでよかった」という箇所で、ごくまれだろうが、ユーザーが実際に0,0 の位置でマウスクリックしたらどうなるのか気になる。JS には詳しくないが、
!= 0の確認が本当に最善、または唯一の方法なのだろうか。読み直すと、イベントハンドラーがkeydownも処理していて複雑なので、後でさらにリファクタリングする必要があるが今はこの修正で十分、という文がこの点にある程度触れているようだ。instanceof MouseEventを使いそうだが、これも危険だったりハックのように感じる。なぜこのようなヒューリスティックに依存しているのか疑問だ。
toggleMenuが複数のイベントハンドラーで使われているからかもしれないし、コードベース固有の別の事情があるのかもしれない。全体像を知らなければ判断は難しい。答えはここにありそうだ: https://news.ycombinator.com/item?id=42174177event.name == 'click'を確認している。だとすると、なぜ一部の正常なクリックイベントを除外しようとしているのか分からない。以前、どのレイアウトを表示するかを選ぶのに使ったことがある。タッチ入力だけを受け取りたいならそうしたうえで、イベントで
preventDefaultを呼び出し、ブラウザーが続いてclickイベントを生成しないようにできる。あるいは、単に手間を省いてクリックハンドラーを書けばよい。BBCがアクセシビリティに投資していて、不快なバグを見つけたのは評価に値する。とはいえ業界は、なぜすべてのユーザーに対して一貫して開くドロップダウンをいまだにきちんと作れないのだろうか?
アクセシビリティはそんなに難しいのか? BBCは、すでにこうしたことを処理するWebフレームワークやWeb Componentsを使うべきだったのだろうか? バックエンド寄りのフルスタック開発者としては、ブラウザコンポーネントに手を入れるのは慎重になる。挙動には微妙な点が多く、実装は長い時間をかけて検証されている。たとえばカスタムテキストボックスを作りながら、プラットフォームごとのテキストボックスの挙動を深く調べないのは失敗しやすそうに見える。大企業のサイトでも、コピー&ペーストの破損や文字の欠落をよく見かける。2024年にどうしてテキストボックスが壊れるのか分からないし、今やReactは傲慢に感じる。
個人的には、サーバーサイドテンプレート、BulmaのようなCSSフレームワーク、最小限のJSで処理しようとしていた。洗練されたカスタムブランディングを求めるサイトには向かないが、テキストボックスはきちんと動作し、開発コストも過度ではない。BBC基準のアクセシビリティを満たすかどうかは確信がない。
実例としてモーダルがある。視覚障害がなければ、灰色の「触らないで」領域の上に白い箱があり、その中にUIコンポーネントが浮いているのが見える。スクリーンリーダーを使うと、その情報を受け取れる保証はない。TabでUI要素を移動していて箱の一番上に戻ったとき、特定のスクリーンリーダーはそれを知らせてくれるだろうか? 利用可能なインタラクティブ要素を列挙するだろうか? 他のスクリーンリーダーと同じ順序で列挙するだろうか? 携帯電話では、Macではどうだろう? スクリーンリーダーとブラウザは入力要素を正しく報告するだろうか、それともユーザーがモーダルの外へ抜け出してサイトの残りの領域に戻ることを、黙って許してしまうだろうか?
アクセシビリティでは、OS、ブラウザ、スクリーンリーダーが協調したり、適切な状況で合理的に動作したりすると信じることはできない。2019年にVoiceOver + Safariで、負のCSS marginのためにRTLテキストブロックをスクリーンリーダーが順序を逆に読んでしまうバグを報告しなければならなかった。視覚的には
9/10/2019と見えるのに、スクリーンリーダーでは「ten slash nine slash two-thousand-and-nineteen」のように聞こえ、応急処置としてテキストをaria-hiddenにしたうえで、正しい順序の見えないpタグを入れる必要があった。だからアクセシビリティ関連の奇妙なコードを見かけたとき、本当にそれより良い方法がない場合もある。コードベースを完全にひっくり返してアクセシビリティを最優先にしても、JAWSやVoiceOverの更新の瞬間に、理解しがたい形で壊れることがある。概ね問題ないが、
reset.cssファイルが存在するのには理由があり、ここではこうした問題を完全に迂回しようとして、より極端なアプローチを使った可能性がありそうだ。彼らの判断を推測しようとしているところだ。これは誤ったヒューリスティックに由来する、自ら招いたバグのように見える。正の
screenX/Y値ならマウスイベントだと仮定していて、トレースやログが不足していたため調査もさらに複雑になった。他のコメントが提案している、より適切なプロパティである
pointerTypeを確認する代わりに、筆者の解決策がぐらついたヒューリスティックをさらに継ぎ足すものだったので少し驚いた。最後の2つの手がかりから、screenXとscreenY座標を確認するときは正の値だけでなく負の値も確認すべきだと結論づけた、という感じだ。pointerId === -1を使い、その後screenX === 0にフォールバックするようにコードをマージするつもりだ。このコードが最初に書かれた4年ほど前には、すべてのブラウザが
clickにPointerEventを使っていたわけではなかった。そもそも、なぜWebサイトが画面座標系におけるマウス位置を取得できるのか分からない。
window.screenX/window.screenYを知ることができ、クリック位置もその座標系で報告され得るというのは、デスクトップでは筋が通らないように聞こえる。TOR Browserはフィンガープリンティングを避けるために
screenXとscreenYを偽装しているようだ。この機能の良いユースケースを見たことがあるか気になる。相互作用する二重ウィンドウのアプリケーションや、仮想画面上の位置によって挙動が変わるサイトくらいが思い浮かぶ。例: https://youtu.be/3al8prbfK5o?si=loNtyqIfMFkppm5V
なぜ
event.typeを確認せずに座標を確認するのかは理解できない。それでも記事自体は良いパズルで、自分が書いたわけではないコードを見ながら「なぜクリック座標が0でないことが重要なのか?」「単にevent.targetが有効化しようとしているボタンか確認すればいいのでは?」「details/summaryタグで同じことができるのに、なぜJavaScriptを使うのか?」と問う状況には共感できる。そもそもなぜ画面座標でフィルタリングするのか? ユーザーが画面のない代替入力デバイスを使っていたらどうなるのか?
clickイベントだけで、ユーザーがメニューを有効化しようとしたという十分なシグナルになる。なぜ車輪を再発明するのか分からない。isInvokedByMouseはclickイベントがキーボードではなくマウスやタッチポインターで呼び出されたかを確認するために、screenXまたはscreenY座標が正かどうかを検査していた。キーボードによる有効化かマウスによる有効化かを検知しようとしていて、筆者はマウスイベントの画面座標が常に正になると仮定していたわけだ。
人々が気にしていた背景を説明し、質問に答えるために、ブログ記事をもう1本公開した。なぜ最初から
screenX === 0を確認していたのか、なぜキーボード入力とマウス入力で異なる挙動にしたかったのか、追加の事故を防ぐためにどうリファクタリングしたのかを説明している役に立てばうれしい: https://www.joshtumath.uk/posts/2024-11-18-how-i-refactored-...
マウスクリックなのかキーボードクリックなのかを判定する正しい方法は何だろう? 直近で発生したイベントを基準にモジュールレベルのフラグを設定し、
mousedownのほうが新しければisKeyboard=false、isMouse=true、keydownのほうが新しければその逆にしたくなりそうそうすれば
isInvokedByMouseとisInvokedByKeyboard関数は不要になる。もっと良い方法はあるだろうか? これを画面座標に依存させるのはかなり怪しく、ハックだと思うevent.detail[1] はキーボードによる「クリック」では 0、ポインタークリックでは 1 になる1: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/det...
とても興味深いが、ブラウザーがなぜモニターによって異なる座標を報告するのか分からない。ブラウザーは、Webページがどのディスプレイ上にあっても全画面のように扱うものだと思っていた
Web API がこうした情報を持つ理由はあるのだろうか? セキュリティ・情報漏えい・トラッキングのリスクのように見える
開発能力の問題では? 画面座標ではなくビューポート座標を使うべきで、
.clientXと.clientYで読むべきだった。画面空間で負の値が出ることの何がバグなのか分からないhttps://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_view/...