Bug 1303781 - Sometimes selecting a history entry opens a Google search instead of navigating to the entry. r?mak draft
authorDrew Willcoxon <adw@mozilla.com>
Fri, 19 May 2017 10:11:55 -0700
changeset 581347 e0f1665d586938aa18761a9486e6ef340efe8d62
parent 580004 3d48e05919b340371ef705a70bcc26c9784d5cc8
child 629548 8bb600a2db9911abb1c12b901f9e170899debee9
push id59837
push userdwillcoxon@mozilla.com
push dateFri, 19 May 2017 17:12:03 +0000
reviewersmak
bugs1303781
milestone55.0a1
Bug 1303781 - Sometimes selecting a history entry opens a Google search instead of navigating to the entry. r?mak MozReview-Commit-ID: 9Ylo1o4DMbK
browser/base/content/urlbarBindings.xml
toolkit/content/widgets/autocomplete.xml
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -130,16 +130,21 @@ file, You can obtain one at http://mozil
         this.inputField.controllers.removeController(this._copyCutController);
         this.inputField.removeEventListener("paste", this);
         this.inputField.removeEventListener("mousedown", this);
         this.inputField.removeEventListener("mousemove", this);
         this.inputField.removeEventListener("mouseout", this);
         this.inputField.removeEventListener("overflow", this);
         this.inputField.removeEventListener("underflow", this);
 
+        if (this._deferredKeyEventTimeout) {
+          clearTimeout(this._deferredKeyEventTimeout);
+          this._deferredKeyEventTimeout = null;
+        }
+
         // Null out the one-offs' popup and textbox so that it cleans up its
         // internal state for both.  Most importantly, it removes the event
         // listeners that it added to both.
         this.popup.oneOffSearchButtons.popup = null;
         this.popup.oneOffSearchButtons.textbox = null;
       ]]></destructor>
 
 #ifdef MOZ_PHOTON_THEME
@@ -232,22 +237,136 @@ file, You can obtain one at http://mozil
             case KeyEvent.DOM_VK_LEFT:
             case KeyEvent.DOM_VK_RIGHT:
             case KeyEvent.DOM_VK_HOME:
               // Reset the selected index so that nsAutoCompleteController
               // simply closes the popup without trying to fill anything.
               this.popup.selectedIndex = -1;
               break;
           }
