summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/UrlbarEventBufferer.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/urlbar/UrlbarEventBufferer.sys.mjs364
1 files changed, 364 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarEventBufferer.sys.mjs b/browser/components/urlbar/UrlbarEventBufferer.sys.mjs
new file mode 100644
index 0000000000..e881d3114b
--- /dev/null
+++ b/browser/components/urlbar/UrlbarEventBufferer.sys.mjs
@@ -0,0 +1,364 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.UrlbarUtils.getLogger({ prefix: "EventBufferer" })
+);
+
+// Maximum time events can be deferred for. In automation providers can be quite
+// slow, thus we need a longer timeout to avoid intermittent failures.
+const DEFERRING_TIMEOUT_MS = Cu.isInAutomation ? 1000 : 300;
+
+// Array of keyCodes to defer.
+const DEFERRED_KEY_CODES = new Set([
+ KeyboardEvent.DOM_VK_RETURN,
+ KeyboardEvent.DOM_VK_DOWN,
+ KeyboardEvent.DOM_VK_TAB,
+]);
+
+// Status of the current or last query.
+const QUERY_STATUS = {
+ UKNOWN: 0,
+ RUNNING: 1,
+ COMPLETE: 2,
+};
+
+/**
+ * The UrlbarEventBufferer can queue up events and replay them later, to make
+ * the urlbar results more predictable.
+ *
+ * Search results arrive asynchronously, which means that keydown events may
+ * arrive before results do, and therefore not have the effect the user intends.
+ * That's especially likely to happen with the down arrow and enter keys, due to
+ * the one-off search buttons: if the user very quickly pastes something in the
+ * input, presses the down arrow key, and then hits enter, they are probably
+ * expecting to visit the first result. But if there are no results, then
+ * pressing down and enter will trigger the first one-off button.
+ * To prevent that undesirable behavior, certain keys are buffered and deferred
+ * until more results arrive, at which time they're replayed.
+ */
+export class UrlbarEventBufferer {
+ /**
+ * Initialises the class.
+ *
+ * @param {UrlbarInput} input The urlbar input object.
+ */
+ constructor(input) {
+ this.input = input;
+ this.input.inputField.addEventListener("blur", this);
+
+ // A queue of {event, callback} objects representing deferred events.
+ // The callback is invoked when it's the right time to handle the event,
+ // but it may also never be invoked, if the context changed and the event
+ // became obsolete.
+ this._eventsQueue = [];
+ // If this timer fires, we will unconditionally replay all the deferred
+ // events so that, after a certain point, we don't keep blocking the user's
+ // actions, when nothing else has caused the events to be replayed.
+ // At that point we won't check whether it's safe to replay the events,
+ // because otherwise it may look like we ignored the user's actions.
+ this._deferringTimeout = null;
+
+ // Tracks the current or last query status.
+ this._lastQuery = {
+ // The time at which the current or last search was started. This is used
+ // to check how much time passed while deferring the user's actions. Must
+ // be set using the monotonic Cu.now() helper.
+ startDate: Cu.now(),
+ // Status of the query; one of QUERY_STATUS.*
+ status: QUERY_STATUS.UKNOWN,
+ // The query context.
+ context: null,
+ };
+
+ // Start listening for queries.
+ this.input.controller.addQueryListener(this);
+ }
+
+ // UrlbarController listener methods.
+ onQueryStarted(queryContext) {
+ this._lastQuery = {
+ startDate: Cu.now(),
+ status: QUERY_STATUS.RUNNING,
+ context: queryContext,
+ };
+ if (this._deferringTimeout) {
+ lazy.clearTimeout(this._deferringTimeout);
+ this._deferringTimeout = null;
+ }
+ }
+
+ onQueryCancelled(queryContext) {
+ this._lastQuery.status = QUERY_STATUS.COMPLETE;
+ }
+
+ onQueryFinished(queryContext) {
+ this._lastQuery.status = QUERY_STATUS.COMPLETE;
+ }
+
+ onQueryResults(queryContext) {
+ // Ensure this runs after other results handling code.
+ Services.tm.dispatchToMainThread(() => {
+ this.replayDeferredEvents(true);
+ });
+ }
+
+ /**
+ * Handles DOM events.
+ *
+ * @param {Event} event DOM event from the input.
+ */
+ handleEvent(event) {
+ if (event.type == "blur") {
+ lazy.logger.debug("Clearing queue on blur");
+ // The input field was blurred, pending events don't matter anymore.
+ // Clear the timeout and the queue.
+ this._eventsQueue.length = 0;
+ if (this._deferringTimeout) {
+ lazy.clearTimeout(this._deferringTimeout);
+ this._deferringTimeout = null;
+ }
+ }
+ }
+
+ /**
+ * Receives DOM events, eventually queues them up, and calls back when it's
+ * the right time to handle the event.
+ *
+ * @param {Event} event DOM event from the input.
+ * @param {Function} callback to be invoked when it's the right time to handle
+ * the event.
+ */
+ maybeDeferEvent(event, callback) {
+ if (!callback) {
+ throw new Error("Must provide a callback");
+ }
+ if (this.shouldDeferEvent(event)) {
+ this.deferEvent(event, callback);
+ return;
+ }
+ // If it has not been deferred, handle the callback immediately.
+ callback();
+ }
+
+ /**
+ * Adds a deferrable event to the deferred event queue.
+ *
+ * @param {Event} event The event to defer.
+ * @param {Function} callback to be invoked when it's the right time to handle
+ * the event.
+ */
+ deferEvent(event, callback) {
+ // TODO Bug 1536822: once one-off buttons are implemented, figure out if the
+ // following is true for the quantum bar as well: somehow event.defaultPrevented
+ // ends up true for deferred events. Autocomplete ignores defaultPrevented
+ // events, which means it would ignore replayed deferred events if we didn't
+ // tell it to bypass defaultPrevented through urlbarDeferred.
+ // Check we don't try to defer events more than once.
+ if (event.urlbarDeferred) {
+ throw new Error(`Event ${event.type}:${event.keyCode} already deferred!`);
+ }
+ lazy.logger.debug(`Deferring ${event.type}:${event.keyCode} event`);
+ // Mark the event as deferred.
+ event.urlbarDeferred = true;
+ // Also store the current search string, as an added safety check. If the
+ // string will differ later, the event is stale and should be dropped.
+ event.searchString = this._lastQuery.context.searchString;
+ this._eventsQueue.push({ event, callback });
+
+ if (!this._deferringTimeout) {
+ let elapsed = Cu.now() - this._lastQuery.startDate;
+ let remaining = DEFERRING_TIMEOUT_MS - elapsed;
+ this._deferringTimeout = lazy.setTimeout(() => {
+ this.replayDeferredEvents(false);
+ this._deferringTimeout = null;
+ }, Math.max(0, remaining));
+ }
+ }
+
+ /**
+ * Replays deferred key events.
+ *
+ * @param {boolean} onlyIfSafe replays only if it's a safe time to do so.
+ * Setting this to false will replay all the queue events, without any
+ * checks, that is something we want to do only if the deferring
+ * timeout elapsed, and we don't want to appear ignoring user's input.
+ */
+ replayDeferredEvents(onlyIfSafe) {
+ if (typeof onlyIfSafe != "boolean") {
+ throw new Error("Must provide a boolean argument");
+ }
+ if (!this._eventsQueue.length) {
+ return;
+ }
+
+ let { event, callback } = this._eventsQueue[0];
+ if (onlyIfSafe && !this.isSafeToPlayDeferredEvent(event)) {
+ return;
+ }
+
+ // Remove the event from the queue and play it.
+ this._eventsQueue.shift();
+ // Safety check: handle only if the search string didn't change meanwhile.
+ if (event.searchString == this._lastQuery.context.searchString) {
+ callback();
+ }
+ Services.tm.dispatchToMainThread(() => {
+ this.replayDeferredEvents(onlyIfSafe);
+ });
+ }
+
+ /**
+ * Checks whether a given event should be deferred
+ *
+ * @param {Event} event The event that should maybe be deferred.
+ * @returns {boolean} Whether the event should be deferred.
+ */
+ shouldDeferEvent(event) {
+ // If any event has been deferred for this search, then defer all subsequent
+ // events so that the user does not experience them out of order.
+ // All events will be replayed when _deferringTimeout fires.
+ if (this._eventsQueue.length) {
+ return true;
+ }
+
+ // At this point, no events have been deferred for this search; we must
+ // figure out if this event should be deferred.
+ let isMacNavigation =
+ AppConstants.platform == "macosx" &&
+ event.ctrlKey &&
+ this.input.view.isOpen &&
+ (event.key === "n" || event.key === "p");
+ if (!DEFERRED_KEY_CODES.has(event.keyCode) && !isMacNavigation) {
+ return false;
+ }
+
+ if (DEFERRED_KEY_CODES.has(event.keyCode)) {
+ // Defer while the user is composing.
+ if (this.input.editor.composing) {
+ return true;
+ }
+ if (this.input.controller.keyEventMovesCaret(event)) {
+ return false;
+ }
+ }
+
+ // This is an event that we'd defer, but if enough time has passed since the
+ // start of the search, we don't want to block the user's workflow anymore.
+ if (this._lastQuery.startDate + DEFERRING_TIMEOUT_MS <= Cu.now()) {
+ return false;
+ }
+
+ if (
+ event.keyCode == KeyEvent.DOM_VK_TAB &&
+ !this.input.view.isOpen &&
+ !this.waitingDeferUserSelectionProviders
+ ) {
+ // The view is closed and the user pressed the Tab key. The focus should
+ // move out of the urlbar immediately.
+ return false;
+ }
+
+ return !this.isSafeToPlayDeferredEvent(event);
+ }
+
+ /**
+ * Checks if the bufferer is deferring events.
+ *
+ * @returns {boolean} Whether the bufferer is deferring events.
+ */
+ get isDeferringEvents() {
+ return !!this._eventsQueue.length;
+ }
+
+ /**
+ * Checks if any of the current query provider asked to defer user selection
+ * events.
+ *
+ * @returns {boolean} Whether a provider asked to defer events.
+ */
+ get waitingDeferUserSelectionProviders() {
+ return !!this._lastQuery.context?.deferUserSelectionProviders.size;
+ }
+
+ /**
+ * Returns true if the given deferred event can be played now without possibly
+ * surprising the user. This depends on the state of the view, the results,
+ * and the type of event.
+ * Use this method only after determining that the event should be deferred,
+ * or after it has been deferred and you want to know if it can be played now.
+ *
+ * @param {Event} event The event.
+ * @returns {boolean} Whether the event can be played.
+ */
+ isSafeToPlayDeferredEvent(event) {
+ if (this._lastQuery.status != QUERY_STATUS.RUNNING) {
+ // The view can't get any more results, so there's no need to further
+ // defer events.
+ return true;
+ }
+
+ let waitingFirstResult =
+ this._lastQuery.status == QUERY_STATUS.RUNNING &&
+ !this._lastQuery.context.results.length;
+ if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
+ // Check if we're waiting for providers that requested deferring.
+ if (this.waitingDeferUserSelectionProviders) {
+ return false;
+ }
+ // Play a deferred Enter if the heuristic result is not selected, or we
+ // are not waiting for the first results yet.
+ let selectedResult = this.input.view.selectedResult;
+ return (
+ (selectedResult && !selectedResult.heuristic) || !waitingFirstResult
+ );
+ }
+
+ if (
+ waitingFirstResult ||
+ !this.input.view.isOpen ||
+ this.waitingDeferUserSelectionProviders
+ ) {
+ // We're still waiting on some results, or the popup hasn't opened yet.
+ return false;
+ }
+
+ let isMacDownNavigation =
+ AppConstants.platform == "macosx" &&
+ event.ctrlKey &&
+ this.input.view.isOpen &&
+ event.key === "n";
+ if (event.keyCode == KeyEvent.DOM_VK_DOWN || isMacDownNavigation) {
+ // Don't play the event if the last result is selected so that the user
+ // doesn't accidentally arrow down into the one-off buttons when they
+ // didn't mean to. Note TAB is unaffected because it only navigates
+ // results, not one-offs.
+ return !this.lastResultIsSelected;
+ }
+
+ return true;
+ }
+
+ get lastResultIsSelected() {
+ // TODO Bug 1536818: Once one-off buttons are fully implemented, it would be
+ // nice to have a better way to check if the next down will focus one-off buttons.
+ let results = this._lastQuery.context.results;
+ return (
+ results.length &&
+ results[results.length - 1] == this.input.view.selectedResult
+ );
+ }
+}