Merge autoland to mozilla-central a=merge
authorRazvan Maries <rmaries@mozilla.com>
Thu, 13 Feb 2020 05:29:59 +0200
changeset 513644 2f6870dd1b99edaba1de4d2aa97f3910a640c5bf
parent 513556 5fa1a31cb9547a0400f0955707e46b3046680123 (current diff)
parent 513643 5d5c8600ac9e0a4fc449f62cfaf2057ae509d44a (diff)
child 513645 b0b5ea1916c54c36caed656a69dedb2ac3ced574
child 513671 11970ade757637cd547ef98e0b17e4e84d5203ff
push id37118
push userrmaries@mozilla.com
push dateThu, 13 Feb 2020 03:57:45 +0000
treeherdermozilla-central@2f6870dd1b99 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone75.0a1
first release with
nightly linux32
2f6870dd1b99 / 75.0a1 / 20200213035745 / files
nightly linux64
2f6870dd1b99 / 75.0a1 / 20200213035745 / files
nightly mac
2f6870dd1b99 / 75.0a1 / 20200213035745 / files
nightly win32
2f6870dd1b99 / 75.0a1 / 20200213035745 / files
nightly win64
2f6870dd1b99 / 75.0a1 / 20200213035745 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge autoland to mozilla-central a=merge
taskcluster/ci/nightly-l10n-signing/kind.yml
taskcluster/ci/nightly-l10n/kind.yml
taskcluster/taskgraph/transforms/nightly_l10n_signing.py
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-from-serviceworker.https.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-cross-in-cross-none-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-cross-in-cross-self-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-cross-in-cross-url-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-cross-in-same-none-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-cross-in-same-self-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-cross-in-same-url-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-same-in-cross-none-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-same-in-cross-self-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-same-in-cross-url-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-same-in-same-none-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-nested-same-in-same-url-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-none-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-overrides-xfo.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-self-block.html.ini
testing/web-platform/meta/content-security-policy/frame-ancestors/frame-ancestors-url-block.html.ini
testing/web-platform/meta/loading/lazyload/below-viewport-image-loading-lazy-load-event.tentative.html.ini
testing/web-platform/meta/loading/lazyload/disconnected-image-loading-lazy.tentative.html.ini
testing/web-platform/meta/loading/lazyload/image-loading-lazy-load-event.tentative.html.ini
testing/web-platform/meta/loading/lazyload/image-loading-lazy.tentative.html.ini
testing/web-platform/meta/loading/lazyload/move-element-and-scroll.tentative.html.ini
testing/web-platform/meta/loading/lazyload/not-rendered-below-viewport-image-loading-lazy.tentative.html.ini
testing/web-platform/meta/loading/lazyload/not-rendered-image-loading-lazy.tentative.html.ini
testing/web-platform/meta/loading/lazyload/picture-loading-lazy.tentative.html.ini
testing/web-platform/meta/loading/lazyload/remove-element-and-scroll.tentative.html.ini
third_party/rust/unic-langid-impl/benches/add_likely_subtags.rs
--- a/.eslintignore
+++ b/.eslintignore
@@ -51,19 +51,16 @@ browser/components/pocket/content/panels
 browser/components/newtab/data/
 browser/components/newtab/logs/
 
 # The only file in browser/locales/ is pre-processed.
 browser/locales/
 # Generated data files
 browser/extensions/formautofill/phonenumberutils/PhoneNumberMetaData.jsm
 
-# Soon to be removed (bug 1609815)
-devtools/client/webreplay/
-
 # Ignore devtools debugger files which aren't intended for linting, and also
 # aren't included in any .eslintignore or .prettierignore file.
 # See https://github.com/firefox-devtools/debugger/blob/master/package.json#L24
 devtools/client/debugger/configs/
 devtools/client/debugger/dist/
 devtools/client/debugger/flow-typed/
 devtools/client/debugger/images/
 devtools/client/debugger/test/
@@ -173,19 +170,16 @@ services/common/kinto-http-client.js
 services/common/kinto-offline-client.js
 
 # Webpack-bundled library
 services/fxaccounts/FxAccountsPairingChannel.js
 
 # Servo is imported.
 servo/
 
-# third party modules
-testing/mochitest/tests/Harness_sanity/
-
 # Test files that we don't want to lint (preprocessed, minified etc)
 testing/marionette/atom.js
 testing/mozbase/mozprofile/tests/files/prefs_with_comments.js
 testing/talos/talos/scripts/jszip.min.js
 testing/talos/talos/startup_test/sessionrestore/profile/sessionstore.js
 testing/talos/talos/startup_test/sessionrestore/profile-manywindows/sessionstore.js
 testing/talos/talos/tests/devtools/addon/content/pages/
 # Runing Talos may extract data here, see bug 1435677.
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -27,16 +27,17 @@ const browserTestPaths = [
 ];
 
 const mochitestTestPaths = [
   // Note: we do not want to match testing/mochitest as that would apply
   // too many globals for that directory.
   "**/test/mochitest/",
   "**/tests/mochitest/",
   "testing/mochitest/tests/SimpleTest/",
+  "testing/mochitest/tests/Harness_sanity/",
 ];
 
 const chromeTestPaths = [
   "**/test*/chrome/",
 ];
 
 const ignorePatterns = [
   ...fs.readFileSync(
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1251,19 +1251,19 @@ dependencies = [
  "crc32fast",
  "libc",
  "miniz-sys",
  "miniz_oxide",
 ]
 
 [[package]]
 name = "fluent-langneg"
-version = "0.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55e840a3a9938e6dd9a57a6a3be02bef1d51b1d75b883cdfe84810c7e7ca1293"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5815efd5542e40841cd34ef9003822352b04c67a70c595c6758597c72e1f56"
 dependencies = [
  "unic-langid",
 ]
 
 [[package]]
 name = "fluent-langneg-ffi"
 version = "0.1.0"
 dependencies = [
@@ -2846,20 +2846,23 @@ dependencies = [
 ]
 
 [[package]]
 name = "osclientcerts-static"
 version = "0.1.4"
 dependencies = [
  "bindgen",
  "byteorder",
+ "core-foundation",
  "env_logger",
  "lazy_static",
+ "libloading",
  "log",
  "pkcs11",
+ "rental",
  "sha2",
  "winapi 0.3.7",
 ]
 
 [[package]]
 name = "owning_ref"
 version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4411,19 +4414,19 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6d7b39d0c32eba57d52d334e4bdd150df6e755264eefaa1ae2e7cd125f35e1ca"
 dependencies = [
  "arrayvec",
 ]
 
 [[package]]
 name = "unic-langid"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7935b530ca240640bf8dd67d04301a3ed02bfc8635105fea9e9a26477143ca22"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d81136159f779c35b10655f45210c71cd5ca5a45aadfe9840a61c7071735ed"
 dependencies = [
  "unic-langid-impl",
 ]
 
 [[package]]
 name = "unic-langid-ffi"
 version = "0.1.0"
 dependencies = [
@@ -4431,19 +4434,19 @@ dependencies = [
  "nsstring",
  "thin-vec",
  "unic-langid",
  "xpcom",
 ]
 
 [[package]]
 name = "unic-langid-impl"
-version = "0.7.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86ab4a5be993d5b9d082476a7dd7149c083cf63a72469e700c09e69784511957"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c43c61e94492eb67f20facc7b025778a904de83d953d8fcb60dd9adfd6e2d0ea"
 dependencies = [
  "tinystr",
 ]
 
 [[package]]
 name = "unicase"
 version = "2.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1301,16 +1301,18 @@ pref("browser.newtabpage.activity-stream
 pref("browser.newtabpage.activity-stream.asrouter.useRemoteL10n", true);
 
 // These prefs control if Discovery Stream is enabled.
 pref("browser.newtabpage.activity-stream.discoverystream.enabled", true);
 pref("browser.newtabpage.activity-stream.discoverystream.hardcoded-basic-layout", false);
 pref("browser.newtabpage.activity-stream.discoverystream.spocs-endpoint", "");
 // List of langs that get the 7 row layout.
 pref("browser.newtabpage.activity-stream.discoverystream.lang-layout-config", "en");
+// List of regions that get stories by default.
+pref("browser.newtabpage.activity-stream.discoverystream.region-stories-config", "US,DE,CA");
 // Switch between different versions of the recommendation provider.
 pref("browser.newtabpage.activity-stream.discoverystream.personalization.version", 1);
 // Configurable keys used by personalization version 2.
 pref("browser.newtabpage.activity-stream.discoverystream.personalization.modelKeys", "nb_model_business, nb_model_career, nb_model_education, nb_model_entertainment, nb_model_environment, nb_model_food, nb_model_gaming, nb_model_health_fitness, nb_model_parenting, nb_model_personal_finance, nb_model_politics, nb_model_science, nb_model_self_improvement, nb_model_sports, nb_model_technology, nb_model_travel");
 
 // The pref controls if search hand-off is enabled for Activity Stream.
 #ifdef NIGHTLY_BUILD
   pref("browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", true);
@@ -1518,21 +1520,17 @@ pref("toolkit.telemetry.shutdownPingSend
 pref("toolkit.telemetry.firstShutdownPing.enabled", true);
 // Enables sending the 'new-profile' ping on new profiles.
 pref("toolkit.telemetry.newProfilePing.enabled", true);
 // Enables sending 'update' pings on Firefox updates.
 pref("toolkit.telemetry.updatePing.enabled", true);
 // Enables sending 'bhr' pings when the browser hangs.
 pref("toolkit.telemetry.bhrPing.enabled", true);
 // Whether to enable Ecosystem Telemetry, requires a restart.
-#ifdef NIGHTLY_BUILD
-  pref("toolkit.telemetry.ecosystemtelemetry.enabled", true);
-#else
-  pref("toolkit.telemetry.ecosystemtelemetry.enabled", false);
-#endif
+pref("toolkit.telemetry.ecosystemtelemetry.enabled", false);
 
 // Ping Centre Telemetry settings.
 pref("browser.ping-centre.telemetry", true);
 pref("browser.ping-centre.log", false);
 
 // Enable GMP support in the addon manager.
 pref("media.gmp-provider.enabled", true);
 
--- a/browser/base/content/browser-toolbarKeyNav.js
+++ b/browser/base/content/browser-toolbarKeyNav.js
@@ -163,16 +163,52 @@ ToolbarKeyboardNavigator = {
         document.commandDispatcher.rewindFocus();
         return;
       }
     }
 
     walker.currentNode = aEvent.target;
     let button = walker.nextNode();
     if (!button || !this._isButton(button)) {
+      // If we think we're moving backward, and focus came from outside the
+      // toolbox, we might actually have wrapped around. This currently only
+      // happens in popup windows (because in normal windows, focus first
+      // goes to the tabstrip, where we don't have tabstops). In this case,
+      // the event target was the first tabstop. If we can't find a button,
+      // e.g. because we're in a popup where most buttons are hidden, we
+      // should ensure focus keeps moving forward:
+      if (
+        oldFocus &&
+        this._isFocusMovingBackward &&
+        !gNavToolbox.contains(oldFocus)
+      ) {
+        let allStops = Array.from(
+          gNavToolbox.querySelectorAll("toolbartabstop")
+        );
+        // Find the previous toolbartabstop:
+        let earlierVisibleStopIndex = allStops.indexOf(aEvent.target) - 1;
+        // Then work out if any of the earlier ones are in a visible
+        // toolbar:
+        while (earlierVisibleStopIndex >= 0) {
+          let stopToolbar = allStops[earlierVisibleStopIndex].closest(
+            "toolbar"
+          );
+          if (
+            window.windowUtils.getBoundsWithoutFlushing(stopToolbar).height > 0
+          ) {
+            break;
+          }
+          earlierVisibleStopIndex--;
+        }
+        // If we couldn't find any earlier visible stops, we're not moving
+        // backwards, we're moving forwards and wrapped around:
+        if (earlierVisibleStopIndex == -1) {
+          this._isFocusMovingBackward = false;
+        }
+      }
       // No navigable buttons for this tab stop. Skip it.
       if (this._isFocusMovingBackward) {
         document.commandDispatcher.rewindFocus();
       } else {
         document.commandDispatcher.advanceFocus();
       }
       return;
     }
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -10,16 +10,17 @@ var { Services } = ChromeUtils.import("r
 var { AppConstants } = ChromeUtils.import(
   "resource://gre/modules/AppConstants.jsm"
 );
 ChromeUtils.import("resource://gre/modules/NotificationDB.jsm");
 
 // lazy module getters
 
 XPCOMUtils.defineLazyModuleGetters(this, {
+  AboutNewTabStartupRecorder: "resource:///modules/AboutNewTabService.jsm",
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   AMTelemetry: "resource://gre/modules/AddonManager.jsm",
   NewTabPagePreloading: "resource:///modules/NewTabPagePreloading.jsm",
   BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.jsm",
   BrowserUtils: "resource://gre/modules/BrowserUtils.jsm",
   BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
   CFRPageActions: "resource://activity-stream/lib/CFRPageActions.jsm",
   CharsetMenu: "resource://gre/modules/CharsetMenu.jsm",
@@ -2339,16 +2340,18 @@ var gBrowserInit = {
 
       let uri = window.arguments[0];
       let defaultArgs = Cc["@mozilla.org/browser/clh;1"].getService(
         Ci.nsIBrowserHandler
       ).defaultArgs;
 
       // If the given URI is different from the homepage, we want to load it.
       if (uri != defaultArgs) {
+        AboutNewTabStartupRecorder.noteNonDefaultStartup();
+
         if (uri instanceof Ci.nsIArray) {
           // Transform the nsIArray of nsISupportsString's into a JS Array of
           // JS strings.
           return Array.from(
             uri.enumerate(Ci.nsISupportsString),
             supportStr => supportStr.data
           );
         } else if (uri instanceof Ci.nsISupportsString) {
--- a/browser/base/content/test/keyboard/browser.ini
+++ b/browser/base/content/test/keyboard/browser.ini
@@ -1,8 +1,10 @@
 [DEFAULT]
 support-files = head.js
 
+[browser_popup_keyNav.js]
+support-files = focusableContent.html
 [browser_toolbarButtonKeyPress.js]
 skip-if = os == "linux" #Bug 1532501
 [browser_toolbarKeyNav.js]
 skip-if = fission && debug # Crashes: @ mozilla::dom::ServiceWorkerManagerService::PropagateRegistration(unsigned long, mozilla::dom::ServiceWorkerRegistrationData&)
 support-files = !/browser/base/content/test/permissions/permissions.html
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/keyboard/browser_popup_keyNav.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+  "chrome://mochitests/content",
+  "http://example.com"
+);
+
+/**
+ * Keyboard navigation has some edgecases in popups because
+ * there is no tabstrip or menubar. Check that tabbing forward
+ * and backward to/from the content document works:
+ */
+add_task(async function test_popup_keynav() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      ["browser.toolbars.keyboard_navigation", true],
+      ["accessibility.tabfocus", 7],
+    ],
+  });
+
+  const kURL = TEST_PATH + "focusableContent.html";
+  await BrowserTestUtils.withNewTab(kURL, async browser => {
+    let windowPromise = BrowserTestUtils.waitForNewWindow({
+      url: kURL,
+    });
+    SpecialPowers.spawn(browser, [], () => {
+      content.window.open(
+        content.location.href,
+        "_blank",
+        "height=500,width=500,menubar=no,toolbar=no,status=1,resizable=1"
+      );
+    });
+    let win = await windowPromise;
+    let hamburgerButton = win.document.getElementById("PanelUI-menu-button");
+    forceFocus(hamburgerButton);
+    await expectFocusAfterKey("Tab", win.gBrowser.selectedBrowser, false, win);
+    // Focus the button inside the webpage.
+    EventUtils.synthesizeKey("KEY_Tab", {}, win);
+    // Focus the first item in the URL bar
+    let firstButton = win.document
+      .getElementById("urlbar-container")
+      .querySelector("toolbarbutton,[role=button]");
+    await expectFocusAfterKey("Tab", firstButton, false, win);
+    await BrowserTestUtils.closeWindow(win);
+  });
+});
--- a/browser/base/content/test/keyboard/browser_toolbarKeyNav.js
+++ b/browser/base/content/test/keyboard/browser_toolbarKeyNav.js
@@ -14,54 +14,16 @@ const PERMISSIONS_PAGE =
 
 // The DevEdition has the DevTools button in the toolbar by default. Remove it
 // to prevent branch-specific rules what button should be focused.
 function resetToolbarWithoutDevEditionButtons() {
   CustomizableUI.reset();
   CustomizableUI.removeWidgetFromArea("developer-button");
 }
 
-async function expectFocusAfterKey(
-  aKey,
-  aFocus,
-  aAncestorOk = false,
-  aWindow = window
-) {
-  let res = aKey.match(/^(Shift\+)?(?:(.)|(.+))$/);
-  let shift = Boolean(res[1]);
-  let key;
-  if (res[2]) {
-    key = res[2]; // Character.
-  } else {
-    key = "KEY_" + res[3]; // Tab, ArrowRight, etc.
-  }
-  let expected;
-  let friendlyExpected;
-  if (typeof aFocus == "string") {
-    expected = aWindow.document.getElementById(aFocus);
-    friendlyExpected = aFocus;
-  } else {
-    expected = aFocus;
-    if (aFocus == aWindow.gURLBar.inputField) {
-      friendlyExpected = "URL bar input";
-    } else if (aFocus == aWindow.gBrowser.selectedBrowser) {
-      friendlyExpected = "Web document";
-    }
-  }
-  info("Listening on item " + (expected.id || expected.className));
-  let focused = BrowserTestUtils.waitForEvent(expected, "focus", aAncestorOk);
-  EventUtils.synthesizeKey(key, { shiftKey: shift }, aWindow);
-  let receivedEvent = await focused;
-  info(
-    "Got focus on item: " +
-      (receivedEvent.target.id || receivedEvent.target.className)
-  );
-  ok(true, friendlyExpected + " focused after " + aKey + " pressed");
-}
-
 function startFromUrlBar(aWindow = window) {
   aWindow.gURLBar.focus();
   is(
     aWindow.document.activeElement,
     aWindow.gURLBar.inputField,
     "URL bar focused for start of test"
   );
 }
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/keyboard/focusableContent.html
@@ -0,0 +1,1 @@
+<button>Just a button here to have something focusable.</button>
--- a/browser/base/content/test/keyboard/head.js
+++ b/browser/base/content/test/keyboard/head.js
@@ -10,8 +10,46 @@
  * when a user is navigating with the keyboard. This function forces focus as
  * is done during toolbar keyboard navigation.
  */
 function forceFocus(aElem) {
   aElem.setAttribute("tabindex", "-1");
   aElem.focus();
   aElem.removeAttribute("tabindex");
 }
+
+async function expectFocusAfterKey(
+  aKey,
+  aFocus,
+  aAncestorOk = false,
+  aWindow = window
+) {
+  let res = aKey.match(/^(Shift\+)?(?:(.)|(.+))$/);
+  let shift = Boolean(res[1]);
+  let key;
+  if (res[2]) {
+    key = res[2]; // Character.
+  } else {
+    key = "KEY_" + res[3]; // Tab, ArrowRight, etc.
+  }
+  let expected;
+  let friendlyExpected;
+  if (typeof aFocus == "string") {
+    expected = aWindow.document.getElementById(aFocus);
+    friendlyExpected = aFocus;
+  } else {
+    expected = aFocus;
+    if (aFocus == aWindow.gURLBar.inputField) {
+      friendlyExpected = "URL bar input";
+    } else if (aFocus == aWindow.gBrowser.selectedBrowser) {
+      friendlyExpected = "Web document";
+    }
+  }
+  info("Listening on item " + (expected.id || expected.className));
+  let focused = BrowserTestUtils.waitForEvent(expected, "focus", aAncestorOk);
+  EventUtils.synthesizeKey(key, { shiftKey: shift }, aWindow);
+  let receivedEvent = await focused;
+  info(
+    "Got focus on item: " +
+      (receivedEvent.target.id || receivedEvent.target.className)
+  );
+  ok(true, friendlyExpected + " focused after " + aKey + " pressed");
+}
--- a/browser/base/content/test/popups/browser.ini
+++ b/browser/base/content/test/popups/browser.ini
@@ -5,17 +5,16 @@ support-files =
 [browser_popup_blocker.js]
 support-files =
   popup_blocker.html
   popup_blocker_a.html
   popup_blocker_b.html
   popup_blocker_10_popups.html
 skip-if = (os == 'linux') || (e10s && debug) # Frequent bug 1081925 and bug 1125520 failures
 [browser_popup_frames.js]
-fail-if = fission
 support-files =
   popup_blocker.html
   popup_blocker_a.html
   popup_blocker_b.html
 [browser_popup_blocker_identity_block.js]
 support-files =
   popup_blocker2.html
   popup_blocker_a.html
--- a/browser/base/content/test/popups/browser_popup_frames.js
+++ b/browser/base/content/test/popups/browser_popup_frames.js
@@ -14,24 +14,25 @@ add_task(async function test_opening_blo
   });
 
   // Open the test page.
   let tab = await BrowserTestUtils.openNewForegroundTab(
     gBrowser,
     "data:text/html,Hello"
   );
 
-  await SpecialPowers.spawn(
+  let popupframeBC = await SpecialPowers.spawn(
     tab.linkedBrowser,
     [baseURL + "popup_blocker.html"],
     uri => {
       let iframe = content.document.createElement("iframe");
       iframe.id = "popupframe";
       iframe.src = uri;
       content.document.body.appendChild(iframe);
+      return iframe.browsingContext;
     }
   );
 
   // Wait for the popup-blocked notification.
   let notification;
   await BrowserTestUtils.waitForCondition(
     () =>
       (notification = gBrowser
@@ -60,20 +61,18 @@ add_task(async function test_opening_blo
   ok(notification, "Should still have notification");
 
   pageHideHappened = BrowserTestUtils.waitForContentEvent(
     tab.linkedBrowser,
     "pagehide",
     true
   );
   // Now navigate the subframe.
-  await SpecialPowers.spawn(tab.linkedBrowser, [], async function() {
-    content.document.getElementById(
-      "popupframe"
-    ).contentDocument.location.href = "about:blank";
+  await SpecialPowers.spawn(popupframeBC, [], async function() {
+    content.document.location.href = "about:blank";
   });
   await pageHideHappened;
   await BrowserTestUtils.waitForCondition(
     () =>
       !gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked"),
     "Notification should go away"
   );
   ok(
--- a/browser/components/enterprisepolicies/Policies.jsm
+++ b/browser/components/enterprisepolicies/Policies.jsm
@@ -1477,16 +1477,46 @@ var Policies = {
   },
 
   SupportMenu: {
     onProfileAfterChange(manager, param) {
       manager.setSupportMenu(param);
     },
   },
 
+  UserMessaging: {
+    onBeforeAddons(manager, param) {
+      let locked = false;
+      if ("Locked" in param) {
+        locked = param.Locked;
+      }
+      if ("WhatsNew" in param) {
+        setDefaultPref(
+          "browser.messaging-system.whatsNewPanel.enabled",
+          param.WhatsNew,
+          locked
+        );
+      }
+      if ("ExtensionRecommendations" in param) {
+        setDefaultPref(
+          "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+          param.ExtensionRecommendations,
+          locked
+        );
+      }
+      if ("FeatureRecommendations" in param) {
+        setDefaultPref(
+          "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons",
+          param.FeatureRecommendations,
+          locked
+        );
+      }
+    },
+  },
+
   WebsiteFilter: {
     onBeforeUIStartup(manager, param) {
       this.filter = new WebsiteFilter(
         param.Block || [],
         param.Exceptions || []
       );
     },
   },
--- a/browser/components/enterprisepolicies/schemas/policies-schema.json
+++ b/browser/components/enterprisepolicies/schemas/policies-schema.json
@@ -1049,16 +1049,34 @@
         },
         "AccessKey": {
           "type": "string"
         }
       },
       "required": ["Title", "URL"],
     },
 
+    "UserMessaging": {
+      "type": "object",
+      "properties": {
+        "WhatsNew": {
+          "type": "boolean"
+        },
+        "ExtensionRecommendations": {
+          "type": "boolean"
+        },
+        "FeatureRecommendations": {
+          "type": "boolean"
+        },
+        "Locked": {
+          "type": "boolean"
+        }
+      }
+    },
+
     "WebsiteFilter": {
       "type": "object",
       "properties": {
         "Block": {
           "type": "array",
           "items": {
             "type": "string"
           }
--- a/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js
@@ -15,16 +15,19 @@ AddonTestUtils.init(this);
 AddonTestUtils.overrideCertDB();
 
 AddonTestUtils.createAppInfo(
   "xpcshell@tests.mozilla.org",
   "XPCShell",
   "1",
   "42"
 );
+// Override ExtensionXPCShellUtils.jsm's overriding of the pref as the
+// search service needs it.
+Services.prefs.clearUserPref("services.settings.default_bucket");
 
 async function setupRemoteSettings() {
   const settings = await RemoteSettings("hijack-blocklists");
   sinon.stub(settings, "get").returns([
     {
       id: "homepage-urls",
       matches: ["ignore=me"],
       _status: "synced",
--- a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js
@@ -18,16 +18,19 @@ const URLTYPE_SUGGEST_JSON = "applicatio
 
 AddonTestUtils.init(this);
 AddonTestUtils.createAppInfo(
   "xpcshell@tests.mozilla.org",
   "XPCShell",
   "42",
   "42"
 );
+// Override ExtensionXPCShellUtils.jsm's overriding of the pref as the
+// search service needs it.
+Services.prefs.clearUserPref("services.settings.default_bucket");
 
 add_task(async function setup() {
   await AddonTestUtils.promiseStartupManager();
   await Services.search.init();
 });
 
 add_task(async function test_extension_adding_engine() {
   let ext1 = ExtensionTestUtils.loadExtension({
--- a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js
@@ -10,16 +10,19 @@ const { AddonTestUtils } = ChromeUtils.i
 AddonTestUtils.init(this);
 AddonTestUtils.overrideCertDB();
 AddonTestUtils.createAppInfo(
   "xpcshell@tests.mozilla.org",
   "XPCShell",
   "42",
   "42"
 );
+// Override ExtensionXPCShellUtils.jsm's overriding of the pref as the
+// search service needs it.
+Services.prefs.clearUserPref("services.settings.default_bucket");
 
 let { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
 
 add_task(async function setup() {
   await promiseStartupManager();
   await Services.search.init();
   registerCleanupFunction(async () => {
     await promiseShutdownManager();
--- a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js
@@ -17,16 +17,19 @@ ChromeUtils.defineModuleGetter(
 AddonTestUtils.init(this);
 AddonTestUtils.overrideCertDB();
 AddonTestUtils.createAppInfo(
   "xpcshell@tests.mozilla.org",
   "XPCShell",
   "42",
   "42"
 );
+// Override ExtensionXPCShellUtils.jsm's overriding of the pref as the
+// search service needs it.
+Services.prefs.clearUserPref("services.settings.default_bucket");
 
 add_task(async function shutdown_during_search_provider_startup() {
   await AddonTestUtils.promiseStartupManager();
 
   let extension = ExtensionTestUtils.loadExtension({
     useAddonManager: "permanent",
     manifest: {
       chrome_settings_overrides: {
--- a/browser/components/extensions/test/xpcshell/test_ext_urlbar.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_urlbar.js
@@ -17,16 +17,19 @@ XPCOMUtils.defineLazyModuleGetters(this,
 AddonTestUtils.init(this);
 AddonTestUtils.overrideCertDB();
 AddonTestUtils.createAppInfo(
   "xpcshell@tests.mozilla.org",
   "XPCShell",
   "1",
   "42"
 );
+// Override ExtensionXPCShellUtils.jsm's overriding of the pref as the
+// search service needs it.
+Services.prefs.clearUserPref("services.settings.default_bucket");
 
 function promiseUninstallCompleted(extensionId) {
   return new Promise(resolve => {
     // eslint-disable-next-line mozilla/balanced-listeners
     ExtensionParent.apiManager.on("uninstall-complete", (type, { id }) => {
       if (id === extensionId) {
         executeSoon(resolve);
       }
--- a/browser/components/newtab/AboutNewTabService.jsm
+++ b/browser/components/newtab/AboutNewTabService.jsm
@@ -46,17 +46,16 @@ function AboutNewTabService() {
   );
   if (!IS_RELEASE_OR_BETA) {
     Services.prefs.addObserver(PREF_ACTIVITY_STREAM_DEBUG, this);
   }
 
   // More initialization happens here
   this.toggleActivityStream(true);
   this.initialized = true;
-  this.alreadyRecordedTopsitesPainted = false;
 
   if (IS_MAIN_PROCESS) {
     AboutNewTab.init();
   } else if (IS_PRIVILEGED_PROCESS) {
     Services.obs.addObserver(this, TOPIC_CONTENT_DOCUMENT_INTERACTIVE);
   }
 }
 
@@ -292,39 +291,54 @@ AboutNewTabService.prototype = {
 
   resetNewTabURL() {
     this._overridden = false;
     this._newTabURL = ABOUT_URL;
     this.toggleActivityStream(true, true);
     this.notifyChange();
   },
 
-  maybeRecordTopsitesPainted(timestamp) {
-    if (this.alreadyRecordedTopsitesPainted) {
-      return;
-    }
-
-    const SCALAR_KEY = "timestamps.about_home_topsites_first_paint";
-
-    let startupInfo = Services.startup.getStartupInfo();
-    let processStartTs = startupInfo.process.getTime();
-    let delta = Math.round(timestamp - processStartTs);
-    Services.telemetry.scalarSet(SCALAR_KEY, delta);
-    this.alreadyRecordedTopsitesPainted = true;
-  },
-
   uninit() {
     if (!this.initialized) {
       return;
     }
     Services.obs.removeObserver(this, TOPIC_APP_QUIT);
     Services.prefs.removeObserver(
       PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS,
       this
     );
     if (!IS_RELEASE_OR_BETA) {
       Services.prefs.removeObserver(PREF_ACTIVITY_STREAM_DEBUG, this);
     }
     this.initialized = false;
   },
 };
 
-const EXPORTED_SYMBOLS = ["AboutNewTabService"];
+/**
+ * We split out the definition of AboutNewTabStartupRecorder from
+ * AboutNewTabService to avoid initializing the AboutNewTabService
+ * unnecessarily early when we just want to record some startup
+ * data.
+ */
+const AboutNewTabStartupRecorder = {
+  _alreadyRecordedTopsitesPainted: false,
+  _nonDefaultStartup: false,
+
+  noteNonDefaultStartup() {
+    this._nonDefaultStartup = true;
+  },
+
+  maybeRecordTopsitesPainted(timestamp) {
+    if (this._alreadyRecordedTopsitesPainted || this._nonDefaultStartup) {
+      return;
+    }
+
+    const SCALAR_KEY = "timestamps.about_home_topsites_first_paint";
+
+    let startupInfo = Services.startup.getStartupInfo();
+    let processStartTs = startupInfo.process.getTime();
+    let delta = Math.round(timestamp - processStartTs);
+    Services.telemetry.scalarSet(SCALAR_KEY, delta);
+    this._alreadyRecordedTopsitesPainted = true;
+  },
+};
+
+const EXPORTED_SYMBOLS = ["AboutNewTabService", "AboutNewTabStartupRecorder"];
--- a/browser/components/newtab/common/Reducers.jsm
+++ b/browser/components/newtab/common/Reducers.jsm
@@ -61,17 +61,20 @@ const INITIAL_STATE = {
         // "https://foo.com/feed1": {lastUpdated: 123, data: []}
       },
       loaded: false,
     },
     spocs: {
       spocs_endpoint: "",
       spocs_per_domain: 1,
       lastUpdated: null,
-      data: {}, // {spocs: []}
+      data: {
+        // "spocs": {title: "", context: "", items: []},
+        // "placement1": {title: "", context: "", items: []},
+      },
       loaded: false,
       frequency_caps: [],
       blocked: [],
       placements: [],
     },
   },
   Personalization: {
     version: 1,
@@ -561,21 +564,28 @@ function DiscoveryStream(prevState = INI
 
   const handlePlacements = handleSites => {
     const { data, placements } = prevState.spocs;
     const result = {};
 
     const forPlacement = placement => {
       const placementSpocs = data[placement.name];
 
-      if (!placementSpocs || !placementSpocs.length) {
+      if (
+        !placementSpocs ||
+        !placementSpocs.items ||
+        !placementSpocs.items.length
+      ) {
         return;
       }
 
-      result[placement.name] = handleSites(placementSpocs);
+      result[placement.name] = {
+        ...placementSpocs,
+        items: handleSites(placementSpocs.items),
+      };
     };
 
     if (!placements || !placements.length) {
       [{ name: "spocs" }].forEach(forPlacement);
     } else {
       placements.forEach(forPlacement);
     }
     return result;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
@@ -1,14 +1,15 @@
 /* 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/. */
 
 import { actionCreators as ac } from "common/Actions.jsm";
 import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
+import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid";
 import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
 import { connect } from "react-redux";
 import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
 import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage";
 import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal";
 import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo";
 import { Hero } from "content-src/components/DiscoveryStreamComponents/Hero/Hero";
 import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights";
@@ -202,16 +203,45 @@ export class _DiscoveryStreamBase extend
       case "Navigation":
         return (
           <Navigation
             links={component.properties.links}
             alignment={component.properties.alignment}
             header={component.header}
           />
         );
+      case "CollectionCardGrid":
+        if (
+          !component.data ||
+          !component.data.spocs ||
+          !component.data.spocs[0] ||
+          // We only display complete collections.
+          component.data.spocs.length < 3
+        ) {
+          return null;
+        }
+        return (
+          <DSDismiss
+            dispatch={this.props.dispatch}
+            shouldSendImpressionStats={true}
+            extraClasses={`ds-dismiss-ds-collection`}
+          >
+            <CollectionCardGrid
+              placement={component.placement}
+              data={component.data}
+              feed={component.feed}
+              border={component.properties.border}
+              type={component.type}
+              dispatch={this.props.dispatch}
+              items={component.properties.items}
+              cta_variant={component.cta_variant}
+              display_engagement_labels={ENGAGEMENT_LABEL_ENABLED}
+            />
+          </DSDismiss>
+        );
       case "CardGrid":
         return (
           <CardGrid
             title={component.header && component.header.title}
             data={component.data}
             feed={component.feed}
             border={component.properties.border}
             type={component.type}
--- a/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss
@@ -16,21 +16,31 @@
     }
 
     $columns: $columns - 1;
   }
 
   .ds-column-grid {
     display: grid;
     grid-row-gap: var(--gridRowGap);
+
+    // We want to completely hide components with no content,
+    // otherwise, it creates grid-row-gap gaps around nothing.
+    > div:empty {
+      display: none;
+    }
   }
 }
 
 .ds-header {
   margin: 8px 0;
+
+  .ds-context {
+    font-weight: 400;
+  }
 }
 
 .ds-header,
 .ds-layout .section-title span {
   @include dark-theme-only {
     color: $grey-30;
   }
 
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
@@ -73,17 +73,22 @@ export class CardGrid extends React.Pure
     }
 
     // Handle the case where a user has dismissed all recommendations
     const isEmpty = data.recommendations.length === 0;
 
     return (
       <div>
         {this.props.title && (
-          <div className="ds-header">{this.props.title}</div>
+          <div className="ds-header">
+            <div>{this.props.title}</div>
+            {this.props.context && (
+              <div className="ds-context">{this.props.context}</div>
+            )}
+          </div>
         )}
         {isEmpty ? (
           <div className="ds-card-grid empty">
             <DSEmptyState
               status={data.status}
               dispatch={this.props.dispatch}
               feed={this.props.feed}
             />
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx
@@ -0,0 +1,50 @@
+/* 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/. */
+
+import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
+import React from "react";
+import { connect } from "react-redux";
+
+export class _CollectionCardGrid extends React.PureComponent {
+  render() {
+    const { placement, DiscoveryStream, data, feed } = this.props;
+
+    // Handle a render before feed has been fetched by displaying nothing
+    if (!data) {
+      return null;
+    }
+
+    const { title, context } = DiscoveryStream.spocs.data[placement.name] || {};
+
+    // Generally a card grid displays recs with spocs already injected.
+    // Normally it doesn't care which rec is a spoc and which isn't,
+    // it just displays content in a grid.
+    // For collections, we're only displaying a list of spocs.
+    // We don't need to tell the card grid that our list of cards are spocs,
+    // it shouldn't need to care. So we just pass our spocs along as recs.
+    // Think of it as injecting all rec positions with spocs.
+    // Consider maybe making recommendations in CardGrid use a more generic name.
+    const recsData = {
+      recommendations: data.spocs,
+    };
+    return (
+      <div className="ds-collection-card-grid">
+        <CardGrid
+          title={title}
+          context={context}
+          data={recsData}
+          feed={feed}
+          border={this.props.border}
+          type={this.props.type}
+          dispatch={this.props.dispatch}
+          items={this.props.items}
+        />
+      </div>
+    );
+  }
+}
+
+export const CollectionCardGrid = connect(state => ({
+  DiscoveryStream: state.DiscoveryStream,
+}))(_CollectionCardGrid);
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss
@@ -0,0 +1,11 @@
+.ds-dismiss-ds-collection {
+  .ds-dismiss-button {
+    margin: 0;
+  }
+}
+
+.ds-collection-card-grid {
+  .story-footer {
+    display: none;
+  }
+}
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss
@@ -1,24 +1,15 @@
 .ds-dismiss {
   position: relative;
-  overflow: hidden;
   border-radius: 8px;
   transition-delay: 100ms;
   transition-duration: 200ms;
   transition-property: background;
 
-  &.hovering {
-    @include dark-theme-only {
-      background: $grey-90-30;
-    }
-
-    background: $grey-90-10;
-  }
-
   &:hover {
     .ds-dismiss-button {
       opacity: 1;
     }
   }
 
   .ds-dismiss-button {
     @include dark-theme-only {
@@ -29,20 +20,19 @@
     cursor: pointer;
     height: 32px;
     width: 32px;
     padding: 0;
     display: flex;
     align-items: center;
     justify-content: center;
     position: absolute;
-    right: 0;
+    inset-inline-end: 0;
     top: 0;
     border-radius: 50%;
-    margin: 18px 18px 0 0;
     background: $grey-90-10;
 
     &:hover {
       @include dark-theme-only {
         background: $grey-90-50;
       }
 
       background: $grey-90-20;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
@@ -1,11 +1,25 @@
 .ds-dismiss-ds-text-promo {
   max-width: 744px;
   margin: auto;
+  overflow: hidden;
+
+  &.hovering {
+    @include dark-theme-only {
+      background: $grey-90-30;
+    }
+
+    background: $grey-90-10;
+  }
+
+  .ds-dismiss-button {
+    margin-inline: 0 18px;
+    margin-block: 18px 0;
+  }
 }
 
 .ds-text-promo {
   max-width: 640px;
   margin: 0;
   padding: 18px;
 
   @media(min-width: $break-point-medium) {
--- a/browser/components/newtab/content-src/lib/selectLayoutRender.js
+++ b/browser/components/newtab/content-src/lib/selectLayoutRender.js
@@ -77,17 +77,19 @@ export const selectLayoutRender = ({
   }
 
   const placeholderComponent = component => {
     if (!component.feed) {
       // TODO we now need a placeholder for topsites and textPromo.
       return {
         ...component,
         data: {
-          spocs: [],
+          spocs: {
+            items: [],
+          },
         },
       };
     }
     const data = {
       recommendations: [],
     };
 
     let items = 0;
@@ -109,21 +111,26 @@ export const selectLayoutRender = ({
       component.spocs &&
       component.spocs.positions &&
       component.spocs.positions.length
     ) {
       const placement = component.placement || {};
       const placementName = placement.name || "spocs";
       const spocsData = spocs.data[placementName];
       // We expect a spoc, spocs are loaded, and the server returned spocs.
-      if (spocs.loaded && spocsData && spocsData.length) {
+      if (
+        spocs.loaded &&
+        spocsData &&
+        spocsData.items &&
+        spocsData.items.length
+      ) {
         result = rollForSpocs(
           result,
           component.spocs,
-          spocsData,
+          spocsData.items,
           placementName
         );
       }
     }
     return result;
   };
 
   const handleComponent = component => {
@@ -227,32 +234,37 @@ export const selectLayoutRender = ({
     rollCache.push(...bufferRollCache);
   }
 
   // Generate the payload for the SPOCS Fill ping. Note that a SPOC could be rejected
   // by the `probability_selection` first, then gets chosen for the next position. For
   // all other SPOCS that never went through the probabilistic selection, its reason will
   // be "out_of_position".
   let spocsFill = [];
-  if (spocs.loaded && feeds.loaded && spocs.data.spocs) {
+  if (
+    spocs.loaded &&
+    feeds.loaded &&
+    spocs.data.spocs &&
+    spocs.data.spocs.items
+  ) {
     const chosenSpocsFill = [...chosenSpocs].map(spoc => ({
       id: spoc.id,
       reason: "n/a",
       displayed: 1,
       full_recalc: 0,
     }));
     const unchosenSpocsFill = [...unchosenSpocs]
       .filter(spoc => !chosenSpocs.has(spoc))
       .map(spoc => ({
         id: spoc.id,
         reason: "probability_selection",
         displayed: 0,
         full_recalc: 0,
       }));
-    const outOfPositionSpocsFill = spocs.data.spocs
+    const outOfPositionSpocsFill = spocs.data.spocs.items
       .slice(spocIndexMap.spocs)
       .filter(spoc => !unchosenSpocs.has(spoc))
       .map(spoc => ({
         id: spoc.id,
         reason: "out_of_position",
         displayed: 0,
         full_recalc: 0,
       }));
--- a/browser/components/newtab/content-src/styles/_activity-stream.scss
+++ b/browser/components/newtab/content-src/styles/_activity-stream.scss
@@ -143,16 +143,17 @@ input {
 @import '../components/CollapsibleSection/CollapsibleSection';
 @import '../components/ASRouterAdmin/ASRouterAdmin';
 @import '../components/PocketLoggedInCta/PocketLoggedInCta';
 @import '../components/MoreRecommendations/MoreRecommendations';
 @import '../components/DiscoveryStreamBase/DiscoveryStreamBase';
 
 // Discovery Stream Components
 @import '../components/DiscoveryStreamComponents/CardGrid/CardGrid';
+@import '../components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid';
 @import '../components/DiscoveryStreamComponents/Hero/Hero';
 @import '../components/DiscoveryStreamComponents/Highlights/Highlights';
 @import '../components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule';
 @import '../components/DiscoveryStreamComponents/List/List';
 @import '../components/DiscoveryStreamComponents/Navigation/Navigation';
 @import '../components/DiscoveryStreamComponents/SectionTitle/SectionTitle';
 @import '../components/DiscoveryStreamComponents/TopSites/TopSites';
 @import '../components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu';
--- a/browser/components/newtab/css/activity-stream-linux.css
+++ b/browser/components/newtab/css/activity-stream-linux.css
@@ -1880,19 +1880,23 @@ main {
     grid-column-start: auto;
     grid-column-end: span 2; }
   .discovery-stream.ds-layout .ds-column-1 {
     grid-column-start: auto;
     grid-column-end: span 1; }
   .discovery-stream.ds-layout .ds-column-grid {
     display: grid;
     grid-row-gap: var(--gridRowGap); }
+    .discovery-stream.ds-layout .ds-column-grid > div:empty {
+      display: none; }
 
 .ds-header {
   margin: 8px 0; }
+  .ds-header .ds-context {
+    font-weight: 400; }
 
 .ds-header,
 .ds-layout .section-title span {
   color: #737373;
   font-size: 13px;
   font-weight: 600;
   line-height: 20px; }
   [lwt-newtab-brighttext] .ds-header, [lwt-newtab-brighttext]
@@ -1979,16 +1983,22 @@ main {
     .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-4 .title {
       font-size: 15px;
       -webkit-line-clamp: 3;
       line-height: 20px; }
   .ds-card-grid.empty {
     grid-template-columns: auto; }
 
+.ds-dismiss-ds-collection .ds-dismiss-button {
+  margin: 0; }
+
+.ds-collection-card-grid .story-footer {
+  display: none; }
+
 .ds-hero {
   position: relative; }
   .ds-hero header {
     font-weight: 600; }
   .ds-hero p {
     line-height: 1.538;
     margin: 8px 0; }
   .ds-hero .excerpt {
@@ -2910,41 +2920,35 @@ main {
     position: absolute;
     top: 0;
     width: 100%;
     height: 100%;
     object-fit: cover; }
 
 .ds-dismiss {
   position: relative;
-  overflow: hidden;
   border-radius: 8px;
   transition-delay: 100ms;
   transition-duration: 200ms;
   transition-property: background; }
-  .ds-dismiss.hovering {
-    background: rgba(12, 12, 13, 0.1); }
-    [lwt-newtab-brighttext] .ds-dismiss.hovering {
-      background: rgba(12, 12, 13, 0.3); }
   .ds-dismiss:hover .ds-dismiss-button {
     opacity: 1; }
   .ds-dismiss .ds-dismiss-button {
     border: 0;
     cursor: pointer;
     height: 32px;
     width: 32px;
     padding: 0;
     display: flex;
     align-items: center;
     justify-content: center;
     position: absolute;
-    right: 0;
+    inset-inline-end: 0;
     top: 0;
     border-radius: 50%;
-    margin: 18px 18px 0 0;
     background: rgba(12, 12, 13, 0.1); }
     [lwt-newtab-brighttext] .ds-dismiss .ds-dismiss-button {
       background: rgba(12, 12, 13, 0.3); }
     .ds-dismiss .ds-dismiss-button:hover {
       background: rgba(12, 12, 13, 0.2); }
       [lwt-newtab-brighttext] .ds-dismiss .ds-dismiss-button:hover {
         background: rgba(12, 12, 13, 0.5); }
     .ds-dismiss .ds-dismiss-button:active {
@@ -3065,17 +3069,25 @@ main {
     margin: 0; }
 
 @keyframes spinner {
   to {
     transform: rotate(360deg); } }
 
 .ds-dismiss-ds-text-promo {
   max-width: 744px;
-  margin: auto; }
+  margin: auto;
+  overflow: hidden; }
+  .ds-dismiss-ds-text-promo.hovering {
+    background: rgba(12, 12, 13, 0.1); }
+    [lwt-newtab-brighttext] .ds-dismiss-ds-text-promo.hovering {
+      background: rgba(12, 12, 13, 0.3); }
+  .ds-dismiss-ds-text-promo .ds-dismiss-button {
+    margin-inline: 0 18px;
+    margin-block: 18px 0; }
 
 .ds-text-promo {
   max-width: 640px;
   margin: 0;
   padding: 18px; }
   @media (min-width: 610px) {
     .ds-text-promo {
       display: flex;
--- a/browser/components/newtab/css/activity-stream-mac.css
+++ b/browser/components/newtab/css/activity-stream-mac.css
@@ -1883,19 +1883,23 @@ main {
     grid-column-start: auto;
     grid-column-end: span 2; }
   .discovery-stream.ds-layout .ds-column-1 {
     grid-column-start: auto;
     grid-column-end: span 1; }
   .discovery-stream.ds-layout .ds-column-grid {
     display: grid;
     grid-row-gap: var(--gridRowGap); }
+    .discovery-stream.ds-layout .ds-column-grid > div:empty {
+      display: none; }
 
 .ds-header {
   margin: 8px 0; }
+  .ds-header .ds-context {
+    font-weight: 400; }
 
 .ds-header,
 .ds-layout .section-title span {
   color: #737373;
   font-size: 13px;
   font-weight: 600;
   line-height: 20px; }
   [lwt-newtab-brighttext] .ds-header, [lwt-newtab-brighttext]
@@ -1982,16 +1986,22 @@ main {
     .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-4 .title {
       font-size: 15px;
       -webkit-line-clamp: 3;
       line-height: 20px; }
   .ds-card-grid.empty {
     grid-template-columns: auto; }
 
+.ds-dismiss-ds-collection .ds-dismiss-button {
+  margin: 0; }
+
+.ds-collection-card-grid .story-footer {
+  display: none; }
+
 .ds-hero {
   position: relative; }
   .ds-hero header {
     font-weight: 600; }
   .ds-hero p {
     line-height: 1.538;
     margin: 8px 0; }
   .ds-hero .excerpt {
@@ -2913,41 +2923,35 @@ main {
     position: absolute;
     top: 0;
     width: 100%;
     height: 100%;
     object-fit: cover; }
 
 .ds-dismiss {
   position: relative;
-  overflow: hidden;
   border-radius: 8px;
   transition-delay: 100ms;
   transition-duration: 200ms;
   transition-property: background; }
-  .ds-dismiss.hovering {
-    background: rgba(12, 12, 13, 0.1); }
-    [lwt-newtab-brighttext] .ds-dismiss.hovering {
-      background: rgba(12, 12, 13, 0.3); }
   .ds-dismiss:hover .ds-dismiss-button {
     opacity: 1; }
   .ds-dismiss .ds-dismiss-button {
     border: 0;
     cursor: pointer;
     height: 32px;
     width: 32px;
     padding: 0;
     display: flex;
     align-items: center;
     justify-content: center;
     position: absolute;
-    right: 0;
+    inset-inline-end: 0;
     top: 0;
     border-radius: 50%;
-    margin: 18px 18px 0 0;
     background: rgba(12, 12, 13, 0.1); }
     [lwt-newtab-brighttext] .ds-dismiss .ds-dismiss-button {
       background: rgba(12, 12, 13, 0.3); }
     .ds-dismiss .ds-dismiss-button:hover {
       background: rgba(12, 12, 13, 0.2); }
       [lwt-newtab-brighttext] .ds-dismiss .ds-dismiss-button:hover {
         background: rgba(12, 12, 13, 0.5); }
     .ds-dismiss .ds-dismiss-button:active {
@@ -3068,17 +3072,25 @@ main {
     margin: 0; }
 
 @keyframes spinner {
   to {
     transform: rotate(360deg); } }
 
 .ds-dismiss-ds-text-promo {
   max-width: 744px;
-  margin: auto; }
+  margin: auto;
+  overflow: hidden; }
+  .ds-dismiss-ds-text-promo.hovering {
+    background: rgba(12, 12, 13, 0.1); }
+    [lwt-newtab-brighttext] .ds-dismiss-ds-text-promo.hovering {
+      background: rgba(12, 12, 13, 0.3); }
+  .ds-dismiss-ds-text-promo .ds-dismiss-button {
+    margin-inline: 0 18px;
+    margin-block: 18px 0; }
 
 .ds-text-promo {
   max-width: 640px;
   margin: 0;
   padding: 18px; }
   @media (min-width: 610px) {
     .ds-text-promo {
       display: flex;
--- a/browser/components/newtab/css/activity-stream-windows.css
+++ b/browser/components/newtab/css/activity-stream-windows.css
@@ -1880,19 +1880,23 @@ main {
     grid-column-start: auto;
     grid-column-end: span 2; }
   .discovery-stream.ds-layout .ds-column-1 {
     grid-column-start: auto;
     grid-column-end: span 1; }
   .discovery-stream.ds-layout .ds-column-grid {
     display: grid;
     grid-row-gap: var(--gridRowGap); }
+    .discovery-stream.ds-layout .ds-column-grid > div:empty {
+      display: none; }
 
 .ds-header {
   margin: 8px 0; }
+  .ds-header .ds-context {
+    font-weight: 400; }
 
 .ds-header,
 .ds-layout .section-title span {
   color: #737373;
   font-size: 13px;
   font-weight: 600;
   line-height: 20px; }
   [lwt-newtab-brighttext] .ds-header, [lwt-newtab-brighttext]
@@ -1979,16 +1983,22 @@ main {
     .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-4 .title {
       font-size: 15px;
       -webkit-line-clamp: 3;
       line-height: 20px; }
   .ds-card-grid.empty {
     grid-template-columns: auto; }
 
+.ds-dismiss-ds-collection .ds-dismiss-button {
+  margin: 0; }
+
+.ds-collection-card-grid .story-footer {
+  display: none; }
+
 .ds-hero {
   position: relative; }
   .ds-hero header {
     font-weight: 600; }
   .ds-hero p {
     line-height: 1.538;
     margin: 8px 0; }
   .ds-hero .excerpt {
@@ -2910,41 +2920,35 @@ main {
     position: absolute;
     top: 0;
     width: 100%;
     height: 100%;
     object-fit: cover; }
 
 .ds-dismiss {
   position: relative;
-  overflow: hidden;
   border-radius: 8px;
   transition-delay: 100ms;
   transition-duration: 200ms;
   transition-property: background; }
-  .ds-dismiss.hovering {
-    background: rgba(12, 12, 13, 0.1); }
-    [lwt-newtab-brighttext] .ds-dismiss.hovering {
-      background: rgba(12, 12, 13, 0.3); }
   .ds-dismiss:hover .ds-dismiss-button {
     opacity: 1; }
   .ds-dismiss .ds-dismiss-button {
     border: 0;
     cursor: pointer;
     height: 32px;
     width: 32px;
     padding: 0;
     display: flex;
     align-items: center;
     justify-content: center;
     position: absolute;
-    right: 0;
+    inset-inline-end: 0;
     top: 0;
     border-radius: 50%;
-    margin: 18px 18px 0 0;
     background: rgba(12, 12, 13, 0.1); }
     [lwt-newtab-brighttext] .ds-dismiss .ds-dismiss-button {
       background: rgba(12, 12, 13, 0.3); }
     .ds-dismiss .ds-dismiss-button:hover {
       background: rgba(12, 12, 13, 0.2); }
       [lwt-newtab-brighttext] .ds-dismiss .ds-dismiss-button:hover {
         background: rgba(12, 12, 13, 0.5); }
     .ds-dismiss .ds-dismiss-button:active {
@@ -3065,17 +3069,25 @@ main {
     margin: 0; }
 
 @keyframes spinner {
   to {
     transform: rotate(360deg); } }
 
 .ds-dismiss-ds-text-promo {
   max-width: 744px;
-  margin: auto; }
+  margin: auto;
+  overflow: hidden; }
+  .ds-dismiss-ds-text-promo.hovering {
+    background: rgba(12, 12, 13, 0.1); }
+    [lwt-newtab-brighttext] .ds-dismiss-ds-text-promo.hovering {
+      background: rgba(12, 12, 13, 0.3); }
+  .ds-dismiss-ds-text-promo .ds-dismiss-button {
+    margin-inline: 0 18px;
+    margin-block: 18px 0; }
 
 .ds-text-promo {
   max-width: 640px;
   margin: 0;
   padding: 18px; }
   @media (min-width: 610px) {
     .ds-text-promo {
       display: flex;
--- a/browser/components/newtab/data/content/activity-stream.bundle.js
+++ b/browser/components/newtab/data/content/activity-stream.bundle.js
@@ -88,25 +88,25 @@
 /******/ ([
 /* 0 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var content_src_components_Base_Base__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3);
-/* harmony import */ var content_src_lib_detect_user_session_start__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(77);
+/* harmony import */ var content_src_lib_detect_user_session_start__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(78);
 /* harmony import */ var content_src_lib_init_store__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(6);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(28);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_4__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_5__);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(12);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_6__);
-/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(81);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(82);
 /* 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/. */
 
 
 
 
 
@@ -561,21 +561,21 @@ var actionUtils = {
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Base", function() { return Base; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var content_src_components_ASRouterAdmin_ASRouterAdmin__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4);
 /* harmony import */ var _asrouter_asrouter_content__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(5);
 /* harmony import */ var content_src_components_ConfirmDialog_ConfirmDialog__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(30);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(28);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_4__);
 /* harmony import */ var content_src_components_DiscoveryStreamBase_DiscoveryStreamBase__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(31);
-/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(48);
+/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(49);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_7__);
-/* harmony import */ var content_src_components_Search_Search__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(76);
-/* harmony import */ var content_src_components_Sections_Sections__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(59);
+/* harmony import */ var content_src_components_Search_Search__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(77);
+/* harmony import */ var content_src_components_Sections_Sections__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(60);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
 /* 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/. */
 
 
 
@@ -1994,26 +1994,26 @@ const ASRouterAdmin = Object(react_redux
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ASRouterUtils", function() { return ASRouterUtils; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ASRouterUISurface", function() { return ASRouterUISurface; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var content_src_lib_init_store__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(6);
-/* harmony import */ var _rich_text_strings__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(80);
+/* harmony import */ var _rich_text_strings__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(81);
 /* harmony import */ var _components_ImpressionsWrapper_ImpressionsWrapper__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(8);
-/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(78);
+/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(79);
 /* harmony import */ var content_src_lib_constants__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(11);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_6__);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(12);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_7__);
-/* harmony import */ var _templates_template_manifest__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(79);
-/* harmony import */ var _templates_FirstRun_FirstRun__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(82);
+/* harmony import */ var _templates_template_manifest__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(80);
+/* harmony import */ var _templates_FirstRun_FirstRun__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(83);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
 /* 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/. */
 
 
 
@@ -2839,20 +2839,20 @@ module.exports = {"title":"EOYSnippet","
 /***/ }),
 /* 14 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "convertLinks", function() { return convertLinks; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RichText", function() { return RichText; });
-/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(78);
+/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(79);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
-/* harmony import */ var _rich_text_strings__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(80);
+/* harmony import */ var _rich_text_strings__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(81);
 /* harmony import */ var _template_utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(15);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
 /* 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/. */
 
 
@@ -4086,36 +4086,38 @@ const ConfirmDialog = Object(react_redux
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "isAllowedCSS", function() { return isAllowedCSS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_DiscoveryStreamBase", function() { return _DiscoveryStreamBase; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DiscoveryStreamBase", function() { return DiscoveryStreamBase; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var content_src_components_DiscoveryStreamComponents_CardGrid_CardGrid__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(32);
-/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(47);
-/* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(28);
-/* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_3__);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_DSDismiss_DSDismiss__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(52);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_DSMessage_DSMessage__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(53);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_DSPrivacyModal_DSPrivacyModal__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(54);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_DSTextPromo_DSTextPromo__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(55);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_Hero_Hero__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(56);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_Highlights_Highlights__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(58);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_HorizontalRule_HorizontalRule__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(71);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_List_List__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(57);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_Navigation_Navigation__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(72);
-/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(9);
-/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_13___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_13__);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_SectionTitle_SectionTitle__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(73);
-/* harmony import */ var content_src_lib_selectLayoutRender__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(74);
-/* harmony import */ var content_src_components_DiscoveryStreamComponents_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(75);
-/* 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/. */
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_CollectionCardGrid_CollectionCardGrid__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(47);
+/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(48);
+/* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(28);
+/* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_4__);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_DSDismiss_DSDismiss__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(53);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_DSMessage_DSMessage__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(54);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_DSPrivacyModal_DSPrivacyModal__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(55);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_DSTextPromo_DSTextPromo__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(56);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_Hero_Hero__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(57);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_Highlights_Highlights__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(59);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_HorizontalRule_HorizontalRule__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(72);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_List_List__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(58);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_Navigation_Navigation__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(73);
+/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(9);
+/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_14___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_14__);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_SectionTitle_SectionTitle__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(74);
+/* harmony import */ var content_src_lib_selectLayoutRender__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(75);
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(76);
+/* 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/. */
+
 
 
 
 
 
 
 
 
@@ -4143,17 +4145,17 @@ function isAllowedCSS(property, value) {
   if (value === undefined) {
     return true;
   } // Make sure all urls are of the allowed protocols/prefixes
 
 
   const urls = value.match(/url\("[^"]+"\)/g);
   return !urls || urls.every(url => ALLOWED_CSS_URL_PREFIXES.some(prefix => url.slice(5).startsWith(prefix)));
 }
-class _DiscoveryStreamBase extends react__WEBPACK_IMPORTED_MODULE_13___default.a.PureComponent {
+class _DiscoveryStreamBase extends react__WEBPACK_IMPORTED_MODULE_14___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onStyleMount = this.onStyleMount.bind(this);
   }
 
   onStyleMount(style) {
     // Unmounting style gets rid of old styles, so nothing else to do
     if (!style) {
@@ -4202,26 +4204,26 @@ class _DiscoveryStreamBase extends react
     });
   }
 
   renderComponent(component, embedWidth) {
     const ENGAGEMENT_LABEL_ENABLED = this.props.Prefs.values[`discoverystream.engagementLabelEnabled`];
 
     switch (component.type) {
       case "Highlights":
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_Highlights_Highlights__WEBPACK_IMPORTED_MODULE_9__["Highlights"], null);
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_Highlights_Highlights__WEBPACK_IMPORTED_MODULE_10__["Highlights"], null);
 
       case "TopSites":
         let promoAlignment;
 
         if (component.spocs && component.spocs.positions && component.spocs.positions.length) {
           promoAlignment = component.spocs.positions[0].index === 0 ? "left" : "right";
         }
 
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_16__["TopSites"], {
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_17__["TopSites"], {
           header: component.header,
           data: component.data,
           promoAlignment: promoAlignment
         });
 
       case "TextPromo":
         if (!component.data || !component.data.spocs || !component.data.spocs[0]) {
           return null;
@@ -4236,113 +4238,135 @@ class _DiscoveryStreamBase extends react
           title,
           url,
           context,
           cta,
           flight_id,
           id,
           shim
         } = spoc;
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_DSDismiss_DSDismiss__WEBPACK_IMPORTED_MODULE_4__["DSDismiss"], {
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_DSDismiss_DSDismiss__WEBPACK_IMPORTED_MODULE_5__["DSDismiss"], {
           data: {
             url: spoc.url,
             guid: spoc.id,
             shim: spoc.shim
           },
           dispatch: this.props.dispatch,
           shouldSendImpressionStats: true,
           extraClasses: `ds-dismiss-ds-text-promo`
-        }, react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_DSTextPromo_DSTextPromo__WEBPACK_IMPORTED_MODULE_7__["DSTextPromo"], {
+        }, react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_DSTextPromo_DSTextPromo__WEBPACK_IMPORTED_MODULE_8__["DSTextPromo"], {
           dispatch: this.props.dispatch,
           image: image_src,
           raw_image_src: raw_image_src,
           alt_text: alt_text || title,
           header: title,
           cta_text: cta,
           cta_url: url,
           subtitle: context,
           flightId: flight_id,
           id: id,
           pos: 0,
           shim: shim,
           type: component.type
         }));
 
       case "Message":
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_DSMessage_DSMessage__WEBPACK_IMPORTED_MODULE_5__["DSMessage"], {
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_DSMessage_DSMessage__WEBPACK_IMPORTED_MODULE_6__["DSMessage"], {
           title: component.header && component.header.title,
           subtitle: component.header && component.header.subtitle,
           link_text: component.header && component.header.link_text,
           link_url: component.header && component.header.link_url,
           icon: component.header && component.header.icon
         });
 
       case "SectionTitle":
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_SectionTitle_SectionTitle__WEBPACK_IMPORTED_MODULE_14__["SectionTitle"], {
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_SectionTitle_SectionTitle__WEBPACK_IMPORTED_MODULE_15__["SectionTitle"], {
           header: component.header
         });
 
       case "Navigation":
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_Navigation_Navigation__WEBPACK_IMPORTED_MODULE_12__["Navigation"], {
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_Navigation_Navigation__WEBPACK_IMPORTED_MODULE_13__["Navigation"], {
           links: component.properties.links,
           alignment: component.properties.alignment,
           header: component.header
         });
 
+      case "CollectionCardGrid":
+        if (!component.data || !component.data.spocs || !component.data.spocs[0] || // We only display complete collections.
+        component.data.spocs.length < 3) {
+          return null;
+        }
+
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_DSDismiss_DSDismiss__WEBPACK_IMPORTED_MODULE_5__["DSDismiss"], {
+          dispatch: this.props.dispatch,
+          shouldSendImpressionStats: true,
+          extraClasses: `ds-dismiss-ds-collection`
+        }, react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_CollectionCardGrid_CollectionCardGrid__WEBPACK_IMPORTED_MODULE_2__["CollectionCardGrid"], {
+          placement: component.placement,
+          data: component.data,
+          feed: component.feed,
+          border: component.properties.border,
+          type: component.type,
+          dispatch: this.props.dispatch,
+          items: component.properties.items,
+          cta_variant: component.cta_variant,
+          display_engagement_labels: ENGAGEMENT_LABEL_ENABLED
+        }));
+
       case "CardGrid":
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_CardGrid_CardGrid__WEBPACK_IMPORTED_MODULE_1__["CardGrid"], {
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_CardGrid_CardGrid__WEBPACK_IMPORTED_MODULE_1__["CardGrid"], {
           title: component.header && component.header.title,
           data: component.data,
           feed: component.feed,
           border: component.properties.border,
           type: component.type,
           dispatch: this.props.dispatch,
           items: component.properties.items,
           cta_variant: component.cta_variant,
           display_engagement_labels: ENGAGEMENT_LABEL_ENABLED
         });
 
       case "Hero":
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_Hero_Hero__WEBPACK_IMPORTED_MODULE_8__["Hero"], {
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_Hero_Hero__WEBPACK_IMPORTED_MODULE_9__["Hero"], {
           subComponentType: embedWidth >= 9 ? `cards` : `list`,
           feed: component.feed,
           title: component.header && component.header.title,
           data: component.data,
           border: component.properties.border,
           type: component.type,
           dispatch: this.props.dispatch,
           items: component.properties.items
         });
 
       case "HorizontalRule":
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_HorizontalRule_HorizontalRule__WEBPACK_IMPORTED_MODULE_10__["HorizontalRule"], null);
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_HorizontalRule_HorizontalRule__WEBPACK_IMPORTED_MODULE_11__["HorizontalRule"], null);
 
       case "List":
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_List_List__WEBPACK_IMPORTED_MODULE_11__["List"], {
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_List_List__WEBPACK_IMPORTED_MODULE_12__["List"], {
           data: component.data,
           feed: component.feed,
           fullWidth: component.properties.full_width,
           hasBorders: component.properties.border === "border",
           hasImages: component.properties.has_images,
           hasNumbers: component.properties.has_numbers,
           items: component.properties.items,
           type: component.type,
           header: component.header
         });
 
       default:
-        return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement("div", null, component.type);
+        return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement("div", null, component.type);
     }
   }
 
   renderStyles(styles) {
     // Use json string as both the key and styles to render so React knows when
     // to unmount and mount a new instance for new styles.
     const json = JSON.stringify(styles);
-    return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement("style", {
+    return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement("style", {
       key: json,
       "data-styles": json,
       ref: this.onStyleMount
     });
   }
 
   componentWillReceiveProps(oldProps) {
     if (this.props.DiscoveryStream.layout !== oldProps.DiscoveryStream.layout) {
@@ -4350,17 +4374,17 @@ class _DiscoveryStreamBase extends react
     }
   }
 
   render() {
     // Select layout render data by adding spocs and position to recommendations
     const {
       layoutRender,
       spocsFill
-    } = Object(content_src_lib_selectLayoutRender__WEBPACK_IMPORTED_MODULE_15__["selectLayoutRender"])({
+    } = Object(content_src_lib_selectLayoutRender__WEBPACK_IMPORTED_MODULE_16__["selectLayoutRender"])({
       state: this.props.DiscoveryStream,
       prefs: this.props.Prefs.values,
       rollCache,
       lang: this.props.document.documentElement.lang
     });
     const {
       config,
       spocs,
@@ -4412,22 +4436,22 @@ class _DiscoveryStreamBase extends react
     const message = extractComponent("Message") || {
       header: {
         link_text: topStories.learnMore.link.message,
         link_url: topStories.learnMore.link.href,
         title: topStories.title
       }
     }; // Render a DS-style TopSites then the rest if any in a collapsible section
 
-    return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_13___default.a.Fragment, null, this.props.DiscoveryStream.isPrivacyInfoModalVisible && react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_DiscoveryStreamComponents_DSPrivacyModal_DSPrivacyModal__WEBPACK_IMPORTED_MODULE_6__["DSPrivacyModal"], {
+    return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_14___default.a.Fragment, null, this.props.DiscoveryStream.isPrivacyInfoModalVisible && react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_DiscoveryStreamComponents_DSPrivacyModal_DSPrivacyModal__WEBPACK_IMPORTED_MODULE_7__["DSPrivacyModal"], {
       dispatch: this.props.dispatch
     }), topSites && this.renderLayout([{
       width: 12,
       components: [topSites]
-    }]), !!layoutRender.length && react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement(content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__["CollapsibleSection"], {
+    }]), !!layoutRender.length && react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement(content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_3__["CollapsibleSection"], {
       className: "ds-layout",
       collapsed: topStories.pref.collapsed,
       dispatch: this.props.dispatch,
       icon: topStories.icon,
       id: topStories.id,
       isFixed: true,
       learnMore: {
         link: {
@@ -4443,37 +4467,37 @@ class _DiscoveryStreamBase extends react
       components: [{
         type: "Highlights"
       }]
     }]));
   }
 
   renderLayout(layoutRender) {
     const styles = [];
-    return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement("div", {
+    return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement("div", {
       className: "discovery-stream ds-layout"
-    }, layoutRender.map((row, rowIndex) => react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement("div", {
+    }, layoutRender.map((row, rowIndex) => react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement("div", {
       key: `row-${rowIndex}`,
       className: `ds-column ds-column-${row.width}`
-    }, react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement("div", {
+    }, react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement("div", {
       className: "ds-column-grid"
     }, row.components.map((component, componentIndex) => {
       if (!component) {
         return null;
       }
 
       styles[rowIndex] = [...(styles[rowIndex] || []), component.styles];
-      return react__WEBPACK_IMPORTED_MODULE_13___default.a.createElement("div", {
+      return react__WEBPACK_IMPORTED_MODULE_14___default.a.createElement("div", {
         key: `component-${componentIndex}`
       }, this.renderComponent(component, row.width));
     })))), this.renderStyles(styles));
   }
 
 }
-const DiscoveryStreamBase = Object(react_redux__WEBPACK_IMPORTED_MODULE_3__["connect"])(state => ({
+const DiscoveryStreamBase = Object(react_redux__WEBPACK_IMPORTED_MODULE_4__["connect"])(state => ({
   DiscoveryStream: state.DiscoveryStream,
   Prefs: state.Prefs,
   Sections: state.Sections,
   document: global.document
 }))(_DiscoveryStreamBase);
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
@@ -4549,17 +4573,19 @@ class CardGrid extends react__WEBPACK_IM
     if (!data) {
       return null;
     } // Handle the case where a user has dismissed all recommendations
 
 
     const isEmpty = data.recommendations.length === 0;
     return react__WEBPACK_IMPORTED_MODULE_2___default.a.createElement("div", null, this.props.title && react__WEBPACK_IMPORTED_MODULE_2___default.a.createElement("div", {
       className: "ds-header"
-    }, this.props.title), isEmpty ? react__WEBPACK_IMPORTED_MODULE_2___default.a.createElement("div", {
+    }, react__WEBPACK_IMPORTED_MODULE_2___default.a.createElement("div", null, this.props.title), this.props.context && react__WEBPACK_IMPORTED_MODULE_2___default.a.createElement("div", {
+      className: "ds-context"
+    }, this.props.context)), isEmpty ? react__WEBPACK_IMPORTED_MODULE_2___default.a.createElement("div", {
       className: "ds-card-grid empty"
     }, react__WEBPACK_IMPORTED_MODULE_2___default.a.createElement(_DSEmptyState_DSEmptyState_jsx__WEBPACK_IMPORTED_MODULE_1__["DSEmptyState"], {
       status: data.status,
       dispatch: this.props.dispatch,
       feed: this.props.feed
     })) : this.renderCards());
   }
 
@@ -6322,24 +6348,90 @@ class DSEmptyState extends react__WEBPAC
 }
 
 /***/ }),
 /* 47 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_CollectionCardGrid", function() { return _CollectionCardGrid; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CollectionCardGrid", function() { return CollectionCardGrid; });
+/* harmony import */ var content_src_components_DiscoveryStreamComponents_CardGrid_CardGrid__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(32);
+/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
+/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(28);
+/* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_2__);
+/* 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/. */
+
+
+
+class _CollectionCardGrid extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
+  render() {
+    const {
+      placement,
+      DiscoveryStream,
+      data,
+      feed
+    } = this.props; // Handle a render before feed has been fetched by displaying nothing
+
+    if (!data) {
+      return null;
+    }
+
+    const {
+      title,
+      context
+    } = DiscoveryStream.spocs.data[placement.name] || {}; // Generally a card grid displays recs with spocs already injected.
+    // Normally it doesn't care which rec is a spoc and which isn't,
+    // it just displays content in a grid.
+    // For collections, we're only displaying a list of spocs.
+    // We don't need to tell the card grid that our list of cards are spocs,
+    // it shouldn't need to care. So we just pass our spocs along as recs.
+    // Think of it as injecting all rec positions with spocs.
+    // Consider maybe making recommendations in CardGrid use a more generic name.
+
+    const recsData = {
+      recommendations: data.spocs
+    };
+    return react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement("div", {
+      className: "ds-collection-card-grid"
+    }, react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(content_src_components_DiscoveryStreamComponents_CardGrid_CardGrid__WEBPACK_IMPORTED_MODULE_0__["CardGrid"], {
+      title: title,
+      context: context,
+      data: recsData,
+      feed: feed,
+      border: this.props.border,
+      type: this.props.type,
+      dispatch: this.props.dispatch,
+      items: this.props.items
+    }));
+  }
+
+}
+const CollectionCardGrid = Object(react_redux__WEBPACK_IMPORTED_MODULE_2__["connect"])(state => ({
+  DiscoveryStream: state.DiscoveryStream
+}))(_CollectionCardGrid);
+
+/***/ }),
+/* 48 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CollapsibleSection", function() { return CollapsibleSection; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(49);
 /* harmony import */ var content_src_components_FluentOrText_FluentOrText__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(45);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_3__);
-/* harmony import */ var content_src_components_SectionMenu_SectionMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(50);
-/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(51);
+/* harmony import */ var content_src_components_SectionMenu_SectionMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(51);
+/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(52);
 /* harmony import */ var content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(39);
 /* 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/. */
 
 
 
 
@@ -6606,24 +6698,24 @@ CollapsibleSection.defaultProps = {
   },
   Prefs: {
     values: {}
   }
 };
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 48 */
+/* 49 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ErrorBoundaryFallback", function() { return ErrorBoundaryFallback; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ErrorBoundary", function() { return ErrorBoundary; });
-/* harmony import */ var content_src_components_A11yLinkButton_A11yLinkButton__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(49);
+/* harmony import */ var content_src_components_A11yLinkButton_A11yLinkButton__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(50);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
 /* 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/. */
 
 
 class ErrorBoundaryFallback extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
@@ -6693,17 +6785,17 @@ class ErrorBoundary extends react__WEBPA
   }
 
 }
 ErrorBoundary.defaultProps = {
   FallbackComponent: ErrorBoundaryFallback
 };
 
 /***/ }),
-/* 49 */
+/* 50 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "A11yLinkButton", function() { return A11yLinkButton; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
@@ -6723,28 +6815,28 @@ function A11yLinkButton(props) {
   return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", _extends({
     type: "button"
   }, props, {
     className: className
   }), props.children);
 }
 
 /***/ }),
-/* 50 */
+/* 51 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_SectionMenu", function() { return _SectionMenu; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SectionMenu", function() { return SectionMenu; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var content_src_components_ContextMenu_ContextMenu__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(37);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_2__);
-/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(51);
+/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(52);
 /* 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/. */
 
 
 
 
 const DEFAULT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "RemoveSection", "CheckCollapsed", "Separator", "ManageSection"];
@@ -6829,17 +6921,17 @@ class _SectionMenu extends react__WEBPAC
       keyboardAccess: this.props.keyboardAccess
     });
   }
 
 }
 const SectionMenu = _SectionMenu;
 
 /***/ }),
-/* 51 */
+/* 52 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SectionMenuOptions", function() { return SectionMenuOptions; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* 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,
@@ -6959,17 +7051,17 @@ const SectionMenuOptions = {
       }
     }),
     userEvent: "MENU_PRIVACY_NOTICE"
   }),
   CheckCollapsed: section => section.collapsed ? SectionMenuOptions.ExpandSection(section) : SectionMenuOptions.CollapseSection(section)
 };
 
 /***/ }),
-/* 52 */
+/* 53 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DSDismiss", function() { return DSDismiss; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
@@ -7041,17 +7133,17 @@ class DSDismiss extends react__WEBPACK_I
     }, react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement("span", {
       className: "icon icon-dismiss"
     })));
   }
 
 }
 
 /***/ }),
-/* 53 */
+/* 54 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DSMessage", function() { return DSMessage; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
 /* harmony import */ var _SafeAnchor_SafeAnchor__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(41);
@@ -7083,17 +7175,17 @@ class DSMessage extends react__WEBPACK_I
     }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(content_src_components_FluentOrText_FluentOrText__WEBPACK_IMPORTED_MODULE_2__["FluentOrText"], {
       message: this.props.link_text
     }))));
   }
 
 }
 
 /***/ }),
-/* 54 */
+/* 55 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DSPrivacyModal", function() { return DSPrivacyModal; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
@@ -7158,17 +7250,17 @@ class DSPrivacyModal extends react__WEBP
       onClick: this.closeModal,
       "data-l10n-id": "newtab-privacy-modal-button-done"
     })));
   }
 
 }
 
 /***/ }),
-/* 55 */
+/* 56 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DSTextPromo", function() { return DSTextPromo; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var _DSImage_DSImage_jsx__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(34);
 /* harmony import */ var _DiscoveryStreamImpressionStats_ImpressionStats__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(40);
@@ -7236,29 +7328,29 @@ class DSTextPromo extends react__WEBPACK
       dispatch: this.props.dispatch,
       source: this.props.type
     }));
   }
 
 }
 
 /***/ }),
-/* 56 */
+/* 57 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Hero", function() { return Hero; });
 /* harmony import */ var _DSCard_DSCard_jsx__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(33);
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
 /* harmony import */ var _DSEmptyState_DSEmptyState_jsx__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(46);
 /* harmony import */ var _DSImage_DSImage_jsx__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(34);
 /* harmony import */ var _DSLinkMenu_DSLinkMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(35);
 /* harmony import */ var _DiscoveryStreamImpressionStats_ImpressionStats__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(40);
-/* harmony import */ var _List_List_jsx__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(57);
+/* harmony import */ var _List_List_jsx__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(58);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_7__);
 /* harmony import */ var _SafeAnchor_SafeAnchor__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(41);
 /* harmony import */ var _DSContextFooter_DSContextFooter_jsx__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(42);
 /* 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/. */
 
@@ -7429,17 +7521,17 @@ class Hero extends react__WEBPACK_IMPORT
 Hero.defaultProps = {
   data: {},
   border: `border`,
   items: 1 // Number of stories to display
 
 };
 
 /***/ }),
-/* 57 */
+/* 58 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ListItem", function() { return ListItem; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PlaceholderListItem", function() { return PlaceholderListItem; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_List", function() { return _List; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "List", function() { return List; });
@@ -7633,28 +7725,28 @@ function _List(props) {
   items: 6 // Number of stories to display.  TODO: get from endpoint
 
 };
 const List = Object(react_redux__WEBPACK_IMPORTED_MODULE_1__["connect"])(state => ({
   DiscoveryStream: state.DiscoveryStream
 }))(_List);
 
 /***/ }),
-/* 58 */
+/* 59 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_Highlights", function() { return _Highlights; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Highlights", function() { return Highlights; });
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(28);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_0__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
-/* harmony import */ var content_src_components_Sections_Sections__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(59);
+/* harmony import */ var content_src_components_Sections_Sections__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(60);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
 /* 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/. */
 
 
 
@@ -7674,38 +7766,38 @@ class _Highlights extends react__WEBPACK
   }
 
 }
 const Highlights = Object(react_redux__WEBPACK_IMPORTED_MODULE_0__["connect"])(state => ({
   Sections: state.Sections
 }))(_Highlights);
 
 /***/ }),
-/* 59 */
+/* 60 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Section", function() { return Section; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SectionIntl", function() { return SectionIntl; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_Sections", function() { return _Sections; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Sections", function() { return Sections; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var content_src_components_Card_Card__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(60);
-/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(47);
-/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(62);
+/* harmony import */ var content_src_components_Card_Card__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(61);
+/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(48);
+/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(63);
 /* harmony import */ var content_src_components_FluentOrText_FluentOrText__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(45);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(28);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_5__);
-/* harmony import */ var content_src_components_MoreRecommendations_MoreRecommendations__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(64);
-/* harmony import */ var content_src_components_PocketLoggedInCta_PocketLoggedInCta__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(65);
+/* harmony import */ var content_src_components_MoreRecommendations_MoreRecommendations__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(65);
+/* harmony import */ var content_src_components_PocketLoggedInCta_PocketLoggedInCta__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(66);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_8__);
-/* harmony import */ var content_src_components_Topics_Topics__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(66);
-/* harmony import */ var content_src_components_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(67);
+/* harmony import */ var content_src_components_Topics_Topics__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(67);
+/* harmony import */ var content_src_components_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(68);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
 /* 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/. */
 
 
 
@@ -8045,33 +8137,33 @@ class _Sections extends react__WEBPACK_I
 }
 const Sections = Object(react_redux__WEBPACK_IMPORTED_MODULE_5__["connect"])(state => ({
   Sections: state.Sections,
   Prefs: state.Prefs
 }))(_Sections);
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 60 */
+/* 61 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_Card", function() { return _Card; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Card", function() { return Card; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PlaceholderCard", function() { return PlaceholderCard; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var _types__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(43);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(28);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_2__);
 /* harmony import */ var content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(39);
 /* harmony import */ var content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(36);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_5__);
-/* harmony import */ var content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(61);
+/* harmony import */ var content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(62);
 /* 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/. */
 
 
 
 
 
@@ -8390,17 +8482,17 @@ const Card = Object(react_redux__WEBPACK
   platform: state.Prefs.values.platform
 }))(_Card);
 const PlaceholderCard = props => react__WEBPACK_IMPORTED_MODULE_5___default.a.createElement(Card, {
   placeholder: true,
   className: props.className
 });
 
 /***/ }),
-/* 61 */
+/* 62 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ScreenshotUtils", function() { return ScreenshotUtils; });
 /* 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/. */
@@ -8459,24 +8551,24 @@ const ScreenshotUtils = {
 
     return !remoteImage && !localImage;
   }
 
 };
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 62 */
+/* 63 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ComponentPerfTimer", function() { return ComponentPerfTimer; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(63);
+/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(64);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_2__);
 /* 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/. */
 
 
  // Currently record only a fixed set of sections. This will prevent data
@@ -8639,17 +8731,17 @@ class ComponentPerfTimer extends react__
     }
 
     return this.props.children;
   }
 
 }
 
 /***/ }),
-/* 63 */
+/* 64 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_PerfService", function() { return _PerfService; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "perfService", function() { return perfService; });
 /* 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,
@@ -8771,17 +8863,17 @@ function _PerfService(options) {
     let mostRecentEntry = entries[entries.length - 1];
     return this._perf.timeOrigin + mostRecentEntry.startTime;
   }
 
 };
 var perfService = new _PerfService();
 
 /***/ }),
-/* 64 */
+/* 65 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MoreRecommendations", function() { return MoreRecommendations; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
 /* This Source Code Form is subject to the terms of the Mozilla Public
@@ -8803,17 +8895,17 @@ class MoreRecommendations extends react_
     }
 
     return null;
   }
 
 }
 
 /***/ }),
-/* 65 */
+/* 66 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_PocketLoggedInCta", function() { return _PocketLoggedInCta; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PocketLoggedInCta", function() { return PocketLoggedInCta; });
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(28);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_0__);
@@ -8846,17 +8938,17 @@ class _PocketLoggedInCta extends react__
   }
 
 }
 const PocketLoggedInCta = Object(react_redux__WEBPACK_IMPORTED_MODULE_0__["connect"])(state => ({
   Pocket: state.Pocket
 }))(_PocketLoggedInCta);
 
 /***/ }),
-/* 66 */
+/* 67 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Topic", function() { return Topic; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Topics", function() { return Topics; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
@@ -8891,36 +8983,36 @@ class Topics extends react__WEBPACK_IMPO
       url: t.url,
       name: t.name
     }))));
   }
 
 }
 
 /***/ }),
-/* 67 */
+/* 68 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_TopSites", function() { return _TopSites; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSites", function() { return TopSites; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(68);
-/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(47);
-/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(62);
+/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(69);
+/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(48);
+/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(63);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(28);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_4__);
 /* harmony import */ var _asrouter_components_ModalOverlay_ModalOverlay__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(21);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_6__);
-/* harmony import */ var _SearchShortcutsForm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(69);
-/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(81);
-/* harmony import */ var _TopSiteForm__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(83);
-/* harmony import */ var _TopSite__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(70);
+/* harmony import */ var _SearchShortcutsForm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(70);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(82);
+/* harmony import */ var _TopSiteForm__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(84);
+/* harmony import */ var _TopSite__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(71);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
 /* 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/. */
 
 
 
@@ -9118,17 +9210,17 @@ const TopSites = Object(react_redux__WEB
   // For SPOC Experiment only, take TopSites from DiscoveryStream TopSites that takes in SPOC Data
   TopSites: props.TopSitesWithSpoc || state.TopSites,
   Prefs: state.Prefs,
   TopSitesRows: state.Prefs.values.topSitesRows
 }))(_TopSites);
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 68 */
+/* 69 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_SOURCE", function() { return TOP_SITES_SOURCE; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_CONTEXT_MENU_OPTIONS", function() { return TOP_SITES_CONTEXT_MENU_OPTIONS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS", function() { return TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS", function() { return TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS; });
@@ -9143,27 +9235,27 @@ const TOP_SITES_SPOC_CONTEXT_MENU_OPTION
 
 const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "BlockUrl"]; // minimum size necessary to show a rich icon instead of a screenshot
 
 const MIN_RICH_FAVICON_SIZE = 96; // minimum size necessary to show any icon in the top left corner with a screenshot
 
 const MIN_CORNER_FAVICON_SIZE = 16;
 
 /***/ }),
-/* 69 */
+/* 70 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SelectableSearchShortcut", function() { return SelectableSearchShortcut; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SearchShortcutsForm", function() { return SearchShortcutsForm; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
-/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(68);
+/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(69);
 /* 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/. */
 
 
 
 class SelectableSearchShortcut extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
   render() {
@@ -9334,33 +9426,33 @@ class SearchShortcutsForm extends react_
       onClick: this.onSaveButtonClick,
       "data-l10n-id": "newtab-topsites-save-button"
     })));
   }
 
 }
 
 /***/ }),
-/* 70 */
+/* 71 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSiteLink", function() { return TopSiteLink; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSite", function() { return TopSite; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSitePlaceholder", function() { return TopSitePlaceholder; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSiteList", function() { return TopSiteList; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(68);
+/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(69);
 /* harmony import */ var content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(36);
 /* harmony import */ var _DiscoveryStreamImpressionStats_ImpressionStats__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(40);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_4__);
-/* harmony import */ var content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(61);
-/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(81);
+/* harmony import */ var content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(62);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(82);
 /* harmony import */ var content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(39);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
 /* 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/. */
 
 
@@ -10011,17 +10103,17 @@ class TopSiteList extends react__WEBPACK
     return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("ul", {
       className: `top-sites-list${this.state.draggedSite ? " dnd-active" : ""}`
     }, topSitesUI);
   }
 
 }
 
 /***/ }),
-/* 71 */
+/* 72 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "HorizontalRule", function() { return HorizontalRule; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
 /* This Source Code Form is subject to the terms of the Mozilla Public
@@ -10033,17 +10125,17 @@ class HorizontalRule extends react__WEBP
     return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("hr", {
       className: "ds-hr"
     });
   }
 
 }
 
 /***/ }),
-/* 72 */
+/* 73 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Topic", function() { return Topic; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Navigation", function() { return Navigation; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
@@ -10088,17 +10180,17 @@ class Navigation extends react__WEBPACK_
       url: t.url,
       name: t.name
     })))));
   }
 
 }
 
 /***/ }),
-/* 73 */
+/* 74 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SectionTitle", function() { return SectionTitle; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
 /* This Source Code Form is subject to the terms of the Mozilla Public
@@ -10120,17 +10212,17 @@ class SectionTitle extends react__WEBPAC
     }, title), subtitle ? react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", {
       className: "subtitle"
     }, subtitle) : null);
   }
 
 }
 
 /***/ }),
-/* 74 */
+/* 75 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "selectLayoutRender", function() { return selectLayoutRender; });
 /* 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/. */
@@ -10207,17 +10299,19 @@ const selectLayoutRender = ({
     filterArray.push(...DS_COMPONENTS);
   }
 
   const placeholderComponent = component => {
     if (!component.feed) {
       // TODO we now need a placeholder for topsites and textPromo.
       return { ...component,
         data: {
-          spocs: []
+          spocs: {
+            items: []
+          }
         }
       };
     }
 
     const data = {
       recommendations: []
     };
     let items = 0;
@@ -10241,18 +10335,18 @@ const selectLayoutRender = ({
   const handleSpocs = (data, component) => {
     let result = [...data]; // Do we ever expect to possibly have a spoc.
 
     if (component.spocs && component.spocs.positions && component.spocs.positions.length) {
       const placement = component.placement || {};
       const placementName = placement.name || "spocs";
       const spocsData = spocs.data[placementName]; // We expect a spoc, spocs are loaded, and the server returned spocs.
 
-      if (spocs.loaded && spocsData && spocsData.length) {
-        result = rollForSpocs(result, component.spocs, spocsData, placementName);
+      if (spocs.loaded && spocsData && spocsData.items && spocsData.items.length) {
+        result = rollForSpocs(result, component.spocs, spocsData.items, placementName);
       }
     }
 
     return result;
   };
 
   const handleComponent = component => {
     return { ...component,
@@ -10344,56 +10438,56 @@ const selectLayoutRender = ({
   } // Generate the payload for the SPOCS Fill ping. Note that a SPOC could be rejected
   // by the `probability_selection` first, then gets chosen for the next position. For
   // all other SPOCS that never went through the probabilistic selection, its reason will
   // be "out_of_position".
 
 
   let spocsFill = [];
 
-  if (spocs.loaded && feeds.loaded && spocs.data.spocs) {
+  if (spocs.loaded && feeds.loaded && spocs.data.spocs && spocs.data.spocs.items) {
     const chosenSpocsFill = [...chosenSpocs].map(spoc => ({
       id: spoc.id,
       reason: "n/a",
       displayed: 1,
       full_recalc: 0
     }));
     const unchosenSpocsFill = [...unchosenSpocs].filter(spoc => !chosenSpocs.has(spoc)).map(spoc => ({
       id: spoc.id,
       reason: "probability_selection",
       displayed: 0,
       full_recalc: 0
     }));
-    const outOfPositionSpocsFill = spocs.data.spocs.slice(spocIndexMap.spocs).filter(spoc => !unchosenSpocs.has(spoc)).map(spoc => ({
+    const outOfPositionSpocsFill = spocs.data.spocs.items.slice(spocIndexMap.spocs).filter(spoc => !unchosenSpocs.has(spoc)).map(spoc => ({
       id: spoc.id,
       reason: "out_of_position",
       displayed: 0,
       full_recalc: 0
     }));
     spocsFill = [...chosenSpocsFill, ...unchosenSpocsFill, ...outOfPositionSpocsFill];
   }
 
   return {
     spocsFill,
     layoutRender
   };
 };
 
 /***/ }),
-/* 75 */
+/* 76 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_TopSites", function() { return _TopSites; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSites", function() { return TopSites; });
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(28);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_0__);
-/* harmony import */ var content_src_components_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(67);
-/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(81);
+/* harmony import */ var content_src_components_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(68);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(82);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_3__);
 /* 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/. */
 
 
 
@@ -10524,17 +10618,17 @@ class _TopSites extends react__WEBPACK_I
   }
 
 }
 const TopSites = Object(react_redux__WEBPACK_IMPORTED_MODULE_0__["connect"])(state => ({
   TopSites: state.TopSites
 }))(_TopSites);
 
 /***/ }),
-/* 76 */
+/* 77 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_Search", function() { return _Search; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Search", function() { return Search; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(28);
@@ -10716,24 +10810,24 @@ class _Search extends react__WEBPACK_IMP
       className: "fake-caret"
     }))));
   }
 
 }
 const Search = Object(react_redux__WEBPACK_IMPORTED_MODULE_1__["connect"])()(_Search);
 
 /***/ }),
-/* 77 */
+/* 78 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DetectUserSessionStart", function() { return DetectUserSessionStart; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(63);
+/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(64);
 /* 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/. */
 
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 class DetectUserSessionStart {
@@ -10798,17 +10892,17 @@ class DetectUserSessionStart {
       this.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
   }
 
 }
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 78 */
+/* 79 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(9);
 
@@ -11595,17 +11689,17 @@ localized_Localized.propTypes = {
  * components for more information.
  */
 
 
 
 
 
 /***/ }),
-/* 79 */
+/* 80 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(9);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
@@ -12763,17 +12857,17 @@ const SnippetsTemplates = {
   newsletter_snippet: NewsletterSnippet,
   fxa_signup_snippet: FXASignupSnippet,
   send_to_device_snippet: SendToDeviceSnippet,
   eoy_snippet: EOYSnippet,
   simple_below_search_snippet: SimpleBelowSearchSnippet_SimpleBelowSearchSnippet
 };
 
 /***/ }),
-/* 80 */
+/* 81 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // CONCATENATED MODULE: ./node_modules/fluent/src/types.js
 /* global Intl */
 
@@ -14183,17 +14277,17 @@ function generateBundles(content) {
     }
 
     bundle.addMessages(`${key} = ${string}`);
   });
   return [bundle];
 }
 
 /***/ }),
-/* 81 */
+/* 82 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
@@ -14308,18 +14402,18 @@ const INITIAL_STATE = {
       data: {// "https://foo.com/feed1": {lastUpdated: 123, data: []}
       },
       loaded: false
     },
     spocs: {
       spocs_endpoint: "",
       spocs_per_domain: 1,
       lastUpdated: null,
-      data: {},
-      // {spocs: []}
+      data: {// {spocs: {items: []}}
+      },
       loaded: false,
       frequency_caps: [],
       blocked: [],
       placements: []
     }
   },
   Personalization: {
     version: 1,
@@ -14905,21 +14999,23 @@ function DiscoveryStream(prevState = INI
       data,
       placements
     } = prevState.spocs;
     const result = {};
 
     const forPlacement = placement => {
       const placementSpocs = data[placement.name];
 
-      if (!placementSpocs || !placementSpocs.length) {
+      if (!placementSpocs || !placementSpocs.items || !placementSpocs.items.length) {
         return;
       }
 
-      result[placement.name] = handleSites(placementSpocs);
+      result[placement.name] = { ...placementSpocs,
+        items: handleSites(placementSpocs.items)
+      };
     };
 
     if (!placements || !placements.length) {
       [{
         name: "spocs"
       }].forEach(forPlacement);
     } else {
       placements.forEach(forPlacement);
@@ -15134,17 +15230,17 @@ var reducers = {
   Sections,
   Pocket,
   Personalization,
   DiscoveryStream,
   Search
 };
 
 /***/ }),
-/* 82 */
+/* 83 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(9);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
@@ -15154,20 +15250,20 @@ var Trailhead = __webpack_require__(20);
 
 // EXTERNAL MODULE: ./content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx
 var ReturnToAMO = __webpack_require__(24);
 
 // EXTERNAL MODULE: ./content-src/asrouter/templates/FullPageInterrupt/FullPageInterrupt.jsx
 var FullPageInterrupt = __webpack_require__(25);
 
 // EXTERNAL MODULE: ./node_modules/fluent-react/src/index.js + 14 modules
-var src = __webpack_require__(78);
+var src = __webpack_require__(79);
 
 // EXTERNAL MODULE: ./content-src/asrouter/rich-text-strings.js + 8 modules
-var rich_text_strings = __webpack_require__(80);
+var rich_text_strings = __webpack_require__(81);
 
 // CONCATENATED MODULE: ./content-src/asrouter/templates/FirstRun/Interrupt.jsx
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
 /* 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/. */
 
@@ -15415,34 +15511,34 @@ class FirstRun_FirstRun extends external
       onAction: executeAction,
       onBlockById: props.onBlockById
     }) : null);
   }
 
 }
 
 /***/ }),
-/* 83 */
+/* 84 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
 // EXTERNAL MODULE: ./content-src/components/A11yLinkButton/A11yLinkButton.jsx
-var A11yLinkButton = __webpack_require__(49);
+var A11yLinkButton = __webpack_require__(50);
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(9);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSitesConstants.js
-var TopSitesConstants = __webpack_require__(68);
+var TopSitesConstants = __webpack_require__(69);
 
 // CONCATENATED MODULE: ./content-src/components/TopSites/TopSiteFormInput.jsx
 /* 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/. */
 
 class TopSiteFormInput_TopSiteFormInput extends external_React_default.a.PureComponent {
   constructor(props) {
@@ -15547,17 +15643,17 @@ class TopSiteFormInput_TopSiteFormInput 
 
 }
 TopSiteFormInput_TopSiteFormInput.defaultProps = {
   showClearButton: false,
   value: "",
   validationError: false
 };
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSite.jsx
-var TopSite = __webpack_require__(70);
+var TopSite = __webpack_require__(71);
 
 // CONCATENATED MODULE: ./content-src/components/TopSites/TopSiteForm.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSiteForm", function() { return TopSiteForm_TopSiteForm; });
 /* 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/. */
 
 
--- a/browser/components/newtab/docs/v2-system-addon/preferences.md
+++ b/browser/components/newtab/docs/v2-system-addon/preferences.md
@@ -172,15 +172,23 @@ Programmatically generated hash table wh
 #### `browser.newtabpage.activity-stream.discoverystream.spoc.impressions`
 
 - Type: `string`
 - Default: `{}`
 - Pref Type: AS
 
 Programmatically generated hash table where the keys are sponsored content IDs and the values are arrays of timestamps for every impression.
 
+#### `browser.newtabpage.activity-stream.discoverystream.region-stories-config`
+
+- Type: `string`
+- Default: `US,DE,CA`
+- Pref Type: Firefox
+
+A comma separated list of geos that by default have stories enabled in newtab. It matches the client's geo with that list, then looks for a matching locale.
+
 #### `browser.newtabpage.activity-stream.discoverystream.spocs-endpoint`
 
 - Type: `string`
 - Default: `null`
 - Pref Type: Firefox
 
 Override to specify endpoint for SPOCs. Will take precedence over remote and hardcoded layout SPOC endpoints.
--- a/browser/components/newtab/lib/ASRouter.jsm
+++ b/browser/components/newtab/lib/ASRouter.jsm
@@ -648,17 +648,17 @@ class _ASRouter {
       if (!providerIDs.includes(prevProvider.id)) {
         this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
           type: "CLEAR_PROVIDER",
           data: { id: prevProvider.id },
         });
       }
     }
 
-    this.setState(prevState => ({
+    return this.setState(prevState => ({
       providers,
       // Clear any messages from removed providers
       messages: [
         ...prevState.messages.filter(message =>
           providerIDs.includes(message.provider)
         ),
       ],
     }));
--- a/browser/components/newtab/lib/ActivityStream.jsm
+++ b/browser/components/newtab/lib/ActivityStream.jsm
@@ -128,16 +128,18 @@ const DEFAULT_SITES = new Map([
     "https://www.youtube.com/,https://www.facebook.com/,https://www.reddit.com/,https://www.amazon.co.uk/,https://www.bbc.co.uk/,https://www.ebay.co.uk/",
   ],
   [
     "FR",
     "https://www.youtube.com/,https://www.facebook.com/,https://www.wikipedia.org/,https://www.amazon.fr/,https://www.leboncoin.fr/,https://twitter.com/",
   ],
 ]);
 const GEO_PREF = "browser.search.region";
+const REGION_STORIES_CONFIG =
+  "browser.newtabpage.activity-stream.discoverystream.region-stories-config";
 const SPOCS_GEOS = ["US"];
 
 // Determine if spocs should be shown for a geo/locale
 function showSpocs({ geo }) {
   return SPOCS_GEOS.includes(geo);
 }
 
 // Configure default Activity Stream prefs with a plain `value` or a `getValue`
@@ -571,22 +573,29 @@ const FEEDS_DATA = [
   {
     name: "section.topstories",
     factory: () =>
       new TopStoriesFeed(PREFS_CONFIG.get("discoverystream.config")),
     title:
       "Fetches content recommendations from a configurable content provider",
     // Dynamically determine if Pocket should be shown for a geo / locale
     getValue: ({ geo, locale }) => {
+      const preffedRegionsString =
+        Services.prefs.getStringPref(REGION_STORIES_CONFIG) || "";
+      const preffedRegions = preffedRegionsString.split(",").map(s => s.trim());
       const locales = {
         US: ["en-CA", "en-GB", "en-US", "en-ZA"],
         CA: ["en-CA", "en-GB", "en-US", "en-ZA"],
+        GB: ["en-CA", "en-GB", "en-US", "en-ZA"],
         DE: ["de", "de-DE", "de-AT", "de-CH"],
+        JP: ["ja", "ja-JP"],
       }[geo];
-      return !!locales && locales.includes(locale);
+      return (
+        preffedRegions.includes(geo) && !!locales && locales.includes(locale)
+      );
     },
   },
   {
     name: "systemtick",
     factory: () => new SystemTickFeed(),
     title: "Produces system tick events to periodically check for data expiry",
     value: true,
   },
--- a/browser/components/newtab/lib/DiscoveryStreamFeed.jsm
+++ b/browser/components/newtab/lib/DiscoveryStreamFeed.jsm
@@ -567,16 +567,29 @@ this.DiscoveryStreamFeed = class Discove
     // Backwards comp for before we had placements, assume just a single spocs placement.
     if (!placements || !placements.length) {
       [{ name: "spocs" }].forEach(callback);
     } else {
       placements.forEach(callback);
     }
   }
 
+  // Bug 1567271 introduced meta data on a list of spocs.
+  // This involved moving the spocs array into an items prop.
+  // However, old data could still be returned, and cached data might also be old.
+  // For ths reason, we want to ensure if we don't find an items array,
+  // we use the previous array placement, and then stub out title and context to empty strings.
+  // We need to do this *after* both fresh fetches and cached data to reduce repetition.
+  normalizeSpocsItems(spocs) {
+    const items = spocs.items || spocs;
+    const title = spocs.title || "";
+    const context = spocs.context || "";
+    return { items, title, context };
+  }
+
   async loadSpocs(sendUpdate, isStartup) {
     const cachedData = (await this.cache.get()) || {};
     let spocsState;
 
     const { placements } = this.store.getState().DiscoveryStream.spocs;
 
     if (this.showSpocs) {
       spocsState = cachedData.spocs;
@@ -633,22 +646,49 @@ this.DiscoveryStreamFeed = class Discove
 
     let frequencyCapped = [];
     let blockedItems = [];
     let belowMinScore = [];
     let flightDupes = [];
     this.placementsForEach(placement => {
       const freshSpocs = spocsState.spocs[placement.name];
 
-      if (!freshSpocs || !freshSpocs.length) {
+      if (!freshSpocs) {
+        return;
+      }
+
+      // spocs can be returns as an array, or an object with an items array.
+      // We want to normalize this so all our spocs have an items array.
+      // There can also be some meta data for title and context.
+      // This is mostly because of backwards compat.
+      const {
+        items: normalizedSpocsItems,
+        title,
+        context,
+      } = this.normalizeSpocsItems(freshSpocs);
+
+      if (!normalizedSpocsItems || !normalizedSpocsItems.length) {
+        // In the case of old data, we still want to ensure we normalize the data structure,
+        // even if it's empty. We expect the empty data to be an object with items array,
+        // and not just an empty array.
+        spocsState.spocs = {
+          ...spocsState.spocs,
+          [placement.name]: {
+            title,
+            context,
+            items: [],
+          },
+        };
         return;
       }
 
       // Migrate flight_id
-      const { data: migratedSpocs } = this.migrateFlightId(freshSpocs);
+      const { data: migratedSpocs } = this.migrateFlightId(
+        normalizedSpocsItems
+      );
 
       const { data: capResult, filtered: caps } = this.frequencyCapSpocs(
         migratedSpocs
       );
       frequencyCapped = [...frequencyCapped, ...caps];
 
       const { data: blockedResults, filtered: blocks } = this.filterBlocked(
         capResult
@@ -662,17 +702,21 @@ this.DiscoveryStreamFeed = class Discove
         below_min_score: minScoreFilter,
         flight_duplicate: dupes,
       } = transformFilter;
       belowMinScore = [...belowMinScore, ...minScoreFilter];
       flightDupes = [...flightDupes, ...dupes];
 
       spocsState.spocs = {
         ...spocsState.spocs,
-        [placement.name]: transformResult,
+        [placement.name]: {
+          title,
+          context,
+          items: transformResult,
+        },
       };
     });
 
     sendUpdate({
       type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
       data: {
         lastUpdated: spocsState.lastUpdated,
         spocs: spocsState.spocs,
@@ -1139,31 +1183,35 @@ this.DiscoveryStreamFeed = class Discove
       }, {});
       await this.cache.set("feeds", feedsResult);
     }
   }
 
   async scoreSpocs(spocsState) {
     let belowMinScore = [];
     this.placementsForEach(placement => {
-      const items = spocsState.data[placement.name];
+      const nextSpocs = spocsState.data[placement.name] || {};
+      const { items } = nextSpocs;
 
       if (!items || !items.length) {
         return;
       }
 
       const { data: scoreResult, filtered: minScoreFilter } = this.scoreItems(
         items
       );
 
       belowMinScore = [...belowMinScore, ...minScoreFilter];
 
       spocsState.data = {
         ...spocsState.data,
-        [placement.name]: scoreResult,
+        [placement.name]: {
+          ...nextSpocs,
+          items: scoreResult,
+        },
       };
     });
 
     // Update cache here so we don't need to re calculate scores on loads from cache.
     // Related Bug 1606276
     await this.cache.set("spocs", {
       lastUpdated: spocsState.lastUpdated,
       spocs: spocsState.data,
@@ -1431,17 +1479,27 @@ this.DiscoveryStreamFeed = class Discove
 
   cleanUpFlightImpressionPref(data) {
     let flightIds = [];
     this.placementsForEach(placement => {
       const newSpocs = data[placement.name];
       if (!newSpocs) {
         return;
       }
-      flightIds = [...flightIds, ...newSpocs.map(s => `${s.flight_id}`)];
+
+      // We need to do a small items migration here.
+      // In bug 1567271 we moved spoc data array into items,
+      // but we also need backwards comp here, because
+      // this is the only place where we use spocs before the migration.
+      // We however don't need to do a total migration, we *just* need the items.
+      // A total migration would involve setting the data with new values,
+      // and also ensuring metadata like context and title are there or empty strings.
+      // see #normalizeSpocsItems function.
+      const items = newSpocs.items || newSpocs;
+      flightIds = [...flightIds, ...items.map(s => `${s.flight_id}`)];
     });
     if (flightIds && flightIds.length) {
       this.cleanUpImpressionPref(
         id => !flightIds.includes(id),
         PREF_SPOC_IMPRESSIONS
       );
     }
   }
@@ -1594,28 +1652,31 @@ this.DiscoveryStreamFeed = class Discove
 
           // Apply frequency capping to SPOCs in the redux store, only update the
           // store if the SPOCs are changed.
           const spocsState = this.store.getState().DiscoveryStream.spocs;
 
           let frequencyCapped = [];
           this.placementsForEach(placement => {
             const freshSpocs = spocsState.data[placement.name];
-            if (!freshSpocs) {
+            if (!freshSpocs || !freshSpocs.items) {
               return;
             }
 
             const { data: newSpocs, filtered } = this.frequencyCapSpocs(
-              freshSpocs
+              freshSpocs.items
             );
             frequencyCapped = [...frequencyCapped, ...filtered];
 
             spocsState.data = {
               ...spocsState.data,
-              [placement.name]: newSpocs,
+              [placement.name]: {
+                ...freshSpocs,
+                items: newSpocs,
+              },
             };
           });
           if (frequencyCapped.length) {
             this.store.dispatch(
               ac.AlsoToPreloaded({
                 type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
                 data: {
                   lastUpdated: spocsState.lastUpdated,
@@ -1631,18 +1692,18 @@ this.DiscoveryStreamFeed = class Discove
       // We match the blocked url with our available spoc urls to see if there is a match.
       // I suspect we *could* instead do this in BLOCK_URL but I'm not sure.
       case at.PLACES_LINK_BLOCKED:
         if (this.showSpocs) {
           const spocsState = this.store.getState().DiscoveryStream.spocs;
           let spocsList = [];
           this.placementsForEach(placement => {
             const spocs = spocsState.data[placement.name];
-            if (spocs && spocs.length) {
-              spocsList = [...spocsList, ...spocs];
+            if (spocs && spocs.items && spocs.items.length) {
+              spocsList = [...spocsList, ...spocs.items];
             }
           });
           const filtered = spocsList.filter(s => s.url === action.data.url);
           if (filtered.length) {
             this._sendSpocsFill({ blocked_by_user: filtered }, false);
 
             // If we're blocking a spoc, we want a slightly different treatment for open tabs.
             // AlsoToPreloaded updates the source data and preloaded tabs with a new spoc.
--- a/browser/components/newtab/lib/TelemetryFeed.jsm
+++ b/browser/components/newtab/lib/TelemetryFeed.jsm
@@ -26,16 +26,21 @@ ChromeUtils.defineModuleGetter(
 );
 ChromeUtils.defineModuleGetter(
   this,
   "perfService",
   "resource://activity-stream/common/PerfService.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
+  "AboutNewTabStartupRecorder",
+  "resource:///modules/AboutNewTabService.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
   "PingCentre",
   "resource:///modules/PingCentre.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "UTEventReporting",
   "resource://activity-stream/lib/UTEventReporting.jsm"
 );
@@ -1068,17 +1073,17 @@ this.TelemetryFeed = class TelemetryFeed
     let timestamp = data.topsites_first_painted_ts;
 
     if (
       timestamp &&
       session.page === "about:home" &&
       !HomePage.overridden &&
       Services.prefs.getIntPref("browser.startup.page") === 1
     ) {
-      aboutNewTabService.maybeRecordTopsitesPainted(timestamp);
+      AboutNewTabStartupRecorder.maybeRecordTopsitesPainted(timestamp);
     }
 
     Object.assign(session.perf, data);
   }
 
   uninit() {
     try {
       Services.obs.removeObserver(
--- a/browser/components/newtab/lib/ToolbarPanelHub.jsm
+++ b/browser/components/newtab/lib/ToolbarPanelHub.jsm
@@ -114,16 +114,22 @@ class _ToolbarPanelHub {
       EveryWindow.registerCallback(
         APPMENU_BUTTON_ID,
         this._showAppmenuButton,
         this._hideAppmenuButton
       );
     }
   }
 
+  // Removes the button from the Appmenu.
+  // Only used in tests.
+  disableAppmenuButton() {
+    EveryWindow.unregisterCallback(APPMENU_BUTTON_ID);
+  }
+
   // Turns on the Toolbar button for all open windows and future windows.
   async enableToolbarButton() {
     if ((await this.messages).length) {
       EveryWindow.registerCallback(
         TOOLBAR_BUTTON_ID,
         this._showToolbarButton,
         this._hideToolbarButton
       );
--- a/browser/components/newtab/test/browser/browser_asrouter_whatsnewpanel.js
+++ b/browser/components/newtab/test/browser/browser_asrouter_whatsnewpanel.js
@@ -1,48 +1,95 @@
 const { PanelTestProvider } = ChromeUtils.import(
   "resource://activity-stream/lib/PanelTestProvider.jsm"
 );
 const { ToolbarPanelHub } = ChromeUtils.import(
   "resource://activity-stream/lib/ToolbarPanelHub.jsm"
 );
+const { RemoteSettings } = ChromeUtils.import(
+  "resource://services-settings/remote-settings.js"
+);
+const { ASRouter } = ChromeUtils.import(
+  "resource://activity-stream/lib/ASRouter.jsm"
+);
 
-add_task(async function test_messages_rendering() {
+add_task(async function test_with_rs_messages() {
+  // Force the WNPanel provider cache to 0 by modifying updateCycleInMs
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      [
+        "browser.newtabpage.activity-stream.asrouter.providers.whats-new-panel",
+        `{"id":"whats-new-panel","enabled":true,"type":"remote-settings","bucket":"whats-new-panel","updateCycleInMs":0}`,
+      ],
+    ],
+  });
   const msgs = (await PanelTestProvider.getMessages()).filter(
     ({ template }) => template === "whatsnew_panel_message"
   );
-
-  Assert.ok(msgs.length, "FxA test message exists");
+  const initialMessageCount = ASRouter.state.messages.length;
+  const client = RemoteSettings("whats-new-panel");
+  const collection = await client.openCollection();
+  await collection.clear();
+  for (const record of msgs) {
+    await collection.create(
+      // Modify targeting to ensure the messages always show up
+      { ...record, targeting: "true" },
+      { useRecordId: true }
+    );
+  }
+  await collection.db.saveLastModified(42); // Prevent from loading JSON dump.
 
-  Object.defineProperty(ToolbarPanelHub, "messages", {
-    get: () => Promise.resolve(msgs),
-    configurable: true,
-  });
-
-  await ToolbarPanelHub.enableAppmenuButton();
+  const whatsNewBtn = document.getElementById("appMenu-whatsnew-button");
+  Assert.equal(whatsNewBtn.hidden, true, "What's New btn doesn't exist");
 
   const mainView = document.getElementById("appMenu-mainView");
   UITour.showMenu(window, "appMenu");
-  await BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+  await BrowserTestUtils.waitForEvent(
+    mainView,
+    "ViewShown",
+    "Panel did not open"
+  );
+
+  // Reload the provider
+  await ASRouter._updateMessageProviders();
+  // Wait to load the WNPanel messages
+  await BrowserTestUtils.waitForCondition(async () => {
+    await ASRouter.loadMessagesFromAllProviders();
+    return ASRouter.state.messages.length > initialMessageCount;
+  }, "Messages did not load");
+  await ToolbarPanelHub.enableAppmenuButton();
 
   Assert.equal(mainView.hidden, false, "Panel is visible");
-
-  const whatsNewBtn = document.getElementById("appMenu-whatsnew-button");
-  Assert.equal(whatsNewBtn.hidden, false, "What's New is present");
+  await BrowserTestUtils.waitForCondition(
+    () => !whatsNewBtn.hidden,
+    "What's new menu entry did not become visible"
+  );
+  Assert.equal(whatsNewBtn.hidden, false, "What's New btn is visible");
 
   // Show the What's New Messages
   whatsNewBtn.click();
 
-  const shownMessages = await BrowserTestUtils.waitForCondition(
+  await BrowserTestUtils.waitForCondition(
+    () => document.getElementById("PanelUI-whatsNew-message-container"),
+    "The message container did not show"
+  );
+  await BrowserTestUtils.waitForCondition(
     () =>
-      document.getElementById("PanelUI-whatsNew-message-container") &&
       document.querySelectorAll(
         "#PanelUI-whatsNew-message-container .whatsNew-message"
-      ).length
-  );
-  Assert.equal(
-    shownMessages,
-    msgs.length,
-    "Expected number of What's New messages rendered."
+      ).length === msgs.length,
+    "The message container was not populated with the expected number of msgs"
   );
 
   UITour.hideMenu(window, "appMenu");
+  // Clean up and remove messages
+  ToolbarPanelHub.disableAppmenuButton();
+  await collection.clear();
+  // Wait to reset the WNPanel messages from state
+  const previousMessageCount = ASRouter.state.messages.length;
+  await BrowserTestUtils.waitForCondition(async () => {
+    await ASRouter.loadMessagesFromAllProviders();
+    return ASRouter.state.messages.length < previousMessageCount;
+  }, "WNPanel messages should have been removed");
+  await SpecialPowers.popPrefEnv();
+  // Reload the provider
+  await ASRouter._updateMessageProviders();
 });
--- a/browser/components/newtab/test/unit/common/Reducers.test.js
+++ b/browser/components/newtab/test/unit/common/Reducers.test.js
@@ -1050,29 +1050,35 @@ describe("Reducers", () => {
     it("should default to a single spoc placement", () => {
       const deleteAction = {
         type: at.DISCOVERY_STREAM_LINK_BLOCKED,
         data: { url: "https://foo.com" },
       };
       const oldState = {
         spocs: {
           data: {
-            spocs: [{ url: "test-spoc.com" }],
+            spocs: {
+              items: [
+                {
+                  url: "test-spoc.com",
+                },
+              ],
+            },
           },
           loaded: true,
         },
         feeds: {
           data: {},
           loaded: true,
         },
       };
 
       const newState = DiscoveryStream(oldState, deleteAction);
 
-      assert.equal(newState.spocs.data.spocs.length, 1);
+      assert.equal(newState.spocs.data.spocs.items.length, 1);
     });
     it("should handle no data from DISCOVERY_STREAM_SPOCS_UPDATE", () => {
       const data = null;
       const state = DiscoveryStream(undefined, {
         type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
         data,
       });
       assert.deepEqual(state.spocs, INITIAL_STATE.DiscoveryStream.spocs);
@@ -1128,28 +1134,32 @@ describe("Reducers", () => {
     it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from spocs if feeds data is empty", () => {
       const deleteAction = {
         type: at.DISCOVERY_STREAM_LINK_BLOCKED,
         data: { url: "https://foo.com" },
       };
       const oldState = {
         spocs: {
           data: {
-            spocs: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+            spocs: {
+              items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+            },
           },
           loaded: true,
           placements: [{ name: "spocs" }],
         },
         feeds: {
           data: {},
           loaded: true,
         },
       };
       const newState = DiscoveryStream(oldState, deleteAction);
-      assert.deepEqual(newState.spocs.data.spocs, [{ url: "test-spoc.com" }]);
+      assert.deepEqual(newState.spocs.data.spocs.items, [
+        { url: "test-spoc.com" },
+      ]);
     });
     it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from feeds if spocs data is empty", () => {
       const deleteAction = {
         type: at.DISCOVERY_STREAM_LINK_BLOCKED,
         data: { url: "https://foo.com" },
       };
       const oldState = {
         spocs: {
@@ -1189,28 +1199,32 @@ describe("Reducers", () => {
                 ],
               },
             },
           },
           loaded: true,
         },
         spocs: {
           data: {
-            spocs: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+            spocs: {
+              items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+            },
           },
           loaded: true,
           placements: [{ name: "spocs" }],
         },
       };
       const deleteAction = {
         type: at.DISCOVERY_STREAM_LINK_BLOCKED,
         data: { url: "https://foo.com" },
       };
       const newState = DiscoveryStream(oldState, deleteAction);
-      assert.deepEqual(newState.spocs.data.spocs, [{ url: "test-spoc.com" }]);
+      assert.deepEqual(newState.spocs.data.spocs.items, [
+        { url: "test-spoc.com" },
+      ]);
       assert.deepEqual(
         newState.feeds.data["https://foo.com/feed1"].data.recommendations,
         [{ url: "test.com" }]
       );
     });
     it("should not update state for empty action.data on PLACES_SAVED_TO_POCKET", () => {
       const newState = DiscoveryStream(undefined, {
         type: at.PLACES_SAVED_TO_POCKET,
@@ -1229,40 +1243,45 @@ describe("Reducers", () => {
                 ],
               },
             },
           },
           loaded: true,
         },
         spocs: {
           data: {
-            spocs: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+            spocs: {
+              items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+            },
           },
           placements: [{ name: "spocs" }],
           loaded: true,
         },
       };
       const action = {
         type: at.PLACES_SAVED_TO_POCKET,
         data: {
           url: "https://foo.com",
           pocket_id: 1234,
           open_url: "https://foo-1234",
         },
       };
 
       const newState = DiscoveryStream(oldState, action);
 
-      assert.lengthOf(newState.spocs.data.spocs, 2);
+      assert.lengthOf(newState.spocs.data.spocs.items, 2);
       assert.equal(
-        newState.spocs.data.spocs[0].pocket_id,
+        newState.spocs.data.spocs.items[0].pocket_id,
         action.data.pocket_id
       );
-      assert.equal(newState.spocs.data.spocs[0].open_url, action.data.open_url);
-      assert.isUndefined(newState.spocs.data.spocs[1].pocket_id);
+      assert.equal(
+        newState.spocs.data.spocs.items[0].open_url,
+        action.data.open_url
+      );
+      assert.isUndefined(newState.spocs.data.spocs.items[1].pocket_id);
 
       assert.lengthOf(
         newState.feeds.data["https://foo.com/feed1"].data.recommendations,
         2
       );
       assert.equal(
         newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
           .pocket_id,
@@ -1296,34 +1315,38 @@ describe("Reducers", () => {
                 ],
               },
             },
           },
           loaded: true,
         },
         spocs: {
           data: {
-            spocs: [
-              { url: "https://foo.com", pocket_id: 1234 },
-              { url: "test-spoc.com" },
-            ],
+            spocs: {
+              items: [
+                { url: "https://foo.com", pocket_id: 1234 },
+                { url: "test-spoc.com" },
+              ],
+            },
           },
           loaded: true,
           placements: [{ name: "spocs" }],
         },
       };
       const deleteAction = {
         type: at.DELETE_FROM_POCKET,
         data: {
           pocket_id: 1234,
         },
       };
 
       const newState = DiscoveryStream(oldState, deleteAction);
-      assert.deepEqual(newState.spocs.data.spocs, [{ url: "test-spoc.com" }]);
+      assert.deepEqual(newState.spocs.data.spocs.items, [
+        { url: "test-spoc.com" },
+      ]);
       assert.deepEqual(
         newState.feeds.data["https://foo.com/feed1"].data.recommendations,
         [{ url: "test.com" }]
       );
     });
     it("should remove site on ARCHIVE_FROM_POCKET in both feeds and spocs", () => {
       const oldState = {
         feeds: {
@@ -1336,34 +1359,38 @@ describe("Reducers", () => {
                 ],
               },
             },
           },
           loaded: true,
         },
         spocs: {
           data: {
-            spocs: [
-              { url: "https://foo.com", pocket_id: 1234 },
-              { url: "test-spoc.com" },
-            ],
+            spocs: {
+              items: [
+                { url: "https://foo.com", pocket_id: 1234 },
+                { url: "test-spoc.com" },
+              ],
+            },
           },
           loaded: true,
           placements: [{ name: "spocs" }],
         },
       };
       const deleteAction = {
         type: at.ARCHIVE_FROM_POCKET,
         data: {
           pocket_id: 1234,
         },
       };
 
       const newState = DiscoveryStream(oldState, deleteAction);
-      assert.deepEqual(newState.spocs.data.spocs, [{ url: "test-spoc.com" }]);
+      assert.deepEqual(newState.spocs.data.spocs.items, [
+        { url: "test-spoc.com" },
+      ]);
       assert.deepEqual(
         newState.feeds.data["https://foo.com/feed1"].data.recommendations,
         [{ url: "test.com" }]
       );
     });
     it("should add boookmark details on PLACES_BOOKMARK_ADDED in both feeds and spocs", () => {
       const oldState = {
         feeds: {
@@ -1376,17 +1403,19 @@ describe("Reducers", () => {
                 ],
               },
             },
           },
           loaded: true,
         },
         spocs: {
           data: {
-            spocs: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+            spocs: {
+              items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+            },
           },
           loaded: true,
           placements: [{ name: "spocs" }],
         },
       };
       const bookmarkAction = {
         type: at.PLACES_BOOKMARK_ADDED,
         data: {
@@ -1394,26 +1423,26 @@ describe("Reducers", () => {
           bookmarkGuid: "bookmark123",
           bookmarkTitle: "Title for bar.com",
           dateAdded: 1234567,
         },
       };
 
       const newState = DiscoveryStream(oldState, bookmarkAction);
 
-      assert.lengthOf(newState.spocs.data.spocs, 2);
+      assert.lengthOf(newState.spocs.data.spocs.items, 2);
       assert.equal(
-        newState.spocs.data.spocs[0].bookmarkGuid,
+        newState.spocs.data.spocs.items[0].bookmarkGuid,
         bookmarkAction.data.bookmarkGuid
       );
       assert.equal(
-        newState.spocs.data.spocs[0].bookmarkTitle,
+        newState.spocs.data.spocs.items[0].bookmarkTitle,
         bookmarkAction.data.bookmarkTitle
       );
-      assert.isUndefined(newState.spocs.data.spocs[1].bookmarkGuid);
+      assert.isUndefined(newState.spocs.data.spocs.items[1].bookmarkGuid);
 
       assert.lengthOf(
         newState.feeds.data["https://foo.com/feed1"].data.recommendations,
         2
       );
       assert.equal(
         newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
           .bookmarkGuid,
@@ -1446,41 +1475,43 @@ describe("Reducers", () => {
                 ],
               },
             },
           },
           loaded: true,
         },
         spocs: {
           data: {
-            spocs: [
-              {
-                url: "https://foo.com",
-                bookmarkGuid: "bookmark123",
-                bookmarkTitle: "Title for bar.com",
-              },
-              { url: "test-spoc.com" },
-            ],
+            spocs: {
+              items: [
+                {
+                  url: "https://foo.com",
+                  bookmarkGuid: "bookmark123",
+                  bookmarkTitle: "Title for bar.com",
+                },
+                { url: "test-spoc.com" },
+              ],
+            },
           },
           loaded: true,
           placements: [{ name: "spocs" }],
         },
       };
       const action = {
         type: at.PLACES_BOOKMARK_REMOVED,
         data: {
           url: "https://foo.com",
         },
       };
 
       const newState = DiscoveryStream(oldState, action);
 
-      assert.lengthOf(newState.spocs.data.spocs, 2);
-      assert.isUndefined(newState.spocs.data.spocs[0].bookmarkGuid);
-      assert.isUndefined(newState.spocs.data.spocs[0].bookmarkTitle);
+      assert.lengthOf(newState.spocs.data.spocs.items, 2);
+      assert.isUndefined(newState.spocs.data.spocs.items[0].bookmarkGuid);
+      assert.isUndefined(newState.spocs.data.spocs.items[0].bookmarkTitle);
 
       assert.lengthOf(
         newState.feeds.data["https://foo.com/feed1"].data.recommendations,
         2
       );
       assert.isUndefined(
         newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
           .bookmarkGuid
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx
@@ -0,0 +1,103 @@
+import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid";
+import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
+import { combineReducers, createStore } from "redux";
+import { INITIAL_STATE, reducers } from "common/Reducers.jsm";
+import { Provider } from "react-redux";
+import React from "react";
+import { mount } from "enzyme";
+
+function mountCollectionWithProps(props, spocsState) {
+  const state = {
+    ...INITIAL_STATE,
+    DiscoveryStream: {
+      ...INITIAL_STATE.DiscoveryStream,
+      spocs: {
+        ...INITIAL_STATE.DiscoveryStream.spocs,
+        ...spocsState,
+      },
+    },
+  };
+  const store = createStore(combineReducers(reducers), state);
+
+  return mount(
+    <Provider store={store}>
+      <CollectionCardGrid {...props} />
+    </Provider>
+  );
+}
+
+describe("<CollectionCardGrid>", () => {
+  let wrapper;
+
+  beforeEach(() => {
+    const initialSpocs = [{ id: 123 }, { id: 456 }, { id: 789 }];
+    wrapper = mountCollectionWithProps(
+      {
+        placement: {
+          name: "spocs",
+        },
+        data: {
+          spocs: initialSpocs,
+        },
+      },
+      {
+        data: {
+          spocs: {
+            title: "title",
+            context: "context",
+            items: initialSpocs,
+          },
+        },
+      }
+    );
+  });
+
+  it("should render an empty div", () => {
+    wrapper = mountCollectionWithProps({}, {});
+    assert.ok(wrapper.exists());
+    assert.ok(!wrapper.exists(".ds-collection-card-grid"));
+  });
+
+  it("should render a CardGrid", () => {
+    assert.lengthOf(wrapper.find(".ds-collection-card-grid").children(), 1);
+    assert.equal(
+      wrapper
+        .find(".ds-collection-card-grid")
+        .children()
+        .at(0)
+        .type(),
+      CardGrid
+    );
+  });
+
+  it("should inject spocs in every CardGrid rec position", () => {
+    assert.lengthOf(
+      wrapper
+        .find(".ds-collection-card-grid")
+        .children()
+        .at(0)
+        .props().data.recommendations,
+      3
+    );
+  });
+
+  it("should pass along title and context to CardGrid", () => {
+    assert.equal(
+      wrapper
+        .find(".ds-collection-card-grid")
+        .children()
+        .at(0)
+        .props().title,
+      "title"
+    );
+
+    assert.equal(
+      wrapper
+        .find(".ds-collection-card-grid")
+        .children()
+        .at(0)
+        .props().context,
+      "context"
+    );
+  });
+});
--- a/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js
+++ b/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js
@@ -102,17 +102,17 @@ describe("selectLayoutRender", () => {
     store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
 
     const { layoutRender } = selectLayoutRender({
       state: store.getState().DiscoveryStream,
     });
 
     assert.lengthOf(layoutRender, 1);
     assert.propertyVal(layoutRender[0], "width", 3);
-    assert.deepEqual(layoutRender[0].components[0].data.spocs, []);
+    assert.deepEqual(layoutRender[0].components[0].data.spocs.items, []);
   });
 
   it("should return layout with spocs data if feed isn't defined but spocs is", () => {
     const fakeLayout = [
       {
         width: 3,
         components: [
           { type: "foo", spocs: { probability: 1, positions: [{ index: 0 }] } },
@@ -121,17 +121,17 @@ describe("selectLayoutRender", () => {
     ];
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: fakeLayout },
     });
     store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
     store.dispatch({
       type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
-      data: { lastUpdated: 0, spocs: { spocs: [1, 2, 3] } },
+      data: { lastUpdated: 0, spocs: { spocs: { items: [1, 2, 3] } } },
     });
 
     const { layoutRender } = selectLayoutRender({
       state: store.getState().DiscoveryStream,
     });
 
     assert.lengthOf(layoutRender, 1);
     assert.propertyVal(layoutRender[0], "width", 3);
@@ -176,17 +176,17 @@ describe("selectLayoutRender", () => {
         width: 3,
         components: [
           { type: "foo", feed: { url: "foo.com" }, spocs: fakeSpocConfig },
         ],
       },
     ];
     const fakeSpocsData = {
       lastUpdated: 0,
-      spocs: { spocs: ["fooSpoc", "barSpoc"] },
+      spocs: { spocs: { items: ["fooSpoc", "barSpoc"] } },
     };
 
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: fakeLayout },
     });
     store.dispatch({
       type: at.DISCOVERY_STREAM_FEED_UPDATE,
@@ -236,17 +236,17 @@ describe("selectLayoutRender", () => {
         width: 3,
         components: [
           { type: "foo", feed: { url: "foo.com" }, spocs: fakeSpocConfig },
         ],
       },
     ];
     const fakeSpocsData = {
       lastUpdated: 0,
-      spocs: { spocs: ["fooSpoc", "barSpoc"] },
+      spocs: { spocs: { items: ["fooSpoc", "barSpoc"] } },
     };
 
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: fakeLayout },
     });
     store.dispatch({
       type: at.DISCOVERY_STREAM_FEED_UPDATE,
@@ -296,17 +296,17 @@ describe("selectLayoutRender", () => {
         width: 3,
         components: [
           { type: "foo", feed: { url: "foo.com" }, spocs: fakeSpocConfig },
         ],
       },
     ];
     const fakeSpocsData = {
       lastUpdated: 0,
-      spocs: { spocs: ["fooSpoc", "barSpoc", "lastSpoc"] },
+      spocs: { spocs: { items: ["fooSpoc", "barSpoc", "lastSpoc"] } },
     };
 
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: fakeLayout },
     });
     store.dispatch({
       type: at.DISCOVERY_STREAM_FEED_UPDATE,
@@ -364,17 +364,17 @@ describe("selectLayoutRender", () => {
         width: 3,
         components: [
           { type: "foo", feed: { url: "foo.com" }, spocs: fakeSpocConfig },
         ],
       },
     ];
     const fakeSpocsData = {
       lastUpdated: 0,
-      spocs: { spocs: ["fooSpoc", "barSpoc"] },
+      spocs: { spocs: { items: ["fooSpoc", "barSpoc"] } },
     };
 
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: fakeLayout },
     });
     store.dispatch({
       type: at.DISCOVERY_STREAM_FEED_UPDATE,
@@ -426,17 +426,17 @@ describe("selectLayoutRender", () => {
         width: 3,
         components: [
           { type: "foo", feed: { url: "foo.com" }, spocs: fakeSpocConfig },
         ],
       },
     ];
     const fakeSpocsData = {
       lastUpdated: 0,
-      spocs: { spocs: ["fooSpoc", "barSpoc"] },
+      spocs: { spocs: { items: ["fooSpoc", "barSpoc"] } },
     };
 
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: fakeLayout },
     });
     store.dispatch({
       type: at.DISCOVERY_STREAM_FEED_UPDATE,
@@ -487,17 +487,17 @@ describe("selectLayoutRender", () => {
         width: 3,
         components: [
           { type: "foo", feed: { url: "foo.com" }, spocs: fakeSpocConfig },
         ],
       },
     ];
     const fakeSpocsData = {
       lastUpdated: 0,
-      spocs: { spocs: ["fooSpoc", "barSpoc"] },
+      spocs: { spocs: { items: ["fooSpoc", "barSpoc"] } },
     };
 
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: fakeLayout },
     });
     store.dispatch({
       type: at.DISCOVERY_STREAM_FEED_UPDATE,
@@ -550,17 +550,17 @@ describe("selectLayoutRender", () => {
         width: 3,
         components: [
           { type: "foo", feed: { url: "foo.com" }, spocs: fakeSpocConfig },
         ],
       },
     ];
     const fakeSpocsData = {
       lastUpdated: 0,
-      spocs: { spocs: ["fooSpoc", "barSpoc"] },
+      spocs: { spocs: { items: ["fooSpoc", "barSpoc"] } },
     };
 
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: fakeLayout },
     });
     store.dispatch({
       type: at.DISCOVERY_STREAM_FEED_UPDATE,
@@ -900,17 +900,17 @@ describe("selectLayoutRender", () => {
             spocs: { positions: [{ index: 0 }], probability: 1 },
           },
         ],
       },
     ];
     const fakeSpocsData = {
       lastUpdated: 0,
       spocs: {
-        spocs: [{ name: "spoc", url: "https://foo.com" }],
+        spocs: { items: [{ name: "spoc", url: "https://foo.com" }] },
       },
     };
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: fakeLayout },
     });
     store.dispatch({
       type: at.DISCOVERY_STREAM_FEED_UPDATE,
--- a/browser/components/newtab/test/unit/lib/ActivityStream.test.js
+++ b/browser/components/newtab/test/unit/lib/ActivityStream.test.js
@@ -243,68 +243,94 @@ describe("ActivityStream", () => {
       as._updateDynamicPrefs();
 
       assert.isTrue(
         JSON.parse(PREFS_CONFIG.get("discoverystream.config").value).enabled
       );
     });
   });
   describe("_updateDynamicPrefs topstories default value", () => {
+    let getStringPrefStub;
+    let appLocaleAsBCP47Stub;
+    let prefHasUserValueStub;
+    beforeEach(() => {
+      prefHasUserValueStub = sandbox.stub(
+        global.Services.prefs,
+        "prefHasUserValue"
+      );
+      getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
+      appLocaleAsBCP47Stub = sandbox.stub(
+        global.Services.locale,
+        "appLocaleAsBCP47"
+      );
+
+      prefHasUserValueStub.returns(true);
+      appLocaleAsBCP47Stub.get(() => "en-US");
+
+      getStringPrefStub.withArgs("browser.search.region").returns("US");
+
+      getStringPrefStub
+        .withArgs(
+          "browser.newtabpage.activity-stream.discoverystream.region-stories-config"
+        )
+        .returns("US,CA");
+    });
     it("should be false with no geo/locale", () => {
+      prefHasUserValueStub.returns(false);
+      appLocaleAsBCP47Stub.get(() => "");
+      getStringPrefStub.withArgs("browser.search.region").returns("");
+
       as._updateDynamicPrefs();
 
       assert.isFalse(PREFS_CONFIG.get("feeds.section.topstories").value);
     });
     it("should be false with unexpected geo", () => {
-      sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
-      sandbox.stub(global.Services.prefs, "getStringPref").returns("NOGEO");
+      getStringPrefStub.withArgs("browser.search.region").returns("NOGEO");
 
       as._updateDynamicPrefs();
 
       assert.isFalse(PREFS_CONFIG.get("feeds.section.topstories").value);
     });
     it("should be false with expected geo and unexpected locale", () => {
-      sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
-      sandbox.stub(global.Services.prefs, "getStringPref").returns("US");
-      sandbox
-        .stub(global.Services.locale, "appLocaleAsBCP47")
-        .get(() => "no-LOCALE");
+      appLocaleAsBCP47Stub.get(() => "no-LOCALE");
 
       as._updateDynamicPrefs();
 
       assert.isFalse(PREFS_CONFIG.get("feeds.section.topstories").value);
     });
     it("should be true with expected geo and locale", () => {
-      sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
-      sandbox.stub(global.Services.prefs, "getStringPref").returns("US");
-      sandbox
-        .stub(global.Services.locale, "appLocaleAsBCP47")
-        .get(() => "en-US");
-
       as._updateDynamicPrefs();
-
       assert.isTrue(PREFS_CONFIG.get("feeds.section.topstories").value);
     });
     it("should be false after expected geo and locale then unexpected", () => {
-      sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
-      sandbox
-        .stub(global.Services.prefs, "getStringPref")
+      getStringPrefStub
+        .withArgs("browser.search.region")
         .onFirstCall()
         .returns("US")
         .onSecondCall()
         .returns("NOGEO");
-      sandbox
-        .stub(global.Services.locale, "appLocaleAsBCP47")
-        .get(() => "en-US");
 
       as._updateDynamicPrefs();
       as._updateDynamicPrefs();
 
       assert.isFalse(PREFS_CONFIG.get("feeds.section.topstories").value);
     });
+    it("should be true with updated pref change", () => {
+      appLocaleAsBCP47Stub.get(() => "en-GB");
+      getStringPrefStub.withArgs("browser.search.region").returns("GB");
+      getStringPrefStub
+        .withArgs(
+          "browser.newtabpage.activity-stream.discoverystream.region-stories-config"
+        )
+        .returns("US,CA,GB");
+
+      as._updateDynamicPrefs();
+
+      assert.isTrue(PREFS_CONFIG.get("feeds.section.topstories").value);
+    });
   });
   describe("_updateDynamicPrefs topstories delayed default value", () => {
     let clock;
     beforeEach(() => {
       clock = sinon.useFakeTimers();
 
       // Have addObserver cause prefHasUserValue to now return true then observe
       sandbox
--- a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
+++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
@@ -828,31 +828,139 @@ describe("DiscoveryStreamFeed", () => {
         feed.store.getState().DiscoveryStream.spocs.data.placement,
         "old"
       );
     });
     it("should properly transform spocs using placements", async () => {
       sandbox.stub(feed.cache, "get").returns(Promise.resolve());
       sandbox
         .stub(feed, "fetchFromEndpoint")
-        .resolves({ spocs: [{ id: "data" }] });
+        .resolves({ spocs: { items: [{ id: "data" }] } });
       sandbox.stub(feed.cache, "set").returns(Promise.resolve());
 
       await feed.loadSpocs(feed.store.dispatch);
 
       assert.calledWith(feed.cache.set, "spocs", {
-        spocs: { spocs: [{ id: "data", min_score: 0, score: 1 }] },
+        spocs: {
+          spocs: {
+            context: "",
+            title: "",
+            items: [{ id: "data", min_score: 0, score: 1 }],
+          },
+        },
         lastUpdated: 0,
       });
 
       assert.deepEqual(
-        feed.store.getState().DiscoveryStream.spocs.data.spocs[0],
+        feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0],
+        { id: "data", min_score: 0, score: 1 }
+      );
+    });
+    it("should normalizeSpocsItems for older spoc data", async () => {
+      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+      sandbox
+        .stub(feed, "fetchFromEndpoint")
+        .resolves({ spocs: [{ id: "data" }] });
+      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+      await feed.loadSpocs(feed.store.dispatch);
+
+      assert.deepEqual(
+        feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0],
         { id: "data", min_score: 0, score: 1 }
       );
     });
+    it("should return expected data if normalizeSpocsItems returns no spoc data", async () => {
+      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+      sandbox
+        .stub(feed, "fetchFromEndpoint")
+        .resolves({ placement1: [{ id: "data" }], placement2: [] });
+      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+      const fakeComponents = {
+        components: [
+          { placement: { name: "placement1" } },
+          { placement: { name: "placement2" } },
+        ],
+      };
+      feed.updatePlacements(feed.store.dispatch, [fakeComponents]);
+
+      await feed.loadSpocs(feed.store.dispatch);
+
+      assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, {
+        placement1: {
+          title: "",
+          context: "",
+          items: [{ id: "data", score: 1, min_score: 0 }],
+        },
+        placement2: {
+          title: "",
+          context: "",
+          items: [],
+        },
+      });
+    });
+    it("should use title and context on spoc data", async () => {
+      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+      sandbox.stub(feed, "fetchFromEndpoint").resolves({
+        placement1: {
+          title: "title",
+          context: "context",
+          items: [{ id: "data" }],
+        },
+      });
+      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+      const fakeComponents = {
+        components: [{ placement: { name: "placement1" } }],
+      };
+      feed.updatePlacements(feed.store.dispatch, [fakeComponents]);
+
+      await feed.loadSpocs(feed.store.dispatch);
+
+      assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, {
+        placement1: {
+          title: "title",
+          context: "context",
+          items: [{ id: "data", score: 1, min_score: 0 }],
+        },
+      });
+    });
+  });
+
+  describe("#normalizeSpocsItems", () => {
+    it("should return correct data if new data passed in", async () => {
+      const spocs = {
+        title: "title",
+        context: "context",
+        items: [{ id: "id" }],
+      };
+      const result = feed.normalizeSpocsItems(spocs);
+      assert.deepEqual(result, spocs);
+    });
+    it("should return normalized data if new data passed in without title or context", async () => {
+      const spocs = {
+        items: [{ id: "id" }],
+      };
+      const result = feed.normalizeSpocsItems(spocs);
+      assert.deepEqual(result, {
+        title: "",
+        context: "",
+        items: [{ id: "id" }],
+      });
+    });
+    it("should return normalized data if old data passed in", async () => {
+      const spocs = [{ id: "id" }];
+      const result = feed.normalizeSpocsItems(spocs);
+      assert.deepEqual(result, {
+        title: "",
+        context: "",
+        items: [{ id: "id" }],
+      });
+    });
   });
 
   describe("#showSpocs", () => {
     it("should return false from showSpocs if user pref showSponsored is false", async () => {
       feed.store.getState = () => ({
         Prefs: { values: { showSponsored: false } },
       });
       Object.defineProperty(feed, "config", {
@@ -1372,52 +1480,68 @@ describe("DiscoveryStreamFeed", () => {
         "5678": 1,
       });
     });
   });
 
   describe("#cleanUpFlightImpressionPref", () => {
     it("should remove flight-3 because it is no longer being used", async () => {
       const fakeSpocs = {
-        spocs: [
-          {
-            flight_id: "flight-1",
-            caps: {
-              lifetime: 3,
-              flight: {
-                count: 1,
-                period: 1,
+        spocs: {
+          items: [
+            {
+              flight_id: "flight-1",
+              caps: {
+                lifetime: 3,
+                flight: {
+                  count: 1,
+                  period: 1,
+                },
               },
             },
-          },
-          {
-            flight_id: "flight-2",
-            caps: {
-              lifetime: 3,
-              flight: {
-                count: 1,
-                period: 1,
+            {
+              flight_id: "flight-2",
+              caps: {
+                lifetime: 3,
+                flight: {
+                  count: 1,
+                  period: 1,
+                },
               },
             },
-          },
-        ],
+          ],
+        },
       };
       const fakeImpressions = {
         "flight-2": [Date.now() - 1],
         "flight-3": [Date.now() - 1],
       };
       sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
       sandbox.stub(feed, "writeDataPref").returns();
 
       feed.cleanUpFlightImpressionPref(fakeSpocs);
 
       assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, {
         "flight-2": [-1],
       });
     });
+    it("should use old spocs data strucutre", async () => {
+      const fakeSpocs = {
+        spocs: [
+          {
+            flight_id: "flight-2",
+          },
+        ],
+      };
+      sandbox.stub(feed, "cleanUpImpressionPref").returns();
+
+      feed.cleanUpFlightImpressionPref(fakeSpocs);
+
+      assert.calledOnce(feed.cleanUpImpressionPref);
+    });
   });
 
   describe("#recordTopRecImpressions", () => {
     it("should add a rec id to the rec impression pref", () => {
       sandbox.stub(feed, "readDataPref").returns({});
       sandbox.stub(feed, "writeDataPref");
 
       feed.recordTopRecImpressions("rec");
@@ -1525,40 +1649,42 @@ describe("DiscoveryStreamFeed", () => {
 
       assert.calledWith(feed.recordTopRecImpressions, "seen");
     });
   });
 
   describe("#onAction: DISCOVERY_STREAM_SPOC_IMPRESSION", () => {
     beforeEach(() => {
       const data = {
-        spocs: [
-          {
-            id: 1,
-            flight_id: "seen",
-            caps: {
-              lifetime: 3,
-              flight: {
-                count: 1,
-                period: 1,
+        spocs: {
+          items: [
+            {
+              id: 1,
+              flight_id: "seen",
+              caps: {
+                lifetime: 3,
+                flight: {
+                  count: 1,
+                  period: 1,
+                },
               },
             },
-          },
-          {
-            id: 2,
-            flight_id: "not-seen",
-            caps: {
-              lifetime: 3,
-              flight: {
-                count: 1,
-                period: 1,
+            {
+              id: 2,
+              flight_id: "not-seen",
+              caps: {
+                lifetime: 3,
+                flight: {
+                  count: 1,
+                  period: 1,
+                },
               },
             },
-          },
-        ],
+          ],
+        },
       };
       sandbox.stub(feed.store, "getState").returns({
         DiscoveryStream: {
           spocs: {
             data,
             spocs_per_domain: 2,
           },
         },
@@ -1566,46 +1692,48 @@ describe("DiscoveryStreamFeed", () => {
     });
 
     it("should call dispatch to ac.AlsoToPreloaded with filtered spoc data", async () => {
       Object.defineProperty(feed, "showSpocs", { get: () => true });
       const fakeImpressions = {
         seen: [Date.now() - 1],
       };
       const result = {
-        spocs: [
-          {
-            id: 2,
-            flight_id: "not-seen",
-            caps: {
-              lifetime: 3,
-              flight: {
-                count: 1,
-                period: 1,
+        spocs: {
+          items: [
+            {
+              id: 2,
+              flight_id: "not-seen",
+              caps: {
+                lifetime: 3,
+                flight: {
+                  count: 1,
+                  period: 1,
+                },
               },
             },
-          },
-        ],
+          ],
+        },
       };
       const spocFillResult = [
         {
           id: 1,
           reason: "frequency_cap",
           displayed: 0,
           full_recalc: 0,
         },
       ];
 
       sandbox.stub(feed, "recordFlightImpression").returns();
       sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
       sandbox.spy(feed.store, "dispatch");
 
       await feed.onAction({
         type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
-        data: { flight_id: "seen" },
+        data: { flightId: "seen" },
       });
 
       assert.deepEqual(
         feed.store.dispatch.secondCall.args[0].data.spocs,
         result
       );
       assert.deepEqual(
         feed.store.dispatch.thirdCall.args[0].data.spoc_fills,
@@ -1631,29 +1759,31 @@ describe("DiscoveryStreamFeed", () => {
       Object.defineProperty(feed, "showSpocs", { get: () => true });
       const fakeImpressions = {};
       sandbox.stub(feed, "recordFlightImpression").returns();
       sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
       sandbox.spy(feed.store, "dispatch");
       sandbox.spy(feed, "frequencyCapSpocs");
 
       const data = {
-        spocs: [
-          {
-            id: 2,
-            flight_id: "seen-2",
-            caps: {
-              lifetime: 3,
-              flight: {
-                count: 1,
-                period: 1,
+        spocs: {
+          items: [
+            {
+              id: 2,
+              flight_id: "seen-2",
+              caps: {
+                lifetime: 3,
+                flight: {
+                  count: 1,
+                  period: 1,
+                },
               },
             },
-          },
-        ],
+          ],
+        },
       };
       sandbox.stub(feed.store, "getState").returns({
         DiscoveryStream: {
           spocs: {
             data,
             placements: [{ name: "spocs" }, { name: "notSpocs" }],
             spocs_per_domain: 1,
           },
@@ -1661,35 +1791,37 @@ describe("DiscoveryStreamFeed", () => {
       });
 
       await feed.onAction({
         type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
         data: { flight_id: "doesn't matter" },
       });
 
       assert.calledOnce(feed.frequencyCapSpocs);
-      assert.calledWith(feed.frequencyCapSpocs, data.spocs);
+      assert.calledWith(feed.frequencyCapSpocs, data.spocs.items);
     });
   });
 
   describe("#onAction: PLACES_LINK_BLOCKED", () => {
     beforeEach(() => {
       const data = {
-        spocs: [
-          {
-            id: 1,
-            flight_id: "foo",
-            url: "foo.com",
-          },
-          {
-            id: 2,
-            flight_id: "bar",
-            url: "bar.com",
-          },
-        ],
+        spocs: {
+          items: [
+            {
+              id: 1,
+              flight_id: "foo",
+              url: "foo.com",
+            },
+            {
+              id: 2,
+              flight_id: "bar",
+              url: "bar.com",
+            },
+          ],
+        },
       };
       sandbox.stub(feed.store, "getState").returns({
         DiscoveryStream: {
           spocs: {
             data,
             placements: [{ name: "spocs" }],
           },
         },
@@ -2507,74 +2639,82 @@ describe("DiscoveryStreamFeed", () => {
             ],
           },
         },
       };
       sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
       const fakeSpocs = {
         lastUpdated: 1234,
         data: {
-          placement1: [
-            {
-              min_score: 0.5,
-              item_score: 0.6,
-            },
-            {
-              min_score: 0.5,
-              item_score: 0.4,
-            },
-            {
-              min_score: 0.5,
-              item_score: 0.8,
-            },
-          ],
-          placement2: [
-            {
-              min_score: 0.5,
-              item_score: 0.6,
-            },
-            {
-              min_score: 0.5,
-              item_score: 0.8,
-            },
-          ],
-          placement3: [],
+          placement1: {
+            items: [
+              {
+                min_score: 0.5,
+                item_score: 0.6,
+              },
+              {
+                min_score: 0.5,
+                item_score: 0.4,
+              },
+              {
+                min_score: 0.5,
+                item_score: 0.8,
+              },
+            ],
+          },
+          placement2: {
+            items: [
+              {
+                min_score: 0.5,
+                item_score: 0.6,
+              },
+              {
+                min_score: 0.5,
+                item_score: 0.8,
+              },
+            ],
+          },
+          placement3: { items: [] },
         },
       };
 
       await feed.scoreSpocs(fakeSpocs);
 
       const spocsTestResult = {
         lastUpdated: 1234,
         spocs: {
-          placement1: [
-            {
-              min_score: 0.5,
-              score: 0.8,
-              item_score: 0.8,
-            },
-            {
-              min_score: 0.5,
-              score: 0.6,
-              item_score: 0.6,
-            },
-          ],
-          placement2: [
-            {
-              min_score: 0.5,
-              score: 0.8,
-              item_score: 0.8,
-            },
-            {
-              min_score: 0.5,
-              score: 0.6,
-              item_score: 0.6,
-            },
-          ],
-          placement3: [],
+          placement1: {
+            items: [
+              {
+                min_score: 0.5,
+                score: 0.8,
+                item_score: 0.8,
+              },
+              {
+                min_score: 0.5,
+                score: 0.6,
+                item_score: 0.6,
+              },
+            ],
+          },
+          placement2: {
+            items: [
+              {
+                min_score: 0.5,
+                score: 0.8,
+                item_score: 0.8,
+              },
+              {
+                min_score: 0.5,
+                score: 0.6,
+                item_score: 0.6,
+              },
+            ],
+          },
+          placement3: { items: [] },
         },
       };
       assert.calledWith(feed.cache.set, "spocs", spocsTestResult);
       assert.equal(
         feed.store.dispatch.firstCall.args[0].type,
         at.DISCOVERY_STREAM_SPOCS_UPDATE
       );
       assert.deepEqual(
--- a/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
+++ b/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
@@ -1254,19 +1254,17 @@ describe("TelemetryFeed", () => {
     });
 
     it("should call maybeRecordTopsitesPainted when url is about:home and topsites_first_painted_ts is given", () => {
       const topsites_first_painted_ts = 44455;
       const data = { topsites_first_painted_ts };
       const spy = sandbox.spy();
 
       sandbox.stub(Services.prefs, "getIntPref").returns(1);
-      globals.set("aboutNewTabService", {
-        overridden: false,
-        newTabURL: "",
+      globals.set("AboutNewTabStartupRecorder", {
         maybeRecordTopsitesPainted: spy,
       });
       instance.addSession("port123", "about:home");
       instance.saveSessionPerfData("port123", data);
 
       assert.calledOnce(spy);
       assert.calledWith(spy, topsites_first_painted_ts);
     });
--- a/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
+++ b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
@@ -221,16 +221,29 @@ describe("ToolbarPanelHub", () => {
       // init calls `enableAppmenuButton`
       everyWindowStub.registerCallback.resetHistory();
 
       await instance.enableAppmenuButton();
 
       assert.notCalled(everyWindowStub.registerCallback);
     });
   });
+  describe("#disableAppmenuButton", () => {
+    it("should call the unregisterCallback", () => {
+      assert.notCalled(everyWindowStub.unregisterCallback);
+
+      instance.disableAppmenuButton();
+
+      assert.calledOnce(everyWindowStub.unregisterCallback);
+      assert.calledWithExactly(
+        everyWindowStub.unregisterCallback,
+        "appMenu-whatsnew-button"
+      );
+    });
+  });
   describe("#enableToolbarButton", () => {
     it("should registerCallback on enableToolbarButton if messages.length", async () => {
       instance.init(waitForInitializedStub, {
         getMessages: sandbox.stub().resolves([{}, {}]),
       });
 
       await instance.enableToolbarButton();
 
--- a/browser/extensions/formautofill/test/unit/xpcshell.ini
+++ b/browser/extensions/formautofill/test/unit/xpcshell.ini
@@ -57,8 +57,9 @@ skip-if = tsan # Times out, bug 1612707
 skip-if = tsan # Times out, bug 1612707
 [test_storage_remove.js]
 skip-if = tsan # Times out, bug 1612707
 [test_storage_syncfields.js]
 [test_transformFields.js]
 skip-if = tsan # Times out, bug 1612707
 [test_sync.js]
 head = head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js
+skip-if = tsan # Times out, bug 1612707
--- a/browser/installer/package-manifest.in
+++ b/browser/installer/package-manifest.in
@@ -369,19 +369,23 @@ bin/libfreebl_64int_3.so
 @BINPATH@/@DLL_PREFIX@smime3@DLL_SUFFIX@
 @BINPATH@/@DLL_PREFIX@ssl3@DLL_SUFFIX@
 #endif
 @BINPATH@/@DLL_PREFIX@softokn3@DLL_SUFFIX@
 #endif
 @RESPATH@/chrome/pippki@JAREXT@
 @RESPATH@/chrome/pippki.manifest
 
-#if defined(XP_WIN) && !defined(_ARM64_)
+; preprocessor.py doesn't handle parentheses, so while the following could be
+; expressed in a single line, it's more clear to break them up.
+#if defined(XP_WIN) || defined(XP_MACOSX)
+#if !defined(_ARM64_)
 @BINPATH@/@DLL_PREFIX@osclientcerts@DLL_SUFFIX@
 #endif
+#endif
 
 ; For process sandboxing
 #if defined(MOZ_SANDBOX)
 #if defined(XP_LINUX)
 @BINPATH@/@DLL_PREFIX@mozsandbox@DLL_SUFFIX@
 #endif
 #endif
 
--- a/browser/locales/en-US/browser/policies/policies-descriptions.ftl
+++ b/browser/locales/en-US/browser/policies/policies-descriptions.ftl
@@ -151,10 +151,12 @@ policy-SearchSuggestEnabled = Enable or 
 policy-SecurityDevices = Install PKCS #11 modules.
 
 policy-SSLVersionMax = Set the maximum SSL version.
 
 policy-SSLVersionMin = Set the minimum SSL version.
 
 policy-SupportMenu = Add a custom support menu item to the help menu.
 
+policy-UserMessaging = Don’t show certain messages to the user.
+
 # “format” refers to the format used for the value of this policy.
 policy-WebsiteFilter = Block websites from being visited. See documentation for more details on the format.
--- a/build/clang-plugin/FopenUsageChecker.cpp
+++ b/build/clang-plugin/FopenUsageChecker.cpp
@@ -3,18 +3,18 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 #include "FopenUsageChecker.h"
 #include "CustomMatchers.h"
 
 void FopenUsageChecker::registerMatchers(MatchFinder *AstMatcher) {
 
   auto hasConstCharPtrParam = [](const unsigned int Position) {
-    return allOf(hasParameter(Position, hasType(pointsTo(isAnyCharacter()))),
-                 hasParameter(Position, hasType(pointsTo(isConstQualified()))));
+    return hasParameter(
+        Position, hasType(hasCanonicalType(pointsTo(asString("const char")))));
   };
 
   auto hasParamOfType = [](const unsigned int Position, const char *Name) {
     return hasParameter(Position, hasType(asString(Name)));
   };
 
   auto hasIntegerParam = [](const unsigned int Position) {
     return hasParameter(Position, hasType(isInteger()));
@@ -38,20 +38,20 @@ void FopenUsageChecker::registerMatchers
                       allOf(anyOf(hasName("open"),
                                   allOf(hasName("_open"), hasIntegerParam(2)),
                                   allOf(hasName("_sopen"), hasIntegerParam(3))),
                             hasConstCharPtrParam(0), hasIntegerParam(1)),
                       allOf(hasName("_sopen_s"),
                             hasParameter(0, hasType(pointsTo(isInteger()))),
                             hasConstCharPtrParam(1), hasIntegerParam(2),
                             hasIntegerParam(3), hasIntegerParam(4)),
-                      allOf(hasName("OpenFile"), hasParamOfType(0, "LPCSTR"),
+                      allOf(hasName("OpenFile"), hasConstCharPtrParam(0),
                             hasParamOfType(1, "LPOFSTRUCT"),
                             hasIntegerParam(2)),
-                      allOf(hasName("CreateFileA"), hasParamOfType(0, "LPCSTR"),
+                      allOf(hasName("CreateFileA"), hasConstCharPtrParam(0),
                             hasIntegerParam(1), hasIntegerParam(2),
                             hasParamOfType(3, "LPSECURITY_ATTRIBUTES"),
                             hasIntegerParam(4), hasIntegerParam(5),
                             hasParamOfType(6, "HANDLE"))))))))
           .bind("funcCall"),
       this);
 }
 
--- a/build/clang-plugin/tests/TestFopenUsage.cpp
+++ b/build/clang-plugin/tests/TestFopenUsage.cpp
@@ -1,34 +1,50 @@
 #include <stdio.h>
 #include <io.h>
 #include <fcntl.h>
+#include <fstream>
 #include <windows.h>
 
 void func_fopen() {
   FILE *f1 = fopen("dummy.txt", "rt"); // expected-warning {{Usage of ASCII file functions (here fopen) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
   FILE *f2;
   fopen_s(&f2, "dummy.txt", "rt"); // expected-warning {{Usage of ASCII file functions (here fopen_s) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
 
   int fh1 = _open("dummy.txt", _O_RDONLY); // expected-warning {{Usage of ASCII file functions (here _open) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
   int fh2 = open("dummy.txt", _O_RDONLY); // expected-warning {{Usage of ASCII file functions (here open) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
   int fh3 = _sopen("dummy.txt", _O_RDONLY, _SH_DENYRW); // expected-warning {{Usage of ASCII file functions (here _sopen) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
   int fd4;
   errno_t err = _sopen_s(&fd4, "dummy.txt", _O_RDONLY, _SH_DENYRW, 0); // expected-warning {{Usage of ASCII file functions (here _sopen_s) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
 
+  std::fstream fs1;
+  fs1.open("dummy.txt"); // expected-warning {{Usage of ASCII file functions (here open) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
+  std::ifstream ifs1;
+  ifs1.open("dummy.txt"); // expected-warning {{Usage of ASCII file functions (here open) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
+  std::ofstream ofs1;
+  ofs1.open("dummy.txt"); // expected-warning {{Usage of ASCII file functions (here open) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
+#ifdef _MSC_VER
+  std::fstream fs2;
+  fs2.open(L"dummy.txt");
+  std::ifstream ifs2;
+  ifs2.open(L"dummy.txt");
+  std::ofstream ofs2;
+  ofs2.open(L"dummy.txt");
+#endif
+
   LPOFSTRUCT buffer;
   HFILE hFile1 = OpenFile("dummy.txt", buffer, OF_READ); // expected-warning {{Usage of ASCII file functions (here OpenFile) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
 
 #ifndef UNICODE
   // CreateFile is just an alias of CreateFileA
   LPCSTR buffer2;
   HANDLE hFile2 = CreateFile(buffer2, GENERIC_WRITE, 0, NULL, CREATE_NEW, // expected-warning {{Usage of ASCII file functions (here CreateFileA) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
 	  FILE_ATTRIBUTE_NORMAL, NULL);
 #else
   // CreateFile is just an alias of CreateFileW and should not be matched
   LPCWSTR buffer2;
   HANDLE hFile2 = CreateFile(buffer2, GENERIC_WRITE, 0, NULL, CREATE_NEW,
 	  FILE_ATTRIBUTE_NORMAL, NULL);
+#endif
   LPCSTR buffer3;
   HANDLE hFile3 = CreateFileA(buffer3, GENERIC_WRITE, 0, NULL, CREATE_NEW, // expected-warning {{Usage of ASCII file functions (here CreateFileA) is forbidden on Windows.}} expected-note {{On Windows executed functions: fopen, fopen_s, open, _open, _sopen, _sopen_s, OpenFile, CreateFileA should never be used due to lossy conversion from UTF8 to ANSI.}}
 	  FILE_ATTRIBUTE_NORMAL, NULL);
-#endif
 }
--- a/caps/BasePrincipal.cpp
+++ b/caps/BasePrincipal.cpp
@@ -522,16 +522,26 @@ BasePrincipal::GetExposablePrePath(nsACS
   nsCOMPtr<nsIURI> fixedURI;
   rv = fixup->CreateExposableURI(prinURI, getter_AddRefs(fixedURI));
 
   if (NS_FAILED(rv) || NS_WARN_IF(!fixedURI)) {
     return NS_OK;
   }
   return fixedURI->GetDisplayPrePath(aPrepath);
 }
+NS_IMETHODIMP
+BasePrincipal::GetPrepath(nsACString& aPath) {
+  aPath.Truncate();
+  nsCOMPtr<nsIURI> prinURI;
+  nsresult rv = GetURI(getter_AddRefs(prinURI));
+  if (NS_FAILED(rv) || !prinURI) {
+    return NS_OK;
+  }
+  return prinURI->GetPrePath(aPath);
+}
 
 NS_IMETHODIMP
 BasePrincipal::GetIsSystemPrincipal(bool* aResult) {
   *aResult = IsSystemPrincipal();
   return NS_OK;
 }
 
 NS_IMETHODIMP
--- a/caps/BasePrincipal.h
+++ b/caps/BasePrincipal.h
@@ -124,16 +124,17 @@ class BasePrincipal : public nsJSPrincip
   NS_IMETHOD IsURIInPrefList(const char* aPref, bool* aResult) override;
   NS_IMETHOD GetAboutModuleFlags(uint32_t* flags) override;
   NS_IMETHOD GetIsAddonOrExpandedAddonPrincipal(bool* aResult) override;
   NS_IMETHOD GetOriginAttributes(JSContext* aCx,
                                  JS::MutableHandle<JS::Value> aVal) final;
   NS_IMETHOD GetAsciiSpec(nsACString& aSpec) override;
   NS_IMETHOD GetExposablePrePath(nsACString& aResult) override;
   NS_IMETHOD GetHostPort(nsACString& aRes) override;
+  NS_IMETHOD GetPrepath(nsACString& aResult) override;
   NS_IMETHOD GetOriginSuffix(nsACString& aOriginSuffix) final;
   NS_IMETHOD GetIsOnion(bool* aIsOnion) override;
   NS_IMETHOD GetIsInIsolatedMozBrowserElement(
       bool* aIsInIsolatedMozBrowserElement) final;
   NS_IMETHOD GetUserContextId(uint32_t* aUserContextId) final;
   NS_IMETHOD GetPrivateBrowsingId(uint32_t* aPrivateBrowsingId) final;
   NS_IMETHOD GetSiteOrigin(nsACString& aOrigin) override;
   NS_IMETHOD IsThirdPartyURI(nsIURI* uri, bool* aRes) override;
--- a/caps/nsIPrincipal.idl
+++ b/caps/nsIPrincipal.idl
@@ -220,16 +220,23 @@ interface nsIPrincipal : nsISerializable
     /**
      * Returns the "host:port" portion of the 
      * Principals URI, if any.
      */
     [noscript] readonly attribute ACString hostPort;
 
 
     /**
+        * Returns the prepath of the principals uri
+        * follows the format scheme:
+        * "scheme://username:password@hostname:portnumber/"
+    */
+        [noscript] readonly attribute ACString prepath;
+
+    /**
       * Returns the ASCII Spec from the Principals URI.
       * Might return the empty string, e.g. for the case of
       * a SystemPrincipal or an EpxandedPrincipal.
       *
       * WARNING: DO NOT USE FOR SECURITY CHECKS.
       * just for logging purposes!
      */
     [noscript] readonly attribute ACString asciiSpec;
--- a/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js
+++ b/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js
@@ -38,16 +38,20 @@ async function initBrowserToolboxTask({
   enableBrowserToolboxFission,
   enableContentMessages,
 } = {}) {
   await pushPref("devtools.chrome.enabled", true);
   await pushPref("devtools.debugger.remote-enabled", true);
   await pushPref("devtools.browsertoolbox.enable-test-server", true);
   await pushPref("devtools.debugger.prompt-connection", false);
 
+  if (enableBrowserToolboxFission) {
+    await pushPref("devtools.browsertoolbox.fission", true);
+  }
+
   // This rejection seems to affect all tests using the browser toolbox.
   ChromeUtils.import(
     "resource://testing-common/PromiseTestUtils.jsm"
   ).PromiseTestUtils.whitelistRejectionsGlobally(/File closed/);
 
   const process = await new Promise(onRun => {
     BrowserToolboxLauncher.init(null, onRun, /* overwritePreferences */ true);
   });
@@ -79,20 +83,16 @@ async function initBrowserToolboxTask({
   await client.connect();
 
   ok(true, "Connected");
 
   const target = await client.mainRoot.getMainProcess();
   const consoleFront = await target.getFront("console");
   const preferenceFront = await client.mainRoot.getFront("preference");
 
-  if (enableBrowserToolboxFission) {
-    await preferenceFront.setBoolPref("devtools.browsertoolbox.fission", true);
-  }
-
   if (enableContentMessages) {
     await preferenceFront.setBoolPref(
       "devtools.browserconsole.contentMessages",
       true
     );
   }
 
   importFunctions({
--- a/devtools/client/performance-new/@types/perf.d.ts
+++ b/devtools/client/performance-new/@types/perf.d.ts
@@ -403,28 +403,32 @@ export interface PresetDefinition {
 
 export interface PresetDefinitions {
   [presetName: string]: PresetDefinition;
 }
 
 export type MessageFromFrontend =
   | {
       type: "STATUS_QUERY";
+      requestId: number;
     }
   | {
       type: "ENABLE_MENU_BUTTON";
+      requestId: number;
     };
 
 export type MessageToFrontend =
   | {
       type: "STATUS_RESPONSE";
       menuButtonIsEnabled: boolean;
+      requestId: number;
     }
   | {
       type: "ENABLE_MENU_BUTTON_DONE";
+      requestId: number;
     }
 
 /**
  * This represents an event channel that can talk to a content page on the web.
  * This interface is a manually typed version of toolkit/modules/WebChannel.jsm
  * and is opinionated about the types of messages we can send with it.
  *
  * The definition is here rather than gecko.d.ts because it was simpler than getting
--- a/devtools/client/performance-new/popup/background.jsm.js
+++ b/devtools/client/performance-new/popup/background.jsm.js
@@ -435,25 +435,27 @@ function handleWebChannelMessage(channel
   if (typeof message !== "object" || typeof message.type !== "string") {
     console.error(
       "An malformed message was received by the profiler's WebChannel handler.",
       message
     );
     return;
   }
   const messageFromFrontend = /** @type {MessageFromFrontend} */ (message);
+  const { requestId } = messageFromFrontend;
   switch (messageFromFrontend.type) {
     case "STATUS_QUERY": {
       // The content page wants to know if this channel exists. It does, so respond
       // back to the ping.
       const { ProfilerMenuButton } = lazyProfilerMenuButton();
       channel.send(
         {
           type: "STATUS_RESPONSE",
           menuButtonIsEnabled: ProfilerMenuButton.isEnabled(),
+          requestId,
         },
         target
       );
       break;
     }
     case "ENABLE_MENU_BUTTON": {
       // Enable the profiler menu button.
       const { ProfilerMenuButton } = lazyProfilerMenuButton();
@@ -467,16 +469,17 @@ function handleWebChannelMessage(channel
         }
         ProfilerMenuButton.toggle(ownerDocument);
       }
 
       // Respond back that we've done it.
       channel.send(
         {
           type: "ENABLE_MENU_BUTTON_DONE",
+          requestId,
         },
         target
       );
       break;
     }
     default:
       console.error(
         "An unknown message type was received by the profiler's WebChannel handler.",
--- a/devtools/client/performance-new/test/browser/webchannel.html
+++ b/devtools/client/performance-new/test/browser/webchannel.html
@@ -13,14 +13,15 @@
       "use strict";
       document.title = "WebChannel Page Ready";
 
       window.dispatchEvent(
         new CustomEvent('WebChannelMessageToChrome', {
           detail: JSON.stringify({
             id: 'profiler.firefox.com',
             message: { type: "ENABLE_MENU_BUTTON" },
+            requestId: 0,
           }),
         })
       );
     </script>
   </body>
 </html>
--- a/dom/base/DOMIntersectionObserver.cpp
+++ b/dom/base/DOMIntersectionObserver.cpp
@@ -9,16 +9,17 @@
 #include "nsIFrame.h"
 #include "nsContentUtils.h"
 #include "nsLayoutUtils.h"
 #include "mozilla/PresShell.h"
 #include "mozilla/ServoBindings.h"
 #include "mozilla/dom/BrowserChild.h"
 #include "mozilla/dom/BrowsingContext.h"
 #include "mozilla/dom/DocumentInlines.h"
+#include "mozilla/dom/HTMLImageElement.h"
 #include "Units.h"
 
 namespace mozilla {
 namespace dom {
 
 NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DOMIntersectionObserverEntry)
   NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
   NS_INTERFACE_MAP_ENTRY(nsISupports)
@@ -46,25 +47,32 @@ NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(DOM
   NS_IMPL_CYCLE_COLLECTION_TRACE_PRESERVED_WRAPPER
 NS_IMPL_CYCLE_COLLECTION_TRACE_END
 
 NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(DOMIntersectionObserver)
   NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER
   tmp->Disconnect();
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mOwner)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocument)
-  NS_IMPL_CYCLE_COLLECTION_UNLINK(mCallback)
+  if (tmp->mCallback.is<RefPtr<dom::IntersectionCallback>>()) {
+    ImplCycleCollectionUnlink(
+        tmp->mCallback.as<RefPtr<dom::IntersectionCallback>>());
+  }
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mRoot)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mQueuedEntries)
 NS_IMPL_CYCLE_COLLECTION_UNLINK_END
 
 NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(DOMIntersectionObserver)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mOwner)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocument)
-  NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mCallback)
+  if (tmp->mCallback.is<RefPtr<dom::IntersectionCallback>>()) {
+    ImplCycleCollectionTraverse(
+        cb, tmp->mCallback.as<RefPtr<dom::IntersectionCallback>>(), "mCallback",
+        0);
+  }
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mRoot)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mQueuedEntries)
 NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
 
 already_AddRefed<DOMIntersectionObserver> DOMIntersectionObserver::Constructor(
     const GlobalObject& aGlobal, dom::IntersectionCallback& aCb,
     ErrorResult& aRv) {
   return Constructor(aGlobal, aCb, IntersectionObserverInit(), aRv);
@@ -108,16 +116,35 @@ already_AddRefed<DOMIntersectionObserver
       return nullptr;
     }
     observer->mThresholds.AppendElement(thresh);
   }
 
   return observer.forget();
 }
 
+already_AddRefed<DOMIntersectionObserver>
+DOMIntersectionObserver::CreateLazyLoadObserver(nsPIDOMWindowInner* aOwner) {
+  RefPtr<DOMIntersectionObserver> observer = new DOMIntersectionObserver(
+      aOwner,
+      [](const Sequence<OwningNonNull<DOMIntersectionObserverEntry>>& entries) {
+        for (const auto& entry : entries) {
+          MOZ_ASSERT(entry->Target()->IsHTMLElement(nsGkAtoms::img));
+          if (entry->IsIntersecting()) {
+            static_cast<HTMLImageElement*>(entry->Target())
+                ->StopLazyLoadingAndStartLoadIfNeeded();
+          }
+        }
+      });
+
+  observer->mThresholds.AppendElement(std::numeric_limits<double>::min());
+
+  return observer.forget();
+}
+
 bool DOMIntersectionObserver::SetRootMargin(const nsAString& aString) {
   return Servo_IntersectionObserverRootMargin_Parse(&aString, &mRootMargin);
 }
 
 void DOMIntersectionObserver::GetRootMargin(DOMString& aRetVal) {
   nsString& retVal = aRetVal;
   Servo_IntersectionObserverRootMargin_ToString(&mRootMargin, &retVal);
 }
@@ -600,14 +627,20 @@ void DOMIntersectionObserver::Notify() {
   Sequence<OwningNonNull<DOMIntersectionObserverEntry>> entries;
   if (entries.SetCapacity(mQueuedEntries.Length(), mozilla::fallible)) {
     for (size_t i = 0; i < mQueuedEntries.Length(); ++i) {
       RefPtr<DOMIntersectionObserverEntry> next = mQueuedEntries[i];
       *entries.AppendElement(mozilla::fallible) = next;
     }
   }
   mQueuedEntries.Clear();
-  RefPtr<dom::IntersectionCallback> callback(mCallback);
-  callback->Call(this, entries, *this);
+
+  if (mCallback.is<RefPtr<dom::IntersectionCallback>>()) {
+    RefPtr<dom::IntersectionCallback> callback(
+        mCallback.as<RefPtr<dom::IntersectionCallback>>());
+    callback->Call(this, entries, *this);
+  } else {
+    mCallback.as<NativeIntersectionObserverCallback>()(entries);
+  }
 }
 
 }  // namespace dom
 }  // namespace mozilla
--- a/dom/base/DOMIntersectionObserver.h
+++ b/dom/base/DOMIntersectionObserver.h
@@ -5,16 +5,17 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 #ifndef DOMIntersectionObserver_h
 #define DOMIntersectionObserver_h
 
 #include "mozilla/Attributes.h"
 #include "mozilla/dom/IntersectionObserverBinding.h"
 #include "mozilla/ServoStyleConsts.h"
+#include "mozilla/Variant.h"
 #include "nsTArray.h"
 
 namespace mozilla {
 namespace dom {
 
 class DOMIntersectionObserver;
 
 class DOMIntersectionObserverEntry final : public nsISupports,
@@ -77,22 +78,33 @@ class DOMIntersectionObserverEntry final
       0xb6, 0xb1, 0x4d, 0x2b, 0x49, 0xd8, 0xef, 0x94 \
     }                                                \
   }
 
 class DOMIntersectionObserver final : public nsISupports,
                                       public nsWrapperCache {
   virtual ~DOMIntersectionObserver() { Disconnect(); }
 
+  typedef void (*NativeIntersectionObserverCallback)(
+      const Sequence<OwningNonNull<DOMIntersectionObserverEntry>>& aEntries);
+  DOMIntersectionObserver(nsPIDOMWindowInner* aOwner,
+                          NativeIntersectionObserverCallback aCb)
+      : mOwner(aOwner),
+        mDocument(mOwner->GetExtantDoc()),
+        mCallback(aCb),
+        mConnected(false) {
+    MOZ_ASSERT(mOwner);
+  }
+
  public:
   DOMIntersectionObserver(already_AddRefed<nsPIDOMWindowInner>&& aOwner,
                           dom::IntersectionCallback& aCb)
       : mOwner(aOwner),
         mDocument(mOwner->GetExtantDoc()),
-        mCallback(&aCb),
+        mCallback(RefPtr<dom::IntersectionCallback>(&aCb)),
         mConnected(false) {}
   NS_DECL_CYCLE_COLLECTING_ISUPPORTS
   NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(DOMIntersectionObserver)
   NS_DECLARE_STATIC_IID_ACCESSOR(NS_DOM_INTERSECTION_OBSERVER_IID)
 
   static already_AddRefed<DOMIntersectionObserver> Constructor(
       const GlobalObject&, dom::IntersectionCallback&, ErrorResult&);
   static already_AddRefed<DOMIntersectionObserver> Constructor(
@@ -113,35 +125,41 @@ class DOMIntersectionObserver final : pu
   void Observe(Element& aTarget);
   void Unobserve(Element& aTarget);
 
   void UnlinkTarget(Element& aTarget);
   void Disconnect();
 
   void TakeRecords(nsTArray<RefPtr<DOMIntersectionObserverEntry>>& aRetVal);
 
-  dom::IntersectionCallback* IntersectionCallback() { return mCallback; }
+  dom::IntersectionCallback* IntersectionCallback() {
+    return mCallback.as<RefPtr<dom::IntersectionCallback>>();
+  }
 
   bool SetRootMargin(const nsAString& aString);
 
   void Update(Document* aDocument, DOMHighResTimeStamp time);
   MOZ_CAN_RUN_SCRIPT void Notify();
 
+  static already_AddRefed<DOMIntersectionObserver> CreateLazyLoadObserver(
+      nsPIDOMWindowInner* aOwner);
+
  protected:
   void Connect();
   void QueueIntersectionObserverEntry(Element* aTarget,
                                       DOMHighResTimeStamp time,
                                       const Maybe<nsRect>& aRootRect,
                                       const nsRect& aTargetRect,
                                       const Maybe<nsRect>& aIntersectionRect,
                                       double aIntersectionRatio);
 
   nsCOMPtr<nsPIDOMWindowInner> mOwner;
   RefPtr<Document> mDocument;
-  RefPtr<dom::IntersectionCallback> mCallback;
+  Variant<RefPtr<dom::IntersectionCallback>, NativeIntersectionObserverCallback>
+      mCallback;
   RefPtr<Element> mRoot;
   StyleRect<LengthPercentage> mRootMargin;
   nsTArray<double> mThresholds;
 
   // Holds raw pointers which are explicitly cleared by UnlinkTarget().
   nsTArray<Element*> mObservationTargets;
 
   nsTArray<RefPtr<DOMIntersectionObserverEntry>> mQueuedEntries;
--- a/dom/base/Document.cpp
+++ b/dom/base/Document.cpp
@@ -2143,16 +2143,17 @@ NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mStyleSheetSetList)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mScriptLoader)
 
   DocumentOrShadowRoot::Traverse(tmp, cb);
 
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mChannel)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mLayoutHistoryState)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mOnloadBlocker)
+  NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mLazyLoadImageObserver)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDOMImplementation)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mImageMaps)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mOrientationPendingPromise)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mOriginalDocument)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mCachedEncoder)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mStateObjectCached)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocumentTimeline)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mPendingAnimationTracker)
@@ -2250,16 +2251,17 @@ NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(Do
     tmp->DisconnectChild(child);
     child->UnbindFromTree();
   }
 
   tmp->UnlinkOriginalDocumentIfStatic();
 
   tmp->mCachedRootElement = nullptr;  // Avoid a dangling pointer
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mDisplayDocument)
+  NS_IMPL_CYCLE_COLLECTION_UNLINK(mLazyLoadImageObserver)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mDOMImplementation)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mImageMaps)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mCachedEncoder)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocumentTimeline)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mPendingAnimationTracker)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mTemplateContentsOwner)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mChildrenCollection)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mImages);
@@ -7106,21 +7108,20 @@ void Document::BeginUpdate() {
 }
 
 void Document::EndUpdate() {
   const bool reset = !mPendingMaybeEditingStateChanged;
   mPendingMaybeEditingStateChanged = true;
 
   NS_DOCUMENT_NOTIFY_OBSERVERS(EndUpdate, (this));
 
+  --mUpdateNestLevel;
+
   nsContentUtils::RemoveScriptBlocker();
 
-  --mUpdateNestLevel;
-
-  MaybeInitializeFinalizeFrameLoaders();
   if (mXULBroadcastManager) {
     mXULBroadcastManager->MaybeBroadcast();
   }
 
   if (reset) {
     mPendingMaybeEditingStateChanged = false;
   }
   MaybeEditingStateChanged();
@@ -8472,19 +8473,18 @@ nsresult Document::FinalizeFrameLoader(n
                           &Document::MaybeInitializeFinalizeFrameLoaders);
     NS_ENSURE_TRUE(mFrameLoaderRunner, NS_ERROR_OUT_OF_MEMORY);
     nsContentUtils::AddScriptRunner(mFrameLoaderRunner);
   }
   return NS_OK;
 }
 
 void Document::MaybeInitializeFinalizeFrameLoaders() {
-  if (mDelayFrameLoaderInitialization || mUpdateNestLevel != 0) {
-    // This method will be recalled when mUpdateNestLevel drops to 0,
-    // or when !mDelayFrameLoaderInitialization.
+  if (mDelayFrameLoaderInitialization) {
+    // This method will be recalled when !mDelayFrameLoaderInitialization.
     mFrameLoaderRunner = nullptr;
     return;
   }
 
   // We're not in an update, but it is not safe to run scripts, so
   // postpone frameloader initialization and finalization.
   if (!nsContentUtils::IsSafeToRunScript()) {
     if (!mInDestructor && !mFrameLoaderRunner &&
@@ -14661,16 +14661,32 @@ void Document::NotifyIntersectionObserve
   }
   for (const auto& observer : observers) {
     if (observer) {
       observer->Notify();
     }
   }
 }
 
+DOMIntersectionObserver* Document::GetLazyLoadImageObserver() {
+  Document* rootDoc = nsContentUtils::GetRootDocument(this);
+  MOZ_ASSERT(rootDoc);
+
+  if (rootDoc->mLazyLoadImageObserver) {
+    return rootDoc->mLazyLoadImageObserver;
+  }
+
+  if (nsPIDOMWindowInner* inner = rootDoc->GetInnerWindow()) {
+    rootDoc->mLazyLoadImageObserver =
+        DOMIntersectionObserver::CreateLazyLoadObserver(inner);
+  }
+
+  return rootDoc->mLazyLoadImageObserver;
+}
+
 static CallState NotifyLayerManagerRecreatedCallback(Document& aDocument,
                                                      void*) {
   aDocument.NotifyLayerManagerRecreated();
   return CallState::Continue;
 }
 
 void Document::NotifyLayerManagerRecreated() {
   EnumerateActivityObservers(NotifyActivityChanged, nullptr);
--- a/dom/base/Document.h
+++ b/dom/base/Document.h
@@ -3607,16 +3607,18 @@ class Document : public nsINode,
   bool HasIntersectionObservers() const {
     return !mIntersectionObservers.IsEmpty();
   }
 
   void UpdateIntersectionObservations();
   void ScheduleIntersectionObserverNotification();
   MOZ_CAN_RUN_SCRIPT void NotifyIntersectionObservers();
 
+  DOMIntersectionObserver* GetLazyLoadImageObserver();
+
   // Dispatch a runnable related to the document.
   nsresult Dispatch(TaskCategory aCategory,
                     already_AddRefed<nsIRunnable>&& aRunnable) final;
 
   virtual nsISerialEventTarget* EventTargetFor(
       TaskCategory aCategory) const override;
 
   virtual AbstractThread* AbstractMainThreadFor(
@@ -4863,16 +4865,18 @@ class Document : public nsINode,
   // Weak reference to the scope object (aka the script global object)
   // that, unlike mScriptGlobalObject, is never unset once set. This
   // is a weak reference to avoid leaks due to circular references.
   nsWeakPtr mScopeObject;
 
   // Array of intersection observers
   nsTHashtable<nsPtrHashKey<DOMIntersectionObserver>> mIntersectionObservers;
 
+  RefPtr<DOMIntersectionObserver> mLazyLoadImageObserver;
+
   // Stack of fullscreen elements. When we request fullscreen we push the
   // fullscreen element onto this stack, and when we cancel fullscreen we
   // pop one off this stack, restoring the previous fullscreen state
   nsTArray<nsWeakPtr> mFullscreenStack;
 
   // The root of the doc tree in which this document is in. This is only
   // non-null when this document is in fullscreen mode.
   nsWeakPtr mFullscreenRoot;
--- a/dom/base/nsFocusManager.cpp
+++ b/dom/base/nsFocusManager.cpp
@@ -3486,29 +3486,29 @@ nsresult nsFocusManager::GetNextTabbable
           nsIContent* contentToFocus = GetNextTabbableContentInScope(
               currentTopLevelScopeOwner, currentTopLevelScopeOwner,
               aOriginalStartContent, aForward, aForward ? 1 : 0,
               aIgnoreTabIndex, aForDocumentNavigation, true /* aSkipOwner */);
           if (contentToFocus) {
             NS_ADDREF(*aResultContent = contentToFocus);
             return NS_OK;
           }
+          // If we've wrapped around already, then carry on.
+          if (aOriginalStartContent &&
+              currentTopLevelScopeOwner ==
+                  GetTopLevelScopeOwner(aOriginalStartContent)) {
+            // FIXME: Shouldn't this return null instead?  aOriginalStartContent
+            // isn't focusable after all.
+            NS_ADDREF(*aResultContent = aOriginalStartContent);
+            return NS_OK;
+          }
         }
         // There is no next tabbable content in currentTopLevelScopeOwner's
         // scope. We should continue the loop in order to skip all contents that
-        // is in currentTopLevelScopeOwner's scope. Unless we've wrapped around
-        // already.
-        if (aOriginalStartContent &&
-            currentTopLevelScopeOwner ==
-                GetTopLevelScopeOwner(aOriginalStartContent)) {
-          // FIXME: Shouldn't this return null instead?  aOriginalStartContent
-          // isn't focusable after all.
-          NS_ADDREF(*aResultContent = aOriginalStartContent);
-          return NS_OK;
-        }
+        // is in currentTopLevelScopeOwner's scope.
         continue;
       }
 
       MOZ_ASSERT(!GetTopLevelScopeOwner(currentContent),
                  "currentContent should be in top-level-scope at this point");
 
       // TabIndex not set defaults to 0 for form elements, anchors and other
       // elements that are normally focusable. Tabindex defaults to -1
--- a/dom/base/nsImageLoadingContent.cpp
+++ b/dom/base/nsImageLoadingContent.cpp
@@ -40,16 +40,17 @@
 
 #include "mozAutoDocUpdate.h"
 #include "mozilla/AsyncEventDispatcher.h"
 #include "mozilla/AutoRestore.h"
 #include "mozilla/CycleCollectedJSContext.h"
 #include "mozilla/EventStateManager.h"
 #include "mozilla/EventStates.h"
 #include "mozilla/dom/Element.h"
+#include "mozilla/dom/HTMLImageElement.h"
 #include "mozilla/dom/ImageTracker.h"
 #include "mozilla/dom/ScriptSettings.h"
 #include "mozilla/net/UrlClassifierFeatureFactory.h"
 #include "mozilla/Preferences.h"
 #include "mozilla/PresShell.h"
 #include "mozilla/StaticPrefs_image.h"
 
 #ifdef LoadImage
@@ -1001,18 +1002,19 @@ void nsImageLoadingContent::ForceReload(
     return;
   }
 
   // We keep this flag around along with the old URI even for failed requests
   // without a live request object
   ImageLoadType loadType = (mCurrentRequestFlags & REQUEST_IS_IMAGESET)
                                ? eImageLoadType_Imageset
                                : eImageLoadType_Normal;
-  nsresult rv = LoadImage(currentURI, true, aNotify, loadType, true, nullptr,
-                          nsIRequest::VALIDATE_ALWAYS);
+  nsresult rv =
+      LoadImage(currentURI, true, aNotify, loadType,
+                nsIRequest::VALIDATE_ALWAYS | LoadFlags(), true, nullptr);
   if (NS_FAILED(rv)) {
     aError.Throw(rv);
   }
 }
 
 /*
  * Non-interface methods
  */
@@ -1046,25 +1048,25 @@ nsresult nsImageLoadingContent::LoadImag
   FireEvent(NS_LITERAL_STRING("loadstart"));
 
   // Parse the URI string to get image URI
   nsCOMPtr<nsIURI> imageURI;
   nsresult rv = StringToURI(aNewURI, doc, getter_AddRefs(imageURI));
   NS_ENSURE_SUCCESS(rv, rv);
   // XXXbiesi fire onerror if that failed?
 
-  return LoadImage(imageURI, aForce, aNotify, aImageLoadType, false, doc,
-                   nsIRequest::LOAD_NORMAL, aTriggeringPrincipal);
+  return LoadImage(imageURI, aForce, aNotify, aImageLoadType, LoadFlags(),
+                   false, doc, aTriggeringPrincipal);
 }
 
 nsresult nsImageLoadingContent::LoadImage(nsIURI* aNewURI, bool aForce,
                                           bool aNotify,
                                           ImageLoadType aImageLoadType,
+                                          nsLoadFlags aLoadFlags,
                                           bool aLoadStart, Document* aDocument,
-                                          nsLoadFlags aLoadFlags,
                                           nsIPrincipal* aTriggeringPrincipal) {
   MOZ_ASSERT(!mIsStartingImageLoad, "some evil code is reentering LoadImage.");
   if (mIsStartingImageLoad) {
     return NS_OK;
   }
 
   // Pending load/error events need to be canceled in some situations. This
   // is not documented in the spec, but can cause site compat problems if not
@@ -1845,8 +1847,20 @@ Element* nsImageLoadingContent::FindImag
         map->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::name,
                                       mapName, eCaseMatters)) {
       return map->AsElement();
     }
   }
 
   return nullptr;
 }
+
+nsLoadFlags nsImageLoadingContent::LoadFlags() {
+  nsIContent* thisContent = AsContent();
+  if (thisContent->IsHTMLElement(nsGkAtoms::img) &&
+      thisContent->OwnerDoc()->IsScriptEnabled() &&
+      static_cast<HTMLImageElement*>(thisContent)->LoadingState() ==
+          HTMLImageElement::Loading::Lazy) {
+    return nsIRequest::LOAD_BACKGROUND;
+  }
+
+  return nsIRequest::LOAD_NORMAL;
+}
--- a/dom/base/nsImageLoadingContent.h
+++ b/dom/base/nsImageLoadingContent.h
@@ -145,26 +145,26 @@ class nsImageLoadingContent : public nsI
    * @param aDocument Optional parameter giving the document this node is in.
    *        This is purely a performance optimization.
    * @param aLoadFlags Optional parameter specifying load flags to use for
    *        the image load
    * @param aTriggeringPrincipal Optional parameter specifying the triggering
    *        principal to use for the image load
    */
   nsresult LoadImage(nsIURI* aNewURI, bool aForce, bool aNotify,
-                     ImageLoadType aImageLoadType, bool aLoadStart = true,
+                     ImageLoadType aImageLoadType, nsLoadFlags aLoadFlags,
+                     bool aLoadStart = true,
                      mozilla::dom::Document* aDocument = nullptr,
-                     nsLoadFlags aLoadFlags = nsIRequest::LOAD_NORMAL,
                      nsIPrincipal* aTriggeringPrincipal = nullptr);
 
   nsresult LoadImage(nsIURI* aNewURI, bool aForce, bool aNotify,
                      ImageLoadType aImageLoadType,
                      nsIPrincipal* aTriggeringPrincipal) {
-    return LoadImage(aNewURI, aForce, aNotify, aImageLoadType, true, nullptr,
-                     nsIRequest::LOAD_NORMAL, aTriggeringPrincipal);
+    return LoadImage(aNewURI, aForce, aNotify, aImageLoadType, LoadFlags(),
+                     true, nullptr, aTriggeringPrincipal);
   }
 
   /**
    * helpers to get the document for this content (from the nodeinfo
    * and such).  Not named GetOwnerDoc/GetCurrentDoc to prevent ambiguous
    * method names in subclasses
    *
    * @return the document we belong to
@@ -457,16 +457,18 @@ class nsImageLoadingContent : public nsI
    *                          frame is requested to ask any images it's
    *                          associated with to discard their surfaces if
    *                          possible.
    */
   void TrackImage(imgIRequest* aImage, nsIFrame* aFrame = nullptr);
   void UntrackImage(imgIRequest* aImage,
                     const Maybe<OnNonvisible>& aNonvisibleAction = Nothing());
 
+  nsLoadFlags LoadFlags();
+
   /* MEMBERS */
   RefPtr<imgRequestProxy> mCurrentRequest;
   RefPtr<imgRequestProxy> mPendingRequest;
   uint32_t mCurrentRequestFlags;
   uint32_t mPendingRequestFlags;
 
   enum {
     // Set if the request needs ResetAnimation called on it.
--- a/dom/credentialmanagement/Credential.cpp
+++ b/dom/credentialmanagement/Credential.cpp
@@ -18,17 +18,17 @@ NS_IMPL_CYCLE_COLLECTING_RELEASE(Credent
 
 NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(Credential)
   NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
   NS_INTERFACE_MAP_ENTRY(nsISupports)
 NS_INTERFACE_MAP_END
 
 Credential::Credential(nsPIDOMWindowInner* aParent) : mParent(aParent) {}
 
-Credential::~Credential() {}
+Credential::~Credential() = default;
 
 JSObject* Credential::WrapObject(JSContext* aCx,
                                  JS::Handle<JSObject*> aGivenProto) {
   return Credential_Binding::Wrap(aCx, this, aGivenProto);
 }
 
 void Credential::GetId(nsAString& aId) const { aId.Assign(mId); }
 
--- a/dom/credentialmanagement/CredentialsContainer.cpp
+++ b/dom/credentialmanagement/CredentialsContainer.cpp
@@ -119,17 +119,17 @@ static bool IsSameOriginWithAncestors(ns
   return true;
 }
 
 CredentialsContainer::CredentialsContainer(nsPIDOMWindowInner* aParent)
     : mParent(aParent) {
   MOZ_ASSERT(aParent);
 }
 
-CredentialsContainer::~CredentialsContainer() {}
+CredentialsContainer::~CredentialsContainer() = default;
 
 void CredentialsContainer::EnsureWebAuthnManager() {
   MOZ_ASSERT(NS_IsMainThread());
 
   if (!mManager) {
     mManager = new WebAuthnManager(mParent);
   }
 }
--- a/dom/file/uri/BlobURLProtocolHandler.cpp
+++ b/dom/file/uri/BlobURLProtocolHandler.cpp
@@ -298,21 +298,18 @@ class BlobURLsReporter final : public ns
 
     if (maxFrames == 0) {
       return;
     }
 
     nsCOMPtr<nsIStackFrame> frame = dom::GetCurrentJSStack(maxFrames);
 
     nsAutoCString origin;
-    nsCOMPtr<nsIURI> principalURI;
-    if (NS_SUCCEEDED(aInfo->mPrincipal->GetURI(getter_AddRefs(principalURI))) &&
-        principalURI) {
-      principalURI->GetPrePath(origin);
-    }
+
+    aInfo->mPrincipal->GetPrepath(origin);
 
     // If we got a frame, we better have a current JSContext.  This is cheating
     // a bit; ideally we'd have our caller pass in a JSContext, or have
     // GetCurrentJSStack() hand out the JSContext it found.
     JSContext* cx = frame ? nsContentUtils::GetCurrentJSContext() : nullptr;
 
     for (uint32_t i = 0; frame; ++i) {
       nsString fileNameUTF16;
@@ -349,21 +346,19 @@ class BlobURLsReporter final : public ns
     }
   }
 
  private:
   ~BlobURLsReporter() {}
 
   static void BuildPath(nsAutoCString& path, nsCStringHashKey::KeyType aKey,
                         DataInfo* aInfo, bool anonymize) {
-    nsCOMPtr<nsIURI> principalURI;
     nsAutoCString url, owner;
-    if (NS_SUCCEEDED(aInfo->mPrincipal->GetURI(getter_AddRefs(principalURI))) &&
-        principalURI != nullptr && NS_SUCCEEDED(principalURI->GetSpec(owner)) &&
-        !owner.IsEmpty()) {
+    aInfo->mPrincipal->GetAsciiSpec(owner);
+    if (!owner.IsEmpty()) {
       owner.ReplaceChar('/', '\\');
       path += "owner(";
       if (anonymize) {
         path += "<anonymized>";
       } else {
         path += owner;
       }
       path += ")";
--- a/dom/gamepad/windows/WindowsGamepad.cpp
+++ b/dom/gamepad/windows/WindowsGamepad.cpp
@@ -511,45 +511,49 @@ void WindowsGamepadService::CheckXInputC
                gamepad.state.Gamepad.wButtons & kXIButtonMap[b].button) {
       // Button released
       service->NewButtonEvent(gamepad.id, kXIButtonMap[b].mapped, false);
     }
   }
 
   // Then triggers
   if (state.Gamepad.bLeftTrigger != gamepad.state.Gamepad.bLeftTrigger) {
-    bool pressed =
+    const bool pressed =
         state.Gamepad.bLeftTrigger >= XINPUT_GAMEPAD_TRIGGER_THRESHOLD;
     service->NewButtonEvent(gamepad.id, kButtonLeftTrigger, pressed,
                             state.Gamepad.bLeftTrigger / 255.0);
   }
   if (state.Gamepad.bRightTrigger != gamepad.state.Gamepad.bRightTrigger) {
-    bool pressed =
+    const bool pressed =
         state.Gamepad.bRightTrigger >= XINPUT_GAMEPAD_TRIGGER_THRESHOLD;
     service->NewButtonEvent(gamepad.id, kButtonRightTrigger, pressed,
                             state.Gamepad.bRightTrigger / 255.0);
   }
 
   // Finally deal with analog sticks
   // TODO: bug 1001955 - Support deadzones.
   if (state.Gamepad.sThumbLX != gamepad.state.Gamepad.sThumbLX) {
+    const float div = state.Gamepad.sThumbLX > 0 ? 32767.0 : 32768.0;
     service->NewAxisMoveEvent(gamepad.id, kLeftStickXAxis,
-                              state.Gamepad.sThumbLX / 32767.0);
+                              state.Gamepad.sThumbLX / div);
   }
   if (state.Gamepad.sThumbLY != gamepad.state.Gamepad.sThumbLY) {
+    const float div = state.Gamepad.sThumbLY > 0 ? 32767.0 : 32768.0;
     service->NewAxisMoveEvent(gamepad.id, kLeftStickYAxis,
-                              -1.0 * state.Gamepad.sThumbLY / 32767.0);
+                              -1.0 * state.Gamepad.sThumbLY / div);
   }
   if (state.Gamepad.sThumbRX != gamepad.state.Gamepad.sThumbRX) {
+    const float div = state.Gamepad.sThumbRX > 0 ? 32767.0 : 32768.0;
     service->NewAxisMoveEvent(gamepad.id, kRightStickXAxis,
-                              state.Gamepad.sThumbRX / 32767.0);
+                              state.Gamepad.sThumbRX / div);
   }
   if (state.Gamepad.sThumbRY != gamepad.state.Gamepad.sThumbRY) {
+    const float div = state.Gamepad.sThumbRY > 0 ? 32767.0 : 32768.0;
     service->NewAxisMoveEvent(gamepad.id, kRightStickYAxis,
-                              -1.0 * state.Gamepad.sThumbRY / 32767.0);
+                              -1.0 * state.Gamepad.sThumbRY / div);
   }
   gamepad.state = state;
 }
 
 // Used to sort a list of axes by HID usage.
 class HidValueComparator {
  public:
   bool Equals(const Gamepad::axisValue& c1,
--- a/dom/html/HTMLImageElement.cpp
+++ b/dom/html/HTMLImageElement.cpp
@@ -16,16 +16,17 @@
 #include "nsImageFrame.h"
 #include "nsIScriptContext.h"
 #include "nsContentUtils.h"
 #include "nsContainerFrame.h"
 #include "nsNodeInfoManager.h"
 #include "mozilla/MouseEvents.h"
 #include "nsContentPolicyUtils.h"
 #include "nsFocusManager.h"
+#include "mozilla/dom/DOMIntersectionObserver.h"
 #include "mozilla/dom/HTMLFormElement.h"
 #include "mozilla/dom/MutationEventBinding.h"
 #include "mozilla/dom/UserActivation.h"
 #include "nsAttrValueOrString.h"
 #include "imgLoader.h"
 #include "Image.h"
 
 // Responsive images!
@@ -108,16 +109,17 @@ class ImageLoadTask final : public Micro
   bool mUseUrgentStartForChannel;
 };
 
 HTMLImageElement::HTMLImageElement(
     already_AddRefed<mozilla::dom::NodeInfo>&& aNodeInfo)
     : nsGenericHTMLElement(std::move(aNodeInfo)),
       mForm(nullptr),
       mInDocResponsiveContent(false),
+      mLazyLoading(false),
       mCurrentDensity(1.0) {
   // We start out broken
   AddStatesSilently(NS_EVENT_STATE_BROKEN);
 }
 
 HTMLImageElement::~HTMLImageElement() { DestroyImageLoadingContent(); }
 
 NS_IMPL_CYCLE_COLLECTION_INHERITED(HTMLImageElement, nsGenericHTMLElement,
@@ -198,16 +200,24 @@ static const nsAttrValue::EnumTable kLoa
     {"eager", HTMLImageElement::Loading::Eager},
     {"lazy", HTMLImageElement::Loading::Lazy},
     {nullptr, 0}};
 
 void HTMLImageElement::GetLoading(nsAString& aValue) const {
   GetEnumAttr(nsGkAtoms::loading, kLoadingTable[0].tag, aValue);
 }
 
+HTMLImageElement::Loading HTMLImageElement::LoadingState() const {
+  const nsAttrValue* val = mAttrs.GetAttr(nsGkAtoms::loading);
+  if (!val) {
+    return HTMLImageElement::Loading::Eager;
+  }
+  return static_cast<HTMLImageElement::Loading>(val->GetEnumValue());
+}
+
 already_AddRefed<Promise> HTMLImageElement::Decode(ErrorResult& aRv) {
   return nsImageLoadingContent::QueueDecodeAsync(aRv);
 }
 
 bool HTMLImageElement::ParseAttribute(int32_t aNamespaceID, nsAtom* aAttribute,
                                       const nsAString& aValue,
                                       nsIPrincipal* aMaybeScriptedPrincipal,
                                       nsAttrValue& aResult) {
@@ -296,16 +306,30 @@ nsresult HTMLImageElement::BeforeSetAttr
 
 nsresult HTMLImageElement::AfterSetAttr(int32_t aNameSpaceID, nsAtom* aName,
                                         const nsAttrValue* aValue,
                                         const nsAttrValue* aOldValue,
                                         nsIPrincipal* aMaybeScriptedPrincipal,
                                         bool aNotify) {
   nsAttrValueOrString attrVal(aValue);
 
+  if (aName == nsGkAtoms::loading && aNameSpaceID == kNameSpaceID_None) {
+    if (aValue &&
+        static_cast<HTMLImageElement::Loading>(aValue->GetEnumValue()) ==
+            Loading::Lazy &&
+        !ImageState().HasState(NS_EVENT_STATE_LOADING)) {
+      SetLazyLoading();
+    } else if (aOldValue &&
+               static_cast<HTMLImageElement::Loading>(
+                   aOldValue->GetEnumValue()) == Loading::Lazy &&
+               !ImageState().HasState(NS_EVENT_STATE_LOADING)) {
+      StopLazyLoadingAndStartLoadIfNeeded();
+    }
+  }
+
   if (aValue) {
     AfterMaybeChangeAttr(aNameSpaceID, aName, attrVal, aOldValue,
                          aMaybeScriptedPrincipal, true, aNotify);
   }
 
   if (aNameSpaceID == kNameSpaceID_None && mForm &&
       (aName == nsGkAtoms::name || aName == nsGkAtoms::id) && aValue &&
       !aValue->IsEmptyString()) {
@@ -396,17 +420,17 @@ void HTMLImageElement::AfterMaybeChangeA
         this, aValue.String(), aMaybeScriptedPrincipal);
 
     if (InResponsiveMode()) {
       if (mResponsiveSelector && mResponsiveSelector->Content() == this) {
         mResponsiveSelector->SetDefaultSource(aValue.String(),
                                               mSrcTriggeringPrincipal);
       }
       QueueImageLoadTask(true);
-    } else if (aNotify && OwnerDoc()->ShouldLoadImages()) {
+    } else if (aNotify && ShouldLoadImage()) {
       // If aNotify is false, we are coming from the parser or some such place;
       // we'll get bound after all the attributes have been set, so we'll do the
       // sync image load from BindToTree. Skip the LoadImage call in that case.
 
       // Note that this sync behavior is partially removed from the spec, bug
       // 1076583
 
       // A hack to get animations to reset. See bug 594771.
@@ -454,17 +478,17 @@ void HTMLImageElement::AfterMaybeChangeA
     // Mark channel as urgent-start before load image if the image load is
     // initaiated by a user interaction.
     mUseUrgentStartForChannel = UserActivation::IsHandlingUserInput();
 
     if (InResponsiveMode()) {
       // per spec, full selection runs when this changes, even though
       // it doesn't directly affect the source selection
       QueueImageLoadTask(true);
-    } else if (OwnerDoc()->ShouldLoadImages()) {
+    } else if (ShouldLoadImage()) {
       // Bug 1076583 - We still use the older synchronous algorithm in
       // non-responsive mode. Force a new load of the image with the
       // new cross origin policy
       ForceReload(aNotify, IgnoreErrors());
     }
   }
 }
 
@@ -545,17 +569,17 @@ nsresult HTMLImageElement::BindToTree(Bi
 
     // We still act synchronously for the non-responsive case (Bug
     // 1076583), but still need to delay if it is unsafe to run
     // script.
 
     // If loading is temporarily disabled, don't even launch MaybeLoadImage.
     // Otherwise MaybeLoadImage may run later when someone has reenabled
     // loading.
-    if (LoadingEnabled() && aContext.OwnerDoc().ShouldLoadImages()) {
+    if (LoadingEnabled() && ShouldLoadImage()) {
       nsContentUtils::AddScriptRunner(
           NewRunnableMethod<bool>("dom::HTMLImageElement::MaybeLoadImage", this,
                                   &HTMLImageElement::MaybeLoadImage, false));
     }
   }
 
   return rv;
 }
@@ -619,31 +643,27 @@ void HTMLImageElement::MaybeLoadImage(bo
 
 EventStates HTMLImageElement::IntrinsicState() const {
   return nsGenericHTMLElement::IntrinsicState() |
          nsImageLoadingContent::ImageState();
 }
 
 void HTMLImageElement::NodeInfoChanged(Document* aOldDoc) {
   nsGenericHTMLElement::NodeInfoChanged(aOldDoc);
+
+  if (mLazyLoading) {
+    aOldDoc->GetLazyLoadImageObserver()->Unobserve(*this);
+    mLazyLoading = false;
+    SetLazyLoading();
+  }
+
   // Force reload image if adoption steps are run.
   // If loading is temporarily disabled, don't even launch script runner.
   // Otherwise script runner may run later when someone has reenabled loading.
-  if (LoadingEnabled() && OwnerDoc()->ShouldLoadImages()) {
-    // Use script runner for the case the adopt is from appendChild.
-    // Bug 1076583 - We still behave synchronously in the non-responsive case
-    nsContentUtils::AddScriptRunner(
-        (InResponsiveMode())
-            ? NewRunnableMethod<bool>(
-                  "dom::HTMLImageElement::QueueImageLoadTask", this,
-                  &HTMLImageElement::QueueImageLoadTask, true)
-            : NewRunnableMethod<bool>("dom::HTMLImageElement::MaybeLoadImage",
-                                      this, &HTMLImageElement::MaybeLoadImage,
-                                      true));
-  }
+  StartLoadingIfNeeded();
 }
 
 // static
 already_AddRefed<HTMLImageElement> HTMLImageElement::Image(
     const GlobalObject& aGlobal, const Optional<uint32_t>& aWidth,
     const Optional<uint32_t>& aHeight, ErrorResult& aError) {
   nsCOMPtr<nsPIDOMWindowInner> win = do_QueryInterface(aGlobal.GetAsSupports());
   Document* doc;
@@ -721,17 +741,17 @@ nsresult HTMLImageElement::CopyInnerTo(H
 
   if (!destIsStatic) {
     // In SetAttr (called from nsGenericHTMLElement::CopyInnerTo), aDest skipped
     // doing the image load because we passed in false for aNotify.  But we
     // really do want it to do the load, so set it up to happen once the cloning
     // reaches a stable state.
     if (!aDest->InResponsiveMode() &&
         aDest->HasAttr(kNameSpaceID_None, nsGkAtoms::src) &&
-        aDest->OwnerDoc()->ShouldLoadImages()) {
+        aDest->ShouldLoadImage()) {
       // Mark channel as urgent-start before load image if the image load is
       // initaiated by a user interaction.
       mUseUrgentStartForChannel = UserActivation::IsHandlingUserInput();
 
       nsContentUtils::AddScriptRunner(NewRunnableMethod<bool>(
           "dom::HTMLImageElement::MaybeLoadImage", aDest,
           &HTMLImageElement::MaybeLoadImage, false));
     }
@@ -787,17 +807,17 @@ void HTMLImageElement::ClearForm(bool aR
 
   UnsetFlags(ADDED_TO_FORM);
   mForm = nullptr;
 }
 
 void HTMLImageElement::QueueImageLoadTask(bool aAlwaysLoad) {
   // If loading is temporarily disabled, we don't want to queue tasks
   // that may then run when loading is re-enabled.
-  if (!LoadingEnabled() || !OwnerDoc()->ShouldLoadImages()) {
+  if (!LoadingEnabled() || !ShouldLoadImage()) {
     return;
   }
 
   // Ensure that we don't overwrite a previous load request that requires
   // a complete load to occur.
   bool alwaysLoad = aAlwaysLoad;
   if (mPendingImageLoadTask) {
     alwaysLoad = alwaysLoad || mPendingImageLoadTask->AlwaysLoad();
@@ -1216,10 +1236,65 @@ void HTMLImageElement::DestroyContent() 
 
   nsGenericHTMLElement::DestroyContent();
 }
 
 void HTMLImageElement::MediaFeatureValuesChanged() {
   QueueImageLoadTask(false);
 }
 
+bool HTMLImageElement::ShouldLoadImage() const {
+  return OwnerDoc()->ShouldLoadImages() && !mLazyLoading;
+}
+
+void HTMLImageElement::SetLazyLoading() {
+  if (mLazyLoading) {
+    return;
+  }
+
+  // If scripting is disabled don't do lazy load.
+  // https://whatpr.org/html/3752/images.html#updating-the-image-data
+  if (!OwnerDoc()->IsScriptEnabled()) {
+    return;
+  }
+
+  // There (maybe) is a race condition that we have no LazyLoadImageObserver
+  // when the root document has been removed from the docshell.
+  // In the case we don't need to worry about lazy-loading.
+  if (DOMIntersectionObserver* lazyLoadObserver =
+          OwnerDoc()->GetLazyLoadImageObserver()) {
+    lazyLoadObserver->Observe(*this);
+    mLazyLoading = true;
+  }
+}
+
+void HTMLImageElement::StartLoadingIfNeeded() {
+  if (LoadingEnabled() && ShouldLoadImage()) {
+    // Use script runner for the case the adopt is from appendChild.
+    // Bug 1076583 - We still behave synchronously in the non-responsive case
+    nsContentUtils::AddScriptRunner(
+        (InResponsiveMode())
+            ? NewRunnableMethod<bool>(
+                  "dom::HTMLImageElement::QueueImageLoadTask", this,
+                  &HTMLImageElement::QueueImageLoadTask, true)
+            : NewRunnableMethod<bool>("dom::HTMLImageElement::MaybeLoadImage",
+                                      this, &HTMLImageElement::MaybeLoadImage,
+                                      true));
+  }
+}
+
+void HTMLImageElement::StopLazyLoadingAndStartLoadIfNeeded() {
+  if (!mLazyLoading) {
+    return;
+  }
+  mLazyLoading = false;
+
+  DOMIntersectionObserver* lazyLoadObserver =
+      OwnerDoc()->GetLazyLoadImageObserver();
+  MOZ_ASSERT(lazyLoadObserver);
+
+  lazyLoadObserver->Unobserve(*this);
+
+  StartLoadingIfNeeded();
+}
+
 }  // namespace dom
 }  // namespace mozilla
--- a/dom/html/HTMLImageElement.h
+++ b/dom/html/HTMLImageElement.h
@@ -188,16 +188,18 @@ class HTMLImageElement final : public ns
     Lazy,
   };
 
   void SetLoading(const nsAString& aLoading, ErrorResult& aError) {
     SetHTMLAttr(nsGkAtoms::loading, aLoading, aError);
   }
   void GetLoading(nsAString&) const;
 
+  Loading LoadingState() const;
+
   already_AddRefed<Promise> Decode(ErrorResult& aRv);
 
   ReferrerPolicy GetImageReferrerPolicy() override {
     return GetReferrerPolicyAsEnum();
   }
 
   MOZ_CAN_RUN_SCRIPT int32_t X();
   MOZ_CAN_RUN_SCRIPT int32_t Y();
@@ -252,16 +254,18 @@ class HTMLImageElement final : public ns
    * further <source> or <img> tags would be considered.
    */
   static bool SelectSourceForTagWithAttrs(
       Document* aDocument, bool aIsSourceTag, const nsAString& aSrcAttr,
       const nsAString& aSrcsetAttr, const nsAString& aSizesAttr,
       const nsAString& aTypeAttr, const nsAString& aMediaAttr,
       nsAString& aResult);
 
+  void StopLazyLoadingAndStartLoadIfNeeded();
+
  protected:
   virtual ~HTMLImageElement();
 
   // Queues a task to run LoadSelectedImage pending stable state.
   //
   // Pending Bug 1076583 this is only used by the responsive image
   // algorithm (InResponsiveMode()) -- synchronous actions when just
   // using img.src will bypass this, and update source and kick off
@@ -372,17 +376,28 @@ class HTMLImageElement final : public ns
    * @param aNotify Whether we plan to notify document observers.
    */
   void AfterMaybeChangeAttr(int32_t aNamespaceID, nsAtom* aName,
                             const nsAttrValueOrString& aValue,
                             const nsAttrValue* aOldValue,
                             nsIPrincipal* aMaybeScriptedPrincipal,
                             bool aValueMaybeChanged, bool aNotify);
 
+  bool ShouldLoadImage() const;
+
+  // Set this image as a lazy load image due to loading="lazy".
+  void SetLazyLoading();
+
+  void StartLoadingIfNeeded();
+
   bool mInDocResponsiveContent;
+
+  // Represents the image is deferred loading until this element gets visible.
+  bool mLazyLoading;
+
   RefPtr<ImageLoadTask> mPendingImageLoadTask;
   nsCOMPtr<nsIPrincipal> mSrcTriggeringPrincipal;
   nsCOMPtr<nsIPrincipal> mSrcsetTriggeringPrincipal;
 
   // Last URL that was attempted to load by this element.
   nsCOMPtr<nsIURI> mLastSelectedSource;
   // Last pixel density that was selected.
   double mCurrentDensity;
--- a/dom/html/TextControlState.cpp
+++ b/dom/html/TextControlState.cpp
@@ -718,17 +718,20 @@ TextInputSelectionController::CompleteMo
 
       if (child->IsHTMLElement(nsGkAtoms::br)) {
         --offset;
         hint = CARET_ASSOCIATE_AFTER;  // for Bug 106855
       }
     }
   }
 
-  frameSelection->HandleClick(parentDIV, offset, offset, aExtend, false, hint);
+  const nsFrameSelection::FocusMode focusMode =
+      aExtend ? nsFrameSelection::FocusMode::kExtendSelection
+              : nsFrameSelection::FocusMode::kCollapseToNewPoint;
+  frameSelection->HandleClick(parentDIV, offset, offset, focusMode, hint);
 
   // if we got this far, attempt to scroll no matter what the above result is
   return CompleteScroll(aForward);
 }
 
 NS_IMETHODIMP
 TextInputSelectionController::ScrollPage(bool aForward) {
   if (!mScrollFrame) {
--- a/dom/media/CubebUtils.cpp
+++ b/dom/media/CubebUtils.cpp
@@ -30,16 +30,18 @@
 #include <algorithm>
 #include <stdint.h>
 #ifdef MOZ_WIDGET_ANDROID
 #  include "GeneratedJNIWrappers.h"
 #endif
 #ifdef XP_WIN
 #  include "mozilla/mscom/EnsureMTA.h"
 #endif
+#include "audioipc_server_ffi_generated.h"
+#include "audioipc_client_ffi_generated.h"
 
 #define AUDIOIPC_POOL_SIZE_DEFAULT 1
 #define AUDIOIPC_STACK_SIZE_DEFAULT (64 * 4096)
 
 #define PREF_VOLUME_SCALE "media.volume_scale"
 #define PREF_CUBEB_BACKEND "media.cubeb.backend"
 #define PREF_CUBEB_OUTPUT_DEVICE "media.cubeb.output_device"
 #define PREF_CUBEB_LATENCY_PLAYBACK "media.cubeb_latency_playback_ms"
@@ -57,37 +59,16 @@
 #define PREF_AUDIOIPC_POOL_SIZE "media.audioipc.pool_size"
 #define PREF_AUDIOIPC_STACK_SIZE "media.audioipc.stack_size"
 
 #if (defined(XP_LINUX) && !defined(MOZ_WIDGET_ANDROID)) || \
     defined(XP_MACOSX) || (defined(XP_WIN) && !defined(_ARM64_))
 #  define MOZ_CUBEB_REMOTING
 #endif
 
-extern "C" {
-
-// This must match AudioIpcInitParams in media/audioipc/client/src/lib.rs.
-// TODO: Generate this from the Rust definition rather than duplicating it.
-struct AudioIpcInitParams {
-  mozilla::ipc::FileDescriptor::PlatformHandleType mServerConnection;
-  size_t mPoolSize;
-  size_t mStackSize;
-  void (*mThreadCreateCallback)(const char*);
-};
-
-// These functions are provided by audioipc-server crate
-extern void* audioipc_server_start(const char*, const char*);
-extern mozilla::ipc::FileDescriptor::PlatformHandleType
-audioipc_server_new_client(void*);
-extern void audioipc_server_stop(void*);
-// These functions are provided by audioipc-client crate
-extern int audioipc_client_init(cubeb**, const char*,
-                                const AudioIpcInitParams*);
-}
-
 namespace mozilla {
 
 namespace {
 
 LazyLogModule gCubebLog("cubeb");
 
 void CubebLogCallback(const char* aFmt, ...) {
   char buffer[256];
@@ -165,26 +146,27 @@ uint32_t sPreferredSampleRate;
 #ifdef MOZ_CUBEB_REMOTING
 // AudioIPC server handle
 void* sServerHandle = nullptr;
 
 // Initialized during early startup, protected by sMutex.
 StaticAutoPtr<ipc::FileDescriptor> sIPCConnection;
 
 static bool StartAudioIPCServer() {
-  sServerHandle = audioipc_server_start(sBrandName, sCubebBackendName);
+  sServerHandle =
+      audioipc::audioipc_server_start(sBrandName, sCubebBackendName);
   return sServerHandle != nullptr;
 }
 
 static void ShutdownAudioIPCServer() {
   if (!sServerHandle) {
     return;
   }
 
-  audioipc_server_stop(sServerHandle);
+  audioipc::audioipc_server_stop(sServerHandle);
   sServerHandle = nullptr;
 }
 #endif  // MOZ_CUBEB_REMOTING
 }  // namespace
 
 static const uint32_t CUBEB_NORMAL_LATENCY_MS = 100;
 // Consevative default that can work on all platforms.
 static const uint32_t CUBEB_NORMAL_LATENCY_FRAMES = 1024;
@@ -418,17 +400,17 @@ ipc::FileDescriptor CreateAudioIPCConnec
     MOZ_LOG(gCubebLog, LogLevel::Debug, ("Starting cubeb server..."));
     if (!StartAudioIPCServer()) {
       MOZ_LOG(gCubebLog, LogLevel::Error, ("audioipc_server_start failed"));
       return ipc::FileDescriptor();
     }
   }
   MOZ_ASSERT(sServerHandle);
   ipc::FileDescriptor::PlatformHandleType rawFD =
-      audioipc_server_new_client(sServerHandle);
+      audioipc::audioipc_server_new_client(sServerHandle);
   ipc::FileDescriptor fd(rawFD);
   if (!fd.IsValid()) {
     MOZ_LOG(gCubebLog, LogLevel::Error, ("audioipc_server_new_client failed"));
     return ipc::FileDescriptor();
   }
   // Close rawFD since FileDescriptor's ctor cloned it.
   // TODO: Find cleaner cross-platform way to close rawFD.
 #  ifdef XP_WIN
@@ -483,31 +465,33 @@ cubeb* GetCubebContextUnlocked() {
       }
     }
     if (NS_WARN_IF(!sIPCConnection)) {
       // Either the IPC connection failed to init or we're still waiting for
       // InitAudioIPCConnection to complete (bug 1454782).
       return nullptr;
     }
 
-    AudioIpcInitParams initParams;
+    audioipc::AudioIpcInitParams initParams;
     initParams.mPoolSize = sAudioIPCPoolSize;
     initParams.mStackSize = sAudioIPCStackSize;
     initParams.mServerConnection =
         sIPCConnection->ClonePlatformHandle().release();
     initParams.mThreadCreateCallback = [](const char* aName) {
       PROFILER_REGISTER_THREAD(aName);
     };
+    initParams.mThreadDestroyCallback = []() { PROFILER_UNREGISTER_THREAD(); };
 
     MOZ_LOG(gCubebLog, LogLevel::Debug,
             ("%s: %d", PREF_AUDIOIPC_POOL_SIZE, (int)initParams.mPoolSize));
     MOZ_LOG(gCubebLog, LogLevel::Debug,
             ("%s: %d", PREF_AUDIOIPC_STACK_SIZE, (int)initParams.mStackSize));
 
-    rv = audioipc_client_init(&sCubebContext, sBrandName, &initParams);
+    rv =
+        audioipc::audioipc_client_init(&sCubebContext, sBrandName, &initParams);
   } else {
 #endif  // MOZ_CUBEB_REMOTING
 #ifdef XP_WIN
     mozilla::mscom::EnsureMTA([&]() -> void {
 #endif
       rv = cubeb_init(&sCubebContext, sBrandName, sCubebBackendName);
 #ifdef XP_WIN
     });
--- a/dom/media/moz.build
+++ b/dom/media/moz.build
@@ -366,16 +366,34 @@ if CONFIG['MOZ_WEBRTC']:
     ]
 
 DEFINES['MOZILLA_INTERNAL_API'] = True
 DEFINES['TRACING'] = True
 
 if CONFIG['MOZ_ANDROID_HLS_SUPPORT']:
     DEFINES['MOZ_ANDROID_HLS_SUPPORT'] = True
 
+if CONFIG['COMPILE_ENVIRONMENT']:
+    EXPORTS += [
+        '!audioipc_client_ffi_generated.h',
+        '!audioipc_server_ffi_generated.h',
+    ]
+
+    GeneratedFile('audioipc_client_ffi_generated.h',
+                  script='/layout/style/RunCbindgen.py', entry_point='generate',
+                  inputs=[
+                      '/media/audioipc/client',
+                  ])
+
+    GeneratedFile('audioipc_server_ffi_generated.h',
+                  script='/layout/style/RunCbindgen.py', entry_point='generate',
+                  inputs=[
+                      '/media/audioipc/server',
+                  ])
+
 include('/ipc/chromium/chromium-config.mozbuild')
 
 # Suppress some GCC warnings being treated as errors:
 #  - about attributes on forward declarations for types that are already
 #    defined, which complains about an important MOZ_EXPORT for android::AString
 if CONFIG['CC_TYPE'] in ('clang', 'gcc'):
     CXXFLAGS += [
         '-Wno-error=attributes',
--- a/dom/media/webaudio/AnalyserNode.cpp
+++ b/dom/media/webaudio/AnalyserNode.cpp
@@ -150,54 +150,62 @@ size_t AnalyserNode::SizeOfIncludingThis
 JSObject* AnalyserNode::WrapObject(JSContext* aCx,
                                    JS::Handle<JSObject*> aGivenProto) {
   return AnalyserNode_Binding::Wrap(aCx, this, aGivenProto);
 }
 
 void AnalyserNode::SetFftSize(uint32_t aValue, ErrorResult& aRv) {
   // Disallow values that are not a power of 2 and outside the [32,32768] range
   if (aValue < 32 || aValue > MAX_FFT_SIZE || (aValue & (aValue - 1)) != 0) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(nsPrintfCString(
+        "FFT size %u is not a power of two in the range 32 to 32768", aValue));
     return;
   }
   if (FftSize() != aValue) {
     mAnalysisBlock.SetFFTSize(aValue);
     AllocateBuffer();
   }
 }
 
 void AnalyserNode::SetMinDecibels(double aValue, ErrorResult& aRv) {
   if (aValue >= mMaxDecibels) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(nsPrintfCString(
+        "%g is not strictly smaller than current maxDecibels (%g)", aValue,
+        mMaxDecibels));
     return;
   }
   mMinDecibels = aValue;
 }
 
 void AnalyserNode::SetMaxDecibels(double aValue, ErrorResult& aRv) {
   if (aValue <= mMinDecibels) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(nsPrintfCString(
+        "%g is not strictly larger than current minDecibels (%g)", aValue,
+        mMinDecibels));
     return;
   }
   mMaxDecibels = aValue;
 }
 
 void AnalyserNode::SetMinAndMaxDecibels(double aMinValue, double aMaxValue,
                                         ErrorResult& aRv) {
   if (aMinValue >= aMaxValue) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(nsPrintfCString(
+        "minDecibels value (%g) must be smaller than maxDecibels value (%g)",
+        aMinValue, aMaxValue));
     return;
   }
   mMinDecibels = aMinValue;
   mMaxDecibels = aMaxValue;
 }
 
 void AnalyserNode::SetSmoothingTimeConstant(double aValue, ErrorResult& aRv) {
   if (aValue < 0 || aValue > 1) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("%g is not in the range [0, 1]", aValue));
     return;
   }
   mSmoothingTimeConstant = aValue;
 }
 
 void AnalyserNode::GetFloatFrequencyData(const Float32Array& aArray) {
   if (!FFTAnalysis()) {
     // Might fail to allocate memory
--- a/dom/media/webaudio/AudioBuffer.cpp
+++ b/dom/media/webaudio/AudioBuffer.cpp
@@ -10,16 +10,17 @@
 #include "js/ArrayBuffer.h"  // JS::StealArrayBufferContents
 #include "mozilla/ErrorResult.h"
 #include "AudioSegment.h"
 #include "AudioChannelFormat.h"
 #include "mozilla/PodOperations.h"
 #include "mozilla/CheckedInt.h"
 #include "mozilla/MemoryReporting.h"
 #include "AudioNodeEngine.h"
+#include "nsPrintfCString.h"
 
 namespace mozilla {
 namespace dom {
 
 NS_IMPL_CYCLE_COLLECTION_CLASS(AudioBuffer)
 
 NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(AudioBuffer)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mJSChannels)
@@ -147,20 +148,31 @@ AudioBufferMemoryTracker::CollectReports
 AudioBuffer::AudioBuffer(nsPIDOMWindowInner* aWindow,
                          uint32_t aNumberOfChannels, uint32_t aLength,
                          float aSampleRate, ErrorResult& aRv)
     : mOwnerWindow(do_GetWeakReference(aWindow)), mSampleRate(aSampleRate) {
   // Note that a buffer with zero channels is permitted here for the sake of
   // AudioProcessingEvent, where channel counts must match parameters passed
   // to createScriptProcessor(), one of which may be zero.
   if (aSampleRate < WebAudioUtils::MinSampleRate ||
-      aSampleRate > WebAudioUtils::MaxSampleRate ||
-      aNumberOfChannels > WebAudioUtils::MaxChannelCount || !aLength ||
-      aLength > INT32_MAX) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aSampleRate > WebAudioUtils::MaxSampleRate) {
+    aRv.ThrowNotSupportedError(
+        nsPrintfCString("Sample rate (%g) is out of range", aSampleRate));
+    return;
+  }
+
+  if (aNumberOfChannels > WebAudioUtils::MaxChannelCount) {
+    aRv.ThrowNotSupportedError(nsPrintfCString(
+        "Number of channels (%u) is out of range", aNumberOfChannels));
+    return;
+  }
+
+  if (!aLength || aLength > INT32_MAX) {
+    aRv.ThrowNotSupportedError(
+        nsPrintfCString("Length (%u) is out of range", aLength));
     return;
   }
 
   mSharedChannels.mDuration = aLength;
   mJSChannels.SetLength(aNumberOfChannels);
   mozilla::HoldJSObjects(this);
   AudioBufferMemoryTracker::RegisterAudioBuffer(this);
 }
@@ -171,17 +183,17 @@ AudioBuffer::~AudioBuffer() {
   mozilla::DropJSObjects(this);
 }
 
 /* static */
 already_AddRefed<AudioBuffer> AudioBuffer::Constructor(
     const GlobalObject& aGlobal, const AudioBufferOptions& aOptions,
     ErrorResult& aRv) {
   if (!aOptions.mNumberOfChannels) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowNotSupportedError("Must have nonzero number of channels");
     return nullptr;
   }
 
   nsCOMPtr<nsPIDOMWindowInner> window =
       do_QueryInterface(aGlobal.GetAsSupports());
 
   return Create(window, aOptions.mNumberOfChannels, aOptions.mLength,
                 aOptions.mSampleRate, aRv);
@@ -297,28 +309,39 @@ bool AudioBuffer::RestoreJSChannelData(J
   return true;
 }
 
 void AudioBuffer::CopyFromChannel(const Float32Array& aDestination,
                                   uint32_t aChannelNumber,
                                   uint32_t aStartInChannel, ErrorResult& aRv) {
   aDestination.ComputeState();
 
-  if (aChannelNumber >= NumberOfChannels() || aStartInChannel > Length()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+  if (aChannelNumber >= NumberOfChannels()) {
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Channel number (%u) is out of range", aChannelNumber));
+    return;
+  }
+
+  if (aStartInChannel > Length()) {
+    // FIXME: this is not in the spec.  See
+    // https://bugzilla.mozilla.org/show_bug.cgi?id=1614006
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Start index (%u) is out of range", aStartInChannel));
     return;
   }
 
   uint32_t count = std::min(Length() - aStartInChannel, aDestination.Length());
   JS::AutoCheckCannotGC nogc;
   JSObject* channelArray = mJSChannels[aChannelNumber];
   if (channelArray) {
     if (JS_GetTypedArrayLength(channelArray) != Length()) {
       // The array's buffer was detached.
-      aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+      // FIXME: this is not in the spec.  See
+      // https://bugzilla.mozilla.org/show_bug.cgi?id=1614006
+      aRv.ThrowIndexSizeError("Channel's backing buffer is detached");
       return;
     }
 
     bool isShared = false;
     const float* sourceData =
         JS_GetFloat32ArrayData(channelArray, &isShared, nogc);
     // The sourceData arrays should all have originated in
     // RestoreJSChannelData, where they are created unshared.
@@ -337,48 +360,60 @@ void AudioBuffer::CopyFromChannel(const 
 }
 
 void AudioBuffer::CopyToChannel(JSContext* aJSContext,
                                 const Float32Array& aSource,
                                 uint32_t aChannelNumber,
                                 uint32_t aStartInChannel, ErrorResult& aRv) {
   aSource.ComputeState();
 
-  if (aChannelNumber >= NumberOfChannels() || aStartInChannel > Length()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+  if (aChannelNumber >= NumberOfChannels()) {
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Channel number (%u) is out of range", aChannelNumber));
+    return;
+  }
+
+  if (aStartInChannel > Length()) {
+    // FIXME: this is not in the spec.  See
+    // https://bugzilla.mozilla.org/show_bug.cgi?id=1614006
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Start index (%u) is out of range", aStartInChannel));
     return;
   }
 
   if (!RestoreJSChannelData(aJSContext)) {
     aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
     return;
   }
 
   JS::AutoCheckCannotGC nogc;
   JSObject* channelArray = mJSChannels[aChannelNumber];
   if (JS_GetTypedArrayLength(channelArray) != Length()) {
     // The array's buffer was detached.
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    // FIXME: this is not in the spec.  See
+    // https://bugzilla.mozilla.org/show_bug.cgi?id=1614006
+    aRv.ThrowIndexSizeError("Channel's backing buffer is detached");
     return;
   }
 
   uint32_t count = std::min(Length() - aStartInChannel, aSource.Length());
   bool isShared = false;
   float* channelData = JS_GetFloat32ArrayData(channelArray, &isShared, nogc);
   // The channelData arrays should all have originated in
   // RestoreJSChannelData, where they are created unshared.
   MOZ_ASSERT(!isShared);
   PodMove(channelData + aStartInChannel, aSource.Data(), count);
 }
 
 void AudioBuffer::GetChannelData(JSContext* aJSContext, uint32_t aChannel,
                                  JS::MutableHandle<JSObject*> aRetval,
                                  ErrorResult& aRv) {
   if (aChannel >= NumberOfChannels()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Channel number (%u) is out of range", aChannel));
     return;
   }
 
   if (!RestoreJSChannelData(aJSContext)) {
     aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
     return;
   }
 
--- a/dom/media/webaudio/AudioContext.cpp
+++ b/dom/media/webaudio/AudioContext.cpp
@@ -268,17 +268,19 @@ already_AddRefed<AudioContext> AudioCont
     aRv.Throw(NS_ERROR_FAILURE);
     return nullptr;
   }
 
   float sampleRate = MediaTrackGraph::REQUEST_DEFAULT_SAMPLE_RATE;
   if (aOptions.mSampleRate > 0 &&
       (aOptions.mSampleRate - WebAudioUtils::MinSampleRate < 0.0 ||
        WebAudioUtils::MaxSampleRate - aOptions.mSampleRate < 0.0)) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowNotSupportedError(nsPrintfCString(
+        "Sample rate %g is not in the range [%u, %u]", aOptions.mSampleRate,
+        WebAudioUtils::MinSampleRate, WebAudioUtils::MaxSampleRate));
     return nullptr;
   }
   sampleRate = aOptions.mSampleRate;
 
   RefPtr<AudioContext> object =
       new AudioContext(window, false, 2, 0, sampleRate);
   aRv = object->Init();
   if (NS_WARN_IF(aRv.Failed())) {
@@ -312,21 +314,33 @@ already_AddRefed<AudioContext> AudioCont
   nsCOMPtr<nsPIDOMWindowInner> window =
       do_QueryInterface(aGlobal.GetAsSupports());
   if (!window) {
     aRv.Throw(NS_ERROR_FAILURE);
     return nullptr;
   }
 
   if (aNumberOfChannels == 0 ||
-      aNumberOfChannels > WebAudioUtils::MaxChannelCount || aLength == 0 ||
-      aSampleRate < WebAudioUtils::MinSampleRate ||
+      aNumberOfChannels > WebAudioUtils::MaxChannelCount) {
+    aRv.ThrowNotSupportedError(
+        nsPrintfCString("%u is not a valid channel count", aNumberOfChannels));
+    return nullptr;
+  }
+
+  if (aLength == 0) {
+    aRv.ThrowNotSupportedError("Length must be nonzero");
+    return nullptr;
+  }
+
+  if (aSampleRate < WebAudioUtils::MinSampleRate ||
       aSampleRate > WebAudioUtils::MaxSampleRate) {
     // The DOM binding protects us against infinity and NaN
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowNotSupportedError(nsPrintfCString(
+        "Sample rate %g is not in the range [%u, %u]", aSampleRate,
+        WebAudioUtils::MinSampleRate, WebAudioUtils::MaxSampleRate));
     return nullptr;
   }
 
   RefPtr<AudioContext> object =
       new AudioContext(window, true, aNumberOfChannels, aLength, aSampleRate);
 
   RegisterWeakMemoryReporter(object);
 
@@ -342,17 +356,17 @@ already_AddRefed<ConstantSourceNode> Aud
   RefPtr<ConstantSourceNode> constantSourceNode = new ConstantSourceNode(this);
   return constantSourceNode.forget();
 }
 
 already_AddRefed<AudioBuffer> AudioContext::CreateBuffer(
     uint32_t aNumberOfChannels, uint32_t aLength, float aSampleRate,
     ErrorResult& aRv) {
   if (!aNumberOfChannels) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowNotSupportedError("Number of channels must be nonzero");
     return nullptr;
   }
 
   return AudioBuffer::Create(GetOwner(), aNumberOfChannels, aLength,
                              aSampleRate, aRv);
 }
 
 namespace {
@@ -379,21 +393,39 @@ already_AddRefed<MediaStreamAudioDestina
 AudioContext::CreateMediaStreamDestination(ErrorResult& aRv) {
   return MediaStreamAudioDestinationNode::Create(*this, AudioNodeOptions(),
                                                  aRv);
 }
 
 already_AddRefed<ScriptProcessorNode> AudioContext::CreateScriptProcessor(
     uint32_t aBufferSize, uint32_t aNumberOfInputChannels,
     uint32_t aNumberOfOutputChannels, ErrorResult& aRv) {
-  if ((aNumberOfInputChannels == 0 && aNumberOfOutputChannels == 0) ||
-      aNumberOfInputChannels > WebAudioUtils::MaxChannelCount ||
-      aNumberOfOutputChannels > WebAudioUtils::MaxChannelCount ||
-      !IsValidBufferSize(aBufferSize)) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+  if (aNumberOfInputChannels == 0 && aNumberOfOutputChannels == 0) {
+    aRv.ThrowIndexSizeError(
+        "At least one of numberOfInputChannels and numberOfOutputChannels must "
+        "be nonzero");
+    return nullptr;
+  }
+
+  if (aNumberOfInputChannels > WebAudioUtils::MaxChannelCount) {
+    aRv.ThrowIndexSizeError(nsPrintfCString(
+        "%u is not a valid number of input channels", aNumberOfInputChannels));
+    return nullptr;
+  }
+
+  if (aNumberOfOutputChannels > WebAudioUtils::MaxChannelCount) {
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("%u is not a valid number of output channels",
+                        aNumberOfOutputChannels));
+    return nullptr;
+  }
+
+  if (!IsValidBufferSize(aBufferSize)) {
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("%u is not a valid bufferSize", aBufferSize));
     return nullptr;
   }
 
   RefPtr<ScriptProcessorNode> scriptProcessor = new ScriptProcessorNode(
       this, aBufferSize, aNumberOfInputChannels, aNumberOfOutputChannels);
   return scriptProcessor.forget();
 }
 
@@ -498,18 +530,23 @@ already_AddRefed<OscillatorNode> AudioCo
 }
 
 already_AddRefed<PeriodicWave> AudioContext::CreatePeriodicWave(
     const Float32Array& aRealData, const Float32Array& aImagData,
     const PeriodicWaveConstraints& aConstraints, ErrorResult& aRv) {
   aRealData.ComputeState();
   aImagData.ComputeState();
 
-  if (aRealData.Length() != aImagData.Length() || aRealData.Length() == 0) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+  if (aRealData.Length() != aImagData.Length()) {
+    aRv.ThrowIndexSizeError("\"real\" and \"imag\" must be the same length");
+    return nullptr;
+  }
+
+  if (aRealData.Length() == 0) {
+    aRv.ThrowIndexSizeError("\"real\" and \"imag\" are both empty arrays");
     return nullptr;
   }
 
   RefPtr<PeriodicWave> periodicWave = new PeriodicWave(
       this, aRealData.Data(), aImagData.Data(), aImagData.Length(),
       aConstraints.mDisableNormalization, aRv);
   if (aRv.Failed()) {
     return nullptr;
@@ -586,17 +623,17 @@ already_AddRefed<Promise> AudioContext::
   RefPtr<Promise> promise;
   AutoJSAPI jsapi;
   jsapi.Init();
   JSContext* cx = jsapi.cx();
 
   // CheckedUnwrapStatic is OK, since we know we have an ArrayBuffer.
   JS::Rooted<JSObject*> obj(cx, js::CheckedUnwrapStatic(aBuffer.Obj()));
   if (!obj) {
-    aRv.Throw(NS_ERROR_DOM_SECURITY_ERR);
+    aRv.ThrowSecurityError("Can't get audio data from cross-origin object");
     return nullptr;
   }
 
   JSAutoRealm ar(cx, obj);
 
   promise = Promise::Create(parentObject, aRv);
   if (aRv.Failed()) {
     return nullptr;
@@ -744,23 +781,23 @@ void AudioContext::Shutdown() {
 
   // We don't want to touch promises if the global is going away soon.
   if (!mIsDisconnecting) {
     if (!mIsOffline) {
       CloseInternal(nullptr, AudioContextOperationFlags::None);
     }
 
     for (auto p : mPromiseGripArray) {
-      p->MaybeReject(NS_ERROR_DOM_INVALID_STATE_ERR);
+      p->MaybeRejectWithInvalidStateError("Navigated away from page");
     }
 
     mPromiseGripArray.Clear();
 
     for (const auto& p : mPendingResumePromises) {
-      p->MaybeReject(NS_ERROR_DOM_INVALID_STATE_ERR);
+      p->MaybeRejectWithInvalidStateError("Navigated away from page");
     }
     mPendingResumePromises.Clear();
   }
 
   // Release references to active nodes.
   // Active AudioNodes don't unregister in destructors, at which point the
   // Node is already unregistered.
   mActiveNodes.Clear();
@@ -915,22 +952,26 @@ nsTArray<mozilla::MediaTrack*> AudioCont
 already_AddRefed<Promise> AudioContext::Suspend(ErrorResult& aRv) {
   nsCOMPtr<nsIGlobalObject> parentObject = do_QueryInterface(GetParentObject());
   RefPtr<Promise> promise;
   promise = Promise::Create(parentObject, aRv);
   if (aRv.Failed()) {
     return nullptr;
   }
   if (mIsOffline) {
-    promise->MaybeReject(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    // XXXbz This is not reachable, since we don't implement this
+    // method on OfflineAudioContext at all!
+    promise->MaybeRejectWithNotSupportedError(
+        "Can't suspend OfflineAudioContext yet");
     return promise.forget();
   }
 
   if (mAudioContextState == AudioContextState::Closed || mCloseCalled) {
-    promise->MaybeReject(NS_ERROR_DOM_INVALID_STATE_ERR);
+    promise->MaybeRejectWithInvalidStateError(
+        "Can't suspend if the control thread state is \"closed\"");
     return promise.forget();
   }
 
   mSuspendedByContent = true;
   mPromiseGripArray.AppendElement(promise);
   SuspendInternal(promise, AudioContextOperationFlags::SendStateChange);
   return promise.forget();
 }
@@ -981,22 +1022,24 @@ already_AddRefed<Promise> AudioContext::
   nsCOMPtr<nsIGlobalObject> parentObject = do_QueryInterface(GetParentObject());
   RefPtr<Promise> promise;
   promise = Promise::Create(parentObject, aRv);
   if (aRv.Failed()) {
     return nullptr;
   }
 
   if (mIsOffline) {
-    promise->MaybeReject(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    promise->MaybeRejectWithNotSupportedError(
+        "Can't resume OfflineAudioContext");
     return promise.forget();
   }
 
   if (mAudioContextState == AudioContextState::Closed || mCloseCalled) {
-    promise->MaybeReject(NS_ERROR_DOM_INVALID_STATE_ERR);
+    promise->MaybeRejectWithInvalidStateError(
+        "Can't resume if the control thread state is \"closed\"");
     return promise.forget();
   }
 
   mSuspendedByContent = false;
   mPendingResumePromises.AppendElement(promise);
 
   const bool isAllowedToPlay = AutoplayPolicy::IsAllowedToPlay(*this);
   AUTOPLAY_LOG("Trying to resume AudioContext %p, IsAllowedToPlay=%d", this,
@@ -1115,22 +1158,26 @@ already_AddRefed<Promise> AudioContext::
   nsCOMPtr<nsIGlobalObject> parentObject = do_QueryInterface(GetParentObject());
   RefPtr<Promise> promise;
   promise = Promise::Create(parentObject, aRv);
   if (aRv.Failed()) {
     return nullptr;
   }
 
   if (mIsOffline) {
-    promise->MaybeReject(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    // XXXbz This is not reachable, since we don't implement this
+    // method on OfflineAudioContext at all!
+    promise->MaybeRejectWithNotSupportedError(
+        "Can't close OfflineAudioContext yet");
     return promise.forget();
   }
 
   if (mAudioContextState == AudioContextState::Closed) {
-    promise->MaybeResolve(NS_ERROR_DOM_INVALID_STATE_ERR);
+    promise->MaybeRejectWithInvalidStateError(
+        "Can't close an AudioContext twice");
     return promise.forget();
   }
 
   mPromiseGripArray.AppendElement(promise);
 
   CloseInternal(promise, AudioContextOperationFlags::SendStateChange);
 
   return promise.forget();
@@ -1176,17 +1223,17 @@ void AudioContext::UnregisterNode(AudioN
   mAllNodes.RemoveEntry(aNode);
 }
 
 already_AddRefed<Promise> AudioContext::StartRendering(ErrorResult& aRv) {
   nsCOMPtr<nsIGlobalObject> parentObject = do_QueryInterface(GetParentObject());
 
   MOZ_ASSERT(mIsOffline, "This should only be called on OfflineAudioContext");
   if (mIsStarted) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    aRv.ThrowInvalidStateError("Rendering already started");
     return nullptr;
   }
 
   mIsStarted = true;
   RefPtr<Promise> promise = Promise::Create(parentObject, aRv);
   if (aRv.Failed()) {
     return nullptr;
   }
--- a/dom/media/webaudio/AudioContext.h
+++ b/dom/media/webaudio/AudioContext.h
@@ -182,17 +182,17 @@ class AudioContext final : public DOMEve
                                                     ErrorResult& aRv);
 
   // AudioContext methods
 
   AudioDestinationNode* Destination() const { return mDestination; }
 
   float SampleRate() const { return mSampleRate; }
 
-  bool ShouldSuspendNewTrack() const { return mSuspendCalled; }
+  bool ShouldSuspendNewTrack() const { return mSuspendCalled || mCloseCalled; }
 
   double CurrentTime();
 
   AudioListener* Listener();
 
   AudioContextState State() const { return mAudioContextState; }
 
   double BaseLatency() const {
--- a/dom/media/webaudio/AudioDestinationNode.cpp
+++ b/dom/media/webaudio/AudioDestinationNode.cpp
@@ -447,17 +447,18 @@ void AudioDestinationNode::ResolvePromis
 
 uint32_t AudioDestinationNode::MaxChannelCount() const {
   return Context()->MaxChannelCount();
 }
 
 void AudioDestinationNode::SetChannelCount(uint32_t aChannelCount,
                                            ErrorResult& aRv) {
   if (aChannelCount > MaxChannelCount()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("%u is larger than maxChannelCount", aChannelCount));
     return;
   }
 
   if (aChannelCount == ChannelCount()) {
     return;
   }
 
   AudioNode::SetChannelCount(aChannelCount, aRv);
--- a/dom/media/webaudio/AudioEventTimeline.h
+++ b/dom/media/webaudio/AudioEventTimeline.h
@@ -131,17 +131,17 @@ class AudioEventTimeline {
       aRv.ThrowRangeError(
           u"The exponential constant passed to setTargetAtTime must be "
           u"non-negative.");
       return false;
     }
 
     if (aEvent.mType == AudioTimelineEvent::SetValueCurve) {
       if (!aEvent.mCurve || aEvent.mCurveLength < 2) {
-        aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+        aRv.ThrowInvalidStateError("Curve length must be at least 2");
         return false;
       }
       if (aEvent.mDuration <= 0) {
         aRv.ThrowRangeError(
             u"The curve duration for setValueCurveAtTime must be strictly "
             u"positive.");
         return false;
       }
@@ -150,28 +150,29 @@ class AudioEventTimeline {
     MOZ_ASSERT(IsValid(aEvent.mValue) && IsValid(aEvent.mDuration));
 
     // Make sure that new events don't fall within the duration of a
     // curve event.
     for (unsigned i = 0; i < mEvents.Length(); ++i) {
       if (mEvents[i].mType == AudioTimelineEvent::SetValueCurve &&
           TimeOf(mEvents[i]) <= TimeOf(aEvent) &&
           TimeOf(mEvents[i]) + mEvents[i].mDuration > TimeOf(aEvent)) {
-        aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+        aRv.ThrowNotSupportedError("Can't add events during a curve event");
         return false;
       }
     }
 
     // Make sure that new curve events don't fall in a range which includes
     // other events.
     if (aEvent.mType == AudioTimelineEvent::SetValueCurve) {
       for (unsigned i = 0; i < mEvents.Length(); ++i) {
         if (TimeOf(aEvent) < TimeOf(mEvents[i]) &&
             TimeOf(aEvent) + aEvent.mDuration > TimeOf(mEvents[i])) {
-          aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+          aRv.ThrowNotSupportedError(
+              "Can't add curve events that overlap other events");
           return false;
         }
       }
     }
 
     // Make sure that invalid values are not used for exponential curves
     if (aEvent.mType == AudioTimelineEvent::ExponentialRamp) {
       if (aEvent.mValue <= 0.f) {
@@ -179,22 +180,24 @@ class AudioEventTimeline {
             u"The value passed to exponentialRampToValueAtTime must be "
             u"positive.");
         return false;
       }
       const AudioTimelineEvent* previousEvent =
           GetPreviousEvent(TimeOf(aEvent));
       if (previousEvent) {
         if (previousEvent->mValue <= 0.f) {
-          aRv.Throw(NS_ERROR_DOM_SYNTAX_ERR);
+          // XXXbz I see no mention of SyntaxError in the Web Audio API spec
+          aRv.ThrowSyntaxError("Previous event value must be positive");
           return false;
         }
       } else {
         if (mValue <= 0.f) {
-          aRv.Throw(NS_ERROR_DOM_SYNTAX_ERR);
+          // XXXbz I see no mention of SyntaxError in the Web Audio API spec
+          aRv.ThrowSyntaxError("Our value must be positive");
           return false;
         }
       }
     }
     return true;
   }
 
   template <typename TimeType>
--- a/dom/media/webaudio/AudioNode.cpp
+++ b/dom/media/webaudio/AudioNode.cpp
@@ -188,23 +188,31 @@ void AudioNode::DisconnectFromGraph() {
     output->RemoveInputNode(inputIndex);
   }
 
   DestroyMediaTrack();
 }
 
 AudioNode* AudioNode::Connect(AudioNode& aDestination, uint32_t aOutput,
                               uint32_t aInput, ErrorResult& aRv) {
-  if (aOutput >= NumberOfOutputs() || aInput >= aDestination.NumberOfInputs()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+  if (aOutput >= NumberOfOutputs()) {
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Output index %u is out of bounds", aOutput));
+    return nullptr;
+  }
+
+  if (aInput >= aDestination.NumberOfInputs()) {
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Input index %u is out of bounds", aInput));
     return nullptr;
   }
 
   if (Context() != aDestination.Context()) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_ACCESS_ERR);
+    aRv.ThrowInvalidAccessError(
+        "Can't connect nodes from different AudioContexts");
     return nullptr;
   }
 
   if (FindIndexOfNodeWithPorts(aDestination.mInputNodes, this, aInput,
                                aOutput) !=
       nsTArray<AudioNode::InputNode>::NoIndex) {
     // connection already exists.
     return &aDestination;
@@ -233,22 +241,24 @@ AudioNode* AudioNode::Connect(AudioNode&
   aDestination.NotifyInputsChanged();
 
   return &aDestination;
 }
 
 void AudioNode::Connect(AudioParam& aDestination, uint32_t aOutput,
                         ErrorResult& aRv) {
   if (aOutput >= NumberOfOutputs()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Output index %u is out of bounds", aOutput));
     return;
   }
 
   if (Context() != aDestination.GetParentObject()) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_ACCESS_ERR);
+    aRv.ThrowInvalidAccessError(
+        "Can't connect a node to an AudioParam from a different AudioContext");
     return;
   }
 
   if (FindIndexOfNodeWithPorts(aDestination.InputNodes(), this, INVALID_PORT,
                                aOutput) !=
       nsTArray<AudioNode::InputNode>::NoIndex) {
     // connection already exists.
     return;
@@ -405,17 +415,18 @@ void AudioNode::Disconnect(ErrorResult& 
        --outputIndex) {
     DisconnectMatchingDestinationInputs<AudioParam>(
         outputIndex, [](const InputNode&) { return true; });
   }
 }
 
 void AudioNode::Disconnect(uint32_t aOutput, ErrorResult& aRv) {
   if (aOutput >= NumberOfOutputs()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Output index %u is out of bounds", aOutput));
     return;
   }
 
   for (int32_t outputIndex = mOutputNodes.Length() - 1; outputIndex >= 0;
        --outputIndex) {
     DisconnectMatchingDestinationInputs<AudioNode>(
         outputIndex, [aOutput](const InputNode& aInputNode) {
           return aInputNode.mOutputPort == aOutput;
@@ -439,25 +450,27 @@ void AudioNode::Disconnect(AudioNode& aD
     if (mOutputNodes[outputIndex] != &aDestination) {
       continue;
     }
     wasConnected |= DisconnectMatchingDestinationInputs<AudioNode>(
         outputIndex, [](const InputNode&) { return true; });
   }
 
   if (!wasConnected) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_ACCESS_ERR);
+    aRv.ThrowInvalidAccessError(
+        "Trying to disconnect from a node we're not connected to");
     return;
   }
 }
 
 void AudioNode::Disconnect(AudioNode& aDestination, uint32_t aOutput,
                            ErrorResult& aRv) {
   if (aOutput >= NumberOfOutputs()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Output index %u is out of bounds", aOutput));
     return;
   }
 
   bool wasConnected = false;
 
   for (int32_t outputIndex = mOutputNodes.Length() - 1; outputIndex >= 0;
        --outputIndex) {
     if (mOutputNodes[outputIndex] != &aDestination) {
@@ -465,30 +478,33 @@ void AudioNode::Disconnect(AudioNode& aD
     }
     wasConnected |= DisconnectMatchingDestinationInputs<AudioNode>(
         outputIndex, [aOutput](const InputNode& aInputNode) {
           return aInputNode.mOutputPort == aOutput;
         });
   }
 
   if (!wasConnected) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_ACCESS_ERR);
+    aRv.ThrowInvalidAccessError(
+        "Trying to disconnect from a node we're not connected to");
     return;
   }
 }
 
 void AudioNode::Disconnect(AudioNode& aDestination, uint32_t aOutput,
                            uint32_t aInput, ErrorResult& aRv) {
   if (aOutput >= NumberOfOutputs()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Output index %u is out of bounds", aOutput));
     return;
   }
 
   if (aInput >= aDestination.NumberOfInputs()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Input index %u is out of bounds", aInput));
     return;
   }
 
   bool wasConnected = false;
 
   for (int32_t outputIndex = mOutputNodes.Length() - 1; outputIndex >= 0;
        --outputIndex) {
     if (mOutputNodes[outputIndex] != &aDestination) {
@@ -497,17 +513,18 @@ void AudioNode::Disconnect(AudioNode& aD
     wasConnected |= DisconnectMatchingDestinationInputs<AudioNode>(
         outputIndex, [aOutput, aInput](const InputNode& aInputNode) {
           return aInputNode.mOutputPort == aOutput &&
                  aInputNode.mInputPort == aInput;
         });
   }
 
   if (!wasConnected) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_ACCESS_ERR);
+    aRv.ThrowInvalidAccessError(
+        "Trying to disconnect from a node we're not connected to");
     return;
   }
 }
 
 void AudioNode::Disconnect(AudioParam& aDestination, ErrorResult& aRv) {
   bool wasConnected = false;
 
   for (int32_t outputIndex = mOutputParams.Length() - 1; outputIndex >= 0;
@@ -515,25 +532,27 @@ void AudioNode::Disconnect(AudioParam& a
     if (mOutputParams[outputIndex] != &aDestination) {
       continue;
     }
     wasConnected |= DisconnectMatchingDestinationInputs<AudioParam>(
         outputIndex, [](const InputNode&) { return true; });
   }
 
   if (!wasConnected) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_ACCESS_ERR);
+    aRv.ThrowInvalidAccessError(
+        "Trying to disconnect from an AudioParam we're not connected to");
     return;
   }
 }
 
 void AudioNode::Disconnect(AudioParam& aDestination, uint32_t aOutput,
                            ErrorResult& aRv) {
   if (aOutput >= NumberOfOutputs()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Output index %u is out of bounds", aOutput));
     return;
   }
 
   bool wasConnected = false;
 
   for (int32_t outputIndex = mOutputParams.Length() - 1; outputIndex >= 0;
        --outputIndex) {
     if (mOutputParams[outputIndex] != &aDestination) {
@@ -541,17 +560,18 @@ void AudioNode::Disconnect(AudioParam& a
     }
     wasConnected |= DisconnectMatchingDestinationInputs<AudioParam>(
         outputIndex, [aOutput](const InputNode& aInputNode) {
           return aInputNode.mOutputPort == aOutput;
         });
   }
 
   if (!wasConnected) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_ACCESS_ERR);
+    aRv.ThrowInvalidAccessError(
+        "Trying to disconnect from an AudioParam we're not connected to");
     return;
   }
 }
 
 void AudioNode::DestroyMediaTrack() {
   if (mTrack) {
     // Remove the node pointer on the engine.
     AudioNodeTrack* ns = mTrack;
--- a/dom/media/webaudio/AudioNode.h
+++ b/dom/media/webaudio/AudioNode.h
@@ -10,16 +10,17 @@
 #include "mozilla/DOMEventTargetHelper.h"
 #include "mozilla/dom/AudioNodeBinding.h"
 #include "nsCycleCollectionParticipant.h"
 #include "nsTArray.h"
 #include "AudioContext.h"
 #include "MediaTrackGraph.h"
 #include "WebAudioUtils.h"
 #include "mozilla/MemoryReporting.h"
+#include "nsPrintfCString.h"
 #include "nsWeakReference.h"
 #include "SelfRef.h"
 
 namespace mozilla {
 
 class AbstractThread;
 
 namespace dom {
@@ -112,17 +113,20 @@ class AudioNode : public DOMEventTargetH
   uint32_t Id() const { return mId; }
 
   bool PassThrough() const;
   void SetPassThrough(bool aPassThrough);
 
   uint32_t ChannelCount() const { return mChannelCount; }
   virtual void SetChannelCount(uint32_t aChannelCount, ErrorResult& aRv) {
     if (aChannelCount == 0 || aChannelCount > WebAudioUtils::MaxChannelCount) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aRv.ThrowNotSupportedError(
+          nsPrintfCString("Channel count (%u) must be in the range [1, max "
+                          "supported channel count]",
+                          aChannelCount));
       return;
     }
     mChannelCount = aChannelCount;
     SendChannelMixingParametersToTrack();
   }
   ChannelCountMode ChannelCountModeValue() const { return mChannelCountMode; }
   virtual void SetChannelCountModeValue(ChannelCountMode aMode,
                                         ErrorResult& aRv) {
--- a/dom/media/webaudio/AudioWorkletGlobalScope.cpp
+++ b/dom/media/webaudio/AudioWorkletGlobalScope.cpp
@@ -76,17 +76,17 @@ void AudioWorkletGlobalScope::RegisterPr
   }
 
   // We know processorConstructor is callable, so not a WindowProxy or Location.
   JS::Rooted<JSObject*> constructorUnwrapped(
       aCx, js::CheckedUnwrapStatic(processorConstructor));
   if (!constructorUnwrapped) {
     // If the caller's compartment does not have permission to access the
     // unwrapped constructor then throw.
-    aRv.Throw(NS_ERROR_DOM_SECURITY_ERR);
+    aRv.ThrowSecurityError("Constructor cannot be called");
     return;
   }
 
   /**
    * 3. If the result of IsConstructor(argument=processorCtor) is false,
    *    throw a TypeError and abort these steps.
    */
   if (!JS::IsConstructor(constructorUnwrapped)) {
@@ -297,18 +297,22 @@ bool AudioWorkletGlobalScope::ConstructP
       MessagePort::Create(this, aPortIdentifier, rv);
   if (NS_WARN_IF(rv.MaybeSetPendingException(cx))) {
     return false;
   }
   /**
    * 5. Let deserializedOptions be the result of
    *    StructuredDeserialize(serializedOptions, the current Realm).
    */
+  JS::CloneDataPolicy cloneDataPolicy;
+  cloneDataPolicy.allowIntraClusterClonableSharedObjects();
+  cloneDataPolicy.allowSharedMemoryObjects();
+
   JS::Rooted<JS::Value> deserializedOptions(cx);
-  aSerializedOptions->Read(this, cx, &deserializedOptions, rv);
+  aSerializedOptions->Read(this, cx, &deserializedOptions, cloneDataPolicy, rv);
   if (rv.MaybeSetPendingException(cx)) {
     return false;
   }
   /**
    * 6. Let processorCtor be the result of looking up processorName on the
    *    AudioWorkletGlobalScope's node name to processor definition map.
    */
   RefPtr<AudioWorkletProcessorConstructor> processorCtor =
--- a/dom/media/webaudio/AudioWorkletNode.cpp
+++ b/dom/media/webaudio/AudioWorkletNode.cpp
@@ -7,16 +7,17 @@
 #include "AudioWorkletNode.h"
 
 #include "AudioParamMap.h"
 #include "js/Array.h"  // JS::{Get,Set}ArrayLength, JS::NewArrayLength
 #include "mozilla/dom/AudioWorkletNodeBinding.h"
 #include "mozilla/dom/MessageChannel.h"
 #include "mozilla/dom/MessagePort.h"
 #include "PlayingRefChangeHandler.h"
+#include "nsPrintfCString.h"
 
 namespace mozilla {
 namespace dom {
 
 NS_IMPL_ISUPPORTS_CYCLE_COLLECTION_INHERITED_0(AudioWorkletNode, AudioNode)
 NS_IMPL_CYCLE_COLLECTION_INHERITED(AudioWorkletNode, AudioNode, mPort)
 
 class WorkletNodeEngine final : public AudioNodeEngine {
@@ -425,55 +426,66 @@ already_AddRefed<AudioWorkletNode> Audio
   /**
    * 1. If nodeName does not exist as a key in the BaseAudioContext’s node
    *    name to parameter descriptor map, throw a NotSupportedError exception
    *    and abort these steps.
    */
   const AudioParamDescriptorMap* parameterDescriptors =
       aAudioContext.GetParamMapForWorkletName(aName);
   if (!parameterDescriptors) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    // Not using nsPrintfCString in case aName has embedded nulls.
+    aRv.ThrowNotSupportedError(
+        NS_LITERAL_CSTRING("Unknown AudioWorklet name '") +
+        NS_ConvertUTF16toUTF8(aName) + NS_LITERAL_CSTRING("'"));
     return nullptr;
   }
 
   // See https://github.com/WebAudio/web-audio-api/issues/2074 for ordering.
   RefPtr<AudioWorkletNode> audioWorkletNode =
       new AudioWorkletNode(&aAudioContext, aName, aOptions);
   audioWorkletNode->Initialize(aOptions, aRv);
   if (NS_WARN_IF(aRv.Failed())) {
     return nullptr;
   }
 
   /**
    * 3. Configure input, output and output channels of node with options.
    */
   if (aOptions.mNumberOfInputs == 0 && aOptions.mNumberOfOutputs == 0) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowNotSupportedError(
+        "Must have nonzero numbers of inputs or outputs");
     return nullptr;
   }
 
   if (aOptions.mOutputChannelCount.WasPassed()) {
     /**
      * 1. If any value in outputChannelCount is zero or greater than the
      *    implementation’s maximum number of channels, throw a
      *    NotSupportedError and abort the remaining steps.
      */
     for (uint32_t channelCount : aOptions.mOutputChannelCount.Value()) {
       if (channelCount == 0 || channelCount > WebAudioUtils::MaxChannelCount) {
-        aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+        aRv.ThrowNotSupportedError(
+            nsPrintfCString("Channel count (%u) must be in the range [1, max "
+                            "supported channel count]",
+                            channelCount));
         return nullptr;
       }
     }
     /**
      * 2. If the length of outputChannelCount does not equal numberOfOutputs,
      *    throw an IndexSizeError and abort the remaining steps.
      */
     if (aOptions.mOutputChannelCount.Value().Length() !=
         aOptions.mNumberOfOutputs) {
-      aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+      aRv.ThrowIndexSizeError(
+          nsPrintfCString("Length of outputChannelCount (%zu) does not match "
+                          "numberOfOutputs (%u)",
+                          aOptions.mOutputChannelCount.Value().Length(),
+                          aOptions.mNumberOfOutputs));
       return nullptr;
     }
   }
   // MTG does not support more than UINT16_MAX inputs or outputs.
   if (aOptions.mNumberOfInputs > UINT16_MAX) {
     aRv.ThrowRangeError<MSG_VALUE_OUT_OF_RANGE>(u"numberOfInputs");
     return nullptr;
   }
@@ -503,28 +515,37 @@ already_AddRefed<AudioWorkletNode> Audio
    * 8. Convert options dictionary to optionsObject.
    */
   JSContext* cx = aGlobal.Context();
   JS::Rooted<JS::Value> optionsVal(cx);
   if (NS_WARN_IF(!ToJSValue(cx, aOptions, &optionsVal))) {
     aRv.NoteJSContextException(cx);
     return nullptr;
   }
+
   /**
    * 9. Let serializedOptions be the result of
    *    StructuredSerialize(optionsObject).
    */
+
+  // This context and the worklet are part of the same agent cluster and they
+  // can share memory.
+  JS::CloneDataPolicy cloneDataPolicy;
+  cloneDataPolicy.allowIntraClusterClonableSharedObjects();
+  cloneDataPolicy.allowSharedMemoryObjects();
+
   // StructuredCloneHolder does not have a move constructor.  Instead allocate
   // memory so that the pointer can be passed to the rendering thread.
   UniquePtr<StructuredCloneHolder> serializedOptions =
       MakeUnique<StructuredCloneHolder>(
           StructuredCloneHolder::CloningSupported,
           StructuredCloneHolder::TransferringNotSupported,
           JS::StructuredCloneScope::SameProcess);
-  serializedOptions->Write(cx, optionsVal, aRv);
+  serializedOptions->Write(cx, optionsVal, JS::UndefinedHandleValue,
+                           cloneDataPolicy, aRv);
   if (NS_WARN_IF(aRv.Failed())) {
     return nullptr;
   }
   /**
    * 10. Set node’s port to nodePort.
    */
   audioWorkletNode->mPort = messageChannel->Port1();
 
--- a/dom/media/webaudio/BiquadFilterNode.cpp
+++ b/dom/media/webaudio/BiquadFilterNode.cpp
@@ -304,17 +304,17 @@ void BiquadFilterNode::GetFrequencyRespo
                                             const Float32Array& aPhaseResponse,
                                             ErrorResult& aRv) {
   aFrequencyHz.ComputeState();
   aMagResponse.ComputeState();
   aPhaseResponse.ComputeState();
 
   if (!(aFrequencyHz.Length() == aMagResponse.Length() &&
         aMagResponse.Length() == aPhaseResponse.Length())) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_ACCESS_ERR);
+    aRv.ThrowInvalidAccessError("Parameter lengths must match");
     return;
   }
 
   uint32_t length = aFrequencyHz.Length();
   if (!length) {
     return;
   }
 
--- a/dom/media/webaudio/ChannelMergerNode.cpp
+++ b/dom/media/webaudio/ChannelMergerNode.cpp
@@ -3,16 +3,17 @@
 /* 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/. */
 
 #include "mozilla/dom/ChannelMergerNode.h"
 #include "mozilla/dom/ChannelMergerNodeBinding.h"
 #include "AudioNodeEngine.h"
 #include "AudioNodeTrack.h"
+#include "nsPrintfCString.h"
 
 namespace mozilla {
 namespace dom {
 
 class ChannelMergerNodeEngine final : public AudioNodeEngine {
  public:
   explicit ChannelMergerNodeEngine(ChannelMergerNode* aNode)
       : AudioNodeEngine(aNode) {
@@ -67,17 +68,20 @@ ChannelMergerNode::ChannelMergerNode(Aud
 }
 
 /* static */
 already_AddRefed<ChannelMergerNode> ChannelMergerNode::Create(
     AudioContext& aAudioContext, const ChannelMergerOptions& aOptions,
     ErrorResult& aRv) {
   if (aOptions.mNumberOfInputs == 0 ||
       aOptions.mNumberOfInputs > WebAudioUtils::MaxChannelCount) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        nsPrintfCString("Number of inputs (%u) must be in the range [1, number "
+                        "of supported channels]",
+                        aOptions.mNumberOfInputs));
     return nullptr;
   }
 
   RefPtr<ChannelMergerNode> audioNode =
       new ChannelMergerNode(&aAudioContext, aOptions.mNumberOfInputs);
 
   audioNode->Initialize(aOptions, aRv);
   if (NS_WARN_IF(aRv.Failed())) {
--- a/dom/media/webaudio/ChannelMergerNode.h
+++ b/dom/media/webaudio/ChannelMergerNode.h
@@ -34,24 +34,26 @@ class ChannelMergerNode final : public A
 
   uint16_t NumberOfInputs() const override { return mInputCount; }
 
   const char* NodeType() const override { return "ChannelMergerNode"; }
 
   virtual void SetChannelCount(uint32_t aChannelCount,
                                ErrorResult& aRv) override {
     if (aChannelCount != 1) {
-      aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+      aRv.ThrowInvalidStateError(
+          "Cannot change channel count of ChannelMergerNode");
     }
   }
 
   virtual void SetChannelCountModeValue(ChannelCountMode aMode,
                                         ErrorResult& aRv) override {
     if (aMode != ChannelCountMode::Explicit) {
-      aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+      aRv.ThrowInvalidStateError(
+          "Cannot change channel count mode of ChannelMergerNode");
     }
   }
 
   size_t SizeOfIncludingThis(MallocSizeOf aMallocSizeOf) const override {
     return aMallocSizeOf(this) + SizeOfExcludingThis(aMallocSizeOf);
   }
 
  private:
--- a/dom/media/webaudio/ChannelSplitterNode.cpp
+++ b/dom/media/webaudio/ChannelSplitterNode.cpp
@@ -56,42 +56,46 @@ ChannelSplitterNode::ChannelSplitterNode
 }
 
 /* static */
 already_AddRefed<ChannelSplitterNode> ChannelSplitterNode::Create(
     AudioContext& aAudioContext, const ChannelSplitterOptions& aOptions,
     ErrorResult& aRv) {
   if (aOptions.mNumberOfOutputs == 0 ||
       aOptions.mNumberOfOutputs > WebAudioUtils::MaxChannelCount) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(nsPrintfCString(
+        "%u is not a valid number of outputs", aOptions.mNumberOfOutputs));
     return nullptr;
   }
 
   RefPtr<ChannelSplitterNode> audioNode =
       new ChannelSplitterNode(&aAudioContext, aOptions.mNumberOfOutputs);
 
   // Manually check that the other options are valid, this node has
   // channelCount, channelCountMode and channelInterpretation constraints: they
   // cannot be changed from the default.
-  if (aOptions.mChannelCount.WasPassed() &&
-      aOptions.mChannelCount.Value() != audioNode->ChannelCount()) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
-    return nullptr;
+  if (aOptions.mChannelCount.WasPassed()) {
+    audioNode->SetChannelCount(aOptions.mChannelCount.Value(), aRv);
+    if (aRv.Failed()) {
+      return nullptr;
+    }
   }
-  if (aOptions.mChannelInterpretation.WasPassed() &&
-      aOptions.mChannelInterpretation.Value() !=
-          audioNode->ChannelInterpretationValue()) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
-    return nullptr;
+  if (aOptions.mChannelInterpretation.WasPassed()) {
+    audioNode->SetChannelInterpretationValue(
+        aOptions.mChannelInterpretation.Value(), aRv);
+    if (aRv.Failed()) {
+      return nullptr;
+    }
   }
-  if (aOptions.mChannelCountMode.WasPassed() &&
-      aOptions.mChannelCountMode.Value() !=
-          audioNode->ChannelCountModeValue()) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
-    return nullptr;
+  if (aOptions.mChannelCountMode.WasPassed()) {
+    audioNode->SetChannelCountModeValue(aOptions.mChannelCountMode.Value(),
+                                        aRv);
+    if (aRv.Failed()) {
+      return nullptr;
+    }
   }
 
   return audioNode.forget();
 }
 
 JSObject* ChannelSplitterNode::WrapObject(JSContext* aCx,
                                           JS::Handle<JSObject*> aGivenProto) {
   return ChannelSplitterNode_Binding::Wrap(aCx, this, aGivenProto);
--- a/dom/media/webaudio/ChannelSplitterNode.h
+++ b/dom/media/webaudio/ChannelSplitterNode.h
@@ -25,25 +25,34 @@ class ChannelSplitterNode final : public
 
   static already_AddRefed<ChannelSplitterNode> Constructor(
       const GlobalObject& aGlobal, AudioContext& aAudioContext,
       const ChannelSplitterOptions& aOptions, ErrorResult& aRv) {
     return Create(aAudioContext, aOptions, aRv);
   }
 
   void SetChannelCount(uint32_t aChannelCount, ErrorResult& aRv) override {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    if (aChannelCount != ChannelCount()) {
+      aRv.ThrowInvalidStateError(
+          "Cannot change channel count of ChannelSplitterNode");
+    }
   }
   void SetChannelCountModeValue(ChannelCountMode aMode,
                                 ErrorResult& aRv) override {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    if (aMode != ChannelCountModeValue()) {
+      aRv.ThrowInvalidStateError(
+          "Cannot change channel count mode of ChannelSplitterNode");
+    }
   }
   void SetChannelInterpretationValue(ChannelInterpretation aMode,
                                      ErrorResult& aRv) override {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    if (aMode != ChannelInterpretationValue()) {
+      aRv.ThrowInvalidStateError(
+          "Cannot change channel interpretation of ChannelSplitterNode");
+    }
   }
 
   JSObject* WrapObject(JSContext* aCx,
                        JS::Handle<JSObject*> aGivenProto) override;
 
   uint16_t NumberOfOutputs() const override { return mOutputCount; }
 
   const char* NodeType() const override { return "ChannelSplitterNode"; }
--- a/dom/media/webaudio/ConstantSourceNode.cpp
+++ b/dom/media/webaudio/ConstantSourceNode.cpp
@@ -203,17 +203,17 @@ void ConstantSourceNode::DestroyMediaTra
 
 void ConstantSourceNode::Start(double aWhen, ErrorResult& aRv) {
   if (!WebAudioUtils::IsTimeValid(aWhen)) {
     aRv.ThrowRangeError<MSG_VALUE_OUT_OF_RANGE>(u"start time");
     return;
   }
 
   if (mStartCalled) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    aRv.ThrowInvalidStateError("Can't call start() more than once");
     return;
   }
   mStartCalled = true;
 
   if (!mTrack) {
     return;
   }
 
@@ -226,17 +226,17 @@ void ConstantSourceNode::Start(double aW
 
 void ConstantSourceNode::Stop(double aWhen, ErrorResult& aRv) {
   if (!WebAudioUtils::IsTimeValid(aWhen)) {
     aRv.ThrowRangeError<MSG_VALUE_OUT_OF_RANGE>(u"stop time");
     return;
   }
 
   if (!mStartCalled) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    aRv.ThrowInvalidStateError("Can't call stop() without calling start()");
     return;
   }
 
   if (!mTrack || !Context()) {
     return;
   }
 
   mTrack->SetTrackTimeParameter(ConstantSourceNodeEngine::STOP, Context(),
--- a/dom/media/webaudio/ConvolverNode.cpp
+++ b/dom/media/webaudio/ConvolverNode.cpp
@@ -389,23 +389,27 @@ void ConvolverNode::SetBuffer(JSContext*
   if (aBuffer) {
     switch (aBuffer->NumberOfChannels()) {
       case 1:
       case 2:
       case 4:
         // Supported number of channels
         break;
       default:
-        aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+        aRv.ThrowNotSupportedError(
+            nsPrintfCString("%u is not a supported number of channels",
+                            aBuffer->NumberOfChannels()));
         return;
     }
   }
 
   if (aBuffer && (aBuffer->SampleRate() != Context()->SampleRate())) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowNotSupportedError(nsPrintfCString(
+        "Buffer sample rate (%g) does not match AudioContext sample rate (%g)",
+        aBuffer->SampleRate(), Context()->SampleRate()));
     return;
   }
 
   // Send the buffer to the track
   AudioNodeTrack* ns = mTrack;
   MOZ_ASSERT(ns, "Why don't we have a track here?");
   if (aBuffer) {
     AudioChunk data = aBuffer->GetThreadSharedChannelsForRate(aCx);
--- a/dom/media/webaudio/ConvolverNode.h
+++ b/dom/media/webaudio/ConvolverNode.h
@@ -4,16 +4,17 @@
  * 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/. */
 
 #ifndef ConvolverNode_h_
 #define ConvolverNode_h_
 
 #include "AudioNode.h"
 #include "AudioBuffer.h"
+#include "nsPrintfCString.h"
 
 namespace mozilla {
 namespace dom {
 
 class AudioContext;
 struct ConvolverOptions;
 
 class ConvolverNode final : public AudioNode {
@@ -39,25 +40,26 @@ class ConvolverNode final : public Audio
   void SetBuffer(JSContext* aCx, AudioBuffer* aBuffer, ErrorResult& aRv);
 
   bool Normalize() const { return mNormalize; }
 
   void SetNormalize(bool aNormal);
 
   void SetChannelCount(uint32_t aChannelCount, ErrorResult& aRv) override {
     if (aChannelCount > 2) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aRv.ThrowNotSupportedError(
+          nsPrintfCString("%u is greater than 2", aChannelCount));
       return;
     }
     AudioNode::SetChannelCount(aChannelCount, aRv);
   }
   void SetChannelCountModeValue(ChannelCountMode aMode,
                                 ErrorResult& aRv) override {
     if (aMode == ChannelCountMode::Max) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aRv.ThrowNotSupportedError("Cannot set channel count mode to \"max\"");
       return;
     }
     AudioNode::SetChannelCountModeValue(aMode, aRv);
   }
 
   const char* NodeType() const override { return "ConvolverNode"; }
 
   size_t SizeOfExcludingThis(MallocSizeOf aMallocSizeOf) const override;
--- a/dom/media/webaudio/DelayNode.cpp
+++ b/dom/media/webaudio/DelayNode.cpp
@@ -181,17 +181,19 @@ DelayNode::DelayNode(AudioContext* aCont
       aContext, engine, AudioNodeTrack::NO_TRACK_FLAGS, aContext->Graph());
 }
 
 /* static */
 already_AddRefed<DelayNode> DelayNode::Create(AudioContext& aAudioContext,
                                               const DelayOptions& aOptions,
                                               ErrorResult& aRv) {
   if (aOptions.mMaxDelayTime <= 0. || aOptions.mMaxDelayTime >= 180.) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowNotSupportedError(
+        nsPrintfCString("\"maxDelayTime\" (%g) is not in the range (0,180)",
+                        aOptions.mMaxDelayTime));
     return nullptr;
   }
 
   RefPtr<DelayNode> audioNode =
       new DelayNode(&aAudioContext, aOptions.mMaxDelayTime);
 
   audioNode->Initialize(aOptions, aRv);
   if (NS_WARN_IF(aRv.Failed())) {
--- a/dom/media/webaudio/DynamicsCompressorNode.h
+++ b/dom/media/webaudio/DynamicsCompressorNode.h
@@ -55,25 +55,26 @@ class DynamicsCompressorNode final : pub
   void SetReduction(float aReduction) {
     MOZ_ASSERT(NS_IsMainThread());
     mReduction = aReduction;
   }
 
   void SetChannelCountModeValue(ChannelCountMode aMode,
                                 ErrorResult& aRv) override {
     if (aMode == ChannelCountMode::Max) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aRv.ThrowNotSupportedError("Cannot set channel count mode to \"max\"");
       return;
     }
     AudioNode::SetChannelCountModeValue(aMode, aRv);
   }
 
   void SetChannelCount(uint32_t aChannelCount, ErrorResult& aRv) override {
     if (aChannelCount > 2) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aRv.ThrowNotSupportedError(
+          nsPrintfCString("%u is greater than 2", aChannelCount));
       return;
     }
     AudioNode::SetChannelCount(aChannelCount, aRv);
   }
 
  private:
   explicit DynamicsCompressorNode(AudioContext* aContext);
   ~DynamicsCompressorNode() = default;
--- a/dom/media/webaudio/IIRFilterNode.cpp
+++ b/dom/media/webaudio/IIRFilterNode.cpp
@@ -5,16 +5,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 #include "IIRFilterNode.h"
 #include "AudioNodeEngine.h"
 #include "AudioDestinationNode.h"
 #include "blink/IIRFilter.h"
 #include "PlayingRefChangeHandler.h"
 #include "AlignmentUtils.h"
+#include "nsPrintfCString.h"
 
 #include "nsGkAtoms.h"
 
 namespace mozilla {
 namespace dom {
 
 class IIRFilterNodeEngine final : public AudioNodeEngine {
  public:
@@ -158,34 +159,45 @@ IIRFilterNode::IIRFilterNode(AudioContex
 }
 
 /* static */
 already_AddRefed<IIRFilterNode> IIRFilterNode::Create(
     AudioContext& aAudioContext, const IIRFilterOptions& aOptions,
     ErrorResult& aRv) {
   if (aOptions.mFeedforward.Length() == 0 ||
       aOptions.mFeedforward.Length() > 20) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowNotSupportedError(
+        nsPrintfCString("\"feedforward\" length %zu is not in the range [1,20]",
+                        aOptions.mFeedforward.Length()));
     return nullptr;
   }
 
   if (aOptions.mFeedback.Length() == 0 || aOptions.mFeedback.Length() > 20) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowNotSupportedError(
+        nsPrintfCString("\"feedback\" length %zu is not in the range [1,20]",
+                        aOptions.mFeedforward.Length()));
     return nullptr;
   }
 
   bool feedforwardAllZeros = true;
   for (size_t i = 0; i < aOptions.mFeedforward.Length(); ++i) {
     if (aOptions.mFeedforward.Elements()[i] != 0.0) {
       feedforwardAllZeros = false;
+      break;
     }
   }
 
-  if (feedforwardAllZeros || aOptions.mFeedback.Elements()[0] == 0.0) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+  if (feedforwardAllZeros) {
+    aRv.ThrowInvalidStateError(
+        "\"feedforward\" must contain some nonzero values");
+    return nullptr;
+  }
+
+  if (aOptions.mFeedback[0] == 0.0) {
+    aRv.ThrowInvalidStateError("First value in \"feedback\" must be nonzero");
     return nullptr;
   }
 
   RefPtr<IIRFilterNode> audioNode = new IIRFilterNode(
       &aAudioContext, aOptions.mFeedforward, aOptions.mFeedback);
 
   audioNode->Initialize(aOptions, aRv);
   if (NS_WARN_IF(aRv.Failed())) {
--- a/dom/media/webaudio/MediaElementAudioSourceNode.cpp
+++ b/dom/media/webaudio/MediaElementAudioSourceNode.cpp
@@ -40,31 +40,31 @@ MediaElementAudioSourceNode::MediaElemen
   MOZ_ASSERT(aElement);
 }
 
 /* static */
 already_AddRefed<MediaElementAudioSourceNode>
 MediaElementAudioSourceNode::Create(
     AudioContext& aAudioContext, const MediaElementAudioSourceOptions& aOptions,
     ErrorResult& aRv) {
-  if (aAudioContext.IsOffline()) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
-    return nullptr;
-  }
+  // The spec has a pointless check here.  See
+  // https://github.com/WebAudio/web-audio-api/issues/2149
+  MOZ_RELEASE_ASSERT(!aAudioContext.IsOffline(), "Bindings messed up?");
 
   RefPtr<MediaElementAudioSourceNode> node =
       new MediaElementAudioSourceNode(&aAudioContext, aOptions.mMediaElement);
 
   RefPtr<DOMMediaStream> stream = aOptions.mMediaElement->CaptureAudio(
       aRv, aAudioContext.Destination()->Track()->Graph());
   if (aRv.Failed()) {
     return nullptr;
   }
+  MOZ_ASSERT(stream, "CaptureAudio should report failure via aRv!");
 
-  node->Init(stream, aRv);
+  node->Init(*stream, aRv);
   if (aRv.Failed()) {
     return nullptr;
   }
 
   node->ListenForAllowedToPlay(aOptions);
   return node.forget();
 }
 
--- a/dom/media/webaudio/MediaStreamAudioDestinationNode.cpp
+++ b/dom/media/webaudio/MediaStreamAudioDestinationNode.cpp
@@ -103,20 +103,19 @@ MediaStreamAudioDestinationNode::MediaSt
   mDOMStream->AddTrackInternal(track);
 }
 
 /* static */
 already_AddRefed<MediaStreamAudioDestinationNode>
 MediaStreamAudioDestinationNode::Create(AudioContext& aAudioContext,
                                         const AudioNodeOptions& aOptions,
                                         ErrorResult& aRv) {
-  if (aAudioContext.IsOffline()) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
-    return nullptr;
-  }
+  // The spec has a pointless check here.  See
+  // https://github.com/WebAudio/web-audio-api/issues/2149
+  MOZ_RELEASE_ASSERT(!aAudioContext.IsOffline(), "Bindings messed up?");
 
   RefPtr<MediaStreamAudioDestinationNode> audioNode =
       new MediaStreamAudioDestinationNode(&aAudioContext);
 
   audioNode->Initialize(aOptions, aRv);
   if (NS_WARN_IF(aRv.Failed())) {
     return nullptr;
   }
--- a/dom/media/webaudio/MediaStreamAudioSourceNode.cpp
+++ b/dom/media/webaudio/MediaStreamAudioSourceNode.cpp
@@ -42,40 +42,35 @@ MediaStreamAudioSourceNode::MediaStreamA
     : AudioNode(aContext, 2, ChannelCountMode::Max,
                 ChannelInterpretation::Speakers),
       mBehavior(aBehavior) {}
 
 /* static */
 already_AddRefed<MediaStreamAudioSourceNode> MediaStreamAudioSourceNode::Create(
     AudioContext& aAudioContext, const MediaStreamAudioSourceOptions& aOptions,
     ErrorResult& aRv) {
-  if (aAudioContext.IsOffline()) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
-    return nullptr;
-  }
+  // The spec has a pointless check here.  See
+  // https://github.com/WebAudio/web-audio-api/issues/2149
+  MOZ_RELEASE_ASSERT(!aAudioContext.IsOffline(), "Bindings messed up?");
 
   RefPtr<MediaStreamAudioSourceNode> node =
       new MediaStreamAudioSourceNode(&aAudioContext, LockOnTrackPicked);
 
-  node->Init(aOptions.mMediaStream, aRv);
+  // aOptions.mMediaStream is not nullable.
+  node->Init(*aOptions.mMediaStream, aRv);
   if (aRv.Failed()) {
     return nullptr;
   }
 
   return node.forget();
 }
 
-void MediaStreamAudioSourceNode::Init(DOMMediaStream* aMediaStream,
+void MediaStreamAudioSourceNode::Init(DOMMediaStream& aMediaStream,
                                       ErrorResult& aRv) {
-  if (!aMediaStream) {
-    aRv.Throw(NS_ERROR_FAILURE);
-    return;
-  }
-
-  mInputStream = aMediaStream;
+  mInputStream = &aMediaStream;
   AudioNodeEngine* engine = new MediaStreamAudioSourceNodeEngine(this);
   mTrack = AudioNodeExternalInputTrack::Create(Context()->Graph(), engine);
   mInputStream->AddConsumerToKeepAlive(ToSupports(this));
 
   mInputStream->RegisterTrackListener(this);
   if (mInputStream->Audible()) {
     NotifyAudible();
   }
@@ -104,17 +99,21 @@ void MediaStreamAudioSourceNode::AttachT
 
   if (NS_WARN_IF(Context()->Graph() != aTrack->Graph())) {
     nsCOMPtr<nsPIDOMWindowInner> pWindow = Context()->GetParentObject();
     Document* document = pWindow ? pWindow->GetExtantDoc() : nullptr;
     nsContentUtils::ReportToConsole(nsIScriptError::warningFlag,
                                     NS_LITERAL_CSTRING("Web Audio"), document,
                                     nsContentUtils::eDOM_PROPERTIES,
                                     "MediaStreamAudioSourceNodeDifferentRate");
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    // This is not a spec-required exception, just a limitation of our
+    // implementation.
+    aRv.ThrowNotSupportedError(
+        "Connecting AudioNodes from AudioContexts with different sample-rate "
+        "is currently not supported.");
     return;
   }
 
   mInputTrack = aTrack;
   ProcessedMediaTrack* outputTrack =
       static_cast<ProcessedMediaTrack*>(mTrack.get());
   mInputPort = mInputTrack->ForwardTrackContentsTo(outputTrack);
   PrincipalChanged(mInputTrack);  // trigger enabling/disabling of the connector
@@ -144,17 +143,17 @@ static int AudioTrackCompare(const RefPt
 }
 
 void MediaStreamAudioSourceNode::AttachToRightTrack(
     const RefPtr<DOMMediaStream>& aMediaStream, ErrorResult& aRv) {
   nsTArray<RefPtr<AudioStreamTrack>> tracks;
   aMediaStream->GetAudioTracks(tracks);
 
   if (tracks.IsEmpty() && mBehavior == LockOnTrackPicked) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    aRv.ThrowInvalidStateError("No audio tracks in MediaStream");
     return;
   }
 
   // Sort the track to have a stable order, on their ID by lexicographic
   // ordering on sequences of code unit values.
   tracks.Sort(AudioTrackCompare);
 
   for (const RefPtr<AudioStreamTrack>& track : tracks) {
--- a/dom/media/webaudio/MediaStreamAudioSourceNode.h
+++ b/dom/media/webaudio/MediaStreamAudioSourceNode.h
@@ -106,17 +106,17 @@ class MediaStreamAudioSourceNode
     // MediaElementAudioSourceNode can change track, depending on what the
     // HTMLMediaElement does.
     FollowChanges
   };
 
  protected:
   MediaStreamAudioSourceNode(AudioContext* aContext,
                              TrackChangeBehavior aBehavior);
-  void Init(DOMMediaStream* aMediaStream, ErrorResult& aRv);
+  void Init(DOMMediaStream& aMediaStream, ErrorResult& aRv);
   virtual void Destroy();
   virtual ~MediaStreamAudioSourceNode();
 
  private:
   const TrackChangeBehavior mBehavior;
   RefPtr<MediaInputPort> mInputPort;
   RefPtr<DOMMediaStream> mInputStream;
 
--- a/dom/media/webaudio/MediaStreamTrackAudioSourceNode.cpp
+++ b/dom/media/webaudio/MediaStreamTrackAudioSourceNode.cpp
@@ -39,30 +39,33 @@ MediaStreamTrackAudioSourceNode::MediaSt
     : AudioNode(aContext, 2, ChannelCountMode::Max,
                 ChannelInterpretation::Speakers),
       mTrackListener(this) {}
 
 /* static */ already_AddRefed<MediaStreamTrackAudioSourceNode>
 MediaStreamTrackAudioSourceNode::Create(
     AudioContext& aAudioContext,
     const MediaStreamTrackAudioSourceOptions& aOptions, ErrorResult& aRv) {
-  if (aAudioContext.IsOffline()) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
-    return nullptr;
-  }
+  // The spec has a pointless check here.  See
+  // https://github.com/WebAudio/web-audio-api/issues/2149
+  MOZ_RELEASE_ASSERT(!aAudioContext.IsOffline(), "Bindings messed up?");
 
   if (!aOptions.mMediaStreamTrack->Ended() &&
       aAudioContext.Graph() != aOptions.mMediaStreamTrack->Graph()) {
     nsCOMPtr<nsPIDOMWindowInner> pWindow = aAudioContext.GetParentObject();
     Document* document = pWindow ? pWindow->GetExtantDoc() : nullptr;
     nsContentUtils::ReportToConsole(nsIScriptError::warningFlag,
                                     NS_LITERAL_CSTRING("Web Audio"), document,
                                     nsContentUtils::eDOM_PROPERTIES,
                                     "MediaStreamAudioSourceNodeDifferentRate");
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    // This is not a spec-required exception, just a limitation of our
+    // implementation.
+    aRv.ThrowNotSupportedError(
+        "Connecting AudioNodes from AudioContexts with different sample-rate "
+        "is currently not supported.");
     return nullptr;
   }
 
   RefPtr<MediaStreamTrackAudioSourceNode> node =
       new MediaStreamTrackAudioSourceNode(&aAudioContext);
 
   node->Init(aOptions.mMediaStreamTrack, aRv);
   if (aRv.Failed()) {
@@ -72,17 +75,17 @@ MediaStreamTrackAudioSourceNode::Create(
   return node.forget();
 }
 
 void MediaStreamTrackAudioSourceNode::Init(MediaStreamTrack* aMediaStreamTrack,
                                            ErrorResult& aRv) {
   MOZ_ASSERT(aMediaStreamTrack);
 
   if (!aMediaStreamTrack->AsAudioStreamTrack()) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    aRv.ThrowInvalidStateError("\"mediaStreamTrack\" must be an audio track");
     return;
   }
 
   if (aMediaStreamTrack->Ended()) {
     // The track is ended and will never produce any data. Pretend like this is
     // fine.
     return;
   }
--- a/dom/media/webaudio/OscillatorNode.cpp
+++ b/dom/media/webaudio/OscillatorNode.cpp
@@ -459,22 +459,22 @@ void OscillatorNode::SendPeriodicWaveToT
   SendInt32ParameterToTrack(OscillatorNodeEngine::DISABLE_NORMALIZATION,
                             mPeriodicWave->DisableNormalization());
   AudioChunk data = mPeriodicWave->GetThreadSharedBuffer();
   mTrack->SetBuffer(std::move(data));
 }
 
 void OscillatorNode::Start(double aWhen, ErrorResult& aRv) {
   if (!WebAudioUtils::IsTimeValid(aWhen)) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowRangeError<MSG_VALUE_OUT_OF_RANGE>(u"start time");
     return;
   }
 
   if (mStartCalled) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    aRv.ThrowInvalidStateError("Can't call start() more than once");
     return;
   }
   mStartCalled = true;
 
   if (!mTrack) {
     // Nothing to play, or we're already dead for some reason
     return;
   }
@@ -483,22 +483,22 @@ void OscillatorNode::Start(double aWhen,
   mTrack->SetTrackTimeParameter(OscillatorNodeEngine::START, Context(), aWhen);
 
   MarkActive();
   Context()->StartBlockedAudioContextIfAllowed();
 }
 
 void OscillatorNode::Stop(double aWhen, ErrorResult& aRv) {
   if (!WebAudioUtils::IsTimeValid(aWhen)) {
-    aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+    aRv.ThrowRangeError<MSG_VALUE_OUT_OF_RANGE>(u"stop time");
     return;
   }
 
   if (!mStartCalled) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    aRv.ThrowInvalidStateError("Can't call stop() without calling start()");
     return;
   }
 
   if (!mTrack || !Context()) {
     // We've already stopped and had our track shut down
     return;
   }
 
--- a/dom/media/webaudio/OscillatorNode.h
+++ b/dom/media/webaudio/OscillatorNode.h
@@ -42,17 +42,17 @@ class OscillatorNode final : public Audi
 
   uint16_t NumberOfInputs() const final { return 0; }
 
   OscillatorType Type() const { return mType; }
   void SetType(OscillatorType aType, ErrorResult& aRv) {
     if (aType == OscillatorType::Custom) {
       // ::Custom can only be set by setPeriodicWave().
       // https://github.com/WebAudio/web-audio-api/issues/105 for exception.
-      aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+      aRv.ThrowInvalidStateError("Can't set type to \"custom\"");
       return;
     }
     mType = aType;
     SendTypeToTrack();
   }
 
   AudioParam* Frequency() const { return mFrequency; }
   AudioParam* Detune() const { return mDetune; }
--- a/dom/media/webaudio/PannerNode.h
+++ b/dom/media/webaudio/PannerNode.h
@@ -4,16 +4,17 @@
  * 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/. */
 
 #ifndef PannerNode_h_
 #define PannerNode_h_
 
 #include "AudioNode.h"
 #include "AudioParam.h"
+#include "nsPrintfCString.h"
 #include "mozilla/dom/PannerNodeBinding.h"
 #include "ThreeDPoint.h"
 #include <limits>
 #include <set>
 
 namespace mozilla {
 namespace dom {
 
@@ -34,25 +35,26 @@ class PannerNode final : public AudioNod
     return Create(aAudioContext, aOptions, aRv);
   }
 
   JSObject* WrapObject(JSContext* aCx,
                        JS::Handle<JSObject*> aGivenProto) override;
 
   void SetChannelCount(uint32_t aChannelCount, ErrorResult& aRv) override {
     if (aChannelCount > 2) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aRv.ThrowNotSupportedError(
+          nsPrintfCString("%u is greater than 2", aChannelCount));
       return;
     }
     AudioNode::SetChannelCount(aChannelCount, aRv);
   }
   void SetChannelCountModeValue(ChannelCountMode aMode,
                                 ErrorResult& aRv) override {
     if (aMode == ChannelCountMode::Max) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aRv.ThrowNotSupportedError("Cannot set channel count mode to \"max\"");
       return;
     }
     AudioNode::SetChannelCountModeValue(aMode, aRv);
   }
 
   NS_DECL_ISUPPORTS_INHERITED
   NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(PannerNode, AudioNode)
 
@@ -156,17 +158,18 @@ class PannerNode final : public AudioNod
 
   double ConeOuterGain() const { return mConeOuterGain; }
   void SetConeOuterGain(double aConeOuterGain, ErrorResult& aRv) {
     if (WebAudioUtils::FuzzyEqual(mConeOuterGain, aConeOuterGain)) {
       return;
     }
 
     if (aConeOuterGain < 0 || aConeOuterGain > 1) {
-      aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+      aRv.ThrowInvalidStateError(
+          nsPrintfCString("%g is not in the range [0, 1]", aConeOuterGain));
       return;
     }
 
     mConeOuterGain = aConeOuterGain;
     SendDoubleParameterToTrack(CONE_OUTER_GAIN, mConeOuterGain);
   }
 
   AudioParam* PositionX() { return mPositionX; }
--- a/dom/media/webaudio/PeriodicWave.cpp
+++ b/dom/media/webaudio/PeriodicWave.cpp
@@ -69,33 +69,35 @@ PeriodicWave::PeriodicWave(AudioContext*
 }
 
 /* static */
 already_AddRefed<PeriodicWave> PeriodicWave::Constructor(
     const GlobalObject& aGlobal, AudioContext& aAudioContext,
     const PeriodicWaveOptions& aOptions, ErrorResult& aRv) {
   if (aOptions.mReal.WasPassed() && aOptions.mImag.WasPassed() &&
       aOptions.mReal.Value().Length() != aOptions.mImag.Value().Length()) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError(
+        "\"real\" and \"imag\" parameters of PeriodicWaveOptions are different "
+        "lengths");
     return nullptr;
   }
 
   uint32_t length = 0;
   if (aOptions.mReal.WasPassed()) {
     length = aOptions.mReal.Value().Length();
   } else if (aOptions.mImag.WasPassed()) {
     length = aOptions.mImag.Value().Length();
   } else {
     // If nothing has been passed, this PeriodicWave will be a sine wave: 2
     // elements for each array, the second imaginary component set to 1.0.
     length = 2;
   }
 
   if (length == 0) {
-    aRv.Throw(NS_ERROR_DOM_INDEX_SIZE_ERR);
+    aRv.ThrowIndexSizeError("\"real\" and \"imag\" are both empty arrays");
     return nullptr;
   }
 
   const float* realData =
       aOptions.mReal.WasPassed() ? aOptions.mReal.Value().Elements() : nullptr;
   const float* imagData =
       aOptions.mImag.WasPassed() ? aOptions.mImag.Value().Elements() : nullptr;
 
--- a/dom/media/webaudio/ScriptProcessorNode.h
+++ b/dom/media/webaudio/ScriptProcessorNode.h
@@ -84,23 +84,29 @@ class ScriptProcessorNode final : public
   }
   void Disconnect(AudioParam& aDestination, uint32_t aOutput,
                   ErrorResult& aRv) override {
     AudioNode::Disconnect(aDestination, aOutput, aRv);
     UpdateConnectedStatus();
   }
   void SetChannelCount(uint32_t aChannelCount, ErrorResult& aRv) override {
     if (aChannelCount != ChannelCount()) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      // Spec says to throw InvalidStateError, but see
+      // https://github.com/WebAudio/web-audio-api/issues/2153
+      aRv.ThrowNotSupportedError(
+          "Cannot change channel count of ScriptProcessorNode");
     }
   }
   void SetChannelCountModeValue(ChannelCountMode aMode,
                                 ErrorResult& aRv) override {
     if (aMode != ChannelCountMode::Explicit) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      // Spec says to throw InvalidStateError, but see
+      // https://github.com/WebAudio/web-audio-api/issues/2154
+      aRv.ThrowNotSupportedError(
+          "Cannot change channel count mode of ScriptProcessorNode");
     }
   }
 
   uint32_t BufferSize() const { return mBufferSize; }
 
   uint32_t NumberOfOutputChannels() const { return mNumberOfOutputChannels; }
 
   using DOMEventTargetHelper::DispatchTrustedEvent;
--- a/dom/media/webaudio/StereoPannerNode.h
+++ b/dom/media/webaudio/StereoPannerNode.h
@@ -3,16 +3,17 @@
 /* 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/. */
 
 #ifndef StereoPannerNode_h_
 #define StereoPannerNode_h_
 
 #include "AudioNode.h"
+#include "nsPrintfCString.h"
 #include "mozilla/dom/StereoPannerNodeBinding.h"
 
 namespace mozilla {
 namespace dom {
 
 class AudioContext;
 struct StereoPannerOptions;
 
@@ -31,25 +32,26 @@ class StereoPannerNode final : public Au
   }
 
   virtual JSObject* WrapObject(JSContext* aCx,
                                JS::Handle<JSObject*> aGivenProto) override;
 
   virtual void SetChannelCount(uint32_t aChannelCount,
                                ErrorResult& aRv) override {
     if (aChannelCount > 2) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aRv.ThrowNotSupportedError(
+          nsPrintfCString("%u is greater than 2", aChannelCount));
       return;
     }
     AudioNode::SetChannelCount(aChannelCount, aRv);
   }
   virtual void SetChannelCountModeValue(ChannelCountMode aMode,
                                         ErrorResult& aRv) override {
     if (aMode == ChannelCountMode::Max) {
-      aRv.Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
+      aRv.ThrowNotSupportedError("Cannot set channel count mode to \"max\"");
       return;
     }
     AudioNode::SetChannelCountModeValue(aMode, aRv);
   }
 
   AudioParam* Pan() const { return mPan; }
 
   NS_DECL_ISUPPORTS_INHERITED
--- a/dom/media/webaudio/WaveShaperNode.cpp
+++ b/dom/media/webaudio/WaveShaperNode.cpp
@@ -341,17 +341,17 @@ void WaveShaperNode::SetCurve(const Null
 
   PodCopy(curve.Elements(), floats.Data(), argLength);
   SetCurveInternal(curve, aRv);
 }
 
 void WaveShaperNode::SetCurveInternal(const nsTArray<float>& aCurve,
                                       ErrorResult& aRv) {
   if (aCurve.Length() < 2) {
-    aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR);
+    aRv.ThrowInvalidStateError("Must have at least two entries");
     return;
   }
 
   mCurve = aCurve;
   SendCurveToTrack();
 }
 
 void WaveShaperNode::CleanCurveInternal() {
--- a/dom/media/webaudio/test/test_AudioBuffer.html
+++ b/dom/media/webaudio/test/test_AudioBuffer.html
@@ -90,16 +90,16 @@ addLoadEvent(function() {
   context.createBuffer(32, 2048, 48000); // no exception
   // Null length
   expectException(function() {
     context.createBuffer(2, 0, 48000);
   }, DOMException.NOT_SUPPORTED_ERR);
   // Null number of channels
   expectException(function() {
     context.createBuffer(0, 2048, 48000);
-  }, DOMException.INDEX_SIZE_ERR);
+  }, DOMException.NOT_SUPPORTED_ERR);
   SimpleTest.finish();
 });
 
 </script>
 </pre>
 </body>
 </html>
--- a/dom/security/test/general/window_nosniff_navigation.html
+++ b/dom/security/test/general/window_nosniff_navigation.html
@@ -49,61 +49,45 @@
  * Navigations.
  * If Firefox cant Display the Page, it will prompt a download 
  * and the URL of the Page will be about:blank.
  * So we will try to open different content send with
  * no-mime, mismatched-mime and garbage-mime types.
  * 
  */
 
-
-/*
-  What the Sniffer Should sniff, given a querystring for file_nosniff_navigation.sjs
-*/
-const EXPECTED_MIMES={
-  "xml": "text/xml",
-  "html": "text/html",
-  "img": "image/png",
-  "css": "text/plain",
-  "js": "text/plain",
-  "json": "text/plain", 
-  "": "text/plain"
-}
-
 SimpleTest.waitForExplicitFinish();
 
 window.addEventListener("load", ()=>{
   let noMimeFrames = Array.from(document.querySelectorAll(".no-mime"));
 
   noMimeFrames.forEach( frame => {
-    // In case of no Provided Content Type + XTCO set, we still should do sniffing
-    let sniffedMimeType = frame.contentWindow.document.contentType;
-    let contentTypeQuery = (new URL(frame.src)).search.substr(1);
-    let expectedMime = EXPECTED_MIMES[contentTypeQuery];
-    let result = expectedMime == sniffedMimeType;
-    window.opener.ok(result, `${contentTypeQuery} without MIME send -> was Sniffed: ${frame.contentWindow.document.contentType}`);
+    // In case of no Provided Content Type, not rendering or assuming text/plain is valid
+    let result = frame.contentWindow.document.URL == "about:blank" || frame.contentWindow.document.contentType == "text/plain";
+    let sniffTarget = (new URL(frame.src)).search;
+    window.opener.ok(result, `${sniffTarget} without MIME - was not Sniffed`);
   });
 
   let mismatchedMimes = Array.from(document.querySelectorAll(".mismatch-mime"));
   mismatchedMimes.forEach(frame => {
     // In case the Server mismatches the Mime Type (sends content X as image/png)
     // assert that we do not sniff and correct this.
     let result = frame.contentWindow.document.contentType == "image/png";
-    let sniffTarget = (new URL(frame.src)).search.substr(1);
+    let sniffTarget = (new URL(frame.src)).search;
     window.opener.ok(result, `${sniffTarget} send as image/png - was not Sniffed`);
   });
 
   let badMimeFrames = Array.from(document.querySelectorAll(".garbage-mime"));
 
   badMimeFrames.forEach( frame => {
     // In the case we got a bogous mime, assert that we dont sniff. 
     // We must not default here to text/plain
     // as the Server at least provided a mime type. 
     let result = frame.contentWindow.document.URL == "about:blank";
-    let sniffTarget = (new URL(frame.src)).search.substr(1);;
+    let sniffTarget = (new URL(frame.src)).search;
     window.opener.ok(result, `${sniffTarget} send as garbage/garbage - was not Sniffed`);
   });
   
   window.opener.SimpleTest.finish();
   this.close();
 });
 </script>
 </body>
new file mode 100644
--- /dev/null
+++ b/dom/serviceworkers/test/abrupt_completion_worker.js
@@ -0,0 +1,18 @@
+function setMessageHandler(response) {
+  onmessage = e => {
+    e.source.postMessage(response);
+  };
+}
+
+setMessageHandler("handler-before-throw");
+
+// importScripts will throw when the ServiceWorker is past the "intalling" state.
+importScripts(`empty.js?${Date.now()}`);
+
+// When importScripts throws an uncaught exception, these calls should never be
+// made and the message handler should remain responding "handler-before-throw".
+setMessageHandler("handler-after-throw");
+
+// There needs to be a fetch handler to avoid the no-fetch optimizaiton,
+// which will skip starting up this worker.
+onfetch = e => e.respondWith(new Response("handler-after-throw"));
--- a/dom/serviceworkers/test/mochitest.ini
+++ b/dom/serviceworkers/test/mochitest.ini
@@ -3,16 +3,17 @@
 # too. The result is that we have nested iframes. CookieBehavior 4
 # (BEHAVIOR_REJECT_TRACKER) doesn't grant storage access permission to nested
 # iframes because trackers could use them to follow users across sites. Let's
 # use cookieBehavior 0 (BEHAVIOR_ACCEPT) here.
 prefs =
   network.cookie.cookieBehavior=0
   plugin.load_flash_only=false
 support-files =
+  abrupt_completion_worker.js
   worker.js
   worker2.js
   worker3.js
   fetch_event_worker.js
   parse_error_worker.js
   activate_event_error_worker.js
   install_event_worker.js
   install_event_error_worker.js
@@ -127,16 +128,17 @@ support-files =
   notificationclick.js
   notificationclick_focus.html
   notificationclick_focus.js
   notificationclose.html
   notificationclose.js
   worker_updatefoundevent.js
   worker_updatefoundevent2.js
   updatefoundevent.html
+  empty.html
   empty.js
   notification_constructor_error.js
   notification_get_sw.js
   notification/register.html
   notification/unregister.html
   notification/listener.html
   notification_alt/register.html
   notification_alt/unregister.html
@@ -223,16 +225,17 @@ support-files =
   service_worker_client.html
   utils.js
   sw_storage_not_allow.js
   update_worker.sjs
   self_update_worker.sjs
   !/dom/events/test/event_leak_utils.js
   onmessageerror_worker.js
 
+[test_abrupt_completion.html]
 [test_bug1151916.html]
 [test_bug1240436.html]
 [test_bug1408734.html]
 [test_claim.html]
 [test_claim_oninstall.html]
 [test_controller.html]
 [test_cookie_fetch.html]
 [test_cross_origin_url_after_redirect.html]
new file mode 100644
--- /dev/null
+++ b/dom/serviceworkers/test/test_abrupt_completion.html
@@ -0,0 +1,147 @@
+<!doctype html>
+<meta charset=utf-8>
+<title></title>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script>
+
+// Tests a _registered_ ServiceWorker whose script evaluation results in an
+// "abrupt completion", e.g. threw an uncaught exception. Such a ServiceWorker's
+// first script evaluation must result in a "normal completion", however, for
+// the Update algorithm to not abort in its step 18 when registering:
+//
+// 18. If runResult is failure or an abrupt completion, then: [...]
+
+const script = "./abrupt_completion_worker.js";
+const scope = "./empty.html";
+const expectedMessage = "handler-before-throw";
+let registration = null;
+
+// Should only be called once registration.active is non-null. Uses
+// implementation details by zero-ing the "idle timeout"s and then sending  an
+// event to the ServiceWorker, which should immediately cause its termination.
+// The idle timeouts are restored after the ServiceWorker is terminated.
+async function startAndStopServiceWorker() {
+  SpecialPowers.registerObservers("service-worker-shutdown");
+
+  const spTopic =
+    SpecialPowers.getBoolPref("dom.serviceWorkers.parent_intercept") ?
+      "specialpowers-service-worker-shutdown" :
+      "service-worker-shutdown";
+
+  const origIdleTimeout =
+    SpecialPowers.getIntPref("dom.serviceWorkers.idle_timeout");
+
+  const origIdleExtendedTimeout =
+    SpecialPowers.getIntPref("dom.serviceWorkers.idle_extended_timeout");
+
+  await new Promise(resolve => {
+    const observer = {
+      async observe(subject, topic, data) {
+        if (topic !== spTopic) {
+          return;
+        }
+
+        SpecialPowers.removeObserver(observer, spTopic);
+
+        await SpecialPowers.pushPrefEnv({
+          set: [
+              ["dom.serviceWorkers.idle_timeout", origIdleTimeout],
+              ["dom.serviceWorkers.idle_extended_timeout", origIdleExtendedTimeout]
+            ]
+        });
+
+        resolve();
+      },
+    };
+
+    // Speed things up.
+    SpecialPowers.pushPrefEnv({
+      set: [
+          ["dom.serviceWorkers.idle_timeout", 0],
+          ["dom.serviceWorkers.idle_extended_timeout", 0]
+        ]
+    }).then(() => {
+      SpecialPowers.addObserver(observer, spTopic);
+
+      registration.active.postMessage("");
+    });
+  });
+}
+
+add_task(async function setup() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+        ["dom.serviceWorkers.enabled", true],
+        ["dom.serviceWorkers.testing.enabled", true]
+      ]
+  });
+
+  registration = await navigator.serviceWorker.register(script, { scope });
+  SimpleTest.registerCleanupFunction(async function unregisterRegistration() {
+    await registration.unregister();
+  });
+
+  await new Promise(resolve => {
+    const serviceWorker = registration.installing;
+
+    serviceWorker.onstatechange = () => {
+      if (serviceWorker.state === "activated") {
+        resolve();
+      }
+    };
+  });
+
+  ok(registration.active instanceof ServiceWorker, "ServiceWorker is activated");
+});
+
+// We expect that the restarted SW that experiences an abrupt completion at
+// startup after adding its message handler 1) will be active in order to
+// respond to our postMessage and 2) will respond with the global value set
+// prior to the importScripts call that throws (and not the global value that
+// would have been assigned after the importScripts call if it didn't throw).
+add_task(async function testMessageHandler() {
+  await startAndStopServiceWorker();
+
+  await new Promise(resolve => {
+    navigator.serviceWorker.onmessage = e => {
+      is(e.data, expectedMessage, "Correct message handler");
+      resolve();
+    };
+    registration.active.postMessage("");
+  });
+});
+
+// We expect that the restarted SW that experiences an abrupt completion at
+// startup before adding its "fetch" listener will 1) successfully dispatch the
+// event and 2) it will not be handled (respondWith() will not be called) so
+// interception will be reset and the response will contain the contents of
+// empty.html. Before the fix in bug 1603484 the SW would fail to properly start
+// up and the fetch event would result in a NetworkError, breaking the
+// controlled page.
+add_task(async function testFetchHandler() {
+  await startAndStopServiceWorker();
+
+  const iframe = document.createElement("iframe");
+  SimpleTest.registerCleanupFunction(function removeIframe() {
+    iframe.remove();
+  });
+
+  await new Promise(resolve => {
+    iframe.src = scope;
+    iframe.onload = resolve;
+    document.body.appendChild(iframe);
+  });
+
+  const response = await iframe.contentWindow.fetch(scope);
+
+  // NetworkError will have a status of 0, which is not "ok", and this is
+  // a stronger guarantee that should be true instead of just checking if there
+  // isn't a NetworkError.
+  ok(response.ok, "Fetch succeeded and didn't result in a NetworkError");
+
+  const text = await response.text();
+  is(text, "", "Correct response text");
+});
+
+</script>
+
--- a/dom/tests/mochitest/chrome/chrome.ini
+++ b/dom/tests/mochitest/chrome/chrome.ini
@@ -15,16 +15,17 @@ support-files =
   file_bug1224790-1_modal.xhtml
   file_bug1224790-1_nonmodal.xhtml
   file_bug1224790-2_modal.xhtml
   file_bug1224790-2_nonmodal.xhtml
   file_popup_blocker_chrome.html
   file_subscript_bindings.js
   focus_frameset.html
   focus_window2.xhtml
+  focus_dialog.xhtml
   fullscreen.xhtml
   queryCaretRectUnix.html
   queryCaretRectWin.html
   selectAtPoint.html
   selectAtPoint-innerframe.html
   sizemode_attribute.xhtml
   window_activation.xhtml
   window_callback_wrapping.xhtml
@@ -52,16 +53,17 @@ tags = openwindow
 skip-if = os != 'mac' || os_version == '10.14' # 10.14 due to bug 1558642
 [test_callback_wrapping.xhtml]
 [test_clonewrapper.xhtml]
 [test_cyclecollector.xhtml]
 [test_docshell_swap.xhtml]
 [test_elements_proto.xhtml]
 [test_focus.xhtml]
 skip-if = os == 'linux' # bug 1296622, Bug 1605253
+[test_focus_dialog.xhtml]
 [test_focus_docnav.xhtml]
 [test_focused_link_scroll.xhtml]
 [test_fullscreen.xhtml]
 tags = fullscreen
 # disabled on linux for timeouts--bug-867745
 skip-if = os == 'linux'
 [test_geolocation.xhtml]
 [test_getTransformTo.html]
new file mode 100644
--- /dev/null
+++ b/dom/tests/mochitest/chrome/focus_dialog.xhtml
@@ -0,0 +1,71 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+
+<window id="other-document"
+        xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+<dialog buttons="accept">
+
+<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+
+<button id="button-1" label="Something"/>
+<button id="button-2" label="Something else"/>
+
+<script>
+<![CDATA[
+
+window.is = window.arguments[0].is;
+window.isnot = window.arguments[0].isnot;
+window.ok = window.arguments[0].ok;
+window.SimpleTest = window.arguments[0].SimpleTest;
+
+(async function() {
+  await new Promise(resolve => {
+    window.onload = resolve;
+  });
+
+  await new Promise(resolve => SimpleTest.waitForFocus(resolve, window));
+
+  let dialog = document.querySelector("dialog");
+  let shadow = dialog.shadowRoot;
+
+  let button = document.getElementById("button-1");
+  let button2 = document.getElementById("button-2");
+  let forwardSequence = [];
+
+  button.focus();
+
+  is(document.activeElement, button, "Should've focused the button");
+  forwardSequence.push(button);
+
+  synthesizeKey("KEY_Tab");
+
+  is(document.activeElement, button2, "Should've moved to the second button");
+  forwardSequence.push(button2);
+
+  synthesizeKey("KEY_Tab");
+
+  isnot(shadow.activeElement, null, "Should've focused the shadow root button");
+  is(document.activeElement, dialog, "document.focusedElement should be the dialog because retargeting");
+  forwardSequence.push(shadow.activeElement);
+
+  synthesizeKey("KEY_Tab");
+
+  is(document.activeElement, button, "Should properly wrap around going forward");
+
+  while (forwardSequence.length) {
+    synthesizeKey("KEY_Tab", { shiftKey: true });
+    is(
+      shadow.activeElement || document.activeElement,
+      forwardSequence.pop(),
+      "Should travel backwards correctly: " + forwardSequence.length
+    );
+  }
+
+  window.close();
+  SimpleTest.finish();
+}());
+
+]]>
+</script>
+</dialog>
+</window>
new file mode 100644
--- /dev/null
+++ b/dom/tests/mochitest/chrome/test_focus_dialog.xhtml
@@ -0,0 +1,33 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+
+<window title="Focus Tests"
+  onload="setTimeout(runTest, 0);"
+  xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/>
+
+<script>
+if (navigator.platform.startsWith("Win")) {
+  SimpleTest.expectAssertions(0, 1);
+}
+
+SimpleTest.waitForExplicitFinish();
+async function runTest() {
+  // Enable full tab focus model on mac.
+  await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+  window.openDialog("focus_dialog.xhtml", "_blank", "chrome,modal,noopener", window);
+}
+</script>
+
+<body xmlns="http://www.w3.org/1999/xhtml">
+<p id="display">
+</p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+</pre>
+</body>
+
+</window>
--- a/dom/workers/WorkerPrivate.cpp
+++ b/dom/workers/WorkerPrivate.cpp
@@ -377,17 +377,33 @@ class CompileScriptRunnable final : publ
 
     // This is a little dumb, but aCx is in the null realm here because we
     // set it up that way in our Run(), since we had not created the global at
     // that point yet.  So we need to enter the realm of our global,
     // because setting a pending exception on aCx involves wrapping into its
     // current compartment.  Luckily we have a global now.
     JSAutoRealm ar(aCx, globalScope->GetGlobalJSObject());
     if (rv.MaybeSetPendingException(aCx)) {
-      return false;
+      // In the event of an uncaught exception, the worker should still keep
+      // running (return true) but should not be marked as having executed
+      // successfully (which will cause ServiceWorker installation to fail).
+      // In previous error handling cases in this method, we return false (to
+      // trigger CloseInternal) because the global is not in an operable
+      // state at all.
+      //
+      // For ServiceWorkers, this would correspond to the "Run Service Worker"
+      // algorithm returning an "abrupt completion" and _not_ failure.
+      //
+      // For DedicatedWorkers and SharedWorkers, this would correspond to the
+      // "run a worker" algorithm disregarding the return value of "run the
+      // classic script"/"run the module script" in step 24:
+      //
+      // "If script is a classic script, then run the classic script script.
+      // Otherwise, it is a module script; run the module script script."
+      return true;
     }
 
     aWorkerPrivate->SetWorkerScriptExecutedSuccessfully();
     return true;
   }
 
   void PostRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate,
                bool aRunResult) override {
--- a/dom/workers/WorkerRunnable.cpp
+++ b/dom/workers/WorkerRunnable.cpp
@@ -362,17 +362,16 @@ WorkerRunnable::Run() {
                "Must either be in the null compartment or in our reflector "
                "compartment");
 
     ar.emplace(cx, wrapper);
   }
 
   MOZ_ASSERT(!jsapi->HasException());
   result = WorkerRun(cx, mWorkerPrivate);
-  MOZ_ASSERT_IF(result, !jsapi->HasException());
   jsapi->ReportException();
 
   // We can't even assert that this didn't create our global, since in the case
   // of CompileScriptRunnable it _does_.
 
   // It would be nice to avoid passing a JSContext to PostRun, but in the case
   // of ScriptExecutorRunnable we need to know the current compartment on the
   // JSContext (the one we set up based on the global returned from PreRun) so
--- a/dom/worklet/tests/mochitest.ini
+++ b/dom/worklet/tests/mochitest.ini
@@ -20,10 +20,12 @@ support-files=worklet_audioWorklet.js
 support-files=worklet_test_audioWorkletGlobalScopeRegisterProcessor.js
 [test_exception.html]
 support-files=worklet_exception.js
 [test_paintWorklet.html]
 scheme = http
 support-files=worklet_paintWorklet.js
 [test_audioWorklet_WASM.html]
 support-files=worklet_audioWorklet_WASM.js
+[test_audioWorklet_options.html]
+support-files=worklet_audioWorklet_options.js
 [test_promise.html]
 support-files=worklet_promise.js
new file mode 100644
--- /dev/null
+++ b/dom/worklet/tests/test_audioWorklet_options.html
@@ -0,0 +1,72 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Test for AudioWorklet + Options + WASM</title>
+  <script src="/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+  <script type="application/javascript" src="common.js"></script>
+</head>
+<body>
+<script type="application/javascript">
+
+function configureTest() {
+  return SpecialPowers.pushPrefEnv(
+    {"set": [["dom.audioworklet.enabled", true],
+             ["dom.worklet.enabled", true],
+             ["dom.postMessage.sharedArrayBuffer.bypassCOOP_COEP.insecure.enabled", true],
+             ["browser.tabs.remote.useCrossOriginOpenerPolicy", true],
+             ["browser.tabs.remote.useCrossOriginEmbedderPolicy", true],
+             ["dom.postMessage.sharedArrayBuffer.withCOOP_COEP", true],
+             ["javascript.options.shared_memory", true],
+    ]});
+}
+
+function create_wasmModule() {
+  return new Promise(resolve => {
+    info("Checking if we can play with WebAssembly...");
+
+    if (!SpecialPowers.Cu.getJSTestingFunctions().wasmIsSupported()) {
+      resolve(null);
+      return;
+    }
+
+    ok(WebAssembly, "WebAssembly object should exist");
+    ok(WebAssembly.compile, "WebAssembly.compile function should exist");
+
+    const wasmTextToBinary = SpecialPowers.unwrap(SpecialPowers.Cu.getJSTestingFunctions().wasmTextToBinary);
+    const fooModuleCode = wasmTextToBinary(`(module
+      (func $foo (result i32) (i32.const 42))
+      (export "foo" $foo)
+    )`);
+
+    WebAssembly.compile(fooModuleCode).then(m => {
+      ok(m instanceof WebAssembly.Module, "The WasmModule has been compiled.");
+      resolve(m);
+    }, () => {
+      ok(false, "The compilation of the wasmModule failed.");
+      resolve(null);
+    });
+  });
+}
+
+function runTestInIframe() {
+  let audioContext = new AudioContext();
+  audioContext.audioWorklet.addModule("worklet_audioWorklet_options.js")
+  .then(() => create_wasmModule())
+  .then(wasmModule => {
+    const node = new AudioWorkletNode(audioContext, 'options', { processorOptions: {
+      wasmModule, sab: new SharedArrayBuffer(1024),
+    }});
+    node.port.onmessage = e => {
+      ok(e.data.wasmModule instanceof WebAssembly.Module, "WasmModule received");
+      ok(e.data.sab instanceof SharedArrayBuffer, "SAB received");
+      SimpleTest.finish();
+    }
+
+    node.connect(audioContext.destination);
+  });
+}
+
+</script>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/dom/worklet/tests/worklet_audioWorklet_options.js
@@ -0,0 +1,12 @@
+class OptionsProcessWorkletProcessor extends AudioWorkletProcessor {
+  constructor(...args) {
+    super(...args);
+    this.port.postMessage(args[0].processorOptions);
+  }
+
+  process(inputs, outputs, parameters) {
+    return true;
+  }
+}
+
+registerProcessor("options", OptionsProcessWorkletProcessor);
--- a/gfx/gl/SharedSurfaceDMABUF.cpp
+++ b/gfx/gl/SharedSurfaceDMABUF.cpp
@@ -15,18 +15,19 @@ UniquePtr<SharedSurface_DMABUF> SharedSu
     bool hasAlpha) {
   auto flags = static_cast<WaylandDMABufSurfaceFlags>(DMABUF_TEXTURE |
                                                       DMABUF_USE_MODIFIERS);
   if (hasAlpha) {
     flags = static_cast<WaylandDMABufSurfaceFlags>(flags | DMABUF_ALPHA);
   }
 
   RefPtr<WaylandDMABufSurface> surface =
-      WaylandDMABufSurface::CreateDMABufSurface(size.width, size.height, flags);
-  if (!surface || !surface->CreateEGLImage(prodGL)) {
+      WaylandDMABufSurfaceRGBA::CreateDMABufSurface(size.width, size.height,
+                                                    flags);
+  if (!surface || !surface->CreateTexture(prodGL)) {
     return nullptr;
   }
 
   UniquePtr<SharedSurface_DMABUF> ret;
   ret.reset(new SharedSurface_DMABUF(prodGL, size, hasAlpha, surface));
   return ret;
 }
 
@@ -36,17 +37,17 @@ SharedSurface_DMABUF::SharedSurface_DMAB
     : SharedSurface(SharedSurfaceType::EGLSurfaceDMABUF,
                     AttachmentType::GLTexture, gl, size, hasAlpha, true),
       mSurface(aSurface) {}
 
 SharedSurface_DMABUF::~SharedSurface_DMABUF() {
   if (!mGL || !mGL->MakeCurrent()) {
     return;
   }
-  mSurface->ReleaseEGLImage();
+  mSurface->ReleaseTextures();
 }
 
 void SharedSurface_DMABUF::ProducerReleaseImpl() {
   mGL->MakeCurrent();
   // We don't have a better sync mechanism here so use glFinish() at least.
   mGL->fFinish();
 }
 
--- a/gfx/gl/SharedSurfaceDMABUF.h
+++ b/gfx/gl/SharedSurfaceDMABUF.h
@@ -46,17 +46,17 @@ class SharedSurface_DMABUF final : publi
   // Non-exclusive Content/WebGL lock/unlock of surface for write
   virtual void ProducerAcquireImpl() override {}
   virtual void ProducerReleaseImpl() override;
 
   // Non-exclusive Content/WebGL lock/unlock for read from surface
   virtual void ProducerReadAcquireImpl() override {}
   virtual void ProducerReadReleaseImpl() override {}
 
-  virtual GLuint ProdTexture() override { return mSurface->GetGLTexture(); }
+  virtual GLuint ProdTexture() override { return mSurface->GetTexture(); }
 
   virtual bool ToSurfaceDescriptor(
       layers::SurfaceDescriptor* const out_descriptor) override;
 };
 
 class SurfaceFactory_DMABUF : public SurfaceFactory {
  public:
   SurfaceFactory_DMABUF(GLContext* prodGL, const SurfaceCaps& caps,
--- a/gfx/layers/ipc/LayersSurfaces.ipdlh
+++ b/gfx/layers/ipc/LayersSurfaces.ipdlh
@@ -55,25 +55,26 @@ struct SurfaceDescriptorDXGIYCbCr {
 struct SurfaceDescriptorMacIOSurface {
   uint32_t surfaceId;
   double scaleFactor;
   bool isOpaque;
   YUVColorSpace yUVColorSpace;
 };
 
 struct SurfaceDescriptorDMABuf {
-  uint32_t width;
-  uint32_t height;
-  uint32_t format;
+  uint32_t bufferType;
   uint64_t modifier;
   uint32_t flags;
-  uint32_t numFds;
   FileDescriptor[] fds;
+  uint32_t[] width;
+  uint32_t[] height;
+  uint32_t[] format;
   uint32_t[] strides;
   uint32_t[] offsets;
+  YUVColorSpace yUVColorSpace;
 };
 
 struct SurfaceTextureDescriptor {
   uint64_t handle;
   IntSize size;
   SurfaceFormat format;
   bool continuous;
   bool ignoreTransform;
@@ -93,16 +94,17 @@ struct SurfaceDescriptorSharedGLTexture 
   IntSize size;
   bool hasAlpha;
 };
 
 
 union RemoteDecoderVideoSubDescriptor {
   SurfaceDescriptorD3D10;
   SurfaceDescriptorDXGIYCbCr;
+  SurfaceDescriptorDMABuf;
   null_t;
 };
 
 struct SurfaceDescriptorRemoteDecoder {
   uint64_t handle;
   RemoteDecoderVideoSubDescriptor subdesc;
   MaybeVideoBridgeSource source;
 };
--- a/gfx/layers/opengl/WaylandDMABUFTextureClientOGL.cpp
+++ b/gfx/layers/opengl/WaylandDMABUFTextureClientOGL.cpp
@@ -28,18 +28,19 @@ WaylandDMABUFTextureData::~WaylandDMABUF
     NS_WARNING("WaylandDMABUFTextureData::Create() - wrong surface format!");
     return nullptr;
   }
 
   int flags = DMABUF_TEXTURE;
   if (aFormat == SurfaceFormat::B8G8R8A8) {
     flags |= DMABUF_ALPHA;
   }
-  RefPtr<WaylandDMABufSurface> surf = WaylandDMABufSurface::CreateDMABufSurface(
-      aSize.width, aSize.height, flags);
+  RefPtr<WaylandDMABufSurface> surf =
+      WaylandDMABufSurfaceRGBA::CreateDMABufSurface(aSize.width, aSize.height,
+                                                    flags);
   return new WaylandDMABUFTextureData(surf, aBackend);
 }
 
 TextureData* WaylandDMABUFTextureData::CreateSimilar(
     LayersIPCChannel* aAllocator, LayersBackend aLayersBackend,
     TextureFlags aFlags, TextureAllocationFlags aAllocFlags) const {
   return WaylandDMABUFTextureData::Create(
       gfx::IntSize(mSurface->GetWidth(), mSurface->GetHeight()),
@@ -56,38 +57,44 @@ void WaylandDMABUFTextureData::FillInfo(
   aInfo.format = mSurface->GetFormat();
   aInfo.hasIntermediateBuffer = false;
   aInfo.hasSynchronization = false;
   aInfo.supportsMoz2D = true;
   aInfo.canExposeMappedData = false;
 }
 
 bool WaylandDMABUFTextureData::Lock(OpenMode) {
-  MOZ_ASSERT(!mSurface->IsMapped(), "Already locked?");
-  mSurface->Map();
+  auto surf = mSurface->GetAsWaylandDMABufSurfaceRGBA();
+  MOZ_ASSERT(!surf->IsMapped(), "Already locked?");
+  surf->Map();
   return true;
 }
 
-void WaylandDMABUFTextureData::Unlock() { mSurface->Unmap(); }
+void WaylandDMABUFTextureData::Unlock() {
+  auto surf = mSurface->GetAsWaylandDMABufSurfaceRGBA();
+  MOZ_ASSERT(surf->IsMapped(), "Already unlocked?");
+  surf->Unmap();
+}
 
 already_AddRefed<DataSourceSurface> WaylandDMABUFTextureData::GetAsSurface() {
   // TODO: Update for debug purposes.
   return nullptr;
 }
 
 already_AddRefed<DrawTarget> WaylandDMABUFTextureData::BorrowDrawTarget() {
   MOZ_ASSERT(mBackend != BackendType::NONE);
   if (mBackend == BackendType::NONE) {
     // shouldn't happen, but degrade gracefully
     return nullptr;
   }
+  auto surf = mSurface->GetAsWaylandDMABufSurfaceRGBA();
   return Factory::CreateDrawTargetForData(
-      mBackend, (unsigned char*)mSurface->GetMappedRegion(),
-      IntSize(mSurface->GetWidth(), mSurface->GetHeight()),
-      mSurface->GetMappedRegionStride(), mSurface->GetFormat(), true);
+      mBackend, (unsigned char*)surf->GetMappedRegion(),
+      IntSize(surf->GetWidth(), surf->GetHeight()),
+      surf->GetMappedRegionStride(), surf->GetFormat(), true);
 }
 
 void WaylandDMABUFTextureData::Deallocate(LayersIPCChannel*) {
   mSurface = nullptr;
 }
 
 void WaylandDMABUFTextureData::Forget(LayersIPCChannel*) { mSurface = nullptr; }
 
--- a/gfx/layers/opengl/WaylandDMABUFTextureHostOGL.cpp
+++ b/gfx/layers/opengl/WaylandDMABUFTextureHostOGL.cpp
@@ -21,28 +21,48 @@ WaylandDMABUFTextureHostOGL::WaylandDMAB
   mSurface = WaylandDMABufSurface::CreateDMABufSurface(
       aDesc.get_SurfaceDescriptorDMABuf());
 }
 
 WaylandDMABUFTextureHostOGL::~WaylandDMABUFTextureHostOGL() {
   MOZ_COUNT_DTOR(WaylandDMABUFTextureHostOGL);
 }
 
+GLTextureSource* WaylandDMABUFTextureHostOGL::CreateTextureSourceForPlane(
+    size_t aPlane) {
+  MOZ_ASSERT(mSurface);
+
+  if (!mSurface->GetTexture(aPlane)) {
+    if (!mSurface->CreateTexture(gl(), aPlane)) {
+      return nullptr;
+    }
+  }
+
+  return new GLTextureSource(
+      mProvider, mSurface->GetTexture(aPlane), LOCAL_GL_TEXTURE_2D,
+      gfx::IntSize(mSurface->GetWidth(aPlane), mSurface->GetHeight(aPlane)),
+      // XXX: This isn't really correct (but isn't used), we should be using the
+      // format of the individual plane, not of the whole buffer.
+      mSurface->GetFormat());
+}
+
 bool WaylandDMABUFTextureHostOGL::Lock() {
   if (!gl() || !gl()->MakeCurrent() || !mSurface) {
     return false;
   }
 
-  if (!mTextureSource && mSurface->CreateEGLImage(gl())) {
-    auto format = mSurface->HasAlpha() ? gfx::SurfaceFormat::R8G8B8A8
-                                       : gfx::SurfaceFormat::R8G8B8X8;
-    mTextureSource = new EGLImageTextureSource(
-        mProvider, mSurface->GetEGLImage(), format, LOCAL_GL_TEXTURE_2D,
-        LOCAL_GL_CLAMP_TO_EDGE,
-        gfx::IntSize(mSurface->GetWidth(), mSurface->GetHeight()));
+  if (!mTextureSource) {
+    mTextureSource = CreateTextureSourceForPlane(0);
+
+    RefPtr<TextureSource> prev = mTextureSource;
+    for (size_t i = 1; i < mSurface->GetTextureCount(); i++) {
+      RefPtr<TextureSource> next = CreateTextureSourceForPlane(i);
+      prev->SetNextSibling(next);
+      prev = next;
+    }
   }
   return true;
 }
 
 void WaylandDMABUFTextureHostOGL::Unlock() {}
 
 void WaylandDMABUFTextureHostOGL::SetTextureSourceProvider(
     TextureSourceProvider* aProvider) {
@@ -55,18 +75,39 @@ void WaylandDMABUFTextureHostOGL::SetTex
   mProvider = aProvider;
 
   if (mTextureSource) {
     mTextureSource->SetTextureSourceProvider(aProvider);
   }
 }
 
 gfx::SurfaceFormat WaylandDMABUFTextureHostOGL::GetFormat() const {
-  return mTextureSource ? mTextureSource->GetFormat()
-                        : gfx::SurfaceFormat::UNKNOWN;
+  if (!mSurface) {
+    return gfx::SurfaceFormat::UNKNOWN;
+  }
+  return mSurface->GetFormat();
+}
+
+gfx::YUVColorSpace WaylandDMABUFTextureHostOGL::GetYUVColorSpace() const {
+  if (!mSurface) {
+    return gfx::YUVColorSpace::UNKNOWN;
+  }
+  return mSurface->GetYUVColorSpace();
+}
+
+gfx::ColorRange WaylandDMABUFTextureHostOGL::GetColorRange() const {
+  if (!mSurface) {
+    return gfx::ColorRange::LIMITED;
+  }
+  return mSurface->IsFullRange() ? gfx::ColorRange::FULL
+                                 : gfx::ColorRange::LIMITED;
+}
+
+uint32_t WaylandDMABUFTextureHostOGL::NumSubTextures() {
+  return mSurface->GetTextureCount();
 }
 
 gfx::IntSize WaylandDMABUFTextureHostOGL::GetSize() const {
   if (!mSurface) {
     return gfx::IntSize();
   }
   return gfx::IntSize(mSurface->GetWidth(), mSurface->GetHeight());
 }
@@ -90,33 +131,72 @@ void WaylandDMABUFTextureHostOGL::PushRe
   MOZ_ASSERT(mSurface);
 
   auto method = aOp == TextureHost::ADD_IMAGE
                     ? &wr::TransactionBuilder::AddExternalImage
                     : &wr::TransactionBuilder::UpdateExternalImage;
   auto imageType =
       wr::ExternalImageType::TextureHandle(wr::TextureTarget::Default);
 
-  gfx::SurfaceFormat format = mSurface->HasAlpha()
-                                  ? gfx::SurfaceFormat::R8G8B8A8
-                                  : gfx::SurfaceFormat::R8G8B8X8;
-
-  MOZ_ASSERT(aImageKeys.length() == 1);
-  // XXX Add RGBA handling. Temporary hack to avoid crash
-  // With BGRA format setting, rendering works without problem.
-  auto formatTmp = format == gfx::SurfaceFormat::R8G8B8A8
-                       ? gfx::SurfaceFormat::B8G8R8A8
-                       : gfx::SurfaceFormat::B8G8R8X8;
-  wr::ImageDescriptor descriptor(GetSize(), formatTmp,
-                                 aPreferCompositorSurface);
-  (aResources.*method)(aImageKeys[0], descriptor, aExtID, imageType, 0);
+  switch (mSurface->GetFormat()) {
+    case gfx::SurfaceFormat::R8G8B8X8:
+    case gfx::SurfaceFormat::R8G8B8A8:
+    case gfx::SurfaceFormat::B8G8R8X8:
+    case gfx::SurfaceFormat::B8G8R8A8: {
+      MOZ_ASSERT(aImageKeys.length() == 1);
+      // XXX Add RGBA handling. Temporary hack to avoid crash
+      // With BGRA format setting, rendering works without problem.
+      wr::ImageDescriptor descriptor(GetSize(), mSurface->GetFormat(),
+                                     aPreferCompositorSurface);
+      (aResources.*method)(aImageKeys[0], descriptor, aExtID, imageType, 0);
+      break;
+    }
+    case gfx::SurfaceFormat::NV12: {
+      MOZ_ASSERT(aImageKeys.length() == 2);
+      MOZ_ASSERT(mSurface->GetTextureCount() == 2);
+      wr::ImageDescriptor descriptor0(
+          gfx::IntSize(mSurface->GetWidth(0), mSurface->GetHeight(0)),
+          gfx::SurfaceFormat::A8, aPreferCompositorSurface);
+      wr::ImageDescriptor descriptor1(
+          gfx::IntSize(mSurface->GetWidth(1), mSurface->GetHeight(1)),
+          gfx::SurfaceFormat::R8G8, aPreferCompositorSurface);
+      (aResources.*method)(aImageKeys[0], descriptor0, aExtID, imageType, 0);
+      (aResources.*method)(aImageKeys[1], descriptor1, aExtID, imageType, 1);
+      break;
+    }
+    default: {
+      MOZ_ASSERT_UNREACHABLE("unexpected to be called");
+    }
+  }
 }
 
 void WaylandDMABUFTextureHostOGL::PushDisplayItems(
     wr::DisplayListBuilder& aBuilder, const wr::LayoutRect& aBounds,
     const wr::LayoutRect& aClip, wr::ImageRendering aFilter,
     const Range<wr::ImageKey>& aImageKeys) {
-  MOZ_ASSERT(aImageKeys.length() == 1);
-  aBuilder.PushImage(aBounds, aClip, true, aFilter, aImageKeys[0],
-                     !(mFlags & TextureFlags::NON_PREMULTIPLIED));
+  switch (mSurface->GetFormat()) {
+    case gfx::SurfaceFormat::R8G8B8X8:
+    case gfx::SurfaceFormat::R8G8B8A8:
+    case gfx::SurfaceFormat::B8G8R8A8:
+    case gfx::SurfaceFormat::B8G8R8X8: {
+      MOZ_ASSERT(aImageKeys.length() == 1);
+      aBuilder.PushImage(aBounds, aClip, true, aFilter, aImageKeys[0],
+                         !(mFlags & TextureFlags::NON_PREMULTIPLIED));
+      break;
+    }
+    case gfx::SurfaceFormat::NV12: {
+      MOZ_ASSERT(aImageKeys.length() == 2);
+      MOZ_ASSERT(mSurface->GetTextureCount() == 2);
+      // Those images can only be generated at present by the VAAPI H264 decoder
+      // which only supports 8 bits color depth.
+      aBuilder.PushNV12Image(aBounds, aClip, true, aImageKeys[0], aImageKeys[1],
+                             wr::ColorDepth::Color8,
+                             wr::ToWrYuvColorSpace(GetYUVColorSpace()),
+                             wr::ToWrColorRange(GetColorRange()), aFilter);
+      break;
+    }
+    default: {
+      MOZ_ASSERT_UNREACHABLE("unexpected to be called");
+    }
+  }
 }
 
 }  // namespace mozilla::layers
--- a/gfx/layers/opengl/WaylandDMABUFTextureHostOGL.h
+++ b/gfx/layers/opengl/WaylandDMABUFTextureHostOGL.h
@@ -44,32 +44,39 @@ class WaylandDMABUFTextureHostOGL : publ
 
   gl::GLContext* gl() const;
 
   gfx::IntSize GetSize() const override;
 
 #ifdef MOZ_LAYERS_HAVE_LOG
   const char* Name() override { return "WaylandDMABUFTextureHostOGL"; }
 #endif
+  uint32_t NumSubTextures() override;
+
+  gfx::YUVColorSpace GetYUVColorSpace() const override;
+  gfx::ColorRange GetColorRange() const override;
 
   void CreateRenderTexture(
       const wr::ExternalImageId& aExternalImageId) override;
 
   void PushResourceUpdates(wr::TransactionBuilder& aResources,
                            ResourceUpdateOp aOp,
                            const Range<wr::ImageKey>& aImageKeys,
                            const wr::ExternalImageId& aExtID,
                            const bool aPreferCompositorSurface) override;
 
   void PushDisplayItems(wr::DisplayListBuilder& aBuilder,
                         const wr::LayoutRect& aBounds,
                         const wr::LayoutRect& aClip, wr::ImageRendering aFilter,
                         const Range<wr::ImageKey>& aImageKeys) override;
 
+ private:
+  GLTextureSource* CreateTextureSourceForPlane(size_t aPlane);
+
  protected:
-  RefPtr<EGLImageTextureSource> mTextureSource;
+  RefPtr<GLTextureSource> mTextureSource;
   RefPtr<WaylandDMABufSurface> mSurface;
 };
 
 }  // namespace layers
 }  // namespace mozilla
 
 #endif  // MOZILLA_GFX_WAYLANDDMABUFTEXTUREHOSTOGL_H
--- a/gfx/thebes/gfxFT2FontList.cpp
+++ b/gfx/thebes/gfxFT2FontList.cpp
@@ -28,16 +28,17 @@
 #include "cairo-ft.h"
 
 #include "gfxFT2FontList.h"
 #include "gfxFT2Fonts.h"
 #include "gfxFT2Utils.h"
 #include "gfxUserFontSet.h"
 #include "gfxFontUtils.h"
 #include "SharedFontList-impl.h"
+#include "harfbuzz/hb-ot.h"  // for name ID constants
 
 #include "nsServiceManagerUtils.h"
 #include "nsIObserverService.h"
 #include "nsTArray.h"
 #include "nsUnicharUtils.h"
 #include "nsCRT.h"
 
 #include "nsDirectoryServiceUtils.h"
@@ -221,17 +222,17 @@ FT2FontEntry* FT2FontEntry::CreateFontEn
   RefPtr<FTUserFontData> ufd = new FTUserFontData(aFontData, aLength);
   RefPtr<SharedFTFace> face = ufd->CloneFace();
   if (!face) {
     return nullptr;
   }
   // Create our FT2FontEntry, which inherits the name of the userfont entry
   // as it's not guaranteed that the face has valid names (bug 737315)
   FT2FontEntry* fe =
-      FT2FontEntry::CreateFontEntry(face->GetFace(), nullptr, 0, aFontName);
+      FT2FontEntry::CreateFontEntry(aFontName, nullptr, 0, nullptr);
   if (fe) {
     fe->mFTFace = face;
     fe->mStyleRange = aStyle;
     fe->mWeightRange = aWeight;
     fe->mStretchRange = aStretch;
     fe->mIsDataUserFont = true;
   }
   return fe;
@@ -243,86 +244,90 @@ FT2FontEntry* FT2FontEntry::CreateFontEn
   fe->mFilename = aFLE.filepath();
   fe->mFTFontIndex = aFLE.index();
   fe->mWeightRange = WeightRange::FromScalar(aFLE.weightRange());
   fe->mStretchRange = StretchRange::FromScalar(aFLE.stretchRange());
   fe->mStyleRange = SlantStyleRange::FromScalar(aFLE.styleRange());
   return fe;
 }
 
-// Helpers to extract font entry properties from an FT_Face
-static bool FTFaceIsItalic(FT_Face aFace) {
-  return !!(aFace->style_flags & FT_STYLE_FLAG_ITALIC);
-}
+// Extract font entry properties from an hb_face_t
+static void SetPropertiesFromFace(gfxFontEntry* aFontEntry,
+                                  const hb_face_t* aFace) {
+  // OS2 width class to CSS 'stretch' mapping from
+  // https://docs.microsoft.com/en-gb/typography/opentype/spec/os2#uswidthclass
+  const float kOS2WidthToStretch[] = {
+      100,    // (invalid, treat as normal)
+      50,     // Ultra-condensed
+      62.5,   // Extra-condensed
+      75,     // Condensed
+      87.5,   // Semi-condensed
+      100,    // Normal
+      112.5,  // Semi-expanded
+      125,    // Expanded
+      150,    // Extra-expanded
+      200     // Ultra-expanded
+  };
 
-static FontWeight FTFaceGetWeight(FT_Face aFace) {
-  TT_OS2* os2 = static_cast<TT_OS2*>(FT_Get_Sfnt_Table(aFace, ft_sfnt_os2));
-  uint16_t os2weight = 0;
-  if (os2 && os2->version != 0xffff) {
-    // Technically, only 100 to 900 are valid, but some fonts
-    // have this set wrong -- e.g. "Microsoft Logo Bold Italic" has
-    // it set to 6 instead of 600.  We try to be nice and handle that
-    // as well.
-    if (os2->usWeightClass >= 100 && os2->usWeightClass <= 900) {
-      os2weight = os2->usWeightClass;
-    } else if (os2->usWeightClass >= 1 && os2->usWeightClass <= 9) {
-      os2weight = os2->usWeightClass * 100;
+  // Get the macStyle field from the 'head' table
+  hb_blob_t* blob = hb_face_reference_table(aFace, HB_TAG('h', 'e', 'a', 'd'));
+  unsigned int len;
+  const char* data = hb_blob_get_data(blob, &len);
+  uint16_t style = 0;
+  if (len >= sizeof(HeadTable)) {
+    const HeadTable* head = reinterpret_cast<const HeadTable*>(data);
+    style = head->macStyle;
+  }
+  hb_blob_destroy(blob);
+
+  // Get the OS/2 table for weight & width fields
+  blob = hb_face_reference_table(aFace, HB_TAG('O', 'S', '/', '2'));
+  data = hb_blob_get_data(blob, &len);
+  uint16_t os2weight = 400;
+  float stretch = 100.0;
+  if (len >= offsetof(OS2Table, fsType)) {
+    const OS2Table* os2 = reinterpret_cast<const OS2Table*>(data);
+    os2weight = os2->usWeightClass;
+    uint16_t os2width = os2->usWidthClass;
+    if (os2width < ArrayLength(kOS2WidthToStretch)) {
+      stretch = kOS2WidthToStretch[os2width];
     }
   }
+  hb_blob_destroy(blob);
 
-  uint16_t result;
-  if (os2weight != 0) {
-    result = os2weight;
-  } else if (aFace->style_flags & FT_STYLE_FLAG_BOLD) {
-    result = 700;
-  } else {
-    result = 400;
-  }
-
-  NS_ASSERTION(result >= 100 && result <= 900, "Invalid weight in font!");
-
-  return FontWeight(int(result));
+  aFontEntry->mStyleRange = SlantStyleRange(
+      (style & 2) ? FontSlantStyle::Italic() : FontSlantStyle::Normal());
+  aFontEntry->mWeightRange = WeightRange(FontWeight(int(os2weight)));
+  aFontEntry->mStretchRange = StretchRange(FontStretch(stretch));
 }
 
 // Used to create the font entry for installed faces on the device,
 // when iterating over the fonts directories.
-// We use the FT_Face to retrieve the details needed for the font entry,
-// but unless we have been passed font data (i.e. for a user font),
-// we do *not* save a reference to it, nor create a cairo face,
-// as we don't want to keep a freetype face for every installed font
-// permanently in memory.
+// We use the hb_face_t to retrieve the details needed for the font entry,
+// but do *not* save a reference to it, nor create a cairo face.
 /* static */
-FT2FontEntry* FT2FontEntry::CreateFontEntry(FT_Face aFace,
+FT2FontEntry* FT2FontEntry::CreateFontEntry(const nsACString& aName,
                                             const char* aFilename,
                                             uint8_t aIndex,
-                                            const nsACString& aName) {
+                                            const hb_face_t* aFace) {
   FT2FontEntry* fe = new FT2FontEntry(aName);
-  fe->mStyleRange =
-      SlantStyleRange(FTFaceIsItalic(aFace) ? FontSlantStyle::Italic()
-                                            : FontSlantStyle::Normal());
-  fe->mWeightRange = WeightRange(FTFaceGetWeight(aFace));
   fe->mFilename = aFilename;
   fe->mFTFontIndex = aIndex;
 
-  return fe;
-}
+  if (aFace) {
+    SetPropertiesFromFace(fe, aFace);
+  } else {
+    // If nullptr is passed for aFace, the caller is intending to override
+    // these attributes anyway. We just set defaults here to be safe.
+    fe->mStyleRange = SlantStyleRange(FontSlantStyle::Normal());
+    fe->mWeightRange = WeightRange(FontWeight::Normal());
+    fe->mStretchRange = StretchRange(FontStretch::Normal());
+  }
 
-// construct font entry name for an installed font from names in the FT_Face,
-// and then create our FT2FontEntry
-static FT2FontEntry* CreateNamedFontEntry(FT_Face aFace, const char* aFilename,
-                                          uint8_t aIndex) {
-  if (!aFace->family_name) {
-    return nullptr;
-  }
-  nsAutoCString fontName(aFace->family_name);
-  if (aFace->style_name && strcmp("Regular", aFace->style_name)) {
-    fontName.Append(' ');
-    fontName.Append(aFace->style_name);
-  }
-  return FT2FontEntry::CreateFontEntry(aFace, aFilename, aIndex, fontName);
+  return fe;
 }
 
 FT2FontEntry* gfxFT2Font::GetFontEntry() {
   return static_cast<FT2FontEntry*>(mFontEntry.get());
 }
 
 // Copied/modified from similar code in gfxMacPlatformFontList.mm:
 // Complex scripts will not render correctly unless Graphite or OT
@@ -480,27 +485,26 @@ hb_blob_t* FT2FontEntry::GetFontTable(ui
   }
 
   // Otherwise, use the default method (which in turn will call our
   // implementation of CopyFontTable).
   return gfxFontEntry::GetFontTable(aTableTag);
 }
 
 bool FT2FontEntry::HasVariations() {
-  if (mHasVariationsInitialized) {
-    return mHasVariations;
+  if (!mHasVariationsInitialized) {
+    mHasVariationsInitialized = true;
+    if (mFTFace) {
+      mHasVariations =
+          mFTFace->GetFace()->face_flags & FT_FACE_FLAG_MULTIPLE_MASTERS;
+    } else {
+      mHasVariations = gfxPlatform::GetPlatform()->HasVariationFontSupport() &&
+                       HasFontTable(TRUETYPE_TAG('f', 'v', 'a', 'r'));
+    }
   }
-  mHasVariationsInitialized = true;
-
-  RefPtr<SharedFTFace> face = GetFTFace();
-  if (face) {
-    mHasVariations =
-        face->GetFace()->face_flags & FT_FACE_FLAG_MULTIPLE_MASTERS;
-  }
-
   return mHasVariations;
 }
 
 void FT2FontEntry::GetVariationAxes(nsTArray<gfxFontVariationAxis>& aAxes) {
   if (!HasVariations()) {
     return;
   }
   FT_MM_Var* mmVar = GetMMVar();
@@ -1052,16 +1056,42 @@ void FT2FontEntry::CheckForBrokenFont(co
           FT_Get_Sfnt_Table(face->GetFace(), ft_sfnt_head));
       if (head && head->CheckSum_Adjust == 0xe445242) {
         mIgnoreGSUB = true;
       }
     }
   }
 }
 
+void gfxFT2FontList::AppendFacesFromBlob(
+    const nsCString& aFileName, StandardFile aStdFile, hb_blob_t* aBlob,
+    FontNameCache* aCache, uint32_t aTimestamp, uint32_t aFilesize) {
+  nsCString newFaceList;
+  uint32_t numFaces = 1;
+  unsigned int length;
+  const char* data = hb_blob_get_data(aBlob, &length);
+  // Check for a possible TrueType Collection
+  if (length >= sizeof(TTCHeader)) {
+    const TTCHeader* ttc = reinterpret_cast<const TTCHeader*>(data);
+    if (ttc->ttcTag == TRUETYPE_TAG('t', 't', 'c', 'f')) {
+      numFaces = ttc->numFonts;
+    }
+  }
+  for (unsigned int index = 0; index < numFaces; index++) {
+    hb_face_t* face = hb_face_create(aBlob, index);
+    if (face != hb_face_get_empty()) {
+      AddFaceToList(aFileName, index, aStdFile, face, newFaceList);
+    }
+    hb_face_destroy(face);
+  }
+  if (aCache && !newFaceList.IsEmpty()) {
+    aCache->CacheFileInfo(aFileName, newFaceList, aTimestamp, aFilesize);
+  }
+}
+
 void gfxFT2FontList::AppendFacesFromFontFile(const nsCString& aFileName,
                                              FontNameCache* aCache,
                                              StandardFile aStdFile) {
   nsCString cachedFaceList;
   uint32_t filesize = 0, timestamp = 0;
   if (aCache) {
     aCache->GetInfoForFile(aFileName, cachedFaceList, &timestamp, &filesize);
   }
@@ -1081,35 +1111,24 @@ void gfxFT2FontList::AppendFacesFromFont
     };
     if (AppendFacesFromCachedFaceList(SharedFontList() ? shared : unshared,
                                       aFileName, cachedFaceList, aStdFile)) {
       LOG(("using cached font info for %s", aFileName.get()));
       return;
     }
   }
 
-  FT_Face dummy = Factory::NewFTFace(nullptr, aFileName.get(), -1);
-  if (dummy) {
-    LOG(("reading font info via FreeType for %s", aFileName.get()));
-    nsCString newFaceList;
-    timestamp = s.st_mtime;
-    filesize = s.st_size;
-    for (FT_Long i = 0; i < dummy->num_faces; i++) {
-      FT_Face face = Factory::NewFTFace(nullptr, aFileName.get(), i);
-      if (!face) {
-        continue;
-      }
-      AddFaceToList(aFileName, i, aStdFile, face, newFaceList);
-      Factory::ReleaseFTFace(face);
-    }
-    Factory::ReleaseFTFace(dummy);
-    if (aCache && 0 == statRetval && !newFaceList.IsEmpty()) {
-      aCache->CacheFileInfo(aFileName, newFaceList, timestamp, filesize);
-    }
+  hb_blob_t* fileBlob = hb_blob_create_from_file(aFileName.get());
+  if (hb_blob_get_length(fileBlob) > 0) {
+    LOG(("reading font info via harfbuzz for %s", aFileName.get()));
+    AppendFacesFromBlob(aFileName, aStdFile, fileBlob,
+                        0 == statRetval ? aCache : nullptr, s.st_mtime,
+                        s.st_size);
   }
+  hb_blob_destroy(fileBlob);
 }
 
 void gfxFT2FontList::FindFontsInOmnijar(FontNameCache* aCache) {
   bool jarChanged = false;
 
   mozilla::scache::StartupCache* cache =
       mozilla::scache::StartupCache::GetSingleton();
   const char* cachedModifiedTimeBuf;
@@ -1138,50 +1157,75 @@ void gfxFT2FontList::FindFontsInOmnijar(
         nsCString entryName(path, len);
         AppendFacesFromOmnijarEntry(reader, entryName, aCache, jarChanged);
       }
       delete find;
     }
   }
 }
 
-// Given the freetype face corresponding to an entryName and face index,
+static void GetName(hb_face_t* aFace, hb_ot_name_id_t aNameID,
+                    nsACString& aName) {
+  unsigned int n = 0;
+  n = hb_ot_name_get_utf8(aFace, aNameID, HB_LANGUAGE_INVALID, &n, nullptr);
+  if (n) {
+    aName.SetLength(n++);  // increment n to account for NUL terminator
+    n = hb_ot_name_get_utf8(aFace, aNameID, HB_LANGUAGE_INVALID, &n,
+                            aName.BeginWriting());
+  }
+}
+