回転した要素上で水平スクロールがしたいのにスワイプするとページが移動しちゃうよ〜なバグを直した(Firefox)
さいきんFirefoxでスワイプジェスチャー関連のバグを直したのでメモ。
どんなバグだったのか
これです、これ。
"Scroll not working after transform and rotate an element with CSS - macOS Catalina"
細かい話はバグチケットを見てもらうとして、要するに「縦スクロールできる要素を回転して横にしたとき、水平スクロールしようと思ってスワイプするとページが移動しちゃう」というバグです。これは今日(2026/7/3 EST)時点、つまりFirefox 152.0.4だとまだ再現します。
下にページと手順を用意しましたので、ぜひ試してみてください。
- このタブで https://output.jsbin.com/toqigirata を開く
- 右にガッとスワイプしてスクロールする
- 左にスワイプすると、スクロールされずにこの記事に戻ってくる
こんなHTMLで再現できます。
<!DOCTYPE html>
<html>
<style>
body {
margin: 0;
}
#container {
position: absolute;
width: 100vh;
height: 100vw;
overflow-x: hidden;
overflow-y: scroll;
transform: rotate(270deg) translateX(-100%);
transform-origin: top left;
}
</style>
<div id="container">
<div style="height: 400%;"></div>
<!-- divの代わりに非常に長い文章を差し込むでもよい -->
</div>
</html>
すでにパッチはマージされてNightlyでは直っています!
ちなみにChromeやSafariでは縦方向のスワイプで左右にスクロールされます。スワイプジェスチャーの処理系が座標変換を行なっていないのかなと推測しています。
どうやって直したのか
APZ、あるいはユーザージェスチャー処理の高度さについて
Firefoxにはスクロールやズームなど、ユーザージェスチャーのうち画面のアップデートが単純な変換によって表現できるもの(スクロールは平行移動、ズームは縮尺変更を画面に施せばおしまい)を素早く処理するための機構があります。それはAPZと呼ばれています。
↑このドキュメントを読んだ人はこの章をとばしてくれていいです
そもそも、ユーザージェスチャーを処理する上でブラウザはかなり高度なことをやっています。それをほんの少しばかり実感するために、たとえばスクロールについて考えてみましょう。
まず、ユーザーがスクロールするつもりでマウスのボタンをコロコロしたり、タッチパッドをスイスイしたりします。すると、OSがインプットを発行して、ブラウザの親プロセスに渡してあげます。親プロセスとはbrowser processとかparent process、main processとか呼ばれるやつです。セキュリティ文脈だとbrowser kernelとか言う人もいます、かっこいいですね。
Chromiumのドキュメントにちょうどこのことを言っている部分があったので引用しておきます。
Because the renderer is in a sandboxed process, it doesn't directly get user input like key presses or mouse clicks, and it doesn't directly draw to the screen. These things are all handled by communicating with the browser process. The browser process owns the window; when there's a user input event like a mouse click or key press, it forwards that event to the appropriate renderer. The renderer figures out how to draw the webpage, but it doesn't draw directly to the screen - because it's sandboxed - it either sends pixels (in software rendering mode) or sends the drawing commands to Chromium's separate gpu process.
https://source.chromium.org/chromium/chromium/src/+/main:docs/accessibility/browser/how_a11y_works_2.md

Chromiumのドキュメントdocs/accessibility/browser/how_a11y_works_2.mdより
さて、ブラウザはまずこのインプットがどのframeに対して行われたものなのか特定する必要があります。そこで、Firefoxの場合は親プロセスかGPUプロセスでヒットテストを行います。たぶんChromiumもそうなんじゃないかなと思います。
ポイントは、このインプットはタブのコンテンツに対するものかもしれないし、ブラウザ本体のUI(お気に入りボタンとか)に対するものかもしれないということです!そのことも考慮した上で、どのプロセスがこのインプットを受け取るべきなのかを判定しなくてはいけません。もちろん現代のブラウザではだいたいthird party iframeもプロセス分離されていますから、それも含めて判定します。したがって、普段フロントエンド開発の文脈で耳にする「ヒットテスト」よりもスコープが広いわけです。もっと言うと、これは子プロセス(content process, child process, renderer processなどと呼ばれる)でできる類の処理ではないはずです。子プロセスは自分のコンテンツのことは知っているものの、隣のタブを管理する子プロセスが何をしているかは全く知らないはずだからです。
このハイレベルなヒットテストをFirefoxではAPZが担当します。インプットが紐づくコンテンツが決定されたら、そのコンテンツに対応するプロセスに送信されます。こんな図があります。

