Merge autoland to mozilla-central a=merge
authorDorel Luca <dluca@mozilla.com>
Wed, 23 May 2018 12:48:41 +0300
changeset 798690 bdb0b4d7712dc14d3a5f0d0b644adbedd40144b2
parent 798564 9055d9d89a4bca5cf48dda789299559aefca4e54 (current diff)
parent 798662 e6d7e41f8f4be0b559cab8553e107db8a245ac4d (diff)
child 798691 d36cd8bdbc5c0df1d1d7a167f5fedb95c3a3648e
push id110828
push userbmo:dburns@mozilla.com
push dateWed, 23 May 2018 10:39:43 +0000
reviewersmerge
milestone62.0a1
Merge autoland to mozilla-central a=merge
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -51,17 +51,16 @@ module.exports = {
   }, {
     // XXX Bug 1452706. These directories are still being fixed, so turn off
     //  mozilla/require-expected-throws-or-rejects for now.
     "files": [
       "browser/extensions/formautofill/test/unit/test_storage_tombstones.js",
       "browser/modules/test/browser/**",
       "browser/tools/mozscreenshots/browser_boundingbox.js",
       "devtools/client/inspector/extensions/test/head_devtools_inspector_sidebar.js",
-      "services/**",
       "storage/test/unit/**",
       "testing/marionette/test/unit/**",
       "toolkit/components/**",
       "toolkit/modules/tests/xpcshell/**",
       "toolkit/mozapps/extensions/test/xpcshell/**"
     ],
     "rules": {
       "mozilla/require-expected-throws-or-rejects": "off",
--- a/browser/base/content/browser-siteIdentity.js
+++ b/browser/base/content/browser-siteIdentity.js
@@ -196,26 +196,63 @@ var gIdentityHandler = {
   get _permissionReloadHint() {
     delete this._permissionReloadHint;
     return this._permissionReloadHint = document.getElementById("identity-popup-permission-reload-hint");
   },
   get _popupExpander() {
     delete this._popupExpander;
     return this._popupExpander = document.getElementById("identity-popup-security-expander");
   },
+  get _clearSiteDataFooter() {
+    delete this._clearSiteDataFooter;
+    return this._clearSiteDataFooter = document.getElementById("identity-popup-clear-sitedata-footer");
+  },
   get _permissionAnchors() {
     delete this._permissionAnchors;
     let permissionAnchors = {};
     for (let anchor of document.getElementById("blocked-permissions-container").children) {
       permissionAnchors[anchor.getAttribute("data-permission-id")] = anchor;
     }
     return this._permissionAnchors = permissionAnchors;
   },
 
   /**
+   * Handles clicks on the "Clear Cookies and Site Data" button.
+   */
+  async clearSiteData(event) {
+    if (!this._uriHasHost) {
+      return;
+    }
+
+    let host = this._uri.host;
+
+    // Site data could have changed while the identity popup was open,
+    // reload again to be sure.
+    await SiteDataManager.updateSites();
+
+    let baseDomain = SiteDataManager.getBaseDomainFromHost(host);
+    let siteData = await SiteDataManager.getSites(baseDomain);
+
+    // Hide the popup before showing the removal prompt, to
+    // avoid a pretty ugly transition. Also hide it even
+    // if the update resulted in no site data, to keep the
+    // illusion that clicking the button had an effect.
+    PanelMultiView.hidePopup(this._identityPopup);
+
+    if (siteData && siteData.length) {
+      let hosts = siteData.map(site => site.host);
+      if (SiteDataManager.promptSiteDataRemoval(window, hosts)) {
+        SiteDataManager.remove(hosts);
+      }
+    }
+
+    event.stopPropagation();
+  },
+
+  /**
    * Handler for mouseclicks on the "More Information" button in the
    * "identity-popup" panel.
    */
   handleMoreInfoClick(event) {
     displaySecurityInfo();
     event.stopPropagation();
     PanelMultiView.hidePopup(this._identityPopup);
   },
@@ -573,16 +610,31 @@ var gIdentityHandler = {
   },
 
   /**
    * Set up the title and content messages for the identity message popup,
    * based on the specified mode, and the details of the SSL cert, where
    * applicable
    */
   refreshIdentityPopup() {
+    // Update cookies and site data information and show the
+    // "Clear Site Data" button if the site is storing local data.
+    this._clearSiteDataFooter.hidden = true;
+    if (this._uriHasHost) {
+      let host = this._uri.host;
+      SiteDataManager.updateSites().then(async () => {
+        let baseDomain = SiteDataManager.getBaseDomainFromHost(host);
+        let siteData = await SiteDataManager.getSites(baseDomain);
+
+        if (siteData && siteData.length) {
+          this._clearSiteDataFooter.hidden = false;
+        }
+      });
+    }
+
     // Update "Learn More" for Mixed Content Blocking and Insecure Login Forms.
     let baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
     this._identityPopupMixedContentLearnMore
         .setAttribute("href", baseURL + "mixed-content");
     this._identityPopupInsecureLoginFormsLearnMore
         .setAttribute("href", baseURL + "insecure-password");
 
     // This is in the properties file because the expander used to switch its tooltip.
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -50,16 +50,17 @@ XPCOMUtils.defineLazyModuleGetters(this,
   ReaderMode: "resource://gre/modules/ReaderMode.jsm",
   ReaderParent: "resource:///modules/ReaderParent.jsm",
   SafeBrowsing: "resource://gre/modules/SafeBrowsing.jsm",
   Sanitizer: "resource:///modules/Sanitizer.jsm",
   SessionStore: "resource:///modules/sessionstore/SessionStore.jsm",
   SchedulePressure: "resource:///modules/SchedulePressure.jsm",
   ShortcutUtils: "resource://gre/modules/ShortcutUtils.jsm",
   SimpleServiceDiscovery: "resource://gre/modules/SimpleServiceDiscovery.jsm",
+  SiteDataManager: "resource:///modules/SiteDataManager.jsm",
   SitePermissions: "resource:///modules/SitePermissions.jsm",
   TabCrashHandler: "resource:///modules/ContentCrashHandlers.jsm",
   TelemetryStopwatch: "resource://gre/modules/TelemetryStopwatch.jsm",
   Translation: "resource:///modules/translation/Translation.jsm",
   UITour: "resource:///modules/UITour.jsm",
   UpdateUtils: "resource://gre/modules/UpdateUtils.jsm",
   Utils: "resource://gre/modules/sessionstore/Utils.jsm",
   Weave: "resource://services-sync/main.js",
--- a/browser/base/content/test/siteIdentity/browser.ini
+++ b/browser/base/content/test/siteIdentity/browser.ini
@@ -41,16 +41,17 @@ support-files =
 [browser_csp_block_all_mixedcontent.js]
 tags = mcb
 support-files =
   file_csp_block_all_mixedcontent.html
   file_csp_block_all_mixedcontent.js
 [browser_identity_UI.js]
 [browser_identityBlock_focus.js]
 support-files = ../permissions/permissions.html
+[browser_identityPopup_clearSiteData.js]
 [browser_identityPopup_focus.js]
 [browser_insecureLoginForms.js]
 support-files =
   insecure_opener.html
   !/toolkit/components/passwordmgr/test/browser/form_basic.html
   !/toolkit/components/passwordmgr/test/browser/insecure_test.html
   !/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html
 [browser_mcb_redirect.js]
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_ORIGIN = "https://example.com";
+const TEST_SUB_ORIGIN = "https://test1.example.com";
+const REMOVE_DIALOG_URL = "chrome://browser/content/preferences/siteDataRemoveSelected.xul";
+
+ChromeUtils.defineModuleGetter(this, "SiteDataTestUtils",
+                               "resource://testing-common/SiteDataTestUtils.jsm");
+
+async function testClearing(testQuota, testCookies) {
+  // Add some test quota storage.
+  if (testQuota) {
+    await SiteDataTestUtils.addToIndexedDB(TEST_ORIGIN);
+    await SiteDataTestUtils.addToIndexedDB(TEST_SUB_ORIGIN);
+  }
+
+  // Add some test cookies.
+  if (testCookies) {
+    SiteDataTestUtils.addToCookies(TEST_ORIGIN, "test1", "1");
+    SiteDataTestUtils.addToCookies(TEST_ORIGIN, "test2", "2");
+    SiteDataTestUtils.addToCookies(TEST_SUB_ORIGIN, "test3", "1");
+  }
+
+  await BrowserTestUtils.withNewTab(TEST_ORIGIN, async function(browser) {
+    // Verify we have added quota storage.
+    if (testQuota) {
+      let usage = await SiteDataTestUtils.getQuotaUsage(TEST_ORIGIN);
+      Assert.greater(usage, 0, "Should have data for the base origin.");
+
+      usage = await SiteDataTestUtils.getQuotaUsage(TEST_SUB_ORIGIN);
+      Assert.greater(usage, 0, "Should have data for the sub origin.");
+    }
+
+    // Open the identity popup.
+    let { gIdentityHandler } = gBrowser.ownerGlobal;
+    let promisePanelOpen = BrowserTestUtils.waitForEvent(gIdentityHandler._identityPopup, "popupshown");
+    let siteDataUpdated = TestUtils.topicObserved("sitedatamanager:sites-updated");
+    gIdentityHandler._identityBox.click();
+    await promisePanelOpen;
+    await siteDataUpdated;
+
+    let clearFooter = document.getElementById("identity-popup-clear-sitedata-footer");
+    let clearButton = document.getElementById("identity-popup-clear-sitedata-button");
+    ok(!clearFooter.hidden, "The clear data footer is not hidden.");
+
+    let cookiesCleared;
+    if (testCookies) {
+      cookiesCleared = Promise.all([
+        TestUtils.topicObserved("cookie-changed", (subj, data) => data == "deleted" && subj.name == "test1"),
+        TestUtils.topicObserved("cookie-changed", (subj, data) => data == "deleted" && subj.name == "test2"),
+        TestUtils.topicObserved("cookie-changed", (subj, data) => data == "deleted" && subj.name == "test3"),
+      ]);
+    }
+
+    // Click the "Clear data" button.
+    siteDataUpdated = TestUtils.topicObserved("sitedatamanager:sites-updated");
+    let hideEvent = BrowserTestUtils.waitForEvent(gIdentityHandler._identityPopup, "popuphidden");
+    let removeDialogPromise = BrowserTestUtils.promiseAlertDialogOpen("accept", REMOVE_DIALOG_URL);
+    clearButton.click();
+    await hideEvent;
+    await removeDialogPromise;
+
+    await siteDataUpdated;
+
+    // Check that cookies were deleted.
+    if (testCookies) {
+      await cookiesCleared;
+      let uri = Services.io.newURI(TEST_ORIGIN);
+      is(Services.cookies.countCookiesFromHost(uri.host), 0, "Cookies from the base domain should be cleared");
+      uri = Services.io.newURI(TEST_SUB_ORIGIN);
+      is(Services.cookies.countCookiesFromHost(uri.host), 0, "Cookies from the sub domain should be cleared");
+    }
+
+    // Check that quota storage was deleted.
+    if (testQuota) {
+      await TestUtils.waitForCondition(async () => {
+        let usage = await SiteDataTestUtils.getQuotaUsage(TEST_ORIGIN);
+        return usage == 0;
+      }, "Should have no data for the base origin.");
+
+      let usage = await SiteDataTestUtils.getQuotaUsage(TEST_SUB_ORIGIN);
+      is(usage, 0, "Should have no data for the sub origin.");
+    }
+
+    // Open the site identity panel again to check that the button isn't shown anymore.
+    promisePanelOpen = BrowserTestUtils.waitForEvent(gIdentityHandler._identityPopup, "popupshown");
+    siteDataUpdated = TestUtils.topicObserved("sitedatamanager:sites-updated");
+    gIdentityHandler._identityBox.click();
+    await promisePanelOpen;
+    await siteDataUpdated;
+
+    ok(clearFooter.hidden, "The clear data footer is hidden after clearing data.");
+  });
+}
+
+// Test removing quota managed storage.
+add_task(async function test_ClearSiteData() {
+  await testClearing(true, false);
+});
+
+// Test removing cookies.
+add_task(async function test_ClearCookies() {
+  await testClearing(false, true);
+});
+
+// Test removing both.
+add_task(async function test_ClearCookiesAndSiteData() {
+  await testClearing(true, true);
+});
--- a/browser/components/controlcenter/content/panel.inc.xul
+++ b/browser/components/controlcenter/content/panel.inc.xul
@@ -90,16 +90,26 @@
           <label id="identity-popup-permissions-headline"
                  class="identity-popup-headline"
                  value="&identity.permissions;"/>
           <vbox id="identity-popup-permission-list"/>
           <description id="identity-popup-permission-reload-hint">&identity.permissionsReloadHint;</description>
           <description id="identity-popup-permission-empty-hint">&identity.permissionsEmpty;</description>
         </vbox>
       </hbox>
+
+      <!-- Clear Site Data Button -->
+      <vbox hidden="true"
+            id="identity-popup-clear-sitedata-footer"
+            class="identity-popup-footer">
+        <button class="subviewkeynav"
+                id="identity-popup-clear-sitedata-button"
+                label="&identity.clearSiteData;"
+                oncommand="gIdentityHandler.clearSiteData(event);"/>
+      </vbox>
     </panelview>
 
     <!-- Security SubView -->
     <panelview id="identity-popup-securityView"
                title="&identity.securityView.label;"
                descriptionheightworkaround="true">
       <vbox class="identity-popup-security-content">
         <label class="plain">
@@ -173,17 +183,17 @@
                 accesskey="&identity.disableMixedContentBlocking.accesskey;"
                 oncommand="gIdentityHandler.disableMixedContentProtection()"/>
         <button when-mixedcontent="active-loaded" class="subviewkeynav"
                 label="&identity.enableMixedContentBlocking.label;"
                 accesskey="&identity.enableMixedContentBlocking.accesskey;"
                 oncommand="gIdentityHandler.enableMixedContentProtection()"/>
       </vbox>
 
-      <vbox id="identity-popup-securityView-footer">
+      <vbox id="identity-popup-more-info-footer" class="identity-popup-footer">
         <!-- More Security Information -->
         <button id="identity-popup-more-info"  class="subviewkeynav"
                 label="&identity.moreInfoLinkText2;"
                 oncommand="gIdentityHandler.handleMoreInfoClick(event);"/>
       </vbox>
 
     </panelview>
   </panelmultiview>
--- a/browser/components/payments/content/paymentDialogWrapper.js
+++ b/browser/components/payments/content/paymentDialogWrapper.js
@@ -572,19 +572,30 @@ var paymentDialogWrapper = {
         // so add updated collection here
         Object.assign(successStateChange, {
           tempBasicCards: this.temporaryStore.creditCards.getAll(),
         });
       }
 
       // Select the new record
       if (selectedStateKey) {
-        Object.assign(successStateChange, {
-          [selectedStateKey]: guid,
-        });
+        if (selectedStateKey.length == 1) {
+          Object.assign(successStateChange, {
+            [selectedStateKey[0]]: guid,
+          });
+        } else if (selectedStateKey.length == 2) {
+          // Need to keep properties like preserveFieldValues from getting removed.
+          let subObj = Object.assign({}, successStateChange[selectedStateKey[0]]);
+          subObj[selectedStateKey[1]] = guid;
+          Object.assign(successStateChange, {
+            [selectedStateKey[0]]: subObj,
+          });
+        } else {
+          throw new Error(`selectedStateKey not supported: '${selectedStateKey}'`);
+        }
       }
 
       this.sendMessageToContent("updateState", successStateChange);
     } catch (ex) {
       this.sendMessageToContent("updateState", errorStateChange);
     }
   },
 
--- a/browser/components/payments/res/containers/address-form.js
+++ b/browser/components/payments/res/containers/address-form.js
@@ -81,48 +81,49 @@ export default class AddressForm extends
       super.connectedCallback();
     });
   }
 
   render(state) {
     let record = {};
     let {
       page,
+      "address-page": addressPage,
     } = state;
 
     if (this.id && page && page.id !== this.id) {
       log.debug(`AddressForm: no need to further render inactive page: ${page.id}`);
       return;
     }
 
     this.cancelButton.textContent = this.dataset.cancelButtonLabel;
     this.backButton.textContent = this.dataset.backButtonLabel;
     this.saveButton.textContent = this.dataset.saveButtonLabel;
     this.persistCheckbox.label = this.dataset.persistCheckboxLabel;
 
     this.backButton.hidden = page.onboardingWizard;
     this.cancelButton.hidden = !page.onboardingWizard;
 
-    if (page.addressFields) {
-      this.setAttribute("address-fields", page.addressFields);
+    if (addressPage.addressFields) {
+      this.setAttribute("address-fields", addressPage.addressFields);
     } else {
       this.removeAttribute("address-fields");
     }
 
-    this.pageTitle.textContent = page.title;
+    this.pageTitle.textContent = addressPage.title;
     this.genericErrorText.textContent = page.error;
 
-    let editing = !!page.guid;
+    let editing = !!addressPage.guid;
     let addresses = paymentRequest.getAddresses(state);
 
     // If an address is selected we want to edit it.
     if (editing) {
-      record = addresses[page.guid];
+      record = addresses[addressPage.guid];
       if (!record) {
-        throw new Error("Trying to edit a non-existing address: " + page.guid);
+        throw new Error("Trying to edit a non-existing address: " + addressPage.guid);
       }
       // When editing an existing record, prevent changes to persistence
       this.persistCheckbox.hidden = true;
     } else {
       // Adding a new record: default persistence to checked when in a not-private session
       this.persistCheckbox.hidden = false;
       this.persistCheckbox.checked = !state.isPrivate;
     }
@@ -141,71 +142,89 @@ export default class AddressForm extends
 
   onClick(evt) {
     switch (evt.target) {
       case this.cancelButton: {
         paymentRequest.cancel();
         break;
       }
       case this.backButton: {
-        this.requestStore.setState({
+        let currentState = this.requestStore.getState();
+        const previousId = currentState.page.previousId;
+        let state = {
           page: {
-            id: "payment-summary",
+            id: previousId || "payment-summary",
           },
-        });
+        };
+        if (previousId) {
+          state[previousId] = Object.assign({}, currentState[previousId], {
+            preserveFieldValues: true,
+          });
+        }
+        this.requestStore.setState(state);
         break;
       }
       case this.saveButton: {
         this.saveRecord();
         break;
       }
       default: {
         throw new Error("Unexpected click target");
       }
     }
   }
 
   saveRecord() {
     let record = this.formHandler.buildFormObject();
+    let currentState = this.requestStore.getState();
     let {
       page,
       tempAddresses,
       savedBasicCards,
-    } = this.requestStore.getState();
-    let editing = !!page.guid;
+      "address-page": addressPage,
+    } = currentState;
+    let editing = !!addressPage.guid;
 
-    if (editing ? (page.guid in tempAddresses) : !this.persistCheckbox.checked) {
+    if (editing ? (addressPage.guid in tempAddresses) : !this.persistCheckbox.checked) {
       record.isTemporary = true;
     }
 
     let state = {
       errorStateChange: {
         page: {
           id: "address-page",
           onboardingWizard: page.onboardingWizard,
           error: this.dataset.errorGenericSave,
         },
+        "address-page": addressPage,
       },
       preserveOldProperties: true,
       selectedStateKey: page.selectedStateKey,
     };
 
+    const previousId = page.previousId;
     if (page.onboardingWizard && !Object.keys(savedBasicCards).length) {
       state.successStateChange = {
         page: {
           id: "basic-card-page",
-          onboardingWizard: true,
-          guid: null,
+          previousId: "address-page",
+          onboardingWizard: page.onboardingWizard,
         },
       };
     } else {
       state.successStateChange = {
         page: {
-          id: "payment-summary",
+          id: previousId || "payment-summary",
+          onboardingWizard: page.onboardingWizard,
         },
       };
     }
 
-    paymentRequest.updateAutofillRecord("addresses", record, page.guid, state);
+    if (previousId) {
+      state.successStateChange[previousId] = Object.assign({}, currentState[previousId]);
+      state.successStateChange[previousId].preserveFieldValues = true;
+    }
+
+    paymentRequest.updateAutofillRecord("addresses", record, addressPage.guid, state);
   }
 }
 
 customElements.define("address-form", AddressForm);
--- a/browser/components/payments/res/containers/address-picker.js
+++ b/browser/components/payments/res/containers/address-picker.js
@@ -163,32 +163,34 @@ export default class AddressPicker exten
       });
     }
   }
 
   onClick({target}) {
     let nextState = {
       page: {
         id: "address-page",
+      },
+      "address-page": {
+        addressFields: this.getAttribute("address-fields"),
         selectedStateKey: this.selectedStateKey,
-        addressFields: this.getAttribute("address-fields"),
       },
     };
 
     switch (target) {
       case this.addLink: {
-        nextState.page.guid = null;
-        nextState.page.title = this.dataset.addAddressTitle;
+        nextState["address-page"].guid = null;
+        nextState["address-page"].title = this.dataset.addAddressTitle;
         break;
       }
       case this.editLink: {
         let state = this.requestStore.getState();
         let selectedAddressGUID = state[this.selectedStateKey];
-        nextState.page.guid = selectedAddressGUID;
-        nextState.page.title = this.dataset.editAddressTitle;
+        nextState["address-page"].guid = selectedAddressGUID;
+        nextState["address-page"].title = this.dataset.editAddressTitle;
         break;
       }
       default: {
         throw new Error("Unexpected onClick");
       }
     }
 
     this.requestStore.setState(nextState);
--- a/browser/components/payments/res/containers/basic-card-form.js
+++ b/browser/components/payments/res/containers/basic-card-form.js
@@ -22,16 +22,25 @@ export default class BasicCardForm exten
 
     this.pageTitle = document.createElement("h1");
     this.genericErrorText = document.createElement("div");
 
     this.cancelButton = document.createElement("button");
     this.cancelButton.className = "cancel-button";
     this.cancelButton.addEventListener("click", this);
 
+    this.addressAddLink = document.createElement("a");
+    this.addressAddLink.className = "add-link";
+    this.addressAddLink.href = "javascript:void(0)";
+    this.addressAddLink.addEventListener("click", this);
+    this.addressEditLink = document.createElement("a");
+    this.addressEditLink.className = "edit-link";
+    this.addressEditLink.href = "javascript:void(0)";
+    this.addressEditLink.addEventListener("click", this);
+
     this.backButton = document.createElement("button");
     this.backButton.className = "back-button";
     this.backButton.addEventListener("click", this);
 
     this.saveButton = document.createElement("button");
     this.saveButton.className = "save-button";
     this.saveButton.addEventListener("click", this);
 
@@ -67,77 +76,95 @@ export default class BasicCardForm exten
       let addresses = [];
       this.formHandler = new EditCreditCard({
         form,
       }, record, addresses, {
         isCCNumber: PaymentDialogUtils.isCCNumber,
         getAddressLabel: PaymentDialogUtils.getAddressLabel,
       });
 
+      let fragment = document.createDocumentFragment();
+      fragment.append(this.addressAddLink);
+      fragment.append(" ");
+      fragment.append(this.addressEditLink);
+      let billingAddressRow = this.form.querySelector(".billingAddressRow");
+      billingAddressRow.appendChild(fragment);
+
       this.appendChild(this.persistCheckbox);
       this.appendChild(this.genericErrorText);
       this.appendChild(this.cancelButton);
       this.appendChild(this.backButton);
       this.appendChild(this.saveButton);
       // Only call the connected super callback(s) once our markup is fully
       // connected, including the shared form fetched asynchronously.
       super.connectedCallback();
     });
   }
 
   render(state) {
     let {
       page,
       selectedShippingAddress,
+      "basic-card-page": basicCardPage,
     } = state;
 
     if (this.id && page && page.id !== this.id) {
       log.debug(`BasicCardForm: no need to further render inactive page: ${page.id}`);
       return;
     }
 
     this.cancelButton.textContent = this.dataset.cancelButtonLabel;
     this.backButton.textContent = this.dataset.backButtonLabel;
     this.saveButton.textContent = this.dataset.saveButtonLabel;
     this.persistCheckbox.label = this.dataset.persistCheckboxLabel;
+    this.addressAddLink.textContent = this.dataset.addressAddLinkLabel;
+    this.addressEditLink.textContent = this.dataset.addressEditLinkLabel;
 
     // The back button is temporarily hidden(See Bug 1462461).
     this.backButton.hidden = !!page.onboardingWizard;
     this.cancelButton.hidden = !page.onboardingWizard;
 
     let record = {};
     let basicCards = paymentRequest.getBasicCards(state);
     let addresses = paymentRequest.getAddresses(state);
 
     this.genericErrorText.textContent = page.error;
 
-    let editing = !!page.guid;
+    let editing = !!basicCardPage.guid;
     this.form.querySelector("#cc-number").disabled = editing;
 
     // If a card is selected we want to edit it.
     if (editing) {
       this.pageTitle.textContent = this.dataset.editBasicCardTitle;
-      record = basicCards[page.guid];
+      record = basicCards[basicCardPage.guid];
       if (!record) {
-        throw new Error("Trying to edit a non-existing card: " + page.guid);
+        throw new Error("Trying to edit a non-existing card: " + basicCardPage.guid);
       }
       // When editing an existing record, prevent changes to persistence
       this.persistCheckbox.hidden = true;
     } else {
       this.pageTitle.textContent = this.dataset.addBasicCardTitle;
       // Use a currently selected shipping address as the default billing address
-      if (selectedShippingAddress) {
+      if (!record.billingAddressGUID && selectedShippingAddress) {
         record.billingAddressGUID = selectedShippingAddress;
       }
       // Adding a new record: default persistence to checked when in a not-private session
       this.persistCheckbox.hidden = false;
       this.persistCheckbox.checked = !state.isPrivate;
     }
 
-    this.formHandler.loadRecord(record, addresses);
+    this.formHandler.loadRecord(record, addresses, basicCardPage.preserveFieldValues);
+
+    this.form.querySelector(".billingAddressRow").hidden = false;
+
+    if (basicCardPage.billingAddressGUID) {
+      let addressGuid = basicCardPage.billingAddressGUID;
+      let billingAddressSelect = this.form.querySelector("#billingAddressGUID");
+      billingAddressSelect.value = addressGuid;
+    }
   }
 
   handleEvent(event) {
     switch (event.type) {
       case "click": {
         this.onClick(event);
         break;
       }
@@ -145,16 +172,46 @@ export default class BasicCardForm exten
   }
 
   onClick(evt) {
     switch (evt.target) {
       case this.cancelButton: {
         paymentRequest.cancel();
         break;
       }
+      case this.addressAddLink:
+      case this.addressEditLink: {
+        let {
+          "basic-card-page": basicCardPage,
+        } = this.requestStore.getState();
+        let nextState = {
+          page: {
+            id: "address-page",
+            previousId: "basic-card-page",
+            selectedStateKey: ["basic-card-page", "billingAddressGUID"],
+          },
+          "address-page": {
+            guid: null,
+            title: this.dataset.billingAddressTitleAdd,
+          },
+          "basic-card-page": {
+            preserveFieldValues: true,
+            guid: basicCardPage.guid,
+          },
+        };
+        let billingAddressGUID = this.form.querySelector("#billingAddressGUID");
+        let selectedOption = billingAddressGUID.selectedOptions.length &&
+                             billingAddressGUID.selectedOptions[0];
+        if (evt.target == this.addressEditLink && selectedOption && selectedOption.value) {
+          nextState["address-page"].title = this.dataset.billingAddressTitleEdit;
+          nextState["address-page"].guid = selectedOption.value;
+        }
+        this.requestStore.setState(nextState);
+        break;
+      }
       case this.backButton: {
         this.requestStore.setState({
           page: {
             id: "payment-summary",
           },
         });
         break;
       }
@@ -165,47 +222,56 @@ export default class BasicCardForm exten
       default: {
         throw new Error("Unexpected click target");
       }
     }
   }
 
   saveRecord() {
     let record = this.formHandler.buildFormObject();
+    let currentState = this.requestStore.getState();
     let {
       page,
       tempBasicCards,
-    } = this.requestStore.getState();
-    let editing = !!page.guid;
+      "basic-card-page": basicCardPage,
+    } = currentState;
+    let editing = !!basicCardPage.guid;
 
-    if (editing ? (page.guid in tempBasicCards) : !this.persistCheckbox.checked) {
+    if (editing ? (basicCardPage.guid in tempBasicCards) : !this.persistCheckbox.checked) {
       record.isTemporary = true;
     }
 
     for (let editableFieldName of ["cc-name", "cc-exp-month", "cc-exp-year"]) {
       record[editableFieldName] = record[editableFieldName] || "";
     }
 
     // Only save the card number if we're saving a new record, otherwise we'd
     // overwrite the unmasked card number with the masked one.
     if (!editing) {
       record["cc-number"] = record["cc-number"] || "";
     }
 
-    paymentRequest.updateAutofillRecord("creditCards", record, page.guid, {
+    let state = {
       errorStateChange: {
         page: {
           id: "basic-card-page",
           error: this.dataset.errorGenericSave,
         },
       },
       preserveOldProperties: true,
-      selectedStateKey: "selectedPaymentCard",
+      selectedStateKey: ["selectedPaymentCard"],
       successStateChange: {
         page: {
           id: "payment-summary",
         },
       },
-    });
+    };
+
+    const previousId = page.previousId;
+    if (previousId) {
+      state.successStateChange[previousId] = Object.assign({}, currentState[previousId]);
+    }
+
+    paymentRequest.updateAutofillRecord("creditCards", record, basicCardPage.guid, state);
   }
 }
 
 customElements.define("basic-card-form", BasicCardForm);
--- a/browser/components/payments/res/containers/payment-method-picker.js
+++ b/browser/components/payments/res/containers/payment-method-picker.js
@@ -128,27 +128,28 @@ export default class PaymentMethodPicker
     this.requestStore.setState(stateChange);
   }
 
   onClick({target}) {
     let nextState = {
       page: {
         id: "basic-card-page",
       },
+      "basic-card-page": {},
     };
 
     switch (target) {
       case this.addLink: {
-        nextState.page.guid = null;
+        nextState["basic-card-page"].guid = null;
         break;
       }
       case this.editLink: {
         let state = this.requestStore.getState();
         let selectedPaymentCardGUID = state[this.selectedStateKey];
-        nextState.page.guid = selectedPaymentCardGUID;
+        nextState["basic-card-page"].guid = selectedPaymentCardGUID;
         break;
       }
       default: {
         throw new Error("Unexpected onClick");
       }
     }
 
     this.requestStore.setState(nextState);
--- a/browser/components/payments/res/mixins/PaymentStateSubscriberMixin.js
+++ b/browser/components/payments/res/mixins/PaymentStateSubscriberMixin.js
@@ -10,20 +10,31 @@ import PaymentsStore from "../PaymentsSt
 
 /**
  * State of the payment request dialog.
  */
 export let requestStore = new PaymentsStore({
   changesPrevented: false,
   completionState: "initial",
   orderDetailsShowing: false,
+  "basic-card-page": {
+    guid: null,
+    // preserveFieldValues: true,
+  },
+  "address-page": {
+    guid: null,
+    title: "",
+  },
+  "payment-summary": {
+  },
   page: {
     id: "payment-summary",
+    previousId: null,
     // onboardingWizard: true,
-    // error: "My error",
+    // error: "",
   },
   request: {
     tabId: null,
     topLevelPrincipal: {URI: {displayHost: null}},
     requestId: null,
     paymentMethods: [],
     paymentDetails: {
       id: null,
--- a/browser/components/payments/res/paymentRequest.js
+++ b/browser/components/payments/res/paymentRequest.js
@@ -129,17 +129,16 @@ var paymentRequest = {
       state.page = {
         id: "address-page",
         onboardingWizard: true,
       };
     } else if (Object.keys(detail.savedBasicCards).length == 0) {
       state.page = {
         id: "basic-card-page",
         onboardingWizard: true,
-        guid: null,
       };
     }
 
     document.querySelector("payment-dialog").setStateFromParent(state);
   },
 
   cancel() {
     this.sendMessageToChrome("paymentCancel");
--- a/browser/components/payments/res/paymentRequest.xhtml
+++ b/browser/components/payments/res/paymentRequest.xhtml
@@ -20,30 +20,34 @@
   <!ENTITY payer.addLink.label        "Add">
   <!ENTITY payer.editLink.label       "Edit">
   <!ENTITY shippingAddress.addPage.title  "Add Shipping Address">
   <!ENTITY shippingAddress.editPage.title "Edit Shipping Address">
   <!ENTITY deliveryAddress.addPage.title  "Add Delivery Address">
   <!ENTITY deliveryAddress.editPage.title "Edit Delivery Address">
   <!ENTITY pickupAddress.addPage.title    "Add Pickup Address">
   <!ENTITY pickupAddress.editPage.title   "Edit Pickup Address">
+  <!ENTITY billingAddress.addPage.title   "Add Billing Address">
+  <!ENTITY billingAddress.editPage.title  "Edit Billing Address">
   <!ENTITY basicCard.addPage.title    "Add Credit Card">
   <!ENTITY basicCard.editPage.title   "Edit Credit Card">
   <!ENTITY payer.addPage.title        "Add Payer Contact">
   <!ENTITY payer.editPage.title       "Edit Payer Contact">
   <!ENTITY payerLabel                 "Contact Information">
   <!ENTITY cancelPaymentButton.label   "Cancel">
   <!ENTITY approvePaymentButton.label  "Pay">
   <!ENTITY processingPaymentButton.label "Processing">
   <!ENTITY successPaymentButton.label    "Done">
   <!ENTITY failPaymentButton.label       "Fail">
   <!ENTITY unknownPaymentButton.label    "Unknown">
   <!ENTITY orderDetailsLabel          "Order Details">
   <!ENTITY orderTotalLabel            "Total">
   <!ENTITY basicCardPage.error.genericSave    "There was an error saving the payment card.">
+  <!ENTITY basicCardPage.addressAddLink.label "Add">
+  <!ENTITY basicCardPage.addressEditLink.label "Edit">
   <!ENTITY basicCardPage.backButton.label     "Back">
   <!ENTITY basicCardPage.saveButton.label     "Save">
   <!ENTITY basicCardPage.persistCheckbox.label     "Save credit card to Firefox (Security code will not be saved)">
   <!ENTITY addressPage.error.genericSave      "There was an error saving the address.">
   <!ENTITY addressPage.cancelButton.label     "Cancel">
   <!ENTITY addressPage.backButton.label       "Back">
   <!ENTITY addressPage.saveButton.label       "Save">
   <!ENTITY addressPage.persistCheckbox.label  "Save address to Firefox">
@@ -133,16 +137,20 @@
         <order-details></order-details>
       </section>
 
       <basic-card-form id="basic-card-page"
                        class="page"
                        data-add-basic-card-title="&basicCard.addPage.title;"
                        data-edit-basic-card-title="&basicCard.editPage.title;"
                        data-error-generic-save="&basicCardPage.error.genericSave;"
+                       data-address-add-link-label="&basicCardPage.addressAddLink.label;"
+                       data-address-edit-link-label="&basicCardPage.addressEditLink.label;"
+                       data-billing-address-title-add="&billingAddress.addPage.title;"
+                       data-billing-address-title-edit="&billingAddress.editPage.title;"
                        data-back-button-label="&basicCardPage.backButton.label;"
                        data-save-button-label="&basicCardPage.saveButton.label;"
                        data-cancel-button-label="&cancelPaymentButton.label;"
                        data-persist-checkbox-label="&basicCardPage.persistCheckbox.label;"
                        hidden="hidden"></basic-card-form>
 
       <address-form id="address-page"
                     class="page"
--- a/browser/components/payments/test/browser/browser_address_edit.js
+++ b/browser/components/payments/test/browser/browser_address_edit.js
@@ -49,25 +49,25 @@ add_task(async function test_add_link() 
       }, "No saved addresses when starting test");
 
       let addLink = content.document.querySelector("address-picker .add-link");
       is(addLink.textContent, "Add", "Add link text");
 
       addLink.click();
 
       state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-        return state.page.id == "address-page" && !state.page.guid;
+        return state.page.id == "address-page" && !state["address-page"].guid;
       }, "Check add page state");
 
       let title = content.document.querySelector("address-form h1");
       is(title.textContent, "Add Shipping Address", "Page title should be set");
 
-      let persistInput = content.document.querySelector("address-form labelled-checkbox");
-      ok(!persistInput.hidden, "checkbox should be visible when adding a new address");
-      ok(Cu.waiveXrays(persistInput).checked, "persist checkbox should be checked by default");
+      let persistCheckbox = content.document.querySelector("address-form labelled-checkbox");
+      ok(!persistCheckbox.hidden, "checkbox should be visible when adding a new address");
+      ok(Cu.waiveXrays(persistCheckbox).checked, "persist checkbox should be checked by default");
 
       info("filling fields");
       for (let [key, val] of Object.entries(address)) {
         let field = content.document.getElementById(key);
         if (!field) {
           ok(false, `${key} field not found`);
         }
         field.value = val;
@@ -140,24 +140,24 @@ add_task(async function test_edit_link()
       Cu.waiveXrays(picker).dropdown.popupBox.children[0].click();
 
       let editLink = content.document.querySelector("address-picker .edit-link");
       is(editLink.textContent, "Edit", "Edit link text");
 
       editLink.click();
 
       state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-        return state.page.id == "address-page" && !!state.page.guid;
+        return state.page.id == "address-page" && !!state["address-page"].guid;
       }, "Check edit page state");
 
       let title = content.document.querySelector("address-form h1");
       is(title.textContent, "Edit Shipping Address", "Page title should be set");
 
-      let persistInput = content.document.querySelector("address-form labelled-checkbox");
-      ok(persistInput.hidden, "checkbox should be hidden when editing an address");
+      let persistCheckbox = content.document.querySelector("address-form labelled-checkbox");
+      ok(persistCheckbox.hidden, "checkbox should be hidden when editing an address");
 
       info("overwriting field values");
       for (let [key, val] of Object.entries(address)) {
         let field = content.document.getElementById(key);
         field.value = val;
         ok(!field.disabled, `Field #${key} shouldn't be disabled`);
       }
 
@@ -225,25 +225,25 @@ add_task(async function test_add_payer_c
       }, "No saved addresses when starting test");
 
       let addLink = content.document.querySelector("address-picker.payer-related .add-link");
       is(addLink.textContent, "Add", "Add link text");
 
       addLink.click();
 
       state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-        return state.page.id == "address-page" && !state.page.guid;
+        return state.page.id == "address-page" && !state["address-page"].guid;
       }, "Check add page state");
 
       let title = content.document.querySelector("address-form h1");
       is(title.textContent, "Add Payer Contact", "Page title should be set");
 
