Merge fx-team to m-c a=merge
authorWes Kocher <wkocher@mozilla.com>
Tue, 24 Feb 2015 15:41:09 -0800
changeset 230564 0a8b3b67715ac0f58470431ebf0052a265055e40
parent 230545 204c5ec572eb85bf355b043c17026589fef52e43 (current diff)
parent 230563 4aad4f99b867973adbcc78b346e8e13fcf081a9c (diff)
child 230574 5f26f19972f563e52750d6a0a120db8b35639c79
child 230583 b6554e486c6d443ba3f219cf6dfc068007d4e14a
child 230618 dbc0c699827cf97d867b6a80511887b2e1e95e1e
push id28329
push userkwierso@gmail.com
push dateTue, 24 Feb 2015 23:41:17 +0000
treeherdermozilla-central@0a8b3b67715a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone39.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
Merge fx-team to m-c a=merge
browser/base/content/content.js
toolkit/content/widgets/browser.xml
toolkit/content/widgets/remote-browser.xml
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -903,16 +903,21 @@ notification[value="translation"] {
 /* Note the chatbox 'width' values are duplicated in socialchat.xml */
 chatbox {
   -moz-binding: url("chrome://browser/content/socialchat.xml#chatbox");
   transition: height 150ms ease-out, width 150ms ease-out;
   height: 285px;
   width: 260px; /* CHAT_WIDTH_OPEN in socialchat.xml */
 }
 
+chatbox[large="true"] {
+  width: 300px;
+  heigth: 272px;
+}
+
 chatbox[minimized="true"] {
   width: 160px;
   height: 20px; /* CHAT_WIDTH_MINIMIZED in socialchat.xml */
 }
 
 chatbar {
   -moz-binding: url("chrome://browser/content/socialchat.xml#chatbar");
   height: 0;
--- a/browser/base/content/content.js
+++ b/browser/base/content/content.js
@@ -926,16 +926,23 @@ let DOMFullscreenHandler = {
   }
 };
 DOMFullscreenHandler.init();
 
 ContentWebRTC.init();
 addMessageListener("webrtc:Allow", ContentWebRTC);
 addMessageListener("webrtc:Deny", ContentWebRTC);
 addMessageListener("webrtc:StopSharing", ContentWebRTC);
+addMessageListener("webrtc:StartBrowserSharing", () => {
+  let windowID = content.QueryInterface(Ci.nsIInterfaceRequestor)
+                        .getInterface(Ci.nsIDOMWindowUtils).outerWindowID;
+  sendAsyncMessage("webrtc:response:StartBrowserSharing", {
+    windowID: windowID
+  });
+});
 
 function gKeywordURIFixup(fixupInfo) {
   fixupInfo.QueryInterface(Ci.nsIURIFixupInfo);
 
   // Ignore info from other docshells
   let parent = fixupInfo.consumer.QueryInterface(Ci.nsIDocShellTreeItem).sameTypeRootTreeItem;
   if (parent != docShell)
     return;
--- a/browser/base/content/socialchat.xml
+++ b/browser/base/content/socialchat.xml
@@ -458,17 +458,19 @@
 
       <method name="getTotalChildWidth">
         <parameter name="aChatbox"/>
         <body><![CDATA[
           // These are from the CSS for the chatbox and must be kept in sync.
           // We can't use calcTotalWidthOf due to the transitions...
           const CHAT_WIDTH_OPEN = 260;
           const CHAT_WIDTH_MINIMIZED = 160;
-          return aChatbox.minimized ? CHAT_WIDTH_MINIMIZED : CHAT_WIDTH_OPEN;
+          let openWidth = aChatbox.hasAttribute("large") ? 300 : CHAT_WIDTH_OPEN;
+
+          return aChatbox.minimized ? CHAT_WIDTH_MINIMIZED : openWidth;
         ]]></body>
       </method>
 
       <method name="collapseChat">
         <parameter name="aChatbox"/>
         <body><![CDATA[
           // we ensure that the cached width for a child of this type is
           // up-to-date so we can use it when resizing.
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -177,34 +177,32 @@ skip-if = buildapp == 'mulet' || e10s # 
 skip-if = e10s
 [browser_bug460146.js]
 skip-if = e10s # Bug 866413 - PageInfo doesn't work in e10s
 [browser_bug462289.js]
 skip-if = toolkit == "cocoa" || e10s # Bug 1102017 - middle-button mousedown on selected tab2 does not activate tab - Didn't expect [object XULElement], but got it
 [browser_bug462673.js]
 skip-if = e10s # Bug 1093404 - test expects sync window opening from content and is disappointed in that expectation
 [browser_bug477014.js]
-skip-if = e10s # Bug 1093206 - need to re-enable tests relying on swapFrameLoaders et al for e10s
 [browser_bug479408.js]
 skip-if = buildapp == 'mulet'
 [browser_bug481560.js]
 [browser_bug484315.js]
 skip-if = e10s
 [browser_bug491431.js]
 skip-if = buildapp == 'mulet'
 [browser_bug495058.js]
-skip-if = e10s # Bug 1093206 - need to re-enable tests relying on swapFrameLoaders et al (and thus replaceTabWithWindow) for e10s
 [browser_bug517902.js]
 skip-if = e10s # Bug 866413 - PageInfo doesn't work in e10s
 [browser_bug519216.js]
 [browser_bug520538.js]
 [browser_bug521216.js]
 [browser_bug533232.js]
 [browser_bug537013.js]
-skip-if = buildapp == 'mulet' || e10s # Bug 1093206 - need to re-enable tests relying on swapFrameLoaders et al for e10s (test calls replaceTabWithWindow)
+skip-if = buildapp == 'mulet' || e10s # Bug 1134458 - Find bar doesn't work correctly in a detached tab
 [browser_bug537474.js]
 skip-if = e10s # Bug 1102020 - test tries to use browserDOMWindow.openURI to open a link, and gets a null rv where it expects a window
 [browser_bug550565.js]
 [browser_bug553455.js]
 skip-if = buildapp == 'mulet' # Bug 1066070 - I don't think either popup notifications nor addon install stuff works on mulet?
 [browser_bug555224.js]
 skip-if = e10s # Bug 1056146 - zoom tests use FullZoomHelper and break in e10s
 [browser_bug555767.js]
@@ -403,25 +401,24 @@ support-files =
   searchSuggestionUI.js
 [browser_selectTabAtIndex.js]
 [browser_ssl_error_reports.js]
 [browser_star_hsts.js]
 [browser_subframe_favicons_not_used.js]
 [browser_tabDrop.js]
 skip-if = buildapp == 'mulet' || e10s
 [browser_tabMatchesInAwesomebar.js]
-skip-if = e10s # Bug 1093206 - need to re-enable tests relying on swapFrameLoaders et al for e10s (test calls gBrowser.swapBrowsersAndCloseOther)
 [browser_tabMatchesInAwesomebar_perwindowpb.js]
 skip-if = e10s || os == 'linux' # Bug 1093373, bug 1104755
 [browser_tab_drag_drop_perwindow.js]
 skip-if = buildapp == 'mulet'
 [browser_tab_dragdrop.js]
-skip-if = buildapp == 'mulet' || e10s # Bug 1093206 - need to re-enable tests relying on swapFrameLoaders et al for e10s (test calls gBrowser.swapBrowsersAndCloseOther)
+skip-if = buildapp == 'mulet'
 [browser_tab_dragdrop2.js]
-skip-if = buildapp == 'mulet' || e10s # Bug 1093206 - need to re-enable tests relying on swapFrameLoaders et al for e10s (test calls gBrowser.swapBrowsersAndCloseOther)
+skip-if = buildapp == 'mulet'
 [browser_tabbar_big_widgets.js]
 skip-if = os == "linux" || os == "mac" # No tabs in titlebar on linux
                                        # Disabled on OS X because of bug 967917
 [browser_tabfocus.js]
 skip-if = e10s # Bug 921935 - focusmanager issues with e10s (test calls getFocusedElementForWindow with a content window)
 [browser_tabkeynavigation.js]
 skip-if = e10s
 [browser_tabopen_reflows.js]
--- a/browser/base/content/test/general/browser_bug477014.js
+++ b/browser/base/content/test/general/browser_bug477014.js
@@ -1,55 +1,24 @@
 /* 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/. */
 
 // That's a gecko!
 const iconURLSpec = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==";
 var testPage="data:text/plain,test bug 477014";
 
-function test() {
-  waitForExplicitFinish();
-
-  var newWindow;
-  var tabToDetach;
-  var documentToDetach;
+add_task(function*() {
+  let tabToDetach = gBrowser.addTab(testPage);
+  yield waitForDocLoadComplete(tabToDetach.linkedBrowser);
 
-  function onPageShow(event) {
-    // we get here if the test is executed before the pageshow
-    // event for the window's first tab
-    if (!tabToDetach || documentToDetach != event.target)
-      return;
-
-    event.currentTarget.removeEventListener("pageshow", onPageShow, false);
-
-    if (!newWindow) {
-      // prepare the tab (set icon and busy state)
-      // we have to set these only after onState* notification, otherwise
-      // they're overriden
-      setTimeout(function() {
-        gBrowser.setIcon(tabToDetach, iconURLSpec);
-        tabToDetach.setAttribute("busy", "true");
+  gBrowser.setIcon(tabToDetach, iconURLSpec);
+  tabToDetach.setAttribute("busy", "true");
 
-        // detach and set the listener on the new window
-        newWindow = gBrowser.replaceTabWithWindow(tabToDetach);
-        // wait for gBrowser to come along
-        newWindow.addEventListener("load", function () {
-          newWindow.removeEventListener("load", arguments.callee, false);
-          newWindow.gBrowser.addEventListener("pageshow", onPageShow, false);
-        }, false);
-      }, 0);
-      return;
-    }
+  // detach and set the listener on the new window
+  let newWindow = gBrowser.replaceTabWithWindow(tabToDetach);
+  yield promiseWaitForEvent(tabToDetach.linkedBrowser, "SwapDocShells");
 
-    is(newWindow.gBrowser.selectedTab.hasAttribute("busy"), true);
-    is(newWindow.gBrowser.getIcon(), iconURLSpec);
-    newWindow.close();
-    finish();
-  }
+  is(newWindow.gBrowser.selectedTab.hasAttribute("busy"), true, "Busy attribute should be correct");
+  is(newWindow.gBrowser.getIcon(), iconURLSpec, "Icon should be correct");
 
-  tabToDetach = gBrowser.addTab(testPage);
-  tabToDetach.linkedBrowser.addEventListener("load", function onLoad() {
-    tabToDetach.linkedBrowser.removeEventListener("load", onLoad, true);
-    documentToDetach = tabToDetach.linkedBrowser.contentDocument;
-    gBrowser.addEventListener("pageshow", onPageShow, false);
-  }, true);
-}
+  newWindow.close();
+});
--- a/browser/base/content/test/general/browser_tabMatchesInAwesomebar.js
+++ b/browser/base/content/test/general/browser_tabMatchesInAwesomebar.js
@@ -60,16 +60,17 @@ var gTestSteps = [
     ensure_opentabs_match_db(nextStep);
   },
   function() {
     info("Running step 6 - check swapBrowsersAndCloseOther preserves registered switch-to-tab result");
     let tabToKeep = gBrowser.addTab();
     let tab = gBrowser.addTab();
     tab.linkedBrowser.addEventListener("load", function () {
       tab.linkedBrowser.removeEventListener("load", arguments.callee, true);
+      gBrowser.updateBrowserRemoteness(tabToKeep.linkedBrowser, tab.linkedBrowser.isRemoteBrowser);
       gBrowser.swapBrowsersAndCloseOther(tabToKeep, tab);
       ensure_opentabs_match_db(function () {
         gBrowser.removeTab(tabToKeep);
         ensure_opentabs_match_db(nextStep);
       });
     }, true);
     tab.linkedBrowser.loadURI("about:mozilla");
   },
--- a/browser/base/content/test/general/browser_tab_dragdrop.js
+++ b/browser/base/content/test/general/browser_tab_dragdrop.js
@@ -10,109 +10,121 @@ function test()
     gBrowser.tabs[0],
     gBrowser.addTab("about:blank", {skipAnimation: true}),
     gBrowser.addTab("about:blank", {skipAnimation: true}),
     gBrowser.addTab("about:blank", {skipAnimation: true}),
     gBrowser.addTab("about:blank", {skipAnimation: true})
   ];
 
   function setLocation(i, url) {
-    gBrowser.getBrowserForTab(tabs[i]).contentWindow.location = url;
+    tabs[i].linkedBrowser.contentWindow.location = url;
   }
   function moveTabTo(a, b) {
     gBrowser.swapBrowsersAndCloseOther(gBrowser.tabs[b], gBrowser.tabs[a]);
   }
-  function clickTest(doc, win) {
+  function clickTest(tab, doc, win) {
     var clicks = doc.defaultView.clicks;
-    EventUtils.synthesizeMouseAtCenter(doc.body, {}, win);
+
+    yield ContentTask.spawn(tab.linkedBrowser, {}, function() {
+      let target = content.document.body;
+      let rect = target.getBoundingClientRect();
+      let left = (rect.left + rect.right) / 2;
+      let top = (rect.top + rect.bottom) / 2;
+
+      let utils = content.QueryInterface(Components.interfaces.nsIInterfaceRequestor)
+                         .getInterface(Components.interfaces.nsIDOMWindowUtils);
+      utils.sendMouseEvent("mousedown", left, top, 0, 1, 0, false, 0, 0);
+      utils.sendMouseEvent("mouseup", left, top, 0, 1, 0, false, 0, 0);
+    });
+
     is(doc.defaultView.clicks, clicks+1, "adding 1 more click on BODY");
   }
   function test1() {
     moveTabTo(2, 3); // now: 0 1 2 4
     is(gBrowser.tabs[1], tabs[1], "tab1");
     is(gBrowser.tabs[2], tabs[3], "tab3");
 
-    var plugin = gBrowser.getBrowserForTab(tabs[4]).docShell.contentViewer.DOMDocument.wrappedJSObject.body.firstChild;
+    var plugin = tabs[4].linkedBrowser.contentDocument.wrappedJSObject.body.firstChild;
     var tab4_plugin_object = plugin.getObjectValue();
 
     gBrowser.selectedTab = gBrowser.tabs[2];
     moveTabTo(3, 2); // now: 0 1 4
     gBrowser.selectedTab = tabs[4];
-    var doc = gBrowser.getBrowserForTab(gBrowser.tabs[2]).docShell.contentViewer.DOMDocument.wrappedJSObject;
+    var doc = gBrowser.tabs[2].linkedBrowser.contentDocument.wrappedJSObject;
     plugin = doc.body.firstChild;
     ok(plugin && plugin.checkObjectValue(tab4_plugin_object), "same plugin instance");
     is(gBrowser.tabs[1], tabs[1], "tab1");
     is(gBrowser.tabs[2], tabs[3], "tab4");
     is(doc.defaultView.clicks, 0, "no click on BODY so far");
-    clickTest(doc, window);
+    clickTest(gBrowser.tabs[2], doc, window);
 
     moveTabTo(2, 1); // now: 0 4
     is(gBrowser.tabs[1], tabs[1], "tab1");
-    doc = gBrowser.getBrowserForTab(gBrowser.tabs[1]).docShell.contentViewer.DOMDocument.wrappedJSObject;
+    doc = gBrowser.tabs[1].linkedBrowser.contentDocument.wrappedJSObject;
     plugin = doc.body.firstChild;
     ok(plugin && plugin.checkObjectValue(tab4_plugin_object), "same plugin instance");
-    clickTest(doc, window);
+    clickTest(gBrowser.tabs[1], doc, window);
 
     // Load a new document (about:blank) in tab4, then detach that tab into a new window.
     // In the new window, navigate back to the original document and click on its <body>,
     // verify that its onclick was called.
     var t = tabs[1];
-    var b = gBrowser.getBrowserForTab(t);
+    var b = t.linkedBrowser;
     gBrowser.selectedTab = t;
     b.addEventListener("load", function() {
       b.removeEventListener("load", arguments.callee, true);
 
       executeSoon(function () {
         var win = gBrowser.replaceTabWithWindow(t);
         whenDelayedStartupFinished(win, function () {
           // Verify that the original window now only has the initial tab left in it.
           is(gBrowser.tabs[0], tabs[0], "tab0");
-          is(gBrowser.getBrowserForTab(gBrowser.tabs[0]).contentWindow.location, "about:blank", "tab0 uri");
+          is(gBrowser.tabs[0].linkedBrowser.contentWindow.location, "about:blank", "tab0 uri");
 
           executeSoon(function () {
             win.gBrowser.addEventListener("pageshow", function () {
               win.gBrowser.removeEventListener("pageshow", arguments.callee, false);
               executeSoon(function () {
                 t = win.gBrowser.tabs[0];
-                b = win.gBrowser.getBrowserForTab(t);
-                var doc = b.docShell.contentViewer.DOMDocument.wrappedJSObject;
-                clickTest(doc, win);
+                b = t.linkedBrowser;
+                var doc = b.contentDocument.wrappedJSObject;
+                clickTest(t, doc, win);
                 win.close();
                 finish();
               });
             }, false);
             win.gBrowser.goBack();
           });
         });
       });
     }, true);
     b.loadURI("about:blank");
 
   }
 
   var loads = 0;
   function waitForLoad(event, tab, listenerContainer) {
-    var b = gBrowser.getBrowserForTab(gBrowser.tabs[tab]);
+    var b = tabs[tab].linkedBrowser;
     if (b.contentDocument != event.target) {
       return;
     }
-    gBrowser.getBrowserForTab(gBrowser.tabs[tab]).removeEventListener("load", listenerContainer.listener, true);
+    gBrowser.tabs[tab].linkedBrowser.removeEventListener("load", listenerContainer.listener, true);
     ++loads;
     if (loads == tabs.length - 1) {
       executeSoon(test1);
     }
   }
 
   function fn(f, arg) {
     var listenerContainer = { listener: null }
     listenerContainer.listener = function (event) { return f(event, arg, listenerContainer); };
     return listenerContainer.listener;
   }
   for (var i = 1; i < tabs.length; ++i) {
-    gBrowser.getBrowserForTab(tabs[i]).addEventListener("load", fn(waitForLoad,i), true);
+    tabs[i].linkedBrowser.addEventListener("load", fn(waitForLoad,i), true);
   }
 
   setLocation(1, "data:text/html;charset=utf-8,<title>tab1</title><body>tab1<iframe>");
   setLocation(2, "data:text/plain;charset=utf-8,tab2");
   setLocation(3, "data:text/html;charset=utf-8,<title>tab3</title><body>tab3<iframe>");
   setLocation(4, "data:text/html;charset=utf-8,<body onload='clicks=0' onclick='++clicks'>"+embed);
   gBrowser.selectedTab = tabs[3];
 
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -1044,48 +1044,59 @@
         <xul:hbox anonid="search-panel-searchforwith"
                   class="search-panel-current-input">
           <xul:label anonid="searchbar-oneoffheader-before" value="&searchFor.label;"/>
           <xul:label anonid="searchbar-oneoffheader-searchtext" flex="1" crop="end" class="search-panel-input-value"/>
           <xul:label anonid="searchbar-oneoffheader-after" flex="10000" value="&searchWith.label;"/>
         </xul:hbox>
       </xul:deck>
       <xul:description anonid="search-panel-one-offs"
+                       role="group"
                        class="search-panel-one-offs"/>
       <xul:vbox anonid="add-engines"/>
       <xul:button anonid="search-settings"
                   oncommand="BrowserUITelemetry.countSearchSettingsEvent('searchbar');openPreferences('paneSearch')"
                   class="search-setting-button search-panel-header"
                   label="&changeSearchSettings.button;"/>
     </content>
     <implementation>
       <!-- Popup rollup is triggered by native events before the mousedown event
            reaches the DOM. The will be set to true by the popuphiding event and
            false after the mousedown event has been triggered to detect what
            caused rollup. -->
       <field name="_isHiding">false</field>
+      <field name="_bundle">null</field>
+      <property name="bundle" readonly="true">
+        <getter>
+          <![CDATA[
+            if (!this._bundle) {
+              const kBundleURI = "chrome://browser/locale/search.properties";
+              this._bundle = Services.strings.createBundle(kBundleURI);
+            }
+            return this._bundle;
+          ]]>
+        </getter>
+      </property>
 
       <method name="updateHeader">
         <body><![CDATA[
           let currentEngine = Services.search.currentEngine;
           let uri = currentEngine.iconURI;
           if (uri) {
             uri = uri.spec;
             this.setAttribute("src", PlacesUtils.getImageURLForResolution(window, uri));
           }
           else {
             // If the default has just been changed to a provider without icon,
             // avoid showing the icon of the previous default provider.
             this.removeAttribute("src");
           }
 
-          const kBundleURI = "chrome://browser/locale/search.properties";
-          let bundle = Services.strings.createBundle(kBundleURI);
-          let headerText = bundle.formatStringFromName("searchHeader",
-                                                       [currentEngine.name], 1);
+          let headerText = this.bundle.formatStringFromName("searchHeader",
+                                                            [currentEngine.name], 1);
           document.getAnonymousElementByAttribute(this, "anonid", "searchbar-engine-name")
                   .setAttribute("value", headerText);
           document.getAnonymousElementByAttribute(this, "anonid", "searchbar-engine")
                   .engine = currentEngine;
         ]]></body>
       </method>
     </implementation>
     <handlers>
@@ -1120,27 +1131,38 @@
 
         // Update the 'Search for <keywords> with:" header.
         let headerSearchText =
           document.getAnonymousElementByAttribute(this, "anonid",
                                                   "searchbar-oneoffheader-searchtext");
         let headerPanel =
           document.getAnonymousElementByAttribute(this, "anonid",
                                                   "search-panel-one-offs-header");
+        let list = document.getAnonymousElementByAttribute(this, "anonid",
+                                                           "search-panel-one-offs");
         let textbox = searchbar.textbox;
         let self = this;
         let inputHandler = function() {
           headerSearchText.setAttribute("value", textbox.value);
+          let groupText;
           if (textbox.value) {
             self.removeAttribute("showonlysettings");
+            groupText = headerSearchText.previousSibling.value +
+                        '"' + headerSearchText.value + '"' +
+                        headerSearchText.nextSibling.value;
             headerPanel.selectedIndex = 1;
           }
           else {
+            let noSearchHeader =
+              document.getAnonymousElementByAttribute(self, "anonid",
+                                                      "searchbar-oneoffheader-search");
+            groupText = noSearchHeader.value;
             headerPanel.selectedIndex = 0;
           }
+          list.setAttribute("aria-label", groupText);
         };
         textbox.addEventListener("input", inputHandler);
         this.addEventListener("popuphiding", function hiding() {
           textbox.removeEventListener("input", inputHandler);
           this.removeEventListener("popuphiding", hiding);
         });
         inputHandler();
 
@@ -1152,22 +1174,21 @@
         while (addEngineList.firstChild)
           addEngineList.firstChild.remove();
 
         const kXULNS =
           "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 
         let addEngines = gBrowser.selectedBrowser.engines;
         if (addEngines && addEngines.length > 0) {
-          const kBundleURI = "chrome://browser/locale/search.properties";
-          let bundle = Services.strings.createBundle(kBundleURI);
           for (let engine of addEngines) {
             let button = document.createElementNS(kXULNS, "button");
-            let label = bundle.formatStringFromName("cmd_addFoundEngine",
-                                                    [engine.title], 1);
+            let label = this.bundle.formatStringFromName("cmd_addFoundEngine",
+                                                         [engine.title], 1);
+            button.id = "searchbar-add-engine-" + engine.title.replace(/ /g, '-');
             button.setAttribute("class", "addengine-item");
             button.setAttribute("label", label);
             button.setAttribute("pack", "start");
 
             button.setAttribute("crop", "end");
             button.setAttribute("tooltiptext", engine.uri);
             button.setAttribute("uri", engine.uri);
             if (engine.icon) {
@@ -1175,18 +1196,16 @@
               button.setAttribute("image", uri);
             }
             button.setAttribute("title", engine.title);
             addEngineList.appendChild(button);
           }
         }
 
         // Finally, build the list of one-off buttons.
-        let list = document.getAnonymousElementByAttribute(this, "anonid",
-                                                           "search-panel-one-offs")
         while (list.firstChild)
           list.firstChild.remove();
 
         let hiddenList;
         try {
           let pref =
             Services.prefs.getCharPref("browser.search.hiddenOneOffs");
           hiddenList = pref ? pref.split(",") : [];
@@ -1235,21 +1254,25 @@
 
         // If the <description> tag with the list of search engines doesn't have
         // a fixed height, the panel will be sized incorrectly, causing the bottom
         // of the suggestion <tree> to be hidden.
         let rowCount = Math.ceil(engines.length / enginesPerRow);
         let height = rowCount * 33; // 32px per row, 1px border.
         list.setAttribute("height", height + "px");
 
+        // Ensure we can refer to the settings button by ID:
+        let settingsEl = document.getAnonymousElementByAttribute(this, "anonid", "search-settings");
+        settingsEl.id = this.id + "-anon-search-settings";
+
         let dummyItems = enginesPerRow - (engines.length % enginesPerRow || enginesPerRow);
         for (let i = 0; i < engines.length; ++i) {
           let engine = engines[i];
           let button = document.createElementNS(kXULNS, "button");
-          button.setAttribute("label", engine.name);
+          button.id = "searchbar-engine-one-off-item-" + engine.name.replace(/ /g, '-');
           let uri = "chrome://browser/skin/search-engine-placeholder.png";
           if (engine.iconURI) {
             uri = PlacesUtils.getImageURLForResolution(window, engine.iconURI.spec);
           }
           button.setAttribute("image", uri);
           button.setAttribute("class", "searchbar-engine-one-off-item");
           button.setAttribute("tooltiptext", engine.name);
           button.setAttribute("width", buttonWidth);
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -252,16 +252,40 @@ function injectLoopAPI(targetWindow) {
      */
     locale: {
       enumerable: true,
       get: function() {
         return MozLoopService.locale;
       }
     },
 
+    getActiveTabWindowId: {
+      enumerable: true,
+      writable: true,
+      value: function(callback) {
+        let win = Services.wm.getMostRecentWindow("navigator:browser");
+        let browser = win && win.gBrowser.selectedTab.linkedBrowser;
+        if (!win || !browser) {
+          // This may happen when an undocked conversation window is the only
+          // window left.
+          let err = new Error("No tabs available to share.");
+          MozLoopService.log.error(err);
+          callback(cloneValueInto(err, targetWindow));
+          return;
+        }
+
+        let mm = browser.messageManager;
+        mm.addMessageListener("webrtc:response:StartBrowserSharing", function listener(message) {
+          mm.removeMessageListener("webrtc:response:StartBrowserSharing", listener);
+          callback(null, message.data.windowID);
+        });
+        mm.sendAsyncMessage("webrtc:StartBrowserSharing");
+      }
+    },
+
     /**
      * Returns the window data for a specific conversation window id.
      *
      * This data will be relevant to the type of window, e.g. rooms or calls.
      * See LoopRooms or LoopCalls for more information.
      *
      * @param {String} conversationWindowId
      * @returns {Object} The window data or null if error.
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -843,16 +843,17 @@ let MozLoopServiceInternal = {
       // Sadly we can't use chatbox.promiseChatLoaded() as promise chaining
       // involves event loop spins, which means it might be too late.
       // Have we already done it?
       if (chatbox.contentWindow.navigator.mozLoop) {
         return;
       }
 
       chatbox.setAttribute("dark", true);
+      chatbox.setAttribute("large", true);
 
       chatbox.addEventListener("DOMContentLoaded", function loaded(event) {
         if (event.target != chatbox.contentDocument) {
           return;
         }
         chatbox.removeEventListener("DOMContentLoaded", loaded, true);
 
         let window = chatbox.contentWindow;
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -123,17 +123,19 @@ loop.store.ActiveRoomStore = (function()
         "setMute",
         "screenSharingState",
         "receivingScreenShare",
         "remotePeerDisconnected",
         "remotePeerConnected",
         "windowUnload",
         "leaveRoom",
         "feedbackComplete",
-        "videoDimensionsChanged"
+        "videoDimensionsChanged",
+        "startScreenShare",
+        "endScreenShare"
       ]);
     },
 
     /**
      * Execute setupWindowData event action from the dispatcher. This gets
      * the room data from the mozLoop api, and dispatches an UpdateRoomInfo event.
      * It also dispatches JoinRoom as this action is only applicable to the desktop
      * client, and needs to auto-join.
@@ -389,16 +391,59 @@ loop.store.ActiveRoomStore = (function()
     /**
      * Used to note the current state of receiving screenshare data.
      */
     receivingScreenShare: function(actionData) {
       this.setStoreState({receivingScreenShare: actionData.receiving});
     },
 
     /**
+     * Initiates a screen sharing publisher.
+     *
+     * @param {sharedActions.StartScreenShare} actionData
+     */
+    startScreenShare: function(actionData) {
+      this.dispatchAction(new sharedActions.ScreenSharingState({
+        state: SCREEN_SHARE_STATES.PENDING
+      }));
+
+      var options = {
+        videoSource: actionData.type
+      };
+      if (options.videoSource === "browser") {
+        this._mozLoop.getActiveTabWindowId(function(err, windowId) {
+          if (err || !windowId) {
+            this.dispatchAction(new sharedActions.ScreenSharingState({
+              state: SCREEN_SHARE_STATES.INACTIVE
+            }));
+            return;
+          }
+          options.constraints = {
+            browserWindow: windowId,
+            scrollWithPage: true
+          };
+          this._sdkDriver.startScreenShare(options);
+        }.bind(this));
+      } else {
+        this._sdkDriver.startScreenShare(options);
+      }
+    },
+
+    /**
+     * Ends an active screenshare session.
+     */
+    endScreenShare: function() {
+      if (this._sdkDriver.endScreenShare()) {
+        this.dispatchAction(new sharedActions.ScreenSharingState({
+          state: SCREEN_SHARE_STATES.INACTIVE
+        }));
+      }
+    },
+
+    /**
      * Handles recording when a remote peer has connected to the servers.
      */
     remotePeerConnected: function() {
       this.setStoreState({
         roomState: ROOM_STATES.HAS_PARTICIPANTS,
         used: true
       });
 
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -26,19 +26,17 @@ loop.OTSdkDriver = (function() {
 
       this.dispatcher = options.dispatcher;
       this.sdk = options.sdk;
 
       this.connections = {};
 
       this.dispatcher.register(this, [
         "setupStreamElements",
-        "setMute",
-        "startScreenShare",
-        "endScreenShare"
+        "setMute"
       ]);
   };
 
   OTSdkDriver.prototype = {
     /**
      * Clones the publisher config into a new object, as the sdk modifies the
      * properties object.
      */
@@ -83,46 +81,54 @@ loop.OTSdkDriver = (function() {
         this.publisher.publishAudio(actionData.enabled);
       } else {
         this.publisher.publishVideo(actionData.enabled);
       }
     },
 
     /**
      * Initiates a screen sharing publisher.
+     *
+     * options items:
+     *  - {String}  videoSource    The type of screen to share. Values of 'screen',
+     *                             'window', 'application' and 'browser' are
+     *                             currently supported.
+     *  - {mixed}   browserWindow  The unique identifier of a browser window. May
+     *                             be passed when `videoSource` is 'browser'.
+     *  - {Boolean} scrollWithPage Flag to signal that scrolling a page should
+     *                             update the stream. May be passed when
+     *                             `videoSource` is 'browser'.
+     *
+     * @param {Object} options Hash containing options for the SDK
      */
-    startScreenShare: function(actionData) {
-      this.dispatcher.dispatch(new sharedActions.ScreenSharingState({
-        state: SCREEN_SHARE_STATES.PENDING
-      }));
-
-      var config = this._getCopyPublisherConfig();
-      config.videoSource = actionData.type;
+    startScreenShare: function(options) {
+      var config = _.extend(this._getCopyPublisherConfig(), options);
 
       this.screenshare = this.sdk.initPublisher(this.getScreenShareElementFunc(),
         config);
       this.screenshare.on("accessAllowed", this._onScreenShareGranted.bind(this));
       this.screenshare.on("accessDenied", this._onScreenShareDenied.bind(this));
     },
 
     /**
-     * Ends an active screenshare session.
+     * Ends an active screenshare session. Return `true` when an active screen-
+     * sharing session was ended or `false` when no session is active.
+     *
+     * @type {Boolean}
      */
     endScreenShare: function() {
       if (!this.screenshare) {
-        return;
+        return false;
       }
 
       this.session.unpublish(this.screenshare);
       this.screenshare.off("accessAllowed accessDenied");
       this.screenshare.destroy();
       delete this.screenshare;
-      this.dispatcher.dispatch(new sharedActions.ScreenSharingState({
-        state: SCREEN_SHARE_STATES.INACTIVE
-      }));
+      return true;
     },
 
     /**
      * Connects a session for the SDK, listening to the required events.
      *
      * sessionData items:
      * - sessionId: The OT session ID
      * - apiKey: The OT API key
--- a/browser/components/loop/test/desktop-local/contacts_test.js
+++ b/browser/components/loop/test/desktop-local/contacts_test.js
@@ -13,17 +13,17 @@ describe("loop.contacts", function() {
 
   var fakeAddContactButtonText = "Fake Add Contact";
   var fakeEditContactButtonText = "Fake Edit Contact";
   var fakeDoneButtonText = "Fake Done";
   var sandbox;
   var fakeWindow;
   var notifications;
 
-  beforeEach(function(done) {
+  beforeEach(function() {
     sandbox = sinon.sandbox.create();
     navigator.mozLoop = {
       getStrings: function(entityName) {
         var textContentValue = "fakeText";
         if (entityName == "add_contact_button") {
           textContentValue = fakeAddContactButtonText;
         } else if (entityName == "edit_contact_title") {
           textContentValue = fakeEditContactButtonText;
@@ -35,18 +35,16 @@ describe("loop.contacts", function() {
     };
 
     fakeWindow = {
       close: sandbox.stub(),
     };
     loop.shared.mixins.setRootObject(fakeWindow);
 
     document.mozL10n.initialize(navigator.mozLoop);
-    // XXX prevent a race whenever mozL10n hasn't been initialized yet
-    setTimeout(done, 0);
   });
 
   afterEach(function() {
     loop.shared.mixins.setRootObject(window);
     sandbox.restore();
   });
 
 
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -12,17 +12,17 @@ var sharedUtils = loop.shared.utils;
 
 describe("loop.panel", function() {
   "use strict";
 
   var sandbox, notifications;
   var fakeXHR, fakeWindow, fakeMozLoop;
   var requests = [];
 
-  beforeEach(function(done) {
+  beforeEach(function() {
     sandbox = sinon.sandbox.create();
     fakeXHR = sandbox.useFakeXMLHttpRequest();
     requests = [];
     // https://github.com/cjohansen/Sinon.JS/issues/393
     fakeXHR.xhr.onCreate = function (xhr) {
       requests.push(xhr);
     };
 
@@ -60,18 +60,16 @@ describe("loop.panel", function() {
         },
         on: sandbox.stub()
       },
       confirm: sandbox.stub(),
       notifyUITour: sandbox.stub()
     };
 
     document.mozL10n.initialize(navigator.mozLoop);
-    // XXX prevent a race whenever mozL10n hasn't been initialized yet
-    setTimeout(done, 0);
   });
 
   afterEach(function() {
     delete navigator.mozLoop;
     loop.shared.mixins.setRootObject(window);
     sandbox.restore();
   });
 
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -16,10 +16,11 @@ skip-if = e10s
 [browser_loop_fxa_server.js]
 [browser_LoopContacts.js]
 [browser_mozLoop_appVersionInfo.js]
 [browser_mozLoop_prefs.js]
 [browser_mozLoop_doNotDisturb.js]
 skip-if = buildapp == 'mulet'
 [browser_toolbarbutton.js]
 [browser_mozLoop_pluralStrings.js]
+[browser_mozLoop_tabSharing.js]
 [browser_mozLoop_telemetry.js]
 skip-if = e10s
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/browser_mozLoop_tabSharing.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This is an integration test to make sure that passing window IDs is working as
+ * expected, with or without e10s enabled - rather than just testing MozLoopAPI
+ * alone.
+ */
+
+const {injectLoopAPI} = Cu.import("resource:///modules/loop/MozLoopAPI.jsm");
+gMozLoopAPI = injectLoopAPI({});
+
+let promiseTabWindowId = function() {
+  return new Promise(resolve => {
+    gMozLoopAPI.getActiveTabWindowId((err, windowId) => {
+      Assert.equal(null, err, "No error should've occurred.");
+      Assert.equal(typeof windowId, "number", "We should have a window ID");
+      resolve(windowId);
+    });
+  });
+};
+
+add_task(function* test_windowIdFetch_simple() {
+  Assert.ok(gMozLoopAPI, "mozLoop should exist");
+
+  yield promiseTabWindowId();
+});
+
+add_task(function* test_windowIdFetch_multipleTabs() {
+  let previousTab = gBrowser.selectedTab;
+  let previousTabId = yield promiseTabWindowId();
+
+  let tab = gBrowser.selectedTab = gBrowser.addTab();
+  yield promiseTabLoadEvent(tab, "about:mozilla");
+  let tabId = yield promiseTabWindowId();
+  Assert.ok(tabId !== previousTabId, "Tab contentWindow IDs shouldn't be the same");
+  gBrowser.removeTab(tab);
+
+  tabId = yield promiseTabWindowId();
+  Assert.equal(previousTabId, tabId, "Window IDs should be back to what they were");
+});
--- a/browser/components/loop/test/mochitest/head.js
+++ b/browser/components/loop/test/mochitest/head.js
@@ -171,16 +171,60 @@ function promiseOAuthGetRegistration(bas
     xhr.open("GET", baseURL + "/get_registration", true);
     xhr.responseType = "json";
     xhr.addEventListener("load", () => resolve(xhr));
     xhr.addEventListener("error", reject);
     xhr.send();
   });
 }
 
+/**
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ *        The tab to load into.
+ * @param [optional] url
+ *        The url to load, or the current url.
+ * @param [optional] event
+ *        The load event type to wait for.  Defaults to "load".
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url, eventType="load") {
+  return new Promise((resolve, reject) => {
+    info("Wait tab event: " + eventType);
+
+    function handle(event) {
+      if (event.originalTarget != tab.linkedBrowser.contentDocument ||
+          event.target.location.href == "about:blank" ||
+          (url && event.target.location.href != url)) {
+        info("Skipping spurious '" + eventType + "'' event" +
+             " for " + event.target.location.href);
+        return;
+      }
+      clearTimeout(timeout);
+      tab.linkedBrowser.removeEventListener(eventType, handle, true);
+      info("Tab event received: " + eventType);
+      resolve(event);
+    }
+
+    let timeout = setTimeout(() => {
+      if (tab.linkedBrowser)
+        tab.linkedBrowser.removeEventListener(eventType, handle, true);
+      reject(new Error("Timed out while waiting for a '" + eventType + "'' event"));
+    }, 30000);
+
+    tab.linkedBrowser.addEventListener(eventType, handle, true, true);
+    if (url)
+      tab.linkedBrowser.loadURI(url);
+  });
+}
+
 function getLoopString(stringID) {
   return MozLoopServiceInternal.localizedStrings.get(stringID);
 }
 
 /**
  * This is used to fake push registration and notifications for
  * MozLoopService tests. There is only one object created per test instance, as
  * once registration has taken place, the object cannot currently be changed.
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -26,23 +26,26 @@ describe("loop.store.ActiveRoomStore", f
       rooms: {
         get: sinon.stub(),
         join: sinon.stub(),
         refreshMembership: sinon.stub(),
         leave: sinon.stub(),
         on: sinon.stub(),
         off: sinon.stub()
       },
-      setScreenShareState: sinon.stub()
+      setScreenShareState: sinon.stub(),
+      getActiveTabWindowId: sandbox.stub().callsArgWith(0, null, 42)
     };
 
     fakeSdkDriver = {
       connectSession: sandbox.stub(),
       disconnectSession: sandbox.stub(),
-      forceDisconnectAll: sandbox.stub().callsArg(0)
+      forceDisconnectAll: sandbox.stub().callsArg(0),
+      startScreenShare: sandbox.stub(),
+      endScreenShare: sandbox.stub().returns(true)
     };
 
     fakeMultiplexGum = {
         reset: sandbox.spy()
     };
 
     loop.standaloneMedia = {
       multiplexGum: fakeMultiplexGum
@@ -685,16 +688,70 @@ describe("loop.store.ActiveRoomStore", f
       store.receivingScreenShare(new sharedActions.ReceivingScreenShare({
         receiving: true
       }));
 
       expect(store.getStoreState().receivingScreenShare).eql(true);
     });
   });
 
+  describe("#startScreenShare", function() {
+    it("should set the state to 'pending'", function() {
+      store.startScreenShare(new sharedActions.StartScreenShare({
+        type: "window"
+      }));
+
+      sinon.assert.calledOnce(dispatcher.dispatch);
+      sinon.assert.calledWith(dispatcher.dispatch,
+        new sharedActions.ScreenSharingState({
+          state: SCREEN_SHARE_STATES.PENDING
+        }));
+    });
+
+    it("should invoke the SDK driver with the correct options for window sharing", function() {
+      store.startScreenShare(new sharedActions.StartScreenShare({
+        type: "window"
+      }));
+
+      sinon.assert.calledOnce(fakeSdkDriver.startScreenShare);
+      sinon.assert.calledWith(fakeSdkDriver.startScreenShare, {
+        videoSource: "window"
+      });
+    });
+
+    it("should invoke the SDK driver with the correct options for tab sharing", function() {
+      store.startScreenShare(new sharedActions.StartScreenShare({
+        type: "browser"
+      }));
+
+      sinon.assert.calledOnce(fakeMozLoop.getActiveTabWindowId);
+
+      sinon.assert.calledOnce(fakeSdkDriver.startScreenShare);
+      sinon.assert.calledWith(fakeSdkDriver.startScreenShare, {
+        videoSource: "browser",
+        constraints: {
+          browserWindow: 42,
+          scrollWithPage: true
+        }
+      });
+    })
+  });
+
+  describe("#endScreenShare", function() {
+    it("should set the state to 'inactive'", function() {
+      store.endScreenShare();
+
+      sinon.assert.calledOnce(dispatcher.dispatch);
+      sinon.assert.calledWith(dispatcher.dispatch,
+        new sharedActions.ScreenSharingState({
+          state: SCREEN_SHARE_STATES.INACTIVE
+        }));
+    });
+  });
+
   describe("#remotePeerConnected", function() {
     it("should set the state to `HAS_PARTICIPANTS`", function() {
       store.remotePeerConnected();
 
       expect(store.getStoreState().roomState).eql(ROOM_STATES.HAS_PARTICIPANTS);
     });
 
     it("should set the pref for ToS to `seen`", function() {
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -133,73 +133,55 @@ describe("loop.OTSdkDriver", function ()
         className: "fakeVideo"
       };
 
       driver.getScreenShareElementFunc = function() {
         return fakeElement;
       };
     });
 
-    it("should dispatch a `ScreenSharingState` action", function() {
-      driver.startScreenShare(new sharedActions.StartScreenShare({
-        type: "window"
-      }));
-
-      sinon.assert.calledOnce(dispatcher.dispatch);
-      sinon.assert.calledWithExactly(dispatcher.dispatch,
-        new sharedActions.ScreenSharingState({
-          state: SCREEN_SHARE_STATES.PENDING
-        }));
-    });
-
     it("should initialize a publisher", function() {
-      driver.startScreenShare(new sharedActions.StartScreenShare({
-        type: "window"
-      }));
+      // We're testing with `videoSource` set to 'browser', not 'window', as it
+      // has multiple options.
+      var options = {
+        videoSource: "browser",
+        browserWindow: 42,
+        scrollWithPage: true
+      };
+      driver.startScreenShare(options);
 
       sinon.assert.calledOnce(sdk.initPublisher);
-      sinon.assert.calledWithMatch(sdk.initPublisher,
-        fakeElement, {videoSource: "window"});
+      sinon.assert.calledWithMatch(sdk.initPublisher, fakeElement, options);
     });
   });
 
   describe("#endScreenShare", function() {
     beforeEach(function() {
       driver.getScreenShareElementFunc = function() {};
 
-      driver.startScreenShare(new sharedActions.StartScreenShare({
-        type: "window"
-      }));
+      driver.startScreenShare({
+        videoSource: "window"
+      });
 
       sandbox.stub(dispatcher, "dispatch");
 
       driver.session = session;
     });
 
     it("should unpublish the share", function() {
       driver.endScreenShare(new sharedActions.EndScreenShare());
 
       sinon.assert.calledOnce(session.unpublish);
     });
 
     it("should destroy the share", function() {
-      driver.endScreenShare(new sharedActions.EndScreenShare());
+      expect(driver.endScreenShare()).to.equal(true);
 
       sinon.assert.calledOnce(publisher.destroy);
     });
-
-    it("should dispatch a `ScreenSharingState` action", function() {
-      driver.endScreenShare(new sharedActions.EndScreenShare());
-
-      sinon.assert.calledOnce(dispatcher.dispatch);
-      sinon.assert.calledWithExactly(dispatcher.dispatch,
-        new sharedActions.ScreenSharingState({
-          state: SCREEN_SHARE_STATES.INACTIVE
-        }));
-    });
   });
 
   describe("#connectSession", function() {
     it("should initialise a new session", function() {
       driver.connectSession(sessionData);
 
       sinon.assert.calledOnce(sdk.initSession);
       sinon.assert.calledWithExactly(sdk.initSession, "3216549870");
@@ -612,19 +594,19 @@ describe("loop.OTSdkDriver", function ()
   });
 
   describe("Events (screenshare)", function() {
     beforeEach(function() {
       driver.connectSession(sessionData);
 
       driver.getScreenShareElementFunc = function() {};
 
-      driver.startScreenShare(new sharedActions.StartScreenShare({
-        type: "window"
-      }));
+      driver.startScreenShare({
+        videoSource: "window"
+      });
 
       sandbox.stub(dispatcher, "dispatch");
     });
 
     describe("accessAllowed", function() {
       it("should publish the stream", function() {
         publisher.trigger("accessAllowed", fakeEvent);
 
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -282,56 +282,56 @@
                          mozLoop: mockMozLoopRooms, 
                          dispatcher: dispatcher, 
                          roomStore: roomStore, 
                          selectedTab: "contacts"})
             )
           ), 
 
           React.createElement(Section, {name: "IncomingCallView"}, 
-            React.createElement(Example, {summary: "Default / incoming video call", dashed: "true", style: {width: "260px", height: "254px"}}, 
+            React.createElement(Example, {summary: "Default / incoming video call", dashed: "true", style: {width: "300px", height: "272px"}}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(IncomingCallView, {model: mockConversationModel, 
                                   video: true})
               )
             ), 
 
-            React.createElement(Example, {summary: "Default / incoming audio only call", dashed: "true", style: {width: "260px", height: "254px"}}, 
+            React.createElement(Example, {summary: "Default / incoming audio only call", dashed: "true", style: {width: "300px", height: "272px"}}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(IncomingCallView, {model: mockConversationModel, 
                                   video: false})
               )
             )
           ), 
 
           React.createElement(Section, {name: "IncomingCallView-ActiveState"}, 
-            React.createElement(Example, {summary: "Default", dashed: "true", style: {width: "260px", height: "254px"}}, 
+            React.createElement(Example, {summary: "Default", dashed: "true", style: {width: "300px", height: "272px"}}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(IncomingCallView, {model: mockConversationModel, 
                                    showMenu: true})
               )
             )
           ), 
 
           React.createElement(Section, {name: "ConversationToolbar"}, 
             React.createElement("h2", null, "Desktop Conversation Window"), 
             React.createElement("div", {className: "fx-embedded override-position"}, 
-              React.createElement(Example, {summary: "Default (260x265)", dashed: "true"}, 
+              React.createElement(Example, {summary: "Default", dashed: "true", style: {width: "300px", height: "272px"}}, 
                 React.createElement(ConversationToolbar, {video: {enabled: true}, 
                                      audio: {enabled: true}, 
                                      hangup: noop, 
                                      publishStream: noop})
               ), 
-              React.createElement(Example, {summary: "Video muted"}, 
+              React.createElement(Example, {summary: "Video muted", style: {width: "300px", height: "272px"}}, 
                 React.createElement(ConversationToolbar, {video: {enabled: false}, 
                                      audio: {enabled: true}, 
                                      hangup: noop, 
                                      publishStream: noop})
               ), 
-              React.createElement(Example, {summary: "Audio muted"}, 
+              React.createElement(Example, {summary: "Audio muted", style: {width: "300px", height: "272px"}}, 
                 React.createElement(ConversationToolbar, {video: {enabled: true}, 
                                      audio: {enabled: false}, 
                                      hangup: noop, 
                                      publishStream: noop})
               )
             ), 
 
             React.createElement("h2", null, "Standalone"), 
@@ -378,34 +378,34 @@
                                          dispatcher: dispatcher, 
                                          callState: "ringing"})
               )
             )
           ), 
 
           React.createElement(Section, {name: "PendingConversationView (Desktop)"}, 
             React.createElement(Example, {summary: "Connecting", dashed: "true", 
-                     style: {width: "260px", height: "265px"}}, 
+                     style: {width: "300px", height: "272px"}}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopPendingConversationView, {callState: "gather", 
                                                 contact: mockContact, 
                                                 dispatcher: dispatcher})
               )
             )
           ), 
 
           React.createElement(Section, {name: "CallFailedView"}, 
             React.createElement(Example, {summary: "Call Failed", dashed: "true", 
-                     style: {width: "260px", height: "265px"}}, 
+                     style: {width: "300px", height: "272px"}}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(CallFailedView, {dispatcher: dispatcher, store: conversationStore})
               )
             ), 
             React.createElement(Example, {summary: "Call Failed — with call URL error", dashed: "true", 
-                     style: {width: "260px", height: "265px"}}, 
+                     style: {width: "300px", height: "272px"}}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(CallFailedView, {dispatcher: dispatcher, emailLinkError: true, 
                                 store: conversationStore})
               )
             )
           ), 
 
           React.createElement(Section, {name: "StartConversationView"}, 
@@ -425,17 +425,17 @@
                                         client: mockClient, 
                                         notifications: notifications})
               )
             )
           ), 
 
           React.createElement(Section, {name: "ConversationView"}, 
             React.createElement(Example, {summary: "Desktop conversation window", dashed: "true", 
-                     style: {width: "260px", height: "265px"}}, 
+                     style: {width: "300px", height: "272px"}}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(ConversationView, {sdk: mockSDK, 
                                   model: mockConversationModel, 
                                   video: {enabled: true}, 
                                   audio: {enabled: true}})
               )
             ), 
 
@@ -447,17 +447,17 @@
                     video: {enabled: true}, 
                     audio: {enabled: true}, 
                     model: mockConversationModel})
                 )
               )
             ), 
 
             React.createElement(Example, {summary: "Desktop conversation window local audio stream", 
-                     dashed: "true", style: {width: "260px", height: "265px"}}, 
+                     dashed: "true", style: {width: "300px", height: "272px"}}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(ConversationView, {sdk: mockSDK, 
                                   video: {enabled: false}, 
                                   audio: {enabled: true}, 
                                   model: mockConversationModel})
               )
             ), 
 
@@ -498,23 +498,23 @@
             )
           ), 
 
           React.createElement(Section, {name: "FeedbackView"}, 
             React.createElement("p", {className: "note"}, 
               React.createElement("strong", null, "Note:"), " For the useable demo, you can access submitted data at ", 
               React.createElement("a", {href: "https://input.allizom.org/"}, "input.allizom.org"), "."
             ), 
-            React.createElement(Example, {summary: "Default (useable demo)", dashed: "true", style: {width: "260px"}}, 
+            React.createElement(Example, {summary: "Default (useable demo)", dashed: "true", style: {width: "300px", height: "272px"}}, 
               React.createElement(FeedbackView, {feedbackStore: feedbackStore})
             ), 
-            React.createElement(Example, {summary: "Detailed form", dashed: "true", style: {width: "260px"}}, 
+            React.createElement(Example, {summary: "Detailed form", dashed: "true", style: {width: "300px", height: "272px"}}, 
               React.createElement(FeedbackView, {feedbackStore: feedbackStore, feedbackState: FEEDBACK_STATES.DETAILS})
             ), 
-            React.createElement(Example, {summary: "Thank you!", dashed: "true", style: {width: "260px"}}, 
+            React.createElement(Example, {summary: "Thank you!", dashed: "true", style: {width: "300px", height: "272px"}}, 
               React.createElement(FeedbackView, {feedbackStore: feedbackStore, feedbackState: FEEDBACK_STATES.SENT})
             )
           ), 
 
           React.createElement(Section, {name: "CallUrlExpiredView"}, 
             React.createElement(Example, {summary: "Firefox User"}, 
               React.createElement(CallUrlExpiredView, {isFirefox: true})
             ), 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -282,56 +282,56 @@
                          mozLoop={mockMozLoopRooms}
                          dispatcher={dispatcher}
                          roomStore={roomStore}
                          selectedTab="contacts" />
             </Example>
           </Section>
 
           <Section name="IncomingCallView">
-            <Example summary="Default / incoming video call" dashed="true" style={{width: "260px", height: "254px"}}>
+            <Example summary="Default / incoming video call" dashed="true" style={{width: "300px", height: "272px"}}>
               <div className="fx-embedded">
                 <IncomingCallView model={mockConversationModel}
                                   video={true} />
               </div>
             </Example>
 
-            <Example summary="Default / incoming audio only call" dashed="true" style={{width: "260px", height: "254px"}}>
+            <Example summary="Default / incoming audio only call" dashed="true" style={{width: "300px", height: "272px"}}>
               <div className="fx-embedded">
                 <IncomingCallView model={mockConversationModel}
                                   video={false} />
               </div>
             </Example>
           </Section>
 
           <Section name="IncomingCallView-ActiveState">
-            <Example summary="Default" dashed="true" style={{width: "260px", height: "254px"}}>
+            <Example summary="Default" dashed="true" style={{width: "300px", height: "272px"}}>
               <div className="fx-embedded" >
                 <IncomingCallView  model={mockConversationModel}
                                    showMenu={true} />
               </div>
             </Example>
           </Section>
 
           <Section name="ConversationToolbar">
             <h2>Desktop Conversation Window</h2>
             <div className="fx-embedded override-position">
-              <Example summary="Default (260x265)" dashed="true">
+              <Example summary="Default" dashed="true" style={{width: "300px", height: "272px"}}>
                 <ConversationToolbar video={{enabled: true}}
                                      audio={{enabled: true}}
                                      hangup={noop}
                                      publishStream={noop} />
               </Example>
-              <Example summary="Video muted">
+              <Example summary="Video muted" style={{width: "300px", height: "272px"}}>
                 <ConversationToolbar video={{enabled: false}}
                                      audio={{enabled: true}}
                                      hangup={noop}
                                      publishStream={noop} />
               </Example>
-              <Example summary="Audio muted">
+              <Example summary="Audio muted" style={{width: "300px", height: "272px"}}>
                 <ConversationToolbar video={{enabled: true}}
                                      audio={{enabled: false}}
                                      hangup={noop}
                                      publishStream={noop} />
               </Example>
             </div>
 
             <h2>Standalone</h2>
@@ -378,34 +378,34 @@
                                          dispatcher={dispatcher}
                                          callState="ringing"/>
               </div>
             </Example>
           </Section>
 
           <Section name="PendingConversationView (Desktop)">
             <Example summary="Connecting" dashed="true"
-                     style={{width: "260px", height: "265px"}}>
+                     style={{width: "300px", height: "272px"}}>
               <div className="fx-embedded">
                 <DesktopPendingConversationView callState={"gather"}
                                                 contact={mockContact}
                                                 dispatcher={dispatcher} />
               </div>
             </Example>
           </Section>
 
           <Section name="CallFailedView">
             <Example summary="Call Failed" dashed="true"
-                     style={{width: "260px", height: "265px"}}>
+                     style={{width: "300px", height: "272px"}}>
               <div className="fx-embedded">
                 <CallFailedView dispatcher={dispatcher} store={conversationStore} />
               </div>
             </Example>
             <Example summary="Call Failed — with call URL error" dashed="true"
-                     style={{width: "260px", height: "265px"}}>
+                     style={{width: "300px", height: "272px"}}>
               <div className="fx-embedded">
                 <CallFailedView dispatcher={dispatcher} emailLinkError={true}
                                 store={conversationStore} />
               </div>
             </Example>
           </Section>
 
           <Section name="StartConversationView">
@@ -425,17 +425,17 @@
                                         client={mockClient}
                                         notifications={notifications} />
               </div>
             </Example>
           </Section>
 
           <Section name="ConversationView">
             <Example summary="Desktop conversation window" dashed="true"
-                     style={{width: "260px", height: "265px"}}>
+                     style={{width: "300px", height: "272px"}}>
               <div className="fx-embedded">
                 <ConversationView sdk={mockSDK}
                                   model={mockConversationModel}
                                   video={{enabled: true}}
                                   audio={{enabled: true}} />
               </div>
             </Example>
 
@@ -447,17 +447,17 @@
                     video={{enabled: true}}
                     audio={{enabled: true}}
                     model={mockConversationModel} />
                 </div>
               </div>
             </Example>
 
             <Example summary="Desktop conversation window local audio stream"
-                     dashed="true" style={{width: "260px", height: "265px"}}>
+                     dashed="true" style={{width: "300px", height: "272px"}}>
               <div className="fx-embedded">
                 <ConversationView sdk={mockSDK}
                                   video={{enabled: false}}
                                   audio={{enabled: true}}
                                   model={mockConversationModel} />
               </div>
             </Example>
 
@@ -498,23 +498,23 @@
             </Example>
           </Section>
 
           <Section name="FeedbackView">
             <p className="note">
               <strong>Note:</strong> For the useable demo, you can access submitted data at&nbsp;
               <a href="https://input.allizom.org/">input.allizom.org</a>.
             </p>
-            <Example summary="Default (useable demo)" dashed="true" style={{width: "260px"}}>
+            <Example summary="Default (useable demo)" dashed="true" style={{width: "300px", height: "272px"}}>
               <FeedbackView feedbackStore={feedbackStore} />
             </Example>
-            <Example summary="Detailed form" dashed="true" style={{width: "260px"}}>
+            <Example summary="Detailed form" dashed="true" style={{width: "300px", height: "272px"}}>
               <FeedbackView feedbackStore={feedbackStore} feedbackState={FEEDBACK_STATES.DETAILS} />
             </Example>
-            <Example summary="Thank you!" dashed="true" style={{width: "260px"}}>
+            <Example summary="Thank you!" dashed="true" style={{width: "300px", height: "272px"}}>
               <FeedbackView feedbackStore={feedbackStore} feedbackState={FEEDBACK_STATES.SENT} />
             </Example>
           </Section>
 
           <Section name="CallUrlExpiredView">
             <Example summary="Firefox User">
               <CallUrlExpiredView isFirefox={true} />
             </Example>
--- a/browser/components/search/content/search.xml
+++ b/browser/components/search/content/search.xml
@@ -821,16 +821,18 @@
             pasteAndSearch.setAttribute("disabled", "true");
         }, false);
 
         var element, label, akey;
 
         element = document.createElementNS(kXULNS, "menuseparator");
         cxmenu.appendChild(element);
 
+        this.setAttribute("aria-owns", this.popup.id);
+
         var insertLocation = cxmenu.firstChild;
         while (insertLocation.nextSibling &&
                insertLocation.getAttribute("cmd") != "cmd_paste")
           insertLocation = insertLocation.nextSibling;
         if (insertLocation) {
           element = document.createElementNS(kXULNS, "menuitem");
           label = this._stringBundle.getString("cmd_pasteAndSearch");
           element.setAttribute("label", label);
@@ -1014,19 +1016,21 @@
         <setter><![CDATA[
           if (this._selectedButton)
             this._selectedButton.removeAttribute("selected");
 
           // Avoid selecting dummy buttons.
           if (val && !val.classList.contains("dummy")) {
             val.setAttribute("selected", "true");
             this._selectedButton = val;
+            this.setAttribute("aria-activedescendant", val.id);
             return;
           }
 
+          this.removeAttribute("aria-activedescendant");
           this._selectedButton = null;
         ]]></setter>
       </property>
 
       <method name="getSelectableButtons">
         <parameter name="aCycleEngines"/>
         <body><![CDATA[
           let buttons = [];
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/callslist.js
@@ -0,0 +1,513 @@
+/* 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";
+
+/**
+ * Functions handling details about a single recorded animation frame snapshot
+ * (the calls list, rendering preview, thumbnails filmstrip etc.).
+ */
+let CallsListView = Heritage.extend(WidgetMethods, {
+  /**
+   * Initialization function, called when the tool is started.
+   */
+  initialize: function() {
+    this.widget = new SideMenuWidget($("#calls-list"));
+    this._slider = $("#calls-slider");
+    this._searchbox = $("#calls-searchbox");
+    this._filmstrip = $("#snapshot-filmstrip");
+
+    this._onSelect = this._onSelect.bind(this);
+    this._onSlideMouseDown = this._onSlideMouseDown.bind(this);
+    this._onSlideMouseUp = this._onSlideMouseUp.bind(this);
+    this._onSlide = this._onSlide.bind(this);
+    this._onSearch = this._onSearch.bind(this);
+    this._onScroll = this._onScroll.bind(this);
+    this._onExpand = this._onExpand.bind(this);
+    this._onStackFileClick = this._onStackFileClick.bind(this);
+    this._onThumbnailClick = this._onThumbnailClick.bind(this);
+
+    this.widget.addEventListener("select", this._onSelect, false);
+    this._slider.addEventListener("mousedown", this._onSlideMouseDown, false);
+    this._slider.addEventListener("mouseup", this._onSlideMouseUp, false);
+    this._slider.addEventListener("change", this._onSlide, false);
+    this._searchbox.addEventListener("input", this._onSearch, false);
+    this._filmstrip.addEventListener("wheel", this._onScroll, false);
+  },
+
+  /**
+   * Destruction function, called when the tool is closed.
+   */
+  destroy: function() {
+    this.widget.removeEventListener("select", this._onSelect, false);
+    this._slider.removeEventListener("mousedown", this._onSlideMouseDown, false);
+    this._slider.removeEventListener("mouseup", this._onSlideMouseUp, false);
+    this._slider.removeEventListener("change", this._onSlide, false);
+    this._searchbox.removeEventListener("input", this._onSearch, false);
+    this._filmstrip.removeEventListener("wheel", this._onScroll, false);
+  },
+
+  /**
+   * Populates this container with a list of function calls.
+   *
+   * @param array functionCalls
+   *        A list of function call actors received from the backend.
+   */
+  showCalls: function(functionCalls) {
+    this.empty();
+
+    for (let i = 0, len = functionCalls.length; i < len; i++) {
+      let call = functionCalls[i];
+
+      let view = document.createElement("vbox");
+      view.className = "call-item-view devtools-monospace";
+      view.setAttribute("flex", "1");
+
+      let contents = document.createElement("hbox");
+      contents.className = "call-item-contents";
+      contents.setAttribute("align", "center");
+      contents.addEventListener("dblclick", this._onExpand);
+      view.appendChild(contents);
+
+      let index = document.createElement("label");
+      index.className = "plain call-item-index";
+      index.setAttribute("flex", "1");
+      index.setAttribute("value", i + 1);
+
+      let gutter = document.createElement("hbox");
+      gutter.className = "call-item-gutter";
+      gutter.appendChild(index);
+      contents.appendChild(gutter);
+
+      // Not all function calls have a caller that was stringified (e.g.
+      // context calls have a "gl" or "ctx" caller preview).
+      if (call.callerPreview) {
+        let context = document.createElement("label");
+        context.className = "plain call-item-context";
+        context.setAttribute("value", call.callerPreview);
+        contents.appendChild(context);
+
+        let separator = document.createElement("label");
+        separator.className = "plain call-item-separator";
+        separator.setAttribute("value", ".");
+        contents.appendChild(separator);
+      }
+
+      let name = document.createElement("label");
+      name.className = "plain call-item-name";
+      name.setAttribute("value", call.name);
+      contents.appendChild(name);
+
+      let argsPreview = document.createElement("label");
+      argsPreview.className = "plain call-item-args";
+      argsPreview.setAttribute("crop", "end");
+      argsPreview.setAttribute("flex", "100");
+      // Getters and setters are displayed differently from regular methods.
+      if (call.type == CallWatcherFront.METHOD_FUNCTION) {
+        argsPreview.setAttribute("value", "(" + call.argsPreview + ")");
+      } else {
+        argsPreview.setAttribute("value", " = " + call.argsPreview);
+      }
+      contents.appendChild(argsPreview);
+
+      let location = document.createElement("label");
+      location.className = "plain call-item-location";
+      location.setAttribute("value", getFileName(call.file) + ":" + call.line);
+      location.setAttribute("crop", "start");
+      location.setAttribute("flex", "1");
+      location.addEventListener("mousedown", this._onExpand);
+      contents.appendChild(location);
+
+      // Append a function call item to this container.
+      this.push([view], {
+        staged: true,
+        attachment: {
+          actor: call
+        }
+      });
+
+      // Highlight certain calls that are probably more interesting than
+      // everything else, making it easier to quickly glance over them.
+      if (CanvasFront.DRAW_CALLS.has(call.name)) {
+        view.setAttribute("draw-call", "");
+      }
+      if (CanvasFront.INTERESTING_CALLS.has(call.name)) {
+        view.setAttribute("interesting-call", "");
+      }
+    }
+
+    // Flushes all the prepared function call items into this container.
+    this.commit();
+    window.emit(EVENTS.CALL_LIST_POPULATED);
+
+    // Resetting the function selection slider's value (shown in this
+    // container's toolbar) would trigger a selection event, which should be
+    // ignored in this case.
+    this._ignoreSliderChanges = true;
+    this._slider.value = 0;
+    this._slider.max = functionCalls.length - 1;
+    this._ignoreSliderChanges = false;
+  },
+
+  /**
+   * Displays an image in the rendering preview of this container, generated
+   * for the specified draw call in the recorded animation frame snapshot.
+   *
+   * @param array screenshot
+   *        A single "snapshot-image" instance received from the backend.
+   */
+  showScreenshot: function(screenshot) {
+    let { index, width, height, scaling, flipped, pixels } = screenshot;
+
+    let screenshotNode = $("#screenshot-image");
+    screenshotNode.setAttribute("flipped", flipped);
+    drawBackground("screenshot-rendering", width, height, pixels);
+
+    let dimensionsNode = $("#screenshot-dimensions");
+    let actualWidth = (width / scaling) | 0;
+    let actualHeight = (height / scaling) | 0;
+    dimensionsNode.setAttribute("value",
+      SHARED_L10N.getFormatStr("dimensions", actualWidth, actualHeight));
+
+    window.emit(EVENTS.CALL_SCREENSHOT_DISPLAYED);
+  },
+
+  /**
+   * Populates this container's footer with a list of thumbnails, one generated
+   * for each draw call in the recorded animation frame snapshot.
+   *
+   * @param array thumbnails
+   *        An array of "snapshot-image" instances received from the backend.
+   */
+  showThumbnails: function(thumbnails) {
+    while (this._filmstrip.hasChildNodes()) {
+      this._filmstrip.firstChild.remove();
+    }
+    for (let thumbnail of thumbnails) {
+      this.appendThumbnail(thumbnail);
+    }
+
+    window.emit(EVENTS.THUMBNAILS_DISPLAYED);
+  },
+
+  /**
+   * Displays an image in the thumbnails list of this container, generated
+   * for the specified draw call in the recorded animation frame snapshot.
+   *
+   * @param array thumbnail
+   *        A single "snapshot-image" instance received from the backend.
+   */
+  appendThumbnail: function(thumbnail) {
+    let { index, width, height, flipped, pixels } = thumbnail;
+
+    let thumbnailNode = document.createElementNS(HTML_NS, "canvas");
+    thumbnailNode.setAttribute("flipped", flipped);
+    thumbnailNode.width = Math.max(CanvasFront.THUMBNAIL_SIZE, width);
+    thumbnailNode.height = Math.max(CanvasFront.THUMBNAIL_SIZE, height);
+    drawImage(thumbnailNode, width, height, pixels, { centered: true });
+
+    thumbnailNode.className = "filmstrip-thumbnail";
+    thumbnailNode.onmousedown = e => this._onThumbnailClick(e, index);
+    thumbnailNode.setAttribute("index", index);
+    this._filmstrip.appendChild(thumbnailNode);
+  },
+
+  /**
+   * Sets the currently highlighted thumbnail in this container.
+   * A screenshot will always correlate to a thumbnail in the filmstrip,
+   * both being identified by the same 'index' of the context function call.
+   *
+   * @param number index
+   *        The context function call's index.
+   */
+  set highlightedThumbnail(index) {
+    let currHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + index + "']");
+    if (currHighlightedThumbnail == null) {
+      return;
+    }
+
+    let prevIndex = this._highlightedThumbnailIndex
+    let prevHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + prevIndex + "']");
+    if (prevHighlightedThumbnail) {
+      prevHighlightedThumbnail.removeAttribute("highlighted");
+    }
+
+    currHighlightedThumbnail.setAttribute("highlighted", "");
+    currHighlightedThumbnail.scrollIntoView();
+    this._highlightedThumbnailIndex = index;
+  },
+
+  /**
+   * Gets the currently highlighted thumbnail in this container.
+   * @return number
+   */
+  get highlightedThumbnail() {
+    return this._highlightedThumbnailIndex;
+  },
+
+  /**
+   * The select listener for this container.
+   */
+  _onSelect: function({ detail: callItem }) {
+    if (!callItem) {
+      return;
+    }
+
+    // Some of the stepping buttons don't make sense specifically while the
+    // last function call is selected.
+    if (this.selectedIndex == this.itemCount - 1) {
+      $("#resume").setAttribute("disabled", "true");
+      $("#step-over").setAttribute("disabled", "true");
+      $("#step-out").setAttribute("disabled", "true");
+    } else {
+      $("#resume").removeAttribute("disabled");
+      $("#step-over").removeAttribute("disabled");
+      $("#step-out").removeAttribute("disabled");
+    }
+
+    // Correlate the currently selected item with the function selection
+    // slider's value. Avoid triggering a redundant selection event.
+    this._ignoreSliderChanges = true;
+    this._slider.value = this.selectedIndex;
+    this._ignoreSliderChanges = false;
+
+    // Can't generate screenshots for function call actors loaded from disk.
+    // XXX: Bug 984844.
+    if (callItem.attachment.actor.isLoadedFromDisk) {
+      return;
+    }
+
+    // To keep continuous selection buttery smooth (for example, while pressing
+    // the DOWN key or moving the slider), only display the screenshot after
+    // any kind of user input stops.
+    setConditionalTimeout("screenshot-display", SCREENSHOT_DISPLAY_DELAY, () => {
+      return !this._isSliding;
+    }, () => {
+      let frameSnapshot = SnapshotsListView.selectedItem.attachment.actor
+      let functionCall = callItem.attachment.actor;
+      frameSnapshot.generateScreenshotFor(functionCall).then(screenshot => {
+        this.showScreenshot(screenshot);
+        this.highlightedThumbnail = screenshot.index;
+      }).catch(Cu.reportError);
+    });
+  },
+
+  /**
+   * The mousedown listener for the call selection slider.
+   */
+  _onSlideMouseDown: function() {
+    this._isSliding = true;
+  },
+
+  /**
+   * The mouseup listener for the call selection slider.
+   */
+  _onSlideMouseUp: function() {
+    this._isSliding = false;
+  },
+
+  /**
+   * The change listener for the call selection slider.
+   */
+  _onSlide: function() {
+    // Avoid performing any operations when programatically changing the value.
+    if (this._ignoreSliderChanges) {
+      return;
+    }
+    let selectedFunctionCallIndex = this.selectedIndex = this._slider.value;
+
+    // While sliding, immediately show the most relevant thumbnail for a
+    // function call, for a nice diff-like animation effect between draws.
+    let thumbnails = SnapshotsListView.selectedItem.attachment.thumbnails;
+    let thumbnail = getThumbnailForCall(thumbnails, selectedFunctionCallIndex);
+
+    // Avoid drawing and highlighting if the selected function call has the
+    // same thumbnail as the last one.
+    if (thumbnail.index == this.highlightedThumbnail) {
+      return;
+    }
+    // If a thumbnail wasn't found (e.g. the backend avoids creating thumbnails
+    // when rendering offscreen), simply defer to the first available one.
+    if (thumbnail.index == -1) {
+      thumbnail = thumbnails[0];
+    }
+
+    let { index, width, height, flipped, pixels } = thumbnail;
+    this.highlightedThumbnail = index;
+
+    let screenshotNode = $("#screenshot-image");
+    screenshotNode.setAttribute("flipped", flipped);
+    drawBackground("screenshot-rendering", width, height, pixels);
+  },
+
+  /**
+   * The input listener for the calls searchbox.
+   */
+  _onSearch: function(e) {
+    let lowerCaseSearchToken = this._searchbox.value.toLowerCase();
+
+    this.filterContents(e => {
+      let call = e.attachment.actor;
+      let name = call.name.toLowerCase();
+      let file = call.file.toLowerCase();
+      let line = call.line.toString().toLowerCase();
+      let args = call.argsPreview.toLowerCase();
+
+      return name.contains(lowerCaseSearchToken) ||
+             file.contains(lowerCaseSearchToken) ||
+             line.contains(lowerCaseSearchToken) ||
+             args.contains(lowerCaseSearchToken);
+    });
+  },
+
+  /**
+   * The wheel listener for the filmstrip that contains all the thumbnails.
+   */
+  _onScroll: function(e) {
+    this._filmstrip.scrollLeft += e.deltaX;
+  },
+
+  /**
+   * The click/dblclick listener for an item or location url in this container.
+   * When expanding an item, it's corresponding call stack will be displayed.
+   */
+  _onExpand: function(e) {
+    let callItem = this.getItemForElement(e.target);
+    let view = $(".call-item-view", callItem.target);
+
+    // If the call stack nodes were already created, simply re-show them
+    // or jump to the corresponding file and line in the Debugger if a
+    // location link was clicked.
+    if (view.hasAttribute("call-stack-populated")) {
+      let isExpanded = view.getAttribute("call-stack-expanded") == "true";
+
+      // If clicking on the location, jump to the Debugger.
+      if (e.target.classList.contains("call-item-location")) {
+        let { file, line } = callItem.attachment.actor;
+        viewSourceInDebugger(file, line);
+        return;
+      }
+      // Otherwise hide the call stack.
+      else {
+        view.setAttribute("call-stack-expanded", !isExpanded);
+        $(".call-item-stack", view).hidden = isExpanded;
+        return;
+      }
+    }
+
+    let list = document.createElement("vbox");
+    list.className = "call-item-stack";
+    view.setAttribute("call-stack-populated", "");
+    view.setAttribute("call-stack-expanded", "true");
+    view.appendChild(list);
+
+    /**
+     * Creates a function call nodes in this container for a stack.
+     */
+    let display = stack => {
+      for (let i = 1; i < stack.length; i++) {
+        let call = stack[i];
+
+        let contents = document.createElement("hbox");
+        contents.className = "call-item-stack-fn";
+        contents.style.MozPaddingStart = (i * STACK_FUNC_INDENTATION) + "px";
+
+        let name = document.createElement("label");
+        name.className = "plain call-item-stack-fn-name";
+        name.setAttribute("value", "↳ " + call.name + "()");
+        contents.appendChild(name);
+
+        let spacer = document.createElement("spacer");
+        spacer.setAttribute("flex", "100");
+        contents.appendChild(spacer);
+
+        let location = document.createElement("label");
+        location.className = "plain call-item-stack-fn-location";
+        location.setAttribute("value", getFileName(call.file) + ":" + call.line);
+        location.setAttribute("crop", "start");
+        location.setAttribute("flex", "1");
+        location.addEventListener("mousedown", e => this._onStackFileClick(e, call));
+        contents.appendChild(location);
+
+        list.appendChild(contents);
+      }
+
+      window.emit(EVENTS.CALL_STACK_DISPLAYED);
+    };
+
+    // If this animation snapshot is loaded from disk, there are no corresponding
+    // backend actors available and the data is immediately available.
+    let functionCall = callItem.attachment.actor;
+    if (functionCall.isLoadedFromDisk) {
+      display(functionCall.stack);
+    }
+    // ..otherwise we need to request the function call stack from the backend.
+    else {
+      callItem.attachment.actor.getDetails().then(fn => display(fn.stack));
+    }
+  },
+
+  /**
+   * The click listener for a location link in the call stack.
+   *
+   * @param string file
+   *        The url of the source owning the function.
+   * @param number line
+   *        The line of the respective function.
+   */
+  _onStackFileClick: function(e, { file, line }) {
+    viewSourceInDebugger(file, line);
+  },
+
+  /**
+   * The click listener for a thumbnail in the filmstrip.
+   *
+   * @param number index
+   *        The function index in the recorded animation frame snapshot.
+   */
+  _onThumbnailClick: function(e, index) {
+    this.selectedIndex = index;
+  },
+
+  /**
+   * The click listener for the "resume" button in this container's toolbar.
+   */
+  _onResume: function() {
+    // Jump to the next draw call in the recorded animation frame snapshot.
+    let drawCall = getNextDrawCall(this.items, this.selectedItem);
+    if (drawCall) {
+      this.selectedItem = drawCall;
+      return;
+    }
+
+    // If there are no more draw calls, just jump to the last context call.
+    this._onStepOut();
+  },
+
+  /**
+   * The click listener for the "step over" button in this container's toolbar.
+   */
+  _onStepOver: function() {
+    this.selectedIndex++;
+  },
+
+  /**
+   * The click listener for the "step in" button in this container's toolbar.
+   */
+  _onStepIn: function() {
+    if (this.selectedIndex == -1) {
+      this._onResume();
+      return;
+    }
+    let callItem = this.selectedItem;
+    let { file, line } = callItem.attachment.actor;
+    viewSourceInDebugger(file, line);
+  },
+
+  /**
+   * The click listener for the "step out" button in this container's toolbar.
+   */
+  _onStepOut: function() {
+    this.selectedIndex = this.itemCount - 1;
+  }
+});
--- a/browser/devtools/canvasdebugger/canvasdebugger.js
+++ b/browser/devtools/canvasdebugger/canvasdebugger.js
@@ -4,25 +4,29 @@
 "use strict";
 
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource:///modules/devtools/SideMenuWidget.jsm");
 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource://gre/modules/devtools/Console.jsm");
+Cu.import("resource:///modules/devtools/gDevTools.jsm");
 
 const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
 const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
 const EventEmitter = require("devtools/toolkit/event-emitter");
 const { CallWatcherFront } = require("devtools/server/actors/call-watcher");
 const { CanvasFront } = require("devtools/server/actors/canvas");
 const Telemetry = require("devtools/shared/telemetry");
 const telemetry = new Telemetry();
 
+const CANVAS_ACTOR_RECORDING_ATTEMPT = gDevTools.testing ? 500 : 5000;
+
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
   "resource://gre/modules/Task.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
   "resource://gre/modules/PluralForm.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
   "resource://gre/modules/FileUtils.jsm");
@@ -36,19 +40,22 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 // The panel's window global is an EventEmitter firing the following events:
 const EVENTS = {
   // When the UI is reset from tab navigation.
   UI_RESET: "CanvasDebugger:UIReset",
 
   // When all the animation frame snapshots are removed by the user.
   SNAPSHOTS_LIST_CLEARED: "CanvasDebugger:SnapshotsListCleared",
 
-  // When an animation frame snapshot starts/finishes being recorded.
+  // When an animation frame snapshot starts/finishes being recorded, and
+  // whether it was completed succesfully or cancelled.
   SNAPSHOT_RECORDING_STARTED: "CanvasDebugger:SnapshotRecordingStarted",
   SNAPSHOT_RECORDING_FINISHED: "CanvasDebugger:SnapshotRecordingFinished",
+  SNAPSHOT_RECORDING_COMPLETED: "CanvasDebugger:SnapshotRecordingCompleted",
+  SNAPSHOT_RECORDING_CANCELLED: "CanvasDebugger:SnapshotRecordingCancelled",
 
   // When an animation frame snapshot was selected and all its data displayed.
   SNAPSHOT_RECORDING_SELECTED: "CanvasDebugger:SnapshotRecordingSelected",
 
   // After all the function calls associated with an animation frame snapshot
   // are displayed in the UI.
   CALL_LIST_POPULATED: "CanvasDebugger:CallListPopulated",
 
@@ -152,915 +159,27 @@ let EventsHandler = {
     CallsListView.empty();
 
     $("#record-snapshot").removeAttribute("checked");
     $("#record-snapshot").removeAttribute("disabled");
     $("#record-snapshot").hidden = false;
 
     $("#reload-notice").hidden = true;
     $("#empty-notice").hidden = false;
-    $("#import-notice").hidden = true;
+    $("#waiting-notice").hidden = true;
 
     $("#debugging-pane-contents").hidden = true;
     $("#screenshot-container").hidden = true;
     $("#snapshot-filmstrip").hidden = true;
 
     window.emit(EVENTS.UI_RESET);
   }
 };
 
 /**
- * Functions handling the recorded animation frame snapshots UI.
- */
-let SnapshotsListView = Heritage.extend(WidgetMethods, {
-  /**
-   * Initialization function, called when the tool is started.
-   */
-  initialize: function() {
-    this.widget = new SideMenuWidget($("#snapshots-list"), {
-      showArrows: true
-    });
-
-    this._onSelect = this._onSelect.bind(this);
-    this._onClearButtonClick = this._onClearButtonClick.bind(this);
-    this._onRecordButtonClick = this._onRecordButtonClick.bind(this);
-    this._onImportButtonClick = this._onImportButtonClick.bind(this);
-    this._onSaveButtonClick = this._onSaveButtonClick.bind(this);
-
-    this.emptyText = L10N.getStr("noSnapshotsText");
-    this.widget.addEventListener("select", this._onSelect, false);
-  },
-
-  /**
-   * Destruction function, called when the tool is closed.
-   */
-  destroy: function() {
-    this.widget.removeEventListener("select", this._onSelect, false);
-  },
-
-  /**
-   * Adds a snapshot entry to this container.
-   *
-   * @return object
-   *         The newly inserted item.
-   */
-  addSnapshot: function() {
-    let contents = document.createElement("hbox");
-    contents.className = "snapshot-item";
-
-    let thumbnail = document.createElementNS(HTML_NS, "canvas");
-    thumbnail.className = "snapshot-item-thumbnail";
-    thumbnail.width = CanvasFront.THUMBNAIL_SIZE;
-    thumbnail.height = CanvasFront.THUMBNAIL_SIZE;
-
-    let title = document.createElement("label");
-    title.className = "plain snapshot-item-title";
-    title.setAttribute("value",
-      L10N.getFormatStr("snapshotsList.itemLabel", this.itemCount + 1));
-
-    let calls = document.createElement("label");
-    calls.className = "plain snapshot-item-calls";
-    calls.setAttribute("value",
-      L10N.getStr("snapshotsList.loadingLabel"));
-
-    let save = document.createElement("label");
-    save.className = "plain snapshot-item-save";
-    save.addEventListener("click", this._onSaveButtonClick, false);
-
-    let spacer = document.createElement("spacer");
-    spacer.setAttribute("flex", "1");
-
-    let footer = document.createElement("hbox");
-    footer.className = "snapshot-item-footer";
-    footer.appendChild(save);
-
-    let details = document.createElement("vbox");
-    details.className = "snapshot-item-details";
-    details.appendChild(title);
-    details.appendChild(calls);
-    details.appendChild(spacer);
-    details.appendChild(footer);
-
-    contents.appendChild(thumbnail);
-    contents.appendChild(details);
-
-    // Append a recorded snapshot item to this container.
-    return this.push([contents], {
-      attachment: {
-        // The snapshot and function call actors, along with the thumbnails
-        // will be available as soon as recording finishes.
-        actor: null,
-        calls: null,
-        thumbnails: null,
-        screenshot: null
-      }
-    });
-  },
-
-  /**
-   * Customizes a shapshot in this container.
-   *
-   * @param Item snapshotItem
-   *        An item inserted via `SnapshotsListView.addSnapshot`.
-   * @param object snapshotActor
-   *        The frame snapshot actor received from the backend.
-   * @param object snapshotOverview
-   *        Additional data about the snapshot received from the backend.
-   */
-  customizeSnapshot: function(snapshotItem, snapshotActor, snapshotOverview) {
-    // Make sure the function call actors are stored on the item,
-    // to be used when populating the CallsListView.
-    snapshotItem.attachment.actor = snapshotActor;
-    let functionCalls = snapshotItem.attachment.calls = snapshotOverview.calls;
-    let thumbnails = snapshotItem.attachment.thumbnails = snapshotOverview.thumbnails;
-    let screenshot = snapshotItem.attachment.screenshot = snapshotOverview.screenshot;
-
-    let lastThumbnail = thumbnails[thumbnails.length - 1];
-    let { width, height, flipped, pixels } = lastThumbnail;
-
-    let thumbnailNode = $(".snapshot-item-thumbnail", snapshotItem.target);
-    thumbnailNode.setAttribute("flipped", flipped);
-    drawImage(thumbnailNode, width, height, pixels, { centered: true });
-
-    let callsNode = $(".snapshot-item-calls", snapshotItem.target);
-    let drawCalls = functionCalls.filter(e => CanvasFront.DRAW_CALLS.has(e.name));
-
-    let drawCallsStr = PluralForm.get(drawCalls.length,
-      L10N.getStr("snapshotsList.drawCallsLabel"));
-    let funcCallsStr = PluralForm.get(functionCalls.length,
-      L10N.getStr("snapshotsList.functionCallsLabel"));
-
-    callsNode.setAttribute("value",
-      drawCallsStr.replace("#1", drawCalls.length) + ", " +
-      funcCallsStr.replace("#1", functionCalls.length));
-
-    let saveNode = $(".snapshot-item-save", snapshotItem.target);
-    saveNode.setAttribute("disabled", !!snapshotItem.isLoadedFromDisk);
-    saveNode.setAttribute("value", snapshotItem.isLoadedFromDisk
-      ? L10N.getStr("snapshotsList.loadedLabel")
-      : L10N.getStr("snapshotsList.saveLabel"));
-
-    // Make sure there's always a selected item available.
-    if (!this.selectedItem) {
-      this.selectedIndex = 0;
-    }
-  },
-
-  /**
-   * The select listener for this container.
-   */
-  _onSelect: function({ detail: snapshotItem }) {
-    if (!snapshotItem) {
-      return;
-    }
-    let { calls, thumbnails, screenshot } = snapshotItem.attachment;
-
-    $("#reload-notice").hidden = true;
-    $("#empty-notice").hidden = true;
-    $("#import-notice").hidden = false;
-
-    $("#debugging-pane-contents").hidden = true;
-    $("#screenshot-container").hidden = true;
-    $("#snapshot-filmstrip").hidden = true;
-
-    Task.spawn(function*() {
-      // Wait for a few milliseconds between presenting the function calls,
-      // screenshot and thumbnails, to allow each component being
-      // sequentially drawn. This gives the illusion of snappiness.
-
-      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
-      CallsListView.showCalls(calls);
-      $("#debugging-pane-contents").hidden = false;
-      $("#import-notice").hidden = true;
-
-      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
-      CallsListView.showThumbnails(thumbnails);
-      $("#snapshot-filmstrip").hidden = false;
-
-      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
-      CallsListView.showScreenshot(screenshot);
-      $("#screenshot-container").hidden = false;
-
-      window.emit(EVENTS.SNAPSHOT_RECORDING_SELECTED);
-    });
-  },
-
-  /**
-   * The click listener for the "clear" button in this container.
-   */
-  _onClearButtonClick: function() {
-    Task.spawn(function*() {
-      SnapshotsListView.empty();
-      CallsListView.empty();
-
-      $("#reload-notice").hidden = true;
-      $("#empty-notice").hidden = true;
-      $("#import-notice").hidden = true;
-
-      if (yield gFront.isInitialized()) {
-        $("#empty-notice").hidden = false;
-      } else {
-        $("#reload-notice").hidden = false;
-      }
-
-      $("#debugging-pane-contents").hidden = true;
-      $("#screenshot-container").hidden = true;
-      $("#snapshot-filmstrip").hidden = true;
-
-      window.emit(EVENTS.SNAPSHOTS_LIST_CLEARED);
-    });
-  },
-
-  /**
-   * The click listener for the "record" button in this container.
-   */
-  _onRecordButtonClick: function() {
-    Task.spawn(function*() {
-      $("#record-snapshot").setAttribute("checked", "true");
-      $("#record-snapshot").setAttribute("disabled", "true");
-
-      // Insert a "dummy" snapshot item in the view, to hint that recording
-      // has now started. However, wait for a few milliseconds before actually
-      // starting the recording, since that might block rendering and prevent
-      // the dummy snapshot item from being drawn.
-      let snapshotItem = this.addSnapshot();
-
-      // If this is the first item, immediately show the "Loading…" notice.
-      if (this.itemCount == 1) {
-        $("#empty-notice").hidden = true;
-        $("#import-notice").hidden = false;
-      }
-
-      yield DevToolsUtils.waitForTime(SNAPSHOT_START_RECORDING_DELAY);
-      window.emit(EVENTS.SNAPSHOT_RECORDING_STARTED);
-
-      let snapshotActor = yield gFront.recordAnimationFrame();
-      let snapshotOverview = yield snapshotActor.getOverview();
-      this.customizeSnapshot(snapshotItem, snapshotActor, snapshotOverview);
-
-      $("#record-snapshot").removeAttribute("checked");
-      $("#record-snapshot").removeAttribute("disabled");
-
-      window.emit(EVENTS.SNAPSHOT_RECORDING_FINISHED);
-    }.bind(this));
-  },
-
-  /**
-   * The click listener for the "import" button in this container.
-   */
-  _onImportButtonClick: function() {
-    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
-    fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeOpen);
-    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
-    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
-
-    if (fp.show() != Ci.nsIFilePicker.returnOK) {
-      return;
-    }
-
-    let channel = NetUtil.newChannel2(fp.file,
-                                      null,
-                                      null,
-                                      window.document,
-                                      null, // aLoadingPrincipal
-                                      null, // aTriggeringPrincipal
-                                      Ci.nsILoadInfo.SEC_NORMAL,
-                                      Ci.nsIContentPolicy.TYPE_OTHER);
-    channel.contentType = "text/plain";
-
-    NetUtil.asyncFetch2(channel, (inputStream, status) => {
-      if (!Components.isSuccessCode(status)) {
-        console.error("Could not import recorded animation frame snapshot file.");
-        return;
-      }
-      try {
-        let string = NetUtil.readInputStreamToString(inputStream, inputStream.available());
-        var data = JSON.parse(string);
-      } catch (e) {
-        console.error("Could not read animation frame snapshot file.");
-        return;
-      }
-      if (data.fileType != CALLS_LIST_SERIALIZER_IDENTIFIER) {
-        console.error("Unrecognized animation frame snapshot file.");
-        return;
-      }
-
-      // Add a `isLoadedFromDisk` flag on everything to avoid sending invalid
-      // requests to the backend, since we're not dealing with actors anymore.
-      let snapshotItem = this.addSnapshot();
-      snapshotItem.isLoadedFromDisk = true;
-      data.calls.forEach(e => e.isLoadedFromDisk = true);
-
-      this.customizeSnapshot(snapshotItem, data.calls, data);
-    });
-  },
-
-  /**
-   * The click listener for the "save" button of each item in this container.
-   */
-  _onSaveButtonClick: function(e) {
-    let snapshotItem = this.getItemForElement(e.target);
-
-    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
-    fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeSave);
-    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
-    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
-    fp.defaultString = "snapshot.json";
-
-    // Start serializing all the function call actors for the specified snapshot,
-    // while the nsIFilePicker dialog is being opened. Snappy.
-    let serialized = Task.spawn(function*() {
-      let data = {
-        fileType: CALLS_LIST_SERIALIZER_IDENTIFIER,
-        version: CALLS_LIST_SERIALIZER_VERSION,
-        calls: [],
-        thumbnails: [],
-        screenshot: null
-      };
-      let functionCalls = snapshotItem.attachment.calls;
-      let thumbnails = snapshotItem.attachment.thumbnails;
-      let screenshot = snapshotItem.attachment.screenshot;
-
-      // Prepare all the function calls for serialization.
-      yield DevToolsUtils.yieldingEach(functionCalls, (call, i) => {
-        let { type, name, file, line, argsPreview, callerPreview } = call;
-        return call.getDetails().then(({ stack }) => {
-          data.calls[i] = {
-            type: type,
-            name: name,
-            file: file,
-            line: line,
-            stack: stack,
-            argsPreview: argsPreview,
-            callerPreview: callerPreview
-          };
-        });
-      });
-
-      // Prepare all the thumbnails for serialization.
-      yield DevToolsUtils.yieldingEach(thumbnails, (thumbnail, i) => {
-        let { index, width, height, flipped, pixels } = thumbnail;
-        data.thumbnails.push({ index, width, height, flipped, pixels });
-      });
-
-      // Prepare the screenshot for serialization.
-      let { index, width, height, flipped, pixels } = screenshot;
-      data.screenshot = { index, width, height, flipped, pixels };
-
-      let string = JSON.stringify(data);
-      let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
-        createInstance(Ci.nsIScriptableUnicodeConverter);
-
-      converter.charset = "UTF-8";
-      return converter.convertToInputStream(string);
-    });
-
-    // Open the nsIFilePicker and wait for the function call actors to finish
-    // being serialized, in order to save the generated JSON data to disk.
-    fp.open({ done: result => {
-      if (result == Ci.nsIFilePicker.returnCancel) {
-        return;
-      }
-      let footer = $(".snapshot-item-footer", snapshotItem.target);
-      let save = $(".snapshot-item-save", snapshotItem.target);
-
-      // Show a throbber and a "Saving…" label if serializing isn't immediate.
-      setNamedTimeout("call-list-save", CALLS_LIST_SLOW_SAVE_DELAY, () => {
-        footer.classList.add("devtools-throbber");
-        save.setAttribute("disabled", "true");
-        save.setAttribute("value", L10N.getStr("snapshotsList.savingLabel"));
-      });
-
-      serialized.then(inputStream => {
-        let outputStream = FileUtils.openSafeFileOutputStream(fp.file);
-
-        NetUtil.asyncCopy(inputStream, outputStream, status => {
-          if (!Components.isSuccessCode(status)) {
-            console.error("Could not save recorded animation frame snapshot file.");
-          }
-          clearNamedTimeout("call-list-save");
-          footer.classList.remove("devtools-throbber");
-          save.removeAttribute("disabled");
-          save.setAttribute("value", L10N.getStr("snapshotsList.saveLabel"));
-        });
-      });
-    }});
-  }
-});
-
-/**
- * Functions handling details about a single recorded animation frame snapshot
- * (the calls list, rendering preview, thumbnails filmstrip etc.).
- */
-let CallsListView = Heritage.extend(WidgetMethods, {
-  /**
-   * Initialization function, called when the tool is started.
-   */
-  initialize: function() {
-    this.widget = new SideMenuWidget($("#calls-list"));
-    this._slider = $("#calls-slider");
-    this._searchbox = $("#calls-searchbox");
-    this._filmstrip = $("#snapshot-filmstrip");
-
-    this._onSelect = this._onSelect.bind(this);
-    this._onSlideMouseDown = this._onSlideMouseDown.bind(this);
-    this._onSlideMouseUp = this._onSlideMouseUp.bind(this);
-    this._onSlide = this._onSlide.bind(this);
-    this._onSearch = this._onSearch.bind(this);
-    this._onScroll = this._onScroll.bind(this);
-    this._onExpand = this._onExpand.bind(this);
-    this._onStackFileClick = this._onStackFileClick.bind(this);
-    this._onThumbnailClick = this._onThumbnailClick.bind(this);
-
-    this.widget.addEventListener("select", this._onSelect, false);
-    this._slider.addEventListener("mousedown", this._onSlideMouseDown, false);
-    this._slider.addEventListener("mouseup", this._onSlideMouseUp, false);
-    this._slider.addEventListener("change", this._onSlide, false);
-    this._searchbox.addEventListener("input", this._onSearch, false);
-    this._filmstrip.addEventListener("wheel", this._onScroll, false);
-  },
-
-  /**
-   * Destruction function, called when the tool is closed.
-   */
-  destroy: function() {
-    this.widget.removeEventListener("select", this._onSelect, false);
-    this._slider.removeEventListener("mousedown", this._onSlideMouseDown, false);
-    this._slider.removeEventListener("mouseup", this._onSlideMouseUp, false);
-    this._slider.removeEventListener("change", this._onSlide, false);
-    this._searchbox.removeEventListener("input", this._onSearch, false);
-    this._filmstrip.removeEventListener("wheel", this._onScroll, false);
-  },
-
-  /**
-   * Populates this container with a list of function calls.
-   *
-   * @param array functionCalls
-   *        A list of function call actors received from the backend.
-   */
-  showCalls: function(functionCalls) {
-    this.empty();
-
-    for (let i = 0, len = functionCalls.length; i < len; i++) {
-      let call = functionCalls[i];
-
-      let view = document.createElement("vbox");
-      view.className = "call-item-view devtools-monospace";
-      view.setAttribute("flex", "1");
-
-      let contents = document.createElement("hbox");
-      contents.className = "call-item-contents";
-      contents.setAttribute("align", "center");
-      contents.addEventListener("dblclick", this._onExpand);
-      view.appendChild(contents);
-
-      let index = document.createElement("label");
-      index.className = "plain call-item-index";
-      index.setAttribute("flex", "1");
-      index.setAttribute("value", i + 1);
-
-      let gutter = document.createElement("hbox");
-      gutter.className = "call-item-gutter";
-      gutter.appendChild(index);
-      contents.appendChild(gutter);
-
-      // Not all function calls have a caller that was stringified (e.g.
-      // context calls have a "gl" or "ctx" caller preview).
-      if (call.callerPreview) {
-        let context = document.createElement("label");
-        context.className = "plain call-item-context";
-        context.setAttribute("value", call.callerPreview);
-        contents.appendChild(context);
-
-        let separator = document.createElement("label");
-        separator.className = "plain call-item-separator";
-        separator.setAttribute("value", ".");
-        contents.appendChild(separator);
-      }
-
-      let name = document.createElement("label");
-      name.className = "plain call-item-name";
-      name.setAttribute("value", call.name);
-      contents.appendChild(name);
-
-      let argsPreview = document.createElement("label");
-      argsPreview.className = "plain call-item-args";
-      argsPreview.setAttribute("crop", "end");
-      argsPreview.setAttribute("flex", "100");
-      // Getters and setters are displayed differently from regular methods.
-      if (call.type == CallWatcherFront.METHOD_FUNCTION) {
-        argsPreview.setAttribute("value", "(" + call.argsPreview + ")");
-      } else {
-        argsPreview.setAttribute("value", " = " + call.argsPreview);
-      }
-      contents.appendChild(argsPreview);
-
-      let location = document.createElement("label");
-      location.className = "plain call-item-location";
-      location.setAttribute("value", getFileName(call.file) + ":" + call.line);
-      location.setAttribute("crop", "start");
-      location.setAttribute("flex", "1");
-      location.addEventListener("mousedown", this._onExpand);
-      contents.appendChild(location);
-
-      // Append a function call item to this container.
-      this.push([view], {
-        staged: true,
-        attachment: {
-          actor: call
-        }
-      });
-
-      // Highlight certain calls that are probably more interesting than
-      // everything else, making it easier to quickly glance over them.
-      if (CanvasFront.DRAW_CALLS.has(call.name)) {
-        view.setAttribute("draw-call", "");
-      }
-      if (CanvasFront.INTERESTING_CALLS.has(call.name)) {
-        view.setAttribute("interesting-call", "");
-      }
-    }
-
-    // Flushes all the prepared function call items into this container.
-    this.commit();
-    window.emit(EVENTS.CALL_LIST_POPULATED);
-
-    // Resetting the function selection slider's value (shown in this
-    // container's toolbar) would trigger a selection event, which should be
-    // ignored in this case.
-    this._ignoreSliderChanges = true;
-    this._slider.value = 0;
-    this._slider.max = functionCalls.length - 1;
-    this._ignoreSliderChanges = false;
-  },
-
-  /**
-   * Displays an image in the rendering preview of this container, generated
-   * for the specified draw call in the recorded animation frame snapshot.
-   *
-   * @param array screenshot
-   *        A single "snapshot-image" instance received from the backend.
-   */
-  showScreenshot: function(screenshot) {
-    let { index, width, height, scaling, flipped, pixels } = screenshot;
-
-    let screenshotNode = $("#screenshot-image");
-    screenshotNode.setAttribute("flipped", flipped);
-    drawBackground("screenshot-rendering", width, height, pixels);
-
-    let dimensionsNode = $("#screenshot-dimensions");
-    let actualWidth = (width / scaling) | 0;
-    let actualHeight = (height / scaling) | 0;
-    dimensionsNode.setAttribute("value",
-      SHARED_L10N.getFormatStr("dimensions", actualWidth, actualHeight));
-
-    window.emit(EVENTS.CALL_SCREENSHOT_DISPLAYED);
-  },
-
-  /**
-   * Populates this container's footer with a list of thumbnails, one generated
-   * for each draw call in the recorded animation frame snapshot.
-   *
-   * @param array thumbnails
-   *        An array of "snapshot-image" instances received from the backend.
-   */
-  showThumbnails: function(thumbnails) {
-    while (this._filmstrip.hasChildNodes()) {
-      this._filmstrip.firstChild.remove();
-    }
-    for (let thumbnail of thumbnails) {
-      this.appendThumbnail(thumbnail);
-    }
-
-    window.emit(EVENTS.THUMBNAILS_DISPLAYED);
-  },
-
-  /**
-   * Displays an image in the thumbnails list of this container, generated
-   * for the specified draw call in the recorded animation frame snapshot.
-   *
-   * @param array thumbnail
-   *        A single "snapshot-image" instance received from the backend.
-   */
-  appendThumbnail: function(thumbnail) {
-    let { index, width, height, flipped, pixels } = thumbnail;
-
-    let thumbnailNode = document.createElementNS(HTML_NS, "canvas");
-    thumbnailNode.setAttribute("flipped", flipped);
-    thumbnailNode.width = Math.max(CanvasFront.THUMBNAIL_SIZE, width);
-    thumbnailNode.height = Math.max(CanvasFront.THUMBNAIL_SIZE, height);
-    drawImage(thumbnailNode, width, height, pixels, { centered: true });
-
-    thumbnailNode.className = "filmstrip-thumbnail";
-    thumbnailNode.onmousedown = e => this._onThumbnailClick(e, index);
-    thumbnailNode.setAttribute("index", index);
-    this._filmstrip.appendChild(thumbnailNode);
-  },
-
-  /**
-   * Sets the currently highlighted thumbnail in this container.
-   * A screenshot will always correlate to a thumbnail in the filmstrip,
-   * both being identified by the same 'index' of the context function call.
-   *
-   * @param number index
-   *        The context function call's index.
-   */
-  set highlightedThumbnail(index) {
-    let currHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + index + "']");
-    if (currHighlightedThumbnail == null) {
-      return;
-    }
-
-    let prevIndex = this._highlightedThumbnailIndex
-    let prevHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + prevIndex + "']");
-    if (prevHighlightedThumbnail) {
-      prevHighlightedThumbnail.removeAttribute("highlighted");
-    }
-
-    currHighlightedThumbnail.setAttribute("highlighted", "");
-    currHighlightedThumbnail.scrollIntoView();
-    this._highlightedThumbnailIndex = index;
-  },
-
-  /**
-   * Gets the currently highlighted thumbnail in this container.
-   * @return number
-   */
-  get highlightedThumbnail() {
-    return this._highlightedThumbnailIndex;
-  },
-
-  /**
-   * The select listener for this container.
-   */
-  _onSelect: function({ detail: callItem }) {
-    if (!callItem) {
-      return;
-    }
-
-    // Some of the stepping buttons don't make sense specifically while the
-    // last function call is selected.
-    if (this.selectedIndex == this.itemCount - 1) {
-      $("#resume").setAttribute("disabled", "true");
-      $("#step-over").setAttribute("disabled", "true");
-      $("#step-out").setAttribute("disabled", "true");
-    } else {
-      $("#resume").removeAttribute("disabled");
-      $("#step-over").removeAttribute("disabled");
-      $("#step-out").removeAttribute("disabled");
-    }
-
-    // Correlate the currently selected item with the function selection
-    // slider's value. Avoid triggering a redundant selection event.
-    this._ignoreSliderChanges = true;
-    this._slider.value = this.selectedIndex;
-    this._ignoreSliderChanges = false;
-
-    // Can't generate screenshots for function call actors loaded from disk.
-    // XXX: Bug 984844.
-    if (callItem.attachment.actor.isLoadedFromDisk) {
-      return;
-    }
-
-    // To keep continuous selection buttery smooth (for example, while pressing
-    // the DOWN key or moving the slider), only display the screenshot after
-    // any kind of user input stops.
-    setConditionalTimeout("screenshot-display", SCREENSHOT_DISPLAY_DELAY, () => {
-      return !this._isSliding;
-    }, () => {
-      let frameSnapshot = SnapshotsListView.selectedItem.attachment.actor
-      let functionCall = callItem.attachment.actor;
-      frameSnapshot.generateScreenshotFor(functionCall).then(screenshot => {
-        this.showScreenshot(screenshot);
-        this.highlightedThumbnail = screenshot.index;
-      }).catch(Cu.reportError);
-    });
-  },
-
-  /**
-   * The mousedown listener for the call selection slider.
-   */
-  _onSlideMouseDown: function() {
-    this._isSliding = true;
-  },
-
-  /**
-   * The mouseup listener for the call selection slider.
-   */
-  _onSlideMouseUp: function() {
-    this._isSliding = false;
-  },
-
-  /**
-   * The change listener for the call selection slider.
-   */
-  _onSlide: function() {
-    // Avoid performing any operations when programatically changing the value.
-    if (this._ignoreSliderChanges) {
-      return;
-    }
-    let selectedFunctionCallIndex = this.selectedIndex = this._slider.value;
-
-    // While sliding, immediately show the most relevant thumbnail for a
-    // function call, for a nice diff-like animation effect between draws.
-    let thumbnails = SnapshotsListView.selectedItem.attachment.thumbnails;
-    let thumbnail = getThumbnailForCall(thumbnails, selectedFunctionCallIndex);
-
-    // Avoid drawing and highlighting if the selected function call has the
-    // same thumbnail as the last one.
-    if (thumbnail.index == this.highlightedThumbnail) {
-      return;
-    }
-    // If a thumbnail wasn't found (e.g. the backend avoids creating thumbnails
-    // when rendering offscreen), simply defer to the first available one.
-    if (thumbnail.index == -1) {
-      thumbnail = thumbnails[0];
-    }
-
-    let { index, width, height, flipped, pixels } = thumbnail;
-    this.highlightedThumbnail = index;
-
-    let screenshotNode = $("#screenshot-image");
-    screenshotNode.setAttribute("flipped", flipped);
-    drawBackground("screenshot-rendering", width, height, pixels);
-  },
-
-  /**
-   * The input listener for the calls searchbox.
-   */
-  _onSearch: function(e) {
-    let lowerCaseSearchToken = this._searchbox.value.toLowerCase();
-
-    this.filterContents(e => {
-      let call = e.attachment.actor;
-      let name = call.name.toLowerCase();
-      let file = call.file.toLowerCase();
-      let line = call.line.toString().toLowerCase();
-      let args = call.argsPreview.toLowerCase();
-
-      return name.contains(lowerCaseSearchToken) ||
-             file.contains(lowerCaseSearchToken) ||
-             line.contains(lowerCaseSearchToken) ||
-             args.contains(lowerCaseSearchToken);
-    });
-  },
-
-  /**
-   * The wheel listener for the filmstrip that contains all the thumbnails.
-   */
-  _onScroll: function(e) {
-    this._filmstrip.scrollLeft += e.deltaX;
-  },
-
-  /**
-   * The click/dblclick listener for an item or location url in this container.
-   * When expanding an item, it's corresponding call stack will be displayed.
-   */
-  _onExpand: function(e) {
-    let callItem = this.getItemForElement(e.target);
-    let view = $(".call-item-view", callItem.target);
-
-    // If the call stack nodes were already created, simply re-show them
-    // or jump to the corresponding file and line in the Debugger if a
-    // location link was clicked.
-    if (view.hasAttribute("call-stack-populated")) {
-      let isExpanded = view.getAttribute("call-stack-expanded") == "true";
-
-      // If clicking on the location, jump to the Debugger.
-      if (e.target.classList.contains("call-item-location")) {
-        let { file, line } = callItem.attachment.actor;
-        viewSourceInDebugger(file, line);
-        return;
-      }
-      // Otherwise hide the call stack.
-      else {
-        view.setAttribute("call-stack-expanded", !isExpanded);
-        $(".call-item-stack", view).hidden = isExpanded;
-        return;
-      }
-    }
-
-    let list = document.createElement("vbox");
-    list.className = "call-item-stack";
-    view.setAttribute("call-stack-populated", "");
-    view.setAttribute("call-stack-expanded", "true");
-    view.appendChild(list);
-
-    /**
-     * Creates a function call nodes in this container for a stack.
-     */
-    let display = stack => {
-      for (let i = 1; i < stack.length; i++) {
-        let call = stack[i];
-
-        let contents = document.createElement("hbox");
-        contents.className = "call-item-stack-fn";
-        contents.style.MozPaddingStart = (i * STACK_FUNC_INDENTATION) + "px";
-
-        let name = document.createElement("label");
-        name.className = "plain call-item-stack-fn-name";
-        name.setAttribute("value", "↳ " + call.name + "()");
-        contents.appendChild(name);
-
-        let spacer = document.createElement("spacer");
-        spacer.setAttribute("flex", "100");
-        contents.appendChild(spacer);
-
-        let location = document.createElement("label");
-        location.className = "plain call-item-stack-fn-location";
-        location.setAttribute("value", getFileName(call.file) + ":" + call.line);
-        location.setAttribute("crop", "start");
-        location.setAttribute("flex", "1");
-        location.addEventListener("mousedown", e => this._onStackFileClick(e, call));
-        contents.appendChild(location);
-
-        list.appendChild(contents);
-      }
-
-      window.emit(EVENTS.CALL_STACK_DISPLAYED);
-    };
-
-    // If this animation snapshot is loaded from disk, there are no corresponding
-    // backend actors available and the data is immediately available.
-    let functionCall = callItem.attachment.actor;
-    if (functionCall.isLoadedFromDisk) {
-      display(functionCall.stack);
-    }
-    // ..otherwise we need to request the function call stack from the backend.
-    else {
-      callItem.attachment.actor.getDetails().then(fn => display(fn.stack));
-    }
-  },
-
-  /**
-   * The click listener for a location link in the call stack.
-   *
-   * @param string file
-   *        The url of the source owning the function.
-   * @param number line
-   *        The line of the respective function.
-   */
-  _onStackFileClick: function(e, { file, line }) {
-    viewSourceInDebugger(file, line);
-  },
-
-  /**
-   * The click listener for a thumbnail in the filmstrip.
-   *
-   * @param number index
-   *        The function index in the recorded animation frame snapshot.
-   */
-  _onThumbnailClick: function(e, index) {
-    this.selectedIndex = index;
-  },
-
-  /**
-   * The click listener for the "resume" button in this container's toolbar.
-   */
-  _onResume: function() {
-    // Jump to the next draw call in the recorded animation frame snapshot.
-    let drawCall = getNextDrawCall(this.items, this.selectedItem);
-    if (drawCall) {
-      this.selectedItem = drawCall;
-      return;
-    }
-
-    // If there are no more draw calls, just jump to the last context call.
-    this._onStepOut();
-  },
-
-  /**
-   * The click listener for the "step over" button in this container's toolbar.
-   */
-  _onStepOver: function() {
-    this.selectedIndex++;
-  },
-
-  /**
-   * The click listener for the "step in" button in this container's toolbar.
-   */
-  _onStepIn: function() {
-    if (this.selectedIndex == -1) {
-      this._onResume();
-      return;
-    }
-    let callItem = this.selectedItem;
-    let { file, line } = callItem.attachment.actor;
-    viewSourceInDebugger(file, line);
-  },
-
-  /**
-   * The click listener for the "step out" button in this container's toolbar.
-   */
-  _onStepOut: function() {
-    this.selectedIndex = this.itemCount - 1;
-  }
-});
-
-/**
  * Localization convenience methods.
  */
 let L10N = new ViewHelpers.L10N(STRINGS_URI);
 let SHARED_L10N = new ViewHelpers.L10N(SHARED_STRINGS_URI);
 
 /**
  * Convenient way of emitting events from the panel window.
  */
--- a/browser/devtools/canvasdebugger/canvasdebugger.xul
+++ b/browser/devtools/canvasdebugger/canvasdebugger.xul
@@ -10,16 +10,18 @@
 <!DOCTYPE window [
   <!ENTITY % canvasDebuggerDTD SYSTEM "chrome://browser/locale/devtools/canvasdebugger.dtd">
   %canvasDebuggerDTD;
 ]>
 
 <window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
   <script src="chrome://browser/content/devtools/theme-switching.js"/>
   <script type="application/javascript" src="canvasdebugger.js"/>
+  <script type="application/javascript" src="canvasdebugger/callslist.js"/>
+  <script type="application/javascript" src="canvasdebugger/snapshotslist.js"/>
 
   <hbox class="theme-body" flex="1">
     <vbox id="snapshots-pane">
       <toolbar id="snapshots-toolbar"
                class="devtools-toolbar">
         <hbox id="snapshots-controls">
           <toolbarbutton id="record-snapshot"
                          class="devtools-toolbarbutton"
@@ -66,23 +68,25 @@
         <label value="&canvasDebuggerUI.emptyNotice1;"/>
         <button id="canvas-debugging-empty-notice-button"
                 class="devtools-toolbarbutton"
                 standalone="true"
                 oncommand="SnapshotsListView._onRecordButtonClick()"/>
         <label value="&canvasDebuggerUI.emptyNotice2;"/>
       </hbox>
 
-      <hbox id="import-notice"
-            class="notice-container"
+      <hbox id="waiting-notice"
+            class="notice-container devtools-throbber"
             align="center"
             pack="center"
             flex="1"
             hidden="true">
-        <label value="&canvasDebuggerUI.importNotice;"/>
+        <label id="requests-menu-waiting-notice-label"
+               class="plain"
+               value="&canvasDebuggerUI.waitingNotice;"/>
       </hbox>
 
       <box id="debugging-pane-contents"
            class="devtools-responsive-container"
            flex="1"
            hidden="true">
         <vbox id="calls-list-container" flex="1">
           <toolbar id="debugging-toolbar"
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/snapshotslist.js
@@ -0,0 +1,496 @@
+/* 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";
+
+/**
+ * Functions handling the recorded animation frame snapshots UI.
+ */
+let SnapshotsListView = Heritage.extend(WidgetMethods, {
+  /**
+   * Initialization function, called when the tool is started.
+   */
+  initialize: function() {
+    this.widget = new SideMenuWidget($("#snapshots-list"), {
+      showArrows: true
+    });
+
+    this._onSelect = this._onSelect.bind(this);
+    this._onClearButtonClick = this._onClearButtonClick.bind(this);
+    this._onRecordButtonClick = this._onRecordButtonClick.bind(this);
+    this._onImportButtonClick = this._onImportButtonClick.bind(this);
+    this._onSaveButtonClick = this._onSaveButtonClick.bind(this);
+    this._onRecordSuccess = this._onRecordSuccess.bind(this);
+    this._onRecordFailure = this._onRecordFailure.bind(this);
+    this._stopRecordingAnimation = this._stopRecordingAnimation.bind(this);
+
+    window.on(EVENTS.SNAPSHOT_RECORDING_FINISHED, this._enableRecordButton);
+    this.emptyText = L10N.getStr("noSnapshotsText");
+    this.widget.addEventListener("select", this._onSelect, false);
+  },
+
+  /**
+   * Destruction function, called when the tool is closed.
+   */
+  destroy: function() {
+    clearNamedTimeout("canvas-actor-recording");
+    window.off(EVENTS.SNAPSHOT_RECORDING_FINISHED, this._enableRecordButton);
+    this.widget.removeEventListener("select", this._onSelect, false);
+  },
+
+  /**
+   * Adds a snapshot entry to this container.
+   *
+   * @return object
+   *         The newly inserted item.
+   */
+  addSnapshot: function() {
+    let contents = document.createElement("hbox");
+    contents.className = "snapshot-item";
+
+    let thumbnail = document.createElementNS(HTML_NS, "canvas");
+    thumbnail.className = "snapshot-item-thumbnail";
+    thumbnail.width = CanvasFront.THUMBNAIL_SIZE;
+    thumbnail.height = CanvasFront.THUMBNAIL_SIZE;
+
+    let title = document.createElement("label");
+    title.className = "plain snapshot-item-title";
+    title.setAttribute("value",
+      L10N.getFormatStr("snapshotsList.itemLabel", this.itemCount + 1));
+
+    let calls = document.createElement("label");
+    calls.className = "plain snapshot-item-calls";
+    calls.setAttribute("value",
+      L10N.getStr("snapshotsList.loadingLabel"));
+
+    let save = document.createElement("label");
+    save.className = "plain snapshot-item-save";
+    save.addEventListener("click", this._onSaveButtonClick, false);
+
+    let spacer = document.createElement("spacer");
+    spacer.setAttribute("flex", "1");
+
+    let footer = document.createElement("hbox");
+    footer.className = "snapshot-item-footer";
+    footer.appendChild(save);
+
+    let details = document.createElement("vbox");
+    details.className = "snapshot-item-details";
+    details.appendChild(title);
+    details.appendChild(calls);
+    details.appendChild(spacer);
+    details.appendChild(footer);
+
+    contents.appendChild(thumbnail);
+    contents.appendChild(details);
+
+    // Append a recorded snapshot item to this container.
+    return this.push([contents], {
+      attachment: {
+        // The snapshot and function call actors, along with the thumbnails
+        // will be available as soon as recording finishes.
+        actor: null,
+        calls: null,
+        thumbnails: null,
+        screenshot: null
+      }
+    });
+  },
+
+  /**
+   * Removes the last snapshot added, in the event no requestAnimationFrame loop was found.
+   */
+  removeLastSnapshot: function () {
+    this.removeAt(this.itemCount - 1);
+    // If this is the only item, revert back to the empty notice
+    if (this.itemCount === 0) {
+      $("#empty-notice").hidden = false;
+      $("#waiting-notice").hidden = true;
+    }
+  },
+
+  /**
+   * Customizes a shapshot in this container.
+   *
+   * @param Item snapshotItem
+   *        An item inserted via `SnapshotsListView.addSnapshot`.
+   * @param object snapshotActor
+   *        The frame snapshot actor received from the backend.
+   * @param object snapshotOverview
+   *        Additional data about the snapshot received from the backend.
+   */
+  customizeSnapshot: function(snapshotItem, snapshotActor, snapshotOverview) {
+    // Make sure the function call actors are stored on the item,
+    // to be used when populating the CallsListView.
+    snapshotItem.attachment.actor = snapshotActor;
+    let functionCalls = snapshotItem.attachment.calls = snapshotOverview.calls;
+    let thumbnails = snapshotItem.attachment.thumbnails = snapshotOverview.thumbnails;
+    let screenshot = snapshotItem.attachment.screenshot = snapshotOverview.screenshot;
+
+    let lastThumbnail = thumbnails[thumbnails.length - 1];
+    let { width, height, flipped, pixels } = lastThumbnail;
+
+    let thumbnailNode = $(".snapshot-item-thumbnail", snapshotItem.target);
+    thumbnailNode.setAttribute("flipped", flipped);
+    drawImage(thumbnailNode, width, height, pixels, { centered: true });
+
+    let callsNode = $(".snapshot-item-calls", snapshotItem.target);
+    let drawCalls = functionCalls.filter(e => CanvasFront.DRAW_CALLS.has(e.name));
+
+    let drawCallsStr = PluralForm.get(drawCalls.length,
+      L10N.getStr("snapshotsList.drawCallsLabel"));
+    let funcCallsStr = PluralForm.get(functionCalls.length,
+      L10N.getStr("snapshotsList.functionCallsLabel"));
+
+    callsNode.setAttribute("value",
+      drawCallsStr.replace("#1", drawCalls.length) + ", " +
+      funcCallsStr.replace("#1", functionCalls.length));
+
+    let saveNode = $(".snapshot-item-save", snapshotItem.target);
+    saveNode.setAttribute("disabled", !!snapshotItem.isLoadedFromDisk);
+    saveNode.setAttribute("value", snapshotItem.isLoadedFromDisk
+      ? L10N.getStr("snapshotsList.loadedLabel")
+      : L10N.getStr("snapshotsList.saveLabel"));
+
+    // Make sure there's always a selected item available.
+    if (!this.selectedItem) {
+      this.selectedIndex = 0;
+    }
+  },
+
+  /**
+   * The select listener for this container.
+   */
+  _onSelect: function({ detail: snapshotItem }) {
+    if (!snapshotItem) {
+      return;
+    }
+    let { calls, thumbnails, screenshot } = snapshotItem.attachment;
+
+    $("#reload-notice").hidden = true;
+    $("#empty-notice").hidden = true;
+    $("#waiting-notice").hidden = false;
+
+    $("#debugging-pane-contents").hidden = true;
+    $("#screenshot-container").hidden = true;
+    $("#snapshot-filmstrip").hidden = true;
+
+    Task.spawn(function*() {
+      // Wait for a few milliseconds between presenting the function calls,
+      // screenshot and thumbnails, to allow each component being
+      // sequentially drawn. This gives the illusion of snappiness.
+
+      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
+      CallsListView.showCalls(calls);
+      $("#debugging-pane-contents").hidden = false;
+      $("#waiting-notice").hidden = true;
+
+      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
+      CallsListView.showThumbnails(thumbnails);
+      $("#snapshot-filmstrip").hidden = false;
+
+      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
+      CallsListView.showScreenshot(screenshot);
+      $("#screenshot-container").hidden = false;
+
+      window.emit(EVENTS.SNAPSHOT_RECORDING_SELECTED);
+    });
+  },
+
+  /**
+   * The click listener for the "clear" button in this container.
+   */
+  _onClearButtonClick: function() {
+    Task.spawn(function*() {
+      SnapshotsListView.empty();
+      CallsListView.empty();
+
+      $("#reload-notice").hidden = true;
+      $("#empty-notice").hidden = true;
+      $("#waiting-notice").hidden = true;
+
+      if (yield gFront.isInitialized()) {
+        $("#empty-notice").hidden = false;
+      } else {
+        $("#reload-notice").hidden = false;
+      }
+
+      $("#debugging-pane-contents").hidden = true;
+      $("#screenshot-container").hidden = true;
+      $("#snapshot-filmstrip").hidden = true;
+
+      window.emit(EVENTS.SNAPSHOTS_LIST_CLEARED);
+    });
+  },
+
+  /**
+   * The click listener for the "record" button in this container.
+   */
+  _onRecordButtonClick: function () {
+    this._disableRecordButton();
+
+    if (this._recording) {
+      this._stopRecordingAnimation();
+      return;
+    }
+
+    // Insert a "dummy" snapshot item in the view, to hint that recording
+    // has now started. However, wait for a few milliseconds before actually
+    // starting the recording, since that might block rendering and prevent
+    // the dummy snapshot item from being drawn.
+    this.addSnapshot();
+
+    // If this is the first item, immediately show the "Loading…" notice.
+    if (this.itemCount == 1) {
+      $("#empty-notice").hidden = true;
+      $("#waiting-notice").hidden = false;
+    }
+
+    this._recordAnimation();
+  },
+
+  /**
+   * Makes the record button able to be clicked again.
+   */
+  _enableRecordButton: function () {
+    $("#record-snapshot").removeAttribute("disabled");
+  },
+
+  /**
+   * Makes the record button unable to be clicked.
+   */
+  _disableRecordButton: function () {
+    $("#record-snapshot").setAttribute("disabled", true);
+  },
+
+  /**
+   * Begins recording an animation.
+   */
+  _recordAnimation: Task.async(function *() {
+    if (this._recording) {
+      return;
+    }
+    this._recording = true;
+    $("#record-snapshot").setAttribute("checked", "true");
+
+    setNamedTimeout("canvas-actor-recording", CANVAS_ACTOR_RECORDING_ATTEMPT, this._stopRecordingAnimation);
+
+    yield DevToolsUtils.waitForTime(SNAPSHOT_START_RECORDING_DELAY);
+    window.emit(EVENTS.SNAPSHOT_RECORDING_STARTED);
+
+    gFront.recordAnimationFrame().then(snapshot => {
+      if (snapshot) {
+        this._onRecordSuccess(snapshot);
+      } else {
+        this._onRecordFailure();
+      }
+    });
+
+    // Wait another delay before reenabling the button to stop the recording
+    // if a recording is not found.
+    yield DevToolsUtils.waitForTime(SNAPSHOT_START_RECORDING_DELAY);
+    this._enableRecordButton();
+  }),
+
+  /**
+   * Stops recording animation. Called when a click on the stopwatch occurs during a recording,
+   * or if a recording times out.
+   */
+  _stopRecordingAnimation: Task.async(function *() {
+    clearNamedTimeout("canvas-actor-recording");
+    let actorCanStop = yield gTarget.actorHasMethod("canvas", "stopRecordingAnimationFrame");
+
+    if (actorCanStop) {
+      yield gFront.stopRecordingAnimationFrame();
+    }
+    // If actor does not have the method to stop recording (Fx39+),
+    // manually call the record failure method. This will call a connection failure
+    // on disconnect as a result of `gFront.recordAnimationFrame()` never resolving,
+    // but this is better than it hanging when there is no requestAnimationFrame anyway.
+    else {
+      this._onRecordFailure();
+    }
+
+    this._recording = false;
+    $("#record-snapshot").removeAttribute("checked");
+    this._enableRecordButton();
+  }),
+
+  /**
+   * Resolves from the front's recordAnimationFrame to setup the interface with the screenshots.
+   */
+  _onRecordSuccess: Task.async(function *(snapshotActor) {
+    // Clear bail-out case if frame found in CANVAS_ACTOR_RECORDING_ATTEMPT milliseconds
+    clearNamedTimeout("canvas-actor-recording");
+    let snapshotItem = this.getItemAtIndex(this.itemCount - 1);
+    let snapshotOverview = yield snapshotActor.getOverview();
+    this.customizeSnapshot(snapshotItem, snapshotActor, snapshotOverview);
+
+    this._recording = false;
+    $("#record-snapshot").removeAttribute("checked");
+
+    window.emit(EVENTS.SNAPSHOT_RECORDING_COMPLETED);
+    window.emit(EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  }),
+
+  /**
+   * Called as a reject from the front's recordAnimationFrame.
+   */
+  _onRecordFailure: function () {
+    clearNamedTimeout("canvas-actor-recording");
+    showNotification(gToolbox, "canvas-debugger-timeout", L10N.getStr("recordingTimeoutFailure"));
+    window.emit(EVENTS.SNAPSHOT_RECORDING_CANCELLED);
+    window.emit(EVENTS.SNAPSHOT_RECORDING_FINISHED);
+    this.removeLastSnapshot();
+  },
+
+  /**
+   * The click listener for the "import" button in this container.
+   */
+  _onImportButtonClick: function() {
+    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+    fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeOpen);
+    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
+    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
+
+    if (fp.show() != Ci.nsIFilePicker.returnOK) {
+      return;
+    }
+
+    let channel = NetUtil.newChannel2(fp.file,
+                                      null,
+                                      null,
+                                      window.document,
+                                      null, // aLoadingPrincipal
+                                      null, // aTriggeringPrincipal
+                                      Ci.nsILoadInfo.SEC_NORMAL,
+                                      Ci.nsIContentPolicy.TYPE_OTHER);
+    channel.contentType = "text/plain";
+
+    NetUtil.asyncFetch2(channel, (inputStream, status) => {
+      if (!Components.isSuccessCode(status)) {
+        console.error("Could not import recorded animation frame snapshot file.");
+        return;
+      }
+      try {
+        let string = NetUtil.readInputStreamToString(inputStream, inputStream.available());
+        var data = JSON.parse(string);
+      } catch (e) {
+        console.error("Could not read animation frame snapshot file.");
+        return;
+      }
+      if (data.fileType != CALLS_LIST_SERIALIZER_IDENTIFIER) {
+        console.error("Unrecognized animation frame snapshot file.");
+        return;
+      }
+
+      // Add a `isLoadedFromDisk` flag on everything to avoid sending invalid
+      // requests to the backend, since we're not dealing with actors anymore.
+      let snapshotItem = this.addSnapshot();
+      snapshotItem.isLoadedFromDisk = true;
+      data.calls.forEach(e => e.isLoadedFromDisk = true);
+
+      this.customizeSnapshot(snapshotItem, data.calls, data);
+    });
+  },
+
+  /**
+   * The click listener for the "save" button of each item in this container.
+   */
+  _onSaveButtonClick: function(e) {
+    let snapshotItem = this.getItemForElement(e.target);
+
+    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+    fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeSave);
+    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
+    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
+    fp.defaultString = "snapshot.json";
+
+    // Start serializing all the function call actors for the specified snapshot,
+    // while the nsIFilePicker dialog is being opened. Snappy.
+    let serialized = Task.spawn(function*() {
+      let data = {
+        fileType: CALLS_LIST_SERIALIZER_IDENTIFIER,
+        version: CALLS_LIST_SERIALIZER_VERSION,
+        calls: [],
+        thumbnails: [],
+        screenshot: null
+      };
+      let functionCalls = snapshotItem.attachment.calls;
+      let thumbnails = snapshotItem.attachment.thumbnails;
+      let screenshot = snapshotItem.attachment.screenshot;
+
+      // Prepare all the function calls for serialization.
+      yield DevToolsUtils.yieldingEach(functionCalls, (call, i) => {
+        let { type, name, file, line, argsPreview, callerPreview } = call;
+        return call.getDetails().then(({ stack }) => {
+          data.calls[i] = {
+            type: type,
+            name: name,
+            file: file,
+            line: line,
+            stack: stack,
+            argsPreview: argsPreview,
+            callerPreview: callerPreview
+          };
+        });
+      });
+
+      // Prepare all the thumbnails for serialization.
+      yield DevToolsUtils.yieldingEach(thumbnails, (thumbnail, i) => {
+        let { index, width, height, flipped, pixels } = thumbnail;
+        data.thumbnails.push({ index, width, height, flipped, pixels });
+      });
+
+      // Prepare the screenshot for serialization.
+      let { index, width, height, flipped, pixels } = screenshot;
+      data.screenshot = { index, width, height, flipped, pixels };
+
+      let string = JSON.stringify(data);
+      let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
+        createInstance(Ci.nsIScriptableUnicodeConverter);
+
+      converter.charset = "UTF-8";
+      return converter.convertToInputStream(string);
+    });
+
+    // Open the nsIFilePicker and wait for the function call actors to finish
+    // being serialized, in order to save the generated JSON data to disk.
+    fp.open({ done: result => {
+      if (result == Ci.nsIFilePicker.returnCancel) {
+        return;
+      }
+      let footer = $(".snapshot-item-footer", snapshotItem.target);
+      let save = $(".snapshot-item-save", snapshotItem.target);
+
+      // Show a throbber and a "Saving…" label if serializing isn't immediate.
+      setNamedTimeout("call-list-save", CALLS_LIST_SLOW_SAVE_DELAY, () => {
+        footer.classList.add("devtools-throbber");
+        save.setAttribute("disabled", "true");
+        save.setAttribute("value", L10N.getStr("snapshotsList.savingLabel"));
+      });
+
+      serialized.then(inputStream => {
+        let outputStream = FileUtils.openSafeFileOutputStream(fp.file);
+
+        NetUtil.asyncCopy(inputStream, outputStream, status => {
+          if (!Components.isSuccessCode(status)) {
+            console.error("Could not save recorded animation frame snapshot file.");
+          }
+          clearNamedTimeout("call-list-save");
+          footer.classList.remove("devtools-throbber");
+          save.removeAttribute("disabled");
+          save.setAttribute("value", L10N.getStr("snapshotsList.saveLabel"));
+        });
+      });
+    }});
+  }
+});
+
+function showNotification (toolbox, name, message) {
+  let notificationBox = toolbox.getNotificationBox();
+  let notification = notificationBox.getNotificationWithValue(name);
+  if (!notification) {
+    notificationBox.appendNotification(message, name, "", notificationBox.PRIORITY_WARNING_HIGH);
+  }
+}
--- a/browser/devtools/canvasdebugger/test/browser.ini
+++ b/browser/devtools/canvasdebugger/test/browser.ini
@@ -1,13 +1,14 @@
 [DEFAULT]
 subsuite = devtools
 support-files =
   doc_raf-begin.html
   doc_settimeout.html
+  doc_no-canvas.html
   doc_simple-canvas.html
   doc_simple-canvas-bitmasks.html
   doc_simple-canvas-deep-stack.html
   doc_simple-canvas-transparent.html
   doc_webgl-bindings.html
   doc_webgl-enum.html
   head.js
 
@@ -17,16 +18,17 @@ support-files =
 [browser_canvas-actor-test-04.js]
 [browser_canvas-actor-test-05.js]
 [browser_canvas-actor-test-06.js]
 [browser_canvas-actor-test-07.js]
 [browser_canvas-actor-test-08.js]
 [browser_canvas-actor-test-09.js]
 [browser_canvas-actor-test-10.js]
 [browser_canvas-actor-test-11.js]
+[browser_canvas-actor-test-12.js]
 [browser_canvas-frontend-call-highlight.js]
 [browser_canvas-frontend-call-list.js]
 [browser_canvas-frontend-call-search.js]
 [browser_canvas-frontend-call-stack-01.js]
 [browser_canvas-frontend-call-stack-02.js]
 [browser_canvas-frontend-call-stack-03.js]
 [browser_canvas-frontend-clear.js]
 [browser_canvas-frontend-img-screenshots.js]
@@ -39,8 +41,10 @@ skip-if = e10s # bug 1102301 - leaks whi
 [browser_canvas-frontend-record-03.js]
 [browser_canvas-frontend-record-04.js]
 [browser_canvas-frontend-reload-01.js]
 [browser_canvas-frontend-reload-02.js]
 [browser_canvas-frontend-slider-01.js]
 [browser_canvas-frontend-slider-02.js]
 [browser_canvas-frontend-snapshot-select.js]
 [browser_canvas-frontend-stepping.js]
+[browser_canvas-frontend-stop-01.js]
+[browser_canvas-frontend-stop-02.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-actor-test-12.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that the recording can be disabled via stopRecordingAnimationFrame
+ * in the event no rAF loop is found.
+ */
+
+function ifTestingSupported() {
+  let { target, front } = yield initCanvasDebuggerBackend(NO_CANVAS_URL);
+  loadFrameScripts();
+
+  let navigated = once(target, "navigate");
+
+  yield front.setup({ reload: true });
+  ok(true, "The front was setup up successfully.");
+
+  yield navigated;
+  ok(true, "Target automatically navigated when the front was set up.");
+
+  let startRecording = front.recordAnimationFrame();
+  yield front.stopRecordingAnimationFrame();
+
+  ok(!(yield startRecording),
+    "recordAnimationFrame() does not return a SnapshotActor when cancelled.");
+
+  yield removeTab(target.tab);
+  finish();
+}
--- a/browser/devtools/canvasdebugger/test/browser_canvas-frontend-open.js
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-open.js
@@ -20,18 +20,18 @@ function ifTestingSupported() {
     "The 'import snapshot' button should initially be visible.");
   is($("#clear-snapshots").hasAttribute("hidden"), false,
     "The 'clear snapshots' button should initially be visible.");
 
   is($("#reload-notice").hasAttribute("hidden"), false,
     "The reload notice should initially be visible.");
   is($("#empty-notice").getAttribute("hidden"), "true",
     "The empty notice should initially be hidden.");
-  is($("#import-notice").getAttribute("hidden"), "true",
-    "The import notice should initially be hidden.");
+  is($("#waiting-notice").getAttribute("hidden"), "true",
+    "The waiting notice should initially be hidden.");
 
   is($("#screenshot-container").getAttribute("hidden"), "true",
     "The screenshot container should initially be hidden.");
   is($("#snapshot-filmstrip").getAttribute("hidden"), "true",
     "The snapshot filmstrip should initially be hidden.");
 
   is($("#debugging-pane-contents").getAttribute("hidden"), "true",
     "The rest of the UI should initially be hidden.");
--- a/browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-01.js
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-01.js
@@ -27,18 +27,16 @@ function ifTestingSupported() {
   let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
   SnapshotsListView._onRecordButtonClick();
 
   yield recordingStarted;
   ok(true, "Started recording a snapshot of the animation loop.");
 
   is($("#record-snapshot").getAttribute("checked"), "true",
     "The 'record snapshot' button should now be checked.");
-  is($("#record-snapshot").getAttribute("disabled"), "true",
-    "The 'record snapshot' button should now be disabled.");
   is($("#record-snapshot").hasAttribute("hidden"), false,
     "The 'record snapshot' button should still be visible.");
 
   is(SnapshotsListView.itemCount, 1,
     "There should be one item available in the snapshots list view now.");
   is(SnapshotsListView.selectedIndex, -1,
     "There should be no selected item in the snapshots list view yet.");
 
--- a/browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-02.js
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-record-02.js
@@ -31,18 +31,18 @@ function ifTestingSupported() {
 
   is($(".snapshot-item-save", item.target).getAttribute("value"), "",
     "The placeholder item's save label should not have a value yet.");
 
   is($("#reload-notice").getAttribute("hidden"), "true",
     "The reload notice should now be hidden.");
   is($("#empty-notice").getAttribute("hidden"), "true",
     "The empty notice should now be hidden.");
-  is($("#import-notice").hasAttribute("hidden"), false,
-    "The import notice should now be visible.");
+  is($("#waiting-notice").hasAttribute("hidden"), false,
+    "The waiting notice should now be visible.");
 
   is($("#screenshot-container").getAttribute("hidden"), "true",
     "The screenshot container should still be hidden.");
   is($("#snapshot-filmstrip").getAttribute("hidden"), "true",
     "The snapshot filmstrip should still be hidden.");
 
   is($("#debugging-pane-contents").getAttribute("hidden"), "true",
     "The rest of the UI should still be hidden.");
@@ -52,18 +52,18 @@ function ifTestingSupported() {
 
   yield recordingSelected;
   ok(true, "Finished selecting a snapshot of the animation loop.");
 
   is($("#reload-notice").getAttribute("hidden"), "true",
     "The reload notice should now be hidden.");
   is($("#empty-notice").getAttribute("hidden"), "true",
     "The empty notice should now be hidden.");
-  is($("#import-notice").getAttribute("hidden"), "true",
-    "The import notice should now be hidden.");
+  is($("#waiting-notice").getAttribute("hidden"), "true",
+    "The waiting notice should now be hidden.");
 
   is($("#screenshot-container").hasAttribute("hidden"), false,
     "The screenshot container should now be visible.");
   is($("#snapshot-filmstrip").hasAttribute("hidden"), false,
     "The snapshot filmstrip should now be visible.");
 
   is($("#debugging-pane-contents").hasAttribute("hidden"), false,
     "The rest of the UI should now be visible.");
--- a/browser/devtools/canvasdebugger/test/browser_canvas-frontend-reload-01.js
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-reload-01.js
@@ -34,18 +34,18 @@ function ifTestingSupported() {
     "The 'import snapshot' button should still be visible.");
   is($("#clear-snapshots").hasAttribute("hidden"), false,
     "The 'clear snapshots' button should still be visible.");
 
   is($("#reload-notice").getAttribute("hidden"), "true",
     "The reload notice should now be hidden.");
   is($("#empty-notice").hasAttribute("hidden"), false,
     "The empty notice should now be visible.");
-  is($("#import-notice").getAttribute("hidden"), "true",
-    "The import notice should now be hidden.");
+  is($("#waiting-notice").getAttribute("hidden"), "true",
+    "The waiting notice should now be hidden.");
 
   is($("#snapshot-filmstrip").getAttribute("hidden"), "true",
     "The snapshot filmstrip should still be hidden.");
   is($("#screenshot-container").getAttribute("hidden"), "true",
     "The screenshot container should still be hidden.");
 
   is($("#debugging-pane-contents").getAttribute("hidden"), "true",
     "The rest of the UI should still be hidden.");
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-stop-01.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that you can stop a recording that does not have a rAF cycle.
+ */
+
+function ifTestingSupported() {
+  let { target, panel } = yield initCanvasDebuggerFrontend(NO_CANVAS_URL);
+  let { window, EVENTS, $, SnapshotsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingStarted = once(window, EVENTS.SNAPSHOT_RECORDING_STARTED);
+  SnapshotsListView._onRecordButtonClick();
+
+  yield recordingStarted;
+
+  is($("#empty-notice").hidden, true, "Empty notice not shown");
+  is($("#waiting-notice").hidden, false, "Waiting notice shown");
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let recordingCancelled = once(window, EVENTS.SNAPSHOT_RECORDING_CANCELLED);
+  SnapshotsListView._onRecordButtonClick();
+
+  yield promise.all([recordingFinished, recordingCancelled]);
+
+  ok(true, "Recording stopped and was considered failed.");
+
+  is(SnapshotsListView.itemCount, 0, "No snapshots in the list.");
+  is($("#empty-notice").hidden, false, "Empty notice shown");
+  is($("#waiting-notice").hidden, true, "Waiting notice not shown");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/browser_canvas-frontend-stop-02.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that a recording that does not have a rAF cycle fails after timeout.
+ */
+
+function ifTestingSupported() {
+  let { target, panel } = yield initCanvasDebuggerFrontend(NO_CANVAS_URL);
+  let { window, EVENTS, $, SnapshotsListView } = panel.panelWin;
+
+  yield reload(target);
+
+  let recordingStarted = once(window, EVENTS.SNAPSHOT_RECORDING_STARTED);
+  SnapshotsListView._onRecordButtonClick();
+
+  yield recordingStarted;
+
+  is($("#empty-notice").hidden, true, "Empty notice not shown");
+  is($("#waiting-notice").hidden, false, "Waiting notice shown");
+
+  let recordingFinished = once(window, EVENTS.SNAPSHOT_RECORDING_FINISHED);
+  let recordingCancelled = once(window, EVENTS.SNAPSHOT_RECORDING_CANCELLED);
+
+  yield promise.all([recordingFinished, recordingCancelled]);
+
+  ok(true, "Recording stopped and was considered failed.");
+
+  is(SnapshotsListView.itemCount, 0, "No snapshots in the list.");
+  is($("#empty-notice").hidden, false, "Empty notice shown");
+  is($("#waiting-notice").hidden, true, "Waiting notice not shown");
+
+  yield teardown(panel);
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/canvasdebugger/test/doc_no-canvas.html
@@ -0,0 +1,14 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <title>Canvas inspector test page</title>
+  </head>
+
+  <body>
+  </body>
+
+</html>
--- a/browser/devtools/canvasdebugger/test/head.js
+++ b/browser/devtools/canvasdebugger/test/head.js
@@ -24,29 +24,32 @@ let { setTimeout } = devtools.require("s
 let TiltGL = devtools.require("devtools/tilt/tilt-gl");
 let TargetFactory = devtools.TargetFactory;
 let Toolbox = devtools.Toolbox;
 let mm = null
 
 const FRAME_SCRIPT_UTILS_URL = "chrome://browser/content/devtools/frame-script-utils.js";
 const EXAMPLE_URL = "http://example.com/browser/browser/devtools/canvasdebugger/test/";
 const SET_TIMEOUT_URL = EXAMPLE_URL + "doc_settimeout.html";
+const NO_CANVAS_URL = EXAMPLE_URL + "doc_no-canvas.html";
 const SIMPLE_CANVAS_URL = EXAMPLE_URL + "doc_simple-canvas.html";
 const SIMPLE_BITMASKS_URL = EXAMPLE_URL + "doc_simple-canvas-bitmasks.html";
 const SIMPLE_CANVAS_TRANSPARENT_URL = EXAMPLE_URL + "doc_simple-canvas-transparent.html";
 const SIMPLE_CANVAS_DEEP_STACK_URL = EXAMPLE_URL + "doc_simple-canvas-deep-stack.html";
 const WEBGL_ENUM_URL = EXAMPLE_URL + "doc_webgl-enum.html";
 const WEBGL_BINDINGS_URL = EXAMPLE_URL + "doc_webgl-bindings.html";
 const RAF_BEGIN_URL = EXAMPLE_URL + "doc_raf-begin.html";
 
 // All tests are asynchronous.
 waitForExplicitFinish();
 
 let gToolEnabled = Services.prefs.getBoolPref("devtools.canvasdebugger.enabled");
 
+gDevTools.testing = true;
+
 registerCleanupFunction(() => {
   info("finish() was called, cleaning up...");
   Services.prefs.setBoolPref("devtools.debugger.log", gEnableLogging);
   Services.prefs.setBoolPref("devtools.canvasdebugger.enabled", gToolEnabled);
 
   // Some of yhese tests use a lot of memory due to GL contexts, so force a GC
   // to help fragmentation.
   info("Forcing GC after canvas debugger test.");
--- a/browser/devtools/framework/target.js
+++ b/browser/devtools/framework/target.js
@@ -179,17 +179,23 @@ function TabTarget(tab) {
 
 TabTarget.prototype = {
   _webProgressListener: null,
 
   /**
    * Returns a promise for the protocol description from the root actor.
    * Used internally with `target.actorHasMethod`. Takes advantage of
    * caching if definition was fetched previously with the corresponding
-   * actor information. Must be a remote target.
+   * actor information. Actors are lazily loaded, so not only must the tool using
+   * a specific actor be in use, the actors are only registered after invoking
+   * a method (for performance reasons, added in bug 988237), so to use these actor
+   * detection methods, one must already be communicating with a specific actor of
+   * that type.
+   *
+   * Must be a remote target.
    *
    * @return {Promise}
    * {
    *   "category": "actor",
    *   "typeName": "longstractor",
    *   "methods": [{
    *     "name": "substring",
    *     "request": {
@@ -245,17 +251,18 @@ TabTarget.prototype = {
     if (this.form) {
       return !!this.form[actorName + "Actor"];
     }
     return false;
   },
 
   /**
    * Queries the protocol description to see if an actor has
-   * an available method. The actor must already be lazily-loaded,
+   * an available method. The actor must already be lazily-loaded (read
+   * the restrictions in the `getActorDescription` comments),
    * so this is for use inside of tool. Returns a promise that
    * resolves to a boolean. Must be a remote target.
    *
    * @param {String} actorName
    * @param {String} methodName
    * @return {Promise}
    */
   actorHasMethod: function (actorName, methodName) {
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -71,16 +71,18 @@ browser.jar:
     content/browser/devtools/debugger-controller.js                    (debugger/debugger-controller.js)
     content/browser/devtools/debugger-view.js                          (debugger/debugger-view.js)
     content/browser/devtools/debugger-toolbar.js                       (debugger/debugger-toolbar.js)
     content/browser/devtools/debugger-panes.js                         (debugger/debugger-panes.js)
     content/browser/devtools/shadereditor.xul                          (shadereditor/shadereditor.xul)
     content/browser/devtools/shadereditor.js                           (shadereditor/shadereditor.js)
     content/browser/devtools/canvasdebugger.xul                        (canvasdebugger/canvasdebugger.xul)
     content/browser/devtools/canvasdebugger.js                         (canvasdebugger/canvasdebugger.js)
+    content/browser/devtools/canvasdebugger/snapshotslist.js           (canvasdebugger/snapshotslist.js)
+    content/browser/devtools/canvasdebugger/callslist.js               (canvasdebugger/callslist.js)
     content/browser/devtools/d3.js                                     (shared/d3.js)
     content/browser/devtools/webaudioeditor.xul                        (webaudioeditor/webaudioeditor.xul)
     content/browser/devtools/dagre-d3.js                               (webaudioeditor/lib/dagre-d3.js)
     content/browser/devtools/webaudioeditor/includes.js                (webaudioeditor/includes.js)
     content/browser/devtools/webaudioeditor/models.js                  (webaudioeditor/models.js)
     content/browser/devtools/webaudioeditor/controller.js              (webaudioeditor/controller.js)
     content/browser/devtools/webaudioeditor/views/utils.js             (webaudioeditor/views/utils.js)
     content/browser/devtools/webaudioeditor/views/context.js           (webaudioeditor/views/context.js)
--- a/browser/devtools/shared/options-view.js
+++ b/browser/devtools/shared/options-view.js
@@ -16,16 +16,19 @@ const PREF_CHANGE_EVENT = "pref-changed"
  */
 const OptionsView = function (options={}) {
   this.branchName = options.branchName;
   this.menupopup = options.menupopup;
   this.window = this.menupopup.ownerDocument.defaultView;
   let { document } = this.window;
   this.$ = document.querySelector.bind(document);
   this.$$ = document.querySelectorAll.bind(document);
+  // Get the corresponding button that opens the popup by looking
+  // for an element with a `popup` attribute matching the menu's ID
+  this.button = this.$(`[popup=${this.menupopup.getAttribute("id")}]`);
 
   this.prefObserver = new PrefObserver(this.branchName);
 
   EventEmitter.decorate(this);
 };
 exports.OptionsView = OptionsView;
 
 OptionsView.prototype = {
@@ -121,24 +124,26 @@ OptionsView.prototype = {
     this.prefObserver.set(prefName, value);
   },
 
   /**
    * Fired when the `menupopup` is opened, bound via XUL.
    * Fires an event used in tests.
    */
   _onPopupShown: function () {
+    this.button.setAttribute("open", true);
     this.emit(OPTIONS_SHOWN_EVENT);
   },
 
   /**
    * Fired when the `menupopup` is closed, bound via XUL.
    * Fires an event used in tests.
    */
   _onPopupHidden: function () {
+    this.button.removeAttribute("open");
     this.emit(OPTIONS_HIDDEN_EVENT);
   }
 };
 
 /**
  * Constructor for PrefObserver. Small helper for observing changes
  * on a preference branch. Takes a `branchName`, like "devtools.debugger."
  *
--- a/browser/devtools/shared/test/browser_options-view-01.js
+++ b/browser/devtools/shared/test/browser_options-view-01.js
@@ -90,12 +90,14 @@ function createOptionsView(win) {
 
 function* click(view, win, menuitem) {
   let opened = view.once("options-shown");
   let closed = view.once("options-hidden");
 
   let button = win.document.querySelector("#options-button");
   EventUtils.synthesizeMouseAtCenter(button, {}, win);
   yield opened;
+  is(button.getAttribute("open"), "true", "button has `open` attribute");
 
   EventUtils.synthesizeMouseAtCenter(menuitem, {}, win);
   yield closed;
+  ok(!button.hasAttribute("open"), "button does not have `open` attribute");
 }
--- a/browser/devtools/styleinspector/test/browser.ini
+++ b/browser/devtools/styleinspector/test/browser.ini
@@ -40,16 +40,17 @@ support-files =
 [browser_computedview_select-and-copy-styles.js]
 [browser_computedview_style-editor-link.js]
 [browser_ruleview_add-property-and-reselect.js]
 [browser_ruleview_add-property-cancel_01.js]
 [browser_ruleview_add-property-cancel_02.js]
 [browser_ruleview_add-property-cancel_03.js]
 [browser_ruleview_add-property_01.js]
 [browser_ruleview_add-property_02.js]
+[browser_ruleview_add-property-svg.js]
 [browser_ruleview_add-rule_01.js]
 [browser_ruleview_add-rule_02.js]
 [browser_ruleview_add-rule_03.js]
 [browser_ruleview_colorpicker-and-image-tooltip_01.js]
 [browser_ruleview_colorpicker-and-image-tooltip_02.js]
 [browser_ruleview_colorpicker-appears-on-swatch-click.js]
 [browser_ruleview_colorpicker-commit-on-ENTER.js]
 [browser_ruleview_colorpicker-edit-gradient.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/styleinspector/test/browser_ruleview_add-property-svg.js
@@ -0,0 +1,62 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests editing SVG styles using the rules view
+
+let TEST_URL = "chrome://global/skin/icons/warning.svg";
+let TEST_SELECTOR = "path";
+
+add_task(function*() {
+  yield addTab(TEST_URL);
+
+  info("Opening the rule-view");
+  let {toolbox, inspector, view} = yield openRuleView();
+
+  info("Selecting the test element");
+  yield selectNode(TEST_SELECTOR, inspector);
+
+  yield testCreateNew(view);
+});
+
+function* testCreateNew(ruleView) {
+  info("Test creating a new property");
+
+  let elementRuleEditor = getRuleViewRuleEditor(ruleView, 0);
+
+  info("Focusing a new property name in the rule-view");
+  let editor = yield focusEditableField(elementRuleEditor.closeBrace);
+
+  is(inplaceEditor(elementRuleEditor.newPropSpan), editor,
+    "Next focused editor should be the new property editor.");
+
+  let input = editor.input;
+
+  info("Entering the property name");
+  input.value = "fill";
+
+  info("Pressing RETURN and waiting for the value field focus");
+  let onFocus = once(elementRuleEditor.element, "focus", true);
+  EventUtils.sendKey("return", ruleView.doc.defaultView);
+  yield onFocus;
+  yield elementRuleEditor.rule._applyingModifications;
+
+  editor = inplaceEditor(ruleView.doc.activeElement);
+
+  is(elementRuleEditor.rule.textProps.length,  1, "Should have created a new text property.");
+  is(elementRuleEditor.propertyList.children.length, 1, "Should have created a property editor.");
+  let textProp = elementRuleEditor.rule.textProps[0];
+  is(editor, inplaceEditor(textProp.editor.valueSpan), "Should be editing the value span now.");
+
+  editor.input.value = "red";
+  let onBlur = once(editor.input, "blur");
+  EventUtils.sendKey("return", ruleView.doc.defaultView);
+  yield onBlur;
+  yield elementRuleEditor.rule._applyingModifications;
+
+  is(textProp.value, "red", "Text prop should have been changed.");
+
+  is((yield getComputedStyleProperty(TEST_SELECTOR, null, "fill")), "rgb(255, 0, 0)", "The fill was changed to red");
+}
--- a/browser/devtools/webaudioeditor/controller.js
+++ b/browser/devtools/webaudioeditor/controller.js
@@ -56,18 +56,25 @@ let WebAudioEditorController = {
     gFront.on("change-param", this._onChangeParam);
     gFront.on("destroy-node", this._onDestroyNode);
 
     // Hook into theme change so we can change
     // the graph's marker styling, since we can't do this
     // with CSS
     gDevTools.on("pref-changed", this._onThemeChange);
 
-    // Store the AudioNode definitions from the WebAudioFront
-    AUDIO_NODE_DEFINITION = yield gFront.getDefinition();
+    // Store the AudioNode definitions from the WebAudioFront, if the method exists.
+    // If not, get the JSON directly. Using the actor method is preferable so the client
+    // knows exactly what methods are supported on the server.
+    let actorHasDefinition = yield gTarget.actorHasMethod("webaudio", "getDefinition");
+    if (actorHasDefinition) {
+      AUDIO_NODE_DEFINITION = yield gFront.getDefinition();
+    } else {
+      AUDIO_NODE_DEFINITION = require("devtools/server/actors/utils/audionodes.json");
+    }
   }),
 
   /**
    * Remove events emitted by the current tab target.
    */
   destroy: function() {
     telemetry.toolClosed("webaudioeditor");
     gTarget.off("will-navigate", this._onTabNavigated);
--- a/browser/locales/en-US/chrome/browser/devtools/canvasdebugger.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/canvasdebugger.dtd
@@ -19,19 +19,19 @@
   -  along with the button that triggers a page refresh. -->
 <!ENTITY canvasDebuggerUI.reloadNotice2   "the page to be able to debug &lt;canvas&gt; contexts.">
 
 <!-- LOCALIZATION NOTE (canvasDebuggerUI.emptyNotice1/2): This is the label shown
   -  in the call list view when empty. -->
 <!ENTITY canvasDebuggerUI.emptyNotice1    "Click on the">
 <!ENTITY canvasDebuggerUI.emptyNotice2    "button to record an animation frame's call stack.">
 
-<!-- LOCALIZATION NOTE (canvasDebuggerUI.reloadNotice1): This is the label shown
-  -  in the call list view while loading a snapshot. -->
-<!ENTITY canvasDebuggerUI.importNotice    "Loading…">
+<!-- LOCALIZATION NOTE (canvasDebuggerUI.waitingNotice): This is the label shown
+  -  in the call list view while recording a snapshot. -->
+<!ENTITY canvasDebuggerUI.waitingNotice   "Recording an animation cycle…">
 
 <!-- LOCALIZATION NOTE (canvasDebuggerUI.recordSnapshot): This string is displayed
   -  on a button that starts a new snapshot. -->
 <!ENTITY canvasDebuggerUI.recordSnapshot.tooltip "Record the next frame in the animation loop.">
 
 <!-- LOCALIZATION NOTE (canvasDebuggerUI.importSnapshot): This string is displayed
   -  on a button that opens a dialog to import a saved snapshot data file. -->
 <!ENTITY canvasDebuggerUI.importSnapshot "Import…">
--- a/browser/locales/en-US/chrome/browser/devtools/canvasdebugger.properties
+++ b/browser/locales/en-US/chrome/browser/devtools/canvasdebugger.properties
@@ -71,8 +71,14 @@ snapshotsList.saveDialogAllFilter=All Fi
 # as a generic description about how many draw calls were made.
 snapshotsList.drawCallsLabel=#1 draw;#1 draws
 
 # LOCALIZATION NOTE (snapshotsList.functionCallsLabel):
 # See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
 # This string is displayed in the snapshots list of the Canvas Debugger,
 # as a generic description about how many function calls were made in total.
 snapshotsList.functionCallsLabel=#1 call;#1 calls
+
+# LOCALIZATION NOTE (recordingTimeoutFailure):
+# This notification alert is displayed when attempting to record a requestAnimationFrame
+# cycle in the Canvas Debugger and no cycles detected. This alerts the user that no
+# loops were found.
+recordingTimeoutFailure=Canvas Debugger could not find a requestAnimationFrame or setTimeout cycle.
--- a/browser/themes/shared/devtools/canvasdebugger.inc.css
+++ b/browser/themes/shared/devtools/canvasdebugger.inc.css
@@ -9,40 +9,33 @@
 %define checkerboardPattern linear-gradient(45deg, @checkerboardCell@ 25%, transparent 25%, transparent 75%, @checkerboardCell@ 75%, @checkerboardCell@), linear-gradient(45deg, @checkerboardCell@ 25%, transparent 25%, transparent 75%, @checkerboardCell@ 75%, @checkerboardCell@)
 %define gutterWidth 3em
 %define gutterPaddingStart 22px
 
 /* Reload and waiting notices */
 
 .notice-container {
   margin-top: -50vh;
-  font-size: 120%;
   background-color: var(--theme-toolbar-background);
   color: var(--theme-body-color-alt);
 }
 
 #empty-notice > button {
   min-width: 30px;
   min-height: 28px;
   margin: 0;
   list-style-image: url(profiler-stopwatch.svg);
 }
 
 #empty-notice > button .button-text {
   display: none;
 }
 
-.theme-dark #import-notice {
-  font-size: 250%;
-  color: rgba(255,255,255,0.2);
-}
-
-.theme-light #import-notice {
-  font-size: 250%;
-  color: rgba(0,0,0,0.2);
+#waiting-notice {
+  font-size: 110%;
 }
 
 /* Snapshots pane */
 
 #snapshots-pane > tabs {
   -moz-border-end: 1px solid;
 }
 
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -67,16 +67,17 @@ import org.mozilla.gecko.tabs.TabHistory
 import org.mozilla.gecko.tabs.TabHistoryFragment;
 import org.mozilla.gecko.tabs.TabHistoryPage;
 import org.mozilla.gecko.tabs.TabsPanel;
 import org.mozilla.gecko.tabs.TabHistoryController.OnShowTabHistory;
 import org.mozilla.gecko.toolbar.AutocompleteHandler;
 import org.mozilla.gecko.toolbar.BrowserToolbar;
 import org.mozilla.gecko.toolbar.BrowserToolbar.TabEditingState;
 import org.mozilla.gecko.toolbar.ToolbarProgressView;
+import org.mozilla.gecko.util.ActivityUtils;
 import org.mozilla.gecko.util.Clipboard;
 import org.mozilla.gecko.util.EventCallback;
 import org.mozilla.gecko.util.GamepadUtils;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.MenuUtils;
 import org.mozilla.gecko.util.NativeEventListener;
 import org.mozilla.gecko.util.NativeJSObject;
@@ -2880,16 +2881,20 @@ public class BrowserApp extends GeckoApp
     @Override
     public void openOptionsMenu() {
         // Disable menu access (for hardware buttons) when the software menu button is inaccessible.
         // Note that the software button is always accessible on new tablet.
         if (mBrowserToolbar.isEditing() && !HardwareUtils.isTablet()) {
             return;
         }
 
+        if (ActivityUtils.isFullScreen(this)) {
+            return;
+        }
+
         if (areTabsShown()) {
             mTabsPanel.showMenu();
             return;
         }
 
         // Scroll custom menu to the top
         if (mMenuPanel != null)
             mMenuPanel.scrollTo(0, 0);
--- a/mobile/android/base/db/LocalBrowserDB.java
+++ b/mobile/android/base/db/LocalBrowserDB.java
@@ -27,16 +27,17 @@ import org.mozilla.gecko.AboutPages;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.db.BrowserContract.Bookmarks;
 import org.mozilla.gecko.db.BrowserContract.Combined;
 import org.mozilla.gecko.db.BrowserContract.ExpirePriority;
 import org.mozilla.gecko.db.BrowserContract.Favicons;
 import org.mozilla.gecko.db.BrowserContract.History;
 import org.mozilla.gecko.db.BrowserContract.ReadingListItems;
+import org.mozilla.gecko.db.BrowserContract.SearchHistory;
 import org.mozilla.gecko.db.BrowserContract.SyncColumns;
 import org.mozilla.gecko.db.BrowserContract.Thumbnails;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.favicons.decoders.FaviconDecoder;
 import org.mozilla.gecko.favicons.decoders.LoadFaviconResult;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.mozglue.RobocopTarget;
 import org.mozilla.gecko.sync.Utils;
@@ -93,16 +94,17 @@ public class LocalBrowserDB implements B
     private final Uri mBookmarksUriWithProfile;
     private final Uri mParentsUriWithProfile;
     private final Uri mHistoryUriWithProfile;
     private final Uri mHistoryExpireUriWithProfile;
     private final Uri mCombinedUriWithProfile;
     private final Uri mUpdateHistoryUriWithProfile;
     private final Uri mFaviconsUriWithProfile;
     private final Uri mThumbnailsUriWithProfile;
+    private final Uri mSearchHistoryUri;
 
     private LocalSearches searches;
     private LocalTabsAccessor tabsAccessor;
     private LocalURLMetadata urlMetadata;
     private LocalReadingListAccessor readingListAccessor;
 
     private static final String[] DEFAULT_BOOKMARK_COLUMNS =
             new String[] { Bookmarks._ID,
@@ -119,16 +121,18 @@ public class LocalBrowserDB implements B
         mBookmarksUriWithProfile = DBUtils.appendProfile(profile, Bookmarks.CONTENT_URI);
         mParentsUriWithProfile = DBUtils.appendProfile(profile, Bookmarks.PARENTS_CONTENT_URI);
         mHistoryUriWithProfile = DBUtils.appendProfile(profile, History.CONTENT_URI);
         mHistoryExpireUriWithProfile = DBUtils.appendProfile(profile, History.CONTENT_OLD_URI);
         mCombinedUriWithProfile = DBUtils.appendProfile(profile, Combined.CONTENT_URI);
         mFaviconsUriWithProfile = DBUtils.appendProfile(profile, Favicons.CONTENT_URI);
         mThumbnailsUriWithProfile = DBUtils.appendProfile(profile, Thumbnails.CONTENT_URI);
 
+        mSearchHistoryUri = BrowserContract.SearchHistory.CONTENT_URI;
+
         mUpdateHistoryUriWithProfile =
                 mHistoryUriWithProfile.buildUpon()
                                       .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
                                       .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true")
                                       .build();
 
         searches = new LocalSearches(mProfile);
         tabsAccessor = new LocalTabsAccessor(mProfile);
@@ -713,16 +717,17 @@ public class LocalBrowserDB implements B
         cr.delete(mHistoryUriWithProfile,
                   History.URL + " = ?",
                   new String[] { url });
     }
 
     @Override
     public void clearHistory(ContentResolver cr) {
         cr.delete(mHistoryUriWithProfile, null, null);
+        cr.delete(mSearchHistoryUri, null, null);
     }
 
     @Override
     @RobocopTarget
     public Cursor getBookmarksInFolder(ContentResolver cr, long folderId) {
         final boolean addDesktopFolder;
 
         // We always want to show mobile bookmarks in the root view.
--- a/mobile/android/base/preferences/LocaleListPreference.java
+++ b/mobile/android/base/preferences/LocaleListPreference.java
@@ -194,16 +194,17 @@ public class LocaleListPreference extend
                 }
             }
 
             // These locales use a script that is often unavailable
             // on common Android devices. Make sure we can show them.
             // See documentation for CharacterValidator.
             // Note that bn-IN is checked here even if it passed above.
             if (this.tag.equals("or") ||
+                this.tag.equals("my") ||
                 this.tag.equals("pa-IN") ||
                 this.tag.equals("gu-IN") ||
                 this.tag.equals("bn-IN")) {
                 if (validator.characterIsMissingInFont(this.nativeName.substring(0, 1))) {
                     return false;
                 }
             }
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/tests/SelectionHandlerTest.java
@@ -0,0 +1,56 @@
+package org.mozilla.gecko.tests;
+
+import org.mozilla.gecko.Actions;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.tests.helpers.GeckoHelper;
+import org.mozilla.gecko.tests.helpers.NavigationHelper;
+
+import android.util.Log;
+
+import org.json.JSONObject;
+
+/**
+ * A base test class for selection handler tests.
+ */
+abstract class SelectionHandlerTest extends UITest {
+    private static final String geckoEventString = "Robocop:testSelectionHandler";
+    private final String url;
+
+    public SelectionHandlerTest(String url) {
+        this.url = url;
+    }
+
+    public void testSelection() {
+        GeckoHelper.blockForReady();
+
+        Actions.EventExpecter robocopTestExpecter = getActions().expectGeckoEvent(geckoEventString);
+        NavigationHelper.enterAndLoadUrl(url);
+        mToolbar.assertTitle(url);
+
+        while (!test(robocopTestExpecter)) {
+            // do nothing
+        }
+
+        robocopTestExpecter.unregisterListener();
+    }
+
+    protected boolean test(Actions.EventExpecter expecter) {
+        final JSONObject eventData;
+        try {
+            eventData = new JSONObject(expecter.blockForEventData());
+        } catch(Exception ex) {
+            // Log and ignore
+            getAsserter().ok(false, "JS Test", "Error decoding data " + ex);
+            return false;
+        }
+
+        if (eventData.has("result")) {
+            getAsserter().ok(eventData.optBoolean("result"), "JS Test", eventData.optString("msg"));
+        } else if (eventData.has("todo")) {
+            getAsserter().todo(eventData.optBoolean("todo"), "JS TODO", eventData.optString("msg"));
+        }
+
+        EventDispatcher.sendResponse(eventData, new JSONObject());
+        return eventData.optBoolean("done", false);
+    }
+}
--- a/mobile/android/base/tests/roboextender/SelectionUtils.js
+++ b/mobile/android/base/tests/roboextender/SelectionUtils.js
@@ -7,16 +7,18 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 
 const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 Cu.import("resource://gre/modules/Messaging.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import('resource://gre/modules/Geometry.jsm');
 
+const TYPE_NAME = "Robocop:testSelectionHandler";
+
 /* ============================== Utility functions ================================================
  *
  * Common functions available to all tests.
  *
  */
 function getSelectionHandler() {
   return (!this._selectionHandler) ?
     this._selectionHandler = Services.wm.getMostRecentWindow("navigator:browser").SelectionHandler :
--- a/mobile/android/base/tests/roboextender/testInputSelections.html
+++ b/mobile/android/base/tests/roboextender/testInputSelections.html
@@ -2,19 +2,16 @@
   <head>
     <title>Automated RTL/LTR Text Selection tests for Input elements</title>
     <meta name="viewport" content="initial-scale=1.0"/>
     <script type="application/javascript"
       src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
     <script type="application/javascript" src="SelectionUtils.js"></script>
     <script type="application/javascript;version=1.8">
 
-// Name of this test.
-const TYPE_NAME = "Robocop:testInputSelections";
-
 // Used to create handle movement events for SelectionHandler.
 const ANCHOR = "ANCHOR";
 const FOCUS = "FOCUS";
 
 // Types of DOM nodes that serve as Selection Anchor/Focus nodes.
 const DIV_NODE = "DIV";
 const TEXT_NODE = "#text";
 
--- a/mobile/android/base/tests/roboextender/testSelectionHandler.html
+++ b/mobile/android/base/tests/roboextender/testSelectionHandler.html
@@ -2,19 +2,16 @@
   <head>
     <title>Automated Text Selection tests for Mobile</title>
     <meta name="viewport" content="initial-scale=1.0"/>
     <script type="application/javascript"
       src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
     <script type="application/javascript" src="SelectionUtils.js"></script>
     <script type="application/javascript;version=1.8">
 
-// Name of this test.
-const TYPE_NAME = "Robocop:testSelectionHandler";
-
 const DIV_POINT_TEXT = "Under";
 const INPUT_TEXT = "Text for select all in an <input>";
 const TEXTAREA_TEXT = "Text for select all in a <textarea>";
 const READONLY_INPUT_TEXT = "readOnly text";
 
 /* =================================================================================
  *
  * Start of all text selection tests, check initialization state.
--- a/mobile/android/base/tests/roboextender/testTextareaSelections.html
+++ b/mobile/android/base/tests/roboextender/testTextareaSelections.html
@@ -2,19 +2,16 @@
   <head>
     <title>Automated RTL/LTR Text Selection tests for Textareas</title>
     <meta name="viewport" content="initial-scale=1.0"/>
     <script type="application/javascript"
       src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
     <script type="application/javascript" src="SelectionUtils.js"></script>
     <script type="application/javascript;version=1.8">
 
-// Name of this test.
-const TYPE_NAME = "Robocop:testTextareaSelections";
-
 // Used to create handle movement events for SelectionHandler.
 const ANCHOR = "ANCHOR";
 const FOCUS = "FOCUS";
 
 // Used to specifiy midpoint selection text left/right of center.
 const EST_SEL_TEXT_BOUND_CHARS = 5;
 
 // Used to ensure calculated coords for handle movement events get us
--- a/mobile/android/base/tests/testInputSelections.java
+++ b/mobile/android/base/tests/testInputSelections.java
@@ -1,50 +1,12 @@
 package org.mozilla.gecko.tests;
 
-import org.mozilla.gecko.Actions;
-import org.mozilla.gecko.EventDispatcher;
-import org.mozilla.gecko.tests.helpers.GeckoHelper;
-import org.mozilla.gecko.tests.helpers.NavigationHelper;
+public class testInputSelections extends SelectionHandlerTest {
 
-import android.util.Log;
-
-import org.json.JSONObject;
-
-
-public class testInputSelections extends UITest {
+    public testInputSelections() {
+        super("chrome://roboextender/content/testInputSelections.html");
+    }
 
     public void testInputSelections() {
-        GeckoHelper.blockForReady();
-
-        Actions.EventExpecter robocopTestExpecter =
-            getActions().expectGeckoEvent("Robocop:testInputSelections");
-        final String url = "chrome://roboextender/content/testInputSelections.html";
-        NavigationHelper.enterAndLoadUrl(url);
-        mToolbar.assertTitle(url);
-
-        while (!test(robocopTestExpecter)) {
-            // do nothing
-        }
-
-        robocopTestExpecter.unregisterListener();
-    }
-
-    private boolean test(Actions.EventExpecter expecter) {
-        final JSONObject eventData;
-        try {
-            eventData = new JSONObject(expecter.blockForEventData());
-        } catch(Exception ex) {
-            // Log and ignore
-            getAsserter().ok(false, "JS Test", "Error decoding data " + ex);
-            return false;
-        }
-
-        if (eventData.has("result")) {
-            getAsserter().ok(eventData.optBoolean("result"), "JS Test", eventData.optString("msg"));
-        } else if (eventData.has("todo")) {
-            getAsserter().todo(eventData.optBoolean("todo"), "JS TODO", eventData.optString("msg"));
-        }
-
-        EventDispatcher.sendResponse(eventData, new JSONObject());
-        return eventData.optBoolean("done", false);
+        super.testSelection();
     }
 }
--- a/mobile/android/base/tests/testSelectionHandler.java
+++ b/mobile/android/base/tests/testSelectionHandler.java
@@ -1,47 +1,12 @@
 package org.mozilla.gecko.tests;
 
-import org.mozilla.gecko.Actions;
-import org.mozilla.gecko.EventDispatcher;
-import org.mozilla.gecko.tests.helpers.GeckoHelper;
-import org.mozilla.gecko.tests.helpers.NavigationHelper;
+public class testSelectionHandler extends SelectionHandlerTest {
 
-import android.util.Log;
-
-import org.json.JSONObject;
-
-
-public class testSelectionHandler extends UITest {
+    public testSelectionHandler() {
+        super("chrome://roboextender/content/testSelectionHandler.html");
+    }
 
     public void testSelectionHandler() {
-        GeckoHelper.blockForReady();
-
-        Actions.EventExpecter robocopTestExpecter = getActions().expectGeckoEvent("Robocop:testSelectionHandler");
-        final String url = "chrome://roboextender/content/testSelectionHandler.html";
-        NavigationHelper.enterAndLoadUrl(url);
-        mToolbar.assertTitle(url);
-
-        while (!test(robocopTestExpecter)) {
-            // do nothing
-        }
-
-        robocopTestExpecter.unregisterListener();
-    }
-
-    private boolean test(Actions.EventExpecter expecter) {
-        final JSONObject eventData;
-        try {
-            eventData = new JSONObject(expecter.blockForEventData());
-        } catch(Exception ex) {
-            // Log and ignore
-            getAsserter().ok(false, "JS Test", "Error decoding data " + ex);
-            return false;
-        }
-
-        if (eventData.has("result")) {
-            getAsserter().ok(eventData.optBoolean("result"), "JS Test", eventData.optString("msg"));
-        }
-
-        EventDispatcher.sendResponse(eventData, new JSONObject());
-        return eventData.optBoolean("done", false);
+        super.testSelection();
     }
 }
--- a/mobile/android/base/tests/testTextareaSelections.java
+++ b/mobile/android/base/tests/testTextareaSelections.java
@@ -1,50 +1,12 @@
 package org.mozilla.gecko.tests;
 
-import org.mozilla.gecko.Actions;
-import org.mozilla.gecko.EventDispatcher;
-import org.mozilla.gecko.tests.helpers.GeckoHelper;
-import org.mozilla.gecko.tests.helpers.NavigationHelper;
+public class testTextareaSelections extends SelectionHandlerTest {
 
-import android.util.Log;
-
-import org.json.JSONObject;
-
-
-public class testTextareaSelections extends UITest {
+    public testTextareaSelections() {
+        super("chrome://roboextender/content/testTextareaSelections.html");
+    }
 
     public void testTextareaSelections() {
-        GeckoHelper.blockForReady();
-
-        Actions.EventExpecter robocopTestExpecter =
-            getActions().expectGeckoEvent("Robocop:testTextareaSelections");
-        final String url = "chrome://roboextender/content/testTextareaSelections.html";
-        NavigationHelper.enterAndLoadUrl(url);
-        mToolbar.assertTitle(url);
-
-        while (!test(robocopTestExpecter)) {
-            // do nothing
-        }
-
-        robocopTestExpecter.unregisterListener();
-    }
-
-    private boolean test(Actions.EventExpecter expecter) {
-        final JSONObject eventData;
-        try {
-            eventData = new JSONObject(expecter.blockForEventData());
-        } catch(Exception ex) {
-            // Log and ignore
-            getAsserter().ok(false, "JS Test", "Error decoding data " + ex);
-            return false;
-        }
-
-        if (eventData.has("result")) {
-            getAsserter().ok(eventData.optBoolean("result"), "JS Test", eventData.optString("msg"));
-        } else if (eventData.has("todo")) {
-            getAsserter().todo(eventData.optBoolean("todo"), "JS TODO", eventData.optString("msg"));
-        }
-
-        EventDispatcher.sendResponse(eventData, new JSONObject());
-        return eventData.optBoolean("done", false);
+        super.testSelection();
     }
 }
--- a/mobile/android/base/util/ActivityUtils.java
+++ b/mobile/android/base/util/ActivityUtils.java
@@ -31,9 +31,21 @@ public class ActivityUtils {
 
             window.getDecorView().setSystemUiVisibility(newVis);
         } else {
             window.setFlags(fullscreen ?
                             WindowManager.LayoutParams.FLAG_FULLSCREEN : 0,
                             WindowManager.LayoutParams.FLAG_FULLSCREEN);
         }
     }
+
+    public static boolean isFullScreen(final Activity activity) {
+        final Window window = activity.getWindow();
+
+        if (Versions.feature11Plus) {
+            final int vis = window.getDecorView().getSystemUiVisibility();
+            return (vis & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0;
+        } else {
+            final int flags = window.getAttributes().flags;
+            return ((flags & WindowManager.LayoutParams.FLAG_FULLSCREEN) != 0);
+        }
+    }
 }
--- a/mobile/android/modules/DownloadNotifications.jsm
+++ b/mobile/android/modules/DownloadNotifications.jsm
@@ -214,16 +214,22 @@ DownloadNotification.prototype = {
   },
 
   showOrUpdate: function () {
     this._updateFromDownload();
 
     if (this._show) {
       if (!this.id) {
         this.id = Notifications.create(this.options);
+      } else if (!this.options.ongoing) {
+        // We need to explictly cancel ongoing notifications,
+        // since updating them to be non-ongoing doesn't seem
+        // to work. See bug 1130834.
+        Notifications.cancel(this.id);
+        this.id = Notifications.create(this.options);
       } else {
         Notifications.update(this.id, this.options);
       }
     } else {
       this.hide();
     }
   },
 
--- a/toolkit/content/browser-child.js
+++ b/toolkit/content/browser-child.js
@@ -536,16 +536,20 @@ let AutoCompletePopup = {
 
     addMessageListener("FormAutoComplete:HandleEnter", message => {
       this.selectedIndex = message.data.selectedIndex;
 
       let controller = Components.classes["@mozilla.org/autocomplete/controller;1"].
                   getService(Components.interfaces.nsIAutoCompleteController);
       controller.handleEnter(message.data.isPopupSelection);
     });
+
+    addEventListener("unload", function() {
+      AutoCompletePopup.destroy();
+    });
   },
 
   destroy: function() {
     let controller = Cc["@mozilla.org/satchel/form-fill-controller;1"]
                        .getService(Ci.nsIFormFillController);
 
     controller.detachFromBrowser(docShell);
   },
@@ -594,12 +598,8 @@ let outerWindowID = content.QueryInterfa
                            .outerWindowID;
 let initData = sendSyncMessage("Browser:Init", {outerWindowID: outerWindowID});
 if (initData.length) {
   docShell.useGlobalHistory = initData[0].useGlobalHistory;
   if (initData[0].initPopup) {
     setTimeout(() => AutoCompletePopup.init(), 0);
   }
 }
-
-addEventListener("unload", function() {
-  AutoCompletePopup.destroy();
-});
--- a/toolkit/content/widgets/browser.xml
+++ b/toolkit/content/widgets/browser.xml
@@ -1077,65 +1077,101 @@
         ]]>
         </body>
       </method>
 
       <method name="swapDocShells">
         <parameter name="aOtherBrowser"/>
         <body>
         <![CDATA[
+          if (this.isRemoteBrowser != aOtherBrowser.isRemoteBrowser)
+            throw new Error("Can only swap docshells between browsers in the same process.");
+
           // We need to swap fields that are tied to our docshell or related to
           // the loaded page
           // Fields which are built as a result of notifactions (pageshow/hide,
           // DOMLinkAdded/Removed, onStateChange) should not be swapped here,
           // because these notifications are dispatched again once the docshells
           // are swapped.
           var fieldsToSwap = [
             "_docShell",
             "_webBrowserFind",
             "_contentWindow",
             "_webNavigation",
             "_permanentKey"
           ];
 
+          if (this.isRemoteBrowser) {
+            fieldsToSwap.push(...[
+              "_remoteWebNavigation",
+              "_remoteWebProgressManager",
+              "_remoteWebProgress",
+              "_remoteFinder",
+              "_securityUI",
+              "_documentURI",
+              "_documentContentType",
+              "_contentTitle",
+              "_characterSet",
+              "_contentPrincipal",
+              "_syncHandler",
+              "_imageDocument",
+              "_fullZoom",
+              "_textZoom",
+              "_isSyntheticDocument",
+            ]);
+          }
+
           var ourFieldValues = {};
           var otherFieldValues = {};
           for each (var field in fieldsToSwap) {
             ourFieldValues[field] = this[field];
             otherFieldValues[field] = aOtherBrowser[field];
           }
 
           if (window.PopupNotifications)
             PopupNotifications._swapBrowserNotifications(aOtherBrowser, this);
 
           this.QueryInterface(Components.interfaces.nsIFrameLoaderOwner)
               .swapFrameLoaders(aOtherBrowser);
 
           // Before we swap the actual docShell property we need to detach the
           // form fill controller from those docShells.
-          this.detachFormFill();
-          aOtherBrowser.detachFormFill();
+          if (!this.isRemoteBrowser) {
+            this.detachFormFill();
+            aOtherBrowser.detachFormFill();
+          }
 
           for each (var field in fieldsToSwap) {
             this[field] = otherFieldValues[field];
             aOtherBrowser[field] = ourFieldValues[field];
           }
 
           // Re-attach the docShells to the form fill controller.
           if (!this.isRemoteBrowser) {
             this.attachFormFill();
-          }
-          if (!aOtherBrowser.isRemoteBrowser) {
             aOtherBrowser.attachFormFill();
-          }
 
-          // Null the current nsITypeAheadFind instances so that they're
-          // lazily re-created on access. We need to do this because they
-          // might have attached the wrong docShell.
-          this._fastFind = aOtherBrowser._fastFind = null;
+            // Null the current nsITypeAheadFind instances so that they're
+            // lazily re-created on access. We need to do this because they
+            // might have attached the wrong docShell.
+            this._fastFind = aOtherBrowser._fastFind = null;
+          }
+          else {
+            // Rewire the remote listeners
+            this._remoteWebNavigation.swapBrowser(this);
+            aOtherBrowser._remoteWebNavigation.swapBrowser(aOtherBrowser);
+
+            this._remoteWebProgressManager.swapBrowser(this);
+            aOtherBrowser._remoteWebProgressManager.swapBrowser(aOtherBrowser);
+
+            if (this._remoteFinder)
+              this._remoteFinder.swapBrowser(this);
+            if (aOtherBrowser._remoteFinder)
+              aOtherBrowser._remoteFinder.swapBrowser(aOtherBrowser);
+          }
         ]]>
         </body>
       </method>
     </implementation>
 
     <handlers>
       <handler event="keypress" keycode="VK_F7" group="system">
         <![CDATA[
--- a/toolkit/content/widgets/remote-browser.xml
+++ b/toolkit/content/widgets/remote-browser.xml
@@ -74,17 +74,17 @@
       	</getter>
       </property>
 
       <field name="_remoteFinder">null</field>
 
       <property name="finder" readonly="true">
         <getter><![CDATA[
           if (!this._remoteFinder) {
-            // Don't attempt to create the remote web progress if the
+            // Don't attempt to create the remote finder if the
             // messageManager has already gone away
             if (!this.messageManager)
               return null;
 
             let jsm = "resource://gre/modules/RemoteFinder.jsm";
             let { RemoteFinder } = Cu.import(jsm, {});
             this._remoteFinder = new RemoteFinder(this);
           }
--- a/toolkit/devtools/server/actors/canvas.js
+++ b/toolkit/devtools/server/actors/canvas.js
@@ -317,17 +317,33 @@ let CanvasActor = exports.CanvasActor = 
 
     this._recordingContainsDrawCall = false;
     this._callWatcher.eraseRecording();
     this._callWatcher.resumeRecording();
 
     let deferred = this._currentAnimationFrameSnapshot = promise.defer();
     return deferred.promise;
   }, {
-    response: { snapshot: RetVal("frame-snapshot") }
+    response: { snapshot: RetVal("nullable:frame-snapshot") }
+  }),
+
+  /**
+   * Cease attempts to record an animation frame.
+   */
+  stopRecordingAnimationFrame: method(function() {
+   if (!this._callWatcher.isRecording()) {
+      return;
+    }
+    this._animationStarted = false;
+    this._callWatcher.pauseRecording();
+    this._callWatcher.eraseRecording();
+    this._currentAnimationFrameSnapshot.resolve(null);
+    this._currentAnimationFrameSnapshot = null;
+  }, {
+    oneway: true
   }),
 
   /**
    * Invoked whenever an instrumented function is called, be it on a
    * 2d or WebGL context, or an animation generator like requestAnimationFrame.
    */
   _onContentFunctionCall: function(functionCall) {
     let { window, name, args } = functionCall.details;
--- a/toolkit/devtools/server/actors/styles.js
+++ b/toolkit/devtools/server/actors/styles.js
@@ -1063,17 +1063,17 @@ var StyleRuleActor = protocol.ActorClass
       while (parentStyleSheet.ownerRule &&
           parentStyleSheet.ownerRule instanceof Ci.nsIDOMCSSImportRule) {
         parentStyleSheet = parentStyleSheet.ownerRule.parentStyleSheet;
       }
 
       document = this.getDocument(parentStyleSheet);
     }
 
-    let tempElement = document.createElement("div");
+    let tempElement = document.createElementNS(XHTML_NS, "div");
 
     for (let mod of modifications) {
       if (mod.type === "set") {
         tempElement.style.setProperty(mod.name, mod.value, mod.priority || "");
         this.rawStyle.setProperty(mod.name,
           tempElement.style.getPropertyValue(mod.name), mod.priority || "");
       } else if (mod.type === "remove") {
         this.rawStyle.removeProperty(mod.name);
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/server/actors/utils/audionodes.json
@@ -0,0 +1,107 @@
+{
+  "OscillatorNode": {
+    "properties": {
+      "type": {},
+      "frequency": {
+        "param": true
+      },
+      "detune": {
+        "param": true
+      }
+    }
+  },
+  "GainNode": {
+    "properties": { "gain": { "param": true }}
+  },
+  "DelayNode": {
+    "properties": { "delayTime": { "param": true }}
+  },
+  "AudioBufferSourceNode": {
+    "properties": {
+      "buffer": { "Buffer": true },
+      "playbackRate": {
+        "param": true
+      },
+      "loop": {},
+      "loopStart": {},
+      "loopEnd": {}
+    }
+  },
+  "ScriptProcessorNode": {
+    "properties": { "bufferSize": { "readonly": true }}
+  },
+  "PannerNode": {
+    "properties": {
+      "panningModel": {},
+      "distanceModel": {},
+      "refDistance": {},
+      "maxDistance": {},
+      "rolloffFactor": {},
+      "coneInnerAngle": {},
+      "coneOuterAngle": {},
+      "coneOuterGain": {}
+    }
+  },
+  "ConvolverNode": {
+    "properties": {
+      "buffer": { "Buffer": true },
+      "normalize": {}
+    }
+  },
+  "DynamicsCompressorNode": {
+    "properties": {
+      "threshold": { "param": true },
+      "knee": { "param": true },
+      "ratio": { "param": true },
+      "reduction": {},
+      "attack": { "param": true },
+      "release": { "param": true }
+    }
+  },
+  "BiquadFilterNode": {
+    "properties": {
+      "type": {},
+      "frequency": { "param": true },
+      "Q": { "param": true },
+      "detune": { "param": true },
+      "gain": { "param": true }
+    }
+  },
+  "WaveShaperNode": {
+    "properties": {
+      "curve": { "Float32Array": true },
+      "oversample": {}
+    }
+  },
+  "AnalyserNode": {
+    "properties": {
+      "fftSize": {},
+      "minDecibels": {},
+      "maxDecibels": {},
+      "smoothingTimeConstant": {},
+      "frequencyBinCount": { "readonly": true }
+    }
+  },
+  "AudioDestinationNode": {
+    "unbypassable": true
+  },
+  "ChannelSplitterNode": {
+    "unbypassable": true
+  },
+  "ChannelMergerNode": {
+    "unbypassable": true
+  },
+  "MediaElementAudioSourceNode": {},
+  "MediaStreamAudioSourceNode": {},
+  "MediaStreamAudioDestinationNode": {
+    "unbypassable": true,
+    "properties": {
+      "stream": { "MediaStream": true }
+    }
+  },
+  "StereoPannerNode": {
+    "properties": {
+      "pan": {}
+    }
+  }
+}
--- a/toolkit/devtools/server/actors/webaudio.js
+++ b/toolkit/devtools/server/actors/webaudio.js
@@ -11,17 +11,17 @@ const { Promise: promise } = Cu.import("
 const events = require("sdk/event/core");
 const { on: systemOn, off: systemOff } = require("sdk/system/events");
 const protocol = require("devtools/server/protocol");
 const { CallWatcherActor, CallWatcherFront } = require("devtools/server/actors/call-watcher");
 const { ThreadActor } = require("devtools/server/actors/script");
 const AutomationTimeline = require("./utils/automation-timeline");
 const { on, once, off, emit } = events;
 const { types, method, Arg, Option, RetVal } = protocol;
-
+const AUDIO_NODE_DEFINITION = require("devtools/server/actors/utils/audionodes.json");
 const ENABLE_AUTOMATION = false;
 const AUTOMATION_GRANULARITY = 2000;
 const AUTOMATION_GRANULARITY_MAX = 6000;
 
 const AUDIO_GLOBALS = [
   "AudioContext", "AudioNode", "AudioParam"
 ];
 
@@ -37,128 +37,16 @@ const AUTOMATION_METHODS = [
   "setValueAtTime", "linearRampToValueAtTime", "exponentialRampToValueAtTime",
   "setTargetAtTime", "setValueCurveAtTime", "cancelScheduledValues"
 ];
 
 const NODE_ROUTING_METHODS = [
   "connect", "disconnect"
 ];
 
-const NODE_PROPERTIES = {
-  "OscillatorNode": {
-    "properties": {
-      "type": {},
-      "frequency": {
-        "param": true
-      },
-      "detune": {
-        "param": true
-      }
-    }
-  },
-  "GainNode": {
-    "properties": { "gain": { "param": true }}
-  },
-  "DelayNode": {
-    "properties": { "delayTime": { "param": true }}
-  },
-  // TODO deal with figuring out adding `detune` AudioParam
-  // for AudioBufferSourceNode, which is in the spec
-  // but not yet added in implementation
-  // bug 1116852
-  "AudioBufferSourceNode": {
-    "properties": {
-      "buffer": { "Buffer": true },
-      "playbackRate": {
-        "param": true
-      },
-      "loop": {},
-      "loopStart": {},
-      "loopEnd": {}
-    }
-  },
-  "ScriptProcessorNode": {
-    "properties": { "bufferSize": { "readonly": true }}
-  },
-  "PannerNode": {
-    "properties": {
-      "panningModel": {},
-      "distanceModel": {},
-      "refDistance": {},
-      "maxDistance": {},
-      "rolloffFactor": {},
-      "coneInnerAngle": {},
-      "coneOuterAngle": {},
-      "coneOuterGain": {}
-    }
-  },
-  "ConvolverNode": {
-    "properties": {
-      "buffer": { "Buffer": true },
-      "normalize": {},
-    }
-  },
-  "DynamicsCompressorNode": {
-    "properties": {
-      "threshold": { "param": true },
-      "knee": { "param": true },
-      "ratio": { "param": true },
-      "reduction": {},
-      "attack": { "param": true },
-      "release": { "param": true }
-    }
-  },
-  "BiquadFilterNode": {
-    "properties": {
-      "type": {},
-      "frequency": { "param": true },
-      "Q": { "param": true },
-      "detune": { "param": true },
-      "gain": { "param": true }
-    }
-  },
-  "WaveShaperNode": {
-    "properties": {
-      "curve": { "Float32Array": true },
-      "oversample": {}
-    }
-  },
-  "AnalyserNode": {
-    "properties": {
-      "fftSize": {},
-      "minDecibels": {},
-      "maxDecibels": {},
-      "smoothingTimeConstant": {},
-      "frequencyBinCount": { "readonly": true },
-    }
-  },
-  "AudioDestinationNode": {
-    "unbypassable": true
-  },
-  "ChannelSplitterNode": {
-    "unbypassable": true
-  },
-  "ChannelMergerNode": {
-    "unbypassable": true
-  },
-  "MediaElementAudioSourceNode": {},
-  "MediaStreamAudioSourceNode": {},
-  "MediaStreamAudioDestinationNode": {
-    "unbypassable": true,
-    "properties": {
-      "stream": { "MediaStream": true }
-    }
-  },
-  "StereoPannerNode": {
-    "properties": {
-      "pan": {}
-    }
-  }
-};
-
 /**
  * An Audio Node actor allowing communication to a specific audio node in the
  * Audio Context graph.
  */
 types.addActorType("audionode");
 let AudioNodeActor = exports.AudioNodeActor = protocol.ActorClass({
   typeName: "audionode",
 
@@ -184,17 +72,17 @@ let AudioNodeActor = exports.AudioNodeAc
 
     try {
       this.type = getConstructorName(node);
     } catch (e) {
       this.type = "";
     }
 
     // Create automation timelines for all AudioParams
-    Object.keys(NODE_PROPERTIES[this.type].properties || {})
+    Object.keys(AUDIO_NODE_DEFINITION[this.type].properties || {})
       .filter(isAudioParam.bind(null, node))
       .forEach(paramName => {
         this.automation[paramName] = new AutomationTimeline(node[paramName].defaultValue);
       });
   },
 
   /**
    * Returns the name of the audio type.
@@ -247,17 +135,17 @@ let AudioNodeActor = exports.AudioNodeAc
    */
   bypass: method(function (enable) {
     let node = this.node.get();
 
     if (node === null) {
       return;
     }
 
-    let bypassable = !NODE_PROPERTIES[this.type].unbypassable;
+    let bypassable = !AUDIO_NODE_DEFINITION[this.type].unbypassable;
     if (bypassable) {
       node.passThrough = enable;
     }
 
     return this.isBypassed();
   }, {
     request: { enable: Arg(0, "boolean") },
     response: { bypassed: RetVal("boolean") }
@@ -340,28 +228,28 @@ let AudioNodeActor = exports.AudioNodeAc
    * Get an object containing key-value pairs of additional attributes
    * to be consumed by a front end, like if a property should be read only,
    * or is a special type (Float32Array, Buffer, etc.)
    *
    * @param String param
    *        Name of the AudioParam whose flags are desired.
    */
   getParamFlags: method(function (param) {
-    return ((NODE_PROPERTIES[this.type] || {}).properties || {})[param];
+    return ((AUDIO_NODE_DEFINITION[this.type] || {}).properties || {})[param];
   }, {
     request: { param: Arg(0, "string") },
     response: { flags: RetVal("nullable:primitive") }
   }),
 
   /**
    * Get an array of objects each containing a `param` and `value` property,
    * corresponding to a property name and current value of the audio node.
    */
   getParams: method(function (param) {
-    let props = Object.keys(NODE_PROPERTIES[this.type].properties || {});
+    let props = Object.keys(AUDIO_NODE_DEFINITION[this.type].properties || {});
     return props.map(prop =>
       ({ param: prop, value: this.getParam(prop), flags: this.getParamFlags(prop) }));
   }, {
     response: { params: RetVal("json") }
   }),
 
   /**
    * Connects this audionode to an AudioParam via `node.connect(param)`.
@@ -613,17 +501,17 @@ let WebAudioActor = exports.WebAudioActo
     this.finalize();
   },
 
   /**
    * Returns definition of all AudioNodes, such as AudioParams, and
    * flags.
    */
   getDefinition: method(function () {
-    return NODE_PROPERTIES;
+    return AUDIO_NODE_DEFINITION;
   }, {
     response: { definition: RetVal("json") }
   }),
 
   /**
    * Starts waiting for the current tab actor's document global to be
    * created, in order to instrument the Canvas context and become
    * aware of everything the content does with Web Audio.
@@ -828,17 +716,17 @@ let WebAudioActor = exports.WebAudioActo
 
   /**
    * Takes an XrayWrapper node, and attaches the node's `nativeID`
    * to the AudioParams as `_parentID`, as well as the the type of param
    * as a string on `_paramName`.
    */
   _instrumentParams: function (node) {
     let type = getConstructorName(node);
-    Object.keys(NODE_PROPERTIES[type].properties || {})
+    Object.keys(AUDIO_NODE_DEFINITION[type].properties || {})
       .filter(isAudioParam.bind(null, node))
       .forEach(paramName => {
         let param = node[paramName];
         param._parentID = node.id;
         param._paramName = paramName;
       });
   },
 
--- a/toolkit/devtools/server/moz.build
+++ b/toolkit/devtools/server/moz.build
@@ -69,16 +69,17 @@ EXTRA_JS_MODULES.devtools.server.actors 
     'actors/webaudio.js',
     'actors/webbrowser.js',
     'actors/webconsole.js',
     'actors/webgl.js',
 ]
 
 EXTRA_JS_MODULES.devtools.server.actors.utils += [
     'actors/utils/actor-registry-utils.js',
+    'actors/utils/audionodes.json',
     'actors/utils/automation-timeline.js',
     'actors/utils/make-debugger.js',
     'actors/utils/map-uri-to-addon-id.js',
     'actors/utils/ScriptStore.js',
     'actors/utils/stack.js',
 ]
 
 FAIL_ON_WARNINGS = True
--- a/toolkit/modules/RemoteFinder.jsm
+++ b/toolkit/modules/RemoteFinder.jsm
@@ -12,28 +12,44 @@ const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "GetClipboardSearchString",
   () => Cu.import("resource://gre/modules/Finder.jsm", {}).GetClipboardSearchString
 );
 
 function RemoteFinder(browser) {
-  this._browser = browser;
   this._listeners = new Set();
   this._searchString = null;
 
-  let mm = this._browser.messageManager;
-  mm.addMessageListener("Finder:Result", this);
-  mm.addMessageListener("Finder:MatchesResult", this);
-  mm.addMessageListener("Finder:CurrentSelectionResult",this);
-  mm.sendAsyncMessage("Finder:Initialize");
+  this.swapBrowser(browser);
 }
 
 RemoteFinder.prototype = {
+  swapBrowser: function(aBrowser) {
+    if (this._messageManager) {
+      this._messageManager.removeMessageListener("Finder:Result", this);
+      this._messageManager.removeMessageListener("Finder:MatchesResult", this);
+      this._messageManager.removeMessageListener("Finder:CurrentSelectionResult",this);
+    }
+    else {
+      aBrowser.messageManager.sendAsyncMessage("Finder:Initialize");
+    }
+
+    this._browser = aBrowser;
+    this._messageManager = this._browser.messageManager;
+    this._messageManager.addMessageListener("Finder:Result", this);
+    this._messageManager.addMessageListener("Finder:MatchesResult", this);
+    this._messageManager.addMessageListener("Finder:CurrentSelectionResult", this);
+
+    // Ideally listeners would have removed themselves but that doesn't happen
+    // right now
+    this._listeners.clear();
+  },
+
   addResultListener: function (aListener) {
     this._listeners.add(aListener);
   },
 
   removeResultListener: function (aListener) {
     this._listeners.delete(aListener);
   },
 
@@ -53,17 +69,23 @@ RemoteFinder.prototype = {
         break;
       case "Finder:CurrentSelectionResult":
         callback = "onCurrentSelection";
         params = [ aMessage.data.selection, aMessage.data.initial ];
         break;
     }
 
     for (let l of this._listeners) {
-      l[callback].apply(l, params);
+      // Don't let one callback throwing stop us calling the rest
+      try {
+        l[callback].apply(l, params);
+      }
+      catch (e) {
+        Cu.reportError(e);
+      }
     }
   },
 
   get searchString() {
     return this._searchString;
   },
 
   get clipboardSearchString() {
--- a/toolkit/modules/RemoteWebNavigation.jsm
+++ b/toolkit/modules/RemoteWebNavigation.jsm
@@ -13,23 +13,32 @@ function makeURI(url)
 {
   return Cc["@mozilla.org/network/io-service;1"].
          getService(Ci.nsIIOService).
          newURI(url, null, null);
 }
 
 function RemoteWebNavigation(browser)
 {
-  this._browser = browser;
-  this._browser.messageManager.addMessageListener("WebNavigation:setHistory", this);
+  this.swapBrowser(browser);
 }
 
 RemoteWebNavigation.prototype = {
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebNavigation, Ci.nsISupports]),
 
+  swapBrowser: function(aBrowser) {
+    if (this._messageManager) {
+      this._messageManager.removeMessageListener("WebNavigation:setHistory", this);
+    }
+
+    this._browser = aBrowser;
+    this._messageManager = aBrowser.messageManager;
+    this._messageManager.addMessageListener("WebNavigation:setHistory", this);
+  },
+
   LOAD_FLAGS_MASK: 65535,
   LOAD_FLAGS_NONE: 0,
   LOAD_FLAGS_IS_REFRESH: 16,
   LOAD_FLAGS_IS_LINK: 32,
   LOAD_FLAGS_BYPASS_HISTORY: 64,
   LOAD_FLAGS_REPLACE_HISTORY: 128,
   LOAD_FLAGS_BYPASS_CACHE: 256,
   LOAD_FLAGS_BYPASS_PROXY: 512,
--- a/toolkit/modules/RemoteWebProgress.jsm
+++ b/toolkit/modules/RemoteWebProgress.jsm
@@ -64,28 +64,41 @@ RemoteWebProgress.prototype = {
   },
 
   removeProgressListener: function (aListener) {
     this._manager.removeProgressListener(aListener);
   }
 };
 
 function RemoteWebProgressManager (aBrowser) {
-  this._browser = aBrowser;
   this._topLevelWebProgress = new RemoteWebProgress(this, true);
   this._progressListeners = [];
 
-  this._browser.messageManager.addMessageListener("Content:StateChange", this);
-  this._browser.messageManager.addMessageListener("Content:LocationChange", this);
-  this._browser.messageManager.addMessageListener("Content:SecurityChange", this);
-  this._browser.messageManager.addMessageListener("Content:StatusChange", this);
-  this._browser.messageManager.addMessageListener("Content:ProgressChange", this);
+  this.swapBrowser(aBrowser);
 }
 
 RemoteWebProgressManager.prototype = {
+  swapBrowser: function(aBrowser) {
+    if (this._messageManager) {
+      this._messageManager.removeMessageListener("Content:StateChange", this);
+      this._messageManager.removeMessageListener("Content:LocationChange", this);
+      this._messageManager.removeMessageListener("Content:SecurityChange", this);
+      this._messageManager.removeMessageListener("Content:StatusChange", this);
+      this._messageManager.removeMessageListener("Content:ProgressChange", this);
+    }
+
+    this._browser = aBrowser;
+    this._messageManager = aBrowser.messageManager;
+    this._messageManager.addMessageListener("Content:StateChange", this);
+    this._messageManager.addMessageListener("Content:LocationChange", this);
+    this._messageManager.addMessageListener("Content:SecurityChange", this);
+    this._messageManager.addMessageListener("Content:StatusChange", this);
+    this._messageManager.addMessageListener("Content:ProgressChange", this);
+  },
+
   get topLevelWebProgress() {
     return this._topLevelWebProgress;
   },
 
   addProgressListener: function (aListener) {
     let listener = aListener.QueryInterface(Ci.nsIWebProgressListener);
     this._progressListeners.push(listener);
   },