/* 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/. */ // @ts-check "use strict"; /** * @typedef {import("../@types/perf").Action} Action * @typedef {import("../@types/perf").State} State * @typedef {import("../@types/perf").RecordingState} RecordingState * @typedef {import("../@types/perf").InitializedValues} InitializedValues * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings */ /** * @template S * @typedef {import("../@types/perf").Reducer} Reducer */ /** * The current state of the recording. * @type {Reducer} */ // eslint-disable-next-line complexity function recordingState(state = "not-yet-known", action) { switch (action.type) { case "REPORT_PROFILER_READY": { // It's theoretically possible we got an event that already let us know about // the current state of the profiler. if (state !== "not-yet-known") { return state; } const { isActive } = action; if (isActive) { return "recording"; } return "available-to-record"; } case "REPORT_PROFILER_STARTED": switch (state) { case "not-yet-known": // We couldn't have started it yet, so it must have been someone // else. (fallthrough) case "available-to-record": // We aren't recording, someone else started it up. (fallthrough) case "request-to-stop-profiler": // We requested to stop the profiler, but someone else already started // it up. (fallthrough) case "request-to-get-profile-and-stop-profiler": return "recording"; case "request-to-start-recording": // Wait for the profiler to tell us that it has started. return "recording"; case "recording": // These state cases don't make sense to happen, and means we have a logical // fallacy somewhere. throw new Error( "The profiler started recording, when it shouldn't have " + `been able to. Current state: "${state}"` ); default: throw new Error("Unhandled recording state"); } case "REPORT_PROFILER_STOPPED": switch (state) { case "not-yet-known": case "request-to-get-profile-and-stop-profiler": case "request-to-stop-profiler": return "available-to-record"; case "request-to-start-recording": // Highly unlikely, but someone stopped the recorder, this is fine. // Do nothing. return state; case "recording": return "available-to-record"; case "available-to-record": throw new Error( "The profiler stopped recording, when it shouldn't have been able to." ); default: throw new Error("Unhandled recording state"); } case "REQUESTING_TO_START_RECORDING": return "request-to-start-recording"; case "REQUESTING_TO_STOP_RECORDING": return "request-to-stop-profiler"; case "REQUESTING_PROFILE": return "request-to-get-profile-and-stop-profiler"; case "OBTAINED_PROFILE": return "available-to-record"; default: return state; } } /** * Whether or not the recording state unexpectedly stopped. This allows * the UI to display a helpful message. * @param {RecordingState | undefined} recState * @param {boolean} state * @param {Action} action * @returns {boolean} */ function recordingUnexpectedlyStopped(recState, state = false, action) { switch (action.type) { case "REPORT_PROFILER_STOPPED": if ( recState === "recording" || recState == "request-to-start-recording" ) { return true; } return state; case "REPORT_PROFILER_STARTED": return false; default: return state; } } /** * The profiler needs to be queried asynchronously on whether or not * it supports the user's platform. * @type {Reducer} */ function isSupportedPlatform(state = null, action) { switch (action.type) { case "INITIALIZE_STORE": return action.isSupportedPlatform; default: return state; } } /** * This object represents the default recording settings. They should be * overriden by whatever is read from the Firefox preferences at load time. * @type {RecordingSettings} */ const DEFAULT_RECORDING_SETTINGS = { // The preset name. presetName: "", // The setting for the recording interval. Defaults to 1ms. interval: 1, // The number of entries in the profiler's circular buffer. entries: 0, // The features that are enabled for the profiler. features: [], // The thread list threads: [], // The objdirs list objdirs: [], // The client doesn't implement durations yet. See Bug 1587165. duration: 0, }; /** * This small utility returns true if the parameters contain the same values. * This is essentially a deepEqual operation specific to this structure. * @param {RecordingSettings} a * @param {RecordingSettings} b * @return {boolean} */ function areSettingsEquals(a, b) { if (a === b) { return true; } /* Simple properties */ /* These types look redundant, but they actually help TypeScript assess that * the following code is correct, as well as prevent typos. */ /** @type {Array<"presetName" | "interval" | "entries" | "duration">} */ const simpleProperties = ["presetName", "interval", "entries", "duration"]; /* arrays */ /** @type {Array<"features" | "threads" | "objdirs">} */ const arrayProperties = ["features", "threads", "objdirs"]; for (const property of simpleProperties) { if (a[property] !== b[property]) { return false; } } for (const property of arrayProperties) { if (a[property].length !== b[property].length) { return false; } const arrayA = a[property].slice().sort(); const arrayB = b[property].slice().sort(); if (arrayA.some((valueA, i) => valueA !== arrayB[i])) { return false; } } return true; } /** * This handles all values used as recording settings. * @type {Reducer} */ function recordingSettings(state = DEFAULT_RECORDING_SETTINGS, action) { /** * @template {keyof RecordingSettings} K * @param {K} settingName * @param {RecordingSettings[K]} settingValue * @return {RecordingSettings} */ function changeOneSetting(settingName, settingValue) { if (state[settingName] === settingValue) { // Do not change the state if the new value equals the old value. return state; } return { ...state, [settingName]: settingValue, presetName: "custom", }; } switch (action.type) { case "CHANGE_INTERVAL": return changeOneSetting("interval", action.interval); case "CHANGE_ENTRIES": return changeOneSetting("entries", action.entries); case "CHANGE_FEATURES": return changeOneSetting("features", action.features); case "CHANGE_THREADS": return changeOneSetting("threads", action.threads); case "CHANGE_OBJDIRS": return changeOneSetting("objdirs", action.objdirs); case "CHANGE_PRESET": return action.preset ? { ...state, presetName: action.presetName, interval: action.preset.interval, entries: action.preset.entries, features: action.preset.features, threads: action.preset.threads, // The client doesn't implement durations yet. See Bug 1587165. duration: action.preset.duration, } : { ...state, presetName: action.presetName, // it's probably "custom". }; case "UPDATE_SETTINGS_FROM_PREFERENCES": if (areSettingsEquals(state, action.recordingSettingsFromPreferences)) { return state; } return { ...action.recordingSettingsFromPreferences }; default: return state; } } /** * These are all the values used to initialize the profiler. They should never * change once added to the store. * * @type {Reducer} */ function initializedValues(state = null, action) { switch (action.type) { case "INITIALIZE_STORE": return { presets: action.presets, pageContext: action.pageContext, supportedFeatures: action.supportedFeatures, openRemoteDevTools: action.openRemoteDevTools, }; default: return state; } } /** * Some features may need a browser restart with an environment flag. Request * one here. * * @type {Reducer} */ function promptEnvRestart(state = null, action) { switch (action.type) { case "CHANGE_FEATURES": return action.promptEnvRestart; default: return state; } } /** * The main reducer for the performance-new client. * @type {Reducer} */ module.exports = (state = undefined, action) => { return { recordingState: recordingState(state?.recordingState, action), // Treat this one specially - it also gets the recordingState. recordingUnexpectedlyStopped: recordingUnexpectedlyStopped( state?.recordingState, state?.recordingUnexpectedlyStopped, action ), isSupportedPlatform: isSupportedPlatform( state?.isSupportedPlatform, action ), recordingSettings: recordingSettings(state?.recordingSettings, action), initializedValues: initializedValues(state?.initializedValues, action), promptEnvRestart: promptEnvRestart(state?.promptEnvRestart, action), }; };