Bug 1565263 - Support target switching for the console. r=nchevobbe,yulia,jdescottes
authorAlexandre Poirot <poirot.alex@gmail.com>
Wed, 09 Oct 2019 08:03:43 +0000
changeset 496927 263886b0a46b9c355307989d69c6e7476a4ede9b
parent 496926 85e51a860dae62fb65d52c37576ad20b878f9762
child 496928 b5e0f6f76dd6b71e168419949b3e567e33507788
push id36671
push usershindli@mozilla.com
push dateWed, 09 Oct 2019 16:04:03 +0000
treeherdermozilla-central@0efb4f268d16 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe, yulia, jdescottes
bugs1565263
milestone71.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1565263 - Support target switching for the console. r=nchevobbe,yulia,jdescottes Differential Revision: https://phabricator.services.mozilla.com/D40016
browser/app/profile/firefox.js
devtools/client/framework/devtools.js
devtools/client/framework/source-map-url-service.js
devtools/client/framework/target.js
devtools/client/framework/test/browser_toolbox_remoteness_change.js
devtools/client/framework/test/browser_toolbox_window_title_frame_select.js
devtools/client/framework/toolbox.js
devtools/client/inspector/grids/test/browser_grids_restored-after-reload.js
devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-restored-after-reload.js
devtools/client/inspector/rules/test/browser_rules_grid-highlighter-restored-after-reload.js
devtools/client/inspector/test/head.js
devtools/client/webconsole/panel.js
devtools/client/webconsole/webconsole-connection-proxy.js
devtools/client/webconsole/webconsole-ui.js
devtools/shared/fronts/targets/local-tab.js
devtools/shared/fronts/targets/target-mixin.js
devtools/shared/fronts/webconsole.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1955,16 +1955,25 @@ pref("devtools.toolbox.splitconsoleHeigh
 pref("devtools.toolbox.tabsOrder", "");
 
 // The fission pref is enabling the "Omniscient Browser Toolbox", which will
 // make it possible to debug anything in Firefox (See Bug 1570639 for more
 // information).
 // ⚠ This is a work in progress. Expect weirdness when the pref is enabled. ⚠
 pref("devtools.browsertoolbox.fission", false);
 
+// This pref is also related to fission, but not only. It allows the toolbox
+// to stay open even if the debugged tab switches to another process.
+// It can happen between two documents, one running in the parent process like
+// about:sessionrestore and another one running in the content process like
+// any web page. Or between two distinct domain when running with fission turned
+// on. See bug 1565263.
+// ⚠ This is a work in progress. Expect weirdness when the pref is flipped on ⚠
+pref("devtools.target-switching.enabled", false);
+
 // Toolbox Button preferences
 pref("devtools.command-button-pick.enabled", true);
 pref("devtools.command-button-frames.enabled", true);
 pref("devtools.command-button-splitconsole.enabled", true);
 pref("devtools.command-button-paintflashing.enabled", false);
 pref("devtools.command-button-scratchpad.enabled", false);
 pref("devtools.command-button-responsive.enabled", true);
 pref("devtools.command-button-screenshot.enabled", false);
--- a/devtools/client/framework/devtools.js
+++ b/devtools/client/framework/devtools.js
@@ -629,16 +629,23 @@ DevTools.prototype = {
     toolbox.once("destroy", () => {
       this.emit("toolbox-destroy", target);
     });
 
     toolbox.once("destroyed", () => {
       this._toolboxes.delete(target);
       this.emit("toolbox-destroyed", target);
     });
+    // If the document navigates to another process, the current target will be
+    // destroyed in favor of a new one. So acknowledge this swap here.
+    toolbox.on("switch-target", newTarget => {
+      this._toolboxes.delete(target);
+      this._toolboxes.set(newTarget, toolbox);
+      target = newTarget;
+    });
 
     await toolbox.open();
     this.emit("toolbox-ready", toolbox);
 
     return toolbox;
   },
 
   /**
--- a/devtools/client/framework/source-map-url-service.js
+++ b/devtools/client/framework/source-map-url-service.js
@@ -14,17 +14,21 @@ const SOURCE_MAP_PREF = "devtools.source
  *
  * @param {object} toolbox
  *        The toolbox.
  * @param {SourceMapService} sourceMapService
  *        The devtools-source-map functions
  */
 function SourceMapURLService(toolbox, sourceMapService) {
   this._toolbox = toolbox;
-  this._target = toolbox.target;
+  Object.defineProperty(this, "_target", {
+    get() {
+      return toolbox.target;
+    },
+  });
   this._sourceMapService = sourceMapService;
   // Map from content URLs to descriptors.  Descriptors are later
   // passed to the source map worker.
   this._urls = new Map();
   // Map from (stringified) locations to callbacks that are called
   // when the service decides a location should change (say, a source
   // map is available or the user changes the pref).
   this._subscriptions = new Map();
@@ -115,17 +119,17 @@ SourceMapURLService.prototype.reset = fu
 SourceMapURLService.prototype.destroy = function() {
   this.reset();
   this._target.off("source-updated", this._onSourceUpdated);
   this._target.off("will-navigate", this.reset);
   if (this._stylesheetsFront) {
     this._stylesheetsFront.off("stylesheet-added", this._onNewStyleSheet);
   }
   Services.prefs.removeObserver(SOURCE_MAP_PREF, this._onPrefChanged);
-  this._target = this._urls = this._subscriptions = this._idMap = null;
+  this._urls = this._subscriptions = this._idMap = null;
 };
 
 /**
  * A helper function that is called when a new source is available.
  */
 SourceMapURLService.prototype._onSourceUpdated = function(sourceEvent) {
   const url = this._registerNewSource(sourceEvent.source);
 
--- a/devtools/client/framework/target.js
+++ b/devtools/client/framework/target.js
@@ -24,51 +24,55 @@ const targets = new WeakMap();
  */
 exports.TargetFactory = {
   /**
    * Construct a Target. The target will be cached for each Tab so that we create only
    * one per tab.
    *
    * @param {XULTab} tab
    *        The tab to use in creating a new target.
+   * @param {DebuggerClient} client
+   *        Optional client to fetch the target actor from.
    *
    * @return A target object
    */
-  forTab: async function(tab) {
+  forTab: async function(tab, client) {
     let target = targets.get(tab);
     if (target) {
       return target;
     }
-    const promise = this.createTargetForTab(tab);
+    const promise = this.createTargetForTab(tab, client);
     // Immediately set the target's promise in cache to prevent race
     targets.set(tab, promise);
     target = await promise;
     // Then replace the promise with the target object
     targets.set(tab, target);
     target.once("close", () => {
       targets.delete(tab);
     });
     return target;
   },
 
   /**
    * Instantiate a target for the given tab.
    *
    * This will automatically:
-   * - spawn a DebuggerServer in the parent process,
-   * - create a DebuggerClient and connect it to this local DebuggerServer,
+   * - if no client is passed, spawn a DebuggerServer in the parent process,
+   *   and create a DebuggerClient and connect it to this local DebuggerServer,
    * - call RootActor's `getTab` request to retrieve the FrameTargetActor's form,
    * - instantiate a Target instance.
    *
    * @param {XULTab} tab
    *        The tab to use in creating a new target.
+   * @param {DebuggerClient} client
+   *        Optional client to fetch the target actor from.
    *
    * @return A target object
    */
-  async createTargetForTab(tab) {
+  async createTargetForTab(tab, client) {
     function createLocalServer() {
       // Since a remote protocol connection will be made, let's start the
       // DebuggerServer here, once and for all tools.
       DebuggerServer.init();
 
       // When connecting to a local tab, we only need the root actor.
       // Then we are going to call frame-connector.js' connectToFrame and talk
       // directly with actors living in the child process.
@@ -76,24 +80,26 @@ exports.TargetFactory = {
       // to register custom actors.
       // TODO: the comment and implementation are out of sync here. See Bug 1420134.
       DebuggerServer.registerAllActors();
       // Enable being able to get child process actors
       DebuggerServer.allowChromeProcess = true;
     }
 
     function createLocalClient() {
+      createLocalServer();
       return new DebuggerClient(DebuggerServer.connectPipe());
     }
 
-    createLocalServer();
-    const client = createLocalClient();
+    if (!client) {
+      client = createLocalClient();
 
-    // Connect the local client to the local server
-    await client.connect();
+      // Connect the local client to the local server
+      await client.connect();
+    }
 
     // Fetch the FrameTargetActor's Front which is a BrowsingContextTargetFront
     return client.mainRoot.getTab({ tab });
   },
 
   /**
    * Creating a target for a tab that is being closed is a problem because it
    * allows a leak as a result of coming after the close event which normally
--- a/devtools/client/framework/test/browser_toolbox_remoteness_change.js
+++ b/devtools/client/framework/test/browser_toolbox_remoteness_change.js
@@ -2,16 +2,31 @@
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const URL_1 = "about:robots";
 const URL_2 =
   "data:text/html;charset=UTF-8," +
   encodeURIComponent('<div id="remote-page">foo</div>');
 
 add_task(async function() {
+  // Test twice.
+  // Once without target switching, where the toolbox closes and reopens
+  // And a second time, with target switching, where the toolbox stays open
+  await navigateBetweenProcesses(false);
+  await navigateBetweenProcesses(true);
+});
+
+async function navigateBetweenProcesses(enableTargetSwitching) {
+  info(
+    `Testing navigation between processes ${
+      enableTargetSwitching ? "with" : "without"
+    } target switching`
+  );
+  await pushPref("devtools.target-switching.enabled", enableTargetSwitching);
+
   info("Open a tab on a URL supporting only running in parent process");
   const tab = await addTab(URL_1);
   is(
     tab.linkedBrowser.currentURI.spec,
     URL_1,
     "We really are on the expected document"
   );
   is(
@@ -19,41 +34,51 @@ add_task(async function() {
     "",
     "And running in parent process"
   );
 
   let toolbox = await openToolboxForTab(tab);
 
   const onToolboxDestroyed = toolbox.once("destroyed");
   const onToolboxCreated = gDevTools.once("toolbox-created");
+  const onToolboxSwitchedToTarget = toolbox.once("switched-target");
 
   info("Navigate to a URL supporting remote process");
   const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
   BrowserTestUtils.loadURI(gBrowser, URL_2);
   await onLoaded;
 
   is(
     tab.linkedBrowser.getAttribute("remote"),
     "true",
     "Navigated to a data: URI and switching to remote"
   );
 
-  info("Waiting for the toolbox to be destroyed");
-  await onToolboxDestroyed;
+  if (enableTargetSwitching) {
+    info("Waiting for the toolbox to be switched to the new target");
+    await onToolboxSwitchedToTarget;
+  } else {
+    info("Waiting for the toolbox to be destroyed");
+    await onToolboxDestroyed;
 
-  info("Waiting for a new toolbox to be created");
-  toolbox = await onToolboxCreated;
+    info("Waiting for a new toolbox to be created");
+    toolbox = await onToolboxCreated;
 
-  info("Waiting for the new toolbox to be ready");
-  await toolbox.once("ready");
+    info("Waiting for the new toolbox to be ready");
+    await toolbox.once("ready");
+  }
 
   info("Veryify we are inspecting the new document");
   const console = await toolbox.selectTool("webconsole");
   const { ui } = console.hud;
   ui.wrapper.dispatchEvaluateExpression("document.location.href");
   await waitUntil(() => ui.outputNode.querySelector(".result"));
   const url = ui.outputNode.querySelector(".result");
 
   ok(
     url.textContent.includes(URL_2),
     "The console inspects the second document"
   );
-});
+
+  const { client } = toolbox.target;
+  await toolbox.destroy();
+  ok(client._closed, "The client is closed after closing the toolbox");
+}
--- a/devtools/client/framework/test/browser_toolbox_window_title_frame_select.js
+++ b/devtools/client/framework/test/browser_toolbox_window_title_frame_select.js
@@ -82,22 +82,22 @@ add_task(async function() {
 
   // Listen to will-navigate to check if the view is empty
   const willNavigate = toolbox.target.once("will-navigate");
 
   const onTitleChanged = waitForTitleChange(toolbox);
 
   // Only select the iframe after we are able to select an element from the top
   // level document.
-  const newRoot = toolbox.getPanel("inspector").once("new-root");
+  const onInspectorReloaded = toolbox.getPanel("inspector").once("reloaded");
   info("Select the iframe");
   iframeBtn.click();
 
   await willNavigate;
-  await newRoot;
+  await onInspectorReloaded;
   await onTitleChanged;
 
   info("Navigation to the iframe is done, the inspector should be back up");
   is(
     getTitle(),
     `Developer Tools - Page title - ${URL}`,
     "Devtools title was not updated after changing inspected frame"
   );
--- a/devtools/client/framework/toolbox.js
+++ b/devtools/client/framework/toolbox.js
@@ -294,25 +294,16 @@ function Toolbox(
   this.isOpen = new Promise(
     function(resolve) {
       this._resolveIsOpen = resolve;
     }.bind(this)
   );
 
   EventEmitter.decorate(this);
 
-  this._target.on("will-navigate", this._onWillNavigate);
-  this._target.on("navigate", this._refreshHostTitle);
-  this._target.on("frame-update", this._updateFrames);
-  this._target.on("inspect-object", this._onInspectObject);
-
-  this._target.onFront("inspector", async inspectorFront => {
-    registerWalkerListeners(this.store, inspectorFront.walker);
-  });
-
   this.on("host-changed", this._refreshHostTitle);
   this.on("select", this._onToolSelected);
 
   this.selection.on("new-node-front", this._onNewSelectedNodeFront);
 
   gDevTools.on("tool-registered", this._toolRegistered);
   gDevTools.on("tool-unregistered", this._toolUnregistered);
 
@@ -479,16 +470,49 @@ Toolbox.prototype = {
     return this._toolPanels.get(this.currentToolId);
   },
 
   toggleDragging: function() {
     this.doc.querySelector("window").classList.toggle("dragging");
   },
 
   /**
+   * Instruct the toolbox to switch to a new top-level target.
+   * It means that the currently debugged target is destroyed in favor of a new one.
+   * This typically happens when navigating to a new URL which has to be loaded
+   * in a distinct process.
+   */
+  async switchToTarget(newTarget) {
+    // First unregister the current target
+    this.detachTarget();
+
+    this._target = newTarget;
+
+    // Notify gDevTools that the toolbox is now hooked to another tab target.
+    this.emit("switch-target", newTarget);
+
+    // Attach the toolbox to this new target
+    await this._attachTargets(newTarget);
+    await this._listFrames();
+    await this.initPerformance();
+
+    // Notify all the tools that the target has changed
+    await Promise.all(
+      [...this._toolPanels.values()].map(panel => {
+        if (panel.switchToTarget) {
+          return panel.switchToTarget(newTarget);
+        }
+        return Promise.resolve();
+      })
+    );
+
+    this.emit("switched-target", newTarget);
+  },
+
+  /**
    * Get/alter the target of a Toolbox so we're debugging something different.
    * See Target.jsm for more details.
    * TODO: Do we allow |toolbox.target = null;| ?
    */
   get target() {
     return this._target;
   },
 
@@ -579,16 +603,26 @@ Toolbox.prototype = {
   },
 
   /**
    * Attach to a new top-level target.
    * This method will attach to the top-level target, as well as any potential
    * additional targets we may care about.
    */
   async _attachTargets(target) {
+    // For now, register these event listeners only on the top level target
+    this._target.on("will-navigate", this._onWillNavigate);
+    this._target.on("navigate", this._refreshHostTitle);
+    this._target.on("frame-update", this._updateFrames);
+    this._target.on("inspect-object", this._onInspectObject);
+
+    this._target.onFront("inspector", async inspectorFront => {
+      registerWalkerListeners(this.store, inspectorFront.walker);
+    });
+
     this._threadFront = await this._attachTarget(target);
 
     const fissionSupport = Services.prefs.getBoolPref(
       "devtools.browsertoolbox.fission"
     );
 
     if (fissionSupport && target.isParentProcess && !target.isAddon) {
       const { mainRoot } = target.client;
@@ -691,17 +725,16 @@ Toolbox.prototype = {
       const domReady = new Promise(resolve => {
         domHelper.onceDOMReady(() => {
           resolve();
         }, this._URL);
       });
 
       // Optimization: fire up a few other things before waiting on
       // the iframe being ready (makes startup faster)
-
       await this._attachTargets(this.target);
 
       await domReady;
 
       this.browserRequire = BrowserLoader({
         window: this.win,
         useOnlyShared: true,
       }).require;
@@ -844,16 +877,27 @@ Toolbox.prototype = {
       .catch(e => {
         console.error("Exception while opening the toolbox", String(e), e);
         // While the exception stack is correctly printed in the Browser console when
         // passing `e` to console.error, it is not on the stdout, so print it via dump.
         dump(e.stack + "\n");
       });
   },
 
+  detachTarget() {
+    this._target.off("inspect-object", this._onInspectObject);
+    this._target.off("will-navigate", this._onWillNavigate);
+    this._target.off("navigate", this._refreshHostTitle);
+    this._target.off("frame-update", this._updateFrames);
+
+    // Detach the thread
+    this._stopThreadFrontListeners();
+    this._threadFront = null;
+  },
+
   /**
    * Retrieve the ChromeEventHandler associated to the toolbox frame.
    * When DevTools are loaded in a content frame, this will return the containing chrome
    * frame. Events from nested frames will bubble up to this chrome frame, which allows to
    * listen to events from nested frames.
    */
   getChromeEventHandler() {
     if (!this.win || !this.win.docShell) {
@@ -3464,20 +3508,16 @@ Toolbox.prototype = {
     this._destroyer = this._destroyToolbox();
 
     return this._destroyer;
   },
 
   _destroyToolbox: async function() {
     this.emit("destroy");
 
-    this._target.off("inspect-object", this._onInspectObject);
-    this._target.off("will-navigate", this._onWillNavigate);
-    this._target.off("navigate", this._refreshHostTitle);
-    this._target.off("frame-update", this._updateFrames);
     this.off("select", this._onToolSelected);
     this.off("host-changed", this._refreshHostTitle);
 
     gDevTools.off("tool-registered", this._toolRegistered);
     gDevTools.off("tool-unregistered", this._toolUnregistered);
 
     Services.prefs.removeObserver(
       "devtools.cache.disabled",
@@ -3544,19 +3584,17 @@ Toolbox.prototype = {
     }
 
     this.browserRequire = null;
     this._toolNames = null;
 
     // Reset preferences set by the toolbox
     outstanding.push(this.resetPreference());
 
-    // Detach the thread
-    this._stopThreadFrontListeners();
-    this._threadFront = null;
+    this.detachTarget();
 
     // Unregister buttons listeners
     this.toolbarButtons.forEach(button => {
       if (typeof button.teardown == "function") {
         // teardown arguments have already been bound in _createButtonState
         button.teardown();
       }
     });
--- a/devtools/client/inspector/grids/test/browser_grids_restored-after-reload.js
+++ b/devtools/client/inspector/grids/test/browser_grids_restored-after-reload.js
@@ -65,17 +65,22 @@ add_task(async function() {
     "Reload the page, expect the highlighter to be displayed once again and " +
       "grid is checked"
   );
   let onStateRestored = highlighters.once("grid-state-restored");
   let onGridListRestored = waitUntilState(
     store,
     state => state.grids.length == 1 && state.grids[0].highlighted
   );
+
+  const onReloaded = inspector.once("reloaded");
   await refreshTab();
+  info("Wait for inspector to be reloaded after page reload");
+  await onReloaded;
+
   let { restored } = await onStateRestored;
   await onGridListRestored;
 
   info(
     "Check that the grid highlighter can be displayed after reloading the page"
   );
   ok(restored, "The highlighter state was restored");
   is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown.");
--- a/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-restored-after-reload.js
+++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-restored-after-reload.js
@@ -38,17 +38,22 @@ add_task(async function() {
   const onHighlighterShown = highlighters.once("flexbox-highlighter-shown");
   flexboxToggle.click();
   await onHighlighterShown;
 
   ok(highlighters.flexboxHighlighterShown, "Flexbox highlighter is shown.");
 
   info("Reload the page, expect the highlighter to be displayed once again");
   let onStateRestored = highlighters.once("flexbox-state-restored");
+
+  const onReloaded = inspector.once("reloaded");
   await refreshTab();
+  info("Wait for inspector to be reloaded after page reload");
+  await onReloaded;
+
   let { restored } = await onStateRestored;
   ok(restored, "The highlighter state was restored");
 
   info(
     "Check that the flexbox highlighter can be displayed after reloading the page"
   );
   ok(highlighters.flexboxHighlighterShown, "Flexbox highlighter is shown.");
 
--- a/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-restored-after-reload.js
+++ b/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-restored-after-reload.js
@@ -45,17 +45,22 @@ add_task(async function() {
   const onHighlighterShown = highlighters.once("grid-highlighter-shown");
   gridToggle.click();
   await onHighlighterShown;
 
   is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown.");
 
   info("Reload the page, expect the highlighter to be displayed once again");
   let onStateRestored = highlighters.once("grid-state-restored");
+
+  const onReloaded = inspector.once("reloaded");
   await refreshTab();
+  info("Wait for inspector to be reloaded after page reload");
+  await onReloaded;
+
   let { restored } = await onStateRestored;
   ok(restored, "The highlighter state was restored");
 
   info(
     "Check that the grid highlighter can be displayed after reloading the page"
   );
   is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown.");
 
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -47,29 +47,33 @@ registerCleanupFunction(function() {
     window
   );
 });
 
 var navigateTo = async function(inspector, url) {
   const markuploaded = inspector.once("markuploaded");
   const onNewRoot = inspector.once("new-root");
   const onUpdated = inspector.once("inspector-updated");
+  const onReloaded = inspector.once("reloaded");
 
   info("Navigating to: " + url);
   const target = inspector.toolbox.target;
   await target.navigateTo({ url });
 
   info("Waiting for markup view to load after navigation.");
   await markuploaded;
 
   info("Waiting for new root.");
   await onNewRoot;
 
   info("Waiting for inspector to update after new-root event.");
   await onUpdated;
+
+  info("Waiting for inspector updates after page reload");
+  await onReloaded;
 };
 
 /**
  * Start the element picker and focus the content window.
  * @param {Toolbox} toolbox
  * @param {Boolean} skipFocus - Allow tests to bypass the focus event.
  */
 var startPicker = async function(toolbox, skipFocus) {
--- a/devtools/client/webconsole/panel.js
+++ b/devtools/client/webconsole/panel.js
@@ -84,16 +84,20 @@ WebConsolePanel.prototype = {
       const msg = "WebConsolePanel open failed. " + e.error + ": " + e.message;
       dump(msg + "\n");
       console.error(msg, e);
     }
 
     return this;
   },
 
+  switchToTarget(newTarget) {
+    return this.hud.ui.switchToTarget(newTarget);
+  },
+
   get currentTarget() {
     return this._toolbox.target;
   },
 
   _isReady: false,
   get isReady() {
     return this._isReady;
   },
--- a/devtools/client/webconsole/webconsole-connection-proxy.js
+++ b/devtools/client/webconsole/webconsole-connection-proxy.js
@@ -378,13 +378,12 @@ class WebConsoleConnectionProxy {
     }
 
     this._removeWebConsoleClientEventListeners();
     this.target.off("will-navigate", this._onTabWillNavigate);
     this.target.off("navigate", this._onTabNavigated);
 
     this.client = null;
     this.webConsoleClient = null;
-    this.webConsoleUI = null;
   }
 }
 
 exports.WebConsoleConnectionProxy = WebConsoleConnectionProxy;
--- a/devtools/client/webconsole/webconsole-ui.js
+++ b/devtools/client/webconsole/webconsole-ui.js
@@ -89,41 +89,51 @@ class WebConsoleUI {
   getProxy() {
     return this.proxy;
   }
 
   /**
    * Return all the proxies we're currently managing (i.e. the "main" one, and the
    * possible additional ones).
    *
+   * @param {Boolean} filterDisconnectedProxies: True by default, if false, this
+   *   function also returns not-already-connected or already disconnected proxies.
+   *
    * @returns {Array<WebConsoleConnectionProxy>}
    */
-  getAllProxies() {
+  getAllProxies(filterDisconnectedProxies = true) {
     let proxies = [this.getProxy()];
 
     if (this.additionalProxies) {
       proxies = proxies.concat(this.additionalProxies);
     }
 
+    // Ignore Fronts that are already destroyed
+    if (filterDisconnectedProxies) {
+      proxies = proxies.filter(proxy => {
+        return proxy.webConsoleClient && !!proxy.webConsoleClient.actorID;
+      });
+    }
+
     return proxies;
   }
 
   /**
    * Initialize the WebConsoleUI instance.
    * @return object
    *         A promise object that resolves once the frame is ready to use.
    */
   init() {
     if (this._initializer) {
       return this._initializer;
     }
 
     this._initializer = (async () => {
       this._initUI();
-      await this._initConnection();
+      await this._attachTargets();
       await this.wrapper.init();
 
       const id = WebConsoleUtils.supportsString(this.hudId);
       if (Services.obs) {
         Services.obs.notifyObservers(id, "web-console-created");
       }
     })();
 
@@ -164,16 +174,41 @@ class WebConsoleUI {
     }
     this.proxy = null;
     this.additionalProxies = null;
 
     // Nullify `hud` last as it nullify also target which is used on destroy
     this.window = this.hud = this.wrapper = null;
   }
 
+  async switchToTarget(newTarget) {
+    // Fake a will-navigate and navigate event packets
+    // The only three attribute being used are the following:
+    const packet = {
+      url: newTarget.url,
+      title: newTarget.title,
+      // We always pass true here as the warning message will
+      // be logged when calling `connect`. This flag is also returned
+      // by `startListeners` request
+      nativeConsoleAPI: true,
+    };
+    this.handleTabWillNavigate(packet);
+
+    // Disconnect all previous proxies, including the top level one
+    for (const proxy of this.getAllProxies()) {
+      proxy.disconnect();
+    }
+    this.proxy = null;
+    this.additionalProxies = [];
+
+    await this._attachTargets();
+
+    this.handleTabNavigated(packet);
+  }
+
   /**
    * Clear the Web Console output.
    *
    * This method emits the "messages-cleared" notification.
    *
    * @param boolean clearStorage
    *        True if you want to clear the console messages storage associated to
    *        this Web Console.
@@ -265,17 +300,17 @@ class WebConsoleUI {
 
   /**
    * Connect to the server using the remote debugging protocol.
    *
    * @private
    * @return object
    *         A promise object that is resolved/reject based on the proxies connections.
    */
-  async _initConnection() {
+  async _attachTargets() {
     const target = this.hud.currentTarget;
     const fissionSupport = Services.prefs.getBoolPref(
       PREFS.FEATURES.BROWSER_TOOLBOX_FISSION
     );
     const needContentProcessMessagesListener =
       target.isParentProcess && !target.isAddon && !fissionSupport;
 
     this.proxy = new WebConsoleConnectionProxy(
@@ -307,17 +342,17 @@ class WebConsoleUI {
         }
 
         this.additionalProxies.push(
           new WebConsoleConnectionProxy(this, targetFront)
         );
       }
     }
 
-    return Promise.all(this.getAllProxies().map(proxy => proxy.connect()));
+    return Promise.all(this.getAllProxies(false).map(proxy => proxy.connect()));
   }
 
   _initUI() {
     this.document = this.window.document;
     this.rootElement = this.document.documentElement;
 
     this.outputNode = this.document.getElementById("app-wrapper");
 
--- a/devtools/shared/fronts/targets/local-tab.js
+++ b/devtools/shared/fronts/targets/local-tab.js
@@ -1,13 +1,14 @@
 /* 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/. */
 "use strict";
 
+const Services = require("Services");
 loader.lazyRequireGetter(
   this,
   "gDevTools",
   "devtools/client/framework/devtools",
   true
 );
 loader.lazyRequireGetter(
   this,
@@ -121,18 +122,58 @@ class LocalTabTargetFront extends Browsi
     // remotenesschange events. But we should ignore them as at the end
     // the content doesn't change its remoteness.
     if (this.tab.isResponsiveDesignMode) {
       return;
     }
 
     const toolbox = gDevTools.getToolbox(this);
 
-    // Force destroying the toolbox as the target will be destroyed,
-    // but not the toolbox.
-    await toolbox.destroy();
+    const targetSwitchingEnabled = Services.prefs.getBoolPref(
+      "devtools.target-switching.enabled",
+      false
+    );
+
+    // Cache the client as this property will be nullified when the target is closed
+    const client = this.client;
+
+    if (targetSwitchingEnabled) {
+      // By default, we do close the DebuggerClient when the target is destroyed.
+      // This happens when we close the toolbox (Toolbox.destroy calls Target.destroy),
+      // or when the tab is closes, the server emits tabDetached and the target
+      // destroy itself.
+      // Here, in the context of the process switch, the current target will be destroyed
+      // due to a tabDetached event and a we will create a new one. But we want to reuse
+      // the same client.
+      this.shouldCloseClient = false;
 
-    // Recreate a fresh target instance as the current one is now destroyed
-    const newTarget = await TargetFactory.forTab(this.tab);
-    gDevTools.showToolbox(newTarget);
+      // If we support target switching, only wait for the target to be
+      // destroyed so that TargetFactory clears its memoized target for this tab
+      await this.once("close");
+    } else {
+      // Otherwise, if we don't support target switching, ensure the toolbox is destroyed.
+      // We need to wait for the toolbox destruction because the TargetFactory memoized the targets,
+      // and only cleans up the cache after the target is destroyed via toolbox destruction.
+      await toolbox.destroy();
+    }
+
+    // Fetch the new target for this tab
+    // Only try to fetch the the target from the existing client when target switching
+    // is enabled. We keep the toolbox open with the original client we created it from.
+    const newTarget = await TargetFactory.forTab(
+      this.tab,
+      targetSwitchingEnabled ? client : null
+    );
+
+    // Depending on if we support target switching or not, we should either
+    // reopen a brand new toolbox against the new target we switched to, or
+    // only communicate the new target to the toolbox.
+    if (targetSwitchingEnabled) {
+      // Restore automatic destruction of the client on target destroy
+      // so that the client is closed when the toolbox closes.
+      this.shouldCloseClient = true;
+      toolbox.switchToTarget(newTarget);
+    } else {
+      gDevTools.showToolbox(newTarget);
+    }
   }
 }
 exports.LocalTabTargetFront = LocalTabTargetFront;
--- a/devtools/shared/fronts/targets/target-mixin.js
+++ b/devtools/shared/fronts/targets/target-mixin.js
@@ -328,17 +328,17 @@ function TargetMixin(parentClass) {
       );
     }
 
     get isMultiProcess() {
       return !this.window;
     }
 
     get canRewind() {
-      return this.traits.canRewind;
+      return this.traits && this.traits.canRewind;
     }
 
     isReplayEnabled() {
       return this.canRewind && this.isLocalTab;
     }
 
     getExtensionPathName(url) {
       // Return the url if the target is not a webextension.
--- a/devtools/shared/fronts/webconsole.js
+++ b/devtools/shared/fronts/webconsole.js
@@ -479,17 +479,20 @@ class WebConsoleFront extends FrontClass
     this.pendingEvaluationResults.clear();
     this.pendingEvaluationResults = null;
     this.clearNetworkRequests();
     this._networkRequests = null;
     return super.destroy();
   }
 
   clearNetworkRequests() {
-    this._networkRequests.clear();
+    // Prevent exception if the front has already been destroyed.
+    if (this._networkRequests) {
+      this._networkRequests.clear();
+    }
   }
 
   /**
    * Fetches the full text of a LongString.
    *
    * @param object | string stringGrip
    *        The long string grip containing the corresponding actor.
    *        If you pass in a plain string (by accident or because you're lazy),