← Another post?
Available languages:
日本語English

Fixing a Firefox bug where swiping to scroll a rotated element navigates the page instead

Table of Contents

I recently fixed a swipe-gesture bug in Firefox, so here are some notes.

Background: APZ, or how surprisingly involved user-gesture handling is

Firefox has a mechanism for quickly handling user gestures whose screen updates can be expressed as simple transforms — scrolling is just a translation, zooming is just a scale change. It's called APZ.

https://firefox-source-docs.mozilla.org/gfx/AsyncPanZoom.html
↑ If you've read this doc, feel free to skip this chapter.

To begin with, browsers do quite a lot of work to handle user gestures. To get a small sense of that, let's think about scrolling.

First, the user rolls the mouse wheel or swipes across the trackpad intending to scroll. The OS emits input and hands it to the browser's parent process. The parent process goes by a few names: browser process, parent process, main process. In security contexts some people call it the browser kernel.

There's a passage in the Chromium docs that talks about exactly this, so I'll quote it.

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


From the Chromium docs docs/accessibility/browser/how_a11y_works_2.md

So, the browser first needs to figure out which frame this input was directed at. In Firefox's case, hit testing happens in the parent process or the GPU process. I'd guess Chromium does something similar.

The key point is that this input might be for the tab's content, or it might be for the browser's own UI (like the bookmark button). Taking that into account, the browser has to decide which process should receive this input. And of course, in modern browsers third-party iframes are usually process-isolated too, so that has to factor into the decision as well. This is therefore a broader notion than the "hit testing" you usually hear about in frontend development. More to the point, this isn't the kind of processing that could be done in a child process (called the content process, child process, or renderer process). A child process knows about its own content, but it has no idea what the child process managing the neighboring tab is doing.

In Firefox, APZ handles this high-level hit test. Once the content the input belongs to is determined, the input is sent to the process corresponding to that content. Here's a diagram.


From the Firefox docs (https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html)

Now the process that received the input starts handling the event. There's a lot going on, but the star here is probably the event listener. Running listeners is a job unique to child processes, which can execute JS. Here, something like a wheel listener should fire. In Firefox, a more detailed hit test happens in the child process, and then listener processing begins.

On top of event listeners, we also need to actually scroll the screen. Here's the interesting bit: waiting for listener processing to finish before scrolling is, at the very least, too slow. It's just plain slow, and if a listener eats up a ton of time, it's a disaster. So the scrolling needs to happen on a different thread or process from the listener.

But another fun wrinkle appears: preventDefault(). Can we really just say "no time to wait for listeners" and update the screen? At minimum, we'd need to check whether preventDefault() was called. And how should we handle the case where preventDefault() is called a little late?

Let's consider a slightly more complex case. JS can trigger a scroll too. If a scroll is triggered inside listener processing while scrolling is also being handled on a different thread or process, how should the two be reconciled? And what if JS calls something like getBoundingClientRect()? As of which point in time does it return its result?

Things are getting out of hand. So handling user gestures is genuinely impressive work for a browser. And APZ is Firefox's module for doing a subset of those gestures fast.

What the bug was

The bug I fixed was also APZ-related. This one:

"Scroll not working after transform and rotate an element with CSS - macOS Catalina"
https://bugzilla.mozilla.org/show_bug.cgi?id=1611949

I'll leave the details to the bug ticket, but in short: "when you rotate a vertically-scrollable element so it's sideways, and then swipe intending to scroll horizontally, the page navigates instead." As of today (2026/7/3 EST), i.e. Firefox 152.0.4, this still reproduces.

I've set up a page and steps below, so give it a try.

  1. Open https://output.jsbin.com/toqigirata in this tab
  2. Swipe hard to the right to scroll
  3. Swipe left, and instead of scrolling you'll come back to this article

You can reproduce it with HTML like this.

<!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>
  <!-- you could also drop in a very long block of text instead of the div -->
</div>
</html>

The patch is already merged and it's fixed on Nightly!

By the way, on Chrome and Safari a vertical swipe scrolls left/right. I'd guess their swipe-gesture handling doesn't apply the coordinate transform.

How I fixed it

How swipes are handled, and the cause of the bug

