tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 7c99e9f7c9b7a40d88e3c300cb995ca661ecebb0
parent 1beb76bf5808e9b5661589bcd8dd0cb60de8930e
Author: Mike Conley <mconley@mozilla.com>
Date:   Thu,  8 Jan 2026 19:18:48 +0000

Bug 2004784 - Part 1: Add a basic external component registry for newtab. r=home-newtab-reviewers,nbarrett

Differential Revision: https://phabricator.services.mozilla.com/D275513

Diffstat:
Mbrowser/app/profile/firefox.js | 1+
Abrowser/components/newtab/AboutNewTabComponents.sys.mjs | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/newtab/moz.build | 9+++++++++
Abrowser/components/newtab/test/xpcshell/component-registrants/NotARegistrant.sys.mjs | 16++++++++++++++++
Abrowser/components/newtab/test/xpcshell/component-registrants/TestRegistrant1.sys.mjs | 18++++++++++++++++++
Abrowser/components/newtab/test/xpcshell/component-registrants/TestRegistrant2.sys.mjs | 18++++++++++++++++++
Abrowser/components/newtab/test/xpcshell/component-registrants/TestRegistrantDuplicateTypes.sys.mjs | 24++++++++++++++++++++++++
Abrowser/components/newtab/test/xpcshell/component-registrants/TestRegistrantInvalidConfigs.sys.mjs | 11+++++++++++
Abrowser/components/newtab/test/xpcshell/test_AboutNewTabComponentRegistry.js | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/newtab/test/xpcshell/xpcshell.toml | 9+++++++++
Mbrowser/extensions/newtab/common/Actions.mjs | 1+
Mbrowser/extensions/newtab/common/Reducers.sys.mjs | 16++++++++++++++++
Mbrowser/extensions/newtab/content-src/components/Base/Base.jsx | 24+++++++++++++-----------
Abrowser/extensions/newtab/content-src/components/ExternalComponentWrapper/ExternalComponentWrapper.jsx | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/content-src/components/Search/Search.jsx | 21+++++++++++++++++++++
Mbrowser/extensions/newtab/data/content/activity-stream.bundle.js | 166++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Abrowser/extensions/newtab/docs/v2-system-addon/external_components_architecture.md | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/extensions/newtab/docs/v2-system-addon/external_components_guide.md | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/lib/ActivityStream.sys.mjs | 19+++++++++++++++++++
Abrowser/extensions/newtab/lib/ExternalComponentsFeed.sys.mjs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/lib/PrefsFeed.sys.mjs | 1+
Mbrowser/extensions/newtab/test/unit/common/Reducers.test.js | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/extensions/newtab/test/unit/content-src/components/ExternalComponentWrapper.test.jsx | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/extensions/newtab/test/xpcshell/test_ExternalComponentsFeed.js | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/test/xpcshell/xpcshell.toml | 2++
25 files changed, 2126 insertions(+), 21 deletions(-)

diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js @@ -1873,6 +1873,7 @@ pref("browser.newtabpage.activity-stream.discoverystream.refinedCardsLayout.enab * Remove the old implementation and the pref once this ships to Release. */ pref("browser.newtabpage.activity-stream.search.useHandoffComponent", true); +pref("browser.newtabpage.activity-stream.externalComponents.enabled", true); // Mozilla Ad Routing Service (MARS) unified ads service pref("browser.newtabpage.activity-stream.unifiedAds.tiles.enabled", true); diff --git a/browser/components/newtab/AboutNewTabComponents.sys.mjs b/browser/components/newtab/AboutNewTabComponents.sys.mjs @@ -0,0 +1,249 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const CATEGORY_NAME = "browser-newtab-external-component"; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { + return console.createInstance({ + prefix: "AboutNewTabComponents", + maxLogLevel: Services.prefs.getBoolPref( + "browser.newtabpage.activity-stream.externalComponents.log", + false + ) + ? "Debug" + : "Warn", + }); +}); + +/** + * @typedef {object} NewTabComponentConfiguration + * @property {string} type + * @property {string[]} l10nURLs + * @property {string} componentURL + * @property {string} tagName + */ + +/** + * The AboutNewTabComponentRegistry is a class that manages a list of + * external components registered to appear on the newtab page. + * + * The registry is an EventEmitter, and will emit the UPDATED_EVENT when the + * registry changes. + * + * The registry is bootstrapped via entries in the nsICategoryManager of name + * CATEGORY_NAME. + */ +class AboutNewTabComponentRegistry extends EventEmitter { + static TYPES = Object.freeze({ + SEARCH: "SEARCH", + }); + static UPDATED_EVENT = "updated"; + + /** + * The list of registered external component configurations, keyed on their + * type. + * + * @type {Map<string, NewTabComponentConfiguration>} + */ + #registeredComponents = new Map(); + + /** + * A mapping of external component registrant instances, keyed on their + * module URI. + * + * @type {Map<string, BaseAboutNewTabComponentRegistrant>} + */ + #registrants = new Map(); + + constructor() { + super(); + + lazy.logConsole.debug("Instantiating AboutNewTabComponentRegistry"); + this.#infalliblyLoadConfigurations(); + + Services.obs.addObserver(this, "xpcom-category-entry-removed"); + Services.obs.addObserver(this, "xpcom-category-entry-added"); + Services.obs.addObserver(this, "xpcom-category-cleared"); + Services.obs.addObserver(this, "profile-before-change"); + } + + observe(subject, topic, data) { + switch (topic) { + case "xpcom-category-entry-removed": + // Intentional fall-through + case "xpcom-category-entry-added": + // Intentional fall-through + case "xpcom-category-cleared": { + // Intentional fall-through + if (data === CATEGORY_NAME) { + this.#infalliblyLoadConfigurations(); + } + break; + } + case "profile-before-change": { + this.destroy(); + break; + } + } + } + + destroy() { + for (let registrant of this.#registrants.values()) { + registrant.destroy(); + } + this.#registrants.clear(); + this.#registeredComponents.clear(); + + Services.obs.removeObserver(this, "xpcom-category-entry-removed"); + Services.obs.removeObserver(this, "xpcom-category-entry-added"); + Services.obs.removeObserver(this, "xpcom-category-cleared"); + Services.obs.removeObserver(this, "profile-before-change"); + } + + /** + * Iterates the CATEGORY_NAME nsICategoryManager category, and attempts to + * load each registrant's configuration, which updates the + * #registeredComponents. Updating the #registeredComponents will cause the + * UPDATED_EVENT event to be emitted from this class. + * + * Invalid configurations are skipped. + * + * This method will log errors but is guaranteed to return, even if one or + * more of the configurations is invalid. + */ + #infalliblyLoadConfigurations() { + lazy.logConsole.debug("Loading configurations"); + this.#registeredComponents.clear(); + + for (let { entry, value } of Services.catMan.enumerateCategory( + CATEGORY_NAME + )) { + try { + lazy.logConsole.debug("Loading ", entry, value); + let registrar = null; + if (this.#registrants.has(entry)) { + lazy.logConsole.debug("Found pre-existing registrant for ", entry); + registrar = this.#registrants.get(entry); + } else { + lazy.logConsole.debug("Constructing registrant for ", entry); + const module = ChromeUtils.importESModule(entry); + const registrarClass = module[value]; + + if ( + !( + registrarClass.prototype instanceof + BaseAboutNewTabComponentRegistrant + ) + ) { + throw new Error( + `Registrant for ${entry} does not subclass BaseAboutNewTabComponentRegistrant` + ); + } + + registrar = new registrarClass(); + this.#registrants.set(entry, registrar); + registrar.on(AboutNewTabComponentRegistry.UPDATED_EVENT, () => { + this.#infalliblyLoadConfigurations(); + }); + } + + let configurations = registrar.getComponents(); + for (let configuration of configurations) { + if (this.#validateConfiguration(configuration)) { + lazy.logConsole.debug( + `Validated a configuration for type ${configuration.type}` + ); + this.#registeredComponents.set(configuration.type, configuration); + } else { + lazy.logConsole.error( + `Failed to validate a configuration:`, + configuration + ); + } + } + } catch (e) { + lazy.logConsole.error( + "Failed to load configurations ", + entry, + value, + e.message + ); + } + } + + this.emit(AboutNewTabComponentRegistry.UPDATED_EVENT); + } + + /** + * Ensures that the configuration abides by newtab's external component + * rules. Currently, that just means that two components cannot share the + * same type. + * + * @param {NewTabComponentConfiguration} configuration + * @returns {boolean} + */ + #validateConfiguration(configuration) { + if (!configuration.type) { + return false; + } + + // Currently, the only validation is to ensure that something isn't already + // registered with the same type. This rule might evolve over time if we + // start allowing multiples of a type. + if (this.#registeredComponents.has(configuration.type)) { + return false; + } + + return true; + } + + /** + * Returns a copy of the configuration registry for external consumption. + * + * @returns {NewTabComponentConfiguration[]} + */ + get values() { + return Array.from(this.#registeredComponents.values()); + } +} + +/** + * Any registrants that want to register an external component onto newtab must + * subclass this base class in order to provide the configuration for their + * component. They must then add their registrant to the nsICategoryManager + * category `browser-newtab-external-component`, where the entry is the URI + * for the module containing the subclass, and the value is the name of the + * subclass exported by the module. + */ +class BaseAboutNewTabComponentRegistrant extends EventEmitter { + /** + * Subclasses can override this method to do any cleanup when the component + * registry starts being shut down. + */ + destroy() {} + + /** + * Subclasses should override this method to provide one or more + * NewTabComponentConfiguration's. + * + * @returns {NewTabComponentConfiguration[]} + */ + getComponents() { + return []; + } + + /** + * Subclasses can call this method if their component registry ever needs + * updating. This will alert the registry to update itself. + */ + updated() { + this.emit(AboutNewTabComponentRegistry.UPDATED_EVENT); + } +} + +export { AboutNewTabComponentRegistry, BaseAboutNewTabComponentRegistrant }; diff --git a/browser/components/newtab/moz.build b/browser/components/newtab/moz.build @@ -14,6 +14,7 @@ EXTRA_JS_MODULES += [ ] MOZ_SRC_FILES += [ + "AboutNewTabComponents.sys.mjs", "SponsorProtection.sys.mjs", ] @@ -23,6 +24,14 @@ XPCOM_MANIFESTS += [ FINAL_LIBRARY = "browsercomps" +TESTING_JS_MODULES += [ + "test/xpcshell/component-registrants/NotARegistrant.sys.mjs", + "test/xpcshell/component-registrants/TestRegistrant1.sys.mjs", + "test/xpcshell/component-registrants/TestRegistrant2.sys.mjs", + "test/xpcshell/component-registrants/TestRegistrantDuplicateTypes.sys.mjs", + "test/xpcshell/component-registrants/TestRegistrantInvalidConfigs.sys.mjs", +] + XPCSHELL_TESTS_MANIFESTS += [ "test/xpcshell/xpcshell.toml", ] diff --git a/browser/components/newtab/test/xpcshell/component-registrants/NotARegistrant.sys.mjs b/browser/components/newtab/test/xpcshell/component-registrants/NotARegistrant.sys.mjs @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +export class NotARegistrant { + getComponents() { + return [ + { + type: "SEARCH", + componentURL: "chrome://test/content/component.mjs", + tagName: "test-component", + l10nURLs: [], + }, + ]; + } +} diff --git a/browser/components/newtab/test/xpcshell/component-registrants/TestRegistrant1.sys.mjs b/browser/components/newtab/test/xpcshell/component-registrants/TestRegistrant1.sys.mjs @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +import { BaseAboutNewTabComponentRegistrant } from "moz-src:///browser/components/newtab/AboutNewTabComponents.sys.mjs"; + +export class TestRegistrant1 extends BaseAboutNewTabComponentRegistrant { + getComponents() { + return [ + { + type: "SEARCH", + componentURL: "chrome://test/content/component.mjs", + tagName: "test-component", + l10nURLs: [], + }, + ]; + } +} diff --git a/browser/components/newtab/test/xpcshell/component-registrants/TestRegistrant2.sys.mjs b/browser/components/newtab/test/xpcshell/component-registrants/TestRegistrant2.sys.mjs @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +import { BaseAboutNewTabComponentRegistrant } from "moz-src:///browser/components/newtab/AboutNewTabComponents.sys.mjs"; + +export class TestRegistrant2 extends BaseAboutNewTabComponentRegistrant { + getComponents() { + return [ + { + type: "OTHER", + componentURL: "chrome://test/content/component2.mjs", + tagName: "test-component-2", + l10nURLs: [], + }, + ]; + } +} diff --git a/browser/components/newtab/test/xpcshell/component-registrants/TestRegistrantDuplicateTypes.sys.mjs b/browser/components/newtab/test/xpcshell/component-registrants/TestRegistrantDuplicateTypes.sys.mjs @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +import { BaseAboutNewTabComponentRegistrant } from "moz-src:///browser/components/newtab/AboutNewTabComponents.sys.mjs"; + +export class TestRegistrantDuplicateTypes extends BaseAboutNewTabComponentRegistrant { + getComponents() { + return [ + { + type: "SEARCH", + componentURL: "chrome://test/content/component1.mjs", + tagName: "test-component-1", + l10nURLs: [], + }, + { + type: "SEARCH", + componentURL: "chrome://test/content/component2.mjs", + tagName: "test-component-2", + l10nURLs: [], + }, + ]; + } +} diff --git a/browser/components/newtab/test/xpcshell/component-registrants/TestRegistrantInvalidConfigs.sys.mjs b/browser/components/newtab/test/xpcshell/component-registrants/TestRegistrantInvalidConfigs.sys.mjs @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +import { BaseAboutNewTabComponentRegistrant } from "moz-src:///browser/components/newtab/AboutNewTabComponents.sys.mjs"; + +export class TestRegistrantInvalidConfigs extends BaseAboutNewTabComponentRegistrant { + getComponents() { + return [{}, { type: "" }, { type: null }]; + } +} diff --git a/browser/components/newtab/test/xpcshell/test_AboutNewTabComponentRegistry.js b/browser/components/newtab/test/xpcshell/test_AboutNewTabComponentRegistry.js @@ -0,0 +1,321 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const { AboutNewTabComponentRegistry } = ChromeUtils.importESModule( + "moz-src:///browser/components/newtab/AboutNewTabComponents.sys.mjs" +); + +const CATEGORY_NAME = "browser-newtab-external-component"; + +const originalCategoryEntries = []; + +add_setup(() => { + for (let { entry, value } of Services.catMan.enumerateCategory( + CATEGORY_NAME + )) { + originalCategoryEntries.push({ entry, value }); + } + Services.catMan.deleteCategory(CATEGORY_NAME); + + registerCleanupFunction(() => { + Services.catMan.deleteCategory(CATEGORY_NAME); + + for (let { entry, value } of originalCategoryEntries) { + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + entry, + value, + false, + true + ); + } + }); +}); + +/** + * Tests that the AboutNewTabComponentRegistry can be instantiated. + */ +add_task(async function test_registry_initializes() { + let registry = new AboutNewTabComponentRegistry(); + Assert.ok(registry, "Registry should instantiate"); + registry.destroy(); +}); + +/** + * Tests that the registry loads component configurations from category entries + * and fires an UPDATED_EVENT when components are registered. + */ +add_task(async function test_registry_loads_valid_configuration() { + let updateEventFired = false; + let registry = new AboutNewTabComponentRegistry(); + + registry.on(AboutNewTabComponentRegistry.UPDATED_EVENT, () => { + updateEventFired = true; + }); + + const testModuleURI = "resource://testing-common/TestRegistrant1.sys.mjs"; + + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + testModuleURI, + "TestRegistrant1", + false, + true + ); + + await TestUtils.waitForCondition( + () => updateEventFired, + "Should fire updated event" + ); + + let components = Array.from(registry.values); + Assert.equal(components.length, 1, "Should have one component registered"); + Assert.equal(components[0].type, "SEARCH", "Component type should be SEARCH"); + + Services.catMan.deleteCategoryEntry(CATEGORY_NAME, testModuleURI, false); + registry.destroy(); +}); + +/** + * Tests that the registry rejects duplicate component types and only + * registers the first component when multiple registrants provide + * components with the same type. + */ +add_task(async function test_registry_rejects_duplicate_types() { + let registry = new AboutNewTabComponentRegistry(); + + const testModuleURI = + "resource://testing-common/TestRegistrantDuplicateTypes.sys.mjs"; + + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + testModuleURI, + "TestRegistrantDuplicateTypes", + false, + true + ); + + await TestUtils.waitForTick(); + + let components = Array.from(registry.values); + Assert.equal( + components.length, + 1, + "Should only register one component when types conflict" + ); + + Services.catMan.deleteCategoryEntry(CATEGORY_NAME, testModuleURI, false); + registry.destroy(); +}); + +/** + * Tests that the registry rejects component configurations that are missing + * required fields like the type property. + */ +add_task(async function test_registry_rejects_invalid_configurations() { + let registry = new AboutNewTabComponentRegistry(); + + const testModuleURI = + "resource://testing-common/TestRegistrantInvalidConfigs.sys.mjs"; + + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + testModuleURI, + "TestRegistrantInvalidConfigs", + false, + true + ); + + await TestUtils.waitForTick(); + + let components = Array.from(registry.values); + Assert.equal( + components.length, + 0, + "Should reject configurations without valid type" + ); + + Services.catMan.deleteCategoryEntry(CATEGORY_NAME, testModuleURI, false); + registry.destroy(); +}); + +/** + * Tests that the registry properly handles category entry removal by + * unregistering components and firing an UPDATED_EVENT. + */ +add_task(async function test_registry_handles_category_removal() { + let updateCount = 0; + let registry = new AboutNewTabComponentRegistry(); + + registry.on(AboutNewTabComponentRegistry.UPDATED_EVENT, () => { + updateCount++; + }); + + const testModuleURI = "resource://testing-common/TestRegistrant1.sys.mjs"; + + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + testModuleURI, + "TestRegistrant1", + false, + true + ); + + await TestUtils.waitForCondition(() => updateCount >= 1); + + const initialUpdateCount = updateCount; + let components = Array.from(registry.values); + Assert.equal(components.length, 1, "Should have component registered"); + + Services.catMan.deleteCategoryEntry(CATEGORY_NAME, testModuleURI, false); + + await TestUtils.waitForCondition(() => updateCount > initialUpdateCount); + + components = Array.from(registry.values); + Assert.equal(components.length, 0, "Should have no components after removal"); + + registry.destroy(); +}); + +/** + * Tests that the registry responds to registrant updates by refreshing + * its component list and firing update events. + */ +add_task(async function test_registry_handles_registrant_updates() { + let registry = new AboutNewTabComponentRegistry(); + let updateCount = 0; + + registry.on(AboutNewTabComponentRegistry.UPDATED_EVENT, () => { + updateCount++; + }); + + const testModuleURI = "resource://testing-common/TestRegistrant1.sys.mjs"; + + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + testModuleURI, + "TestRegistrant1", + false, + true + ); + + await TestUtils.waitForCondition(() => updateCount >= 1); + + let components = Array.from(registry.values); + Assert.equal(components.length, 1, "Should have initial component"); + Assert.equal(components[0].type, "SEARCH", "Initial type should be SEARCH"); + + Services.catMan.deleteCategoryEntry(CATEGORY_NAME, testModuleURI, false); + registry.destroy(); +}); + +/** + * Tests that calling destroy() on the registry properly cleans up all + * registered components and internal state. + */ +add_task(async function test_registry_cleanup_on_destroy() { + let registry = new AboutNewTabComponentRegistry(); + + const testModuleURI = "resource://testing-common/TestRegistrant1.sys.mjs"; + + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + testModuleURI, + "TestRegistrant1", + false, + true + ); + + await TestUtils.waitForTick(); + + registry.destroy(); + + let components = Array.from(registry.values); + Assert.equal(components.length, 0, "Should have no components after destroy"); + + Services.catMan.deleteCategoryEntry(CATEGORY_NAME, testModuleURI, false); +}); + +/** + * Tests that the registry validates registrants are instances of + * BaseAboutNewTabComponentRegistrant and rejects invalid registrants. + */ +add_task(async function test_registrant_subclass_validation() { + let registry = new AboutNewTabComponentRegistry(); + + const invalidModuleURI = "resource://testing-common/NotARegistrant.sys.mjs"; + + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + invalidModuleURI, + "NotARegistrant", + false, + true + ); + + await TestUtils.waitForTick(); + + let components = Array.from(registry.values); + Assert.equal( + components.length, + 0, + "Should reject registrant that doesn't subclass BaseAboutNewTabComponentRegistrant" + ); + + Services.catMan.deleteCategoryEntry(CATEGORY_NAME, invalidModuleURI, false); + registry.destroy(); +}); + +/** + * Tests that the registry can handle multiple registrants providing + * different component types simultaneously. + */ +add_task(async function test_multiple_registrants() { + let registry = new AboutNewTabComponentRegistry(); + + const testModule1URI = "resource://testing-common/TestRegistrant1.sys.mjs"; + const testModule2URI = "resource://testing-common/TestRegistrant2.sys.mjs"; + + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + testModule1URI, + "TestRegistrant1", + false, + true + ); + + Services.catMan.addCategoryEntry( + CATEGORY_NAME, + testModule2URI, + "TestRegistrant2", + false, + true + ); + + await TestUtils.waitForTick(); + + let components = Array.from(registry.values); + Assert.equal( + components.length, + 2, + "Should register components from multiple registrants" + ); + + let types = components.map(c => c.type).sort(); + Assert.deepEqual( + types, + ["OTHER", "SEARCH"], + "Should have both component types" + ); + + Services.catMan.deleteCategoryEntry(CATEGORY_NAME, testModule1URI, false); + Services.catMan.deleteCategoryEntry(CATEGORY_NAME, testModule2URI, false); + registry.destroy(); +}); diff --git a/browser/components/newtab/test/xpcshell/xpcshell.toml b/browser/components/newtab/test/xpcshell/xpcshell.toml @@ -8,11 +8,20 @@ prefs = [ "browser.newtabpage.resource-mapping.log=true", "browser.startup.homepage.abouthome_cache.testing=true", ] +support-files = [ + "component-registrants/TestRegistrant1.sys.mjs", + "component-registrants/TestRegistrant2.sys.mjs", + "component-registrants/TestRegistrantDuplicateTypes.sys.mjs", + "component-registrants/TestRegistrantInvalidConfigs.sys.mjs", + "component-registrants/NotARegistrant.sys.mjs", +] ["test_AboutHomeStartupCacheChild.js"] ["test_AboutNewTab.js"] +["test_AboutNewTabComponentRegistry.js"] + ["test_AboutNewTabRedirector.js"] ["test_disableNewTabAsAddon_pref.js"] diff --git a/browser/extensions/newtab/common/Actions.mjs b/browser/extensions/newtab/common/Actions.mjs @@ -137,6 +137,7 @@ for (const type of [ "PROMO_CARD_CLICK", "PROMO_CARD_DISMISS", "PROMO_CARD_IMPRESSION", + "REFRESH_EXTERNAL_COMPONENTS", "REMOVE_DOWNLOAD_FILE", "REPORT_AD_OPEN", "REPORT_AD_SUBMIT", diff --git a/browser/extensions/newtab/common/Reducers.sys.mjs b/browser/extensions/newtab/common/Reducers.sys.mjs @@ -205,6 +205,9 @@ export const INITIAL_STATE = { isRunning: false, }, }, + ExternalComponents: { + components: [], + }, }; function App(prevState = INITIAL_STATE.App, action) { @@ -1179,6 +1182,18 @@ function ListsWidget(prevState = INITIAL_STATE.ListsWidget, action) { } } +function ExternalComponents( + prevState = INITIAL_STATE.ExternalComponents, + action +) { + switch (action.type) { + case at.REFRESH_EXTERNAL_COMPONENTS: + return { ...prevState, components: action.data }; + default: + return prevState; + } +} + export const reducers = { TopSites, App, @@ -1197,4 +1212,5 @@ export const reducers = { ListsWidget, Wallpapers, Weather, + ExternalComponents, }; diff --git a/browser/extensions/newtab/content-src/components/Base/Base.jsx b/browser/extensions/newtab/content-src/components/Base/Base.jsx @@ -207,17 +207,19 @@ export class BaseContent extends React.PureComponent { const prefs = this.props.Prefs.values; const wallpapersEnabled = prefs["newtabWallpapers.enabled"]; - if (prefs["search.useHandoffComponent"]) { - // Dynamically import the contentSearchHandoffUI module, but don't worry - // about webpacking this one. - import( - /* webpackIgnore: true */ "chrome://browser/content/contentSearchHandoffUI.mjs" - ); - } else { - const scriptURL = "chrome://browser/content/contentSearchHandoffUI.js"; - const scriptEl = document.createElement("script"); - scriptEl.src = scriptURL; - document.head.appendChild(scriptEl); + if (!prefs["externalComponents.enabled"]) { + if (prefs["search.useHandoffComponent"]) { + // Dynamically import the contentSearchHandoffUI module, but don't worry + // about webpacking this one. + import( + /* webpackIgnore: true */ "chrome://browser/content/contentSearchHandoffUI.mjs" + ); + } else { + const scriptURL = "chrome://browser/content/contentSearchHandoffUI.js"; + const scriptEl = document.createElement("script"); + scriptEl.src = scriptURL; + document.head.appendChild(scriptEl); + } } if (this.props.document.visibilityState === VISIBLE) { diff --git a/browser/extensions/newtab/content-src/components/ExternalComponentWrapper/ExternalComponentWrapper.jsx b/browser/extensions/newtab/content-src/components/ExternalComponentWrapper/ExternalComponentWrapper.jsx @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { useSelector } from "react-redux"; + +/** + * A React component that dynamically loads and embeds external custom elements + * into the newtab page. + * + * This component serves as a bridge between React's declarative rendering and + * browser-native custom elements that are registered and managed outside of + * React's control. It: + * + * 1. Looks up the component configuration by type from the ExternalComponents + * registry + * 2. Dynamically imports the component's script module (which registers the + * custom element) + * 3. Creates an instance of the custom element using imperative DOM APIs + * 4. Appends it to a React-managed container div + * 5. Cleans up the custom element on unmount + * + * This approach is necessary because: + * - Custom elements have their own lifecycle separate from React + * - They need to be created imperatively (document.createElement) rather than + * declaratively (JSX) + * - React shouldn't try to diff/reconcile their internal DOM, as they manage + * their own shadow DOM + * - We need manual cleanup to prevent memory leaks when the component unmounts + * + * @param {object} props + * @param {string} props.type - The component type to load (e.g., "SEARCH") + * @param {string} props.className - CSS class name(s) to apply to the wrapper div + * @param {Function} props.importModule - Function to import modules (for testing) + */ +function ExternalComponentWrapper({ + type, + className, + // importFunction is declared as an arrow function here purely so that we can + // override it for testing. + // eslint-disable-next-line no-unsanitized/method + importModule = url => import(/* webpackIgnore: true */ url), +}) { + const containerRef = React.useRef(null); + const customElementRef = React.useRef(null); + const l10nLinksRef = React.useRef([]); + const [error, setError] = React.useState(null); + const { components } = useSelector(state => state.ExternalComponents); + + React.useEffect(() => { + const container = containerRef.current; + + const loadComponent = async () => { + try { + const config = components.find(c => c.type === type); + + if (!config) { + console.warn( + `No external component configuration found for type: ${type}` + ); + return; + } + + await importModule(config.componentURL); + + l10nLinksRef.current = []; + for (let l10nURL of config.l10nURLs) { + const l10nEl = document.createElement("link"); + l10nEl.rel = "localization"; + l10nEl.href = l10nURL; + document.head.appendChild(l10nEl); + l10nLinksRef.current.push(l10nEl); + } + + if (containerRef.current && !customElementRef.current) { + const element = document.createElement(config.tagName); + + if (config.attributes) { + for (const [key, value] of Object.entries(config.attributes)) { + element.setAttribute(key, value); + } + } + + if (config.cssVariables) { + for (const [variable, style] of Object.entries( + config.cssVariables + )) { + element.style.setProperty(variable, style); + } + } + + customElementRef.current = element; + containerRef.current.appendChild(element); + } + } catch (err) { + console.error( + `Failed to load external component for type ${type}:`, + err + ); + setError(err); + } + }; + + loadComponent(); + + return () => { + if (customElementRef.current && container) { + container.removeChild(customElementRef.current); + customElementRef.current = null; + } + + for (const link of l10nLinksRef.current) { + link.remove(); + } + l10nLinksRef.current = []; + }; + }, [type, components, importModule]); + + if (error) { + return null; + } + + return <div ref={containerRef} className={className} />; +} + +export { ExternalComponentWrapper }; diff --git a/browser/extensions/newtab/content-src/components/Search/Search.jsx b/browser/extensions/newtab/content-src/components/Search/Search.jsx @@ -18,6 +18,7 @@ import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { connect } from "react-redux"; import { Logo } from "content-src/components/Logo/Logo"; import React from "react"; +import { ExternalComponentWrapper } from "content-src/components/ExternalComponentWrapper/ExternalComponentWrapper"; export class _Search extends React.PureComponent { constructor(props) { @@ -75,8 +76,15 @@ export class _Search extends React.PureComponent { caretBlinkCount, caretBlinkTime, "search.useHandoffComponent": useHandoffComponent, + "externalComponents.enabled": useExternalComponents, } = this.props.Prefs.values; + if (useExternalComponents) { + // Nothing to do - the external component will have set the caret + // values itself. + return; + } + if (useHandoffComponent) { const { handoffUI } = this; if (handoffUI) { @@ -131,8 +139,21 @@ export class _Search extends React.PureComponent { render() { const useHandoffComponent = this.props.Prefs.values["search.useHandoffComponent"]; + const useExternalComponents = + this.props.Prefs.values["externalComponents.enabled"]; if (useHandoffComponent) { + if (useExternalComponents) { + return ( + <div className="search-wrapper"> + {this.props.showLogo && <Logo />} + <ExternalComponentWrapper + type="SEARCH" + className="search-inner-wrapper" + ></ExternalComponentWrapper> + </div> + ); + } return ( <div className="search-wrapper"> {this.props.showLogo && <Logo />} diff --git a/browser/extensions/newtab/data/content/activity-stream.bundle.js b/browser/extensions/newtab/data/content/activity-stream.bundle.js @@ -210,6 +210,7 @@ for (const type of [ "PROMO_CARD_CLICK", "PROMO_CARD_DISMISS", "PROMO_CARD_IMPRESSION", + "REFRESH_EXTERNAL_COMPONENTS", "REMOVE_DOWNLOAD_FILE", "REPORT_AD_OPEN", "REPORT_AD_SUBMIT", @@ -6623,6 +6624,9 @@ const INITIAL_STATE = { isRunning: false, }, }, + ExternalComponents: { + components: [], + }, }; function App(prevState = INITIAL_STATE.App, action) { @@ -7597,6 +7601,18 @@ function ListsWidget(prevState = INITIAL_STATE.ListsWidget, action) { } } +function ExternalComponents( + prevState = INITIAL_STATE.ExternalComponents, + action +) { + switch (action.type) { + case actionTypes.REFRESH_EXTERNAL_COMPONENTS: + return { ...prevState, components: action.data }; + default: + return prevState; + } +} + const reducers = { TopSites, App, @@ -7615,6 +7631,7 @@ const reducers = { ListsWidget, Wallpapers, Weather, + ExternalComponents, }; ;// CONCATENATED MODULE: ./content-src/components/TopSites/TopSiteFormInput.jsx @@ -14861,6 +14878,117 @@ function Logo() { }))); } +;// CONCATENATED MODULE: ./content-src/components/ExternalComponentWrapper/ExternalComponentWrapper.jsx +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + + + +/** + * A React component that dynamically loads and embeds external custom elements + * into the newtab page. + * + * This component serves as a bridge between React's declarative rendering and + * browser-native custom elements that are registered and managed outside of + * React's control. It: + * + * 1. Looks up the component configuration by type from the ExternalComponents + * registry + * 2. Dynamically imports the component's script module (which registers the + * custom element) + * 3. Creates an instance of the custom element using imperative DOM APIs + * 4. Appends it to a React-managed container div + * 5. Cleans up the custom element on unmount + * + * This approach is necessary because: + * - Custom elements have their own lifecycle separate from React + * - They need to be created imperatively (document.createElement) rather than + * declaratively (JSX) + * - React shouldn't try to diff/reconcile their internal DOM, as they manage + * their own shadow DOM + * - We need manual cleanup to prevent memory leaks when the component unmounts + * + * @param {object} props + * @param {string} props.type - The component type to load (e.g., "SEARCH") + * @param {string} props.className - CSS class name(s) to apply to the wrapper div + * @param {Function} props.importModule - Function to import modules (for testing) + */ +function ExternalComponentWrapper({ + type, + className, + // importFunction is declared as an arrow function here purely so that we can + // override it for testing. + // eslint-disable-next-line no-unsanitized/method + importModule = url => import(/* webpackIgnore: true */url) +}) { + const containerRef = external_React_default().useRef(null); + const customElementRef = external_React_default().useRef(null); + const l10nLinksRef = external_React_default().useRef([]); + const [error, setError] = external_React_default().useState(null); + const { + components + } = (0,external_ReactRedux_namespaceObject.useSelector)(state => state.ExternalComponents); + external_React_default().useEffect(() => { + const container = containerRef.current; + const loadComponent = async () => { + try { + const config = components.find(c => c.type === type); + if (!config) { + console.warn(`No external component configuration found for type: ${type}`); + return; + } + await importModule(config.componentURL); + l10nLinksRef.current = []; + for (let l10nURL of config.l10nURLs) { + const l10nEl = document.createElement("link"); + l10nEl.rel = "localization"; + l10nEl.href = l10nURL; + document.head.appendChild(l10nEl); + l10nLinksRef.current.push(l10nEl); + } + if (containerRef.current && !customElementRef.current) { + const element = document.createElement(config.tagName); + if (config.attributes) { + for (const [key, value] of Object.entries(config.attributes)) { + element.setAttribute(key, value); + } + } + if (config.cssVariables) { + for (const [variable, style] of Object.entries(config.cssVariables)) { + element.style.setProperty(variable, style); + } + } + customElementRef.current = element; + containerRef.current.appendChild(element); + } + } catch (err) { + console.error(`Failed to load external component for type ${type}:`, err); + setError(err); + } + }; + loadComponent(); + return () => { + if (customElementRef.current && container) { + container.removeChild(customElementRef.current); + customElementRef.current = null; + } + for (const link of l10nLinksRef.current) { + link.remove(); + } + l10nLinksRef.current = []; + }; + }, [type, components, importModule]); + if (error) { + return null; + } + return /*#__PURE__*/external_React_default().createElement("div", { + ref: containerRef, + className: className + }); +} + ;// CONCATENATED MODULE: ./content-src/components/Search/Search.jsx /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, @@ -14882,6 +15010,7 @@ function Logo() { + class _Search extends (external_React_default()).PureComponent { constructor(props) { super(props); @@ -14941,8 +15070,14 @@ class _Search extends (external_React_default()).PureComponent { const { caretBlinkCount, caretBlinkTime, - "search.useHandoffComponent": useHandoffComponent + "search.useHandoffComponent": useHandoffComponent, + "externalComponents.enabled": useExternalComponents } = this.props.Prefs.values; + if (useExternalComponents) { + // Nothing to do - the external component will have set the caret + // values itself. + return; + } if (useHandoffComponent) { const { handoffUI @@ -14984,7 +15119,16 @@ class _Search extends (external_React_default()).PureComponent { */ render() { const useHandoffComponent = this.props.Prefs.values["search.useHandoffComponent"]; + const useExternalComponents = this.props.Prefs.values["externalComponents.enabled"]; if (useHandoffComponent) { + if (useExternalComponents) { + return /*#__PURE__*/external_React_default().createElement("div", { + className: "search-wrapper" + }, this.props.showLogo && /*#__PURE__*/external_React_default().createElement(Logo, null), /*#__PURE__*/external_React_default().createElement(ExternalComponentWrapper, { + type: "SEARCH", + className: "search-inner-wrapper" + })); + } return /*#__PURE__*/external_React_default().createElement("div", { className: "search-wrapper" }, this.props.showLogo && /*#__PURE__*/external_React_default().createElement(Logo, null), /*#__PURE__*/external_React_default().createElement("div", { @@ -15741,15 +15885,17 @@ class BaseContent extends (external_React_default()).PureComponent { __webpack_require__.g.addEventListener("keydown", this.handleOnKeyDown); const prefs = this.props.Prefs.values; const wallpapersEnabled = prefs["newtabWallpapers.enabled"]; - if (prefs["search.useHandoffComponent"]) { - // Dynamically import the contentSearchHandoffUI module, but don't worry - // about webpacking this one. - import(/* webpackIgnore: true */"chrome://browser/content/contentSearchHandoffUI.mjs"); - } else { - const scriptURL = "chrome://browser/content/contentSearchHandoffUI.js"; - const scriptEl = document.createElement("script"); - scriptEl.src = scriptURL; - document.head.appendChild(scriptEl); + if (!prefs["externalComponents.enabled"]) { + if (prefs["search.useHandoffComponent"]) { + // Dynamically import the contentSearchHandoffUI module, but don't worry + // about webpacking this one. + import(/* webpackIgnore: true */"chrome://browser/content/contentSearchHandoffUI.mjs"); + } else { + const scriptURL = "chrome://browser/content/contentSearchHandoffUI.js"; + const scriptEl = document.createElement("script"); + scriptEl.src = scriptURL; + document.head.appendChild(scriptEl); + } } if (this.props.document.visibilityState === Base_VISIBLE) { this.onVisible(); diff --git a/browser/extensions/newtab/docs/v2-system-addon/external_components_architecture.md b/browser/extensions/newtab/docs/v2-system-addon/external_components_architecture.md @@ -0,0 +1,178 @@ +# External Components Architecture + +Internal documentation for the New Tab team on the External Components system implementation. + +## Overview + +The External Components system provides a pluggable architecture for embedding custom web components from other Firefox features into about:newtab and about:home. This document describes the internal architecture, data flow, and implementation details. + +## System Components + +### 1. AboutNewTabComponentRegistry + +**Location**: `browser/components/newtab/AboutNewTabComponents.sys.mjs` + +The registry is the central coordinator for external components. It: + +- Observes the `browser-newtab-external-component` category for registrant modules +- Loads and validates registrants and their component configurations +- Maintains a deduplicated map of components keyed by type +- Emits `UPDATED_EVENT` when components are added or removed +- Provides access via `AboutNewTabComponentRegistry.instance()` +- Lives under `browser/components/newtab`, and therefore, does not train-hop. + Since the train-hopping `ExternalComponentsFeed.sys.mjs` talks to it, care must + be given to ensure train-hop compatibility if either changes. + +#### Validation Rules for Registrants + +- Registrants must extend `BaseAboutNewTabComponentRegistrant` +- Component configurations must have `type`, `componentURL`, and `tagName` +- Duplicate types are rejected (first registrant wins) +- Invalid configurations are logged but don't break the system + +### 2. ExternalComponentsFeed + +**Location**: `browser/extensions/newtab/lib/ExternalComponentsFeed.sys.mjs` + +The feed connects the registry to the Redux store and manages component data distribution. + +The feed instantiates and has responsibility over the `AboutNewTabComponentRegistry` +instance. + +This feed lives within `browser/extensions/newtab`, and will train-hop - however, +it depends on `AboutNewTabComponents.sys.mjs`, which does not train-hop. Care must +be given to ensure train-hop compatibility if either changes. + +#### Responsibilities + +- Initializes on `INIT` action +- Queries the registry for all registered components +- Dispatches `REFRESH_EXTERNAL_COMPONENTS` to broadcast component data to content processes +- Responds to registry `UPDATED_EVENT` to refresh component data + +#### Data Flow + +``` +INIT action + ↓ +ExternalComponentsFeed.onAction() + ↓ +refreshComponents() + ↓ +AboutNewTabComponentRegistry instance.values() + ↓ +ac.BroadcastToContent(REFRESH_EXTERNAL_COMPONENTS, [...components]) + ↓ +Redux Store (ExternalComponents state) +``` + +### 3. ExternalComponentWrapper + +**Location**: `browser/extensions/newtab/content-src/components/ExternalComponentWrapper/ExternalComponentWrapper.jsx` + +A React component that loads and renders external custom elements. + +#### Component Lifecycle + +```javascript +<ExternalComponentWrapper type="SEARCH" className="search-wrapper" /> +``` + +**Mount**: +1. Look up configuration by type from Redux store +2. If no config, log warning and return +3. Dynamically import the component module +4. Create and append localization link elements to document head +5. Create the custom element via `document.createElement()` +6. Apply attributes and CSS variables from configuration +7. Append custom element to container div + +**Unmount**: +1. Remove custom element from DOM +2. Remove localization link elements + +#### Key Implementation Details + +- Uses `useEffect` with dependency on `[type, ExternalComponents.components]` +- Uses `importModule` prop for dependency injection (enables testing) +- Uses refs to track custom element and l10n links +- Renders error state (null) if component loading fails +- Prevents duplicate element creation on re-renders + +## Complete Data Flow + +``` +1. Feature registers with category manager + +2. AboutNewTabComponentRegistry observes category change + +3. Registry emits UPDATED_EVENT + +4. On ActivityStream INIT: + ExternalComponentsFeed.onAction(INIT) + → refreshComponents() + → dispatch(BroadcastToContent(REFRESH_EXTERNAL_COMPONENTS)) + +5. Redux reducer updates state.ExternalComponents + +6. ExternalComponentWrappers do the work of mapping configurations to hook + points within the DOM. + <ExternalComponentWrapper type="SEARCH" /> + → connect(state => ({ ExternalComponents: state.ExternalComponents })) + +7. Component loads and renders at the ExternalComponentWrapper hook point: + useEffect → import(componentURL) → createElement(tagName) → appendChild() +``` + +## Adding New Features + +### For the New Tab Team + +When adding support for a new component placement: + +1. Add `<ExternalComponentWrapper>` to the desired location in your React component +2. Specify the `type` prop matching the component type +3. Add appropriate CSS for the wrapper element +4. Update tests to account for the new component location + +Example: +```jsx +<div className="newtab-search-section"> + <ExternalComponentWrapper + type="SEARCH" + className="search-handoff-wrapper" + /> +</div> +``` + +## Error Handling + +The system is designed to be resilient: + +- Invalid registrants are logged but don't crash the registry +- Invalid component configurations are skipped +- Component loading errors are caught and logged +- Failed components render null without breaking the page + +All errors are logged to the browser console with descriptive messages. + +## Future Improvements + +Potential areas for enhancement: + +- Add support for component communication to the parent process via custom events and subclassable JSActor pairs +- Add support for React components (not just custom elements) +- Add component lifecycle hooks for more complex initialization +- Add support for conditional rendering based on prefs or experiments +- Add performance monitoring for component load times +- Add support for component updates without full remount +- Add support for opt-in train-hopping for external components + +## Debugging + +### Logging + +Enable verbose logging: +``` +browser.newtabpage.activity-stream.externalComponents.log=true +``` diff --git a/browser/extensions/newtab/docs/v2-system-addon/external_components_guide.md b/browser/extensions/newtab/docs/v2-system-addon/external_components_guide.md @@ -0,0 +1,274 @@ +# External Components Registration Guide + +This guide is for Firefox feature teams who want to embed custom web components into the about:newtab and about:home pages. + +## Overview + +The External Components system allows you to register custom web components that will be displayed on the New Tab page without modifying the New Tab codebase. You provide a registrant module that returns component configurations, and the New Tab system handles loading and rendering your components. + +## Getting Started + +The first step is always to engage with the New Tab team in order to arrange for your component to be integrated, as there's some work that must occur within the New Tab code to allow this. + +The New Tab team will help guide you as you develop your component so that it integrates properly and sustainably. The intent of this mechanism is so that external teams can focus on providing the features that they're the experts in, while New Tab can ensure that the integration of those features maps properly to New Tab's needs. + +## Component Configuration + +Each component configuration must include: + +```javascript +{ + type: "UNIQUE_TYPE", // Required: Unique identifier for this component type. + componentURL: "chrome://...", // Required: URL to the module that defines the custom element + tagName: "custom-element", // Required: Tag name of the custom element + l10nURLs: [], // Optional: Array of localization file URLs + attributes: {}, // Optional: HTML attributes to set on the element + cssVariables: {} // Optional: CSS custom properties to set on the element +} +``` + +### Required Fields + +- **type**: A unique string identifier for your component (e.g., `"SEARCH"`, `"MY_FEATURE"`). This must be unique across all registered components. The New Tab team will assign this to you once you've started talking to them about your external component. +- **componentURL**: A chrome:// or resource:// URL pointing to the ES module that defines your custom element. +- **tagName**: The HTML tag name for your custom element (must contain a hyphen per web component standards). + +### Optional Fields + +- **l10nURLs**: Array of Fluent localization file paths (e.g., `["browser/myfeature.ftl"]`) +- **attributes**: Object mapping attribute names to values to set on your custom element +- **cssVariables**: Object mapping CSS custom property names to values for styling + +## Step-by-Step Registration + +### 1. Create a Registrant Module + +Create a module that extends `BaseAboutNewTabComponentRegistrant`: + +```javascript +// MyComponentRegistrant.sys.mjs +import { + AboutNewTabComponentRegistry, + BaseAboutNewTabComponentRegistrant, +} from "moz-src:///browser/components/newtab/AboutNewTabComponents.sys.mjs"; + +export class MyComponentRegistrant extends BaseAboutNewTabComponentRegistrant { + getComponents() { + return [ + { + type: AboutNewTabComponentRegistry.TYPES.MY_FEATURE, + componentURL: "chrome://browser/content/myfeature/component.mjs", + tagName: "my-feature-component", + l10nURLs: ["browser/myfeature.ftl"], + attributes: { + "data-feature-id": "my-feature", + "role": "region" + }, + cssVariables: { + "--feature-primary-color": "var(--in-content-primary-button-background)", + "--feature-spacing": "16px" + } + } + ]; + } +} +``` + +### 2. Define Your Custom Element + +Create the custom element referenced in your `componentURL`. This can be a +vanilla web component, or a `MozLitElement`. + +```javascript +// component.mjs +class MyFeatureComponent extends HTMLElement { + connectedCallback() { + const shadow = this.attachShadow({ mode: "open" }); + + shadow.innerHTML = ` + <style> + :host { + display: block; + padding: var(--feature-spacing, 12px); + color: var(--feature-primary-color, blue); + } + </style> + <div data-l10n-id="my-feature-title"></div> + <div class="content"></div> + `; + + this.render(); + } + + disconnectedCallback() { + // Clean up any event listeners or resources + } + + render() { + const content = this.shadowRoot.querySelector(".content"); + content.textContent = "My feature content"; + } +} + +customElements.define("my-feature-component", MyFeatureComponent); +``` + +### 3. Register with the Category Manager + +Register your registrant with the category manager when your feature initializes. + +Typically, this is done declaratively inside of a chrome manifest file like +`BrowserComponents.manifest`, like so: + +``` +category browser-newtab-external-component moz-src:///browser/components/my-team/MyComponentRegistrant.sys.mjs MyComponentRegistrant +``` + +Declarative is preferable, but if you need to do this dynamically, it can be done +by adding a category at runtime like so: + +```javascript +Services.catMan.addCategoryEntry( + "browser-newtab-external-component", + "moz-src:///browser/components/my-team/MyComponentRegistrant.sys.mjs", + "MyComponentRegistrant", + false, + true +); +``` + +## Best Practices + +### Use Shadow DOM + +Always use Shadow DOM to encapsulate your component's styles and avoid conflicts: + +```javascript +connectedCallback() { + const shadow = this.attachShadow({ mode: "open" }); + // Your component markup goes here +} +``` + +### Never reach into the New Tab page DOM + +**External components are forbidden** from reaching outside of themselves into the +surrounding page DOM either for reading or writing state. If there's some state +value that your component needs to read, talk to the New Tab team so that we +can expose that value to you. + +### Localization + +Use Fluent for all user-facing strings: + +1. Add your localization file to `l10nURLs` in your configuration +2. Use `data-l10n-id` attributes in your markup +3. Create corresponding entries in your .ftl file + +Example .ftl file: +```fluent +my-feature-title = My Feature +my-feature-description = This is my feature description +``` + +### Styling + +Use CSS custom properties for theming to integrate with Firefox's design system: + +```css +:host { + color: var(--in-content-text-color); + background: var(--in-content-box-background); + font: message-box; +} +``` + +### Cleanup + +Always clean up resources in `disconnectedCallback`: + +```javascript +disconnectedCallback() { + // Remove event listeners + // Cancel pending operations + // Clear timers +} +``` + +## Testing + +### Writing Tests + +Test your registrant in xpcshell tests: + +```javascript +add_task(async function test_my_registrant() { + const registrant = new MyComponentRegistrant(); + const components = registrant.getComponents(); + + Assert.equal(components.length, 1); + Assert.equal(components[0].type, "MY_FEATURE"); + Assert.equal(components[0].tagName, "my-feature-component"); +}); +``` + +### Integration Testing + +Test that your component registers correctly with the category manager: + +```javascript +add_task(async function test_component_registration() { + let registry = new AboutNewTabComponentRegistry(); + + Services.catMan.addCategoryEntry( + "browser-newtab-external-component", + "resource://gre/modules/MyComponentRegistrant.sys.mjs", + "MyComponentRegistrant", + false, + true + ); + + await TestUtils.waitForTick(); + + let components = Array.from(registry.values); + Assert.ok(components.some(c => c.type === "MY_FEATURE")); + + Services.catMan.deleteCategoryEntry( + "browser-newtab-external-component", + "resource://gre/modules/MyComponentRegistrant.sys.mjs", + false + ); + + registry.destroy(); +}); +``` + +## Debugging + +### Enable Logging + +Set this pref to enable component registration logging: + +``` +browser.newtabpage.activity-stream.externalComponents.log=true +``` + +### Common Issues + +**Component not appearing on New Tab:** +- Verify your registrant is added to the category manager +- Check that your component type is unique +- Ensure your custom element is properly defined +- Check the browser console for errors + +**Styling issues:** +- Verify you're using Shadow DOM +- Check that CSS custom properties are defined +- Ensure localization files are loaded + +**Type conflicts:** +- Each component type must be unique. If two registrants provide the same type, only the first one will be registered. + +## Support + +For questions or issues with the External Components system, contact the Firefox New Tab team or file a bug in the `Firefox :: New Tab Page` component. diff --git a/browser/extensions/newtab/lib/ActivityStream.sys.mjs b/browser/extensions/newtab/lib/ActivityStream.sys.mjs @@ -30,6 +30,8 @@ ChromeUtils.defineESModuleGetters(lazy, { DEFAULT_SITES: "resource://newtab/lib/DefaultSites.sys.mjs", DefaultPrefs: "resource://newtab/lib/ActivityStreamPrefs.sys.mjs", DiscoveryStreamFeed: "resource://newtab/lib/DiscoveryStreamFeed.sys.mjs", + ExternalComponentsFeed: + "resource://newtab/lib/ExternalComponentsFeed.sys.mjs", FaviconFeed: "resource://newtab/lib/FaviconFeed.sys.mjs", HighlightsFeed: "resource://newtab/lib/HighlightsFeed.sys.mjs", ListsFeed: "resource://newtab/lib/Widgets/ListsFeed.sys.mjs", @@ -116,6 +118,9 @@ const PREF_IMAGE_PROXY_ENABLED = const PREF_IMAGE_PROXY_ENABLED_STORE = "discoverystream.imageProxy.enabled"; +const PREF_SHOULD_ENABLE_EXTERNAL_COMPONENTS_FEED = + "browser.newtabpage.activity-stream.externalComponents.enabled"; + export const WEATHER_OPTIN_REGIONS = [ "AT", // Austria "BE", // Belgium @@ -1592,6 +1597,20 @@ const FEEDS_DATA = [ title: "Handles the data for the Timer widget", value: true, }, + { + name: "externalcomponentsfeed", + factory: () => new lazy.ExternalComponentsFeed(), + title: "Handles updating the registry of external components", + getValue() { + // This feed should only be enabled on versions of the app that have the + // AboutNewTabComponents module. Those versions of the app have this + // preference set to true. + return Services.prefs.getBoolPref( + PREF_SHOULD_ENABLE_EXTERNAL_COMPONENTS_FEED, + false + ); + }, + }, ]; const FEEDS_CONFIG = new Map(); diff --git a/browser/extensions/newtab/lib/ExternalComponentsFeed.sys.mjs b/browser/extensions/newtab/lib/ExternalComponentsFeed.sys.mjs @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { + actionTypes as at, + actionCreators as ac, +} from "resource://newtab/common/Actions.mjs"; + +const lazy = XPCOMUtils.declareLazy({ + AboutNewTabComponentRegistry: + "moz-src:///browser/components/newtab/AboutNewTabComponents.sys.mjs", +}); + +/** + * ExternalComponentsFeed manages the integration between the + * AboutNewTabComponentRegistry and the New Tab Redux store. + * + * This feed: + * - Listens to the AboutNewTabComponentRegistry for component updates + * - Dispatches REFRESH_EXTERNAL_COMPONENTS actions to update the store + * - Ensures external components are loaded during New Tab initialization + * + * External components registered through this system can be rendered on the + * newtab page via the ExternalComponentWrapper React component. + */ +export class ExternalComponentsFeed { + /** + * The AboutNewTabComponentRegistry instance that tracks registered components. + * + * @type {AboutNewTabComponentRegistry} + */ + #registry = null; + + /** + * Creates a new ExternalComponentsFeed instance. + * + * Initializes the AboutNewTabComponentRegistry and sets up a listener to + * refresh components whenever the registry updates. + */ + constructor() { + this.#registry = new lazy.AboutNewTabComponentRegistry(); + this.#registry.on(lazy.AboutNewTabComponentRegistry.UPDATED_EVENT, () => { + this.refreshComponents(); + }); + } + + /** + * Dispatches a REFRESH_EXTERNAL_COMPONENTS action with the current list of + * registered components from the registry. + * + * This action is broadcast to all content processes to update their Redux + * stores with the latest component configurations. + * + * @param {object} options - Optional configuration + * @param {boolean} [options.isStartup=false] - If true, marks the action as a + * startup action (meta.isStartup: true), which prevents the cached + * about:home document from unnecessarily reprocessing the action. + */ + refreshComponents(options = {}) { + const action = { + type: at.REFRESH_EXTERNAL_COMPONENTS, + data: [...this.#registry.values], + }; + + if (options.isStartup) { + action.meta = { isStartup: true }; + } + + this.store.dispatch(ac.BroadcastToContent(action)); + } + + /** + * Handles Redux actions dispatched to this feed. + * + * Currently handles: + * - INIT: Refreshes components when Activity Stream initializes, marking + * the action as a startup action to optimize cached document handling. + * + * @param {object} action - The Redux action to handle + * @param {string} action.type - The action type + */ + onAction(action) { + switch (action.type) { + case at.INIT: + this.refreshComponents({ isStartup: true }); + break; + } + } +} diff --git a/browser/extensions/newtab/lib/PrefsFeed.sys.mjs b/browser/extensions/newtab/lib/PrefsFeed.sys.mjs @@ -349,6 +349,7 @@ export class PrefsFeed { this._setStringPref(values, "discoverystream.spocs-endpoint-query", ""); this._setStringPref(values, "newNewtabExperience.colors", ""); this._setBoolPref(values, "search.useHandoffComponent", false); + this._setBoolPref(values, "externalComponents.enabled", false); // Set the initial state of all prefs in redux this.store.dispatch( diff --git a/browser/extensions/newtab/test/unit/common/Reducers.test.js b/browser/extensions/newtab/test/unit/common/Reducers.test.js @@ -9,6 +9,7 @@ const { Personalization, DiscoveryStream, Search, + ExternalComponents, } = reducers; import { actionTypes as at } from "common/Actions.mjs"; @@ -1171,4 +1172,71 @@ describe("Reducers", () => { assert.propertyVal(nextState, "disable", false); }); }); + describe("ExternalComponents", () => { + it("should return INITIAL_STATE by default", () => { + const nextState = ExternalComponents(undefined, { type: "some_action" }); + assert.equal(nextState, INITIAL_STATE.ExternalComponents); + }); + it("should return initial state with empty components array", () => { + const nextState = ExternalComponents(undefined, { type: "some_action" }); + assert.deepEqual(nextState.components, []); + }); + it("should update components on REFRESH_EXTERNAL_COMPONENTS", () => { + const testComponents = [ + { + type: "SEARCH", + componentURL: "chrome://test/content/component.mjs", + tagName: "test-component", + l10nURLs: [], + }, + ]; + const nextState = ExternalComponents(undefined, { + type: at.REFRESH_EXTERNAL_COMPONENTS, + data: testComponents, + }); + assert.deepEqual(nextState.components, testComponents); + }); + it("should preserve other state when updating components", () => { + const testComponents = [ + { + type: "SEARCH", + componentURL: "chrome://test/content/component.mjs", + tagName: "test-component", + l10nURLs: [], + }, + ]; + const prevState = { components: [], otherProp: "value" }; + const nextState = ExternalComponents(prevState, { + type: at.REFRESH_EXTERNAL_COMPONENTS, + data: testComponents, + }); + assert.deepEqual(nextState.components, testComponents); + assert.propertyVal(nextState, "otherProp", "value"); + }); + it("should replace existing components on REFRESH_EXTERNAL_COMPONENTS", () => { + const oldComponents = [ + { + type: "OLD", + componentURL: "chrome://old/content/component.mjs", + tagName: "old-component", + l10nURLs: [], + }, + ]; + const newComponents = [ + { + type: "NEW", + componentURL: "chrome://new/content/component.mjs", + tagName: "new-component", + l10nURLs: [], + }, + ]; + const prevState = { components: oldComponents }; + const nextState = ExternalComponents(prevState, { + type: at.REFRESH_EXTERNAL_COMPONENTS, + data: newComponents, + }); + assert.deepEqual(nextState.components, newComponents); + assert.notDeepEqual(nextState.components, oldComponents); + }); + }); }); diff --git a/browser/extensions/newtab/test/unit/content-src/components/ExternalComponentWrapper.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/ExternalComponentWrapper.test.jsx @@ -0,0 +1,324 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { mount } from "enzyme"; +import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; +import { combineReducers, createStore } from "redux"; +import { Provider } from "react-redux"; +import { ExternalComponentWrapper } from "content-src/components/ExternalComponentWrapper/ExternalComponentWrapper"; +import React from "react"; + +const DEFAULT_PROPS = { + type: "SEARCH", + className: "test-wrapper", +}; + +const flushPromises = () => new Promise(resolve => queueMicrotask(resolve)); + +const createMockConfig = (overrides = {}) => ({ + type: "SEARCH", + componentURL: "chrome://test/content/component.mjs", + tagName: "test-component", + l10nURLs: [], + ...overrides, +}); + +const createStateWithConfig = config => ({ + ...INITIAL_STATE, + ExternalComponents: { + components: [config], + }, +}); + +const createMockElement = sandbox => { + const element = document.createElement("div"); + sandbox.spy(element, "setAttribute"); + sandbox.spy(element.style, "setProperty"); + return element; +}; + +// Wrap this around any component that uses useSelector, +// or any mount that uses a child that uses redux. +function WrapWithProvider({ children, state = INITIAL_STATE }) { + let store = createStore(combineReducers(reducers), state); + return <Provider store={store}>{children}</Provider>; +} + +describe("<ExternalComponentWrapper>", () => { + let globals; + let sandbox; + const TestWrapper = ExternalComponentWrapper; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + }); + + afterEach(() => { + globals.restore(); + }); + + const stubCreateElement = handlers => { + const originalCreateElement = document.createElement.bind(document); + return sandbox.stub(document, "createElement").callsFake(tagName => { + if (handlers[tagName]) { + return handlers[tagName](); + } + return originalCreateElement(tagName); + }); + }; + + it("should render a container div", () => { + const wrapper = mount( + <WrapWithProvider> + <TestWrapper {...DEFAULT_PROPS} /> + </WrapWithProvider> + ); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find("div").length, 1); + }); + + it("should apply className to container div", () => { + const wrapper = mount( + <WrapWithProvider> + <TestWrapper {...DEFAULT_PROPS} /> + </WrapWithProvider> + ); + assert.equal(wrapper.find("div.test-wrapper").length, 1); + }); + + it("should warn when no configuration is found for type", async () => { + const consoleWarnStub = sandbox.stub(console, "warn"); + mount( + <WrapWithProvider> + <TestWrapper {...DEFAULT_PROPS} /> + </WrapWithProvider> + ); + + await flushPromises(); + + assert.calledWith( + consoleWarnStub, + "No external component configuration found for type: SEARCH" + ); + }); + + it("should not render custom element without configuration", async () => { + const importModuleStub = sandbox.stub().resolves(); + const wrapper = mount( + <WrapWithProvider> + <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> + </WrapWithProvider> + ); + + await flushPromises(); + + assert.notCalled(importModuleStub); + wrapper.unmount(); + }); + + it("should load component module when configuration is available", async () => { + const mockConfig = createMockConfig(); + const stateWithConfig = createStateWithConfig(mockConfig); + const importModuleStub = sandbox.stub().resolves(); + + const wrapper = mount( + <WrapWithProvider state={stateWithConfig}> + <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> + </WrapWithProvider> + ); + await flushPromises(); + + assert.calledWith(importModuleStub, mockConfig.componentURL); + wrapper.unmount(); + }); + + it("should create custom element with correct tag name", async () => { + const mockConfig = createMockConfig(); + const stateWithConfig = createStateWithConfig(mockConfig); + const mockElement = createMockElement(sandbox); + const importModuleStub = sandbox.stub().resolves(); + + const createElementStub = stubCreateElement({ + "test-component": () => mockElement, + }); + + const wrapper = mount( + <WrapWithProvider state={stateWithConfig}> + <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> + </WrapWithProvider> + ); + await flushPromises(); + + assert.calledWith(createElementStub, "test-component"); + wrapper.unmount(); + }); + + it("should add l10n link elements to document head", async () => { + const mockConfig = createMockConfig({ + l10nURLs: ["browser/test.ftl", "browser/test2.ftl"], + }); + const stateWithConfig = createStateWithConfig(mockConfig); + const mockLinkElement = { rel: "", href: "", remove: sandbox.spy() }; + const importModuleStub = sandbox.stub().resolves(); + + stubCreateElement({ + link: () => mockLinkElement, + "test-component": () => createMockElement(sandbox), + }); + + const appendChildStub = sandbox.stub(document.head, "appendChild"); + + const wrapper = mount( + <WrapWithProvider state={stateWithConfig}> + <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> + </WrapWithProvider> + ); + await flushPromises(); + + assert.equal(appendChildStub.callCount, 2, "Should append two l10n links"); + assert.equal(mockLinkElement.rel, "localization"); + wrapper.unmount(); + }); + + it("should set attributes on custom element", async () => { + const mockConfig = createMockConfig({ + attributes: { + "data-test": "value", + role: "search", + }, + }); + const stateWithConfig = createStateWithConfig(mockConfig); + const mockElement = createMockElement(sandbox); + const importModuleStub = sandbox.stub().resolves(); + + stubCreateElement({ + "test-component": () => mockElement, + }); + + const wrapper = mount( + <WrapWithProvider state={stateWithConfig}> + <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> + </WrapWithProvider> + ); + await flushPromises(); + + assert.calledWith(mockElement.setAttribute, "data-test", "value"); + assert.calledWith(mockElement.setAttribute, "role", "search"); + wrapper.unmount(); + }); + + it("should set CSS variables on custom element", async () => { + const mockConfig = createMockConfig({ + cssVariables: { + "--test-color": "blue", + "--test-size": "10px", + }, + }); + const stateWithConfig = createStateWithConfig(mockConfig); + const mockElement = createMockElement(sandbox); + const importModuleStub = sandbox.stub().resolves(); + + stubCreateElement({ + "test-component": () => mockElement, + }); + + const wrapper = mount( + <WrapWithProvider state={stateWithConfig}> + <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> + </WrapWithProvider> + ); + await flushPromises(); + + assert.calledWith(mockElement.style.setProperty, "--test-color", "blue"); + assert.calledWith(mockElement.style.setProperty, "--test-size", "10px"); + wrapper.unmount(); + }); + + it("should handle component load errors gracefully", async () => { + const mockConfig = createMockConfig(); + const stateWithConfig = createStateWithConfig(mockConfig); + const consoleErrorStub = sandbox.stub(console, "error"); + const importModuleStub = sandbox + .stub() + .rejects(new Error("Module load failed")); + + const wrapper = mount( + <WrapWithProvider state={stateWithConfig}> + <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> + </WrapWithProvider> + ); + await flushPromises(); + wrapper.update(); + + assert.calledWith( + consoleErrorStub, + "Failed to load external component for type SEARCH:", + sinon.match.instanceOf(Error) + ); + + assert.equal(wrapper.html(), "", "Should render null on error"); + wrapper.unmount(); + }); + + it("should clean up l10n links on unmount", async () => { + const mockConfig = createMockConfig({ + l10nURLs: ["browser/test.ftl"], + }); + const stateWithConfig = createStateWithConfig(mockConfig); + const mockLinkElements = []; + const importModuleStub = sandbox.stub().resolves(); + + stubCreateElement({ + "test-component": () => createMockElement(sandbox), + link: () => { + const linkEl = { remove: sandbox.spy() }; + mockLinkElements.push(linkEl); + return linkEl; + }, + }); + + sandbox.stub(document.head, "appendChild"); + + const wrapper = mount( + <WrapWithProvider state={stateWithConfig}> + <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> + </WrapWithProvider> + ); + await flushPromises(); + + assert.equal(mockLinkElements.length, 1, "Should create one l10n link"); + + wrapper.unmount(); + + assert.called(mockLinkElements[0].remove); + }); + + it("should not create duplicate elements on multiple renders", async () => { + const mockConfig = createMockConfig(); + const stateWithConfig = createStateWithConfig(mockConfig); + const mockElement = createMockElement(sandbox); + const importModuleStub = sandbox.stub().resolves(); + + const createElementStub = stubCreateElement({ + "test-component": () => mockElement, + }); + + const wrapper = mount( + <WrapWithProvider state={stateWithConfig}> + <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> + </WrapWithProvider> + ); + await flushPromises(); + + const initialCallCount = createElementStub.callCount; + + wrapper.setProps({ className: "new-class" }); + await flushPromises(); + + assert.equal( + createElementStub.callCount, + initialCallCount, + "Should not create element again on re-render with same type" + ); + wrapper.unmount(); + }); +}); diff --git a/browser/extensions/newtab/test/xpcshell/test_ExternalComponentsFeed.js b/browser/extensions/newtab/test/xpcshell/test_ExternalComponentsFeed.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExternalComponentsFeed: + "resource://newtab/lib/ExternalComponentsFeed.sys.mjs", + actionTypes: "resource://newtab/common/Actions.mjs", + actionCreators: "resource://newtab/common/Actions.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +/** + * Tests that ExternalComponentsFeed can be constructed successfully. + */ +add_task(async function test_construction() { + info("ExternalComponentsFeed should construct with registry"); + + const feed = new ExternalComponentsFeed(); + + Assert.ok(feed, "Could construct an ExternalComponentsFeed"); +}); + +/** + * Tests that the INIT action triggers refreshComponents to be called with isStartup flag. + */ +add_task(async function test_onAction_INIT_dispatches_refresh() { + info( + "ExternalComponentsFeed.onAction INIT should refresh components with isStartup" + ); + + const feed = new ExternalComponentsFeed(); + const dispatchSpy = sinon.spy(); + + feed.store = { + dispatch: dispatchSpy, + }; + + sinon.stub(feed, "refreshComponents"); + + await feed.onAction({ + type: actionTypes.INIT, + }); + + Assert.ok( + feed.refreshComponents.calledOnce, + "refreshComponents should be called on INIT" + ); + + Assert.ok( + feed.refreshComponents.calledWith({ isStartup: true }), + "refreshComponents should be called with isStartup: true" + ); + + feed.refreshComponents.restore(); +}); + +/** + * Tests that refreshComponents dispatches a REFRESH_EXTERNAL_COMPONENTS action + * with the correct structure and routing metadata. + */ +add_task(async function test_refreshComponents_dispatches_action() { + info("ExternalComponentsFeed.refreshComponents should dispatch broadcast"); + + const feed = new ExternalComponentsFeed(); + const dispatchSpy = sinon.spy(); + + feed.store = { + dispatch: dispatchSpy, + }; + + feed.refreshComponents(); + + Assert.ok(dispatchSpy.calledOnce, "dispatch should be called"); + + const [action] = dispatchSpy.firstCall.args; + Assert.equal(action.type, actionTypes.REFRESH_EXTERNAL_COMPONENTS); + Assert.ok(Array.isArray(action.data), "data should be an array"); +}); + +/** + * Tests that the dispatched action includes component data as an array. + */ +add_task(async function test_refreshComponents_includes_registry_values() { + info( + "ExternalComponentsFeed.refreshComponents should include all components" + ); + + const feed = new ExternalComponentsFeed(); + const dispatchSpy = sinon.spy(); + + feed.store = { + dispatch: dispatchSpy, + }; + + feed.refreshComponents(); + + Assert.ok(dispatchSpy.calledOnce, "dispatch should be called"); + + const [action] = dispatchSpy.firstCall.args; + Assert.ok( + Array.isArray(action.data), + "Dispatched data should be an array of components" + ); +}); + +/** + * Tests that refreshComponents marks the action as a startup action when isStartup is true. + */ +add_task(async function test_refreshComponents_marks_startup_action() { + info( + "ExternalComponentsFeed.refreshComponents should mark action as startup when isStartup is true" + ); + + const feed = new ExternalComponentsFeed(); + const dispatchSpy = sinon.spy(); + + feed.store = { + dispatch: dispatchSpy, + }; + + feed.refreshComponents({ isStartup: true }); + + Assert.ok(dispatchSpy.calledOnce, "dispatch should be called"); + + const [action] = dispatchSpy.firstCall.args; + Assert.equal( + action.meta?.isStartup, + true, + "Action should have meta.isStartup set to true" + ); +}); + +/** + * Tests that refreshComponents does not mark the action as startup when isStartup is false or not provided. + */ +add_task(async function test_refreshComponents_non_startup_action() { + info( + "ExternalComponentsFeed.refreshComponents should not mark action as startup by default" + ); + + const feed = new ExternalComponentsFeed(); + const dispatchSpy = sinon.spy(); + + feed.store = { + dispatch: dispatchSpy, + }; + + feed.refreshComponents(); + + Assert.ok(dispatchSpy.calledOnce, "dispatch should be called"); + + const [action] = dispatchSpy.firstCall.args; + Assert.ok( + !action.meta?.isStartup, + "Action should not have meta.isStartup set" + ); +}); diff --git a/browser/extensions/newtab/test/xpcshell/xpcshell.toml b/browser/extensions/newtab/test/xpcshell/xpcshell.toml @@ -15,6 +15,8 @@ support-files = ["topstories.json"] ["test_AdsFeed.js"] +["test_ExternalComponentsFeed.js"] + ["test_HighlightsFeed.js"] ["test_InferredFeatureModel.js"]