UrlbarEventBufferer.sys.mjs (13160B)
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 5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 7 8 const lazy = XPCOMUtils.declareLazy({ 9 UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", 10 clearTimeout: "resource://gre/modules/Timer.sys.mjs", 11 setTimeout: "resource://gre/modules/Timer.sys.mjs", 12 logger: () => lazy.UrlbarUtils.getLogger({ prefix: "EventBufferer" }), 13 }); 14 15 /** 16 * Array of keyCodes to defer. 17 * 18 * @type {Set<number>} 19 */ 20 const DEFERRED_KEY_CODES = new Set([ 21 KeyboardEvent.DOM_VK_RETURN, 22 KeyboardEvent.DOM_VK_DOWN, 23 KeyboardEvent.DOM_VK_TAB, 24 ]); 25 26 /** 27 * Status of the current or last query. 28 */ 29 const QUERY_STATUS = Object.freeze({ 30 UKNOWN: 0, 31 RUNNING: 1, 32 RUNNING_GOT_ALL_HEURISTIC_RESULTS: 2, 33 COMPLETE: 3, 34 }); 35 36 /** 37 * The UrlbarEventBufferer can queue up events and replay them later, to make 38 * the urlbar results more predictable. 39 * 40 * Search results arrive asynchronously, which means that keydown events may 41 * arrive before results do, and therefore not have the effect the user intends. 42 * That's especially likely to happen with the down arrow and enter keys, due to 43 * the one-off search buttons: if the user very quickly pastes something in the 44 * input, presses the down arrow key, and then hits enter, they are probably 45 * expecting to visit the first result. But if there are no results, then 46 * pressing down and enter will trigger the first one-off button. 47 * To prevent that undesirable behavior, certain keys are buffered and deferred 48 * until more results arrive, at which time they're replayed. 49 */ 50 export class UrlbarEventBufferer { 51 // Maximum time events can be deferred for. In automation providers can be 52 // quite slow, thus we need a longer timeout to avoid intermittent failures. 53 // Note: to avoid handling events too early, this timer should be larger than 54 // UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS. 55 static DEFERRING_TIMEOUT_MS = Cu.isInAutomation ? 1500 : 300; 56 57 /** 58 * Initialises the class. 59 * 60 * @param {UrlbarInput} input 61 * The urlbar input object. 62 */ 63 constructor(input) { 64 this.input = input; 65 this.input.inputField.addEventListener("blur", this); 66 67 this.#lastQuery = { 68 // The time at which the current or last search was started. This is used 69 // to check how much time passed while deferring the user's actions. Must 70 // be set using the monotonic ChromeUtils.now() helper. 71 startDate: ChromeUtils.now(), 72 // Status of the query; one of QUERY_STATUS.* 73 status: QUERY_STATUS.UKNOWN, 74 // The query context. 75 context: null, 76 }; 77 78 // Start listening for queries. 79 this.input.controller.addListener(this); 80 } 81 82 // UrlbarController listener methods. 83 84 /** 85 * Handles when a query is started. 86 * 87 * @param {UrlbarQueryContext} queryContext 88 */ 89 onQueryStarted(queryContext) { 90 this.#lastQuery = { 91 startDate: ChromeUtils.now(), 92 status: QUERY_STATUS.RUNNING, 93 context: queryContext, 94 }; 95 if (this.#deferringTimeout) { 96 lazy.clearTimeout(this.#deferringTimeout); 97 this.#deferringTimeout = null; 98 } 99 } 100 101 onQueryCancelled() { 102 this.#lastQuery.status = QUERY_STATUS.COMPLETE; 103 } 104 105 onQueryFinished() { 106 this.#lastQuery.status = QUERY_STATUS.COMPLETE; 107 } 108 109 /** 110 * Handles results of the query. 111 * 112 * @param {UrlbarQueryContext} queryContext 113 */ 114 onQueryResults(queryContext) { 115 if (queryContext.pendingHeuristicProviders.size) { 116 return; 117 } 118 this.#lastQuery.status = QUERY_STATUS.RUNNING_GOT_ALL_HEURISTIC_RESULTS; 119 // Ensure this runs after other results handling code. 120 Services.tm.dispatchToMainThread(() => { 121 this.replayDeferredEvents(true); 122 }); 123 } 124 125 /** 126 * Handles DOM events. 127 * 128 * @param {Event} event 129 * DOM event from the input. 130 */ 131 handleEvent(event) { 132 if (event.type == "blur") { 133 lazy.logger.debug("Clearing queue on blur"); 134 // The input field was blurred, pending events don't matter anymore. 135 // Clear the timeout and the queue. 136 this.#eventsQueue.length = 0; 137 if (this.#deferringTimeout) { 138 lazy.clearTimeout(this.#deferringTimeout); 139 this.#deferringTimeout = null; 140 } 141 } 142 } 143 144 /** 145 * Receives DOM events, eventually queues them up, and calls back when it's 146 * the right time to handle the event. 147 * 148 * @param {KeyboardEvent} event DOM event from the input. 149 * @param {() => void} callback to be invoked when it's the right time to handle 150 * the event. 151 */ 152 maybeDeferEvent(event, callback) { 153 if (!callback) { 154 throw new Error("Must provide a callback"); 155 } 156 if (this.shouldDeferEvent(event)) { 157 this.deferEvent(event, callback); 158 return; 159 } 160 // If it has not been deferred, handle the callback immediately. 161 callback(); 162 } 163 164 /** 165 * Adds a deferrable event to the deferred event queue. 166 * 167 * @param {KeyboardEvent} event The event to defer. 168 * @param {() => void} callback to be invoked when it's the right time to handle 169 * the event. 170 */ 171 deferEvent(event, callback) { 172 // Check we don't try to defer events more than once. 173 if (this.#eventsQueue.find(item => item.event == event)) { 174 throw new Error(`Event ${event.type}:${event.keyCode} already deferred!`); 175 } 176 lazy.logger.debug(`Deferring ${event.type}:${event.keyCode} event`); 177 this.#eventsQueue.push({ 178 event, 179 callback, 180 // Also store the current search string, as an added safety check. If the 181 // string will differ later, the event is stale and should be dropped. 182 searchString: this.#lastQuery.context.searchString, 183 }); 184 185 if (!this.#deferringTimeout) { 186 let elapsed = ChromeUtils.now() - this.#lastQuery.startDate; 187 let remaining = UrlbarEventBufferer.DEFERRING_TIMEOUT_MS - elapsed; 188 this.#deferringTimeout = lazy.setTimeout( 189 () => { 190 this.replayDeferredEvents(false); 191 this.#deferringTimeout = null; 192 }, 193 Math.max(0, remaining) 194 ); 195 } 196 } 197 198 /** 199 * Replays deferred key events. 200 * 201 * @param {boolean} onlyIfSafe replays only if it's a safe time to do so. 202 * Setting this to false will replay all the queue events, without any 203 * checks, that is something we want to do only if the deferring 204 * timeout elapsed, and we don't want to appear ignoring user's input. 205 */ 206 replayDeferredEvents(onlyIfSafe) { 207 if (typeof onlyIfSafe != "boolean") { 208 throw new Error("Must provide a boolean argument"); 209 } 210 if (!this.#eventsQueue.length) { 211 return; 212 } 213 214 let { event, callback, searchString } = this.#eventsQueue[0]; 215 if (onlyIfSafe && !this.isSafeToPlayDeferredEvent(event)) { 216 return; 217 } 218 219 // Remove the event from the queue and play it. 220 this.#eventsQueue.shift(); 221 // Safety check: handle only if the search string didn't change meanwhile. 222 if (searchString == this.#lastQuery.context.searchString) { 223 callback(); 224 } 225 Services.tm.dispatchToMainThread(() => { 226 this.replayDeferredEvents(onlyIfSafe); 227 }); 228 } 229 230 /** 231 * Checks whether a given event should be deferred 232 * 233 * @param {KeyboardEvent} event The event that should maybe be deferred. 234 * @returns {boolean} Whether the event should be deferred. 235 */ 236 shouldDeferEvent(event) { 237 // If any event has been deferred for this search, then defer all subsequent 238 // events so that the user does not experience them out of order. 239 // All events will be replayed when #deferringTimeout fires. 240 if (this.#eventsQueue.length) { 241 return true; 242 } 243 244 // At this point, no events have been deferred for this search; we must 245 // figure out if this event should be deferred. 246 let isMacNavigation = 247 AppConstants.platform == "macosx" && 248 event.ctrlKey && 249 this.input.view.isOpen && 250 (event.key === "n" || event.key === "p"); 251 if (!DEFERRED_KEY_CODES.has(event.keyCode) && !isMacNavigation) { 252 return false; 253 } 254 255 if (DEFERRED_KEY_CODES.has(event.keyCode)) { 256 // Defer while the user is composing. 257 if (this.input.editor.composing) { 258 return true; 259 } 260 if (this.input.controller.keyEventMovesCaret(event)) { 261 return false; 262 } 263 } 264 265 // This is an event that we'd defer, but if enough time has passed since the 266 // start of the search, we don't want to block the user's workflow anymore. 267 if ( 268 this.#lastQuery.startDate + UrlbarEventBufferer.DEFERRING_TIMEOUT_MS <= 269 ChromeUtils.now() 270 ) { 271 return false; 272 } 273 274 if ( 275 event.keyCode == KeyEvent.DOM_VK_TAB && 276 !this.input.view.isOpen && 277 !this.waitingDeferUserSelectionProviders 278 ) { 279 // The view is closed and the user pressed the Tab key. The focus should 280 // move out of the urlbar immediately. 281 return false; 282 } 283 284 return !this.isSafeToPlayDeferredEvent(event); 285 } 286 287 /** 288 * Checks if the bufferer is deferring events. 289 * 290 * @returns {boolean} Whether the bufferer is deferring events. 291 */ 292 get isDeferringEvents() { 293 return !!this.#eventsQueue.length; 294 } 295 296 /** 297 * Checks if any of the current query provider asked to defer user selection 298 * events. 299 * 300 * @returns {boolean} Whether a provider asked to defer events. 301 */ 302 get waitingDeferUserSelectionProviders() { 303 return !!this.#lastQuery.context?.deferUserSelectionProviders.size; 304 } 305 306 /** 307 * Returns true if the given deferred event can be played now without possibly 308 * surprising the user. This depends on the state of the view, the results, 309 * and the type of event. 310 * Use this method only after determining that the event should be deferred, 311 * or after it has been deferred and you want to know if it can be played now. 312 * 313 * @param {KeyboardEvent} event The event. 314 * @returns {boolean} Whether the event can be played. 315 */ 316 isSafeToPlayDeferredEvent(event) { 317 if ( 318 this.#lastQuery.status == QUERY_STATUS.COMPLETE || 319 this.#lastQuery.status == QUERY_STATUS.UKNOWN 320 ) { 321 // The view can't get any more results, so there's no need to further 322 // defer events. 323 return true; 324 } 325 let waitingHeuristicResults = 326 this.#lastQuery.status == QUERY_STATUS.RUNNING; 327 if (event.keyCode == KeyEvent.DOM_VK_RETURN) { 328 // Check if we're waiting for providers that requested deferring. 329 if (this.waitingDeferUserSelectionProviders) { 330 return false; 331 } 332 // Play a deferred Enter if the heuristic result is not selected, or we 333 // are not waiting for heuristic results yet. 334 let selectedResult = this.input.view.selectedResult; 335 return ( 336 (selectedResult && !selectedResult.heuristic) || 337 !waitingHeuristicResults 338 ); 339 } 340 341 if ( 342 waitingHeuristicResults || 343 !this.input.view.isOpen || 344 this.waitingDeferUserSelectionProviders 345 ) { 346 // We're still waiting on some results, or the popup hasn't opened yet. 347 return false; 348 } 349 350 let isMacDownNavigation = 351 AppConstants.platform == "macosx" && 352 event.ctrlKey && 353 this.input.view.isOpen && 354 event.key === "n"; 355 if (event.keyCode == KeyEvent.DOM_VK_DOWN || isMacDownNavigation) { 356 // Don't play the event if the last result is selected so that the user 357 // doesn't accidentally arrow down into the one-off buttons when they 358 // didn't mean to. Note TAB is unaffected because it only navigates 359 // results, not one-offs. 360 return !this.lastResultIsSelected; 361 } 362 363 return true; 364 } 365 366 get lastResultIsSelected() { 367 // TODO Bug 1536818: Once one-off buttons are fully implemented, it would be 368 // nice to have a better way to check if the next down will focus one-off buttons. 369 let results = this.#lastQuery.context.results; 370 return ( 371 results.length && 372 results[results.length - 1] == this.input.view.selectedResult 373 ); 374 } 375 376 /** 377 * A queue of deferred events. 378 * The callback is invoked when it's the right time to handle the event, 379 * but it may also never be invoked, if the context changed and the event 380 * became obsolete. 381 * 382 * @type {{event: KeyboardEvent, callback: () => void, searchString: string}[]} 383 */ 384 #eventsQueue = []; 385 386 /** 387 * If this timer fires, we will unconditionally replay all the deferred 388 * events so that, after a certain point, we don't keep blocking the user's 389 * actions, when nothing else has caused the events to be replayed. 390 * At that point we won't check whether it's safe to replay the events, 391 * because otherwise it may look like we ignored the user's actions. 392 * 393 * @type {?number} 394 */ 395 #deferringTimeout = null; 396 397 /** 398 * Tracks the current or last query status. 399 * 400 * @type {{ startDate: number, status: Values<typeof QUERY_STATUS>, context: UrlbarQueryContext}} 401 */ 402 #lastQuery; 403 }