Let's get concrete. How are swipes handled in Firefox?
The short answer: navigation is decided by cross-checking judgments from three parties — APZ, the parent process, and the child process.
More precisely, it's decided by combining a coarse scrollability judgment by APZ in the GPU process (or parent process), a history judgment by chrome code in the parent process, and a fine-grained scrollability judgment by the ESM in the content process.

First, when the user swipes, a bunch of stuff happens and this function gets called in the widget code (the boundary between the OS and 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 judgment
        if (result.GetStatus() == nsEventStatus_eConsumeNoDefault) {
          return;
        }

        event = MayStartSwipeForAPZ(aEvent.AsPanGestureInput(), result); // parent-process judgment
        break;
      }
      [...]
    }
    if (event.mMessage == eWheel &&
        (event.mDeltaX != 0 || event.mDeltaY != 0)) {
      ProcessUntransformedAPZEvent(&event, result); // child-process judgment
    }
    return;
  }
  [...]
}

The function above is our anchor. Let's start here. The processing goes like this:

  1. In the parent process or GPU process, APZ judges whether navigation might occur
  2. If step 1 says it might, APZ doesn't scroll right away — it waits a bit for the subsequent judgments
  3. Again, in the Widget, judge whether navigation might occur
  4. If step 3 says it might, in the parent process, judge whether session history exists to navigate through
  5. If step 4 finds history, in the child process, judge whether we should navigate rather than scroll
  6. If step 5 says we should navigate, a message is sent to the parent process and navigation happens

And the bug occurred when the following two conditions overlapped.

A. Steps 3 onward run even when step 1 determined there's no room to navigate (i.e. scrolling is possible), and
B. Step 5 didn't account for the element's rotation

Let's go through them in order.

1, 2: APZ's navigation-possibility judgment and waiting

First, in the parent process or GPU process, APZ judges the following two things, and if navigation might occur — i.e. A && !B below — it enables the waiting behavior.

A. Whether the event is eligible to perform swipe-to-navigation
B. Whether the target element has room to scroll horizontally

https://searchfox.org/firefox-main/rev/abda5a5bbb85fa2a9327f44e0893cffef043a755/gfx/layers/apz/src/InputQueue.cpp#503

    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);
      }
      [...]
    }

If you follow AllowsSwipe, it leads to OverscrollBehaviorAllowsHandoff, and you can see it's looking at things like the overscroll-behavior property.

https://searchfox.org/firefox-main/rev/ed6cfb3b/gfx/layers/apz/src/Axis.cpp#513

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;
}

By "waiting," I mean APZ waits for the subsequent judgments in the parent and child processes. With a timeout, of course. In fact, APZ takes the same approach — waiting — for the preventDefault problem I mentioned earlier. That's described in the doc below, so give it a read.

https://firefox-source-docs.mozilla.org/gfx/AsyncPanZoom.html

3: The judgment in the Widget

The Widget is the code that sits at the boundary between Firefox and the OS. The Firefox repository has a widget directory, and that's what I mean. There's code like this in there.

https://searchfox.org/firefox-main/rev/abda5a5bbb85fa2a9327f44e0893cffef043a755/widget/nsIWidget.cpp#2343

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 showed up earlier. The gist here is: it doesn't go as far as checking whether scrolling is actually possible (that was CanScrollTargetHorizontally), but if swipe-based navigation is allowed (AllowsSwipe), let's SendMayStartSwipe.

Unlike the earlier judgment, CanScrollTargetHorizontally isn't used here. That's because this is Widget code and it doesn't have as much information as APZ. This is the first cause. The important thing here is that APZ already knows the answer. As we saw in step 1, APZ computes "is there room to scroll horizontally?" with CanScrollTargetHorizontally. But before the fix, it only used that result for its own waiting judgment and never passed it to the Widget. So the Widget couldn't reproduce the same judgment and moved ahead looking only at AllowsSwipe. APZ knew "this can scroll (= don't navigate)," but that information wasn't reaching the Widget.

As we'll see later, the patch does exactly this: carry APZ's judgment all the way to the Widget.

4: The judgment in the parent process

Next, we judge whether history exists to navigate through. Even if you swipe in the "back" direction, there's nothing to go back to if there's no previous page.

This judgment happens in the parent process. Let's think a bit about why it isn't the child process.

In Firefox, thanks to Fission, documents from different sites — cross-origin iframes, for example — run in separate content processes. This kind of per-origin or per-site process separation for browser safety is sometimes called Site Isolation. Chromium does it too. In Chromium, iframes split off into their own process are called OOPIFs (Out-of-Process Iframes).

