Bug 1266134 - Pull host management out of toolbox.xul. r=jryans draft
authorAlexandre Poirot <poirot.alex@gmail.com>
Thu, 25 Aug 2016 05:51:11 -0700
changeset 410117 5a4d0c837db7f8308e3fa247f876272a5e6b96ad
parent 410066 8c9c4e816e86f903c1d820f3f29715dc070a5a4a
child 410118 dd30fe30321e1651a44e843a5c512de2f7b8e301
child 410263 7da9c8df7b21eb85da6c8f3650fb3b007fc01e97
push id28652
push userbmo:poirot.alex@gmail.com
push dateTue, 06 Sep 2016 09:20:22 +0000
reviewersjryans
bugs1266134
milestone51.0a1
Bug 1266134 - Pull host management out of toolbox.xul. r=jryans MozReview-Commit-ID: 71GNAFuVFgl
devtools/client/framework/devtools.js
devtools/client/framework/moz.build
devtools/client/framework/toolbox-wrapper.js
devtools/client/framework/toolbox.js
--- a/devtools/client/framework/devtools.js
+++ b/devtools/client/framework/devtools.js
@@ -5,16 +5,17 @@
 "use strict";
 
 const Services = require("Services");
 const promise = require("promise");
 const defer = require("devtools/shared/defer");
 
 // Load gDevToolsBrowser toolbox lazily as they need gDevTools to be fully initialized
 loader.lazyRequireGetter(this, "Toolbox", "devtools/client/framework/toolbox", true);
+loader.lazyRequireGetter(this, "ToolboxWrapper", "devtools/client/framework/toolbox-wrapper", true);
 loader.lazyRequireGetter(this, "gDevToolsBrowser", "devtools/client/framework/devtools-browser", true);
 
 const {defaultTools: DefaultTools, defaultThemes: DefaultThemes} =
   require("devtools/client/definitions");
 const EventEmitter = require("devtools/shared/event-emitter");
 const {JsonView} = require("devtools/client/jsonview/main");
 const AboutDevTools = require("devtools/client/framework/about-devtools-toolbox");
 const {when: unload} = require("sdk/system/unload");
