Merge mozilla-central to inbound a=merge
authorCoroiu Cristina <ccoroiu@mozilla.com>
Thu, 29 Aug 2019 07:12:55 +0300
changeset 554323 d08cf6ecb122743ab3eb567c52f37df956721f92
parent 554322 a7fca0a737156b364cfab37ee0f9cb127514b989 (current diff)
parent 554263 7004b8779a36084c898634e5e31d8f406815d68a (diff)
child 554325 23824765c6aa026ccc3e3aea1c851c07ab8937ee
push id2165
push userffxbld-merge
push dateMon, 14 Oct 2019 16:30:58 +0000
treeherdermozilla-release@0eae18af659f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge mozilla-central to inbound a=merge
third_party/rust/bzip2-sys/.cargo-checksum.json
third_party/rust/bzip2-sys/Cargo.toml
third_party/rust/bzip2-sys/build.rs
third_party/rust/bzip2-sys/bzip2-1.0.6/CHANGES
third_party/rust/bzip2-sys/bzip2-1.0.6/LICENSE
third_party/rust/bzip2-sys/bzip2-1.0.6/Makefile
third_party/rust/bzip2-sys/bzip2-1.0.6/Makefile-libbz2_so
third_party/rust/bzip2-sys/bzip2-1.0.6/README
third_party/rust/bzip2-sys/bzip2-1.0.6/README.COMPILATION.PROBLEMS
third_party/rust/bzip2-sys/bzip2-1.0.6/README.XML.STUFF
third_party/rust/bzip2-sys/bzip2-1.0.6/blocksort.c
third_party/rust/bzip2-sys/bzip2-1.0.6/bz-common.xsl
third_party/rust/bzip2-sys/bzip2-1.0.6/bz-fo.xsl
third_party/rust/bzip2-sys/bzip2-1.0.6/bz-html.xsl
third_party/rust/bzip2-sys/bzip2-1.0.6/bzdiff
third_party/rust/bzip2-sys/bzip2-1.0.6/bzdiff.1
third_party/rust/bzip2-sys/bzip2-1.0.6/bzgrep
third_party/rust/bzip2-sys/bzip2-1.0.6/bzgrep.1
third_party/rust/bzip2-sys/bzip2-1.0.6/bzip.css
third_party/rust/bzip2-sys/bzip2-1.0.6/bzip2.1
third_party/rust/bzip2-sys/bzip2-1.0.6/bzip2.1.preformatted
third_party/rust/bzip2-sys/bzip2-1.0.6/bzip2.c
third_party/rust/bzip2-sys/bzip2-1.0.6/bzip2.txt
third_party/rust/bzip2-sys/bzip2-1.0.6/bzip2recover.c
third_party/rust/bzip2-sys/bzip2-1.0.6/bzlib.c
third_party/rust/bzip2-sys/bzip2-1.0.6/bzlib.h
third_party/rust/bzip2-sys/bzip2-1.0.6/bzlib_private.h
third_party/rust/bzip2-sys/bzip2-1.0.6/bzmore
third_party/rust/bzip2-sys/bzip2-1.0.6/bzmore.1
third_party/rust/bzip2-sys/bzip2-1.0.6/compress.c
third_party/rust/bzip2-sys/bzip2-1.0.6/crctable.c
third_party/rust/bzip2-sys/bzip2-1.0.6/decompress.c
third_party/rust/bzip2-sys/bzip2-1.0.6/dlltest.c
third_party/rust/bzip2-sys/bzip2-1.0.6/dlltest.dsp
third_party/rust/bzip2-sys/bzip2-1.0.6/entities.xml
third_party/rust/bzip2-sys/bzip2-1.0.6/format.pl
third_party/rust/bzip2-sys/bzip2-1.0.6/huffman.c
third_party/rust/bzip2-sys/bzip2-1.0.6/libbz2.def
third_party/rust/bzip2-sys/bzip2-1.0.6/libbz2.dsp
third_party/rust/bzip2-sys/bzip2-1.0.6/makefile.msc
third_party/rust/bzip2-sys/bzip2-1.0.6/manual.html
third_party/rust/bzip2-sys/bzip2-1.0.6/manual.ps
third_party/rust/bzip2-sys/bzip2-1.0.6/manual.xml
third_party/rust/bzip2-sys/bzip2-1.0.6/mk251.c
third_party/rust/bzip2-sys/bzip2-1.0.6/randtable.c
third_party/rust/bzip2-sys/bzip2-1.0.6/sample1.bz2
third_party/rust/bzip2-sys/bzip2-1.0.6/sample1.ref
third_party/rust/bzip2-sys/bzip2-1.0.6/sample2.bz2
third_party/rust/bzip2-sys/bzip2-1.0.6/sample2.ref
third_party/rust/bzip2-sys/bzip2-1.0.6/sample3.bz2
third_party/rust/bzip2-sys/bzip2-1.0.6/sample3.ref
third_party/rust/bzip2-sys/bzip2-1.0.6/spewG.c
third_party/rust/bzip2-sys/bzip2-1.0.6/unzcrash.c
third_party/rust/bzip2-sys/bzip2-1.0.6/words0
third_party/rust/bzip2-sys/bzip2-1.0.6/words1
third_party/rust/bzip2-sys/bzip2-1.0.6/words2
third_party/rust/bzip2-sys/bzip2-1.0.6/words3
third_party/rust/bzip2-sys/bzip2-1.0.6/xmlproc.sh
third_party/rust/bzip2-sys/lib.rs
third_party/rust/bzip2/.cargo-checksum.json
third_party/rust/bzip2/Cargo.toml
third_party/rust/bzip2/LICENSE-APACHE
third_party/rust/bzip2/LICENSE-MIT
third_party/rust/bzip2/README.md
third_party/rust/bzip2/appveyor.yml
third_party/rust/bzip2/src/bufread.rs
third_party/rust/bzip2/src/lib.rs
third_party/rust/bzip2/src/mem.rs
third_party/rust/bzip2/src/read.rs
third_party/rust/bzip2/src/write.rs
third_party/rust/bzip2/tests/tokio.rs
toolkit/modules/WebProgressChild.jsm
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -422,34 +422,16 @@ name = "bytes"
 version = "0.4.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 dependencies = [
  "byteorder 1.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "iovec 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
 [[package]]
-name = "bzip2"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "bzip2-sys 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)",
- "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "bzip2-sys"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "cc 1.0.34 (registry+https://github.com/rust-lang/crates.io-index)",
- "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
 name = "cc"
 version = "1.0.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 
 [[package]]
 name = "cert_storage"
 version = "0.0.1"
 dependencies = [
@@ -3851,17 +3833,16 @@ dependencies = [
  "linked-hash-map 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
 [[package]]
 name = "zip"
 version = "0.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 dependencies = [
- "bzip2 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
  "flate2 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "msdos_time 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)",
  "podio 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
  "time 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
 [metadata]
 "checksum Inflector 0.11.2 (registry+https://github.com/rust-lang/crates.io-index)" = "1b33cd9b653730fc539c53c7b3c672d2f47108fa20c6df571fa5817178f5a14c"
@@ -3893,18 +3874,16 @@ dependencies = [
 "checksum blake2-rfc 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)" = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400"
 "checksum block-buffer 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)" = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b"
 "checksum block-padding 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "4fc4358306e344bf9775d0197fd00d2603e5afb0771bb353538630f022068ea3"
 "checksum boxfnonce 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "8380105befe91099e6f69206164072c05bc92427ff6aa8a5171388317346dd75"
 "checksum build_const 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e90dc84f5e62d2ebe7676b83c22d33b6db8bd27340fb6ffbff0a364efa0cb9c9"
 "checksum byte-tools 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "980479e6fde23246dfb54d47580d66b4e99202e7579c5eaa9fe10ecb5ebd2182"
 "checksum byteorder 1.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a019b10a2a7cdeb292db131fc8113e57ea2a908f6e7894b0c3c671893b65dbeb"
 "checksum bytes 0.4.9 (registry+https://github.com/rust-lang/crates.io-index)" = "e178b8e0e239e844b083d5a0d4a156b2654e67f9f80144d48398fcd736a24fb8"
-"checksum bzip2 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "c3eafc42c44e0d827de6b1c131175098fe7fb53b8ce8a47e65cb3ea94688be24"
-"checksum bzip2-sys 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "2c5162604199bbb17690ede847eaa6120a3f33d5ab4dcc8e7c25b16d849ae79b"
 "checksum cc 1.0.34 (registry+https://github.com/rust-lang/crates.io-index)" = "30f813bf45048a18eda9190fd3c6b78644146056740c43172a5a3699118588fd"
 "checksum cexpr 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "8fc0086be9ca82f7fc89fc873435531cb898b86e850005850de1f820e2db6e9b"
 "checksum cfg-if 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "082bb9b28e00d3c9d39cc03e64ce4cea0f1bb9b3fde493f0cbc008472d22bdf4"
 "checksum chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)" = "45912881121cb26fad7c38c17ba7daa18764771836b34fab7d3fbd93ed633878"
 "checksum clang-sys 0.28.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4227269cec09f5f83ff160be12a1e9b0262dd1aa305302d5ba296c2ebd291055"
 "checksum clap 2.31.2 (registry+https://github.com/rust-lang/crates.io-index)" = "f0f16b89cbb9ee36d87483dc939fe9f1e13c05898d56d7b230a0d4dff033a536"
 "checksum cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f"
 "checksum cmake 0.1.29 (registry+https://github.com/rust-lang/crates.io-index)" = "56d741ea7a69e577f6d06b36b7dff4738f680593dc27a701ffa8506b73ce28bb"
--- a/accessible/generic/DocAccessible.cpp
+++ b/accessible/generic/DocAccessible.cpp
@@ -1996,16 +1996,30 @@ void DocAccessible::ContentRemoved(nsICo
     ContentRemoved(acc);
   }
 
   dom::AllChildrenIterator iter =
       dom::AllChildrenIterator(aContentNode, nsIContent::eAllChildren, true);
   while (nsIContent* childNode = iter.GetNextChild()) {
     ContentRemoved(childNode);
   }
+
+  // If this node has a shadow root, remove its explicit children too.
+  // The host node may be removed after the shadow root was attached, and
+  // before we asynchronously prune the light DOM and construct the shadow DOM.
+  // If this is a case where the node does not have its own accessible, we will
+  // not recurse into its current children, so we need to use an
+  // ExplicitChildIterator in order to get its accessible children in the light
+  // DOM, since they are not accessible anymore via AllChildrenIterator.
+  if (aContentNode->GetShadowRoot()) {
+    dom::ExplicitChildIterator iter = dom::ExplicitChildIterator(aContentNode);
+    while (nsIContent* childNode = iter.GetNextChild()) {
+      ContentRemoved(childNode);
+    }
+  }
 }
 
 bool DocAccessible::RelocateARIAOwnedIfNeeded(nsIContent* aElement) {
   if (!aElement->HasID()) return false;
 
   AttrRelProviders* list = GetRelProviders(
       aElement->AsElement(), nsDependentAtomString(aElement->GetID()));
   if (list) {
--- a/accessible/tests/mochitest/treeupdate/test_delayed_removal.html
+++ b/accessible/tests/mochitest/treeupdate/test_delayed_removal.html
@@ -178,16 +178,28 @@
       getNode("c8_owned_container").hidden = true;
       await events;
 
       testAccessibleTree("c8",{ SECTION: [
         { EDITCOMBOBOX: [] }, // c8_owner
       ] });
     }
 
+    // Bug 1572829
+    async function removeShadowRootHost() {
+      info("removeShadowRootHost");
+      document.body.offsetTop; // Flush layout.
+
+      let event = waitForEvent(EVENT_REORDER, "c9", "removeShadowRootHost");
+      getNode("c9").firstElementChild.attachShadow({mode: "open"});
+      getNode("c9").firstElementChild.replaceWith("");
+
+      await event;
+    }
+
     async function doTest() {
       await hideDivFromInsideSpan();
 
       await showDivFromInsideSpan();
 
       await removeDivFromInsideSpan();
 
       await addCSSGeneratedContent();
@@ -195,16 +207,18 @@
       await removeCSSGeneratedContent();
 
       await intermediateNonAccessibleContainers();
 
       await intermediateNonAccessibleContainerBecomesAccessible();
 
       await removeRelocatedWhenDomAncestorHidden();
 
+      await removeShadowRootHost();
+
       SimpleTest.finish();
     }
 
     SimpleTest.waitForExplicitFinish();
     addA11yLoadEvent(doTest);
   </script>
 </head>
 <body>
@@ -244,11 +258,15 @@
 
   <div id="c8">
     <div id="c8_owner" role="combobox" aria-owns="c8_owned"></div>
     <div id="c8_owned_container">
       <div id="c8_owned" role="listbox"></div>
     </div>
   </div>
 
+  <div id="c9">
+    <div><dir>a</dir></div>
+  </div>
+
   <div id="eventdump"></div>
 </body>
 </html>
--- a/browser/app/winlauncher/moz.build
+++ b/browser/app/winlauncher/moz.build
@@ -55,13 +55,14 @@ OS_LIBS += [
 
 TEST_DIRS += [
     'test',
 ]
 
 if CONFIG['MOZ_LAUNCHER_PROCESS']:
     UNIFIED_SOURCES += [
         '/toolkit/xre/LauncherRegistryInfo.cpp',
+        '/toolkit/xre/WinTokenUtils.cpp',
     ]
     for var in ('MOZ_APP_BASENAME', 'MOZ_APP_VENDOR'):
         DEFINES[var] = '"%s"' % CONFIG[var]
 
 DisableStlWrapping()
--- a/browser/base/content/browser-siteIdentity.js
+++ b/browser/base/content/browser-siteIdentity.js
@@ -933,24 +933,18 @@ var gIdentityHandler = {
    * based on the specified mode, and the details of the SSL cert, where
    * applicable
    */
   refreshIdentityPopup() {
     // Update cookies and site data information and show the
     // "Clear Site Data" button if the site is storing local data.
     this._clearSiteDataFooter.hidden = true;
     if (this._uriHasHost) {
-      let host = this._uri.host;
-      SiteDataManager.updateSites().then(async () => {
-        let baseDomain = SiteDataManager.getBaseDomainFromHost(host);
-        let siteData = await SiteDataManager.getSites(baseDomain);
-
-        if (siteData && siteData.length) {
-          this._clearSiteDataFooter.hidden = false;
-        }
+      SiteDataManager.hasSiteData(this._uri.asciiHost).then(hasData => {
+        this._clearSiteDataFooter.hidden = !hasData;
       });
     }
 
     // Update "Learn More" for Mixed Content Blocking and Insecure Login Forms.
     let baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
     this._identityPopupMixedContentLearnMore.forEach(e =>
       e.setAttribute("href", baseURL + "mixed-content")
     );
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -7960,48 +7960,23 @@ var OfflineApps = {
     // again.
     Services.perms.add(
       uri,
       "offline-app",
       Ci.nsIOfflineCacheUpdateService.ALLOW_NO_WARN
     );
   },
 
-  // XXX: duplicated in preferences/advanced.js
-  _getOfflineAppUsage(host, groups) {
-    let cacheService = Cc[
-      "@mozilla.org/network/application-cache-service;1"
-    ].getService(Ci.nsIApplicationCacheService);
-    if (!groups) {
-      try {
-        groups = cacheService.getGroups();
-      } catch (ex) {
-        return 0;
-      }
-    }
-
-    let usage = 0;
-    for (let group of groups) {
-      let uri = Services.io.newURI(group);
-      if (uri.asciiHost == host) {
-        let cache = cacheService.getActiveCache(group);
-        usage += cache.usage;
-      }
-    }
-
-    return usage;
-  },
-
   _usedMoreThanWarnQuota(uri) {
     // if the user has already allowed excessive usage, don't bother checking
     if (
       Services.perms.testExactPermission(uri, "offline-app") !=
       Ci.nsIOfflineCacheUpdateService.ALLOW_NO_WARN
     ) {
-      let usageBytes = this._getOfflineAppUsage(uri.asciiHost);
+      let usageBytes = SiteDataManager.getAppCacheUsageByHost(uri.asciiHost);
       let warnQuotaKB = Services.prefs.getIntPref("offline-apps.quota.warn");
       // The pref is in kb, the usage we get is in bytes, so multiply the quota
       // to compare correctly:
       if (usageBytes >= warnQuotaKB * 1024) {
         return true;
       }
     }
 
--- a/browser/base/content/test/performance/browser_startup_content.js
+++ b/browser/base/content/test/performance/browser_startup_content.js
@@ -44,17 +44,16 @@ const whitelist = {
     "resource:///actors/BrowserTabChild.jsm",
     "resource:///modules/ContentMetaHandler.jsm",
     "resource:///actors/LinkHandlerChild.jsm",
     "resource:///actors/SearchTelemetryChild.jsm",
     "resource://gre/modules/ActorChild.jsm",
     "resource://gre/modules/ActorManagerChild.jsm",
     "resource://gre/modules/E10SUtils.jsm",
     "resource://gre/modules/Readerable.jsm",
-    "resource://gre/modules/WebProgressChild.jsm",
 
     // Telemetry
     "resource://gre/modules/TelemetryController.jsm", // bug 1470339
     "resource://gre/modules/TelemetryUtils.jsm", // bug 1470339
 
     // Extensions
     "resource://gre/modules/ExtensionProcessScript.jsm",
     "resource://gre/modules/ExtensionUtils.jsm",
--- a/browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData.js
+++ b/browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData.js
@@ -1,75 +1,86 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const TEST_ORIGIN = "https://example.com";
 const TEST_SUB_ORIGIN = "https://test1.example.com";
 const REMOVE_DIALOG_URL =
   "chrome://browser/content/preferences/siteDataRemoveSelected.xul";
 
+// Greek IDN for 'example.test'.
+const TEST_IDN_ORIGIN =
+  "https://\u03C0\u03B1\u03C1\u03AC\u03B4\u03B5\u03B9\u03B3\u03BC\u03B1.\u03B4\u03BF\u03BA\u03B9\u03BC\u03AE";
+const TEST_PUNY_ORIGIN = "https://xn--hxajbheg2az3al.xn--jxalpdlp/";
+const TEST_PUNY_SUB_ORIGIN = "https://sub1.xn--hxajbheg2az3al.xn--jxalpdlp/";
+
 ChromeUtils.defineModuleGetter(
   this,
   "SiteDataTestUtils",
   "resource://testing-common/SiteDataTestUtils.jsm"
 );
 
 add_task(async function setup() {
   let oldCanRecord = Services.telemetry.canRecordExtended;
   Services.telemetry.canRecordExtended = true;
 
   registerCleanupFunction(() => {
     Services.telemetry.canRecordExtended = oldCanRecord;
   });
 });
 
-async function testClearing(testQuota, testCookies) {
+async function testClearing(
+  testQuota,
+  testCookies,
+  testURI,
+  origin,
+  subOrigin
+) {
   // Add some test quota storage.
   if (testQuota) {
-    await SiteDataTestUtils.addToIndexedDB(TEST_ORIGIN);
-    await SiteDataTestUtils.addToIndexedDB(TEST_SUB_ORIGIN);
+    await SiteDataTestUtils.addToIndexedDB(origin);
+    await SiteDataTestUtils.addToIndexedDB(subOrigin);
   }
 
   // Add some test cookies.
   if (testCookies) {
-    SiteDataTestUtils.addToCookies(TEST_ORIGIN, "test1", "1");
-    SiteDataTestUtils.addToCookies(TEST_ORIGIN, "test2", "2");
-    SiteDataTestUtils.addToCookies(TEST_SUB_ORIGIN, "test3", "1");
+    SiteDataTestUtils.addToCookies(origin, "test1", "1");
+    SiteDataTestUtils.addToCookies(origin, "test2", "2");
+    SiteDataTestUtils.addToCookies(subOrigin, "test3", "1");
   }
 
-  await BrowserTestUtils.withNewTab(TEST_ORIGIN, async function(browser) {
+  await BrowserTestUtils.withNewTab(testURI, async function(browser) {
     // Verify we have added quota storage.
     if (testQuota) {
-      let usage = await SiteDataTestUtils.getQuotaUsage(TEST_ORIGIN);
+      let usage = await SiteDataTestUtils.getQuotaUsage(origin);
       Assert.greater(usage, 0, "Should have data for the base origin.");
 
-      usage = await SiteDataTestUtils.getQuotaUsage(TEST_SUB_ORIGIN);
+      usage = await SiteDataTestUtils.getQuotaUsage(subOrigin);
       Assert.greater(usage, 0, "Should have data for the sub origin.");
     }
 
     // Open the identity popup.
     let { gIdentityHandler } = gBrowser.ownerGlobal;
     let promisePanelOpen = BrowserTestUtils.waitForEvent(
       gIdentityHandler._identityPopup,
       "popupshown"
     );
-    let siteDataUpdated = TestUtils.topicObserved(
-      "sitedatamanager:sites-updated"
-    );
     gIdentityHandler._identityBox.click();
     await promisePanelOpen;
-    await siteDataUpdated;
 
     let clearFooter = document.getElementById(
       "identity-popup-clear-sitedata-footer"
     );
     let clearButton = document.getElementById(
       "identity-popup-clear-sitedata-button"
     );
-    ok(!clearFooter.hidden, "The clear data footer is not hidden.");
+    TestUtils.waitForCondition(
+      () => !clearFooter.hidden,
+      "The clear data footer is not hidden."
+    );
 
     let cookiesCleared;
     if (testCookies) {
       cookiesCleared = Promise.all([
         TestUtils.topicObserved(
           "cookie-changed",
           (subj, data) => data == "deleted" && subj.name == "test1"
         ),
@@ -82,17 +93,19 @@ async function testClearing(testQuota, t
           (subj, data) => data == "deleted" && subj.name == "test3"
         ),
       ]);
     }
 
     Services.telemetry.clearEvents();
 
     // Click the "Clear data" button.
-    siteDataUpdated = TestUtils.topicObserved("sitedatamanager:sites-updated");
+    let siteDataUpdated = TestUtils.topicObserved(
+      "sitedatamanager:sites-updated"
+    );
     let hideEvent = BrowserTestUtils.waitForEvent(
       gIdentityHandler._identityPopup,
       "popuphidden"
     );
     let removeDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
       "accept",
       REMOVE_DIALOG_URL
     );
@@ -111,64 +124,77 @@ async function testClearing(testQuota, t
     );
     is(buttonEvents.length, 1, "recorded telemetry for the button click");
 
     await siteDataUpdated;
 
     // Check that cookies were deleted.
     if (testCookies) {
       await cookiesCleared;
-      let uri = Services.io.newURI(TEST_ORIGIN);
+      let uri = Services.io.newURI(origin);
       is(
         Services.cookies.countCookiesFromHost(uri.host),
         0,
         "Cookies from the base domain should be cleared"
       );
-      uri = Services.io.newURI(TEST_SUB_ORIGIN);
+      uri = Services.io.newURI(subOrigin);
       is(
         Services.cookies.countCookiesFromHost(uri.host),
         0,
         "Cookies from the sub domain should be cleared"
       );
     }
 
     // Check that quota storage was deleted.
     if (testQuota) {
       await TestUtils.waitForCondition(async () => {
-        let usage = await SiteDataTestUtils.getQuotaUsage(TEST_ORIGIN);
+        let usage = await SiteDataTestUtils.getQuotaUsage(origin);
         return usage == 0;
       }, "Should have no data for the base origin.");
 
-      let usage = await SiteDataTestUtils.getQuotaUsage(TEST_SUB_ORIGIN);
+      let usage = await SiteDataTestUtils.getQuotaUsage(subOrigin);
       is(usage, 0, "Should have no data for the sub origin.");
     }
 
     // Open the site identity panel again to check that the button isn't shown anymore.
     promisePanelOpen = BrowserTestUtils.waitForEvent(
       gIdentityHandler._identityPopup,
       "popupshown"
     );
-    siteDataUpdated = TestUtils.topicObserved("sitedatamanager:sites-updated");
     gIdentityHandler._identityBox.click();
     await promisePanelOpen;
-    await siteDataUpdated;
+
+    // Wait for a second to see if the button is shown.
+    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+    await new Promise(c => setTimeout(c, 1000));
 
     ok(
       clearFooter.hidden,
       "The clear data footer is hidden after clearing data."
     );
   });
 }
 
 // Test removing quota managed storage.
 add_task(async function test_ClearSiteData() {
-  await testClearing(true, false);
+  await testClearing(true, false, TEST_ORIGIN, TEST_ORIGIN, TEST_SUB_ORIGIN);
 });
 
 // Test removing cookies.
 add_task(async function test_ClearCookies() {
-  await testClearing(false, true);
+  await testClearing(false, true, TEST_ORIGIN, TEST_ORIGIN, TEST_SUB_ORIGIN);
 });
 
 // Test removing both.
 add_task(async function test_ClearCookiesAndSiteData() {
-  await testClearing(true, true);
+  await testClearing(true, true, TEST_ORIGIN, TEST_ORIGIN, TEST_SUB_ORIGIN);
 });
+
+// Test IDN Domains
+add_task(async function test_IDN_ClearCookiesAndSiteData() {
+  await testClearing(
+    true,
+    true,
+    TEST_IDN_ORIGIN,
+    TEST_PUNY_ORIGIN,
+    TEST_PUNY_SUB_ORIGIN
+  );
+});
--- a/browser/components/aboutlogins/content/aboutLogins.html
+++ b/browser/components/aboutlogins/content/aboutLogins.html
@@ -93,16 +93,20 @@
       </div>
       <!-- This container is to work around bug 1569292 -->
       <div class="container">
         <ol role="listbox" tabindex="0" data-l10n-id="login-list"></ol>
         <div class="intro">
           <p data-l10n-id="login-list-intro-title"></p>
           <span data-l10n-id="login-list-intro-description"></span>
         </div>
+        <div class="empty-search-message">
+          <p data-l10n-id="about-logins-login-list-empty-search-title"></p>
+          <span data-l10n-id="about-logins-login-list-empty-search-description"></span>
+        </div>
       </div>
       <button class="create-login-button" data-l10n-id="create-login-button"></button>
     </template>
 
     <template id="login-list-item-template">
       <li class="login-list-item" role="option">
         <div class="favicon-wrapper">
           <img class="favicon" src="" alt=""/>
--- a/browser/components/aboutlogins/content/components/login-item.css
+++ b/browser/components/aboutlogins/content/components/login-item.css
@@ -135,16 +135,17 @@ input[type="url"][readOnly]:hover:active
 }
 
 :host([data-editing]) .detail-cell input:not([readOnly]):not([type="checkbox"]) {
   width: 280px;
 }
 
 .copy-button {
   margin-bottom: 0; /* Align button at the bottom of the row */
+  margin-inline-start: 20px;
 }
 
 .copy-button:not([data-copied]) .copied-button-text,
 .copy-button[data-copied] .copy-button-text {
   display: none;
 }
 
 .copy-button[data-copied] {
@@ -183,17 +184,17 @@ input[type="url"][readOnly]:hover:active
   font-family: monospace;
 }
 
 .reveal-password-checkbox {
   /* !important is needed to override common.css styling for checkboxes */
   background-color: transparent !important;
   border-width: 0 !important;
   background-image: url("chrome://browser/content/aboutlogins/icons/show-password.svg") !important;
-  margin-inline-start: 8px !important;
+  margin-inline: 10px 0 !important;
   cursor: pointer;
   -moz-context-properties: fill;
   fill: currentColor !important;
   opacity: var(--reveal-checkbox-opacity);
   flex-shrink: 0;
 }
 
 .reveal-password-checkbox:hover {
--- a/browser/components/aboutlogins/content/components/login-item.js
+++ b/browser/components/aboutlogins/content/components/login-item.js
@@ -75,16 +75,17 @@ export default class LoginItem extends H
     this._copyPasswordButton.addEventListener("click", this);
     this._copyUsernameButton.addEventListener("click", this);
     this._deleteButton.addEventListener("click", this);
     this._dismissBreachAlert.addEventListener("click", this);
     this._editButton.addEventListener("click", this);
     this._form.addEventListener("submit", this);
     this._originInput.addEventListener("click", this);
     this._revealCheckbox.addEventListener("click", this);
+    this._originInput.addEventListener("auxclick", this);
     window.addEventListener("AboutLoginsInitialLoginSelected", this);
     window.addEventListener("AboutLoginsLoadInitialFavicon", this);
     window.addEventListener("AboutLoginsLoginSelected", this);
     window.addEventListener("AboutLoginsShowBlankLogin", this);
   }
 
   async render() {
     this._breachAlert.hidden = true;
@@ -163,16 +164,22 @@ export default class LoginItem extends H
       case "AboutLoginsLoginSelected": {
         this.confirmPendingChangesOnEvent(event, event.detail);
         break;
       }
       case "AboutLoginsShowBlankLogin": {
         this.confirmPendingChangesOnEvent(event, {});
         break;
       }
+      case "auxclick": {
+        if (event.button == 1) {
+          this._handleOriginClick();
+        }
+        break;
+      }
       case "blur": {
         // Add https:// prefix if one was not provided.
         let originValue = this._originInput.value.trim();
         if (!originValue) {
           return;
         }
         if (!originValue.match(/:\/\//)) {
           this._originInput.value = "https://" + originValue;
@@ -271,28 +278,18 @@ export default class LoginItem extends H
           return;
         }
         if (classList.contains("edit-button")) {
           this._toggleEditing();
 
           recordTelemetryEvent({ object: "existing_login", method: "edit" });
           return;
         }
-        if (classList.contains("origin-input") && !this.readOnly) {
-          document.dispatchEvent(
-            new CustomEvent("AboutLoginsOpenSite", {
-              bubbles: true,
-              detail: this._login,
-            })
-          );
-
-          recordTelemetryEvent({
-            object: "existing_login",
-            method: "open_site",
-          });
+        if (classList.contains("origin-input")) {
+          this._handleOriginClick();
         }
         break;
       }
       case "submit": {
         // Prevent page navigation form submit behavior.
         event.preventDefault();
         if (!this._isFormValid({ reportErrors: true })) {
           return;
@@ -493,16 +490,27 @@ export default class LoginItem extends H
       return;
     }
 
     this._login = {};
     this._toggleEditing(false);
     this.render();
   }
 
+  _handleOriginClick() {
+    document.dispatchEvent(
+      new CustomEvent("AboutLoginsOpenSite", {
+        bubbles: true,
+        detail: this._login,
+      })
+    );
+
+    recordTelemetryEvent({ object: "existing_login", method: "open_site" });
+  }
+
   /**
    * Checks that the edit/new-login form has valid values present for their
    * respective required fields.
    *
    * @param {boolean} reportErrors If true, validation errors will be reported
    *                               to the user.
    */
   _isFormValid({ reportErrors } = {}) {
--- a/browser/components/aboutlogins/content/components/login-list.css
+++ b/browser/components/aboutlogins/content/components/login-list.css
@@ -40,31 +40,37 @@
   text-align: end;
   margin-inline-start: 18px;
 }
 
 .container {
   display: contents;
 }
 
+:host(.no-logins) .empty-search-message,
+:host(:not(.empty-search)) .empty-search-message,
+:host(.empty-search:not(.create-login-selected)) ol,
 :host(.no-logins:not(.create-login-selected)) ol,
 :host(:not(.no-logins)) .intro,
 :host(.create-login-selected) .intro,
+:host(.create-login-selected) .empty-search-message,
 :host(:not(.create-login-selected)) #new-login-list-item {
   display: none;
 }
 
+.empty-search-message,
 .intro {
   text-align: center;
   padding: 1em;
   max-width: 50ch; /* This should be kept in sync with login-list-item username and title max-width */
   flex-grow: 1;
   border-bottom: 1px solid var(--in-content-box-border-color);
 }
 
+.empty-search-message span,
 .intro span {
   font-size: 0.85em;
 }
 
 ol {
   margin-top: 0;
   margin-bottom: 0;
   padding-inline-start: 0;
--- a/browser/components/aboutlogins/content/components/login-list.js
+++ b/browser/components/aboutlogins/content/components/login-list.js
@@ -66,16 +66,17 @@ export default class LoginList extends H
     this._list.addEventListener("click", this);
     this.addEventListener("keydown", this);
     this._createLoginButton.addEventListener("click", this);
   }
 
   async render() {
     let visibleLoginGuids = this._applyFilter();
     this._updateVisibleLoginCount(visibleLoginGuids.size);
+    this.classList.toggle("empty-search", visibleLoginGuids.size == 0);
 
     // Add all of the logins that are not in the DOM yet.
     let fragment = document.createDocumentFragment();
     for (let guid of this._loginGuidsSortedOrder) {
       if (this._logins[guid].listItem) {
         continue;
       }
       let login = this._logins[guid].login;
--- a/browser/components/aboutlogins/tests/chrome/test_login_item.html
+++ b/browser/components/aboutlogins/tests/chrome/test_login_item.html
@@ -79,24 +79,46 @@ add_task(async function test_empty_item(
 });
 
 add_task(async function test_set_login() {
   gLoginItem.setLogin(TEST_LOGIN_1);
   await asyncElementRendered();
 
   ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode");
   ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode");
-  is(gLoginItem.shadowRoot.querySelector("input[name='origin']").value, TEST_LOGIN_1.origin, "origin should be populated");
+  let originInput = gLoginItem.shadowRoot.querySelector("input[name='origin']");
+  is(originInput.value, TEST_LOGIN_1.origin, "origin should be populated");
   is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, TEST_LOGIN_1.username, "username should be populated");
   is(gLoginItem.shadowRoot.querySelector("input[name='password']").value, TEST_LOGIN_1.password, "password should be populated");
   is(document.l10n.getAttributes(gLoginItem.shadowRoot.querySelector(".time-created")).args.timeCreated, TEST_LOGIN_1.timeCreated, "time-created should be populated");
   is(document.l10n.getAttributes(gLoginItem.shadowRoot.querySelector(".time-changed")).args.timeChanged, TEST_LOGIN_1.timePasswordChanged, "time-changed should be populated");
   is(document.l10n.getAttributes(gLoginItem.shadowRoot.querySelector(".time-used")).args.timeUsed, TEST_LOGIN_1.timeLastUsed, "time-used should be populated");
   let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")];
   ok(copyButtons.every(button => !isHidden(button)), "The copy buttons should be visible when viewing a login");
+
+  let openSiteEventDispatched = false;
+  document.addEventListener("AboutLoginsOpenSite", event => {
+    is(event.detail.guid, TEST_LOGIN_1.guid, "event should include guid");
+    openSiteEventDispatched = true;
+  }, {once: true});
+  originInput.click();
+  ok(openSiteEventDispatched, "Clicking the .origin-input should dispatch the AboutLoginsOpenSite event");
+
+  openSiteEventDispatched = false;
+  document.addEventListener("AboutLoginsOpenSite", event => {
+    is(event.detail.guid, TEST_LOGIN_1.guid, "event should include guid");
+    openSiteEventDispatched = true;
+  }, {once: true});
+  synthesizeMouseAtCenter(originInput, {button: 1});
+  ok(openSiteEventDispatched, "Middle-clicking the .origin-input should dispatch the AboutLoginsOpenSite event");
+
+  document.addEventListener("AboutLoginsOpenSite", event => {
+    ok(false, "right-clicking the .origin-input should not trigger the AboutLoginsOpenSite event");
+  }, {once: true});
+  synthesizeMouseAtCenter(originInput, {button: 2});
 });
 
 add_task(async function test_update_breaches() {
   gLoginItem.setLogin(TEST_LOGIN_1);
   gLoginItem.updateBreaches(TEST_BREACHES_MAP);
   await asyncElementRendered();
 
   let correspondingBreach = TEST_BREACHES_MAP.get(gLoginItem._login.guid);
--- a/browser/components/aboutlogins/tests/chrome/test_login_list.html
+++ b/browser/components/aboutlogins/tests/chrome/test_login_list.html
@@ -96,16 +96,44 @@ add_task(async function setup() {
 
   gLoginList = document.createElement("login-list");
   displayEl.appendChild(gLoginList);
 });
 
 add_task(async function test_empty_list() {
   ok(gLoginList, "loginList exists");
   is(gLoginList.textContent, "", "Initially empty");
+  gLoginList.classList.add("no-logins");
+  let loginListBox = gLoginList.shadowRoot.querySelector("ol");
+  let introText = gLoginList.shadowRoot.querySelector(".intro");
+  let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message");
+  ok(isHidden(loginListBox), "The login-list ol should be hidden when there are no logins");
+  ok(!isHidden(introText), "The intro text should be visible when the list is empty");
+  ok(isHidden(emptySearchText), "The empty-search text should be hidden when there are no logins");
+
+  gLoginList.classList.add("create-login-selected");
+  ok(!isHidden(loginListBox), "The login-list ol should be visible when the create-login mode is active");
+  ok(isHidden(introText), "The intro text should be hidden when the create-login mode is active");
+  ok(isHidden(emptySearchText), "The empty-search text should be hidden when the create-login mode is active");
+  gLoginList.classList.remove("create-login-selected");
+
+  window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+    bubbles: true,
+    detail: "foo",
+  }));
+  ok(isHidden(loginListBox), "The login-list ol should be hidden when there are no logins");
+  ok(!isHidden(introText), "The intro text should be visible when the list is empty");
+  ok(isHidden(emptySearchText), "The empty-search text should be hidden when there are no logins even if a filter is applied");
+
+  // Clean up state for next test
+  gLoginList.classList.remove("no-logins");
+  window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+    bubbles: true,
+    detail: "",
+  }));
 });
 
 add_task(async function test_keyboard_navigation() {
   gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2, TEST_LOGIN_3]);
 
   while (document.activeElement != gLoginList &&
          gLoginList.shadowRoot.querySelector("#login-sort") != gLoginList.shadowRoot.activeElement) {
     sendKey("TAB");
@@ -185,56 +213,63 @@ add_task(async function test_populated_l
 add_task(async function test_breach_indicator() {
   gLoginList.updateBreaches(TEST_BREACHES_MAP);
   let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item):not([hidden])");
   ok(loginListItems[0].classList.contains("breached"), "The first login should have the .breached class.");
   ok(!loginListItems[1].classList.contains("breached"), "The second login should not have the .breached class");
 });
 
 add_task(async function test_filtered_list() {
+  let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message");
+  ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
   is(gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item):not([hidden])").length, 2, "Both logins should be visible");
   let countSpan = gLoginList.shadowRoot.querySelector(".count");
   is(JSON.parse(countSpan.getAttribute("data-l10n-args")).count, 2, "Count should match full list length");
   window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
     bubbles: true,
     detail: "user1",
   }));
   is(JSON.parse(countSpan.getAttribute("data-l10n-args")).count, 1, "Count should match result amount");
+  ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
   let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
   is(loginListItems[0].querySelector(".username").textContent, "user1", "user1 is expected first");
   ok(!loginListItems[0].hidden, "user1 should remain visible");
   ok(loginListItems[1].hidden, "user2 should be hidden");
   window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
     bubbles: true,
     detail: "user2",
   }));
   is(JSON.parse(countSpan.getAttribute("data-l10n-args")).count, 1, "Count should match result amount");
+  ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
   loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
   ok(loginListItems[0].hidden, "user1 should be hidden");
   ok(!loginListItems[1].hidden, "user2 should be visible");
   window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
     bubbles: true,
     detail: "user",
   }));
   is(JSON.parse(countSpan.getAttribute("data-l10n-args")).count, 2, "Count should match result amount");
+  ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
   loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
   ok(!loginListItems[0].hidden, "user1 should be visible");
   ok(!loginListItems[1].hidden, "user2 should be visible");
   window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
     bubbles: true,
     detail: "foo",
   }));
   is(JSON.parse(countSpan.getAttribute("data-l10n-args")).count, 0, "Count should match result amount");
+  ok(!isHidden(emptySearchText), "The empty search text should be visible when there are no results in the list");
   loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
   ok(loginListItems[0].hidden, "user1 should be hidden");
   ok(loginListItems[1].hidden, "user2 should be hidden");
   window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
     bubbles: true,
     detail: "",
   }));
