gecko-dev/browser/components/preferences/experimental.js
Beth Rennie 84b133c757 Bug 1956080 - Make the unenroll flow (mostly) async r=nimbus-reviewers,relud,settings-reviewers,omc-reviewers,emcminn,bytesized
Writing enrollments to the SQL database is an async process, so the
entire unenroll flow needs to become async. This patch lays the
groundwork for making that happen by updating our testing helpers to use
async functions, as well as adding some new helpers for asserting the
state of the enrollments database.

For now the unenroll() (_unenroll()) functions are marked async but
otherwise have no behavioural changes -- this is just a first step to
port all the tests over before landing changes that write to the
enrollments store (which have to all be landed together).

Most callers of unenroll() have been updated so that they await the
result. There are a few callers left that do not await the result,
however, mostly because doing so causes race conditions in tests (most
notably in the pref observers in ExperimentManager and the
PrefFlipsFeature). These issues will be addressed in bug 1956082.

Differential Revision: https://phabricator.services.mozilla.com/D250504
2025-05-22 18:22:02 +00:00

221 lines
6.4 KiB
JavaScript

/* 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-globals-from preferences.js */
ChromeUtils.defineESModuleGetters(this, {
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
FirefoxLabs: "resource://nimbus/FirefoxLabs.sys.mjs",
});
const gExperimentalPane = {
inited: false,
_featureGatesContainer: null,
_firefoxLabs: null,
async init() {
if (this.inited) {
return;
}
this.inited = true;
this._featureGatesContainer = document.getElementById(
"pane-experimental-featureGates"
);
this._onCheckboxChanged = this._onCheckboxChanged.bind(this);
this._onNimbusUpdate = this._onNimbusUpdate.bind(this);
this._onStudiesEnabledChanged = this._onStudiesEnabledChanged.bind(this);
this._resetAllFeatures = this._resetAllFeatures.bind(this);
setEventListener(
"experimentalCategory-reset",
"click",
this._resetAllFeatures
);
Services.obs.addObserver(
this._onStudiesEnabledChanged,
ExperimentAPI.STUDIES_ENABLED_CHANGED
);
window.addEventListener("unload", () => this._removeObservers());
await this._maybeRenderLabsRecipes();
},
async _maybeRenderLabsRecipes() {
this._firefoxLabs = await FirefoxLabs.create();
const shouldHide = this._firefoxLabs.count === 0;
this._setCategoryVisibility(shouldHide);
if (shouldHide) {
return;
}
const frag = document.createDocumentFragment();
const groups = new Map();
for (const optIn of this._firefoxLabs.all()) {
if (!groups.has(optIn.firefoxLabsGroup)) {
groups.set(optIn.firefoxLabsGroup, []);
}
groups.get(optIn.firefoxLabsGroup).push(optIn);
}
for (const [group, optIns] of groups) {
const card = document.createElement("moz-card");
card.classList.add("featureGate");
const fieldset = document.createElement("moz-fieldset");
document.l10n.setAttributes(fieldset, group);
card.append(fieldset);
for (const optIn of optIns) {
const checkbox = document.createElement("moz-checkbox");
checkbox.dataset.nimbusSlug = optIn.slug;
checkbox.dataset.nimbusBranchSlug = optIn.branches[0].slug;
const description = document.createElement("div");
description.slot = "description";
description.id = `${optIn.slug}-description`;
description.classList.add("featureGateDescription");
for (const [key, value] of Object.entries(
optIn.firefoxLabsDescriptionLinks ?? {}
)) {
const link = document.createElement("a");
link.setAttribute("data-l10n-name", key);
link.setAttribute("href", value);
link.setAttribute("target", "_blank");
description.append(link);
}
document.l10n.setAttributes(description, optIn.firefoxLabsDescription);
checkbox.id = optIn.slug;
checkbox.setAttribute("aria-describedby", description.id);
document.l10n.setAttributes(checkbox, optIn.firefoxLabsTitle);
checkbox.checked =
ExperimentAPI.manager.store.get(optIn.slug)?.active ?? false;
checkbox.addEventListener("change", this._onCheckboxChanged);
checkbox.append(description);
fieldset.append(checkbox);
}
frag.append(card);
}
this._featureGatesContainer.appendChild(frag);
ExperimentAPI.manager.store.on("update", this._onNimbusUpdate);
Services.obs.notifyObservers(window, "experimental-pane-loaded");
},
_removeLabsRecipes() {
ExperimentAPI.manager.store.off("update", this._onNimbusUpdate);
this._featureGatesContainer
.querySelectorAll(".featureGate")
.forEach(el => el.remove());
},
async _onCheckboxChanged(event) {
const target = event.target;
const slug = target.dataset.nimbusSlug;
const branchSlug = target.dataset.nimbusBranchSlug;
const enrolling = !(ExperimentAPI.manager.store.get(slug)?.active ?? false);
let shouldRestart = false;
if (this._firefoxLabs.get(slug).requiresRestart) {
const buttonIndex = await confirmRestartPrompt(enrolling, 1, true, false);
shouldRestart = buttonIndex === CONFIRM_RESTART_PROMPT_RESTART_NOW;
if (!shouldRestart) {
// The user declined to restart, so we will not enroll in the opt-in.
target.checked = false;
return;
}
}
// Disable the checkbox so that the user cannot interact with it during enrollment.
target.disabled = true;
if (enrolling) {
await this._firefoxLabs.enroll(slug, branchSlug);
} else {
await this._firefoxLabs.unenroll(slug);
}
target.disabled = false;
if (shouldRestart) {
Services.startup.quit(
Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
);
}
},
_onNimbusUpdate(_event, { slug, active }) {
if (this._firefoxLabs.get(slug)) {
document.getElementById(slug).checked = active;
}
},
async _onStudiesEnabledChanged() {
const studiesEnabled = ExperimentAPI.studiesEnabled;
if (studiesEnabled) {
await this._maybeRenderLabsRecipes();
} else {
this._setCategoryVisibility(true);
this._removeLabsRecipes();
this._firefoxLabs = null;
}
},
_removeObservers() {
ExperimentAPI.manager.store.off("update", this._onNimbusUpdate);
Services.obs.removeObserver(
this._onStudiesEnabledChanged,
ExperimentAPI.STUDIES_ENABLED_CHANGED
);
},
// Reset the features to their default values
async _resetAllFeatures() {
for (const optIn of this._firefoxLabs.all()) {
const enrolled =
(await ExperimentAPI.manager.store.get(optIn.slug)?.active) ?? false;
if (enrolled) {
await this._firefoxLabs.unenroll(optIn.slug);
}
}
},
_setCategoryVisibility(shouldHide) {
document.getElementById("category-experimental").hidden = shouldHide;
// Cache the visibility so we can show it quicker in subsequent loads.
Services.prefs.setBoolPref(
"browser.preferences.experimental.hidden",
shouldHide
);
if (
shouldHide &&
document.getElementById("categories").selectedItem?.id ==
"category-experimental"
) {
// Leave the 'experimental' category if there are no available features
gotoPref("general");
}
},
};