Firefoxのドキュメント(https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html)より
さて、受け取ったプロセスはイベント処理を始めます。色々な処理がありますが、ここでの主役はイベントリスナーでしょう。イベントリスナーの処理はJSを実行できる子プロセスならではの仕事ですね。ここでは、wheelあたりのリスナーが発火すべきです。Firefoxでは子プロセスでより詳細なヒットテストが行われたうえで、リスナー処理が始まっていきます。
一方、イベントリスナーに追加で、画面のスクロール処理も必要です。ここで面白いのは、少なくとも、リスナー処理を待ってからこのスクロール処理を行うのでは遅すぎるということです。普通に遅いし、リスナーがめちゃくちゃ時間を食ったときなんかには目も当てられません。ということでリスナー処理とは異なるスレッドかプロセスで実行する必要が生じます。
しかし、さらなるおもしろポイントが発生します。preventDefault()がありますね。単純に、リスナー処理なんか待ってられないよと言って画面をアップデートしてもいいのでしょうか?少なくともpreventDefault()が呼ばれたかどうかは確認しなければいけなさそうです。それに、preventDefault()を呼ぶのが少し遅かった場合のハンドリングはどうしましょうか?
もう少し複雑なケースを考えてみましょう。たとえば、JS側でスクロールを起こすこともできるわけです。リスナー処理の中でスクロールがトリガーされつつ、リスナー処理とは異なるスレッドやプロセスでスクロール処理を行なっているのだとしたら、両者はどう調停(reconcile)されるべきでしょうか?じゃあ、JS側でgetBoundingClientRect()なんかをしたら?いつの時点での計算結果を返すのでしょうか?
とんでもないことになってきましたね。ということで、ブラウザがユーザージェスチャーを処理するというのはすごいことなんです。そして、一部のジェスチャーに対してそれを高速に行うためのFirefoxのモジュールがAPZなのです。
スワイプの処理のされ方
具体的な話に入っていきます。Firefoxではスワイプ処理がどのように行われているのでしょうか。
結論から言うと、APZ、親プロセス、子プロセスの3者による判定が突き合わされてナビゲーションを行うかが決定されます。
より詳細に言うと、GPU process(もしくはparent process)のAPZによる荒いscrollability判定、parent processのchrome codeによるhistory判定、content processのESMによる細かなscrollability判定を総合して決まります。
まず、ユーザーがスワイプをすると、いろいろあって、widgetコード(OSとFirefoxの境目)でこの関数が呼び出されます。
void nsCocoaWindow::DispatchAPZWheelInputEvent(InputData& aEvent) {
[...]
WidgetWheelEvent event(true, eWheel, this);
if (mAPZC) {
APZEventResult result;
switch (aEvent.mInputType) {
case PANGESTURE_INPUT: {
result = mAPZC->InputBridge()->ReceiveInputEvent(aEvent); // APZ判定
if (result.GetStatus() == nsEventStatus_eConsumeNoDefault) {
return;
}
event = MayStartSwipeForAPZ(aEvent.AsPanGestureInput(), result); // 親プロセス判定
break;
}
[...]
}
if (event.mMessage == eWheel &&
(event.mDeltaX != 0 || event.mDeltaY != 0)) {
ProcessUntransformedAPZEvent(&event, result); // 子プロセス判定
}
return;
}
[...]
}
上の関数がアンカーになります。ここを起点に見ていきましょう。下記のような流れで処理が行われます。
- 親プロセスかGPUプロセスで、APZがナビゲーションが行われる可能性があるかを判定する
- 1において可能性があるとされた場合、APZはすぐにスクロールをするのではなく、後続の判定処理のために少し待機する
- 再度、Widgetにてナビゲーションが行われる可能性があるかを判定する
- 3において可能性があるとされた場合、親プロセスで、ナビゲーションを行うためのセッションヒストリーが存在するかを判定する
- 4においてヒストリーが存在する場合、子プロセスで、スクロールではなくてナビゲーションをすべきか判定する
- 5においてナビゲーションをすべきと判定された場合、親プロセスにメッセージングが行われてナビゲーションが発生する
そして不具合は下記2つの条件が重なって発生していました。
A. 3以降が1においてナビゲーションをする余地がない(=スクロールが可能である)とされた場合にも実行され、
B. 5において要素の回転が考慮されていなかった
では、順に見ていきましょう。
1,2: APZによるナビゲーション可能性判定と待機
まず、親プロセスかGPUプロセスでAPZが下記2点を判定し、ナビゲーションが行われる可能性がある場合、つまり下記でいうA && !Bの場合に待機処理を有効にします。
A. イベントがswipe-to-navigationを実行する資格を有しているか
B. ターゲット要素に水平スクロールをする余地があるか
if (event.AllowsSwipe() && !CanScrollTargetHorizontally(event, block)) {
// We will ask the browser whether this pan event is going to be used for
// swipe or not, so we need to wait the response.
block->SetNeedsToWaitForBrowserGestureResponse(true);
if (!waitingForContentResponse) {
ScheduleMainThreadTimeout(aTarget, block);
}
[...]
}
AllowsSwipeはたどっていくとOverscrollBehaviorAllowsHandoffにつながって、overscroll-behaviorプロパティなんかを見ていることがわかります。
bool Axis::OverscrollBehaviorAllowsHandoff() const {
// Scroll handoff is a "non-local" overscroll behavior, so it's allowed
// with "auto" and disallowed with "contain" and "none".
return GetOverscrollBehavior() == OverscrollBehavior::Auto;
}
待機処理というのは、APZが、後続の親プロセスや子プロセスでの判定処理を待つことを意味します。もちろんタイムアウトはありつつ。実は先述のpreventDefaultに関する問題に対してもAPZは同様のアプローチ、つまり待機を行ないます。そのことは下記のドキュメントに書いてあるので読んでみてください。
3: Widgetにおける判定
WidgetとはFirefoxとOSの境界に位置するコードのことです。Firefoxレポジトリにはwidgetディレクトリがあり、そこのことです。そこにこんなコードがあります。
WidgetWheelEvent nsIWidget::MayStartSwipeForAPZ(
const PanGestureInput& aPanInput, const APZEventResult& aApzResult) {
[...]
if (aPanInput.mHandledByAPZ && aPanInput.AllowsSwipe()) {
SwipeInfo swipeInfo = SendMayStartSwipe(aPanInput);
event.mCanTriggerSwipe = swipeInfo.wantsSwipe;
[...]
}
[...]
return event;
}
AllowsSwipeは先ほど使われていましたね。ここは要するに、実際にスクロールが可能かどうか(さっきのCanScrollTargetHorizontallyに該当)までは見ないけど、swipeによりナビゲーションが認められている(AllowsSwipe)ならSendMayStartSwipeしようということです。
先ほどの判定とは異なり、CanScrollTargetHorizontallyが使われていません。ここはWidgetコードであってAPZほどの情報を持っていないためです。これが1つ目の原因です。ここで大事なのは、APZは既に答えを知っているということです。「1」で見たとおり、APZはCanScrollTargetHorizontallyで「水平スクロールの余地があるか」を計算しています。ところが修正前は、その計算結果を後続の待機判定に使うだけで、Widgetには渡していませんでした。だからWidgetは同じ判定を再現できず、AllowsSwipeだけを見て先に進んでしまう。APZは「スクロールできる(=ナビゲーションしない)」と分かっているのに、その情報がWidgetに届いていなかったわけです。
あとで見るように、パッチはまさにこの「APZの判定結果をWidgetまで運ぶ」ことをやっています。
4: 親プロセスでの判定
次はナビゲーションを起こすための履歴が存在するかどうかを判定します。戻る方向にスワイプをしても、前のページがなければ戻りません。
この判定は親プロセスで行われますが、どうして子プロセスではないのかを少し考えてみましょう。
Firefoxではfissionによって異なるsiteのdocument、たとえばcross-origin iframeなんかは別のcontent processで動作します。このようなオリジンやサイト単位でプロセスを切り分けるブラウザの安全機構をSite Isolationと呼ぶことがあります。Chromiumもやっていますよね。なお、Chromiumではプロセスが分かれたiframeをOOPIF(Out-of-Process Iframe)と呼んだりします。
さて、このとき、セッション履歴はどう管理されているでしょうか。素直に考えると、各プロセスに持たせればよくない?ということになります。ただ、まさに3rd party iframeが埋め込まれていると大変になってきます。WHATWGの仕様を見るとわかりますが、セッション履歴はiframeの中身も含めて管理されなければなりません。もしも各プロセスが自分の履歴だけを持っていると、3rd partyのiframeが埋め込まれているときに処理がけっこう複雑になりそうです。
ということで、Firefoxでは親プロセスで履歴を一元管理しています。これをSHIPと言います。Session-History-In-Parentです。SHIPがあるので特定のタブで戻ったり進んだりするナビゲーションができるかどうかは親プロセスで判定することになるはずです。
ということで先ほど呼び出されたSendMayStartSwipeからDispatchWindowEventを通じてたどっていってみましょう。
nsIWidget::SwipeInfo nsIWidget::SendMayStartSwipe(
const mozilla::PanGestureInput& aSwipeStartEvent) {
[...]
WidgetSimpleGestureEvent geckoEvent = SwipeTracker::CreateSwipeGestureEvent(
eSwipeGestureMayStart, this, position, aSwipeStartEvent.mTimeStamp);
geckoEvent.mDirection = direction;
geckoEvent.mDelta = 0.0;
geckoEvent.mAllowedDirections = 0;
bool shouldStartSwipe =
DispatchWindowEvent(geckoEvent); // event cancelled == swipe should start
SwipeInfo result = {shouldStartSwipe, geckoEvent.mAllowedDirections};
return result;
}
bool nsIWidget::DispatchWindowEvent(WidgetGUIEvent& event) {
return ConvertStatus(DispatchEvent(&event));
}
ここのDispatchEventはもともとの呼び出し元を考えると、nsCocoaWindow::DispatchEventであることがわかります。
// Invokes callback and ProcessEvent methods on Event Listener object
nsEventStatus nsCocoaWindow::DispatchEvent(WidgetGUIEvent* event) {
RefPtr kungFuDeathGrip{this};
[...]
return nsIWidget::DispatchEvent(event);
}
nsEventStatus nsIWidget::DispatchEvent(WidgetGUIEvent* aEvent) {
if (mAttachedWidgetListener) {
return mAttachedWidgetListener->HandleEvent(aEvent);
}
if (mWidgetListener) {
return mWidgetListener->HandleEvent(aEvent);
}
return nsEventStatus_eIgnore;
}
どちらが呼ばれるのかここでは分からなくても一旦よくて、いずれもPresShellWidgetListener::HandleEventだろうと検討がつきます。というのも、mAttachedWidgetListenerもmWidgetListenerもnsIWidgetListenerであって、nsIWidgetListener::HandleEventをoverrideしているのはPresShellWidgetListener::HandleEventかnsMenuPopupFrame::HandleEventなので、さすがに前者だろうと思われるからです。
nsEventStatus PresShellWidgetListener::HandleEvent(WidgetGUIEvent* aEvent) {
MOZ_ASSERT(aEvent->mWidget, "null widget ptr");
nsEventStatus result = nsEventStatus_eIgnore;
MaybeUpdateLastUserEventTime(aEvent);
if (RefPtr<PresShell> ps = GetPresShell()) {
if (nsIFrame* root = ps->GetRootFrame()) {
ps->HandleEvent(root, aEvent, false, &result);
}
}
return result;
}
どうやらroot frameをとっていますね。さらに追っていくと下記のようになり、DOMにイベントをdispatchしていることがわかります。
- PresShell::HandleEvent
- PresShell::EventHandler::HandleEvent
- PresShell::EventHandler::HandleEventWithFrameForPresShell
- PresShell::EventHandler::HandleEventWithCurrentEventInfo
- PresShell::EventHandler::DispatchEvent
- PresShell::EventHandler::HandleEventWithCurrentEventInfo
- PresShell::EventHandler::HandleEventWithFrameForPresShell
- PresShell::EventHandler::HandleEvent
nsresult PresShell::EventHandler::DispatchEvent(
EventStateManager* aEventStateManager, WidgetEvent* aEvent,
bool aTouchIsNew, nsEventStatus* aEventStatus,
nsIContent* aOverrideClickTarget) {
[...]
if (aEvent->IsAllowedToDispatchDOMEvent() &&
!(aEvent->PropagationStopped() &&
aEvent->IsWaitingReplyFromRemoteProcess())) {
[...]
if (aEvent->mClass == eTouchEventClass) {
DispatchTouchEventToDOM(aEvent, aEventStatus, &eventCB, aTouchIsNew);
} else {
DispatchEventToDOM(aEvent, aEventStatus, &eventCB);
}
}
[...]
}
ということで親プロセスのDOM、つまりchrome codeで判定を行っていそうな気配がします。いわゆるchrome codeですね。
chromeというとGoogle Chromeが想起されますが、ブラウザのUIのことを指すことがあります。実はMDNにもこんな用語定義が掲載されています。
Chrome
In a browser, the chrome is any visible aspect of a browser aside from the webpages themselves (e.g., toolbars, menu bar, tabs). This is not to be confused with the Google Chrome browser.
https://developer.mozilla.org/en-US/docs/Glossary/Chrome
で、chrome codeというとFirefoxではブラウザUIを実装しているJSのことを指します。
The JavaScript code that along with the C++ core, implements the browser itself is called chrome code and runs using system privileges.
https://firefox-source-docs.mozilla.org/dom/scriptSecurity/xray_vision.html
先ほどの図でいうと、browser UIと書いてある部分ではchrome codeが動いていそうです。