+  ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
   is(JSON.parse(countSpan.getAttribute("data-l10n-args")).count, 2, "Count should be reset to full list length");
   loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
   ok(!loginListItems[0].hidden, "user1 should be visible");
   ok(!loginListItems[1].hidden, "user2 should be visible");
 
   info("Add an HTTP Auth login");
   gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2, TEST_HTTP_AUTH_LOGIN_1]);
   await asyncElementRendered();
--- a/browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js
@@ -41,22 +41,28 @@ function createExtension(backgroundScrip
 }
 
 async function run(test) {
   let extension = createExtension(
     test.backgroundScript,
     test.permissions || ["normandyAddonStudy"],
     test.isPrivileged
   );
+  const promiseValidation = test.validationScript
+    ? test.validationScript(extension)
+    : Promise.resolve();
+
   await extension.startup();
+
+  await promiseValidation;
+
   if (test.doneSignal) {
     await extension.awaitFinish(test.doneSignal);
-  } else if (test.validationScript) {
-    await test.validationScript(extension);
   }
+
   await extension.unload();
 }
 
 add_task(async function setup() {
   await ExtensionTestUtils.startAddonManager();
 });
 
 add_task(
@@ -183,22 +189,24 @@ add_task(async function test_getClientMe
 add_task(async function test_onUnenroll_works() {
   const study = addonStudyFactory({
     addonId: "test@shield.mozilla.com",
   });
 
   const testWrapper = AddonStudies.withStudies([study]);
   const test = testWrapper(async () => {
     await run({
-      backgroundScript: async () => {
+      backgroundScript: () => {
         browser.normandyAddonStudy.onUnenroll.addListener(reason => {
           browser.test.sendMessage("unenrollReason", reason);
         });
+        browser.test.sendMessage("bgpageReady");
       },
       validationScript: async extension => {
+        await extension.awaitMessage("bgpageReady");
         await AddonStudies.markAsEnded(study, "test");
         const unenrollReason = await extension.awaitMessage("unenrollReason");
         equal(unenrollReason, "test", "Unenroll listener should be called.");
       },
     });
   });
 
   await test();
--- a/browser/components/newtab/common/Actions.jsm
+++ b/browser/components/newtab/common/Actions.jsm
@@ -52,16 +52,17 @@ for (const type of [
   "DISCOVERY_STREAM_LAYOUT_RESET",
   "DISCOVERY_STREAM_LAYOUT_UPDATE",
   "DISCOVERY_STREAM_LINK_BLOCKED",
   "DISCOVERY_STREAM_LOADED_CONTENT",
   "DISCOVERY_STREAM_RETRY_FEED",
   "DISCOVERY_STREAM_SPOCS_CAPS",
   "DISCOVERY_STREAM_SPOCS_ENDPOINT",
   "DISCOVERY_STREAM_SPOCS_FILL",
+  "DISCOVERY_STREAM_SPOCS_PLACEMENTS",
   "DISCOVERY_STREAM_SPOCS_UPDATE",
   "DISCOVERY_STREAM_SPOC_BLOCKED",
   "DISCOVERY_STREAM_SPOC_IMPRESSION",
   "DOWNLOAD_CHANGED",
   "FAKE_FOCUS_SEARCH",
   "FILL_SEARCH_TERM",
   "HANDOFF_SEARCH_TO_AWESOMEBAR",
   "HIDE_SEARCH",
--- a/browser/components/newtab/common/Reducers.jsm
+++ b/browser/components/newtab/common/Reducers.jsm
@@ -63,16 +63,17 @@ const INITIAL_STATE = {
     spocs: {
       spocs_endpoint: "",
       spocs_per_domain: 1,
       lastUpdated: null,
       data: {}, // {spocs: []}
       loaded: false,
       frequency_caps: [],
       blocked: [],
+      placements: [],
     },
   },
   Search: {
     // When search hand-off is enabled, we render a big button that is styled to
     // look like a search textbox. If the button is clicked, we style
     // the button as if it was a focused search box and show a fake cursor but
     // really focus the awesomebar without the focus styles ("hidden focus").
     fakeFocus: false,
@@ -516,25 +517,37 @@ function Pocket(prevState = INITIAL_STAT
   }
 }
 
 function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) {
   // Return if action data is empty, or spocs or feeds data is not loaded
   const isNotReady = () =>
     !action.data || !prevState.spocs.loaded || !prevState.feeds.loaded;
 
+  const handlePlacements = handleSites => {
+    const { data, placements } = prevState.spocs;
+    const result = {};
+
+    placements.forEach(placement => {
+      const placementSpocs = data[placement.name];
+
+      if (!placementSpocs || !placementSpocs.length) {
+        return;
+      }
+
+      result[placement.name] = handleSites(placementSpocs);
+    });
+    return result;
+  };
+
   const nextState = handleSites => ({
     ...prevState,
     spocs: {
       ...prevState.spocs,
-      data: prevState.spocs.data.spocs
-        ? {
-            spocs: handleSites(prevState.spocs.data.spocs),
-          }
-        : {},
+      data: handlePlacements(handleSites),
     },
     feeds: {
       ...prevState.feeds,
       data: Object.keys(prevState.feeds.data).reduce(
         (accumulator, feed_url) => {
           accumulator[feed_url] = {
             data: {
               ...prevState.feeds.data[feed_url].data,
@@ -600,16 +613,26 @@ function DiscoveryStream(prevState = INI
           spocs_endpoint:
             action.data.url ||
             INITIAL_STATE.DiscoveryStream.spocs.spocs_endpoint,
           spocs_per_domain:
             action.data.spocs_per_domain ||
             INITIAL_STATE.DiscoveryStream.spocs.spocs_per_domain,
         },
       };
+    case at.DISCOVERY_STREAM_SPOCS_PLACEMENTS:
+      return {
+        ...prevState,
+        spocs: {
+          ...prevState.spocs,
+          placements:
+            action.data.placements ||
+            INITIAL_STATE.DiscoveryStream.spocs.placements,
+        },
+      };
     case at.DISCOVERY_STREAM_SPOCS_UPDATE:
       if (action.data) {
         return {
           ...prevState,
           spocs: {
             ...prevState.spocs,
             lastUpdated: action.data.lastUpdated,
             data: action.data.spocs,
--- a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
@@ -1,16 +1,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/. */
 
 import { actionCreators as ac } from "common/Actions.jsm";
 import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
 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 { 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";
 import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
 import { List } from "content-src/components/DiscoveryStreamComponents/List/List";
 import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation";
 import React from "react";
@@ -109,28 +110,80 @@ export class _DiscoveryStreamBase extend
     });
   }
 
   renderComponent(component, embedWidth) {
     switch (component.type) {
       case "Highlights":
         return <Highlights />;
       case "TopSites":
-        return <TopSites header={component.header} />;
-      case "TextPromo":
+        let promoAlignment;
+        if (
+          component.spocs &&
+          component.spocs.positions &&
+          component.spocs.positions.length
+        ) {
+          promoAlignment =
+            component.spocs.positions[0].index === 0 ? "left" : "right";
+        }
         return (
-          <DSTextPromo
-            image={component.properties.image_src}
-            alt_text={component.properties.alt_text}
-            header={component.properties.excerpt}
-            cta_text={component.properties.cta_text}
-            cta_url={component.properties.cta_url}
-            subtitle={component.properties.context}
+          <TopSites
+            header={component.header}
+            data={component.data}
+            promoAlignment={promoAlignment}
           />
         );
+      case "TextPromo":
+        if (
+          !component.data ||
+          !component.data.spocs ||
+          !component.data.spocs[0]
+        ) {
+          return null;
+        }
+        // Grab the first item in the array as we only have 1 spoc position.
+        const [spoc] = component.data.spocs;
+        const {
+          image_src,
+          alt_text,
+          title,
+          url,
+          context,
+          cta,
+          campaign_id,
+          id,
+          shim,
+        } = spoc;
+
+        return (
+          <DSDismiss
+            data={{
+              url: spoc.url,
+              guid: spoc.id,
+              shim: spoc.shim,
+            }}
+            dispatch={this.props.dispatch}
+            shouldSendImpressionStats={true}
+          >
+            <DSTextPromo
+              dispatch={this.props.dispatch}
+              image={image_src}
+              alt_text={alt_text || title}
+              header={title}
+              cta_text={cta}
+              cta_url={url}
+              subtitle={context}
+              campaignId={campaign_id}
+              id={id}
+              pos={0}
+              shim={shim}
+              type={component.type}
+            />
+          </DSDismiss>
+        );
       case "Message":
         return (
           <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}
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx
@@ -0,0 +1,78 @@
+/* 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 React from "react";
+import { LinkMenuOptions } from "content-src/lib/link-menu-options";
+
+export class DSDismiss extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onDismissClick = this.onDismissClick.bind(this);
+    this.onHover = this.onHover.bind(this);
+    this.offHover = this.offHover.bind(this);
+    this.state = {
+      hovering: false,
+    };
+  }
+
+  onDismissClick() {
+    const index = 0;
+    const source = "DISCOVERY_STREAM";
+    const blockUrlOption = LinkMenuOptions.BlockUrl(
+      this.props.data,
+      index,
+      source
+    );
+
+    const { action, impression, userEvent } = blockUrlOption;
+
+    this.props.dispatch(action);
+    const userEventData = Object.assign(
+      {
+        event: userEvent,
+        source,
+        action_position: index,
+      },
+      this.props.data
+    );
+    this.props.dispatch(ac.UserEvent(userEventData));
+    if (impression && this.props.shouldSendImpressionStats) {
+      this.props.dispatch(impression);
+    }
+  }
+
+  onHover() {
+    this.setState({
+      hovering: true,
+    });
+  }
+
+  offHover() {
+    this.setState({
+      hovering: false,
+    });
+  }
+
+  render() {
+    let className = "ds-dismiss";
+    if (this.state.hovering) {
+      className += " hovering";
+    }
+    return (
+      <div className={className}>
+        {this.props.children}
+        <button
+          className="ds-dismiss-button"
+          onHover={this.onHover}
+          onClick={this.onDismissClick}
+          onMouseEnter={this.onHover}
+          onMouseLeave={this.offHover}
+        >
+          <span className="icon icon-dismiss" />
+        </button>
+      </div>
+    );
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss
@@ -0,0 +1,38 @@
+.ds-dismiss {
+  position: relative;
+  overflow: hidden;
+  border-radius: 8px;
+  transition-delay: 100ms;
+  transition-duration: 100ms;
+  transition-property: background;
+
+  &.hovering {
+    background: var(--newtab-element-hover-color);
+  }
+
+  &:hover {
+    .ds-dismiss-button {
+      opacity: 1;
+    }
+  }
+
+  .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;
+    top: 0;
+    border-radius: 50%;
+    margin: 16px 24px 0 0;
+    transition-duration: 200ms;
+    transition-property: opacity;
+    opacity: 0;
+    background: var(--newtab-element-active-color);
+  }
+}
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
@@ -1,25 +1,77 @@
 /* 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 { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
 import React from "react";
 import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
 
 export class DSTextPromo extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onLinkClick = this.onLinkClick.bind(this);
+  }
+
+  onLinkClick() {
+    if (this.props.dispatch) {
+      this.props.dispatch(
+        ac.UserEvent({
+          event: "CLICK",
+          source: this.props.type.toUpperCase(),
+          action_position: this.props.pos,
+        })
+      );
+
+      this.props.dispatch(
+        ac.ImpressionStats({
+          source: this.props.type.toUpperCase(),
+          click: 0,
+          tiles: [
+            {
+              id: this.props.id,
+              pos: this.props.pos,
+              ...(this.props.shim && this.props.shim.click
+                ? { shim: this.props.shim.click }
+                : {}),
+            },
+          ],
+        })
+      );
+    }
+  }
+
   render() {
     return (
       <div className="ds-text-promo">
         <img src={this.props.image} alt={this.props.alt_text} />
         <div className="text">
           <h3>
             {`${this.props.header}\u2003`}
-            <SafeAnchor className="ds-chevron-link" url={this.props.cta_url}>
+            <SafeAnchor
+              className="ds-chevron-link"
+              dispatch={this.props.dispatch}
+              onLinkClick={this.onLinkClick}
+              url={this.props.cta_url}
+            >
               {this.props.cta_text}
             </SafeAnchor>
           </h3>
           <p className="subtitle">{this.props.subtitle}</p>
         </div>
+        <ImpressionStats
+          campaignId={this.props.campaignId}
+          rows={[
+            {
+              id: this.props.id,
+              pos: this.props.pos,
+              shim: this.props.shim && this.props.shim.impression,
+            },
+          ]}
+          dispatch={this.props.dispatch}
+          source={this.props.type}
+        />
       </div>
     );
   }
 }
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx
@@ -1,22 +1,157 @@
 /* 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 { connect } from "react-redux";
 import { TopSites as OldTopSites } from "content-src/components/TopSites/TopSites";
+import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.jsm";
 import React from "react";
 
 export class _TopSites extends React.PureComponent {
+  // Find a SPOC that doesn't already exist in User's TopSites
+  getFirstAvailableSpoc(topSites, data) {
+    const { spocs } = data;
+    if (!spocs || spocs.length === 0) {
+      return null;
+    }
+
+    const userTopSites = new Set(
+      topSites.map(topSite => topSite && topSite.url)
+    );
+
+    // We "clean urls" with http in TopSiteForm.jsx
+    // Spoc domains are in the format 'sponsorname.com'
+    return spocs.find(
+      spoc =>
+        !userTopSites.has(spoc.url) &&
+        !userTopSites.has(`http://${spoc.domain}`) &&
+        !userTopSites.has(`https://${spoc.domain}`) &&
+        !userTopSites.has(`http://www.${spoc.domain}`) &&
+        !userTopSites.has(`https://www.${spoc.domain}`)
+    );
+  }
+
+  // Find the first empty or unpinned index we can place the SPOC in.
+  // Return -1 if no available index and we should push it at the end.
+  getFirstAvailableIndex(topSites, promoAlignment) {
+    if (promoAlignment === "left") {
+      return topSites.findIndex(topSite => !topSite || !topSite.isPinned);
+    }
+
+    // The row isn't full so we can push it to the end of the row.
+    if (topSites.length < TOP_SITES_MAX_SITES_PER_ROW) {
+      return -1;
+    }
+
+    // If the row is full, we can check the row first for unpinned topsites to replace.
+    // Else we can check after the row. This behavior is how unpinned topsites move while drag and drop.
+    let endOfRow = TOP_SITES_MAX_SITES_PER_ROW - 1;
+    for (let i = endOfRow; i >= 0; i--) {
+      if (!topSites[i] || !topSites[i].isPinned) {
+        return i;
+      }
+    }
+
+    for (let i = endOfRow + 1; i < topSites.length; i++) {
+      if (!topSites[i] || !topSites[i].isPinned) {
+        return i;
+      }
+    }
+
+    return -1;
+  }
+
+  insertSpocContent(TopSites, data, promoAlignment) {
+    if (
+      !TopSites.rows ||
+      TopSites.rows.length === 0 ||
+      !data.spocs ||
+      data.spocs.length === 0
+    ) {
+      return null;
+    }
+
+    let topSites = [...TopSites.rows];
+    const topSiteSpoc = this.getFirstAvailableSpoc(topSites, data);
+
+    if (!topSiteSpoc) {
+      return null;
+    }
+
+    const link = {
+      customScreenshotURL: topSiteSpoc.image_src,
+      type: "SPOC",
+      label: topSiteSpoc.sponsor,
+      title: topSiteSpoc.sponsor,
+      url: topSiteSpoc.url,
+      campaignId: topSiteSpoc.campaign_id,
+      id: topSiteSpoc.id,
+      guid: topSiteSpoc.id,
+      shim: topSiteSpoc.shim,
+      // For now we are assuming position based on intended position.
+      // Actual position can shift based on other content.
+      // We also hard code left and right to be 0 and 7.
+      // We send the intended postion in the ping.
+      pos: promoAlignment === "left" ? 0 : 7,
+    };
+
+    const firstAvailableIndex = this.getFirstAvailableIndex(
+      topSites,
+      promoAlignment
+    );
+
+    if (firstAvailableIndex === -1) {
+      topSites.push(link);
+    } else {
+      // Normal insertion will not work since pinned topsites are in their correct index already
+      // Similar logic is done to handle drag and drop with pinned topsites in TopSite.jsx
+
+      let shiftedTopSite = topSites[firstAvailableIndex];
+      let index = firstAvailableIndex + 1;
+
+      // Shift unpinned topsites to the right by finding the next unpinned topsite to replace
+      while (shiftedTopSite) {
+        if (index === topSites.length) {
+          topSites.push(shiftedTopSite);
+          shiftedTopSite = null;
+        } else if (topSites[index] && topSites[index].isPinned) {
+          index += 1;
+        } else {
+          const nextTopSite = topSites[index];
+          topSites[index] = shiftedTopSite;
+          shiftedTopSite = nextTopSite;
+          index += 1;
+        }
+      }
+
+      topSites[firstAvailableIndex] = link;
+    }
+
+    return { ...TopSites, rows: topSites };
+  }
+
   render() {
-    const header = this.props.header || {};
+    const { header = {}, data, promoAlignment, TopSites } = this.props;
+
+    const TopSitesWithSpoc =
+      TopSites && data && promoAlignment
+        ? this.insertSpocContent(TopSites, data, promoAlignment)
+        : null;
+
     return (
-      <div className="ds-top-sites">
-        <OldTopSites isFixed={true} title={header.title} />
+      <div
+        className={`ds-top-sites ${TopSitesWithSpoc ? "top-sites-spoc" : ""}`}
+      >
+        <OldTopSites
+          isFixed={true}
+          title={header.title}
+          TopSitesWithSpoc={TopSitesWithSpoc}
+        />
       </div>
     );
   }
 }
 
 export const TopSites = connect(state => ({ TopSites: state.TopSites }))(
   _TopSites
 );
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss
@@ -1,8 +1,10 @@
+$top-sites-vertical-space-with-spoc: 20px;
+
 // ds topsites wraps the original topsites, with a few css changes.
 .ds-top-sites {
 
   // This is the override layer.
   .top-sites {
     // Slightly different alignment with the other DS components than AS has.
     margin: 0 (-$section-horizontal-padding);
 
@@ -105,8 +107,38 @@
       }
 
       .title {
         width: var(--rightPanelIconWidth);
       }
     }
   }
 }
+
+.top-sites-spoc {
+  .top-sites-list {
+    display: flex;
+    flex-wrap: wrap;
+
+    .top-site-outer {
+      margin: 0 0 $top-sites-vertical-space-with-spoc;
+
+      .top-site-spoc-label {
+        @include dark-theme-only {
+          color: $grey-40;
+        }
+
+        color: $grey-50;
+        font-size: 11px;
+        display: flex;
+        justify-content: center;
+        margin-top: -4px;
+      }
+
+      &.dragged {
+
+        .top-site-spoc-label {
+          visibility: hidden;
+        }
+      }
+    }
+  }
+}
--- a/browser/components/newtab/content-src/components/TopSites/TopSite.jsx
+++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx
@@ -2,24 +2,27 @@
  * 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, actionTypes as at } from "common/Actions.jsm";
 import {
   MIN_CORNER_FAVICON_SIZE,
   MIN_RICH_FAVICON_SIZE,
   TOP_SITES_CONTEXT_MENU_OPTIONS,
+  TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS,
   TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS,
   TOP_SITES_SOURCE,
 } from "./TopSitesConstants";
 import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats";
 import React from "react";
 import { ScreenshotUtils } from "content-src/lib/screenshot-utils";
 import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.jsm";
 import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+const SPOC_TYPE = "SPOC";
 
 export class TopSiteLink extends React.PureComponent {
   constructor(props) {
     super(props);
     this.state = { screenshotImage: null };
     this.onDragEvent = this.onDragEvent.bind(this);
     this.onKeyPress = this.onKeyPress.bind(this);
   }
@@ -167,22 +170,27 @@ export class TopSiteLink extends React.P
       imageClassName = "top-site-icon rich-icon";
       imageStyle = {
         backgroundColor: link.backgroundColor,
         backgroundImage: `url(${tippyTopIcon})`,
       };
       smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` };
     } else if (link.customScreenshotURL) {
       // assume high quality custom screenshot and use rich icon styles and class names
+
+      // TopSite spoc experiment only
+      const spocImgURL =
+        link.type === SPOC_TYPE ? link.customScreenshotURL : "";
+
       imageClassName = "top-site-icon rich-icon";
       imageStyle = {
         backgroundColor: link.backgroundColor,
         backgroundImage: hasScreenshotImage
           ? `url(${this.state.screenshotImage.url})`
-          : "none",
+          : `url(${spocImgURL})`,
       };
     } else if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) {
       // styles and class names for top sites with rich icons
       imageClassName = "top-site-icon rich-icon";
       imageStyle = {
         backgroundColor: link.backgroundColor,
         backgroundImage: `url(${tippyTopIcon || link.favicon})`,
       };
@@ -251,18 +259,35 @@ export class TopSiteLink extends React.P
                   style={smallFaviconStyle}
                 />
               )}
             </div>
             <div className={`title ${link.isPinned ? "pinned" : ""}`}>
               {link.isPinned && <div className="icon icon-pin-small" />}
               <span dir="auto">{title}</span>
             </div>
+            {link.type === SPOC_TYPE ? (
+              <span className="top-site-spoc-label">Sponsored</span>
+            ) : null}
           </a>
           {children}
+          {link.type === SPOC_TYPE ? (
+            <ImpressionStats
+              campaignId={link.campaignId}
+              rows={[
+                {
+                  id: link.id,
+                  pos: link.pos,
+                  shim: link.shim && link.shim.impression,
+                },
+              ]}
+              dispatch={this.props.dispatch}
+              source={TOP_SITES_SOURCE}
+            />
+          ) : null}
         </div>
       </li>
     );
   }
 }
 TopSiteLink.defaultProps = {
   title: "",
   link: {},
@@ -320,16 +345,33 @@ export class TopSite extends React.PureC
       this.props.dispatch(
         ac.OnlyToMain({
           type: at.OPEN_LINK,
           data: Object.assign(this.props.link, {
             event: { altKey, button, ctrlKey, metaKey, shiftKey },
           }),
         })
       );
+
+      // Fire off a spoc specific impression.
+      if (this.props.link.type === SPOC_TYPE) {
+        this.props.dispatch(
+          ac.ImpressionStats({
+            source: TOP_SITES_SOURCE,
+            click: 0,
+            tiles: [
+              {
+                id: this.props.link.id,
+                pos: this.props.link.pos,
+                shim: this.props.link.shim && this.props.link.shim.click,
+              },
+            ],
+          })
+        );
+      }
     } else {
       this.props.dispatch(
         ac.OnlyToMain({
           type: at.FILL_SEARCH_TERM,
           data: { label: this.props.link.label },
         })
       );
     }
@@ -343,16 +385,21 @@ export class TopSite extends React.PureC
     }
   }
 
   render() {
     const { props } = this;
     const { link } = props;
     const isContextMenuOpen = props.activeIndex === props.index;
     const title = link.label || link.hostname;
+    const menuOptions =
+      link.type !== SPOC_TYPE
+        ? TOP_SITES_CONTEXT_MENU_OPTIONS
+        : TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS;
+
     return (
       <TopSiteLink
         {...props}
         onClick={this.onLinkClick}
         onDragEvent={this.props.onDragEvent}
         className={`${props.className || ""}${
           isContextMenuOpen ? " active" : ""
         }`}
@@ -366,19 +413,20 @@ export class TopSite extends React.PureC
           >
             <LinkMenu
               dispatch={props.dispatch}
               index={props.index}
               onUpdate={this.onMenuUpdate}
               options={
                 link.searchTopSite
                   ? TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS
-                  : TOP_SITES_CONTEXT_MENU_OPTIONS
+                  : menuOptions
               }
               site={link}
+              shouldSendImpressionStats={link.type === SPOC_TYPE}
               siteInfo={this._getTelemetryInfo()}
               source={TOP_SITES_SOURCE}
             />
           </ContextMenuButton>
         </div>
       </TopSiteLink>
     );
   }
--- a/browser/components/newtab/content-src/components/TopSites/TopSites.jsx
+++ b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx
@@ -66,17 +66,19 @@ export class _TopSites extends React.Pur
       this
     );
   }
 
   /**
    * Dispatch session statistics about the quality of TopSites icons and pinned count.
    */
   _dispatchTopSitesStats() {
-    const topSites = this._getVisibleTopSites();
+    const topSites = this._getVisibleTopSites().filter(
+      topSite => topSite !== null && topSite !== undefined
+    );
     const topSitesIconsStats = countTopSitesIconsTypes(topSites);
     const topSitesPinned = topSites.filter(site => !!site.isPinned).length;
     const searchShortcuts = topSites.filter(site => !!site.searchTopSite)
       .length;
     // Dispatch telemetry event with the count of TopSites images types.
     this.props.dispatch(
       ac.AlsoToMain({
         type: at.SAVE_SESSION_PERF_DATA,
@@ -203,13 +205,14 @@ export class _TopSites extends React.Pur
             )}
           </div>
         </CollapsibleSection>
       </ComponentPerfTimer>
     );
   }
 }
 
-export const TopSites = connect(state => ({
-  TopSites: state.TopSites,
+export const TopSites = connect((state, props) => ({
+  // 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);
--- a/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js
+++ b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js
@@ -8,16 +8,24 @@ export const TOP_SITES_CONTEXT_MENU_OPTI
   "EditTopSite",
   "Separator",
   "OpenInNewWindow",
   "OpenInPrivateWindow",
   "Separator",
   "BlockUrl",
   "DeleteUrl",
 ];
+export const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = [
+  "PinSpocTopSite",
+  "Separator",
+  "OpenInNewWindow",
+  "OpenInPrivateWindow",
+  "Separator",
+  "BlockUrl",
+];
 // the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite
 export const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = [
   "CheckPinTopSite",
   "Separator",
   "BlockUrl",
 ];
 // minimum size necessary to show a rich icon instead of a screenshot
 export const MIN_RICH_FAVICON_SIZE = 96;
--- a/browser/components/newtab/content-src/lib/link-menu-options.js
+++ b/browser/components/newtab/content-src/lib/link-menu-options.js
@@ -156,16 +156,28 @@ export const LinkMenuOptions = {
   RemoveDownload: site => ({
     id: "newtab-menu-remove-download",
     icon: "delete",
     action: ac.OnlyToMain({
       type: at.REMOVE_DOWNLOAD_FILE,
       data: { url: site.url },
     }),
   }),
+  PinSpocTopSite: (site, index) => ({
+    id: "newtab-menu-pin",
+    icon: "pin",
+    action: ac.AlsoToMain({
+      type: at.TOP_SITES_PIN,
+      data: {
+        site,
+        index,
+      },
+    }),
+    userEvent: "PIN",
+  }),
   PinTopSite: ({ url, searchTopSite, label }, index) => ({
     id: "newtab-menu-pin",
     icon: "pin",
     action: ac.AlsoToMain({
       type: at.TOP_SITES_PIN,
       data: {
         site: {
           url,
--- a/browser/components/newtab/content-src/lib/selectLayoutRender.js
+++ b/browser/components/newtab/content-src/lib/selectLayoutRender.js
@@ -1,58 +1,59 @@
 /* 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/. */
 
 export const selectLayoutRender = (state, prefs, rickRollCache) => {
   const { layout, feeds, spocs } = state;
-  let spocIndex = 0;
+  let spocIndexMap = {};
   let bufferRollCache = [];
   // Records the chosen and unchosen spocs by the probability selection.
   let chosenSpocs = new Set();
   let unchosenSpocs = new Set();
 
-  function rollForSpocs(data, spocsConfig) {
-    const recommendations = [...data.recommendations];
+  function rollForSpocs(data, spocsConfig, spocsData, placementName) {
+    if (!spocIndexMap[placementName] && spocIndexMap[placementName] !== 0) {
+      spocIndexMap[placementName] = 0;
+    }
+    const results = [...data];
     for (let position of spocsConfig.positions) {
-      const spoc = spocs.data.spocs[spocIndex];
+      const spoc = spocsData[spocIndexMap[placementName]];
       if (!spoc) {
         break;
       }
 
       // Cache random number for a position
       let rickRoll;
       if (!rickRollCache.length) {
         rickRoll = Math.random();
         bufferRollCache.push(rickRoll);
       } else {
         rickRoll = rickRollCache.shift();
         bufferRollCache.push(rickRoll);
       }
 
       if (rickRoll <= spocsConfig.probability) {
-        spocIndex++;
+        spocIndexMap[placementName]++;
         if (!spocs.blocked.includes(spoc.url)) {
-          recommendations.splice(position.index, 0, spoc);
+          results.splice(position.index, 0, spoc);
           chosenSpocs.add(spoc);
         }
       } else {
         unchosenSpocs.add(spoc);
       }
     }
 
-    return {
-      ...data,
-      recommendations,
-    };
+    return results;
   }
 
   const positions = {};
   const DS_COMPONENTS = [
     "Message",
+    "TextPromo",
     "SectionTitle",
     "Navigation",
     "CardGrid",
     "Hero",
     "HorizontalRule",
     "List",
   ];
 
@@ -62,69 +63,101 @@ export const selectLayoutRender = (state
     filterArray.push("TopSites");
   }
 
   if (!prefs["feeds.section.topstories"]) {
     filterArray.push(...DS_COMPONENTS);
   }
 
   const placeholderComponent = component => {
+    if (!component.feed) {
+      // TODO we now need a placeholder for topsites and textPromo.
+      return {
+        ...component,
+        data: {
+          spocs: [],
+        },
+      };
+    }
     const data = {
       recommendations: [],
     };
 
     let items = 0;
     if (component.properties && component.properties.items) {
       items = component.properties.items;
     }
     for (let i = 0; i < items; i++) {
       data.recommendations.push({ placeholder: true });
     }
 
     return { ...component, data };
   };
 
+  // TODO update devtools to show placements
+  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
+        );
+      }
+    }
+    return result;
+  };
+
   const handleComponent = component => {
+    return {
+      ...component,
+      data: {
+        spocs: handleSpocs([], component),
+      },
+    };
+  };
+
+  const handleComponentWithFeed = component => {
     positions[component.type] = positions[component.type] || 0;
-
-    const feed = feeds.data[component.feed.url];
     let data = {
       recommendations: [],
     };
+
+    const feed = feeds.data[component.feed.url];
     if (feed && feed.data) {
       data = {
         ...feed.data,
         recommendations: [...(feed.data.recommendations || [])],
       };
     }
 
     if (component && component.properties && component.properties.offset) {
       data = {
         ...data,
         recommendations: data.recommendations.slice(
           component.properties.offset
         ),
       };
     }
 
-    // Ensure we have recs available for this feed.
-    const hasRecs = data && data.recommendations;
-
-    // Do we ever expect to possibly have a spoc.
-    if (
-      hasRecs &&
-      component.spocs &&
-      component.spocs.positions &&
-      component.spocs.positions.length
-    ) {
-      // We expect a spoc, spocs are loaded, and the server returned spocs.
-      if (spocs.loaded && spocs.data.spocs && spocs.data.spocs.length) {
-        data = rollForSpocs(data, component.spocs);
-      }
-    }
+    data = {
+      ...data,
+      recommendations: handleSpocs(data.recommendations, component),
+    };
 
     let items = 0;
     if (component.properties && component.properties.items) {
       items = Math.min(component.properties.items, data.recommendations.length);
     }
 
     // loop through a component items
     // Store the items position sequentially for multiple components of the same type.
@@ -147,40 +180,43 @@ export const selectLayoutRender = (state
       let components = [];
       renderedLayoutArray.push({
         ...row,
         components,
       });
       for (const component of row.components.filter(
         c => !filterArray.includes(c.type)
       )) {
-        if (component.feed) {
-          const spocsConfig = component.spocs;
-          // Are we still waiting on a feed/spocs, render what we have,
-          // add a placeholder for this component, and bail out early.
+        const spocsConfig = component.spocs;
+        if (spocsConfig || component.feed) {
+          // TODO make sure this still works for different loading cases.
           if (
-            !feeds.data[component.feed.url] ||
+            (component.feed && !feeds.data[component.feed.url]) ||
             (spocsConfig &&
               spocsConfig.positions &&
               spocsConfig.positions.length &&
               !spocs.loaded)
           ) {
             components.push(placeholderComponent(component));
             return renderedLayoutArray;
           }
-          components.push(handleComponent(component));
+          if (component.feed) {
+            components.push(handleComponentWithFeed(component));
+          } else {
+            components.push(handleComponent(component));
+          }
         } else {
           components.push(component);
         }
       }
     }
     return renderedLayoutArray;
   };
 
-  const layoutRender = renderLayout(layout);
+  const layoutRender = renderLayout();
 
   // If empty, fill rickRollCache with random probability values from bufferRollCache
   if (!rickRollCache.length) {
     rickRollCache.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
@@ -198,17 +234,17 @@ export const selectLayoutRender = (state
       .filter(spoc => !chosenSpocs.has(spoc))
       .map(spoc => ({
         id: spoc.id,
         reason: "probability_selection",
         displayed: 0,
         full_recalc: 0,
       }));
     const outOfPositionSpocsFill = spocs.data.spocs
-      .slice(spocIndex)
+      .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
@@ -154,16 +154,17 @@ input {
 @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';
 @import '../components/DiscoveryStreamComponents/DSCard/DSCard';
 @import '../components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter';
 @import '../components/DiscoveryStreamComponents/DSImage/DSImage';
+@import '../components/DiscoveryStreamComponents/DSDismiss/DSDismiss';
 @import '../components/DiscoveryStreamComponents/DSMessage/DSMessage';
 @import '../components/DiscoveryStreamImpressionStats/ImpressionStats';
 @import '../components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState';
 @import '../components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo';
 
 // AS Router
 @import '../asrouter/components/Button/Button';
 @import '../asrouter/components/SnippetBase/SnippetBase';
--- a/browser/components/newtab/css/activity-stream-linux.css
+++ b/browser/components/newtab/css/activity-stream-linux.css
@@ -2558,16 +2558,32 @@ main {
     width: var(--rightPanelIconWidth);
     height: var(--rightPanelIconWidth); }
   .ds-column-1 .ds-top-sites .top-site-inner .title,
   .ds-column-2 .ds-top-sites .top-site-inner .title,
   .ds-column-3 .ds-top-sites .top-site-inner .title,
   .ds-column-4 .ds-top-sites .top-site-inner .title {
     width: var(--rightPanelIconWidth); }
 
+.top-sites-spoc .top-sites-list {
+  display: flex;
+  flex-wrap: wrap; }
+  .top-sites-spoc .top-sites-list .top-site-outer {
+    margin: 0 0 20px; }
+    .top-sites-spoc .top-sites-list .top-site-outer .top-site-spoc-label {
+      color: #737373;
+      font-size: 11px;
+      display: flex;
+      justify-content: center;
+      margin-top: -4px; }
+      [lwt-newtab-brighttext] .top-sites-spoc .top-sites-list .top-site-outer .top-site-spoc-label {
+        color: #B1B1B3; }
+    .top-sites-spoc .top-sites-list .top-site-outer.dragged .top-site-spoc-label {
+      visibility: hidden; }
+
 .ds-hero-item .context-menu-button,
 .ds-list-item .context-menu-button,
 .ds-card .context-menu-button {
   background-clip: padding-box;
   background-color: var(--newtab-contextmenu-button-color);
   background-image: url("chrome://global/skin/icons/more.svg");
   background-position: 55%;
   border: 1px solid var(--newtab-border-primary-color);
@@ -2859,16 +2875,46 @@ main {
   .ds-image .broken-image {
     background-color: var(--newtab-card-placeholder-color);
     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: 100ms;
+  transition-property: background; }
+  .ds-dismiss.hovering {
+    background: var(--newtab-element-hover-color); }
+  .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;
+    top: 0;
+    border-radius: 50%;
+    margin: 16px 24px 0 0;
+    transition-duration: 200ms;
+    transition-property: opacity;
+    opacity: 0;
+    background: var(--newtab-element-active-color); }
+
 .ds-message {
   margin: 8px 0 0; }
   .ds-message .title {
     display: flex;
     align-items: center; }
     .ds-message .title .glyph {
       width: 16px;
       height: 16px;
--- a/browser/components/newtab/css/activity-stream-mac.css
+++ b/browser/components/newtab/css/activity-stream-mac.css
@@ -2561,16 +2561,32 @@ main {
     width: var(--rightPanelIconWidth);
     height: var(--rightPanelIconWidth); }
   .ds-column-1 .ds-top-sites .top-site-inner .title,
   .ds-column-2 .ds-top-sites .top-site-inner .title,
   .ds-column-3 .ds-top-sites .top-site-inner .title,
   .ds-column-4 .ds-top-sites .top-site-inner .title {
     width: var(--rightPanelIconWidth); }
 
+.top-sites-spoc .top-sites-list {
+  display: flex;
+  flex-wrap: wrap; }
+  .top-sites-spoc .top-sites-list .top-site-outer {
+    margin: 0 0 20px; }
+    .top-sites-spoc .top-sites-list .top-site-outer .top-site-spoc-label {
+      color: #737373;
+      font-size: 11px;
+      display: flex;
+      justify-content: center;
+      margin-top: -4px; }
+      [lwt-newtab-brighttext] .top-sites-spoc .top-sites-list .top-site-outer .top-site-spoc-label {
+        color: #B1B1B3; }
+    .top-sites-spoc .top-sites-list .top-site-outer.dragged .top-site-spoc-label {
+      visibility: hidden; }
+
 .ds-hero-item .context-menu-button,
 .ds-list-item .context-menu-button,
 .ds-card .context-menu-button {
   background-clip: padding-box;
   background-color: var(--newtab-contextmenu-button-color);
   background-image: url("chrome://global/skin/icons/more.svg");
   background-position: 55%;
   border: 1px solid var(--newtab-border-primary-color);
@@ -2862,16 +2878,46 @@ main {
   .ds-image .broken-image {
     background-color: var(--newtab-card-placeholder-color);
     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: 100ms;
+  transition-property: background; }
+  .ds-dismiss.hovering {
+    background: var(--newtab-element-hover-color); }
+  .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;
+    top: 0;
+    border-radius: 50%;
+    margin: 16px 24px 0 0;
+    transition-duration: 200ms;
+    transition-property: opacity;
+    opacity: 0;
+    background: var(--newtab-element-active-color); }
+
 .ds-message {
   margin: 8px 0 0; }
   .ds-message .title {
     display: flex;
     align-items: center; }
     .ds-message .title .glyph {
       width: 16px;
       height: 16px;
--- a/browser/components/newtab/css/activity-stream-windows.css
+++ b/browser/components/newtab/css/activity-stream-windows.css
@@ -2558,16 +2558,32 @@ main {
     width: var(--rightPanelIconWidth);
     height: var(--rightPanelIconWidth); }
   .ds-column-1 .ds-top-sites .top-site-inner .title,
   .ds-column-2 .ds-top-sites .top-site-inner .title,
   .ds-column-3 .ds-top-sites .top-site-inner .title,
   .ds-column-4 .ds-top-sites .top-site-inner .title {
     width: var(--rightPanelIconWidth); }
 
+.top-sites-spoc .top-sites-list {
+  display: flex;
+  flex-wrap: wrap; }
+  .top-sites-spoc .top-sites-list .top-site-outer {
+    margin: 0 0 20px; }
+    .top-sites-spoc .top-sites-list .top-site-outer .top-site-spoc-label {
+      color: #737373;
+      font-size: 11px;
+      display: flex;
+      justify-content: center;
+      margin-top: -4px; }
+      [lwt-newtab-brighttext] .top-sites-spoc .top-sites-list .top-site-outer .top-site-spoc-label {
+        color: #B1B1B3; }
+    .top-sites-spoc .top-sites-list .top-site-outer.dragged .top-site-spoc-label {
+      visibility: hidden; }
+
 .ds-hero-item .context-menu-button,
 .ds-list-item .context-menu-button,
 .ds-card .context-menu-button {
   background-clip: padding-box;
   background-color: var(--newtab-contextmenu-button-color);
   background-image: url("chrome://global/skin/icons/more.svg");
   background-position: 55%;
   border: 1px solid var(--newtab-border-primary-color);
@@ -2859,16 +2875,46 @@ main {
   .ds-image .broken-image {
     background-color: var(--newtab-card-placeholder-color);
     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: 100ms;
+  transition-property: background; }
+  .ds-dismiss.hovering {
+    background: var(--newtab-element-hover-color); }
+  .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;
+    top: 0;
+    border-radius: 50%;
+    margin: 16px 24px 0 0;
+    transition-duration: 200ms;
+    transition-property: opacity;
+    opacity: 0;
+    background: var(--newtab-element-active-color); }
+
 .ds-message {
   margin: 8px 0 0; }
   .ds-message .title {
     display: flex;
     align-items: center; }
     .ds-message .title .glyph {
       width: 16px;
       height: 16px;
--- 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__(54);
+/* harmony import */ var content_src_lib_detect_user_session_start__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(56);
 /* 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__(27);
 /* 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__(59);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(61);
 /* 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/. */
 
 
 
 
 
@@ -189,17 +189,17 @@ const globalImportContext = typeof Windo
 
 // Create an object that avoids accidental differing key/value pairs:
 // {
 //   INIT: "INIT",
 //   UNINIT: "UNINIT"
 // }
 const actionTypes = {};
 
-for (const type of ["ADDONS_INFO_REQUEST", "ADDONS_INFO_RESPONSE", "ARCHIVE_FROM_POCKET", "AS_ROUTER_INITIALIZED", "AS_ROUTER_PREF_CHANGED", "AS_ROUTER_TARGETING_UPDATE", "AS_ROUTER_TELEMETRY_USER_EVENT", "BLOCK_URL", "BOOKMARK_URL", "CLEAR_PREF", "COPY_DOWNLOAD_LINK", "DELETE_BOOKMARK_BY_ID", "DELETE_FROM_POCKET", "DELETE_HISTORY_URL", "DIALOG_CANCEL", "DIALOG_OPEN", "DISCOVERY_STREAM_CONFIG_CHANGE", "DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", "DISCOVERY_STREAM_CONFIG_SETUP", "DISCOVERY_STREAM_CONFIG_SET_VALUE", "DISCOVERY_STREAM_FEEDS_UPDATE", "DISCOVERY_STREAM_FEED_UPDATE", "DISCOVERY_STREAM_IMPRESSION_STATS", "DISCOVERY_STREAM_LAYOUT_RESET", "DISCOVERY_STREAM_LAYOUT_UPDATE", "DISCOVERY_STREAM_LINK_BLOCKED", "DISCOVERY_STREAM_LOADED_CONTENT", "DISCOVERY_STREAM_RETRY_FEED", "DISCOVERY_STREAM_SPOCS_CAPS", "DISCOVERY_STREAM_SPOCS_ENDPOINT", "DISCOVERY_STREAM_SPOCS_FILL", "DISCOVERY_STREAM_SPOCS_UPDATE", "DISCOVERY_STREAM_SPOC_BLOCKED", "DISCOVERY_STREAM_SPOC_IMPRESSION", "DOWNLOAD_CHANGED", "FAKE_FOCUS_SEARCH", "FILL_SEARCH_TERM", "HANDOFF_SEARCH_TO_AWESOMEBAR", "HIDE_SEARCH", "INIT", "NEW_TAB_INIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_REHYDRATED", "NEW_TAB_STATE_REQUEST", "NEW_TAB_UNLOAD", "OPEN_DOWNLOAD_FILE", "OPEN_LINK", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "OPEN_WEBEXT_SETTINGS", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINKS_CHANGED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PLACES_SAVED_TO_POCKET", "POCKET_CTA", "POCKET_LINK_DELETED_OR_ARCHIVED", "POCKET_LOGGED_IN", "POCKET_WAITING_FOR_SPOC", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "PREVIEW_REQUEST", "PREVIEW_REQUEST_CANCEL", "PREVIEW_RESPONSE", "REMOVE_DOWNLOAD_FILE", "RICH_ICON_MISSING", "SAVE_SESSION_PERF_DATA", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_DISABLE", "SECTION_ENABLE", "SECTION_MOVE", "SECTION_OPTIONS_CHANGED", "SECTION_REGISTER", "SECTION_UPDATE", "SECTION_UPDATE_CARD", "SETTINGS_CLOSE", "SETTINGS_OPEN", "SET_PREF", "SHOW_DOWNLOAD_FILE", "SHOW_FIREFOX_ACCOUNTS", "SHOW_SEARCH", "SKIPPED_SIGNIN", "SNIPPETS_BLOCKLIST_CLEARED", "SNIPPETS_BLOCKLIST_UPDATED", "SNIPPETS_DATA", "SNIPPETS_PREVIEW_MODE", "SNIPPETS_RESET", "SNIPPET_BLOCKED", "SUBMIT_EMAIL", "SYSTEM_TICK", "TELEMETRY_IMPRESSION_STATS", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_CANCEL_EDIT", "TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_EDIT", "TOP_SITES_INSERT", "TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_PIN", "TOP_SITES_PREFS_UPDATED", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "TOTAL_BOOKMARKS_REQUEST", "TOTAL_BOOKMARKS_RESPONSE", "TRAILHEAD_ENROLL_EVENT", "UNINIT", "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WEBEXT_CLICK", "WEBEXT_DISMISS"]) {
+for (const type of ["ADDONS_INFO_REQUEST", "ADDONS_INFO_RESPONSE", "ARCHIVE_FROM_POCKET", "AS_ROUTER_INITIALIZED", "AS_ROUTER_PREF_CHANGED", "AS_ROUTER_TARGETING_UPDATE", "AS_ROUTER_TELEMETRY_USER_EVENT", "BLOCK_URL", "BOOKMARK_URL", "CLEAR_PREF", "COPY_DOWNLOAD_LINK", "DELETE_BOOKMARK_BY_ID", "DELETE_FROM_POCKET", "DELETE_HISTORY_URL", "DIALOG_CANCEL", "DIALOG_OPEN", "DISCOVERY_STREAM_CONFIG_CHANGE", "DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", "DISCOVERY_STREAM_CONFIG_SETUP", "DISCOVERY_STREAM_CONFIG_SET_VALUE", "DISCOVERY_STREAM_FEEDS_UPDATE", "DISCOVERY_STREAM_FEED_UPDATE", "DISCOVERY_STREAM_IMPRESSION_STATS", "DISCOVERY_STREAM_LAYOUT_RESET", "DISCOVERY_STREAM_LAYOUT_UPDATE", "DISCOVERY_STREAM_LINK_BLOCKED", "DISCOVERY_STREAM_LOADED_CONTENT", "DISCOVERY_STREAM_RETRY_FEED", "DISCOVERY_STREAM_SPOCS_CAPS", "DISCOVERY_STREAM_SPOCS_ENDPOINT", "DISCOVERY_STREAM_SPOCS_FILL", "DISCOVERY_STREAM_SPOCS_PLACEMENTS", "DISCOVERY_STREAM_SPOCS_UPDATE", "DISCOVERY_STREAM_SPOC_BLOCKED", "DISCOVERY_STREAM_SPOC_IMPRESSION", "DOWNLOAD_CHANGED", "FAKE_FOCUS_SEARCH", "FILL_SEARCH_TERM", "HANDOFF_SEARCH_TO_AWESOMEBAR", "HIDE_SEARCH", "INIT", "NEW_TAB_INIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_REHYDRATED", "NEW_TAB_STATE_REQUEST", "NEW_TAB_UNLOAD", "OPEN_DOWNLOAD_FILE", "OPEN_LINK", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "OPEN_WEBEXT_SETTINGS", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINKS_CHANGED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PLACES_SAVED_TO_POCKET", "POCKET_CTA", "POCKET_LINK_DELETED_OR_ARCHIVED", "POCKET_LOGGED_IN", "POCKET_WAITING_FOR_SPOC", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "PREVIEW_REQUEST", "PREVIEW_REQUEST_CANCEL", "PREVIEW_RESPONSE", "REMOVE_DOWNLOAD_FILE", "RICH_ICON_MISSING", "SAVE_SESSION_PERF_DATA", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_DISABLE", "SECTION_ENABLE", "SECTION_MOVE", "SECTION_OPTIONS_CHANGED", "SECTION_REGISTER", "SECTION_UPDATE", "SECTION_UPDATE_CARD", "SETTINGS_CLOSE", "SETTINGS_OPEN", "SET_PREF", "SHOW_DOWNLOAD_FILE", "SHOW_FIREFOX_ACCOUNTS", "SHOW_SEARCH", "SKIPPED_SIGNIN", "SNIPPETS_BLOCKLIST_CLEARED", "SNIPPETS_BLOCKLIST_UPDATED", "SNIPPETS_DATA", "SNIPPETS_PREVIEW_MODE", "SNIPPETS_RESET", "SNIPPET_BLOCKED", "SUBMIT_EMAIL", "SYSTEM_TICK", "TELEMETRY_IMPRESSION_STATS", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_CANCEL_EDIT", "TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_EDIT", "TOP_SITES_INSERT", "TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_PIN", "TOP_SITES_PREFS_UPDATED", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "TOTAL_BOOKMARKS_REQUEST", "TOTAL_BOOKMARKS_RESPONSE", "TRAILHEAD_ENROLL_EVENT", "UNINIT", "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WEBEXT_CLICK", "WEBEXT_DISMISS"]) {
   actionTypes[type] = type;
 } // These are acceptable actions for AS Router messages to have. They can show up
 // as call-to-action buttons in snippets, onboarding tour, etc.
 
 
 const ASRouterActions = {};
 
 for (const type of ["INSTALL_ADDON_FROM_URL", "OPEN_APPLICATIONS_MENU", "OPEN_PRIVATE_BROWSER_WINDOW", "OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PREFERENCES_PAGE", "SHOW_FIREFOX_ACCOUNTS", "PIN_CURRENT_TAB", "ENABLE_FIREFOX_MONITOR"]) {
@@ -560,22 +560,22 @@ var actionUtils = {
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "BaseContent", function() { return BaseContent; });
 /* 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__(29);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(27);
 /* 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__(55);
-/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(36);
+/* harmony import */ var content_src_components_DiscoveryStreamBase_DiscoveryStreamBase__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(57);
+/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(38);
 /* 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__(53);
-/* harmony import */ var content_src_components_Sections_Sections__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(41);
+/* harmony import */ var content_src_components_Search_Search__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(55);
+/* harmony import */ var content_src_components_Sections_Sections__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(43);
 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/. */
 
 
 
@@ -1756,26 +1756,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__(58);
+/* harmony import */ var _rich_text_strings__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(60);
 /* harmony import */ var _components_ImpressionsWrapper_ImpressionsWrapper__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(8);
-/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(56);
+/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(58);
 /* 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__(57);
-/* harmony import */ var _templates_FirstRun_FirstRun__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(60);
+/* harmony import */ var _templates_template_manifest__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(59);
+/* harmony import */ var _templates_FirstRun_FirstRun__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(62);
 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/. */
 
 
 
@@ -2580,20 +2580,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__(56);
+/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(58);
 /* 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__(58);
+/* harmony import */ var _rich_text_strings__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(60);
 /* 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/. */
 
 
@@ -3778,16 +3778,111 @@ class _ConfirmDialog extends react__WEBP
 const ConfirmDialog = Object(react_redux__WEBPACK_IMPORTED_MODULE_1__["connect"])(state => state.Dialog)(_ConfirmDialog);
 
 /***/ }),
 /* 30 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_LinkMenu", function() { return _LinkMenu; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "LinkMenu", function() { return LinkMenu; });
+/* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
+/* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(27);
+/* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony import */ var content_src_components_ContextMenu_ContextMenu__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(31);
+/* harmony import */ var content_src_lib_link_menu_options__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(32);
+/* 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__);
+/* 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_SITE_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"];
+class _LinkMenu extends react__WEBPACK_IMPORTED_MODULE_4___default.a.PureComponent {
+  getOptions() {
+    const {
+      props
+    } = this;
+    const {
+      site,
+      index,
+      source,
+      isPrivateBrowsingEnabled,
+      siteInfo,
+      platform
+    } = props; // Handle special case of default site
+
+    const propOptions = !site.isDefault || site.searchTopSite ? props.options : DEFAULT_SITE_MENU_OPTIONS;
+    const options = propOptions.map(o => content_src_lib_link_menu_options__WEBPACK_IMPORTED_MODULE_3__["LinkMenuOptions"][o](site, index, source, isPrivateBrowsingEnabled, siteInfo, platform)).map(option => {
+      const {
+        action,
+        impression,
+        id,
+        type,
+        userEvent
+      } = option;
+
+      if (!type && id) {
+        option.onClick = () => {
+          props.dispatch(action);
+
+          if (userEvent) {
+            const userEventData = Object.assign({
+              event: userEvent,
+              source,
+              action_position: index
+            }, siteInfo);
+            props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].UserEvent(userEventData));
+          }
+
+          if (impression && props.shouldSendImpressionStats) {
+            props.dispatch(impression);
+          }
+        };
+      }
+
+      return option;
+    }); // This is for accessibility to support making each item tabbable.
+    // We want to know which item is the first and which item
+    // is the last, so we can close the context menu accordingly.
+
+    options[0].first = true;
+    options[options.length - 1].last = true;
+    return options;
+  }
+
+  render() {
+    return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(content_src_components_ContextMenu_ContextMenu__WEBPACK_IMPORTED_MODULE_2__["ContextMenu"], {
+      onUpdate: this.props.onUpdate,
+      onShow: this.props.onShow,
+      options: this.getOptions(),
+      keyboardAccess: this.props.keyboardAccess
+    });
+  }
+
+}
+
+const getState = state => ({
+  isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled,
+  platform: state.Prefs.values.platform
+});
+
+const LinkMenu = Object(react_redux__WEBPACK_IMPORTED_MODULE_1__["connect"])(getState)(_LinkMenu);
+
+/***/ }),
+/* 31 */
+/***/ (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__, "ContextMenu", function() { return ContextMenu; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ContextMenuItem", function() { return ContextMenuItem; });
 /* 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
  * 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/. */
 
@@ -3951,17 +4046,315 @@ class ContextMenuItem extends react__WEB
       "data-l10n-id": option.string_id || option.id
     })));
   }
 
 }
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 31 */
+/* 32 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "LinkMenuOptions", function() { return LinkMenuOptions; });
+/* 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,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+const _OpenInPrivateWindow = site => ({
+  id: "newtab-menu-open-new-private-window",
+  icon: "new-window-private",
+  action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({
+    type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].OPEN_PRIVATE_WINDOW,
+    data: {
+      url: site.url,
+      referrer: site.referrer
+    }
+  }),
+  userEvent: "OPEN_PRIVATE_WINDOW"
+});
+/**
+ * List of functions that return items that can be included as menu options in a
+ * LinkMenu. All functions take the site as the first parameter, and optionally
+ * the index of the site.
+ */
+
+
+const LinkMenuOptions = {
+  Separator: () => ({
+    type: "separator"
+  }),
+  EmptyItem: () => ({
+    type: "empty"
+  }),
+  RemoveBookmark: site => ({
+    id: "newtab-menu-remove-bookmark",
+    icon: "bookmark-added",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].DELETE_BOOKMARK_BY_ID,
+      data: site.bookmarkGuid
+    }),
+    userEvent: "BOOKMARK_DELETE"
+  }),
+  AddBookmark: site => ({
+    id: "newtab-menu-bookmark",
+    icon: "bookmark-hollow",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].BOOKMARK_URL,
+      data: {
+        url: site.url,
+        title: site.title,
+        type: site.type
+      }
+    }),
+    userEvent: "BOOKMARK_ADD"
+  }),
+  OpenInNewWindow: site => ({
+    id: "newtab-menu-open-new-window",
+    icon: "new-window",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].OPEN_NEW_WINDOW,
+      data: {
+        referrer: site.referrer,
+        typedBonus: site.typedBonus,
+        url: site.url
+      }
+    }),
+    userEvent: "OPEN_NEW_WINDOW"
+  }),
+  BlockUrl: (site, index, eventSource) => ({
+    id: "newtab-menu-dismiss",
+    icon: "dismiss",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].BLOCK_URL,
+      data: {
+        url: site.open_url || site.url,
+        pocket_id: site.pocket_id
+      }
+    }),
+    impression: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].ImpressionStats({
+      source: eventSource,
+      block: 0,
+      tiles: [{
+        id: site.guid,
+        pos: index,
+        ...(site.shim && site.shim.delete ? {
+          shim: site.shim.delete
+        } : {})
+      }]
+    }),
+    userEvent: "BLOCK"
+  }),
+  // This is an option for web extentions which will result in remove items from
+  // memory and notify the web extenion, rather than using the built-in block list.
+  WebExtDismiss: (site, index, eventSource) => ({
+    id: "menu_action_webext_dismiss",
+    string_id: "newtab-menu-dismiss",
+    icon: "dismiss",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].WebExtEvent(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].WEBEXT_DISMISS, {
+      source: eventSource,
+      url: site.url,
+      action_position: index
+    })
+  }),
+  DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({
+    id: "newtab-menu-delete-history",
+    icon: "delete",
+    action: {
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].DIALOG_OPEN,
+      data: {
+        onConfirm: [common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+          type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].DELETE_HISTORY_URL,
+          data: {
+            url: site.url,
+            pocket_id: site.pocket_id,
+            forceBlock: site.bookmarkGuid
+          }
+        }), common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].UserEvent(Object.assign({
+          event: "DELETE",
+          source: eventSource,
+          action_position: index
+        }, siteInfo))],
+        eventSource,
+        body_string_id: ["newtab-confirm-delete-history-p1", "newtab-confirm-delete-history-p2"],
+        confirm_button_string_id: "newtab-topsites-delete-history-button",
+        cancel_button_string_id: "newtab-topsites-cancel-button",
+        icon: "modal-delete"
+      }
+    },
+    userEvent: "DIALOG_OPEN"
+  }),
+  ShowFile: site => ({
+    id: "newtab-menu-show-file",
+    icon: "search",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].SHOW_DOWNLOAD_FILE,
+      data: {
+        url: site.url
+      }
+    })
+  }),
+  OpenFile: site => ({
+    id: "newtab-menu-open-file",
+    icon: "open-file",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].OPEN_DOWNLOAD_FILE,
+      data: {
+        url: site.url
+      }
+    })
+  }),
+  CopyDownloadLink: site => ({
+    id: "newtab-menu-copy-download-link",
+    icon: "copy",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].COPY_DOWNLOAD_LINK,
+      data: {
+        url: site.url
+      }
+    })
+  }),
+  GoToDownloadPage: site => ({
+    id: "newtab-menu-go-to-download-page",
+    icon: "download",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].OPEN_LINK,
+      data: {
+        url: site.referrer
+      }
+    }),
+    disabled: !site.referrer
+  }),
+  RemoveDownload: site => ({
+    id: "newtab-menu-remove-download",
+    icon: "delete",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].REMOVE_DOWNLOAD_FILE,
+      data: {
+        url: site.url
+      }
+    })
+  }),
+  PinSpocTopSite: (site, index) => ({
+    id: "newtab-menu-pin",
+    icon: "pin",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].TOP_SITES_PIN,
+      data: {
+        site,
+        index
+      }
+    }),
+    userEvent: "PIN"
+  }),
+  PinTopSite: ({
+    url,
+    searchTopSite,
+    label
+  }, index) => ({
+    id: "newtab-menu-pin",
+    icon: "pin",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].TOP_SITES_PIN,
+      data: {
+        site: {
+          url,
+          ...(searchTopSite && {
+            searchTopSite,
+            label
+          })
+        },
+        index
+      }
+    }),
+    userEvent: "PIN"
+  }),
+  UnpinTopSite: site => ({
+    id: "newtab-menu-unpin",
+    icon: "unpin",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].TOP_SITES_UNPIN,
+      data: {
+        site: {
+          url: site.url
+        }
+      }
+    }),
+    userEvent: "UNPIN"
+  }),
+  SaveToPocket: (site, index, eventSource) => ({
+    id: "newtab-menu-save-to-pocket",
+    icon: "pocket-save",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].SAVE_TO_POCKET,
+      data: {
+        site: {
+          url: site.url,
+          title: site.title
+        }
+      }
+    }),
+    impression: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].ImpressionStats({
+      source: eventSource,
+      pocket: 0,
+      tiles: [{
+        id: site.guid,
+        pos: index,
+        ...(site.shim && site.shim.save ? {
+          shim: site.shim.save
+        } : {})
+      }]
+    }),
+    userEvent: "SAVE_TO_POCKET"
+  }),
+  DeleteFromPocket: site => ({
+    id: "newtab-menu-delete-pocket",
+    icon: "pocket-delete",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].DELETE_FROM_POCKET,
+      data: {
+        pocket_id: site.pocket_id
+      }
+    }),
+    userEvent: "DELETE_FROM_POCKET"
+  }),
+  ArchiveFromPocket: site => ({
+    id: "newtab-menu-archive-pocket",
+    icon: "pocket-archive",
+    action: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].ARCHIVE_FROM_POCKET,
+      data: {
+        pocket_id: site.pocket_id
+      }
+    }),
+    userEvent: "ARCHIVE_FROM_POCKET"
+  }),
+  EditTopSite: (site, index) => ({
+    id: "newtab-menu-edit-topsites",
+    icon: "edit",
+    action: {
+      type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].TOP_SITES_EDIT,
+      data: {
+        index
+      }
+    }
+  }),
+  CheckBookmark: site => site.bookmarkGuid ? LinkMenuOptions.RemoveBookmark(site) : LinkMenuOptions.AddBookmark(site),
+  CheckPinTopSite: (site, index) => site.isPinned ? LinkMenuOptions.UnpinTopSite(site) : LinkMenuOptions.PinTopSite(site, index),
+  CheckSavedToPocket: (site, index) => site.pocket_id ? LinkMenuOptions.DeleteFromPocket(site) : LinkMenuOptions.SaveToPocket(site, index),
+  CheckBookmarkOrArchive: site => site.pocket_id ? LinkMenuOptions.ArchiveFromPocket(site) : LinkMenuOptions.CheckBookmark(site),
+  OpenInPrivateWindow: (site, index, eventSource, isEnabled) => isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem()
+};
+
+/***/ }),
+/* 33 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ContextMenuButton", function() { return ContextMenuButton; });
 /* 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
@@ -4036,17 +4429,17 @@ class ContextMenuButton extends react__W
       keyboardAccess: contextMenuKeyboard,
       onUpdate: this.onUpdate
     }) : null);
   }
 
 }
 
 /***/ }),