Now, how is session history managed in this setup? The naive thought is: just give each process its own, right? But things get hairy precisely when a third-party iframe is embedded. As you can see in the WHATWG spec, session history has to be managed including the contents of iframes. If each process only had its own history, processing would get pretty complex once third-party iframes are embedded.

So Firefox centralizes history management in the parent process. This is called SHIP — Session-History-In-Parent. Because of SHIP, whether a given tab can navigate back or forward ends up being judged in the parent process.

So let's follow the earlier SendMayStartSwipe call, tracing through DispatchWindowEvent.

https://searchfox.org/firefox-main/rev/ed6cfb3b/widget/nsIWidget.cpp#2305

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));
}

Given the original caller, we can see this DispatchEvent is 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;
}

We don't need to know which of the two gets called here; either way we can guess it's PresShellWidgetListener::HandleEvent. That's because both mAttachedWidgetListener and mWidgetListener are nsIWidgetListener, and the only overrides of nsIWidgetListener::HandleEvent are PresShellWidgetListener::HandleEvent and nsMenuPopupFrame::HandleEvent — so it's surely the former.

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;
}

It's grabbing the root frame. Following further leads to the below, and we can see it's dispatching the event to the DOM.

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);
    }
  }
  [...]
}

So it looks like the judgment happens in the parent process's DOM — i.e. chrome code.
"Chrome" makes you think of Google Chrome, but it can refer to the browser's UI. There's actually a term definition for this on 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

And in Firefox, "chrome code" refers to the JS that implements the browser UI.

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

In the earlier diagram, chrome code seems to run in the part labeled "browser UI."


From the Firefox docs (https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html)

Chrome code means the processing happens in JS. We could grind through tracing the code, but let's take a shortcut and search by the event name. We had this code earlier.

  WidgetSimpleGestureEvent geckoEvent = SwipeTracker::CreateSwipeGestureEvent(
      eSwipeGestureMayStart, this, position, aSwipeStartEvent.mTimeStamp);

So if we searchfox eSwipeGestureMayStart, we find the below.

https://searchfox.org/firefox-main/rev/ed6cfb3b/dom/events/EventNameList.inc#464

NON_IDL_EVENT(MozSwipeGestureMayStart, eSwipeGestureMayStart,
              EventNameType_None, eSimpleGestureEventClass)

Looks like it's converted to the name MozSwipeGestureMayStart. Searching for that turns out to be a jackpot — it leads straight to 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;
      [...]
    }
  },

Looking at _shouldDoSwipeGesture, it's checking history!

https://searchfox.org/firefox-main/rev/ed6cfb3b/browser/base/content/browser-gestureSupport.js#209

  _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;
  },

So in the parent process, chrome code is used to judge whether history exists.

If history exists, event.mCanTriggerSwipe is set in MayStartSwipeForAPZ.

https://searchfox.org/firefox-main/rev/abda5a5bbb85fa2a9327f44e0893cffef043a755/widget/nsIWidget.cpp#2343

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: The judgment in the child process

Now, once MayStartSwipeForAPZ finishes, ProcessUntransformedAPZEvent gets called, as we can see in the first code block.

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 judgment
        if (result.GetStatus() == nsEventStatus_eConsumeNoDefault) {
          return;
        }

        event = MayStartSwipeForAPZ(aEvent.AsPanGestureInput(), result); // parent-process judgment
        break;
      }
      [...]
    }
    if (event.mMessage == eWheel &&
        (event.mDeltaX != 0 || event.mDeltaY != 0)) {
      ProcessUntransformedAPZEvent(&event, result); // child-process judgment
    }
    return;
  }
  [...]
}

Following ProcessUntransformedAPZEvent, DispatchEvent gets called again just like before. This time, after reaching PresShell::EventHandler::DispatchEvent, the event is sent to the child process via EventStateManager::PostHandleEvent and then 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);
  }
  [...]
}

Incidentally, why was the earlier event handled in the parent process while this one gets forwarded to the child process? Because the event types are a bit different. The CanBeSentToRemoteProcess hiding in the function above looks like this. This is where it decides, per event type, whether to forward or not.

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;
  }
}

