New Tab Widget Scaffold
Widgets live in browser/extensions/newtab/content-src/components/Widgets/{Name}/.
The Widget Registry (common/WidgetsRegistry.mjs) is the single source of
truth for every widget. Adding a widget is now mostly declarative: you add one
registry entry plus one component-registry entry, and shared helpers
(isWidgetEnabled, resolveWidgetSize, resolveWidgetHasSidebar,
getHideAllTargets, resolveWidgetOrder), shared UI (WidgetMenuFooter,
SizeSubmenu, MoveSubmenu, WidgetWrapper), and the shared
useWidgetTelemetry hook do the wiring that used to be hand-copied into
Widgets.jsx, Base.jsx, and every widget's menu and telemetry. Do not
hand-wire enabled-logic, size derivation, hide-all, the null-guard, the menu
footer, or telemetry payloads anymore.
Naming convention (read first)
The widget id/key and telemetry name must NOT contain the word
"widget" — they already live under the widgets. pref namespace, so a *Widget
suffix is redundant. Use widgets.example.enabled, telemetry "example" — not
widgets.exampleWidget.enabled / "example_widget". The Sports widget shipped
with the redundant id sportsWidget and it caused a pref + telemetry bug that
had to be fixed (its telemetryName is "sports"). Treat the Sports *Widget
naming as legacy — do not copy it.
Telemetry (do NOT touch metrics.yaml)
Widget telemetry is fully generic and already defined once for all widgets in
browser/components/newtab/metrics.yaml: widgets_impression,
widgets_user_event, widgets_enabled, widgets_error, and
widgets_container_action. Every event is keyed by widget_name (your
telemetryName) plus widget_size, error_type, user_action, etc.
A new widget therefore adds nothing to metrics.yaml and requires no
./mach newtab channel-metrics-diff run. It emits the shared events through the
useWidgetTelemetry({ dispatch, widget, widgetSize }) hook, which returns
impressionRef (attach to the widget root — fires widgets_impression once via
its own IntersectionObserver), recordUserAction(action, { source, value })
(widgets_user_event), recordEnabled (widgets_enabled), recordImpression
(manual one-shot), and recordError (widgets_error). Do NOT hand-build these
payloads or wire up your own observer — the hook keys every event on the
registry entry's telemetryName for you.
Only edit metrics.yaml if the widget needs a genuinely new event shape that
the shared widgets_* events cannot express — which is rare. Never scaffold a
per-widget widgets.{key}.* metric.
Workflow
Step 1 — Gather requirements
Ask the user to run the requirements script first:
python3 {BASE_DIR}/scripts/gather_requirements.py
The script asks all required questions (including the registry fields:
telemetryName, order, validSizes, defaultSize, hasSidebar, trainhop
keys) and prints a widget spec plus a ready-to-paste WIDGET_REGISTRY entry.
Wait for the user to paste that summary before proceeding.
Step 2 — Plan
Enter plan mode. Using the spec and the example in
references/ExampleWidget/, propose the full list of files to create/modify
before writing anything. Read references/notes.md for non-obvious requirements
and gotchas.
The registry-centric core (do these first, in order):
common/WidgetsRegistry.mjs— add theWIDGET_REGISTRYentry (nextorderinteger) and export the widget's pref-key constants (PREF_WIDGETS_{KEY}_ENABLED,PREF_{KEY}_SIZE,PREF_WIDGETS_SYSTEM_{KEY}_ENABLED). This replaces the old per-component pref constants and the hand-wired enabled expressions —isWidgetEnabledderives everything from the entry.lib/ActivityStream.sys.mjs— register the three prefs (do this first, then run./mach build fasterbefore proceeding). The size pref'svalueis""(empty = "user hasn't chosen"; lets trainhop/defaultSizeapply).Widgets/{Name}/{Name}.jsx— the widget component. Mirrorreferences/ExampleWidget/ExampleWidget.jsx:- Look up its entry once:
const ENTRY = WIDGET_REGISTRY.find(w => w.id === "{key}"). - Derive size with
resolveWidgetSize(ENTRY, prefs)— never read the size pref directly. - Accept the standard props:
dispatch,handleUserInteraction(interactive widgets only),isMaximized,widgetsMayBeMaximized,widgetEnabledMap. - Emit telemetry through the
useWidgetTelemetry({ dispatch, widget: ENTRY, widgetSize })hook: attach the returnedimpressionRefto the widget's root element and callrecordUserAction(action, { source, value })for interactions (valuesetsaction_value; passsizeto override the reportedwidget_size, as the resize handler does). Do not hand-buildWIDGETS_IMPRESSION/WIDGETS_USER_EVENTpayloads or wire up your ownIntersectionObserver. - Render the shared
<WidgetMenuFooter>as the last child of the menu's<panel-list>; it owns the common trailing block in a fixed order (divider, Change size, Move, Hide widget, Learn more — Learn more opens in a new tab). Put widget-specific items above it. Pass the Change size submenu via thesizeSubmenuprop as<SizeSubmenu submenuId=... sizes={[...]} checkedSize={widgetSize} />, gated onwidgetsMayBeMaximized(passnullotherwise); include"small"only ifvalidSizeshas it. Do not hand-roll the size submenu,MoveSubmenu, the Hide item, or the Learn more item — that hand-rolled footer is exactly what drifted out of sync (Bug 2046045 / D306294). The footer'sonLearnMoreis only for the extralearn_moretelemetry; it dispatchesOPEN_LINK(withwhere: "tab") itself. - Render its own
<article className="{css} widget col-4 ${widgetSize}-widget">root. Do not add a per-widget Nova gate — the container (Widgets.jsx) rendersWIDGET_ROW_COMPONENTSonly in its Nova branch, so the widget already mounts only under Nova; anova.enabledearly return here is redundant. (Sports has one; it is legacy — do not copy it.)
- Look up its entry once:
Widgets/WidgetsComponentRegistry.jsx— add the component toWIDGET_ROW_COMPONENTS. If the spec hashasSidebar: true, also add a sidebar component toWIDGET_SIDEBAR_COMPONENTS— seereferences/SidebarVariant.mdfor that wiring.Widgets/Widgets.jsx— add the widget's id to the hand-maintainedwidgetEnabledMapobject (mirror theclocks/sportsWidgetentries):{key}: isWidgetEnabled(WIDGET_REGISTRY.find(w => w.id === "{key}"), prefs, widgetsEnabled). This is the one place the container is NOT yet registry-driven (see theBug 2034542TODO above the map). The render loop gates every widget onwidgetEnabledMap[id], so a widget missing from this map silently never renders — even though its registry entry, component-registry entry, and prefs are all correct. This is the single most common reason a freshly-scaffolded widget shows nothing onabout:newtab. (Until Bug 2034542 lands, "don't hand-wire enabled-logic into Widgets.jsx" has this one exception.)
The supporting files (each widget still touches these):
Widgets/{Name}/_{Name}.scss— styles.@include widget-base-styleon the root (NOTnewtab-card-style): it provides the shared per-size card height, hover, transitions, andposition: relative. Includingnewtab-card-styledirectly leaves the card with no height, so a sparse or not-yet-wired widget body collapses. Add&.medium-widget { grid-row: span 2; }and&.large-widget { grid-row: span 4; }inside the root class; add&.small-widget { grid-row: span 1; }only ifvalidSizesincludes"small".content-src/styles/activity-stream.scss— add@importof the widget SCSS.stylelint-rollouts.config.js(repo root) — add the SCSS path in alphabetical order alongside the other widget entries.lib/AboutPreferences.sys.mjs+browser/locales/en-US/browser/preferences/preferences.ftl— register the widget forabout:preferences#home(see notes.md) with ahome-prefs-{css-class}-headerstring.- Customize panel toggle (all required, see notes.md):
Base.jsx(computemayHave{Name}Widgetand pass it to both<CustomizeMenu>renders) →CustomizeMenu.jsx(passthrough) →Nova/CustomizeMenu/WidgetsManagementPanel/WidgetsManagementPanel.jsx(add themoz-toggle) →ContentSection.jsx(classic path) →browser/locales/en-US/browser/newtab/newtab.ftl(toggle label). test/jest/content-src/components/Widgets/{Name}.test.jsx— a dedicated Jest test file. New tests go in Jest (not the legacytest/unit/Enzyme suite). Note:WidgetsRegistry.test.jsxhas hardcoded expected widget-order arrays — adding an entry breaks them until you append the new id (in registry order).
Additional files only if the spec requires them:
common/Actions.mjs+common/Reducers.sys.mjs— only if Redux state is needed. NewWIDGETS_*action types must stay alphabetically sorted (a test asserts it).lib/{Name}Feed.sys.mjs— only if the widget needs a backend data feed (seeWeatherFeed.sys.mjs).
Step 3 — Scaffold
After plan approval, implement all files end-to-end without stopping between
edits. Do not pause to summarize progress or ask for confirmation mid-scaffold.
Work through every file in the plan in sequence, replicating the patterns in
references/ExampleWidget/ and substituting values from the spec.
Only stop if you hit a genuine blocker (e.g. a file doesn't exist where expected, or the codebase structure differs from what the plan assumed). In that case, explain what you found and what decision is needed before continuing.
Step 4 — Build and verify
After scaffolding, the build artifacts must be regenerated:
./mach newtab bundle— compile SCSS and JS (./mach build fasteralone does NOT recompile SCSS)./mach build faster— copy compiled artifacts to the build output- Commit the build artifacts:
css/activity-stream.css,css/nova/activity-stream.css,data/content/activity-stream.bundle.js
Step 5 — Follow-up
Always output this section in full after Step 4 — do not skip or summarize it.
Tell the user:
- Add any remaining Fluent strings for context menu items and widget body labels
- Run
./mach lint
Then output the full enable instructions below:
Option A — about:config
Set all three of these to true:
browser.newtabpage.activity-stream.widgets.system.enabled(parent gate for all widgets — defaults tofalse)browser.newtabpage.activity-stream.widgets.{key}.enabledbrowser.newtabpage.activity-stream.widgets.system.{key}.enabled
({key} is the registry id, with no Widget suffix.)
Option B — Nimbus trainhop
- Install Nimbus devtools
- Choose "Feature configuration enrollment" on the left side
- Opt into an experiment for
newtabTrainhopwith the entry'strainhopEnabledKey(e.g.{key}Enabled):
{
"type": "widgets",
"payload": {
"enabled": true,
"{key}Enabled": true
}
}