-/* 32 */
+/* 34 */
 /***/ (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__, "INTERSECTION_RATIO", function() { return INTERSECTION_RATIO; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ImpressionStats", function() { return ImpressionStats; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
@@ -4263,17 +4656,17 @@ ImpressionStats.defaultProps = {
   IntersectionObserver: global.IntersectionObserver,
   document: global.document,
   rows: [],
   source: ""
 };
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 33 */
+/* 35 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "cardContextTypes", function() { return cardContextTypes; });
 /* 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/. */
@@ -4300,36 +4693,36 @@ const cardContextTypes = {
   },
   download: {
     fluentID: "newtab-label-download",
     icon: "download"
   }
 };
 
 /***/ }),
-/* 34 */
+/* 36 */
 /***/ (function(module, exports) {
 
 module.exports = ReactTransitionGroup;
 
 /***/ }),
-/* 35 */
+/* 37 */
 /***/ (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__(36);
-/* harmony import */ var content_src_components_FluentOrText_FluentOrText__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(38);
+/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(38);
+/* harmony import */ var content_src_components_FluentOrText_FluentOrText__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(40);
 /* 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__(39);
-/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(40);
-/* harmony import */ var content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(31);
+/* harmony import */ var content_src_components_SectionMenu_SectionMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(41);
+/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(42);
+/* harmony import */ var content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(33);
 /* 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/. */
 
 
 
 
 
@@ -4595,24 +4988,24 @@ CollapsibleSection.defaultProps = {
   },
   Prefs: {
     values: {}
   }
 };
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 36 */
+/* 38 */
 /***/ (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__(37);
+/* harmony import */ var content_src_components_A11yLinkButton_A11yLinkButton__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(39);
 /* 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 {
@@ -4682,17 +5075,17 @@ class ErrorBoundary extends react__WEBPA
   }
 
 }
 ErrorBoundary.defaultProps = {
   FallbackComponent: ErrorBoundaryFallback
 };
 
 /***/ }),
-/* 37 */
+/* 39 */
 /***/ (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); }
@@ -4712,17 +5105,17 @@ function A11yLinkButton(props) {
   return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", _extends({
     type: "button"
   }, props, {
     className: className
   }), props.children);
 }
 
 /***/ }),
-/* 38 */
+/* 40 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "FluentOrText", function() { return FluentOrText; });
 /* 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
@@ -4758,28 +5151,28 @@ class FluentOrText extends react__WEBPAC
 
 
     return react__WEBPACK_IMPORTED_MODULE_0___default.a.cloneElement(child, extraProps, grandChildren);
   }
 
 }
 
 /***/ }),
-/* 39 */
+/* 41 */
 /***/ (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__(30);
+/* harmony import */ var content_src_components_ContextMenu_ContextMenu__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(31);
 /* 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__(40);
+/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_3__ = __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/. */
 
 
 
 
 const DEFAULT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "RemoveSection", "CheckCollapsed", "Separator", "ManageSection"];
@@ -4864,17 +5257,17 @@ class _SectionMenu extends react__WEBPAC
       keyboardAccess: this.props.keyboardAccess
     });
   }
 
 }
 const SectionMenu = _SectionMenu;
 
 /***/ }),
-/* 40 */
+/* 42 */
 /***/ (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,
@@ -4994,38 +5387,38 @@ const SectionMenuOptions = {
       }
     }),
     userEvent: "MENU_PRIVACY_NOTICE"
   }),
   CheckCollapsed: section => section.collapsed ? SectionMenuOptions.ExpandSection(section) : SectionMenuOptions.CollapseSection(section)
 };
 
 /***/ }),
-/* 41 */
+/* 43 */
 /***/ (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__(42);
-/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(35);
-/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(44);
-/* harmony import */ var content_src_components_FluentOrText_FluentOrText__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(38);
+/* harmony import */ var content_src_components_Card_Card__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44);
+/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(37);
+/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(46);
+/* harmony import */ var content_src_components_FluentOrText_FluentOrText__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(40);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(27);
 /* 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__(46);
-/* harmony import */ var content_src_components_PocketLoggedInCta_PocketLoggedInCta__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(47);
+/* harmony import */ var content_src_components_MoreRecommendations_MoreRecommendations__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(48);
+/* harmony import */ var content_src_components_PocketLoggedInCta_PocketLoggedInCta__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(49);
 /* 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__(48);
-/* harmony import */ var content_src_components_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(49);
+/* harmony import */ var content_src_components_Topics_Topics__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(50);
+/* harmony import */ var content_src_components_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(51);
 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/. */
 
 
 
@@ -5365,33 +5758,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)))
 
 /***/ }),
-/* 42 */
+/* 44 */
 /***/ (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__(33);
+/* harmony import */ var _types__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(35);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(27);
 /* 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__(31);
-/* harmony import */ var content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(61);
+/* harmony import */ var content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(33);
+/* harmony import */ var content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(30);
 /* 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__(43);
+/* harmony import */ var content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(45);
 /* 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/. */
 
 
 
 
 
@@ -5710,17 +6103,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
 });
 
 /***/ }),
-/* 43 */
+/* 45 */
 /***/ (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/. */
@@ -5779,24 +6172,24 @@ const ScreenshotUtils = {
 
     return !remoteImage && !localImage;
   }
 
 };
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 44 */
+/* 46 */
 /***/ (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__(45);
+/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(47);
 /* 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
@@ -5959,17 +6352,17 @@ class ComponentPerfTimer extends react__
     }
 
     return this.props.children;
   }
 
 }
 
 /***/ }),
-/* 45 */
+/* 47 */
 /***/ (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,
@@ -6091,17 +6484,17 @@ function _PerfService(options) {
     let mostRecentEntry = entries[entries.length - 1];
     return this._perf.timeOrigin + mostRecentEntry.startTime;
   }
 
 };
 var perfService = new _PerfService();
 
 /***/ }),
-/* 46 */
+/* 48 */
 /***/ (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
@@ -6123,17 +6516,17 @@ class MoreRecommendations extends react_
     }
 
     return null;
   }
 
 }
 
 /***/ }),
-/* 47 */
+/* 49 */
 /***/ (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__(27);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_0__);
@@ -6166,17 +6559,17 @@ class _PocketLoggedInCta extends react__
   }
 
 }
 const PocketLoggedInCta = Object(react_redux__WEBPACK_IMPORTED_MODULE_0__["connect"])(state => ({
   Pocket: state.Pocket
 }))(_PocketLoggedInCta);
 
 /***/ }),
-/* 48 */
+/* 50 */
 /***/ (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__);
@@ -6211,36 +6604,36 @@ class Topics extends react__WEBPACK_IMPO
       url: t.url,
       name: t.name
     }))));
   }
 
 }
 
 /***/ }),
-/* 49 */
+/* 51 */
 /***/ (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__(50);
-/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(35);
-/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(44);
+/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(52);
+/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(37);
+/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(46);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(27);
 /* 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__(51);
-/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(59);
-/* harmony import */ var _TopSiteForm__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(62);
-/* harmony import */ var _TopSite__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(52);
+/* harmony import */ var _SearchShortcutsForm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(53);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(61);
+/* harmony import */ var _TopSiteForm__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(63);
+/* harmony import */ var _TopSite__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(54);
 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/. */
 
 
 
@@ -6306,17 +6699,17 @@ class _TopSites extends react__WEBPACK_I
     this.onSearchShortcutsFormClose = this.onSearchShortcutsFormClose.bind(this);
   }
   /**
    * Dispatch session statistics about the quality of TopSites icons and pinned count.
    */
 
 
   _dispatchTopSitesStats() {
-    const topSites = this._getVisibleTopSites();
+    const topSites = this._getVisibleTopSites().filter(topSite => topSite !== null && topSite !== undefined);
 
     const topSitesIconsStats = countTopSitesIconsTypes(topSites);
     const topSitesPinned = topSites.filter(site => !!site.isPinned).length;
     const searchShortcuts = topSites.filter(site => !!site.searchTopSite).length; // Dispatch telemetry event with the count of TopSites images types.
 
     this.props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].AlsoToMain({
       type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].SAVE_SESSION_PERF_DATA,
       data: {
@@ -6429,58 +6822,61 @@ class _TopSites extends react__WEBPACK_I
     }, react__WEBPACK_IMPORTED_MODULE_6___default.a.createElement(_SearchShortcutsForm__WEBPACK_IMPORTED_MODULE_7__["SearchShortcutsForm"], {
       TopSites: props.TopSites,
       onClose: this.onSearchShortcutsFormClose,
       dispatch: this.props.dispatch
     }))))));
   }
 
 }
-const TopSites = Object(react_redux__WEBPACK_IMPORTED_MODULE_4__["connect"])(state => ({
-  TopSites: state.TopSites,
+const TopSites = Object(react_redux__WEBPACK_IMPORTED_MODULE_4__["connect"])((state, props) => ({
+  // 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)))
 
 /***/ }),
-/* 50 */
+/* 52 */
 /***/ (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; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MIN_RICH_FAVICON_SIZE", function() { return MIN_RICH_FAVICON_SIZE; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MIN_CORNER_FAVICON_SIZE", function() { return MIN_CORNER_FAVICON_SIZE; });
 /* 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 TOP_SITES_SOURCE = "TOP_SITES";
-const TOP_SITES_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"]; // the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite
+const TOP_SITES_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"];
+const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = ["PinSpocTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"]; // the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite
 
 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;
 
 /***/ }),
-/* 51 */
+/* 53 */
 /***/ (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__(50);
+/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_2__ = __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/. */
 
 
 
 class SelectableSearchShortcut extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
   render() {
@@ -6651,46 +7047,49 @@ class SearchShortcutsForm extends react_
       onClick: this.onSaveButtonClick,
       "data-l10n-id": "newtab-topsites-save-button"
     })));
   }
 
 }
 
 /***/ }),
-/* 52 */
+/* 54 */
 /***/ (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__(50);
-/* harmony import */ var content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(61);
-/* 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_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(43);
-/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(59);
-/* harmony import */ var content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(31);
+/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(52);
+/* harmony import */ var content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(30);
+/* harmony import */ var _DiscoveryStreamImpressionStats_ImpressionStats__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(34);
+/* 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__(45);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(61);
+/* harmony import */ var content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(33);
 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/. */
 
 
 
 
 
 
 
-class TopSiteLink extends react__WEBPACK_IMPORTED_MODULE_3___default.a.PureComponent {
+
+const SPOC_TYPE = "SPOC";
+class TopSiteLink extends react__WEBPACK_IMPORTED_MODULE_4___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.state = {
       screenshotImage: null
     };
     this.onDragEvent = this.onDragEvent.bind(this);
     this.onKeyPress = this.onKeyPress.bind(this);
   }
@@ -6759,26 +7158,26 @@ class TopSiteLink extends react__WEBPACK
    * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
    */
 
 
   static getNextStateFromProps(nextProps, prevState) {
     const {
       screenshot
     } = nextProps.link;
-    const imageInState = content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_4__["ScreenshotUtils"].isRemoteImageLocal(prevState.screenshotImage, screenshot);
+    const imageInState = content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_5__["ScreenshotUtils"].isRemoteImageLocal(prevState.screenshotImage, screenshot);
 
     if (imageInState) {
       return null;
     } // Since image was updated, attempt to revoke old image blob URL, if it exists.
 
 
-    content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_4__["ScreenshotUtils"].maybeRevokeBlobObjectURL(prevState.screenshotImage);
+    content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_5__["ScreenshotUtils"].maybeRevokeBlobObjectURL(prevState.screenshotImage);
     return {
-      screenshotImage: content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_4__["ScreenshotUtils"].createLocalImageObject(screenshot)
+      screenshotImage: content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_5__["ScreenshotUtils"].createLocalImageObject(screenshot)
     };
   } // NOTE: Remove this function when we update React to >= 16.3 since React will
   //       call getDerivedStateFromProps automatically. We will also need to
   //       rename getNextStateFromProps to getDerivedStateFromProps.
 
 
   componentWillMount() {
     const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state);
@@ -6795,17 +7194,17 @@ class TopSiteLink extends react__WEBPACK
     const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state);
 
     if (nextState) {
       this.setState(nextState);
     }
   }
 
   componentWillUnmount() {
-    content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_4__["ScreenshotUtils"].maybeRevokeBlobObjectURL(this.state.screenshotImage);
+    content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_5__["ScreenshotUtils"].maybeRevokeBlobObjectURL(this.state.screenshotImage);
   }
 
   onKeyPress(event) {
     // If we have tabbed to a search shortcut top site, and we click 'enter',
     // we should execute the onClick function. This needs to be added because
     // search top sites are anchor tags without an href. See bug 1483135
     if (this.props.link.searchTopSite && event.key === "Enter") {
       this.props.onClick(event);
@@ -6844,20 +7243,22 @@ class TopSiteLink extends react__WEBPACK
         backgroundColor: link.backgroundColor,
         backgroundImage: `url(${tippyTopIcon})`
       };
       smallFaviconStyle = {
         backgroundImage: `url(${tippyTopIcon})`
       };
     } else if (link.customScreenshotURL) {
       // assume high quality custom screenshot and use rich icon styles and class names
+      // TopSite spoc experiment only
+      const spocImgURL = link.type === SPOC_TYPE ? link.customScreenshotURL : "";
       imageClassName = "top-site-icon rich-icon";
       imageStyle = {
         backgroundColor: link.backgroundColor,
-        backgroundImage: hasScreenshotImage ? `url(${this.state.screenshotImage.url})` : "none"
+        backgroundImage: hasScreenshotImage ? `url(${this.state.screenshotImage.url})` : `url(${spocImgURL})`
       };
     } else if (tippyTopIcon || faviconSize >= _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__["MIN_RICH_FAVICON_SIZE"]) {
       // styles and class names for top sites with rich icons
       imageClassName = "top-site-icon rich-icon";
       imageStyle = {
         backgroundColor: link.backgroundColor,
         backgroundImage: `url(${tippyTopIcon || link.favicon})`
       };
@@ -6887,60 +7288,71 @@ class TopSiteLink extends react__WEBPACK
       draggableProps = {
         onClick: this.onDragEvent,
         onDragEnd: this.onDragEvent,
         onDragStart: this.onDragEvent,
         onMouseDown: this.onDragEvent
       };
     }
 
