Bug 1428839 - Part 4 - Avoid re-entrancy in PanelMultiView navigation functions. r=Gijs draft
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Tue, 27 Feb 2018 15:33:33 +0000
changeset 761005 fbf5424642a6eec4ff24a53d8f11d1bc7292182e
parent 761004 f387a13fc376e3359e5819d126a79d22affad555
child 761006 071398e4381f890fdb097e09f90dde6c5cd910df
push id100802
push userpaolo.mozmail@amadzone.org
push dateWed, 28 Feb 2018 13:44:56 +0000
reviewersGijs
bugs1428839
milestone60.0a1
Bug 1428839 - Part 4 - Avoid re-entrancy in PanelMultiView navigation functions. r=Gijs We now use the "active" property of views to track whether navigation is possible. This has the advantage of being already handled correctly when views are moved to a different panel, and is in line with the purpose of the "active" state. The note about using the "popupshown" event for navigation has been updated accordingly. Keyboard navigation is also linked to the "active" property now, so there is no need to track the state of the "_transitioning" property anymore. Since the goBack and showSubView methods can only be called when the view is active, we don't need to check for attempts to start a transition while the panel is closed anymore. MozReview-Commit-ID: 3KT3A5EwGFy
browser/components/customizableui/PanelMultiView.jsm
browser/components/customizableui/test/browser_PanelMultiView.js
--- a/browser/components/customizableui/PanelMultiView.jsm
+++ b/browser/components/customizableui/PanelMultiView.jsm
@@ -57,22 +57,24 @@
  *
  * -- Active or inactive
  *
  *    This indicates whether the view is fully scrolled into the visible area
  *    and ready to receive mouse and keyboard events. An active view is always
  *    visible, but a visible view may be inactive. For example, during a scroll
  *    transition, both views will be inactive.
  *
- *    When a view becomes active, the ViewShown event is fired synchronously.
- *    For the main view of the panel, this happens during the "popupshown"
- *    event, which means that other "popupshown" handlers may be called before
- *    the view is active. However, the main view can already receive mouse and
- *    keyboard events at this point, only because this allows regression tests
- *    to use the "popupshown" event to simulate interaction.
+ *    When a view becomes active, the ViewShown event is fired synchronously,
+ *    and the showSubView and goBack methods can be called for navigation.
+ *
+ *    For the main view of the panel, the ViewShown event is dispatched during
+ *    the "popupshown" event, which means that other "popupshown" handlers may
+ *    be called before the view is active. Thus, code that needs to perform
+ *    further navigation automatically should either use the ViewShown event or
+ *    wait for an event loop tick, like BrowserTestUtils.waitForEvent does.
  *
  * -- Navigating with the keyboard
  *
  *    An open view may keep state related to keyboard navigation, even if it is
  *    invisible. When a view is closed, keyboard navigation state is cleared.
  *
  * This diagram shows how <panelview> nodes move during navigation:
  *
@@ -341,21 +343,17 @@ var PanelMultiView = class extends this.
 
     gWindowsWithUnloadHandler.add(window);
   }
 
   get _panel() {
     return this.node.parentNode;
   }
 