-      let persistInput = content.document.querySelector("address-form labelled-checkbox");
-      ok(!persistInput.hidden, "checkbox should be visible when adding a new address");
-      ok(Cu.waiveXrays(persistInput).checked, "persist checkbox should be checked by default");
+      let persistCheckbox = content.document.querySelector("address-form labelled-checkbox");
+      ok(!persistCheckbox.hidden, "checkbox should be visible when adding a new address");
+      ok(Cu.waiveXrays(persistCheckbox).checked, "persist checkbox should be checked by default");
 
       info("filling fields");
       for (let [key, val] of Object.entries(address)) {
         let field = content.document.getElementById(key);
         if (!field) {
           ok(false, `${key} field not found`);
         }
         field.value = val;
@@ -312,25 +312,24 @@ add_task(async function test_edit_payer_
 
       let editLink =
         content.document.querySelector("address-picker.payer-related .edit-link");
       is(editLink.textContent, "Edit", "Edit link text");
 
       editLink.click();
 
       state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-        info("state.page.id: " + state.page.id + "; state.page.guid: " + state.page.guid);
-        return state.page.id == "address-page" && !!state.page.guid;
+        return state.page.id == "address-page" && !!state["address-page"].guid;
       }, "Check edit page state");
 
       let title = content.document.querySelector("address-form h1");
       is(title.textContent, "Edit Payer Contact", "Page title should be set");
 
-      let persistInput = content.document.querySelector("address-form labelled-checkbox");
-      ok(persistInput.hidden, "checkbox should be hidden when editing an address");
+      let persistCheckbox = content.document.querySelector("address-form labelled-checkbox");
+      ok(persistCheckbox.hidden, "checkbox should be hidden when editing an address");
 
       info("overwriting field values");
       for (let [key, val] of Object.entries(address)) {
         let field = content.document.getElementById(key);
         field.value = val + "1";
         ok(!field.disabled, `Field #${key} shouldn't be disabled`);
       }
 
@@ -432,19 +431,20 @@ add_task(async function test_private_per
     // add an address
     // (return to summary view)
     info("add an address");
     await spawnPaymentDialogTask(frame, async () => {
       let {
         PaymentTestUtils: PTU,
       } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
-      let persistInput = content.document.querySelector("address-form labelled-checkbox");
-      ok(!persistInput.hidden, "checkbox should be visible when adding a new address");
-      ok(!Cu.waiveXrays(persistInput).checked, "persist checkbox should be unchecked by default");
+      let persistCheckbox = content.document.querySelector("address-form labelled-checkbox");
+      ok(!persistCheckbox.hidden, "checkbox should be visible when adding a new address");
+      ok(!Cu.waiveXrays(persistCheckbox).checked,
+         "persist checkbox should be unchecked by default");
 
       info("add the temp address");
       let addressToAdd = PTU.Addresses.Temp;
       for (let [key, val] of Object.entries(addressToAdd)) {
         let field = content.document.getElementById(key);
         field.value = val;
       }
       content.document.querySelector("address-form button:last-of-type").click();
--- a/browser/components/payments/test/browser/browser_card_edit.js
+++ b/browser/components/payments/test/browser/browser_card_edit.js
@@ -13,55 +13,139 @@ add_task(async function test_add_link() 
     } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
     let addLink = content.document.querySelector("payment-method-picker .add-link");
     is(addLink.textContent, "Add", "Add link text");
 
     addLink.click();
 
     let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-      return state.page.id == "basic-card-page" && !state.page.guid;
+      return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
     }, "Check add page state");
 
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return Object.keys(state.savedBasicCards).length == 0 &&
+             Object.keys(state.savedAddresses).length == 0;
+    }, "Check no cards or addresses present at beginning of test");
+
     let title = content.document.querySelector("basic-card-form h1");
     is(title.textContent, "Add Credit Card", "Add title should be set");
 
     ok(!state.isPrivate,
        "isPrivate flag is not set when paymentrequest is shown from a non-private session");
-    let persistInput = content.document.querySelector("basic-card-form labelled-checkbox");
-    ok(Cu.waiveXrays(persistInput).checked, "persist checkbox should be checked by default");
+    let persistCheckbox = content.document.querySelector("basic-card-form labelled-checkbox");
+    ok(Cu.waiveXrays(persistCheckbox).checked, "persist checkbox should be checked by default");
 
     let year = (new Date()).getFullYear();
     let card = {
       "cc-number": "4111111111111111",
       "cc-name": "J. Smith",
       "cc-exp-month": 11,
       "cc-exp-year": year,
     };
 
     info("filling fields");
     for (let [key, val] of Object.entries(card)) {
       let field = content.document.getElementById(key);
       field.value = val;
       ok(!field.disabled, `Field #${key} shouldn't be disabled`);
     }
 
+    let billingAddressSelect = content.document.querySelector("#billingAddressGUID");
+    isnot(billingAddressSelect.getBoundingClientRect().height, 0,
+          "The billing address selector should always be visible");
+    is(billingAddressSelect.childElementCount, 1,
+       "Only one child option should exist by default");
+    is(billingAddressSelect.children[0].value, "",
+       "The only option should be the blank/empty option");
+
+    let addressAddLink = content.document.querySelector(".billingAddressRow .add-link");
+    addressAddLink.click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "address-page" && !state["address-page"].guid;
+    }, "Check address page state");
+
+    let addressTitle = content.document.querySelector("address-form h1");
+    is(addressTitle.textContent, "Add Billing Address",
+       "Address on add address page should be correct");
+
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return Object.keys(state.savedBasicCards).length == 0;
+    }, "Check card was not added when clicking the 'add' address button");
+
+    let addressBackButton = content.document.querySelector("address-form .back-button");
+    addressBackButton.click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "basic-card-page" && !state["basic-card-page"].guid &&
+             Object.keys(state.savedAddresses).length == 0;
+    }, "Check basic-card page, but card should not be saved and no addresses present");
+
+    is(title.textContent, "Add Credit Card", "Add title should be still be on credit card page");
+
+    for (let [key, val] of Object.entries(card)) {
+      let field = content.document.getElementById(key);
+      is(field.value, val, "Field should still have previous value entered");
+      ok(!field.disabled, "Fields should still be enabled for editing");
+    }
+
+    addressAddLink.click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "address-page" && !state["address-page"].guid;
+    }, "Check address page state");
+
+    info("filling address fields");
+    for (let [key, val] of Object.entries(PTU.Addresses.TimBL)) {
+      let field = content.document.getElementById(key);
+      if (!field) {
+        ok(false, `${key} field not found`);
+      }
+      field.value = val;
+      ok(!field.disabled, `Field #${key} shouldn't be disabled`);
+    }
+
+    content.document.querySelector("address-form button:last-of-type").click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "basic-card-page" && !state["basic-card-page"].guid &&
+             Object.keys(state.savedAddresses).length == 1;
+    }, "Check address was added and we're back on basic-card page (add)");
+
+    ok(state["basic-card-page"].preserveFieldValues,
+       "preserveFieldValues should be set when coming back from address-page");
+
+    ok(state["basic-card-page"].billingAddressGUID,
+       "billingAddressGUID should be set when coming back from address-page");
+
+    is(billingAddressSelect.childElementCount, 2,
+       "Two options should exist in the billingAddressSelect");
+    let selectedOption =
+      billingAddressSelect.children[billingAddressSelect.selectedIndex];
+    let selectedAddressGuid = selectedOption.value;
+    is(selectedAddressGuid, Object.values(state.savedAddresses)[0].guid,
+       "The select should have the new address selected");
+
+    for (let [key, val] of Object.entries(card)) {
+      let field = content.document.getElementById(key);
+      is(field.value, val, `Field #${key} should have value`);
+    }
+
     content.document.querySelector("basic-card-form button:last-of-type").click();
 
     state = await PTU.DialogContentUtils.waitForState(content, (state) => {
       return Object.keys(state.savedBasicCards).length == 1;
-    }, "Check card was added");
+    }, "Check card was not added again");
 
     let cardGUIDs = Object.keys(state.savedBasicCards);
     is(cardGUIDs.length, 1, "Check there is one card");
     let savedCard = state.savedBasicCards[cardGUIDs[0]];
     card["cc-number"] = "************1111"; // Card should be masked
     for (let [key, val] of Object.entries(card)) {
       is(savedCard[key], val, "Check " + key);
     }
+    is(savedCard.billingAddressGUID, selectedAddressGuid,
+       "The saved card should be associated with the billing address");
 
     state = await PTU.DialogContentUtils.waitForState(content, (state) => {
       return state.page.id == "payment-summary";
     }, "Switched back to payment-summary");
   }, args);
 });
 
 add_task(async function test_edit_link() {
@@ -75,19 +159,24 @@ add_task(async function test_edit_link()
     } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
     let editLink = content.document.querySelector("payment-method-picker .edit-link");
     is(editLink.textContent, "Edit", "Edit link text");
 
     editLink.click();
 
     let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-      return state.page.id == "basic-card-page" && !!state.page.guid;
+      return state.page.id == "basic-card-page" && state["basic-card-page"].guid;
     }, "Check edit page state");
 
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return Object.keys(state.savedBasicCards).length == 1 &&
+             Object.keys(state.savedAddresses).length == 1;
+    }, "Check card and address present at beginning of test");
+
     let title = content.document.querySelector("basic-card-form h1");
     is(title.textContent, "Edit Credit Card", "Edit title should be set");
 
     let nextYear = (new Date()).getFullYear() + 1;
     let card = {
       // cc-number cannot be modified
       "cc-name": "A. Nonymous",
       "cc-exp-month": 3,
@@ -97,16 +186,100 @@ add_task(async function test_edit_link()
     info("overwriting field values");
     for (let [key, val] of Object.entries(card)) {
       let field = content.document.getElementById(key);
       field.value = val;
       ok(!field.disabled, `Field #${key} shouldn't be disabled`);
     }
     ok(content.document.getElementById("cc-number").disabled, "cc-number field should be disabled");
 
+    let billingAddressSelect = content.document.querySelector("#billingAddressGUID");
+    is(billingAddressSelect.childElementCount, 2,
+       "Two options should exist in the billingAddressSelect");
+    is(billingAddressSelect.selectedIndex, 1,
+       "The billing address set by the previous test should be selected by default");
+
+    info("Test clicking 'edit' on the empty option first");
+    billingAddressSelect.selectedIndex = 0;
+
+    let addressEditLink = content.document.querySelector(".billingAddressRow .edit-link");
+    addressEditLink.click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "address-page" && !state["address-page"].guid;
+    }, "Clicking edit button when the empty option is selected will go to 'add' page (no guid)");
+
+    let addressTitle = content.document.querySelector("address-form h1");
+    is(addressTitle.textContent, "Add Billing Address",
+       "Address on add address page should be correct");
+
+    let addressBackButton = content.document.querySelector("address-form .back-button");
+    addressBackButton.click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "basic-card-page" && state["basic-card-page"].guid &&
+             Object.keys(state.savedAddresses).length == 1;
+    }, "Check we're back at basic-card page with no state changed after adding");
+
+    info("Go back to previously selected option before clicking 'edit' now");
+    billingAddressSelect.selectedIndex = 1;
+
+    let selectedOption = billingAddressSelect.selectedOptions.length &&
+                         billingAddressSelect.selectedOptions[0];
+    ok(selectedOption && selectedOption.value, "select should have a selected option value");
+
+    addressEditLink.click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "address-page" && state["address-page"].guid;
+    }, "Check address page state (editing)");
+
+    is(addressTitle.textContent, "Edit Billing Address",
+       "Address on edit address page should be correct");
+
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return Object.keys(state.savedBasicCards).length == 1;
+    }, "Check card was not added again when clicking the 'edit' address button");
+
+    addressBackButton.click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "basic-card-page" && state["basic-card-page"].guid &&
+             Object.keys(state.savedAddresses).length == 1;
+    }, "Check we're back at basic-card page with no state changed after editing");
+
+    for (let [key, val] of Object.entries(card)) {
+      let field = content.document.getElementById(key);
+      is(field.value, val, "Field should still have previous value entered");
+    }
+
+    selectedOption = billingAddressSelect.selectedOptions.length &&
+                     billingAddressSelect.selectedOptions[0];
+    ok(selectedOption && selectedOption.value, "select should have a selected option value");
+
+    addressEditLink.click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "address-page" && state["address-page"].guid;
+    }, "Check address page state (editing)");
+
+    info("filling address fields");
+    for (let [key, val] of Object.entries(PTU.Addresses.TimBL)) {
+      let field = content.document.getElementById(key);
+      if (!field) {
+        ok(false, `${key} field not found`);
+      }
+      field.value = val + "1";
+      ok(!field.disabled, `Field #${key} shouldn't be disabled`);
+    }
+
+    content.document.querySelector("address-form button:last-of-type").click();
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "basic-card-page" && state["basic-card-page"].guid &&
+             Object.keys(state.savedAddresses).length == 1;
+    }, "Check still only one address and we're back on basic-card page");
+
+    is(Object.values(state.savedAddresses)[0].tel, PTU.Addresses.TimBL.tel + "1",
+       "Check that address was edited and saved");
+
     content.document.querySelector("basic-card-form button:last-of-type").click();
 
     state = await PTU.DialogContentUtils.waitForState(content, (state) => {
       let cards = Object.entries(state.savedBasicCards);
       return cards.length == 1 &&
              cards[0][1]["cc-name"] == card["cc-name"];
     }, "Check card was edited");
 
