|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | + * Licensed under the MIT License. See License.txt in the project root for license information. |
| 4 | + *--------------------------------------------------------------------------------------------*/ |
| 5 | + |
| 6 | +export namespace inputLatency { |
| 7 | + |
| 8 | + // Measurements are recorded as totals, the average is calculated when the final measurements |
| 9 | + // are created. |
| 10 | + interface ICumulativeMeasurement { |
| 11 | + total: number; |
| 12 | + min: number; |
| 13 | + max: number; |
| 14 | + } |
| 15 | + const totalKeydownTime: ICumulativeMeasurement = { total: 0, min: Number.MAX_VALUE, max: 0 }; |
| 16 | + const totalInputTime: ICumulativeMeasurement = { ...totalKeydownTime }; |
| 17 | + const totalRenderTime: ICumulativeMeasurement = { ...totalKeydownTime }; |
| 18 | + const totalInputLatencyTime: ICumulativeMeasurement = { ...totalKeydownTime }; |
| 19 | + let measurementsCount = 0; |
| 20 | + |
| 21 | + |
| 22 | + |
| 23 | + // The state of each event, this helps ensure the integrity of the measurement and that |
| 24 | + // something unexpected didn't happen that could skew the measurement. |
| 25 | + const enum EventPhase { |
| 26 | + Before = 0, |
| 27 | + InProgress = 1, |
| 28 | + Finished = 2 |
| 29 | + } |
| 30 | + const state = { |
| 31 | + keydown: EventPhase.Before, |
| 32 | + input: EventPhase.Before, |
| 33 | + render: EventPhase.Before, |
| 34 | + }; |
| 35 | + |
| 36 | + /** |
| 37 | + * Record the start of the keydown event. |
| 38 | + */ |
| 39 | + export function onKeyDown() { |
| 40 | + /** Direct Check C. See explanation in {@link recordIfFinished} */ |
| 41 | + recordIfFinished(); |
| 42 | + performance.mark('inputlatency/start'); |
| 43 | + performance.mark('keydown/start'); |
| 44 | + state.keydown = EventPhase.InProgress; |
| 45 | + queueMicrotask(markKeyDownEnd); |
| 46 | + } |
| 47 | + |
| 48 | + /** |
| 49 | + * Mark the end of the keydown event. |
| 50 | + */ |
| 51 | + function markKeyDownEnd() { |
| 52 | + performance.mark('keydown/end'); |
| 53 | + state.keydown = EventPhase.Finished; |
| 54 | + } |
| 55 | + |
| 56 | + /** |
| 57 | + * Record the start of the beforeinput event. |
| 58 | + */ |
| 59 | + export function onBeforeInput() { |
| 60 | + performance.mark('input/start'); |
| 61 | + state.input = EventPhase.InProgress; |
| 62 | + /** Schedule Task A. See explanation in {@link recordIfFinished} */ |
| 63 | + scheduleRecordIfFinishedTask(); |
| 64 | + } |
| 65 | + |
| 66 | + /** |
| 67 | + * Record the start of the input event. |
| 68 | + */ |
| 69 | + export function onInput() { |
| 70 | + queueMicrotask(markInputEnd); |
| 71 | + } |
| 72 | + |
| 73 | + function markInputEnd() { |
| 74 | + performance.mark('input/end'); |
| 75 | + state.input = EventPhase.Finished; |
| 76 | + } |
| 77 | + |
| 78 | + /** |
| 79 | + * Record the start of the keyup event. |
| 80 | + */ |
| 81 | + export function onKeyUp() { |
| 82 | + /** Direct Check D. See explanation in {@link recordIfFinished} */ |
| 83 | + recordIfFinished(); |
| 84 | + } |
| 85 | + |
| 86 | + /** |
| 87 | + * Record the start of the selectionchange event. |
| 88 | + */ |
| 89 | + export function onSelectionChange() { |
| 90 | + /** Direct Check E. See explanation in {@link recordIfFinished} */ |
| 91 | + recordIfFinished(); |
| 92 | + } |
| 93 | + |
| 94 | + /** |
| 95 | + * Record the start of the animation frame performing the rendering. |
| 96 | + */ |
| 97 | + export function onRenderStart() { |
| 98 | + // Render may be triggered during input, but we only measure the following animation frame |
| 99 | + if (state.keydown === EventPhase.Finished && state.input === EventPhase.Finished && state.render === EventPhase.Before) { |
| 100 | + // Only measure the first render after keyboard input |
| 101 | + performance.mark('render/start'); |
| 102 | + state.render = EventPhase.InProgress; |
| 103 | + queueMicrotask(markRenderEnd); |
| 104 | + /** Schedule Task B. See explanation in {@link recordIfFinished} */ |
| 105 | + scheduleRecordIfFinishedTask(); |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + /** |
| 110 | + * Mark the end of the animation frame performing the rendering. |
| 111 | + */ |
| 112 | + function markRenderEnd() { |
| 113 | + performance.mark('render/end'); |
| 114 | + state.render = EventPhase.Finished; |
| 115 | + } |
| 116 | + |
| 117 | + function scheduleRecordIfFinishedTask() { |
| 118 | + // Here we can safely assume that the `setTimeout` will not be |
| 119 | + // artificially delayed by 4ms because we schedule it from |
| 120 | + // event handlers |
| 121 | + setTimeout(recordIfFinished); |
| 122 | + } |
| 123 | + |
| 124 | + /** |
| 125 | + * Record the input latency sample if input handling and rendering are finished. |
| 126 | + * |
| 127 | + * The challenge here is that we want to record the latency in such a way that it includes |
| 128 | + * also the layout and painting work the browser does during the animation frame task. |
| 129 | + * |
| 130 | + * Simply scheduling a new task (via `setTimeout`) from the animation frame task would |
| 131 | + * schedule the new task at the end of the task queue (after other code that uses `setTimeout`), |
| 132 | + * so we need to use multiple strategies to make sure our task runs before others: |
| 133 | + * |
| 134 | + * We schedule tasks (A and B): |
| 135 | + * - we schedule a task A (via a `setTimeout` call) when the input starts in `markInputStart`. |
| 136 | + * If the animation frame task is scheduled quickly by the browser, then task A has a very good |
| 137 | + * chance of being the very first task after the animation frame and thus will record the input latency. |
| 138 | + * - however, if the animation frame task is scheduled a bit later, then task A might execute |
| 139 | + * before the animation frame task. We therefore schedule another task B from `markRenderStart`. |
| 140 | + * |
| 141 | + * We do direct checks in browser event handlers (C, D, E): |
| 142 | + * - if the browser has multiple keydown events queued up, they will be scheduled before the `setTimeout` tasks, |
| 143 | + * so we do a direct check in the keydown event handler (C). |
| 144 | + * - depending on timing, sometimes the animation frame is scheduled even before the `keyup` event, so we |
| 145 | + * do a direct check there too (E). |
| 146 | + * - the browser oftentimes emits a `selectionchange` event after an `input`, so we do a direct check there (D). |
| 147 | + */ |
| 148 | + function recordIfFinished() { |
| 149 | + if (state.keydown === EventPhase.Finished && state.input === EventPhase.Finished && state.render === EventPhase.Finished) { |
| 150 | + performance.mark('inputlatency/end'); |
| 151 | + |
| 152 | + performance.measure('keydown', 'keydown/start', 'keydown/end'); |
| 153 | + performance.measure('input', 'input/start', 'input/end'); |
| 154 | + performance.measure('render', 'render/start', 'render/end'); |
| 155 | + performance.measure('inputlatency', 'inputlatency/start', 'inputlatency/end'); |
| 156 | + |
| 157 | + addMeasure('keydown', totalKeydownTime); |
| 158 | + addMeasure('input', totalInputTime); |
| 159 | + addMeasure('render', totalRenderTime); |
| 160 | + addMeasure('inputlatency', totalInputLatencyTime); |
| 161 | + |
| 162 | + // console.info( |
| 163 | + // `input latency=${performance.getEntriesByName('inputlatency')[0].duration.toFixed(1)} [` + |
| 164 | + // `keydown=${performance.getEntriesByName('keydown')[0].duration.toFixed(1)}, ` + |
| 165 | + // `input=${performance.getEntriesByName('input')[0].duration.toFixed(1)}, ` + |
| 166 | + // `render=${performance.getEntriesByName('render')[0].duration.toFixed(1)}` + |
| 167 | + // `]` |
| 168 | + // ); |
| 169 | + |
| 170 | + measurementsCount++; |
| 171 | + |
| 172 | + reset(); |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + function addMeasure(entryName: string, cumulativeMeasurement: ICumulativeMeasurement): void { |
| 177 | + const duration = performance.getEntriesByName(entryName)[0].duration; |
| 178 | + cumulativeMeasurement.total += duration; |
| 179 | + cumulativeMeasurement.min = Math.min(cumulativeMeasurement.min, duration); |
| 180 | + cumulativeMeasurement.max = Math.max(cumulativeMeasurement.max, duration); |
| 181 | + } |
| 182 | + |
| 183 | + /** |
| 184 | + * Clear the current sample. |
| 185 | + */ |
| 186 | + function reset() { |
| 187 | + performance.clearMarks('keydown/start'); |
| 188 | + performance.clearMarks('keydown/end'); |
| 189 | + performance.clearMarks('input/start'); |
| 190 | + performance.clearMarks('input/end'); |
| 191 | + performance.clearMarks('render/start'); |
| 192 | + performance.clearMarks('render/end'); |
| 193 | + performance.clearMarks('inputlatency/start'); |
| 194 | + performance.clearMarks('inputlatency/end'); |
| 195 | + |
| 196 | + performance.clearMeasures('keydown'); |
| 197 | + performance.clearMeasures('input'); |
| 198 | + performance.clearMeasures('render'); |
| 199 | + performance.clearMeasures('inputlatency'); |
| 200 | + |
| 201 | + state.keydown = EventPhase.Before; |
| 202 | + state.input = EventPhase.Before; |
| 203 | + state.render = EventPhase.Before; |
| 204 | + } |
| 205 | + |
| 206 | + export interface IInputLatencyMeasurements { |
| 207 | + keydown: IInputLatencySingleMeasurement; |
| 208 | + input: IInputLatencySingleMeasurement; |
| 209 | + render: IInputLatencySingleMeasurement; |
| 210 | + total: IInputLatencySingleMeasurement; |
| 211 | + sampleCount: number; |
| 212 | + } |
| 213 | + |
| 214 | + export interface IInputLatencySingleMeasurement { |
| 215 | + average: number; |
| 216 | + min: number; |
| 217 | + max: number; |
| 218 | + } |
| 219 | + |
| 220 | + /** |
| 221 | + * Gets all input latency samples and clears the internal buffers to start recording a new set |
| 222 | + * of samples. |
| 223 | + */ |
| 224 | + export function getAndClearMeasurements(): IInputLatencyMeasurements | undefined { |
| 225 | + if (measurementsCount === 0) { |
| 226 | + return undefined; |
| 227 | + } |
| 228 | + |
| 229 | + // Assemble the result |
| 230 | + const result = { |
| 231 | + keydown: cumulativeToFinalMeasurement(totalKeydownTime), |
| 232 | + input: cumulativeToFinalMeasurement(totalInputTime), |
| 233 | + render: cumulativeToFinalMeasurement(totalRenderTime), |
| 234 | + total: cumulativeToFinalMeasurement(totalInputLatencyTime), |
| 235 | + sampleCount: measurementsCount |
| 236 | + }; |
| 237 | + |
| 238 | + // Clear the cumulative measurements |
| 239 | + clearCumulativeMeasurement(totalKeydownTime); |
| 240 | + clearCumulativeMeasurement(totalInputTime); |
| 241 | + clearCumulativeMeasurement(totalRenderTime); |
| 242 | + clearCumulativeMeasurement(totalInputLatencyTime); |
| 243 | + measurementsCount = 0; |
| 244 | + |
| 245 | + return result; |
| 246 | + } |
| 247 | + |
| 248 | + function cumulativeToFinalMeasurement(cumulative: ICumulativeMeasurement): IInputLatencySingleMeasurement { |
| 249 | + return { |
| 250 | + average: cumulative.total / measurementsCount, |
| 251 | + max: cumulative.max, |
| 252 | + min: cumulative.min, |
| 253 | + }; |
| 254 | + } |
| 255 | + |
| 256 | + function clearCumulativeMeasurement(cumulative: ICumulativeMeasurement): void { |
| 257 | + cumulative.total = 0; |
| 258 | + cumulative.min = Number.MAX_VALUE; |
| 259 | + cumulative.max = 0; |
| 260 | + } |
| 261 | + |
| 262 | +} |
0 commit comments