-    return react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("li", _extends({
+    return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("li", _extends({
       className: topSiteOuterClassName,
       onDrop: this.onDragEvent,
       onDragOver: this.onDragEvent,
       onDragEnter: this.onDragEvent,
       onDragLeave: this.onDragEvent
-    }, draggableProps), react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("div", {
+    }, draggableProps), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: "top-site-inner"
-    }, react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("a", {
+    }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("a", {
       className: "top-site-button",
       href: link.searchTopSite ? undefined : link.url,
       tabIndex: "0",
       onKeyPress: this.onKeyPress,
       onClick: onClick,
       draggable: true
-    }, react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("div", {
+    }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: "tile",
       "aria-hidden": true,
       "data-fallback": letterFallback
-    }, react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("div", {
+    }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: imageClassName,
       style: imageStyle
-    }), link.searchTopSite && react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("div", {
+    }), link.searchTopSite && react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: "top-site-icon search-topsite"
-    }), showSmallFavicon && react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("div", {
+    }), showSmallFavicon && react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: "top-site-icon default-icon",
       "data-fallback": smallFaviconFallback && letterFallback,
       style: smallFaviconStyle
-    })), react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("div", {
+    })), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: `title ${link.isPinned ? "pinned" : ""}`
-    }, link.isPinned && react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("div", {
+    }, link.isPinned && react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: "icon icon-pin-small"
-    }), react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("span", {
+    }), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("span", {
       dir: "auto"
-    }, title))), children));
+    }, title)), link.type === SPOC_TYPE ? react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("span", {
+      className: "top-site-spoc-label"
+    }, "Sponsored") : null), children, link.type === SPOC_TYPE ? react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(_DiscoveryStreamImpressionStats_ImpressionStats__WEBPACK_IMPORTED_MODULE_3__["ImpressionStats"], {
+      campaignId: link.campaignId,
+      rows: [{
+        id: link.id,
+        pos: link.pos,
+        shim: link.shim && link.shim.impression
+      }],
+      dispatch: this.props.dispatch,
+      source: _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__["TOP_SITES_SOURCE"]
+    }) : null));
   }
 
 }
 TopSiteLink.defaultProps = {
   title: "",
   link: {},
   isDraggable: true
 };
-class TopSite extends react__WEBPACK_IMPORTED_MODULE_3___default.a.PureComponent {
+class TopSite extends react__WEBPACK_IMPORTED_MODULE_4___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.state = {
       showContextMenu: false
     };
     this.onLinkClick = this.onLinkClick.bind(this);
     this.onMenuUpdate = this.onMenuUpdate.bind(this);
   }
@@ -6997,17 +7409,29 @@ class TopSite extends react__WEBPACK_IMP
           event: {
             altKey,
             button,
             ctrlKey,
             metaKey,
             shiftKey
           }
         })
-      }));
+      })); // Fire off a spoc specific impression.
+
+      if (this.props.link.type === SPOC_TYPE) {
+        this.props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].ImpressionStats({
+          source: _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__["TOP_SITES_SOURCE"],
+          click: 0,
+          tiles: [{
+            id: this.props.link.id,
+            pos: this.props.link.pos,
+            shim: this.props.link.shim && this.props.link.shim.click
+          }]
+        }));
+      }
     } else {
       this.props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({
         type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].FILL_SEARCH_TERM,
         data: {
           label: this.props.link.label
         }
       }));
     }
@@ -7025,74 +7449,76 @@ class TopSite extends react__WEBPACK_IMP
     const {
       props
     } = this;
     const {
       link
     } = props;
     const isContextMenuOpen = props.activeIndex === props.index;
     const title = link.label || link.hostname;
-    return react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement(TopSiteLink, _extends({}, props, {
+    const menuOptions = link.type !== SPOC_TYPE ? _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__["TOP_SITES_CONTEXT_MENU_OPTIONS"] : _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__["TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS"];
+    return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(TopSiteLink, _extends({}, props, {
       onClick: this.onLinkClick,
       onDragEvent: this.props.onDragEvent,
       className: `${props.className || ""}${isContextMenuOpen ? " active" : ""}`,
       title: title
-    }), react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("div", null, react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement(content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_6__["ContextMenuButton"], {
+    }), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(content_src_components_ContextMenu_ContextMenuButton__WEBPACK_IMPORTED_MODULE_7__["ContextMenuButton"], {
       tooltip: "newtab-menu-content-tooltip",
       tooltipArgs: {
         title
       },
       onUpdate: this.onMenuUpdate
-    }, react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement(content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_2__["LinkMenu"], {
+    }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_2__["LinkMenu"], {
       dispatch: props.dispatch,
       index: props.index,
       onUpdate: this.onMenuUpdate,
-      options: link.searchTopSite ? _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__["TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS"] : _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__["TOP_SITES_CONTEXT_MENU_OPTIONS"],
+      options: link.searchTopSite ? _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__["TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS"] : menuOptions,
       site: link,
+      shouldSendImpressionStats: link.type === SPOC_TYPE,
       siteInfo: this._getTelemetryInfo(),
       source: _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__["TOP_SITES_SOURCE"]
     }))));
   }
 
 }
 TopSite.defaultProps = {
   link: {},
 
   onActivate() {}
 
 };
-class TopSitePlaceholder extends react__WEBPACK_IMPORTED_MODULE_3___default.a.PureComponent {
+class TopSitePlaceholder extends react__WEBPACK_IMPORTED_MODULE_4___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onEditButtonClick = this.onEditButtonClick.bind(this);
   }
 
   onEditButtonClick() {
     this.props.dispatch({
       type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].TOP_SITES_EDIT,
       data: {
         index: this.props.index
       }
     });
   }
 
   render() {
-    return react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement(TopSiteLink, _extends({}, this.props, {
+    return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(TopSiteLink, _extends({}, this.props, {
       className: `placeholder ${this.props.className || ""}`,
       isDraggable: false
-    }), react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("button", {
+    }), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("button", {
       "aria-haspopup": "true",
       className: "context-menu-button edit-button icon",
       "data-l10n-id": "newtab-menu-topsites-placeholder-tooltip",
       onClick: this.onEditButtonClick
     }));
   }
 
 }
-class TopSiteList extends react__WEBPACK_IMPORTED_MODULE_3___default.a.PureComponent {
+class TopSiteList extends react__WEBPACK_IMPORTED_MODULE_4___default.a.PureComponent {
   static get DEFAULT_STATE() {
     return {
       activeIndex: null,
       draggedIndex: null,
       draggedSite: null,
       draggedTitle: null,
       topSitesPreview: null
     };
@@ -7183,17 +7609,17 @@ class TopSiteList extends react__WEBPACK
 
         break;
     }
   }
 
   _getTopSites() {
     // Make a copy of the sites to truncate or extend to desired length
     let topSites = this.props.TopSites.rows.slice();
-    topSites.length = this.props.TopSitesRows * common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_5__["TOP_SITES_MAX_SITES_PER_ROW"];
+    topSites.length = this.props.TopSitesRows * common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_6__["TOP_SITES_MAX_SITES_PER_ROW"];
     return topSites;
   }
   /**
    * Make a preview of the topsites that will be the result of dropping the currently
    * dragged site at the specified index.
    */
 
 
@@ -7279,32 +7705,32 @@ class TopSiteList extends react__WEBPACK
         key: link ? link.url : holeIndex++,
         index: i
       };
 
       if (i >= maxNarrowVisibleIndex) {
         slotProps.className = "hide-for-narrow";
       }
 
-      topSitesUI.push(!link ? react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement(TopSitePlaceholder, _extends({}, slotProps, commonProps)) : react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement(TopSite, _extends({
+      topSitesUI.push(!link ? react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(TopSitePlaceholder, _extends({}, slotProps, commonProps)) : react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(TopSite, _extends({
         link: link,
         activeIndex: this.state.activeIndex,
         onActivate: this.onActivate
       }, slotProps, commonProps)));
     }
 
-    return react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("ul", {
+    return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("ul", {
       className: `top-sites-list${this.state.draggedSite ? " dnd-active" : ""}`
     }, topSitesUI);
   }
 
 }
 
 /***/ }),
-/* 53 */
+/* 55 */
 /***/ (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__(27);
@@ -7482,24 +7908,24 @@ class _Search extends react__WEBPACK_IMP
       ref: this.onInputMount
     })));
   }
 
 }
 const Search = Object(react_redux__WEBPACK_IMPORTED_MODULE_1__["connect"])()(_Search);
 
 /***/ }),
-/* 54 */
+/* 56 */
 /***/ (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__(45);
+/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(47);
 /* 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 {
@@ -7564,17 +7990,17 @@ class DetectUserSessionStart {
       this.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
   }
 
 }
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 55 */
+/* 57 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
@@ -7703,21 +8129,21 @@ DSImage_DSImage.defaultProps = {
   // The current source style from Pocket API (always 450px)
   rawSource: null,
   // Unadulterated image URL to filter through Thumbor
   extraClassNames: null,
   // Additional classnames to append to component
   optimize: true // Measure parent container to request exact sizes
 
 };
-// EXTERNAL MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx + 1 modules
-var LinkMenu = __webpack_require__(61);
+// EXTERNAL MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx
+var LinkMenu = __webpack_require__(30);
 
 // EXTERNAL MODULE: ./content-src/components/ContextMenu/ContextMenuButton.jsx
-var ContextMenuButton = __webpack_require__(31);
+var ContextMenuButton = __webpack_require__(33);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.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/. */
 
 
 
@@ -7786,17 +8212,17 @@ class DSLinkMenu_DSLinkMenu extends exte
         shim: this.props.shim,
         bookmarkGuid: this.props.bookmarkGuid
       }
     })));
   }
 
 }
 // EXTERNAL MODULE: ./content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
-var ImpressionStats = __webpack_require__(32);
+var ImpressionStats = __webpack_require__(34);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.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 SafeAnchor_SafeAnchor extends external_React_default.a.PureComponent {
@@ -7868,20 +8294,20 @@ class SafeAnchor_SafeAnchor extends exte
       href: this.safeURI(url),
       className: className,
       onClick: this.onClick
     }, this.props.children);
   }
 
 }
 // EXTERNAL MODULE: ./content-src/components/Card/types.js
-var types = __webpack_require__(33);
+var types = __webpack_require__(35);
 
 // EXTERNAL MODULE: external "ReactTransitionGroup"
-var external_ReactTransitionGroup_ = __webpack_require__(34);
+var external_ReactTransitionGroup_ = __webpack_require__(36);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.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/. */
 
 
  // Animation time is mirrored in DSContextFooter.scss
@@ -8302,21 +8728,97 @@ class CardGrid_CardGrid extends external
 
 }
 CardGrid_CardGrid.defaultProps = {
   border: `border`,
   items: 4 // Number of stories to display
 
 };
 // EXTERNAL MODULE: ./content-src/components/CollapsibleSection/CollapsibleSection.jsx
-var CollapsibleSection = __webpack_require__(35);
+var CollapsibleSection = __webpack_require__(37);
 
 // EXTERNAL MODULE: external "ReactRedux"
 var external_ReactRedux_ = __webpack_require__(27);
 
+// EXTERNAL MODULE: ./content-src/lib/link-menu-options.js
+var link_menu_options = __webpack_require__(32);
+
+// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.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 DSDismiss_DSDismiss extends external_React_default.a.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onDismissClick = this.onDismissClick.bind(this);
+    this.onHover = this.onHover.bind(this);
+    this.offHover = this.offHover.bind(this);
+    this.state = {
+      hovering: false
+    };
+  }
+
+  onDismissClick() {
+    const index = 0;
+    const source = "DISCOVERY_STREAM";
+    const blockUrlOption = link_menu_options["LinkMenuOptions"].BlockUrl(this.props.data, index, source);
+    const {
+      action,
+      impression,
+      userEvent
+    } = blockUrlOption;
+    this.props.dispatch(action);
+    const userEventData = Object.assign({
+      event: userEvent,
+      source,
+      action_position: index
+    }, this.props.data);
+    this.props.dispatch(Actions["actionCreators"].UserEvent(userEventData));
+
+    if (impression && this.props.shouldSendImpressionStats) {
+      this.props.dispatch(impression);
+    }
+  }
+
+  onHover() {
+    this.setState({
+      hovering: true
+    });
+  }
+
+  offHover() {
+    this.setState({
+      hovering: false
+    });
+  }
+
+  render() {
+    let className = "ds-dismiss";
+
+    if (this.state.hovering) {
+      className += " hovering";
+    }
+
+    return external_React_default.a.createElement("div", {
+      className: className
+    }, this.props.children, external_React_default.a.createElement("button", {
+      className: "ds-dismiss-button",
+      onHover: this.onHover,
+      onClick: this.onDismissClick,
+      onMouseEnter: this.onHover,
+      onMouseLeave: this.offHover
+    }, external_React_default.a.createElement("span", {
+      className: "icon icon-dismiss"
+    })));
+  }
+
+}
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.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 DSMessage_DSMessage extends external_React_default.a.PureComponent {
   render() {
@@ -8339,31 +8841,70 @@ class DSMessage_DSMessage extends extern
 
 }
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.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 DSTextPromo_DSTextPromo extends external_React_default.a.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onLinkClick = this.onLinkClick.bind(this);
+  }
+
+  onLinkClick() {
+    if (this.props.dispatch) {
+      this.props.dispatch(Actions["actionCreators"].UserEvent({
+        event: "CLICK",
+        source: this.props.type.toUpperCase(),
+        action_position: this.props.pos
+      }));
+      this.props.dispatch(Actions["actionCreators"].ImpressionStats({
+        source: this.props.type.toUpperCase(),
+        click: 0,
+        tiles: [{
+          id: this.props.id,
+          pos: this.props.pos,
+          ...(this.props.shim && this.props.shim.click ? {
+            shim: this.props.shim.click
+          } : {})
+        }]
+      }));
+    }
+  }
+
   render() {
     return external_React_default.a.createElement("div", {
       className: "ds-text-promo"
     }, external_React_default.a.createElement("img", {
       src: this.props.image,
       alt: this.props.alt_text
     }), external_React_default.a.createElement("div", {
       className: "text"
     }, external_React_default.a.createElement("h3", null, `${this.props.header}\u2003`, external_React_default.a.createElement(SafeAnchor_SafeAnchor, {
       className: "ds-chevron-link",
+      dispatch: this.props.dispatch,
+      onLinkClick: this.onLinkClick,
       url: this.props.cta_url
     }, this.props.cta_text)), external_React_default.a.createElement("p", {
       className: "subtitle"
-    }, this.props.subtitle)));
+    }, this.props.subtitle)), external_React_default.a.createElement(ImpressionStats["ImpressionStats"], {
+      campaignId: this.props.campaignId,
+      rows: [{
+        id: this.props.id,
+        pos: this.props.pos,
+        shim: this.props.shim && this.props.shim.impression
+      }],
+      dispatch: this.props.dispatch,
+      source: this.props.type
+    }));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/List/List.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/. */
 
@@ -8711,17 +9252,17 @@ class Hero_Hero extends external_React_d
 }
 Hero_Hero.defaultProps = {
   data: {},
   border: `border`,
   items: 1 // Number of stories to display
 
 };
 // EXTERNAL MODULE: ./content-src/components/Sections/Sections.jsx
-var Sections = __webpack_require__(41);
+var Sections = __webpack_require__(43);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/Highlights/Highlights.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/. */
 
@@ -8827,27 +9368,31 @@ class SectionTitle_SectionTitle extends 
  * 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 selectLayoutRender = (state, prefs, rickRollCache) => {
   const {
     layout,
     feeds,
     spocs
   } = state;
-  let spocIndex = 0;
+  let spocIndexMap = {};
   let bufferRollCache = []; // Records the chosen and unchosen spocs by the probability selection.
 
   let chosenSpocs = new Set();
   let unchosenSpocs = new Set();
 
-  function rollForSpocs(data, spocsConfig) {
-    const recommendations = [...data.recommendations];
+  function rollForSpocs(data, spocsConfig, spocsData, placementName) {
+    if (!spocIndexMap[placementName] && spocIndexMap[placementName] !== 0) {
+      spocIndexMap[placementName] = 0;
+    }
+
+    const results = [...data];
 
     for (let position of spocsConfig.positions) {
-      const spoc = spocs.data.spocs[spocIndex];
+      const spoc = spocsData[spocIndexMap[placementName]];
 
       if (!spoc) {
         break;
       } // Cache random number for a position
 
 
       let rickRoll;
 
@@ -8855,45 +9400,52 @@ const selectLayoutRender = (state, prefs
         rickRoll = Math.random();
         bufferRollCache.push(rickRoll);
       } else {
         rickRoll = rickRollCache.shift();
         bufferRollCache.push(rickRoll);
       }
 
       if (rickRoll <= spocsConfig.probability) {
-        spocIndex++;
+        spocIndexMap[placementName]++;
 
         if (!spocs.blocked.includes(spoc.url)) {
-          recommendations.splice(position.index, 0, spoc);
+          results.splice(position.index, 0, spoc);
           chosenSpocs.add(spoc);
         }
       } else {
         unchosenSpocs.add(spoc);
       }
     }
 
-    return { ...data,
-      recommendations
-    };
+    return results;
   }
 
   const positions = {};
-  const DS_COMPONENTS = ["Message", "SectionTitle", "Navigation", "CardGrid", "Hero", "HorizontalRule", "List"];
+  const DS_COMPONENTS = ["Message", "TextPromo", "SectionTitle", "Navigation", "CardGrid", "Hero", "HorizontalRule", "List"];
   const filterArray = [];
 
   if (!prefs["feeds.topsites"]) {
     filterArray.push("TopSites");
   }
 
   if (!prefs["feeds.section.topstories"]) {
     filterArray.push(...DS_COMPONENTS);
   }
 
   const placeholderComponent = component => {
+    if (!component.feed) {
+      // TODO we now need a placeholder for topsites and textPromo.
+      return { ...component,
+        data: {
+          spocs: []
+        }
+      };
+    }
+
     const data = {
       recommendations: []
     };
     let items = 0;
 
     if (component.properties && component.properties.items) {
       items = component.properties.items;
     }
@@ -8902,47 +9454,65 @@ const selectLayoutRender = (state, prefs
       data.recommendations.push({
         placeholder: true
       });
     }
 
     return { ...component,
       data
     };
+  }; // TODO update devtools to show placements
+
+
+  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);
+      }
+    }
+
+    return result;
   };
 
   const handleComponent = component => {
+    return { ...component,
+      data: {
+        spocs: handleSpocs([], component)
+      }
+    };
+  };
+
+  const handleComponentWithFeed = component => {
     positions[component.type] = positions[component.type] || 0;
-    const feed = feeds.data[component.feed.url];
     let data = {
       recommendations: []
     };
+    const feed = feeds.data[component.feed.url];
 
     if (feed && feed.data) {
       data = { ...feed.data,
         recommendations: [...(feed.data.recommendations || [])]
       };
     }
 
     if (component && component.properties && component.properties.offset) {
       data = { ...data,
         recommendations: data.recommendations.slice(component.properties.offset)
       };
-    } // Ensure we have recs available for this feed.
-
-
-    const hasRecs = data && data.recommendations; // Do we ever expect to possibly have a spoc.
-
-    if (hasRecs && component.spocs && component.spocs.positions && component.spocs.positions.length) {
-      // We expect a spoc, spocs are loaded, and the server returned spocs.
-      if (spocs.loaded && spocs.data.spocs && spocs.data.spocs.length) {
-        data = rollForSpocs(data, component.spocs);
-      }
-    }
-
+    }
+
+    data = { ...data,
+      recommendations: handleSpocs(data.recommendations, component)
+    };
     let items = 0;
 
     if (component.properties && component.properties.items) {
       items = Math.min(component.properties.items, data.recommendations.length);
     } // loop through a component items
     // Store the items position sequentially for multiple components of the same type.
     // Example: A second card grid starts pos offset from the last card grid.
 
@@ -8963,36 +9533,40 @@ const selectLayoutRender = (state, prefs
 
     for (const row of layout.filter(r => r.components.filter(c => !filterArray.includes(c.type)).length)) {
       let components = [];
       renderedLayoutArray.push({ ...row,
         components
       });
 
       for (const component of row.components.filter(c => !filterArray.includes(c.type))) {
-        if (component.feed) {
-          const spocsConfig = component.spocs; // Are we still waiting on a feed/spocs, render what we have,
-          // add a placeholder for this component, and bail out early.
-
-          if (!feeds.data[component.feed.url] || spocsConfig && spocsConfig.positions && spocsConfig.positions.length && !spocs.loaded) {
+        const spocsConfig = component.spocs;
+
+        if (spocsConfig || component.feed) {
+          // TODO make sure this still works for different loading cases.
+          if (component.feed && !feeds.data[component.feed.url] || spocsConfig && spocsConfig.positions && spocsConfig.positions.length && !spocs.loaded) {
             components.push(placeholderComponent(component));
             return renderedLayoutArray;
           }
 
-          components.push(handleComponent(component));
+          if (component.feed) {
+            components.push(handleComponentWithFeed(component));
+          } else {
+            components.push(handleComponent(component));
+          }
         } else {
           components.push(component);
         }
       }
     }
 
     return renderedLayoutArray;
   };
 
-  const layoutRender = renderLayout(layout); // If empty, fill rickRollCache with random probability values from bufferRollCache
+  const layoutRender = renderLayout(); // If empty, fill rickRollCache with random probability values from bufferRollCache
 
   if (!rickRollCache.length) {
     rickRollCache.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".
 
@@ -9007,55 +9581,173 @@ const selectLayoutRender = (state, prefs
       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(spocIndex).filter(spoc => !unchosenSpocs.has(spoc)).map(spoc => ({
+    const outOfPositionSpocsFill = spocs.data.spocs.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
   };
 };
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSites.jsx
-var TopSites = __webpack_require__(49);
+var TopSites_TopSites = __webpack_require__(51);
+
+// EXTERNAL MODULE: ./common/Reducers.jsm + 1 modules
+var Reducers = __webpack_require__(61);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/TopSites/TopSites.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 TopSites_TopSites extends external_React_default.a.PureComponent {
-  render() {
-    const header = this.props.header || {};
+
+class TopSites_TopSites_TopSites extends external_React_default.a.PureComponent {
+  // Find a SPOC that doesn't already exist in User's TopSites
+  getFirstAvailableSpoc(topSites, data) {
+    const {
+      spocs
+    } = data;
+
+    if (!spocs || spocs.length === 0) {
+      return null;
+    }
+
+    const userTopSites = new Set(topSites.map(topSite => topSite && topSite.url)); // We "clean urls" with http in TopSiteForm.jsx
+    // Spoc domains are in the format 'sponsorname.com'
+
+    return spocs.find(spoc => !userTopSites.has(spoc.url) && !userTopSites.has(`http://${spoc.domain}`) && !userTopSites.has(`https://${spoc.domain}`) && !userTopSites.has(`http://www.${spoc.domain}`) && !userTopSites.has(`https://www.${spoc.domain}`));
+  } // Find the first empty or unpinned index we can place the SPOC in.
+  // Return -1 if no available index and we should push it at the end.
+
+
+  getFirstAvailableIndex(topSites, promoAlignment) {
+    if (promoAlignment === "left") {
+      return topSites.findIndex(topSite => !topSite || !topSite.isPinned);
+    } // The row isn't full so we can push it to the end of the row.
+
+
+    if (topSites.length < Reducers["TOP_SITES_MAX_SITES_PER_ROW"]) {
+      return -1;
+    } // If the row is full, we can check the row first for unpinned topsites to replace.
+    // Else we can check after the row. This behavior is how unpinned topsites move while drag and drop.
+
+
+    let endOfRow = Reducers["TOP_SITES_MAX_SITES_PER_ROW"] - 1;
+
+    for (let i = endOfRow; i >= 0; i--) {
+      if (!topSites[i] || !topSites[i].isPinned) {
+        return i;
+      }
+    }
+
+    for (let i = endOfRow + 1; i < topSites.length; i++) {
+      if (!topSites[i] || !topSites[i].isPinned) {
+        return i;
+      }
+    }
+
+    return -1;
+  }
+
+  insertSpocContent(TopSites, data, promoAlignment) {
+    if (!TopSites.rows || TopSites.rows.length === 0 || !data.spocs || data.spocs.length === 0) {
+      return null;
+    }
+
+    let topSites = [...TopSites.rows];
+    const topSiteSpoc = this.getFirstAvailableSpoc(topSites, data);
+
+    if (!topSiteSpoc) {
+      return null;
+    }
+
+    const link = {
+      customScreenshotURL: topSiteSpoc.image_src,
+      type: "SPOC",
+      label: topSiteSpoc.sponsor,
+      title: topSiteSpoc.sponsor,
+      url: topSiteSpoc.url,
+      campaignId: topSiteSpoc.campaign_id,
+      id: topSiteSpoc.id,
+      guid: topSiteSpoc.id,
+      shim: topSiteSpoc.shim,
+      // For now we are assuming position based on intended position.
+      // Actual position can shift based on other content.
+      // We also hard code left and right to be 0 and 7.
+      // We send the intended postion in the ping.
+      pos: promoAlignment === "left" ? 0 : 7
+    };
+    const firstAvailableIndex = this.getFirstAvailableIndex(topSites, promoAlignment);
+
+    if (firstAvailableIndex === -1) {
+      topSites.push(link);
+    } else {
+      // Normal insertion will not work since pinned topsites are in their correct index already
+      // Similar logic is done to handle drag and drop with pinned topsites in TopSite.jsx
+      let shiftedTopSite = topSites[firstAvailableIndex];
+      let index = firstAvailableIndex + 1; // Shift unpinned topsites to the right by finding the next unpinned topsite to replace
+
+      while (shiftedTopSite) {
+        if (index === topSites.length) {
+          topSites.push(shiftedTopSite);
+          shiftedTopSite = null;
+        } else if (topSites[index] && topSites[index].isPinned) {
+          index += 1;
+        } else {
+          const nextTopSite = topSites[index];
+          topSites[index] = shiftedTopSite;
+          shiftedTopSite = nextTopSite;
+          index += 1;
+        }
+      }
+
+      topSites[firstAvailableIndex] = link;
+    }
+
+    return { ...TopSites,
+      rows: topSites
+    };
+  }
+
+  render() {
+    const {
+      header = {},
+      data,
+      promoAlignment,
+      TopSites
+    } = this.props;
+    const TopSitesWithSpoc = TopSites && data && promoAlignment ? this.insertSpocContent(TopSites, data, promoAlignment) : null;
     return external_React_default.a.createElement("div", {
-      className: "ds-top-sites"
-    }, external_React_default.a.createElement(TopSites["TopSites"], {
+      className: `ds-top-sites ${TopSitesWithSpoc ? "top-sites-spoc" : ""}`
+    }, external_React_default.a.createElement(TopSites_TopSites["TopSites"], {
       isFixed: true,
-      title: header.title
+      title: header.title,
+      TopSitesWithSpoc: TopSitesWithSpoc
     }));
   }
 
 }
-const TopSites_TopSites_TopSites = Object(external_ReactRedux_["connect"])(state => ({
+const DiscoveryStreamComponents_TopSites_TopSites_TopSites = Object(external_ReactRedux_["connect"])(state => ({
   TopSites: state.TopSites
-}))(TopSites_TopSites);
+}))(TopSites_TopSites_TopSites);
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "isAllowedCSS", function() { return isAllowedCSS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_DiscoveryStreamBase", function() { return DiscoveryStreamBase_DiscoveryStreamBase; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DiscoveryStreamBase", function() { return DiscoveryStreamBase; });
 /* 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/. */
 
@@ -9068,16 +9760,17 @@ const TopSites_TopSites_TopSites = Objec
 
 
 
 
 
 
 
 
+
 const ALLOWED_CSS_URL_PREFIXES = ["chrome://", "resource://", "https://img-getpocket.cdn.mozilla.net/"];
 const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR";
 let rickRollCache = []; // Cache of random probability values for a spoc position
 
 /**
  * Validate a CSS declaration. The values are assumed to be normalized by CSSOM.
  */
 
@@ -9148,29 +9841,68 @@ class DiscoveryStreamBase_DiscoveryStrea
   }
 
   renderComponent(component, embedWidth) {
     switch (component.type) {
       case "Highlights":
         return external_React_default.a.createElement(Highlights, null);
 
       case "TopSites":
-        return external_React_default.a.createElement(TopSites_TopSites_TopSites, {
-          header: component.header
+        let promoAlignment;
+
+        if (component.spocs && component.spocs.positions && component.spocs.positions.length) {
+          promoAlignment = component.spocs.positions[0].index === 0 ? "left" : "right";
+        }
+
+        return external_React_default.a.createElement(DiscoveryStreamComponents_TopSites_TopSites_TopSites, {
+          header: component.header,
+          data: component.data,
+          promoAlignment: promoAlignment
         });
 
       case "TextPromo":
-        return external_React_default.a.createElement(DSTextPromo_DSTextPromo, {
-          image: component.properties.image_src,
-          alt_text: component.properties.alt_text,
-          header: component.properties.excerpt,
-          cta_text: component.properties.cta_text,
-          cta_url: component.properties.cta_url,
-          subtitle: component.properties.context
-        });
+        if (!component.data || !component.data.spocs || !component.data.spocs[0]) {
+          return null;
+        } // Grab the first item in the array as we only have 1 spoc position.
+
+
+        const [spoc] = component.data.spocs;
+        const {
+          image_src,
+          alt_text,
+          title,
+          url,
+          context,
+          cta,
+          campaign_id,
+          id,
+          shim
+        } = spoc;
+        return external_React_default.a.createElement(DSDismiss_DSDismiss, {
+          data: {
+            url: spoc.url,
+            guid: spoc.id,
+            shim: spoc.shim
+          },
+          dispatch: this.props.dispatch,
+          shouldSendImpressionStats: true
+        }, external_React_default.a.createElement(DSTextPromo_DSTextPromo, {
+          dispatch: this.props.dispatch,
+          image: image_src,
+          alt_text: alt_text || title,
+          header: title,
+          cta_text: cta,
+          cta_url: url,
+          subtitle: context,
+          campaignId: campaign_id,
+          id: id,
+          pos: 0,
+          shim: shim,
+          type: component.type
+        }));
 
       case "Message":
         return external_React_default.a.createElement(DSMessage_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
@@ -9363,17 +10095,17 @@ class DiscoveryStreamBase_DiscoveryStrea
 }
 const DiscoveryStreamBase = Object(external_ReactRedux_["connect"])(state => ({
   DiscoveryStream: state.DiscoveryStream,
   Prefs: state.Prefs,
   Sections: state.Sections
 }))(DiscoveryStreamBase_DiscoveryStreamBase);
 
 /***/ }),
-/* 56 */
+/* 58 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(9);
 
@@ -10160,17 +10892,17 @@ localized_Localized.propTypes = {
  * components for more information.
  */
 
 
 
 
 
 /***/ }),
-/* 57 */
+/* 59 */
 /***/ (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_);
@@ -11324,17 +12056,17 @@ const SnippetsTemplates = {
   newsletter_snippet: NewsletterSnippet,
   fxa_signup_snippet: FXASignupSnippet,
   send_to_device_snippet: SendToDeviceSnippet,
   eoy_snippet: EOYSnippet,
   simple_below_search_snippet: SimpleBelowSearchSnippet_SimpleBelowSearchSnippet
 };
 
 /***/ }),
-/* 58 */
+/* 60 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // CONCATENATED MODULE: ./node_modules/fluent/src/types.js
 /* global Intl */
 
@@ -12744,17 +13476,17 @@ function generateBundles(content) {
     }
 
     bundle.addMessages(`${key} = ${string}`);
   });
   return [bundle];
 }
 
 /***/ }),
-/* 59 */
+/* 61 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
@@ -12871,17 +13603,18 @@ const INITIAL_STATE = {
     spocs: {
       spocs_endpoint: "",
       spocs_per_domain: 1,
       lastUpdated: null,
       data: {},
       // {spocs: []}
       loaded: false,
       frequency_caps: [],
-      blocked: []
+      blocked: [],
+      placements: []
     }
   },
   Search: {
     // When search hand-off is enabled, we render a big button that is styled to
     // look like a search textbox. If the button is clicked, we style
     // the button as if it was a focused search box and show a fake cursor but
     // really focus the awesomebar without the focus styles ("hidden focus").
     fakeFocus: false,
@@ -13417,21 +14150,37 @@ function Pocket(prevState = INITIAL_STAT
       return prevState;
   }
 }
 
 function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) {
   // Return if action data is empty, or spocs or feeds data is not loaded
   const isNotReady = () => !action.data || !prevState.spocs.loaded || !prevState.feeds.loaded;
 
+  const handlePlacements = handleSites => {
+    const {
+      data,
+      placements
+    } = prevState.spocs;
+    const result = {};
+    placements.forEach(placement => {
+      const placementSpocs = data[placement.name];
+
+      if (!placementSpocs || !placementSpocs.length) {
+        return;
+      }
+
+      result[placement.name] = handleSites(placementSpocs);
+    });
+    return result;
+  };
+
   const nextState = handleSites => ({ ...prevState,
     spocs: { ...prevState.spocs,
-      data: prevState.spocs.data.spocs ? {
-        spocs: handleSites(prevState.spocs.data.spocs)
-      } : {}
+      data: handlePlacements(handleSites)
     },
     feeds: { ...prevState.feeds,
       data: Object.keys(prevState.feeds.data).reduce((accumulator, feed_url) => {
         accumulator[feed_url] = {
           data: { ...prevState.feeds.data[feed_url].data,
             recommendations: handleSites(prevState.feeds.data[feed_url].data.recommendations)
           }
         };
@@ -13487,16 +14236,23 @@ function DiscoveryStream(prevState = INI
     case Actions["actionTypes"].DISCOVERY_STREAM_SPOCS_ENDPOINT:
       return { ...prevState,
         spocs: { ...INITIAL_STATE.DiscoveryStream.spocs,
           spocs_endpoint: action.data.url || INITIAL_STATE.DiscoveryStream.spocs.spocs_endpoint,
           spocs_per_domain: action.data.spocs_per_domain || INITIAL_STATE.DiscoveryStream.spocs.spocs_per_domain
         }
       };
 
+    case Actions["actionTypes"].DISCOVERY_STREAM_SPOCS_PLACEMENTS:
+      return { ...prevState,
+        spocs: { ...prevState.spocs,
+          placements: action.data.placements || INITIAL_STATE.DiscoveryStream.spocs.placements
+        }
+      };
+
     case Actions["actionTypes"].DISCOVERY_STREAM_SPOCS_UPDATE:
       if (action.data) {
         return { ...prevState,
           spocs: { ...prevState.spocs,
             lastUpdated: action.data.lastUpdated,
             data: action.data.spocs,
             loaded: true
           }
@@ -13612,17 +14368,17 @@ var reducers = {
   Dialog,
   Sections,
   Pocket,
   DiscoveryStream,
   Search
 };
 
 /***/ }),
-/* 60 */
+/* 62 */
 /***/ (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_);
@@ -13632,20 +14388,20 @@ var Trailhead = __webpack_require__(20);
 
 // EXTERNAL MODULE: ./content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx
 var ReturnToAMO = __webpack_require__(23);
 
 // EXTERNAL MODULE: ./content-src/asrouter/templates/StartupOverlay/StartupOverlay.jsx
 var StartupOverlay = __webpack_require__(24);
 
 // EXTERNAL MODULE: ./node_modules/fluent-react/src/index.js + 14 modules
-var src = __webpack_require__(56);
+var src = __webpack_require__(58);
 
 // EXTERNAL MODULE: ./content-src/asrouter/rich-text-strings.js + 8 modules
-var rich_text_strings = __webpack_require__(58);
+var rich_text_strings = __webpack_require__(60);
 
 // 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/. */
 
@@ -13904,415 +14660,34 @@ class FirstRun_FirstRun extends external
       flowParams: flowParams,
       onAction: executeAction
     }) : null);
   }
 
 }
 
 /***/ }),