What got sent inside SendMayStartSwipe was a WidgetSimpleGestureEvent (eSwipeGestureMayStart). That has mClass == eSimpleGestureEventClass, and eSwipeGestureMayStart isn't in the switch statement either. So it returns false and stays in the parent process.

On the other hand, ProcessUntransformedAPZEvent sends an event with mClass == eWheelEventClass, which returns true and gets forwarded to the child process.

The forwarded event is dispatched again in the child process and handled there in PostHandleEvent.

First, let's look a bit more closely at the event sent to the child process.
This WidgetWheelEvent was created by PanGestureInput::ToWidgetEvent back when MayStartSwipeForAPZ was called.

WidgetWheelEvent PanGestureInput::ToWidgetEvent(nsIWidget* aWidget) const {
  WidgetWheelEvent wheelEvent(true, eWheel, aWidget);
  [...]

  wheelEvent.mDeltaX = mPanDisplacement.x;
  wheelEvent.mDeltaY = mPanDisplacement.y;

  [...]
  return wheelEvent;
}

The key point here is that mDeltaX and mDeltaY hold mPanDisplacement — the raw pan amount before any coordinate transform. In our example, the user swipes horizontally on screen. So the event is created as a horizontal wheel event with an mDeltaX.

But if you look at the target element from the CSS world, it's like this:

overflow-x: hidden;
overflow-y: scroll;
transform: rotate(270deg);

On screen it looks horizontally scrollable, but that's because the whole vertically-scrollable element is rotated. The layout-level scroll axis is still vertical. So a mismatch arises:

APZ accounts for the transform, so it understands the target element can consume this pan gesture. The ESM-side judgment we're about to look at, however, compares the pre-transform scroll axis with the raw wheel delta. Let's actually look inside PostHandleEvent. The code is heavily abbreviated.

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;
    }
  }

  [...]
}

First, mHandledByAPZ is true. This event has already been through APZ. And since the parent process determined that history exists, mCanTriggerSwipe is also true thanks to MayStartSwipeForAPZ from earlier.

So ComputeScrollTarget gets called. As the name suggests, this function looks for a target that can scroll using this wheel event. Internally, it looks at the wheel event's delta and decides whether to look for an element that can scroll in X or in Y. Roughly like this:

checkIfScrollableX =
    aDirectionX &&
    (aOptions & PREFER_ACTUAL_SCROLLABLE_TARGET_ALONG_X_AXIS);

checkIfScrollableY =
    aDirectionY &&
    (aOptions & PREFER_ACTUAL_SCROLLABLE_TARGET_ALONG_Y_AXIS);

Our event carries a raw horizontal delta. So the ESM starts looking for "an element that can scroll in X." And it also checks which direction the scroll frame it finds can actually scroll in.

layers::ScrollDirections directions =
    scrollContainerFrame
        ->GetAvailableScrollingDirectionsForUserInputEvents();

if ((checkIfScrollableY && !checkIfScrollableX &&
     !directions.contains(layers::ScrollDirection::eVertical)) ||
    (checkIfScrollableX && !checkIfScrollableY &&
     !directions.contains(layers::ScrollDirection::eHorizontal))) {
  continue;
}

Our target element is overflow-y: scroll and overflow-x: hidden in CSS. So from the ESM's point of view, this element can scroll vertically but not horizontally. The fact that the whole element is rotated and moves horizontally on screen isn't reflected in this judgment. As a result, the target element is excluded from the candidates, and ComputeScrollTarget can't find a scroll target and returns nullptr.

So the following becomes true in the earlier code.

allDeltaOverflown = !ComputeScrollTarget(...);

In other words, the ESM concludes "there was no scroll target that could consume this wheel delta." Delta that wasn't consumed by a scroll target is treated as delta that overflowed the viewport.

wheelEvent->mOverflowDeltaX = wheelEvent->mDeltaX;
wheelEvent->mOverflowDeltaY = wheelEvent->mDeltaY;
wheelEvent->mViewPortIsOverscrolled = true;

This completes the following state:

At this point, the next TriggersSwipe returns true.

bool TriggersSwipe() const {
  return mCanTriggerSwipe &&
         mViewPortIsOverscrolled &&
         mOverflowDeltaX != 0.0;
}

It satisfies all of them, beautifully. Finally, this result is returned to the parent process from 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() is true, so SendRespondStartSwipeEvent sends the response "you may start the swipe" to the parent process. And eventually, history navigation begins.