-  get _transitioning() {
-    return this.__transitioning;
-  }
   set _transitioning(val) {
-    this.__transitioning = val;
     if (val) {
       this.node.setAttribute("transitioning", "true");
     } else {
       this.node.removeAttribute("transitioning");
     }
   }
 
   get _screenManager() {
@@ -391,17 +389,16 @@ var PanelMultiView = class extends this.
       this.document.createElement("box");
     offscreenViewStack.classList.add("panel-viewstack");
     offscreenViewContainer.append(offscreenViewStack);
 
     this.node.prepend(offscreenViewContainer);
     this.node.prepend(viewContainer);
 
     this.openViews = [];
-    this.__transitioning = false;
 
     this._panel.addEventListener("popupshowing", this);
     this._panel.addEventListener("popuppositioned", this);
     this._panel.addEventListener("popuphidden", this);
     this._panel.addEventListener("popupshown", this);
     let cs = this.window.getComputedStyle(this.document.documentElement);
     // Set CSS-determined attributes now to prevent a layout flush when we do
     // it when transitioning between panels.
@@ -605,23 +602,52 @@ var PanelMultiView = class extends this.
   async _showSubView(viewIdOrNode, anchor) {
     let viewNode = typeof viewIdOrNode == "string" ?
                    this.document.getElementById(viewIdOrNode) : viewIdOrNode;
     if (!viewNode) {
       Cu.reportError(new Error(`Subview ${viewIdOrNode} doesn't exist.`));
       return;
     }
 
+    if (!this.openViews.length) {
+      Cu.reportError(new Error(`Cannot show a subview in a closed panel.`));
+      return;
+    }
+
     let prevPanelView = this.openViews[this.openViews.length - 1];
     let nextPanelView = PanelView.forNode(viewNode);
     if (this.openViews.includes(nextPanelView)) {
       Cu.reportError(new Error(`Subview ${viewNode.id} is already open.`));
       return;
     }
+
+    // Do not re-enter the process if navigation is already in progress. Since
+    // there is only one active view at any given time, we can do this check
+    // safely, even considering that during the navigation process the actual
+    // view to which prevPanelView refers will change.
+    if (!prevPanelView.active) {
+      return;
+    }
+    // Marking the view that is about to scrolled out of the visible area as
+    // inactive will prevent re-entrancy and also disable keyboard navigation.
+    // From this point onwards, "await" statements can be used safely.
+    prevPanelView.active = false;
+
+    // If the ViewShowing event cancels the operation we have to re-enable
+    // keyboard navigation, but this must be avoided if the panel was closed.
     if (!(await this._openView(nextPanelView))) {
+      if (prevPanelView.isOpenIn(this)) {
+        // We don't raise a ViewShown event because nothing actually changed.
+        // Technically we should use a different state flag just because there
+        // is code that could check the "active" property to determine whether
+        // to wait for a ViewShown event later, but this only happens in
+        // regression tests and is less likely to be a technique used in
+        // production code, where use of ViewShown is less common.
+        prevPanelView.active = true;
+      }
       return;
     }
 
     prevPanelView.captureKnownSize();
 
     // The main view of a panel can be a subview in another one. Make sure to
     // reset all the properties that may be set on a subview.
     nextPanelView.mainview = false;
@@ -650,16 +676,24 @@ var PanelMultiView = class extends this.
       // This may be called by keyboard navigation or external code when only
       // the main view is open.
       return;
     }
 
     let prevPanelView = this.openViews[this.openViews.length - 1];
     let nextPanelView = this.openViews[this.openViews.length - 2];
 
+    // Like in the showSubView method, do not re-enter navigation while it is
+    // in progress, and make the view inactive immediately. From this point
+    // onwards, "await" statements can be used safely.
+    if (!prevPanelView.active) {
+      return;
+    }
+    prevPanelView.active = false;
+
     prevPanelView.captureKnownSize();
     await this._transitionViews(prevPanelView.node, nextPanelView.node, true);
 
     this._closeLatestView();
 
     this._activateView(nextPanelView);
   }
 
