Merge fx-team to m-c. a=merge
authorRyan VanderMeulen <ryanvm@gmail.com>
Fri, 27 Feb 2015 13:21:37 -0500
changeset 261455 ff17afa674114c7ed0370e6da03e48a4e0ceecb6
parent 261438 9dd9d1e5b43cd8cca7a2313f13517f3dbea489fc (current diff)
parent 261454 54d3bf38ca10c3c04db9d7bee35582ee19c5eae5 (diff)
child 261487 b94bcbc389e828344479f700d3970537c4abfff2
push id830
push userraliiev@mozilla.com
push dateFri, 19 Jun 2015 19:24:37 +0000
treeherdermozilla-release@932614382a68 [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 CLOSED TREE
browser/app/profile/firefox.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1713,25 +1713,27 @@ pref("loop.ping.interval", 1800000);
 pref("loop.ping.timeout", 10000);
 pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback");
 pref("loop.feedback.product", "Loop");
 pref("loop.debug.loglevel", "Error");
 pref("loop.debug.dispatcher", false);
 pref("loop.debug.websocket", false);
 pref("loop.debug.sdk", false);
 #ifdef DEBUG
-pref("loop.CSP", "default-src 'self' about: file: chrome: http://localhost:*; img-src 'self' data: http://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net http://localhost:* ws://localhost:*; media-src blob:");
+pref("loop.CSP", "default-src 'self' about: file: chrome: http://localhost:*; img-src 'self' data: https://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net http://localhost:* ws://localhost:*; media-src blob:");
 #else
-pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src 'self' data: http://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net; media-src blob:");
+pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src 'self' data: https://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net; media-src blob:");
 #endif
 pref("loop.oauth.google.redirect_uri", "urn:ietf:wg:oauth:2.0:oob:auto");
 pref("loop.oauth.google.scope", "https://www.google.com/m8/feeds");
 pref("loop.fxa_oauth.tokendata", "");
 pref("loop.fxa_oauth.profile", "");
 pref("loop.support_url", "https://support.mozilla.org/kb/group-conversations-firefox-hello-webrtc");
+pref("loop.contacts.gravatars.show", false);
+pref("loop.contacts.gravatars.promo", true);
 
 // serverURL to be assigned by services team
 pref("services.push.serverURL", "wss://push.services.mozilla.com/");
 
 pref("social.sidebar.unload_timeout_ms", 10000);
 
 // activation from inside of share panel is possible if activationPanelEnabled
 // is true. Pref'd off for release while usage testing is done through beta.
--- a/browser/base/content/browser-menubar.inc
+++ b/browser/base/content/browser-menubar.inc
@@ -441,17 +441,19 @@
                    placespopup="true"
 #endif
                    context="placesContext"
                    onpopupshowing="if (!this.parentNode._placesView)
                                      new PlacesMenu(event, 'place:folder=TOOLBAR');"/>
       </menu>
 #ifndef XP_MACOSX
 # Disabled on Mac because we can't fill native menupopups asynchronously
-      <menuseparator/>
+      <menuseparator id="menu_readingListSeparator">
+        <observes element="readingListSidebar" attribute="hidden"/>
+      </menuseparator>
       <menu id="menu_readingList"
             class="menu-iconic bookmark-item"
             label="&readingList.label;"
             container="true">
         <observes element="readingListSidebar" attribute="hidden"/>
         <menupopup id="readingListPopup"
 #ifndef XP_MACOSX
                    placespopup="true"
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -113,16 +113,25 @@ const cloneValueInto = function(value, t
     MozLoopService.log.debug("Failed to clone value:", value);
     throw ex;
   }
 
   return clone;
 };
 
 /**
+ * Get the two-digit hexadecimal code for a byte
+ *
+ * @param {byte} charCode
+ */
+const toHexString = function(charCode) {
+  return ("0" + charCode.toString(16)).slice(-2);
+};
+
+/**
  * Inject any API containing _only_ function properties into the given window.
  *
  * @param {Object}       api          Object containing functions that need to
  *                                    be exposed to content
  * @param {nsIDOMWindow} targetWindow The content window to attach the API
  */
 const injectObjectAPI = function(api, targetWindow) {
   let injectedAPI = {};
@@ -736,16 +745,53 @@ function injectLoopAPI(targetWindow) {
           callback(null, cloneValueInto(blob, targetWindow));
         };
 
         request.send();
       }
     },
 
     /**
+     * Compose a URL pointing to the location of an avatar by email address.
+     * At the moment we use the Gravatar service to match email addresses with
+     * avatars. This might change in the future as avatars might come from another
+     * source.
+     *
+     * @param {String} emailAddress Users' email address
+     * @param {Number} size         Size of the avatar image to return in pixels.
+     *                              Optional. Default value: 40.
+     * @return the URL pointing to an avatar matching the provided email address.
+     */
+    getUserAvatar: {
+      enumerable: true,
+      writable: true,
+      value: function(emailAddress, size = 40) {
+        const kEmptyGif = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+        if (!emailAddress || !MozLoopService.getLoopPref("contacts.gravatars.show")) {
+          return kEmptyGif;
+        }
+
+        // Do the MD5 dance.
+        let hasher = Cc["@mozilla.org/security/hash;1"]
+                       .createInstance(Ci.nsICryptoHash);
+        hasher.init(Ci.nsICryptoHash.MD5);
+        let stringStream = Cc["@mozilla.org/io/string-input-stream;1"]
+                             .createInstance(Ci.nsIStringInputStream);
+        stringStream.data = emailAddress.trim().toLowerCase();
+        hasher.updateFromStream(stringStream, -1);
+        let hash = hasher.finish(false);
+        // Convert the binary hash data to a hex string.
+        let md5Email = [toHexString(hash.charCodeAt(i)) for (i in hash)].join("");
+
+        // Compose the Gravatar URL.
+        return "https://www.gravatar.com/avatar/" + md5Email + ".jpg?default=blank&s=" + size;
+      }
+    },
+
+    /**
      * Associates a session-id and a call-id with a window for debugging.
      *
      * @param  {string}  windowId  The window id.
      * @param  {string}  sessionId OT session id.
      * @param  {string}  callId    The callId on the server.
      */
     addConversationContext: {
       enumerable: true,
--- a/browser/components/loop/content/css/contacts.css
+++ b/browser/components/loop/content/css/contacts.css
@@ -224,8 +224,49 @@
 
 .contact > .dropdown-menu > .dropdown-menu-item > .icon-remove {
   background-image: url("../shared/img/icons-16x16.svg#delete");
 }
 
 .contact-form > .button-group {
   margin-top: 1rem;
 }
+
+.contacts-gravatar-promo {
+  position: relative;
+  border: 1px dashed #c1c1c1;
+  border-radius: 2px;
+  background-color: #fbfbfb;
+  padding: 10px;
+  margin-top: 10px;
+}
+
+.contacts-gravatar-promo > p {
+  margin-top: 2px;
+  margin-bottom: 8px;
+  margin-right: 4px;
+  word-wrap: break-word;
+}
+
+body[dir=rtl] .contacts-gravatar-promo > p {
+  margin-right: 0;
+  margin-left: 4px;
+}
+
+.contacts-gravatar-promo > p > a {
+  color: #0295df;
+  text-decoration: none;
+}
+
+.contacts-gravatar-promo > p > a:hover {
+  text-decoration: underline;
+}
+
+.contacts-gravatar-promo > .button-close {
+  position: absolute;
+  top: 8px;
+  right: 8px;
+}
+
+body[dir=rtl] .contacts-gravatar-promo > .button-close {
+  right: auto;
+  left: 8px;
+}
--- a/browser/components/loop/content/css/panel.css
+++ b/browser/components/loop/content/css/panel.css
@@ -385,16 +385,33 @@ body {
 }
 
 .button.button-accept:hover:active {
   background-color: #3aa689;
   border-color: #3aa689;
   color: #fff;
 }
 
+.button-close {
+  background-color: transparent;
+  background-image: url(../shared/img/icons-10x10.svg#close);
+  background-repeat: no-repeat;
+  background-size: 8px 8px;
+  border: none;
+  padding: 0;
+  height: 8px;
+  width: 8px;
+}
+
+.button-close:hover,
+.button-close:hover:active {
+  background-color: transparent;
+  border: none;
+}
+
 /* Dropdown menu */
 
 .dropdown {
   position: relative;
 }
 
 .dropdown-menu {
   position: absolute;
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -76,16 +76,69 @@ loop.contacts = (function(_, mozL10n) {
       if (contact[field][i].pref) {
         contact[field][i].value = value;
         return;
       }
     }
     contact[field][0].value = value;
   };
 
+  const GravatarPromo = React.createClass({displayName: "GravatarPromo",
+    propTypes: {
+      handleUse: React.PropTypes.func.isRequired
+    },
+
+    getInitialState: function() {
+      return {
+        showMe: navigator.mozLoop.getLoopPref("contacts.gravatars.promo") &&
+          !navigator.mozLoop.getLoopPref("contacts.gravatars.show")
+      };
+    },
+
+    handleCloseButtonClick: function() {
+      navigator.mozLoop.setLoopPref("contacts.gravatars.promo", false);
+      this.setState({ showMe: false });
+    },
+
+    handleUseButtonClick: function() {
+      navigator.mozLoop.setLoopPref("contacts.gravatars.promo", false);
+      navigator.mozLoop.setLoopPref("contacts.gravatars.show", true);
+      this.setState({ showMe: false });
+      this.props.handleUse();
+    },
+
+    render: function() {
+      if (!this.state.showMe) {
+        return null;
+      }
+
+      let privacyUrl = navigator.mozLoop.getLoopPref("legal.privacy_url");
+      let message = mozL10n.get("gravatars_promo_message", {
+        "learn_more": React.renderToStaticMarkup(
+          React.createElement("a", {href: privacyUrl, target: "_blank"}, 
+            mozL10n.get("gravatars_promo_message_learnmore")
+          )
+        )
+      });
+      return (
+        React.createElement("div", {className: "contacts-gravatar-promo"}, 
+          React.createElement(Button, {additionalClass: "button-close", onClick: this.handleCloseButtonClick}), 
+          React.createElement("p", {dangerouslySetInnerHTML: {__html: message}}), 
+          React.createElement(ButtonGroup, null, 
+            React.createElement(Button, {caption: mozL10n.get("gravatars_promo_button_nothanks"), 
+                    onClick: this.handleCloseButtonClick}), 
+            React.createElement(Button, {caption: mozL10n.get("gravatars_promo_button_use"), 
+                    additionalClass: "button-accept", 
+                    onClick: this.handleUseButtonClick})
+          )
+        )
+      );
+    }
+  });
+
   const ContactDropdown = React.createClass({displayName: "ContactDropdown",
     propTypes: {
       handleAction: React.PropTypes.func.isRequired,
       canEdit: React.PropTypes.bool
     },
 
     getInitialState: function () {
       return {
@@ -227,17 +280,19 @@ loop.contacts = (function(_, mozL10n) {
       let cx = React.addons.classSet;
       let contactCSSClass = cx({
         contact: true,
         blocked: this.props.contact.blocked
       });
 
       return (
         React.createElement("li", {className: contactCSSClass, onMouseLeave: this.hideDropdownMenu}, 
-          React.createElement("div", {className: "avatar"}), 
+          React.createElement("div", {className: "avatar"}, 
+            React.createElement("img", {src: navigator.mozLoop.getUserAvatar(email.value)})
+          ), 
           React.createElement("div", {className: "details"}, 
             React.createElement("div", {className: "username"}, React.createElement("strong", null, names.firstName), " ", names.lastName, 
               React.createElement("i", {className: cx({"icon icon-google": this.props.contact.category[0] == "google"})}), 
               React.createElement("i", {className: cx({"icon icon-blocked": this.props.contact.blocked})})
             ), 
             React.createElement("div", {className: "email"}, email.value)
           ), 
           React.createElement("div", {className: "icons"}, 
@@ -457,16 +512,22 @@ loop.contacts = (function(_, mozL10n) {
           }
           break;
         default:
           console.error("Unrecognized action: " + actionName);
           break;
       }
     },
 
+    handleUseGravatar: function() {
+      // We got permission to use Gravatar icons now, so we need to redraw the
+      // list entirely to show them.
+      this.refresh();
+    },
+
     sortContacts: function(contact1, contact2) {
       let comp = contact1.name[0].localeCompare(contact2.name[0]);
       if (comp !== 0) {
         return comp;
       }
       // If names are equal, compare against unique ids to make sure we have
       // consistent ordering.
       return contact1._guid - contact2._guid;
@@ -517,17 +578,18 @@ loop.contacts = (function(_, mozL10n) {
               ), 
               React.createElement(Button, {caption: mozL10n.get("new_contact_button"), 
                       onClick: this.handleAddContactButtonClick})
             ), 
             showFilter ?
             React.createElement("input", {className: "contact-filter", 
                    placeholder: mozL10n.get("contacts_search_placesholder"), 
                    valueLink: this.linkState("filter")})
-            : null
+            : null, 
+            React.createElement(GravatarPromo, {handleUse: this.handleUseGravatar})
           ), 
           React.createElement("ul", {className: "contact-list"}, 
             shownContacts.available ?
               shownContacts.available.sort(this.sortContacts).map(viewForItem) :
               null, 
             shownContacts.blocked && shownContacts.blocked.length > 0 ?
               React.createElement("div", {className: "contact-separator"}, mozL10n.get("contacts_blocked_contacts")) :
               null, 
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -76,16 +76,69 @@ loop.contacts = (function(_, mozL10n) {
       if (contact[field][i].pref) {
         contact[field][i].value = value;
         return;
       }
     }
     contact[field][0].value = value;
   };
 
+  const GravatarPromo = React.createClass({
+    propTypes: {
+      handleUse: React.PropTypes.func.isRequired
+    },
+
+    getInitialState: function() {
+      return {
+        showMe: navigator.mozLoop.getLoopPref("contacts.gravatars.promo") &&
+          !navigator.mozLoop.getLoopPref("contacts.gravatars.show")
+      };
+    },
+
+    handleCloseButtonClick: function() {
+      navigator.mozLoop.setLoopPref("contacts.gravatars.promo", false);
+      this.setState({ showMe: false });
+    },
+
+    handleUseButtonClick: function() {
+      navigator.mozLoop.setLoopPref("contacts.gravatars.promo", false);
+      navigator.mozLoop.setLoopPref("contacts.gravatars.show", true);
+      this.setState({ showMe: false });
+      this.props.handleUse();
+    },
+
+    render: function() {
+      if (!this.state.showMe) {
+        return null;
+      }
+
+      let privacyUrl = navigator.mozLoop.getLoopPref("legal.privacy_url");
+      let message = mozL10n.get("gravatars_promo_message", {
+        "learn_more": React.renderToStaticMarkup(
+          <a href={privacyUrl} target="_blank">
+            {mozL10n.get("gravatars_promo_message_learnmore")}
+          </a>
+        )
+      });
+      return (
+        <div className="contacts-gravatar-promo">
+          <Button additionalClass="button-close" onClick={this.handleCloseButtonClick}/>
+          <p dangerouslySetInnerHTML={{__html: message}}></p>
+          <ButtonGroup>
+            <Button caption={mozL10n.get("gravatars_promo_button_nothanks")}
+                    onClick={this.handleCloseButtonClick}/>
+            <Button caption={mozL10n.get("gravatars_promo_button_use")}
+                    additionalClass="button-accept"
+                    onClick={this.handleUseButtonClick}/>
+          </ButtonGroup>
+        </div>
+      );
+    }
+  });
+
   const ContactDropdown = React.createClass({
     propTypes: {
       handleAction: React.PropTypes.func.isRequired,
       canEdit: React.PropTypes.bool
     },
 
     getInitialState: function () {
       return {
@@ -227,17 +280,19 @@ loop.contacts = (function(_, mozL10n) {
       let cx = React.addons.classSet;
       let contactCSSClass = cx({
         contact: true,
         blocked: this.props.contact.blocked
       });
 
       return (
         <li className={contactCSSClass} onMouseLeave={this.hideDropdownMenu}>
-          <div className="avatar" />
+          <div className="avatar">
+            <img src={navigator.mozLoop.getUserAvatar(email.value)} />
+          </div>
           <div className="details">
             <div className="username"><strong>{names.firstName}</strong> {names.lastName}
               <i className={cx({"icon icon-google": this.props.contact.category[0] == "google"})} />
               <i className={cx({"icon icon-blocked": this.props.contact.blocked})} />
             </div>
             <div className="email">{email.value}</div>
           </div>
           <div className="icons">
@@ -457,16 +512,22 @@ loop.contacts = (function(_, mozL10n) {
           }
           break;
         default:
           console.error("Unrecognized action: " + actionName);
           break;
       }
     },
 
+    handleUseGravatar: function() {
+      // We got permission to use Gravatar icons now, so we need to redraw the
+      // list entirely to show them.
+      this.refresh();
+    },
+
     sortContacts: function(contact1, contact2) {
       let comp = contact1.name[0].localeCompare(contact2.name[0]);
       if (comp !== 0) {
         return comp;
       }
       // If names are equal, compare against unique ids to make sure we have
       // consistent ordering.
       return contact1._guid - contact2._guid;
@@ -518,16 +579,17 @@ loop.contacts = (function(_, mozL10n) {
               <Button caption={mozL10n.get("new_contact_button")}
                       onClick={this.handleAddContactButtonClick} />
             </ButtonGroup>
             {showFilter ?
             <input className="contact-filter"
                    placeholder={mozL10n.get("contacts_search_placesholder")}
                    valueLink={this.linkState("filter")} />
             : null }
+            <GravatarPromo handleUse={this.handleUseGravatar}/>
           </div>
           <ul className="contact-list">
             {shownContacts.available ?
               shownContacts.available.sort(this.sortContacts).map(viewForItem) :
               null}
             {shownContacts.blocked && shownContacts.blocked.length > 0 ?
               <div className="contact-separator">{mozL10n.get("contacts_blocked_contacts")}</div> :
               null}
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -35,16 +35,28 @@ loop.panel = (function(_, mozL10n) {
       };
     },
 
     shouldComponentUpdate: function(nextProps, nextState) {
       var tabChange = this.state.selectedTab !== nextState.selectedTab;
       if (tabChange) {
         this.props.mozLoop.notifyUITour("Loop:PanelTabChanged", nextState.selectedTab);
       }
+
+      if (!tabChange && nextProps.buttonsHidden) {
+        if (nextProps.buttonsHidden.length !== this.props.buttonsHidden.length) {
+          tabChange = true;
+        } else {
+          for (var i = 0, l = nextProps.buttonsHidden.length; i < l && !tabChange; ++i) {
+            if (this.props.buttonsHidden.indexOf(nextProps.buttonsHidden[i]) === -1) {
+              tabChange = true;
+            }
+          }
+        }
+      }
       return tabChange;
     },
 
     getInitialState: function() {
       // XXX Work around props.selectedTab being undefined initially.
       // When we don't need to rely on the pref, this can move back to
       // getDefaultProps (bug 1100258).
       return {
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -35,16 +35,28 @@ loop.panel = (function(_, mozL10n) {
       };
     },
 
     shouldComponentUpdate: function(nextProps, nextState) {
       var tabChange = this.state.selectedTab !== nextState.selectedTab;
       if (tabChange) {
         this.props.mozLoop.notifyUITour("Loop:PanelTabChanged", nextState.selectedTab);
       }
+
+      if (!tabChange && nextProps.buttonsHidden) {
+        if (nextProps.buttonsHidden.length !== this.props.buttonsHidden.length) {
+          tabChange = true;
+        } else {
+          for (var i = 0, l = nextProps.buttonsHidden.length; i < l && !tabChange; ++i) {
+            if (this.props.buttonsHidden.indexOf(nextProps.buttonsHidden[i]) === -1) {
+              tabChange = true;
+            }
+          }
+        }
+      }
       return tabChange;
     },
 
     getInitialState: function() {
       // XXX Work around props.selectedTab being undefined initially.
       // When we don't need to rely on the pref, this can move back to
       // getDefaultProps (bug 1100258).
       return {
--- a/browser/components/loop/content/shared/js/dispatcher.js
+++ b/browser/components/loop/content/shared/js/dispatcher.js
@@ -67,17 +67,21 @@ loop.Dispatcher = (function() {
 
       this._active = true;
 
       if (this._debug) {
         console.log("[Dispatcher] Dispatching action", action);
       }
 
       registeredStores.forEach(function(store) {
-        store[type](action);
+        try {
+          store[type](action);
+        } catch (x) {
+          console.error("[Dispatcher] Dispatching action caused an exception: ", x);
+        }
       });
 
       this._active = false;
       this._dispatchNextAction();
     }
   };
 
   return Dispatcher;
--- a/browser/components/loop/test/desktop-local/contacts_test.js
+++ b/browser/components/loop/test/desktop-local/contacts_test.js
@@ -9,73 +9,251 @@ var expect = chai.expect;
 var TestUtils = React.addons.TestUtils;
 
 describe("loop.contacts", function() {
   "use strict";
 
   var fakeAddContactButtonText = "Fake Add Contact";
   var fakeEditContactButtonText = "Fake Edit Contact";
   var fakeDoneButtonText = "Fake Done";
+  // The fake contacts array is copied each time mozLoop.contacts.getAll() is called.
+  var fakeContacts = [{
+    id: 1,
+    _guid: 1,
+    name: ["Ally Avocado"],
+    email: [{
+      "pref": true,
+      "type": ["work"],
+      "value": "ally@mail.com"
+    }],
+    tel: [{
+      "pref": true,
+      "type": ["mobile"],
+      "value": "+31-6-12345678"
+    }],
+    category: ["google"],
+    published: 1406798311748,
+    updated: 1406798311748
+  },{
+    id: 2,
+    _guid: 2,
+    name: ["Bob Banana"],
+    email: [{
+      "pref": true,
+      "type": ["work"],
+      "value": "bob@gmail.com"
+    }],
+    tel: [{
+      "pref": true,
+      "type": ["mobile"],
+      "value": "+1-214-5551234"
+    }],
+    category: ["local"],
+    published: 1406798311748,
+    updated: 1406798311748
+  }, {
+    id: 3,
+    _guid: 3,
+    name: ["Caitlin Cantaloupe"],
+    email: [{
+      "pref": true,
+      "type": ["work"],
+      "value": "caitlin.cant@hotmail.com"
+    }],
+    category: ["local"],
+    published: 1406798311748,
+    updated: 1406798311748
+  }, {
+    id: 4,
+    _guid: 4,
+    name: ["Dave Dragonfruit"],
+    email: [{
+      "pref": true,
+      "type": ["work"],
+      "value": "dd@dragons.net"
+    }],
+    category: ["google"],
+    published: 1406798311748,
+    updated: 1406798311748
+  }];
   var sandbox;
   var fakeWindow;
   var notifications;
+  var listView;
+  var oldMozLoop = navigator.mozLoop;
 
   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;
         } else if (entityName == "edit_contact_done_button") {
           textContentValue = fakeDoneButtonText;
         }
         return JSON.stringify({textContent: textContentValue});
       },
+      getLoopPref: function(pref) {
+        if (pref == "contacts.gravatars.promo") {
+          return true;
+        } else if (pref == "contacts.gravatars.show") {
+          return false;
+        }
+        return "";
+      },
+      setLoopPref: sandbox.stub(),
+      getUserAvatar: function() {
+        if (navigator.mozLoop.getLoopPref("contacts.gravatars.show")) {
+          return "gravatarsEnabled";
+        }
+        return "gravatarsDisabled";
+      },
+      contacts: {
+        getAll: function(callback) {
+          callback(null, [].concat(fakeContacts));
+        },
+        on: sandbox.stub()
+      }
     };
 
     fakeWindow = {
       close: sandbox.stub(),
     };
     loop.shared.mixins.setRootObject(fakeWindow);
 
+    notifications = new loop.shared.models.NotificationCollection();
+
     document.mozL10n.initialize(navigator.mozLoop);
   });
 
   afterEach(function() {
+    listView = null;
     loop.shared.mixins.setRootObject(window);
+    navigator.mozLoop = oldMozLoop;
     sandbox.restore();
   });
 
+  describe("GravatarsPromo", function() {
+    function checkGravatarContacts(enabled) {
+      var node = listView.getDOMNode();
+
+      // When gravatars are enabled, contacts should be rendered with gravatars.
+      var gravatars = node.querySelectorAll(".contact img[src=gravatarsEnabled]");
+      expect(gravatars.length).to.equal(enabled ? fakeContacts.length : 0);
+      // Sanity check the reverse:
+      gravatars = node.querySelectorAll(".contact img[src=gravatarsDisabled]");
+      expect(gravatars.length).to.equal(enabled ? 0 : fakeContacts.length);
+    }
+
+    it("should show the gravatars promo box", function() {
+      listView = TestUtils.renderIntoDocument(
+        React.createElement(loop.contacts.ContactsList, {
+          notifications: notifications
+        }));
+
+      var promo = listView.getDOMNode().querySelector(".contacts-gravatar-promo");
+      expect(promo).to.not.equal(null);
+
+      checkGravatarContacts(false);
+    });
+
+    it("should not show the gravatars promo box when the 'contacts.gravatars.promo' pref is set", function() {
+      navigator.mozLoop.getLoopPref = function(pref) {
+        if (pref == "contacts.gravatars.promo") {
+          return false;
+        } else if (pref == "contacts.gravatars.show") {
+          return true;
+        }
+        return "";
+      };
+      listView = TestUtils.renderIntoDocument(
+        React.createElement(loop.contacts.ContactsList, {
+          notifications: notifications
+        }));
+
+      var promo = listView.getDOMNode().querySelector(".contacts-gravatar-promo");
+      expect(promo).to.equal(null);
+
+      checkGravatarContacts(true);
+    });
+
+    it("should hide the gravatars promo box when the 'use' button is clicked", function() {
+      listView = TestUtils.renderIntoDocument(
+        React.createElement(loop.contacts.ContactsList, {
+          notifications: notifications
+        }));
+
+      React.addons.TestUtils.Simulate.click(listView.getDOMNode().querySelector(
+        ".contacts-gravatar-promo .button-accept"));
+
+      sinon.assert.calledTwice(navigator.mozLoop.setLoopPref);
+
+      var promo = listView.getDOMNode().querySelector(".contacts-gravatar-promo");
+      expect(promo).to.equal(null);
+    });
+
+    it("should should set the prefs correctly when the 'use' button is clicked", function() {
+      listView = TestUtils.renderIntoDocument(
+        React.createElement(loop.contacts.ContactsList, {
+          notifications: notifications
+        }));
+
+      React.addons.TestUtils.Simulate.click(listView.getDOMNode().querySelector(
+        ".contacts-gravatar-promo .button-accept"));
+
+      sinon.assert.calledTwice(navigator.mozLoop.setLoopPref);
+      sinon.assert.calledWithExactly(navigator.mozLoop.setLoopPref, "contacts.gravatars.promo", false);
+      sinon.assert.calledWithExactly(navigator.mozLoop.setLoopPref, "contacts.gravatars.show", true);
+    });
+
+    it("should hide the gravatars promo box when the 'close' button is clicked", function() {
+      listView = TestUtils.renderIntoDocument(
+        React.createElement(loop.contacts.ContactsList, {
+          notifications: notifications
+        }));
+
+      React.addons.TestUtils.Simulate.click(listView.getDOMNode().querySelector(
+        ".contacts-gravatar-promo .button-close"));
+
+      var promo = listView.getDOMNode().querySelector(".contacts-gravatar-promo");
+      expect(promo).to.equal(null);
+    });
+
+    it("should set prefs correctly when the 'close' button is clicked", function() {
+      listView = TestUtils.renderIntoDocument(
+        React.createElement(loop.contacts.ContactsList, {
+          notifications: notifications
+        }));
+
+      React.addons.TestUtils.Simulate.click(listView.getDOMNode().querySelector(
+        ".contacts-gravatar-promo .button-close"));
+
+      sinon.assert.calledOnce(navigator.mozLoop.setLoopPref);
+      sinon.assert.calledWithExactly(navigator.mozLoop.setLoopPref,
+        "contacts.gravatars.promo", false);
+    });
+  });
 
   describe("ContactsList", function () {
-    var listView;
-
     beforeEach(function() {
       navigator.mozLoop.calls = {
         startDirectCall: sandbox.stub(),
         clearCallInProgress: sandbox.stub()
       };
       navigator.mozLoop.contacts = {getAll: sandbox.stub()};
 
-      notifications = new loop.shared.models.NotificationCollection();
       listView = TestUtils.renderIntoDocument(
         React.createElement(loop.contacts.ContactsList, {
           notifications: notifications
         }));
     });
 
-    afterEach(function() {
-      listView = null;
-      delete navigator.mozLoop.calls;
-      delete navigator.mozLoop.contacts;
-    });
-
     describe("#handleContactAction", function() {
       it("should call window.close when called with 'video-call' action",
         function() {
           listView.handleContactAction({}, "video-call");
 
           sinon.assert.calledOnce(fakeWindow.close);
       });
 
--- a/browser/components/loop/test/mochitest/browser_toolbarbutton.js
+++ b/browser/components/loop/test/mochitest/browser_toolbarbutton.js
@@ -33,26 +33,34 @@ add_task(function* test_LoopUI_getters()
   Assert.ok(LoopUI.panel, "LoopUI panel element should be set");
   Assert.strictEqual(LoopUI.browser, null, "Browser element should not be there yet");
   Assert.strictEqual(LoopUI.selectedTab, null, "No tab should be selected yet");
 
   // Load and show the Loop panel for the very first time this session.
   yield loadLoopPanel();
   Assert.ok(LoopUI.browser, "Browser element should be there");
   Assert.strictEqual(LoopUI.selectedTab, "rooms", "Initially the rooms tab should be selected");
+  let panelTabs = LoopUI.browser.contentDocument.querySelectorAll(".tab-view > li");
+  Assert.strictEqual(panelTabs.length, 1, "Only one tab, 'rooms', should be visible");
 
   // Hide the panel.
   yield LoopUI.togglePanel();
   Assert.strictEqual(LoopUI.selectedTab, "rooms", "Rooms tab should still be selected");
 
   // Make sure the contacts tab shows up by simulating a login.
   MozLoopServiceInternal.fxAOAuthTokenData = fxASampleToken;
   MozLoopServiceInternal.fxAOAuthProfile = fxASampleProfile;
   yield MozLoopServiceInternal.notifyStatusChanged("login");
 
+  yield LoopUI.togglePanel();
+  Assert.strictEqual(LoopUI.selectedTab, "rooms", "Rooms tab should still be selected");
+  panelTabs = LoopUI.browser.contentDocument.querySelectorAll(".tab-view > li");
+  Assert.strictEqual(panelTabs.length, 2, "Two tabs should be visible");
+  yield LoopUI.togglePanel();
+
   // Programmatically select the contacts tab.
   yield LoopUI.togglePanel(null, "contacts");
   Assert.strictEqual(LoopUI.selectedTab, "contacts", "Contacts tab should be selected now");
 
   // Switch back to the rooms tab.
   yield LoopUI.openCallPanel(null, "rooms");
   Assert.strictEqual(LoopUI.selectedTab, "rooms", "Rooms tab should be selected now");
 
--- a/browser/components/loop/test/shared/dispatcher_test.js
+++ b/browser/components/loop/test/shared/dispatcher_test.js
@@ -99,16 +99,41 @@ describe("loop.Dispatcher", function () 
       dispatcher.dispatch(cancelAction);
       dispatcher.dispatch(getDataAction);
 
       sinon.assert.calledOnce(cancelStore1.cancelCall);
       sinon.assert.calledOnce(getDataStore1.getWindowData);
       sinon.assert.calledOnce(getDataStore2.getWindowData);
     });
 
+    describe("Error handling", function() {
+      beforeEach(function() {
+        sandbox.stub(console, "error");
+      });
+
+      it("should handle uncaught exceptions", function() {
+        getDataStore1.getWindowData.throws("Uncaught Error");
+
+        dispatcher.dispatch(getDataAction);
+        dispatcher.dispatch(cancelAction);
+
+        sinon.assert.calledOnce(getDataStore1.getWindowData);
+        sinon.assert.calledOnce(getDataStore2.getWindowData);
+        sinon.assert.calledOnce(cancelStore1.cancelCall);
+      });
+
+      it("should log uncaught exceptions", function() {
+        getDataStore1.getWindowData.throws("Uncaught Error");
+
+        dispatcher.dispatch(getDataAction);
+
+        sinon.assert.calledOnce(console.error);
+      });
+    });
+
     describe("Queued actions", function() {
       beforeEach(function() {
         // Restore the stub, so that we can easily add a function to be
         // returned. Unfortunately, sinon doesn't make this easy.
         sandbox.stub(connectStore1, "connectCall", function() {
           dispatcher.dispatch(getDataAction);
 
           sinon.assert.notCalled(getDataStore1.getWindowData);
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -38,39 +38,106 @@ var fakeRooms = [
     "expiresAt": 1405534180,
     "participants": [
        { "displayName": "Alexis", "account": "alexis@example.com", "roomConnectionId": "2a1787a6-4a73-43b5-ae3e-906ec1e763cb" },
        { "displayName": "Adam", "roomConnectionId": "781f012b-f1ea-4ce1-9105-7cfc36fb4ec7" }
      ]
   }
 ];
 
+var fakeContacts = [{
+  id: 1,
+  _guid: 1,
+  name: ["Ally Avocado"],
+  email: [{
+    "pref": true,
+    "type": ["work"],
+    "value": "ally@mail.com"
+  }],
+  tel: [{
+    "pref": true,
+    "type": ["mobile"],
+    "value": "+31-6-12345678"
+  }],
+  category: ["google"],
+  published: 1406798311748,
+  updated: 1406798311748
+},{
+  id: 2,
+  _guid: 2,
+  name: ["Bob Banana"],
+  email: [{
+    "pref": true,
+    "type": ["work"],
+    "value": "bob@gmail.com"
+  }],
+  tel: [{
+    "pref": true,
+    "type": ["mobile"],
+    "value": "+1-214-5551234"
+  }],
+  category: ["local"],
+  published: 1406798311748,
+  updated: 1406798311748
+}, {
+  id: 3,
+  _guid: 3,
+  name: ["Caitlin Cantaloupe"],
+  email: [{
+    "pref": true,
+    "type": ["work"],
+    "value": "caitlin.cant@hotmail.com"
+  }],
+  category: ["local"],
+  published: 1406798311748,
+  updated: 1406798311748
+}, {
+  id: 4,
+  _guid: 4,
+  name: ["Dave Dragonfruit"],
+  email: [{
+    "pref": true,
+    "type": ["work"],
+    "value": "dd@dragons.net"
+  }],
+  category: ["google"],
+  published: 1406798311748,
+  updated: 1406798311748
+}];
+
 /**
  * Faking the mozLoop object which doesn't exist in regular web pages.
  * @type {Object}
  */
 navigator.mozLoop = {
   ensureRegistered: function() {},
   getAudioBlob: function(){},
   getLoopPref: function(pref) {
     switch(pref) {
       // Ensure we skip FTE completely.
       case "gettingStarted.seen":
+      case "contacts.gravatars.promo":
         return true;
+      case "contacts.gravatars.show":
+        return false;
     }
   },
   setLoopPref: function(){},
   releaseCallData: function() {},
   copyString: function() {},
+  getUserAvatar: function(emailAddress) {
+    return "http://www.gravatar.com/avatar/" + (Math.ceil(Math.random() * 3) === 2 ?
+      "0a996f0fe2727ef1668bdb11897e4459" : "foo") + ".jpg?default=blank&s=40";
+  },
   contacts: {
     getAll: function(callback) {
-      callback(null, []);
+      callback(null, [].concat(fakeContacts));
     },
     on: function() {}
   },
   rooms: {
     getAll: function(version, callback) {
-      callback(null, fakeRooms);
+      callback(null, [].concat(fakeRooms));
     },
     on: function() {}
   },
   fxAEnabled: true
 };
--- a/browser/components/readinglist/test/browser/browser_ui_enable_disable.js
+++ b/browser/components/readinglist/test/browser/browser_ui_enable_disable.js
@@ -5,28 +5,47 @@
 
 function checkRLState() {
   let enabled = RLUtils.enabled;
   info("Checking ReadingList UI is " + (enabled ? "enabled" : "disabled"));
 
   let sidebarBroadcaster = document.getElementById("readingListSidebar");
   let sidebarMenuitem = document.getElementById("menu_readingListSidebar");
 
+  let bookmarksMenubarItem = document.getElementById("menu_readingList");
+  let bookmarksMenubarSeparator = document.getElementById("menu_readingListSeparator");
+
   if (enabled) {
     Assert.notEqual(sidebarBroadcaster.getAttribute("hidden"), "true",
                     "Sidebar broadcaster should not be hidden");
     Assert.notEqual(sidebarMenuitem.getAttribute("hidden"), "true",
                     "Sidebar menuitem should be visible");
+
+    // Currently disabled on OSX.
+    if (bookmarksMenubarItem) {
+      Assert.notEqual(bookmarksMenubarItem.getAttribute("hidden"), "true",
+                      "RL bookmarks submenu in menubar should not be hidden");
+      Assert.notEqual(sidebarMenuitem.getAttribute("hidden"), "true",
+                      "RL bookmarks separator in menubar should be visible");
+    }
   } else {
     Assert.equal(sidebarBroadcaster.getAttribute("hidden"), "true",
                  "Sidebar broadcaster should be hidden");
     Assert.equal(sidebarMenuitem.getAttribute("hidden"), "true",
                  "Sidebar menuitem should be hidden");
     Assert.equal(ReadingListUI.isSidebarOpen, false,
                  "ReadingListUI should not think sidebar is open");
+
+    // Currently disabled on OSX.
+    if (bookmarksMenubarItem) {
+      Assert.equal(bookmarksMenubarItem.getAttribute("hidden"), "true",
+                      "RL bookmarks submenu in menubar should not be hidden");
+      Assert.equal(sidebarMenuitem.getAttribute("hidden"), "true",
+                      "RL bookmarks separator in menubar should be visible");
+    }
   }
 
   if (!enabled) {
     Assert.equal(SidebarUI.isOpen, false, "Sidebar should not be open");
   }
 }
 
 add_task(function*() {
--- a/browser/devtools/performance/test/head.js
+++ b/browser/devtools/performance/test/head.js
@@ -261,17 +261,20 @@ function command (button) {
 function click (win, button) {
   EventUtils.sendMouseEvent({ type: "click" }, button, win);
 }
 
 function mousedown (win, button) {
   EventUtils.sendMouseEvent({ type: "mousedown" }, button, win);
 }
 
-function* startRecording(panel, options={}) {
+function* startRecording(panel, options = {
+  waitForOverview: true,
+  waitForStateChanged: true
+}) {
   let win = panel.panelWin;
   let clicked = panel.panelWin.PerformanceView.once(win.EVENTS.UI_START_RECORDING);
   let willStart = panel.panelWin.PerformanceController.once(win.EVENTS.RECORDING_WILL_START);
   let hasStarted = panel.panelWin.PerformanceController.once(win.EVENTS.RECORDING_STARTED);
   let button = win.$("#main-record-button");
 
   ok(!button.hasAttribute("checked"),
     "The record button should not be checked yet.");
@@ -282,34 +285,41 @@ function* startRecording(panel, options=
   yield clicked;
 
   ok(button.hasAttribute("checked"),
     "The record button should now be checked.");
   ok(button.hasAttribute("locked"),
     "The record button should be locked.");
 
   yield willStart;
-  let stateChanged = once(win.PerformanceView, win.EVENTS.UI_STATE_CHANGED);
+  let stateChanged = options.waitForStateChanged
+    ? once(win.PerformanceView, win.EVENTS.UI_STATE_CHANGED)
+    : Promise.resolve();
 
   yield hasStarted;
-  let overviewRendered = options.waitForOverview ? once(win.OverviewView, win.EVENTS.OVERVIEW_RENDERED) : Promise.resolve();
+  let overviewRendered = options.waitForOverview
+    ? once(win.OverviewView, win.EVENTS.OVERVIEW_RENDERED)
+    : Promise.resolve();
 
   yield stateChanged;
   yield overviewRendered;
 
   is(win.PerformanceView.getState(), "recording",
     "The current state is 'recording'.");
 
   ok(button.hasAttribute("checked"),
     "The record button should still be checked.");
   ok(!button.hasAttribute("locked"),
     "The record button should not be locked.");
 }
 
-function* stopRecording(panel, options={}) {
+function* stopRecording(panel, options = {
+  waitForOverview: true,
+  waitForStateChanged: true
+}) {
   let win = panel.panelWin;
   let clicked = panel.panelWin.PerformanceView.once(win.EVENTS.UI_STOP_RECORDING);
   let willStop = panel.panelWin.PerformanceController.once(win.EVENTS.RECORDING_WILL_STOP);
   let hasStopped = panel.panelWin.PerformanceController.once(win.EVENTS.RECORDING_STOPPED);
   let button = win.$("#main-record-button");
 
   ok(button.hasAttribute("checked"),
     "The record button should already be checked.");
@@ -320,20 +330,24 @@ function* stopRecording(panel, options={
   yield clicked;
 
   ok(!button.hasAttribute("checked"),
     "The record button should not be checked.");
   ok(button.hasAttribute("locked"),
     "The record button should be locked.");
 
   yield willStop;
-  let stateChanged = once(win.PerformanceView, win.EVENTS.UI_STATE_CHANGED);
+  let stateChanged = options.waitForStateChanged
+    ? once(win.PerformanceView, win.EVENTS.UI_STATE_CHANGED)
+    : Promise.resolve();
 
   yield hasStopped;
-  let overviewRendered = options.waitForOverview ? once(win.OverviewView, win.EVENTS.OVERVIEW_RENDERED) : Promise.resolve();
+  let overviewRendered = options.waitForOverview
+    ? once(win.OverviewView, win.EVENTS.OVERVIEW_RENDERED)
+    : Promise.resolve();
 
   yield stateChanged;
   yield overviewRendered;
 
   is(win.PerformanceView.getState(), "recorded",
     "The current state is 'recorded'.");
 
   ok(!button.hasAttribute("checked"),
--- a/browser/devtools/performance/views/details-js-flamegraph.js
+++ b/browser/devtools/performance/views/details-js-flamegraph.js
@@ -29,21 +29,23 @@ let JsFlameGraphView = Heritage.extend(D
     this._onRangeChangeInGraph = this._onRangeChangeInGraph.bind(this);
 
     this.graph.on("selecting", this._onRangeChangeInGraph);
   }),
 
   /**
    * Unbinds events.
    */
-  destroy: function () {
+  destroy: Task.async(function* () {
     DetailsSubview.destroy.call(this);
 
     this.graph.off("selecting", this._onRangeChangeInGraph);
-  },
+
+    yield this.graph.destroy();
+  }),
 
   /**
    * Method for handling all the set up for rendering a new flamegraph.
    *
    * @param object interval [optional]
    *        The { startTime, endTime }, in milliseconds.
    */
   render: function (interval={}) {
--- a/browser/devtools/performance/views/details-memory-flamegraph.js
+++ b/browser/devtools/performance/views/details-memory-flamegraph.js
@@ -28,21 +28,23 @@ let MemoryFlameGraphView = Heritage.exte
     this._onRangeChangeInGraph = this._onRangeChangeInGraph.bind(this);
 
     this.graph.on("selecting", this._onRangeChangeInGraph);
   }),
 
   /**
    * Unbinds events.
    */
-  destroy: function () {
+  destroy: Task.async(function* () {
     DetailsSubview.destroy.call(this);
 
     this.graph.off("selecting", this._onRangeChangeInGraph);
-  },
+
+    yield this.graph.destroy();
+  }),
 
   /**
    * Method for handling all the set up for rendering a new flamegraph.
    *
    * @param object interval [optional]
    *        The { startTime, endTime }, in milliseconds.
    */
   render: function (interval={}) {
--- a/browser/devtools/performance/views/overview.js
+++ b/browser/devtools/performance/views/overview.js
@@ -49,34 +49,34 @@ let OverviewView = {
     PerformanceController.on(EVENTS.RECORDING_WILL_STOP, this._onRecordingWillStop);
     PerformanceController.on(EVENTS.RECORDING_STOPPED, this._onRecordingStopped);
     PerformanceController.on(EVENTS.RECORDING_SELECTED, this._onRecordingSelected);
   },
 
   /**
    * Unbinds events.
    */
-  destroy: function () {
+  destroy: Task.async(function*() {
     if (this.markersOverview) {
-      this.markersOverview.destroy();
+      yield this.markersOverview.destroy();
     }
     if (this.memoryOverview) {
-      this.memoryOverview.destroy();
+      yield this.memoryOverview.destroy();
     }
     if (this.framerateGraph) {
-      this.framerateGraph.destroy();
+      yield this.framerateGraph.destroy();
     }
 
     PerformanceController.off(EVENTS.PREF_CHANGED, this._onPrefChanged);
     PerformanceController.off(EVENTS.RECORDING_WILL_START, this._onRecordingWillStart);
     PerformanceController.off(EVENTS.RECORDING_STARTED, this._onRecordingStarted);
     PerformanceController.off(EVENTS.RECORDING_WILL_STOP, this._onRecordingWillStop);
     PerformanceController.off(EVENTS.RECORDING_STOPPED, this._onRecordingStopped);
     PerformanceController.off(EVENTS.RECORDING_SELECTED, this._onRecordingSelected);
-  },
+  }),
 
   /**
    * Disabled in the event we're using a Timeline mock, so we'll have no
    * markers, ticks or memory data to show, so just block rendering and hide
    * the panel.
    */
   disable: function () {
     this._disabled = true;
--- a/browser/devtools/shared/test/browser_flame-graph-01.js
+++ b/browser/devtools/shared/test/browser_flame-graph-01.js
@@ -21,17 +21,17 @@ function* performTest() {
   let readyEventEmitted;
   graph.once("ready", () => readyEventEmitted = true);
 
   yield graph.ready();
   ok(readyEventEmitted, "The 'ready' event should have been emitted");
 
   testGraph(host, graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(host, graph) {
   ok(graph._container.classList.contains("flame-graph-widget-container"),
     "The correct graph container was created.");
   ok(graph._canvas.classList.contains("flame-graph-widget-canvas"),
     "The correct graph container was created.");
--- a/browser/devtools/shared/test/browser_flame-graph-02.js
+++ b/browser/devtools/shared/test/browser_flame-graph-02.js
@@ -18,17 +18,17 @@ function* performTest() {
 
   let graph = new FlameGraph(doc.body);
   graph.fixedWidth = 200;
   graph.fixedHeight = 100;
 
   yield graph.ready();
   testGraph(host, graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(host, graph) {
   let bounds = host.frame.getBoundingClientRect();
 
   isnot(graph.width, bounds.width * window.devicePixelRatio,
     "The graph should not span all the parent node's width.");
--- a/browser/devtools/shared/test/browser_flame-graph-03a.js
+++ b/browser/devtools/shared/test/browser_flame-graph-03a.js
@@ -24,17 +24,17 @@ function* performTest() {
   let graph = new FlameGraph(doc.body, 1);
   graph.fixedWidth = TEST_WIDTH;
   graph.fixedHeight = TEST_HEIGHT;
 
   yield graph.ready();
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   graph.setData({ data: TEST_DATA, bounds: TEST_BOUNDS });
 
   is(graph.getViewRange().startTime, 0,
     "The selection start boundary is correct (1).");
--- a/browser/devtools/shared/test/browser_flame-graph-03b.js
+++ b/browser/devtools/shared/test/browser_flame-graph-03b.js
@@ -25,17 +25,17 @@ function* performTest() {
   let graph = new FlameGraph(doc.body, TEST_DPI_DENSITIY);
   graph.fixedWidth = TEST_WIDTH;
   graph.fixedHeight = TEST_HEIGHT;
 
   yield graph.ready();
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   graph.setData({ data: TEST_DATA, bounds: TEST_BOUNDS });
 
   is(graph.getViewRange().startTime, 0,
     "The selection start boundary is correct on HiDPI (1).");
--- a/browser/devtools/shared/test/browser_flame-graph-04.js
+++ b/browser/devtools/shared/test/browser_flame-graph-04.js
@@ -20,17 +20,17 @@ add_task(function*() {
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new FlameGraph(doc.body, 1);
   yield graph.ready();
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   is(graph._averageCharWidth, getAverageCharWidth(),
     "The average char width was calculated correctly.");
   is(graph._overflowCharWidth, getCharWidth(L10N.ellipsis),
     "The ellipsis char width was calculated correctly.");
--- a/browser/devtools/shared/test/browser_graphs-01.js
+++ b/browser/devtools/shared/test/browser_graphs-01.js
@@ -22,17 +22,17 @@ function* performTest() {
   let readyEventEmitted;
   graph.once("ready", () => readyEventEmitted = true);
 
   yield graph.ready();
   ok(readyEventEmitted, "The 'ready' event should have been emitted");
 
   testGraph(host, graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(host, graph) {
   ok(graph._container.classList.contains("line-graph-widget-container"),
     "The correct graph container was created.");
   ok(graph._canvas.classList.contains("line-graph-widget-canvas"),
     "The correct graph container was created.");
--- a/browser/devtools/shared/test/browser_graphs-02.js
+++ b/browser/devtools/shared/test/browser_graphs-02.js
@@ -17,17 +17,17 @@ add_task(function*() {
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
   yield graph.once("ready");
 
   testDataAndRegions(graph);
   testHighlights(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testDataAndRegions(graph) {
   let thrown1;
   try {
     graph.setRegions(TEST_REGIONS);
   } catch (e) {
--- a/browser/devtools/shared/test/browser_graphs-03.js
+++ b/browser/devtools/shared/test/browser_graphs-03.js
@@ -16,17 +16,17 @@ add_task(function*() {
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
   yield graph.once("ready");
 
   yield testSelection(graph);
   yield testCursor(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testSelection(graph) {
   ok(graph.getSelection().start === null,
     "The graph's selection should initially have a null start value.");
   ok(graph.getSelection().end === null,
     "The graph's selection should initially have a null end value.");
--- a/browser/devtools/shared/test/browser_graphs-04.js
+++ b/browser/devtools/shared/test/browser_graphs-04.js
@@ -14,17 +14,17 @@ add_task(function*() {
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
   yield graph.once("ready");
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   ok(!graph.hasSelection(),
     "There shouldn't initially be any selection.");
   is(graph.getSelectionWidth(), 0,
     "The selection width should be 0 when there's no selection.");
--- a/browser/devtools/shared/test/browser_graphs-05.js
+++ b/browser/devtools/shared/test/browser_graphs-05.js
@@ -16,17 +16,17 @@ add_task(function*() {
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
   yield graph.once("ready");
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   ok(!graph.getHoveredRegion(),
     "There should be no hovered region yet because there's no regions.");
 
   ok(!graph._isHoveringStartBoundary(),
--- a/browser/devtools/shared/test/browser_graphs-06.js
+++ b/browser/devtools/shared/test/browser_graphs-06.js
@@ -16,17 +16,17 @@ add_task(function*() {
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
   yield graph.once("ready");
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   graph.setData(TEST_DATA);
   graph.setRegions(TEST_REGIONS);
 
   click(graph, (graph._regions[0].start + graph._regions[0].end) / 2);
--- a/browser/devtools/shared/test/browser_graphs-07a.js
+++ b/browser/devtools/shared/test/browser_graphs-07a.js
@@ -15,17 +15,17 @@ add_task(function*() {
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
   yield graph.once("ready");
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   graph.setData(TEST_DATA);
 
   info("Making a selection.");
 
--- a/browser/devtools/shared/test/browser_graphs-07b.js
+++ b/browser/devtools/shared/test/browser_graphs-07b.js
@@ -15,17 +15,17 @@ add_task(function*() {
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
   yield graph.once("ready");
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   graph.setData(TEST_DATA);
   graph.selectionEnabled = false;
 
   info("Attempting to make a selection.");
--- a/browser/devtools/shared/test/browser_graphs-08.js
+++ b/browser/devtools/shared/test/browser_graphs-08.js
@@ -15,17 +15,17 @@ add_task(function*() {
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
   yield graph.once("ready");
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   graph.setData(TEST_DATA);
 
   dragStart(graph, 300);
   dragStop(graph, 500);
--- a/browser/devtools/shared/test/browser_graphs-09a.js
+++ b/browser/devtools/shared/test/browser_graphs-09a.js
@@ -14,17 +14,17 @@ add_task(function*() {
 });
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, { metric: "fps" });
 
   yield testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testGraph(graph) {
   info("Should be able to set the graph data before waiting for the ready event.");
 
   yield graph.setDataWhenReady(TEST_DATA);
   ok(graph.hasData(), "Data was set successfully.");
--- a/browser/devtools/shared/test/browser_graphs-09b.js
+++ b/browser/devtools/shared/test/browser_graphs-09b.js
@@ -16,17 +16,17 @@ add_task(function*() {
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
   graph.withTooltipArrows = false;
   graph.withFixedTooltipPositions = true;
 
   yield testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testGraph(graph) {
   yield graph.setDataWhenReady(TEST_DATA);
 
   is(graph._gutter.hidden, false,
     "The gutter should be visible even if the tooltips don't have arrows.");
--- a/browser/devtools/shared/test/browser_graphs-09c.js
+++ b/browser/devtools/shared/test/browser_graphs-09c.js
@@ -14,17 +14,17 @@ add_task(function*() {
 });
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
 
   yield testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testGraph(graph) {
   yield graph.setDataWhenReady(TEST_DATA);
 
   is(graph._gutter.hidden, true,
     "The gutter should be hidden, since there's no data available.");
--- a/browser/devtools/shared/test/browser_graphs-09d.js
+++ b/browser/devtools/shared/test/browser_graphs-09d.js
@@ -15,17 +15,17 @@ add_task(function*() {
 });
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
 
   yield testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testGraph(graph) {
   yield graph.setDataWhenReady(TEST_DATA);
 
   is(graph._gutter.hidden, false,
     "The gutter should not be hidden.");
--- a/browser/devtools/shared/test/browser_graphs-09e.js
+++ b/browser/devtools/shared/test/browser_graphs-09e.js
@@ -17,17 +17,17 @@ add_task(function*() {
 });
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
 
   yield testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testGraph(graph) {
   yield graph.setDataWhenReady(NO_DATA);
 
   is(graph._gutter.hidden, true,
     "The gutter should be hidden when there's no data available.");
--- a/browser/devtools/shared/test/browser_graphs-09f.js
+++ b/browser/devtools/shared/test/browser_graphs-09f.js
@@ -43,10 +43,10 @@ function* testGraph (parent, options) {
     `The min tooltip should ${options.min === false ? "not " : ""}be shown`);
   is(graph._minGutterLine.hidden, options.min === false,
     `The min gutter should ${options.min === false ? "not " : ""}be shown`);
   is(graph._avgTooltip.hidden, options.avg === false,
     `The avg tooltip should ${options.avg === false ? "not " : ""}be shown`);
   is(graph._avgGutterLine.hidden, options.avg === false,
     `The avg gutter should ${options.avg === false ? "not " : ""}be shown`);
 
-  graph.destroy();
+  yield graph.destroy();
 }
--- a/browser/devtools/shared/test/browser_graphs-10a.js
+++ b/browser/devtools/shared/test/browser_graphs-10a.js
@@ -22,17 +22,17 @@ function* performTest() {
 
   let refreshCount = 0;
   graph.on("refresh", () => refreshCount++);
 
   yield testGraph(host, graph);
 
   is(refreshCount, 2, "The graph should've been refreshed 2 times.");
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testGraph(host, graph) {
   graph.setData(TEST_DATA);
   let initialBounds = host.frame.getBoundingClientRect();
 
   host._window.resizeBy(-100, -100);
--- a/browser/devtools/shared/test/browser_graphs-10b.js
+++ b/browser/devtools/shared/test/browser_graphs-10b.js
@@ -28,17 +28,17 @@ function* performTest() {
   graph.on("refresh", () => refreshCount++);
   graph.on("refresh-cancelled", () => refreshCancelledCount++);
 
   yield testGraph(host, graph);
 
   is(refreshCount, 0, "The graph shouldn't have been refreshed at all.");
   is(refreshCancelledCount, 2, "The graph should've had 2 refresh attempts.");
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testGraph(host, graph) {
   graph.setData(TEST_DATA);
 
   host._window.resizeBy(-100, -100);
   yield graph.once("refresh-cancelled");
--- a/browser/devtools/shared/test/browser_graphs-11a.js
+++ b/browser/devtools/shared/test/browser_graphs-11a.js
@@ -20,17 +20,17 @@ add_task(function*() {
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new BarGraphWidget(doc.body);
   yield graph.once("ready");
 
   testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(graph) {
   graph.format = CATEGORIES;
   graph.setData([{ delta: 0, values: [] }]);
 
   let legendContainer = graph._document.querySelector(".bar-graph-widget-legend");
--- a/browser/devtools/shared/test/browser_graphs-11b.js
+++ b/browser/devtools/shared/test/browser_graphs-11b.js
@@ -24,17 +24,17 @@ function* performTest() {
 
   let graph = new BarGraphWidget(doc.body, 1);
   graph.fixedWidth = 200;
   graph.fixedHeight = 100;
 
   yield graph.once("ready");
   yield testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testGraph(graph) {
   graph.format = CATEGORIES;
   graph.dataOffsetX = 1000;
   graph.setData([{
     delta: 1100, values: [0, 2, 3]
--- a/browser/devtools/shared/test/browser_graphs-12.js
+++ b/browser/devtools/shared/test/browser_graphs-12.js
@@ -30,18 +30,18 @@ function* performTest() {
   CanvasGraphUtils.linkAnimation(graph1, graph2);
   CanvasGraphUtils.linkSelection(graph1, graph2);
 
   yield graph1.ready();
   yield graph2.ready();
 
   testGraphs(graph1, graph2);
 
-  graph1.destroy();
-  graph2.destroy();
+  yield graph1.destroy();
+  yield graph2.destroy();
   host.destroy();
 }
 
 function testGraphs(graph1, graph2) {
   info("Making a selection in the first graph.");
 
   dragStart(graph1, 300);
   ok(graph1.hasSelectionInProgress(),
--- a/browser/devtools/shared/test/browser_graphs-13.js
+++ b/browser/devtools/shared/test/browser_graphs-13.js
@@ -18,17 +18,17 @@ function* performTest() {
 
   let graph = new LineGraphWidget(doc.body, "fps");
   graph.fixedWidth = 200;
   graph.fixedHeight = 100;
 
   yield graph.ready();
   testGraph(host, graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function testGraph(host, graph) {
   let bounds = host.frame.getBoundingClientRect();
 
   isnot(graph.width, bounds.width * window.devicePixelRatio,
     "The graph should not span all the parent node's width.");
--- a/browser/devtools/shared/test/browser_graphs-14.js
+++ b/browser/devtools/shared/test/browser_graphs-14.js
@@ -14,17 +14,17 @@ add_task(function*() {
 });
 
 function* performTest() {
   let [host, win, doc] = yield createHost();
   let graph = new LineGraphWidget(doc.body, "fps");
 
   yield testGraph(graph);
 
-  graph.destroy();
+  yield graph.destroy();
   host.destroy();
 }
 
 function* testGraph(graph) {
   let mouseDownEvents = 0;
   let mouseUpEvents = 0;
   let scrollEvents = 0;
   graph.on("mousedown", () => mouseDownEvents++);
--- a/browser/devtools/shared/timeline/markers-overview.js
+++ b/browser/devtools/shared/timeline/markers-overview.js
@@ -21,16 +21,17 @@ loader.lazyRequireGetter(this, "L10N",
   "devtools/shared/timeline/global", true);
 
 const OVERVIEW_HEADER_HEIGHT = 14; // px
 const OVERVIEW_ROW_HEIGHT = 11; // px
 
 const OVERVIEW_SELECTION_LINE_COLOR = "#666";
 const OVERVIEW_CLIPHEAD_LINE_COLOR = "#555";
 
+const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100;
 const OVERVIEW_HEADER_TICKS_MULTIPLE = 100; // ms
 const OVERVIEW_HEADER_TICKS_SPACING_MIN = 75; // px
 const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px
 const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif";
 const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px
 const OVERVIEW_HEADER_TEXT_PADDING_TOP = 1; // px
 const OVERVIEW_MARKERS_COLOR_STOPS = [0, 0.1, 0.75, 1];
 const OVERVIEW_MARKER_WIDTH_MIN = 4; // px
@@ -194,19 +195,28 @@ MarkersOverview.prototype = Heritage.ext
   },
 
   /**
    * Finds the optimal tick interval between time markers in this overview.
    */
   _findOptimalTickInterval: function(dataScale) {
     let timingStep = OVERVIEW_HEADER_TICKS_MULTIPLE;
     let spacingMin = OVERVIEW_HEADER_TICKS_SPACING_MIN * this._pixelRatio;
+    let maxIters = FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS;
+    let numIters = 0;
+
+    if (dataScale > spacingMin) {
+      return dataScale;
+    }
 
     while (true) {
       let scaledStep = dataScale * timingStep;
+      if (++numIters > maxIters) {
+        return scaledStep;
+      }
       if (scaledStep < spacingMin) {
         timingStep <<= 1;
         continue;
       }
       return scaledStep;
     }
   },
 
--- a/browser/devtools/shared/timeline/waterfall.js
+++ b/browser/devtools/shared/timeline/waterfall.js
@@ -22,16 +22,17 @@ loader.lazyRequireGetter(this, "EventEmi
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 
 const WATERFALL_SIDEBAR_WIDTH = 150; // px
 
 const WATERFALL_IMMEDIATE_DRAW_MARKERS_COUNT = 30;
 const WATERFALL_FLUSH_OUTSTANDING_MARKERS_DELAY = 75; // ms
 
+const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100;
 const WATERFALL_HEADER_TICKS_MULTIPLE = 5; // ms
 const WATERFALL_HEADER_TICKS_SPACING_MIN = 50; // px
 const WATERFALL_HEADER_TEXT_PADDING = 3; // px
 
 const WATERFALL_BACKGROUND_TICKS_MULTIPLE = 5; // ms
 const WATERFALL_BACKGROUND_TICKS_SCALES = 3;
 const WATERFALL_BACKGROUND_TICKS_SPACING_MIN = 10; // px
 const WATERFALL_BACKGROUND_TICKS_COLOR_RGB = [128, 136, 144];
@@ -570,19 +571,28 @@ Waterfall.prototype = {
    *
    * @param number ticksMultiple
    * @param number ticksSpacingMin
    * @param number dataScale
    * @return number
    */
   _findOptimalTickInterval: function({ ticksMultiple, ticksSpacingMin, dataScale }) {
     let timingStep = ticksMultiple;
+    let maxIters = FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS;
+    let numIters = 0;
+
+    if (dataScale > ticksSpacingMin) {
+      return dataScale;
+    }
 
     while (true) {
       let scaledStep = dataScale * timingStep;
+      if (++numIters > maxIters) {
+        return scaledStep;
+      }
       if (scaledStep < ticksSpacingMin) {
         timingStep <<= 1;
         continue;
       }
       return scaledStep;
     }
   },
 
--- a/browser/devtools/shared/widgets/FlameGraph.jsm
+++ b/browser/devtools/shared/widgets/FlameGraph.jsm
@@ -21,16 +21,17 @@ const GRAPH_SRC = "chrome://browser/cont
 const L10N = new ViewHelpers.L10N();
 
 const GRAPH_RESIZE_EVENTS_DRAIN = 100; // ms
 
 const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00035;
 const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.5;
 const GRAPH_MIN_SELECTION_WIDTH = 0.001; // ms
 
+const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100;
 const TIMELINE_TICKS_MULTIPLE = 5; // ms
 const TIMELINE_TICKS_SPACING_MIN = 75; // px
 
 const OVERVIEW_HEADER_HEIGHT = 16; // px
 const OVERVIEW_HEADER_TEXT_COLOR = "#18191a";
 const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px
 const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif";
 const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px
@@ -175,17 +176,19 @@ FlameGraph.prototype = {
    */
   ready: function() {
     return this._ready.promise;
   },
 
   /**
    * Destroys this graph.
    */
-  destroy: function() {
+  destroy: Task.async(function*() {
+    yield this.ready();
+
     this._window.removeEventListener("mousemove", this._onMouseMove);
     this._window.removeEventListener("mousedown", this._onMouseDown);
     this._window.removeEventListener("mouseup", this._onMouseUp);
     this._window.removeEventListener("MozMousePixelScroll", this._onMouseWheel);
 
     let ownerWindow = this._parent.ownerDocument.defaultView;
     ownerWindow.removeEventListener("resize", this._onResize);
 
@@ -195,17 +198,17 @@ FlameGraph.prototype = {
     this._bounds = null;
     this._selection = null;
     this._selectionDragger = null;
     this._textWidthsCache = null;
 
     this._data = null;
 
     this.emit("destroyed");
-  },
+  }),
 
   /**
    * Rendering options. Subclasses should override these.
    */
   overviewHeaderTextColor: OVERVIEW_HEADER_TEXT_COLOR,
   overviewTimelineStrokes: OVERVIEW_TIMELINE_STROKES,
   blockTextColor: FLAME_GRAPH_BLOCK_TEXT_COLOR,
 
@@ -784,22 +787,27 @@ FlameGraph.prototype = {
    * Finds the optimal tick interval between time markers in this graph.
    *
    * @param number dataScale
    * @return number
    */
   _findOptimalTickInterval: function(dataScale) {
     let timingStep = TIMELINE_TICKS_MULTIPLE;
     let spacingMin = TIMELINE_TICKS_SPACING_MIN * this._pixelRatio;
+    let maxIters = FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS;
+    let numIters = 0;
 
     if (dataScale > spacingMin) {
       return dataScale;
     }
 
     while (true) {
+      if (++numIters > maxIters) {
+        return scaledStep;
+      }
       let scaledStep = dataScale * timingStep;
       if (scaledStep < spacingMin) {
         timingStep <<= 1;
         continue;
       }
       return scaledStep;
     }
   },
--- a/browser/devtools/shared/widgets/Graphs.jsm
+++ b/browser/devtools/shared/widgets/Graphs.jsm
@@ -224,17 +224,19 @@ AbstractCanvasGraph.prototype = {
    */
   ready: function() {
     return this._ready.promise;
   },
 
   /**
    * Destroys this graph.
    */
-  destroy: function() {
+  destroy: Task.async(function *() {
+    yield this.ready();
+
     this._window.removeEventListener("mousemove", this._onMouseMove);
     this._window.removeEventListener("mousedown", this._onMouseDown);
     this._window.removeEventListener("mouseup", this._onMouseUp);
     this._window.removeEventListener("MozMousePixelScroll", this._onMouseWheel);
     this._window.removeEventListener("mouseout", this._onMouseOut);
 
     let ownerWindow = this._parent.ownerDocument.defaultView;
     ownerWindow.removeEventListener("resize", this._onResize);
@@ -254,17 +256,17 @@ AbstractCanvasGraph.prototype = {
 
     this._cachedBackgroundImage = null;
     this._cachedGraphImage = null;
     this._cachedMaskImage = null;
     this._renderTargets.clear();
     gCachedStripePattern.clear();
 
     this.emit("destroyed");
-  },
+  }),
 
   /**
    * Rendering options. Subclasses should override these.
    */
   clipheadLineWidth: 1,
   clipheadLineColor: "transparent",
   selectionLineWidth: 1,
   selectionLineColor: "transparent",
--- a/browser/modules/WindowsPreviewPerTab.jsm
+++ b/browser/modules/WindowsPreviewPerTab.jsm
@@ -382,19 +382,17 @@ PreviewController.prototype = {
         if (evt.originalTarget === this.linkedBrowser.contentWindow) {
           let clientRects = evt.clientRects;
           let length = clientRects.length;
           for (let i = 0; i < length; i++) {
             let r = clientRects.item(i);
             this.onTabPaint(r);
           }
         }
-        let preview = this.preview;
-        if (preview.visible)
-          preview.invalidate();
+        this.preview.invalidate();
         break;
       case "TabAttrModified":
         this.updateTitleAndTooltip();
         break;
       case "resize":
         // We need to invalidate our window's other tabs' previews since layout
         // due to resizing is delayed for background tabs. Note that this
         // resize may not be the first after the main window has been resized -
--- a/browser/themes/shared/devtools/animationinspector.css
+++ b/browser/themes/shared/devtools/animationinspector.css
@@ -80,18 +80,17 @@ body {
   filter: none; /* Icon is blue when checked, don't invert for light theme */
 }
 
 #toggle-all.paused::before {
   background-image: url("debugger-play.png");
 }
 
 @media (min-resolution: 2dppx) {
-  #element-picker::before,
-  #toggle-all::before {
+  #element-picker::before {
     background-image: url("chrome://browser/skin/devtools/command-pick@2x.png");
     background-size: 64px;
   }
 
   #toggle-all::before {
     background-image: url("debugger-pause@2x.png");
   }
 
--- a/mobile/android/base/CustomEditText.java
+++ b/mobile/android/base/CustomEditText.java
@@ -76,13 +76,13 @@ public class CustomEditText extends Them
         return mHighlightColor;
     }
 
     @Override
     public void setPrivateMode(boolean isPrivate) {
         super.setPrivateMode(isPrivate);
 
         mHighlightColor = getContext().getResources().getColor(isPrivate
-                ? R.color.url_bar_text_highlight_pb : R.color.url_bar_text_highlight);
+                ? R.color.url_bar_text_highlight_pb : R.color.fennec_ui_orange);
         // android:textColorHighlight cannot support a ColorStateList.
         setHighlightColor(mHighlightColor);
     }
 }
--- a/mobile/android/base/resources/drawable/progressbar.xml
+++ b/mobile/android/base/resources/drawable/progressbar.xml
@@ -1,9 +1,9 @@
 <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
     <item android:id="@android:id/progress">
         <clip>
             <shape>
-                <solid android:color="@color/highlight_orange"/>
+                <solid android:color="@color/fennec_ui_orange"/>
             </shape>
         </clip>
     </item>
 </layer-list>
--- a/mobile/android/base/resources/drawable/suggestion_selector.xml
+++ b/mobile/android/base/resources/drawable/suggestion_selector.xml
@@ -1,16 +1,16 @@
 <?xml version="1.0" encoding="utf-8"?>
 <selector xmlns:android="http://schemas.android.com/apk/res/android">
 
     <item android:state_pressed="true">
         <shape>
             <gradient android:angle="90"
                       android:startColor="#E66000"
-                      android:endColor="#FF9500"
+                      android:endColor="@color/fennec_ui_orange"
                       android:type="linear"/>
 
             <corners android:radius="4dp"/>
         </shape>
     </item>
 
     <item android:state_focused="true"
           android:state_enabled="true">
--- a/mobile/android/base/resources/drawable/tab_thumbnail.xml
+++ b/mobile/android/base/resources/drawable/tab_thumbnail.xml
@@ -34,17 +34,17 @@
     </item>
 
     <item android:state_focused="false"
           android:state_pressed="false"
           android:state_checked="true"
           gecko:state_recording="false">
 
         <shape android:shape="rectangle">
-            <solid android:color="#FFFF9500"/>
+            <solid android:color="@color/fennec_ui_orange"/>
             <corners android:radius="3dp"/>            
         </shape>
 
     </item>
 
     <item android:drawable="@android:color/transparent"/>
 
 </selector>
--- a/mobile/android/base/resources/layout/doorhanger.xml
+++ b/mobile/android/base/resources/layout/doorhanger.xml
@@ -47,12 +47,12 @@
                   android:layout_width="match_parent"
                   android:layout_height="wrap_content"
                   android:orientation="horizontal"
                   android:visibility="gone"/>
 
     <View android:id="@+id/divider_doorhanger"
           android:layout_width="match_parent"
           android:layout_height="1dp"
-          android:background="#FFFF9500"
+          android:background="@color/fennec_ui_orange"
           android:visibility="gone"/>
 
 </merge>
--- a/mobile/android/base/resources/layout/find_in_page_content.xml
+++ b/mobile/android/base/resources/layout/find_in_page_content.xml
@@ -11,17 +11,17 @@
               android:contentDescription="@string/find_text"
               android:background="@drawable/url_bar_entry"
               android:singleLine="true"
               android:textColor="#000000"
               android:textCursorDrawable="@null"
               android:inputType="text"
               android:paddingLeft="@dimen/find_in_page_text_padding_left"
               android:paddingRight="@dimen/find_in_page_text_padding_right"
-              android:textColorHighlight="@color/url_bar_text_highlight"
+              android:textColorHighlight="@color/fennec_ui_orange"
               android:imeOptions="actionSearch"
               android:selectAllOnFocus="true"
               android:gravity="center_vertical|left"/>
 
     <TextView android:id="@+id/find_status"
               android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               android:layout_marginRight="@dimen/find_in_page_status_margin_right"
--- a/mobile/android/base/resources/layout/firstrun_pane.xml
+++ b/mobile/android/base/resources/layout/firstrun_pane.xml
@@ -17,12 +17,12 @@
                     android:background="@color/firstrun_pager_background">
 
         <org.mozilla.gecko.home.HomePagerTabStrip android:id="@+id/firstrun_tab_strip"
                            android:layout_width="match_parent"
                            android:layout_height="40dp"
                            android:layout_gravity="top"
                            android:gravity="center_vertical"
                            android:background="@color/firstrun_tabstrip"
-                           gecko:tabIndicatorColor="@color/text_color_highlight"
+                           gecko:tabIndicatorColor="@color/fennec_ui_orange"
                            android:textColor="@color/android:white"/>
     </org.mozilla.gecko.firstrun.FirstrunPager>
 </org.mozilla.gecko.firstrun.FirstrunPane>
--- a/mobile/android/base/resources/layout/home_pager.xml
+++ b/mobile/android/base/resources/layout/home_pager.xml
@@ -13,12 +13,12 @@
                                   android:layout_height="match_parent"
                                   android:background="@android:color/white">
 
     <org.mozilla.gecko.home.HomePagerTabStrip android:layout_width="match_parent"
                                               android:layout_height="@dimen/tabs_strip_height"
                                               android:layout_gravity="top"
                                               android:gravity="center_vertical"
                                               android:background="@color/background_light"
-                                              gecko:tabIndicatorColor="@color/text_color_highlight"
+                                              gecko:tabIndicatorColor="@color/fennec_ui_orange"
                                               android:textAppearance="@style/TextAppearance.Widget.HomePagerTabStrip"/>
 
 </org.mozilla.gecko.home.HomePager>
--- a/mobile/android/base/resources/layout/pin_site_dialog.xml
+++ b/mobile/android/base/resources/layout/pin_site_dialog.xml
@@ -18,17 +18,17 @@
                   style="@style/UrlBar.Button"
                   android:layout_width="match_parent"
                   android:layout_height="match_parent"
                   android:padding="6dip"
                   android:hint="@string/pin_site_dialog_hint"
                   android:background="@drawable/url_bar_entry"
                   android:textColor="@color/url_bar_title"
                   android:textColorHint="@color/url_bar_title_hint"
-                  android:textColorHighlight="@color/url_bar_text_highlight"
+                  android:textColorHighlight="@color/fennec_ui_orange"
                   android:textSelectHandle="@drawable/handle_middle"
                   android:textSelectHandleLeft="@drawable/handle_start"
                   android:textSelectHandleRight="@drawable/handle_end"
                   android:textCursorDrawable="@null"
                   android:inputType="textUri|textNoSuggestions"
                   android:imeOptions="actionGo|flagNoExtractUi|flagNoFullscreen"
                   android:singleLine="true"
                   android:gravity="center_vertical|left"/>
--- a/mobile/android/base/resources/layout/search_bar.xml
+++ b/mobile/android/base/resources/layout/search_bar.xml
@@ -11,17 +11,17 @@
         android:layout_gravity="center_vertical"
         android:imeOptions="actionSearch"
         android:inputType="textNoSuggestions"
         android:drawableLeft="@drawable/search_icon_inactive"
         android:drawablePadding="5dp"
         android:textSize="@dimen/query_text_size"
         android:focusable="false"
         android:focusableInTouchMode="false"
-        android:textColorHighlight="@color/highlight_orange"
+        android:textColorHighlight="@color/fennec_ui_orange"
         android:textSelectHandle="@drawable/handle_middle"
         android:textSelectHandleLeft="@drawable/handle_start"
         android:textSelectHandleRight="@drawable/handle_end" />
 
     <ImageButton
         android:id="@+id/clear_button"
         android:layout_width="30dp"
         android:layout_height="30dp"
--- a/mobile/android/base/resources/values/colors.xml
+++ b/mobile/android/base/resources/values/colors.xml
@@ -1,14 +1,36 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- 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/. -->
 
 <resources>
+  <!-- Fennec color palette (bug 1127517) -->
+  <color name="fennec_ui_orange">#FF9500</color>
+  <color name="action_orange">#E66000</color>
+  <color name="action_orange_pressed">#DC5600</color>
+  <color name="link_blue">#0096DD</color>
+  <color name="private_browsing_purple">#CF68FF</color>
+
+  <color name="placeholder_active_grey">#222222</color>
+  <color name="placeholder_grey">#777777</color>
+  <color name="private_toolbar_grey">#292C29</color>
+  <color name="text_and_tabs_tray_grey">#363B40</color>
+  <color name="tabs_tray_grey_pressed">#45494E</color>
+  <color name="toolbar_icon_grey">#5F6368</color>
+
+  <color name="tabs_tray_icon_grey">#AFB1B3</color>
+  <color name="disabled_grey">#BFBFBF</color>
+  <color name="toolbar_grey_pressed">#D7D7DC</color>
+  <color name="toolbar_menu_dark_grey">#E1E1E6</color>
+  <color name="toolbar_grey">#EBEBF0</color>
+  <color name="about_page_header_grey">#F5F5F5</color>
+
+  <!-- Non-palette colors -->
   <color name="primary">#363B40</color>
 
   <color name="background_light">#FFF5F5F5</color>
 
   <!-- If you update one, update the other. -->
   <color name="background_normal">#FFEBEBF0</color>
   <color name="background_normal_lwt">#DDEBEBF0</color>
 
@@ -78,17 +100,16 @@
   <color name="text_color_primary_disable_only">#999999</color>
 
   <!-- Hint colors -->
   <color name="text_color_hint">#666666</color>
   <color name="text_color_hint_inverse">#7F828A</color>
   <color name="text_color_hint_floating_focused">#33b5e5</color>
 
   <!-- Highlight colors -->
-  <color name="text_color_highlight">#FF9500</color>
   <color name="text_color_highlight_inverse">#D06BFF</color>
 
   <!-- Link colors -->
   <color name="text_color_link">#22629E</color>
 
   <!-- Divider colors -->
   <color name="divider_light">#FFD7D9DB</color>
   <color name="divider_dark">#FFB3C2CE</color>
@@ -98,17 +119,16 @@
   <color name="splash_urlfont">#000000</color>
   <color name="splash_content">#ffffff</color>
 
   <color name="doorhanger_text">#FF222222</color>
   <color name="doorhanger_link">#FF2AA1FE</color>
   <color name="doorhanger_background_dark">#FFDDE4EA</color>
 
   <color name="validation_message_text">#ffffff</color>
-  <color name="url_bar_text_highlight">#FFFF9500</color>
   <color name="url_bar_text_highlight_pb">#FFD06BFF</color>
   <color name="suggestion_primary">#dddddd</color>
   <color name="suggestion_pressed">#bbbbbb</color>
   <color name="tab_row_pressed">#4D000000</color>
 
   <color name="textbox_background">#FFF</color>
   <color name="textbox_background_disabled">#DDD</color>
   <color name="textbox_stroke">#000</color>
--- a/mobile/android/base/resources/values/search_colors.xml
+++ b/mobile/android/base/resources/values/search_colors.xml
@@ -1,18 +1,16 @@
 <!-- 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/. -->
 
 <resources>
 
     <color name="global_background_color">#EBEBF0</color>
 
-    <color name="highlight_orange">#FF9500</color>
-
     <color name="edit_text_default">#AFB1B3</color>
 
     <!-- card colors -->
     <color name="row_background">#ffffff</color>
     <color name="row_background_pressed">#DCDCE1</color>
 
     <color name="widget_logo_default">#EBEBF0</color>
     <color name="widget_button_pressed">#33000000</color>
--- a/mobile/android/base/resources/values/styles.xml
+++ b/mobile/android/base/resources/values/styles.xml
@@ -138,17 +138,17 @@
     <style name="Widget.ReadingListRow.Description">
         <item name="android:textAppearance">@style/TextAppearance.Widget.Home.ItemDescription</item>
         <item name="android:maxLines">3</item>
         <item name="android:ellipsize">end</item>
         <item name="android:lineSpacingMultiplier">1.3</item>
     </style>
 
     <style name="Widget.ReadingListRow.ReadTime">
-        <item name="android:textColor">@color/text_color_highlight</item>
+        <item name="android:textColor">@color/fennec_ui_orange</item>
     </style>
 
     <style name="Widget.BookmarkFolderView" parent="Widget.TwoLinePageRow.Title">
         <item name="android:singleLine">true</item>
         <item name="android:ellipsize">none</item>
         <item name="android:paddingLeft">10dip</item>
         <item name="android:drawablePadding">10dip</item>
         <item name="android:drawableLeft">@drawable/bookmark_folder</item>
@@ -287,17 +287,17 @@
         Note: Gecko uses light theme as default, while Android uses dark.
         If Android convention has to be followd, the list of colors specified 
         in themes.xml would be inverse, and things would get confusing.
         Hence, Gecko's TextAppearance is based on text over light theme and
         TextAppearance.Inverse is based on text over dark theme.
     -->
     <style name="TextAppearance">
         <item name="android:textColor">?android:attr/textColorPrimary</item>
-        <item name="android:textColorHighlight">@color/text_color_highlight</item>
+        <item name="android:textColorHighlight">@color/fennec_ui_orange</item>
         <item name="android:textColorHint">?android:attr/textColorHint</item>
         <item name="android:textColorLink">?android:attr/textColorLink</item>
         <item name="android:textSize">@dimen/menu_item_textsize</item>
         <item name="android:textStyle">normal</item>
     </style>
 
     <style name="TextAppearance.Inverse">
         <item name="android:textColor">?android:attr/textColorPrimaryInverse</item>
@@ -452,17 +452,17 @@
         <item name="android:background">@android:color/transparent</item>
     </style>
 
     <!-- URL bar - Button -->
     <style name="UrlBar.Title" parent="UrlBar.Button">
         <item name="android:textAppearance">@style/TextAppearance.UrlBar.Title</item>
         <item name="android:textColor">@color/url_bar_title</item>
         <item name="android:textColorHint">@color/url_bar_title_hint</item>
-        <item name="android:textColorHighlight">@color/url_bar_text_highlight</item>
+        <item name="android:textColorHighlight">@color/fennec_ui_orange</item>
         <item name="android:textSelectHandle">@drawable/handle_middle</item>
         <item name="android:textSelectHandleLeft">@drawable/handle_start</item>
         <item name="android:textSelectHandleRight">@drawable/handle_end</item>
         <item name="android:textCursorDrawable">@null</item>
         <item name="android:singleLine">true</item>
         <item name="android:gravity">center_vertical|left</item>
         <item name="android:hint">@string/url_bar_default_text</item>
     </style>
--- a/mobile/android/base/resources/values/themes.xml
+++ b/mobile/android/base/resources/values/themes.xml
@@ -44,17 +44,17 @@
         <!-- Disabled colors -->
         <item name="android:textColorPrimaryDisableOnly">@color/text_color_primary_disable_only</item>
 
         <!-- Hint colors -->
         <item name="android:textColorHint">@color/text_color_hint</item>
         <item name="android:textColorHintInverse">@color/text_color_hint_inverse</item>
 
         <!-- Highlight colors -->
-        <item name="android:textColorHighlight">@color/text_color_highlight</item>
+        <item name="android:textColorHighlight">@color/fennec_ui_orange</item>
         <item name="android:textColorHighlightInverse">@color/text_color_highlight_inverse</item>
 
         <!-- Link colors -->
         <item name="android:textColorLink">@color/text_color_link</item>
 
         <!-- TextAppearances -->
         <item name="android:textAppearance">@style/TextAppearance</item>
         <item name="android:textAppearanceInverse">@style/TextAppearance.Inverse</item>
--- a/widget/windows/TaskbarPreview.cpp
+++ b/widget/windows/TaskbarPreview.cpp
@@ -179,17 +179,17 @@ NS_IMETHODIMP
 TaskbarPreview::GetActive(bool *active) {
   *active = sActivePreview == this;
   return NS_OK;
 }
 
 NS_IMETHODIMP
 TaskbarPreview::Invalidate() {
   if (!mVisible)
-    return NS_ERROR_FAILURE;
+    return NS_OK;
 
   // DWM Composition is required for previews
   if (!nsUXThemeData::CheckForCompositor())
     return NS_OK;
 
   HWND previewWindow = PreviewWindow();
   return FAILED(WinUtils::dwmInvalidateIconicBitmapsPtr(previewWindow))
        ? NS_ERROR_FAILURE