-          if (this.popup.popupOpen &&
-              !this.popup.disableKeyNavigation &&
-              this.popup.handleKeyPress(aEvent)) {
+          if (!this.popup.disableKeyNavigation) {
+            if (this._keyCodesToDefer.has(aEvent.keyCode) &&
+                this._shouldDeferKeyEvent(aEvent)) {
+              this._deferKeyEvent(aEvent, "onKeyPress");
+              return false;
+            }
+            if (this.popup.popupOpen && this.popup.handleKeyPress(aEvent)) {
+              return true;
+            }
+          }
+          return this.handleKeyPress(aEvent);
+        ]]></body>
+      </method>
+
+      <!--
+        Search results arrive asynchronously, which means that keypresses 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.
+
+        @param event
+               The key event that should maybe be deferred.  You can pass null
+               or undefined if you don't have one to see whether the next key
+               event in general should be deferred.
+        @return True if the event should be deferred, false if not.
+       -->
+      <method name="_shouldDeferKeyEvent">
+        <parameter name="event"/>
+        <body><![CDATA[
+          let waitedLongEnough =
+            this._searchStartDate + this._deferredKeyEventTimeoutMs < Date.now();
+          if (waitedLongEnough && !this._deferredKeyEventTimeout) {
+            return false;
+          }
+          if (event && event.keyCode == KeyEvent.DOM_VK_TAB && !this.popupOpen) {
+            // In this case, the popup is closed and the user pressed the Tab
+            // key.  The focus should move out of the urlbar immediately.
+            return false;
+          }
+          if (!this.gotResultForCurrentQuery || !this.popupOpen) {
             return true;
           }
-          return this.handleKeyPress(aEvent);
+          let maxResultsRemaining =
+            this.popup.maxResults - this.popup._matchCount;
+          let lastResultSelected =
+            this.popup.selectedIndex + 1 == this.popup._matchCount;
+          return maxResultsRemaining > 0 && lastResultSelected;
+        ]]></body>
+      </method>
+
+      <!--
+        Adds a key event to the deferred event queue.
+
+        @param event
+               The key event to defer.
+        @param methodName
+               The name of the method on `this` to call.  It's expected to take
+               a single argument, the event.
+      -->
+      <method name="_deferKeyEvent">
+        <parameter name="event"/>
+        <parameter name="methodName"/>
+        <body><![CDATA[
+          // 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.  That's the purpose of this expando.  If we could
+          // figure out what's setting defaultPrevented and prevent it, then we
+          // could get rid of this.
+          event.urlbarDeferred = true;
+
+          this._deferredKeyEventQueue.push({
+            methodName,
+            event,
+            searchString: this.mController.searchString,
+          });
+
+          if (!this._deferredKeyEventTimeout) {
+            this._deferredKeyEventTimeout = setTimeout(() => {
+              this._deferredKeyEventTimeout = null;
+              this.maybeReplayDeferredKeyEvents();
+            }, this._deferredKeyEventTimeoutMs);
+          }
+        ]]></body>
+      </method>
+
+      <!-- The enter key is always deferred, so it's not included here. -->
+      <field name="_keyCodesToDefer">new Set([
+        Ci.nsIDOMKeyEvent.DOM_VK_DOWN,
+        Ci.nsIDOMKeyEvent.DOM_VK_TAB,
+      ])</field>
+      <field name="_deferredKeyEventQueue">[]</field>
+      <field name="_deferredKeyEventTimeout">null</field>
+      <field name="_deferredKeyEventTimeoutMs">200</field>
+      <field name="_searchStartDate">0</field>
+
+      <method name="maybeReplayDeferredKeyEvents">
+        <body><![CDATA[
+          if (!this._deferredKeyEventQueue.length ||
+              this._shouldDeferKeyEvent()) {
+            return;
+          }
+          if (this._deferredKeyEventTimeout) {
+            clearTimeout(this._deferredKeyEventTimeout);
+            this._deferredKeyEventTimeout = null;
+          }
+          let instance = this._deferredKeyEventQueue.shift();
+          // Safety check: handle only if the search string didn't change.
+          if (this.mController.searchString == instance.searchString) {
+            this[instance.methodName](instance.event);
+          }
+          setTimeout(() => {
+            this.maybeReplayDeferredKeyEvents();
+          });
         ]]></body>
       </method>
 
       <field name="_mayTrimURLs">true</field>
       <method name="trimValue">
         <parameter name="aURL"/>
         <body><![CDATA[
           // This method must not modify the given URL such that calling
@@ -545,17 +664,17 @@ file, You can obtain one at http://mozil
         ]]></body>
       </method>
 
       <property name="oneOffSearchQuery">
         <getter><![CDATA[
           // this.textValue may be an autofilled string.  Search only with the
           // portion that the user typed, if any, by preferring the autocomplete
           // controller's searchString (including handleEnterInstance.searchString).
-          return (this.handleEnterInstance && this.handleEnterInstance.searchString) ||
+          return this.handleEnterSearchString ||
                  this.mController.searchString ||
                  this.textValue;
         ]]></getter>
       </property>
 
       <method name="_loadURL">
         <parameter name="url"/>
         <parameter name="browser"/>
@@ -1135,16 +1254,22 @@ file, You can obtain one at http://mozil
             this._value = this.inputField.value;
             gBrowser.userTypedValue = this.value;
             this.valueIsTyped = true;
             // Only wait for a result when we are sure to get one.  In some
             // cases, like when pasting the same exact text, we may not fire
             // a new search and we won't get a result.
             if (this.mController.handleText()) {
               this.gotResultForCurrentQuery = false;
+              this._searchStartDate = Date.now();
+              this._deferredKeyEventQueue = [];
+              if (this._deferredKeyEventTimeout) {
+                clearTimeout(this._deferredKeyEventTimeout);
+                this._deferredKeyEventTimeout = null;
+              }
             }
           }
           this.resetActionType();
         ]]></body>
       </method>
 
       <method name="handleEnter">
         <parameter name="event"/>
@@ -1158,35 +1283,32 @@ file, You can obtain one at http://mozil
           // result selected.
           // If anything other than the default (first) result is selected, then
           // it must have been manually selected by the human. We let this
           // explicit choice be used, even if it may be related to a previous
           // input.
           // However, if the default result is automatically selected, we
           // ensure that it corresponds to the current input.
 
-          // Store the current search string so it can be used in
-          // handleCommand, which will be called as a result of
-          // mController.handleEnter().
-          // Note this is also used to detect if we should perform a delayed
-          // handleEnter, in such a case it won't have been cleared.
-          this.handleEnterInstance = {
-            searchString: this.mController.searchString,
-            event
-          };
+          // Store the current search string so it can be used in handleCommand,
+          // which will be called as a result of mController.handleEnter().
+          this.handleEnterSearchString = this.mController.searchString;
 
