Bug 1240913 - Swap page state between tabs and RDM viewports. r=ochameau draft
authorJ. Ryan Stinnett <jryans@gmail.com>
Mon, 18 Apr 2016 18:20:22 -0500
changeset 367557 aba8c8b9e83f12dc2773df1feacf8ad37cabf8ba
parent 364634 651a682c1454b8539b05ac3abae8b6282ac238b9
child 367558 2320a0bf6fca4a1bfb15601a38cea5c4a9fb334c
push id18271
push userbmo:jryans@gmail.com
push dateMon, 16 May 2016 21:10:48 +0000
reviewersochameau
bugs1240913
milestone49.0a1
Bug 1240913 - Swap page state between tabs and RDM viewports. r=ochameau This change brings the following improvements to RDM: * Page state is preserved when toggling in and out of RDM * Session history is no longer manipulated, so the tool UI won't end up in the tab's back-forward page list. Known issues to be fixed later: * The browser UI is not hooked up to the viewport browser * Restarting the browser with the tool open shows a confused, empty RDM MozReview-Commit-ID: Fb6QRv6LYow
devtools/client/responsive.html/browser/moz.build
devtools/client/responsive.html/browser/swap.js
devtools/client/responsive.html/components/browser.js
devtools/client/responsive.html/components/resizable-viewport.js
devtools/client/responsive.html/components/viewport.js
devtools/client/responsive.html/components/viewports.js
devtools/client/responsive.html/docs/browser-swap.md
devtools/client/responsive.html/index.js
devtools/client/responsive.html/manager.js
devtools/client/responsive.html/moz.build
devtools/client/responsive.html/test/browser/browser.ini
devtools/client/responsive.html/test/browser/browser_device_width.js
devtools/client/responsive.html/test/browser/browser_frame_script_active.js
devtools/client/responsive.html/test/browser/browser_menu_item_02.js
devtools/client/responsive.html/test/browser/browser_mouse_resize.js
devtools/client/responsive.html/test/browser/browser_page_state.js
devtools/client/responsive.html/test/browser/doc_page_state.html
devtools/client/responsive.html/test/browser/head.js
devtools/client/responsive.html/utils/message.js
devtools/client/responsive.html/utils/moz.build
devtools/client/responsivedesign/responsivedesign-child.js
devtools/docs/SUMMARY.md
devtools/docs/responsive-design-mode.md
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/browser/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+    'swap.js',
+)
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/browser/swap.js
@@ -0,0 +1,179 @@
+/* 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 promise = require("promise");
+const { Task } = require("resource://gre/modules/Task.jsm");
+
+/**
+ * Swap page content from an existing tab into a new browser within a container
+ * page.  Page state is preserved by using `swapFrameLoaders`, just like when
+ * you move a tab to a new window.  This provides a seamless transition for the
+ * user since the page is not reloaded.
+ *
+ * See /devtools/docs/responsive-design-mode.md for a high level overview of how
+ * this is used in RDM.  The steps described there are copied into the code
+ * below.
+ *
+ * For additional low level details about swapping browser content,
+ * see /devtools/client/responsive.html/docs/browser-swap.md.
+ *
+ * @param tab
+ *        A browser tab with content to be swapped.
+ * @param containerURL
+ *        URL to a page that holds an inner browser.
+ * @param getInnerBrowser
+ *        Function that returns a Promise to the inner browser within the
+ *        container page.  It is called with the outer browser that loaded the
+ *        container page.
+ */
+function swapToInnerBrowser({ tab, containerURL, getInnerBrowser }) {
+  let gBrowser = tab.ownerDocument.defaultView.gBrowser;
+  let innerBrowser;
+
+  return {
+
+    start: Task.async(function* () {
+      // 1. Create a temporary, hidden tab to load the tool UI.
+      let containerTab = gBrowser.addTab(containerURL, {
+        skipAnimation: true,
+      });
+      gBrowser.hideTab(containerTab);
+      let containerBrowser = containerTab.linkedBrowser;
+
+      // 2. Mark the tool tab browser's docshell as active so the viewport frame
+      //    is created eagerly and will be ready to swap.
+      // This line is crucial when the tool UI is loaded into a background tab.
+      // Without it, the viewport browser's frame is created lazily, leading to
+      // a multi-second delay before it would be possible to `swapFrameLoaders`.
+      // Even worse than the delay, there appears to be no obvious event fired
+      // after the frame is set lazily, so it's unclear how to know that work
+      // has finished.
+      containerBrowser.docShellIsActive = true;
+
+      // 3. Create the initial viewport inside the tool UI.
+      // The calling application will use container page loaded into the tab to
+      // do whatever it needs to create the inner browser.
+      yield tabLoaded(containerTab);
+      innerBrowser = yield getInnerBrowser(containerBrowser);
+      addXULBrowserDecorations(innerBrowser);
+      if (innerBrowser.isRemoteBrowser != tab.linkedBrowser.isRemoteBrowser) {
+        throw new Error("The inner browser's remoteness must match the " +
+                        "original tab.");
+      }
+
+      // 4. Swap tab content from the regular browser tab to the browser within
+      //    the viewport in the tool UI, preserving all state via
+      //    `gBrowser._swapBrowserDocShells`.
+      gBrowser._swapBrowserDocShells(tab, innerBrowser);
+
+      // 5. Force the original browser tab to be non-remote since the tool UI
+      //    must be loaded in the parent process, and we're about to swap the
+      //    tool UI into this tab.
+      gBrowser.updateBrowserRemoteness(tab.linkedBrowser, false);
+
+      // 6. Swap the tool UI (with viewport showing the content) into the
+      //    original browser tab and close the temporary tab used to load the
+      //    tool via `swapBrowsersAndCloseOther`.
+      gBrowser.swapBrowsersAndCloseOther(tab, containerTab);
+    }),
+
+    stop() {
+      // 1. Create a temporary, hidden tab to hold the content.
+      let contentTab = gBrowser.addTab("about:blank", {
+        skipAnimation: true,
+      });
+      gBrowser.hideTab(contentTab);
+      let contentBrowser = contentTab.linkedBrowser;
+
+      // 2. Mark the content tab browser's docshell as active so the frame
+      //    is created eagerly and will be ready to swap.
+      contentBrowser.docShellIsActive = true;
+
+      // 3. Swap tab content from the browser within the viewport in the tool UI
+      //    to the regular browser tab, preserving all state via
+      //    `gBrowser._swapBrowserDocShells`.
+      gBrowser._swapBrowserDocShells(contentTab, innerBrowser);
+      innerBrowser = null;
+
+      // 4. Force the original browser tab to be remote since web content is
+      //    loaded in the child process, and we're about to swap the content
+      //    into this tab.
+      gBrowser.updateBrowserRemoteness(tab.linkedBrowser, true);
+
+      // 5. Swap the content into the original browser tab and close the
+      //    temporary tab used to hold the content via
+      //    `swapBrowsersAndCloseOther`.
+      gBrowser.swapBrowsersAndCloseOther(tab, contentTab);
+      gBrowser = null;
+    },
+
+  };
+}
+
+/**
+ * Browser elements that are passed to `gBrowser._swapBrowserDocShells` are
+ * expected to have certain properties that currently exist only on
+ * <xul:browser> elements.  In particular, <iframe mozbrowser> elements don't
+ * have them.
+ *
+ * Rather than duplicate the swapping code used by the browser to work around
+ * this, we stub out the missing properties needed for the swap to complete.
+ */
+function addXULBrowserDecorations(browser) {
+  if (browser.isRemoteBrowser == undefined) {
+    Object.defineProperty(browser, "isRemoteBrowser", {
+      get() {
+        return this.getAttribute("remote") == "true";
+      },
+      configurable: true,
+      enumerable: true,
+    });
+  }
+  if (browser.messageManager == undefined) {
+    Object.defineProperty(browser, "messageManager", {
+      get() {
+        return this.frameLoader.messageManager;
+      },
+      configurable: true,
+      enumerable: true,
+    });
+  }
+
+  // It's not necessary for these to actually do anything.  These properties are
+  // swapped between browsers in browser.xml's `swapDocShells`, and then their
+  // `swapBrowser` methods are called, so we define them here for that to work
+  // without errors.  During the swap process above, these will move from the
+  // the new inner browser to the original tab's browser (step 4) and then to
+  // the temporary container tab's browser (step 7), which is then closed.
+  if (browser._remoteWebNavigationImpl == undefined) {
+    browser._remoteWebNavigationImpl = {
+      swapBrowser() {},
+    };
+  }
+  if (browser._remoteWebProgressManager == undefined) {
+    browser._remoteWebProgressManager = {
+      swapBrowser() {},
+    };
+  }
+}
+
+function tabLoaded(tab) {
+  let deferred = promise.defer();
+
+  function handle(event) {
+    if (event.originalTarget != tab.linkedBrowser.contentDocument ||
+        event.target.location.href == "about:blank") {
+      return;
+    }
+    tab.linkedBrowser.removeEventListener("load", handle, true);
+    deferred.resolve(event);
+  }
+
+  tab.linkedBrowser.addEventListener("load", handle, true);
+  return deferred.promise;
+}
+
+exports.swapToInnerBrowser = swapToInnerBrowser;
--- a/devtools/client/responsive.html/components/browser.js
+++ b/devtools/client/responsive.html/components/browser.js
@@ -9,39 +9,76 @@
 const { Task } = require("resource://gre/modules/Task.jsm");
 const DevToolsUtils = require("devtools/shared/DevToolsUtils");
 const { getToplevelWindow } = require("sdk/window/utils");
 const { DOM: dom, createClass, addons, PropTypes } =
   require("devtools/client/shared/vendor/react");
 
 const Types = require("../types");
 const e10s = require("../utils/e10s");
