Merge mozilla-central to inbound. a=merge CLOSED TREE
authorNarcis Beleuzu <nbeleuzu@mozilla.com>
Fri, 10 May 2019 01:20:00 +0300
changeset 473235 38895d59d3d007f10a771552e5055b51c4425050
parent 473213 a2c6cc61a9f29bc5d3ea828b59315091fee086db (current diff)
parent 473234 f28f80e2466839e5b50c9b9f728988044fecb186 (diff)
child 473337 f71645b9b3e028458b5a4bb1c49807424e5ee924
push id113071
push usernbeleuzu@mozilla.com
push dateThu, 09 May 2019 22:20:39 +0000
treeherdermozilla-inbound@38895d59d3d0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone68.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 CLOSED TREE
testing/web-platform/meta/html/browsers/browsing-the-web/history-traversal/window-name-after-same-origin-main-frame-navigation.sub.html.ini
testing/web-platform/meta/html/browsers/history/the-location-interface/allow_prototype_cycle_through_location.sub.html.ini
testing/web-platform/meta/html/browsers/history/the-location-interface/location-prototype-setting-goes-cross-origin-domain.sub.html.ini
testing/web-platform/meta/html/browsers/origin/relaxing-the-same-origin-restriction/document_domain_setter_srcdoc.html.ini
testing/web-platform/meta/html/browsers/the-windowproxy-exotic-object/windowproxy-prototype-setting-goes-cross-origin-domain.sub.html.ini
testing/web-platform/meta/html/webappapis/dynamic-markup-insertion/opening-the-input-stream/origin-check-in-document-open-same-origin-domain.sub.html.ini
testing/web-platform/tests/html/browsers/browsing-the-web/history-traversal/window-name-after-same-origin-main-frame-navigation.sub.html
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -39,16 +39,20 @@ pref("extensions.webextOptionalPermissio
 // Preferences for AMO integration
 pref("extensions.getAddons.cache.enabled", true);
 pref("extensions.getAddons.get.url", "https://services.addons.mozilla.org/api/v3/addons/search/?guid=%IDS%&lang=%LOCALE%");
 pref("extensions.getAddons.compatOverides.url", "https://services.addons.mozilla.org/api/v3/addons/compat-override/?guid=%IDS%&lang=%LOCALE%");
 pref("extensions.getAddons.search.browseURL", "https://addons.mozilla.org/%LOCALE%/firefox/search?q=%TERMS%&platform=%OS%&appver=%VERSION%");
 pref("extensions.webservice.discoverURL", "https://discovery.addons.mozilla.org/%LOCALE%/firefox/discovery/pane/%VERSION%/%OS%/%COMPATIBILITY_MODE%");
 pref("extensions.getAddons.link.url", "https://addons.mozilla.org/%LOCALE%/firefox/");
 pref("extensions.getAddons.langpacks.url", "https://services.addons.mozilla.org/api/v3/addons/language-tools/?app=firefox&type=language&appversion=%VERSION%");
+pref("extensions.getAddons.discovery.api_url", "https://services.addons.mozilla.org/api/v4/discovery/?lang=%LOCALE%");
+
+// Enable the HTML-based discovery panel at about:addons.
+pref("extensions.htmlaboutaddons.discover.enabled", false);
 
 pref("extensions.update.autoUpdateDefault", true);
 
 // Check AUS for system add-on updates.
 pref("extensions.systemAddon.update.url", "https://aus5.mozilla.org/update/3/SystemAddons/%VERSION%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/update.xml");
 pref("extensions.systemAddon.update.enabled", true);
 
 // Disable add-ons that are not installed by the user in all scopes by default.
--- a/devtools/client/aboutdebugging-new/src/components/shared/Message.js
+++ b/devtools/client/aboutdebugging-new/src/components/shared/Message.js
@@ -43,35 +43,37 @@ class Message extends PureComponent {
     };
   }
 
   closeMessage() {
     this.setState({ isClosed: true });
   }
 
   renderButton(level) {
-    return dom.button({
-            className: `ghost-button message__button message__button--${level}`,
-    },
-        Localized(
-          {
-            id: "about-debugging-message-close-icon",
-            attrs: {
-              alt: true,
-            },
+    return dom.button(
+      {
+        className: `ghost-button message__button message__button--${level} ` +
+          `qa-message-button-close-button`,
+        onClick: () => this.closeMessage(),
+      },
+      Localized(
+        {
+          id: "about-debugging-message-close-icon",
+          attrs: {
+            alt: true,
           },
-          dom.img(
-            {
-              className: "qa-message-button-close",
-              src: CLOSE_ICON_SRC,
-              onClick: () => this.closeMessage(),
-            },
-          ),
+        },
+        dom.img(
+          {
+            className: "qa-message-button-close-icon",
+            src: CLOSE_ICON_SRC,
+          },
         ),
-      );
+      ),
+    );
   }
 
   render() {
     const { children, className, level, isCloseable } = this.props;
     const { isClosed } = this.state;
 
     if (isClosed) {
       return null;
--- a/devtools/client/aboutdebugging-new/test/browser/browser_aboutdebugging_message_close.js
+++ b/devtools/client/aboutdebugging-new/test/browser/browser_aboutdebugging_message_close.js
@@ -1,44 +1,70 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 /* import-globals-from helper-addons.js */
 Services.scriptloader.loadSubScript(CHROME_URL_ROOT + "helper-addons.js", this);
 
+const EXTENSION_NAME = "Temporary web extension";
+const EXTENSION_ID = "test-devtools@mozilla.org";
+
 // Test that Message component can be closed with the X button
 add_task(async function() {
-  const EXTENSION_NAME = "Temporary web extension";
-  const EXTENSION_ID = "test-devtools@mozilla.org";
-
   const { document, tab, window } = await openAboutDebugging();
   await selectThisFirefoxPage(document, window.AboutDebugging.store);
 
+  info("Check that the message can be closed with icon");
+  let warningMessage = await installExtensionWithWarning(document);
+  await testCloseMessageWithIcon(warningMessage, document);
+  await removeTemporaryExtension(EXTENSION_NAME, document);
+
+  info("Check that the message can be closed with the button around the icon");
+  warningMessage = await installExtensionWithWarning(document);
+  await testCloseMessageWithButton(warningMessage, document);
+  await removeTemporaryExtension(EXTENSION_NAME, document);
+
+  await removeTab(tab);
+});
+
+async function testCloseMessageWithIcon(warningMessage, doc) {
+  const closeIcon = warningMessage.querySelector(".qa-message-button-close-icon");
+  ok(!!closeIcon, "The warning message has a close icon");
+
+  info("Closing the message and waiting for it to disappear");
+  closeIcon.click();
+
+  const target = findDebugTargetByText(EXTENSION_NAME, doc);
+  await waitUntil(() => target.querySelector(".qa-message") === null);
+}
+
+async function testCloseMessageWithButton(warningMessage, doc) {
+  const closeButton = warningMessage.querySelector(".qa-message-button-close-button");
+  ok(!!closeButton, "The warning message has a close button");
+
+  info("Click on the button and wait for the message to disappear");
+  EventUtils.synthesizeMouse(closeButton, 1, 1, {}, doc.defaultView);
+
+  const target = findDebugTargetByText(EXTENSION_NAME, doc);
+  await waitUntil(() => target.querySelector(".qa-message") === null);
+}
+
+async function installExtensionWithWarning(doc) {
   await installTemporaryExtensionFromXPI({
     id: EXTENSION_ID,
     name: EXTENSION_NAME,
     extraProperties: {
       // This property is not expected in the manifest and should trigger a warning!
       "wrongProperty": {},
     },
-  }, document);
+  }, doc);
 
   info("Wait until a debug target item appears");
-  await waitUntil(() => findDebugTargetByText(EXTENSION_NAME, document));
-  const target = findDebugTargetByText(EXTENSION_NAME, document);
+  await waitUntil(() => findDebugTargetByText(EXTENSION_NAME, doc));
 
+  const target = findDebugTargetByText(EXTENSION_NAME, doc);
   const warningMessage = target.querySelector(".qa-message");
   ok(!!warningMessage, "A warning message is displayed for the installed addon");
 
-  const button = warningMessage.querySelector(".qa-message-button-close");
-  ok(!!button, "The warning message has a close button");
-
-  info("Closing the message and waiting for it to disappear");
-  button.click();
-  await waitUntil(() => {
-    return target.querySelector(".qa-message") === null;
-  });
-
-  await removeTemporaryExtension(EXTENSION_NAME, document);
-  await removeTab(tab);
-});
+  return warningMessage;
+}
new file mode 100644
--- /dev/null
+++ b/dom/canvas/crashtests/1549853.html
@@ -0,0 +1,8 @@
+<canvas id='a' height='67108864' width='80'></canvas>
+<script>
+document.addEventListener("DOMContentLoaded", function() {
+  var c=document.getElementById('a')
+  var x=c.getContext('2d', {alpha: true})
+  c.setAttribute('width', 800)
+})
+</script>
--- a/dom/svg/SVGPathData.cpp
+++ b/dom/svg/SVGPathData.cpp
@@ -525,17 +525,17 @@ already_AddRefed<Path> SVGPathData::Buil
   return BuildPath(builder, NS_STYLE_STROKE_LINECAP_BUTT, 0);
 }
 
 // We could simplify this function because this is only used by CSS motion path
 // and clip-path, which don't render the SVG Path. i.e. The returned path is
 // used as a reference.
 /* static */
 already_AddRefed<Path> SVGPathData::BuildPath(
-    const nsTArray<StylePathCommand>& aPath, PathBuilder* aBuilder,
+    Span<const StylePathCommand> aPath, PathBuilder* aBuilder,
     uint8_t aStrokeLineCap, Float aStrokeWidth, float aZoomFactor) {
   if (aPath.IsEmpty() || !aPath[0].IsMoveTo()) {
     return nullptr;  // paths without an initial moveto are invalid
   }
 
   auto toGfxPoint = [](const StyleCoordPair& aPair) {
     return Point(aPair._0, aPair._1);
   };
--- a/dom/svg/SVGPathData.h
+++ b/dom/svg/SVGPathData.h
@@ -156,22 +156,22 @@ class SVGPathData {
    * See the comment for that function for more info on that.
    */
   already_AddRefed<Path> BuildPathForMeasuring() const;
 
   already_AddRefed<Path> BuildPath(PathBuilder* aBuilder,
                                    uint8_t aStrokeLineCap,
                                    Float aStrokeWidth) const;
   /**
-   * This function tries to build the path by an array of StylePathCommand,
+   * This function tries to build the path from an array of StylePathCommand,
    * which is generated by cbindgen from Rust (see ServoStyleConsts.h).
    * Basically, this is a variant of the above BuildPath() functions.
    */
   static already_AddRefed<Path> BuildPath(
-      const nsTArray<StylePathCommand>& aPath, PathBuilder* aBuilder,
+      Span<const StylePathCommand> aPath, PathBuilder* aBuilder,
       uint8_t aStrokeLineCap, Float aStrokeWidth, float aZoomFactor = 1.0);
 
   const_iterator begin() const { return mData.Elements(); }
   const_iterator end() const { return mData.Elements() + mData.Length(); }
 
   // memory reporting methods
   size_t SizeOfExcludingThis(mozilla::MallocSizeOf aMallocSizeOf) const;
   size_t SizeOfIncludingThis(mozilla::MallocSizeOf aMallocSizeOf) const;
--- a/js/src/builtin/TestingFunctions.cpp
+++ b/js/src/builtin/TestingFunctions.cpp
@@ -1364,17 +1364,17 @@ static bool NondeterministicGetWeakMapKe
                               "nondeterministicGetWeakMapKeys", "WeakMap",
                               args[0].toObject().getClass()->name);
     return false;
   }
   args.rval().setObject(*arr);
   return true;
 }
 