-          if (this.popup.selectedIndex != 0 || this.gotResultForCurrentQuery) {
+          if (!this._deferredKeyEventQueue.length &&
+              (this.popup.selectedIndex != 0 || this.gotResultForCurrentQuery)) {
             this.maybeCanonizeURL(event, this.value);
-            let rv = this.mController.handleEnter(false, event);
-            this.handleEnterInstance = null;
+            let handled = this.mController.handleEnter(false, event);
+            this.handleEnterSearchString = null;
             this.popup.overrideValue = null;
-            return rv;
+            return handled;
           }
 
-          return true;
+          // Defer the event until the first non-heuristic result comes in.
+          this._deferKeyEvent(event, "handleEnter");
+          return false;
         ]]></body>
       </method>
 
       <method name="handleDelete">
         <body><![CDATA[
           // If the heuristic result is selected, then the autocomplete
           // controller's handleDelete implementation will remove it, which is
           // not what we want.  So in that case, call handleText so it acts as
@@ -1336,16 +1458,21 @@ file, You can obtain one at http://mozil
           this.formatValue();
           if (this.getAttribute("pageproxystate") != "valid") {
             UpdatePopupNotificationsVisibility();
           }
         }
         if (this.ExtensionSearchHandler.hasActiveInputSession()) {
           this.ExtensionSearchHandler.handleInputCancelled();
         }
+        if (this._deferredKeyEventTimeout) {
+          clearTimeout(this._deferredKeyEventTimeout);
+          this._deferredKeyEventTimeout = null;
+        }
+        this._deferredKeyEventQueue = [];
       ]]></handler>
 
       <handler event="dragstart" phase="capturing"><![CDATA[
         // Drag only if the gesture starts from the input field.
         if (this.inputField != event.originalTarget &&
             !(this.inputField.compareDocumentPosition(event.originalTarget) &
               Node.DOCUMENT_POSITION_CONTAINED_BY))
           return;
@@ -1888,17 +2015,17 @@ file, You can obtain one at http://mozil
            search buttons and between the one-offs and the listbox.  It returns
            true if the keypress was consumed and false if not. -->
       <method name="handleKeyPress">
         <parameter name="aEvent"/>
         <body><![CDATA[
           this.oneOffSearchButtons.handleKeyPress(aEvent, this._matchCount,
                                                   !this._isFirstResultHeuristic,
                                                   gBrowser.userTypedValue);
-          return aEvent.defaultPrevented;
+          return aEvent.defaultPrevented && !aEvent.urlbarDeferred;
         ]]></body>
       </method>
 
       <!-- This is called when a one-off is clicked and when "search in new tab"
            is selected from a one-off context menu. -->
       <method name="handleOneOffSearch">
         <parameter name="event"/>
         <parameter name="engine"/>
@@ -1966,33 +2093,17 @@ file, You can obtain one at http://mozil
               // Don't fire DOMMenuItemActive so that screen readers still see
               // the input as being focused.
               this.richlistbox.suppressMenuItemEvent = true;
               this.input.controller.setInitiallySelectedIndex(0);
               this.richlistbox.suppressMenuItemEvent = false;
             }
 
             this.input.gotResultForCurrentQuery = true;
-
-            // Check if we should perform a delayed handleEnter.
-            if (this.input.handleEnterInstance) {
-              let instance = this.input.handleEnterInstance;
-              this.input.handleEnterInstance = null;
-              // Don't handle this immediately or we could cause a recursive
-              // loop where the controller sets popupOpen and re-enters here.
-              setTimeout(() => {
-                // Safety check: handle only if the search string didn't change.
-                let { event, searchString } = instance;
-                if (this.input.mController.searchString == searchString) {
-                  this.input.maybeCanonizeURL(event, searchString);
-                  this.input.mController.handleEnter(false, event);
-                  this.overrideValue = null;
-                }
-              }, 0);
-            }
+            this.input.maybeReplayDeferredKeyEvents();
           ]]>
         </body>
       </method>
 
       <method name="_onSearchBegin">
         <body><![CDATA[
           // Set the selected index to 0 (heuristic) until a result comes back
           // and we can evaluate it better.
--- a/toolkit/content/widgets/autocomplete.xml
+++ b/toolkit/content/widgets/autocomplete.xml
@@ -471,19 +471,20 @@
       </method>
 
       <method name="handleKeyPress">
         <parameter name="aEvent"/>
         <body><![CDATA[
           if (aEvent.target.localName != "textbox")
             return true; // Let child buttons of autocomplete take input
 
-          // XXXpch this is so bogus...
-          if (aEvent.defaultPrevented)
+          // Re: urlbarDeferred, see the comment in urlbarBindings.xml.
+          if (aEvent.defaultPrevented && !aEvent.urlbarDeferred) {
             return false;
+          }
 
           var cancel = false;
 
           // Catch any keys that could potentially move the caret. Ctrl can be
           // used in combination with these keys on Windows and Linux; and Alt
           // can be used on OS X, so make sure the unused one isn't used.
           let metaKey = /Mac/.test(navigator.platform) ? aEvent.ctrlKey : aEvent.altKey;
           if (!this.disableKeyNavigation && !metaKey) {