commit 706c0c30068f984b28a3701c575dc5bd128ec944
parent 007eaddbe00b09a792be8bb3c6b0a9b610021e09
Author: Nathan Barrett <nbarrett@mozilla.com>
Date: Wed, 12 Nov 2025 22:22:12 +0000
Bug 1997956 - add fallback section layout configuration r=thecount,home-newtab-reviewers
Differential Revision: https://phabricator.services.mozilla.com/D271116
Diffstat:
4 files changed, 738 insertions(+), 0 deletions(-)
diff --git a/browser/extensions/newtab/lib/ActivityStream.sys.mjs b/browser/extensions/newtab/lib/ActivityStream.sys.mjs
@@ -1308,6 +1308,13 @@ export const PREFS_CONFIG = new Map([
},
],
[
+ "discoverystream.sections.clientLayout.enabled",
+ {
+ title: "Enables client side layout for recommended stories",
+ value: false,
+ },
+ ],
+ [
"support.url",
{
title: "Link to HNT's support page",
diff --git a/browser/extensions/newtab/lib/DiscoveryStreamFeed.sys.mjs b/browser/extensions/newtab/lib/DiscoveryStreamFeed.sys.mjs
@@ -5,6 +5,7 @@
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
+ DEFAULT_SECTION_LAYOUT: "resource://newtab/lib/SectionsLayoutManager.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
@@ -123,6 +124,8 @@ const PREF_VISIBLE_SECTIONS =
"discoverystream.sections.interestPicker.visibleSections";
const PREF_PRIVATE_PING_ENABLED = "telemetry.privatePing.enabled";
const PREF_SURFACE_ID = "telemetry.surfaceId";
+const PREF_CLIENT_LAYOUT_ENABLED =
+ "discoverystream.sections.clientLayout.enabled";
let getHardcodedLayout;
@@ -1877,6 +1880,9 @@ export class DiscoveryStreamFeed {
}));
if (sectionsEnabled) {
+ const useClientLayout =
+ this.store.getState().Prefs.values[PREF_CLIENT_LAYOUT_ENABLED];
+
for (const [sectionKey, sectionData] of Object.entries(
feedResponse.feeds
)) {
@@ -1903,6 +1909,7 @@ export class DiscoveryStreamFeed {
isTimeSensitive: item.isTimeSensitive,
});
}
+
sections.push({
sectionKey,
title: sectionData.title,
@@ -1915,6 +1922,19 @@ export class DiscoveryStreamFeed {
});
}
}
+
+ if (useClientLayout || sections.some(s => !s.layout)) {
+ sections.sort((a, b) => a.receivedRank - b.receivedRank);
+
+ sections.forEach((section, index) => {
+ if (useClientLayout || !section.layout) {
+ section.layout =
+ lazy.DEFAULT_SECTION_LAYOUT[
+ index % lazy.DEFAULT_SECTION_LAYOUT.length
+ ];
+ }
+ });
+ }
}
const { data: scoredItems, personalized } = await this.scoreItems(
diff --git a/browser/extensions/newtab/lib/SectionsLayoutManager.sys.mjs b/browser/extensions/newtab/lib/SectionsLayoutManager.sys.mjs
@@ -0,0 +1,605 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export const DEFAULT_SECTION_LAYOUT = [
+ {
+ name: "7-double-row-2-ad",
+ responsiveLayouts: [
+ {
+ columnCount: 4,
+ tiles: [
+ {
+ size: "large",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 5,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 4,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 6,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ ],
+ },
+ {
+ columnCount: 3,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 2,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 5,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 4,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 6,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ {
+ columnCount: 2,
+ tiles: [
+ {
+ size: "large",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 4,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 5,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 6,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ ],
+ },
+ {
+ columnCount: 1,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 4,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 5,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 6,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: "6-small-medium-1-ad",
+ responsiveLayouts: [
+ {
+ columnCount: 4,
+ tiles: [
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 4,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 5,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ {
+ columnCount: 3,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 3,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 4,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 5,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ ],
+ },
+ {
+ columnCount: 2,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 4,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 5,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ {
+ columnCount: 1,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 4,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 5,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: "4-large-small-medium-1-ad",
+ responsiveLayouts: [
+ {
+ columnCount: 4,
+ tiles: [
+ {
+ size: "large",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ {
+ columnCount: 3,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ {
+ columnCount: 2,
+ tiles: [
+ {
+ size: "large",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ {
+ columnCount: 1,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: "4-medium-small-1-ad",
+ responsiveLayouts: [
+ {
+ columnCount: 4,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ { size: "medium", position: 3, hasAd: true, hasExcerpt: true },
+ ],
+ },
+ {
+ columnCount: 3,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ {
+ columnCount: 2,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ {
+ columnCount: 1,
+ tiles: [
+ {
+ size: "medium",
+ position: 0,
+ hasAd: false,
+ hasExcerpt: true,
+ },
+ {
+ size: "medium",
+ position: 1,
+ hasAd: true,
+ hasExcerpt: true,
+ },
+ {
+ size: "small",
+ position: 2,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ {
+ size: "small",
+ position: 3,
+ hasAd: false,
+ hasExcerpt: false,
+ },
+ ],
+ },
+ ],
+ },
+];
diff --git a/browser/extensions/newtab/test/unit/lib/DiscoveryStreamFeed.test.js b/browser/extensions/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
@@ -10,6 +10,7 @@ import { RecommendationProvider } from "lib/RecommendationProvider.sys.mjs";
import { reducers } from "common/Reducers.sys.mjs";
import { PersistentCache } from "lib/PersistentCache.sys.mjs";
+import { DEFAULT_SECTION_LAYOUT } from "lib/SectionsLayoutManager.sys.mjs";
const CONFIG_PREF_NAME = "discoverystream.config";
const ENDPOINTS_PREF_NAME = "discoverystream.endpoints";
@@ -3743,6 +3744,111 @@ describe("DiscoveryStreamFeed", () => {
assert.deepEqual(feedData, expectedData);
});
+
+ describe("client layout for sections", () => {
+ beforeEach(() => {
+ setPref("discoverystream.sections.enabled", true);
+ globals.set("DEFAULT_SECTION_LAYOUT", DEFAULT_SECTION_LAYOUT);
+ const fakeCache = {};
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed, "rotate").callsFake(val => val);
+ sandbox
+ .stub(feed, "scoreItems")
+ .callsFake(val => ({ data: val, filtered: [], personalized: false }));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ recommendedAt: 1755834072383,
+ surfaceId: "NEW_TAB_EN_US",
+ data: [],
+ feeds: {
+ "section-1": {
+ title: "Section 1",
+ subtitle: "Subtitle 1",
+ receivedFeedRank: 1,
+ layout: { name: "original-layout" },
+ iab: "iab-category",
+ isInitiallyVisible: true,
+ recommendations: [],
+ },
+ "section-2": {
+ title: "Section 2",
+ subtitle: "Subtitle 2",
+ receivedFeedRank: 2,
+ layout: { name: "another-layout" },
+ iab: "iab-category-2",
+ isInitiallyVisible: true,
+ recommendations: [],
+ },
+ },
+ });
+ });
+ it("should return default layout when sections.clientLayout.enabled is false and server returns a layout object", async () => {
+ const feedData = await feed.getComponentFeed("url");
+ assert.equal(feedData.data.sections.length, 2);
+ assert.equal(
+ feedData.data.sections[0].layout.name,
+ "original-layout",
+ "First section should use original layout from server"
+ );
+ assert.equal(
+ feedData.data.sections[1].layout.name,
+ "another-layout",
+ "Second section should use second default layout"
+ );
+ });
+ it("should apply client layout when sections.clientLayout.enabled is true", async () => {
+ setPref("discoverystream.sections.clientLayout.enabled", true);
+ const feedData = await feed.getComponentFeed("url");
+
+ assert.equal(
+ feedData.data.sections[0].layout.name,
+ "7-double-row-2-ad",
+ "First section should use first default layout"
+ );
+ assert.equal(
+ feedData.data.sections[1].layout.name,
+ "6-small-medium-1-ad",
+ "Second section should use second default layout"
+ );
+ });
+ it("should apply client layout when any section has a missing layout property", async () => {
+ feed.fetchFromEndpoint.resolves({
+ recommendedAt: 1755834072383,
+ surfaceId: "NEW_TAB_EN_US",
+ data: [],
+ feeds: {
+ "section-1": {
+ title: "Section 1",
+ subtitle: "Subtitle 1",
+ receivedFeedRank: 1,
+ iab: "iab-category",
+ isInitiallyVisible: true,
+ recommendations: [],
+ },
+ "section-2": {
+ title: "Section 2",
+ subtitle: "Subtitle 2",
+ receivedFeedRank: 2,
+ layout: { name: "another-layout" },
+ iab: "iab-category-2",
+ isInitiallyVisible: true,
+ recommendations: [],
+ },
+ },
+ });
+ const feedData = await feed.getComponentFeed("url");
+
+ assert.equal(
+ feedData.data.sections[0].layout.name,
+ "7-double-row-2-ad",
+ "First section without layout should use client default layout"
+ );
+ assert.equal(
+ feedData.data.sections[1].layout.name,
+ "another-layout",
+ "Second section with layout should keep its original layout"
+ );
+ });
+ });
});
describe("#getContextualAdsPlacements", () => {