To sum up, here's what was happening:

  1. APZ correctly determines, accounting for the transform, that the target element can be scrolled by a horizontal gesture
  2. But that determination isn't communicated to the Widget-side swipe judgment
  3. Because history exists, the Widget sets mCanTriggerSwipe on the event
  4. The child process's ESM uses the raw horizontal wheel delta to look for an element that can scroll horizontally
  5. Since the target element can only scroll vertically in CSS terms, it isn't found as a scroll target
  6. The ESM treats the event as viewport overscroll
  7. TriggersSwipe() becomes true, and a swipe-start response is sent to the parent process

From APZ's perspective it's "this gesture can be used for scrolling just fine," while from the ESM's perspective it's "there's no horizontally-scrollable element, so it overflowed the viewport." For the same input, different layers judged using different coordinate systems and different information, and their conclusions disagreed. That was the direct cause of this bug.

What the patch does

Let's look at the steps again.

  1. In the parent process or GPU process, APZ judges whether navigation might occur
  2. If step 1 says it might, APZ doesn't scroll right away — it waits a bit for the subsequent judgments
  3. Again, in the Widget, judge whether navigation might occur
  4. If step 3 says it might, in the parent process, judge whether session history exists to navigate through
  5. If step 4 finds history, in the child process, judge whether we should navigate rather than scroll
  6. If step 5 says we should navigate, a message is sent to the parent process and navigation happens

And the bug occurred when the following two conditions overlapped.

A. Steps 3 onward run even when step 1 determined there's no room to navigate (i.e. scrolling is possible), and
B. Step 5 didn't account for the element's rotation

The actual patch only fixes A. The diff is essentially just this. To the Widget's judgment (that MayStartSwipeForAPZ), it adds one more thing: APZ's result for "can it scroll horizontally?"

// widget/nsIWidget.cpp
-  if (aPanInput.mHandledByAPZ && aPanInput.AllowsSwipe()) {
+  if (aPanInput.mHandledByAPZ && aPanInput.AllowsSwipe() &&
+      !aApzResult.mTargetCanScrollHorizontally) {
     SwipeInfo swipeInfo = SendMayStartSwipe(aPanInput);

This mTargetCanScrollHorizontally is a field I added this time. It carries the result APZ computed with CanScrollTargetHorizontally on the APZEventResult, all the way from APZ to the 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) {

In other words, it fixes the first cause I described in step 3 — APZ knew the answer but wasn't telling the Widget. Now the Widget can make the same judgment as APZ, and events that APZ judged "can scroll" no longer proceed to the swipe judgment.

There are also a couple of related changes: adding the field in APZInputBridge.h, and IPC serialization support in LayersMessageUtils.h. The IPC serialization is there so the result can still be returned in configurations where APZ runs in the GPU process.

So what about cause B?

Cause B was: "the child process's ESM doesn't account for the coordinate transform, so it can't find the rotated element as a horizontal-scroll target, and the swipe ends up treated as viewport overscroll."

B wasn't fixed in this patch. By closing the gate at A, we avoid the ESM's misjudgment in the first place. Even when rotated, APZ can correctly judge "can scroll horizontally," so if we close the Widget's gate using that result, things never get as far as the ESM.

By the way, wouldn't it be better to fix B (make the ESM transform-aware)? That came up in review, exactly. Here's a quote from the reviewer, Hiro:

[make the 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 doesn't know all of the layout information. The child process has more information than APZ, so ideally it should be the one making the correct judgment — that's the argument. And I think that's true. So I split out the approach of making the ESM transform-aware into a follow-up, Bug 2040773.

To sum up, the fix steps look like this:

  1. In the parent process or GPU process, APZ judges whether navigation might occur
  2. If step 1 says it might, APZ doesn't scroll right away — it waits a bit for the subsequent judgments
  3. Again, in the Widget, judge whether navigation might occur <- If APZ judged "can scroll horizontally," bail out here (the fix for cause A)
  4. If step 3 says it might, in the parent process, judge whether session history exists to navigate through
  5. If step 4 finds history, in the child process, judge whether we should navigate rather than scroll <- Rotation isn't accounted for (cause B). But this time we bail out at step 3, so we never reach here. The fix here goes to the follow-up (Bug 2040773)
  6. If step 5 says we should navigate, a message is sent to the parent process and navigation happens

What I learned

Things I want to remember:

← Another post?