@@ -414,38 +415,38 @@ DevTools.prototype = {
       }
 
       return hostPromise.then(function () {
         toolbox.raise();
         return toolbox;
       });
     }
     else {
-      // No toolbox for target, create one
-      toolbox = new Toolbox(target, toolId, hostType, hostOptions);
-
-      this.emit("toolbox-created", toolbox);
+      let wrapper = new ToolboxWrapper(target, hostType, hostOptions);
 
-      this._toolboxes.set(target, toolbox);
+      wrapper.create(toolId)
+        .then(toolbox => {
+          this._toolboxes.set(target, toolbox);
 
-      toolbox.once("destroy", () => {
-        this.emit("toolbox-destroy", target);
-      });
+          this.emit("toolbox-created", toolbox);
 
-      toolbox.once("destroyed", () => {
-        this._toolboxes.delete(target);
-        this.emit("toolbox-destroyed", target);
-      });
+          toolbox.once("destroy", () => {
+            this.emit("toolbox-destroy", target);
+          });
 
-      // If toolId was passed in, it will already be selected before the
-      // open promise resolves.
-      toolbox.open().then(() => {
-        deferred.resolve(toolbox);
-        this.emit("toolbox-ready", toolbox);
-      });
+          toolbox.once("destroyed", () => {
+            this._toolboxes.delete(target);
+            this.emit("toolbox-destroyed", target);
+          });
+
+          toolbox.open().then(() => {
+            deferred.resolve(toolbox);
+            this.emit("toolbox-ready", toolbox);
+          });
+        });
     }
 
     return deferred.promise;
   },
 
   /**
    * Return the toolbox for a given target.
    *
--- a/devtools/client/framework/moz.build
+++ b/devtools/client/framework/moz.build
@@ -22,11 +22,12 @@ DevToolsModules(
     'selection.js',
     'sidebar.js',
     'source-map-service.js',
     'target-from-url.js',
     'target.js',
     'toolbox-highlighter-utils.js',
     'toolbox-hosts.js',
     'toolbox-options.js',
+    'toolbox-wrapper.js',
     'toolbox.js',
     'ToolboxProcess.jsm',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/toolbox-wrapper.js
@@ -0,0 +1,194 @@
+const Services = require("Services");
+const { Ci } = require("chrome");
+const {LocalizationHelper} = require("devtools/shared/l10n");
+const L10N = new LocalizationHelper("devtools/locale/toolbox.properties");
+
+loader.lazyRequireGetter(this, "Toolbox", "devtools/client/framework/toolbox", true);
+loader.lazyRequireGetter(this, "Hosts", "devtools/client/framework/toolbox-hosts", true);
+
+/**
+ * Implement a wrapper on the chrome side to setup a Toolbox within Firefox UI.
+ *
+ * This components handles iframe creation within Firefox, in which we are loading
+ * the toolbox document. Then both the chrome and the toolbox document communicate
+ * via "message" events.
+ *
+ * Messages sent by the toolbox to the chrome:
+ * - switch-host: order to display the toolbox in another host (side, bottom or window)
+ *
+ * Messages sent by the chrome to the toolbox:
+ * - host-will-change: tells the toolbox document that the host is about to change
+ */
+
+const LAST_HOST = "devtools.toolbox.host";
+let ID_COUNTER = 1;
+
+function ToolboxWrapper(target, hostType, hostOptions) {
+  this.target = target;
+
+  this.frameId = ID_COUNTER++;
+
+  if (!hostType) {
+    hostType = Services.prefs.getCharPref(LAST_HOST);
+  }
+  this.onHostMinimized = this.onHostMinimized.bind(this);
+  this.onHostMaximized = this.onHostMaximized.bind(this);
+  this.host = this.createHost(hostType, hostOptions);
+}
+
+ToolboxWrapper.prototype = {
+  create(toolId) {
+    return this.host.create()
+      .then(() => {
+        this.host.frame.setAttribute("aria-label", L10N.getStr("toolbox.label"));
+        this.host.frame.ownerDocument.defaultView.addEventListener("message", this);
+        this.host.frame.addEventListener("unload", this);
+
+        let toolbox = new Toolbox(this.target, toolId, this.host.type, this.host.frame.contentWindow, this.frameId);
+
+        // Prevent reloading the toolbox when loading the tools in a tab (e.g. from about:debugging)
+        if (!this.host.frame.contentWindow.location.href.startsWith("about:devtools-toolbox")) {
+          this.host.frame.setAttribute("src", "about:devtools-toolbox");
+        }
+
+        return toolbox;
+      });
+  },
+
+  handleEvent(event) {
+    switch(event.type) {
+      case "message":
+        this.onMessage(event);
+        break;
+      case "unload":
+        if (event.target.location.href == "about:blank") {
+          break;
+        }
+        this.destroy();
+        break;
+    }
+  },
+
+  onMessage(event) {
+    if (!event.data) return;
+    // Toolbox document is still chrome and disallow identifying message
+    // origin via event.source as it is null. So use a custom id.
+    if (event.data.frameId != this.frameId) {
+      return;
+    }
+    switch (event.data.name) {
+      case "switch-host":
+        this.switchHost(event.data.hostType);
+        break;
+      case "maximize-host":
+        this.host.maximize();
+        break;
+      case "raise-host":
+        this.host.raise();
+        break;
+      case "toggle-minimize-mode":
+        this.host.toggleMinimizeMode(event.data.toolbarHeight);
+        break;
+      case "set-host-title":
+        this.host.setTitle(event.data.title);
+        break;
+      case "destroy-host":
+        this.destroy();
+        break;
+    }
+  },
+
+  postMessage(data) {
+    let window = this.host.frame.contentWindow;
+    window.postMessage(data, "*");
+  },
+
+  destroy() {
+    this.destroyHost();
+    this.host = null;
+    this.target = null;
+  },
+
+  /**
+   * Create a host object based on the given host type.
+   *
+   * Warning: some hosts require that the toolbox target provides a reference to
+   * the attached tab. Not all Targets have a tab property - make sure you
+   * correctly mix and match hosts and targets.
+   *
+   * @param {string} hostType
+   *        The host type of the new host object
+   *
+   * @return {Host} host
+   *        The created host object
+   */
+  createHost(hostType, options) {
+    if (!Hosts[hostType]) {
+      throw new Error("Unknown hostType: " + hostType);
+    }
+
+    let newHost = new Hosts[hostType](this.target.tab, options);
+    // Update the label and icon when the state changes.
+    newHost.on("minimized", this.onHostMinimized);
+    newHost.on("maximized", this.onHostMaximized);
+    return newHost;
+  },
+
+  onHostMinimized() {
+    this.postMessage({
+      name: "host-minimized"
+    });
+  },
+  onHostMaximized() {
+    this.postMessage({
+      name: "host-maximized"
+    });
+  },
+
+  switchHost(hostType) {
+    let iframe = this.host.frame;
+    let newHost = this.createHost(hostType);
+    return newHost.create().then(newIframe => {
+      // change toolbox document's parent to the new host
+      newIframe.QueryInterface(Ci.nsIFrameLoaderOwner);
+      newIframe.swapFrameLoaders(iframe);
+
+      // See bug 1022726, most probably because of swapFrameLoaders we need to
+      // first focus the window here, and then once again further from
+      // toolbox.js to make sure focus actually happens.
+      iframe.contentWindow.focus();
+
+      this.destroyHost();
+
+      this.host = newHost;
+      this.host.setTitle(this.host.frame.contentWindow.document.title);
+      this.host.frame.ownerDocument.defaultView.addEventListener("message", this);
+      this.host.frame.addEventListener("unload", this);
+
+      if (hostType != Toolbox.HostType.CUSTOM) {
+        Services.prefs.setCharPref(LAST_HOST, hostType);
+      }
+
+      // Tell the toolbox the host changed
+      this.postMessage({
+        name: "switched-host",
+        hostType
+      });
+    });
+  },
+
+  /**
+   * Destroy the current host, and remove event listeners from its frame.
+   *
+   * @return {promise} to be resolved when the host is destroyed.
+   */
+  destroyHost() {
+    this.host.frame.ownerDocument.defaultView.removeEventListener("message", this);
+    this.host.frame.removeEventListener("unload", this);
+
+    this.host.off("minimized", this.onHostMinimized);
+    this.host.off("maximized", this.onHostMaximized);
+    return this.host.destroy();
+  }
+};
+exports.ToolboxWrapper = ToolboxWrapper;
--- a/devtools/client/framework/toolbox.js
+++ b/devtools/client/framework/toolbox.js
@@ -35,18 +35,16 @@ const { BrowserLoader } =
 
 const {LocalizationHelper} = require("devtools/shared/l10n");
 const L10N = new LocalizationHelper("devtools/locale/toolbox.properties");
 
 loader.lazyRequireGetter(this, "CommandUtils",
   "devtools/client/shared/developer-toolbar", true);
 loader.lazyRequireGetter(this, "getHighlighterUtils",
   "devtools/client/framework/toolbox-highlighter-utils", true);