-/* 61 */
-/***/ (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: external "ReactRedux"
-var external_ReactRedux_ = __webpack_require__(27);
-
-// EXTERNAL MODULE: ./content-src/components/ContextMenu/ContextMenu.jsx
-var ContextMenu = __webpack_require__(30);
-
-// CONCATENATED MODULE: ./content-src/lib/link-menu-options.js
-/* 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 _OpenInPrivateWindow = site => ({
-  id: "newtab-menu-open-new-private-window",
-  icon: "new-window-private",
-  action: Actions["actionCreators"].OnlyToMain({
-    type: Actions["actionTypes"].OPEN_PRIVATE_WINDOW,
-    data: {
-      url: site.url,
-      referrer: site.referrer
-    }
-  }),
-  userEvent: "OPEN_PRIVATE_WINDOW"
-});
-/**
- * List of functions that return items that can be included as menu options in a
- * LinkMenu. All functions take the site as the first parameter, and optionally
- * the index of the site.
- */
-
-
-const LinkMenuOptions = {
-  Separator: () => ({
-    type: "separator"
-  }),
-  EmptyItem: () => ({
-    type: "empty"
-  }),
-  RemoveBookmark: site => ({
-    id: "newtab-menu-remove-bookmark",
-    icon: "bookmark-added",
-    action: Actions["actionCreators"].AlsoToMain({
-      type: Actions["actionTypes"].DELETE_BOOKMARK_BY_ID,
-      data: site.bookmarkGuid
-    }),
-    userEvent: "BOOKMARK_DELETE"
-  }),
-  AddBookmark: site => ({
-    id: "newtab-menu-bookmark",
-    icon: "bookmark-hollow",
-    action: Actions["actionCreators"].AlsoToMain({
-      type: Actions["actionTypes"].BOOKMARK_URL,
-      data: {
-        url: site.url,
-        title: site.title,
-        type: site.type
-      }
-    }),
-    userEvent: "BOOKMARK_ADD"
-  }),
-  OpenInNewWindow: site => ({
-    id: "newtab-menu-open-new-window",
-    icon: "new-window",
-    action: Actions["actionCreators"].AlsoToMain({
-      type: Actions["actionTypes"].OPEN_NEW_WINDOW,
-      data: {
-        referrer: site.referrer,
-        typedBonus: site.typedBonus,
-        url: site.url
-      }
-    }),
-    userEvent: "OPEN_NEW_WINDOW"
-  }),
-  BlockUrl: (site, index, eventSource) => ({
-    id: "newtab-menu-dismiss",
-    icon: "dismiss",
-    action: Actions["actionCreators"].AlsoToMain({
-      type: Actions["actionTypes"].BLOCK_URL,
-      data: {
-        url: site.open_url || site.url,
-        pocket_id: site.pocket_id
-      }
-    }),
-    impression: Actions["actionCreators"].ImpressionStats({
-      source: eventSource,
-      block: 0,
-      tiles: [{
-        id: site.guid,
-        pos: index,
-        ...(site.shim && site.shim.delete ? {
-          shim: site.shim.delete
-        } : {})
-      }]
-    }),
-    userEvent: "BLOCK"
-  }),
-  // This is an option for web extentions which will result in remove items from
-  // memory and notify the web extenion, rather than using the built-in block list.
-  WebExtDismiss: (site, index, eventSource) => ({
-    id: "menu_action_webext_dismiss",
-    string_id: "newtab-menu-dismiss",
-    icon: "dismiss",
-    action: Actions["actionCreators"].WebExtEvent(Actions["actionTypes"].WEBEXT_DISMISS, {
-      source: eventSource,
-      url: site.url,
-      action_position: index
-    })
-  }),
-  DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({
-    id: "newtab-menu-delete-history",
-    icon: "delete",
-    action: {
-      type: Actions["actionTypes"].DIALOG_OPEN,
-      data: {
-        onConfirm: [Actions["actionCreators"].AlsoToMain({
-          type: Actions["actionTypes"].DELETE_HISTORY_URL,
-          data: {
-            url: site.url,
-            pocket_id: site.pocket_id,
-            forceBlock: site.bookmarkGuid
-          }
-        }), Actions["actionCreators"].UserEvent(Object.assign({
-          event: "DELETE",
-          source: eventSource,
-          action_position: index
-        }, siteInfo))],
-        eventSource,
-        body_string_id: ["newtab-confirm-delete-history-p1", "newtab-confirm-delete-history-p2"],
-        confirm_button_string_id: "newtab-topsites-delete-history-button",
-        cancel_button_string_id: "newtab-topsites-cancel-button",
-        icon: "modal-delete"
-      }
-    },
-    userEvent: "DIALOG_OPEN"
-  }),
-  ShowFile: site => ({
-    id: "newtab-menu-show-file",
-    icon: "search",
-    action: Actions["actionCreators"].OnlyToMain({
-      type: Actions["actionTypes"].SHOW_DOWNLOAD_FILE,
-      data: {
-        url: site.url
-      }
-    })
-  }),
-  OpenFile: site => ({
-    id: "newtab-menu-open-file",
-    icon: "open-file",
-    action: Actions["actionCreators"].OnlyToMain({
-      type: Actions["actionTypes"].OPEN_DOWNLOAD_FILE,
-      data: {
-        url: site.url
-      }
-    })
-  }),
-  CopyDownloadLink: site => ({
-    id: "newtab-menu-copy-download-link",
-    icon: "copy",
-    action: Actions["actionCreators"].OnlyToMain({
-      type: Actions["actionTypes"].COPY_DOWNLOAD_LINK,
-      data: {
-        url: site.url
-      }
-    })
-  }),
-  GoToDownloadPage: site => ({
-    id: "newtab-menu-go-to-download-page",
-    icon: "download",
-    action: Actions["actionCreators"].OnlyToMain({
-      type: Actions["actionTypes"].OPEN_LINK,
-      data: {
-        url: site.referrer
-      }
-    }),
-    disabled: !site.referrer
-  }),
-  RemoveDownload: site => ({
-    id: "newtab-menu-remove-download",
-    icon: "delete",
-    action: Actions["actionCreators"].OnlyToMain({
-      type: Actions["actionTypes"].REMOVE_DOWNLOAD_FILE,
-      data: {
-        url: site.url
-      }
-    })
-  }),
-  PinTopSite: ({
-    url,
-    searchTopSite,
-    label
-  }, index) => ({
-    id: "newtab-menu-pin",
-    icon: "pin",
-    action: Actions["actionCreators"].AlsoToMain({
-      type: Actions["actionTypes"].TOP_SITES_PIN,
-      data: {
-        site: {
-          url,
-          ...(searchTopSite && {
-            searchTopSite,
-            label
-          })
-        },
-        index
-      }
-    }),
-    userEvent: "PIN"
-  }),
-  UnpinTopSite: site => ({
-    id: "newtab-menu-unpin",
-    icon: "unpin",
-    action: Actions["actionCreators"].AlsoToMain({
-      type: Actions["actionTypes"].TOP_SITES_UNPIN,
-      data: {
-        site: {
-          url: site.url
-        }
-      }
-    }),
-    userEvent: "UNPIN"
-  }),
-  SaveToPocket: (site, index, eventSource) => ({
-    id: "newtab-menu-save-to-pocket",
-    icon: "pocket-save",
-    action: Actions["actionCreators"].AlsoToMain({
-      type: Actions["actionTypes"].SAVE_TO_POCKET,
-      data: {
-        site: {
-          url: site.url,
-          title: site.title
-        }
-      }
-    }),
-    impression: Actions["actionCreators"].ImpressionStats({
-      source: eventSource,
-      pocket: 0,
-      tiles: [{
-        id: site.guid,
-        pos: index,
-        ...(site.shim && site.shim.save ? {
-          shim: site.shim.save
-        } : {})
-      }]
-    }),
-    userEvent: "SAVE_TO_POCKET"
-  }),
-  DeleteFromPocket: site => ({
-    id: "newtab-menu-delete-pocket",
-    icon: "pocket-delete",
-    action: Actions["actionCreators"].AlsoToMain({
-      type: Actions["actionTypes"].DELETE_FROM_POCKET,
-      data: {
-        pocket_id: site.pocket_id
-      }
-    }),
-    userEvent: "DELETE_FROM_POCKET"
-  }),
-  ArchiveFromPocket: site => ({
-    id: "newtab-menu-archive-pocket",
-    icon: "pocket-archive",
-    action: Actions["actionCreators"].AlsoToMain({
-      type: Actions["actionTypes"].ARCHIVE_FROM_POCKET,
-      data: {
-        pocket_id: site.pocket_id
-      }
-    }),
-    userEvent: "ARCHIVE_FROM_POCKET"
-  }),
-  EditTopSite: (site, index) => ({
-    id: "newtab-menu-edit-topsites",
-    icon: "edit",
-    action: {
-      type: Actions["actionTypes"].TOP_SITES_EDIT,
-      data: {
-        index
-      }
-    }
-  }),
-  CheckBookmark: site => site.bookmarkGuid ? LinkMenuOptions.RemoveBookmark(site) : LinkMenuOptions.AddBookmark(site),
-  CheckPinTopSite: (site, index) => site.isPinned ? LinkMenuOptions.UnpinTopSite(site) : LinkMenuOptions.PinTopSite(site, index),
-  CheckSavedToPocket: (site, index) => site.pocket_id ? LinkMenuOptions.DeleteFromPocket(site) : LinkMenuOptions.SaveToPocket(site, index),
-  CheckBookmarkOrArchive: site => site.pocket_id ? LinkMenuOptions.ArchiveFromPocket(site) : LinkMenuOptions.CheckBookmark(site),
-  OpenInPrivateWindow: (site, index, eventSource, isEnabled) => isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem()
-};
-// EXTERNAL MODULE: external "React"
-var external_React_ = __webpack_require__(9);
-var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
-
-// CONCATENATED MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx
-/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_LinkMenu", function() { return LinkMenu_LinkMenu; });
-/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "LinkMenu", function() { return LinkMenu; });
-/* 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_SITE_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"];
-class LinkMenu_LinkMenu extends external_React_default.a.PureComponent {
-  getOptions() {
-    const {
-      props
-    } = this;
-    const {
-      site,
-      index,
-      source,
-      isPrivateBrowsingEnabled,
-      siteInfo,
-      platform
-    } = props; // Handle special case of default site
-
-    const propOptions = !site.isDefault || site.searchTopSite ? props.options : DEFAULT_SITE_MENU_OPTIONS;
-    const options = propOptions.map(o => LinkMenuOptions[o](site, index, source, isPrivateBrowsingEnabled, siteInfo, platform)).map(option => {
-      const {
-        action,
-        impression,
-        id,
-        type,
-        userEvent
-      } = option;
-
-      if (!type && id) {
-        option.onClick = () => {
-          props.dispatch(action);
-
-          if (userEvent) {
-            const userEventData = Object.assign({
-              event: userEvent,
-              source,
-              action_position: index
-            }, siteInfo);
-            props.dispatch(Actions["actionCreators"].UserEvent(userEventData));
-          }
-
-          if (impression && props.shouldSendImpressionStats) {
-            props.dispatch(impression);
-          }
-        };
-      }
-
-      return option;
-    }); // This is for accessibility to support making each item tabbable.
-    // We want to know which item is the first and which item
-    // is the last, so we can close the context menu accordingly.
-
-    options[0].first = true;
-    options[options.length - 1].last = true;
-    return options;
-  }
-
-  render() {
-    return external_React_default.a.createElement(ContextMenu["ContextMenu"], {
-      onUpdate: this.props.onUpdate,
-      onShow: this.props.onShow,
-      options: this.getOptions(),
-      keyboardAccess: this.props.keyboardAccess
-    });
-  }
-
-}
-
-const getState = state => ({
-  isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled,
-  platform: state.Prefs.values.platform
-});
-
-const LinkMenu = Object(external_ReactRedux_["connect"])(getState)(LinkMenu_LinkMenu);
-
-/***/ }),
-/* 62 */
+/* 63 */
 /***/ (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__(37);
+var A11yLinkButton = __webpack_require__(39);
 
 // 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__(50);
+var TopSitesConstants = __webpack_require__(52);
 
 // 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) {
@@ -14417,17 +14792,17 @@ class TopSiteFormInput_TopSiteFormInput 
 
 }
 TopSiteFormInput_TopSiteFormInput.defaultProps = {
   showClearButton: false,
   value: "",
   validationError: false
 };
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSite.jsx
-var TopSite = __webpack_require__(52);
+var TopSite = __webpack_require__(54);
 
 // 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/lib/DiscoveryStreamFeed.jsm
+++ b/browser/components/newtab/lib/DiscoveryStreamFeed.jsm
@@ -87,16 +87,17 @@ this.DiscoveryStreamFeed = class Discove
     }
     return impressionId;
   }
 
   /**
    * Send SPOCS Fill telemetry.
    * @param {object} filteredItems An object keyed on filter reasons, and the value
    *                 is a list of SPOCS.
+   *                 reasons: blocked_by_user, frequency_cap, below_min_score, campaign_duplicate
    * @param {boolean} fullRecalc A boolean indicating if it's a full recalculation.
    *                  Calling `loadSpocs` will be treated as a full recalculation.
    *                  Whereas responding the action "DISCOVERY_STREAM_SPOC_IMPRESSION"
    *                  is not a full recalculation.
    */
   _sendSpocsFill(filteredItems, fullRecalc) {
     const full_recalc = fullRecalc ? 1 : 0;
     const spocsFill = [];
@@ -326,57 +327,81 @@ this.DiscoveryStreamFeed = class Discove
         await this.cache.set("layout", layout);
       } else {
         Cu.reportError("No response for response.layout prop");
       }
     }
     return layout;
   }
 
+  updatePlacements(sendUpdate, layout) {
+    const placements = [];
+    const placementsMap = {};
+    for (const row of layout.filter(r => r.components && r.components.length)) {
+      for (const component of row.components) {
+        if (component.placement) {
+          // Throw away any dupes for the request.
+          if (!placementsMap[component.placement.name]) {
+            placementsMap[component.placement.name] = component.placement;
+            placements.push(component.placement);
+          }
+        }
+      }
+    }
+    if (placements.length) {
+      sendUpdate({
+        type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
+        data: { placements },
+      });
+    }
+  }
+
   async loadLayout(sendUpdate, isStartup) {
-    let layout = {};
+    let layoutResp = {};
     if (!this.config.hardcoded_layout) {
-      layout = await this.fetchLayout(isStartup);
+      layoutResp = await this.fetchLayout(isStartup);
     }
 
-    if (!layout || !layout.layout) {
+    if (!layoutResp || !layoutResp.layout) {
       if (
         this.config.hardcoded_basic_layout ||
         this.store.getState().Prefs.values[PREF_HARDCODED_BASIC_LAYOUT]
       ) {
-        layout = { lastUpdate: Date.now(), ...basicLayoutResp };
+        layoutResp = { lastUpdate: Date.now(), ...basicLayoutResp };
       } else {
-        layout = { lastUpdate: Date.now(), ...defaultLayoutResp };
+        layoutResp = { lastUpdate: Date.now(), ...defaultLayoutResp };
       }
     }
 
     if (
-      layout.spocs &&
+      layoutResp.spocs &&
       (this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT] ||
         this.config.spocs_endpoint)
     ) {
-      layout.spocs.url =
+      layoutResp.spocs.url =
         this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT] ||
         this.config.spocs_endpoint;
     }
 
     sendUpdate({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
-      data: layout,
+      data: layoutResp,
     });