Firefoxのドキュメント(https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html)より
chrome codeということはJSで処理をしているということになります。がんばってコードを辿ってもいいですが、一旦イベントの名前で検索してショートカットを図ります。先ほどこのようなコードがありました。
WidgetSimpleGestureEvent geckoEvent = SwipeTracker::CreateSwipeGestureEvent(
eSwipeGestureMayStart, this, position, aSwipeStartEvent.mTimeStamp);
ということでeSwipeGestureMayStartでsearchfoxすると、下記が見つかります。
NON_IDL_EVENT(MozSwipeGestureMayStart, eSwipeGestureMayStart,
EventNameType_None, eSimpleGestureEventClass)
どうやらMozSwipeGestureMayStartという名前に変換されているようですね。これで検索してみるとこれがなんと大当たりで、JSにたどりつきます。
/**
* Dispatch events based on the type of mouse gesture event. For now, make
* sure to stop propagation of every gesture event so that web content cannot
* receive gesture events.
*
* @param aEvent
* The gesture event to handle
*/
handleEvent: function GS_handleEvent(aEvent) {
[...]
switch (aEvent.type) {
case "MozSwipeGestureMayStart":
if (this._shouldDoSwipeGesture(aEvent)) {
aEvent.preventDefault();
}
break;
[...]
}
},
_shouldDoSwipeGestureを見てみると、履歴の確認をしています!!
_shouldDoSwipeGesture: function GS__shouldDoSwipeGesture(aEvent) {
[...]
let canGoBack = gHistorySwipeAnimation.canGoBack();
let canGoForward = gHistorySwipeAnimation.canGoForward();
let isLTR = gHistorySwipeAnimation.isLTR;
if (canGoBack) {
aEvent.allowedDirections |= isLTR
? aEvent.DIRECTION_LEFT
: aEvent.DIRECTION_RIGHT;
}
if (canGoForward) {
aEvent.allowedDirections |= isLTR
? aEvent.DIRECTION_RIGHT
: aEvent.DIRECTION_LEFT;
}
return canGoBack || canGoForward;
},
/**
* Checks if there is a page in the browser history to go back to.
*
* @return true if there is a previous page in history, false otherwise.
*/
canGoBack: function HSA_canGoBack() {
return gBrowser.webNavigation.canGoBack;
},
ということで、親プロセスではchrome codeを使って履歴の有無の判定をしているのです。
履歴があるということになると、MayStartSwipeForAPZにてevent.mCanTriggerSwipeがセットされます。
WidgetWheelEvent nsIWidget::MayStartSwipeForAPZ(
const PanGestureInput& aPanInput, const APZEventResult& aApzResult) {
[...]
if (aPanInput.mHandledByAPZ && aPanInput.AllowsSwipe()) {
SwipeInfo swipeInfo = SendMayStartSwipe(aPanInput);
event.mCanTriggerSwipe = swipeInfo.wantsSwipe;
[...]
}
[...]
return event;
}
5: 子プロセスでの判定
さて、MayStartSwipeForAPZが終わるとProcessUntransformedAPZEventが呼び出されることが最初のコードをみるとわかります。
void nsCocoaWindow::DispatchAPZWheelInputEvent(InputData& aEvent) {
[...]
WidgetWheelEvent event(true, eWheel, this);
if (mAPZC) {
APZEventResult result;
switch (aEvent.mInputType) {
case PANGESTURE_INPUT: {
result = mAPZC->InputBridge()->ReceiveInputEvent(aEvent); // APZ判定
if (result.GetStatus() == nsEventStatus_eConsumeNoDefault) {
return;
}
event = MayStartSwipeForAPZ(aEvent.AsPanGestureInput(), result); // 親プロセス判定
break;
}
[...]
}
if (event.mMessage == eWheel &&
(event.mDeltaX != 0 || event.mDeltaY != 0)) {
ProcessUntransformedAPZEvent(&event, result); // 子プロセス判定
}
return;
}
[...]
}
このProcessUntransformedAPZEventを見ていくと、また先ほどと同様にDispatchEventが呼ばれます。で、こんどはPresShell::EventHandler::DispatchEventにたどりついたあと、EventStateManager::PostHandleEventを介して、EventStateManager::HandleCrossProcessEventによって、子プロセスにイベントが送られます。
nsresult PresShell::EventHandler::DispatchEvent(
EventStateManager* aEventStateManager, WidgetEvent* aEvent,
bool aTouchIsNew, nsEventStatus* aEventStatus,
nsIContent* aOverrideClickTarget) {
[...]
RefPtr<nsPresContext> presContext = GetPresContext();
return aEventStateManager->PostHandleEvent(
presContext, aEvent, mPresShell->GetCurrentEventFrame(), aEventStatus,
aOverrideClickTarget);
}
nsresult EventStateManager::PostHandleEvent([...]) {
[...]
HandleCrossProcessEvent(aEvent, aStatus);
[...]
}
bool EventStateManager::HandleCrossProcessEvent(WidgetEvent* aEvent,
nsEventStatus* aStatus) {
if (!aEvent->CanBeSentToRemoteProcess()) {
return false;
}
[...]
// Collect the remote event targets we're going to forward this
// event to.
//
// NB: the elements of |remoteTargets| must be unique, for correctness.
AutoTArray<RefPtr<BrowserParent>, 1> remoteTargets;
if (aEvent->mClass != eTouchEventClass || aEvent->mMessage == eTouchStart) {
// If this event only has one target, and it's remote, add it to
// the array.
nsIFrame* frame = aEvent->mMessage == eDragExit
? sLastDragOverFrame.GetFrame()
: GetEventTarget();
nsIContent* target = frame ? frame->GetContent() : nullptr;
if (BrowserParent* remoteTarget = BrowserParent::GetFrom(target)) {
remoteTargets.AppendElement(remoteTarget);
}
} else {
[...]
}
if (remoteTargets.Length() == 0) {
return false;
}
// Dispatch the event to the remote target.
for (uint32_t i = 0; i < remoteTargets.Length(); ++i) {
DispatchCrossProcessEvent(aEvent, remoteTargets[i], aStatus);
}
[...]
}
ちなみにどうして先ほどのイベントは親プロセスで処理されたのに、こんどは子プロセスに転送されるかというとイベントの種類が少し違うからです。上の関数に潜んでいるCanBeSentToRemoteProcessはこんな感じになっています。ここで、イベントの種類ごとに送るだの送らないだのを決めているわけですね。
bool WidgetEvent::CanBeSentToRemoteProcess() const {
// If this event is explicitly marked as shouldn't be sent to remote process,
// just return false.
if (IsCrossProcessForwardingStopped()) {
return false;
}
if (mClass == eKeyboardEventClass || mClass == eWheelEventClass) {
return true;
}
switch (mMessage) {
case eMouseDown:
case eMouseUp:
case eMouseMove:
case eMouseExploreByTouch:
case eContextMenu:
case eMouseEnterIntoWidget:
case eMouseExitFromWidget:
case eMouseTouchDrag:
case eTouchStart:
case eTouchMove:
case eTouchEnd:
case eTouchCancel:
case eDragOver:
case eDragExit:
case eDrop:
return true;
default:
return false;
}
}
SendMayStartSwipeの中で送られていたのは、WidgetSimpleGestureEvent(eSwipeGestureMayStart)でした。これはmClass == eSimpleGestureEventClassで、かつeSwipeGestureMayStartがswitch文にも存在しません。ということでfalseになり、親プロセスにとどまります。
一方で、ProcessUntransformedAPZEventからは、mClass == eWheelEventClassなるイベントがとばされるので、これはtrueになり子プロセスにとばされます。
さて、とばされたイベントは再び子プロセスでdispatchされて、子プロセス側でPostHandleEventにて処理されます。
まず、子プロセスに送られてきたイベントについて、もう少し詳しく見てみましょう。
このWidgetWheelEventは、先ほどMayStartSwipeForAPZが呼び出されたときに、PanGestureInput::ToWidgetEventによって作られています。
WidgetWheelEvent PanGestureInput::ToWidgetEvent(nsIWidget* aWidget) const {
WidgetWheelEvent wheelEvent(true, eWheel, aWidget);
[...]
wheelEvent.mDeltaX = mPanDisplacement.x;
wheelEvent.mDeltaY = mPanDisplacement.y;
[...]
return wheelEvent;
}
ここでポイントになるのは、mDeltaXとmDeltaYに入っているのがmPanDisplacement、つまり座標変換前の生のpan量だということです。今回の例では、ユーザーは画面上で横方向にスワイプしています。そのため、このイベントはmDeltaXを持った水平wheel eventとして作られます。
しかし、対象の要素をCSSの世界から見てみると、こうなっています。
overflow-x: hidden;
overflow-y: scroll;
transform: rotate(270deg);
画面上では横にスクロールできるように見えますが、それは縦スクロール可能な要素全体が回転しているからです。レイアウト上のscroll axisはあくまで縦方向です。つまり、ここでは次のような食い違いが生まれています。
- 入力イベント: 横方向のwheel event
- 対象要素: 縦方向にscrollableな要素
- ただし見た目上は、transformによって横方向にscrollable
APZはtransformを考慮しているので、このpan gestureを対象要素がconsumeできると理解しています。一方、これから見ていくESM側の判定では、transform前のscroll axisと、生のwheel deltaが比較されることになります。ということで実際にPostHandleEventの中を見てみます。コードはだいぶ省略しています。
nsresult EventStateManager::PostHandleEvent([...]) {
[...]
switch (aEvent->mMessage) {
case eWheel:
case eWheelOperationStart: {
[...]
case WheelPrefs::ACTION_NONE:
default:
bool allDeltaOverflown = false;
if (wheelEvent->mFlags.mHandledByAPZ) {
if (wheelEvent->mCanTriggerSwipe) {
[...]
if (!wheelTransactionHandlesInput) {
allDeltaOverflown = !ComputeScrollTarget(
mCurrentTarget, wheelEvent,
COMPUTE_DEFAULT_ACTION_TARGET_WITHOUT_WHEEL_TRANSACTION);
}
}
}
if (!allDeltaOverflown) {
break;
}
wheelEvent->mOverflowDeltaX = wheelEvent->mDeltaX;
wheelEvent->mOverflowDeltaY = wheelEvent->mDeltaY;
wheelEvent->mViewPortIsOverscrolled = true;
break;
}
}
[...]
}
まず、mHandledByAPZはtrueです。このイベントは既にAPZを通ってきていますからね。さらに、親プロセスで履歴が存在すると判定されているため、先ほど見たMayStartSwipeForAPZによってmCanTriggerSwipeもtrueになっています。
ということで、ComputeScrollTargetが呼び出されます。この関数は名前のとおり、このwheel eventを使ってスクロールできる対象を探します。内部ではwheel eventのdeltaを見て、X方向にスクロールできる要素を探すべきか、Y方向にスクロールできる要素を探すべきかを決定します。だいたい下記のような感じです。
checkIfScrollableX =
aDirectionX &&
(aOptions & PREFER_ACTUAL_SCROLLABLE_TARGET_ALONG_X_AXIS);
checkIfScrollableY =
aDirectionY &&
(aOptions & PREFER_ACTUAL_SCROLLABLE_TARGET_ALONG_Y_AXIS);
今回のイベントには生の水平deltaが入っています。したがって、ESMは「X方向にスクロールできる要素」を探し始めます。そして、見つけたscroll frameが実際にどちらの方向へスクロールできるかも確認されます。
layers::ScrollDirections directions =
scrollContainerFrame
->GetAvailableScrollingDirectionsForUserInputEvents();
if ((checkIfScrollableY && !checkIfScrollableX &&
!directions.contains(layers::ScrollDirection::eVertical)) ||
(checkIfScrollableX && !checkIfScrollableY &&
!directions.contains(layers::ScrollDirection::eHorizontal))) {
continue;
}
今回の対象要素は、CSS上はoverflow-y: scrollであり、overflow-x: hiddenです。そのためESMから見ると、この要素は縦方向にはスクロールできますが、横方向にはスクロールできません。要素全体が回転しており、画面上では横方向に動くという事情は、この判定には反映されていません。結果として、対象要素は候補から除外され、ComputeScrollTargetはスクロール先を見つけられずにnullptrを返します。
ということで、先ほどのコードで下記がtrueになります。
allDeltaOverflown = !ComputeScrollTarget(...);
つまりESMは、「このwheel deltaをconsumeできるscroll targetは存在しなかった」と判断するわけです。scroll targetに消費されなかったdeltaは、viewportからあふれたdeltaとして扱われます。
wheelEvent->mOverflowDeltaX = wheelEvent->mDeltaX;
wheelEvent->mOverflowDeltaY = wheelEvent->mDeltaY;
wheelEvent->mViewPortIsOverscrolled = true;
これによって、下記の状態が完成します。
- mCanTriggerSwipe == true
- mViewPortIsOverscrolled == true
- mOverflowDeltaX != 0
ここまで来ると、次のTriggersSwipeがtrueを返します。
bool TriggersSwipe() const {
return mCanTriggerSwipe &&
mViewPortIsOverscrolled &&
mOverflowDeltaX != 0.0;
}
見事に全部満たしていますね。最後に、この結果がBrowserChild::DispatchWheelEventから親プロセスへ返されます。
void BrowserChild::DispatchWheelEvent(
const WidgetWheelEvent& aEvent,
const ScrollableLayerGuid& aGuid,
const uint64_t& aInputBlockId) {
WidgetWheelEvent localEvent(aEvent);
[...]
DispatchWidgetEventViaAPZ(localEvent);
if (localEvent.mCanTriggerSwipe) {
SendRespondStartSwipeEvent(
aInputBlockId, localEvent.TriggersSwipe());
}
[...]
}
localEvent.TriggersSwipe()はtrueなので、SendRespondStartSwipeEventによって「swipeを開始してよい」という応答が親プロセスへ送られます。そして、最終的に履歴ナビゲーションが始まるというわけです。
まとめると、こんなことが起きていたわけです。
- APZはtransformを考慮して、対象要素を横方向のgestureでスクロールできると正しく判定する
- しかし、その判定結果はWidget側のswipe判定には伝えられていない
- Widgetは履歴が存在するため、イベントにmCanTriggerSwipeをセットする
- 子プロセスのESMは、生の水平wheel deltaを使って、横方向にスクロールできる要素を探す
- 対象要素はCSS上は縦方向にしかスクロールできないため、scroll targetとして見つからない
- ESMはイベントをviewport overscrollとして扱う
- TriggersSwipe()がtrueになり、親プロセスへswipe開始の応答が送られる
APZから見ると「このgestureはちゃんとスクロールに使えますよ」なのに、ESMから見ると「横方向にスクロールできる要素がないのでviewportからはみ出しましたよ」になっていたわけです。同じ入力について、異なるレイヤーが異なる座標系と異なる情報を使って判定し、その結果が食い違っていました。これが今回のバグの直接的な原因でした。
パッチの中身
さて、あらためて手順を見ます。
- 親プロセスかGPUプロセスで、APZがナビゲーションが行われる可能性があるかを判定する
- 1において可能性があるとされた場合、APZはすぐにスクロールをするのではなく、後続の判定処理のために少し待機する
- 再度、Widgetにてナビゲーションが行われる可能性があるかを判定する
- 3において可能性があるとされた場合、親プロセスで、ナビゲーションを行うためのセッションヒストリーが存在するかを判定する
- 4においてヒストリーが存在する場合、子プロセスで、スクロールではなくてナビゲーションをすべきか判定する
- 5においてナビゲーションをすべきと判定された場合、親プロセスにメッセージングが行われてナビゲーションが発生する
そのうえで、不具合は下記2つの条件が重なって発生していました。
A. 3以降が1においてナビゲーションをする余地がない(=スクロールが可能である)とされた場合にも実行され、
B. 5において要素の回転が考慮されていなかった
実際のパッチが直したのはAだけです。差分は実質これだけです。Widgetの判定(例のMayStartSwipeForAPZ)に、APZが出した「水平スクロールできるか」の結果を1つ足しました。
// widget/nsIWidget.cpp
- if (aPanInput.mHandledByAPZ && aPanInput.AllowsSwipe()) {
+ if (aPanInput.mHandledByAPZ && aPanInput.AllowsSwipe() &&
+ !aApzResult.mTargetCanScrollHorizontally) {
SwipeInfo swipeInfo = SendMayStartSwipe(aPanInput);
このmTargetCanScrollHorizontallyは今回追加したフィールドで、APZがCanScrollTargetHorizontallyで計算した結果をAPZEventResultに載せてAPZからWidgetまで運びます。
// gfx/layers/apz/src/InputQueue.cpp
- if (event.AllowsSwipe() && !CanScrollTargetHorizontally(event, block)) {
+ bool targetCanScrollHorizontally =
+ CanScrollTargetHorizontally(event, block);
+ result.mTargetCanScrollHorizontally = targetCanScrollHorizontally;
+ if (event.AllowsSwipe() && !targetCanScrollHorizontally) {
つまり「3」で書いた1つ目の原因、APZは答えを知っていたのにWidgetに伝えていなかったという点を改修しました。これでWidgetはAPZと同じ判定ができるようになり、APZが「スクロールできる」と判定したイベントはswipe判定に進まなくなります。
なお、ほかにAPZInputBridge.hでのフィールド追加と、LayersMessageUtils.hでのIPCシリアライズ対応が入っています。GPU processでAPZが動く構成でも結果を返せるよう、IPC serializationが入っています。
で、原因Bはどうしたのか
原因Bは「子プロセスのESMが座標変換を考慮していないので、回転した要素を水平スクロールのターゲットとして見つけられず、スワイプがviewportのoverscrollとして扱われてしまう」というものでした。
Bはこのパッチでは直しませんでした。Aのゲートを閉じて、そもそもESMの誤判定を回避しています。回転していてもAPZは正しく「水平スクロールできる」と判定できるので、その結果でWidgetのゲートを閉じてしまえば、ESMまで話が進まないというわけです。
ちなみに、Bを直す(ESMをtransform-awareにする)ほうがいいのでは?というのはまさにレビューでも議論になりました。レビュアーのHiroさんのコメントを引用します。
[ESMをtransform-awareにする] the other approach is more reasonable than the current one. As you may know that the content side, i.e. the world in where EventStateManager lives has more information than APZ. So I suspect there are edge cases that this patch won't work.
APZはレイアウト情報のすべてを知っているわけではありません。子プロセスのほうがAPZより情報を持っているので、本当はそっちで正しい判定ができるようにしたほうがいいという議論です。これはたしかにその通りだと思われます。ということで、ESMをtransform-awareにするアプローチをfollow-upのBug 2040773に切り出しました。
まとめると、修正のステップはこうなります。
- 親プロセスかGPUプロセスで、APZがナビゲーションが行われる可能性があるかを判定する
- 1において可能性があるとされた場合、APZはすぐにスクロールをするのではなく、後続の判定処理のために少し待機する
- 再度、Widgetにてナビゲーションが行われる可能性があるかを判定する <- APZが「水平スクロールできる」と判定していたら、ここで打ち切るようにした(原因Aの修正)
- 3において可能性があるとされた場合、親プロセスで、ナビゲーションを行うためのセッションヒストリーが存在するかを判定する
- 4においてヒストリーが存在する場合、子プロセスで、スクロールではなくてナビゲーションをすべきか判定する <- 回転が考慮されていない(原因B)。ただし今回は3で打ち切るので到達しない。ここの修正はfollow-up(Bug 2040773)へ
- 5においてナビゲーションをすべきと判定された場合、親プロセスにメッセージングが行われてナビゲーションが発生する
分かったこと
下記のことを覚えておきたいです。
- APZ関連ドキュメントにあるようなアーキテクチャ全般
- Firefoxは親プロセスで履歴を一元管理している。その機構はSHIPと呼ばれている
- Firefoxコード中のdispatchという言葉は一つの操作を指す用語ではなく、Widgetからlistenerへ渡す処理、Geckoのevent pipelineへ流す処理、DOM event dispatch、IPCによるremote processへのforwardingなど、レイヤーごとに異なる意味で使われていそう