-loader.lazyRequireGetter(this, "Hosts",
-  "devtools/client/framework/toolbox-hosts", true);
 loader.lazyRequireGetter(this, "Selection",
   "devtools/client/framework/selection", true);
 loader.lazyRequireGetter(this, "InspectorFront",
   "devtools/shared/fronts/inspector", true);
 loader.lazyRequireGetter(this, "flags",
   "devtools/shared/flags");
 loader.lazyRequireGetter(this, "showDoorhanger",
   "devtools/client/shared/doorhanger", true);
@@ -98,22 +96,22 @@ const ToolboxButtons = exports.ToolboxBu
  * the iframes where the tool panels will be living in.
  *
  * @param {object} target
  *        The object the toolbox is debugging.
  * @param {string} selectedTool
  *        Tool to select initially
  * @param {Toolbox.HostType} hostType
  *        Type of host that will host the toolbox (e.g. sidebar, window)
- * @param {object} hostOptions
- *        Options for host specifically
  */
-function Toolbox(target, selectedTool, hostType, hostOptions) {
+function Toolbox(target, selectedTool, hostType, contentWindow, frameId) {
   this._target = target;
-  this._win = null;
+  this._win = contentWindow;
+  this.frameId = frameId;
+
   this._toolPanels = new Map();
   this._telemetry = new Telemetry();
   if (Services.prefs.getBoolPref("devtools.sourcemap.locations.enabled")) {
     this._sourceMapService = new SourceMapService(this._target);
   }
 
   this._initInspector = null;
   this._inspector = null;
@@ -131,39 +129,36 @@ function Toolbox(target, selectedTool, h
   this._splitConsoleOnKeypress = this._splitConsoleOnKeypress.bind(this);
   this.destroy = this.destroy.bind(this);
   this.highlighterUtils = getHighlighterUtils(this);
   this._highlighterReady = this._highlighterReady.bind(this);
   this._highlighterHidden = this._highlighterHidden.bind(this);
   this._prefChanged = this._prefChanged.bind(this);
   this._saveSplitConsoleHeight = this._saveSplitConsoleHeight.bind(this);
   this._onFocus = this._onFocus.bind(this);
+  this._onHostMessage = this._onHostMessage.bind(this);
   this._showDevEditionPromo = this._showDevEditionPromo.bind(this);
   this._updateTextboxMenuItems = this._updateTextboxMenuItems.bind(this);
   this._onBottomHostMinimized = this._onBottomHostMinimized.bind(this);
   this._onBottomHostMaximized = this._onBottomHostMaximized.bind(this);
   this._onToolSelectWhileMinimized = this._onToolSelectWhileMinimized.bind(this);
   this._onPerformanceFrontEvent = this._onPerformanceFrontEvent.bind(this);
   this._onBottomHostWillChange = this._onBottomHostWillChange.bind(this);
   this._toggleMinimizeMode = this._toggleMinimizeMode.bind(this);
   this._onTabbarFocus = this._onTabbarFocus.bind(this);
   this._onTabbarArrowKeypress = this._onTabbarArrowKeypress.bind(this);
 
   this._target.on("close", this.destroy);
 
-  if (!hostType) {
-    hostType = Services.prefs.getCharPref(this._prefs.LAST_HOST);
-  }
   if (!selectedTool) {
     selectedTool = Services.prefs.getCharPref(this._prefs.LAST_TOOL);
   }
   this._defaultToolId = selectedTool;
 
-  this._hostOptions = hostOptions;
-  this._host = this._createHost(hostType, hostOptions);
+  this._hostType = hostType;
 
   EventEmitter.decorate(this);
 
   this._target.on("navigate", this._refreshHostTitle);
   this._target.on("frame-update", this._updateFrames);
 
   this.on("host-changed", this._refreshHostTitle);
   this.on("select", this._refreshHostTitle);
@@ -185,17 +180,16 @@ Toolbox.HostType = {
   WINDOW: "window",
   CUSTOM: "custom"
 };
 
 Toolbox.prototype = {
   _URL: "about:devtools-toolbox",
 
   _prefs: {
-    LAST_HOST: "devtools.toolbox.host",
     LAST_TOOL: "devtools.toolbox.selectedTool",
     SIDE_ENABLED: "devtools.toolbox.sideEnabled",
     PREVIOUS_HOST: "devtools.toolbox.previousHost"
   },
 
   currentToolId: null,
   lastUsedToolId: null,
 
@@ -265,17 +259,17 @@ Toolbox.prototype = {
     return this._threadClient;
   },
 
   /**
    * Get/alter the host of a Toolbox, i.e. is it in browser or in a separate
    * tab. See HostType for more details.
    */
   get hostType() {
-    return this._host.type;
+    return this._hostType;
   },
 
   /**
    * Shortcut to the window containing the toolbox UI
    */
   get win() {
     return this._win;
   },
@@ -347,37 +341,28 @@ Toolbox.prototype = {
       this.doc.querySelector("#toolbox-panel-iframe-webconsole").contentWindow;
   },
 
   /**
    * Open the toolbox
    */
   open: function () {
     return Task.spawn(function* () {
-      let iframe = yield this._host.create();
-      this._win = iframe.contentWindow;
-
-      let domReady = defer();
-
-      // Prevent reloading the document when the toolbox is opened in a tab
-      let location = iframe.contentWindow.location.href;
-      if (!location.startsWith(this._URL)) {
-        iframe.setAttribute("src", this._URL);
-      } else {
-        // Update the URL so that onceDOMReady watch for the right url.
-        this._URL = location;
-      }
-
       this.browserRequire = BrowserLoader({
         window: this.doc.defaultView,
         useOnlyShared: true
       }).require;
 
-      iframe.setAttribute("aria-label", L10N.getStr("toolbox.label"));
-      let domHelper = new DOMHelpers(iframe.contentWindow);
+      if (this.win.location.href.startsWith(this._URL)) {
+        // Update the URL so that onceDOMReady watch for the right url.
+        this._URL = this.win.location.href;
+      }
+
+      let domReady = defer();
+      let domHelper = new DOMHelpers(this.win);
       domHelper.onceDOMReady(() => {
         domReady.resolve();
       }, this._URL);
 
       // Optimization: fire up a few other things before waiting on
       // the iframe being ready (makes startup faster)
 
       // Load the toolbox-level actor fronts and utilities now
@@ -618,27 +603,49 @@ Toolbox.prototype = {
                  (name, event) => {
                    this.switchToPreviousHost();
                    event.preventDefault();
                  });
 
     this.doc.addEventListener("keypress", this._splitConsoleOnKeypress, false);
     this.doc.addEventListener("focus", this._onFocus, true);
     this.win.addEventListener("unload", this.destroy);
+    this.win.addEventListener("message", this._onHostMessage);
   },
 
   _removeHostListeners: function () {
     // The host iframe's contentDocument may already be gone.
     if (this.doc) {
       this.doc.removeEventListener("keypress", this._splitConsoleOnKeypress, false);
       this.doc.removeEventListener("focus", this._onFocus, true);
       this.win.removeEventListener("unload", this.destroy);
+      this.win.removeEventListener("message", this._onHostMessage);
     }
   },
 
+  // Called whenever the host, on the chrome side, send a message
+  _onHostMessage: function (event) {
+    if (!event.data) return;
+    switch (event.data.name) {
+      case "switched-host":
+        this._onSwitchedHost(event.data);
+        break;
+      case "host-minimized":
+        if (this.hostType == Toolbox.HostType.BOTTOM) {
+          this._onBottomHostMinimized();
+        }
+        break;
+      case "host-maximized":
+        if (this.hostType == Toolbox.HostType.BOTTOM) {
+          this._onBottomHostMaximized();
+        }
+        break;
+    };
+  },
+
   _registerOverlays: function () {
     registerHarOverlay(this);
   },
 
   _saveSplitConsoleHeight: function () {
     Services.prefs.setIntPref(SPLITCONSOLE_HEIGHT_PREF,
       this.webconsolePanel.height);
   },
@@ -789,19 +796,16 @@ Toolbox.prototype = {
          the UI for it, and until bug 1173849 is fixed too. */
       minimizeBtn.setAttribute("hidden", "true");
 
       minimizeBtn.addEventListener("click", this._toggleMinimizeMode);
       dockBox.appendChild(minimizeBtn);
       // Show the button in its maximized state.
       this._onBottomHostMaximized();
 
-      // Update the label and icon when the state changes.
-      this._host.on("minimized", this._onBottomHostMinimized);
-      this._host.on("maximized", this._onBottomHostMaximized);
       // Maximize again when a tool gets selected.
       this.on("before-select", this._onToolSelectWhileMinimized);
       // Maximize and stop listening before the host type changes.
       this.once("host-will-change", this._onBottomHostWillChange);
     }
 
     if (this.hostType == Toolbox.HostType.WINDOW) {
       this.closeButton.setAttribute("hidden", "true");
@@ -852,37 +856,53 @@ Toolbox.prototype = {
     btn.className = "maximized";
 
     btn.setAttribute("title",
       L10N.getStr("toolboxDockButtons.bottom.minimize") + " " +
       this._getMinimizeButtonShortcutTooltip());
   },
 
   _onToolSelectWhileMinimized: function () {
-    this._host.maximize();
+    this.postMessage({
+      name: "maximize-host"
+    });
+  },
+
+  postMessage: function(msg) {
+    // We sometime try to send messages in middle of destroy(), where the
+    // toolbox iframe may already be detached and no longer have a parent.
+    if (this.win.parent) {
+      // Toolbox document is still chrome and disallow identifying message
+      // origin via event.source as it is null. So use a custom id.
+      msg.frameId = this.frameId;
+      this.win.parent.postMessage(msg, "*");
+    }
   },
 
   _onBottomHostWillChange: function () {
-    this._host.maximize();
+    this.postMessage({
+      name: "maximize-host"
+    });
 
-    this._host.off("minimized", this._onBottomHostMinimized);
-    this._host.off("maximized", this._onBottomHostMaximized);
     this.off("before-select", this._onToolSelectWhileMinimized);
   },
 
   _toggleMinimizeMode: function () {
     if (this.hostType !== Toolbox.HostType.BOTTOM) {
       return;
     }
 
     // Calculate the height to which the host should be minimized so the
     // tabbar is still visible.
     let toolbarHeight = this.tabbar.getBoxQuads({box: "content"})[0].bounds
                                                                     .height;
-    this._host.toggleMinimizeMode(toolbarHeight);
+    this.postMessage({
+      name: "toggle-minimize-mode",
+      toolbarHeight
+    });
   },
 
   /**
    * Add tabs to the toolbox UI for registered tools
    */
   _buildTabs: function () {
     for (let definition of gDevTools.getToolDefinitionArray()) {
       this._buildTabForTool(definition);
@@ -1578,31 +1598,36 @@ Toolbox.prototype = {
     let tab = this.doc.getElementById("toolbox-tab-" + id);
     tab && tab.removeAttribute("highlighted");
   },
 
   /**
    * Raise the toolbox host.
    */
   raise: function () {
-    this._host.raise();
+    this.postMessage({
+      name: "raise-host"
+    });
   },
 
   /**
    * Refresh the host's title.
    */
   _refreshHostTitle: function () {
     let title;
     if (this.target.name && this.target.name != this.target.url) {
       title = L10N.getFormatStr("toolbox.titleTemplate2", this.target.name,
                                                           this.target.url);
     } else {
       title = L10N.getFormatStr("toolbox.titleTemplate1", this.target.url);
     }
-    this._host.setTitle(title);
+    this.postMessage({
+      name: "set-host-title",
+      title
+    });
   },
 
   // Returns an instance of the preference actor
   get _preferenceFront() {
     return this.target.root.then(rootForm => {
       return new getPreferenceFront(this.target.client, rootForm);
     });
   },
@@ -1771,40 +1796,16 @@ Toolbox.prototype = {
     // If non-top level frame is selected the toolbar button is
     // marked as 'checked' indicating that a child frame is active.
     if (!topFrameSelected && this.selectedFrameId) {
       button.setAttribute("checked", "true");
     }
   },
 
   /**
-   * Create a host object based on the given host type.
-   *
-   * Warning: some hosts require that the toolbox target provides a reference to
-   * the attached tab. Not all Targets have a tab property - make sure you
-   * correctly mix and match hosts and targets.
-   *
-   * @param {string} hostType
-   *        The host type of the new host object
-   *
-   * @return {Host} host
-   *        The created host object
-   */
-  _createHost: function (hostType, options) {
-    if (!Hosts[hostType]) {
-      throw new Error("Unknown hostType: " + hostType);
-    }
-
-    // clean up the toolbox if its window is closed
-    let newHost = new Hosts[hostType](this.target.tab, options);
-    newHost.on("window-closed", this.destroy);
-    return newHost;
-  },
-
-  /**
    * Switch to the last used host for the toolbox UI.
    * This is determined by the devtools.toolbox.previousHost pref.
    */
   switchToPreviousHost: function () {
     let hostType = Services.prefs.getCharPref(this._prefs.PREVIOUS_HOST);
 
     // Handle the case where the previous host happens to match the current
     // host. If so, switch to bottom if it's not already used, and side if not.
@@ -1834,43 +1835,41 @@ Toolbox.prototype = {
     this.emit("host-will-change", hostType);
 
     // If we call swapFrameLoaders() when a tool if focused it leaves the
     // browser in a state where it thinks that the tool is focused but in
     // reality the content area is focused. Blurring the tool before calling
     // swapFrameLoaders() works around this issue.
     this.focusTool(this.currentToolId, false);
 
-    let newHost = this._createHost(hostType);
-    return newHost.create().then(iframe => {
-      // change toolbox document's parent to the new host
-      iframe.QueryInterface(Ci.nsIFrameLoaderOwner);
-      iframe.swapFrameLoaders(this._host.frame);
+    // Host code on the chrome side will send back a message once the host
+    // switched
+    this.postMessage({
+      name: "switch-host",
+      hostType
+    });
 
-      this._host.off("window-closed", this.destroy);
-      this.destroyHost();
-
-      let prevHostType = this._host.type;
-      this._host = newHost;
+    return this.once("host-changed");
+  },
 
-      if (this.hostType != Toolbox.HostType.CUSTOM) {
-        Services.prefs.setCharPref(this._prefs.LAST_HOST, this._host.type);
-        Services.prefs.setCharPref(this._prefs.PREVIOUS_HOST, prevHostType);
-      }
+  _onSwitchedHost: function ({ hostType }) {
+    if (hostType != Toolbox.HostType.CUSTOM) {
+      Services.prefs.setCharPref(this._prefs.PREVIOUS_HOST, this.hostType);
+    }
 
-      this._buildDockButtons();
-      this._addKeysToWindow();
+    this._hostType = hostType;
 
-      // Focus the tool to make sure keyboard shortcuts work straight away.
-      this.focusTool(this.currentToolId, true);
+    this._buildDockButtons();
+    this._addKeysToWindow();
 
-      this.emit("host-changed");
+    // Focus the tool to make sure keyboard shortcuts work straight away.
+    this.focusTool(this.currentToolId, true);
 
-      this._telemetry.log(HOST_HISTOGRAM, this._getTelemetryHostId());
-    });
+    this.emit("host-changed");
+    this._telemetry.log(HOST_HISTOGRAM, this._getTelemetryHostId());
   },
 
   /**
    * Return if the tool is available as a tab (i.e. if it's checked
    * in the options panel). This is different from Toolbox.getPanel -
    * a tool could be registered but not yet opened in which case
    * isToolRegistered would return true but getPanel would return false.
    */
@@ -2030,26 +2029,16 @@ Toolbox.prototype = {
    *
    * @return The notification box component.
    */
   getNotificationBox: function () {
     return this.notificationBox;
   },
 
   /**
-   * Destroy the current host, and remove event listeners from its frame.
-   *
-   * @return {promise} to be resolved when the host is destroyed.
-   */
-  destroyHost: function () {
-    this._removeHostListeners();
-    return this._host.destroy();
-  },
-
-  /**
    * Remove all UI elements, detach from target and clear up
    */
   destroy: function () {
     // If several things call destroy then we give them all the same
     // destruction promise so we're sure to destroy only once
     if (this._destroyer) {
       return this._destroyer;
     }
@@ -2135,20 +2124,26 @@ Toolbox.prototype = {
     this._telemetry.toolClosed("toolbox");
     this._telemetry.destroy();
 
     // Finish all outstanding tasks (which means finish destroying panels and
     // then destroying the host, successfully or not) before destroying the
     // target.
     this._destroyer = settleAll(outstanding)
         .catch(console.error)
-        .then(() => this.destroyHost())
-        .catch(console.error)
         .then(() => {
-          this._win = null;
+          this._removeHostListeners();
+
+          // Tell the chrome the toolbox is almost destroyed and we can start
+          // removing the toolbox from Firefox UI. Do it exactly here for
+          // historical reason. If we do it sooner or later we appear to leak
+          // the toolbox in many tests.
+          this.postMessage({
+            name: "destroy-host"
+          });
 
           // Targets need to be notified that the toolbox is being torn down.
           // This is done after other destruction tasks since it may tear down
           // fronts and the debugger transport which earlier destroy methods may
           // require to complete.
           if (!this._target) {
             return null;
           }
@@ -2158,16 +2153,17 @@ Toolbox.prototype = {
           target.off("close", this.destroy);
           return target.destroy();
         }, console.error).then(() => {
           this.emit("destroyed");
 
           // Free _host after the call to destroyed in order to let a chance
           // to destroyed listeners to still query toolbox attributes
           this._host = null;
+          this._win = null;
           this._toolPanels.clear();
 
           // Force GC to prevent long GC pauses when running tests and to free up
           // memory in general when the toolbox is closed.
           if (flags.testing) {
             win.QueryInterface(Ci.nsIInterfaceRequestor)
               .getInterface(Ci.nsIDOMWindowUtils)
               .garbageCollect();