+
     if (
-      layout.spocs &&
-      layout.spocs.url &&
-      layout.spocs.url !==
+      layoutResp.spocs &&
+      layoutResp.spocs.url &&
+      layoutResp.spocs.url !==
         this.store.getState().DiscoveryStream.spocs.spocs_endpoint
     ) {
       sendUpdate({
         type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
-        data: layout.spocs,
+        data: layoutResp.spocs,
       });
+      this.updatePlacements(sendUpdate, layoutResp.layout);
     }
   }
 
   /**
    * buildFeedPromise - Adds the promise result to newFeeds and
    *                    pushes a promise to newsFeedsPromises.
    * @param {Object} Has both newFeedsPromises (Array) and newFeeds (Object)
    * @param {Boolean} isStartup We have different cache handling for startup.
@@ -434,20 +459,25 @@ this.DiscoveryStreamFeed = class Discove
 
   filterRecommendations(feed) {
     if (
       feed &&
       feed.data &&
       feed.data.recommendations &&
       feed.data.recommendations.length
     ) {
-      const { data } = this.filterBlocked(feed.data, "recommendations");
+      const { data: recommendations } = this.filterBlocked(
+        feed.data.recommendations
+      );
       return {
         ...feed,
-        data,
+        data: {
+          ...feed.data,
+          recommendations,
+        },
       };
     }
     return feed;
   }
 
   /**
    * reduceFeedComponents - Filters out components with no feeds, and combines
    *                        all feeds on this component with the feeds from other components.
@@ -507,22 +537,34 @@ this.DiscoveryStreamFeed = class Discove
       this.componentFeedRequestTime = Math.round(perfService.absNow() - start);
     }
     await this.cache.set("feeds", newFeeds);
     sendUpdate({
       type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
     });
   }
 
+  placementsForEach(callback) {
+    const { placements } = this.store.getState().DiscoveryStream.spocs;
+    // 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);
+    }
+  }
+
   async loadSpocs(sendUpdate, isStartup) {
     const cachedData = (await this.cache.get()) || {};
-    let spocs;
+    let spocsState;
+
+    const { placements } = this.store.getState().DiscoveryStream.spocs;
 
     if (this.showSpocs) {
-      spocs = cachedData.spocs;
+      spocsState = cachedData.spocs;
       if (this.isExpired({ cachedData, key: "spocs", isStartup })) {
         const endpoint = this.store.getState().DiscoveryStream.spocs
           .spocs_endpoint;
         const start = perfService.absNow();
 
         const headers = new Headers();
         headers.append("content-type", "application/json");
 
@@ -531,59 +573,104 @@ this.DiscoveryStreamFeed = class Discove
 
         const spocsResponse = await this.fetchFromEndpoint(endpoint, {
           method: "POST",
           headers,
           body: JSON.stringify({
             pocket_id: this._impressionId,
             version: 1,
             consumer_key: apiKey,
+            ...(placements.length ? { placements } : {}),
           }),
         });
 
         if (spocsResponse) {
           this.spocsRequestTime = Math.round(perfService.absNow() - start);
-          spocs = {
+          spocsState = {
             lastUpdated: Date.now(),
-            data: spocsResponse,
+            spocs: {
+              ...spocsResponse,
+            },
           };
 
-          this.cleanUpCampaignImpressionPref(spocs.data);
-          await this.cache.set("spocs", spocs);
+          this.cleanUpCampaignImpressionPref(spocsState.spocs);
+          await this.cache.set("spocs", spocsState);
         } else {
           Cu.reportError("No response for spocs_endpoint prop");
         }
       }
     }
 
     // Use good data if we have it, otherwise nothing.
     // We can have no data if spocs set to off.
     // We can have no data if request fails and there is no good cache.
     // We want to send an update spocs or not, so client can render something.
-    spocs =
-      spocs && spocs.data
-        ? spocs
+    spocsState =
+      spocsState && spocsState.spocs
+        ? spocsState
         : {
             lastUpdated: Date.now(),
-            data: {},
+            spocs: {},
           };
 
-    let { data, filtered: frequencyCapped } = this.frequencyCapSpocs(
-      spocs.data
-    );
-    let { data: newSpocs, filtered } = this.transform(data);
+    let frequencyCapped = [];
+    let blockedItems = [];
+    let belowMinScore = [];
+    let campaignDupes = [];
+    this.placementsForEach(placement => {
+      const freshSpocs = spocsState.spocs[placement.name];
+
+      if (!freshSpocs || !freshSpocs.length) {
+        return;
+      }
+
+      const { data: capResult, filtered: caps } = this.frequencyCapSpocs(
+        freshSpocs
+      );
+      frequencyCapped = [...frequencyCapped, ...caps];
+
+      const { data: blockedResults, filtered: blocks } = this.filterBlocked(
+        capResult
+      );
+      blockedItems = [...blockedItems, ...blocks];
+
+      let { data: transformResult, filtered: transformFilter } = this.transform(
+        blockedResults
+      );
+      let {
+        below_min_score: minScoreFilter,
+        campaign_duplicate: dupes,
+      } = transformFilter;
+      belowMinScore = [...belowMinScore, ...minScoreFilter];
+      campaignDupes = [...campaignDupes, ...dupes];
+
+      spocsState.spocs = {
+        ...spocsState.spocs,
+        [placement.name]: transformResult,
+      };
+    });
 
     sendUpdate({
       type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
       data: {
-        lastUpdated: spocs.lastUpdated,
-        spocs: newSpocs,
+        lastUpdated: spocsState.lastUpdated,
+        spocs: spocsState.spocs,
       },
     });
-    this._sendSpocsFill({ ...filtered, frequency_cap: frequencyCapped }, true);
+    // TODO make sure this works in other places we use it.
+    // TODO make sure to also validate all of these that they still contain the right ites in the array.
+    this._sendSpocsFill(
+      {
+        frequency_cap: frequencyCapped,
+        blocked_by_user: blockedItems,
+        below_min_score: belowMinScore,
+        campaign_duplicate: campaignDupes,
+      },
+      true
+    );
   }
 
   async clearSpocs() {
     const endpoint = this.store.getState().Prefs.values[
       PREF_SPOCS_CLEAR_ENDPOINT
     ];
     if (!endpoint) {
       return;
@@ -678,106 +765,103 @@ this.DiscoveryStreamFeed = class Discove
       );
       if (scoreResult === 0 || scoreResult) {
         item.score = scoreResult;
       }
     }
     return item;
   }
 
-  filterBlocked(data, type) {
+  filterBlocked(data) {
     const filtered = [];
-    if (data && data[type] && data[type].length) {
-      const filteredItems = data[type].filter(item => {
+    if (data && data.length) {
+      const filteredItems = data.filter(item => {
         const blocked = NewTabUtils.blockedLinks.isBlocked({ url: item.url });
         if (blocked) {
           filtered.push(item);
         }
         return !blocked;
       });
       return {
-        data: {
-          ...data,
-          [type]: filteredItems,
-        },
+        data: filteredItems,
         filtered,
       };
     }
     return { data, filtered };
   }
 
   transform(spocs) {
-    const { data, filtered: blockedItems } = this.filterBlocked(spocs, "spocs");
-    if (data && data.spocs && data.spocs.length) {
+    if (spocs && spocs.length) {
       const spocsPerDomain =
         this.store.getState().DiscoveryStream.spocs.spocs_per_domain || 1;
       const campaignMap = {};
       const campaignDuplicates = [];
 
       // This order of operations is intended.
       // scoreItems must be first because it creates this.score.
       const { data: items, filtered: belowMinScoreItems } = this.scoreItems(
-        data.spocs
+        spocs
       );
       // This removes campaign dupes.
       // We do this only after scoring and sorting because that way
       // we can keep the first item we see, and end up keeping the highest scored.
       const newSpocs = items.filter(s => {
         if (!campaignMap[s.campaign_id]) {
           campaignMap[s.campaign_id] = 1;
           return true;
         } else if (campaignMap[s.campaign_id] < spocsPerDomain) {
           campaignMap[s.campaign_id]++;
           return true;
         }
         campaignDuplicates.push(s);
         return false;
       });
       return {
-        data: { ...data, spocs: newSpocs },
+        data: newSpocs,
         filtered: {
-          blocked_by_user: blockedItems,
           below_min_score: belowMinScoreItems,
           campaign_duplicate: campaignDuplicates,
         },
       };
     }
-    return { data, filtered: { blocked: blockedItems } };
+    return {
+      data: spocs,
+      filtered: {
+        below_min_score: [],
+        campaign_duplicate: [],
+      },
+    };
   }
 
   // Filter spocs based on frequency caps
   //
   // @param {Object} data  An object that might have a SPOCS array.
   // @returns {Object} An object with a property `data` as the result, and a property
   //                   `filterItems` as the frequency capped items.
-  frequencyCapSpocs(data) {
-    if (data && data.spocs && data.spocs.length) {
-      const { spocs } = data;
+  frequencyCapSpocs(spocs) {
+    if (spocs && spocs.length) {
       const impressions = this.readImpressionsPref(PREF_SPOC_IMPRESSIONS);
       const caps = [];
-      const result = {
-        ...data,
-        spocs: spocs.filter(s => {
-          const isBelow = this.isBelowFrequencyCap(impressions, s);
-          if (!isBelow) {
-            caps.push(s);
-          }
-          return isBelow;
-        }),
-      };
+      const result = spocs.filter(s => {
+        const isBelow = this.isBelowFrequencyCap(impressions, s);
+        if (!isBelow) {
+          caps.push(s);
+        }
+        return isBelow;
+      });
       // send caps to redux if any.
       if (caps.length) {
         this.store.dispatch({
           type: at.DISCOVERY_STREAM_SPOCS_CAPS,
           data: caps,
         });
       }
       return { data: result, filtered: caps };
     }
-    return { data, filtered: [] };
+    return { data: spocs, filtered: [] };
   }
 
   // Frequency caps are based on campaigns, which may include multiple spocs.
   // We currently support two types of frequency caps:
   // - lifetime: Indicates how many times spocs from a campaign can be shown in total
   // - period: Indicates how many times spocs from a campaign can be shown within a period
   //
   // So, for example, the feed configuration below defines that for campaign 1 no more
@@ -901,17 +985,17 @@ this.DiscoveryStreamFeed = class Discove
     const dispatch = updateOpenTabs
       ? action => this.store.dispatch(ac.BroadcastToContent(action))
       : this.store.dispatch;
 
     this.loadAffinityScoresCache();
     await this.loadLayout(dispatch, isStartup);
     await Promise.all([
       this.loadSpocs(dispatch, isStartup).catch(error =>
-        Cu.reportError(`Error trying to load spocs feed: ${error}`)
+        Cu.reportError(`Error trying to load spocs feeds: ${error}`)
       ),
       this.loadComponentFeeds(dispatch, isStartup).catch(error =>
         Cu.reportError(`Error trying to load component feeds: ${error}`)
       ),
     ]);
     if (isStartup) {
       await this._maybeUpdateCachedData();
     }
@@ -1093,18 +1177,25 @@ this.DiscoveryStreamFeed = class Discove
     let impressions = this.readImpressionsPref(PREF_REC_IMPRESSIONS);
     if (!impressions[recId]) {
       impressions = { ...impressions, [recId]: Date.now() };
       this.writeImpressionsPref(PREF_REC_IMPRESSIONS, impressions);
     }
   }
 
   cleanUpCampaignImpressionPref(data) {
-    if (data.spocs && data.spocs.length) {
-      const campaignIds = data.spocs.map(s => `${s.campaign_id}`);
+    let campaignIds = [];
+    this.placementsForEach(placement => {
+      const newSpocs = data[placement.name];
+      if (!newSpocs) {
+        return;
+      }
+      campaignIds = [...campaignIds, ...newSpocs.map(s => `${s.campaign_id}`)];
+    });
+    if (campaignIds && campaignIds.length) {
       this.cleanUpImpressionPref(
         id => !campaignIds.includes(id),
         PREF_SPOC_IMPRESSIONS
       );
     }
   }
 
   // Clean up rec impression pref by removing all stories that are no
@@ -1202,38 +1293,59 @@ this.DiscoveryStreamFeed = class Discove
         }
         break;
       case at.DISCOVERY_STREAM_SPOC_IMPRESSION:
         if (this.showSpocs) {
           this.recordCampaignImpression(action.data.campaignId);
 
           // Apply frequency capping to SPOCs in the redux store, only update the
           // store if the SPOCs are changed.
-          const { spocs } = this.store.getState().DiscoveryStream;
-          const { data: newSpocs, filtered } = this.frequencyCapSpocs(
-            spocs.data
-          );
-          if (filtered.length) {
+          const spocsState = this.store.getState().DiscoveryStream.spocs;
+
+          let frequencyCapped = [];
+          this.placementsForEach(placement => {
+            const freshSpocs = spocsState.data[placement.name];
+            if (!freshSpocs) {
+              return;
+            }
+
+            const { data: newSpocs, filtered } = this.frequencyCapSpocs(
+              freshSpocs
+            );
+            frequencyCapped = [...frequencyCapped, ...filtered];
+
+            spocsState.data = {
+              ...spocsState.data,
+              [placement.name]: newSpocs,
+            };
+          });
+          if (frequencyCapped.length) {
             this.store.dispatch(
               ac.AlsoToPreloaded({
                 type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
                 data: {
-                  lastUpdated: spocs.lastUpdated,
-                  spocs: newSpocs,
+                  lastUpdated: spocsState.lastUpdated,
+                  spocs: spocsState.data,
                 },
               })
             );
-            this._sendSpocsFill({ frequency_cap: filtered }, false);
+            this._sendSpocsFill({ frequency_cap: frequencyCapped }, false);
           }
         }
         break;
       case at.PLACES_LINK_BLOCKED:
         if (this.showSpocs) {
-          const { spocs } = this.store.getState().DiscoveryStream;
-          const spocsList = spocs.data.spocs || [];
+          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];
+            }
+          });
           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.
             this.store.dispatch(
               ac.AlsoToPreloaded({
                 type: at.DISCOVERY_STREAM_LINK_BLOCKED,
@@ -1241,17 +1353,17 @@ this.DiscoveryStreamFeed = class Discove
               })
             );
             this.store.dispatch(
               ac.BroadcastToContent({
                 type: at.DISCOVERY_STREAM_SPOC_BLOCKED,
                 data: action.data,
               })
             );
-            return;
+            break;
           }
         }
         this.store.dispatch(
           ac.BroadcastToContent({
             type: at.DISCOVERY_STREAM_LINK_BLOCKED,
             data: action.data,
           })
         );
@@ -1307,17 +1419,16 @@ defaultLayoutResp = {
     {
       width: 12,
       components: [
         {
           type: "TopSites",
           header: {
             title: "Top Sites",
           },
-          properties: {},
         },
       ],
     },
     {
       width: 12,
       components: [
         {
           type: "Message",
--- a/browser/components/newtab/package-lock.json
+++ b/browser/components/newtab/package-lock.json
@@ -6544,16 +6544,22 @@
       }
     },
     "karma-firefox-launcher": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-1.1.0.tgz",
       "integrity": "sha512-LbZ5/XlIXLeQ3cqnCbYLn+rOVhuMIK9aZwlP6eOLGzWdo1UVp7t6CN3DP4SafiRLjexKwHeKHDm0c38Mtd3VxA==",
       "dev": true
     },
+    "karma-json-reporter": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/karma-json-reporter/-/karma-json-reporter-1.2.1.tgz",
+      "integrity": "sha512-ASmvranNhUN0ctSuAZKeWISW9Nf4AteMcVy8rJVjS7Qk+qWgssag/nw+yivHWKDROztVFn7TdamHOETMPCkvgA==",
+      "dev": true
+    },
     "karma-mocha": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-1.3.0.tgz",
       "integrity": "sha1-7qrH/8DiAetjxGdEDStpx883eL8=",
       "dev": true,
       "requires": {
         "minimist": "1.2.0"
       }
--- a/browser/components/newtab/package.json
+++ b/browser/components/newtab/package.json
@@ -41,16 +41,17 @@
     "eslint-plugin-react": "7.13.0",
     "eslint-plugin-react-hooks": "1.6.0",
     "istanbul-instrumenter-loader": "3.0.1",
     "joi-browser": "13.4.0",
     "karma": "4.1.0",
     "karma-chai": "0.1.0",
     "karma-coverage-istanbul-reporter": "2.0.5",
     "karma-firefox-launcher": "1.1.0",
+    "karma-json-reporter": "1.2.1",
     "karma-mocha": "1.3.0",
     "karma-mocha-reporter": "2.2.5",
     "karma-sinon": "1.0.5",
     "karma-sourcemap-loader": "0.3.7",
     "karma-webpack": "3.0.5",
     "loader-utils": "1.2.3",
     "lodash": "4.17.14",
     "minimist": "1.2.0",
--- a/browser/components/newtab/test/unit/common/Reducers.test.js
+++ b/browser/components/newtab/test/unit/common/Reducers.test.js
@@ -951,16 +951,32 @@ describe("Reducers", () => {
     it("should set spoc_endpoint and spocs_per_domain with DISCOVERY_STREAM_SPOCS_ENDPOINT", () => {
       const state = DiscoveryStream(undefined, {
         type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
         data: { url: "foo.com", spocs_per_domain: 2 },
       });
       assert.equal(state.spocs.spocs_endpoint, "foo.com");
       assert.equal(state.spocs.spocs_per_domain, 2);
     });
+    it("should use initial state with DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
+      const state = DiscoveryStream(undefined, {
+        type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
+        data: {},
+      });
+      assert.deepEqual(state.spocs.placements, []);
+    });
+    it("should set placements with DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
+      const state = DiscoveryStream(undefined, {
+        type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
+        data: {
+          placements: [1, 2, 3],
+        },
+      });
+      assert.deepEqual(state.spocs.placements, [1, 2, 3]);
+    });
     it("should set spocs with DISCOVERY_STREAM_SPOCS_UPDATE", () => {
       const data = {
         lastUpdated: 123,
         spocs: [1, 2, 3],
       };
       const state = DiscoveryStream(undefined, {
         type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
         data,
@@ -968,16 +984,17 @@ describe("Reducers", () => {
       assert.deepEqual(state.spocs, {
         spocs_endpoint: "",
         spocs_per_domain: 1,
         data: [1, 2, 3],
         lastUpdated: 123,
         loaded: true,
         frequency_caps: [],
         blocked: [],
+        placements: [],
       });
     });
     it("should handle no data from DISCOVERY_STREAM_SPOCS_UPDATE", () => {
       const data = null;
       const state = DiscoveryStream(undefined, {
         type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
         data,
       });
@@ -1016,16 +1033,17 @@ describe("Reducers", () => {
       const deleteAction = {
         type: at.DISCOVERY_STREAM_LINK_BLOCKED,
         data: { url: "foo.com" },
       };
       const oldState = {
         spocs: {
           data: {},
           loaded: true,
+          placements: [{ name: "spocs" }],
         },
         feeds: {
           data: {},
           loaded: true,
         },
       };
       const newState = DiscoveryStream(oldState, deleteAction);
       assert.deepEqual(newState, oldState);
@@ -1036,16 +1054,17 @@ describe("Reducers", () => {
         data: { url: "https://foo.com" },
       };
       const oldState = {
         spocs: {
           data: {
             spocs: [{ 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" }]);
@@ -1054,16 +1073,17 @@ describe("Reducers", () => {
       const deleteAction = {
         type: at.DISCOVERY_STREAM_LINK_BLOCKED,
         data: { url: "https://foo.com" },
       };
       const oldState = {
         spocs: {
           data: {},
           loaded: true,
+          placements: [{ name: "spocs" }],
         },
         feeds: {
           data: {
             "https://foo.com/feed1": {
               data: {
                 recommendations: [
                   { url: "https://foo.com" },
                   { url: "test.com" },
@@ -1095,16 +1115,17 @@ describe("Reducers", () => {
           },
           loaded: true,
         },
         spocs: {
           data: {
             spocs: [{ 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" }]);
@@ -1133,16 +1154,17 @@ describe("Reducers", () => {
             },
           },
           loaded: true,
         },
         spocs: {
           data: {
             spocs: [{ 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,
@@ -1203,16 +1225,17 @@ describe("Reducers", () => {
         spocs: {
           data: {
             spocs: [
               { 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,
         },
       };
@@ -1242,16 +1265,17 @@ describe("Reducers", () => {
         spocs: {
           data: {
             spocs: [
               { 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,
         },
       };
@@ -1278,16 +1302,17 @@ describe("Reducers", () => {
           },
           loaded: true,
         },
         spocs: {
           data: {
             spocs: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
           },
           loaded: true,
+          placements: [{ name: "spocs" }],
         },
       };
       const bookmarkAction = {
         type: at.PLACES_BOOKMARK_ADDED,
         data: {
           url: "https://foo.com",
           bookmarkGuid: "bookmark123",
           bookmarkTitle: "Title for bar.com",
@@ -1354,16 +1379,17 @@ describe("Reducers", () => {
                 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",
         },
       };
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx
@@ -0,0 +1,68 @@
+import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<DSTextPromo>", () => {
+  const fakeSpoc = {
+    url: "https://foo.com",
+    guid: "1234",
+  };
+  let wrapper;
+  let sandbox;
+  let dispatchStub;
+
+  beforeEach(() => {
+    sandbox = sinon.createSandbox();
+    dispatchStub = sandbox.stub();
+    wrapper = shallow(
+      <DSDismiss
+        data={fakeSpoc}
+        dispatch={dispatchStub}
+        shouldSendImpressionStats={true}
+      />
+    );
+  });
+
+  afterEach(() => {
+    sandbox.restore();
+  });
+
+  it("should render", () => {
+    assert.ok(wrapper.exists());
+    assert.ok(wrapper.find(".ds-dismiss").exists());
+  });
+
+  it("should render proper hover state", () => {
+    wrapper.instance().onHover();
+    assert.ok(wrapper.find(".hovering").exists());
+    wrapper.instance().offHover();
+    assert.ok(!wrapper.find(".hovering").exists());
+  });
+
+  it("should dispatch a BlockUrl event on click", () => {
+    wrapper.instance().onDismissClick();
+
+    assert.calledThrice(dispatchStub);
+    assert.deepEqual(dispatchStub.firstCall.args[0].data, {
+      url: "https://foo.com",
+      pocket_id: undefined,
+    });
+    assert.deepEqual(dispatchStub.secondCall.args[0].data, {
+      event: "BLOCK",
+      source: "DISCOVERY_STREAM",
+      action_position: 0,
+      url: "https://foo.com",
+      guid: "1234",
+    });
+    assert.deepEqual(dispatchStub.thirdCall.args[0].data, {
+      source: "DISCOVERY_STREAM",
+      block: 0,
+      tiles: [
+        {
+          id: "1234",
+          pos: 0,
+        },
+      ],
+    });
+  });
+});
--- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx
@@ -1,26 +1,58 @@
 import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo";
 import React from "react";
 import { shallow } from "enzyme";
 
 describe("<DSTextPromo>", () => {
   let wrapper;
+  let sandbox;
+  let dispatchStub;
 
   beforeEach(() => {
-    wrapper = shallow(<DSTextPromo />);
+    sandbox = sinon.createSandbox();
+    dispatchStub = sandbox.stub();
+    wrapper = shallow(
+      <DSTextPromo
+        shim={{ impression: "1234" }}
+        type="TEXTPROMO"
+        pos={0}
+        dispatch={dispatchStub}
+        id="1234"
+      />
+    );
+  });
+
+  afterEach(() => {
+    sandbox.restore();
   });
 
   it("should render", () => {
     assert.ok(wrapper.exists());
     assert.ok(wrapper.find(".ds-text-promo").exists());
   });
 
   it("should render a header", () => {
     wrapper.setProps({ header: "foo" });
     assert.ok(wrapper.find(".text").exists());
   });
 
   it("should render a subtitle", () => {
     wrapper.setProps({ subtitle: "foo" });
     assert.ok(wrapper.find(".subtitle").exists());
   });
+
+  it("should dispatch a click event on click", () => {
+    wrapper.instance().onLinkClick();
+
+    assert.calledTwice(dispatchStub);
+    assert.deepEqual(dispatchStub.firstCall.args[0].data, {
+      event: "CLICK",
+      source: "TEXTPROMO",
+      action_position: 0,
+    });
+    assert.deepEqual(dispatchStub.secondCall.args[0].data, {
+      source: "TEXTPROMO",
+      click: 0,
+      tiles: [{ id: "1234", pos: 0 }],
+    });
+  });
 });
--- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopSites.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopSites.test.jsx
@@ -3,28 +3,40 @@ import {
   INITIAL_STATE,
   reducers,
   TOP_SITES_DEFAULT_ROWS,
 } from "common/Reducers.jsm";
 import { mount } from "enzyme";
 import { TopSites as OldTopSites } from "content-src/components/TopSites/TopSites";
 import { Provider } from "react-redux";
 import React from "react";
-import { TopSites } from "content-src/components/DiscoveryStreamComponents/TopSites/TopSites";
+import {
+  TopSites as TopSitesContainer,
+  _TopSites as TopSites,
+} from "content-src/components/DiscoveryStreamComponents/TopSites/TopSites";
 
 describe("Discovery Stream <TopSites>", () => {
   let wrapper;
   let store;
+  const defaultTopSiteRows = [
+    { label: "facebook" },
+    { label: "amazon" },
+    { label: "google" },
+    { label: "apple" },
+  ];
+  const defaultTopSites = {
+    rows: defaultTopSiteRows,
+  };
 
   beforeEach(() => {
     INITIAL_STATE.Prefs.values.topSitesRows = TOP_SITES_DEFAULT_ROWS;
     store = createStore(combineReducers(reducers), INITIAL_STATE);
     wrapper = mount(
       <Provider store={store}>
-        <TopSites />
+        <TopSitesContainer TopSites={defaultTopSites} />
       </Provider>
     );
   });
 
   afterEach(() => {
     wrapper.unmount();
   });
 
@@ -44,16 +56,192 @@ describe("Discovery Stream <TopSites>", 
     });
 
     it("should set header title on old TopSites", () => {
       let DEFAULT_PROPS = {
         header: { title: "test" },
       };
       wrapper = mount(
         <Provider store={store}>
-          <TopSites {...DEFAULT_PROPS} />
+          <TopSitesContainer {...DEFAULT_PROPS} />
         </Provider>
       );
       const oldTopSites = wrapper.find(OldTopSites);
       assert.equal(oldTopSites.props().title, "test");
     });
   });
+
+  describe("insertSpocContent", () => {
+    let insertSpocContent;
+    const topSiteSpoc = {
+      url: "foo",
+      sponsor: "bar",
+      image_src: "foobar",
+      campaign_id: "1234",
+      id: "5678",
+      shim: { impression: "1011" },
+    };
+    const data = { spocs: [topSiteSpoc] };
+    const resultSpocLeft = {
+      customScreenshotURL: "foobar",
+      type: "SPOC",
+      label: "bar",
+      title: "bar",
+      url: "foo",
+      campaignId: "1234",
+      id: "5678",
+      guid: "5678",
+      shim: {
+        impression: "1011",
+      },
+      pos: 0,
+    };
+    const resultSpocRight = {
+      customScreenshotURL: "foobar",
+      type: "SPOC",
+      label: "bar",
+      title: "bar",
+      url: "foo",
+      campaignId: "1234",
+      id: "5678",
+      guid: "5678",
+      shim: {
+        impression: "1011",
+      },
+      pos: 7,
+    };
+    const pinnedSite = {
+      label: "pinnedSite",
+      isPinned: true,
+    };
+
+    beforeEach(() => {
+      const instance = wrapper.find(TopSites).instance();
+      insertSpocContent = instance.insertSpocContent.bind(instance);
+    });
+
+    it("Should return null if no data or no TopSites", () => {
+      assert.isNull(insertSpocContent(defaultTopSites, {}, "right"));
+      assert.isNull(insertSpocContent({}, data, "right"));
+    });
+
+    it("Should return null if an organic SPOC topsite exists", () => {
+      const topSitesWithOrganicSpoc = {
+        rows: [...defaultTopSiteRows, topSiteSpoc],
+      };
+
+      assert.isNull(insertSpocContent(topSitesWithOrganicSpoc, data, "right"));
+    });
+
+    it("Should return next spoc if the first SPOC is an existing organic top site", () => {
+      const topSitesWithOrganicSpoc = {
+        rows: [...defaultTopSiteRows, topSiteSpoc],
+      };
+      const extraSpocData = {
+        spocs: [
+          topSiteSpoc,
+          {
+            url: "foo2",
+            sponsor: "bar2",
+            image_src: "foobar2",
+            campaign_id: "1234",
+            id: "5678",
+            shim: { impression: "1011" },
+          },
+        ],
+      };
+
+      const result = insertSpocContent(
+        topSitesWithOrganicSpoc,
+        extraSpocData,
+        "right"
+      );
+
+      const availableSpoc = {
+        customScreenshotURL: "foobar2",
+        type: "SPOC",
+        label: "bar2",
+        title: "bar2",
+        url: "foo2",
+        campaignId: "1234",
+        id: "5678",
+        guid: "5678",
+        shim: {
+          impression: "1011",
+        },
+        pos: 7,
+      };
+      const expectedResult = {
+        rows: [...topSitesWithOrganicSpoc.rows, availableSpoc],
+      };
+
+      assert.deepEqual(result, expectedResult);
+    });
+
+    it("should add to end of row if the row is not full and alignment is right", () => {
+      const result = insertSpocContent(defaultTopSites, data, "right");
+
+      const expectedResult = {
+        rows: [...defaultTopSiteRows, resultSpocRight],
+      };
+      assert.deepEqual(result, expectedResult);
+    });
+
+    it("should add to front of row if the row is not full and alignment is left", () => {
+      const result = insertSpocContent(defaultTopSites, data, "left");
+      assert.deepEqual(result, {
+        rows: [resultSpocLeft, ...defaultTopSiteRows],
+      });
+    });
+
+    it("should add to first available in the front row if alignment is left and there are pins", () => {
+      const topSiteRowsWithPins = [
+        pinnedSite,
+        pinnedSite,
+        ...defaultTopSiteRows,
+      ];
+
+      const result = insertSpocContent(
+        { rows: topSiteRowsWithPins },
+        data,
+        "left"
+      );
+
+      assert.deepEqual(result, {
+        rows: [pinnedSite, pinnedSite, resultSpocLeft, ...defaultTopSiteRows],
+      });
+    });
+
+    it("should add to first available in the next row if alignment is right and there are all pins in the front row", () => {
+      const pinnedArray = new Array(8).fill(pinnedSite);
+      const result = insertSpocContent({ rows: pinnedArray }, data, "right");
+
+      assert.deepEqual(result, {
+        rows: [...pinnedArray, resultSpocRight],
+      });
+    });
+
+    it("should add to first available in the current row if alignment is right and there are some pins in the front row", () => {
+      const pinnedArray = new Array(6).fill(pinnedSite);
+      const topSite = { label: "foo" };
+
+      const rowsWithPins = [topSite, topSite, ...pinnedArray];
+
+      const result = insertSpocContent({ rows: rowsWithPins }, data, "right");
+
+      assert.deepEqual(result, {
+        rows: [topSite, resultSpocRight, ...pinnedArray, topSite],
+      });
+    });
+
+    it("should preserve the indices of pinned items", () => {
+      const topSite = { label: "foo" };
+      const rowsWithPins = [pinnedSite, topSite, topSite, pinnedSite];
+
+      const result = insertSpocContent({ rows: rowsWithPins }, data, "left");
+
+      // Pinned items should retain in Index 0 and Index 3 like defined in rowsWithPins
+      assert.deepEqual(result, {
+        rows: [pinnedSite, resultSpocLeft, topSite, pinnedSite, topSite],
+      });
+    });
+  });
 });
--- a/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx
@@ -477,10 +477,47 @@ describe("<LinkMenu>", () => {
             option.onClick();
             assert.calledTwice(dispatch);
             assert.notEqual(dispatch.firstCall.args[0], option.impression);
             assert.notEqual(dispatch.secondCall.args[0], option.impression);
             dispatch.reset();
           }
         });
     });
+    it(`should pin a SPOC with all of the site details sent`, () => {
+      const pinSpocTopSite = "PinSpocTopSite";
+      const { options: spocOptions } = shallow(
+        <LinkMenu
+          site={FAKE_SITE}
+          siteInfo={{ value: { card_type: FAKE_SITE.type } }}
+          dispatch={dispatch}
+          index={FAKE_INDEX}
+          isPrivateBrowsingEnabled={true}
+          platform={"default"}
+          options={[pinSpocTopSite]}
+          source={FAKE_SOURCE}
+          shouldSendImpressionStats={true}
+        />
+      )
+        .find(ContextMenu)
+        .props();
+
+      const [pinSpocOption] = spocOptions;
+      pinSpocOption.onClick();
+
+      if (pinSpocOption.impression && pinSpocOption.userEvent) {
+        assert.calledThrice(dispatch);
+      } else if (pinSpocOption.impression || pinSpocOption.userEvent) {
+        assert.calledTwice(dispatch);
+      } else {
+        assert.calledOnce(dispatch);
+      }
+
+      // option.action is dispatched
+      assert.ok(dispatch.firstCall.calledWith(pinSpocOption.action));
+
+      assert.deepEqual(pinSpocOption.action.data, {
+        site: FAKE_SITE,
+        index: FAKE_INDEX,
+      });
+    });
   });
 });
--- a/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js
+++ b/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js
@@ -68,17 +68,17 @@ describe("selectLayoutRender", () => {
     assert.deepEqual(layoutRender[0].components[0], {
       type: "foo",
       feed: { url: "foo.com" },
       properties: { items: 2 },
       data: { recommendations: [{ id: "foo", pos: 0 }, { id: "bar", pos: 1 }] },
     });
   });
 
-  it("should return layout with placeholder data if feed isn't available", () => {
+  it("should return layout with placeholder data if feed doesn't have data", () => {
     store.dispatch({
       type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
       data: { layout: FAKE_LAYOUT },
     });
     store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
 
     const { layoutRender } = selectLayoutRender(
       store.getState().DiscoveryStream,
@@ -89,16 +89,70 @@ describe("selectLayoutRender", () => {
     assert.lengthOf(layoutRender, 1);
     assert.propertyVal(layoutRender[0], "width", 3);
     assert.deepEqual(layoutRender[0].components[0].data.recommendations, [
       { placeholder: true },
       { placeholder: true },
     ]);
   });
 
+  it("should return layout with empty spocs data if feed isn't defined but spocs is", () => {
+    const fakeLayout = [
+      {
+        width: 3,
+        components: [{ type: "foo", spocs: { positions: [{ index: 2 }] } }],
+      },
+    ];
+    store.dispatch({
+      type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+      data: { layout: fakeLayout },
+    });
+    store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
+
+    const { layoutRender } = selectLayoutRender(
+      store.getState().DiscoveryStream,
+      {},
+      []
+    );
+
+    assert.lengthOf(layoutRender, 1);
+    assert.propertyVal(layoutRender[0], "width", 3);
+    assert.deepEqual(layoutRender[0].components[0].data.spocs, []);
+  });
+
+  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 }] } },
+        ],
+      },
+    ];
+    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] } },
+    });
+
+    const { layoutRender } = selectLayoutRender(
+      store.getState().DiscoveryStream,
+      {},
+      []
+    );
+
+    assert.lengthOf(layoutRender, 1);
+    assert.propertyVal(layoutRender[0], "width", 3);
+    assert.deepEqual(layoutRender[0].components[0].data.spocs, [1]);
+  });
+
   it("should return feed data offset by layout set prop", () => {
     const fakeLayout = [
       {
         width: 3,
         components: [
           { type: "foo", properties: { offset: 1 }, feed: { url: "foo.com" } },
         ],
       },
--- a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
+++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
@@ -387,16 +387,62 @@ describe("DiscoveryStreamFeed", () => {
       assert.calledOnce(fetchStub);
       assert.equal(
         feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
         "https://spocs.getpocket.com/spocs"
       );
     });
   });
 
+  describe("#updatePlacements", () => {
+    it("should fire update placements without dupes with updatePlacements", () => {
+      sandbox.spy(feed.store, "dispatch");
+      const fakeComponents = {
+        components: [
+          { placement: { name: "first" } },
+          { placement: { name: "second" } },
+        ],
+      };
+      const fakeLayout = [fakeComponents];
+
+      feed.updatePlacements(feed.store.dispatch, fakeLayout);
+
+      assert.calledOnce(feed.store.dispatch);
+      assert.calledWith(feed.store.dispatch, {
+        type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS",
+        data: { placements: [{ name: "first" }, { name: "second" }] },
+      });
+    });
+  });
+
+  describe("#placementsForEach", () => {
+    it("should forEach through placements", () => {
+      const fakeComponents = {
+        components: [
+          { placement: { name: "first" } },
+          { placement: { name: "second" } },
+        ],
+      };
+      const fakeLayout = [fakeComponents];
+      feed.updatePlacements(feed.store.dispatch, fakeLayout);
+      let items = [];
+
+      feed.placementsForEach(item => items.push(item.name));
+
+      assert.deepEqual(items, ["first", "second"]);
+    });
+    it("should forEach through placements for just spocs if no placements exist", () => {
+      let items = [];
+
+      feed.placementsForEach(item => items.push(item.name));
+
+      assert.deepEqual(items, ["spocs"]);
+    });
+  });
+
   describe("#loadLayoutEndPointUsingPref", () => {
     it("should return endpoint if valid key", async () => {
       const endpoint = feed.finalLayoutEndpoint(
         "https://somedomain.org/stories?consumer_key=$apiKey",
         "test_key_val"
       );
       assert.equal(
         "https://somedomain.org/stories?consumer_key=test_key_val",
@@ -589,17 +635,17 @@ describe("DiscoveryStreamFeed", () => {
 
       await feed.loadComponentFeeds(feed.store.dispatch);
 
       assert.isUndefined(feed.componentFeedRequestTime);
     });
   });
 
   describe("#getComponentFeed", () => {
-    it("should fetch fresh data if cache is empty", async () => {
+    it("should fetch fresh feed data if cache is empty", async () => {
       const fakeCache = {};
       sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
       sandbox.stub(feed, "rotate").callsFake(val => val);
       sandbox
         .stub(feed, "scoreItems")
         .callsFake(val => ({ data: val, filtered: [] }));
       sandbox.stub(feed, "fetchFromEndpoint").resolves({
         recommendations: "data",
@@ -607,17 +653,17 @@ describe("DiscoveryStreamFeed", () => {
           recsExpireTime: 1,
         },
       });
 
       const feedResp = await feed.getComponentFeed("foo.com");
 
       assert.equal(feedResp.data.recommendations, "data");
     });
-    it("should fetch fresh data if cache is old", async () => {
+    it("should fetch fresh feed data if cache is old", async () => {
       const fakeCache = { feeds: { "foo.com": { lastUpdated: Date.now() } } };
       sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
       sandbox.stub(feed, "fetchFromEndpoint").resolves({
         recommendations: "data",
         settings: {
           recsExpireTime: 1,
         },
       });
@@ -626,17 +672,17 @@ describe("DiscoveryStreamFeed", () => {
         .stub(feed, "scoreItems")
         .callsFake(val => ({ data: val, filtered: [] }));
       clock.tick(THIRTY_MINUTES + 1);
 
       const feedResp = await feed.getComponentFeed("foo.com");
 
       assert.equal(feedResp.data.recommendations, "data");
     });
-    it("should return data from cache if it is fresh", async () => {
+    it("should return feed data from cache if it is fresh", async () => {
       const fakeCache = {
         feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
       };
       sandbox.stub(feed.cache, "get").resolves(fakeCache);
       sandbox.stub(feed, "fetchFromEndpoint").resolves("old data");
       clock.tick(THIRTY_MINUTES - 1);
 
       const feedResp = await feed.getComponentFeed("foo.com");
@@ -671,52 +717,86 @@ describe("DiscoveryStreamFeed", () => {
 
       sandbox.spy(feed.cache, "set");
 
       await feed.loadSpocs(feed.store.dispatch);
 
       assert.notCalled(global.fetch);
       assert.notCalled(feed.cache.set);
     });
-    it("should fetch fresh data if cache is empty", async () => {
+    it("should fetch fresh spocs data if cache is empty", async () => {
       sandbox.stub(feed.cache, "get").returns(Promise.resolve());
-      sandbox.stub(feed, "fetchFromEndpoint").resolves("data");
+      sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "data" });
       sandbox.stub(feed.cache, "set").returns(Promise.resolve());
 
       await feed.loadSpocs(feed.store.dispatch);
 
       assert.calledWith(feed.cache.set, "spocs", {
-        data: "data",
+        spocs: { placement: "data" },
         lastUpdated: 0,
       });
-      assert.equal(feed.store.getState().DiscoveryStream.spocs.data, "data");
+      assert.equal(
+        feed.store.getState().DiscoveryStream.spocs.data.placement,
+        "data"
+      );
     });
     it("should fetch fresh data if cache is old", async () => {
-      const cachedSpoc = { data: "old", lastUpdated: Date.now() };
+      const cachedSpoc = {
+        spocs: { placement: "old" },
+        lastUpdated: Date.now(),
+      };
       const cachedData = { spocs: cachedSpoc };
       sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData));
-      sandbox.stub(feed, "fetchFromEndpoint").resolves("new");
+      sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" });
       sandbox.stub(feed.cache, "set").returns(Promise.resolve());
       clock.tick(THIRTY_MINUTES + 1);
 
       await feed.loadSpocs(feed.store.dispatch);
 
-      assert.equal(feed.store.getState().DiscoveryStream.spocs.data, "new");
+      assert.equal(
+        feed.store.getState().DiscoveryStream.spocs.data.placement,
+        "new"
+      );
     });
-    it("should return data from cache if it is fresh", async () => {
-      const cachedSpoc = { data: "old", lastUpdated: Date.now() };
+    it("should return spoc data from cache if it is fresh", async () => {
+      const cachedSpoc = {
+        spocs: { placement: "old" },
+        lastUpdated: Date.now(),
+      };
       const cachedData = { spocs: cachedSpoc };
       sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData));
-      sandbox.stub(feed, "fetchFromEndpoint").resolves("new");
+      sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" });
       sandbox.stub(feed.cache, "set").returns(Promise.resolve());
       clock.tick(THIRTY_MINUTES - 1);
 
       await feed.loadSpocs(feed.store.dispatch);
 
-      assert.equal(feed.store.getState().DiscoveryStream.spocs.data, "old");
+      assert.equal(
+        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" }] });
+      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 }] },
+        lastUpdated: 0,
+      });
+
+      assert.deepEqual(
+        feed.store.getState().DiscoveryStream.spocs.data.spocs[0],
+        { id: "data", min_score: 0, score: 1 }
+      );
     });
   });
 
   describe("#showSpocs", () => {
     it("should return false from showSpocs if user pref showSponsored is false", async () => {
       feed.store.getState = () => ({
         Prefs: { values: { showSponsored: false } },
       });
@@ -843,67 +923,61 @@ describe("DiscoveryStreamFeed", () => {
 
   describe("#transform", () => {
     it("should return initial data if spocs are empty", () => {
       const { data: result } = feed.transform({ spocs: [] });
 
       assert.equal(result.spocs.length, 0);
     });
     it("should sort based on item_score", () => {
-      const { data: result } = feed.transform({
-        spocs: [
-          { id: 2, campaign_id: 2, item_score: 0.8, min_score: 0.1 },
-          { id: 3, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
-          { id: 1, campaign_id: 1, item_score: 0.9, min_score: 0.1 },
-        ],
-      });
+      const { data: result } = feed.transform([
+        { id: 2, campaign_id: 2, item_score: 0.8, min_score: 0.1 },
+        { id: 3, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
+        { id: 1, campaign_id: 1, item_score: 0.9, min_score: 0.1 },
+      ]);
 
-      assert.deepEqual(result.spocs, [
+      assert.deepEqual(result, [
         { id: 1, campaign_id: 1, item_score: 0.9, score: 0.9, min_score: 0.1 },
         { id: 2, campaign_id: 2, item_score: 0.8, score: 0.8, min_score: 0.1 },
         { id: 3, campaign_id: 3, item_score: 0.7, score: 0.7, min_score: 0.1 },
       ]);
     });
     it("should remove items with scores lower than min_score", () => {
-      const { data: result, filtered } = feed.transform({
-        spocs: [
-          { id: 2, campaign_id: 2, item_score: 0.8, min_score: 0.9 },
-          { id: 3, campaign_id: 3, item_score: 0.7, min_score: 0.7 },
-          { id: 1, campaign_id: 1, item_score: 0.9, min_score: 0.8 },
-        ],
-      });
+      const { data: result, filtered } = feed.transform([
+        { id: 2, campaign_id: 2, item_score: 0.8, min_score: 0.9 },
+        { id: 3, campaign_id: 3, item_score: 0.7, min_score: 0.7 },
+        { id: 1, campaign_id: 1, item_score: 0.9, min_score: 0.8 },
+      ]);
 
-      assert.deepEqual(result.spocs, [
+      assert.deepEqual(result, [
         { id: 1, campaign_id: 1, item_score: 0.9, score: 0.9, min_score: 0.8 },
         { id: 3, campaign_id: 3, item_score: 0.7, score: 0.7, min_score: 0.7 },
       ]);
 
       assert.deepEqual(filtered.below_min_score, [
         { id: 2, campaign_id: 2, item_score: 0.8, min_score: 0.9, score: 0.8 },
       ]);
     });
     it("should add a score prop to spocs", () => {
-      const { data: result } = feed.transform({
-        spocs: [{ campaign_id: 1, item_score: 0.9, min_score: 0.1 }],
-      });
+      const { data: result } = feed.transform([
+        { campaign_id: 1, item_score: 0.9, min_score: 0.1 },
+      ]);
 
-      assert.equal(result.spocs[0].score, 0.9);
+      assert.equal(result[0].score, 0.9);
     });
     it("should filter out duplicate campigns", () => {
-      const { data: result, filtered } = feed.transform({
-        spocs: [
-          { id: 1, campaign_id: 2, item_score: 0.8, min_score: 0.1 },
-          { id: 2, campaign_id: 3, item_score: 0.6, min_score: 0.1 },
-          { id: 3, campaign_id: 1, item_score: 0.9, min_score: 0.1 },
-          { id: 4, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
-          { id: 5, campaign_id: 1, item_score: 0.9, min_score: 0.1 },
-        ],
-      });
+      const { data: result, filtered } = feed.transform([
+        { id: 1, campaign_id: 2, item_score: 0.8, min_score: 0.1 },
+        { id: 2, campaign_id: 3, item_score: 0.6, min_score: 0.1 },
+        { id: 3, campaign_id: 1, item_score: 0.9, min_score: 0.1 },
+        { id: 4, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
+        { id: 5, campaign_id: 1, item_score: 0.9, min_score: 0.1 },
+      ]);
 
-      assert.deepEqual(result.spocs, [
+      assert.deepEqual(result, [
         { id: 3, campaign_id: 1, item_score: 0.9, score: 0.9, min_score: 0.1 },
         { id: 1, campaign_id: 2, item_score: 0.8, score: 0.8, min_score: 0.1 },
         { id: 4, campaign_id: 3, item_score: 0.7, score: 0.7, min_score: 0.1 },
       ]);
 
       assert.deepEqual(filtered.campaign_duplicate, [
         { id: 5, campaign_id: 1, item_score: 0.9, min_score: 0.1, score: 0.9 },
         { id: 2, campaign_id: 3, item_score: 0.6, min_score: 0.1, score: 0.6 },
@@ -911,32 +985,30 @@ describe("DiscoveryStreamFeed", () => {
     });
     it("should filter out duplicate campigns while using spocs_per_domain", () => {
       sandbox.stub(feed.store, "getState").returns({
         DiscoveryStream: {
           spocs: { spocs_per_domain: 2 },
         },
       });
 
-      const { data: result, filtered } = feed.transform({
-        spocs: [
-          { id: 1, campaign_id: 2, item_score: 0.8, min_score: 0.1 },
-          { id: 2, campaign_id: 3, item_score: 0.6, min_score: 0.1 },
-          { id: 3, campaign_id: 1, item_score: 0.6, min_score: 0.1 },
-          { id: 4, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
-          { id: 5, campaign_id: 1, item_score: 0.9, min_score: 0.1 },
-          { id: 6, campaign_id: 2, item_score: 0.6, min_score: 0.1 },
-          { id: 7, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
-          { id: 8, campaign_id: 1, item_score: 0.8, min_score: 0.1 },
-          { id: 9, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
-          { id: 10, campaign_id: 1, item_score: 0.8, min_score: 0.1 },
-        ],
-      });
+      const { data: result, filtered } = feed.transform([
+        { id: 1, campaign_id: 2, item_score: 0.8, min_score: 0.1 },
+        { id: 2, campaign_id: 3, item_score: 0.6, min_score: 0.1 },
+        { id: 3, campaign_id: 1, item_score: 0.6, min_score: 0.1 },
+        { id: 4, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
+        { id: 5, campaign_id: 1, item_score: 0.9, min_score: 0.1 },
+        { id: 6, campaign_id: 2, item_score: 0.6, min_score: 0.1 },
+        { id: 7, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
+        { id: 8, campaign_id: 1, item_score: 0.8, min_score: 0.1 },
+        { id: 9, campaign_id: 3, item_score: 0.7, min_score: 0.1 },
+        { id: 10, campaign_id: 1, item_score: 0.8, min_score: 0.1 },
+      ]);
 
-      assert.deepEqual(result.spocs, [
+      assert.deepEqual(result, [
         { id: 5, campaign_id: 1, item_score: 0.9, score: 0.9, min_score: 0.1 },
         { id: 1, campaign_id: 2, item_score: 0.8, score: 0.8, min_score: 0.1 },
         { id: 8, campaign_id: 1, item_score: 0.8, score: 0.8, min_score: 0.1 },
         { id: 4, campaign_id: 3, item_score: 0.7, score: 0.7, min_score: 0.1 },
         { id: 7, campaign_id: 3, item_score: 0.7, score: 0.7, min_score: 0.1 },
         { id: 6, campaign_id: 2, item_score: 0.6, score: 0.6, min_score: 0.1 },
       ]);
 
@@ -946,76 +1018,48 @@ describe("DiscoveryStreamFeed", () => {
         { id: 2, campaign_id: 3, item_score: 0.6, min_score: 0.1, score: 0.6 },
         { id: 3, campaign_id: 1, item_score: 0.6, min_score: 0.1, score: 0.6 },
       ]);
     });
   });
 
   describe("#filterBlocked", () => {
     it("should return initial data if spocs are empty", () => {
-      const { data: result } = feed.filterBlocked({ spocs: [] });
+      const { data: result } = feed.filterBlocked([]);
 
-      assert.equal(result.spocs.length, 0);
+      assert.equal(result.length, 0);
     });
-    it("should return initial spocs data if links are not blocked", () => {
-      const { data: result } = feed.filterBlocked(
-        {
-          spocs: [{ url: "https://foo.com" }, { url: "test.com" }],
-        },
-        "spocs"
-      );
-      assert.equal(result.spocs.length, 2);
+    it("should return initial data if links are not blocked", () => {
+      const { data: result } = feed.filterBlocked([
+        { url: "https://foo.com" },
+        { url: "test.com" },
+      ]);
+      assert.equal(result.length, 2);
     });
-    it("should return filtered out spocs based on blockedlist", () => {
+    it("should return filtered out based on blockedlist", () => {
       fakeNewTabUtils.blockedLinks.links = [{ url: "https://foo.com" }];
       fakeNewTabUtils.blockedLinks.isBlocked = site =>
         fakeNewTabUtils.blockedLinks.links[0].url === site.url;
 
-      const { data: result, filtered } = feed.filterBlocked(
-        {
-          spocs: [
-            { id: 1, url: "https://foo.com" },
-            { id: 2, url: "test.com" },
-          ],
-        },
-        "spocs"
-      );
+      const { data: result, filtered } = feed.filterBlocked([
+        { id: 1, url: "https://foo.com" },
+        { id: 2, url: "test.com" },
+      ]);
 
-      assert.lengthOf(result.spocs, 1);
-      assert.equal(result.spocs[0].url, "test.com");
-      assert.notInclude(result.spocs, fakeNewTabUtils.blockedLinks.links[0]);
+      assert.lengthOf(result, 1);
+      assert.equal(result[0].url, "test.com");
+      assert.notInclude(result, fakeNewTabUtils.blockedLinks.links[0]);
       assert.deepEqual(filtered, [{ id: 1, url: "https://foo.com" }]);
     });
     it("should return initial recommendations data if links are not blocked", () => {
-      const { data: result } = feed.filterBlocked(
-        {
-          recommendations: [{ url: "https://foo.com" }, { url: "test.com" }],
-        },
-        "recommendations"
-      );
-      assert.equal(result.recommendations.length, 2);
-    });
-    it("should return filtered out recommendations based on blockedlist", () => {
-      fakeNewTabUtils.blockedLinks.links = [{ url: "https://foo.com" }];
-      fakeNewTabUtils.blockedLinks.isBlocked = site =>
-        fakeNewTabUtils.blockedLinks.links[0].url === site.url;
-
-      const { data: result } = feed.filterBlocked(
-        {
-          recommendations: [{ url: "https://foo.com" }, { url: "test.com" }],
-        },
-        "recommendations"
-      );
-
-      assert.lengthOf(result.recommendations, 1);
-      assert.equal(result.recommendations[0].url, "test.com");
-      assert.notInclude(
-        result.recommendations,
-        fakeNewTabUtils.blockedLinks.links[0]
-      );
+      const { data: result } = feed.filterBlocked([
+        { url: "https://foo.com" },
+        { url: "test.com" },
+      ]);
+      assert.equal(result.length, 2);
     });
     it("filterRecommendations based on blockedlist by passing feed data", () => {
       fakeNewTabUtils.blockedLinks.links = [{ url: "https://foo.com" }];
       fakeNewTabUtils.blockedLinks.isBlocked = site =>
         fakeNewTabUtils.blockedLinks.links[0].url === site.url;
 
       const result = feed.filterRecommendations({
         lastUpdated: 4,
@@ -1031,52 +1075,56 @@ describe("DiscoveryStreamFeed", () => {
         result.data.recommendations,
         fakeNewTabUtils.blockedLinks.links[0]
       );
     });
   });
 
   describe("#frequencyCapSpocs", () => {
     it("should return filtered out spocs based on frequency caps", () => {
-      const fakeSpocs = {
-        spocs: [
-          {
-            id: 1,
-            campaign_id: "seen",
-            caps: {
-              lifetime: 3,
-              campaign: {
-                count: 1,
-                period: 1,
-              },
+      const fakeSpocs = [
+        {
+          id: 1,
+          campaign_id: "seen",
+          caps: {
+            lifetime: 3,
+            campaign: {
+              count: 1,
+              period: 1,
             },
           },
-          {
-            id: 2,
-            campaign_id: "not-seen",
-            caps: {
-              lifetime: 3,
-              campaign: {
-                count: 1,
-                period: 1,
-              },
+        },
+        {
+          id: 2,
+          campaign_id: "not-seen",
+          caps: {
+            lifetime: 3,
+            campaign: {
+              count: 1,
+              period: 1,
             },
           },
-        ],
-      };
+        },
+      ];
       const fakeImpressions = {
         seen: [Date.now() - 1],
       };
       sandbox.stub(feed, "readImpressionsPref").returns(fakeImpressions);
 
       const { data: result, filtered } = feed.frequencyCapSpocs(fakeSpocs);
 
-      assert.equal(result.spocs.length, 1);
-      assert.equal(result.spocs[0].campaign_id, "not-seen");
-      assert.deepEqual(filtered, [fakeSpocs.spocs[0]]);
+      assert.equal(result.length, 1);
+      assert.equal(result[0].campaign_id, "not-seen");
+      assert.deepEqual(filtered, [fakeSpocs[0]]);
+    });
+    it("should return simple structure and do nothing with no spocs", () => {
+      const { data: result, filtered } = feed.frequencyCapSpocs([]);
+
+      assert.equal(result.length, 0);
+      assert.equal(filtered.length, 0);
     });
   });
 
   describe("#isBelowFrequencyCap", () => {
     it("should return true if there are no campaign impressions", () => {
       const fakeImpressions = {
         seen: [Date.now() - 1],
       };
@@ -1441,16 +1489,58 @@ describe("DiscoveryStreamFeed", () => {
 
       await feed.onAction({
         type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
         data: { campaign_id: "seen" },
       });
 
       assert.notCalled(feed.store.dispatch);
     });
+    it("should attempt feq cap on valid spocs with placements on impression", async () => {
+      sandbox.restore();
+      Object.defineProperty(feed, "showSpocs", { get: () => true });
+      const fakeImpressions = {};
+      sandbox.stub(feed, "recordCampaignImpression").returns();
+      sandbox.stub(feed, "readImpressionsPref").returns(fakeImpressions);
+      sandbox.spy(feed.store, "dispatch");
+      sandbox.spy(feed, "frequencyCapSpocs");
+
+      const data = {
+        spocs: [
+          {
+            id: 2,
+            campaign_id: "seen-2",
+            caps: {
+              lifetime: 3,
+              campaign: {
+                count: 1,
+                period: 1,
+              },
+            },
+          },
+        ],
+      };
+      sandbox.stub(feed.store, "getState").returns({
+        DiscoveryStream: {
+          spocs: {
+            data,
+            placements: [{ name: "spocs" }, { name: "notSpocs" }],
+            spocs_per_domain: 1,
+          },
+        },
+      });
+
+      await feed.onAction({
+        type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
+        data: { campaign_id: "doesn't matter" },
+      });
+
+      assert.calledOnce(feed.frequencyCapSpocs);
+      assert.calledWith(feed.frequencyCapSpocs, data.spocs);
+    });
   });
 
   describe("#onAction: PLACES_LINK_BLOCKED", () => {
     beforeEach(() => {
       const data = {
         spocs: [
           {
             id: 1,
@@ -1461,17 +1551,20 @@ describe("DiscoveryStreamFeed", () => {
             id: 2,
             campaign_id: "bar",
             url: "bar.com",
           },
         ],
       };
       sandbox.stub(feed.store, "getState").returns({
         DiscoveryStream: {
-          spocs: { data },
+          spocs: {
+            data,
+            placements: [{ name: "spocs" }],
+          },
         },
       });
     });
 
     it("should call dispatch with the SPOCS Fill if found a blocked spoc", async () => {
       Object.defineProperty(feed, "showSpocs", { get: () => true });
       const spocFillResult = [
         {
--- a/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
+++ b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
@@ -582,24 +582,24 @@ describe("ToolbarPanelHub", () => {
         true
       );
     });
     it("should toggle/expand when default collapsed/disabled", async () => {
       fakeElementById.hasAttribute.returns(true);
 
       await fakeInsert();
 
-      assert.calledTwice(fakeElementById.toggleAttribute);
+      assert.calledThrice(fakeElementById.toggleAttribute);
     });
     it("should toggle again when popup hides", async () => {
       fakeElementById.addEventListener.callsArg(1);
 
       await fakeInsert();
 
-      assert.callCount(fakeElementById.toggleAttribute, 4);
+      assert.callCount(fakeElementById.toggleAttribute, 6);
     });
     it("should open link on click", async () => {
       await fakeInsert();
 
       eventListeners.click();
 
       assert.calledOnce(handleUserActionStub);
       assert.calledWithExactly(handleUserActionStub, {
--- a/browser/components/tests/browser/browser_urlbar_matchBuckets_migration60.js
+++ b/browser/components/tests/browser/browser_urlbar_matchBuckets_migration60.js
@@ -265,17 +265,17 @@ function newExperimentOpts(opts = {}) {
   for (const [prefName, prefInfo] of Object.entries(
     opts.preferences || defaultPref
   )) {
     preferences[prefName] = { ...defaultPrefInfo, ...prefInfo };
   }
 
   return Object.assign(
     {
-      name: STUDY_NAME,
+      slug: STUDY_NAME,
       actionName: "SomeAction",
       branch: "branch",
     },
     opts,
     {
       preferences,
     }
   );
--- a/browser/locales/en-US/browser/aboutLogins.ftl
+++ b/browser/locales/en-US/browser/aboutLogins.ftl
@@ -56,16 +56,18 @@ login-list-count =
   }
 login-list-sort-label-text = Sort by:
 login-list-name-option = Name (A-Z)
 login-list-breached-option = Breached Websites
 login-list-last-changed-option = Last Modified
 login-list-last-used-option = Last Used
 login-list-intro-title = No logins found
 login-list-intro-description = When you save a password in { -brand-product-name }, it will show up here.
+about-logins-login-list-empty-search-title = No logins found
+about-logins-login-list-empty-search-description = There are no results matching your search.
 login-list-item-title-new-login = New Login
 login-list-item-subtitle-new-login = Enter your login credentials
 login-list-item-subtitle-missing-username = (no username)
 
 ## Introduction screen
 
 login-intro-heading = Looking for your saved logins? Set up { -sync-brand-short-name }.
 login-intro-description = If you saved your logins to { -brand-product-name } on a different device, here’s how to get them here:
--- a/browser/modules/SiteDataManager.jsm
+++ b/browser/modules/SiteDataManager.jsm
@@ -19,18 +19,16 @@ XPCOMUtils.defineLazyGetter(this, "gStri
 
 XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() {
   return Services.strings.createBundle(
     "chrome://branding/locale/brand.properties"
   );
 });
 
 var SiteDataManager = {
-  _qms: Services.qms,
-
   _appCache: Cc["@mozilla.org/network/application-cache-service;1"].getService(
     Ci.nsIApplicationCacheService
   ),
 
   // A Map of sites and their disk usage according to Quota Manager and appcache
   // Key is host (group sites based on host across scheme, port, origin atttributes).
   // Value is one object holding:
   //   - principals: instances of nsIPrincipal (only when the site has
@@ -169,17 +167,17 @@ var SiteDataManager = {
             }
           }
         }
         resolve();
       };
       // XXX: The work of integrating localStorage into Quota Manager is in progress.
       //      After the bug 742822 and 1286798 landed, localStorage usage will be included.
       //      So currently only get indexedDB usage.
-      this._quotaUsageRequest = this._qms.getUsage(onUsageResult);
+      this._quotaUsageRequest = Services.qms.getUsage(onUsageResult);
     });
     return this._getQuotaUsagePromise;
   },
 
   _getAllCookies() {
     for (let cookie of Services.cookies.enumerator) {
       let site = this._getOrInsertSite(cookie.rawHost);
       site.cookies.push(cookie);
@@ -223,16 +221,104 @@ var SiteDataManager = {
       let site = this._getOrInsertSite(uri.host);
       if (!site.principals.some(p => p.origin == principal.origin)) {
         site.principals.push(principal);
       }
       site.appCacheList.push(cache);
     }
   },
 
+  /**
+   * Gets the current AppCache usage by host. This is using asciiHost to compare
+   * against the provided host.
+   *
+   * @param {String} the ascii host to check usage for
+   * @returns the usage in bytes
+   */
+  getAppCacheUsageByHost(host) {
+    let usage = 0;
+
+    let groups;
+    try {
+      groups = this._appCache.getGroups();
+    } catch (e) {
+      // NS_ERROR_NOT_AVAILABLE means that appCache is not initialized,
+      // which probably means the user has disabled it. Otherwise, log an
+      // error. Either way, there's nothing we can do here.
+      if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+        Cu.reportError(e);
+      }
+      return usage;
+    }
+
+    for (let group of groups) {
+      let uri = Services.io.newURI(group);
+      if (uri.asciiHost == host) {
+        let cache = this._appCache.getActiveCache(group);
+        usage += cache.usage;
+      }
+    }
+
+    return usage;
+  },
+
+  /**
+   * Checks if the site with the provided ASCII host is using any site data at all.
+   * This will check for:
+   *   - Cookies (incl. subdomains)
+   *   - AppCache
+   *   - Quota Usage
+   * in that order. This function is meant to be fast, and thus will
+   * end searching and return true once the first trace of site data is found.
+   *
+   * @param {String} the ASCII host to check
+   * @returns {Boolean} whether the site has any data associated with it
+   */
+  async hasSiteData(asciiHost) {
+    if (Services.cookies.countCookiesFromHost(asciiHost)) {
+      return true;
+    }
+
+    let appCacheUsage = this.getAppCacheUsageByHost(asciiHost);
+    if (appCacheUsage > 0) {
+      return true;
+    }
+
+    let hasQuota = await new Promise(resolve => {
+      Services.qms.getUsage(request => {
+        if (request.resultCode != Cr.NS_OK) {
+          resolve(false);
+          return;
+        }
+
+        for (let item of request.result) {
+          if (!item.persisted && item.usage <= 0) {
+            continue;
+          }
+
+          let principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+            item.origin
+          );
+          if (principal.URI.asciiHost == asciiHost) {
+            resolve(true);
+            return;
+          }
+        }
+
+        resolve(false);
+      });
+    });
+
+    if (hasQuota) {
+      return true;
+    }
+
+    return false;
+  },
+
   getTotalUsage() {
     return this._getQuotaUsagePromise.then(() => {
       let usage = 0;
       for (let site of this._sites.values()) {
         for (let cache of site.appCacheList) {
           usage += cache.usage;
         }
         usage += site.quotaUsage;
--- a/devtools/client/accessibility/accessibility-view.js
+++ b/devtools/client/accessibility/accessibility-view.js
@@ -1,14 +1,14 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
-/* global EVENTS, gToolbox */
+/* global EVENTS */
 
 const nodeConstants = require("devtools/shared/dom-node-constants");
 
 // React & Redux
 const {
   createFactory,
   createElement,
 } = require("devtools/client/shared/vendor/react");
@@ -28,25 +28,16 @@ const createStore = require("devtools/cl
 const { reducers } = require("./reducers/index");
 const store = createStore(reducers);
 
 // Actions
 const { reset } = require("./actions/ui");
 const { select, highlight } = require("./actions/accessibles");
 
 /**
- * A helper function that wraps access to the dom walker that should be updated
- * when fission-ready API is in place. Right now walker is accessed from the
- * toolbox which will no longer be the case.
- */
-async function getDOMWalker() {
-  return (await gToolbox.target.getFront("inspector")).walker;
-}
-
-/**
  * This object represents view of the Accessibility panel and is responsible
  * for rendering the content. It renders the top level ReactJS
  * component: the MainFrame.
  */
 function AccessibilityView(localStore) {
   addEventListener("devtools/chrome/message", this.onMessage.bind(this), true);
   this.store = localStore;
 }
@@ -75,17 +66,16 @@ AccessibilityView.prototype = {
       ReactDOM.render(OldVersionDescription(), container);
       return;
     }
 
     const mainFrame = MainFrame({
       accessibility,
       accessibilityWalker: walker,
       fluentBundles,
-      getDOMWalker,
     });
     // Render top level component
     const provider = createElement(Provider, { store: this.store }, mainFrame);
     this.mainFrame = ReactDOM.render(provider, container);
   },
 
   async selectAccessible(walker, accessible) {
     await this.store.dispatch(select(walker, accessible));
@@ -103,18 +93,17 @@ AccessibilityView.prototype = {
       await accessible.hydrate();
     }
 
     // If node does not have an accessible object, try to find node's child text node and
     // try to retrieve an accessible object for that child instead. This is the best
     // effort approach until there's accessibility API to retrieve accessible object at
     // point.
     if (!accessible || accessible.indexInParent < 0) {
-      const domWalker = await getDOMWalker();
-      const { nodes: children } = await domWalker.children(node);
+      const { nodes: children } = await node.walkerFront.children(node);
       for (const child of children) {
         if (child.nodeType === nodeConstants.TEXT_NODE) {
           accessible = await walker.getAccessibleFor(child);
           // indexInParent property is only available with additional request
           // for data (hydration) about the accessible object.
           if (accessible && supports.hydration) {
             await accessible.hydrate();
           }
--- a/devtools/client/accessibility/accessibility.css
+++ b/devtools/client/accessibility/accessibility.css
@@ -703,36 +703,40 @@ body {
   font-style: italic;
   padding: 0.5em 20px;
   -moz-user-select: none;
   font-size: 12px;
   white-space: initial;
 }
 
 /* Checks */
+.checks .list li:last-of-type {
+  padding-block-end: 4px;
+}
+
 .accessibility-check code {
   background-color: var(--accessibility-code-background);
   border-radius: 2px;
   box-decoration-break: clone;
   padding: 0 4px;
 }
 
-.accessibility-text-label-check .icon {
+.accessibility-check .icon {
   display: inline;
   -moz-context-properties: fill;
   vertical-align: top;
   margin-block-start: 2px;
   margin-inline-end: 4px;
 }
 
-.accessibility-text-label-check .icon.fail {
+.accessibility-check .icon.FAIL {
   fill: var(--theme-icon-error-color);
 }
 
-.accessibility-text-label-check .icon.WARNING {
+.accessibility-check .icon.WARNING {
   fill: var(--theme-icon-warning-color);
 }
 
 .accessibility-check,
 .accessibility-color-contrast {
   position: relative;
   display: flex;
   cursor: default;
--- a/devtools/client/accessibility/components/AccessibilityRow.js
+++ b/devtools/client/accessibility/components/AccessibilityRow.js
@@ -80,17 +80,16 @@ class AccessibilityRow extends Component
   static get propTypes() {
     return {
       ...TreeRow.propTypes,
       hasContextMenu: PropTypes.bool.isRequired,
       dispatch: PropTypes.func.isRequired,
       accessibilityWalker: PropTypes.object,
       scrollContentNodeIntoView: PropTypes.bool.isRequired,
       supports: PropTypes.object,
-      getDOMWalker: PropTypes.func.isRequired,
     };
   }
 
   componentDidMount() {
     const {
       member: { selected, object },
       scrollContentNodeIntoView,
     } = this.props;
@@ -149,23 +148,22 @@ class AccessibilityRow extends Component
     scrollIntoView(row);
   }
 
   async update() {
     const {
       dispatch,
       member: { object },
       supports,
-      getDOMWalker,
     } = this.props;
-    const domWalker = await getDOMWalker();
-    if (!domWalker || !object.actorID) {
+    if (!object.actorID) {
       return;
     }
 
+    const domWalker = (await object.targetFront.getFront("inspector")).walker;
     dispatch(updateDetails(domWalker, object, supports));
     window.emit(EVENTS.NEW_ACCESSIBLE_FRONT_SELECTED, object);
   }
 
   flashValue() {
     const row = findDOMNode(this);
     // Row might not be rendered in the DOM tree if it is filtered out during
     // audit.
@@ -189,23 +187,24 @@ class AccessibilityRow extends Component
    * Scroll the node that corresponds to a current accessible object into view.
    * @param   {Object}
    *          Accessible front that is rendered for this node.
    *
    * @returns {Promise}
    *          Promise that resolves when the node is scrolled into view if
    *          possible.
    */
-  async scrollNodeIntoViewIfNeeded({ actorID }) {
-    const domWalker = await this.props.getDOMWalker();
-    if (!domWalker || !actorID) {
+  async scrollNodeIntoViewIfNeeded(accessible) {
+    if (!accessible.actorID) {
       return;
     }
 
-    const node = await domWalker.getNodeFromActor(actorID, [
+    const domWalker = (await accessible.targetFront.getFront("inspector"))
+      .walker;
+    const node = await domWalker.getNodeFromActor(accessible.actorID, [
       "rawAccessible",
       "DOMNode",
     ]);
     if (!node) {
       return;
     }
 
     if (node.nodeType == nodeConstants.ELEMENT_NODE) {
--- a/devtools/client/accessibility/components/AccessibilityTree.js
+++ b/devtools/client/accessibility/components/AccessibilityTree.js
@@ -31,17 +31,16 @@ const { scrollIntoView } = require("devt
 
 /**
  * Renders Accessibility panel tree.
  */
 class AccessibilityTree extends Component {
   static get propTypes() {
     return {
       accessibilityWalker: PropTypes.object,
-      getDOMWalker: PropTypes.func.isRequired,
       dispatch: PropTypes.func.isRequired,
       accessibles: PropTypes.object,
       expanded: PropTypes.object,
       selected: PropTypes.string,
       highlighted: PropTypes.object,
       supports: PropTypes.object,
       filtered: PropTypes.bool,
     };
@@ -163,31 +162,29 @@ class AccessibilityTree extends Componen
     const {
       accessibles,
       dispatch,
       expanded,
       selected,
       highlighted: highlightedItem,
       supports,
       accessibilityWalker,
-      getDOMWalker,
       filtered,
     } = this.props;
 
     // Historically, the first context menu item is snapshot function and it is available
     // for all accessible object.
     const hasContextMenu = supports.snapshot;
 
     const renderRow = rowProps => {
       const { object } = rowProps.member;
       const highlighted = object === highlightedItem;
       return AccessibilityRow(
         Object.assign({}, rowProps, {
           accessibilityWalker,
-          getDOMWalker,
           hasContextMenu,
           highlighted,
           decorator: {
             getRowClass: function() {
               return highlighted ? ["highlighted"] : [];
             },
           },
         })
--- a/devtools/client/accessibility/components/AccessibilityTreeFilter.js
+++ b/devtools/client/accessibility/components/AccessibilityTreeFilter.js
@@ -39,16 +39,17 @@ const actions = require("../actions/audi
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 const { FILTERS } = require("../constants");
 
 const TELEMETRY_AUDIT_ACTIVATED = "devtools.accessibility.audit_activated";
 const FILTER_LABELS = {
   [FILTERS.NONE]: "accessibility.filter.none",
   [FILTERS.ALL]: "accessibility.filter.all2",
   [FILTERS.CONTRAST]: "accessibility.filter.contrast",
+  [FILTERS.KEYBOARD]: "accessibility.filter.keyboard",
   [FILTERS.TEXT_LABEL]: "accessibility.filter.textLabel",
 };
 
 class AccessibilityTreeFilter extends Component {
   static get propTypes() {
     return {
       auditing: PropTypes.array.isRequired,
       filters: PropTypes.object.isRequired,
--- a/devtools/client/accessibility/components/Accessible.js
+++ b/devtools/client/accessibility/components/Accessible.js
@@ -106,17 +106,16 @@ class Accessible extends Component {
       dispatch: PropTypes.func.isRequired,
       DOMNode: PropTypes.object,
       items: PropTypes.array,
       labelledby: PropTypes.string.isRequired,
       parents: PropTypes.object,
       relations: PropTypes.object,
       supports: PropTypes.object,
       accessibilityWalker: PropTypes.object.isRequired,
-      getDOMWalker: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
     this.state = {
       expanded: new Set(),
@@ -166,22 +165,24 @@ class Accessible extends Component {
   onAccessibleInspected() {
     const { props } = this.refs;
     if (props) {
       props.refs.tree.focus();
     }
   }
 
   async update() {
-    const { dispatch, accessible, supports, getDOMWalker } = this.props;
-    const domWalker = await getDOMWalker();
-    if (!domWalker || !accessible.actorID) {
+    const { dispatch, accessible, supports } = this.props;
+    if (!accessible.actorID) {
       return;
     }
 
+    const domWalker = (await accessible.targetFront.getFront("inspector"))
+      .walker;
+
     dispatch(updateDetails(domWalker, accessible, supports));
   }
 
   setExpanded(item, isExpanded) {
     const { expanded } = this.state;
 
     if (isExpanded) {
       expanded.add(item.path);
--- a/devtools/client/accessibility/components/AuditFilter.js
+++ b/devtools/client/accessibility/components/AuditFilter.js
@@ -21,16 +21,20 @@ function validateCheck({ error, score })
   return !error && [BEST_PRACTICES, FAIL, WARNING].includes(score);
 }
 
 const AUDIT_TYPE_TO_FILTER = {
   [AUDIT_TYPE.CONTRAST]: {
     filterKey: FILTERS.CONTRAST,
     validator: validateCheck,
   },
+  [AUDIT_TYPE.KEYBOARD]: {
+    filterKey: FILTERS.KEYBOARD,
+    validator: validateCheck,
+  },
   [AUDIT_TYPE.TEXT_LABEL]: {
     filterKey: FILTERS.TEXT_LABEL,
     validator: validateCheck,
   },
 };
 
 class AuditFilter extends React.Component {
   static get propTypes() {
--- a/devtools/client/accessibility/components/Badges.js
+++ b/devtools/client/accessibility/components/Badges.js
@@ -16,23 +16,28 @@ const { L10N } = require("../utils/l10n"
 const {
   accessibility: { AUDIT_TYPE },
 } = require("devtools/shared/constants");
 
 loader.lazyGetter(this, "ContrastBadge", () =>
   createFactory(require("./ContrastBadge"))
 );
 
+loader.lazyGetter(this, "KeyboardBadge", () =>
+  createFactory(require("./KeyboardBadge"))
+);
+
 loader.lazyGetter(this, "TextLabelBadge", () =>
   createFactory(require("./TextLabelBadge"))
 );
 
 function getComponentForAuditType(type) {
   const auditTypeToComponentMap = {
     [AUDIT_TYPE.CONTRAST]: ContrastBadge,
+    [AUDIT_TYPE.KEYBOARD]: KeyboardBadge,
     [AUDIT_TYPE.TEXT_LABEL]: TextLabelBadge,
   };
 
   return auditTypeToComponentMap[type];
 }
 
 class Badges extends Component {
   static get propTypes() {
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/components/Check.js
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+// React
+const {
+  Component,
+  createFactory,
+  PureComponent,
+} = require("devtools/client/shared/vendor/react");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom-factories");
+
+const FluentReact = require("devtools/client/shared/vendor/fluent-react");
+const Localized = createFactory(FluentReact.Localized);
+
+const { openDocLink } = require("devtools/client/shared/link");
+
+const {
+  accessibility: {
+    SCORES: { BEST_PRACTICES, FAIL, WARNING },
+  },
+} = require("devtools/shared/constants");
+
+/**
+ * A map of accessibility scores to the text descriptions of check icons.
+ */
+const SCORE_TO_ICON_MAP = {
+  [BEST_PRACTICES]: {
+    l10nId: "accessibility-best-practices",
+    src: "chrome://devtools/skin/images/info.svg",
+  },
+  [FAIL]: {
+    l10nId: "accessibility-fail",
+    src: "chrome://devtools/skin/images/error.svg",
+  },
+  [WARNING]: {
+    l10nId: "accessibility-warning",
+    src: "chrome://devtools/skin/images/alert.svg",
+  },
+};
+
+/**
+ * Localized "Learn more" link that opens a new tab with relevant documentation.
+ */
+class LearnMoreClass extends PureComponent {
+  static get propTypes() {
+    return {
+      href: PropTypes.string,
+      l10nId: PropTypes.string.isRequired,
+      onClick: PropTypes.func,
+    };
+  }
+
+  static get defaultProps() {
+    return {
+      href: "#",
+      l10nId: null,
+      onClick: LearnMoreClass.openDocOnClick,
+    };
+  }
+
+  static openDocOnClick(event) {
+    event.preventDefault();
+    openDocLink(event.target.href);
+  }
+
+  render() {
+    const { href, l10nId, onClick } = this.props;
+    const className = "link";
+
+    return Localized({ id: l10nId }, ReactDOM.a({ className, href, onClick }));
+  }
+}
+
+const LearnMore = createFactory(LearnMoreClass);
+
+/**
+ * Renders icon with text description for the accessibility check.
+ *
+ * @param {Object}
+ *        Options:
+ *          - score: value from SCORES from "devtools/shared/constants"
+ */
+function Icon({ score }) {
+  const { l10nId, src } = SCORE_TO_ICON_MAP[score];
+
+  return Localized(
+    { id: l10nId, attrs: { alt: true } },
+    ReactDOM.img({ src, className: `icon ${score}` })
+  );
+}
+
+/**
+ * Renders text description of the accessibility check.
+ *
+ * @param {Object}
+ *        Options:
+ *          - args:   arguments for fluent localized string
+ *          - href:   url for the learn more link pointing to MDN
+ *          - l10nId: fluent localization id
+ */
+function Annotation({ args, href, l10nId }) {
+  return Localized(
+    {
+      id: l10nId,
+      a: LearnMore({ l10nId: "accessibility-learn-more", href }),
+      ...args,
+    },
+    ReactDOM.p({ className: "accessibility-check-annotation" })
+  );
+}
+
+/**
+ * Component for rendering a check for accessibliity checks section,
+ * warnings and best practices suggestions association with a given
+ * accessibility object in the accessibility tree.
+ */
+class Check extends Component {
+  static get propTypes() {
+    return {
+      getAnnotation: PropTypes.func.isRequired,
+      id: PropTypes.string.isRequired,
+      issue: PropTypes.string.isRequired,
+      score: PropTypes.string.isRequired,
+    };
+  }
+
+  render() {
+    const { getAnnotation, id, issue, score } = this.props;
+
+    return ReactDOM.div(
+      {
+        role: "presentation",
+        className: "accessibility-check",
+      },
+      Localized(
+        {
+          id,
+        },
+        ReactDOM.h3({ className: "accessibility-check-header" })
+      ),
+      ReactDOM.div(
+        {
+          role: "presentation",
+        },
+        Icon({ score }),
+        Annotation({ ...getAnnotation(issue) })
+      )
+    );
+  }
+}
+
+module.exports = Check;
--- a/devtools/client/accessibility/components/Checks.js
+++ b/devtools/client/accessibility/components/Checks.js
@@ -14,16 +14,17 @@ const { div } = require("devtools/client
 
 const List = createFactory(
   require("devtools/client/shared/components/List").List
 );
 const ColorContrastCheck = createFactory(
   require("./ColorContrastAccessibility").ColorContrastCheck
 );
 const TextLabelCheck = createFactory(require("./TextLabelCheck"));
+const KeyboardCheck = createFactory(require("./KeyboardCheck"));
 const { L10N } = require("../utils/l10n");
 
 const {
   accessibility: { AUDIT_TYPE },
 } = require("devtools/shared/constants");
 
 function EmptyChecks() {
   return div(
@@ -44,16 +45,20 @@ class Checks extends Component {
       labelledby: PropTypes.string.isRequired,
     };
   }
 
   [AUDIT_TYPE.CONTRAST](contrastRatio) {
     return ColorContrastCheck(contrastRatio);
   }
 
+  [AUDIT_TYPE.KEYBOARD](keyboardCheck) {
+    return KeyboardCheck(keyboardCheck);
+  }
+
   [AUDIT_TYPE.TEXT_LABEL](textLabelCheck) {
     return TextLabelCheck(textLabelCheck);
   }
 
   render() {
     const { audit, labelledby } = this.props;
     if (!audit) {
       return EmptyChecks();
--- a/devtools/client/accessibility/components/ContrastBadge.js
+++ b/devtools/client/accessibility/components/ContrastBadge.js
@@ -1,48 +1,44 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 // React
 const {
-  Component,
   createFactory,
+  PureComponent,
 } = require("devtools/client/shared/vendor/react");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 
 const { L10N } = require("../utils/l10n");
 
 const {
   accessibility: { SCORES },
 } = require("devtools/shared/constants");
 
 loader.lazyGetter(this, "Badge", () => createFactory(require("./Badge")));
 
 /**
  * Component for rendering a badge for contrast accessibliity check
  * failures association with a given accessibility object in the accessibility
  * tree.
  */
-class ContrastBadge extends Component {
+class ContrastBadge extends PureComponent {
   static get propTypes() {
     return {
       error: PropTypes.string,
       score: PropTypes.string,
     };
   }
 
   render() {
     const { error, score } = this.props;
-    if (error) {
-      return null;
-    }
-
-    if (score !== SCORES.FAIL) {
+    if (error || score !== SCORES.FAIL) {
       return null;
     }
 
     return Badge({
       label: L10N.getStr("accessibility.badge.contrast"),
       ariaLabel: L10N.getStr("accessibility.badge.contrast.warning"),
       tooltip: L10N.getStr("accessibility.badge.contrast.tooltip"),
     });
copy from devtools/client/accessibility/components/TextLabelBadge.js
copy to devtools/client/accessibility/components/KeyboardBadge.js
--- a/devtools/client/accessibility/components/TextLabelBadge.js
+++ b/devtools/client/accessibility/components/KeyboardBadge.js
@@ -1,54 +1,49 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 // React
 const {
-  Component,
   createFactory,
+  PureComponent,
 } = require("devtools/client/shared/vendor/react");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 
 const { L10N } = require("../utils/l10n");
 
 const {
   accessibility: {
     SCORES: { BEST_PRACTICES, FAIL, WARNING },
   },
 } = require("devtools/shared/constants");
 
 loader.lazyGetter(this, "Badge", () => createFactory(require("./Badge")));
 
 /**
- * Component for rendering a badge for text alternative accessibliity check
- * failures association with a given accessibility object in the accessibility
- * tree.
+ * Component for rendering a badge for keyboard accessibliity check failures
+ * association with a given accessibility object in the accessibility tree.
  */
-class TextLabelBadge extends Component {
+class KeyboardBadge extends PureComponent {
   static get propTypes() {
     return {
       error: PropTypes.string,
       score: PropTypes.string,
     };
   }
 
   render() {
     const { error, score } = this.props;
-    if (error) {
-      return null;
-    }
-
-    if (![BEST_PRACTICES, FAIL, WARNING].includes(score)) {
+    if (error || ![BEST_PRACTICES, FAIL, WARNING].includes(score)) {
       return null;
     }
 
     return Badge({
-      label: L10N.getStr("accessibility.badge.textLabel"),
-      tooltip: L10N.getStr("accessibility.badge.textLabel.tooltip"),
+      label: L10N.getStr("accessibility.badge.keyboard"),
+      tooltip: L10N.getStr("accessibility.badge.keyboard.tooltip"),
     });
   }
 }
 
-module.exports = TextLabelBadge;
+module.exports = KeyboardBadge;
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/components/KeyboardCheck.js
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+// React
+const {
+  createFactory,
+  PureComponent,
+} = require("devtools/client/shared/vendor/react");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom-factories");
+
+const Check = createFactory(
+  require("devtools/client/accessibility/components/Check")
+);
+
+const { A11Y_KEYBOARD_LINKS } = require("../constants");
+const {
+  accessibility: {
+    AUDIT_TYPE: { KEYBOARD },
+    ISSUE_TYPE: {
+      [KEYBOARD]: {
+        FOCUSABLE_NO_SEMANTICS,
+        FOCUSABLE_POSITIVE_TABINDEX,
+        INTERACTIVE_NO_ACTION,
+        INTERACTIVE_NOT_FOCUSABLE,
+        NO_FOCUS_VISIBLE,
+      },
+    },
+  },
+} = require("devtools/shared/constants");
+
+/**
+ * A map from text label issues to annotation component properties.
+ */
+const ISSUE_TO_ANNOTATION_MAP = {
+  [FOCUSABLE_NO_SEMANTICS]: {
+    href: A11Y_KEYBOARD_LINKS.FOCUSABLE_NO_SEMANTICS,
+    l10nId: "accessibility-keyboard-issue-semantics",
+  },
+  [FOCUSABLE_POSITIVE_TABINDEX]: {
+    href: A11Y_KEYBOARD_LINKS.FOCUSABLE_POSITIVE_TABINDEX,
+    l10nId: "accessibility-keyboard-issue-tabindex",
+    args: {
+      get code() {
+        return ReactDOM.code({}, "tabindex");
+      },
+    },
+  },
+  [INTERACTIVE_NO_ACTION]: {
+    href: A11Y_KEYBOARD_LINKS.INTERACTIVE_NO_ACTION,
+    l10nId: "accessibility-keyboard-issue-action",
+  },
+  [INTERACTIVE_NOT_FOCUSABLE]: {
+    href: A11Y_KEYBOARD_LINKS.INTERACTIVE_NOT_FOCUSABLE,
+    l10nId: "accessibility-keyboard-issue-focusable",
+  },
+  [NO_FOCUS_VISIBLE]: {
+    href: A11Y_KEYBOARD_LINKS.NO_FOCUS_VISIBLE,
+    l10nId: "accessibility-keyboard-issue-focus-visible",
+  },
+};
+
+/**
+ * Component for rendering a check for text label accessibliity check failures,
+ * warnings and best practices suggestions association with a given
+ * accessibility object in the accessibility tree.
+ */
+class KeyboardCheck extends PureComponent {
+  static get propTypes() {
+    return {
+      issue: PropTypes.string.isRequired,
+      score: PropTypes.string.isRequired,
+    };
+  }
+
+  render() {
+    return Check({
+      ...this.props,
+      getAnnotation: issue => ISSUE_TO_ANNOTATION_MAP[issue],
+      id: "accessibility-keyboard-header",
+    });
+  }
+}
+
+module.exports = KeyboardCheck;
--- a/devtools/client/accessibility/components/MainFrame.js
+++ b/devtools/client/accessibility/components/MainFrame.js
@@ -42,17 +42,16 @@ class MainFrame extends Component {
     return {
       accessibility: PropTypes.object.isRequired,
       fluentBundles: PropTypes.array.isRequired,
       accessibilityWalker: PropTypes.object.isRequired,
       enabled: PropTypes.bool.isRequired,
       dispatch: PropTypes.func.isRequired,
       auditing: PropTypes.array.isRequired,
       supports: PropTypes.object,
-      getDOMWalker: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
     this.resetAccessibility = this.resetAccessibility.bind(this);
     this.onPanelWindowResize = this.onPanelWindowResize.bind(this);
@@ -108,17 +107,16 @@ class MainFrame extends Component {
 
   /**
    * Render Accessibility panel content
    */
   render() {
     const {
       accessibility,
       accessibilityWalker,
-      getDOMWalker,
       fluentBundles,
       enabled,
       auditing,
     } = this.props;
 
     if (!enabled) {
       return Description({ accessibility });
     }
@@ -145,19 +143,19 @@ class MainFrame extends Component {
             maxSize: "80%",
             splitterSize: 1,
             endPanelControl: true,
             startPanel: div(
               {
                 className: "main-panel",
                 role: "presentation",
               },
-              AccessibilityTree({ accessibilityWalker, getDOMWalker })
+              AccessibilityTree({ accessibilityWalker })
             ),
-            endPanel: RightSidebar({ accessibilityWalker, getDOMWalker }),
+            endPanel: RightSidebar({ accessibilityWalker }),
             vert: this.useLandscapeMode,
           })
         )
       )
     );
   }
 }
 
--- a/devtools/client/accessibility/components/RightSidebar.js
+++ b/devtools/client/accessibility/components/RightSidebar.js
@@ -18,28 +18,27 @@ const Accordion = createFactory(
 );
 const Checks = createFactory(require("./Checks"));
 
 // Component that is responsible for rendering accessible panel's sidebar.
 class RightSidebar extends Component {
   static get propTypes() {
     return {
       accessibilityWalker: PropTypes.object.isRequired,
-      getDOMWalker: PropTypes.func.isRequired,
     };
   }
 
   /**
    * Render the sidebar component.
    * @returns Sidebar React component.
    */
   render() {
     const propertiesHeaderID = "accessibility-properties-header";
     const checksHeaderID = "accessibility-checks-header";
-    const { accessibilityWalker, getDOMWalker } = this.props;
+    const { accessibilityWalker } = this.props;
     return div(
       {
         className: "right-sidebar",
         role: "presentation",
       },
       Accordion({
         items: [
           {
@@ -48,17 +47,16 @@ class RightSidebar extends Component {
             header: L10N.getStr("accessibility.checks"),
             labelledby: checksHeaderID,
             opened: true,
           },
           {
             className: "accessible",
             component: Accessible({
               accessibilityWalker,
-              getDOMWalker,
               labelledby: propertiesHeaderID,
             }),
             header: L10N.getStr("accessibility.properties"),
             labelledby: propertiesHeaderID,
             opened: true,
           },
         ],
       })
--- a/devtools/client/accessibility/components/TextLabelBadge.js
+++ b/devtools/client/accessibility/components/TextLabelBadge.js
@@ -1,18 +1,18 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 // React
 const {
-  Component,
   createFactory,
+  PureComponent,
 } = require("devtools/client/shared/vendor/react");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 
 const { L10N } = require("../utils/l10n");
 
 const {
   accessibility: {
     SCORES: { BEST_PRACTICES, FAIL, WARNING },
@@ -21,31 +21,27 @@ const {
 
 loader.lazyGetter(this, "Badge", () => createFactory(require("./Badge")));
 
 /**
  * Component for rendering a badge for text alternative accessibliity check
  * failures association with a given accessibility object in the accessibility
  * tree.
  */
-class TextLabelBadge extends Component {
+class TextLabelBadge extends PureComponent {
   static get propTypes() {
     return {
       error: PropTypes.string,
       score: PropTypes.string,
     };
   }
 
   render() {
     const { error, score } = this.props;
-    if (error) {
-      return null;
-    }
-
-    if (![BEST_PRACTICES, FAIL, WARNING].includes(score)) {
+    if (error || ![BEST_PRACTICES, FAIL, WARNING].includes(score)) {
       return null;
     }
 
     return Badge({
       label: L10N.getStr("accessibility.badge.textLabel"),
       tooltip: L10N.getStr("accessibility.badge.textLabel.tooltip"),
     });
   }
--- a/devtools/client/accessibility/components/TextLabelCheck.js
+++ b/devtools/client/accessibility/components/TextLabelCheck.js
@@ -1,28 +1,26 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 // React
 const {
-  Component,
   createFactory,
+  PureComponent,
 } = require("devtools/client/shared/vendor/react");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 const ReactDOM = require("devtools/client/shared/vendor/react-dom-factories");
 
-const FluentReact = require("devtools/client/shared/vendor/fluent-react");
-const Localized = createFactory(FluentReact.Localized);
-
-const { openDocLink } = require("devtools/client/shared/link");
+const Check = createFactory(
+  require("devtools/client/accessibility/components/Check")
+);
 
 const { A11Y_TEXT_LABEL_LINKS } = require("../constants");
-
 const {
   accessibility: {
     AUDIT_TYPE: { TEXT_LABEL },
     ISSUE_TYPE: {
       [TEXT_LABEL]: {
         AREA_NO_NAME_FROM_ALT,
         DIALOG_NO_NAME,
         DOCUMENT_NO_TITLE,
@@ -38,17 +36,16 @@ const {
         HEADING_NO_NAME,
         IFRAME_NO_NAME_FROM_TITLE,
         IMAGE_NO_NAME,
         INTERACTIVE_NO_NAME,
         MATHML_GLYPH_NO_NAME,
         TOOLBAR_NO_NAME,
       },
     },
-    SCORES: { BEST_PRACTICES, FAIL, WARNING },
   },
 } = require("devtools/shared/constants");
 
 /**
  * A map from text label issues to annotation component properties.
  */
 const ISSUE_TO_ANNOTATION_MAP = {
   [AREA_NO_NAME_FROM_ALT]: {
@@ -197,137 +194,30 @@ const ISSUE_TO_ANNOTATION_MAP = {
   },
   [TOOLBAR_NO_NAME]: {
     href: A11Y_TEXT_LABEL_LINKS.TOOLBAR_NO_NAME,
     l10nId: "accessibility-text-label-issue-toolbar",
   },
 };
 
 /**
- * A map of accessibility scores to the text descriptions of check icons.
- */
-const SCORE_TO_ICON_MAP = {
-  [BEST_PRACTICES]: {
-    l10nId: "accessibility-best-practices",
-    src: "chrome://devtools/skin/images/info.svg",
-  },
-  [FAIL]: {
-    l10nId: "accessibility-fail",
-    src: "chrome://devtools/skin/images/error.svg",
-  },
-  [WARNING]: {
-    l10nId: "accessibility-warning",
-    src: "chrome://devtools/skin/images/alert.svg",
-  },
-};
-
-/**
- * Localized "Learn more" link that opens a new tab with relevant documentation.
- */
-class LearnMoreClass extends Component {
-  static get propTypes() {
-    return {
-      href: PropTypes.string,
-      l10nId: PropTypes.string.isRequired,
-      onClick: PropTypes.func,
-    };
-  }
-
-  static get defaultProps() {
-    return {
-      href: "#",
-      l10nId: null,
-      onClick: LearnMoreClass.openDocOnClick,
-    };
-  }
-
-  static openDocOnClick(event) {
-    event.preventDefault();
-    openDocLink(event.target.href);
-  }
-
-  render() {
-    const { href, l10nId, onClick } = this.props;
-    const className = "link";
-
-    return Localized({ id: l10nId }, ReactDOM.a({ className, href, onClick }));
-  }
-}
-
-const LearnMore = createFactory(LearnMoreClass);
-
-/**
- * Renders icon with text description for the text label accessibility check.
- *
- * @param {Object}
- *        Options:
- *          - score: value from SCORES from "devtools/shared/constants"
- */
-function Icon({ score }) {
-  const { l10nId, src } = SCORE_TO_ICON_MAP[score];
-
-  return Localized(
-    { id: l10nId, attrs: { alt: true } },
-    ReactDOM.img({ src, className: `icon ${score}` })
-  );
-}
-
-/**
- * Renders text description of the text label accessibility check.
- *
- * @param {Object}
- *        Options:
- *          - issue: value from ISSUE_TYPE[AUDIT_TYPE.TEXT_LABEL] from
- *                   "devtools/shared/constants"
- */
-function Annotation({ issue }) {
-  const { args, href, l10nId } = ISSUE_TO_ANNOTATION_MAP[issue];
-
-  return Localized(
-    {
-      id: l10nId,
-      a: LearnMore({ l10nId: "accessibility-learn-more", href }),
-      ...args,
-    },
-    ReactDOM.p({ className: "accessibility-check-annotation" })
-  );
-}
-
-/**
  * Component for rendering a check for text label accessibliity check failures,
  * warnings and best practices suggestions association with a given
  * accessibility object in the accessibility tree.
  */
-class TextLabelCheck extends Component {
+class TextLabelCheck extends PureComponent {
   static get propTypes() {
     return {
       issue: PropTypes.string.isRequired,
       score: PropTypes.string.isRequired,
     };
   }
 
   render() {
-    const { issue, score } = this.props;
-
-    return ReactDOM.div(
-      {
-        role: "presentation",
-        className: "accessibility-check",
-      },
-      Localized(
-        {
-          id: "accessibility-text-label-header",
-        },
-        ReactDOM.h3({ className: "accessibility-check-header" })
-      ),
-      ReactDOM.div(
-        {
-          role: "presentation",
-          className: "accessibility-text-label-check",
-        },
-        Icon({ score }),
-        Annotation({ issue })
-      )
-    );
+    return Check({
+      ...this.props,
+      getAnnotation: issue => ISSUE_TO_ANNOTATION_MAP[issue],
+      id: "accessibility-text-label-header",
+    });
   }
 }
 
 module.exports = TextLabelCheck;
--- a/devtools/client/accessibility/components/moz.build
+++ b/devtools/client/accessibility/components/moz.build
@@ -10,19 +10,22 @@ DevToolsModules(
     'AccessibilityTreeFilter.js',
     'Accessible.js',
     'AuditController.js',
     'AuditFilter.js',
     'AuditProgressOverlay.js',
     'Badge.js',
     'Badges.js',
     'Button.js',
+    'Check.js',
     'Checks.js',
     'ColorContrastAccessibility.js',
     'ContrastBadge.js',
     'Description.js',
+    'KeyboardBadge.js',
+    'KeyboardCheck.js',
     'LearnMoreLink.js',
     'MainFrame.js',
     'RightSidebar.js',
     'TextLabelBadge.js',
     'TextLabelCheck.js',
     'Toolbar.js'
 )
--- a/devtools/client/accessibility/constants.js
+++ b/devtools/client/accessibility/constants.js
@@ -2,16 +2,23 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const {
   accessibility: {
     AUDIT_TYPE,
     ISSUE_TYPE: {
+      [AUDIT_TYPE.KEYBOARD]: {
+        FOCUSABLE_NO_SEMANTICS,
+        FOCUSABLE_POSITIVE_TABINDEX,
+        INTERACTIVE_NO_ACTION,
+        INTERACTIVE_NOT_FOCUSABLE,
+        NO_FOCUS_VISIBLE,
+      },
       [AUDIT_TYPE.TEXT_LABEL]: {
         AREA_NO_NAME_FROM_ALT,
         DIALOG_NO_NAME,
         DOCUMENT_NO_TITLE,
         EMBED_NO_NAME,
         FIGURE_NO_NAME,
         FORM_FIELDSET_NO_NAME,
         FORM_FIELDSET_NO_NAME_FROM_LEGEND,
@@ -63,16 +70,17 @@ exports.AUDIT = "AUDIT";
 exports.AUDITING = "AUDITING";
 exports.AUDIT_PROGRESS = "AUDIT_PROGRESS";
 
 // List of filters for accessibility checks.
 exports.FILTERS = {
   NONE: "NONE",
   ALL: "ALL",
   [AUDIT_TYPE.CONTRAST]: "CONTRAST",
+  [AUDIT_TYPE.KEYBOARD]: "KEYBOARD",
   [AUDIT_TYPE.TEXT_LABEL]: "TEXT_LABEL",
 };
 
 // Ordered accessible properties to be displayed by the accessible component.
 exports.ORDERED_PROPS = [
   "name",
   "role",
   "actions",
@@ -146,16 +154,39 @@ const A11Y_TEXT_LABEL_LINK_IDS = {
 const A11Y_TEXT_LABEL_LINKS = {};
 for (const key in A11Y_TEXT_LABEL_LINK_IDS) {
   A11Y_TEXT_LABEL_LINKS[key] = `${A11Y_TEXT_LABEL_LINK_BASE}#${
     A11Y_TEXT_LABEL_LINK_IDS[key]
   }`;
 }
 exports.A11Y_TEXT_LABEL_LINKS = A11Y_TEXT_LABEL_LINKS;
 
+const A11Y_KEYBOARD_LINK_BASE =
+  "https://developer.mozilla.org/docs/Web/Accessibility/Understanding_WCAG/Keyboard" +
+  "?utm_source=devtools&utm_medium=a11y-panel-checks-keyboard";
+
+const A11Y_KEYBOARD_LINK_IDS = {
+  [FOCUSABLE_NO_SEMANTICS]:
+    "Focusable_elements_should_have_interactive_semantics",
+  [FOCUSABLE_POSITIVE_TABINDEX]:
+    "Avoid_using_tabindex_attribute_greater_than_zero",
+  [INTERACTIVE_NO_ACTION]:
+    "Interactive_elements_must_be_able_to_be_activated_using_a_keyboard",
+  [INTERACTIVE_NOT_FOCUSABLE]: "Interactive_elements_must_be_focusable",
+  [NO_FOCUS_VISIBLE]: "Focusable_element_must_have_focus_styling",
+};
+
+const A11Y_KEYBOARD_LINKS = {};
+for (const key in A11Y_KEYBOARD_LINK_IDS) {
+  A11Y_KEYBOARD_LINKS[key] = `${A11Y_KEYBOARD_LINK_BASE}#${
+    A11Y_KEYBOARD_LINK_IDS[key]
+  }`;
+}
+exports.A11Y_KEYBOARD_LINKS = A11Y_KEYBOARD_LINKS;
+
 // Lists of preference names and keys.
 const PREFS = {
   SCROLL_INTO_VIEW: "SCROLL_INTO_VIEW",
 };
 
 exports.PREFS = PREFS;
 exports.PREF_KEYS = {
   [PREFS.SCROLL_INTO_VIEW]: "devtools.accessibility.scroll-into-view",
--- a/devtools/client/accessibility/reducers/audit.js
+++ b/devtools/client/accessibility/reducers/audit.js
@@ -20,30 +20,32 @@ const {
 /**
  * Initial state definition
  */
 function getInitialState() {
   return {
     filters: {
       [FILTERS.ALL]: false,
       [FILTERS.CONTRAST]: false,
+      [FILTERS.KEYBOARD]: false,
       [FILTERS.TEXT_LABEL]: false,
     },
     auditing: [],
     progress: null,
   };
 }
 
 /**
  * State with all filters active.
  */
 function allActiveFilters() {
   return {
     [FILTERS.ALL]: true,
     [FILTERS.CONTRAST]: true,
+    [FILTERS.KEYBOARD]: true,
     [FILTERS.TEXT_LABEL]: true,
   };
 }
 
 function audit(state = getInitialState(), action) {
   switch (action.type) {
     case FILTER_TOGGLE:
       const { filter } = action;
@@ -57,21 +59,20 @@ function audit(state = getInitialState()
           ? allActiveFilters()
           : getInitialState().filters;
       } else {
         filters = {
           ...filters,
           [filter]: isToggledToActive,
         };
 
-        if (
-          isToggledToActive &&
-          !filters[FILTERS.ALL] &&
-          Object.values(AUDIT_TYPE).every(filterKey => filters[filterKey])
-        ) {
+        const allAuditTypesActive = Object.values(AUDIT_TYPE)
+          .filter(filterKey => filters.hasOwnProperty(filterKey))
+          .every(filterKey => filters[filterKey]);
+        if (isToggledToActive && !filters[FILTERS.ALL] && allAuditTypesActive) {
           filters[FILTERS.ALL] = true;
         } else if (!isToggledToActive && filters[FILTERS.ALL]) {
           filters[FILTERS.ALL] = false;
         }
       }
 
       return {
         ...state,
--- a/devtools/client/accessibility/test/browser/browser_accessibility_panel_toolbar_checks.js
+++ b/devtools/client/accessibility/test/browser/browser_accessibility_panel_toolbar_checks.js
@@ -21,80 +21,89 @@ const TEST_URI = `<html>
  *                        the state of the tree and the sidebar can be checked.
  *   expected {JSON}      An expected states for the tree and the sidebar.
  * }
  */
 const tests = [
   {
     desc: "Check initial state.",
     expected: {
-      activeToolbarFilters: [true, false, false, false],
+      activeToolbarFilters: [true, false, false, false, false],
     },
   },
   {
     desc: "Toggle first filter (all) to activate.",
     setup: async ({ doc }) => {
       await toggleMenuItem(doc, 0, 1);
     },
     expected: {
-      activeToolbarFilters: [false, true, true, true],
+      activeToolbarFilters: [false, true, true, true, true],
     },
   },
   {
     desc: "Click on the filter again.",
     setup: async ({ doc }) => {
       await toggleMenuItem(doc, 0, 1);
     },
     expected: {
-      activeToolbarFilters: [true, false, false, false],
+      activeToolbarFilters: [true, false, false, false, false],
     },
   },
   {
     desc: "Toggle first custom filter to activate.",
     setup: async ({ doc }) => {
       await toggleMenuItem(doc, 0, 2);
     },
     expected: {
-      activeToolbarFilters: [false, false, true, false],
+      activeToolbarFilters: [false, false, true, false, false],
     },
   },
   {
     desc: "Click on the filter again.",
     setup: async ({ doc }) => {
       await toggleMenuItem(doc, 0, 2);
     },
     expected: {
-      activeToolbarFilters: [true, false, false, false],
+      activeToolbarFilters: [true, false, false, false, false],
     },
   },
   {
     desc: "Toggle first custom filter to activate.",
     setup: async ({ doc }) => {
       await toggleMenuItem(doc, 0, 2);
     },
     expected: {
-      activeToolbarFilters: [false, false, true, false],
+      activeToolbarFilters: [false, false, true, false, false],
     },
   },
   {
     desc: "Toggle second custom filter to activate.",
     setup: async ({ doc }) => {
       await toggleMenuItem(doc, 0, 3);
     },
     expected: {
-      activeToolbarFilters: [false, true, true, true],
+      activeToolbarFilters: [false, false, true, true, false],
+    },
+  },
+  {
+    desc: "Toggle third custom filter to activate.",
+    setup: async ({ doc }) => {
+      await toggleMenuItem(doc, 0, 4);
+    },
+    expected: {
+      activeToolbarFilters: [false, true, true, true, true],
     },
   },
   {
     desc: "Click on the none filter to de-activate all.",
     setup: async ({ doc }) => {
       await toggleMenuItem(doc, 0, 0);
     },
     expected: {
-      activeToolbarFilters: [true, false, false, false],
+      activeToolbarFilters: [true, false, false, false, false],
     },
   },
 ];
 
 /**
  * Simple test that checks toggle states for filters in the Accessibility panel
  * toolbar.
  */
--- a/devtools/client/accessibility/test/browser/browser_accessibility_sidebar_checks.js
+++ b/devtools/client/accessibility/test/browser/browser_accessibility_sidebar_checks.js
@@ -1,13 +1,17 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
+const {
+  accessibility: { SCORES },
+} = require("devtools/shared/constants");
+
 const TEST_URI = `<html>
   <head>
     <meta charset="utf-8"/>
     <title>Accessibility Panel Test</title>
   </head>
   <body>
     <p style="color: red;">Red</p>
     <p style="color: blue;">Blue</p>
@@ -40,17 +44,17 @@ const tests = [
     },
     expected: {
       audit: {
         CONTRAST: {
           value: 4.0,
           color: [255, 0, 0, 1],
           backgroundColor: [255, 255, 255, 1],
           isLargeText: false,
-          score: "fail",
+          score: SCORES.FAIL,
         },
       },
     },
   },
   {
     desc: "Check accessible representing text node in blue.",
     setup: async ({ doc }) => {
       await toggleRow(doc, 3);
@@ -58,17 +62,17 @@ const tests = [
     },
     expected: {
       audit: {
         CONTRAST: {
           value: 8.59,
           color: [0, 0, 255, 1],
           backgroundColor: [255, 255, 255, 1],
           isLargeText: false,
-          score: "AAA",
+          score: SCORES.AAA,
         },
       },
     },
   },
 ];
 
 /**
  * Test that checks the Accessibility panel sidebar.
--- a/devtools/client/accessibility/test/browser/browser_accessibility_tree_audit_toolbar.js
+++ b/devtools/client/accessibility/test/browser/browser_accessibility_tree_audit_toolbar.js
@@ -35,17 +35,17 @@ const tests = [
     expected: {
       tree: [
         {
           role: "document",
           name: `"Accessibility Panel Test"`,
           selected: true,
         },
       ],
-      activeToolbarFilters: [true, false, false, false],
+      activeToolbarFilters: [true, false, false, false, false],
     },
   },
   {
     desc: "Run an audit (all) from a11y panel toolbar by activating a filter.",
     setup: async ({ doc }) => {
       await toggleMenuItem(doc, 0, 1);
     },
     expected: {
@@ -57,17 +57,17 @@ const tests = [
           selected: true,
         },
         {
           role: "text leaf",
           name: `"Second level header "contrast`,
           badges: ["contrast"],
         },
       ],
-      activeToolbarFilters: [false, true, true, true],
+      activeToolbarFilters: [false, true, true, true, true],
     },
   },
   {
     desc: "Click on the filter again.",
     setup: async ({ doc }) => {
       await toggleMenuItem(doc, 0, 1);
     },
     expected: {
@@ -91,17 +91,17 @@ const tests = [
           name: `"Second level header"`,
         },
         {
           role: "text leaf",
           name: `"Second level header "contrast`,
           badges: ["contrast"],
         },
       ],
-      activeToolbarFilters: [true, false, false, false],
+      activeToolbarFilters: [true, false, false, false, false],
     },
   },
 ];
 
 /**
  * Simple test that checks content of the Accessibility panel tree when the
  * audit is activated via the panel's toolbar.
  */
--- a/devtools/client/accessibility/test/browser/head.js
+++ b/devtools/client/accessibility/test/browser/head.js
@@ -586,19 +586,20 @@ async function toggleMenuItem(doc, menuB
   EventUtils.synthesizeMouseAtCenter(menuItem, {}, win);
   await BrowserTestUtils.waitForCondition(
     () => expected === menuItem.getAttribute("aria-checked"),
     "Menu item updated."
   );
 }
 
 async function findAccessibleFor(
-  { toolbox: { walker: domWalker }, panel: { walker: accessibilityWalker } },
+  { toolbox: { target }, panel: { walker: accessibilityWalker } },
   selector
 ) {
+  const domWalker = (await target.getFront("inspector")).walker;
   const node = await domWalker.querySelector(domWalker.rootNode, selector);
   return accessibilityWalker.getAccessibleFor(node);
 }
 
 async function selectAccessibleForNode(env, selector) {
   const { panel, win } = env;
   const front = await findAccessibleFor(env, selector);
   const { EVENTS } = win;
--- a/devtools/client/accessibility/test/jest/components/__snapshots__/accessibility-tree-filter.test.js.snap
+++ b/devtools/client/accessibility/test/jest/components/__snapshots__/accessibility-tree-filter.test.js.snap
@@ -15,21 +15,21 @@ exports[`AccessibilityTreeFilter compone
 exports[`AccessibilityTreeFilter component: render filters after state changes 1`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.none</button></div>"`;
 
 exports[`AccessibilityTreeFilter component: render filters after state changes 2`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.none</button></div>"`;
 
 exports[`AccessibilityTreeFilter component: render filters after state changes 3`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.none</button></div>"`;
 
 exports[`AccessibilityTreeFilter component: render filters after state changes 4`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.all2</button></div>"`;
 
-exports[`AccessibilityTreeFilter component: render filters after state changes 5`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.textLabel</button></div>"`;
+exports[`AccessibilityTreeFilter component: render filters after state changes 5`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.keyboard, accessibility.filter.textLabel</button></div>"`;
 
-exports[`AccessibilityTreeFilter component: render filters after state changes 6`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.textLabel</button></div>"`;
+exports[`AccessibilityTreeFilter component: render filters after state changes 6`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.keyboard, accessibility.filter.textLabel</button></div>"`;
 
-exports[`AccessibilityTreeFilter component: render filters after state changes 7`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.textLabel</button></div>"`;
+exports[`AccessibilityTreeFilter component: render filters after state changes 7`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.keyboard, accessibility.filter.textLabel</button></div>"`;
 
 exports[`AccessibilityTreeFilter component: render filters after state changes 8`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.all2</button></div>"`;
 
 exports[`AccessibilityTreeFilter component: render filters after state changes 9`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.none</button></div>"`;
 
 exports[`AccessibilityTreeFilter component: render filters after state changes 10`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.none</button></div>"`;
 
 exports[`AccessibilityTreeFilter component: render filters after state changes 11`] = `"<div role=\\"group\\" class=\\"accessibility-tree-filters\\" aria-labelledby=\\"accessibility-tree-filters-label\\"><span id=\\"accessibility-tree-filters-label\\" role=\\"presentation\\">accessibility.tree.filters</span><button class=\\"devtools-button badge toolbar-menu-button filters\\" aria-expanded=\\"false\\" aria-haspopup=\\"menu\\" aria-controls=\\"accessibility-tree-filters-menu\\">accessibility.filter.none</button></div>"`;
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/test/jest/components/__snapshots__/check.test.js.snap
@@ -0,0 +1,5 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Check component: basic render 1`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/error.svg\\" class=\\"icon FAIL\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+
+exports[`Check component: basic render 2`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/error.svg\\" class=\\"icon FAIL\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/test/jest/components/__snapshots__/keyboard-badge.test.js.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`KeyboardBadge component: error render 1`] = `null`;
+
+exports[`KeyboardBadge component: fail render 1`] = `"<span class=\\"audit-badge badge\\" title=\\"accessibility.badge.keyboard.tooltip\\" aria-label=\\"accessibility.badge.keyboard\\">accessibility.badge.keyboard</span>"`;
+
+exports[`KeyboardBadge component: warning render 1`] = `"<span class=\\"audit-badge badge\\" title=\\"accessibility.badge.keyboard.tooltip\\" aria-label=\\"accessibility.badge.keyboard\\">accessibility.badge.keyboard</span>"`;
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/test/jest/components/__snapshots__/keyboard-check.test.js.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`KeyboardCheck component: FAIL render 1`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/error.svg\\" class=\\"icon FAIL\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+
+exports[`KeyboardCheck component: FAIL render 2`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/error.svg\\" class=\\"icon FAIL\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+
+exports[`KeyboardCheck component: WARNING render 1`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/alert.svg\\" class=\\"icon WARNING\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+
+exports[`KeyboardCheck component: WARNING render 2`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/alert.svg\\" class=\\"icon WARNING\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
--- a/devtools/client/accessibility/test/jest/components/__snapshots__/text-label-check.test.js.snap
+++ b/devtools/client/accessibility/test/jest/components/__snapshots__/text-label-check.test.js.snap
@@ -1,7 +1,13 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`TextLabelCheck component: BEST_PRACTICES render 1`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\" class=\\"accessibility-text-label-check\\"><img src=\\"chrome://devtools/skin/images/info.svg\\" class=\\"icon BEST_PRACTICES\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+exports[`TextLabelCheck component: BEST_PRACTICES render 1`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/info.svg\\" class=\\"icon BEST_PRACTICES\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+
+exports[`TextLabelCheck component: BEST_PRACTICES render 2`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/info.svg\\" class=\\"icon BEST_PRACTICES\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+
+exports[`TextLabelCheck component: FAIL render 1`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/error.svg\\" class=\\"icon FAIL\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
 
-exports[`TextLabelCheck component: WARNING render 1`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\" class=\\"accessibility-text-label-check\\"><img src=\\"chrome://devtools/skin/images/alert.svg\\" class=\\"icon WARNING\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+exports[`TextLabelCheck component: FAIL render 2`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/error.svg\\" class=\\"icon FAIL\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
 
-exports[`TextLabelCheck component: fail render 1`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\" class=\\"accessibility-text-label-check\\"><img src=\\"chrome://devtools/skin/images/error.svg\\" class=\\"icon fail\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+exports[`TextLabelCheck component: WARNING render 1`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/alert.svg\\" class=\\"icon WARNING\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
+
+exports[`TextLabelCheck component: WARNING render 2`] = `"<div role=\\"presentation\\" class=\\"accessibility-check\\"><h3 class=\\"accessibility-check-header\\"></h3><div role=\\"presentation\\"><img src=\\"chrome://devtools/skin/images/alert.svg\\" class=\\"icon WARNING\\"><p class=\\"accessibility-check-annotation\\"></p></div></div>"`;
--- a/devtools/client/accessibility/test/jest/components/accessibility-tree-filter.test.js
+++ b/devtools/client/accessibility/test/jest/components/accessibility-tree-filter.test.js
@@ -80,43 +80,50 @@ describe("AccessibilityTreeFilter compon
         {
           active: false,
           disabled: false,
           text: "accessibility.filter.contrast",
         },
         {
           active: false,
           disabled: false,
+          text: "accessibility.filter.keyboard",
+        },
+        {
+          active: false,
+          disabled: false,
           text: "accessibility.filter.textLabel",
         },
       ],
     });
   });
 
   it("audit filters filtered", () => {
     const store = setupStore({
       preloadedState: {
         audit: {
           filters: {
             [FILTERS.ALL]: true,
             [FILTERS.CONTRAST]: true,
+            [FILTERS.KEYBOARD]: true,
             [FILTERS.TEXT_LABEL]: true,
           },
           auditing: [],
         },
       },
     });
     const wrapper = mount(Provider({ store }, AccessibilityTreeFilter()));
     expect(wrapper.html()).toMatchSnapshot();
     checkFiltersState(wrapper, {
       filters: [
         { active: false, disabled: false },
         { active: true, disabled: false },
         { active: true, disabled: false },
         { active: true, disabled: false },
+        { active: true, disabled: false },
       ],
     });
   });
 
   it("audit all filter not filtered auditing", () => {
     const store = setupStore({
       preloadedState: {
         audit: {
@@ -139,30 +146,32 @@ describe("AccessibilityTreeFilter compon
 
   it("audit other filter not filtered auditing", () => {
     const store = setupStore({
       preloadedState: {
         audit: {
           filters: {
             [FILTERS.ALL]: false,
             [FILTERS.CONTRAST]: false,
+            [FILTERS.KEYBOARD]: false,
             [FILTERS.TEXT_LABEL]: false,
           },
           auditing: [FILTERS.CONTRAST],
         },
       },
     });
     const wrapper = mount(Provider({ store }, AccessibilityTreeFilter()));
     expect(wrapper.html()).toMatchSnapshot();
     checkFiltersState(wrapper, {
       filters: [
         { active: true, disabled: true },
         { active: false, disabled: false },
         { active: false, disabled: true },
         { active: false, disabled: false },
+        { active: false, disabled: false },
       ],
     });
   });
 
   it("audit all filter filtered auditing", () => {
     const store = setupStore({
       preloadedState: {
         audit: {
@@ -181,30 +190,32 @@ describe("AccessibilityTreeFilter compon
 
   it("audit other filter filtered auditing", () => {
     const store = setupStore({
       preloadedState: {
         audit: {
           filters: {
             [FILTERS.ALL]: false,
             [FILTERS.CONTRAST]: true,
+            [FILTERS.KEYBOARD]: false,
             [FILTERS.TEXT_LABEL]: false,
           },
           auditing: [FILTERS.CONTRAST],
         },
       },
     });
     const wrapper = mount(Provider({ store }, AccessibilityTreeFilter()));
     expect(wrapper.html()).toMatchSnapshot();
     checkFiltersState(wrapper, {
       filters: [
         { active: false, disabled: true },
         { active: false, disabled: false },
         { active: true, disabled: true },
         { active: false, disabled: false },
+        { active: false, disabled: false },
       ],
     });
   });
 
   it("toggle filter", () => {
     const store = setupStore();
     const wrapper = mount(Provider({ store }, AccessibilityTreeFilter()));
     const filters = getMenuItems(wrapper, ".filter");
@@ -221,169 +232,181 @@ describe("AccessibilityTreeFilter compon
     const tests = [
       {
         expected: {
           filters: [
             { active: true, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
+            { active: false, disabled: false },
           ],
         },
       },
       {
         action: {
           type: AUDITING,
           auditing: Object.values(FILTERS),
         },
         expected: {
           filters: [
             { active: true, disabled: true },
             { active: false, disabled: true },
             { active: false, disabled: true },
             { active: false, disabled: true },
+            { active: false, disabled: true },
           ],
         },
       },
       {
         action: {
           type: AUDIT,
           response: [],
         },
         expected: {
           filters: [
             { active: true, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
+            { active: false, disabled: false },
           ],
         },
       },
       {
         action: {
           type: FILTER_TOGGLE,
           filter: FILTERS.ALL,
         },
         expected: {
           filters: [
             { active: false, disabled: false },
             { active: true, disabled: false },
             { active: true, disabled: false },
             { active: true, disabled: false },
+            { active: true, disabled: false },
           ],
         },
       },
       {
         action: {
           type: FILTER_TOGGLE,
           filter: FILTERS.CONTRAST,
         },
         expected: {
           filters: [
             { active: false, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
             { active: true, disabled: false },
+            { active: true, disabled: false },
           ],
         },
       },
       {
         action: {
           type: AUDITING,
           auditing: [FILTERS.CONTRAST],
         },
         expected: {
           filters: [
             { active: false, disabled: true },
             { active: false, disabled: false },
             { active: false, disabled: true },
             { active: true, disabled: false },
+            { active: true, disabled: false },
           ],
         },
       },
       {
         action: {
           type: AUDIT,
           response: [],
         },
         expected: {
           filters: [
             { active: false, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
             { active: true, disabled: false },
+            { active: true, disabled: false },
           ],
         },
       },
       {
         action: {
           type: FILTER_TOGGLE,
           filter: FILTERS.CONTRAST,
         },
         expected: {
           filters: [
             { active: false, disabled: false },
             { active: true, disabled: false },
             { active: true, disabled: false },
             { active: true, disabled: false },
+            { active: true, disabled: false },
           ],
         },
       },
       {
         action: {
           type: FILTER_TOGGLE,
           filter: FILTERS.NONE,
         },
         expected: {
           filters: [
             { active: true, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
+            { active: false, disabled: false },
           ],
         },
       },
       {
         action: {
           type: AUDITING,
           auditing: [FILTERS.TEXT_LABEL],
         },
         expected: {
           filters: [
             { active: true, disabled: true },
             { active: false, disabled: false },
             { active: false, disabled: false },
+            { active: false, disabled: false },
             { active: false, disabled: true },
           ],
         },
       },
       {
         action: {
           type: AUDIT,
           response: [],
         },
         expected: {
           filters: [
             { active: true, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
+            { active: false, disabled: false },
           ],
         },
       },
       {
         action: {
           type: FILTER_TOGGLE,
           filter: FILTERS.TEXT_LABEL,
         },
         expected: {
           filters: [
             { active: false, disabled: false },
             { active: false, disabled: false },
             { active: false, disabled: false },
+            { active: false, disabled: false },
             { active: true, disabled: false },
           ],
         },
       },
     ];
 
     for (const test of tests) {
       const { action, expected } = test;
--- a/devtools/client/accessibility/test/jest/components/audit-filter.test.js
+++ b/devtools/client/accessibility/test/jest/components/audit-filter.test.js
@@ -14,16 +14,20 @@ const Provider = createFactory(
 const ConnectedAuditFilterClass = require("devtools/client/accessibility/components/AuditFilter");
 const AuditFilterClass = ConnectedAuditFilterClass.WrappedComponent;
 const AuditFilter = createFactory(ConnectedAuditFilterClass);
 const {
   setupStore,
 } = require("devtools/client/accessibility/test/jest/helpers");
 const { FILTERS } = require("devtools/client/accessibility/constants");
 
+const {
+  accessibility: { SCORES },
+} = require("devtools/shared/constants");
+
 describe("AuditController component:", () => {
   it("audit filter not filtered", () => {
     const store = setupStore();
 
     const wrapper = mount(Provider({ store }, AuditFilter({}, span())));
     expect(wrapper.html()).toMatchSnapshot();
 
     const filter = wrapper.find(AuditFilterClass);
@@ -62,17 +66,17 @@ describe("AuditController component:", (
         AuditFilter(
           {
             checks: {
               CONTRAST: {
                 value: 5.11,
                 color: [255, 0, 0, 1],
                 backgroundColor: [255, 255, 255, 1],
                 isLargeText: false,
-                score: "AA",
+                score: SCORES.AA,
               },
             },
           },
           span()
         )
       )
     );
     expect(wrapper.html()).toMatchSnapshot();
@@ -84,17 +88,17 @@ describe("AuditController component:", (
       preloadedState: { audit: { filters: { [FILTERS.CONTRAST]: true } } },
     });
 
     const CONTRAST = {
       value: 3.1,
       color: [255, 0, 0, 1],
       backgroundColor: [255, 255, 255, 1],
       isLargeText: false,
-      score: "fail",
+      score: SCORES.FAIL,
     };
 
     const wrapper = mount(
       Provider(
         { store },
         AuditFilter(
           {
             checks: { CONTRAST },
@@ -116,17 +120,17 @@ describe("AuditController component:", (
 
     const CONTRAST = {
       min: 1.19,
       max: 1.39,
       color: [128, 128, 128, 1],
       backgroundColorMin: [219, 106, 116, 1],
       backgroundColorMax: [156, 145, 211, 1],
       isLargeText: false,
-      score: "fail",
+      score: SCORES.FAIL,
     };
 
     const wrapper = mount(
       Provider(
         { store },
         AuditFilter(
           {
             checks: { CONTRAST },
--- a/devtools/client/accessibility/test/jest/components/badges.test.js
+++ b/devtools/client/accessibility/test/jest/components/badges.test.js
@@ -15,16 +15,20 @@ const {
 } = require("devtools/client/accessibility/test/jest/helpers");
 
 const Badge = require("devtools/client/accessibility/components/Badge");
 const Badges = createFactory(
   require("devtools/client/accessibility/components/Badges")
 );
 const ContrastBadge = require("devtools/client/accessibility/components/ContrastBadge");
 
+const {
+  accessibility: { SCORES },
+} = require("devtools/shared/constants");
+
 describe("Badges component:", () => {
   const store = setupStore();
 
   it("no props render", () => {
     const wrapper = mount(Provider({ store }, Badges()));
     expect(wrapper.html()).toMatchSnapshot();
     expect(wrapper.isEmptyRender()).toBe(true);
   });
@@ -53,33 +57,33 @@ describe("Badges component:", () => {
         { store },
         Badges({
           checks: {
             CONTRAST: {
               value: 5.11,
               color: [255, 0, 0, 1],
               backgroundColor: [255, 255, 255, 1],
               isLargeText: false,
-              score: "AA",
+              score: SCORES.AA,
             },
           },
         })
       )
     );
     expect(wrapper.html()).toMatchSnapshot();
     expect(wrapper.isEmptyRender()).toBe(true);
   });
 
   it("contrast ratio fail render", () => {
     const CONTRAST = {
       value: 3.1,
       color: [255, 0, 0, 1],
       backgroundColor: [255, 255, 255, 1],
       isLargeText: false,
-      score: "fail",
+      score: SCORES.FAIL,
     };
     const wrapper = mount(
       Provider({ store }, Badges({ checks: { CONTRAST } }))
     );
 
     expect(wrapper.html()).toMatchSnapshot();
     expect(wrapper.find(Badge).length).toBe(1);
     expect(wrapper.find(ContrastBadge).length).toBe(1);
@@ -94,17 +98,17 @@ describe("Badges component:", () => {
   it("contrast ratio fail range render", () => {
     const CONTRAST = {
       min: 1.19,
       max: 1.39,
       color: [128, 128, 128, 1],
       backgroundColorMin: [219, 106, 116, 1],
       backgroundColorMax: [156, 145, 211, 1],
       isLargeText: false,
-      score: "fail",
+      score: SCORES.FAIL,
     };
     const wrapper = mount(
       Provider({ store }, Badges({ checks: { CONTRAST } }))
     );
 
     expect(wrapper.html()).toMatchSnapshot();
     expect(wrapper.find(Badge).length).toBe(1);
     expect(wrapper.find(ContrastBadge).length).toBe(1);
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/test/jest/components/check.test.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { mount } = require("enzyme");
+
+const { createFactory } = require("devtools/client/shared/vendor/react");
+const CheckClass = require("devtools/client/accessibility/components/Check");
+const Check = createFactory(CheckClass);
+
+const FluentReact = require("devtools/client/shared/vendor/fluent-react");
+const LocalizationProvider = createFactory(FluentReact.LocalizationProvider);
+
+const {
+  accessibility: {
+    AUDIT_TYPE: { TEXT_LABEL },
+    ISSUE_TYPE: {
+      [TEXT_LABEL]: { AREA_NO_NAME_FROM_ALT },
+    },
+    SCORES: { FAIL },
+  },
+} = require("devtools/shared/constants");
+
+const {
+  testCheck,
+} = require("devtools/client/accessibility/test/jest/helpers");
+
+describe("Check component:", () => {
+  const props = {
+    id: "accessibility-text-label-header",
+    issue: AREA_NO_NAME_FROM_ALT,
+    score: FAIL,
+    getAnnotation: jest.fn(),
+  };
+
+  it("basic render", () => {
+    const wrapper = mount(LocalizationProvider({ bundles: [] }, Check(props)));
+    expect(wrapper.html()).toMatchSnapshot();
+
+    testCheck(wrapper.childAt(0), {
+      issue: AREA_NO_NAME_FROM_ALT,
+      score: FAIL,
+    });
+    expect(props.getAnnotation.mock.calls.length).toBe(1);
+    expect(props.getAnnotation.mock.calls[0]).toEqual([AREA_NO_NAME_FROM_ALT]);
+  });
+});
--- a/devtools/client/accessibility/test/jest/components/contrast-badge.test.js
+++ b/devtools/client/accessibility/test/jest/components/contrast-badge.test.js
@@ -14,70 +14,74 @@ const Provider = createFactory(
 const {
   setupStore,
 } = require("devtools/client/accessibility/test/jest/helpers");
 
 const Badge = require("devtools/client/accessibility/components/Badge");
 const ContrastBadgeClass = require("devtools/client/accessibility/components/ContrastBadge");
 const ContrastBadge = createFactory(ContrastBadgeClass);
 
+const {
+  accessibility: { SCORES },
+} = require("devtools/shared/constants");
+
 describe("ContrastBadge component:", () => {
   const store = setupStore();
 
   it("error render", () => {
     const wrapper = shallow(ContrastBadge({ error: true }));
     expect(wrapper.html()).toMatchSnapshot();
     expect(wrapper.isEmptyRender()).toBe(true);
   });
 
   it("success render", () => {
     const wrapper = shallow(
       ContrastBadge({
         value: 5.11,
         isLargeText: false,
-        score: "AA",
+        score: SCORES.AA,
       })
     );
     expect(wrapper.html()).toMatchSnapshot();
     expect(wrapper.isEmptyRender()).toBe(true);
   });
 
   it("success range render", () => {
     const wrapper = shallow(
       ContrastBadge({
         min: 5.11,
         max: 6.25,
         isLargeText: false,
-        score: "AA",
+        score: SCORES.AA,
       })
     );
     expect(wrapper.html()).toMatchSnapshot();
     expect(wrapper.isEmptyRender()).toBe(true);
   });
 
   it("success large text render", () => {
     const wrapper = shallow(
       ContrastBadge({
         value: 3.77,
         isLargeText: true,
-        score: "AA",
+        score: SCORES.AA,
       })
     );
     expect(wrapper.html()).toMatchSnapshot();
     expect(wrapper.isEmptyRender()).toBe(true);
   });
 
   it("fail render", () => {
     const wrapper = mount(
       Provider(
         { store },
         ContrastBadge({
           value: 3.77,
           isLargeText: false,
-          score: "fail",
+          score: SCORES.FAIL,
         })
       )
     );
 
     expect(wrapper.html()).toMatchSnapshot();
     expect(wrapper.children().length).toBe(1);
     const contrastBadge = wrapper.find(ContrastBadgeClass);
     const badge = contrastBadge.childAt(0);
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/test/jest/components/keyboard-badge.test.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { shallow, mount } = require("enzyme");
+
+const { createFactory } = require("devtools/client/shared/vendor/react");
+
+const Provider = createFactory(
+  require("devtools/client/shared/vendor/react-redux").Provider
+);
+const {
+  setupStore,
+} = require("devtools/client/accessibility/test/jest/helpers");
+
+const Badge = require("devtools/client/accessibility/components/Badge");
+const KeyboardBadgeClass = require("devtools/client/accessibility/components/KeyboardBadge");
+const KeyboardBadge = createFactory(KeyboardBadgeClass);
+const {
+  accessibility: { SCORES },
+} = require("devtools/shared/constants");
+
+function testBadge(wrapper) {
+  expect(wrapper.html()).toMatchSnapshot();
+  expect(wrapper.children().length).toBe(1);
+  const keyboardBadge = wrapper.find(KeyboardBadgeClass);
+  const badge = keyboardBadge.childAt(0);
+  expect(badge.type()).toBe(Badge);
+  expect(badge.props()).toMatchObject({
+    label: "accessibility.badge.keyboard",
+    tooltip: "accessibility.badge.keyboard.tooltip",
+  });
+}
+
+describe("KeyboardBadge component:", () => {
+  const store = setupStore();
+
+  it("error render", () => {
+    const wrapper = shallow(KeyboardBadge({ error: true }));
+    expect(wrapper.html()).toMatchSnapshot();
+    expect(wrapper.isEmptyRender()).toBe(true);
+  });
+
+  it("fail render", () => {
+    const wrapper = mount(
+      Provider(
+        { store },
+        KeyboardBadge({
+          score: SCORES.FAIL,
+        })
+      )
+    );
+
+    testBadge(wrapper);
+  });
+
+  it("warning render", () => {
+    const wrapper = mount(
+      Provider(
+        { store },
+        KeyboardBadge({
+          score: SCORES.WARNING,
+        })
+      )
+    );
+
+    testBadge(wrapper);
+  });
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/test/jest/components/keyboard-check.test.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { mount } = require("enzyme");
+const { createFactory } = require("devtools/client/shared/vendor/react");
+const KeyboardCheckClass = require("devtools/client/accessibility/components/KeyboardCheck");
+const KeyboardCheck = createFactory(KeyboardCheckClass);
+
+const FluentReact = require("devtools/client/shared/vendor/fluent-react");
+const LocalizationProvider = createFactory(FluentReact.LocalizationProvider);
+
+const {
+  testCustomCheck,
+} = require("devtools/client/accessibility/test/jest/helpers");
+
+const {
+  accessibility: {
+    AUDIT_TYPE: { KEYBOARD },
+    ISSUE_TYPE: {
+      [KEYBOARD]: { INTERACTIVE_NO_ACTION, FOCUSABLE_NO_SEMANTICS },
+    },
+    SCORES: { FAIL, WARNING },
+  },
+} = require("devtools/shared/constants");
+
+describe("KeyboardCheck component:", () => {
+  const testProps = [
+    { score: FAIL, issue: INTERACTIVE_NO_ACTION },
+    { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS },
+  ];
+
+  for (const props of testProps) {
+    it(`${props.score} render`, () => {
+      const wrapper = mount(
+        LocalizationProvider({ bundles: [] }, KeyboardCheck(props))
+      );
+
+      const keyboardCheck = wrapper.find(KeyboardCheckClass);
+      testCustomCheck(keyboardCheck, props);
+    });
+  }
+});
--- a/devtools/client/accessibility/test/jest/components/text-label-check.test.js
+++ b/devtools/client/accessibility/test/jest/components/text-label-check.test.js
@@ -8,63 +8,43 @@ const { mount } = require("enzyme");
 const { createFactory } = require("devtools/client/shared/vendor/react");
 const TextLabelCheckClass = require("devtools/client/accessibility/components/TextLabelCheck");
 const TextLabelCheck = createFactory(TextLabelCheckClass);
 
 const FluentReact = require("devtools/client/shared/vendor/fluent-react");
 const LocalizationProvider = createFactory(FluentReact.LocalizationProvider);
 
 const {
+  testCustomCheck,
+} = require("devtools/client/accessibility/test/jest/helpers");
+
+const {
   accessibility: {
     AUDIT_TYPE: { TEXT_LABEL },
     ISSUE_TYPE: {
       [TEXT_LABEL]: {
         AREA_NO_NAME_FROM_ALT,
         DIALOG_NO_NAME,
         FORM_NO_VISIBLE_NAME,
       },
     },
     SCORES: { BEST_PRACTICES, FAIL, WARNING },