@@ -782,35 +816,30 @@ var PanelMultiView = class extends this.
 
   /**
    * Apply a transition to 'slide' from the currently active view to the next
    * one.
    * Sliding the next subview in means that the previous panelview stays where it
    * is and the active panelview slides in from the left in LTR mode, right in
    * RTL mode.
    *
-   * @param {panelview} previousViewNode Node that is currently shown as active,
-   *                                     but is about to be transitioned away.
+   * @param {panelview} previousViewNode Node that is currently displayed, but
+   *                                     is about to be transitioned away. This
+   *                                     must be already inactive at this point.
    * @param {panelview} viewNode         Node that will becode the active view,
    *                                     after the transition has finished.
    * @param {Boolean}   reverse          Whether we're navigation back to a
    *                                     previous view or forward to a next view.
    * @param {Element}   anchor           the anchor for which we're opening
    *                                     a new panelview, if any
    */
   async _transitionViews(previousViewNode, viewNode, reverse, anchor) {
     // Clean up any previous transition that may be active at this point.
     this._cleanupTransitionPhase();
 
-    // There's absolutely no need to show off our epic animation skillz when
-    // the panel's not even open.
-    if (this._panel.state != "open") {
-      return;
-    }
-
     const { window } = this;
 
     let nextPanelView = PanelView.forNode(viewNode);
     let prevPanelView = PanelView.forNode(previousViewNode);
 
     if (this._autoResizeWorkaroundTimer)
       window.clearTimeout(this._autoResizeWorkaroundTimer);
 
@@ -908,24 +937,16 @@ var PanelMultiView = class extends this.
     this._viewContainer.style.height = viewRect.height + "px";
     this._viewContainer.style.width = viewRect.width + "px";
     this._panel.removeAttribute("width");
     this._panel.removeAttribute("height");
     // We're setting the width property to prevent flickering during the
     // sliding animation with smaller views.
     viewNode.style.width = viewRect.width + "px";
 
-    // For proper bookkeeping, mark the view that is about to scrolled out of
-    // the visible area as inactive, because it won't be possible to simulate
-    // mouse events on it properly. In practice this isn't important, because we
-    // use the separate "transitioning" attribute on the panel to suppress
-    // pointer events. This allows mouse events to be available for the main
-    // view in regression tests that wait for the "popupshown" event.
-    prevPanelView.active = false;
-
     // Kick off the transition!
     details.phase = TRANSITION_PHASES.TRANSITION;
     this._viewStack.style.transform = "translateX(" + (moveToLeft ? "" : "-") + deltaX + "px)";
 
     await new Promise(resolve => {
       details.resolve = resolve;
       this._viewContainer.addEventListener("transitionend", details.listener = ev => {
         // It's quite common that `height` on the view container doesn't need
@@ -1053,23 +1074,21 @@ var PanelMultiView = class extends this.
 
   handleEvent(aEvent) {
     if (aEvent.type.startsWith("popup") && aEvent.target != this._panel) {
       // Shouldn't act on e.g. context menus being shown from within the panel.
       return;
     }
     switch (aEvent.type) {
       case "keydown":
-        if (!this._transitioning) {
-          // Since we start listening for the "keydown" event when the popup is
-          // already showing and stop listening when the panel is hidden, we
-          // always have at least one view open.
-          let currentView = this.openViews[this.openViews.length - 1];
-          currentView.keyNavigation(aEvent, this._dir);
-        }
+        // Since we start listening for the "keydown" event when the popup is
+        // already showing and stop listening when the panel is hidden, we
+        // always have at least one view open.
+        let currentView = this.openViews[this.openViews.length - 1];
+        currentView.keyNavigation(aEvent, this._dir);
         break;
       case "mousemove":
         this.openViews.forEach(panelView => panelView.clearNavigation());
         break;
       case "popupshowing": {
         this._viewContainer.setAttribute("panelopen", "true");
         if (!this.node.hasAttribute("disablekeynav")) {
           this.window.addEventListener("keydown", this);
@@ -1416,21 +1435,28 @@ var PanelView = class extends this.Assoc
    * simulate a click on the currently selected button.
    * The Right or Left key - depending on the text direction - can be used to
    * navigate to the previous view, functioning as a shortcut for the view's
    * back button.
    * Thus, in LTR mode:
    *  - The Right key functions the same as the Enter key, simulating a click
    *  - The Left key triggers a navigation back to the previous view.
    *
+   * Key navigation is only enabled while the view is active, meaning that this
+   * method will return early if it is invoked during a sliding transition.
+   *
    * @param {KeyEvent} event
    * @param {String} dir
    *        Direction for arrow navigation, either "ltr" or "rtl".
    */
   keyNavigation(event, dir) {
+    if (!this.active) {
+      return;
+    }
+
     let buttons = this.buttons;
     if (!buttons || !buttons.length) {
       buttons = this.buttons = this.getNavigableElements();
       // Set the 'tabindex' attribute on the buttons to make sure they're focussable.
       for (let button of buttons) {
         if (!button.classList.contains("subviewbutton-back") &&
             !button.hasAttribute("tabindex")) {
           button.setAttribute("tabindex", 0);
--- a/browser/components/customizableui/test/browser_PanelMultiView.js
+++ b/browser/components/customizableui/test/browser_PanelMultiView.js
@@ -56,22 +56,23 @@ function assertLabelVisible(viewIndex, e
 
 /**
  * Opens the specified view as the main view in the specified panel.
  */
 async function openPopup(panelIndex, viewIndex) {
   gPanelMultiViews[panelIndex].setAttribute("mainViewId",
                                             gPanelViews[viewIndex].id);
 
-  let promiseShown = BrowserTestUtils.waitForEvent(gPanels[panelIndex],
-                                                   "popupshown");
+  let promiseShown = BrowserTestUtils.waitForEvent(gPanelViews[viewIndex],
+                                                   "ViewShown");
   PanelMultiView.openPopup(gPanels[panelIndex], gPanelAnchors[panelIndex],
                            "bottomcenter topright");
   await promiseShown;
 
+  Assert.ok(PanelView.forNode(gPanelViews[viewIndex]).active);
   assertLabelVisible(viewIndex, true);
 }
 
 /**
  * Closes the specified panel.
  */
 async function hidePopup(panelIndex) {
   gPanelMultiViews[panelIndex].setAttribute("mainViewId",
@@ -87,28 +88,30 @@ async function hidePopup(panelIndex) {
  * Opens the specified subview in the specified panel.
  */
 async function showSubView(panelIndex, viewIndex) {
   let promiseShown = BrowserTestUtils.waitForEvent(gPanelViews[viewIndex],
                                                    "ViewShown");
   gPanelMultiViews[panelIndex].showSubView(gPanelViews[viewIndex]);
   await promiseShown;
 
+  Assert.ok(PanelView.forNode(gPanelViews[viewIndex]).active);
   assertLabelVisible(viewIndex, true);
 }
 
 /**
  * Navigates backwards to the specified view, which is displayed as a result.
  */
 async function goBack(panelIndex, viewIndex) {
   let promiseShown = BrowserTestUtils.waitForEvent(gPanelViews[viewIndex],
                                                    "ViewShown");
   gPanelMultiViews[panelIndex].goBack();
   await promiseShown;
 
+  Assert.ok(PanelView.forNode(gPanelViews[viewIndex]).active);
   assertLabelVisible(viewIndex, true);
 }
 
 /**
  * Records the specified events on an element into the specified array. An
  * optional callback can be used to respond to events and trigger nested events.
  */
 function recordEvents(element, eventTypes, recordArray,
@@ -274,16 +277,48 @@ add_task(async function test_simple_even
     "panelview-0: ViewShown",
     "panelview-0: ViewHiding",
     "panelmultiview-0: PanelMultiViewHidden",
     "panel-0: popuphidden",
   ]);
 });
 
 /**
+ * Tests that further navigation is suppressed until the new view is shown.
+ */
+add_task(async function test_navigation_suppression() {
+  await openPopup(0, 0);
+
+  // Test re-entering the "showSubView" method.
+  let promiseShown = BrowserTestUtils.waitForEvent(gPanelViews[1], "ViewShown");
+  gPanelMultiViews[0].showSubView(gPanelViews[1]);
+  Assert.ok(!PanelView.forNode(gPanelViews[0]).active,
+            "The previous view should become inactive synchronously.");
+
+  // The following call will have no effect.
+  gPanelMultiViews[0].showSubView(gPanelViews[2]);
+  await promiseShown;
+
+  // Test re-entering the "goBack" method.
+  promiseShown = BrowserTestUtils.waitForEvent(gPanelViews[0], "ViewShown");
+  gPanelMultiViews[0].goBack();
+  Assert.ok(!PanelView.forNode(gPanelViews[1]).active,
+            "The previous view should become inactive synchronously.");
+
+  // The following call will have no effect.
+  gPanelMultiViews[0].goBack();
+  await promiseShown;
+
+  // Main view 0 should be displayed.
+  assertLabelVisible(0, true);
+
+  await hidePopup(0);
+});
+
+/**
  * Tests reusing views that are already open in another panel. In this test, the
  * structure of the first panel will change dynamically:
  *
  * - Panel 0
  *   - View 0
  *     - View 1
  * - Panel 1
  *   - View 1