reducers.js (9750B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 // @ts-check 5 "use strict"; 6 7 /** 8 * @typedef {import("../@types/perf").Action} Action 9 * @typedef {import("../@types/perf").State} State 10 * @typedef {import("../@types/perf").RecordingState} RecordingState 11 * @typedef {import("../@types/perf").InitializedValues} InitializedValues 12 * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings 13 */ 14 15 /** 16 * @template S 17 * @typedef {import("../@types/perf").Reducer<S>} Reducer<S> 18 */ 19 20 /** 21 * The current state of the recording. 22 * 23 * @type {Reducer<RecordingState>} 24 */ 25 // eslint-disable-next-line complexity 26 function recordingState(state = "not-yet-known", action) { 27 switch (action.type) { 28 case "REPORT_PROFILER_READY": { 29 // It's theoretically possible we got an event that already let us know about 30 // the current state of the profiler. 31 if (state !== "not-yet-known") { 32 return state; 33 } 34 35 const { isActive } = action; 36 if (isActive) { 37 return "recording"; 38 } 39 return "available-to-record"; 40 } 41 42 case "REPORT_PROFILER_STARTED": 43 switch (state) { 44 case "not-yet-known": 45 // We couldn't have started it yet, so it must have been someone 46 // else. (fallthrough) 47 case "available-to-record": 48 // We aren't recording, someone else started it up. (fallthrough) 49 case "request-to-stop-profiler": 50 // We requested to stop the profiler, but someone else already started 51 // it up. (fallthrough) 52 case "request-to-get-profile-and-stop-profiler": 53 return "recording"; 54 55 case "request-to-start-recording": 56 // Wait for the profiler to tell us that it has started. 57 return "recording"; 58 59 case "recording": 60 // These state cases don't make sense to happen, and means we have a logical 61 // fallacy somewhere. 62 throw new Error( 63 "The profiler started recording, when it shouldn't have " + 64 `been able to. Current state: "${state}"` 65 ); 66 default: 67 throw new Error("Unhandled recording state"); 68 } 69 70 case "REPORT_PROFILER_STOPPED": 71 switch (state) { 72 case "not-yet-known": 73 case "request-to-get-profile-and-stop-profiler": 74 case "request-to-stop-profiler": 75 return "available-to-record"; 76 77 case "request-to-start-recording": 78 // Highly unlikely, but someone stopped the recorder, this is fine. 79 // Do nothing. 80 return state; 81 82 case "recording": 83 return "available-to-record"; 84 85 case "available-to-record": 86 throw new Error( 87 "The profiler stopped recording, when it shouldn't have been able to." 88 ); 89 default: 90 throw new Error("Unhandled recording state"); 91 } 92 93 case "REQUESTING_TO_START_RECORDING": 94 return "request-to-start-recording"; 95 96 case "REQUESTING_TO_STOP_RECORDING": 97 return "request-to-stop-profiler"; 98 99 case "REQUESTING_PROFILE": 100 return "request-to-get-profile-and-stop-profiler"; 101 102 case "OBTAINED_PROFILE": 103 return "available-to-record"; 104 105 default: 106 return state; 107 } 108 } 109 110 /** 111 * Whether or not the recording state unexpectedly stopped. This allows 112 * the UI to display a helpful message. 113 * 114 * @param {RecordingState | undefined} recState 115 * @param {boolean} state 116 * @param {Action} action 117 * @returns {boolean} 118 */ 119 function recordingUnexpectedlyStopped(recState, state = false, action) { 120 switch (action.type) { 121 case "REPORT_PROFILER_STOPPED": 122 if ( 123 recState === "recording" || 124 recState == "request-to-start-recording" 125 ) { 126 return true; 127 } 128 return state; 129 case "REPORT_PROFILER_STARTED": 130 return false; 131 default: 132 return state; 133 } 134 } 135 136 /** 137 * The profiler needs to be queried asynchronously on whether or not 138 * it supports the user's platform. 139 * 140 * @type {Reducer<boolean | null>} 141 */ 142 function isSupportedPlatform(state = null, action) { 143 switch (action.type) { 144 case "INITIALIZE_STORE": 145 return action.isSupportedPlatform; 146 default: 147 return state; 148 } 149 } 150 151 /** 152 * This object represents the default recording settings. They should be 153 * overriden by whatever is read from the Firefox preferences at load time. 154 * 155 * @type {RecordingSettings} 156 */ 157 const DEFAULT_RECORDING_SETTINGS = { 158 // The preset name. 159 presetName: "", 160 // The setting for the recording interval. Defaults to 1ms. 161 interval: 1, 162 // The number of entries in the profiler's circular buffer. 163 entries: 0, 164 // The features that are enabled for the profiler. 165 features: [], 166 // The thread list 167 threads: [], 168 // The objdirs list 169 objdirs: [], 170 // The client doesn't implement durations yet. See Bug 1587165. 171 duration: 0, 172 }; 173 174 /** 175 * This small utility returns true if the parameters contain the same values. 176 * This is essentially a deepEqual operation specific to this structure. 177 * 178 * @param {RecordingSettings} a 179 * @param {RecordingSettings} b 180 * @return {boolean} 181 */ 182 function areSettingsEquals(a, b) { 183 if (a === b) { 184 return true; 185 } 186 187 /* Simple properties */ 188 /* These types look redundant, but they actually help TypeScript assess that 189 * the following code is correct, as well as prevent typos. */ 190 /** @type {Array<"presetName" | "interval" | "entries" | "duration">} */ 191 const simpleProperties = ["presetName", "interval", "entries", "duration"]; 192 193 /* arrays */ 194 /** @type {Array<"features" | "threads" | "objdirs">} */ 195 const arrayProperties = ["features", "threads", "objdirs"]; 196 197 for (const property of simpleProperties) { 198 if (a[property] !== b[property]) { 199 return false; 200 } 201 } 202 203 for (const property of arrayProperties) { 204 if (a[property].length !== b[property].length) { 205 return false; 206 } 207 208 const arrayA = a[property].slice().sort(); 209 const arrayB = b[property].slice().sort(); 210 if (arrayA.some((valueA, i) => valueA !== arrayB[i])) { 211 return false; 212 } 213 } 214 215 return true; 216 } 217 218 /** 219 * This handles all values used as recording settings. 220 * 221 * @type {Reducer<RecordingSettings>} 222 */ 223 function recordingSettings(state = DEFAULT_RECORDING_SETTINGS, action) { 224 /** 225 * @template {keyof RecordingSettings} K 226 * @param {K} settingName 227 * @param {RecordingSettings[K]} settingValue 228 * @return {RecordingSettings} 229 */ 230 function changeOneSetting(settingName, settingValue) { 231 if (state[settingName] === settingValue) { 232 // Do not change the state if the new value equals the old value. 233 return state; 234 } 235 236 return { 237 ...state, 238 [settingName]: settingValue, 239 presetName: "custom", 240 }; 241 } 242 243 switch (action.type) { 244 case "CHANGE_INTERVAL": 245 return changeOneSetting("interval", action.interval); 246 case "CHANGE_ENTRIES": 247 return changeOneSetting("entries", action.entries); 248 case "CHANGE_FEATURES": 249 return changeOneSetting("features", action.features); 250 case "CHANGE_THREADS": 251 return changeOneSetting("threads", action.threads); 252 case "CHANGE_OBJDIRS": 253 return changeOneSetting("objdirs", action.objdirs); 254 case "CHANGE_PRESET": 255 return action.preset 256 ? { 257 ...state, 258 presetName: action.presetName, 259 interval: action.preset.interval, 260 entries: action.preset.entries, 261 features: action.preset.features, 262 threads: action.preset.threads, 263 // The client doesn't implement durations yet. See Bug 1587165. 264 duration: action.preset.duration, 265 } 266 : { 267 ...state, 268 presetName: action.presetName, // it's probably "custom". 269 }; 270 case "UPDATE_SETTINGS_FROM_PREFERENCES": 271 if (areSettingsEquals(state, action.recordingSettingsFromPreferences)) { 272 return state; 273 } 274 return { ...action.recordingSettingsFromPreferences }; 275 default: 276 return state; 277 } 278 } 279 280 /** 281 * These are all the values used to initialize the profiler. They should never 282 * change once added to the store. 283 * 284 * @type {Reducer<InitializedValues | null>} 285 */ 286 function initializedValues(state = null, action) { 287 switch (action.type) { 288 case "INITIALIZE_STORE": 289 return { 290 presets: action.presets, 291 pageContext: action.pageContext, 292 supportedFeatures: action.supportedFeatures, 293 openRemoteDevTools: action.openRemoteDevTools, 294 }; 295 default: 296 return state; 297 } 298 } 299 300 /** 301 * Some features may need a browser restart with an environment flag. Request 302 * one here. 303 * 304 * @type {Reducer<string | null>} 305 */ 306 function promptEnvRestart(state = null, action) { 307 switch (action.type) { 308 case "CHANGE_FEATURES": 309 return action.promptEnvRestart; 310 default: 311 return state; 312 } 313 } 314 315 /** 316 * The main reducer for the performance-new client. 317 * 318 * @type {Reducer<State>} 319 */ 320 module.exports = (state = undefined, action) => { 321 return { 322 recordingState: recordingState(state?.recordingState, action), 323 324 // Treat this one specially - it also gets the recordingState. 325 recordingUnexpectedlyStopped: recordingUnexpectedlyStopped( 326 state?.recordingState, 327 state?.recordingUnexpectedlyStopped, 328 action 329 ), 330 331 isSupportedPlatform: isSupportedPlatform( 332 state?.isSupportedPlatform, 333 action 334 ), 335 recordingSettings: recordingSettings(state?.recordingSettings, action), 336 initializedValues: initializedValues(state?.initializedValues, action), 337 promptEnvRestart: promptEnvRestart(state?.promptEnvRestart, action), 338 }; 339 };