Bug 1434883 - Part 2 - Use an asynchronous API to open PanelMultiView panels. r=Gijs draft
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Tue, 06 Feb 2018 09:02:27 +0000
changeset 751402 7302ce6eed5364c80b7dfe9409e5c9d28e030e38
parent 751034 f95967444ffbd2bbdc99560104845af31b71e94b
child 751403 6ce5aba338a77fb70b7a4bf8bb38ed35bb0d992b
push id97966
push userpaolo.mozmail@amadzone.org
push dateTue, 06 Feb 2018 09:06:25 +0000
reviewersGijs
bugs1434883
milestone60.0a1
Bug 1434883 - Part 2 - Use an asynchronous API to open PanelMultiView panels. r=Gijs MozReview-Commit-ID: 3VzoxJ3Ociy
browser/base/content/browser-pageActions.js
browser/base/content/browser.js
browser/base/content/test/general/browser_addCertException.js
browser/base/content/test/performance/browser_appmenu_reflows.js
browser/base/content/test/siteIdentity/head.js
browser/components/customizableui/CustomizableUI.jsm
browser/components/customizableui/PanelMultiView.jsm
browser/components/customizableui/content/panelUI.js
browser/components/downloads/content/downloads.js
--- a/browser/base/content/browser-pageActions.js
+++ b/browser/base/content/browser-pageActions.js
@@ -182,40 +182,44 @@ var BrowserPageActions = {
    *         suitable anchor will be used.
    * @param  panelNode (DOM node, optional)
    *         The panel to use.  This method takes a hands-off approach with
    *         regard to your panel in terms of attributes, styling, etc.
    */
   togglePanelForAction(action, panelNode = null) {
     let aaPanelNode = this.activatedActionPanelNode;
     if (panelNode) {
+      // Note that this particular code path will not prevent the panel from
+      // opening later if PanelMultiView.showPopup was called but the panel has
+      // not been opened yet.
       if (panelNode.state != "closed") {
-        panelNode.hidePopup();
+        PanelMultiView.hidePopup(panelNode);
         return;
       }
       if (aaPanelNode) {
-        aaPanelNode.hidePopup();
+        PanelMultiView.hidePopup(aaPanelNode);
       }
     } else if (aaPanelNode) {
-      aaPanelNode.hidePopup();
+      PanelMultiView.hidePopup(aaPanelNode);
       return;
     } else {
       panelNode = this._makeActivatedActionPanelForAction(action);
     }
 
     // Hide the main panel before showing the action's panel.
-    this.panelNode.hidePopup();
+    PanelMultiView.hidePopup(this.panelNode);
 
     let anchorNode = this.panelAnchorNodeForAction(action);
     anchorNode.setAttribute("open", "true");
     panelNode.addEventListener("popuphiding", () => {
       anchorNode.removeAttribute("open");
     }, { once: true });
 
-    panelNode.openPopup(anchorNode, "bottomcenter topright");
+    PanelMultiView.openPopup(panelNode, anchorNode, "bottomcenter topright")
+                  .catch(Cu.reportError);
   },
 
   _makeActivatedActionPanelForAction(action) {
     let panelNode = document.createElement("panel");
     panelNode.id = this._activatedActionPanelID;
     panelNode.classList.add("cui-widget-panel");
     panelNode.setAttribute("actionID", action.id);
     panelNode.setAttribute("role", "group");
@@ -549,17 +553,17 @@ var BrowserPageActions = {
         buttonNode.closest("panel") == this.panelNode) {
       let panelViewNodeID = this._panelViewNodeIDForActionID(action.id, false);
       let panelViewNode = document.getElementById(panelViewNodeID);
       action.subview.onShowing(panelViewNode);
       this.multiViewNode.showSubView(panelViewNode, buttonNode);
       return;
     }
     // Otherwise, hide the main popup in case it was open:
-    this.panelNode.hidePopup();
+    PanelMultiView.hidePopup(this.panelNode);
 
     // Toggle the activated action's panel if necessary
     if (action.subview || action.wantsIframe) {
       this.togglePanelForAction(action);
       return;
     }
 
     // Otherwise, run the action.
@@ -689,22 +693,22 @@ var BrowserPageActions = {
          event.keyCode != KeyEvent.DOM_VK_RETURN)) {
       return;
     }
 
     // If the activated-action panel is open and anchored to the main button,
     // close it.
     let panelNode = this.activatedActionPanelNode;
     if (panelNode && panelNode.anchorNode.id == this.mainButtonNode.id) {
-      panelNode.hidePopup();
+      PanelMultiView.hidePopup(panelNode);
       return;
     }
 
     if (this.panelNode.state == "open") {
-      this.panelNode.hidePopup();
+      PanelMultiView.hidePopup(this.panelNode);
     } else if (this.panelNode.state == "closed") {
       this.showPanel(event);
     }
   },
 
   /**
    * Show the page action panel
    *
@@ -718,20 +722,20 @@ var BrowserPageActions = {
       action.onShowingInPanel(buttonNode);
     }
 
     this.panelNode.hidden = false;
     this.panelNode.addEventListener("popuphiding", () => {
       this.mainButtonNode.removeAttribute("open");
     }, {once: true});
     this.mainButtonNode.setAttribute("open", "true");
-    this.panelNode.openPopup(this.mainButtonNode, {
+    PanelMultiView.openPopup(this.panelNode, this.mainButtonNode, {
       position: "bottomcenter topright",
       triggerEvent: event,
-    });
+    }).catch(Cu.reportError);
   },
 
   /**
    * Call this on the context menu's popupshowing event.
    *
    * @param  event (DOM event, required)
    *         The popupshowing event.
    * @param  popup (DOM node, required)
@@ -876,20 +880,20 @@ var BrowserPageActionFeedback = {
     return this.feedbackLabel = document.getElementById("pageActionFeedbackMessage");
   },
 
   show(action, event, textContentOverride) {
     this.feedbackLabel.textContent = this.panelNode.getAttribute((textContentOverride || action.id) + "Feedback");
     this.panelNode.hidden = false;
 
     let anchor = BrowserPageActions.panelAnchorNodeForAction(action, event);
-    this.panelNode.openPopup(anchor, {
+    PanelMultiView.openPopup(this.panelNode, anchor, {
       position: "bottomcenter topright",
       triggerEvent: event,
-    });
+    }).catch(Cu.reportError);
 
     this.panelNode.addEventListener("popupshown", () => {
       this.feedbackAnimationBox.setAttribute("animate", "true");
 
       // The timeout value used here allows the panel to stay open for
       // 1 second after the text transition (duration=120ms) has finished.
       setTimeout(() => {
         this.panelNode.hidePopup(true);
@@ -907,47 +911,47 @@ var BrowserPageActionFeedback = {
 // bookmark
 BrowserPageActions.bookmark = {
   onShowingInPanel(buttonNode) {
     // Update the button label via the bookmark observer.
     BookmarkingUI.updateBookmarkPageMenuItem();
   },
 
   onCommand(event, buttonNode) {
-    BrowserPageActions.panelNode.hidePopup();
+    PanelMultiView.hidePopup(BrowserPageActions.panelNode);
     BookmarkingUI.onStarCommand(event);
   },
 };
 
 // copy URL
 BrowserPageActions.copyURL = {
   onPlacedInPanel(buttonNode) {
     let action = PageActions.actionForID("copyURL");
     BrowserPageActions.takeActionTitleFromPanel(action);
   },
 
   onCommand(event, buttonNode) {
-    BrowserPageActions.panelNode.hidePopup();
+    PanelMultiView.hidePopup(BrowserPageActions.panelNode);
     Cc["@mozilla.org/widget/clipboardhelper;1"]
       .getService(Ci.nsIClipboardHelper)
       .copyString(gURLBar.makeURIReadable(gBrowser.selectedBrowser.currentURI).displaySpec);
     let action = PageActions.actionForID("copyURL");
     BrowserPageActionFeedback.show(action, event);
   },
 };
 
 // email link
 BrowserPageActions.emailLink = {
   onPlacedInPanel(buttonNode) {
     let action = PageActions.actionForID("emailLink");
     BrowserPageActions.takeActionTitleFromPanel(action);
   },
 
   onCommand(event, buttonNode) {
-    BrowserPageActions.panelNode.hidePopup();
+    PanelMultiView.hidePopup(BrowserPageActions.panelNode);
     MailIntegration.sendLinkForBrowser(gBrowser.selectedBrowser);
   },
 };
 
 // send to device
 BrowserPageActions.sendToDevice = {
   onPlacedInPanel(buttonNode) {
     let action = PageActions.actionForID("sendToDevice");
@@ -987,17 +991,17 @@ BrowserPageActions.sendToDevice = {
       item.classList.add("pageAction-sendToDevice-device", "subviewbutton");
       if (clientId) {
         item.classList.add("subviewbutton-iconic");
         item.setAttribute("tooltiptext", gSync.formatLastSyncDate(lastModified));
       }
 
       item.addEventListener("command", event => {
         if (panelNode) {
-          panelNode.hidePopup();
+          PanelMultiView.hidePopup(panelNode);
         }
         // There are items in the subview that don't represent devices: "Sign
         // in", "Learn about Sync", etc.  Device items will be .sendtab-target.
         if (event.target.classList.contains("sendtab-target")) {
           let action = PageActions.actionForID("sendToDevice");
           let textOverride = gSync.offline && "sendToDeviceOffline";
           BrowserPageActionFeedback.show(action, event, textOverride);
         }
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -36,16 +36,17 @@ XPCOMUtils.defineLazyModuleGetters(this,
   FormValidationHandler: "resource:///modules/FormValidationHandler.jsm",
   LanguagePrompt: "resource://gre/modules/LanguagePrompt.jsm",
   LightweightThemeManager: "resource://gre/modules/LightweightThemeManager.jsm",
   Log: "resource://gre/modules/Log.jsm",
   LoginManagerParent: "resource://gre/modules/LoginManagerParent.jsm",
   NewTabUtils: "resource://gre/modules/NewTabUtils.jsm",
   PageActions: "resource:///modules/PageActions.jsm",
   PageThumbs: "resource://gre/modules/PageThumbs.jsm",
+  PanelMultiView: "resource:///modules/PanelMultiView.jsm",
   PanelView: "resource:///modules/PanelMultiView.jsm",
   PluralForm: "resource://gre/modules/PluralForm.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
   ProcessHangMonitor: "resource:///modules/ProcessHangMonitor.jsm",
   PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
   ReaderMode: "resource://gre/modules/ReaderMode.jsm",
   ReaderParent: "resource:///modules/ReaderParent.jsm",
   RecentWindow: "resource:///modules/RecentWindow.jsm",
@@ -7243,17 +7244,17 @@ var gIdentityHandler = {
 
   /**
    * Handler for mouseclicks on the "More Information" button in the
    * "identity-popup" panel.
    */
   handleMoreInfoClick(event) {
     displaySecurityInfo();
     event.stopPropagation();
-    this._identityPopup.hidePopup();
+    PanelMultiView.hidePopup(this._identityPopup);
   },
 
   showSecuritySubView() {
     this._identityPopupMultiView.showSubView("identity-popup-securityView",
                                              this._popupExpander);
 
     // Elements of hidden views have -moz-user-focus:ignore but setting that
     // per CSS selector doesn't blur a focused element in those hidden views.
@@ -7265,36 +7266,36 @@ var gIdentityHandler = {
     const kMIXED_CONTENT_UNBLOCK_EVENT = 2;
     let histogram =
       Services.telemetry.getHistogramById(
         "MIXED_CONTENT_UNBLOCK_COUNTER");
     histogram.add(kMIXED_CONTENT_UNBLOCK_EVENT);
     // Reload the page with the content unblocked
     BrowserReloadWithFlags(
       Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_MIXED_CONTENT);
-    this._identityPopup.hidePopup();
+    PanelMultiView.hidePopup(this._identityPopup);
   },
 
   enableMixedContentProtection() {
     gBrowser.selectedBrowser.messageManager.sendAsyncMessage(
       "MixedContent:ReenableProtection", {});
     BrowserReload();
-    this._identityPopup.hidePopup();
+    PanelMultiView.hidePopup(this._identityPopup);
   },
 
   removeCertException() {
     if (!this._uriHasHost) {
       Cu.reportError("Trying to revoke a cert exception on a URI without a host?");
       return;
     }
     let host = this._uri.host;
     let port = this._uri.port > 0 ? this._uri.port : 443;
     this._overrideService.clearValidityOverride(host, port);
     BrowserReloadSkipCache();
-    this._identityPopup.hidePopup();
+    PanelMultiView.hidePopup(this._identityPopup);
   },
 
   /**
    * Helper to parse out the important parts of _sslStatus (of the SSL cert in
    * particular) for use in constructing identity UI strings
   */
   getIdentityData() {
     var result = {};
@@ -7350,17 +7351,17 @@ var gIdentityHandler = {
       this._sslStatus.QueryInterface(Ci.nsISSLStatus);
     }
 
     // Then, update the user interface with the available data.
     this.refreshIdentityBlock();
     // Handle a location change while the Control Center is focused
     // by closing the popup (bug 1207542)
     if (shouldHidePopup) {
-      this._identityPopup.hidePopup();
+      PanelMultiView.hidePopup(this._identityPopup);
     }
 
     // NOTE: We do NOT update the identity popup (the control center) when
     // we receive a new security state on the existing page (i.e. from a
     // subframe). If the user opened the popup and looks at the provided
     // information we don't want to suddenly change the panel contents.
 
     // Finally, if there are warnings to issue, issue them
@@ -7814,17 +7815,18 @@ var gIdentityHandler = {
 
     // Update the popup strings
     this.refreshIdentityPopup();
 
     // Add the "open" attribute to the identity box for styling
     this._identityBox.setAttribute("open", "true");
 
     // Now open the popup, anchored off the primary chrome element
-    this._identityPopup.openPopup(this._identityIcon, "bottomcenter topleft");
+    PanelMultiView.openPopup(this._identityPopup, this._identityIcon,
+                             "bottomcenter topleft").catch(Cu.reportError);
   },
 
   onPopupShown(event) {
     if (event.target == this._identityPopup) {
       if (this._popupTriggeredByKeyboard) {
         // Move focus to the next available element in the identity popup.
         // This is required by role=alertdialog and fixes an issue where
         // an already open panel would steal focus from the identity popup.
@@ -7847,17 +7849,17 @@ var gIdentityHandler = {
     let position = elem.compareDocumentPosition(this._identityPopup);
 
     if (!(position & (Node.DOCUMENT_POSITION_CONTAINS |
                       Node.DOCUMENT_POSITION_CONTAINED_BY)) &&
         !this._identityPopup.hasAttribute("noautohide")) {
       // Hide the panel when focusing an element that is
       // neither an ancestor nor descendant unless the panel has
       // @noautohide (e.g. for a tour).
-      this._identityPopup.hidePopup();
+      PanelMultiView.hidePopup(this._identityPopup);
     }
   },
 
   observe(subject, topic, data) {
     if (topic == "perm-changed") {
       this.refreshIdentityBlock();
     }
   },
--- a/browser/base/content/test/general/browser_addCertException.js
+++ b/browser/base/content/test/general/browser_addCertException.js
@@ -9,17 +9,20 @@
 // using the button contained therein to load the certificate exception
 // dialog, using that to add an exception, and finally successfully visiting
 // the site, including showing the right identity box and control center icons.
 add_task(async function() {
   await BrowserTestUtils.openNewForegroundTab(gBrowser);
   await loadBadCertPage("https://expired.example.com");
 
   let { gIdentityHandler } = gBrowser.ownerGlobal;
+  let promisePanelOpen = BrowserTestUtils.waitForEvent(gIdentityHandler._identityPopup, "popupshown");
   gIdentityHandler._identityBox.click();
+  await promisePanelOpen;
+
   let promiseViewShown = BrowserTestUtils.waitForEvent(gIdentityHandler._identityPopup, "ViewShown");
   document.getElementById("identity-popup-security-expander").click();
   await promiseViewShown;
 
   is_element_visible(document.getElementById("connection-icon"), "Should see connection icon");
   let connectionIconImage = gBrowser.ownerGlobal
         .getComputedStyle(document.getElementById("connection-icon"))
         .getPropertyValue("list-style-image");
--- a/browser/base/content/test/performance/browser_appmenu_reflows.js
+++ b/browser/base/content/test/performance/browser_appmenu_reflows.js
@@ -9,17 +9,17 @@
  *
  * See https://developer.mozilla.org/en-US/Firefox/Performance_best_practices_for_Firefox_fe_engineers
  * for tips on how to do that.
  */
 const EXPECTED_APPMENU_OPEN_REFLOWS = [
   {
     stack: [
       "openPopup@chrome://global/content/bindings/popup.xml",
-      "show/</<@chrome://browser/content/customizableui/panelUI.js",
+      "openPopup/this._openPopupPromise<@resource:///modules/PanelMultiView.jsm",
     ],
   },
 
   {
     stack: [
       "get_alignmentPosition@chrome://global/content/bindings/popup.xml",
       "adjustArrowPosition@chrome://global/content/bindings/popup.xml",
       "onxblpopuppositioned@chrome://global/content/bindings/popup.xml",
--- a/browser/base/content/test/siteIdentity/head.js
+++ b/browser/base/content/test/siteIdentity/head.js
@@ -199,17 +199,19 @@ async function assertMixedContentBlockin
     }
     if (passiveLoaded && activeBlocked) {
       is(connectionIconImage, "url(\"chrome://browser/skin/connection-mixed-passive-loaded.svg\")",
         "Using active blocked and passive loaded icon");
     }
   }
 
   // Make sure the identity popup has the correct mixedcontent states
+  let promisePanelOpen = BrowserTestUtils.waitForEvent(gIdentityHandler._identityPopup, "popupshown");
   gIdentityHandler._identityBox.click();
+  await promisePanelOpen;
   let popupAttr = doc.getElementById("identity-popup").getAttribute("mixedcontent");
   let bodyAttr = doc.getElementById("identity-popup-securityView-body").getAttribute("mixedcontent");
 
   is(popupAttr.includes("active-loaded"), activeLoaded,
       "identity-popup has expected attr for activeLoaded");
   is(bodyAttr.includes("active-loaded"), activeLoaded,
       "securityView-body has expected attr for activeLoaded");
 
--- a/browser/components/customizableui/CustomizableUI.jsm
+++ b/browser/components/customizableui/CustomizableUI.jsm
@@ -11,16 +11,17 @@ const {classes: Cc, interfaces: Ci, util
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   SearchWidgetTracker: "resource:///modules/SearchWidgetTracker.jsm",
   CustomizableWidgets: "resource:///modules/CustomizableWidgets.jsm",
   DeferredTask: "resource://gre/modules/DeferredTask.jsm",
+  PanelMultiView: "resource:///modules/PanelMultiView.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
   ShortcutUtils: "resource://gre/modules/ShortcutUtils.jsm",
   LightweightThemeManager: "resource://gre/modules/LightweightThemeManager.jsm",
 });
 
 XPCOMUtils.defineLazyGetter(this, "gWidgetsBundle", function() {
   const kUrl = "chrome://browser/locale/customizableui/customizableWidgets.properties";
   return Services.strings.createBundle(kUrl);
@@ -1773,17 +1774,17 @@ var CustomizableUIInternal = {
       return true;
     }
     return inInput || !inItem;
   },
 
   hidePanelForNode(aNode) {
     let panel = this._getPanelForNode(aNode);
     if (panel) {
-      panel.hidePopup();
+      PanelMultiView.hidePopup(panel);
     }
   },
 
   maybeAutoHidePanel(aEvent) {
     if (aEvent.type == "keypress") {
       if (aEvent.keyCode != aEvent.DOM_VK_RETURN) {
         return;
       }
@@ -4259,29 +4260,29 @@ OverflowableToolbar.prototype = {
         break;
       case "mousedown":
         if (aEvent.button != 0) {
           break;
         }
         if (aEvent.target == this._chevron) {
           this._onClickChevron(aEvent);
         } else {
-          this._panel.hidePopup();
+          PanelMultiView.hidePopup(this._panel);
         }
         break;
       case "customizationstarting":
         this._disable();
         break;
       case "dragover":
         if (this._enabled) {
           this._showWithTimeout();
         }
         break;
       case "dragend":
-        this._panel.hidePopup();
+        PanelMultiView.hidePopup(this._panel);
         break;
       case "popuphiding":
         this._onPanelHiding(aEvent);
         break;
       case "resize":
         this._onResize(aEvent);
     }
   },
@@ -4297,31 +4298,33 @@ OverflowableToolbar.prototype = {
       let mainViewId = multiview.getAttribute("mainViewId");
       let mainView = doc.getElementById(mainViewId);
       let contextMenu = doc.getElementById(mainView.getAttribute("context"));
       gELS.addSystemEventListener(contextMenu, "command", this, true);
       let anchor = doc.getAnonymousElementByAttribute(this._chevron, "class", "toolbarbutton-icon");
       // Ensure we update the gEditUIVisible flag when opening the popup, in
       // case the edit controls are in it.
       this._panel.addEventListener("popupshowing", () => doc.defaultView.updateEditUIVisibility(), {once: true});
-      this._panel.openPopup(anchor || this._chevron, { triggerEvent: aEvent });
+      PanelMultiView.openPopup(this._panel, anchor || this._chevron, {
+        triggerEvent: aEvent,
+      }).catch(Cu.reportError);
       this._chevron.open = true;
 
       this._panel.addEventListener("popupshown", () => {
         this._panel.addEventListener("dragover", this);
         this._panel.addEventListener("dragend", this);
         resolve();
       }, {once: true});
     });
   },
 
   _onClickChevron(aEvent) {
     if (this._chevron.open) {
-      this._panel.hidePopup();
       this._chevron.open = false;
+      PanelMultiView.hidePopup(this._panel);
     } else if (this._panel.state != "hiding" && !this._chevron.disabled) {
       this.show(aEvent);
     }
   },
 
   _onPanelHiding(aEvent) {
     if (aEvent.target != this._panel) {
       // Ignore context menus, <select> popups, etc.
@@ -4603,16 +4606,16 @@ OverflowableToolbar.prototype = {
   _showWithTimeout() {
     this.show().then(() => {
       let window = this._toolbar.ownerGlobal;
       if (this._hideTimeoutId) {
         window.clearTimeout(this._hideTimeoutId);
       }
       this._hideTimeoutId = window.setTimeout(() => {
         if (!this._panel.firstChild.matches(":hover")) {
-          this._panel.hidePopup();
+          PanelMultiView.hidePopup(this._panel);
         }
       }, OVERFLOW_PANEL_HIDE_DELAY_MS);
     });
   },
 };
 
 CustomizableUIInternal.initialize();
--- a/browser/components/customizableui/PanelMultiView.jsm
+++ b/browser/components/customizableui/PanelMultiView.jsm
@@ -7,19 +7,23 @@
  * panel is opened may slide out to display a subview, which in turn may lead to
  * other subviews in a cascade menu pattern.
  *
  * The <panel> element should contain a <panelmultiview> element. Views are
  * declared using <panelview> elements that are usually children of the main
  * <panelmultiview> element, although they don't need to be, as views can also
  * be imported into the panel from other panels or popup sets.
  *
- * The main view can be declared using the mainViewId attribute, and specific
- * subviews can slide in using the showSubView method. Backwards navigation can
- * be done using the goBack method or through a button in the subview headers.
+ * The panel should be opened asynchronously using the openPopup static method
+ * on the PanelMultiView object. This will display the view specified using the
+ * mainViewId attribute on the contained <panelmultiview> element.
+ *
+ * Specific subviews can slide in using the showSubView method, and backwards
+ * navigation can be done using the goBack method or through a button in the
+ * subview headers.
  *
  * This diagram shows how <panelview> nodes move during navigation:
  *
  *   In this <panelmultiview>     In other panels    Action
  *             ┌───┬───┬───┐        ┌───┬───┐
  *             │(A)│ B │ C │        │ D │ E │          Open panel
  *             └───┴───┴───┘        └───┴───┘
  *         ┌───┬───┬───┐            ┌───┬───┐
@@ -44,16 +48,17 @@
 this.EXPORTED_SYMBOLS = [
   "PanelMultiView",
   "PanelView",
 ];
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.defineModuleGetter(this, "AppConstants",
   "resource://gre/modules/AppConstants.jsm");
 ChromeUtils.defineModuleGetter(this, "BrowserUtils",
   "resource://gre/modules/BrowserUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "CustomizableUI",
   "resource:///modules/CustomizableUI.jsm");
 
 const TRANSITION_PHASES = Object.freeze({
@@ -131,16 +136,51 @@ this.AssociatedToNode = class {
     return event.defaultPrevented;
   }
 };
 
 /**
  * This is associated to <panelmultiview> elements by the panelUI.xml binding.
  */
 this.PanelMultiView = class extends this.AssociatedToNode {
+  /**
+   * Tries to open the specified <panel> and displays the main view specified
+   * with the "mainViewId" attribute on the <panelmultiview> node it contains.
+   *
+   * If the panel does not contain a <panelmultiview>, it is opened directly.
+   * This allows consumers like page actions to accept different panel types.
+   *
+   * @see The non-static openPopup method for details.
+   */
+  static async openPopup(panelNode, ...args) {
+    let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+    if (panelMultiViewNode) {
+      return this.forNode(panelMultiViewNode).openPopup(...args);
+    }
+    panelNode.openPopup(...args);
+    return true;
+  }
+
+  /**
+   * Closes the specified <panel> which contains a <panelmultiview> node.
+   *
+   * If the panel does not contain a <panelmultiview>, it is closed directly.
+   * This allows consumers like page actions to accept different panel types.
+   *
+   * @see The non-static hidePopup method for details.
+   */
+  static hidePopup(panelNode) {
+    let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+    if (panelMultiViewNode) {
+      this.forNode(panelMultiViewNode).hidePopup();
+    } else {
+      panelNode.hidePopup();
+    }
+  }
+
   get _panel() {
     return this.node.parentNode;
   }
 
   get _mainViewId() {
     return this.node.getAttribute("mainViewId");
   }
   get _mainView() {
@@ -191,17 +231,24 @@ this.PanelMultiView = class extends this
   /**
    * @return {Promise} showSubView() returns a promise, which is kept here for
    *                   random access.
    */
   get currentShowPromise() {
     return this._currentShowPromise || Promise.resolve();
   }
 
+  constructor(node) {
+    super(node);
+    this._openPopupPromise = Promise.resolve(false);
+    this._openPopupCancelCallback = () => {};
+  }
+
   connect() {
+    this.connected = true;
     this.knownViews = new Set(Array.from(
       this.node.getElementsByTagName("panelview"),
       node => PanelView.forNode(node)));
     this.openViews = [];
     this.__transitioning = false;
     this.showingSubView = false;
 
     const {document, window} = this;
@@ -261,21 +308,162 @@ this.PanelMultiView = class extends this
 
     this._moveOutKids(this._viewStack);
     this._panel.removeEventListener("mousemove", this);
     this._panel.removeEventListener("popupshowing", this);
     this._panel.removeEventListener("popuppositioned", this);
     this._panel.removeEventListener("popupshown", this);
     this._panel.removeEventListener("popuphidden", this);
     this.window.removeEventListener("keydown", this);
-    this.node = this._viewContainer = this._viewStack = this.__dwu =
+    this.node = this._openPopupPromise = this._openPopupCancelCallback =
+      this._viewContainer = this._viewStack = this.__dwu =
       this._panelViewCache = this._transitionDetails = null;
   }
 
   /**
+   * Tries to open the panel associated with this PanelMultiView, and displays
+   * the main view specified with the "mainViewId" attribute.
+   *
+   * The hidePopup method can be called while the operation is in progress to
+   * prevent the panel from being displayed. View events may also cancel the
+   * operation, so there is no guarantee that the panel will become visible.
+   *
+   * The "popuphidden" event will be fired either when the operation is canceled
+   * or when the popup is closed later. This event can be used for example to
+   * reset the "open" state of the anchor or tear down temporary panels.
+   *
+   * If this method is called again before the panel is shown, the result
+   * depends on the operation currently in progress. If the operation was not
+   * canceled, the panel is opened using the arguments from the previous call,
+   * and this call is ignored. If the operation was canceled, it will be
+   * retried again using the arguments from this call.
+   *
+   * It's not necessary for the <panelmultiview> binding to be connected when
+   * this method is called, but the containing panel must have its display
+   * turned on, for example it shouldn't have the "hidden" attribute.
+   *
+   * @param args
+   *        Arguments to be forwarded to the openPopup method of the panel.
+   *
+   * @resolves With true as soon as the request to display the panel has been
+   *           sent, or with false if the operation was canceled. The state of
+   *           the panel at this point is not guaranteed. It may be still
+   *           showing, completely shown, or completely hidden.
+   * @rejects If an exception is thrown at any point in the process before the
+   *          request to display the panel is sent.
+   */
+  async openPopup(...args) {
+    // Set up the function that allows hidePopup or a second call to showPopup
+    // to cancel the specific panel opening operation that we're starting below.
+    // This function must be synchronous, meaning we can't use Promise.race,
+    // because hidePopup wants to dispatch the "popuphidden" event synchronously
+    // even if the panel has not been opened yet.
+    let canCancel = true;
+    let cancelCallback = this._openPopupCancelCallback = () => {
+      // If the cancel callback is called and the panel hasn't been prepared
+      // yet, cancel showing it. Setting canCancel to false will prevent the
+      // popup from opening. If the panel has opened by the time the cancel
+      // callback is called, canCancel will be false already, and we will not
+      // fire the "popuphidden" event.
+      if (canCancel && this.node) {
+        canCancel = false;
+        this.dispatchCustomEvent("popuphidden");
+      }
+    };
+
+    // Create a promise that is resolved with the result of the last call to
+    // this method, where errors indicate that the panel was not opened.
+    let openPopupPromise = this._openPopupPromise.catch(() => {
+      return false;
+    });
+
+    // Make the preparation done before showing the panel non-reentrant. The
+    // promise created here will be resolved only after the panel preparation is
+    // completed, even if a cancellation request is received in the meantime.
+    return this._openPopupPromise = openPopupPromise.then(async wasShown => {
+      // The panel may have been destroyed in the meantime.
+      if (!this.node) {
+        return false;
+      }
+      // If the panel has been already opened there is nothing more to do. We
+      // check the actual state of the panel rather than setting some state in
+      // our handler of the "popuphidden" event because this has a lower chance
+      // of locking indefinitely if events aren't raised in the expected order.
+      if (wasShown && ["open", "showing"].includes(this._panel.state)) {
+        return true;
+      }
+      try {
+        // Most of the panel elements in the browser window have their display
+        // turned off for performance reasons, typically by setting the "hidden"
+        // attribute. If the caller has just turned on the display, the XBL
+        // binding for the <panelmultiview> element may still be disconnected.
+        // In this case, give the layout code a chance to run.
+        if (!this.connected) {
+          await BrowserUtils.promiseLayoutFlushed(this.document, "layout",
+                                                  () => {});
+          // The XBL binding must be connected at this point. If this is not the
+          // case, the calling code should be updated to unhide the panel.
+          if (!this.connected) {
+            throw new Error("The binding for the panelmultiview element isn't" +
+                            " connected. The containing panel may still have" +
+                            " its display turned off by the hidden attribute.");
+          }
+        }
+        // (The rest of the asynchronous preparation goes here.)
+      } catch (ex) {
+        cancelCallback();
+        throw ex;
+      }
+      // If a cancellation request was received there is nothing more to do.
+      if (!canCancel || !this.node) {
+        return false;
+      }
+      // We have to set canCancel to false before opening the popup because the
+      // hidePopup method of PanelMultiView can be re-entered by event handlers.
+      // If the openPopup call fails, however, we still have to dispatch the
+      // "popuphidden" event even if canCancel was set to false.
+      try {
+        canCancel = false;
+        this._panel.openPopup(...args);
+        return true;
+      } catch (ex) {
+        this.dispatchCustomEvent("popuphidden");
+        throw ex;
+      }
+    });
+  }
+
+  /**
+   * Closes the panel associated with this PanelMultiView.
+   *
+   * If the openPopup method was called but the panel has not been displayed
+   * yet, the operation is canceled and the panel will not be displayed, but the
+   * "popuphidden" event is fired synchronously anyways.
+   *
+   * This means that by the time this method returns all the operations handled
+   * by the "popuphidden" event are completed, for example resetting the "open"
+   * state of the anchor, and the panel is already invisible.
+   */
+  hidePopup() {
+    if (!this.node) {
+      return;
+    }
+
+    // If we have already reached the _panel.openPopup call in the openPopup
+    // method, we can call hidePopup. Otherwise, we have to cancel the latest
+    // request to open the panel, which will have no effect if the request has
+    // been canceled already.
+    if (["open", "showing"].includes(this._panel.state)) {
+      this._panel.hidePopup();
+    } else {
+      this._openPopupCancelCallback();
+    }
+  }
+
+  /**
    * Remove any child subviews into the panelViewCache, to ensure
    * they remain usable even if this panelmultiview instance is removed
    * from the DOM.
    * @param viewNodeContainer the container from which to remove subviews
    */
   _moveOutKids(viewNodeContainer) {
     if (!this._panelViewCache)
       return;
--- a/browser/components/customizableui/content/panelUI.js
+++ b/browser/components/customizableui/content/panelUI.js
@@ -1,16 +1,18 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 ChromeUtils.defineModuleGetter(this, "AppMenuNotifications",
                                "resource://gre/modules/AppMenuNotifications.jsm");
 ChromeUtils.defineModuleGetter(this, "NewTabUtils",
                                "resource://gre/modules/NewTabUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "PanelMultiView",
+                               "resource:///modules/PanelMultiView.jsm");
 ChromeUtils.defineModuleGetter(this, "ScrollbarSampler",
                                "resource:///modules/ScrollbarSampler.jsm");
 
 /**
  * Maintains the state and dispatches events for the main menu panel.
  */
 
 const PanelUI = {
@@ -210,32 +212,34 @@ const PanelUI = {
           anchor = aEvent.target;
         }
 
         this.panel.addEventListener("popupshown", function() {
           resolve();
         }, {once: true});
 
         anchor = this._getPanelAnchor(anchor);
-        this.panel.openPopup(anchor, { triggerEvent: domEvent });
+        PanelMultiView.openPopup(this.panel, anchor, {
+          triggerEvent: domEvent,
+        }).catch(Cu.reportError);
       }, (reason) => {
         console.error("Error showing the PanelUI menu", reason);
       });
     });
   },
 
   /**
    * If the menu panel is being shown, hide it.
    */
   hide() {
     if (document.documentElement.hasAttribute("customizing")) {
       return;
     }
 
-    this.panel.hidePopup();
+    PanelMultiView.hidePopup(this.panel);
   },
 
   observe(subject, topic, status) {
     switch (topic) {
       case "fullscreen-nav-toolbox":
         if (this._notifications) {
           this._updateNotifications(false);
         }
@@ -462,20 +466,20 @@ const PanelUI = {
       }
 
       let anchor = this._getPanelAnchor(aAnchor);
 
       if (aAnchor != anchor && aAnchor.id) {
         anchor.setAttribute("consumeanchor", aAnchor.id);
       }
 
-      tempPanel.openPopup(anchor, {
+      PanelMultiView.openPopup(tempPanel, anchor, {
         position: "bottomcenter topright",
         triggerEvent: domEvent,
-      });
+      }).catch(Cu.reportError);
     }
   },
 
   /**
    * Sets up the event listener for when the Library view is shown.
    *
    * @param {panelview} viewNode The library view.
    */
@@ -615,19 +619,17 @@ const PanelUI = {
   },
 
   updateOverflowStatus() {
     let hasKids = this.overflowFixedList.hasChildNodes();
     if (hasKids && !this.navbar.hasAttribute("nonemptyoverflow")) {
       this.navbar.setAttribute("nonemptyoverflow", "true");
       this.overflowPanel.setAttribute("hasfixeditems", "true");
     } else if (!hasKids && this.navbar.hasAttribute("nonemptyoverflow")) {
-      if (this.overflowPanel.state != "closed") {
-        this.overflowPanel.hidePopup();
-      }
+      PanelMultiView.hidePopup(this.overflowPanel);
       this.overflowPanel.removeAttribute("hasfixeditems");
       this.navbar.removeAttribute("nonemptyoverflow");
     }
   },
 
   onWidgetAfterDOMChange(aNode, aNextNode, aContainer, aWasRemoval) {
     if (aContainer == this.overflowFixedList) {
       this.updateOverflowStatus();
--- a/browser/components/downloads/content/downloads.js
+++ b/browser/components/downloads/content/downloads.js
@@ -245,17 +245,17 @@ var DownloadsPanel = {
   hidePanel() {
     DownloadsCommon.log("Closing the downloads panel.");
 
     if (!this.isPanelShowing) {
       DownloadsCommon.log("Downloads panel is not showing - nothing to do.");
       return;
     }
 
-    this.panel.hidePopup();
+    PanelMultiView.hidePopup(this.panel);
 
     // Ensure that we allow the panel to be reopened.  Note that, if the popup
     // was open, then the onPopupHidden event handler has already updated the
     // current state, otherwise we must update the state ourselves.
     this._state = this.kStateHidden;
     DownloadsCommon.log("Downloads panel is now closed.");
   },
 
@@ -565,17 +565,18 @@ var DownloadsPanel = {
       // still exist, and update the allowed items interactions accordingly.  We
       // do these checks on a background thread, and don't prevent the panel to
       // be displayed while these checks are being performed.
       for (let viewItem of DownloadsView._visibleViewItems.values()) {
         viewItem.download.refresh().catch(Cu.reportError);
       }
 
       DownloadsCommon.log("Opening downloads panel popup.");
-      this.panel.openPopup(anchor, "bottomcenter topright", 0, 0, false, null);
+      PanelMultiView.openPopup(this.panel, anchor, "bottomcenter topright",
+                               0, 0, false, null).catch(Cu.reportError);
     });
   },
 };
 
 XPCOMUtils.defineConstant(this, "DownloadsPanel", DownloadsPanel);
 
 // DownloadsOverlayLoader