-class HasChildTracer : public JS::CallbackTracer {
+class HasChildTracer final : public JS::CallbackTracer {
   RootedValue child_;
   bool found_;
 
   void onChild(const JS::GCCellPtr& thing) override {
     if (thing.asCell() == child_.toGCThing()) {
       found_ = true;
     }
   }
--- a/js/src/gc/DeletePolicy.h
+++ b/js/src/gc/DeletePolicy.h
@@ -7,17 +7,17 @@
 #ifndef gc_DeletePolicy_h
 #define gc_DeletePolicy_h
 
 #include "js/TracingAPI.h"
 
 namespace js {
 namespace gc {
 
-struct ClearEdgesTracer : public JS::CallbackTracer {
+struct ClearEdgesTracer final : public JS::CallbackTracer {
   ClearEdgesTracer();
 
 #ifdef DEBUG
   TracerKind getTracerKind() const override { return TracerKind::ClearEdges; }
 #endif
 
   template <typename T>
   inline void clearEdge(T** thingp);
--- a/js/src/gc/GC.cpp
+++ b/js/src/gc/GC.cpp
@@ -4061,17 +4061,17 @@ bool GCRuntime::shouldPreserveJITCode(Re
   if (reason == JS::GCReason::DEBUG_GC) {
     return true;
   }
 
   return false;
 }
 
 #ifdef DEBUG
-class CompartmentCheckTracer : public JS::CallbackTracer {
+class CompartmentCheckTracer final : public JS::CallbackTracer {
   void onChild(const JS::GCCellPtr& thing) override;
 
  public:
   explicit CompartmentCheckTracer(JSRuntime* rt)
       : JS::CallbackTracer(rt),
         src(nullptr),
         zone(nullptr),
         compartment(nullptr) {}
--- a/js/src/gc/GCInternals.h
+++ b/js/src/gc/GCInternals.h
@@ -144,17 +144,17 @@ struct MOZ_RAII AutoStopVerifyingBarrier
 };
 #endif /* JS_GC_ZEAL */
 
 #ifdef JSGC_HASH_TABLE_CHECKS
 void CheckHashTablesAfterMovingGC(JSRuntime* rt);
 void CheckHeapAfterGC(JSRuntime* rt);
 #endif
 
-struct MovingTracer : JS::CallbackTracer {
+struct MovingTracer final : public JS::CallbackTracer {
   explicit MovingTracer(JSRuntime* rt)
       : CallbackTracer(rt, TraceWeakMapKeysValues) {}
 
   void onObjectEdge(JSObject** objp) override;
   void onShapeEdge(Shape** shapep) override;
   void onStringEdge(JSString** stringp) override;
   void onScriptEdge(JSScript** scriptp) override;
   void onLazyScriptEdge(LazyScript** lazyp) override;
--- a/js/src/gc/Marking.cpp
+++ b/js/src/gc/Marking.cpp
@@ -3366,25 +3366,25 @@ FOR_EACH_PUBLIC_TAGGED_GC_POINTER_TYPE(I
  *   implicit edges to fix any black-gray edges that have been created. This
  *   is implemented in nsXPConnect::FixWeakMappingGrayBits.
  * - To prevent any incorrectly gray objects from escaping to live JS outside
  *   of the containers, we must add unmark-graying read barriers to these
  *   containers.
  */
 
 #ifdef DEBUG
-struct AssertNonGrayTracer : public JS::CallbackTracer {
+struct AssertNonGrayTracer final : public JS::CallbackTracer {
   explicit AssertNonGrayTracer(JSRuntime* rt) : JS::CallbackTracer(rt) {}
   void onChild(const JS::GCCellPtr& thing) override {
     MOZ_ASSERT(!thing.asCell()->isMarkedGray());
   }
 };
 #endif
 
-class UnmarkGrayTracer : public JS::CallbackTracer {
+class UnmarkGrayTracer final : public JS::CallbackTracer {
  public:
   // We set weakMapAction to DoNotTraceWeakMaps because the cycle collector
   // will fix up any color mismatches involving weakmaps when it runs.
   explicit UnmarkGrayTracer(JSRuntime* rt)
       : JS::CallbackTracer(rt, DoNotTraceWeakMaps),
         unmarkedAny(false),
         oom(false),
         stack(rt->gc.unmarkGrayStack) {}
--- a/js/src/gc/RootMarking.cpp
+++ b/js/src/gc/RootMarking.cpp
@@ -410,17 +410,17 @@ void js::gc::GCRuntime::traceRuntimeComm
       if (traceOrMark == TraceRuntime) {
         (*op)(trc, grayRootTracer.data);
       }
     }
   }
 }
 
 #ifdef DEBUG
-class AssertNoRootsTracer : public JS::CallbackTracer {
+class AssertNoRootsTracer final : public JS::CallbackTracer {
   void onChild(const JS::GCCellPtr& thing) override {
     MOZ_CRASH("There should not be any roots after finishRoots");
   }
 
  public:
   AssertNoRootsTracer(JSRuntime* rt, WeakMapTraceKind weakTraceKind)
       : JS::CallbackTracer(rt, weakTraceKind) {}
 };
--- a/js/src/gc/Verifier.cpp
+++ b/js/src/gc/Verifier.cpp
@@ -261,17 +261,17 @@ oom:
   js_delete(trc);
   verifyPreData = nullptr;
 }
 
 static bool IsMarkedOrAllocated(TenuredCell* cell) {
   return cell->isMarkedAny();
 }
 
-struct CheckEdgeTracer : public JS::CallbackTracer {
+struct CheckEdgeTracer final : public JS::CallbackTracer {
   VerifyNode* node;
   explicit CheckEdgeTracer(JSRuntime* rt)
       : JS::CallbackTracer(rt), node(nullptr) {}
   void onChild(const JS::GCCellPtr& thing) override;
 };
 
 static const uint32_t MAX_VERIFIER_EDGES = 1000;
 
--- a/js/src/jsapi-tests/testGCMarking.cpp
+++ b/js/src/jsapi-tests/testGCMarking.cpp
@@ -66,17 +66,17 @@ static bool ConstructCCW(JSContext* cx, 
   if (wrapper->compartment() != global1->compartment()) {
     fprintf(stderr, "wrapper in wrong compartment");
     return false;
   }
 
   return true;
 }
 
-class CCWTestTracer : public JS::CallbackTracer {
+class CCWTestTracer final : public JS::CallbackTracer {
   void onChild(const JS::GCCellPtr& thing) override {
     numberOfThingsTraced++;
 
     printf("*thingp         = %p\n", thing.asCell());
     printf("*expectedThingp = %p\n", *expectedThingp);
 
     printf("kind         = %d\n", static_cast<int>(thing.kind()));
     printf("expectedKind = %d\n", static_cast<int>(expectedKind));
--- a/js/src/jsapi-tests/testPrivateGCThingValue.cpp
+++ b/js/src/jsapi-tests/testPrivateGCThingValue.cpp
@@ -10,17 +10,17 @@
 
 #include "jsapi.h"
 
 #include "js/CompilationAndEvaluation.h"  // JS::CompileDontInflate
 #include "js/HeapAPI.h"
 #include "js/SourceText.h"  // JS::Source{Ownership,Text}
 #include "jsapi-tests/tests.h"
 
-class TestTracer : public JS::CallbackTracer {
+class TestTracer final : public JS::CallbackTracer {
   void onChild(const JS::GCCellPtr& thing) override {
     if (thing.asCell() == expectedCell && thing.kind() == expectedKind) {
       found = true;
     }
   }
 
  public:
   js::gc::Cell* expectedCell;
--- a/js/src/jsapi.cpp
+++ b/js/src/jsapi.cpp
@@ -1151,25 +1151,33 @@ JS_PUBLIC_API void JS_string_free(JSCont
 
 JS_PUBLIC_API void JS_freeop(JSFreeOp* fop, void* p) {
   return FreeOp::get(fop)->free_(p);
 }
 
 JS_PUBLIC_API void JS::AddAssociatedMemory(JSObject* obj, size_t nbytes,
                                            JS::MemoryUse use) {
   MOZ_ASSERT(obj);
+  if (!nbytes) {
+    return;
+  }
+
   Zone* zone = obj->zone();
   zone->updateMallocCounter(nbytes);
   zone->addCellMemory(obj, nbytes, use);
   zone->runtimeFromMainThread()->gc.maybeAllocTriggerZoneGC(zone);
 }
 
 JS_PUBLIC_API void JS::RemoveAssociatedMemory(JSObject* obj, size_t nbytes,
                                               JS::MemoryUse use) {
   MOZ_ASSERT(obj);
+  if (!nbytes) {
+    return;
+  }
+
   obj->zoneFromAnyThread()->removeCellMemory(obj, nbytes, use);
 }
 
 #undef JS_AddRoot
 
 JS_PUBLIC_API bool JS_AddExtraGCRootsTracer(JSContext* cx,
                                             JSTraceDataOp traceOp, void* data) {
   return cx->runtime()->gc.addBlackRootsTracer(traceOp, data);
--- a/js/src/jsfriendapi.cpp
+++ b/js/src/jsfriendapi.cpp
@@ -1001,17 +1001,17 @@ extern JS_FRIEND_API bool JS::ForceLexic
 extern JS_FRIEND_API int JS::IsGCPoisoning() {
 #ifdef JS_GC_POISONING
   return !js::gDisablePoisoning;
 #else
   return false;
 #endif
 }
 
-struct DumpHeapTracer : public JS::CallbackTracer, public WeakMapTracer {
+struct DumpHeapTracer final : public JS::CallbackTracer, public WeakMapTracer {
   const char* prefix;
   FILE* output;
   mozilla::MallocSizeOf mallocSizeOf;
 
   DumpHeapTracer(FILE* fp, JSContext* cx, mozilla::MallocSizeOf mallocSizeOf)
       : JS::CallbackTracer(cx, DoNotTraceWeakMaps),
         js::WeakMapTracer(cx->runtime()),
         prefix(""),
--- a/js/src/vm/UbiNode.cpp
+++ b/js/src/vm/UbiNode.cpp
@@ -186,17 +186,17 @@ Value Node::exposeToJS() const {
 
   ExposeValueToActiveJS(v);
 
   return v;
 }
 
 // A JS::CallbackTracer subclass that adds a Edge to a Vector for each
 // edge on which it is invoked.
-class EdgeVectorTracer : public JS::CallbackTracer {
+class EdgeVectorTracer final : public JS::CallbackTracer {
   // The vector to which we add Edges.
   EdgeVector* vec;
 
   // True if we should populate the edge's names.
   bool wantNames;
 
   void onChild(const JS::GCCellPtr& thing) override {
     if (!okay) {
--- a/layout/style/GeckoBindings.cpp
+++ b/layout/style/GeckoBindings.cpp
@@ -1559,19 +1559,21 @@ void Gecko_StyleShapeSource_SetURLValue(
                                         URLValue* aURL) {
   aShape->SetURL(*aURL);
 }
 
 void Gecko_NewShapeImage(StyleShapeSource* aShape) {
   aShape->SetShapeImage(MakeUnique<nsStyleImage>());
 }
 
-void Gecko_NewStyleSVGPath(StyleShapeSource* aShape) {
+void Gecko_SetToSVGPath(StyleShapeSource* aShape,
+                        StyleForgottenArcSlicePtr<StylePathCommand> aCommands,
+                        StyleFillRule aFill) {
   MOZ_ASSERT(aShape);
-  aShape->SetPath(MakeUnique<StyleSVGPath>());
+  aShape->SetPath(MakeUnique<StyleSVGPath>(aCommands, aFill));
 }
 
 void Gecko_SetStyleMotion(UniquePtr<StyleMotion>* aMotion,
                           StyleMotion* aValue) {
   MOZ_ASSERT(aMotion);
   aMotion->reset(aValue);
 }
 
--- a/layout/style/GeckoBindings.h
+++ b/layout/style/GeckoBindings.h
@@ -527,17 +527,20 @@ void Gecko_CopyShapeSourceFrom(mozilla::
 
 void Gecko_DestroyShapeSource(mozilla::StyleShapeSource* shape);
 
 void Gecko_NewShapeImage(mozilla::StyleShapeSource* shape);
 
 void Gecko_StyleShapeSource_SetURLValue(mozilla::StyleShapeSource* shape,
                                         mozilla::css::URLValue* uri);
 
-void Gecko_NewStyleSVGPath(mozilla::StyleShapeSource* shape);
+void Gecko_SetToSVGPath(
+    mozilla::StyleShapeSource* shape,
+    mozilla::StyleForgottenArcSlicePtr<mozilla::StylePathCommand>,
+    mozilla::StyleFillRule);
 
 void Gecko_SetStyleMotion(mozilla::UniquePtr<mozilla::StyleMotion>* aMotion,
                           mozilla::StyleMotion* aValue);
 
 mozilla::StyleMotion* Gecko_NewStyleMotion();
 
 void Gecko_CopyStyleMotions(mozilla::UniquePtr<mozilla::StyleMotion>* motion,
                             const mozilla::StyleMotion* other);
--- a/layout/style/ServoBindings.toml
+++ b/layout/style/ServoBindings.toml
@@ -325,16 +325,17 @@ whitelist-types = [
     "mozilla::binding_danger::AssertAndSuppressCleanupPolicy",
     "mozilla::ParsingMode",
     "mozilla::InheritTarget",
     "mozilla::dom::MediaList",
     "mozilla::StyleRuleInclusion",
     "nsStyleTransformMatrix::MatrixTransformOperator",
 ]
 opaque-types = [
+    "mozilla::StyleThinArc", # https://github.com/rust-lang/rust-bindgen/issues/1557
     "std::pair__PCCP",
     "std::namespace::atomic___base", "std::atomic__My_base",
     "std::atomic",
     "std::atomic___base",
     # We want everything but FontVariation and Float to be opaque but we don't
     # have negative regexes.
     "mozilla::gfx::(.{0,4}|.{6,12}|.{14,}|([^F][^o][^n][^t][^V][^a][^r][^i][^a][^t][^i][^o][^n])|([^F][^l][^o][^a][^t]))",
     "mozilla::dom::Sequence",
@@ -469,16 +470,18 @@ cbindgen-types = [
     { gecko = "StyleGenericColorOrAuto", servo = "values::generics::color::ColorOrAuto" },
     { gecko = "StyleGenericScrollbarColor", servo = "values::generics::ui::ScrollbarColor" },
     { gecko = "StyleRGBA", servo = "cssparser::RGBA" },
     { gecko = "StyleOrigin", servo = "stylesheets::Origin" },
     { gecko = "StyleGenericGradientItem", servo = "values::generics::image::GradientItem" },
     { gecko = "StyleGenericVerticalAlign", servo = "values::generics::box_::VerticalAlign" },
     { gecko = "StyleVerticalAlignKeyword", servo = "values::generics::box_::VerticalAlignKeyword" },
     { gecko = "StyleGenericBasicShape", servo = "values::generics::basic_shape::BasicShape" },
+    { gecko = "StyleArcSlice", servo = "style_traits::arc_slice::ArcSlice" },
+    { gecko = "StyleForgottenArcSlicePtr", servo = "style_traits::arc_slice::ForgottenArcSlicePtr" },
 ]
 
 mapped-generic-types = [
     { generic = true, gecko = "mozilla::RustCell", servo = "::std::cell::Cell" },
     { generic = false, gecko = "ServoNodeData", servo = "AtomicRefCell<ElementData>" },
     { generic = false, gecko = "mozilla::ServoWritingMode", servo = "::logical_geometry::WritingMode" },
     { generic = false, gecko = "mozilla::ServoCustomPropertiesMap", servo = "Option<::servo_arc::Arc<::custom_properties::CustomPropertiesMap>>" },
     { generic = false, gecko = "mozilla::ServoRuleNode", servo = "Option<::rule_tree::StrongRuleNode>" },
--- a/layout/style/ServoStyleConstsForwards.h
+++ b/layout/style/ServoStyleConstsForwards.h
@@ -22,16 +22,17 @@
 #  include "mozilla/Span.h"
 #  include "Units.h"
 #  include "mozilla/gfx/Types.h"
 #  include "mozilla/MemoryReporting.h"
 #  include "mozilla/ServoTypes.h"
 #  include "mozilla/ServoBindingTypes.h"
 #  include "nsCSSPropertyID.h"
 #  include "nsCompatibility.h"
+#  include <atomic>
 
 struct RawServoAnimationValueTable;
 struct RawServoAnimationValueMap;
 
 class nsAtom;
 class nsIFrame;
 class nsINode;
 class nsCSSPropertyIDSet;
@@ -74,16 +75,19 @@ using ComputedKeyframeValues = nsTArray<
 class ComputedStyle;
 class SeenPtrs;
 class SharedFontList;
 class StyleSheet;
 class WritingMode;
 class ServoElementSnapshotTable;
 enum class StyleContentType : uint8_t;
 
+template<typename T>
+struct StyleForgottenArcSlicePtr;
+
 struct AnimationPropertySegment;
 struct ComputedTiming;
 struct URLExtraData;
 
 enum HalfCorner : uint8_t;
 enum LogicalSide : uint8_t;
 enum class PseudoStyleType : uint8_t;
 enum class OriginFlags : uint8_t;
@@ -130,11 +134,13 @@ using StyleMatrixTransformOperator =
 #  define SERVO_ARC_TYPE(name_, type_) using Style##type_ = type_;
 #  include "mozilla/ServoArcTypeList.h"
 #  undef SERVO_ARC_TYPE
 
 #  define SERVO_BOXED_TYPE(name_, type_) using Style##type_ = type_;
 #  include "mozilla/ServoBoxedTypeList.h"
 #  undef SERVO_BOXED_TYPE
 
+using StyleAtomicUsize = std::atomic<size_t>;
+
 }  // namespace mozilla
 
 #endif
new file mode 100644
--- /dev/null
+++ b/layout/style/ServoStyleConstsInlines.h
@@ -0,0 +1,96 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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/. */
+
+/* Some inline functions declared in cbindgen.toml */
+
+#ifndef mozilla_ServoStyleConstsInlines_h
+#define mozilla_ServoStyleConstsInlines_h
+
+#include "mozilla/ServoStyleConsts.h"
+#include <type_traits>
+
+// TODO(emilio): there are quite a few other implementations scattered around
+// that should move here.
+
+namespace mozilla {
+
+// This code is basically a C++ port of the Arc::clone() implementation in
+// servo/components/servo_arc/lib.rs.
+static constexpr const size_t kStaticRefcount =
+    std::numeric_limits<size_t>::max();
+static constexpr const size_t kMaxRefcount =
+    std::numeric_limits<intptr_t>::max();
+static constexpr const uint32_t kArcSliceCanary = 0xf3f3f3f3;
+
+#define ASSERT_CANARY \
+  MOZ_DIAGNOSTIC_ASSERT(_0.ptr->data.header.header == kArcSliceCanary, "Uh?");
+
+template <typename T>
+inline StyleArcSlice<T>::StyleArcSlice(const StyleArcSlice& aOther) {
+  MOZ_DIAGNOSTIC_ASSERT(aOther._0.ptr);
+  _0.ptr = aOther._0.ptr;
+  if (_0.ptr->count.load(std::memory_order_relaxed) != kStaticRefcount) {
+    auto old_size = _0.ptr->count.fetch_add(1, std::memory_order_relaxed);
+    if (MOZ_UNLIKELY(old_size > kMaxRefcount)) {
+      ::abort();
+    }
+  }
+  ASSERT_CANARY
+}
+
+template <typename T>
+inline StyleArcSlice<T>::StyleArcSlice(
+    const StyleForgottenArcSlicePtr<T>& aPtr) {
+  // See the forget() implementation to see why reinterpret_cast() is ok.
+  _0.ptr = reinterpret_cast<decltype(_0.ptr)>(aPtr._0);
+  ASSERT_CANARY
+}
+
+template <typename T>
+inline size_t StyleArcSlice<T>::Length() const {
+  ASSERT_CANARY
+  return _0.ptr->data.header.length;
+}
+
+template <typename T>
+inline Span<const T> StyleArcSlice<T>::AsSpan() const {
+  ASSERT_CANARY
+  return MakeSpan(_0.ptr->data.slice, Length());
+}
+
+template <typename T>
+inline bool StyleArcSlice<T>::operator==(const StyleArcSlice& aOther) const {
+  ASSERT_CANARY
+  return AsSpan() == aOther.AsSpan();
+}
+
+template <typename T>
+inline bool StyleArcSlice<T>::operator!=(const StyleArcSlice& aOther) const {
+  return !(*this == aOther);
+}
+
+// This is a C++ port-ish of Arc::drop().
+template <typename T>
+inline StyleArcSlice<T>::~StyleArcSlice() {
+  ASSERT_CANARY
+  if (_0.ptr->count.load(std::memory_order_relaxed) == kStaticRefcount) {
+    return;
+  }
+  if (_0.ptr->count.fetch_sub(1, std::memory_order_release) != 1) {
+    return;
+  }
+  _0.ptr->count.load(std::memory_order_acquire);
+  for (T& elem : MakeSpan(_0.ptr->data.slice, Length())) {
+    elem.~T();
+  }
+  free(_0.ptr);  // Drop the allocation now.
+}
+
+#undef ASSERT_CANARY
+
+}  // namespace mozilla
+
+#endif
--- a/layout/style/moz.build
+++ b/layout/style/moz.build
@@ -96,16 +96,17 @@ EXPORTS.mozilla += [
     'ServoBoxedTypeList.h',
     'ServoComputedData.h',
     'ServoComputedDataInlines.h',
     'ServoCSSParser.h',
     'ServoCSSRuleList.h',
     'ServoElementSnapshot.h',
     'ServoElementSnapshotTable.h',
     'ServoStyleConstsForwards.h',
+    'ServoStyleConstsInlines.h',
     'ServoStyleSet.h',
     'ServoStyleSetInlines.h',
     'ServoTraversalStatistics.h',
     'ServoTypes.h',
     'ServoUtils.h',
     'StyleAnimationValue.h',
     'StyleColorInlines.h',
     'StyleSheet.h',
--- a/layout/style/nsStyleStruct.h
+++ b/layout/style/nsStyleStruct.h
@@ -1659,30 +1659,37 @@ struct StyleAnimation {
   RefPtr<nsAtom> mName;  // nsGkAtoms::_empty for 'none'
   dom::PlaybackDirection mDirection;
   dom::FillMode mFillMode;
   StyleAnimationPlayState mPlayState;
   float mIterationCount;  // mozilla::PositiveInfinity<float>() means infinite
 };
 
 struct StyleSVGPath final {
-  const nsTArray<StylePathCommand>& Path() const { return mPath; }
+  StyleSVGPath(StyleForgottenArcSlicePtr<StylePathCommand> aPath,
+               StyleFillRule aFill)
+    : mPath(aPath),
+      mFillRule(aFill) {}
+
+  Span<const StylePathCommand> Path() const {
+    return mPath.AsSpan();
+  }
 
   StyleFillRule FillRule() const { return mFillRule; }
 
   bool operator==(const StyleSVGPath& aOther) const {
     return mPath == aOther.mPath && mFillRule == aOther.mFillRule;
   }
 
   bool operator!=(const StyleSVGPath& aOther) const {
     return !(*this == aOther);
   }
 
  private:
-  nsTArray<StylePathCommand> mPath;
+  StyleArcSlice<StylePathCommand> mPath;
   StyleFillRule mFillRule = StyleFillRule::Nonzero;
 };
 
 struct StyleShapeSource final {
   StyleShapeSource();
 
   StyleShapeSource(const StyleShapeSource& aSource);
 
--- a/servo/components/servo_arc/lib.rs
+++ b/servo/components/servo_arc/lib.rs
@@ -303,16 +303,19 @@ impl<T: ?Sized> Arc<T> {
     fn ptr(&self) -> *mut ArcInner<T> {
         self.p.as_ptr()
     }
 }
 
 impl<T: ?Sized> Clone for Arc<T> {
     #[inline]
     fn clone(&self) -> Self {
+        // NOTE(emilio): If you change anything here, make sure that the
+        // implementation in layout/style/ServoStyleConstsInlines.h matches!
+        //
         // Using a relaxed ordering to check for STATIC_REFCOUNT is safe, since
         // `count` never changes between STATIC_REFCOUNT and other values.
         if self.inner().count.load(Relaxed) != STATIC_REFCOUNT {
             // Using a relaxed ordering is alright here, as knowledge of the
             // original reference prevents other threads from erroneously deleting
             // the object.
             //
             // As explained in the [Boost documentation][1], Increasing the
@@ -411,16 +414,19 @@ impl<T: ?Sized> Arc<T> {
         // [1] https://github.com/servo/servo/issues/21186
         self.inner().count.load(Acquire) == 1
     }
 }
 
 impl<T: ?Sized> Drop for Arc<T> {
     #[inline]
     fn drop(&mut self) {
+        // NOTE(emilio): If you change anything here, make sure that the
+        // implementation in layout/style/ServoStyleConstsInlines.h matches!
+        //
         // Using a relaxed ordering to check for STATIC_REFCOUNT is safe, since
         // `count` never changes between STATIC_REFCOUNT and other values.
         if self.inner().count.load(Relaxed) == STATIC_REFCOUNT {
             return;
         }
 
         // Because `fetch_sub` is already atomic, we do not need to synchronize
         // with other threads unless we are going to delete the object.
@@ -566,16 +572,17 @@ impl<T: Serialize> Serialize for Arc<T> 
     {
         (**self).serialize(serializer)
     }
 }
 
 /// Structure to allow Arc-managing some fixed-sized data and a variably-sized
 /// slice in a single allocation.
 #[derive(Debug, Eq, PartialEq, PartialOrd)]
+#[repr(C)]
 pub struct HeaderSlice<H, T: ?Sized> {
     /// The fixed-sized data.
     pub header: H,
 
     /// The dynamically-sized data.
     pub slice: T,
 }
 
@@ -738,16 +745,17 @@ impl<H, T> Arc<HeaderSlice<H, [T]>> {
         vec.set_len(words_to_allocate);
         Box::into_raw(vec.into_boxed_slice()) as *mut W as *mut u8
     }
 }
 
 /// Header data with an inline length. Consumers that use HeaderWithLength as the
 /// Header type in HeaderSlice can take advantage of ThinArc.
 #[derive(Debug, Eq, PartialEq, PartialOrd)]
+#[repr(C)]
 pub struct HeaderWithLength<H> {
     /// The fixed-sized data.
     pub header: H,
 
     /// The slice length.
     length: usize,
 }
 
@@ -779,16 +787,23 @@ type HeaderSliceWithLength<H, T> = Heade
 /// `ThinArc` solves this by storing the length in the allocation itself,
 /// via `HeaderSliceWithLength`.
 #[repr(C)]
 pub struct ThinArc<H, T> {
     ptr: ptr::NonNull<ArcInner<HeaderSliceWithLength<H, [T; 0]>>>,
     phantom: PhantomData<(H, T)>,
 }
 
+
+impl<H: fmt::Debug, T: fmt::Debug> fmt::Debug for ThinArc<H, T> {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        fmt::Debug::fmt(self.deref(), f)
+    }
+}
+
 unsafe impl<H: Sync + Send, T: Sync + Send> Send for ThinArc<H, T> {}
 unsafe impl<H: Sync + Send, T: Sync + Send> Sync for ThinArc<H, T> {}
 
 // Synthesize a fat pointer from a thin pointer.
 //
 // See the comment around the analogous operation in from_header_and_iter.
 fn thin_to_thick<H, T>(
     thin: *mut ArcInner<HeaderSliceWithLength<H, [T; 0]>>,
@@ -851,27 +866,31 @@ impl<H, T> ThinArc<H, T> {
     {
         let header = HeaderWithLength::new(header, items.len());
         Arc::into_thin(Arc::from_header_and_iter_alloc(
             alloc, header, items, /* is_static = */ true,
         ))
     }
 
     /// Returns the address on the heap of the ThinArc itself -- not the T
-    /// within it -- for memory reporting.
-    ///
+    /// within it -- for memory reporting, and bindings.
+    #[inline]
+    pub fn ptr(&self) -> *const c_void {
+        self.ptr.as_ptr() as *const ArcInner<T> as *const c_void
+    }
+
     /// If this is a static ThinArc, this returns null.
     #[inline]
     pub fn heap_ptr(&self) -> *const c_void {
         let is_static =
             ThinArc::with_arc(self, |a| a.inner().count.load(Relaxed) == STATIC_REFCOUNT);
         if is_static {
             ptr::null()
         } else {
-            self.ptr.as_ptr() as *const ArcInner<T> as *const c_void
+            self.ptr()
         }
     }
 }
 
 impl<H, T> Deref for ThinArc<H, T> {
     type Target = HeaderSliceWithLength<H, [T]>;
 
     #[inline]
--- a/servo/components/style/gecko/conversions.rs
+++ b/servo/components/style/gecko/conversions.rs
@@ -572,22 +572,20 @@ pub mod basic_shape {
                     let fill = unsafe { &*self.__bindgen_anon_1.mSVGPath.as_ref().mPtr }.mFillRule;
                     Some(ShapeSource::Path(Path { fill, path }))
                 },
             }
         }
 
         /// Generate a SVGPathData from StyleShapeSource if possible.
         fn to_svg_path(&self) -> Option<SVGPathData> {
-            use crate::values::specified::svg_path::PathCommand;
             match self.mType {
                 StyleShapeSourceType::Path => {
                     let gecko_path = unsafe { &*self.__bindgen_anon_1.mSVGPath.as_ref().mPtr };
-                    let result: Vec<PathCommand> = gecko_path.mPath.iter().cloned().collect();
-                    Some(SVGPathData::new(result.into_boxed_slice()))
+                    Some(SVGPathData(gecko_path.mPath.clone()))
                 },
                 _ => None,
             }
         }
     }
 
     impl<'a> From<&'a StyleShapeSource> for ClippingShape {
         fn from(other: &'a StyleShapeSource) -> Self {
--- a/servo/components/style/lib.rs
+++ b/servo/components/style/lib.rs
@@ -183,16 +183,17 @@ pub use crate::gecko_string_cache::Names
 pub use html5ever::LocalName;
 #[cfg(feature = "servo")]
 pub use html5ever::Namespace;
 #[cfg(feature = "servo")]
 pub use html5ever::Prefix;
 #[cfg(feature = "servo")]
 pub use servo_atoms::Atom;
 
+pub use style_traits::arc_slice::ArcSlice;
 pub use style_traits::owned_slice::OwnedSlice;
 
 /// The CSS properties supported by the style system.
 /// Generated from the properties.mako.rs template by build.rs
 #[macro_use]
 #[allow(unsafe_code)]
 #[deny(missing_docs)]
 pub mod properties {
--- a/servo/components/style/properties/gecko.mako.rs
+++ b/servo/components/style/properties/gecko.mako.rs
@@ -2896,17 +2896,17 @@ fn static_assert() {
         use crate::gecko_bindings::structs::StyleShapeSourceType;
         use crate::values::generics::basic_shape::FillRule;
         use crate::values::specified::OffsetPath;
 
         let motion = unsafe { Gecko_NewStyleMotion().as_mut().unwrap() };
         match v {
             OffsetPath::None => motion.mOffsetPath.mType = StyleShapeSourceType::None,
             OffsetPath::Path(p) => {
-                set_style_svg_path(&mut motion.mOffsetPath, &p, FillRule::Nonzero)
+                set_style_svg_path(&mut motion.mOffsetPath, p, FillRule::Nonzero)
             },
         }
         unsafe { Gecko_SetStyleMotion(&mut self.gecko.mMotion, motion) };
     }
 
     pub fn clone_offset_path(&self) -> longhands::offset_path::computed_value::T {
         use crate::values::specified::OffsetPath;
         match unsafe { self.gecko.mMotion.mPtr.as_ref() } {
@@ -4018,35 +4018,27 @@ fn static_assert() {
             InitialLetter::Specified(self.gecko.mInitialLetterSize, Some(self.gecko.mInitialLetterSink))
         }
     }
 </%self:impl_trait>
 
 // Set SVGPathData to StyleShapeSource.
 fn set_style_svg_path(
     shape_source: &mut structs::mozilla::StyleShapeSource,
-    servo_path: &values::specified::svg_path::SVGPathData,
+    servo_path: values::specified::svg_path::SVGPathData,
     fill: values::generics::basic_shape::FillRule,
 ) {
-    use crate::gecko_bindings::bindings::Gecko_NewStyleSVGPath;
-    use crate::gecko_bindings::structs::StyleShapeSourceType;
-
-    // Setup type.
-    shape_source.mType = StyleShapeSourceType::Path;
-
     // Setup path.
-    let gecko_path = unsafe {
-        Gecko_NewStyleSVGPath(shape_source);
-        &mut shape_source.__bindgen_anon_1.mSVGPath.as_mut().mPtr.as_mut().unwrap()
-    };
-
-    gecko_path.mPath.assign_from_iter_pod(servo_path.commands().iter().cloned());
-
-    // Setup fill-rule.
-    gecko_path.mFillRule = fill;
+    unsafe {
+        bindings::Gecko_SetToSVGPath(
+            shape_source,
+            servo_path.0.forget(),
+            fill,
+        );
+    }
 }
 
 <%def name="impl_shape_source(ident, gecko_ffi_name)">
     pub fn set_${ident}(&mut self, v: longhands::${ident}::computed_value::T) {
         use crate::values::generics::basic_shape::ShapeSource;
         use crate::gecko_bindings::structs::StyleShapeSourceType;
         use crate::gecko_bindings::structs::StyleGeometryBox;
 
@@ -4076,17 +4068,17 @@ fn set_style_svg_path(
                <% raise Exception("Unknown property: %s" % ident) %>
             }
             % endif
             ShapeSource::None => {} // don't change the type
             ShapeSource::Box(reference) => {
                 ${ident}.mReferenceBox = reference.into();
                 ${ident}.mType = StyleShapeSourceType::Box;
             }
-            ShapeSource::Path(p) => set_style_svg_path(${ident}, &p.path, p.fill),
+            ShapeSource::Path(p) => set_style_svg_path(${ident}, p.path, p.fill),
             ShapeSource::Shape(servo_shape, maybe_box) => {
                 unsafe {
                     ${ident}.__bindgen_anon_1.mBasicShape.as_mut().mPtr =
                         Box::into_raw(servo_shape);
                 }
                 ${ident}.mReferenceBox =
                     maybe_box.map(Into::into).unwrap_or(StyleGeometryBox::NoBox);
                 ${ident}.mType = StyleShapeSourceType::Shape;
--- a/servo/components/style/properties/longhands/svg.mako.rs
+++ b/servo/components/style/properties/longhands/svg.mako.rs
@@ -83,17 +83,16 @@
 )}
 
 ${helpers.predefined_type(
     "clip-path",
     "basic_shape::ClippingShape",
     "generics::basic_shape::ShapeSource::None",
     products="gecko",
     animation_value_type="basic_shape::ClippingShape",
-    boxed=True,
     flags="CREATES_STACKING_CONTEXT",
     spec="https://drafts.fxtf.org/css-masking/#propdef-clip-path",
 )}
 
 ${helpers.single_keyword(
     "mask-mode",
     "match-source alpha luminance",
     gecko_enum_prefix="StyleMaskMode",
--- a/servo/components/style/values/animated/mod.rs
+++ b/servo/components/style/values/animated/mod.rs
@@ -457,8 +457,22 @@ where
     fn to_animated_zero(&self) -> Result<Self, ()> {
         let v = self
             .iter()
             .map(|v| v.to_animated_zero())
             .collect::<Result<Vec<_>, _>>()?;
         Ok(v.into_boxed_slice())
     }
 }
+
+impl<T> ToAnimatedZero for crate::ArcSlice<T>
+where
+    T: ToAnimatedZero,
+{
+    #[inline]
+    fn to_animated_zero(&self) -> Result<Self, ()> {
+        let v = self
+            .iter()
+            .map(|v| v.to_animated_zero())
+            .collect::<Result<Vec<_>, _>>()?;
+        Ok(crate::ArcSlice::from_iter(v.into_iter()))
+    }
+}
--- a/servo/components/style/values/specified/svg_path.rs
+++ b/servo/components/style/values/specified/svg_path.rs
@@ -24,59 +24,58 @@ use style_traits::{CssWriter, ParseError
     MallocSizeOf,
     PartialEq,
     SpecifiedValueInfo,
     ToAnimatedZero,
     ToComputedValue,
     ToResolvedValue,
     ToShmem,
 )]
-pub struct SVGPathData(Box<[PathCommand]>);
+#[repr(C)]
+pub struct SVGPathData(
+    // TODO(emilio): Should probably measure this somehow only from the
+    // specified values.
+    #[ignore_malloc_size_of = "Arc"]
+    pub crate::ArcSlice<PathCommand>
+);
 
 impl SVGPathData {
-    /// Return SVGPathData by a slice of PathCommand.
-    #[inline]
-    pub fn new(cmd: Box<[PathCommand]>) -> Self {
-        debug_assert!(!cmd.is_empty());
-        SVGPathData(cmd)
-    }
-
     /// Get the array of PathCommand.
     #[inline]
     pub fn commands(&self) -> &[PathCommand] {
         debug_assert!(!self.0.is_empty());
         &self.0
     }
 
-    /// Create a normalized copy of this path by converting each relative command to an absolute
-    /// command.
-    fn normalize(&self) -> Self {
+    /// Create a normalized copy of this path by converting each relative
+    /// command to an absolute command.
+    fn normalize(&self) -> Box<[PathCommand]> {
         let mut state = PathTraversalState {
             subpath_start: CoordPair::new(0.0, 0.0),
             pos: CoordPair::new(0.0, 0.0),
         };
         let result = self
             .0
             .iter()
             .map(|seg| seg.normalize(&mut state))
             .collect::<Vec<_>>();
-        SVGPathData(result.into_boxed_slice())
+        result.into_boxed_slice()
     }
 }
 
 impl ToCss for SVGPathData {
     #[inline]
     fn to_css<W>(&self, dest: &mut CssWriter<W>) -> fmt::Result
     where
         W: fmt::Write,
     {
         dest.write_char('"')?;
         {
             let mut writer = SequenceWriter::new(dest, " ");
-            for command in self.0.iter() {
+            for command in self.commands() {
                 writer.item(command)?;
             }
         }
         dest.write_char('"')
     }
 }
 
 impl Parse for SVGPathData {
@@ -99,46 +98,48 @@ impl Parse for SVGPathData {
         // Parse the svg path string as multiple sub-paths.
         let mut path_parser = PathParser::new(path_string);
         while skip_wsp(&mut path_parser.chars) {
             if path_parser.parse_subpath().is_err() {
                 return Err(location.new_custom_error(StyleParseErrorKind::UnspecifiedError));
             }
         }
 
-        Ok(SVGPathData::new(path_parser.path.into_boxed_slice()))
+        Ok(SVGPathData(crate::ArcSlice::from_iter(path_parser.path.into_iter())))
     }
 }
 
 impl Animate for SVGPathData {
     fn animate(&self, other: &Self, procedure: Procedure) -> Result<Self, ()> {
         if self.0.len() != other.0.len() {
             return Err(());
         }
 
+        // FIXME(emilio): This allocates three copies of the path, that's not
+        // great! Specially, once we're normalized once, we don't need to
+        // re-normalize again.
         let result = self
             .normalize()
-            .0
             .iter()
-            .zip(other.normalize().0.iter())
+            .zip(other.normalize().iter())
             .map(|(a, b)| a.animate(&b, procedure))
             .collect::<Result<Vec<_>, _>>()?;
-        Ok(SVGPathData::new(result.into_boxed_slice()))
+
+        Ok(SVGPathData(crate::ArcSlice::from_iter(result.into_iter())))
     }
 }
 
 impl ComputeSquaredDistance for SVGPathData {
     fn compute_squared_distance(&self, other: &Self) -> Result<SquaredDistance, ()> {
         if self.0.len() != other.0.len() {
             return Err(());
         }
         self.normalize()
-            .0
             .iter()
-            .zip(other.normalize().0.iter())
+            .zip(other.normalize().iter())
             .map(|(this, other)| this.compute_squared_distance(&other))
             .sum()
     }
 }
 
 /// The SVG path command.
 /// The fields of these commands are self-explanatory, so we skip the documents.
 /// Note: the index of the control points, e.g. control1, control2, are mapping to the control
new file mode 100644
--- /dev/null
+++ b/servo/components/style_traits/arc_slice.rs
@@ -0,0 +1,66 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+//! A thin atomically-reference-counted slice.
+
+use servo_arc::ThinArc;
+use std::mem;
+use std::ops::Deref;
+use std::ptr::NonNull;
+
+/// A canary that we stash in ArcSlices.
+///
+/// Given we cannot use a zero-sized-type for the header, since well, C++
+/// doesn't have zsts, and we want to use cbindgen for this type, we may as well
+/// assert some sanity at runtime.
+const ARC_SLICE_CANARY: u32 = 0xf3f3f3f3;
+
+/// A wrapper type for a refcounted slice using ThinArc.
+///
+/// cbindgen:derive-eq=false
+/// cbindgen:derive-neq=false
+#[repr(C)]
+#[derive(Debug, Clone, PartialEq, Eq, ToShmem)]
+pub struct ArcSlice<T>(#[shmem(field_bound)] ThinArc<u32, T>);
+
+impl<T> Deref for ArcSlice<T> {
+    type Target = [T];
+
+    #[inline]
+    fn deref(&self) -> &Self::Target {
+        debug_assert_eq!(self.0.header.header, ARC_SLICE_CANARY);
+        &self.0.slice
+    }
+}
+
+/// The inner pointer of an ArcSlice<T>, to be sent via FFI.
+/// The type of the pointer is a bit of a lie, we just want to preserve the type
+/// but these pointers cannot be constructed outside of this crate, so we're
+/// good.
+#[repr(C)]
+pub struct ForgottenArcSlicePtr<T>(NonNull<T>);
+
+impl<T> ArcSlice<T> {
+    /// Creates an Arc for a slice using the given iterator to generate the
+    /// slice.
+    #[inline]
+    pub fn from_iter<I>(items: I) -> Self
+    where
+        I: Iterator<Item = T> + ExactSizeIterator,
+    {
+        ArcSlice(ThinArc::from_header_and_iter(ARC_SLICE_CANARY, items))
+    }
+
+    /// Creates a value that can be passed via FFI, and forgets this value
+    /// altogether.
+    #[inline]
+    #[allow(unsafe_code)]
+    pub fn forget(self) -> ForgottenArcSlicePtr<T> {
+        let ret = unsafe {
+            ForgottenArcSlicePtr(NonNull::new_unchecked(self.0.ptr() as *const _ as *mut _))
+        };
+        mem::forget(self);
+        ret
+    }
+}
--- a/servo/components/style_traits/lib.rs
+++ b/servo/components/style_traits/lib.rs
@@ -79,16 +79,17 @@ impl PinchZoomFactor {
 pub enum CSSPixel {}
 
 // In summary, the hierarchy of pixel units and the factors to convert from one to the next:
 //
 // DevicePixel
 //   / hidpi_ratio => DeviceIndependentPixel
 //     / desktop_zoom => CSSPixel
 
+pub mod arc_slice;
 pub mod specified_value_info;
 #[macro_use]
 pub mod values;
 #[macro_use]
 pub mod viewport;
 pub mod owned_slice;
 
 pub use crate::specified_value_info::{CssType, KeywordsCollectFn, SpecifiedValueInfo};
--- a/servo/components/style_traits/specified_value_info.rs
+++ b/servo/components/style_traits/specified_value_info.rs
@@ -1,14 +1,15 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
 
 //! Value information for devtools.
 
+use crate::arc_slice::ArcSlice;
 use servo_arc::Arc;
 use std::ops::Range;
 use std::sync::Arc as StdArc;
 
 /// Type of value that a property supports. This is used by Gecko's
 /// devtools to make sense about value it parses, and types listed
 /// here should match InspectorPropertyType in InspectorUtils.webidl.
 ///
@@ -111,16 +112,17 @@ macro_rules! impl_generic_specified_valu
             }
         }
     };
 }
 impl_generic_specified_value_info!(Option<T>);
 impl_generic_specified_value_info!(Vec<T>);
 impl_generic_specified_value_info!(Arc<T>);
 impl_generic_specified_value_info!(StdArc<T>);
+impl_generic_specified_value_info!(ArcSlice<T>);
 impl_generic_specified_value_info!(Range<Idx>);
 
 impl<T1, T2> SpecifiedValueInfo for (T1, T2)
 where
     T1: SpecifiedValueInfo,
     T2: SpecifiedValueInfo,
 {
     const SUPPORTED_TYPES: u8 = T1::SUPPORTED_TYPES | T2::SUPPORTED_TYPES;
--- a/servo/ports/geckolib/cbindgen.toml
+++ b/servo/ports/geckolib/cbindgen.toml
@@ -2,17 +2,20 @@ header = """/* This Source Code Form is 
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
 
 // See the comment in ServoBindings.h about the same.
 #pragma GCC diagnostic push
 #ifdef __clang__
 #  pragma GCC diagnostic ignored "-Wreturn-type-c-linkage"
 #endif"""
-trailer = "#pragma GCC diagnostic pop"
+trailer = """
+#pragma GCC diagnostic pop
+#include "mozilla/ServoStyleConstsInlines.h"
+"""
 autogen_warning = """/* DO NOT MODIFY THIS MANUALLY! This file was generated using cbindgen.
  * To generate this file:
  *   1. Get the latest cbindgen using `cargo install --force cbindgen`
  *      a. Alternatively, you can clone `https://github.com/eqrion/cbindgen` and use a tagged release
  *   2. Run `rustup run nightly cbindgen toolkit/library/rust/ --lockfile Cargo.lock --crate style -o layout/style/ServoStyleConsts.h`
  */
 """
 include_guard = "mozilla_ServoStyleConsts_h"
@@ -21,17 +24,17 @@ braces = "SameLine"
 line_length = 80
 tab_width = 2
 language = "C++"
 namespaces = ["mozilla"]
 includes = ["mozilla/ServoStyleConstsForwards.h"]
 
 [parse]
 parse_deps = true
-include = ["style", "cssparser", "style_traits"]
+include = ["style", "cssparser", "style_traits", "servo_arc"]
 
 [struct]
 derive_eq = true
 derive_neq = true
 
 [macro_expansion]
 bitflags = true
 
@@ -116,16 +119,19 @@ include = [
   "Strong",
   "ScrollbarColor",
   "Color",
   "ColorOrAuto",
   "GradientItem",
   "VerticalAlign",
   "BasicShape",
   "ShapeRadius",
+  "ArcSlice",
+  "ForgottenArcSlicePtr",
+  "HeaderWithLength",
 ]
 item_types = ["enums", "structs", "typedefs", "functions"]
 renaming_overrides_prefixing = true
 
 # Prevent some renaming for Gecko types that cbindgen doesn't otherwise understand.
 [export.rename]
 "nscolor" = "nscolor"
 "nsAtom" = "nsAtom"
@@ -366,8 +372,19 @@ renaming_overrides_prefixing = true
   bool operator==(const StyleOwnedSlice& other) const {
     return AsSpan() == other.AsSpan();
   }
 
   bool operator!=(const StyleOwnedSlice& other) const {
     return !(*this == other);
   }
 """
+
+"ArcSlice" = """
+  StyleArcSlice() = delete;
+  inline StyleArcSlice(const StyleArcSlice& aOther);
+  inline explicit StyleArcSlice(const StyleForgottenArcSlicePtr<T>& aPtr);
+  inline ~StyleArcSlice();
+  inline Span<const T> AsSpan() const;
+  inline size_t Length() const;
+  inline bool operator==(const StyleArcSlice& other) const;
+  inline bool operator!=(const StyleArcSlice& other) const;
+"""
--- a/taskcluster/ci/test/misc.yml
+++ b/taskcluster/ci/test/misc.yml
@@ -68,16 +68,17 @@ telemetry-tests-client:
     tier: default
     mozharness:
         script: telemetry/telemetry_client.py
         config:
             by-test-platform:
                 linux.*:
                     - remove_executables.py
                 windows.*: []
+                macosx.*: []
 
 test-verify:
     description: "Extra verification of tests modified on this push"
     suite: test-verify
     treeherder-symbol: TV
     loopback-video: true
     instance-size:
         by-test-platform:
--- a/taskcluster/ci/test/test-sets.yml
+++ b/taskcluster/ci/test/test-sets.yml
@@ -246,16 +246,17 @@ windows-tests:
     - mochitest-gpu
     - mochitest-media
     - mochitest-webgl1-core
     - mochitest-webgl1-ext
     - mochitest-webgl2-core
     - mochitest-webgl2-ext
     - reftest
     - reftest-no-accel
+    - telemetry-tests-client
     - test-verify
     - test-verify-gpu
     - test-verify-wpt
     - xpcshell
 
 windows-aarch64-tests:
     - cppunit
     - crashtest
@@ -320,16 +321,17 @@ macosx64-tests:
     - mochitest-devtools-webreplay
     - mochitest-gpu
     - mochitest-media
     - mochitest-webgl1-core
     - mochitest-webgl1-ext
     - mochitest-webgl2-core
     # - mochitest-webgl2-ext test timeouts
     - reftest
+    - telemetry-tests-client
     - test-verify
     - test-verify-gpu
     - test-verify-wpt
     - web-platform-tests
     - web-platform-tests-reftests
     - web-platform-tests-wdspec
     - xpcshell
 
@@ -368,16 +370,17 @@ linux32-tests:
     - mochitest-a11y
     - mochitest-browser-chrome
     - mochitest-chrome
     - mochitest-gpu
     - mochitest-media
     - mochitest-webgl1-core
     - reftest
     - reftest-no-accel
+    - telemetry-tests-client
     - web-platform-tests
     - web-platform-tests-reftests
     - web-platform-tests-wdspec
     - xpcshell
 
 linux32-opt-tests:
     # mochitest-dt is too slow on linux32/debug
     - mochitest-devtools-chrome
--- a/testing/talos/talos/tests/devtools/addon/content/tests/debugger/custom.js
+++ b/testing/talos/talos/tests/devtools/addon/content/tests/debugger/custom.js
@@ -25,16 +25,18 @@ module.exports = async function() {
 
   const toolbox = await openDebuggerAndLog("custom", EXPECTED);
   await reloadDebuggerAndLog("custom", toolbox, EXPECTED);
 
   // these tests are only run on custom.jsdebugger
   await pauseDebuggerAndLog(tab, toolbox, EXPECTED_FUNCTION);
   await stepDebuggerAndLog(tab, toolbox, EXPECTED_FUNCTION);
 
+  await testProjectSearch(tab, toolbox);
+
   await closeToolboxAndLog("custom.jsdebugger", toolbox);
 
   Services.prefs.clearUserPref("devtools.debugger.features.map-scopes");
   await testTeardown();
 };
 
 async function pauseDebuggerAndLog(tab, toolbox, testFunction) {
   dump("Waiting for debugger panel\n");
@@ -88,8 +90,20 @@ async function stepDebuggerAndLog(tab, t
       await step(dbg, stepTest.key);
     }
     test.done();
     await removeBreakpoints(dbg);
     await resume(dbg);
     await garbageCollect();
   }
 }
+
+async function testProjectSearch(tab, toolbox) {
+  const panel = await toolbox.getPanelWhenReady("jsdebugger");
+  const dbg = await createContext(panel);
+  const cx = dbg.selectors.getContext(dbg.getState());
+
+  dump("Executing project search\n");
+  const test = runTest(`custom.jsdebugger.project-search.DAMP`);
+  await dbg.actions.searchSources(cx, "return");
+  test.done();
+  await garbageCollect();
+}
deleted file mode 100644
--- a/testing/web-platform/meta/html/browsers/browsing-the-web/history-traversal/window-name-after-same-origin-main-frame-navigation.sub.html.ini
+++ /dev/null
@@ -1,2 +0,0 @@
-[window-name-after-same-origin-main-frame-navigation.sub.html]
-  disabled: https://bugzilla.mozilla.org/show_bug.cgi?id=1504776
deleted file mode 100644
--- a/testing/web-platform/meta/html/browsers/history/the-location-interface/allow_prototype_cycle_through_location.sub.html.ini
+++ /dev/null
@@ -1,2 +0,0 @@
-[allow_prototype_cycle_through_location.sub.html]
-  disabled: https://bugzilla.mozilla.org/show_bug.cgi?id=1504776
deleted file mode 100644
--- a/testing/web-platform/meta/html/browsers/history/the-location-interface/location-prototype-setting-goes-cross-origin-domain.sub.html.ini
+++ /dev/null
@@ -1,2 +0,0 @@
-[location-prototype-setting-goes-cross-origin-domain.sub.html]
-  disabled: https://bugzilla.mozilla.org/show_bug.cgi?id=1504776
--- a/testing/web-platform/meta/html/browsers/origin/relaxing-the-same-origin-restriction/document_domain_setter.html.ini
+++ b/testing/web-platform/meta/html/browsers/origin/relaxing-the-same-origin-restriction/document_domain_setter.html.ini
@@ -1,2 +1,4 @@
 [document_domain_setter.html]
-  disabled: https://bugzilla.mozilla.org/show_bug.cgi?id=1504776
+  [failed setting of document.domain for documents without browsing context]
+    expected: FAIL
+
deleted file mode 100644
--- a/testing/web-platform/meta/html/browsers/origin/relaxing-the-same-origin-restriction/document_domain_setter_srcdoc.html.ini
+++ /dev/null
@@ -1,2 +0,0 @@
-[document_domain_setter_srcdoc.html]
-  disabled: https://bugzilla.mozilla.org/show_bug.cgi?id=1504776
deleted file mode 100644
--- a/testing/web-platform/meta/html/browsers/the-windowproxy-exotic-object/windowproxy-prototype-setting-goes-cross-origin-domain.sub.html.ini
+++ /dev/null
@@ -1,5 +0,0 @@
-[windowproxy-prototype-setting-goes-cross-origin-domain.sub.html]
-  disabled: disabled: https://bugzilla.mozilla.org/show_bug.cgi?id=1504776
-  expected:
-    if not debug and not webrender and e10s and not sw-e10s and (os == "linux") and (processor == "x86_64"): TIMEOUT
-    ERROR
deleted file mode 100644
--- a/testing/web-platform/meta/html/webappapis/dynamic-markup-insertion/opening-the-input-stream/origin-check-in-document-open-same-origin-domain.sub.html.ini
+++ /dev/null
@@ -1,2 +0,0 @@
-[origin-check-in-document-open-same-origin-domain.sub.html]
-  disabled: https://bugzilla.mozilla.org/show_bug.cgi?id=1504776
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/html/browsers/browsing-the-web/history-traversal/support/window-name-after-same-origin-main-frame-navigation-1.sub.html
@@ -0,0 +1,4 @@
+<!DOCTYPE html>
+<script>
+  window.location = "window-name-navigation.sub.html?hostname={{host}}&shouldhavename=true&sendmessage=true";
+</script>
--- a/testing/web-platform/tests/html/browsers/browsing-the-web/history-traversal/support/window-name-navigation.sub.html
+++ b/testing/web-platform/tests/html/browsers/browsing-the-web/history-traversal/support/window-name-navigation.sub.html
@@ -1,11 +1,11 @@
 <!DOCTYPE html>
 <html>
     <script>
         var url = new URL(window.location.href);
         url.hostname = "{{GET[hostname]}}";
         url.pathname = "/html/browsers/browsing-the-web/history-traversal/support/window-name-test.sub.html";
         url.search = "shouldhavename={{GET[shouldhavename]}}&sendmessage={{GET[sendmessage]}}";
         window.name = "test";
-        document.location = url.href;
+        window.location = url.href;
     </script>
 </html>
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/html/browsers/browsing-the-web/history-traversal/window-name-after-same-origin-main-frame-navigation.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<title>window.name after a same-origin main frame navigation</title>
+<script src='/resources/testharness.js'></script>
+<script src='/resources/testharnessreport.js'></script>
+<body>
+  <script>
+    var win;
+    async_test(function(t) {
+      win = window.open("support/window-name-after-same-origin-main-frame-navigation-1.sub.html")
+      addEventListener("message", t.step_func_done(e => assert_true(e.data)));
+    }).add_cleanup(() => {if (win) {win.close()}});
+  </script>
deleted file mode 100644
--- a/testing/web-platform/tests/html/browsers/browsing-the-web/history-traversal/window-name-after-same-origin-main-frame-navigation.sub.html
+++ /dev/null
@@ -1,13 +0,0 @@
-<!DOCTYPE HTML>
-<html>
-<head>
-    <!-- window.name should equal "test" after a same-origin main frame navigation. -->
-    <script src='/resources/testharness.js'></script>
-    <script src='/resources/testharnessreport.js'></script>
-</head>
-<body>
-    <script>
-        document.location = "support/window-name-navigation.sub.html?hostname={{host}}&shouldhavename=true&sendmessage=false";
-    </script>
-</body>
-</html>
--- a/toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
+++ b/toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
@@ -100,16 +100,18 @@ type.service.name=Services
 type.legacy.name=Legacy Extensions
 type.unsupported.name=Unsupported
 
 #LOCALIZATION NOTE(legacyWarning.description) %S is the brandShortName
 legacyWarning.description=Missing something? Some extensions are no longer supported by %S.
 #LOCALIZATION NOTE(legacyThemeWarning.description) %S is the brandShortName
 legacyThemeWarning.description=Missing something? Some themes are no longer supported by %S.
 
+#LOCALIZATION NOTE(listHeading.discover) %S is the brandShortName
+listHeading.discover=Personalize Your %S
 listHeading.extension=Manage Your Extensions
 listHeading.shortcuts=Manage Extension Shortcuts
 listHeading.theme=Manage Your Themes
 listHeading.plugin=Manage Your Plugins
 listHeading.locale=Manage Your Languages
 listHeading.dictionary=Manage Your Dictionaries
 
 searchLabel.extension=Find more extensions
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -218,19 +218,19 @@ legacy-extensions-description =
 
 private-browsing-description2 =
     { -brand-short-name } is changing how extensions work in private browsing. Any new extensions you add to
     { -brand-short-name } won’t run by default in Private Windows. Unless you allow it in settings, the
     extension won’t work while private browsing, and won’t have access to your online activities
     there. We’ve made this change to keep your private browsing private.
     <label data-l10n-name="private-browsing-learn-more">Learn how to manage extension settings</label>
 
-extensions-view-discover =
-    .name = Get Add-ons
-    .tooltiptext = { extensions-view-discover.name }
+extensions-view-discopane =
+    .name = Recommendations
+    .tooltiptext = { extensions-view-discopane.name }
 
 extensions-view-recent-updates =
     .name = Recent Updates
     .tooltiptext = { extensions-view-recent-updates.name }
 
 extensions-view-available-updates =
     .name = Available Updates
     .tooltiptext = { extensions-view-available-updates.name }
@@ -338,16 +338,51 @@ shortcuts-card-expand-button =
         *[other] Show { $numberToShow } More
     }
 
 shortcuts-card-collapse-button = Show Less
 
 go-back-button =
     .tooltiptext = Go back
 
+## Recommended add-ons page
+
+# Explanatory introduction to the list of recommended add-ons. The action word
+# ("recommends") in the final sentence is a link to external documentation.
+discopane-intro =
+    Extensions and themes are like apps for your browser, and they let you
+    protect passwords, download videos, find deals, block annoying ads, change
+    how your browser looks, and much more. These small software programs are
+    often developed by a third party. Here’s a selection { -brand-product-name }
+    <a data-l10n-name="learn-more-trigger">recommends</a> for exceptional
+    security, performance, and functionality.
+
+# Notice to make user aware that the recommendations are personalized.
+discopane-notice-recommendations =
+    Some of these recommendations are personalized. They are based on other
+    extensions you’ve installed, profile preferences, and usage statistics.
+discopane-notice-learn-more = Learn more
+
+privacy-policy = Privacy Policy
+
+# Refers to the author of an add-on, shown below the name of the add-on.
+# Variables:
+#   $author (string) - The name of the add-on developer.
+created-by-author = by <a data-l10n-name="author">{ $author }</a>
+# Shows the number of daily users of the add-on.
+# Variables:
+#   $dailyUsers (number) - The number of daily users.
+user-count = Users: { $dailyUsers }
+install-extension-button = Add to { -brand-product-name }
+install-theme-button = Install Theme
+# The label of the button that appears after installing an add-on. Upon click,
+# the detailed add-on view is opened, from where the add-on can be managed.
+manage-addon-button = Manage
+find-more-addons = Find more add-ons
+
 ## Add-on actions
 report-addon-button = Report
 remove-addon-button = Remove
 disable-addon-button = Disable
 enable-addon-button = Enable
 expand-addon-button = More Options
 
 addons-enabled-heading = Enabled
@@ -358,16 +393,22 @@ always-activate-button = Always Activate
 never-activate-button = Never Activate
 
 addon-detail-author-label = Author
 addon-detail-version-label = Version
 addon-detail-last-updated-label = Last Updated
 addon-detail-homepage-label = Homepage
 addon-detail-rating-label = Rating
 
+# The average rating that the add-on has received.
+# Variables:
+#   $rating (number) - A number between 0 and 5. The translation should show at most one digit after the comma.
+five-star-rating =
+  .title = Rated { NUMBER($rating, maximumFractionDigits: 1) } out of 5
+
 # This string is used to show that an add-on is disabled.
 # Variables:
 #   $name (string) - The name of the add-on
 addon-name-disabled = { $name } (disabled)
 
 # The number of reviews that an add-on has received on AMO.
 # Variables:
 #   $numberOfReviews (number) - The number of reviews received
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -52,32 +52,35 @@ addon-list message-bar-stack.pending-uni
 }
 
 addon-list .addon.card {
   -moz-user-select: none;
 }
 
 /* Theme preview image. */
 .card-heading-image {
+  /* If the width, height or aspect ratio changes, don't forget to update the
+   * getScreenshotUrlForAddon function in aboutaddons.js */
   width: var(--section-width);
-  /* This is a magic number for the aspect ratio we get from AMO. */
-  height: 89px;
-  object-fit: cover;
+  /* Adjust height so that the image preserves the aspect ratio from AMO.
+   * For details, see https://bugzilla.mozilla.org/show_bug.cgi?id=1546123 */
+  height: calc(var(--section-width) * 92 / 680);
 }
 
 .card-heading-icon {
   flex-shrink: 0;
   width: var(--addon-icon-size);
   height: var(--addon-icon-size);
   margin-inline-end: 16px;
   -moz-context-properties: fill;
   fill: currentColor;
 }
 
 .card-contents {
+  word-break: break-word;
   flex-grow: 1;
   display: flex;
   flex-direction: column;
 }
 
 .card-actions {
   flex-shrink: 0;
 }
@@ -130,16 +133,92 @@ addon-card:not([expanded]) .addon-descri
 
 .more-options-menu {
   position: relative;
   /* Add some negative margin to account for the button's padding */
   margin-top: -10px;
   margin-inline-end: -8px;
 }
 
+/* Discopane extensions to the add-on card */
+
+recommended-addon-card .addon-name {
+  display: flex;
+}
+
+recommended-addon-card .addon-description:not(:empty) {
+  margin-top: 0.5em;
+}
+
+.disco-card-head {
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.disco-addon-author {
+  font-size: 12px;
+  font-weight: normal;
+}
+
+.disco-description-statistics {
+  margin-top: 1em;
+  display: grid;
+  grid-template-columns: repeat(2, max-content);
+  grid-column-gap: 2em;
+  align-items: center;
+}
+
+.disco-cta-button {
+  font-size: 14px;
+  flex-shrink: 0;
+  flex-grow: 0;
+  align-self: baseline;
+}
+
+.disco-cta-button[action="install-addon"]::before {
+  content: "+";
+  padding-inline-end: 4px;
+}
+
+.discopane-notice {
+  margin: 0.5em 0;
+}
+
+.discopane-notice-content {
+  align-items: center;
+  display: flex;
+  width: 100%;
+}
+
+.discopane-notice-content > span {
+  flex-grow: 1;
+}
+
+.discopane-notice-content > button {
+  flex-grow: 0;
+  flex-shrink: 0;
+}
+
+.discopane-footer {
+  text-align: center;
+}
+
+.discopane-footer > * {
+  margin-top: 30px;
+}
+
+.discopane-privacy-policy-link {
+  font-size: small;
+}
+
+addon-details {
+  color: var(--grey-60);
+}
+
 .addon-detail-description {
   margin: 16px 0;
 }
 
 .addon-detail-contribute {
   padding: var(--card-padding);
   border: 1px solid var(--grey-90-a20);
   border-radius: var(--panel-border-radius);
@@ -187,43 +266,16 @@ addon-card:not([expanded]) .addon-descri
 .addon-detail-row input[type="checkbox"] {
   margin: 0;
 }
 
 .addon-detail-rating {
   display: flex;
 }
 
-.addon-detail-rating-star {
-  display: inline-block;
-  width: 16px;
-  height: 16px;
-  background: url("chrome://browser/skin/bookmark-hollow.svg");
-}
-
-.addon-detail-rating-star[fill="full"] {
-  background: url("chrome://browser/skin/bookmark.svg");
-}
-
-.addon-detail-rating-star[fill="half"] {
-  background: url("chrome://browser/skin/bookmark.svg");
-  width: 8px;
-  margin-inline-end: 8px;
-}
-
-.addon-detail-rating-star[fill="half"]::after {
-  content: "";
-  display: inline-block;
-  background: url("chrome://browser/skin/bookmark-hollow.svg");
-  background-position: 8px;
-  width: 8px;
-  height: 16px;
-  margin-left: 8px;
-}
-
 .addon-detail-rating > a {
   margin-inline-start: 8px;
 }
 
 .more-options-button {
   min-width: auto;
   min-height: auto;
   width: 24px;
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -57,16 +57,36 @@
           </div>
           <div class="more-options-menu">
             <button class="more-options-button ghost-button" action="more-options"></button>
           </div>
         </div>
       </div>
     </template>
 
+    <template name="addon-name-container-in-disco-card">
+      <div class="disco-card-head">
+        <span class="disco-addon-name"></span>
+        <span class="disco-addon-author"><a data-l10n-name="author" target="_blank"></a></span>
+      </div>
+      <button class="disco-cta-button primary" action="install-addon"></button>
+      <button class="disco-cta-button" data-l10n-id="manage-addon-button" action="manage-addon"></button>
+    </template>
+
+    <template name="addon-description-in-disco-card">
+      <div>
+        <strong class="disco-description-intro"></strong>
+        <span class="disco-description-main"></span>
+      </div>
+      <div class="disco-description-statistics">
+        <five-star-rating></five-star-rating>
+        <span class="disco-user-count"></span>
+      </div>
+    </template>
+
     <template name="addon-details">
       <div class="addon-detail-description"></div>
       <div class="addon-detail-contribute">
         <label data-l10n-id="detail-contributions-description"></label>
         <button
           class="addon-detail-contribute-button"
           action="contribute"
           data-l10n-id="detail-contributions-button"
@@ -118,33 +138,72 @@
       </div>
       <div class="addon-detail-row addon-detail-row-homepage">
         <label data-l10n-id="addon-detail-homepage-label"></label>
         <a target="_blank"></a>
       </div>
       <div class="addon-detail-row addon-detail-row-rating">
         <label data-l10n-id="addon-detail-rating-label"></label>
         <div class="addon-detail-rating">
-          <span class="addon-detail-rating-star"></span>
-          <span class="addon-detail-rating-star"></span>
-          <span class="addon-detail-rating-star"></span>
-          <span class="addon-detail-rating-star"></span>
-          <span class="addon-detail-rating-star"></span>
+          <five-star-rating></five-star-rating>
           <a target="_blank"></a>
         </div>
       </div>
     </template>
 
+    <template name="five-star-rating">
+      <link rel="stylesheet" href="chrome://mozapps/content/extensions/rating-star.css">
+      <span class="rating-star"></span>
+      <span class="rating-star"></span>
+      <span class="rating-star"></span>
+      <span class="rating-star"></span>
+      <span class="rating-star"></span>
+    </template>
+
     <template name="panel-list">
       <link rel="stylesheet" href="chrome://mozapps/content/extensions/panel-list.css">
       <div class="arrow top"></div>
       <div class="list">
         <slot></slot>
       </div>
       <div class="arrow bottom"></div>
     </template>
 
     <template name="panel-item">
       <link rel="stylesheet" href="chrome://mozapps/content/extensions/panel-item.css">
       <button><slot></slot></button>
     </template>
+
+    <template name="discopane">
+      <header>
+        <p>
+          <span data-l10n-id="discopane-intro">
+            <a
+              class="discopane-intro-learn-more-link"
+              data-l10n-name="learn-more-trigger"
+              target="_blank">
+            </a>
+          </span>
+        </p>
+      </header>
+      <message-bar class="discopane-notice">
+        <div class="discopane-notice-content">
+          <span data-l10n-id="discopane-notice-recommendations"></span>
+          <button data-l10n-id="discopane-notice-learn-more" action="notice-learn-more"></button>
+        </div>
+      </message-bar>
+      <recommended-addon-list></recommended-addon-list>
+      <footer class="discopane-footer">
+        <div>
+          <button class="primary" action="open-amo" data-l10n-id="find-more-addons"></button>
+        </div>
+        <div>
+          <a
+            class="discopane-privacy-policy-link"
+            data-l10n-id="privacy-policy"
+            href="https://www.mozilla.org/privacy/firefox/?utm_source=firefox-browser&amp;utm_medium=firefox-browser&amp;utm_content=privacy-policy-link#addons"
+            target="_blank"
+          ></a>
+        </div>
+      </footer>
+    </template>
   </body>
 </html>
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -6,17 +6,20 @@
 /* import-globals-from aboutaddonsCommon.js */
 /* import-globals-from abuse-reports.js */
 /* global windowRoot */
 
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
+  AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
+  ClientID: "resource://gre/modules/ClientID.jsm",
   ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
+  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
 });
 
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "allowPrivateBrowsingByDefault",
   "extensions.allowPrivateBrowsingByDefault", true);
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "SUPPORT_URL", "app.support.baseURL",
   "", null, val => Services.urlFormatter.formatURL(val));
@@ -32,16 +35,19 @@ const PERMISSION_MASKS = {
   enable: AddonManager.PERM_CAN_ENABLE,
   "always-activate": AddonManager.PERM_CAN_ENABLE,
   disable: AddonManager.PERM_CAN_DISABLE,
   "never-activate": AddonManager.PERM_CAN_DISABLE,
   uninstall: AddonManager.PERM_CAN_UNINSTALL,
   upgrade: AddonManager.PERM_CAN_UPGRADE,
 };
 
+const PREF_DISCOVERY_API_URL = "extensions.getAddons.discovery.api_url";
+const PREF_RECOMMENDATION_ENABLED = "browser.discovery.enabled";
+const PREF_TELEMETRY_ENABLED = "datareporting.healthreport.uploadEnabled";
 const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed";
 const PRIVATE_BROWSING_PERMS =
   {permissions: [PRIVATE_BROWSING_PERM_NAME], origins: []};
 
 const AddonCardListenerHandler = {
   ADDON_EVENTS: new Set([
     "onDisabled", "onEnabled", "onInstalled", "onPropertyChanged",
     "onUninstalled",
@@ -151,16 +157,151 @@ function nl2br(text) {
       frag.appendChild(document.createElement("br"));
     }
     frag.appendChild(new Text(part));
     hasAppended = true;
   }
   return frag;
 }
 
+/**
+ * Select the screeenshot to display above an add-on card.
+ *
+ * @param {AddonWrapper|DiscoAddonWrapper} addon
+ * @returns {string|null}
+ *          The URL of the best fitting screenshot, if any.
+ */
+function getScreenshotUrlForAddon(addon) {
+  let {screenshots} = addon;
+  if (!screenshots || !screenshots.length) {
+    return null;
+  }
+
+  // The image size is defined at .card-heading-image in aboutaddons.css, and
+  // is based on the aspect ratio for a 680x92 image. Use the image if possible,
+  // and otherwise fall back to the first image and hope for the best.
+  let screenshot = screenshots.find(s => s.width === 680 && s.height === 92);
+  if (!screenshot) {
+    console.warn(`Did not find screenshot with desired size for ${addon.id}.`);
+    screenshot = screenshots[0];
+  }
+  return screenshot.url;
+}
+
+/**
+ * Adds UTM parameters to a given URL, if it is an AMO URL.
+ *
+ * @param {string} contentAttribute
+ *        Identifies the part of the UI with which the link is associated.
+ * @param {string} url
+ * @returns {string}
+ *          The url with UTM parameters if it is an AMO URL.
+ *          Otherwise the url in unmodified form.
+ */
+function formatAmoUrl(contentAttribute, url) {
+  let parsedUrl = new URL(url);
+  let domain = `.${parsedUrl.hostname}`;
+  if (!domain.endsWith(".addons.mozilla.org") &&
+      // For testing: addons-dev.allizom.org and addons.allizom.org
+      !domain.endsWith(".allizom.org")) {
+    return url;
+  }
+
+  parsedUrl.searchParams.set("utm_source", "firefox-browser");
+  parsedUrl.searchParams.set("utm_medium", "firefox-browser");
+  parsedUrl.searchParams.set("utm_content", contentAttribute);
+  return parsedUrl.href;
+}
+
+// A wrapper around an item from the "results" array from AMO's discovery API.
+// See https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+class DiscoAddonWrapper {
+  /**
+   * @param {object} details
+   *        An item in the "results" array from AMO's discovery API.
+   */
+  constructor(details) {
+    // Reuse AddonRepository._parseAddon to have the AMO response parsing logic
+    // in one place.
+    let repositoryAddon = AddonRepository._parseAddon(details.addon);
+
+    // Note: Any property used by RecommendedAddonCard should appear here.
+    // The property names and values should have the same semantics as
+    // AddonWrapper, to ease the reuse of helper functions in this file.
+    this.id = repositoryAddon.id;
+    this.type = repositoryAddon.type;
+    this.name = repositoryAddon.name;
+    this.screenshots = repositoryAddon.screenshots;
+    this.sourceURI = repositoryAddon.sourceURI;
+    this.creator = repositoryAddon.creator;
+    this.averageRating = repositoryAddon.averageRating;
+
+    this.dailyUsers = details.addon.average_daily_users;
+
+    this.editorialHeading = details.heading_text;
+    this.editorialDescription = details.description_text;
+    this.iconURL = details.addon.icon_url;
+    this.amoListingUrl = details.addon.url;
+  }
+}
+
+/**
+ * A helper to retrieve the list of recommended add-ons via AMO's discovery API.
+ */
+var DiscoveryAPI = {
+  /**
+   * Fetch the list of recommended add-ons. The results are cached.
+   *
+   * Pending requests are coalesced, so there is only one request at any given
+   * time. If a request fails, the pending promises are rejected, but a new
+   * call will result in a new request. A succesful response is cached for the
+   * lifetime of the document.
+   *
+   * @returns {Promise<DiscoAddonWrapper[]>}
+   */
+  async getResults() {
+    if (!this._resultPromise) {
+      this._resultPromise = this._fetchRecommendedAddons()
+        .catch(e => {
+          // Delete the pending promise, so _fetchRecommendedAddons can be
+          // called again at the next property access.
+          delete this._resultPromise;
+          Cu.reportError(e);
+          throw e;
+        });
+    }
+    return this._resultPromise;
+  },
+
+  get clientIdDiscoveryEnabled() {
+    // These prefs match Discovery.jsm for enabling clientId cookies.
+    return Services.prefs.getBoolPref(PREF_RECOMMENDATION_ENABLED, false) &&
+           Services.prefs.getBoolPref(PREF_TELEMETRY_ENABLED, false) &&
+           !PrivateBrowsingUtils.isContentWindowPrivate(window);
+  },
+
+  async _fetchRecommendedAddons() {
+    let discoveryApiUrl =
+      new URL(Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL));
+
+    if (DiscoveryAPI.clientIdDiscoveryEnabled) {
+      let clientId = await ClientID.getClientIdHash();
+      discoveryApiUrl.searchParams.set("telemetry-client-id", clientId);
+    }
+    let res = await fetch(discoveryApiUrl.href, {
+      credentials: "omit",
+    });
+    if (!res.ok) {
+      throw new Error(`Failed to fetch recommended add-ons, ${res.status}`);
+    }
+    let {results} = await res.json();
+    return results.map(details => new DiscoAddonWrapper(details));
+  },
+};
+
 class PanelList extends HTMLElement {
   static get observedAttributes() {
     return ["open"];
   }
 
   constructor() {
     super();
     this.attachShadow({mode: "open"});
@@ -457,16 +598,77 @@ class PluginOptions extends HTMLElement 
       let el = this.querySelector(`[action="${action}"]`);
       el.checked = addon.userDisabled === userDisabled;
       el.disabled = !(el.checked || hasPermission(addon, action));
     }
   }
 }
 customElements.define("plugin-options", PluginOptions);
 
+class FiveStarRating extends HTMLElement {
+  static get observedAttributes() {
+    return ["rating"];
+  }
+
+  constructor() {
+    super();
+    this.attachShadow({mode: "open"});
+    this.shadowRoot.append(importTemplate("five-star-rating"));
+  }
+
+  set rating(v) {
+    this.setAttribute("rating", v);
+  }
+
+  get rating() {
+    let v = parseFloat(this.getAttribute("rating"), 10);
+    if (v >= 0 && v <= 5) {
+      return v;
+    }
+    return 0;
+  }
+
+  get ratingBuckets() {
+    // 0    <= x <  0.25 = empty
+    // 0.25 <= x <  0.75 = half
+    // 0.75 <= x <= 1    = full
+    // ... et cetera, until x <= 5.
+    let {rating} = this;
+    return [0, 1, 2, 3, 4].map(ratingStart => {
+      let distanceToFull = rating - ratingStart;
+      if (distanceToFull < 0.25) {
+        return "empty";
+      }
+      if (distanceToFull < 0.75) {
+        return "half";
+      }
+      return "full";
+    });
+  }
+
+  connectedCallback() {
+    this.renderRating();
+  }
+
+  attributeChangedCallback() {
+    this.renderRating();
+  }
+
+  renderRating() {
+    let starElements = this.shadowRoot.querySelectorAll(".rating-star");
+    for (let [i, part] of this.ratingBuckets.entries()) {
+      starElements[i].setAttribute("fill", part);
+    }
+    document.l10n.setAttributes(this, "five-star-rating", {
+      rating: this.rating,
+    });
+  }
+}
+customElements.define("five-star-rating", FiveStarRating);
+
 class AddonDetails extends HTMLElement {
   connectedCallback() {
     if (this.children.length == 0) {
       this.render();
     }
   }
 
   setAddon(addon) {
@@ -576,24 +778,17 @@ class AddonDetails extends HTMLElement {
       homepageURL.textContent = addon.homepageURL;
     } else {
       homepageRow.remove();
     }
 
     // Rating.
     let ratingRow = this.querySelector(".addon-detail-row-rating");
     if (addon.averageRating) {
-      let stars = ratingRow.querySelectorAll(".addon-detail-rating-star");
-      for (let i = 0; i < stars.length; i++) {
-        let fill = "";
-        if (addon.averageRating > i) {
-          fill = addon.averageRating > i + 0.5 ? "full" : "half";
-        }
-        stars[i].setAttribute("fill", fill);
-      }
+      ratingRow.querySelector("five-star-rating").rating = addon.averageRating;
       let reviews = ratingRow.querySelector("a");
       reviews.href = addon.reviewURL;
       document.l10n.setAttributes(reviews, "addon-detail-reviews-link", {
         numberOfReviews: addon.reviewCount,
       });
     } else {
       ratingRow.remove();
     }
@@ -673,48 +868,16 @@ class AddonCard extends HTMLElement {
     if (install && install.state == AddonManager.STATE_AVAILABLE) {
       this.updateInstall = install;
     }
     if (this.children.length > 0) {
       this.render();
     }
   }
 
-  /**
-   * Determine which screenshot fits best into the given img element. The img
-   * should have a width and height set on it.
-   *
-   * @param {HTMLImageElement} img The <img> the screenshot is being set on.
-   */
-  screenshotForImg(img) {
-    let {addon} = this;
-    if (addon.screenshots && addon.screenshots[0]) {
-      let {width, height} = getComputedStyle(img);
-      let sectionWidth = parseInt(width, 10);
-      let sectionHeight = parseInt(height, 10);
-      let screenshots = addon.screenshots
-        // Only check screenshots with a width and height.
-        .filter(s => s.width && s.height)
-        // Sort the screenshots based how close their dimensions are to the
-        // requested size.
-        .sort((a, b) => {
-          let aCloseness =
-            Math.abs((a.width - sectionWidth) * (a.height - sectionHeight));
-          let bCloseness =
-            Math.abs((b.width - sectionWidth) * (b.height - sectionHeight));
-          if (aCloseness == bCloseness) {
-            return 0;
-          }
-          return aCloseness < bCloseness ? -1 : 1;
-        });
-      return screenshots[0];
-    }
-    return null;
-  }
-
   async handleEvent(e) {
     let {addon} = this;
     let action = e.target.getAttribute("action");
 
     if (e.type == "click") {
       switch (action) {
         case "toggle-disabled":
           if (addon.userDisabled) {
@@ -905,19 +1068,19 @@ class AddonCard extends HTMLElement {
       icon = AddonManager.getPreferredIconURL(addon, 32, window);
     }
     card.querySelector(".addon-icon").src = icon;
 
     // Update the theme preview.
     let preview = card.querySelector(".card-heading-image");
     preview.hidden = true;
     if (addon.type == "theme") {
-      let screenshot = this.screenshotForImg(preview);
-      if (screenshot) {
-        preview.src = screenshot.url;
+      let screenshotUrl = getScreenshotUrlForAddon(addon);
+      if (screenshotUrl) {
+        preview.src = screenshotUrl;
         preview.hidden = false;
       }
     }
 
     // Update the name.
     let name = card.querySelector(".addon-name");
     if (addon.isActive) {
       name.textContent = addon.name;
@@ -1005,16 +1168,185 @@ class AddonCard extends HTMLElement {
 
   sendEvent(name, detail) {
     this.dispatchEvent(new CustomEvent(name, {detail}));
   }
 }
 customElements.define("addon-card", AddonCard);
 
 /**
+ * A child element of `<recommended-addon-list>`. It should be initialized
+ * by calling `setDiscoAddon()` first. Call `setAddon(addon)` if it has been
+ * installed, and call `setAddon(null)` upon uninstall.
+ *
+ *    let discoAddon = new DiscoAddonWrapper({ ... });
+ *    let card = document.createElement("recommended-addon-card");
+ *    card.setDiscoAddon(discoAddon);
+ *    document.body.appendChild(card);
+ *
+ *    AddonManager.getAddonsByID(discoAddon.id)
+ *      .then(addon => card.setAddon(addon));
+ */
+class RecommendedAddonCard extends HTMLElement {
+  /**
+   * @param {DiscoAddonWrapper} addon
+   *        The details of the add-on that should be rendered in the card.
+   */
+  setDiscoAddon(addon) {
+    this.addonId = addon.id;
+
+    // Save the information so we can install.
+    this.discoAddon = addon;
+
+    let card = importTemplate("card").firstElementChild;
+    let heading = card.querySelector(".addon-name-container");
+    heading.textContent = "";
+    heading.append(importTemplate("addon-name-container-in-disco-card"));
+    card.querySelector(".more-options-menu").remove();
+
+    this.setCardContent(card, addon);
+    if (addon.type != "theme") {
+      card.querySelector(".addon-description")
+        .append(importTemplate("addon-description-in-disco-card"));
+      this.setCardDescription(card, addon);
+    }
+    this.registerButtons(card, addon);
+
+    this.textContent = "";
+    this.append(card);
+
+    // We initially assume that the add-on is not installed.
+    this.setAddon(null);
+  }
+
+  /**
+   * Fills in all static parts of the card.
+   *
+   * @param {HTMLElement} card
+   *        The primary content of this card.
+   * @param {DiscoAddonWrapper} addon
+   */
+  setCardContent(card, addon) {
+    // Set the icon.
+    if (addon.type == "theme") {
+      card.querySelector(".addon-icon").hidden = true;
+    } else {
+      card.querySelector(".addon-icon").src =
+        AddonManager.getPreferredIconURL(addon, 32, window);
+    }
+
+    // Set the theme preview.
+    let preview = card.querySelector(".card-heading-image");
+    preview.hidden = true;
+    if (addon.type == "theme") {
+      let screenshotUrl = getScreenshotUrlForAddon(addon);
+      if (screenshotUrl) {
+        preview.src = screenshotUrl;
+        preview.hidden = false;
+      }
+    }
+
+    // Set the name.
+    card.querySelector(".disco-addon-name").textContent = addon.name;
+
+    // Set the author name and link to AMO.
+    if (addon.creator) {
+      let authorInfo = card.querySelector(".disco-addon-author");
+      document.l10n.setAttributes(authorInfo, "created-by-author", {
+        author: addon.creator.name,
+      });
+      // This is intentionally a link to the add-on listing instead of the
+      // author page, because the add-on listing provides more relevant info.
+      authorInfo.querySelector("a").href =
+        formatAmoUrl("discopane-entry-link", addon.amoListingUrl);
+      authorInfo.hidden = false;
+    }
+  }
+
+  setCardDescription(card, addon) {
+    // Set the description. Note that this is the editorial description, not
+    // the add-on's original description that would normally appear on a card.
+    card.querySelector(".disco-description-main")
+      .textContent = addon.editorialDescription;
+    if (addon.editorialHeading) {
+      card.querySelector(".disco-description-intro").textContent =
+        addon.editorialHeading;
+    }
+
+    let hasStats = false;
+    if (addon.averageRating) {
+      hasStats = true;
+      card.querySelector("five-star-rating").rating = addon.averageRating;
+    } else {
+      card.querySelector("five-star-rating").hidden = true;
+    }
+
+    if (addon.dailyUsers) {
+      hasStats = true;
+      let userCountElem = card.querySelector(".disco-user-count");
+      document.l10n.setAttributes(userCountElem, "user-count", {
+        dailyUsers: addon.dailyUsers,
+      });
+    }
+
+    card.querySelector(".disco-description-statistics").hidden = !hasStats;
+  }
+
+  registerButtons(card, addon) {
+    let installButton = card.querySelector("[action='install-addon']");
+    if (addon.type == "theme") {
+      document.l10n.setAttributes(installButton, "install-theme-button");
+    } else {
+      document.l10n.setAttributes(installButton, "install-extension-button");
+    }
+
+    this.addEventListener("click", this);
+  }
+
+  handleEvent(event) {
+    let action = event.target.getAttribute("action");
+    switch (action) {
+      case "install-addon":
+        this.installDiscoAddon();
+        break;
+      case "manage-addon":
+        loadViewFn("detail", this.addonId);
+        break;
+    }
+  }
+
+  async installDiscoAddon() {
+    let addon = this.discoAddon;
+    let url = addon.sourceURI.spec;
+    let install = await AddonManager.getInstallForURL(url, {
+      name: addon.name,
+      telemetryInfo: {source: "disco"},
+    });
+    // We are hosted in a <browser> in about:addons, but we can just use the
+    // main tab's browser since all of it is using the system principal.
+    let browser = window.docShell.chromeEventHandler;
+    AddonManager.installAddonFromWebpage("application/x-xpinstall", browser,
+      Services.scriptSecurityManager.getSystemPrincipal(), install);
+  }
+
+  /**
+   * @param {AddonWrapper|null} addon
+   *        The add-on that has been installed; null if it has been removed.
+   */
+  setAddon(addon) {
+    let card = this.firstElementChild;
+    card.querySelector("[action='install-addon']").hidden = !!addon;
+    card.querySelector("[action='manage-addon']").hidden = !addon;
+
+    this.dispatchEvent(new CustomEvent("disco-card-updated")); // For testing.
+  }
+}
+customElements.define("recommended-addon-card", RecommendedAddonCard);
+
+/**
  * A list view for add-ons of a certain type. It should be initialized with the
  * type of add-on to render and have section data set before being connected to
  * the document.
  *
  *    let list = document.createElement("addon-list");
  *    list.type = "plugin";
  *    list.setSections([{
  *      headingId: "plugin-section-heading",
@@ -1345,16 +1677,133 @@ class AddonList extends HTMLElement {
 
   onUninstalled(addon) {
     this.removePendingUninstallBar(addon);
     this.removeAddon(addon);
   }
 }
 customElements.define("addon-list", AddonList);
 
+class RecommendedAddonList extends HTMLElement {
+  connectedCallback() {
+    if (this.isConnected) {
+      this.loadCardsIfNeeded();
+      this.updateCardsWithAddonManager();
+    }
+    AddonManager.addAddonListener(this);
+  }
+
+  disconnectedCallback() {
+    AddonManager.removeAddonListener(this);
+  }
+
+  onInstalled(addon) {
+    let card = this.getCardById(addon.id);
+    if (card) {
+      card.setAddon(addon);
+    }
+  }
+
+  onUninstalled(addon) {
+    let card = this.getCardById(addon.id);
+    if (card) {
+      card.setAddon(null);
+    }
+  }
+
+  getCardById(addonId) {
+    for (let card of this.children) {
+      if (card.addonId === addonId) {
+        return card;
+      }
+    }
+    return null;
+  }
+
+  async updateCardsWithAddonManager() {
+    let cards = Array.from(this.children);
+    let addonIds = cards.map(card => card.addonId);
+    let addons = await AddonManager.getAddonsByIDs(addonIds);
+    for (let [i, card] of cards.entries()) {
+      let addon = addons[i];
+      card.setAddon(addon);
+      if (addon) {
+        // Already installed, move card to end.
+        this.append(card);
+      }
+    }
+  }
+
+  async loadCardsIfNeeded() {
+    // Use promise as guard. Also used by tests to detect when load completes.
+    if (!this.cardsReady) {
+      this.cardsReady = this._loadCards();
+    }
+    return this.cardsReady;
+  }
+
+  async _loadCards() {
+    let recommendedAddons;
+    try {
+      recommendedAddons = await DiscoveryAPI.getResults();
+    } catch (e) {
+      return;
+    }
+
+    let frag = document.createDocumentFragment();
+    for (let addon of recommendedAddons) {
+      let card = document.createElement("recommended-addon-card");
+      card.setDiscoAddon(addon);
+      frag.append(card);
+    }
+    this.append(frag);
+    await this.updateCardsWithAddonManager();
+  }
+}
+customElements.define("recommended-addon-list", RecommendedAddonList);
+
+class DiscoveryPane extends HTMLElement {
+  render() {
+    this.append(importTemplate("discopane"));
+    this.querySelector(".discopane-intro-learn-more-link").href =
+      Services.urlFormatter.formatURLPref("app.support.baseURL") +
+      "recommended-extensions-program";
+
+    this.querySelector(".discopane-notice").hidden =
+      !DiscoveryAPI.clientIdDiscoveryEnabled;
+    this.addEventListener("click", this);
+
+    // Hide footer until the cards is loaded, to prevent the content from
+    // suddenly shifting when the user attempts to interact with it.
+    let footer = this.querySelector("footer");
+    footer.hidden = true;
+    this.querySelector("recommended-addon-list").loadCardsIfNeeded()
+      .finally(() => { footer.hidden = false; });
+  }
+
+  handleEvent(event) {
+    let action = event.target.getAttribute("action");
+    switch (action) {
+      case "notice-learn-more":
+        windowRoot.ownerGlobal.openTrustedLinkIn(
+          Services.urlFormatter.formatURLPref("app.support.baseURL") +
+          "personalized-extension-recommendations", "tab");
+        break;
+      case "open-amo":
+        let amoUrl =
+          Services.urlFormatter.formatURLPref("extensions.getAddons.link.url");
+        amoUrl = formatAmoUrl("find-more-link-bottom", amoUrl);
+        windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab");
+        break;
+    }
+  }
+}
+
+customElements.define("discovery-pane", DiscoveryPane);
+
 class ListView {
   constructor({param, root}) {
     this.type = param;
     this.root = root;
   }
 
   async render() {
     let list = document.createElement("addon-list");
@@ -1435,16 +1884,24 @@ class UpdatesView {
     }
 
     await list.render();
     this.root.textContent = "";
     this.root.appendChild(list);
   }
 }
 
+class DiscoveryView {
+  render() {
+    let discopane = document.createElement("discovery-pane");
+    discopane.render();
+    return discopane;
+  }
+}
+
 // Generic view management.
 let root = null;
 
 /**
  * Called from extensions.js once, when about:addons is loading.
  */
 function initialize(opts) {
   root = document.getElementById("main");
@@ -1464,16 +1921,24 @@ function initialize(opts) {
  * resolve once the view has been updated to conform with other about:addons
  * views.
  */
 async function show(type, param) {
   if (type == "list") {
     await new ListView({param, root}).render();
   } else if (type == "detail") {
     await new DetailView({param, root}).render();
+  } else if (type == "discover") {
+    let discoverView = new DiscoveryView();
+    let elem = discoverView.render();
+    await document.l10n.translateFragment(elem);
+    root.textContent = "";
+    root.append(elem);
   } else if (type == "updates") {
     await new UpdatesView({param, root}).render();
+  } else {
+    throw new Error(`Unknown view type: ${type}`);
   }
 }
 
 function hide() {
   root.textContent = "";
 }
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -37,16 +37,18 @@ XPCOMUtils.defineLazyPreferenceGetter(th
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "allowPrivateBrowsingByDefault",
                                       "extensions.allowPrivateBrowsingByDefault", true);
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "SUPPORT_URL", "app.support.baseURL",
                                       "", null, val => Services.urlFormatter.formatURL(val));
 XPCOMUtils.defineLazyPreferenceGetter(this, "useHtmlViews",
                                       "extensions.htmlaboutaddons.enabled");
+XPCOMUtils.defineLazyPreferenceGetter(this, "useHtmlDiscover",
+                                      "extensions.htmlaboutaddons.discover.enabled");
 
 const PREF_DISCOVERURL = "extensions.webservice.discoverURL";
 const PREF_DISCOVER_ENABLED = "extensions.getAddons.showPane";
 const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
 const PREF_GETADDONS_CACHE_ID_ENABLED = "extensions.%ID%.getAddons.cache.enabled";
 const PREF_UI_TYPE_HIDDEN = "extensions.ui.%TYPE%.hidden";
 const PREF_UI_LASTCATEGORY = "extensions.ui.lastCategory";
 const PREF_LEGACY_EXCEPTIONS = "extensions.legacy.exceptions";
@@ -728,33 +730,38 @@ var gViewController = {
   backButton: null,
 
   initialize() {
     this.viewPort = document.getElementById("view-port");
     this.headeredViews = document.getElementById("headered-views");
     this.headeredViewsDeck = document.getElementById("headered-views-content");
     this.backButton = document.getElementById("go-back");
 
-    this.viewObjects.discover = gDiscoverView;
     this.viewObjects.legacy = gLegacyView;
     this.viewObjects.shortcuts = gShortcutsView;
 
     if (useHtmlViews) {
       this.viewObjects.list = htmlView("list");
       this.viewObjects.detail = htmlView("detail");
       this.viewObjects.updates = htmlView("updates");
       // gUpdatesView still handles when the Available Updates category is
       // shown. Include it in viewObjects so it gets initialized and shutdown.
       this.viewObjects._availableUpdatesSidebar = gUpdatesView;
     } else {
       this.viewObjects.list = gListView;
       this.viewObjects.detail = gDetailView;
       this.viewObjects.updates = gUpdatesView;
     }
 
+    if (useHtmlDiscover) {
+      this.viewObjects.discover = htmlView("discover");
+    } else {
+      this.viewObjects.discover = gDiscoverView;
+    }
+
     for (let type in this.viewObjects) {
       let view = this.viewObjects[type];
       view.initialize();
     }
 
     window.controllers.appendController(this);
 
     window.addEventListener("popstate", function(e) {
@@ -907,21 +914,26 @@ var gViewController = {
 
     this.displayedView = this.currentViewObj;
     this.currentViewObj.node.setAttribute("loading", "true");
 
     recordViewTelemetry(view.param);
 
     let headingName = document.getElementById("heading-name");
     let headingLabel;
-    try {
-      headingLabel = gStrings.ext.GetStringFromName(`listHeading.${view.param}`);
-    } catch (e) {
-      // Some views don't have a label, like the updates view.
-      headingLabel = "";
+    if (view.type == "discover") {
+      headingLabel = gStrings.ext.formatStringFromName(
+        "listHeading.discover", [gStrings.brandShortName], 1);
+    } else {
+      try {
+        headingLabel = gStrings.ext.GetStringFromName(`listHeading.${view.param}`);
+      } catch (e) {
+        // Some views don't have a label, like the updates view.
+        headingLabel = "";
+      }
     }
     headingName.textContent = headingLabel;
     setSearchLabel(view.param);
 
 
     if (aViewId == aPreviousView)
       this.currentViewObj.refresh(view.param, ++this.currentViewRequest, aState);
     else
--- a/toolkit/mozapps/extensions/content/extensions.xul
+++ b/toolkit/mozapps/extensions/content/extensions.xul
@@ -134,17 +134,17 @@
 
   <stack id="main-page-stack" flex="1">
   <hbox id="main-page-content" flex="1">
     <vbox id="category-box">
       <!-- category list -->
       <richlistbox id="categories" flex="1">
         <richlistitem id="category-discover" value="addons://discover/"
                       class="category"
-                      data-l10n-id="extensions-view-discover"
+                      data-l10n-id="extensions-view-discopane"
                       data-l10n-attrs="name"
                       priority="1000"/>
         <richlistitem id="category-legacy" value="addons://legacy/"
                       class="category" priority="20000"
                       hidden="true"/>
         <richlistitem id="category-availableUpdates" value="addons://updates/available"
                       class="category"
                       data-l10n-id="extensions-view-available-updates"
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/rating-star.css
@@ -0,0 +1,37 @@
+:host {
+  --rating-star-size: 1em;
+  --rating-star-spacing: 0.3ch;
+
+  display: inline-grid;
+  grid-template-columns: repeat(5, var(--rating-star-size));
+  grid-column-gap: var(--rating-star-spacing);
+  align-content: center;
+}
+
+:host([hidden]) {
+  display: none;
+}
+
+.rating-star {
+  display: inline-block;
+  width: var(--rating-star-size);
+  height: var(--rating-star-size);
+  background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#empty");
+  background-position: center;
+  background-repeat: no-repeat;
+  background-size: 100%;
+
+  fill: currentColor;
+  -moz-context-properties: fill;
+}
+
+.rating-star[fill="half"] {
+  background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#half");
+}
+.rating-star[fill="full"] {
+  background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#full");
+}
+
+.rating-star[fill="half"]:dir(rtl) {
+  transform: scaleX(-1);
+}
--- a/toolkit/mozapps/extensions/internal/AddonRepository.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonRepository.jsm
@@ -624,16 +624,17 @@ var AddonRepository = {
 
     addon.description = convertHTMLToPlainText(aEntry.summary);
     addon.fullDescription = convertHTMLToPlainText(aEntry.description);
 
     addon.weeklyDownloads = aEntry.weekly_downloads;
 
     switch (aEntry.type) {
       case "persona":
+      case "statictheme":
         addon.type = "theme";
         break;
 
       case "language":
         addon.type = "locale";
         break;
 
       default:
--- a/toolkit/mozapps/extensions/jar.mn
+++ b/toolkit/mozapps/extensions/jar.mn
@@ -26,9 +26,10 @@ toolkit.jar:
   content/mozapps/extensions/abuse-report-frame.html            (content/abuse-report-frame.html)
   content/mozapps/extensions/abuse-report-frame.js              (content/abuse-report-frame.js)
   content/mozapps/extensions/abuse-report-panel.css             (content/abuse-report-panel.css)
   content/mozapps/extensions/abuse-report-panel.js              (content/abuse-report-panel.js)
   content/mozapps/extensions/message-bar.css                    (content/message-bar.css)
   content/mozapps/extensions/message-bar.js                     (content/message-bar.js)
   content/mozapps/extensions/panel-list.css                     (content/panel-list.css)
   content/mozapps/extensions/panel-item.css                     (content/panel-item.css)
+  content/mozapps/extensions/rating-star.css                    (content/rating-star.css)
 #endif
--- a/toolkit/mozapps/extensions/test/browser/browser.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -4,16 +4,18 @@ support-files =
   addons/browser_dragdrop1.xpi
   addons/browser_dragdrop2.xpi
   addons/browser_dragdrop_incompat.xpi
   addons/browser_installssl.xpi
   addons/browser_theme.xpi
   addons/options_signed.xpi
   addons/options_signed/*
   addon_prefs.xul
+  discovery/api_response.json
+  discovery/small-1x1.png
   discovery.html
   head.js
   more_options.xul
   options.xul
   plugin_test.html
   redirect.sjs
   releaseNotes.xhtml
   blockNoPlugins.xml
@@ -72,16 +74,18 @@ skip-if = os == "linux" && !debug # Bug 
 [browser_extension_sideloading_permission.js]
 [browser_file_xpi_no_process_switch.js]
 skip-if = true # Bug 1449071 - Frequent failures
 [browser_globalwarnings.js]
 [browser_gmpProvider.js]
 skip-if = os == 'linux' && !debug # Bug 1398766
 [browser_html_abuse_report.js]
 [browser_html_detail_view.js]
+[browser_html_discover_view.js]
+[browser_html_discover_view_clientid.js]
 [browser_html_list_view.js]
 [browser_html_message_bar.js]
 [browser_html_plugins.js]
 skip-if = (os == 'win' && processor == 'aarch64') # aarch64 has no plugin support, bug 1525174 and 1547495
 [browser_html_recent_updates.js]
 [browser_html_updates.js]
 [browser_inlinesettings_browser.js]
 skip-if = os == 'mac' || os == 'linux' # Bug 1483347
--- a/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
@@ -77,17 +77,17 @@ add_task(async function enableHtmlViews(
     id: "addon1@mochi.test",
     name: "Test add-on 1",
     creator: {name: "The creator", url: "http://example.com/me"},
     version: "3.1",
     description: "Short description",
     fullDescription: "Longer description\nWith brs!",
     type: "extension",
     contributionURL: "http://foo.com",
-    averageRating: 4.3,
+    averageRating: 4.279,
     reviewCount: 5,
     reviewURL: "http://example.com/reviews",
     homepageURL: "http://example.com/addon1",
     updateDate: new Date("2019-03-07T01:00:00"),
     applyBackgroundUpdates: AddonManager.AUTOUPDATE_ENABLE,
   }, {
     id: "addon2@mochi.test",
     name: "Test add-on 2",
@@ -334,25 +334,49 @@ add_task(async function testFullDetails(
   link = row.querySelector("a");
   checkLink(link, "http://example.com/addon1");
 
   // Reviews.
   row = rows.shift();
   checkLabel(row, "rating");
   let rating = row.lastElementChild;
   ok(rating.classList.contains("addon-detail-rating"), "Found the rating el");
-  let stars = Array.from(rating.querySelectorAll(".addon-detail-rating-star"));
+  let starsElem = rating.querySelector("five-star-rating");
+  is(starsElem.rating, 4.279, "Exact rating used for calculations");
+  let stars = Array.from(starsElem.shadowRoot.querySelectorAll(".rating-star"));
   let fullAttrs = stars.map(star => star.getAttribute("fill")).join(",");
   is(fullAttrs, "full,full,full,full,half", "Four and a half stars are full");
   link = rating.querySelector("a");
   checkLink(link, "http://example.com/reviews", {
     id: "addon-detail-reviews-link",
     args: {numberOfReviews: 5},
   });
 
+  // While we are here, let's test edge cases of star ratings.
+  async function testRating(rating, ratingRounded, expectation) {
+    starsElem.rating = rating;
+    await starsElem.ownerDocument.l10n.translateElements([starsElem]);
+    is(starsElem.ratingBuckets.join(","), expectation,
+       `Rendering of rating ${rating}`);
+
+    is(starsElem.title, `Rated ${ratingRounded} out of 5`,
+       "Rendered title must contain at most one fractional digit");
+  }
+  await testRating(0.000, "0", "empty,empty,empty,empty,empty");
+  await testRating(0.123, "0.1", "empty,empty,empty,empty,empty");
+  await testRating(0.249, "0.2", "empty,empty,empty,empty,empty");
+  await testRating(0.250, "0.3", "half,empty,empty,empty,empty");
+  await testRating(0.749, "0.7", "half,empty,empty,empty,empty");
+  await testRating(0.750, "0.8", "full,empty,empty,empty,empty");
+  await testRating(1.000, "1", "full,empty,empty,empty,empty");
+  await testRating(4.249, "4.2", "full,full,full,full,empty");
+  await testRating(4.250, "4.3", "full,full,full,full,half");
+  await testRating(4.749, "4.7", "full,full,full,full,half");
+  await testRating(5.000, "5", "full,full,full,full,full");
+
   // That should've been all the rows.
   is(rows.length, 0, "There are no more rows left");
 
   await closeView(win);
 });
 
 add_task(async function testMinimalExtension() {
   let win = await loadInitialView("extension");
@@ -448,32 +472,32 @@ add_task(async function testStaticTheme(
   let card = getAddonCard(doc, "theme1@mochi.test");
   ok(!card.hasAttribute("expanded"), "The list card is not expanded");
 
   // Make sure the preview is set.
   let preview = card.querySelector(".card-heading-image");
   ok(preview, "There is a preview");
   is(preview.src, "http://example.com/preview.png", "The preview URL is set");
   is(preview.width, "664", "The width is set");
-  is(preview.height, "89", "The height is set");
+  is(preview.height, "90", "The height is set");
   is(preview.hidden, false, "The preview is visible");
 
   // Load the detail view.
   let loaded = waitForViewLoad(win);
   card.querySelector('[action="expand"]').click();
   await loaded;
 
   card = getAddonCard(doc, "theme1@mochi.test");
 
   // Make sure the preview is still set.
   preview = card.querySelector(".card-heading-image");
   ok(preview, "There is a preview");
   is(preview.src, "http://example.com/preview.png", "The preview URL is set");
   is(preview.width, "664", "The width is set");
-  is(preview.height, "89", "The height is set");
+  is(preview.height, "90", "The height is set");
   is(preview.hidden, false, "The preview is visible");
 
   let rows = Array.from(card.querySelectorAll(".addon-detail-row"));
 
   // Automatic updates.
   let row = rows.shift();
   checkLabel(row, "updates");
 
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
@@ -0,0 +1,566 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const {
+  AddonTestUtils,
+} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+
+const {
+  ExtensionUtils: {
+    promiseEvent,
+    promiseObserved,
+  },
+}  = ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+
+// The response to the discovery API, as documented at:
+// https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+//
+// The test is designed to easily verify whether the discopane works with the
+// latest AMO API, by replacing API_RESPONSE_FILE's content with latest AMO API
+// response, e.g. from https://addons.allizom.org/api/v4/discovery/?lang=en-US
+// The response must contain at least one theme, and one extension.
+const API_RESPONSE_FILE = RELATIVE_DIR + "discovery/api_response.json";
+
+const AMO_TEST_HOST = "rewritten-for-testing.addons.allizom.org";
+
+const ArrayBufferInputStream =
+  Components.Constructor("@mozilla.org/io/arraybuffer-input-stream;1",
+                         "nsIArrayBufferInputStream", "setData");
+
+AddonTestUtils.initMochitest(this);
+
+const amoServer = AddonTestUtils.createHttpServer({hosts: [AMO_TEST_HOST]});
+
+amoServer.registerFile("/png",
+  FileUtils.getFile("CurWorkD",
+                    `${RELATIVE_DIR}discovery/small-1x1.png`.split("/")));
+
+// `result` is an element in the `results` array from AMO's discovery API,
+// stored in API_RESPONSE_FILE.
+function getTestExpectationFromApiResult(result) {
+  return {
+    typeIsTheme: result.addon.type === "statictheme",
+    addonName: result.addon.name,
+    authorName: result.addon.authors[0].name,
+    editorialHead: result.heading_text,
+    editorialBody: result.description_text,
+    dailyUsers: result.addon.average_daily_users,
+    rating: result.addon.ratings.average,
+  };
+}
+
+// Read the content of API_RESPONSE_FILE, and replaces any embedded URLs with
+// URLs that point to the `amoServer` test server.
+async function readAPIResponseFixture() {
+  let apiText = await OS.File.read(API_RESPONSE_FILE, {encoding: "utf-8"});
+  apiText = apiText.replace(/\bhttps?:\/\/[^"]+(?=")/g, (url) => {
+    try {
+      url = new URL(url);
+    } catch (e) {
+      // Responses may contain "http://*/*"; ignore it.
+      return url;
+    }
+    // In this test, we only need to distinguish between different file types,
+    // so just use the file extension as path name for amoServer.
+    let ext = url.pathname.split(".").pop();
+    return `http://${AMO_TEST_HOST}/${ext}?${url.pathname}${url.search}`;
+  });
+
+  return apiText;
+}
+
+// A helper to declare a response to discovery API requests.
+class DiscoveryAPIHandler {
+  constructor(responseText) {
+    this.setResponseText(responseText);
+    this.requestCount = 0;
+
+    // Overwrite the previous discovery response handler.
+    amoServer.registerPathHandler("/discoapi", this);
+  }
+
+  setResponseText(responseText) {
+    this.responseBody = new TextEncoder().encode(responseText).buffer;
+  }
+
+  // Suspend discovery API requests until unblockResponses is called.
+  blockNextResponses() {
+    this._unblockPromise = new Promise(resolve => {
+      this.unblockResponses = resolve;
+    });
+  }
+
+  unblockResponses(responseText) {
+    throw new Error("You need to call blockNextResponses first!");
+  }
+
+  // nsIHttpRequestHandler::handle
+  async handle(request, response) {
+    ++this.requestCount;
+
+    response.setHeader("Cache-Control", "no-cache", false);
+    response.processAsync();
+    await this._unblockPromise;
+
+    let body = this.responseBody;
+    let binStream = new ArrayBufferInputStream(body, 0, body.byteLength);
+    response.bodyOutputStream.writeFrom(binStream, body.byteLength);
+    response.finish();
+  }
+}
+
+// Retrieve the list of visible action elements inside a document or container.
+function getVisibleActions(documentOrElement) {
+  return Array.from(documentOrElement.querySelectorAll("[action]"))
+    .filter(elem => elem.offsetWidth && elem.offsetHeight);
+}
+
+function getActionName(actionElement) {
+  return actionElement.getAttribute("action");
+}
+
+function getDiscoveryElement(win) {
+  return win.document.querySelector("discovery-pane");
+}
+
+function getCardContainer(win) {
+  return getDiscoveryElement(win).querySelector("recommended-addon-list");
+}
+
+function getCardByAddonId(win, addonId) {
+  for (let card of win.document.querySelectorAll("recommended-addon-card")) {
+    if (card.addonId === addonId) {
+      return card;
+    }
+  }
+  return null;
+}
+
+// Wait until the current `<discovery-pane>` element has finished loading its
+// cards. This can be used after the cards have been loaded.
+function promiseDiscopaneUpdate(win) {
+  let {cardsReady} = getCardContainer(win);
+  ok(cardsReady, "Discovery cards should have started to initialize");
+  return cardsReady;
+}
+
+// Switch to a different view so we can switch back to the discopane later.
+async function switchToNonDiscoView(win) {
+  // Listeners registered while the discopane was the active view continue to be
+  // active when the view switches to the extensions list, because both views
+  // share the same document.
+  win.managerWindow.gViewController.loadView("addons://list/extensions");
+  await wait_for_view_load(win.managerWindow);
+  ok(win.document.querySelector("addon-list"),
+     "Should be at the extension list view");
+}
+
+// Switch to the discopane and wait until it has fully rendered, including any
+// cards from the discovery API.
+async function switchToDiscoView(win) {
+  is(getDiscoveryElement(win), null,
+     "Cannot switch to discopane when the discopane is already shown");
+  win.managerWindow.gViewController.loadView("addons://discover/");
+  await wait_for_view_load(win.managerWindow);
+  await promiseDiscopaneUpdate(win);
+}
+
+// Wait until all images in the DOM have successfully loaded.
+// There must be at least one `<img>` in the document.
+// Returns the number of loaded images.
+async function waitForAllImagesLoaded(win) {
+  let imgs = Array.from(win.document.querySelectorAll("img[src]"));
+  function areAllImagesLoaded() {
+    let loadCount = imgs.filter(img => img.naturalWidth).length;
+    info(`Loaded ${loadCount} out of ${imgs.length} images`);
+    return loadCount === imgs.length;
+  }
+  if (!areAllImagesLoaded()) {
+    await promiseEvent(win.document, "load", true, areAllImagesLoaded);
+  }
+  return imgs.length;
+}
+
+// A helper that waits until an installation has been requested from `amoServer`
+// and proceeds with approving the installation.
+async function promiseAddonInstall(amoServer, extensionData) {
+  let description = extensionData.manifest.description;
+  let xpiFile = AddonTestUtils.createTempWebExtensionFile(extensionData);
+  amoServer.registerFile("/xpi", xpiFile);
+
+  let addonId = extensionData.manifest.applications.gecko.id;
+  let installedPromise =
+    waitAppMenuNotificationShown("addon-installed", addonId, true);
+
+  if (!extensionData.manifest.theme) {
+    info(`${description}: Waiting for permission prompt`);
+    // Extensions have install prompts.
+    let panel = await promisePopupNotificationShown("addon-webext-permissions");
+    panel.button.click();
+  } else {
+    info(`${description}: Waiting for install prompt`);
+    let panel =
+      await promisePopupNotificationShown("addon-install-confirmation");
+    panel.button.click();
+  }
+
+  info("Waiting for post-install doorhanger");
+  await installedPromise;
+
+  let addon = await AddonManager.getAddonByID(addonId);
+  Assert.deepEqual(addon.installTelemetryInfo, {
+    // This is the expected source because before the HTML-based discopane,
+    // "disco" was already used to mark installs from the AMO-hosted discopane.
+    source: "disco",
+  }, "The installed add-on should have the expected telemetry info");
+}
+
+// Install an add-on by clicking on the card.
+// The promise resolves once the card has been updated.
+async function testCardInstall(card) {
+  Assert.deepEqual(
+    getVisibleActions(card).map(getActionName),
+    ["install-addon"],
+    "Should have an Install button before install");
+
+  let installButton =
+    card.querySelector("[data-l10n-id='install-extension-button']") ||
+    card.querySelector("[data-l10n-id='install-theme-button']");
+
+  let updatePromise = promiseEvent(card, "disco-card-updated");
+  installButton.click();
+  await updatePromise;
+
+  Assert.deepEqual(
+    getVisibleActions(card).map(getActionName),
+    ["manage-addon"],
+    "Should have a Manage button after install");
+}
+
+// Uninstall the add-on (not via the card, since it has no uninstall button).
+// The promise resolves once the card has been updated.
+async function testAddonUninstall(card) {
+  Assert.deepEqual(
+    getVisibleActions(card).map(getActionName),
+    ["manage-addon"],
+    "Should have a Manage button before uninstall");
+
+  let addon = await AddonManager.getAddonByID(card.addonId);
+
+  let updatePromise = promiseEvent(card, "disco-card-updated");
+  await addon.uninstall();
+  await updatePromise;
+
+  Assert.deepEqual(
+    getVisibleActions(card).map(getActionName),
+    ["install-addon"],
+    "Should have an Install button after uninstall");
+}
+
+add_task(async function setup() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      ["extensions.getAddons.discovery.api_url",
+       `http://${AMO_TEST_HOST}/discoapi`],
+      // Enable HTML for all because some tests load non-discopane views.
+      ["extensions.htmlaboutaddons.enabled", true],
+      ["extensions.htmlaboutaddons.discover.enabled", true],
+      // Disable the telemetry client ID (and its associated UI warning).
+      // browser_html_discover_view_clientid.js covers this functionality.
+      ["browser.discovery.enabled", false],
+    ],
+  });
+});
+
+// Test that the discopane can be loaded and that meaningful results are shown.
+// This relies on response data from the AMO API, stored in API_RESPONSE_FILE.
+add_task(async function discopane_with_real_api_data() {
+  const apiText = await readAPIResponseFixture();
+  let apiHandler = new DiscoveryAPIHandler(apiText);
+
+  const apiResultArray = JSON.parse(apiText).results;
+  ok(apiResultArray.length, `Mock has ${Array.length} results`);
+
+  apiHandler.blockNextResponses();
+  let win = await loadInitialView("discover");
+
+  Assert.deepEqual(
+    getVisibleActions(win.document).map(getActionName),
+    [],
+    "The AMO button should be invisible when the AMO API hasn't responded");
+
+  apiHandler.unblockResponses();
+  await promiseDiscopaneUpdate(win);
+
+  let actionElements = getVisibleActions(win.document);
+  Assert.deepEqual(
+    actionElements.map(getActionName),
+    [
+      // Expecting an install button for every result.
+      ...new Array(apiResultArray.length).fill("install-addon"),
+      "open-amo",
+    ],
+    "All add-on cards should be rendered, with AMO button at the end.");
+
+  let imgCount = await waitForAllImagesLoaded(win);
+  is(imgCount, apiResultArray.length, "Expected an image for every result");
+
+  // Check that the cards have the expected content.
+  let cards =
+    Array.from(win.document.querySelectorAll("recommended-addon-card"));
+  is(cards.length, apiResultArray.length, "Every API result has a card");
+  for (let [i, card] of cards.entries()) {
+    let expectations = getTestExpectationFromApiResult(apiResultArray[i]);
+    info(`Expectations for card ${i}: ${JSON.stringify(expectations)}`);
+
+    let checkContent = (selector, expectation) => {
+      let text = card.querySelector(selector).textContent;
+      is(text, expectation, `Content of selector "${selector}"`);
+    };
+    checkContent(".disco-addon-name", expectations.addonName);
+    await win.document.l10n.translateFragment(card);
+    checkContent(".disco-addon-author [data-l10n-name='author']",
+                 expectations.authorName);
+
+    let amoListingLink = card.querySelector(".disco-addon-author a");
+    ok(amoListingLink.search.includes("utm_source=firefox-browser"),
+       `Listing link should have attribution parameter, url=${amoListingLink}`);
+
+    let actions = getVisibleActions(card);
+    is(actions.length, 1, "Card should only have one install button");
+    let installButton = actions[0];
+    if (expectations.typeIsTheme) {
+      // Theme button + screenshot
+      ok(installButton.matches("[data-l10n-id='install-theme-button'"),
+         "Has theme install button");
+      ok(card.querySelector(".card-heading-image").offsetWidth,
+         "Preview image must be visible");
+    } else {
+      // Extension button + extended description.
+      ok(installButton.matches("[data-l10n-id='install-extension-button'"),
+         "Has extension install button");
+      checkContent(".disco-description-intro", expectations.editorialHead);
+      checkContent(".disco-description-main", expectations.editorialBody);
+
+      let ratingElem = card.querySelector("five-star-rating");
+      if (expectations.rating) {
+        is(ratingElem.rating, expectations.rating, "Expected rating value");
+        ok(ratingElem.offsetWidth, "Rating element is visible");
+      } else {
+        is(ratingElem.offsetWidth, 0, "Rating element is not visible");
+      }
+
+      let userCountElem = card.querySelector(".disco-user-count");
+      if (expectations.dailyUsers) {
+        Assert.deepEqual(
+          win.document.l10n.getAttributes(userCountElem),
+          {id: "user-count", args: {dailyUsers: expectations.dailyUsers}},
+          "Card count should be rendered");
+      } else {
+        is(userCountElem.offsetWidth, 0, "User count element is not visible");
+      }
+    }
+  }
+
+  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+  await closeView(win);
+});
+
+// Test whether extensions and themes can be installed from the discopane.
+// Also checks that items in the list do not change position after installation,
+// and that they are shown at the bottom of the list when the discopane is
+// reopened.
+add_task(async function install_from_discopane() {
+  const apiText = await readAPIResponseFixture();
+  const apiResultArray = JSON.parse(apiText).results;
+  let getAddonIdByAMOAddonType =
+    type => apiResultArray.find(r => r.addon.type === type).addon.guid;
+  const FIRST_EXTENSION_ID = getAddonIdByAMOAddonType("extension");
+  const FIRST_THEME_ID = getAddonIdByAMOAddonType("statictheme");
+
+  let apiHandler = new DiscoveryAPIHandler(apiText);
+
+  let win = await loadInitialView("discover");
+  await promiseDiscopaneUpdate(win);
+  await waitForAllImagesLoaded(win);
+
+  // Test extension install.
+  let installExtensionPromise = promiseAddonInstall(amoServer, {
+    manifest: {
+      name: "My Awesome Add-on",
+      description: "Test extension install button",
+      applications: {gecko: {id: FIRST_EXTENSION_ID}},
+      permissions: ["<all_urls>"],
+    },
+  });
+  await testCardInstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
+  await installExtensionPromise;
+
+  // Test theme install.
+  let installThemePromise = promiseAddonInstall(amoServer, {
+    manifest: {
+      name: "My Fancy Theme",
+      description: "Test theme install button",
+      applications: {gecko: {id: FIRST_THEME_ID}},
+      theme: {
+        colors: {
+          tab_selected: "red",
+        },
+      },
+    },
+  });
+  let promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
+  await testCardInstall(getCardByAddonId(win, FIRST_THEME_ID));
+  await installThemePromise;
+  await promiseThemeChange;
+
+  // After installing, the cards should have manage buttons instead of install
+  // buttons. The cards should still be at the top of the pane (and not be
+  // moved to the bottom).
+  Assert.deepEqual(
+    getVisibleActions(win.document).map(getActionName),
+    [
+      "manage-addon",
+      "manage-addon",
+      ...new Array(apiResultArray.length - 2).fill("install-addon"),
+      "open-amo",
+    ],
+    "The Install buttons should be replaced with Manage buttons");
+
+  // End of the testing installation from a card.
+  // Now we are going to force an updated rendering and check that the cards are
+  // in the expected order, and then test uninstallation of the above add-ons.
+
+  // Force the pane to render again.
+  await switchToNonDiscoView(win);
+  await switchToDiscoView(win);
+  await waitForAllImagesLoaded(win);
+
+  Assert.deepEqual(
+    getVisibleActions(win.document).map(getActionName),
+    [
+      ...new Array(apiResultArray.length - 2).fill("install-addon"),
+      "manage-addon",
+      "manage-addon",
+      "open-amo",
+    ],
+    "Already-installed add-ons should be rendered at the end of the list");
+
+  promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
+  await testAddonUninstall(getCardByAddonId(win, FIRST_THEME_ID));
+  await promiseThemeChange;
+  await testAddonUninstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
+
+  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+  await closeView(win);
+});
+
+// Tests that the page is able to switch views while the discopane is loading,
+// without inadvertently replacing the page when the request finishes.
+add_task(async function discopane_navigate_while_loading() {
+  let apiHandler = new DiscoveryAPIHandler(`{"results": []}`);
+
+  apiHandler.blockNextResponses();
+  let win = await loadInitialView("discover");
+
+  let updatePromise = promiseDiscopaneUpdate(win);
+  let didUpdateDiscopane = false;
+  updatePromise.then(() => { didUpdateDiscopane = true; });
+
+  // Switch views while the request is pending.
+  await switchToNonDiscoView(win);
+
+  is(didUpdateDiscopane, false,
+     "discopane should still not be updated because the request is blocked");
+  is(getDiscoveryElement(win), null,
+     "Discopane should be removed after switching to the extension list");
+
+  // Release pending requests, to verify that completing the request will not
+  // cause changes to the visible view. The updatePromise will still resolve
+  // though, because the event is dispatched to the removed `<discovery-pane>`.
+  apiHandler.unblockResponses();
+
+  await updatePromise;
+  ok(win.document.querySelector("addon-list"),
+     "Should still be at the extension list view");
+  is(getDiscoveryElement(win), null,
+     "Discopane should not be in the document when it is not the active view");
+
+  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+  await closeView(win);
+});
+
+// Tests that invalid responses are handled correctly and not cached.
+// Also verifies that the response is cached as long as the page is active,
+// but not when the page is fully reloaded.
+add_task(async function discopane_cache_api_responses() {
+  const INVALID_RESPONSE_BODY = `{"This is some": invalid} JSON`;
+  let apiHandler = new DiscoveryAPIHandler(INVALID_RESPONSE_BODY);
+
+  let expectedErrMsg;
+  try {
+    JSON.parse(INVALID_RESPONSE_BODY);
+    ok(false, "JSON.parse should have thrown");
+  } catch (e) {
+    expectedErrMsg = e.message;
+  }
+
+  let invalidResponseHandledPromise = new Promise(resolve => {
+    Services.console.registerListener(function listener(msg) {
+      if (msg.message.includes(expectedErrMsg)) {
+        resolve();
+        Services.console.unregisterListener(listener);
+      }
+    });
+  });
+
+  let win = await loadInitialView("discover"); // Request #1
+  await promiseDiscopaneUpdate(win);
+
+  info("Waiting for expected error");
+  await invalidResponseHandledPromise;
+  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+  Assert.deepEqual(
+    getVisibleActions(win.document).map(getActionName),
+    ["open-amo"],
+    "The AMO button should be visible even when the response was invalid");
+
+  // Change to a valid response, so that the next response will be cached.
+  apiHandler.setResponseText(`{"results": []}`);
+
+  await switchToNonDiscoView(win);
+  await switchToDiscoView(win); // Request #2
+
+  is(apiHandler.requestCount, 2,
+     "Should fetch new data because an invalid response should not be cached");
+
+  await switchToNonDiscoView(win);
+  await switchToDiscoView(win);
+  await closeView(win);
+
+  is(apiHandler.requestCount, 2,
+     "The previous response was valid and should have been reused");
+
+  // Now open a new about:addons page and verify that a new API request is sent.
+  let anotherWin = await loadInitialView("discover");
+  await promiseDiscopaneUpdate(anotherWin);
+  await closeView(anotherWin);
+
+  is(apiHandler.requestCount, 3, "discovery API should be requested again");
+});
+
+add_task(async function discopane_no_cookies() {
+  let requestPromise = new Promise(resolve => {
+    amoServer.registerPathHandler("/discoapi", resolve);
+  });
+  Services.cookies.add(AMO_TEST_HOST, "/", "name", "value", false, false,
+    false, Date.now() / 1000 + 600, {}, Ci.nsICookie2.SAMESITE_UNSET);
+  let win = await loadInitialView("discover");
+  let request = await requestPromise;
+  ok(!request.hasHeader("Cookie"), "discovery API should not receive cookies");
+  await closeView(win);
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
@@ -0,0 +1,124 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const {ClientID} = ChromeUtils.import("resource://gre/modules/ClientID.jsm");
+
+const {
+  AddonTestUtils,
+} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+
+AddonTestUtils.initMochitest(this);
+const server = AddonTestUtils.createHttpServer();
+const serverBaseUrl = `http://localhost:${server.identity.primaryPort}/`;
+server.registerPathHandler("/sumo/personalized-extension-recommendations",
+  (request, response) => {
+    response.write("This is a SUMO page that explains personalized add-ons.");
+  });
+
+// Before a discovery API request is triggered, this method should be called.
+// Resolves with the value of the "telemetry-client-id" query parameter.
+async function promiseOneDiscoveryApiRequest() {
+  return new Promise(resolve => {
+    let requestCount = 0;
+    // Overwrite previous request handler, if any.
+    server.registerPathHandler("/discoapi", (request, response) => {
+      is(++requestCount, 1, "Expecting one discovery API request");
+      response.write(`{"results": []}`);
+      let searchParams = new URLSearchParams(request.queryString);
+      let clientId = searchParams.get("telemetry-client-id");
+      resolve(clientId);
+    });
+  });
+}
+
+function getNoticeButton(win) {
+  return win.document.querySelector("[action='notice-learn-more']");
+}
+
+function isNoticeVisible(win) {
+  return getNoticeButton(win).closest("message-bar").offsetHeight > 0;
+}
+
+add_task(async function setup() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      // Enable clientid - see Discovery.jsm for the first two prefs.
+      ["browser.discovery.enabled", true],
+      ["datareporting.healthreport.uploadEnabled", true],
+      ["extensions.getAddons.discovery.api_url", `${serverBaseUrl}discoapi`],
+      ["app.support.baseURL", `${serverBaseUrl}sumo/`],
+      ["extensions.htmlaboutaddons.discover.enabled", true],
+    ],
+  });
+});
+
+// Test that the clientid is passed to the API when enabled via prefs.
+add_task(async function clientid_enabled() {
+  let EXPECTED_CLIENT_ID = await ClientID.getClientIdHash();
+  ok(EXPECTED_CLIENT_ID, "ClientID should be available");
+
+  let requestPromise = promiseOneDiscoveryApiRequest();
+  let win = await loadInitialView("discover");
+
+  ok(isNoticeVisible(win), "Notice about personalization should be visible");
+
+  // TODO: This should ideally check whether the result is the expected ID.
+  // But run with --verify, the test may fail with EXPECTED_CLIENT_ID being
+  // "baae8d197cf6b0865d7ba7ddf83829cd2d9844374d7271a5c704199d91059316",
+  // which is sha256(TelemetryUtils.knownClientId).
+  // This happens because at the end of the test, the pushPrefEnv from setup is
+  // reverted, which resets datareporting.healthreport.uploadEnabled to false.
+  // When TelemetryController.jsm detects this, it asynchronously resets the
+  // ClientID to knownClientId - which may happen at the next run of the test.
+  // TODO: Fix this together with bug 1537933
+  //
+  // is(await requestPromise, EXPECTED_CLIENT_ID,
+  ok(await requestPromise,
+     "Moz-Client-Id should be set when telemetry & discovery are enabled");
+
+  let tabbrowser = win.windowRoot.ownerGlobal.gBrowser;
+  let expectedUrl =
+    `${serverBaseUrl}sumo/personalized-extension-recommendations`;
+  let tabPromise = BrowserTestUtils.waitForNewTab(tabbrowser, expectedUrl);
+
+  getNoticeButton(win).click();
+
+  info(`Waiting for new tab with URL: ${expectedUrl}`);
+  let tab = await tabPromise;
+  BrowserTestUtils.removeTab(tab);
+
+  await closeView(win);
+});
+
+// Test that the clientid is not sent when disabled via prefs.
+add_task(async function clientid_disabled() {
+  // Temporarily override the prefs that we had set in setup.
+  await SpecialPowers.pushPrefEnv({
+    set: [["browser.discovery.enabled", false]],
+  });
+  let requestPromise = promiseOneDiscoveryApiRequest();
+  let win = await loadInitialView("discover");
+  ok(!isNoticeVisible(win), "Notice about personalization should be hidden");
+  is(await requestPromise, null,
+     "Moz-Client-Id should not be sent when discovery is disabled");
+  await closeView(win);
+  await SpecialPowers.popPrefEnv();
+});
+
+// Test that the clientid is not sent from private windows.
+add_task(async function clientid_from_private_window() {
+  let privateWindow =
+    await BrowserTestUtils.openNewBrowserWindow({private: true});
+
+  let requestPromise = promiseOneDiscoveryApiRequest();
+  let managerWindow =
+    await open_manager("addons://discover/", null, null, null, privateWindow);
+  ok(PrivateBrowsingUtils.isContentWindowPrivate(managerWindow),
+     "Addon-manager is in a private window");
+
+  is(await requestPromise, null,
+     "Moz-Client-Id should not be sent in private windows");
+
+  await close_manager(managerWindow);
+  await BrowserTestUtils.closeWindow(privateWindow);
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/discovery/api_response.json
@@ -0,0 +1,797 @@
+{
+   "results" : [
+      {
+         "heading_text" : "Tigers Matter ** DON'T DELTE ME**",
+         "description_text" : "",
+         "addon" : {
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "{e0d2e13b-2e07-49d5-9574-eb0227482320}",
+            "authors" : [
+               {
+                  "id" : 7804538,
+                  "name" : "Sondergaard",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/7/7804/7804538.png?modified=1392125542",
+                  "username" : "EatingStick",
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/7804538/"
+               }
+            ],
+            "previews" : [
+               {
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183758.png?modified=1555593109",
+                  "image_size" : [
+                     680,
+                     92
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183758.png?modified=1555593109",
+                  "id" : 183758,
+                  "thumbnail_size" : [
+                     473,
+                     64
+                  ],
+                  "caption" : null
+               },
+               {
+                  "id" : 183768,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183768.png?modified=1555593111",
+                  "image_size" : [
+                     760,
+                     92
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183768.png?modified=1555593111",
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     529,
+                     64
+                  ]
+               },
+               {
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183777.png?modified=1555593112",
+                  "id" : 183777,
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183777.png?modified=1555593112",
+                  "image_size" : [
+                     720,
+                     92
+                  ],
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     501,
+                     64
+                  ]
+               }
+            ],
+            "name" : "Tigers Matter ** DON'T DELTE ME**",
+            "id" : 496012,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/tigers-matter/",
+            "type" : "statictheme",
+            "ratings" : {
+               "average" : 4.7636,
+               "text_count" : 55,
+               "count" : 55,
+               "bayesian_average" : 4.75672
+            },
+            "slug" : "tigers-matter",
+            "average_daily_users" : 1,
+            "current_version" : {
+               "compatibility" : {
+                  "firefox" : {
+                     "max" : "*",
+                     "min" : "53.0"
+                  },
+                  "android" : {
+                     "max" : "*",
+                     "min" : "65.0"
+                  }
+               },
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1655900,
+               "files" : [
+                  {
+                     "is_restart_required" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/376561/tigers_matter_dont_delte_me-2.0-an+fx.xpi?src=",
+                     "created" : "2019-04-18T13:11:48Z",
+                     "size" : 86337,
+                     "status" : "public",
+                     "is_webextension" : true,
+                     "is_mozilla_signed_extension" : false,
+                     "permissions" : [],
+                     "hash" : "sha256:ebeb6e4f40ceafbc4affc5bc9a182ed44ae410d71b8c5f9c547f8d45863e0c37",
+                     "platform" : "all",
+                     "id" : 376561
+                  }
+               ]
+            }
+         },
+         "description" : "",
+         "is_recommendation" : false,
+         "heading" : "Tigers Matter ** DON&#39;T DELTE ME** <span>by <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/tigers-matter/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Sondergaard</a></span>"
+      },
+      {
+         "heading" : "Customize new tab pages <span> with <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/awesome-screenshot-plus-/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Awesome Screenshot Plus - Capture, Annotate &amp; More by Diigo Inc.</a> </span>",
+         "is_recommendation" : false,
+         "addon" : {
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/awesome-screenshot-plus-/",
+            "type" : "extension",
+            "ratings" : {
+               "count" : 848,
+               "bayesian_average" : 3.87925,
+               "average" : 3.8797,
+               "text_count" : 842
+            },
+            "slug" : "awesome-screenshot-plus-",
+            "average_daily_users" : 1,
+            "current_version" : {
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1532816,
+               "files" : [
+                  {
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/253549/awesome_screenshot_plus-7-an+fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "size" : 4196,
+                     "created" : "2017-09-01T13:31:17Z",
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "is_mozilla_signed_extension" : false,
+                     "permissions" : [],
+                     "hash" : "sha256:4cd8e9b7e89f61e6855d01c73c5c05920c1e0e91f3ae0f45adbb4bd9919f59d7",
+                     "platform" : "all",
+                     "id" : 253549
+                  }
+               ],
+               "compatibility" : {
+                  "android" : {
+                     "min" : "48.0",
+                     "max" : "*"
+                  },
+                  "firefox" : {
+                     "max" : "*",
+                     "min" : "48.0"
+                  }
+               }
+            },
+            "authors" : [
+               {
+                  "username" : "diigo-inc",
+                  "name" : "Diigo Inc.",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/0/6/6724.png?modified=1554393597",
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/6724/",
+                  "id" : 6724
+               }
+            ],
+            "icon_url" : "https://addons-dev-cdn.allizom.org/user-media/addon_icons/287/287841-64.png?modified=mcrushed",
+            "guid" : "jid0-GXjLLfbCoAx0LcltEdFrEkQdQPI@jetpack",
+            "previews" : [
+               {
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54638.png?modified=1543388383",
+                  "id" : 54638,
+                  "image_size" : [
+                     625,
+                     525
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54638.png?modified=1543388383",
+                  "caption" : "Capture and annotate a page",
+                  "thumbnail_size" : [
+                     571,
+                     480
+                  ]
+               },
+               {
+                  "caption" : "Crop selected area",
+                  "thumbnail_size" : [
+                     571,
+                     480
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54639.png?modified=1543388385",
+                  "image_size" : [
+                     625,
+                     525
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54639.png?modified=1543388385",
+                  "id" : 54639
+               },
+               {
+                  "caption" : "Save as a local file or upload to get a sharable link",
+                  "thumbnail_size" : [
+                     640,
+                     234
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54641.png?modified=1543388385",
+                  "image_size" : [
+                     700,
+                     256
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54641.png?modified=1543388385",
+                  "id" : 54641
+               }
+            ],
+            "name" : "Awesome Screenshot Plus - Capture, Annotate & More",
+            "id" : 287841
+         },
+         "description" : "<blockquote>Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines and text, blur sensitive info, one-click upload to share. And more! Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines</blockquote>",
+         "heading_text" : "Customize new tab pages  with Awesome Screenshot Plus - Capture, Annotate & More ",
+         "description_text" : "Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines and text, blur sensitive info, one-click upload to share. And more! Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines"
+      },
+      {
+         "heading_text" : "Perform better as an admin  with Admin Assistant ",
+         "description_text" : "Help Admins in their daily work",
+         "addon" : {
+            "slug" : "amo-admin-assistant-test",
+            "average_daily_users" : 0,
+            "current_version" : {
+               "files" : [
+                  {
+                     "is_restart_required" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/255370/amo_admin_assistant-4.2-fx.xpi?src=",
+                     "size" : 16016,
+                     "created" : "2018-08-21T16:49:21Z",
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "is_mozilla_signed_extension" : false,
+                     "permissions" : [
+                        "tabs",
+                        "https://addons-internal.prod.mozaws.net/*",
+                        "https://dxr.mozilla.org/addons/*"
+                     ],
+                     "hash" : "sha256:cd28c841a6daf8a2e3c94b0773b373fec0213404b70074309326cfc75e6725d3",
+                     "platform" : "all",
+                     "id" : 255370
+                  }
+               ],
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1534709,
+               "compatibility" : {
+                  "firefox" : {
+                     "min" : "45.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/amo-admin-assistant-test/",
+            "ratings" : {
+               "bayesian_average" : 0,
+               "count" : 0,
+               "text_count" : 0,
+               "average" : 0
+            },
+            "type" : "extension",
+            "id" : 496168,
+            "guid" : "aaa-test-icon@xulforge.com",
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "authors" : [
+               {
+                  "id" : 4230,
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/4230/",
+                  "username" : "jorge-villalobos",
+                  "name" : "Jorge Villalobos",
+                  "picture_url" : null
+               }
+            ],
+            "previews" : [],
+            "name" : "AMO Admin Assistant Test"
+         },
+         "description" : "<blockquote>Help Admins in their daily work</blockquote>",
+         "is_recommendation" : false,
+         "heading" : "Perform better as an admin <span> with <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/amo-admin-assistant-test/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Admin Assistant by Jorge Villalobos</a> </span>"
+      },
+      {
+         "addon" : {
+            "authors" : [
+               {
+                  "name" : "LexaDev",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10640/10640485.png?modified=1554812253",
+                  "username" : "LexaSV",
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/10640485/",
+                  "id" : 10640485
+               }
+            ],
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "{f9b9cdd3-91ae-476e-9c21-d5ecfce9889f}",
+            "previews" : [
+               {
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183694.png?modified=1555593096",
+                  "image_size" : [
+                     680,
+                     92
+                  ],
+                  "id" : 183694,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183694.png?modified=1555593096",
+                  "thumbnail_size" : [
+                     473,
+                     64
+                  ],
+                  "caption" : null
+               },
+               {
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183699.png?modified=1555593097",
+                  "id" : 183699,
+                  "image_size" : [
+                     760,
+                     92
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183699.png?modified=1555593097",
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     529,
+                     64
+                  ]
+               },
+               {
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183703.png?modified=1555593098",
+                  "image_size" : [
+                     720,
+                     92
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183703.png?modified=1555593098",
+                  "id" : 183703,
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     501,
+                     64
+                  ]
+               }
+            ],
+            "name" : "iarba",
+            "id" : 495969,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/iarba/",
+            "ratings" : {
+               "bayesian_average" : 4.86128,
+               "count" : 10,
+               "text_count" : 10,
+               "average" : 4.9
+            },
+            "type" : "statictheme",
+            "slug" : "iarba",
+            "current_version" : {
+               "files" : [
+                  {
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/376535/iarba-2.0-an+fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "size" : 895804,
+                     "created" : "2019-04-18T13:11:35Z",
+                     "is_mozilla_signed_extension" : false,
+                     "status" : "public",
+                     "is_webextension" : true,
+                     "id" : 376535,
+                     "permissions" : [],
+                     "platform" : "all",
+                     "hash" : "sha256:d7ecbdfa8ba56c5d08129c867e68b02ffc8c6000a7f7f85d85d2a558045babfa"
+                  }
+               ],
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1655874,
+               "compatibility" : {
+                  "android" : {
+                     "min" : "65.0",
+                     "max" : "*"
+                  },
+                  "firefox" : {
+                     "min" : "53.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "average_daily_users" : 1
+         },
+         "description" : "",
+         "heading_text" : "Custom heading  for a theme",
+         "description_text" : "",
+         "heading" : "Custom heading  for a theme",
+         "is_recommendation" : false
+      },
+      {
+         "description_text" : "Get international weather forecasts",
+         "heading_text" : "Have a nice day   with Forcastfox ",
+         "description" : "<blockquote>Get international weather forecasts</blockquote>",
+         "addon" : {
+            "id" : 502855,
+            "authors" : [
+               {
+                  "id" : 10641527,
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/10641527/",
+                  "name" : "Amoga-dev",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641527.png?modified=1555333028",
+                  "username" : "Amoga_dev_REST"
+               }
+            ],
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "forecastfox@s3_fix_version",
+            "previews" : [],
+            "name" : "Forecastfox (fix version)",
+            "slug" : "forecastfox-fix-version",
+            "current_version" : {
+               "id" : 1541667,
+               "is_strict_compatibility_enabled" : false,
+               "files" : [
+                  {
+                     "permissions" : [
+                        "activeTab",
+                        "tabs",
+                        "background",
+                        "storage",
+                        "webRequest",
+                        "webRequestBlocking",
+                        "<all_urls>",
+                        "http://www.s3blog.org/geolocation.html*",
+                        "https://embed.windy.com/embed2.html*"
+                     ],
+                     "platform" : "all",
+                     "hash" : "sha256:89e4de4ce86005c57b0197f671e86936aaf843ebd5751caae02cad4991ccbf0a",
+                     "id" : 262328,
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "is_mozilla_signed_extension" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262328/forecastfox_fix_version-4.20-an+fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "created" : "2019-01-16T07:54:26Z",
+                     "size" : 1331686
+                  }
+               ],
+               "compatibility" : {
+                  "android" : {
+                     "min" : "51.0",
+                     "max" : "*"
+                  },
+                  "firefox" : {
+                     "min" : "51.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "average_daily_users" : 0,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/forecastfox-fix-version/",
+            "type" : "extension",
+            "ratings" : {
+               "count" : 0,
+               "bayesian_average" : 0,
+               "average" : 0,
+               "text_count" : 0
+            }
+         },
+         "is_recommendation" : false,
+         "heading" : "Have a nice day  <span> with <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/forecastfox-fix-version/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Forcastfox by Amoga-dev</a> </span>"
+      },
+      {
+         "description_text" : "A test extension from webext-generator.",
+         "heading_text" : "...because cats are awesome  with Tabby Cat ",
+         "description" : "<blockquote>A test extension from webext-generator.</blockquote>",
+         "addon" : {
+            "name" : "tabby cat",
+            "previews" : [],
+            "guid" : "{1ed4b641-bac7-4492-b304-6ddc01f538ae}",
+            "icon_url" : "https://addons-dev-cdn.allizom.org/user-media/addon_icons/502/502774-64.png?modified=f289a992",
+            "authors" : [
+               {
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/10641572/",
+                  "username" : "AdminUserTestDev1",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641572.png?modified=1555675110",
+                  "name" : "úþÿ Ψ Φ ֎",
+                  "id" : 10641572
+               }
+            ],
+            "id" : 502774,
+            "ratings" : {
+               "bayesian_average" : 0,
+               "count" : 0,
+               "text_count" : 0,
+               "average" : 0
+            },
+            "type" : "extension",
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/tabby-catextension/",
+            "current_version" : {
+               "compatibility" : {
+                  "firefox" : {
+                     "max" : "*",
+                     "min" : "48.0"
+                  },
+                  "android" : {
+                     "max" : "*",
+                     "min" : "48.0"
+                  }
+               },
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1541570,
+               "files" : [
+                  {
+                     "created" : "2018-12-04T09:54:24Z",
+                     "size" : 4374,
+                     "is_restart_required" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262231/tabby_cat-1.0-an+fx.xpi?src=",
+                     "is_mozilla_signed_extension" : false,
+                     "status" : "public",
+                     "is_webextension" : true,
+                     "id" : 262231,
+                     "hash" : "sha256:f12c8a8b71e7d4c48e38db6b6a374ca8dcde42d6cb13fb1f2a8299bb51116615",
+                     "platform" : "all",
+                     "permissions" : []
+                  }
+               ]
+            },
+            "average_daily_users" : 1,
+            "slug" : "tabby-catextension"
+         },
+         "is_recommendation" : false,
+         "heading" : "...because cats are awesome <span> with <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/tabby-catextension/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Tabby Cat by úþÿ Ψ Φ ֎</a> </span>"
+      },
+      {
+         "addon" : {
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/the-moon-cat/",
+            "ratings" : {
+               "average" : 4.8182,
+               "text_count" : 11,
+               "count" : 11,
+               "bayesian_average" : 4.78325
+            },
+            "type" : "statictheme",
+            "slug" : "the-moon-cat",
+            "average_daily_users" : 2,
+            "current_version" : {
+               "files" : [
+                  {
+                     "is_mozilla_signed_extension" : false,
+                     "status" : "public",
+                     "is_webextension" : true,
+                     "id" : 262333,
+                     "permissions" : [],
+                     "hash" : "sha256:d159190add69c739b0fe07b19ad3ff48045c5ded502a8df0f892b8feb645c5ae",
+                     "platform" : "all",
+                     "is_restart_required" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262333/the_moon_cat-1.0-an+fx.xpi?src=",
+                     "size" : 102889,
+                     "created" : "2019-01-16T08:31:21Z"
+                  }
+               ],
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1541672,
+               "compatibility" : {
+                  "firefox" : {
+                     "max" : "*",
+                     "min" : "53.0"
+                  },
+                  "android" : {
+                     "min" : "65.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "authors" : [
+               {
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/5822165/",
+                  "username" : "Rallara",
+                  "name" : "Rallara",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/5/5822/5822165.png?modified=1391855104",
+                  "id" : 5822165
+               }
+            ],
+            "guid" : "{db4f6548-da04-43fb-a03e-249bf70ef5a1}",
+            "previews" : [
+               {
+                  "thumbnail_size" : [
+                     473,
+                     64
+                  ],
+                  "caption" : null,
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14307.png?modified=1547627485",
+                  "image_size" : [
+                     680,
+                     92
+                  ],
+                  "id" : 14307,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14307.png?modified=1547627485"
+               },
+               {
+                  "thumbnail_size" : [
+                     529,
+                     64
+                  ],
+                  "caption" : null,
+                  "id" : 14308,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14308.png?modified=1547627486",
+                  "image_size" : [
+                     760,
+                     92
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14308.png?modified=1547627486"
+               },
+               {
+                  "thumbnail_size" : [
+                     501,
+                     64
+                  ],
+                  "caption" : null,
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14309.png?modified=1547627487",
+                  "image_size" : [
+                     720,
+                     92
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14309.png?modified=1547627487",
+                  "id" : 14309
+               }
+            ],
+            "name" : "the Moon Cat",
+            "id" : 502859
+         },
+         "description" : "",
+         "heading_text" : "cool moon cat",
+         "description_text" : "",
+         "heading" : "cool moon cat <span>by <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/the-moon-cat/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Rallara</a></span>",
+         "is_recommendation" : false
+      },
+      {
+         "heading" : "Testptcustomheading",
+         "is_recommendation" : false,
+         "description" : "<blockquote>AAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGG</blockquote>",
+         "addon" : {
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "{2e5ff8c8-32fe-46d0-9fc8-6b8986621f3c}",
+            "authors" : [
+               {
+                  "id" : 10641570,
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/10641570/",
+                  "name" : "BobsDisplayName",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641570.png?modified=1536063975",
+                  "username" : "BobsUserName"
+               }
+            ],
+            "previews" : [],
+            "name" : "SI",
+            "id" : 495710,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/search_by_image/",
+            "ratings" : {
+               "average" : 3.8333,
+               "text_count" : 5,
+               "count" : 6,
+               "bayesian_average" : 3.77144
+            },
+            "type" : "extension",
+            "slug" : "search_by_image",
+            "current_version" : {
+               "files" : [
+                  {
+                     "id" : 262271,
+                     "permissions" : [
+                        "contextMenus",
+                        "storage",
+                        "tabs",
+                        "activeTab",
+                        "notifications",
+                        "webRequest",
+                        "webRequestBlocking",
+                        "<all_urls>",
+                        "http://*/*",
+                        "https://*/*",
+                        "ftp://*/*",
+                        "file:///*"
+                     ],
+                     "platform" : "all",
+                     "hash" : "sha256:f358b24d0b950f5acf035342dec64c99ee2e22a5cf369e7c787ebb00013127a8",
+                     "is_mozilla_signed_extension" : false,
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262271/search_by_image_reverse_image_search-1.12.6-fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "size" : 372225,
+                     "created" : "2018-12-14T13:48:23Z"
+                  }
+               ],
+               "id" : 1541610,
+               "is_strict_compatibility_enabled" : false,
+               "compatibility" : {
+                  "firefox" : {
+                     "min" : "57.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "average_daily_users" : 374
+         },
+         "description_text" : "AAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGG",
+         "heading_text" : "Testptcustomheading"
+      },
+      {
+         "description" : "",
+         "addon" : {
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "{f5e7a6ee-ebe0-4add-8f75-b5e4015feca1}",
+            "authors" : [
+               {
+                  "id" : 8733220,
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/8733220/",
+                  "username" : "michellet-2",
+                  "name" : "michellet",
+                  "picture_url" : null
+               }
+            ],
+            "previews" : [
+               {
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     473,
+                     64
+                  ],
+                  "id" : 14304,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14304.png?modified=1547627480",
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14304.png?modified=1547627480",
+                  "image_size" : [
+                     680,
+                     92
+                  ]
+               },
+               {
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14305.png?modified=1547627481",
+                  "image_size" : [
+                     760,
+                     92
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14305.png?modified=1547627481",
+                  "id" : 14305,
+                  "thumbnail_size" : [
+                     529,
+                     64
+                  ],
+                  "caption" : null
+               },
+               {
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     501,
+                     64
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14306.png?modified=1547627482",
+                  "id" : 14306,
+                  "image_size" : [
+                     720,
+                     92
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14306.png?modified=1547627482"
+               }
+            ],
+            "name" : "Purple Sparkles",
+            "id" : 502858,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/purple-sparkles/",
+            "type" : "statictheme",
+            "ratings" : {
+               "count" : 4,
+               "bayesian_average" : 4.1476,
+               "average" : 4.25,
+               "text_count" : 3
+            },
+            "slug" : "purple-sparkles",
+            "average_daily_users" : 445,
+            "current_version" : {
+               "compatibility" : {
+                  "firefox" : {
+                     "min" : "53.0",
+                     "max" : "*"
+                  },
+                  "android" : {
+                     "max" : "*",
+                     "min" : "65.0"
+                  }
+               },
+               "id" : 1541671,
+               "is_strict_compatibility_enabled" : false,
+               "files" : [
+                  {
+                     "created" : "2019-01-16T08:31:18Z",
+                     "size" : 237348,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262332/purple_sparkles-1.0-an+fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "is_mozilla_signed_extension" : false,
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "id" : 262332,
+                     "hash" : "sha256:5a3d311b7c1be2ee32446dbcf1422c5d7c786c5a237aa3d4e2939074ab50ad30",
+                     "platform" : "all",
+                     "permissions" : []
+                  }
+               ]
+            }
+         },
+         "description_text" : "",
+         "heading_text" : "Purple Sparkles",
+         "heading" : "Purple Sparkles <span>by <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/purple-sparkles/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">michellet</a></span>",
+         "is_recommendation" : false
+      }
+   ],
+   "count" : 9
+}
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..862d1dd10cc9f261e0b4df57902258a85b2e6f4b
GIT binary patch
literal 82
zc%17D@N?(olHy`uVBq!ia0vp^j3CU&3?x-=hn)ga%mF?jt_=SfOyUl#0CKrJT^vI=
bWRnwsY$gWA^CwK70$B{6u6{1-oD!M<RL&6Z
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/extensions/rating-star.svg
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
+  <!--
+       This image defines three versions of the star:
+       #full = star filled with full color
+       #half = half-filled star (full color at left, transparent color at right)
+       #empty = star filled with transparent color
+  -->
+
+  <!-- Default image: full star -->
+  <view id="full" viewBox="0 0 64 64" />
+  <view id="half" viewBox="0 64 64 64" />
+  <view id="empty" viewBox="0 128 64 64" />
+
+  <defs>
+    <g id="star-shape" fill="context-fill" transform="translate(-140.000000, -607.000000)" fill-opacity="context-fill-opacity">
+      <path d="M154.994575,670.99995 C153.704598,671.000763 152.477615,670.442079 151.630967,669.468394 C150.784319,668.49471 150.401158,667.201652 150.580582,665.923653 L153.046749,648.259919 L141.193762,635.514481 C140.080773,634.318044 139.711733,632.608076 140.232152,631.058811 C140.752571,629.509546 142.078939,628.369589 143.688275,628.088421 L160.214424,625.130961 L168.013827,609.468577 C168.767364,607.955994 170.3113,607 172.000594,607 C173.689888,607 175.233824,607.955994 175.98736,609.468577 L183.790813,625.130961 L200.329111,628.08437 C201.934946,628.371492 203.25546,629.513805 203.771316,631.062053 C204.287172,632.610301 203.915846,634.316807 202.803377,635.51043 L190.954439,648.26397 L193.420606,665.923653 C193.652457,667.578241 192.93975,669.223573 191.574418,670.185702 C190.209085,671.147831 188.420524,671.265104 186.941351,670.489485 L172.002619,662.698806 L157.047688,670.50569 C156.413201,670.833752 155.708782,671.003331 154.994575,670.99995 Z"></path>
+    </g>
+    <clipPath id="left-half">
+      <rect x="0" y="0" width="50%" height="100%" />
+    </clipPath>
+    <clipPath id="right-half">
+      <rect x="50%" y="0" width="50%" height="100%" />
+    </clipPath>
+  </defs>
+
+  <!-- full -->
+  <use href="#star-shape" x="0" y="0" />
+
+  <!-- half -->
+  <g transform="translate(0, 64)">
+    <use href="#star-shape" clip-path="url(#left-half)" />
+    <use href="#star-shape" clip-path="url(#right-half)" opacity="0.25" />
+  </g>
+
+  <!-- empty -->
+  <g transform="translate(0, 128)">
+    <use href="#star-shape" opacity="0.25" />
+  </g>
+</svg>
--- a/toolkit/themes/shared/mozapps.inc.mn
+++ b/toolkit/themes/shared/mozapps.inc.mn
@@ -18,16 +18,19 @@
 
   skin/classic/mozapps/extensions/extensionGeneric-16.svg    (../../shared/extensions/extensionGeneric-16.svg)
   skin/classic/mozapps/extensions/utilities.svg              (../../shared/extensions/utilities.svg)
   skin/classic/mozapps/extensions/alerticon-warning.svg      (../../shared/extensions/alerticon-warning.svg)
   skin/classic/mozapps/extensions/alerticon-error.svg        (../../shared/extensions/alerticon-error.svg)
   skin/classic/mozapps/extensions/alerticon-info-positive.svg (../../shared/extensions/alerticon-info-positive.svg)
   skin/classic/mozapps/extensions/alerticon-info-negative.svg (../../shared/extensions/alerticon-info-negative.svg)
   skin/classic/mozapps/extensions/category-legacy.svg        (../../shared/extensions/category-legacy.svg)
+#ifndef ANDROID
+  skin/classic/mozapps/extensions/rating-star.svg            (../../shared/extensions/rating-star.svg)
+#endif
   skin/classic/mozapps/aboutNetworking.css                   (../../shared/aboutNetworking.css)
 #ifndef ANDROID
   skin/classic/mozapps/aboutProfiles.css                     (../../shared/aboutProfiles.css)
 #endif
   skin/classic/mozapps/aboutServiceWorkers.css               (../../shared/aboutServiceWorkers.css)
   skin/classic/mozapps/profile/profileDowngrade.css          (../../shared/profile/profileDowngrade.css)
   skin/classic/mozapps/profile/information.svg               (../../shared/profile/information.svg)