@@ -135,47 +308,47 @@ add_task(async function test_private_per
     } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
     let addLink = content.document.querySelector("payment-method-picker .add-link");
     is(addLink.textContent, "Add", "Add link text");
 
     addLink.click();
 
     let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-      return state.page.id == "basic-card-page" && !state.page.guid;
+      return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
     },
                                                           "Check add page state");
 
     ok(!state.isPrivate,
        "isPrivate flag is not set when paymentrequest is shown from a non-private session");
-    let persistInput = content.document.querySelector("basic-card-form labelled-checkbox");
-    ok(Cu.waiveXrays(persistInput).checked,
+    let persistCheckbox = content.document.querySelector("basic-card-form labelled-checkbox");
+    ok(Cu.waiveXrays(persistCheckbox).checked,
        "checkbox is checked by default from a non-private session");
   }, args);
 
   let privateWin = await BrowserTestUtils.openNewBrowserWindow({private: true});
   await spawnInDialogForMerchantTask(PTU.ContentTasks.createAndShowRequest, async function check() {
     let {
       PaymentTestUtils: PTU,
     } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
     let addLink = content.document.querySelector("payment-method-picker .add-link");
     is(addLink.textContent, "Add", "Add link text");
 
     addLink.click();
 
     let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-      return state.page.id == "basic-card-page" && !state.page.guid;
+      return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
     },
                                                           "Check add page state");
 
     ok(state.isPrivate,
        "isPrivate flag is set when paymentrequest is shown from a private session");