+const message = require("../utils/message");
 
 module.exports = createClass({
+
   /**
    * This component is not allowed to depend directly on frequently changing
    * data (width, height) due to the use of `dangerouslySetInnerHTML` below.
    * Any changes in props will cause the <iframe> to be removed and added again,
    * throwing away the current state of the page.
    */
   propTypes: {
     location: Types.location.isRequired,
+    swapAfterMount: PropTypes.bool.isRequired,
     onBrowserMounted: PropTypes.func.isRequired,
     onContentResize: PropTypes.func.isRequired,
   },
 
   displayName: "Browser",
 
   mixins: [ addons.PureRenderMixin ],
 
   /**
    * Once the browser element has mounted, load the frame script and enable
    * various features, like floating scrollbars.
    */
   componentDidMount: Task.async(function* () {
+    // If we are not swapping browsers after mount, it's safe to start the frame
+    // script now.
+    if (!this.props.swapAfterMount) {
+      yield this.startFrameScript();
+    }
+
+    // Notify manager.js that this browser has mounted, so that it can trigger
+    // a swap if needed and continue with the rest of its startup.
+    this.props.onBrowserMounted();
+
+    // If we are swapping browsers after mount, wait for the swap to complete
+    // and start the frame script after that.
+    if (this.props.swapAfterMount) {
+      yield message.wait(window, "start-frame-script");
+      yield this.startFrameScript();
+      message.post(window, "start-frame-script:done");
+    }
+
+    // Stop the frame script when requested in the future.
+    message.wait(window, "stop-frame-script").then(() => {
+      this.stopFrameScript();
+    });
+  }),
+
+  onContentResize(msg) {
+    let { onContentResize } = this.props;
+    let { width, height } = msg.data;
+    onContentResize({
+      width,
+      height,
+    });
+  },
+
+  startFrameScript: Task.async(function* () {
     let { onContentResize } = this;
     let browser = this.refs.browserContainer.querySelector("iframe.browser");
     let mm = browser.frameLoader.messageManager;
 
     // Notify tests when the content has received a resize event.  This is not
     // quite the same timing as when we _set_ a new size around the browser,
     // since it still needs to do async work before the content is actually
     // resized to match.
@@ -56,37 +93,26 @@ module.exports = createClass({
     let requiresFloatingScrollbars =
       !browserWindow.matchMedia("(-moz-overlay-scrollbars)").matches;
 
     yield e10s.request(mm, "Start", {
       requiresFloatingScrollbars,
       // Tests expect events on resize to yield on various size changes
       notifyOnResize: DevToolsUtils.testing,
     });
-
-    // manager.js waits for this signal before allowing browser tests to start
-    this.props.onBrowserMounted();
   }),
 
-  componentWillUnmount() {
+  stopFrameScript: Task.async(function* () {
     let { onContentResize } = this;
     let browser = this.refs.browserContainer.querySelector("iframe.browser");
     let mm = browser.frameLoader.messageManager;
     e10s.off(mm, "OnContentResize", onContentResize);
-    e10s.emit(mm, "Stop");
-  },
-
-  onContentResize(msg) {
-    let { onContentResize } = this.props;
-    let { width, height } = msg.data;
-    onContentResize({
-      width,
-      height,
-    });
-  },
+    yield e10s.request(mm, "Stop");
+    message.post(window, "stop-frame-script:done");
+  }),
 
   render() {
     let {
       location,
     } = this.props;
 
     // innerHTML expects & to be an HTML entity
     location = location.replace(/&/g, "&amp;");
--- a/devtools/client/responsive.html/components/resizable-viewport.js
+++ b/devtools/client/responsive.html/components/resizable-viewport.js
@@ -13,20 +13,22 @@ const Constants = require("../constants"
 const Types = require("../types");
 const Browser = createFactory(require("./browser"));
 const ViewportToolbar = createFactory(require("./viewport-toolbar"));
 
 const VIEWPORT_MIN_WIDTH = Constants.MIN_VIEWPORT_DIMENSION;
 const VIEWPORT_MIN_HEIGHT = Constants.MIN_VIEWPORT_DIMENSION;
 
 module.exports = createClass({
+
   propTypes: {
     devices: PropTypes.shape(Types.devices).isRequired,
     location: Types.location.isRequired,
     screenshot: PropTypes.shape(Types.screenshot).isRequired,
+    swapAfterMount: PropTypes.bool.isRequired,
     viewport: PropTypes.shape(Types.viewport).isRequired,
     onBrowserMounted: PropTypes.func.isRequired,
     onChangeViewportDevice: PropTypes.func.isRequired,
     onContentResize: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
     onUpdateDeviceModalOpen: PropTypes.func.isRequired,
   },
@@ -113,16 +115,17 @@ module.exports = createClass({
     });
   },
 
   render() {
     let {
       devices,
       location,
       screenshot,
+      swapAfterMount,
       viewport,
       onBrowserMounted,
       onChangeViewportDevice,
       onContentResize,
       onResizeViewport,
       onRotateViewport,
       onUpdateDeviceModalOpen,
     } = this.props;
@@ -154,16 +157,17 @@ module.exports = createClass({
           className: contentClass,
           style: {
             width: viewport.width + "px",
             height: viewport.height + "px",
           },
         },
         Browser({
           location,
+          swapAfterMount,
           onBrowserMounted,
           onContentResize,
         })
       ),
       dom.div({
         className: resizeHandleClass,
         onMouseDown: this.onResizeStart,
       }),
--- a/devtools/client/responsive.html/components/viewport.js
+++ b/devtools/client/responsive.html/components/viewport.js
@@ -7,20 +7,22 @@
 const { DOM: dom, createClass, createFactory, PropTypes } =
   require("devtools/client/shared/vendor/react");
 
 const Types = require("../types");
 const ResizableViewport = createFactory(require("./resizable-viewport"));
 const ViewportDimension = createFactory(require("./viewport-dimension"));
 
 module.exports = createClass({
+
   propTypes: {
     devices: PropTypes.shape(Types.devices).isRequired,
     location: Types.location.isRequired,
     screenshot: PropTypes.shape(Types.screenshot).isRequired,
+    swapAfterMount: PropTypes.bool.isRequired,
     viewport: PropTypes.shape(Types.viewport).isRequired,
     onBrowserMounted: PropTypes.func.isRequired,
     onChangeViewportDevice: PropTypes.func.isRequired,
     onContentResize: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
     onUpdateDeviceModalOpen: PropTypes.func.isRequired,
   },
@@ -54,16 +56,17 @@ module.exports = createClass({
     onRotateViewport(viewport.id);
   },
 
   render() {
     let {
       devices,
       location,
       screenshot,
+      swapAfterMount,
       viewport,
       onBrowserMounted,
       onContentResize,
       onUpdateDeviceModalOpen,
     } = this.props;
 
     let {
       onChangeViewportDevice,
@@ -74,16 +77,17 @@ module.exports = createClass({
     return dom.div(
       {
         className: "viewport",
       },
       ResizableViewport({
         devices,
         location,
         screenshot,
+        swapAfterMount,
         viewport,
         onBrowserMounted,
         onChangeViewportDevice,
         onContentResize,
         onResizeViewport,
         onRotateViewport,
         onUpdateDeviceModalOpen,
       }),
--- a/devtools/client/responsive.html/components/viewports.js
+++ b/devtools/client/responsive.html/components/viewports.js
@@ -6,16 +6,17 @@
 
 const { DOM: dom, createClass, createFactory, PropTypes } =
   require("devtools/client/shared/vendor/react");
 
 const Types = require("../types");
 const Viewport = createFactory(require("./viewport"));
 
 module.exports = createClass({
+
   propTypes: {
     devices: PropTypes.shape(Types.devices).isRequired,
     location: Types.location.isRequired,
     screenshot: PropTypes.shape(Types.screenshot).isRequired,
     viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired,
     onBrowserMounted: PropTypes.func.isRequired,
     onChangeViewportDevice: PropTypes.func.isRequired,
     onContentResize: PropTypes.func.isRequired,
@@ -39,22 +40,23 @@ module.exports = createClass({
       onRotateViewport,
       onUpdateDeviceModalOpen,
     } = this.props;
 
     return dom.div(
       {
         id: "viewports",
       },
-      viewports.map(viewport => {
+      viewports.map((viewport, i) => {
         return Viewport({
           key: viewport.id,
           devices,
           location,
           screenshot,
+          swapAfterMount: i == 0,
           viewport,
           onBrowserMounted,
           onChangeViewportDevice,
           onContentResize,
           onResizeViewport,
           onRotateViewport,
           onUpdateDeviceModalOpen,
         });
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/docs/browser-swap.md
@@ -0,0 +1,145 @@
+# Overview
+
+The RDM tool uses several forms of tab and browser swapping to integrate the
+tool UI cleanly into the browser UI.  The high level steps of this process are
+documented at `/devtools/docs/responsive-design-mode.md`.
+
+This document contains a random assortment of low level notes about the steps
+the browser goes through when swapping browsers between tabs.
+
+# Connections between Browsers and Tabs
+
+Link between tab and browser (`gBrowser._linkBrowserToTab`):
+
+```
+aTab.linkedBrowser = browser;
+gBrowser._tabForBrowser.set(browser, aTab);
+```
+
+# Swapping Browsers between Tabs
+
+## Legend
+
+* (R): remote browsers only
+* (!R): non-remote browsers only
+
+## Functions Called
+
+When you call `gBrowser.swapBrowsersAndCloseOther` to move tab content from a
+browser in one tab to a browser in another tab, here are all the code paths
+involved:
+
+* `gBrowser.swapBrowsersAndCloseOther`
+  * `gBrowser._beginRemoveTab`
+    * `gBrowser.tabContainer.updateVisibility`
+    * Emit `TabClose`
+    * `browser.webProgress.removeProgressListener`
+    * `filter.removeProgressListener`
+    * `listener.destroy`
+  * `gBrowser._swapBrowserDocShells`
+    * `ourBrowser.webProgress.removeProgressListener`
+    * `filter.removeProgressListener`
+    * `gBrowser._swapRegisteredOpenURIs`
+    * `ourBrowser.swapDocShells(aOtherBrowser)`
+      * Emit `SwapDocShells`
+      * `PopupNotifications._swapBrowserNotifications`
+      * `browser.detachFormFill` (!R)
+      * `browser.swapFrameLoaders`
+      * `browser.attachFormFill` (!R)
+      * `browser._remoteWebNavigationImpl.swapBrowser(browser)` (R)
+      * `browser._remoteWebProgressManager.swapBrowser(browser)` (R)
+      * `browser._remoteFinder.swapBrowser(browser)` (R)
+    * `gBrowser.mTabProgressListener`
+    * `filter.addProgressListener`
+    * `ourBrowser.webProgress.addProgressListener`
+  * `gBrowser._endRemoveTab`
+    * `gBrowser.tabContainer._fillTrailingGap`
+    * `gBrowser._blurTab`
+    * `gBrowser._tabFilters.delete`
+    * `gBrowser._tabListeners.delete`
+    * `gBrowser._outerWindowIDBrowserMap.delete`
+    * `browser.destroy`
+    * `gBrowser.tabContainer.removeChild`
+    * `gBrowser.tabContainer.adjustTabstrip`
+    * `gBrowser.tabContainer._setPositionalAttributes`
+    * `browser.parentNode.removeChild(browser)`
+    * `gBrowser._tabForBrowser.delete`
+    * `gBrowser.mPanelContainer.removeChild`
+  * `gBrowser.setTabTitle` / `gBrowser.setTabTitleLoading`
+    * `browser.currentURI.spec`
+    * `gBrowser._tabAttrModified`
+    * `gBrowser.updateTitlebar`
+  * `gBrowser.updateCurrentBrowser`
+    * `browser.docShellIsActive` (!R)
+    * `gBrowser.showTab`
+    * `gBrowser._appendStatusPanel`
+    * `gBrowser._callProgressListeners` with `onLocationChange`
+    * `gBrowser._callProgressListeners` with `onSecurityChange`
+    * `gBrowser._callProgressListeners` with `onUpdateCurrentBrowser`
+    * `gBrowser._recordTabAccess`
+    * `gBrowser.updateTitlebar`
+    * `gBrowser._callProgressListeners` with `onStateChange`
+    * `gBrowser._setCloseKeyState`
+    * Emit `TabSelect`
+    * `gBrowser._tabAttrModified`
+    * `browser.getInPermitUnload`
+    * `gBrowser.tabContainer._setPositionalAttributes`
+  * `gBrowser._tabAttrModified`
+
+## Browser State
+
+When calling `gBrowser.swapBrowsersAndCloseOther`, the browser is not actually
+moved from one tab to the other.  Instead, various properties _on_ each of the
+browsers are swapped.
+
+Browser attributes `gBrowser.swapBrowsersAndCloseOther` transfers between
+browsers:
+
+* `usercontextid`
+
+Tab attributes `gBrowser.swapBrowsersAndCloseOther` transfers between tabs:
+
+* `usercontextid`
+* `muted`
+* `soundplaying`
+* `busy`
+
+Browser properties `gBrowser.swapBrowsersAndCloseOther` transfers between
+browsers:
+
+* `mIconURL`
+* `getFindBar(aOurTab)._findField.value`
+
+Browser properties `gBrowser._swapBrowserDocShells` transfers between browsers:
+
+* `outerWindowID` in `gBrowser._outerWindowIDBrowserMap`
+* `_outerWindowID` on the browser (R)
+* `docShellIsActive`
+* `permanentKey`
+* `registeredOpenURI`
+
+Browser properties `browser.swapDocShells` transfers between browsers:
+
+* `_docShell`
+* `_webBrowserFind`
+* `_contentWindow`
+* `_webNavigation`
+* `_remoteWebNavigation` (R)
+* `_remoteWebNavigationImpl` (R)
+* `_remoteWebProgressManager` (R)
+* `_remoteWebProgress` (R)
+* `_remoteFinder` (R)
+* `_securityUI` (R)
+* `_documentURI` (R)
+* `_documentContentType` (R)
+* `_contentTitle` (R)
+* `_characterSet` (R)
+* `_contentPrincipal` (R)
+* `_imageDocument` (R)
+* `_fullZoom` (R)
+* `_textZoom` (R)
+* `_isSyntheticDocument` (R)
+* `_innerWindowID` (R)
+* `_manifestURI` (R)
+
+`browser.swapFrameLoaders` swaps the actual page content.
--- a/devtools/client/responsive.html/index.js
+++ b/devtools/client/responsive.html/index.js
@@ -17,16 +17,17 @@ const { Task } = require("resource://gre
 const Telemetry = require("devtools/client/shared/telemetry");
 const { loadSheet } = require("sdk/stylesheet/utils");
 
 const { createFactory, createElement } =
   require("devtools/client/shared/vendor/react");
 const ReactDOM = require("devtools/client/shared/vendor/react-dom");
 const { Provider } = require("devtools/client/shared/vendor/react-redux");
 
+const message = require("./utils/message");
 const { initDevices } = require("./devices");
 const App = createFactory(require("./app"));
 const Store = require("./store");
 const { changeLocation } = require("./actions/location");
 const { addViewport, resizeViewport } = require("./actions/viewports");
 
 let bootstrap = {
 
@@ -40,17 +41,17 @@ let bootstrap = {
     loadSheet(window,
               "resource://devtools/client/responsive.html/responsive-ua.css",
               "agent");
     this.telemetry.toolOpened("responsive");
     let store = this.store = Store();
     yield initDevices(this.dispatch.bind(this));
     let provider = createElement(Provider, { store }, App());
     ReactDOM.render(provider, document.querySelector("#root"));
-    window.postMessage({ type: "init" }, "*");
+    message.post(window, "init:done");
   }),
 
   destroy() {
     this.store = null;
     this.telemetry.toolClosed("responsive");
     this.telemetry = null;
   },
 
@@ -66,20 +67,18 @@ let bootstrap = {
       // initDevices() below are still pending.
       return;
     }
     this.store.dispatch(action);
   },
 
 };
 
-window.addEventListener("load", function onLoad() {
-  window.removeEventListener("load", onLoad);
-  bootstrap.init();
-});
+// manager.js sends a message to signal init
+message.wait(window, "init").then(() => bootstrap.init());
 
 window.addEventListener("unload", function onUnload() {
   window.removeEventListener("unload", onUnload);
   bootstrap.destroy();
 });
 
 // Allows quick testing of actions from the console
 window.dispatch = action => bootstrap.dispatch(action);
@@ -117,16 +116,17 @@ window.setViewportSize = (width, height)
   try {
     bootstrap.dispatch(resizeViewport(0, width, height));
   } catch (e) {
     console.error(e);
   }
 };
 
 /**
- * Called by manager.js when tests want to use the viewport's message manager.
- * It is packed into an object because this is the format most easily usable
- * with ContentTask.spawn().
+ * Called by manager.js when tests want to use the viewport's browser to access
+ * the content inside.  We mock the format of a <xul:browser> to make this
+ * easily usable with ContentTask.spawn(), which expects an object with a
+ * `messageManager` property.
  */
-window.getViewportMessageManager = () => {
+window.getViewportBrowser = () => {
   let { messageManager } = document.querySelector("iframe.browser").frameLoader;
   return { messageManager };
 };
--- a/devtools/client/responsive.html/manager.js
+++ b/devtools/client/responsive.html/manager.js
@@ -1,23 +1,23 @@
 /* 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 { Ci, Cr } = require("chrome");
 const promise = require("promise");
 const { Task } = require("resource://gre/modules/Task.jsm");
-const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
 const EventEmitter = require("devtools/shared/event-emitter");
 const { getOwnerWindow } = require("sdk/tabs/utils");
 const { on, off } = require("sdk/event/core");
 const { startup } = require("sdk/window/helpers");
 const events = require("./events");
+const message = require("./utils/message");
+const { swapToInnerBrowser } = require("./browser/swap");
 
 const TOOL_URL = "chrome://devtools/content/responsive.html/index.xhtml";
 
 /**
  * ResponsiveUIManager is the external API for the browser UI, etc. to use when
  * opening and closing the responsive UI.
  *
  * While the HTML UI is in an experimental stage, the older ResponsiveUIManager
@@ -53,16 +53,19 @@ const ResponsiveUIManager = exports.Resp
    *        The main browser chrome window.
    * @param tab
    *        The browser tab.
    * @return Promise
    *         Resolved to the ResponsiveUI instance for this tab when opening is
    *         complete.
    */
   openIfNeeded: Task.async(function* (window, tab) {
+    if (!tab.linkedBrowser.isRemoteBrowser) {
+      return promise.reject(new Error("RDM only available for remote tabs."));
+    }
     if (!this.isActiveForTab(tab)) {
       if (!this.activeTabs.size) {
         on(events.activate, "data", onActivate);
         on(events.close, "data", onClose);
       }
       let ui = new ResponsiveUI(window, tab);
       this.activeTabs.set(tab, ui);
       yield setMenuCheckFor(tab, window);
@@ -80,26 +83,27 @@ const ResponsiveUIManager = exports.Resp
    * @param tab
    *        The browser tab.
    * @return Promise
    *         Resolved (with no value) when closing is complete.
    */
   closeIfNeeded: Task.async(function* (window, tab) {
     if (this.isActiveForTab(tab)) {
       let ui = this.activeTabs.get(tab);
+      let destroyed = yield ui.destroy();
+      if (!destroyed) {
+        // Already in the process of destroying, abort.
+        return;
+      }
       this.activeTabs.delete(tab);
-
       if (!this.activeTabs.size) {
         off(events.activate, "data", onActivate);
         off(events.close, "data", onClose);
       }
-
-      yield ui.destroy();
       this.emit("off", { tab });
-
       yield setMenuCheckFor(tab, window);
     }
   }),
 
   /**
    * Returns true if responsive UI is active for a given tab.
    *
    * @param tab
@@ -185,64 +189,98 @@ ResponsiveUI.prototype = {
   tab: null,
 
   /**
    * Promise resovled when the UI init has completed.
    */
   inited: null,
 
   /**
+   * Flag set when destruction has begun.
+   */
+  destroying: false,
+
+  /**
    * A window reference for the chrome:// document that displays the responsive
    * design tool.  It is safe to reference this window directly even with e10s,
    * as the tool UI is always loaded in the parent process.  The web content
    * contained *within* the tool UI on the other hand is loaded in the child
    * process.
    */
   toolWindow: null,
 
   /**
-   * For the moment, we open the tool by:
-   * 1. Recording the tab's URL
-   * 2. Navigating the tab to the tool
-   * 3. Passing along the URL to the tool to open in the viewport
+   * Open RDM while preserving the state of the page.  We use `swapFrameLoaders`
+   * to ensure all in-page state is preserved, just like when you move a tab to
+   * a new window.
    *
-   * This approach is simple, but it also discards the user's state on the page.
-   * It's just like opening a fresh tab and pasting the URL.
-   *
-   * In the future, we can do better by using swapFrameLoaders to preserve the
-   * state.  Platform discussions are in progress to make this happen.  See
-   * bug 1238160 about <iframe mozbrowser> for more details.
+   * For more details, see /devtools/docs/responsive-design-mode.md.
    */
   init: Task.async(function* () {
-    let tabBrowser = this.tab.linkedBrowser;
-    let contentURI = tabBrowser.documentURI.spec;
-    tabBrowser.loadURI(TOOL_URL);
-    yield tabLoaded(this.tab);
-    let toolWindow = this.toolWindow = tabBrowser.contentWindow;
-    toolWindow.addEventListener("message", this);
-    yield waitForMessage(toolWindow, "init");
-    toolWindow.addInitialViewport(contentURI);
-    yield waitForMessage(toolWindow, "browser-mounted");
+    let ui = this;
+
+    // Swap page content from the current tab into a viewport within RDM
+    this.swap = swapToInnerBrowser({
+      tab: this.tab,
+      containerURL: TOOL_URL,
+      getInnerBrowser: Task.async(function* (containerBrowser) {
+        let toolWindow = ui.toolWindow = containerBrowser.contentWindow;
+        toolWindow.addEventListener("message", ui);
+        yield message.request(toolWindow, "init");
+        toolWindow.addInitialViewport("about:blank");
+        yield message.wait(toolWindow, "browser-mounted");
+        let toolViewportContentBrowser =
+          toolWindow.document.querySelector("iframe.browser");
+        return toolViewportContentBrowser;
+      })
+    });
+    yield this.swap.start();
+
+    // Notify the inner browser to start the frame script
+    yield message.request(this.toolWindow, "start-frame-script");
+
+    // TODO: Session restore continues to store the tool UI as the page's URL.
+    // Most likely related to browser UI's inability to show correct location.
   }),
 
+  /**
+   * Close RDM and restore page content back into a regular tab.
+   *
+   * @return boolean
+   *         Whether this call is actually destroying.  False means destruction
+   *         was already in progress.
+   */
   destroy: Task.async(function* () {
-    let tabBrowser = this.tab.linkedBrowser;
-    let browserWindow = this.browserWindow;
+    if (this.destroying) {
+      return false;
+    }
+    this.destroying = true;
+
+    // Ensure init has finished before starting destroy
+    yield this.inited;
+
+    // Notify the inner browser to stop the frame script
+    yield message.request(this.toolWindow, "stop-frame-script");
+
+    // Destroy local state
+    let swap = this.swap;
     this.browserWindow = null;
     this.tab = null;
     this.inited = null;
     this.toolWindow = null;
-    let loaded = waitForDocLoadComplete(browserWindow.gBrowser);
-    tabBrowser.goBack();
-    yield loaded;
+    this.swap = null;
+
+    // Undo the swap and return the content back to a normal tab
+    swap.stop();
+
+    return true;
   }),
 
   handleEvent(event) {
-    let { tab, window } = this;
-    let toolWindow = tab.linkedBrowser.contentWindow;
+    let { tab, window, toolWindow } = this;
 
     if (event.origin !== "chrome://devtools") {
       return;
     }
 
     switch (event.data.type) {
       case "content-resize":
         let { width, height } = event.data;
@@ -253,96 +291,52 @@ ResponsiveUI.prototype = {
         break;
       case "exit":
         toolWindow.removeEventListener(event.type, this);
         ResponsiveUIManager.closeIfNeeded(window, tab);
         break;
     }
   },
 
+  /**
+   * Helper for tests. Assumes a single viewport for now.
+   */
   getViewportSize() {
     return this.toolWindow.getViewportSize();
   },
 
+  /**
+   * Helper for tests. Assumes a single viewport for now.
+   */
   setViewportSize: Task.async(function* (width, height) {
     yield this.inited;
     this.toolWindow.setViewportSize(width, height);
   }),
 
-  getViewportMessageManager() {
-    return this.toolWindow.getViewportMessageManager();
+  /**
+   * Helper for tests. Assumes a single viewport for now.
+   */
+  getViewportBrowser() {
+    return this.toolWindow.getViewportBrowser();
   },
 
 };
 
 EventEmitter.decorate(ResponsiveUI.prototype);
 
-function waitForMessage(win, type) {
-  let deferred = promise.defer();
-
-  let onMessage = event => {
-    if (event.data.type !== type) {
-      return;
-    }
-    win.removeEventListener("message", onMessage);
-    deferred.resolve();
-  };
-  win.addEventListener("message", onMessage);
-
-  return deferred.promise;
-}
-
-function tabLoaded(tab) {
-  let deferred = promise.defer();
-
-  function handle(event) {
-    if (event.originalTarget != tab.linkedBrowser.contentDocument ||
-        event.target.location.href == "about:blank") {
-      return;
-    }
-    tab.linkedBrowser.removeEventListener("load", handle, true);
-    deferred.resolve(event);
-  }
-
-  tab.linkedBrowser.addEventListener("load", handle, true);
-  return deferred.promise;
-}
-
-/**
- * Waits for the next load to complete in the current browser.
- */
-function waitForDocLoadComplete(gBrowser) {
-  let deferred = promise.defer();
-  let progressListener = {
-    onStateChange: function (webProgress, req, flags, status) {
-      let docStop = Ci.nsIWebProgressListener.STATE_IS_NETWORK |
-                    Ci.nsIWebProgressListener.STATE_STOP;
-
-      // When a load needs to be retargetted to a new process it is cancelled
-      // with NS_BINDING_ABORTED so ignore that case
-      if ((flags & docStop) == docStop && status != Cr.NS_BINDING_ABORTED) {
-        gBrowser.removeProgressListener(progressListener);
-        deferred.resolve();
-      }
-    },
-    QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
-                                           Ci.nsISupportsWeakReference])
-  };
-  gBrowser.addProgressListener(progressListener);
-  return deferred.promise;
-}
-
 const onActivate = (tab) => setMenuCheckFor(tab);
 
 const onClose = ({ window, tabs }) => {
   for (let tab of tabs) {
     ResponsiveUIManager.closeIfNeeded(window, tab);
   }
 };
 
 const setMenuCheckFor = Task.async(
   function* (tab, window = getOwnerWindow(tab)) {
     yield startup(window);
 
     let menu = window.document.getElementById("menu_responsiveUI");
-    menu.setAttribute("checked", ResponsiveUIManager.isActiveForTab(tab));
+    if (menu) {
+      menu.setAttribute("checked", ResponsiveUIManager.isActiveForTab(tab));
+    }
   }
 );
--- a/devtools/client/responsive.html/moz.build
+++ b/devtools/client/responsive.html/moz.build
@@ -2,16 +2,17 @@
 # vim: set filetype=python:
 # 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/.
 
 DIRS += [
     'actions',
     'audio',
+    'browser',
     'components',
     'images',
     'reducers',
     'utils',
 ]
 
 DevToolsModules(
     'app.js',
--- a/devtools/client/responsive.html/test/browser/browser.ini
+++ b/devtools/client/responsive.html/test/browser/browser.ini
@@ -1,22 +1,26 @@
 [DEFAULT]
 tags = devtools
 subsuite = devtools
-skip-if = (!e10s && debug) # Bug 1262416 - Intermittent crash at MessageLoop::DeletePendingTasks
+skip-if = !e10s # RDM only works for remote tabs
 support-files =
   devices.json
+  doc_page_state.html
   head.js
   !/devtools/client/commandline/test/helpers.js
   !/devtools/client/framework/test/shared-head.js
   !/devtools/client/framework/test/shared-redux-head.js
 
 [browser_device_modal_exit.js]
 [browser_device_modal_submit.js]
 [browser_device_width.js]
 [browser_exit_button.js]
+[browser_frame_script_active.js]
 [browser_menu_item_01.js]
 [browser_menu_item_02.js]
 skip-if = (e10s && debug) # Bug 1267278: browser.xul leaks
 [browser_mouse_resize.js]
+[browser_page_state.js]
 [browser_resize_cmd.js]
+skip-if = true # GCLI target confused after swap, will fix in bug 1240907
 [browser_screenshot_button.js]
 [browser_viewport_basics.js]
--- a/devtools/client/responsive.html/test/browser/browser_device_width.js
+++ b/devtools/client/responsive.html/test/browser/browser_device_width.js
@@ -1,30 +1,30 @@
 /* Any copyright is dedicated to the Public Domain.
 http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
-const TEST_URL = "about:logo";
+const TEST_URL = "data:text/html;charset=utf-8,";
 
 addRDMTask(TEST_URL, function* ({ ui, manager }) {
   ok(ui, "An instance of the RDM should be attached to the tab.");
   yield setViewportSize(ui, manager, 110, 500);
 
   info("Checking initial width/height properties.");
   yield doInitialChecks(ui);
 
   info("Changing the RDM size");
   yield setViewportSize(ui, manager, 90, 500);
 
   info("Checking for screen props");
   yield checkScreenProps(ui);
 
   info("Setting docShell.deviceSizeIsPageSize to false");
-  yield ContentTask.spawn(ui.getViewportMessageManager(), {}, function* () {
+  yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
     let docShell = content.QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIWebNavigation)
                           .QueryInterface(Ci.nsIDocShell);
     docShell.deviceSizeIsPageSize = false;
   });
 
   info("Checking for screen props once again.");
   yield checkScreenProps2(ui);
@@ -48,17 +48,17 @@ function* checkScreenProps(ui) {
 function* checkScreenProps2(ui) {
   let { matchesMedia, screen } = yield grabContentInfo(ui);
   ok(!matchesMedia, "media query should be re-evaluated.");
   is(window.screen.width, screen.width,
      "screen.width should be the size of the screen.");
 }
 
 function grabContentInfo(ui) {
-  return ContentTask.spawn(ui.getViewportMessageManager(), {}, function* () {
+  return ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
     return {
       screen: {
         width: content.screen.width,
         height: content.screen.height
       },
       innerWidth: content.innerWidth,
       matchesMedia: content.matchMedia("(max-device-width:100px)").matches
     };
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_frame_script_active.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Verify frame script is active when expected.
+
+const e10s = require("devtools/client/responsive.html/utils/e10s");
+
+const TEST_URL = "http://example.com/";
+add_task(function* () {
+  let tab = yield addTab(TEST_URL);
+
+  let { ui } = yield openRDM(tab);
+
+  let mm = ui.getViewportBrowser().messageManager;
+  let { active } = yield e10s.request(mm, "IsActive");
+  is(active, true, "Frame script is active");
+
+  yield closeRDM(tab);
+
+  // Must re-get the messageManager on each run since it changes when RDM opens
+  // or closes due to the design of swapFrameLoaders.  Also, we only have access
+  // to a valid `ui` instance while RDM is open.
+  mm = tab.linkedBrowser.messageManager;
+  ({ active } = yield e10s.request(mm, "IsActive"));
+  is(active, false, "Frame script is active");
+
+  // Try another round as well to be sure there is no state saved anywhere
+  ({ ui } = yield openRDM(tab));
+
+  mm = ui.getViewportBrowser().messageManager;
+  ({ active } = yield e10s.request(mm, "IsActive"));
+  is(active, true, "Frame script is active");
+
+  yield closeRDM(tab);
+
+  // Must re-get the messageManager on each run since it changes when RDM opens
+  // or closes due to the design of swapFrameLoaders.  Also, we only have access
+  // to a valid `ui` instance while RDM is open.
+  mm = tab.linkedBrowser.messageManager;
+  ({ active } = yield e10s.request(mm, "IsActive"));
+  is(active, false, "Frame script is active");
+
+  yield removeTab(tab);
+});
--- a/devtools/client/responsive.html/test/browser/browser_menu_item_02.js
+++ b/devtools/client/responsive.html/test/browser/browser_menu_item_02.js
@@ -15,20 +15,22 @@ const { partial } = require("sdk/lang/fu
 const openBrowserWindow = partial(open, null, { features: { toolbar: true } });
 
 const isMenuCheckedFor = ({document}) => {
   let menu = document.getElementById("menu_responsiveUI");
   return menu.getAttribute("checked") === "true";
 };
 
 add_task(function* () {
-  const window1 = yield openBrowserWindow(TEST_URL);
+  const window1 = yield openBrowserWindow();
 
   yield startup(window1);
 
+  yield BrowserTestUtils.openNewForegroundTab(window1.gBrowser, TEST_URL);
+
   const tab1 = getActiveTab(window1);
 
   is(window1, getMostRecentBrowserWindow(),
     "The new window is the active one");
 
   ok(!isMenuCheckedFor(window1),
     "RDM menu item is unchecked by default");
 
--- a/devtools/client/responsive.html/test/browser/browser_mouse_resize.js
+++ b/devtools/client/responsive.html/test/browser/browser_mouse_resize.js
@@ -1,14 +1,14 @@
 /* Any copyright is dedicated to the Public Domain.
 http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
-const TEST_URL = "about:logo";
+const TEST_URL = "data:text/html;charset=utf-8,";
 
 function getElRect(selector, win) {
   let el = win.document.querySelector(selector);
   return el.getBoundingClientRect();
 }
 
 /**
  * Drag an element identified by 'selector' by [x,y] amount. Returns
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_page_state.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test page state to ensure page is not reloaded and session history is not
+// modified.
+
+const DUMMY_1_URL = "http://example.com/";
+const TEST_URL = `${URL_ROOT}doc_page_state.html`;
+const DUMMY_2_URL = "http://example.com/browser/";
+
+add_task(function* () {
+  // Load up a sequence of pages:
+  // 0. DUMMY_1_URL
+  // 1. TEST_URL
+  // 2. DUMMY_2_URL
+  let tab = yield addTab(DUMMY_1_URL);
+  let browser = tab.linkedBrowser;
+
+  let loaded = BrowserTestUtils.browserLoaded(browser, false, TEST_URL);
+  browser.loadURI(TEST_URL, null, null);
+  yield loaded;
+
+  loaded = BrowserTestUtils.browserLoaded(browser, false, DUMMY_2_URL);
+  browser.loadURI(DUMMY_2_URL, null, null);
+  yield loaded;
+
+  // Check session history state
+  let history = yield getSessionHistory(browser);
+  is(history.index, 2, "At page 2 in history");
+  is(history.entries.length, 3, "3 pages in history");
+  is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+  is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+  is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+  // Go back one so we're at the test page
+  let shown = waitForPageShow(browser);
+  browser.goBack();
+  yield shown;
+
+  // Check session history state
+  history = yield getSessionHistory(browser);
+  is(history.index, 1, "At page 1 in history");
+  is(history.entries.length, 3, "3 pages in history");
+  is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+  is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+  is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+  // Click on content to set an altered state that would be lost on reload
+  yield BrowserTestUtils.synthesizeMouseAtCenter("body", {}, browser);
+
+  let { ui } = yield openRDM(tab);
+
+  // Check color inside the viewport
+  let color = yield spawnViewportTask(ui, {}, function* () {
+    // eslint-disable-next-line mozilla/no-cpows-in-tests
+    return content.getComputedStyle(content.document.body)
+                  .getPropertyValue("background-color");
+  });
+  is(color, "rgb(0, 128, 0)",
+     "Content is still modified from click in viewport");
+
+  yield closeRDM(tab);
+
+  // Check color back in the browser tab
+  color = yield ContentTask.spawn(browser, {}, function* () {
+    // eslint-disable-next-line mozilla/no-cpows-in-tests
+    return content.getComputedStyle(content.document.body)
+                  .getPropertyValue("background-color");
+  });
+  is(color, "rgb(0, 128, 0)",
+     "Content is still modified from click in browser tab");
+
+  // Check session history state
+  history = yield getSessionHistory(browser);
+  is(history.index, 1, "At page 1 in history");
+  is(history.entries.length, 3, "3 pages in history");
+  is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+  is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+  is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+  yield removeTab(tab);
+});
+
+function getSessionHistory(browser) {
+  return ContentTask.spawn(browser, {}, function* () {
+    /* eslint-disable no-undef */
+    let { interfaces: Ci } = Components;
+    let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
+    let sessionHistory = webNav.sessionHistory;
+    let result = {
+      index: sessionHistory.index,
+      entries: []
+    };
+
+    for (let i = 0; i < sessionHistory.count; i++) {
+      let entry = sessionHistory.getEntryAtIndex(i, false);
+      result.entries.push({
+        uri: entry.URI.spec,
+        title: entry.title
+      });
+    }
+
+    return result;
+    /* eslint-enable no-undef */
+  });
+}
+
+function waitForPageShow(browser) {
+  let mm = browser.messageManager;
+  return new Promise(resolve => {
+    let onShow = message => {
+      if (message.target != browser) {
+        return;
+      }
+      mm.removeMessageListener("PageVisibility:Show", onShow);
+      resolve();
+    };
+    mm.addMessageListener("PageVisibility:Show", onShow);
+  });
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/doc_page_state.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html>
+  <style>
+    body {
+      height: 100vh;
+      background: red;
+    }
+    body.modified {
+      background: green;
+    }
+  </style>
+  <body onclick="this.classList.add('modified')"/>
+</html>
--- a/devtools/client/responsive.html/test/browser/head.js
+++ b/devtools/client/responsive.html/test/browser/head.js
@@ -87,17 +87,17 @@ function addRDMTask(url, generator) {
     }
 
     yield closeRDM(tab);
     yield removeTab(tab);
   });
 }
 
 function spawnViewportTask(ui, args, task) {
-  return ContentTask.spawn(ui.getViewportMessageManager(), args, task);
+  return ContentTask.spawn(ui.getViewportBrowser(), args, task);
 }
 
 function waitForFrameLoad(ui, targetURL) {
   return spawnViewportTask(ui, { targetURL }, function* (args) {
     if ((content.document.readyState == "complete" ||
          content.document.readyState == "interactive") &&
         content.location.href == args.targetURL) {
       return;
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/utils/message.js
@@ -0,0 +1,38 @@
+/* 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 promise = require("promise");
+
+const REQUEST_DONE_SUFFIX = ":done";
+
+function wait(win, type) {
+  let deferred = promise.defer();
+
+  let onMessage = event => {
+    if (event.data.type !== type) {
+      return;
+    }
+    win.removeEventListener("message", onMessage);
+    deferred.resolve();
+  };
+  win.addEventListener("message", onMessage);
+
+  return deferred.promise;
+}
+
+function post(win, type) {
+  win.postMessage({ type }, "*");
+}
+
+function request(win, type) {
+  let done = wait(win, type + REQUEST_DONE_SUFFIX);
+  post(win, type);
+  return done;
+}
+
+exports.wait = wait;
+exports.post = post;
+exports.request = request;
--- a/devtools/client/responsive.html/utils/moz.build
+++ b/devtools/client/responsive.html/utils/moz.build
@@ -2,9 +2,10 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'e10s.js',
     'l10n.js',
+    'message.js',
 )
--- a/devtools/client/responsivedesign/responsivedesign-child.js
+++ b/devtools/client/responsivedesign/responsivedesign-child.js
@@ -20,24 +20,33 @@ var global = this;
   const gFloatingScrollbarsStylesheet = Services.io.newURI("chrome://devtools/skin/floating-scrollbars-responsive-design.css", null, null);
   var gRequiresFloatingScrollbars;
 
   var active = false;
   var resizeNotifications = false;
 
   addMessageListener("ResponsiveMode:Start", startResponsiveMode);
   addMessageListener("ResponsiveMode:Stop", stopResponsiveMode);
+  addMessageListener("ResponsiveMode:IsActive", isActive);
 
   function debug(msg) {
     // dump(`RDM CHILD: ${msg}\n`);
   }
 
+  /**
+   * Used by tests to verify the state of responsive mode.
+   */
+  function isActive() {
+    sendAsyncMessage("ResponsiveMode:IsActive:Done", { active });
+  }
+
   function startResponsiveMode({data:data}) {
     debug("START");
     if (active) {
+      debug("ALREADY STARTED, ABORT");
       return;
     }
     addMessageListener("ResponsiveMode:RequestScreenshot", screenshot);
     let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebProgress);
     webProgress.addProgressListener(WebProgressListener, Ci.nsIWebProgress.NOTIFY_ALL);
     docShell.deviceSizeIsPageSize = true;
     gRequiresFloatingScrollbars = data.requiresFloatingScrollbars;
     if (data.notifyOnResize) {
@@ -84,16 +93,17 @@ var global = this;
     resizeNotifications = false;
     content.removeEventListener("resize", onResize, false);
     removeEventListener("DOMWindowCreated", bindOnResize, false);
   }
 
   function stopResponsiveMode() {
     debug("STOP");
     if (!active) {
+      debug("ALREADY STOPPED, ABORT");
       return;
     }
     active = false;
     removeMessageListener("ResponsiveMode:RequestScreenshot", screenshot);
     let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebProgress);
     webProgress.removeProgressListener(WebProgressListener);
     docShell.deviceSizeIsPageSize = gDeviceSizeWasPageSize;
     restoreScrollbars();
--- a/devtools/docs/SUMMARY.md
+++ b/devtools/docs/SUMMARY.md
@@ -1,18 +1,19 @@
 
 # Summary
 
 * [Tool Architectures](tools.md)
   * [Inspector](inspector-panel.md)
   * [Memory](memory-panel.md)
   * [Debugger](debugger-panel.md)
+  * [Responsive Design Mode](responsive-design-mode.md)
 * [Frontend](frontend.md)
   * [Panel SVGs](svgs.md)
   * [React](react.md)
     * [Guidelines](react-guidelines.md)
     * [Tips](react-tips.md)
   * [Redux](redux.md)
     * [Guidelines](redux-guidelines.md)
     * [Tips](redux-tips.md)
 * [Backend](backend.md)
   * [Protocol](protocol.md)
-  * [Debugger API](debugger-api.md)
\ No newline at end of file
+  * [Debugger API](debugger-api.md)
new file mode 100644
--- /dev/null
+++ b/devtools/docs/responsive-design-mode.md
@@ -0,0 +1,63 @@
+# Responsive Design Mode Architecture
+
+## Context
+
+You have a single browser tab that has visited several pages, and now has a
+history that looks like, in oldest to newest order:
+
+1. https://newsblur.com
+2. https://mozilla.org (← current page)
+3. https://convolv.es
+
+## Opening RDM During Current Firefox Session
+
+When opening RDM, the browser tab's history must preserved.  Additionally, we
+strive to preserve the exact state of the currently displayed page (effectively
+any in-page state, which is important for single page apps where data can be
+lost if they are reloaded).
+
+This seems a bit convoluted, but one advantage of this technique is that it
+preserves tab state since the same tab is reused.  This helps to maintain any
+extra state that may be set on tab by add-ons or others.
+
+1. Create a temporary, hidden tab to load the tool UI.
+2. Mark the tool tab browser's docshell as active so the viewport frame is
+   created eagerly and will be ready to swap.
+3. Create the initial viewport inside the tool UI.
+4. Swap tab content from the regular browser tab to the browser within the
+   viewport in the tool UI, preserving all state via
+   `gBrowser._swapBrowserDocShells`.
+5. Force the original browser tab to be non-remote since the tool UI must be
+   loaded in the parent process, and we're about to swap the tool UI into
+   this tab.
+6. Swap the tool UI (with viewport showing the content) into the original
+   browser tab and close the temporary tab used to load the tool via
+   `swapBrowsersAndCloseOther`.
+
+## Closing RDM During Current Firefox Session
+
+To close RDM, we follow a similar process to the one from opening RDM so we can
+restore the content back to a normal tab.
+
+1. Create a temporary, hidden tab to hold the content.
+2. Mark the content tab browser's docshell as active so the frame is created
+   eagerly and will be ready to swap.
+3. Swap tab content from the browser within the viewport in the tool UI to the
+   regular browser tab, preserving all state via
+   `gBrowser._swapBrowserDocShells`.
+4. Force the original browser tab to be remote since web content is loaded in
+   the child process, and we're about to swap the content into this tab.
+5. Swap the content into the original browser tab and close the temporary tab
+   used to hold the content via `swapBrowsersAndCloseOther`.
+
+## Session Restore
+
+When restarting Firefox and restoring a user's browsing session, we must
+correctly restore the tab history.  If the RDM tool was opened when the session
+was captured, then it would be acceptable to either:
+
+* A: Restore the tab content without any RDM tool displayed **OR**
+* B: Restore the RDM tool the tab content inside, just as before the restart
+
+Option A (no RDM after session restore) seems more in line with how the rest of
+DevTools currently functions after restore.