-    let persistInput = content.document.querySelector("labelled-checkbox");
-    ok(!Cu.waiveXrays(persistInput).checked,
+    let persistCheckbox = content.document.querySelector("labelled-checkbox");
+    ok(!Cu.waiveXrays(persistCheckbox).checked,
        "checkbox is not checked by default from a private session");
   }, args, {
     browser: privateWin.gBrowser,
   });
   await BrowserTestUtils.closeWindow(privateWin);
 });
 
 add_task(async function test_private_card_adding() {
@@ -190,17 +363,17 @@ add_task(async function test_private_car
     } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
     let addLink = content.document.querySelector("payment-method-picker .add-link");
     is(addLink.textContent, "Add", "Add link text");
 
     addLink.click();
 
     let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-      return state.page.id == "basic-card-page" && !state.page.guid;
+      return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
     },
                                                           "Check add page state");
 
     let savedCardCount = Object.keys(state.savedBasicCards).length;
     let tempCardCount = Object.keys(state.tempBasicCards).length;
 
     let year = (new Date()).getFullYear();
     let card = {
--- a/browser/components/payments/test/mochitest/test_address_form.html
+++ b/browser/components/payments/test/mochitest/test_address_form.html
@@ -69,17 +69,19 @@ add_task(async function test_initialStat
   form.remove();
 });
 
 add_task(async function test_backButton() {
   let form = new AddressForm();
   form.dataset.backButtonLabel = "Back";
   await form.requestStore.setState({
     page: {
-      id: "test-page",
+      id: "address-page",
+    },
+    "address-page": {
       title: "Sample page title",
     },
   });
   await form.promiseReady;
   display.appendChild(form);
   await asyncElementRendered();
 
   let stateChangePromise = promiseStateChange(form.requestStore);
@@ -133,16 +135,19 @@ add_task(async function test_saveButton(
   isDeeply(details, {
     collectionName: "addresses",
     errorStateChange: {
       page: {
         id: "address-page",
         error: "Generic error",
         onboardingWizard: undefined,
       },
+      "address-page": {
+        title: "Sample page title",
+      },
     },
     guid: undefined,
     messageType: "updateAutofillRecord",
     preserveOldProperties: true,
     record: {
       "given-name": "Jaws",
       "family-name": "Swaj",
       "organization": "Allizom",
@@ -153,16 +158,17 @@ add_task(async function test_saveButton(
       "country": "US",
       "email": "test@example.com",
       "tel": "+15555551212",
     },
     selectedStateKey: undefined,
     successStateChange: {
       page: {
         id: "payment-summary",
+        onboardingWizard: undefined,
       },
     },
   }, "Check event details for the message to chrome");
   form.remove();
 });
 
 add_task(async function test_genericError() {
   let form = new AddressForm();
@@ -188,16 +194,18 @@ add_task(async function test_edit() {
   await asyncElementRendered();
 
   let address1 = deepClone(PTU.Addresses.TimBL);
   address1.guid = "9864798564";
 
   await form.requestStore.setState({
     page: {
       id: "address-page",
+    },
+    "address-page": {
       guid: address1.guid,
     },
     savedAddresses: {
       [address1.guid]: deepClone(address1),
     },
   });
   await asyncElementRendered();
   checkAddressForm(form, address1);
@@ -205,30 +213,33 @@ add_task(async function test_edit() {
   info("test change to minimal record");
   let minimalAddress = {
     "given-name": address1["given-name"],
     guid: "9gnjdhen46",
   };
   await form.requestStore.setState({
     page: {
       id: "address-page",
+    },
+    "address-page": {
       guid: minimalAddress.guid,
     },
     savedAddresses: {
       [minimalAddress.guid]: deepClone(minimalAddress),
     },
   });
   await asyncElementRendered();
   checkAddressForm(form, minimalAddress);
 
   info("change to no selected address");
   await form.requestStore.setState({
     page: {
       id: "address-page",
     },
+    "address-page": {},
   });
   await asyncElementRendered();
   checkAddressForm(form, {});
 
   form.remove();
 });
 
 add_task(async function test_restricted_address_fields() {
--- a/browser/components/payments/test/mochitest/test_basic_card_form.html
+++ b/browser/components/payments/test/mochitest/test_basic_card_form.html
@@ -64,17 +64,19 @@ add_task(async function test_initialStat
 });
 
 add_task(async function test_backButton() {
   let form = new BasicCardForm();
   form.dataset.backButtonLabel = "Back";
   form.dataset.addBasicCardTitle = "Sample page title 2";
   await form.requestStore.setState({
     page: {
-      id: "test-page",
+      id: "basic-card-page",
+    },
+    "basic-card-page": {
     },
   });
   await form.promiseReady;
   display.appendChild(form);
   await asyncElementRendered();
 
   let stateChangePromise = promiseStateChange(form.requestStore);
   is(form.pageTitle.textContent, "Sample page title 2", "Check title");
@@ -123,17 +125,17 @@ add_task(async function test_saveButton(
     messageType: "updateAutofillRecord",
     preserveOldProperties: true,
     record: {
       "cc-exp-month": "11",
       "cc-exp-year": year,
       "cc-name": "J. Smith",
       "cc-number": "4111111111111111",
     },
-    selectedStateKey: "selectedPaymentCard",
+    selectedStateKey: ["selectedPaymentCard"],
     successStateChange: {
       page: {
         id: "payment-summary",
       },
     },
   }, "Check event details for the message to chrome");
   form.remove();
 });
@@ -239,16 +241,18 @@ add_task(async function test_edit() {
   info("test year before current");
   let card1 = deepClone(PTU.BasicCards.JohnDoe);
   card1.guid = "9864798564";
   card1["cc-exp-year"] = 2011;
 
   await form.requestStore.setState({
     page: {
       id: "basic-card-page",
+    },
+    "basic-card-page": {
       guid: card1.guid,
     },
     savedBasicCards: {
       [card1.guid]: deepClone(card1),
     },
   });
   await asyncElementRendered();
   checkCCForm(form, card1);
@@ -268,30 +272,35 @@ add_task(async function test_edit() {
   let minimalCard = {
     // no expiration date or name
     "cc-number": "1234567690123",
     guid: "9gnjdhen46",
   };
   await form.requestStore.setState({
     page: {
       id: "basic-card-page",
+    },
+    "basic-card-page": {
       guid: minimalCard.guid,
     },
     savedBasicCards: {
       [minimalCard.guid]: deepClone(minimalCard),
     },
   });
   await asyncElementRendered();
   checkCCForm(form, minimalCard);
 
   info("change to no selected card");
   await form.requestStore.setState({
     page: {
       id: "basic-card-page",
     },
+    "basic-card-page": {
+      guid: null,
+    },
   });
   await asyncElementRendered();
   checkCCForm(form, {});
 
   form.remove();
 });
 </script>
 
--- a/browser/extensions/formautofill/content/autofillEditForms.js
+++ b/browser/extensions/formautofill/content/autofillEditForms.js
@@ -192,23 +192,26 @@ class EditCreditCard extends EditAutofil
       billingAddress: this._elements.form.querySelector("#billingAddressGUID"),
       billingAddressRow: this._elements.form.querySelector(".billingAddressRow"),
     });
 
     this.loadRecord(record, addresses);
     this.attachEventListeners();
   }
 
-  loadRecord(record, addresses) {
+  loadRecord(record, addresses, preserveFieldValues) {
     // _record must be updated before generateYears and generateBillingAddressOptions are called.
     this._record = record;
     this._addresses = addresses;
-    this.generateYears();
     this.generateBillingAddressOptions();
-    super.loadRecord(record);
+    if (!preserveFieldValues) {
+      // Re-generating the years will reset the selected option.
+      this.generateYears();
+      super.loadRecord(record);
+    }
   }
 
   generateYears() {
     const count = 11;
     const currentYear = new Date().getFullYear();
     const ccExpYear = this._record && this._record["cc-exp-year"];
 
     // Clear the list
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -787,16 +787,18 @@ you can use these alternative items. Oth
 <!ENTITY identity.disableMixedContentBlocking.accesskey "D">
 <!ENTITY identity.learnMore "Learn More">
 
 <!ENTITY identity.removeCertException.label "Remove Exception">
 <!ENTITY identity.removeCertException.accesskey "R">
 
 <!ENTITY identity.moreInfoLinkText2 "More Information">
 
+<!ENTITY identity.clearSiteData "Clear Cookies and Site Data">
+
 <!ENTITY identity.permissions "Permissions">
 <!ENTITY identity.permissionsEmpty "You have not granted this site any special permissions.">
 <!ENTITY identity.permissionsReloadHint "You may need to reload the page for changes to apply.">
 
 <!-- Name for the tabs toolbar as spoken by screen readers.
      The word "toolbar" is appended automatically and should not be contained below! -->
 <!ENTITY tabsToolbar.label "Browser tabs">
 
--- a/browser/themes/shared/controlcenter/panel.inc.css
+++ b/browser/themes/shared/controlcenter/panel.inc.css
@@ -234,37 +234,40 @@
 
 #identity-popup-securityView-body {
   margin-inline-start: calc(2em + 24px);
   margin-inline-end: 1em;
   border-top: 1px solid var(--panel-separator-color);
   padding-inline-end: 1em;
 }
 
-#identity-popup-securityView-footer {
+#identity-popup-more-info-footer {
   margin-top: 1em;
+}
+
+.identity-popup-footer {
   background-color: var(--arrowpanel-dimmed);
 }
 
-#identity-popup-securityView-footer > button {
+.identity-popup-footer > button {
   -moz-appearance: none;
   margin: 0;
   border: none;
   border-top: 1px solid var(--panel-separator-color);
   padding: 8px 20px;
   color: inherit;
   background-color: transparent;
 }
 
-#identity-popup-securityView-footer > button:hover,
-#identity-popup-securityView-footer > button:focus {
+.identity-popup-footer > button:hover,
+.identity-popup-footer > button:focus {
   background-color: var(--arrowpanel-dimmed);
 }
 
-#identity-popup-securityView-footer > button:hover:active {
+.identity-popup-footer > button:hover:active {
   background-color: var(--arrowpanel-dimmed-further);
 }
 
 #identity-popup-content-verifier ~ description {
   margin-top: 1em;
   color: Graytext;
 }
 
--- a/devtools/client/shared/test/unit/test_parseDeclarations.js
+++ b/devtools/client/shared/test/unit/test_parseDeclarations.js
@@ -66,23 +66,70 @@ const TEST_DATA = [
   // Test simple priority
   {
     input: "p1: v1 !  important; p2: v2 ! important;",
     expected: [
       {name: "p1", value: "v1", priority: "important", offsets: [0, 20]},
       {name: "p2", value: "v2", priority: "important", offsets: [21, 40]}
     ]
   },
+  // Test simple priority
+  {
+    input: "p1: v1 !/*comment*/important;",
+    expected: [
+      {name: "p1", value: "v1", priority: "important", offsets: [0, 29]},
+    ]
+  },
+  // Test priority without terminating ";".
+  {
+    input: "p1: v1 !important",
+    expected: [
+      {name: "p1", value: "v1", priority: "important", offsets: [0, 17]},
+    ]
+  },
+  // Test trailing "!" without terminating ";".
+  {
+    input: "p1: v1 !",
+    expected: [
+      {name: "p1", value: "v1 !", priority: "", offsets: [0, 8]},
+    ]
+  },
   // Test invalid priority
   {
     input: "p1: v1 important;",
     expected: [
       {name: "p1", value: "v1 important", priority: "", offsets: [0, 17]}
     ]
   },
+  // Test invalid priority (in the middle of the declaration).
+  // See bug 1462553.
+  {
+    input: "p1: v1 !important v2;",
+    expected: [
+      {name: "p1", value: "v1 !important v2", priority: "", offsets: [0, 21]}
+    ]
+  },
+  {
+    input: "p1: v1 !    important v2;",
+    expected: [
+      {name: "p1", value: "v1 ! important v2", priority: "", offsets: [0, 25]}
+    ]
+  },
+  {
+    input: "p1: v1 !  /*comment*/  important v2;",
+    expected: [
+      {name: "p1", value: "v1 ! important v2", priority: "", offsets: [0, 36]}
+    ]
+  },
+  {
+    input: "p1: v1 !/*hi*/important v2;",
+    expected: [
+      {name: "p1", value: "v1 ! important v2", priority: "", offsets: [0, 27]}
+    ]
+  },
   // Test various types of background-image urls
   {
     input: "background-image: url(../../relative/image.png)",
     expected: [{
       name: "background-image",
       value: "url(../../relative/image.png)",
       priority: "",
       offsets: [0, 47]
--- a/devtools/client/shared/test/unit/test_parseSingleValue.js
+++ b/devtools/client/shared/test/unit/test_parseSingleValue.js
@@ -17,17 +17,17 @@ const TEST_DATA = [
   {input: "blue", expected: {value: "blue", priority: ""}},
   {input: "blue !important", expected: {value: "blue", priority: "important"}},
   {input: "blue!important", expected: {value: "blue", priority: "important"}},
   {input: "blue ! important", expected: {value: "blue", priority: "important"}},
   {
     input: "blue !  important",
     expected: {value: "blue", priority: "important"}
   },
-  {input: "blue !", expected: {value: "blue", priority: ""}},
+  {input: "blue !", expected: {value: "blue !", priority: ""}},
   {input: "blue !mportant", expected: {value: "blue !mportant", priority: ""}},
   {
     input: "  blue   !important ",
     expected: {value: "blue", priority: "important"}
   },
   {
     input: "url(\"http://url.com/whyWouldYouDoThat!important.png\") !important",
     expected: {
--- a/devtools/shared/css/parsing-utils.js
+++ b/devtools/shared/css/parsing-utils.js
@@ -297,17 +297,26 @@ function parseDeclarationsInternal(isCss
     throw new Error("empty input string");
   }
 
   let lexer = getCSSLexer(inputString);
 
   let declarations = [getEmptyDeclaration()];
   let lastProp = declarations[0];
 
-  let current = "", hasBang = false;
+  // This tracks the "!important" parsing state.  The states are:
+  // 0 - haven't seen anything
+  // 1 - have seen "!", looking for "important" next (possibly after
+  //     whitespace).
+  // 2 - have seen "!important"
+  let importantState = 0;
+  // This is true if we saw whitespace or comments between the "!" and
+  // the "important".
+  let importantWS = false;
+  let current = "";
   while (true) {
     let token = lexer.nextToken();
     if (!token) {
       break;
     }
 
     // Ignore HTML comment tokens (but parse anything they might
     // happen to surround).
@@ -317,29 +326,33 @@ function parseDeclarationsInternal(isCss
 
     // Update the start and end offsets of the declaration, but only
     // when we see a significant token.
     if (token.tokenType !== "whitespace" && token.tokenType !== "comment") {
       if (lastProp.offsets[0] === undefined) {
         lastProp.offsets[0] = token.startOffset;
       }
       lastProp.offsets[1] = token.endOffset;
-    } else if (lastProp.name && !current && !hasBang &&
+    } else if (lastProp.name && !current && !importantState &&
                !lastProp.priority && lastProp.colonOffsets[1]) {
       // Whitespace appearing after the ":" is attributed to it.
       lastProp.colonOffsets[1] = token.endOffset;
+    } else if (importantState === 1) {
+      importantWS = true;
     }
 
     if (token.tokenType === "symbol" && token.text === ":") {
+      // Either way, a "!important" we've seen is no longer valid now.
+      importantState = 0;
+      importantWS = false;
       if (!lastProp.name) {
         // Set the current declaration name if there's no name yet
         lastProp.name = cssTrim(current);
         lastProp.colonOffsets = [token.startOffset, token.endOffset];
         current = "";
-        hasBang = false;
 
         // When parsing a comment body, if the left-hand-side is not a
         // valid property name, then drop it and stop parsing.
         if (inComment && !commentOverride &&
             !isCssPropertyKnown(lastProp.name)) {
           lastProp.name = null;
           break;
         }
@@ -352,68 +365,100 @@ function parseDeclarationsInternal(isCss
       lastProp.terminator = "";
       // When parsing a comment, if the name hasn't been set, then we
       // have probably just seen an ordinary semicolon used in text,
       // so drop this and stop parsing.
       if (inComment && !lastProp.name) {
         current = "";
         break;
       }
+      if (importantState === 2) {
+        lastProp.priority = "important";
+      } else if (importantState === 1) {
+        current += "!";
+        if (importantWS) {
+          current += " ";
+        }
+      }
       lastProp.value = cssTrim(current);
       current = "";
-      hasBang = false;
+      importantState = 0;
+      importantWS = false;
       declarations.push(getEmptyDeclaration());
       lastProp = declarations[declarations.length - 1];
     } else if (token.tokenType === "ident") {
-      if (token.text === "important" && hasBang) {
-        lastProp.priority = "important";
-        hasBang = false;
+      if (token.text === "important" && importantState === 1) {
+        importantState = 2;
       } else {
-        if (hasBang) {
+        if (importantState > 0) {
           current += "!";
+          if (importantWS) {
+            current += " ";
+          }
+          if (importantState === 2) {
+            current += "important ";
+          }
+          importantState = 0;
+          importantWS = false;
         }
         // Re-escape the token to avoid dequoting problems.
         // See bug 1287620.
         current += CSS.escape(token.text);
       }
     } else if (token.tokenType === "symbol" && token.text === "!") {
-      hasBang = true;
+      importantState = 1;
     } else if (token.tokenType === "whitespace") {
       if (current !== "") {
-        current += " ";
+        current = current.trimRight() + " ";
       }
     } else if (token.tokenType === "comment") {
       if (parseComments && !lastProp.name && !lastProp.value) {
         let commentText = inputString.substring(token.startOffset + 2,
                                                 token.endOffset - 2);
         let newDecls = parseCommentDeclarations(isCssPropertyKnown, commentText,
                                                 token.startOffset,
                                                 token.endOffset);
 
         // Insert the new declarations just before the final element.
         let lastDecl = declarations.pop();
         declarations = [...declarations, ...newDecls, lastDecl];
       } else {
-        current += " ";
+        current = current.trimRight() + " ";
       }
     } else {
+      if (importantState > 0) {
+        current += "!";
+        if (importantWS) {
+          current += " ";
+        }
+        if (importantState === 2) {
+          current += "important ";
+        }
+        importantState = 0;
+        importantWS = false;
+      }
       current += inputString.substring(token.startOffset, token.endOffset);
     }
   }
 
   // Handle whatever trailing properties or values might still be there
   if (current) {
     if (!lastProp.name) {
       // Ignore this case in comments.
       if (!inComment) {
         // Trailing property found, e.g. p1:v1;p2:v2;p3
         lastProp.name = cssTrim(current);
       }
     } else {
       // Trailing value found, i.e. value without an ending ;
+      if (importantState === 2) {
+        lastProp.priority = "important";
+      } else if (importantState === 1) {
+        current += "!";
+      }
       lastProp.value = cssTrim(current);
       let terminator = lexer.performEOFFixup("", true);
       lastProp.terminator = terminator + ";";
       // If the input was unterminated, attribute the remainder to
       // this property.  This avoids some bad behavior when rewriting
       // an unterminated comment.
       if (terminator) {
         lastProp.offsets[1] = inputString.length;
--- a/editor/libeditor/EditorBase.cpp
+++ b/editor/libeditor/EditorBase.cpp
@@ -484,21 +484,22 @@ EditorBase::GetDesiredSpellCheckState()
     // return true and let the spellchecker figure it out.
     nsCOMPtr<nsIHTMLDocument> doc = do_QueryInterface(content->GetComposedDoc());
     return doc && doc->IsEditingOn();
   }
 
   return element->Spellcheck();
 }
 
-NS_IMETHODIMP
+void
 EditorBase::PreDestroy(bool aDestroyingFrames)
 {
-  if (mDidPreDestroy)
-    return NS_OK;
+  if (mDidPreDestroy) {
+    return;
+  }
 
   Selection* selection = GetSelection();
   if (selection) {
     selection->RemoveSelectionListener(this);
   }
 
   IMEStateManager::OnEditorDestroying(*this);
 
@@ -532,17 +533,16 @@ EditorBase::PreDestroy(bool aDestroyingF
   if (mTransactionManager) {
     DebugOnly<bool> disabledUndoRedo = DisableUndoRedo();
     NS_WARNING_ASSERTION(disabledUndoRedo,
       "Failed to disable undo/redo transactions");
     mTransactionManager = nullptr;
   }
 
   mDidPreDestroy = true;
-  return NS_OK;
 }
 
 NS_IMETHODIMP
 EditorBase::GetFlags(uint32_t* aFlags)
 {
   // NOTE: If you need to override this method, you need to make Flags()
   //       virtual.
   *aFlags = Flags();
@@ -1185,23 +1185,16 @@ EditorBase::PasteTransferable(nsITransfe
 
 NS_IMETHODIMP
 EditorBase::CanPaste(int32_t aSelectionType, bool* aCanPaste)
 {
   return NS_ERROR_NOT_IMPLEMENTED;
 }
 
 NS_IMETHODIMP
-EditorBase::CanPasteTransferable(nsITransferable* aTransferable,
-                                 bool* aCanPaste)
-{
-  return NS_ERROR_NOT_IMPLEMENTED;
-}
-
-NS_IMETHODIMP
 EditorBase::SetAttribute(Element* aElement,
                          const nsAString& aAttribute,
                          const nsAString& aValue)
 {
   if (NS_WARN_IF(aAttribute.IsEmpty())) {
     return NS_ERROR_INVALID_ARG;
   }
   if (NS_WARN_IF(!aElement)) {
--- a/editor/libeditor/EditorBase.h
+++ b/editor/libeditor/EditorBase.h
@@ -964,16 +964,25 @@ protected:
 
 public:
   /**
    * PostCreate should be called after Init, and is the time that the editor
    * tells its documentStateObservers that the document has been created.
    */
   nsresult PostCreate();
 
+ /**
+   * PreDestroy is called before the editor goes away, and gives the editor a
+   * chance to tell its documentStateObservers that the document is going away.
+   * @param aDestroyingFrames set to true when the frames being edited
+   * are being destroyed (so there is no need to modify any nsISelections,
+   * nor is it safe to do so)
+   */
+  virtual void PreDestroy(bool aDestroyingFrames);
+
   /**
    * All editor operations which alter the doc should be prefaced
    * with a call to StartOperation, naming the action and direction.
    */
   virtual nsresult StartOperation(EditAction opID,
                                   nsIEditor::EDirection aDirection);
 
   /**
--- a/editor/libeditor/EditorCommands.cpp
+++ b/editor/libeditor/EditorCommands.cpp
@@ -601,17 +601,18 @@ PasteTransferableCommand::IsCommandEnabl
   if (!editor) {
     return NS_OK;
   }
   TextEditor* textEditor = editor->AsTextEditor();
   MOZ_ASSERT(textEditor);
   if (!textEditor->IsSelectionEditable()) {
     return NS_OK;
   }
-  return textEditor->CanPasteTransferable(nullptr, aIsEnabled);
+  *aIsEnabled = textEditor->CanPasteTransferable(nullptr);
+  return NS_OK;
 }
 
 NS_IMETHODIMP
 PasteTransferableCommand::DoCommand(const char* aCommandName,
                                     nsISupports* aCommandRefCon)
 {
   return NS_ERROR_FAILURE;
 }
@@ -662,23 +663,18 @@ PasteTransferableCommand::GetCommandStat
   trans = do_QueryInterface(supports);
   if (NS_WARN_IF(!trans)) {
     return NS_ERROR_FAILURE;
   }
 
   TextEditor* textEditor = editor->AsTextEditor();
   MOZ_ASSERT(textEditor);
 
-  bool canPaste;
-  nsresult rv = textEditor->CanPasteTransferable(trans, &canPaste);
-  if (NS_WARN_IF(NS_FAILED(rv))) {
-    return rv;
-  }
-
-  return aParams->SetBooleanValue(STATE_ENABLED, canPaste);
+  return aParams->SetBooleanValue(STATE_ENABLED,
+                                  textEditor->CanPasteTransferable(trans));
 }
 
 /******************************************************************************
  * mozilla::SwitchTextDirectionCommand
  ******************************************************************************/
 
 NS_IMETHODIMP
 SwitchTextDirectionCommand::IsCommandEnabled(const char* aCommandName,
--- a/editor/libeditor/HTMLEditor.cpp
+++ b/editor/libeditor/HTMLEditor.cpp
@@ -327,37 +327,37 @@ HTMLEditor::Init(nsIDocument& aDoc,
       AddOverrideStyleSheet(NS_LITERAL_STRING("resource://gre/res/EditorOverride.css"));
     }
   }
   NS_ENSURE_SUCCESS(rulesRv, rulesRv);
 
   return NS_OK;
 }
 
-NS_IMETHODIMP
+void
 HTMLEditor::PreDestroy(bool aDestroyingFrames)
 {
   if (mDidPreDestroy) {
-    return NS_OK;
+    return;
   }
 
   nsCOMPtr<nsIDocument> document = GetDocument();
   if (document) {
     document->RemoveMutationObserver(this);
   }
 
   while (!mStyleSheetURLs.IsEmpty()) {
     RemoveOverrideStyleSheet(mStyleSheetURLs[0]);
   }
 
   // Clean up after our anonymous content -- we don't want these nodes to
   // stay around (which they would, since the frames have an owning reference).
   HideAnonymousEditingUIs();
 
-  return TextEditor::PreDestroy(aDestroyingFrames);
+  EditorBase::PreDestroy(aDestroyingFrames);
 }
 
 NS_IMETHODIMP
 HTMLEditor::NotifySelectionChanged(nsIDocument* aDocument,
                                    Selection* aSelection,
                                    int16_t aReason)
 {
   if (NS_WARN_IF(!aDocument) || NS_WARN_IF(!aSelection)) {
--- a/editor/libeditor/HTMLEditor.h
+++ b/editor/libeditor/HTMLEditor.h
@@ -128,16 +128,17 @@ public:
                      nsAtom* aAttribute,
                      bool aSuppressTransaction) override;
   virtual nsresult SetAttributeOrEquivalent(Element* aElement,
                                             nsAtom* aAttribute,
                                             const nsAString& aValue,
                                             bool aSuppressTransaction) override;
   using EditorBase::RemoveAttributeOrEquivalent;
   using EditorBase::SetAttributeOrEquivalent;
+  virtual bool CanPasteTransferable(nsITransferable* aTransferable) override;
 
   // nsStubMutationObserver overrides
   NS_DECL_NSIMUTATIONOBSERVER_CONTENTAPPENDED
   NS_DECL_NSIMUTATIONOBSERVER_CONTENTINSERTED
   NS_DECL_NSIMUTATIONOBSERVER_CONTENTREMOVED
 
   // nsIHTMLEditor methods
   NS_DECL_NSIHTMLEDITOR
@@ -207,17 +208,17 @@ public:
                                   bool* outIsSpace,
                                   bool* outIsNBSP,
                                   nsIContent** outNode = nullptr,
                                   int32_t* outOffset = 0);
 
   // Overrides of EditorBase interface methods
   virtual nsresult EndUpdateViewBatch() override;
 
-  NS_IMETHOD PreDestroy(bool aDestroyingFrames) override;
+  virtual void PreDestroy(bool aDestroyingFrames) override;
 
   virtual nsresult GetPreferredIMEState(widget::IMEState* aState) override;
 
   /**
    * @param aElement        Must not be null.
    */
   static bool NodeIsBlockStatic(const nsINode* aElement);
 
@@ -324,18 +325,16 @@ protected:
 public:
   // XXX Why don't we move following methods above for grouping by the origins?
   NS_IMETHOD SetFlags(uint32_t aFlags) override;
 
   NS_IMETHOD Paste(int32_t aSelectionType) override;
   NS_IMETHOD CanPaste(int32_t aSelectionType, bool* aCanPaste) override;
 
   NS_IMETHOD PasteTransferable(nsITransferable* aTransferable) override;
-  NS_IMETHOD CanPasteTransferable(nsITransferable* aTransferable,
-                                  bool* aCanPaste) override;
 
   NS_IMETHOD DebugUnitTests(int32_t* outNumTests,
                             int32_t* outNumTestsFailed) override;
 
   /**
    * All editor operations which alter the doc should be prefaced
    * with a call to StartOperation, naming the action and direction.
    */
--- a/editor/libeditor/HTMLEditorDataTransfer.cpp
+++ b/editor/libeditor/HTMLEditorDataTransfer.cpp
@@ -1424,17 +1424,17 @@ HTMLEditor::Paste(int32_t aSelectionType
       infoStr.Assign(text.get(), infoLen / 2);
     }
   }
 
   return InsertFromTransferable(trans, nullptr, contextStr, infoStr,
                                 bHavePrivateHTMLFlavor, true);
 }
 
-NS_IMETHODIMP
+nsresult
 HTMLEditor::PasteTransferable(nsITransferable* aTransferable)
 {
   // Use an invalid value for the clipboard type as data comes from aTransferable
   // and we don't currently implement a way to put that in the data transfer yet.
   if (!FireClipboardEvent(ePaste, nsIClipboard::kGlobalClipboard)) {
     return NS_OK;
   }
 
@@ -1520,61 +1520,54 @@ HTMLEditor::CanPaste(int32_t aSelectionT
                                            aSelectionType, &haveFlavors);
   }
   NS_ENSURE_SUCCESS(rv, rv);
 
   *aCanPaste = haveFlavors;
   return NS_OK;
 }
 
-NS_IMETHODIMP
-HTMLEditor::CanPasteTransferable(nsITransferable* aTransferable,
-                                 bool* aCanPaste)
+bool
+HTMLEditor::CanPasteTransferable(nsITransferable* aTransferable)
 {
-  NS_ENSURE_ARG_POINTER(aCanPaste);
-
   // can't paste if readonly
   if (!IsModifiable()) {
-    *aCanPaste = false;
-    return NS_OK;
+    return false;
   }
 
   // If |aTransferable| is null, assume that a paste will succeed.
   if (!aTransferable) {
-    *aCanPaste = true;
-    return NS_OK;
+    return true;
   }
 
   // Peek in |aTransferable| to see if it contains a supported MIME type.
 
   // Use the flavors depending on the current editor mask
   const char ** flavors;
-  unsigned length;
+  size_t length;
   if (IsPlaintextEditor()) {
     flavors = textEditorFlavors;
     length = ArrayLength(textEditorFlavors);
   } else {
     flavors = textHtmlEditorFlavors;
     length = ArrayLength(textHtmlEditorFlavors);
   }
 
-  for (unsigned int i = 0; i < length; i++, flavors++) {
+  for (size_t i = 0; i < length; i++, flavors++) {
     nsCOMPtr<nsISupports> data;
     uint32_t dataLen;
     nsresult rv = aTransferable->GetTransferData(*flavors,
                                                  getter_AddRefs(data),
                                                  &dataLen);
     if (NS_SUCCEEDED(rv) && data) {
-      *aCanPaste = true;
-      return NS_OK;
+      return true;
     }
   }
 
-  *aCanPaste = false;
-  return NS_OK;
+  return false;
 }
 
 /**
  * HTML PasteAsQuotation: Paste in a blockquote type=cite.
  */
 NS_IMETHODIMP
 HTMLEditor::PasteAsQuotation(int32_t aSelectionType)
 {
--- a/editor/libeditor/TextEditor.cpp
+++ b/editor/libeditor/TextEditor.cpp
@@ -1732,35 +1732,31 @@ TextEditor::GetAndInitDocEncoder(const n
 NS_IMETHODIMP
 TextEditor::OutputToString(const nsAString& aFormatType,
                            uint32_t aFlags,
                            nsAString& aOutputString)
 {
   // Protect the edit rules object from dying
   RefPtr<TextEditRules> rules(mRules);
 
-  nsString resultString;
   RulesInfo ruleInfo(EditAction::outputText);
-  ruleInfo.outString = &resultString;
+  ruleInfo.outString = &aOutputString;
   ruleInfo.flags = aFlags;
-  // XXX Struct should store a nsAReadable*
-  nsAutoString str(aFormatType);
-  ruleInfo.outputFormat = &str;
+  ruleInfo.outputFormat = &aFormatType;
   Selection* selection = GetSelection();
   if (NS_WARN_IF(!selection)) {
     return NS_ERROR_FAILURE;
   }
   bool cancel, handled;
   nsresult rv = rules->WillDoAction(selection, &ruleInfo, &cancel, &handled);
   if (cancel || NS_FAILED(rv)) {
     return rv;
   }
   if (handled) {
-    // This case will get triggered by password fields.
-    aOutputString.Assign(*(ruleInfo.outString));
+    // This case will get triggered by password fields or single text node only.
     return rv;
   }
 
   nsAutoCString charsetStr;
   rv = GetDocumentCharacterSet(charsetStr);
   if (NS_FAILED(rv) || charsetStr.IsEmpty()) {
     charsetStr.AssignLiteral("windows-1252");
   }
--- a/editor/libeditor/TextEditor.h
+++ b/editor/libeditor/TextEditor.h
@@ -68,23 +68,28 @@ public:
   NS_IMETHOD Cut() override;
   NS_IMETHOD CanCut(bool* aCanCut) override;
   NS_IMETHOD Copy() override;
   NS_IMETHOD CanCopy(bool* aCanCopy) override;
   NS_IMETHOD CanDelete(bool* aCanDelete) override;
   NS_IMETHOD Paste(int32_t aSelectionType) override;
   NS_IMETHOD CanPaste(int32_t aSelectionType, bool* aCanPaste) override;
   NS_IMETHOD PasteTransferable(nsITransferable* aTransferable) override;
-  NS_IMETHOD CanPasteTransferable(nsITransferable* aTransferable,
-                                  bool* aCanPaste) override;
 
   NS_IMETHOD OutputToString(const nsAString& aFormatType,
                             uint32_t aFlags,
                             nsAString& aOutputString) override;
 
+  /** Can we paste |aTransferable| or, if |aTransferable| is null, will a call
+    * to pasteTransferable later possibly succeed if given an instance of
+    * nsITransferable then? True if the doc is modifiable, and, if
+    * |aTransfeable| is non-null, we have pasteable data in |aTransfeable|.
+    */
+  virtual bool CanPasteTransferable(nsITransferable* aTransferable);
+
   // Overrides of EditorBase
   virtual nsresult RemoveAttributeOrEquivalent(
                      Element* aElement,
                      nsAtom* aAttribute,
                      bool aSuppressTransaction) override;
   virtual nsresult SetAttributeOrEquivalent(Element* aElement,
                                             nsAtom* aAttribute,
                                             const nsAString& aValue,
--- a/editor/libeditor/TextEditorDataTransfer.cpp
+++ b/editor/libeditor/TextEditorDataTransfer.cpp
@@ -369,47 +369,39 @@ TextEditor::CanPaste(int32_t aSelectionT
                                          ArrayLength(textEditorFlavors),
                                          aSelectionType, &haveFlavors);
   NS_ENSURE_SUCCESS(rv, rv);
 
   *aCanPaste = haveFlavors;
   return NS_OK;
 }
 
-
-NS_IMETHODIMP
-TextEditor::CanPasteTransferable(nsITransferable* aTransferable,
-                                 bool* aCanPaste)
+bool
+TextEditor::CanPasteTransferable(nsITransferable* aTransferable)
 {
-  NS_ENSURE_ARG_POINTER(aCanPaste);
-
   // can't paste if readonly
   if (!IsModifiable()) {
-    *aCanPaste = false;
-    return NS_OK;
+    return false;
   }
 
   // If |aTransferable| is null, assume that a paste will succeed.
   if (!aTransferable) {
-    *aCanPaste = true;
-    return NS_OK;
+    return true;
   }
 
   nsCOMPtr<nsISupports> data;
   uint32_t dataLen;
   nsresult rv = aTransferable->GetTransferData(kUnicodeMime,
                                                getter_AddRefs(data),
                                                &dataLen);
   if (NS_SUCCEEDED(rv) && data) {
-    *aCanPaste = true;
-  } else {
-    *aCanPaste = false;
+    return true;
   }
 
-  return NS_OK;
+  return false;
 }
 
 bool
 TextEditor::IsSafeToInsertData(nsIDocument* aSourceDoc)
 {
   // Try to determine whether we should use a sanitizing fragment sink
   bool isSafe = false;
 
--- a/editor/nsIEditor.idl
+++ b/editor/nsIEditor.idl
@@ -54,25 +54,16 @@ interface nsIEditor  : nsISupports
   void setAttributeOrEquivalent(in Element element,
                                 in AString sourceAttrName,
                                 in AString sourceAttrValue,
                                 in boolean aSuppressTransaction);
   void removeAttributeOrEquivalent(in Element element,
                                    in DOMString sourceAttrName,
                                    in boolean aSuppressTransaction);
 
-  /**
-   * preDestroy is called before the editor goes away, and gives the editor a
-   * chance to tell its documentStateObservers that the document is going away.
-   * @param aDestroyingFrames set to true when the frames being edited
-   * are being destroyed (so there is no need to modify any nsISelections,
-   * nor is it safe to do so)
-   */
-  void preDestroy(in boolean aDestroyingFrames);
-
   /** edit flags for this editor.  May be set at any time. */
   attribute unsigned long flags;
 
   /**
    * the MimeType of the document
    */
   attribute string contentsMIMEType;
 
@@ -294,23 +285,16 @@ interface nsIEditor  : nsISupports
     */
   void pasteTransferable(in nsITransferable aTransferable);
 
   /** Can we paste? True if the doc is modifiable, and we have
     * pasteable data in the clipboard.
     */
   boolean canPaste(in long aSelectionType);
 
-  /** Can we paste |aTransferable| or, if |aTransferable| is null, will a call
-    * to pasteTransferable later possibly succeed if given an instance of
-    * nsITransferable then? True if the doc is modifiable, and, if
-    * |aTransfeable| is non-null, we have pasteable data in |aTransfeable|.
-    */
-  boolean canPasteTransferable([optional] in nsITransferable aTransferable);
-
   /* ------------ Selection methods -------------- */
 
   /** sets the document selection to the entire contents of the document */
   void selectAll();
 
   /**
    * Collapses selection at start of the document.  If it's an HTML editor,
    * collapses selection at start of current editing host (<body> element if
--- a/ipc/chromium/src/chrome/common/ipc_channel_posix.cc
+++ b/ipc/chromium/src/chrome/common/ipc_channel_posix.cc
@@ -418,20 +418,37 @@ bool Channel::ChannelImpl::ProcessIncomi
     const int* fds;
     unsigned num_fds;
     unsigned fds_i = 0;  // the index of the first unused descriptor
 
     if (input_overflow_fds_.empty()) {
       fds = wire_fds;
       num_fds = num_wire_fds;
     } else {
-      const size_t prev_size = input_overflow_fds_.size();
-      input_overflow_fds_.resize(prev_size + num_wire_fds);
-      memcpy(&input_overflow_fds_[prev_size], wire_fds,
-             num_wire_fds * sizeof(int));
+      // This code may look like a no-op in the case where
+      // num_wire_fds == 0, but in fact:
+      //
+      // 1. wire_fds will be nullptr, so passing it to memcpy is
+      // undefined behavior according to the C standard, even though
+      // the memcpy length is 0.
+      //
+      // 2. prev_size will be an out-of-bounds index for
+      // input_overflow_fds_; this is undefined behavior according to
+      // the C++ standard, even though the element only has its
+      // pointer taken and isn't accessed (and the corresponding
+      // operation on a C array would be defined).
+      //
+      // UBSan makes #1 a fatal error, and assertions in libstdc++ do
+      // the same for #2 if enabled.
+      if (num_wire_fds > 0) {
+        const size_t prev_size = input_overflow_fds_.size();
+        input_overflow_fds_.resize(prev_size + num_wire_fds);
+        memcpy(&input_overflow_fds_[prev_size], wire_fds,
+               num_wire_fds * sizeof(int));
+      }
       fds = &input_overflow_fds_[0];
       num_fds = input_overflow_fds_.size();
     }
 
     // The data for the message we're currently reading consists of any data
     // stored in incoming_message_ followed by data in input_buf_ (followed by
     // other messages).
 
--- a/layout/base/nsLayoutUtils.cpp
+++ b/layout/base/nsLayoutUtils.cpp
@@ -253,35 +253,36 @@ MayHaveAnimationOfProperty(EffectSet* ef
   if (aProperty == eCSSProperty_opacity &&
       !effects->MayHaveOpacityAnimation()) {
     return false;
   }
 
   return true;
 }
 
-static bool
-MayHaveAnimationOfProperty(const nsIFrame* aFrame, nsCSSPropertyID aProperty)
+bool
+nsLayoutUtils::MayHaveAnimationOfProperty(const nsIFrame* aFrame,
+                                          nsCSSPropertyID aProperty)
 {
   switch (aProperty) {
     case eCSSProperty_transform:
       return aFrame->MayHaveTransformAnimation();
     case eCSSProperty_opacity:
       return aFrame->MayHaveOpacityAnimation();
     default:
       MOZ_ASSERT_UNREACHABLE("unexpected property");
       return false;
   }
 }
 
 bool
 nsLayoutUtils::HasAnimationOfProperty(EffectSet* aEffectSet,
                                       nsCSSPropertyID aProperty)
 {
-  if (!aEffectSet || !MayHaveAnimationOfProperty(aEffectSet, aProperty)) {
+  if (!aEffectSet || !::MayHaveAnimationOfProperty(aEffectSet, aProperty)) {
     return false;
   }
 
   return HasMatchingAnimations(aEffectSet,
     [&aProperty](KeyframeEffect& aEffect)
     {
       return (aEffect.IsInEffect() || aEffect.IsCurrent()) &&
              aEffect.HasAnimationOfProperty(aProperty);
@@ -307,30 +308,41 @@ nsLayoutUtils::HasAnimationOfProperty(co
 
 }
 
 bool
 nsLayoutUtils::HasEffectiveAnimation(const nsIFrame* aFrame,
                                      nsCSSPropertyID aProperty)
 {
   EffectSet* effects = EffectSet::GetEffectSet(aFrame);
-  if (!effects || !MayHaveAnimationOfProperty(effects, aProperty)) {
+  if (!effects || !::MayHaveAnimationOfProperty(effects, aProperty)) {
     return false;
   }
 
 
   return HasMatchingAnimations(effects,
     [&aProperty](KeyframeEffect& aEffect)
     {
       return (aEffect.IsInEffect() || aEffect.IsCurrent()) &&
              aEffect.HasEffectiveAnimationOfProperty(aProperty);
     }
   );
 }
 
+bool
+nsLayoutUtils::MayHaveEffectiveAnimation(const nsIFrame* aFrame,
+                                         nsCSSPropertyID aProperty)
+{
+  EffectSet* effects = EffectSet::GetEffectSet(aFrame);
+  if (!effects || !::MayHaveAnimationOfProperty(effects, aProperty)) {
+    return false;
+  }
+  return true;
+}
+
 static float
 GetSuitableScale(float aMaxScale, float aMinScale,
                  nscoord aVisibleDimension, nscoord aDisplayDimension)
 {
   float displayVisibleRatio = float(aDisplayDimension) /
                               float(aVisibleDimension);
   // We want to rasterize based on the largest scale used during the
   // transform animation, unless that would make us rasterize something
--- a/layout/base/nsLayoutUtils.h
+++ b/layout/base/nsLayoutUtils.h
@@ -2327,30 +2327,34 @@ public:
   static bool HasCurrentTransitions(const nsIFrame* aFrame);
 
   /**
    * Returns true if |aFrame| has an animation of |aProperty| regardless of
    * whether the property is overridden by !important rule.
    */
   static bool HasAnimationOfProperty(const nsIFrame* aFrame,
                                      nsCSSPropertyID aProperty);
+  static bool MayHaveAnimationOfProperty(const nsIFrame* aFrame,
+                                         nsCSSPropertyID aProperty);
 
   /**
    * Returns true if |aEffectSet| has an animation of |aProperty| regardless of
    * whether the property is overridden by !important rule.
    */
   static bool HasAnimationOfProperty(mozilla::EffectSet* aEffectSet,
                                      nsCSSPropertyID aProperty);
 
   /**
    * Returns true if |aFrame| has an animation of |aProperty| which is
    * not overridden by !important rules.
    */
   static bool HasEffectiveAnimation(const nsIFrame* aFrame,
                                     nsCSSPropertyID aProperty);
+  static bool MayHaveEffectiveAnimation(const nsIFrame* aFrame,
+                                        nsCSSPropertyID aProperty);
 
   /**
    * Checks if off-main-thread animations are enabled.
    */
   static bool AreAsyncAnimationsEnabled();
 
   /**
    * Checks if we should warn about animations that can't be async
--- a/layout/generic/nsFrame.cpp
+++ b/layout/generic/nsFrame.cpp
@@ -2949,19 +2949,22 @@ nsIFrame::BuildDisplayListForStackingCon
     aBuilder->EnterSVGEffectsContents(&hoistedScrollInfoItemsStorage);
   }
 
   // We build an opacity item if it's not going to be drawn by SVG content, or
   // SVG effects. SVG effects won't handle the opacity if we want an active
   // layer (for async animations), see
   // nsSVGIntegrationsUtils::PaintMaskAndClipPath or
   // nsSVGIntegrationsUtils::PaintFilter.
+  // Use MayNeedActiveLayer to decide, since we don't want to condition the wrapping
+  // display item on values that might change silently between paints (opacity activity
+  // can depend on the will-change budget).
   bool useOpacity = HasVisualOpacity(effectSet) &&
                     !nsSVGUtils::CanOptimizeOpacity(this) &&
-                    (!usingSVGEffects || nsDisplayOpacity::NeedsActiveLayer(aBuilder, this));
+                    (!usingSVGEffects || nsDisplayOpacity::MayNeedActiveLayer(this));
   bool useBlendMode = effects->mMixBlendMode != NS_STYLE_BLEND_NORMAL;
   bool useStickyPosition = disp->mPosition == NS_STYLE_POSITION_STICKY &&
     IsScrollFrameActive(aBuilder,
                         nsLayoutUtils::GetNearestScrollableFrame(GetParent(),
                         nsLayoutUtils::SCROLLABLE_SAME_DOC |
                         nsLayoutUtils::SCROLLABLE_INCLUDE_HIDDEN));
   bool useFixedPosition = disp->mPosition == NS_STYLE_POSITION_FIXED &&
     (nsLayoutUtils::IsFixedPosFrameInDisplayPort(this) || BuilderHasScrolledClip(aBuilder));
--- a/layout/generic/nsSubDocumentFrame.cpp
+++ b/layout/generic/nsSubDocumentFrame.cpp
@@ -457,16 +457,19 @@ nsSubDocumentFrame::BuildDisplayList(nsD
       aBuilder->RebuildAllItemsInCurrentSubtree();
       // Mark the old caret frame as invalid so that we remove the
       // old nsDisplayCaret. We don't mark the current frame as invalid
       // since we want the nsDisplaySubdocument to retain it's place
       // in the retained display list.
       if (mPreviousCaret) {
         aBuilder->MarkFrameModifiedDuringBuilding(mPreviousCaret);
       }
+      if (aBuilder->GetCaretFrame()) {
+        aBuilder->MarkFrameModifiedDuringBuilding(aBuilder->GetCaretFrame());
+      }
     }
     mPreviousCaret = aBuilder->GetCaretFrame();
   }
 
   nsDisplayList childItems;
 
   {
     DisplayListClipState::AutoSaveRestore nestedClipState(aBuilder);
--- a/layout/painting/ActiveLayerTracker.cpp
+++ b/layout/painting/ActiveLayerTracker.cpp
@@ -431,17 +431,21 @@ ActiveLayerTracker::IsStyleAnimated(nsDi
     }
     if (CheckScrollInducedActivity(layerActivity, activityIndex, aBuilder)) {
       return true;
     }
   }
   if (aProperty == eCSSProperty_transform && aFrame->Combines3DTransformWithAncestors()) {
     return IsStyleAnimated(aBuilder, aFrame->GetParent(), aProperty);
   }
-  return nsLayoutUtils::HasEffectiveAnimation(aFrame, aProperty);
+  if (aBuilder) {
+    return nsLayoutUtils::HasEffectiveAnimation(aFrame, aProperty);
+  } else {
+    return nsLayoutUtils::MayHaveEffectiveAnimation(aFrame, aProperty);
+  }
 }
 
 /* static */ bool
 ActiveLayerTracker::IsOffsetStyleAnimated(nsIFrame* aFrame)
 {
   LayerActivity* layerActivity = GetLayerActivity(aFrame);
   if (layerActivity) {
     if (layerActivity->mRestyleCounts[LayerActivity::ACTIVITY_LEFT] >= 2 ||
--- a/layout/painting/RetainedDisplayListBuilder.cpp
+++ b/layout/painting/RetainedDisplayListBuilder.cpp
@@ -330,18 +330,19 @@ public:
 
   bool HasMatchingItemInOldList(nsDisplayItem* aItem, OldListIndex* aOutIndex)
   {
     nsIFrame::DisplayItemArray* items = aItem->Frame()->GetProperty(nsIFrame::DisplayItems());
     // Look for an item that matches aItem's frame and per-frame-key, but isn't the same item.
     for (nsDisplayItem* i : *items) {
       if (i != aItem && i->Frame() == aItem->Frame() &&
           i->GetPerFrameKey() == aItem->GetPerFrameKey()) {
-        *aOutIndex = i->GetOldListIndex(mOldList, mOuterKey);
-        return true;
+        if (i->GetOldListIndex(mOldList, mOuterKey, aOutIndex)) {
+          return true;
+        }
       }
     }
     return false;
   }
 
   bool HasModifiedFrame(nsDisplayItem* aItem) {
     return AnyContentAncestorModified(aItem->FrameForInvalidation());
   }
--- a/layout/painting/nsDisplayList.cpp
+++ b/layout/painting/nsDisplayList.cpp
@@ -6521,16 +6521,22 @@ nsDisplayOpacity::NeedsActiveLayer(nsDis
       (ActiveLayerTracker::IsStyleAnimated(aBuilder, aFrame,
                                            eCSSProperty_opacity) &&
        !IsItemTooSmallForActiveLayer(aFrame))) {
     return true;
   }
   return false;
 }
 
+/* static */ bool
+nsDisplayOpacity::MayNeedActiveLayer(nsIFrame* aFrame)
+{
+  return ActiveLayerTracker::IsStyleMaybeAnimated(aFrame, eCSSProperty_opacity);
+}
+
 void
 nsDisplayOpacity::ApplyOpacity(nsDisplayListBuilder* aBuilder,
                              float aOpacity,
                              const DisplayItemClipChain* aClip)
 {
   NS_ASSERTION(CanApplyOpacity(), "ApplyOpacity should be allowed");
   mOpacity = mOpacity * aOpacity;
   IntersectClip(aBuilder, aClip, false);
--- a/layout/painting/nsDisplayList.h
+++ b/layout/painting/nsDisplayList.h
@@ -2858,24 +2858,26 @@ public:
   {
 #ifdef MOZ_DIAGNOSTIC_ASSERT_ENABLED
     mOldList = reinterpret_cast<uintptr_t>(aList);
     mOldListKey = aListKey;
     mOldNestingDepth = aNestingDepth;
 #endif
     mOldListIndex = aIndex;
   }
-  OldListIndex GetOldListIndex(nsDisplayList* aList, uint32_t aListKey)
-  {
-#ifdef MOZ_DIAGNOSTIC_ASSERT_ENABLED
+  bool GetOldListIndex(nsDisplayList* aList, uint32_t aListKey, OldListIndex* aOutIndex)
+  {
     if (mOldList != reinterpret_cast<uintptr_t>(aList)) {
+#ifdef MOZ_DIAGNOSTIC_ASSERT_ENABLED
       MOZ_CRASH_UNSAFE_PRINTF("Item found was in the wrong list! type %d (outer type was %d at depth %d, now is %d)", GetPerFrameKey(), mOldListKey, mOldNestingDepth, aListKey);
+#endif
+      return false;
     }
-#endif
-    return mOldListIndex;
+    *aOutIndex = mOldListIndex;
+    return true;
   }
 
   const nsRect& GetPaintRect() const {
     return mPaintRect;
   }
 
 protected:
   nsDisplayItem() = delete;
@@ -2905,24 +2907,24 @@ private:
   // of the item. Paint implementations can use this to limit their drawing.
   // Guaranteed to be contained in GetBounds().
   nsRect    mPaintRect;
 
 protected:
 
 #ifdef MOZ_DIAGNOSTIC_ASSERT_ENABLED
 public:
-  uintptr_t mOldList = 0;
   uint32_t mOldListKey = 0;
   uint32_t mOldNestingDepth = 0;
   bool mMergedItem = false;
   bool mPreProcessedItem = false;
 protected:
 #endif
   OldListIndex mOldListIndex;
+  uintptr_t mOldList = 0;
 
   bool      mForceNotVisible;
   bool      mDisableSubpixelAA;
   bool      mReusedItem;
   bool      mBackfaceHidden;
   bool      mPaintRectValid;
 #ifdef MOZ_DUMP_PAINTING
   // True if this frame has been painted.
@@ -5379,16 +5381,17 @@ public:
   virtual bool ShouldFlattenAway(nsDisplayListBuilder* aBuilder) override;
 
   /**
    * Returns true if ShouldFlattenAway() applied opacity to children.
    */
   bool OpacityAppliedToChildren() const { return mOpacityAppliedToChildren; }
 
   static bool NeedsActiveLayer(nsDisplayListBuilder* aBuilder, nsIFrame* aFrame);
+  static bool MayNeedActiveLayer(nsIFrame* aFrame);
   NS_DISPLAY_DECL_NAME("Opacity", TYPE_OPACITY)
   virtual void WriteDebugInfo(std::stringstream& aStream) override;
 
   bool CanUseAsyncAnimations(nsDisplayListBuilder* aBuilder) override;
 
   virtual bool CreateWebRenderCommands(mozilla::wr::DisplayListBuilder& aBuilder,
                                        mozilla::wr::IpcResourceUpdateQueue& aResources,
                                        const StackingContextHelper& aSc,
--- a/layout/svg/nsSVGUtils.cpp
+++ b/layout/svg/nsSVGUtils.cpp
@@ -1290,17 +1290,17 @@ nsSVGUtils::CanOptimizeOpacity(nsIFrame 
   if (type == LayoutFrameType::SVGImage) {
     return true;
   }
   const nsStyleSVG *style = aFrame->StyleSVG();
   if (style->HasMarker()) {
     return false;
   }
 
-  if (nsLayoutUtils::HasAnimationOfProperty(aFrame, eCSSProperty_opacity)) {
+  if (nsLayoutUtils::MayHaveAnimationOfProperty(aFrame, eCSSProperty_opacity)) {
     return false;
   }
 
   if (!style->HasFill() || !HasStroke(aFrame)) {
     return true;
   }
   return false;
 }
--- a/netwerk/dns/nsHostResolver.cpp
+++ b/netwerk/dns/nsHostResolver.cpp
@@ -1880,16 +1880,17 @@ nsHostResolver::Create(uint32_t maxCache
 
     *result = res;
     return rv;
 }
 
 void
 nsHostResolver::GetDNSCacheEntries(nsTArray<DNSCacheEntries> *args)
 {
+    MutexAutoLock lock(mLock);
     for (auto iter = mRecordDB.Iter(); !iter.Done(); iter.Next()) {
         // We don't pay attention to address literals, only resolved domains.
         // Also require a host.
         nsHostRecord* rec = iter.UserData();
         MOZ_ASSERT(rec, "rec should never be null here!");
         if (!rec || !rec->addr_info) {
             continue;
         }
--- a/security/sandbox/linux/SandboxFilter.cpp
+++ b/security/sandbox/linux/SandboxFilter.cpp
@@ -1048,16 +1048,24 @@ public:
 
     case __NR_mprotect:
     case __NR_brk:
     case __NR_madvise:
       // libc's realloc uses mremap (Bug 1286119); wasm does too (bug 1342385).
     case __NR_mremap:
       return Allow();
 
+      // Bug 1462640: Mesa libEGL uses mincore to test whether values
+      // are pointers, for reasons.
+    case __NR_mincore: {
+      Arg<size_t> length(1);
+      return If(length == getpagesize(), Allow())
+             .Else(SandboxPolicyCommon::EvaluateSyscall(sysno));
+    }
+
     case __NR_sigaltstack:
       return Allow();
 
 #ifdef __NR_set_thread_area
     case __NR_set_thread_area:
       return Allow();
 #endif
 
--- a/services/common/blocklist-clients.js
+++ b/services/common/blocklist-clients.js
@@ -6,18 +6,18 @@
 
 var EXPORTED_SYMBOLS = [
   "initialize",
 ];
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm", {});
 
-ChromeUtils.defineModuleGetter(this, "RemoteSettings",
-                               "resource://services-common/remote-settings.js");
+ChromeUtils.defineModuleGetter(this, "RemoteSettings", "resource://services-common/remote-settings.js");
+ChromeUtils.defineModuleGetter(this, "jexlFilterFunc", "resource://services-common/remote-settings.js");
 
 const PREF_BLOCKLIST_BUCKET                  = "services.blocklist.bucket";
 const PREF_BLOCKLIST_ONECRL_COLLECTION       = "services.blocklist.onecrl.collection";
 const PREF_BLOCKLIST_ONECRL_CHECKED_SECONDS  = "services.blocklist.onecrl.checked";
 const PREF_BLOCKLIST_ONECRL_SIGNER           = "services.blocklist.onecrl.signer";
 const PREF_BLOCKLIST_ADDONS_COLLECTION       = "services.blocklist.addons.collection";
 const PREF_BLOCKLIST_ADDONS_CHECKED_SECONDS  = "services.blocklist.addons.checked";
 const PREF_BLOCKLIST_ADDONS_SIGNER           = "services.blocklist.addons.signer";
@@ -127,27 +127,32 @@ async function updateJSONBlocklist(clien
   }
 }
 
 
 /**
  * This custom filter function is used to limit the entries returned
  * by `RemoteSettings("...").get()` depending on the target app information
  * defined on entries.
- *
- * When landing Bug 1451031, this function will have to check if the `entry`
- * has a JEXL attribute and rely on the JEXL filter function in priority.
- * The legacy target app mechanism will be kept in place for old entries.
  */
-async function targetAppFilter(entry, { appID, version: appVersion }) {
+async function targetAppFilter(entry, environment) {
+  // If the entry has JEXL filters, they should prevail.
+  // The legacy target app mechanism will be kept in place for old entries.
+  // See https://bugzilla.mozilla.org/show_bug.cgi?id=1463377
+  const { filters } = entry;
+  if (filters) {
+    return jexlFilterFunc(entry, environment);
+  }
+
   // Keep entries without target information.
   if (!("versionRange" in entry)) {
     return entry;
   }
 
+  const { appID, version: appVersion } = environment;
   const { versionRange } = entry;
 
   // Gfx blocklist has a specific versionRange object, which is not a list.
   if (!Array.isArray(versionRange)) {
     const { minVersion = "0", maxVersion = "*" } = versionRange;
     const matchesRange = (Services.vc.compare(appVersion, minVersion) >= 0 &&
                           Services.vc.compare(appVersion, maxVersion) <= 0);
     return matchesRange ? entry : null;
--- a/services/common/docs/RemoteSettings.rst
+++ b/services/common/docs/RemoteSettings.rst
@@ -98,16 +98,28 @@ For newly created user profiles, the lis
 It is possible to package a dump of the server records that will be loaded into the local database when no synchronization has happened yet. It will thus serve as the default dataset and also reduce the amount of data to be downloaded on the first synchronization.
 
 #. Place the JSON dump of the server records in the ``services/settings/dumps/main/`` folder
 #. Add the filename to the ``FINAL_TARGET_FILES`` list in ``services/settings/dumps/main/moz.build``
 
 Now, when ``RemoteSettings("some-key").get()`` is called from an empty profile, the ``some-key.json`` file is going to be loaded before the results are returned.
 
 
+Targets and A/B testing
+=======================
+
+In order to deliver settings to subsets of the population, you can set targets on entries (platform, language, channel, version range, preferences values, samples, etc.) when editing records on the server.
+
+From the client API standpoint, this is completely transparent: the ``.get()`` method — as well as the event data — will always filter the entries on which the target matches.
+
+.. note::
+
+    The remote settings targets follow the same approach as the :ref:`Normandy recipe client <components/normandy>` (ie. JEXL filters),
+
+
 Uptake Telemetry
 ================
 
 Some :ref:`uptake telemetry <telemetry/collection/uptake>` is collected in order to monitor how remote settings are propagated.
 
 It is submitted to a single :ref:`keyed histogram <histogram-type-keyed>` whose id is ``UPTAKE_REMOTE_CONTENT_RESULT_1`` and the keys are prefixed with ``main/`` (eg. ``main/a-key`` in the above example).
 
 
--- a/services/common/remote-settings.js
+++ b/services/common/remote-settings.js
@@ -1,31 +1,35 @@
 /* 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";
 
-var EXPORTED_SYMBOLS = ["RemoteSettings"];
+var EXPORTED_SYMBOLS = [
+  "RemoteSettings",
+  "jexlFilterFunc"
+];
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm", {});
 Cu.importGlobalProperties(["fetch"]);
 
 ChromeUtils.defineModuleGetter(this, "Kinto",
                                "resource://services-common/kinto-offline-client.js");
 ChromeUtils.defineModuleGetter(this, "KintoHttpClient",
                                "resource://services-common/kinto-http-client.js");
 ChromeUtils.defineModuleGetter(this, "CanonicalJSON",
                                "resource://gre/modules/CanonicalJSON.jsm");
 ChromeUtils.defineModuleGetter(this, "UptakeTelemetry",
                                "resource://services-common/uptake-telemetry.js");
 ChromeUtils.defineModuleGetter(this, "ClientEnvironmentBase",
                                "resource://gre/modules/components-utils/ClientEnvironment.jsm");
+ChromeUtils.defineModuleGetter(this, "FilterExpressions", "resource://normandy/lib/FilterExpressions.jsm");
 
 const PREF_SETTINGS_SERVER             = "services.settings.server";
 const PREF_SETTINGS_DEFAULT_BUCKET     = "services.settings.default_bucket";
 const PREF_SETTINGS_DEFAULT_SIGNER     = "services.settings.default_signer";
 const PREF_SETTINGS_VERIFY_SIGNATURE   = "services.settings.verify_signature";
 const PREF_SETTINGS_SERVER_BACKOFF     = "services.settings.server.backoff";
 const PREF_SETTINGS_CHANGES_PATH       = "services.settings.changes.path";
 const PREF_SETTINGS_LAST_UPDATE        = "services.settings.last_update_seconds";
@@ -57,16 +61,37 @@ function cacheProxy(target) {
 class ClientEnvironment extends ClientEnvironmentBase {
   static get appID() {
     // eg. Firefox is "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}".
     Services.appinfo.QueryInterface(Ci.nsIXULAppInfo);
     return Services.appinfo.ID;
   }
 }
 
+/**
+ * Default entry filtering function, in charge of excluding remote settings entries
+ * where the JEXL expression evaluates into a falsy value.
+ */
+async function jexlFilterFunc(entry, environment) {
+  const { filters } = entry;
+  if (!filters) {
+    return entry;
+  }
+  let result;
+  try {
+    const context = {
+      environment
+    };
+    result = await FilterExpressions.eval(filters, context);
+  } catch (e) {
+    Cu.reportError(e);
+  }
+  return result ? entry : null;
+}
+
 
 function mergeChanges(collection, localRecords, changes) {
   const records = {};
   // Local records by id.
   localRecords.forEach((record) => records[record.id] = collection.cleanLocalFields(record));
   // All existing records are replaced by the version from the server.
   changes.forEach((record) => records[record.id] = record);
 
@@ -145,17 +170,17 @@ async function fetchLatestChanges(url, l
   }
 
   return {changes, currentEtag, serverTimeMillis, backoffSeconds};
 }
 
 
 class RemoteSettingsClient {
 
-  constructor(collectionName, { bucketName, signerName, filterFunc, lastCheckTimePref }) {
+  constructor(collectionName, { bucketName, signerName, filterFunc = jexlFilterFunc, lastCheckTimePref }) {
     this.collectionName = collectionName;
     this.bucketName = bucketName;
     this.signerName = signerName;
     this.filterFunc = filterFunc;
     this._lastCheckTimePref = lastCheckTimePref;
 
     this._callbacks = new Map();
     this._callbacks.set("sync", []);
--- a/services/common/tests/unit/test_blocklist_clients.js
+++ b/services/common/tests/unit/test_blocklist_clients.js
@@ -240,16 +240,67 @@ add_task(async function test_sync_event_
     // and the event current data should differ.
     const collection = await client.openCollection();
     const { data: internalData } = await collection.list();
     ok(internalData.length > current.length, `event current data for ${client.collectionName}`);
   }
 });
 add_task(clear_state);
 
+add_task(async function test_entries_are_filtered_when_jexl_filters_is_present() {
+  if (IS_ANDROID) {
+    // JEXL filters are not supported on Android.
+    // See https://bugzilla.mozilla.org/show_bug.cgi?id=1463502
+    return;
+  }
+
+  const records = [{
+      willMatch: true,
+    }, {
+      willMatch: true,
+      filters: null
+    }, {
+      willMatch: true,
+      filters: "1 == 1"
+    }, {
+      willMatch: false,
+      filters: "1 == 2"
+    }, {
+      willMatch: true,
+      filters: "1 == 1",
+      versionRange: [{
+        targetApplication: [{
+          guid: "some-guid"
+        }],
+      }]
+    }, {
+      willMatch: false,  // jexl prevails over versionRange.
+      filters: "1 == 2",
+      versionRange: [{
+        targetApplication: [{
+          guid: "xpcshell@tests.mozilla.org",
+          minVersion: "0",
+          maxVersion: "*",
+        }],
+      }]
+    }
+  ];
+  for (let {client} of gBlocklistClients) {
+    const collection = await client.openCollection();
+    for (const record of records) {
+      await collection.create(record);
+    }
+    await collection.db.saveLastModified(42); // Prevent from loading JSON dump.
+    const list = await client.get();
+    equal(list.length, 4);
+    ok(list.every(e => e.willMatch));
+  }
+});
+add_task(clear_state);
+
 
 // get a response for a given request from sample data
 function getSampleResponse(req, port) {
   const responses = {
     "OPTIONS": {
       "sampleHeaders": [
         "Access-Control-Allow-Headers: Content-Length,Expires,Backoff,Retry-After,Last-Modified,Total-Records,ETag,Pragma,Cache-Control,authorization,content-type,if-none-match,Alert,Next-Page",
         "Access-Control-Allow-Methods: GET,HEAD,OPTIONS,POST,DELETE,OPTIONS",
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_remote_settings_jexl_filters.js
@@ -0,0 +1,171 @@
+const { RemoteSettings } = ChromeUtils.import("resource://services-common/remote-settings.js", {});
+
+let client;
+
+async function createRecords(records) {
+  const collection = await client.openCollection();
+  await collection.clear();
+  for (const record of records) {
+    await collection.create(record);
+  }
+  await collection.db.saveLastModified(42); // Prevent from loading JSON dump.
+}
+
+
+function run_test() {
+  client = RemoteSettings("some-key");
+
+  run_next_test();
+}
+
+add_task(async function test_returns_all_without_target() {
+  await createRecords([{
+    passwordSelector: "#pass-signin"
+  }, {
+    filters: null,
+  }, {
+    filters: "",
+  }]);
+
+  const list = await client.get();
+  equal(list.length, 3);
+});
+
+add_task(async function test_filters_can_be_disabled() {
+  const c = RemoteSettings("no-jexl", { filterFunc: null });
+  const collection = await c.openCollection();
+  await collection.create({
+    filters: "1 == 2"
+  });
+  await collection.db.saveLastModified(42); // Prevent from loading JSON dump.
+
+  const list = await c.get();
+  equal(list.length, 1);
+});
+
+add_task(async function test_returns_entries_where_jexl_is_true() {
+  await createRecords([{
+    willMatch: true,
+    filters: "1"
+  }, {
+    willMatch: true,
+    filters: "[42]"
+  }, {
+    willMatch: true,
+    filters: "1 == 2 || 1 == 1"
+  }, {
+    willMatch: true,
+    filters: 'environment.appID == "xpcshell@tests.mozilla.org"'
+  }, {
+    willMatch: false,
+    filters: "environment.version == undefined"
+  }, {
+    willMatch: true,
+    filters: "environment.unknown == undefined"
+  }, {
+    willMatch: false,
+    filters: "1 == 2"
+  }]);
+
+  const list = await client.get();
+  equal(list.length, 5);
+  ok(list.every(e => e.willMatch));
+});
+
+add_task(async function test_ignores_entries_where_jexl_is_invalid() {
+  await createRecords([{
+    filters: "true === true"  // JavaScript Error: "Invalid expression token: ="
+  }, {
+    filters: "Objects.keys({}) == []" // Token ( (openParen) unexpected in expression
+  }]);
+
+  const list = await client.get();
+  equal(list.length, 0);
+});
+
+add_task(async function test_support_of_date_filters() {
+  await createRecords([{
+    willMatch: true,
+    filters: '"1982-05-08"|date < "2016-03-22"|date'
+  }, {
+    willMatch: false,
+    filters: '"2000-01-01"|date < "1970-01-01"|date'
+  }]);
+
+  const list = await client.get();
+  equal(list.length, 1);
+  ok(list.every(e => e.willMatch));
+});
+
+add_task(async function test_support_of_preferences_filters() {
+  await createRecords([{
+    willMatch: true,
+    filters: '"services.settings.last_etag"|preferenceValue == 42'
+  }, {
+    willMatch: true,
+    filters: '"services.settings.changes.path"|preferenceExists == true'
+  }, {
+    willMatch: true,
+    filters: '"services.settings.changes.path"|preferenceIsUserSet == false'
+  }, {
+    willMatch: true,
+    filters: '"services.settings.last_etag"|preferenceIsUserSet == true'
+  }]);
+
+  // Set a pref for the user.
+  Services.prefs.setIntPref("services.settings.last_etag", 42);
+
+  const list = await client.get();
+  equal(list.length, 4);
+  ok(list.every(e => e.willMatch));
+});
+
+add_task(async function test_support_of_intersect_operator() {
+  await createRecords([{
+    willMatch: true,
+    filters: '{foo: 1, bar: 2}|keys intersect ["foo"]'
+  }, {
+    willMatch: true,
+    filters: '(["a", "b"] intersect ["a", 1, 4]) == "a"'
+  }, {
+    willMatch: false,
+    filters: '(["a", "b"] intersect [3, 1, 4]) == "c"'
+  }, {
+    willMatch: true,
+    filters: `
+      [1, 2, 3]
+        intersect
+      [3, 4, 5]
+    `
+  }]);
+
+  const list = await client.get();
+  equal(list.length, 3);
+  ok(list.every(e => e.willMatch));
+});
+
+add_task(async function test_support_of_samples() {
+  await createRecords([{
+    willMatch: true,
+    filters: '"always-true"|stableSample(1)'
+  }, {
+    willMatch: false,
+    filters: '"always-false"|stableSample(0)'
+  }, {
+    willMatch: true,
+    filters: '"turns-to-true-0"|stableSample(0.5)'
+  }, {
+    willMatch: false,
+    filters: '"turns-to-false-1"|stableSample(0.5)'
+  }, {
+    willMatch: true,
+    filters: '"turns-to-true-0"|bucketSample(0, 50, 100)'
+  }, {
+    willMatch: false,
+    filters: '"turns-to-false-1"|bucketSample(0, 50, 100)'
+  }]);
+
+  const list = await client.get();
+  equal(list.length, 3);
+  ok(list.every(e => e.willMatch));
+});
--- a/services/common/tests/unit/test_restrequest.js
+++ b/services/common/tests/unit/test_restrequest.js
@@ -643,17 +643,17 @@ add_task(async function test_abort() {
 
   // Aborting an already aborted request is pointless and will throw.
   do_check_throws(function() {
     request.abort();
   });
 
   Assert.equal(request.status, request.ABORTED);
 
-  await Assert.rejects(responsePromise);
+  await Assert.rejects(responsePromise, /NS_BINDING_ABORTED/);
 
   await promiseStopServer(server);
 });
 
 /**
  * A non-zero 'timeout' property specifies the amount of seconds to wait after
  * channel activity until the request is automatically canceled.
  */
--- a/services/common/tests/unit/xpcshell.ini
+++ b/services/common/tests/unit/xpcshell.ini
@@ -17,16 +17,19 @@ tags = blocklist
 [test_blocklist_targetapp_filter.js]
 tags = blocklist
 [test_blocklist_pinning.js]
 tags = blocklist
 [test_remote_settings.js]
 tags = remote-settings blocklist
 [test_remote_settings_poll.js]
 tags = remote-settings blocklist
+[test_remote_settings_jexl_filters.js]
+skip-if = os == "android"
+tags = remote-settings
 
 [test_kinto.js]
 tags = blocklist
 [test_blocklist_signatures.js]
 tags = remote-settings blocklist
 [test_storage_adapter.js]
 tags = remote-settingsblocklist
 [test_storage_adapter_shutdown.js]
--- a/services/fxaccounts/tests/xpcshell/test_accounts.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -282,31 +282,34 @@ add_task(async function test_update_acco
                "new field value was saved");
 
   // but we should fail attempting to change the uid.
   newCreds = {
     email: credentials.email,
     uid: "another_uid",
     assertion: "new_assertion",
   };
-  await Assert.rejects(account.updateUserAccountData(newCreds));
+  await Assert.rejects(account.updateUserAccountData(newCreds),
+    /The specified credentials aren't for the current user/);
 
   // should fail without the uid.
   newCreds = {
     assertion: "new_assertion",
   };
-  await Assert.rejects(account.updateUserAccountData(newCreds));
+  await Assert.rejects(account.updateUserAccountData(newCreds),
+    /The specified credentials aren't for the current user/);
 
   // and should fail with a field name that's not known by storage.
   newCreds = {
     email: credentials.email,
     uid: "another_uid",
     foo: "bar",
   };
-  await Assert.rejects(account.updateUserAccountData(newCreds));
+  await Assert.rejects(account.updateUserAccountData(newCreds),
+    /The specified credentials aren't for the current user/);
 });
 
 add_task(async function test_getCertificateOffline() {
   _("getCertificateOffline()");
   let fxa = MakeFxAccounts();
   let credentials = {
     email: "foo@example.com",
     uid: "1234@lcip.org",
--- a/services/fxaccounts/tests/xpcshell/test_accounts_config.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_config.js
@@ -18,17 +18,17 @@ add_task(async function test_non_https_r
   Services.prefs.clearUserPref("identity.fxaccounts.remote.root");
   Services.prefs.clearUserPref("identity.fxaccounts.allowHttp");
 });
 
 add_task(async function test_non_https_remote_server_uri() {
   Services.prefs.setCharPref(
     "identity.fxaccounts.remote.root",
     "http://example.com/");
-  Assert.rejects(FxAccounts.config.promiseSignUpURI(), null, "Firefox Accounts server must use HTTPS");
+  await Assert.rejects(FxAccounts.config.promiseSignUpURI(), /Firefox Accounts server must use HTTPS/);
   Services.prefs.clearUserPref("identity.fxaccounts.remote.root");
 });
 
 function createFakeOldPrefs() {
   const baseURL = "https://example.com/myfxa/";
   let createPref = (pref, extraPath) => {
     Services.prefs.setCharPref(pref, `${baseURL}${extraPath}?service=sync&context=fx_desktop_v3`);
   };
--- a/services/fxaccounts/tests/xpcshell/test_storage_manager.js
+++ b/services/fxaccounts/tests/xpcshell/test_storage_manager.js
@@ -87,17 +87,17 @@ function add_storage_task(testFunction) 
 
 // initialized without account data and there's nothing to read. Not logged in.
 add_storage_task(async function checkInitializedEmpty(sm) {
   if (sm.secureStorage) {
     sm.secureStorage = new MockedSecureStorage(null);
   }
   await sm.initialize();
   Assert.strictEqual((await sm.getAccountData()), null);
-  Assert.rejects(sm.updateAccountData({kXCS: "kXCS"}), "No user is logged in");
+  await Assert.rejects(sm.updateAccountData({kXCS: "kXCS"}), /No user is logged in/);
 });
 
 // Initialized with account data (ie, simulating a new user being logged in).
 // Should reflect the initial data and be written to storage.
 add_storage_task(async function checkNewUser(sm) {
   let initialAccountData = {
     uid: "uid",
     email: "someone@somewhere.com",
@@ -180,23 +180,24 @@ add_storage_task(async function checkEve
   } else {
     Assert.equal(sm.plainStorage.data.accountData.kExtKbHash, "kExtKbHash");
     Assert.equal(sm.plainStorage.data.accountData.kExtSync, "kExtSync");
     Assert.equal(sm.plainStorage.data.accountData.kXCS, "kXCS");
     Assert.equal(sm.plainStorage.data.accountData.kSync, "kSync");
   }
 });
 
-add_storage_task(function checkInvalidUpdates(sm) {
+add_storage_task(async function checkInvalidUpdates(sm) {
   sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"});
   if (sm.secureStorage) {
     sm.secureStorage = new MockedSecureStorage(null);
   }
-  Assert.rejects(sm.updateAccountData({uid: "another"}), "Can't change");
-  Assert.rejects(sm.updateAccountData({email: "someoneelse"}), "Can't change");
+  await sm.initialize();
+
+  await Assert.rejects(sm.updateAccountData({uid: "another"}), /Can't change uid/);
 });
 
 add_storage_task(async function checkNullUpdatesRemovedUnlocked(sm) {
   if (sm.secureStorage) {
     sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"});
     sm.secureStorage = new MockedSecureStorage({kSync: "kSync", kXCS: "kXCS", kExtSync: "kExtSync",
                                                 kExtKbHash: "kExtKbHash"});
   } else {
--- a/services/sync/modules/telemetry.js
+++ b/services/sync/modules/telemetry.js
@@ -21,16 +21,18 @@ let constants = {};
 ChromeUtils.import("resource://services-sync/constants.js", constants);
 
 ChromeUtils.defineModuleGetter(this, "TelemetryController",
                               "resource://gre/modules/TelemetryController.jsm");
 ChromeUtils.defineModuleGetter(this, "TelemetryUtils",
                                "resource://gre/modules/TelemetryUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "TelemetryEnvironment",
                                "resource://gre/modules/TelemetryEnvironment.jsm");
+ChromeUtils.defineModuleGetter(this, "ObjectUtils",
+                               "resource://gre/modules/ObjectUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "OS",
                                "resource://gre/modules/osfile.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "Telemetry",
                                    "@mozilla.org/base/telemetry;1",
                                    "nsITelemetry");
 
 const log = Log.repository.getLogger("Sync.Telemetry");
@@ -81,16 +83,37 @@ function tryGetMonotonicTimestamp() {
 function timeDeltaFrom(monotonicStartTime) {
   let now = tryGetMonotonicTimestamp();
   if (monotonicStartTime !== -1 && now !== -1) {
     return Math.round(now - monotonicStartTime);
   }
   return -1;
 }
 
+// Converts extra integer fields to strings, rounds floats to three
+// decimal places (nanosecond precision for timings), and removes profile
+// directory paths and URLs from potential error messages.
+function normalizeExtraTelemetryFields(extra) {
+  let result = {};
+  for (let key in extra) {
+    let value = extra[key];
+    let type = typeof value;
+    if (type == "string") {
+      result[key] = cleanErrorMessage(value);
+    } else if (type == "number") {
+      result[key] = Number.isInteger(value) ? value.toString(10) :
+                    value.toFixed(3);
+    } else if (type != "undefined") {
+      throw new TypeError(`Invalid type ${
+        type} for extra telemetry field ${key}`);
+    }
+  }
+  return ObjectUtils.isEmpty(result) ? undefined : result;
+}
+
 // This function validates the payload of a telemetry "event" - this can be
 // removed once there are APIs available for the telemetry modules to collect
 // these events (bug 1329530) - but for now we simulate that planned API as
 // best we can.
 function validateTelemetryEvent(eventDetails) {
   let { object, method, value, extra } = eventDetails;
   // Do do basic validation of the params - everything except "extra" must
   // be a string. method and object are required.
@@ -572,23 +595,28 @@ class SyncTelemetryImpl {
   }
 
   _recordEvent(eventDetails) {
     if (this.events.length >= this.maxEventsCount) {
       log.warn("discarding event - already queued our maximum", eventDetails);
       return;
     }
 
+    let { object, method, value, extra } = eventDetails;
+    if (extra) {
+      extra = normalizeExtraTelemetryFields(extra);
+      eventDetails = { object, method, value, extra };
+    }
+
     if (!validateTelemetryEvent(eventDetails)) {
       // we've already logged what the problem is...
       return;
     }
     log.debug("recording event", eventDetails);
 
-    let { object, method, value, extra } = eventDetails;
     if (extra && Resource.serverTime && !extra.serverTime) {
       extra.serverTime = String(Resource.serverTime);
     }
     let category = "sync";
     let ts = Math.floor(tryGetMonotonicTimestamp());
 
     // An event record is a simple array with at least 4 items.
     let event = [ts, category, method, object];
--- a/services/sync/tests/unit/test_bookmark_duping.js
+++ b/services/sync/tests/unit/test_bookmark_duping.js
@@ -69,17 +69,18 @@ function getServerRecord(collection, id)
   return collection.cleartext(id);
 }
 
 async function promiseNoLocalItem(guid) {
   // Check there's no item with the specified guid.
   let got = await bms.fetch({ guid });
   ok(!got, `No record remains with GUID ${guid}`);
   // and while we are here ensure the places cache doesn't still have it.
-  await Assert.rejects(PlacesUtils.promiseItemId(guid));
+  await Assert.rejects(PlacesUtils.promiseItemId(guid),
+    /no item found for the given GUID/);
 }
 
 async function validate(collection, expectedFailures = []) {
   let validator = new BookmarkValidator();
   let records = collection.payloads();
 
   let { problemData: problems } = await validator.inspectServerRecords(records);
   // all non-zero problems.
--- a/services/sync/tests/unit/test_browserid_identity.js
+++ b/services/sync/tests/unit/test_browserid_identity.js
@@ -111,17 +111,17 @@ add_task(async function test_initialiali
         accountStatusCalled = true;
         return Promise.resolve(false);
       }
     };
 
     let mockFxAClient = new AuthErrorMockFxAClient();
     browseridManager._fxaService.internal._fxAccountsClient = mockFxAClient;
 
-    await Assert.rejects(browseridManager._ensureValidToken(),
+    await Assert.rejects(browseridManager._ensureValidToken(), AuthenticationError,
                          "should reject due to an auth error");
 
     Assert.ok(signCertificateCalled);
     Assert.ok(accountStatusCalled);
     Assert.ok(!browseridManager._token);
     Assert.ok(!browseridManager._hasValidToken());
     Assert.deepEqual(getLoginTelemetryScalar(), {REJECTED: 1});
 });
@@ -251,17 +251,18 @@ add_task(async function test_ensureLogge
   await globalBrowseridManager._ensureValidToken();
   Assert.equal(Status.login, LOGIN_SUCCEEDED, "original initialize worked");
   Assert.ok(globalBrowseridManager._token);
 
   // arrange for no logged in user.
   let fxa = globalBrowseridManager._fxaService;
   let signedInUser = fxa.internal.currentAccountState.storageManager.accountData;
   fxa.internal.currentAccountState.storageManager.accountData = null;
-  await Assert.rejects(globalBrowseridManager._ensureValidToken(true), "expecting rejection due to no user");
+  await Assert.rejects(globalBrowseridManager._ensureValidToken(true),
+    /Can't possibly get keys; User is not signed in/, "expecting rejection due to no user");
   // Restore the logged in user to what it was.
   fxa.internal.currentAccountState.storageManager.accountData = signedInUser;
   Status.login = LOGIN_FAILED_LOGIN_REJECTED;
   await globalBrowseridManager._ensureValidToken();
   Assert.equal(Status.login, LOGIN_SUCCEEDED, "final ensureLoggedIn worked");
 });
 
 add_task(async function test_tokenExpiration() {
@@ -297,31 +298,33 @@ add_task(async function test_getTokenErr
   initializeIdentityWithTokenServerResponse({
     status: 401,
     headers: {"content-type": "application/json"},
     body: JSON.stringify({}),
   });
   let browseridManager = Service.identity;
 
   await Assert.rejects(browseridManager._ensureValidToken(),
+                       AuthenticationError,
                        "should reject due to 401");
   Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, "login was rejected");
 
   // XXX - other interesting responses to return?
 
   // And for good measure, some totally "unexpected" errors - we generally
   // assume these problems are going to magically go away at some point.
   _("Arrange for an empty body with a 200 response - should reflect a network error.");
   initializeIdentityWithTokenServerResponse({
     status: 200,
     headers: [],
     body: "",
   });
   browseridManager = Service.identity;
   await Assert.rejects(browseridManager._ensureValidToken(),
+                       TokenServerClientServerError,
                        "should reject due to non-JSON response");
   Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login state is LOGIN_FAILED_NETWORK_ERROR");
 });
 
 add_task(async function test_refreshCertificateOn401() {
   _("BrowserIDManager refreshes the FXA certificate after a 401.");
   var identityConfig = makeIdentityConfig();
   var browseridManager = new BrowserIDManager();
@@ -396,16 +399,17 @@ add_task(async function test_getTokenErr
     status: 503,
     headers: {"content-type": "application/json",
               "retry-after": "100"},
     body: JSON.stringify({}),
   });
   let browseridManager = Service.identity;
 
   await Assert.rejects(browseridManager._ensureValidToken(),
+                       TokenServerClientServerError,
                        "should reject due to 503");
 
   // The observer should have fired - check it got the value in the response.
   Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login was rejected");
   // Sync will have the value in ms with some slop - so check it is at least that.
   Assert.ok(Status.backoffInterval >= 100000);
 
   _("Arrange for a 200 with an X-Backoff header.");
@@ -414,16 +418,17 @@ add_task(async function test_getTokenErr
     status: 503,
     headers: {"content-type": "application/json",
               "x-backoff": "200"},
     body: JSON.stringify({}),
   });
   browseridManager = Service.identity;
 
   await Assert.rejects(browseridManager._ensureValidToken(),
+                       TokenServerClientServerError,
                        "should reject due to no token in response");
 
   // The observer should have fired - check it got the value in the response.
   Assert.ok(Status.backoffInterval >= 200000);
 });
 
 add_task(async function test_getKeysErrorWithBackoff() {
   _("Auth server (via hawk) sends an observer notification on backoff headers.");
@@ -448,16 +453,17 @@ add_task(async function test_getKeysErro
       headers: {"content-type": "application/json",
                 "x-backoff": "100"},
       body: "{}",
     };
   });
 
   let browseridManager = Service.identity;
   await Assert.rejects(browseridManager._ensureValidToken(),
+                       TokenServerClientServerError,
                        "should reject due to 503");
 
   // The observer should have fired - check it got the value in the response.
   Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login was rejected");
   // Sync will have the value in ms with some slop - so check it is at least that.
   Assert.ok(Status.backoffInterval >= 100000);
 });
 
@@ -484,16 +490,17 @@ add_task(async function test_getKeysErro
       headers: {"content-type": "application/json",
                 "retry-after": "100"},
       body: "{}",
     };
   });
 
   let browseridManager = Service.identity;
   await Assert.rejects(browseridManager._ensureValidToken(),
+                       TokenServerClientServerError,
                        "should reject due to 503");
 
   // The observer should have fired - check it got the value in the response.
   Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login was rejected");
   // Sync will have the value in ms with some slop - so check it is at least that.
   Assert.ok(Status.backoffInterval >= 100000);
 });
 
@@ -726,16 +733,18 @@ async function initializeIdentityWithHAW
       return new AccountState(storageManager);
     },
   };
   let fxa = new FxAccounts(internal);
 
   globalBrowseridManager._fxaService = fxa;
   globalBrowseridManager._signedInUser = await fxa.getSignedInUser();
   await Assert.rejects(globalBrowseridManager._ensureValidToken(true),
+                       // TODO: Ideally this should have a specific check for an error.
+                       () => true,
                        "expecting rejection due to hawk error");
 }
 
 
 function getTimestamp(hawkAuthHeader) {
   return parseInt(/ts="(\d+)"/.exec(hawkAuthHeader)[1], 10) * SECOND_MS;
 }
 
--- a/services/sync/tests/unit/test_fxa_service_cluster.js
+++ b/services/sync/tests/unit/test_fxa_service_cluster.js
@@ -10,17 +10,18 @@ add_task(async function test_findCluster
 
   _("_findCluster() throws on 500 errors.");
   initializeIdentityWithTokenServerResponse({
     status: 500,
     headers: [],
     body: "",
   });
 
-  await Assert.rejects(Service.identity._findCluster());
+  await Assert.rejects(Service.identity._findCluster(),
+    /TokenServerClientServerError/);
 
   _("_findCluster() returns null on authentication errors.");
   initializeIdentityWithTokenServerResponse({
     status: 401,
     headers: {"content-type": "application/json"},
     body: "{}",
   });
 
--- a/services/sync/tests/unit/test_service_cluster.js
+++ b/services/sync/tests/unit/test_service_cluster.js
@@ -11,17 +11,18 @@ add_task(async function test_findCluster
   try {
     let whenReadyToAuthenticate = PromiseUtils.defer();
     Service.identity.whenReadyToAuthenticate = whenReadyToAuthenticate;
     whenReadyToAuthenticate.resolve(true);
 
     Service.identity._ensureValidToken = () => Promise.reject(new Error("Connection refused"));
 
     _("_findCluster() throws on network errors (e.g. connection refused).");
-    await Assert.rejects(Service.identity._findCluster());
+    await Assert.rejects(Service.identity._findCluster(),
+      /Connection refused/);
 
     Service.identity._ensureValidToken = () => Promise.resolve({ endpoint: "http://weave.user.node" });
 
     _("_findCluster() returns the user's cluster node");
     let cluster = await Service.identity._findCluster();
     Assert.equal(cluster, "http://weave.user.node/");
 
   } finally {
--- a/servo/components/style/properties/properties.mako.rs
+++ b/servo/components/style/properties/properties.mako.rs
@@ -3194,16 +3194,17 @@ impl<'a> StyleBuilder<'a> {
                 % elif product == "gecko" and property.ident in props_need_device:
                 self.device,
                 % endif
             );
     }
     % endif
     % endif
     % endfor
+    <% del property %>
 
     /// Inherits style from the parent element, accounting for the default
     /// computed values that need to be provided as well.
     pub fn for_inheritance(
         device: &'a Device,
         parent: Option<<&'a ComputedValues>,
         pseudo: Option<<&'a PseudoElement>,
     ) -> Self {
@@ -3251,25 +3252,25 @@ impl<'a> StyleBuilder<'a> {
     % for style_struct in data.active_style_structs():
         /// Gets an immutable view of the current `${style_struct.name}` style.
         pub fn get_${style_struct.name_lower}(&self) -> &style_structs::${style_struct.name} {
             &self.${style_struct.ident}
         }
 
         /// Gets a mutable view of the current `${style_struct.name}` style.
         pub fn mutate_${style_struct.name_lower}(&mut self) -> &mut style_structs::${style_struct.name} {
-            % if not property.style_struct.inherited:
+            % if not style_struct.inherited:
             self.modified_reset = true;
             % endif
             self.${style_struct.ident}.mutate()
         }
 
         /// Gets a mutable view of the current `${style_struct.name}` style.
         pub fn take_${style_struct.name_lower}(&mut self) -> UniqueArc<style_structs::${style_struct.name}> {
-            % if not property.style_struct.inherited:
+            % if not style_struct.inherited:
             self.modified_reset = true;
             % endif
             self.${style_struct.ident}.take()
         }
 
         /// Gets a mutable view of the current `${style_struct.name}` style.
         pub fn put_${style_struct.name_lower}(&mut self, s: UniqueArc<style_structs::${style_struct.name}>) {
             self.${style_struct.ident}.put(s)
@@ -3283,16 +3284,17 @@ impl<'a> StyleBuilder<'a> {
         }
 
         /// Reset the current `${style_struct.name}` style to its default value.
         pub fn reset_${style_struct.name_lower}_struct(&mut self) {
             self.${style_struct.ident} =
                 StyleStructRef::Borrowed(self.reset_style.${style_struct.name_lower}_arc());
         }
     % endfor
+    <% del style_struct %>
 
     /// Returns whether this computed style represents a floated object.
     pub fn floated(&self) -> bool {
         self.get_box().clone_float() != longhands::float::computed_value::T::None
     }
 
     /// Returns whether this computed style represents an out of flow-positioned
     /// object.
--- a/taskcluster/ci/test/talos.yml
+++ b/taskcluster/ci/test/talos.yml
@@ -347,20 +347,17 @@ talos-speedometer-profiling:
         extra-options:
             - --suite=speedometer
             - --geckoProfile
 
 talos-svgr:
     description: "Talos svgr"
     try-name: svgr
     treeherder-symbol: T(s)
-    run-on-projects:
-        by-test-platform:
-            windows10-64-qr/.*: []  # bug 1451305
-            default: ['mozilla-beta', 'mozilla-central', 'mozilla-inbound', 'autoland', 'try']
+    run-on-projects: ['mozilla-beta', 'mozilla-central', 'mozilla-inbound', 'autoland', 'try']
     max-run-time: 1800
     mozharness:
         extra-options:
             - --suite=svgr
 
 talos-svgr-profiling:
     description: "Talos profiling svgr"
     try-name: svgr-profiling
--- a/testing/mozharness/scripts/desktop_partner_repacks.py
+++ b/testing/mozharness/scripts/desktop_partner_repacks.py
@@ -165,16 +165,17 @@ class DesktopPartnerRepacks(ReleaseMixin
             repack_cmd.extend(["--partner", self.config['partner']])
         if self.config.get('taskIds'):
             for taskId in self.config['taskIds']:
                 repack_cmd.extend(["--taskid", taskId])
         if self.config.get("limitLocales"):
             for locale in self.config["limitLocales"]:
                 repack_cmd.extend(["--limit-locale", locale])
 
-        return self.run_command(repack_cmd,
-                                cwd=self.query_abs_dirs()['abs_scripts_dir'])
+        self.run_command(repack_cmd,
+                         cwd=self.query_abs_dirs()['abs_scripts_dir'],
+                         halt_on_failure=True)
 
 
 # main {{{
 if __name__ == '__main__':
     partner_repacks = DesktopPartnerRepacks()
     partner_repacks.run_and_exit()
--- a/toolkit/components/places/SyncedBookmarksMirror.jsm
+++ b/toolkit/components/places/SyncedBookmarksMirror.jsm
@@ -55,17 +55,16 @@ Cu.importGlobalProperties(["URL"]);
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   Async: "resource://services-common/async.js",
   AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
   Log: "resource://gre/modules/Log.jsm",
-  ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
   OS: "resource://gre/modules/osfile.jsm",
   PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.jsm",
   PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
   Sqlite: "resource://gre/modules/Sqlite.jsm",
 });
 
 XPCOMUtils.defineLazyGetter(this, "MirrorLog", () =>
   Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror")
@@ -219,18 +218,17 @@ class SyncedBookmarksMirror {
         } else {
           MirrorLog.error("Unrecoverable error attaching mirror to Places", ex);
           throw ex;
         }
       }
       try {
         let info = await OS.File.stat(path);
         let size = Math.floor(info.size / 1024);
-        options.recordTelemetryEvent("mirror", "open", "success",
-                                     normalizeExtraTelemetryFields({ size }));
+        options.recordTelemetryEvent("mirror", "open", "success", { size });
       } catch (ex) {
         MirrorLog.warn("Error recording stats for mirror database size", ex);
       }
     } catch (ex) {
       options.recordTelemetryEvent("mirror", "open", "error",
                                    { why: whyFailed });
       await db.close();
       throw ex;
@@ -347,80 +345,52 @@ class SyncedBookmarksMirror {
    * @param {Boolean} [options.needsMerge]
    *        Indicates if the records were changed remotely since the last sync,
    *        and should be merged into the local tree. This option is set to
    *        `true` for incoming records, and `false` for successfully uploaded
    *        records. Tests can also pass `false` to set up an existing mirror.
    */
   async store(records, { needsMerge = true } = {}) {
     let options = { needsMerge };
-    let ignoreCounts = {
-      bookmark: { id: 0, url: 0 },
-      query: { id: 0, url: 0 },
-      folder: { id: 0, root: 0 },
-      child: { id: 0, root: 0 },
-      livemark: { id: 0, feed: 0 },
-      separator: { id: 0 },
-      tombstone: { id: 0, root: 0 },
-    };
-    let extraTelemetryEvents = [];
-    try {
-      await this.db.executeBeforeShutdown(
-        "SyncedBookmarksMirror: store",
-        db => db.executeTransaction(async () => {
-          for await (let record of yieldingIterator(records)) {
-            MirrorLog.trace(`Storing in mirror: ${record.cleartextToString()}`);
-            switch (record.type) {
-              case "bookmark":
-                await this.storeRemoteBookmark(record, ignoreCounts, options);
-                continue;
-
-              case "query":
-                await this.storeRemoteQuery(record, ignoreCounts, options);
-                continue;
-
-              case "folder":
-                await this.storeRemoteFolder(record, ignoreCounts, options);
+    await this.db.executeBeforeShutdown(
+      "SyncedBookmarksMirror: store",
+      db => db.executeTransaction(async () => {
+        for await (let record of yieldingIterator(records)) {
+          MirrorLog.trace(`Storing in mirror: ${record.cleartextToString()}`);
+          switch (record.type) {
+            case "bookmark":
+              await this.storeRemoteBookmark(record, options);
+              continue;
+
+            case "query":
+              await this.storeRemoteQuery(record, options);
+              continue;
+
+            case "folder":
+              await this.storeRemoteFolder(record, options);
+              continue;
+
+            case "livemark":
+              await this.storeRemoteLivemark(record, options);
+              continue;
+
+            case "separator":
+              await this.storeRemoteSeparator(record, options);
+              continue;
+
+            default:
+              if (record.deleted) {
+                await this.storeRemoteTombstone(record, options);
                 continue;
-
-              case "livemark":
-                await this.storeRemoteLivemark(record, ignoreCounts, options);
-                continue;
-
-              case "separator":
-                await this.storeRemoteSeparator(record, ignoreCounts, options);
-                continue;
-
-              default:
-                if (record.deleted) {
-                  await this.storeRemoteTombstone(record, ignoreCounts,
-                                                  options);
-                  continue;
-                }
-            }
-            MirrorLog.warn("Ignoring record with unknown type", record.type);
-            extraTelemetryEvents.push({
-              method: "ignore",
-              value: "unknown-kind",
-              extra: { kind: record.type },
-            });
+              }
           }
-        }
-      ));
-    } finally {
-      for (let { method, value, extra } of extraTelemetryEvents) {
-        this.recordTelemetryEvent("mirror", method, value, extra);
-      }
-      for (let kind in ignoreCounts) {
-        let extra = normalizeExtraTelemetryFields(ignoreCounts[kind]);
-        if (extra) {
-          this.recordTelemetryEvent("mirror", "ignore", kind, extra);
+          MirrorLog.warn("Ignoring record with unknown type", record.type);
         }
       }
-    }
+    ));
   }
 
   /**
    * Builds a complete merged tree from the local and remote trees, resolves
    * value and structure conflicts, dedupes local items, applies the merged
    * tree back to Places, and notifies observers about the changes.
    *
    * Merging and application happen in a transaction, meaning code that uses the
@@ -438,209 +408,228 @@ class SyncedBookmarksMirror {
    * @return {Object.<String, BookmarkChangeRecord>}
    *         A changeset containing locally changed and reconciled records to
    *         upload to the server, and to store in the mirror once upload
    *         succeeds.
    */
   async apply({ localTimeSeconds = Date.now() / 1000,
                 remoteTimeSeconds = 0,
                 weakUpload = [] } = {}) {
+    // We intentionally don't use `executeBeforeShutdown` in this function,
+    // since merging can take a while for large trees, and we don't want to
+    // block shutdown. Since all new items are in the mirror, we'll just try
+    // to merge again on the next sync.
+
     let hasChanges = weakUpload.length > 0 || (await this.hasChanges());
     if (!hasChanges) {
       MirrorLog.debug("No changes detected in both mirror and Places");
       return {};
     }
-    // We intentionally don't use `executeBeforeShutdown` in this function,
-    // since merging can take a while for large trees, and we don't want to
-    // block shutdown. Since all new items are in the mirror, we'll just try
-    // to merge again on the next sync.
-    let { missingParents, missingChildren } = await this.fetchRemoteOrphans();
+
+    // The flow ID is used to correlate telemetry events for each sync.
+    let flowID = PlacesUtils.history.makeGuid();
+
+    let { missingParents, missingChildren, parentsWithGaps } =
+      await this.fetchRemoteOrphans();
     if (missingParents.length) {
       MirrorLog.warn("Temporarily reparenting remote items with missing " +
                      "parents to unfiled", missingParents);
-      this.recordTelemetryEvent("mirror", "orphans", "parents",
-        normalizeExtraTelemetryFields({ count: missingParents.length }));
     }
     if (missingChildren.length) {
       MirrorLog.warn("Remote tree missing items", missingChildren);
-      this.recordTelemetryEvent("mirror", "orphans", "children",
-        normalizeExtraTelemetryFields({ count: missingChildren.length }));
+    }
+    if (parentsWithGaps.length) {
+      MirrorLog.warn("Remote tree has parents with gaps in positions",
+                     parentsWithGaps);
     }
 
     let { missingLocal, missingRemote, wrongSyncStatus } =
-      await this.fetchInconsistencies();
+      await this.fetchSyncStatusMismatches();
     if (missingLocal.length) {
       MirrorLog.warn("Remote tree has merged items that don't exist locally",
                      missingLocal);
-      this.recordTelemetryEvent("mirror", "inconsistencies", "local",
-        normalizeExtraTelemetryFields({ count: missingLocal.length }));
     }
     if (missingRemote.length) {
       MirrorLog.warn("Local tree has synced items that don't exist remotely",
                      missingRemote);
-      this.recordTelemetryEvent("mirror", "inconsistencies", "remote",
-        normalizeExtraTelemetryFields({ count: missingRemote.length }));
     }
     if (wrongSyncStatus.length) {
       MirrorLog.warn("Local tree has wrong sync statuses for items that " +
                      "exist remotely", wrongSyncStatus);
-      this.recordTelemetryEvent("mirror", "inconsistencies", "syncStatus",
-        normalizeExtraTelemetryFields({ count: wrongSyncStatus.length }));
     }
 
-    let applyStats = {};
+    this.recordTelemetryEvent("mirror", "apply", "problems", {
+      flowID,
+      missingParents: missingParents.length,
+      missingChildren: missingChildren.length,
+      parentsWithGaps: parentsWithGaps.length,
+      missingLocal: missingLocal.length,
+      missingRemote: missingRemote.length,
+      wrongSyncStatus: wrongSyncStatus.length,
+    });
 
     // It's safe to build the remote tree outside the transaction because
     // `fetchRemoteTree` doesn't join to Places, only Sync writes to the
     // mirror, and we're holding the Sync lock at this point.
-    MirrorLog.debug("Building remote tree from mirror");
-    let { result: remoteTree, time: remoteTreeTiming } = await withTiming(
-      "Fetch remote tree",
-      () => this.fetchRemoteTree(remoteTimeSeconds)
+    let remoteTree = await withTiming(
+      "Building remote tree from mirror",
+      () => this.fetchRemoteTree(remoteTimeSeconds),
+      (time, tree) => this.recordTelemetryEvent("mirror", "apply",
+        "fetchRemoteTree", { flowID, time, deletions: tree.deletedGuids.size,
+                             nodes: tree.byGuid.size })
     );
-    applyStats.remoteTree = { time: remoteTreeTiming,
-                              count: remoteTree.guidCount };
     if (MirrorLog.level <= Log.Level.Debug) {
       MirrorLog.debug("Built remote tree from mirror\n" +
                       remoteTree.toASCIITreeString());
     }
 
     let observersToNotify = new BookmarkObserverRecorder(this.db);
 
-    let changeRecords = await this.db.executeTransaction(async () => {
-      MirrorLog.debug("Building local tree from Places");
-      let { result: localTree, time: localTreeTiming } = await withTiming(
-        "Fetch local tree",
-        () => this.fetchLocalTree(localTimeSeconds)
-      );
-      applyStats.localTree = { time: localTreeTiming,
-                               count: localTree.guidCount };
-      if (MirrorLog.level <= Log.Level.Debug) {
-        MirrorLog.debug("Built local tree from Places\n" +
-                        localTree.toASCIITreeString());
-      }
-
-      MirrorLog.debug("Fetching content info for new mirror items");
-      let {
-        result: newRemoteContents,
-        time: remoteContentsTiming,
-      } = await withTiming(
-        "Fetch new remote contents",
-        () => this.fetchNewRemoteContents()
-      );
-      applyStats.remoteContents = { time: remoteContentsTiming,
-                                    count: newRemoteContents.size };
-
-      MirrorLog.debug("Fetching content info for new Places items");
-      let {
-        result: newLocalContents,
-        time: localContentsTiming,
-      } = await withTiming(
-        "Fetch new local contents",
-        () => this.fetchNewLocalContents()
-      );
-      applyStats.localContents = { time: localContentsTiming,
-                                   count: newLocalContents.size };
-
-      MirrorLog.debug("Building complete merged tree");
-      let merger = new BookmarkMerger(localTree, newLocalContents,
-                                      remoteTree, newRemoteContents);
-      let mergedRoot;
-      try {
-        let time;
-        ({ result: mergedRoot, time } = await withTiming(
-          "Build merged tree",
-          () => merger.merge()
-        ));
-        applyStats.merge = { time };
-      } finally {
-        for (let { value, extra } of merger.summarizeTelemetryEvents()) {
-          this.recordTelemetryEvent("mirror", "merge", value, extra);
+    let changeRecords;
+    try {
+      changeRecords = await this.db.executeTransaction(async () => {
+        let localTree = await withTiming(
+          "Building local tree from Places",
+          () => this.fetchLocalTree(localTimeSeconds),
+          (time, tree) => this.recordTelemetryEvent("mirror", "apply",
+            "fetchLocalTree", { flowID, time, deletions: tree.deletedGuids.size,
+                                nodes: tree.byGuid.size })
+        );
+        if (MirrorLog.level <= Log.Level.Debug) {
+          MirrorLog.debug("Built local tree from Places\n" +
+                          localTree.toASCIITreeString());
+        }
+
+        let newRemoteContents = await withTiming(
+          "Fetching content info for new mirror items",
+          () => this.fetchNewRemoteContents(),
+          (time, contents) => this.recordTelemetryEvent("mirror", "apply",
+            "fetchNewRemoteContents", { flowID, time, count: contents.size })
+        );
+
+        let newLocalContents = await withTiming(
+          "Fetching content info for new Places items",
+          () => this.fetchNewLocalContents(),
+          (time, contents) => this.recordTelemetryEvent("mirror", "apply",
+            "fetchNewLocalContents", { flowID, time, count: contents.size })
+        );
+
+        let merger = new BookmarkMerger(localTree, newLocalContents,
+                                        remoteTree, newRemoteContents);
+        let mergedRoot = await withTiming(
+          "Building complete merged tree",
+          () => merger.merge(),
+          time => {
+            this.recordTelemetryEvent("mirror", "apply", "merge",
+              { flowID, time, nodes: merger.mergedGuids.size,
+                localDeletions: merger.deleteLocally.size,
+                remoteDeletions: merger.deleteRemotely.size,
+                dupes: merger.dupeCount });
+
+            this.recordTelemetryEvent("mirror", "merge", "structure",
+              merger.structureCounts);
+          }
+        );
+
+        if (MirrorLog.level <= Log.Level.Debug) {
+          MirrorLog.debug([
+            "Built new merged tree",
+            mergedRoot.toASCIITreeString(),
+            ...merger.deletionsToStrings(),
+          ].join("\n"));
+        }
+
+        // The merged tree should know about all items mentioned in the local
+        // and remote trees. Otherwise, it's incomplete, and we'll corrupt
+        // Places or lose data on the server if we try to apply it.
+        if (!await merger.subsumes(localTree)) {
+          throw new SyncedBookmarksMirror.ConsistencyError(
+            "Merged tree doesn't mention all items from local tree");
+        }
+        if (!await merger.subsumes(remoteTree)) {
+          throw new SyncedBookmarksMirror.ConsistencyError(
+            "Merged tree doesn't mention all items from remote tree");
         }
-      }
-
-      if (MirrorLog.level <= Log.Level.Debug) {
-        MirrorLog.debug([
-          "Built new merged tree",
-          mergedRoot.toASCIITreeString(),
-          ...merger.deletionsToStrings(),
-        ].join("\n"));
-      }
-
-      // The merged tree should know about all items mentioned in the local
-      // and remote trees. Otherwise, it's incomplete, and we'll corrupt
-      // Places or lose data on the server if we try to apply it.
-      if (!await merger.subsumes(localTree)) {
-        throw new SyncedBookmarksMirror.ConsistencyError(
-          "Merged tree doesn't mention all items from local tree");
-      }
-      if (!await merger.subsumes(remoteTree)) {
-        throw new SyncedBookmarksMirror.ConsistencyError(
-          "Merged tree doesn't mention all items from remote tree");
-      }
-
-      MirrorLog.debug("Applying merged tree");
-      let deletions = [];
-      for await (let deletion of yieldingIterator(merger.deletions())) {
-        deletions.push(deletion);
-      }
-      let { time: updateTiming } = await withTiming(
-        "Apply merged tree",
-        () => this.updateLocalItemsInPlaces(mergedRoot, deletions)
-      );
-      applyStats.update = { time: updateTiming };
-
-      // At this point, the database is consistent, and we can fetch info to
-      // pass to observers. Note that we can't fetch observer info in the
-      // triggers above, because the structure might not be complete yet. An
-      // incomplete structure might cause us to miss or record wrong parents and
-      // positions.
-
-      MirrorLog.debug("Recording observer notifications");
-      await this.noteObserverChanges(observersToNotify);
-
-      let {
-        result: changeRecords,
-        time: stageTiming,
-      } = await withTiming("Stage outgoing items", async () => {
-        MirrorLog.debug("Staging locally changed items for upload");
-        await this.stageItemsToUpload(weakUpload);
-
-        MirrorLog.debug("Fetching records for local items to upload");
-        return this.fetchLocalChangeRecords();
+
+        await withTiming(
+          "Applying merged tree",
+          async () => {
+            let deletions = [];
+            for await (let deletion of yieldingIterator(merger.deletions())) {
+              deletions.push(deletion);
+            }
+            await this.updateLocalItemsInPlaces(mergedRoot, deletions);
+          },
+          time => this.recordTelemetryEvent("mirror", "apply",
+            "updateLocalItemsInPlaces", { flowID, time })
+        );
+
+        // At this point, the database is consistent, and we can fetch info to
+        // pass to observers. Note that we can't fetch observer info in the
+        // triggers above, because the structure might not be complete yet. An
+        // incomplete structure might cause us to miss or record wrong parents and
+        // positions.
+
+        await withTiming(
+          "Recording observer notifications",
+          () => this.noteObserverChanges(observersToNotify),
+          time => this.recordTelemetryEvent("mirror", "apply",
+            "noteObserverChanges", { flowID, time })
+        );
+
+        await withTiming(
+          "Staging locally changed items for upload",
+          () => this.stageItemsToUpload(weakUpload),
+          time => this.recordTelemetryEvent("mirror", "apply",
+            "stageItemsToUpload", { flowID, time })
+        );
+
+        let changeRecords = await withTiming(
+          "Fetching records for local items to upload",
+          () => this.fetchLocalChangeRecords(),
+          (time, records) => this.recordTelemetryEvent("mirror", "apply",
+            "fetchLocalChangeRecords", { flowID,
+              count: Object.keys(records).length })
+        );
+
+        await withTiming(
+          "Cleaning up merge tables",
+          async () => {
+            await this.db.execute(`DELETE FROM mergeStates`);
+            await this.db.execute(`DELETE FROM itemsAdded`);
+            await this.db.execute(`DELETE FROM guidsChanged`);
+            await this.db.execute(`DELETE FROM itemsChanged`);
+            await this.db.execute(`DELETE FROM itemsRemoved`);
+            await this.db.execute(`DELETE FROM itemsMoved`);
+            await this.db.execute(`DELETE FROM annosChanged`);
+            await this.db.execute(`DELETE FROM idsToWeaklyUpload`);
+            await this.db.execute(`DELETE FROM itemsToUpload`);
+          },
+          time => this.recordTelemetryEvent("mirror", "apply", "cleanup",
+            { flowID, time })
+        );
+
+        return changeRecords;
       });
-      applyStats.stage = { time: stageTiming };
-
-      await this.db.execute(`DELETE FROM mergeStates`);
-      await this.db.execute(`DELETE FROM itemsAdded`);
-      await this.db.execute(`DELETE FROM guidsChanged`);
-      await this.db.execute(`DELETE FROM itemsChanged`);
-      await this.db.execute(`DELETE FROM itemsRemoved`);
-      await this.db.execute(`DELETE FROM itemsMoved`);
-      await this.db.execute(`DELETE FROM annosChanged`);
-      await this.db.execute(`DELETE FROM idsToWeaklyUpload`);
-      await this.db.execute(`DELETE FROM itemsToUpload`);
-
-      return changeRecords;
-    });
+    } catch (ex) {
+      // Include the error message in the event payload, since we can't
+      // easily correlate event telemetry to engine errors in the Sync ping.
+      let why = (typeof ex.message == "string" ? ex.message :
+                 String(ex)).slice(0, 85);
+      this.recordTelemetryEvent("mirror", "apply", "error", { flowID, why });
+      throw ex;
+    }
 
     MirrorLog.debug("Replaying recorded observer notifications");
     try {
       await observersToNotify.notifyAll();
     } catch (ex) {
       MirrorLog.warn("Error notifying Places observers", ex);
     }
 
-    for (let value in applyStats) {
-      let extra = normalizeExtraTelemetryFields(applyStats[value]);
-      if (extra) {
-        this.recordTelemetryEvent("mirror", "apply", value, extra);
-      }
-    }
-
     return changeRecords;
   }
 
   /**
    * Discards the mirror contents. This is called when the user is node
    * reassigned, disables the bookmarks engine, or signs out.
    */
   async reset() {
@@ -657,29 +646,27 @@ class SyncedBookmarksMirror {
    * @return {String[]}
    *         Remotely changed GUIDs that need to be merged into Places.
    */
   async fetchUnmergedGuids() {
     let rows = await this.db.execute(`SELECT guid FROM items WHERE needsMerge`);
     return rows.map(row => row.getResultByName("guid"));
   }
 
-  async storeRemoteBookmark(record, ignoreCounts, { needsMerge }) {
+  async storeRemoteBookmark(record, { needsMerge }) {
     let guid = validateGuid(record.id);
     if (!guid) {
       MirrorLog.warn("Ignoring bookmark with invalid ID", record.id);
-      ignoreCounts.bookmark.id++;
       return;
     }
 
     let url = validateURL(record.bmkUri);
     if (!url) {
       MirrorLog.warn("Ignoring bookmark ${guid} with invalid URL ${url}",
                      { guid, url: record.bmkUri });
-      ignoreCounts.bookmark.url++;
       return;
     }
 
     await this.maybeStoreRemoteURL(url);
 
     let serverModified = determineServerModified(record);
     let dateAdded = determineDateAdded(record);
     let title = validateTitle(record.title);
@@ -712,43 +699,40 @@ class SyncedBookmarksMirror {
           INSERT INTO tags(itemId, tag)
           SELECT id, :tag FROM items
           WHERE guid = :guid`,
           { tag, guid });
       }
     }
   }
 
-  async storeRemoteQuery(record, ignoreCounts, { needsMerge }) {
+  async storeRemoteQuery(record, { needsMerge }) {
     let guid = validateGuid(record.id);
     if (!guid) {
       MirrorLog.warn("Ignoring query with invalid ID", record.id);
-      ignoreCounts.query.id++;
       return;
     }
 
     let url = validateURL(record.bmkUri);
     if (!url) {
       MirrorLog.warn("Ignoring query ${guid} with invalid URL ${url}",
                      { guid, url: record.bmkUri });
-      ignoreCounts.query.url++;
       return;
     }
 
     // Legacy tag queries may use a `place:` URL with a `folder` param that
     // points to the tag folder ID. We need to rewrite these queries to
     // directly reference the tag.
     let params = new URLSearchParams(url.pathname);
     let type = +params.get("type");
     if (type == Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
       let tagFolderName = validateTag(record.folderName);
       if (!tagFolderName) {
         MirrorLog.warn("Ignoring tag query ${guid} with invalid tag name " +
                        "${tagFolderName}", { guid, tagFolderName });
-        ignoreCounts.query.url++;
         return;
       }
       url = new URL(`place:tag=${tagFolderName}`);
     }
 
     await this.maybeStoreRemoteURL(url);
 
     let serverModified = determineServerModified(record);
@@ -768,27 +752,25 @@ class SyncedBookmarksMirror {
               WHERE hash = hash(:url) AND
                     url = :url),
              :description, :smartBookmarkName)`,
       { guid, serverModified, needsMerge,
         kind: SyncedBookmarksMirror.KIND.QUERY, dateAdded, title,
         url: url.href, description, smartBookmarkName });
   }
 
-  async storeRemoteFolder(record, ignoreCounts, { needsMerge }) {
+  async storeRemoteFolder(record, { needsMerge }) {
     let guid = validateGuid(record.id);
     if (!guid) {
       MirrorLog.warn("Ignoring folder with invalid ID", record.id);
-      ignoreCounts.folder.id++;
       return;
     }
     if (guid == PlacesUtils.bookmarks.rootGuid) {
       // The Places root shouldn't be synced at all.
       MirrorLog.warn("Ignoring Places root record", record);
-      ignoreCounts.folder.root++;
     }
 
     let serverModified = determineServerModified(record);
     let dateAdded = determineDateAdded(record);
     let title = validateTitle(record.title);
     let description = validateDescription(record.description);
 
     await this.db.executeCached(`
@@ -805,23 +787,21 @@ class SyncedBookmarksMirror {
       for (let position = 0; position < children.length; ++position) {
         await maybeYield();
         let childRecordId = children[position];
         let childGuid = validateGuid(childRecordId);
         if (!childGuid) {
           MirrorLog.warn("Ignoring child of folder ${parentGuid} with " +
                          "invalid ID ${childRecordId}", { parentGuid: guid,
                                                           childRecordId });
-          ignoreCounts.child.id++;
           continue;
         }
         if (childGuid == PlacesUtils.bookmarks.rootGuid ||
             PlacesUtils.bookmarks.userContentRoots.includes(childGuid)) {
           MirrorLog.warn("Ignoring move for root", childGuid);
-          ignoreCounts.child.root++;
           continue;
         }
         await this.db.executeCached(`
           REPLACE INTO structure(guid, parentGuid, position)
           VALUES(:childGuid, :parentGuid, :position)`,
           { childGuid, parentGuid: guid, position });
       }
     }
@@ -836,29 +816,27 @@ class SyncedBookmarksMirror {
         !PlacesUtils.bookmarks.userContentRoots.includes(guid)) {
         await this.db.executeCached(`
           INSERT OR IGNORE INTO structure(guid, parentGuid, position)
           VALUES(:guid, :parentGuid, -1)`,
           { guid, parentGuid });
       }
   }
 
-  async storeRemoteLivemark(record, ignoreCounts, { needsMerge }) {
+  async storeRemoteLivemark(record, { needsMerge }) {
     let guid = validateGuid(record.id);
     if (!guid) {
       MirrorLog.warn("Ignoring livemark with invalid ID", record.id);
-      ignoreCounts.livemark.id++;
       return;
     }
 
     let feedURL = validateURL(record.feedUri);
     if (!feedURL) {
       MirrorLog.warn("Ignoring livemark ${guid} with invalid feed URL ${url}",
                      { guid, url: record.feedUri });
-      ignoreCounts.livemark.feed++;
       return;
     }
 
     let serverModified = determineServerModified(record);
     let dateAdded = determineDateAdded(record);
     let title = validateTitle(record.title);
     let description = validateDescription(record.description);
     let siteURL = validateURL(record.siteUri);
@@ -869,48 +847,45 @@ class SyncedBookmarksMirror {
       VALUES(:guid, :serverModified, :needsMerge, :kind, :dateAdded,
              NULLIF(:title, ""), :description, :feedURL, :siteURL)`,
       { guid, serverModified, needsMerge,
         kind: SyncedBookmarksMirror.KIND.LIVEMARK,
         dateAdded, title, description, feedURL: feedURL.href,
         siteURL: siteURL ? siteURL.href : null });
   }
 
-  async storeRemoteSeparator(record, ignoreCounts, { needsMerge }) {
+  async storeRemoteSeparator(record, { needsMerge }) {
     let guid = validateGuid(record.id);
     if (!guid) {
       MirrorLog.warn("Ignoring separator with invalid ID", record.id);
-      ignoreCounts.separator.id++;
       return;
     }
 
     let serverModified = determineServerModified(record);
     let dateAdded = determineDateAdded(record);
 
     await this.db.executeCached(`
       REPLACE INTO items(guid, serverModified, needsMerge, kind,
                          dateAdded)
       VALUES(:guid, :serverModified, :needsMerge, :kind,
              :dateAdded)`,
       { guid, serverModified, needsMerge, kind: SyncedBookmarksMirror.KIND.SEPARATOR,
         dateAdded });
   }
 
-  async storeRemoteTombstone(record, ignoreCounts, { needsMerge }) {
+  async storeRemoteTombstone(record, { needsMerge }) {
     let guid = validateGuid(record.id);
     if (!guid) {
       MirrorLog.warn("Ignoring tombstone with invalid ID", record.id);
-      ignoreCounts.tombstone.id++;
       return;
     }
 
     if (guid == PlacesUtils.bookmarks.rootGuid ||
         PlacesUtils.bookmarks.userContentRoots.includes(guid)) {
       MirrorLog.warn("Ignoring tombstone for root", guid);
-      ignoreCounts.tombstone.root++;
       return;
     }
 
     await this.db.executeCached(`
       REPLACE INTO items(guid, serverModified, needsMerge, isDeleted)
       VALUES(:guid, :serverModified, :needsMerge, 1)`,
       { guid, serverModified: determineServerModified(record), needsMerge });
   }
@@ -924,40 +899,54 @@ class SyncedBookmarksMirror {
                     GENERATE_GUID()), :url, hash(:url), :revHost)`,
       { url: url.href, revHost: PlacesUtils.getReversedHost(url) });
   }
 
   async fetchRemoteOrphans() {
     let infos = {
       missingParents: [],
       missingChildren: [],
+      parentsWithGaps: [],
     };
 
     let orphanRows = await this.db.execute(`
-      SELECT v.guid AS guid, 1 AS missingParent, 0 AS missingChild
+      SELECT v.guid AS guid, 1 AS missingParent, 0 AS missingChild,
+             0 AS parentWithGaps
       FROM items v
       LEFT JOIN structure s ON s.guid = v.guid
       WHERE NOT v.isDeleted AND
             s.guid IS NULL
       UNION ALL
-      SELECT s.guid AS guid, 0 AS missingParent, 1 AS missingChild
+      SELECT s.guid AS guid, 0 AS missingParent, 1 AS missingChild,
+             0 AS parentsWithGaps
       FROM structure s
       LEFT JOIN items v ON v.guid = s.guid
-      WHERE v.guid IS NULL`);
+      WHERE v.guid IS NULL
+      UNION ALL
+      SELECT s.parentGuid AS guid, 0 AS missingParent, 0 AS missingChild,
+             1 AS parentWithGaps
+      FROM structure s
+      GROUP BY s.parentGuid
+      HAVING (sum(DISTINCT position + 1) -
+                  (count(*) * (count(*) + 1) / 2)) <> 0`);
 
     for await (let row of yieldingIterator(orphanRows)) {
       let guid = row.getResultByName("guid");
       let missingParent = row.getResultByName("missingParent");
       if (missingParent) {
         infos.missingParents.push(guid);
       }
       let missingChild = row.getResultByName("missingChild");
       if (missingChild) {
         infos.missingChildren.push(guid);
       }
+      let parentWithGaps = row.getResultByName("parentWithGaps");
+      if (parentWithGaps) {
+        infos.parentsWithGaps.push(guid);
+      }
     }
 
     return infos;
   }
 
   /**
    * Checks the sync statuses of all items for consistency. All merged items in
    * the remote tree should exist as either items or tombstones in the local
@@ -966,17 +955,17 @@ class SyncedBookmarksMirror {
    *
    * @return {Object.<String, String[]>}
    *         An object containing GUIDs for each problem type:
    *           - `missingLocal`: Merged items in the remote tree that aren't
    *             mentioned in the local tree.
    *           - `missingRemote`: NORMAL items in the local tree that aren't
    *             mentioned in the remote tree.
    */
-  async fetchInconsistencies() {
+  async fetchSyncStatusMismatches() {
     let infos = {
       missingLocal: [],
       missingRemote: [],
       wrongSyncStatus: [],
     };
 
     let problemRows = await this.db.execute(`
       SELECT v.guid, 1 AS missingLocal, 0 AS missingRemote, 0 AS wrongSyncStatus
@@ -1890,38 +1879,16 @@ SyncedBookmarksMirror.ConsistencyError =
  */
 class DatabaseCorruptError extends Error {
   constructor(message) {
     super(message);
     this.name = "DatabaseCorruptError";
   }
 }
 
-// Converts extra integer fields to strings, and rounds timings to nanosecond
-// precision.
-function normalizeExtraTelemetryFields(extra) {
-  let result = {};
-  for (let key in extra) {
-    let value = extra[key];
-    let type = typeof value;
-    if (type == "string") {
-      result[key] = value;
-    } else if (type == "number") {
-      if (value > 0) {
-        result[key] = Number.isInteger(value) ? value.toString(10) :
-                      value.toFixed(3);
-      }
-    } else if (type != "undefined") {
-      throw new TypeError(`Invalid type ${
-        type} for extra telemetry field ${key}`);
-    }
-  }
-  return ObjectUtils.isEmpty(result) ? undefined : result;
-}
-
 // Indicates if the mirror should be replaced because the database file is
 // corrupt.
 function isDatabaseCorrupt(error) {
   if (error instanceof DatabaseCorruptError) {
     return true;
   }
   if (error.errors) {
     return error.errors.some(error =>
@@ -2830,25 +2797,41 @@ async function inflateTree(tree, pseudoT
         PlacesUtils.bookmarks.userContentRoots.includes(node.guid) :
         parentNode.isSyncable;
       tree.insert(parentNode.guid, node);
       await inflateTree(tree, pseudoTree, node);
     }
   }
 }
 
-// Executes a function and returns a `{ result, time }` tuple, where `result` is
-// the function's return value, and `time` is the time taken to execute the
-// function.
-async function withTiming(name, func) {
+/**
+ * Measures and logs the time taken to execute a function, using a monotonic
+ * clock.
+ *
+ * @param  {String} name
+ *         The name of the operation, used for logging.
+ * @param  {Function} func
+ *         The function to time.
+ * @param  {Function} recordTiming
+ *         A function with the signature `(time: Number, result: Object?)`,
+ *         where `time` is the measured time, and `result` is the return
+ *         value of the timed function.
+ * @return The return value of the timed function.
+ */
+async function withTiming(name, func, recordTiming) {
+  MirrorLog.debug(name);
+
   let startTime = Cu.now();
   let result = await func();
   let elapsedTime = Cu.now() - startTime;
+
   MirrorLog.trace(`${name} took ${elapsedTime.toFixed(3)}ms`);
-  return { result, time: elapsedTime };
+  recordTiming(elapsedTime, result);
+
+  return result;
 }
 
 /**
  * Content info for an item in the local or remote tree. This is used to dedupe
  * NEW local items to remote items that don't exist locally. See `makeDupeKey`
  * for how we determine if two items are dupes.
  */
 class BookmarkContent {
@@ -3189,20 +3172,16 @@ class BookmarkNode {
 class BookmarkTree {
   constructor(root) {
     this.root = root;
     this.byGuid = new Map([[this.root.guid, this.root]]);
     this.parentNodeByChildNode = new Map([[this.root, null]]);
     this.deletedGuids = new Set();
   }
 
-  get guidCount() {
-    return this.byGuid.size + this.deletedGuids.size;
-  }
-
   isDeleted(guid) {
     return this.deletedGuids.has(guid);
   }
 
   nodeForGuid(guid) {
     return this.byGuid.get(guid);
   }
 
@@ -3377,32 +3356,16 @@ class BookmarkMerger {
     this.structureCounts = {
       new: 0,
       remoteRevives: 0, // Remote non-folder change wins over local deletion.
       localDeletes: 0, // Local folder deletion wins over remote change.
       localRevives: 0, // Local non-folder change wins over remote deletion.
       remoteDeletes: 0, // Remote folder deletion wins over local change.
     };
     this.dupeCount = 0;
-    this.extraTelemetryEvents = [];
-  }
-
-  summarizeTelemetryEvents() {
-    let events = [...this.extraTelemetryEvents];
-    if (this.dupeCount > 0) {
-      events.push({
-        value: "dupes",
-        extra: normalizeExtraTelemetryFields({ count: this.dupeCount }),
-      });
-    }
-    let structureExtra = normalizeExtraTelemetryFields(this.structureCounts);
-    if (structureExtra) {
-      events.push({ value: "structure", extra: structureExtra });
-    }
-    return events;
   }
 
   async merge() {
     let localRoot = this.localTree.nodeForGuid(PlacesUtils.bookmarks.rootGuid);
     let remoteRoot = this.remoteTree.nodeForGuid(PlacesUtils.bookmarks.rootGuid);
     let mergedRoot = await this.mergeNode(PlacesUtils.bookmarks.rootGuid, localRoot,
                                           remoteRoot);
 
@@ -3543,21 +3506,16 @@ class BookmarkMerger {
                     { mergedGuid, mergeState });
 
     let mergedNode = new MergedBookmarkNode(mergedGuid, localNode, remoteNode,
                                             mergeState);
 
     if (!localNode.hasCompatibleKind(remoteNode)) {
       MirrorLog.error("Merging local ${localNode} and remote ${remoteNode} " +
                       "with different kinds", { localNode, remoteNode });
-      this.extraTelemetryEvents.push({
-        value: "kind-mismatch",
-        extra: { local: localNode.kindToString().toLowerCase(),
-                 remote: remoteNode.kindToString().toLowerCase() },
-      });
       throw new SyncedBookmarksMirror.ConsistencyError(
         "Can't merge different item kinds");
     }
 
     if (localNode.isFolder()) {
       if (remoteNode.isFolder()) {
         // Merging two folders, so we need to walk their children to handle
         // structure changes.
--- a/toolkit/components/places/tests/sync/test_bookmark_corruption.js
+++ b/toolkit/components/places/tests/sync/test_bookmark_corruption.js
@@ -3,26 +3,17 @@
 
 async function getCountOfBookmarkRows(db) {
   let queryRows = await db.execute("SELECT COUNT(*) FROM moz_bookmarks");
   Assert.equal(queryRows.length, 1);
   return queryRows[0].getResultByIndex(0);
 }
 
 add_task(async function test_corrupt_roots() {
-  let telemetryEvents = [];
-  let buf = await openMirror("corrupt_roots", {
-    recordTelemetryEvent(object, method, value, extra) {
-      if (object == "mirror" && ["open", "apply"].includes(method)) {
-        // Ignore timings, mirror database file, and tree sizes.
-        return;
-      }
-      telemetryEvents.push({ object, method, value, extra });
-    },
-  });
+  let buf = await openMirror("corrupt_roots");
 
   info("Set up empty mirror");
   await PlacesTestUtils.markBookmarksAsSynced();
 
   info("Make remote changes: Menu > Unfiled");
   await storeRecords(buf, [{
     id: "menu",
     type: "folder",
@@ -43,27 +34,16 @@ add_task(async function test_corrupt_roo
     bmkUri: "http://example.com/b",
   }, {
     id: "toolbar",
     deleted: true,
   }]);
 
   let changesToUpload = await buf.apply();
   deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
-  deepEqual(telemetryEvents, [{
-    object: "mirror",
-    method: "ignore",
-    value: "child",
-    extra: { root: "1" },
-  }, {
-    object: "mirror",
-    method: "ignore",
-    value: "tombstone",
-    extra: { root: "1" },
-  }], "Should record telemetry for ignored invalid roots");
 
   deepEqual(changesToUpload, {}, "Should not reupload invalid roots");
 
   await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
     guid: PlacesUtils.bookmarks.rootGuid,
     type: PlacesUtils.bookmarks.TYPE_FOLDER,
     index: 0,
     title: "",
--- a/toolkit/components/places/tests/sync/test_bookmark_deduping.js
+++ b/toolkit/components/places/tests/sync/test_bookmark_deduping.js
@@ -23,17 +23,17 @@ add_task(async function test_duping_loca
     type: "bookmark",
     bmkUri: "http://example.com/a",
     title: "A",
   }], { needsMerge: false });
   await PlacesTestUtils.markBookmarksAsSynced();
 
   // The mirror is out of sync because `bookmarkAAA5` is marked as merged,
   // even though it's not in Places, but we should still recover.
-  deepEqual(await buf.fetchInconsistencies(), {
+  deepEqual(await buf.fetchSyncStatusMismatches(), {
     missingLocal: ["bookmarkAAA5"],
     missingRemote: [],
     wrongSyncStatus: [],
   }, "Mirror should be out of sync with Places before deduping");
 
   info("Add newer local dupes");
   await PlacesUtils.bookmarks.insertTree({
     guid: PlacesUtils.bookmarks.menuGuid,
@@ -81,21 +81,19 @@ add_task(async function test_duping_loca
   }]);
 
   info("Apply remote");
   let changesToUpload = await buf.apply({
     remoteTimeSeconds: localModified / 1000,
   });
   deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
   deepEqual(mergeTelemetryEvents, [{
-    value: "dupes",
-    extra: { count: "2" },
-  }, {
     value: "structure",
-    extra: { new: "1" },
+    extra: { new: 1, remoteRevives: 0, localDeletes: 0, localRevives: 0,
+             remoteDeletes: 0 },
   }], "Should record telemetry with dupe counts");
 
   let menuInfo = await PlacesUtils.bookmarks.fetch(
     PlacesUtils.bookmarks.menuGuid);
   deepEqual(changesToUpload, {
     menu: {
       tombstone: false,
       counter: 2,
--- a/toolkit/components/places/tests/sync/test_bookmark_deletion.js
+++ b/toolkit/components/places/tests/sync/test_bookmark_deletion.js
@@ -109,17 +109,18 @@ add_task(async function test_complex_orp
     bmkUri: "http://example.com/f",
   }]));
 
   info("Apply remote");
   let changesToUpload = await buf.apply();
   deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
   deepEqual(mergeTelemetryEvents, [{
     value: "structure",
-    extra: { new: "2", localDeletes: "1", remoteDeletes: "1" },
+    extra: { new: 2, remoteRevives: 0, localDeletes: 1, localRevives: 0,
+             remoteDeletes: 1 },
   }], "Should record telemetry with structure change counts");
 
   let idsToUpload = inspectChangeRecords(changesToUpload);
   deepEqual(idsToUpload, {
     updated: ["bookmarkEEEE", "bookmarkFFFF", "folderAAAAAA", "folderCCCCCC"],
     deleted: ["folderDDDDDD"],
   }, "Should upload new records for (A > E), (C > F); tombstone for D");
 
@@ -301,17 +302,18 @@ add_task(async function test_locally_mod
     deleted: true,
   }]);
 
   info("Apply remote");
   let changesToUpload = await buf.apply();
   deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
   deepEqual(mergeTelemetryEvents, [{
     value: "structure",
-    extra: { new: "1", localRevives: "1", remoteDeletes: "2" },
+    extra: { new: 1, remoteRevives: 0, localDeletes: 0, localRevives: 1,
+             remoteDeletes: 2 },
   }], "Should record telemetry for local item and remote folder deletions");
 
   let idsToUpload = inspectChangeRecords(changesToUpload);
   deepEqual(idsToUpload, {
     updated: ["bookmarkAAAA", "bookmarkFFFF", "bookmarkGGGG", "menu"],
     deleted: [],
   }, "Should upload A, relocated local orphans, and menu");
 
@@ -448,17 +450,18 @@ add_task(async function test_locally_del
     bmkUri: "http://example.com/g-remote",
   }]);
 
   info("Apply remote");
   let changesToUpload = await buf.apply();
   deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
   deepEqual(mergeTelemetryEvents, [{
     value: "structure",
-    extra: { new: "1", remoteRevives: "1", localDeletes: "2" },
+    extra: { new: 1, remoteRevives: 1, localDeletes: 2, localRevives: 0,
+             remoteDeletes: 0 },
   }], "Should record telemetry for remote item and local folder deletions");
 
   let idsToUpload = inspectChangeRecords(changesToUpload);
   deepEqual(idsToUpload, {
     updated: ["bookmarkFFFF", "bookmarkGGGG", "menu"],
     deleted: ["bookmarkCCCC", "bookmarkEEEE", "folderBBBBBB", "folderDDDDDD"],
   }, "Should upload relocated remote orphans and menu");
 
--- a/toolkit/components/places/tests/sync/test_bookmark_kinds.js
+++ b/toolkit/components/places/tests/sync/test_bookmark_kinds.js
@@ -413,25 +413,22 @@ add_task(async function test_mismatched_
   }, "Should not reupload merged livemark");
 
   await buf.finalize();
   await PlacesUtils.bookmarks.eraseEverything();
   await PlacesSyncUtils.bookmarks.reset();
 });
 
 add_task(async function test_mismatched_but_incompatible_folder_types() {
-  let sawMismatchEvent = false;
+  let sawMismatchError = false;
   let recordTelemetryEvent = (object, method, value, extra) => {
-    // expecting to see a kind-mismatch event.
-    if (value == "kind-mismatch" &&
-        extra.local && typeof extra.local == "string" &&
-        extra.local == "livemark" &&
-        extra.remote && typeof extra.remote == "string" &&
-        extra.remote == "folder") {
-      sawMismatchEvent = true;
+    // expecting to see an error for kind mismatches.
+    if (method == "apply" && value == "error" &&
+        extra && extra.why == "Can't merge different item kinds") {
+      sawMismatchError = true;
     }
   };
   let buf = await openMirror("mismatched_incompatible_types",
                              {recordTelemetryEvent});
   try {
     info("Set up mirror");
     await PlacesUtils.bookmarks.insertTree({
       guid: PlacesUtils.bookmarks.menuGuid,
@@ -453,17 +450,17 @@ add_task(async function test_mismatched_
       "type": "folder",
       "title": "not really a Livemark",
       "description": null,
       "parentid": "menu"
     }]);
 
     info("Apply remote, should fail");
     await Assert.rejects(buf.apply(), /Can't merge different item kinds/);
-    Assert.ok(sawMismatchEvent, "saw the correct mismatch event");
+    Assert.ok(sawMismatchError, "saw the correct mismatch event");
   } finally {
     await buf.finalize();
     await PlacesUtils.bookmarks.eraseEverything();
     await PlacesSyncUtils.bookmarks.reset();
   }
 });
 
 add_task(async function test_different_but_compatible_bookmark_types() {
@@ -534,25 +531,22 @@ add_task(async function test_different_b
     Assert.equal(changes.bookmarkBBBB.cleartext.type, "bookmark");
   } finally {
     await PlacesUtils.bookmarks.eraseEverything();
     await PlacesSyncUtils.bookmarks.reset();
   }
 });
 
 add_task(async function test_incompatible_types() {
-  let sawMismatchEvent = false;
+  let sawMismatchError = false;
   let recordTelemetryEvent = (object, method, value, extra) => {
-    // expecting to see a kind-mismatch event.
-    if (value == "kind-mismatch" &&
-        extra.local && typeof extra.local == "string" &&
-        extra.local == "bookmark" &&
-        extra.remote && typeof extra.remote == "string" &&
-        extra.remote == "folder") {
-      sawMismatchEvent = true;
+    // expecting to see an error for kind mismatches.
+    if (method == "apply" && value == "error" &&
+        extra && extra.why == "Can't merge different item kinds") {
+      sawMismatchError = true;
     }
   };
   try {
     let buf = await openMirror("partial_queries", {recordTelemetryEvent});
 
     await PlacesUtils.bookmarks.insertTree({
       guid: PlacesUtils.bookmarks.menuGuid,
       children: [
@@ -577,14 +571,14 @@ add_task(async function test_incompatibl
       id: "AAAAAAAAAAAA",
       parentId: PlacesSyncUtils.bookmarks.guidToRecordId(PlacesUtils.bookmarks.menuGuid),
       type: "folder",
       title: "conflicting folder",
     }], { needsMerge: true });
     await PlacesTestUtils.markBookmarksAsSynced();
 
     await Assert.rejects(buf.apply(), /Can't merge different item kinds/);
-    Assert.ok(sawMismatchEvent, "saw expected mismatch event");
+    Assert.ok(sawMismatchError, "saw expected mismatch event");
   } finally {
     await PlacesUtils.bookmarks.eraseEverything();
     await PlacesSyncUtils.bookmarks.reset();
   }
 });
--- a/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js
+++ b/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js
@@ -642,17 +642,18 @@ add_task(async function test_complex_mov
   }]));
 
   info("Apply remote");
   let observer = expectBookmarkChangeNotifications();
   let changesToUpload = await buf.apply();
   deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
   deepEqual(mergeTelemetryEvents, [{
     value: "structure",
-    extra: { new: "1" },
+    extra: { new: 1, remoteRevives: 0, localDeletes: 0, localRevives: 0,
+             remoteDeletes: 0 },
   }], "Should record telemetry with structure change counts");
 
   let idsToUpload = inspectChangeRecords(changesToUpload);
   deepEqual(idsToUpload, {
     updated: ["bookmarkDDDD", "folderAAAAAA"],
     deleted: [],
   }, "Should upload new records for (A D)");
 
--- a/toolkit/components/places/tests/sync/test_bookmark_validation.js
+++ b/toolkit/components/places/tests/sync/test_bookmark_validation.js
@@ -43,17 +43,17 @@ add_task(async function test_inconsisten
       guid: "bookmarkFFFF",
       title: "F",
       url: "http://example.com/f",
     }],
   });
   await PlacesUtils.bookmarks.remove("bookmarkCCCC");
   await PlacesUtils.bookmarks.remove("bookmarkDDDD");
 
-  deepEqual(await buf.fetchInconsistencies(), {
+  deepEqual(await buf.fetchSyncStatusMismatches(), {
     missingLocal: [],
     missingRemote: [],
     wrongSyncStatus: [],
   }, "Should not report inconsistencies with empty mirror");
 
   info("Set up mirror");
   await storeRecords(buf, [{
     id: "menu",
@@ -91,17 +91,17 @@ add_task(async function test_inconsisten
     title: "H",
     bmkUri: "http://example.com/h",
   }, {
     id: "bookmarkIIII",
     deleted: true,
   }]);
 
   let { missingLocal, missingRemote, wrongSyncStatus } =
-    await buf.fetchInconsistencies();
+    await buf.fetchSyncStatusMismatches();
   deepEqual(missingLocal, ["bookmarkGGGG"],
     "Should report merged remote items that don't exist locally");
   deepEqual(missingRemote.sort(), ["bookmarkBBBB", "bookmarkCCCC"],
     "Should report NORMAL local items that don't exist remotely");
   deepEqual(wrongSyncStatus.sort(), [PlacesUtils.bookmarks.menuGuid,
     PlacesUtils.bookmarks.toolbarGuid, PlacesUtils.bookmarks.unfiledGuid,
     PlacesUtils.bookmarks.mobileGuid, "bookmarkFFFF"].sort(),
     "Should report remote items with wrong local sync status");