Bug 612453 - Provide search suggestions on Firefox Start Page (about:home). r=MattN, r=felipe, a=sledru
authorDrew Willcoxon <adw@mozilla.com>
Fri, 01 Aug 2014 11:57:20 -0700
changeset 217659 ea4892a75c909b65d42591c8ccf060064aa06653
parent 217658 e2efba644fb3c032d60cad02db8f65107b3e8256
child 217660 62505420ae112a5df004f2ec8fb8df5e1fce659f
push id515
push userraliiev@mozilla.com
push dateMon, 06 Oct 2014 12:51:51 +0000
treeherdermozilla-release@267c7a481bef [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN, felipe, sledru
bugs612453
milestone33.0a2
Bug 612453 - Provide search suggestions on Firefox Start Page (about:home). r=MattN, r=felipe, a=sledru
browser/base/content/abouthome/aboutHome.js
browser/base/content/abouthome/aboutHome.xhtml
browser/base/content/content.js
browser/base/content/searchSuggestionUI.css
browser/base/content/searchSuggestionUI.js
browser/base/content/test/general/browser.ini
browser/base/content/test/general/browser_aboutHome.js
browser/base/content/test/general/browser_searchSuggestionUI.js
browser/base/content/test/general/searchSuggestionEngine.sjs
browser/base/content/test/general/searchSuggestionEngine.xml
browser/base/content/test/general/searchSuggestionUI.html
browser/base/content/test/general/searchSuggestionUI.js
browser/base/jar.mn
browser/modules/ContentSearch.jsm
browser/modules/test/browser.ini
browser/modules/test/browser_ContentSearch.js
browser/modules/test/contentSearchSuggestions.sjs
browser/modules/test/contentSearchSuggestions.xml
toolkit/components/search/SearchSuggestionController.jsm
--- a/browser/base/content/abouthome/aboutHome.js
+++ b/browser/base/content/abouthome/aboutHome.js
@@ -305,20 +305,26 @@ function onSearchSubmit(aEvent)
     let eventData = JSON.stringify({
       engineName: engineName,
       searchTerms: searchTerms
     });
     let event = new CustomEvent("AboutHomeSearchEvent", {detail: eventData});
     document.dispatchEvent(event);
   }
 
-  aEvent.preventDefault();
+  gSearchSuggestionController.addInputValueToFormHistory();
+
+  if (aEvent) {
+    aEvent.preventDefault();
+  }
 }
 
 
+let gSearchSuggestionController;
+
 function setupSearchEngine()
 {
   // The "autofocus" attribute doesn't focus the form element
   // immediately when the element is first drawn, so the
   // attribute is also used for styling when the page first loads.
   let searchText = document.getElementById("searchText");
   searchText.addEventListener("blur", function searchText_onBlur() {
     searchText.removeEventListener("blur", searchText_onBlur);
@@ -336,16 +342,22 @@ function setupSearchEngine()
     logoElt.alt = searchEngineName;
     searchText.placeholder = "";
   }
   else {
     logoElt.parentNode.hidden = true;
     searchText.placeholder = searchEngineName;
   }
 
+  if (!gSearchSuggestionController) {
+    gSearchSuggestionController =
+      new SearchSuggestionUIController(searchText, searchText.parentNode,
+                                       onSearchSubmit);
+  }
+  gSearchSuggestionController.engineName = searchEngineName;
 }
 
 /**
  * Inform the test harness that we're done loading the page.
  */
 function loadCompleted()
 {
   var event = new CustomEvent("AboutHomeLoadSnippetsCompleted", {bubbles:true});
--- a/browser/base/content/abouthome/aboutHome.xhtml
+++ b/browser/base/content/abouthome/aboutHome.xhtml
@@ -19,20 +19,24 @@
 
 <html xmlns="http://www.w3.org/1999/xhtml">
   <head>
     <title>&abouthome.pageTitle;</title>
 
     <link rel="icon" type="image/png" id="favicon"
           href="chrome://branding/content/icon32.png"/>
     <link rel="stylesheet" type="text/css" media="all"
+          href="chrome://browser/content/searchSuggestionUI.css"/>
+    <link rel="stylesheet" type="text/css" media="all" defer="defer"
           href="chrome://browser/content/abouthome/aboutHome.css"/>
 
     <script type="text/javascript;version=1.8"
             src="chrome://browser/content/abouthome/aboutHome.js"/>
+    <script type="text/javascript;version=1.8"
+            src="chrome://browser/content/searchSuggestionUI.js"/>
   </head>
 
   <body dir="&locale.dir;">
     <div class="spacer"/>
     <div id="topSection">
       <div id="brandLogo"></div>
 
       <div id="searchContainer">
--- a/browser/base/content/content.js
+++ b/browser/base/content/content.js
@@ -214,16 +214,17 @@ let AboutHomeListener = {
   },
 };
 AboutHomeListener.init(this);
 
 
 let ContentSearchMediator = {
 
   whitelist: new Set([
+    "about:home",
     "about:newtab",
   ]),
 
   init: function (chromeGlobal) {
     chromeGlobal.addEventListener("ContentSearchClient", this, true, true);
     addMessageListener("ContentSearch", this);
   },
 
@@ -242,33 +243,35 @@ let ContentSearchMediator = {
       return;
     }
     if (this._contentWhitelisted) {
       this._fireEvent(msg.data.type, msg.data.data);
     }
   },
 
   get _contentWhitelisted() {
-    return this.whitelist.has(content.document.documentURI.toLowerCase());
+    return this.whitelist.has(content.document.documentURI);
   },
 
   _sendMsg: function (type, data=null) {
     sendAsyncMessage("ContentSearch", {
       type: type,
       data: data,
     });
   },
 
   _fireEvent: function (type, data=null) {
-    content.dispatchEvent(new content.CustomEvent("ContentSearchService", {
+    let event = Cu.cloneInto({
       detail: {
         type: type,
         data: data,
       },
-    }));
+    }, content);
+    content.dispatchEvent(new content.CustomEvent("ContentSearchService",
+                                                  event));
   },
 };
 ContentSearchMediator.init(this);
 
 
 var global = this;
 
 // Lazily load the finder code
new file mode 100644
--- /dev/null
+++ b/browser/base/content/searchSuggestionUI.css
@@ -0,0 +1,48 @@
+/* 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/. */
+
+.searchSuggestionTable {
+  background-color: hsla(0,0%,100%,.99);
+  border: 1px solid;
+  border-color: hsla(210,54%,20%,.15) hsla(210,54%,20%,.17) hsla(210,54%,20%,.2);
+  border-spacing: 0;
+  border-top: 0;
+  box-shadow: 0 1px 0 hsla(210,65%,9%,.02) inset,
+              0 0 2px hsla(210,65%,9%,.1) inset,
+              0 1px 0 hsla(0,0%,100%,.2);
+  overflow: hidden;
+  padding: 0;
+  position: absolute;
+  text-align: start;
+  z-index: 1001;
+}
+
+.searchSuggestionRow {
+  cursor: default;
+  margin: 0;
+  max-width: inherit;
+  padding: 0;
+}
+
+.searchSuggestionRow.formHistory {
+  color: hsl(210,100%,40%);
+}
+
+.searchSuggestionRow.selected {
+  background-color: hsl(210,100%,40%);
+  color: hsl(0,0%,100%);
+}
+
+.searchSuggestionEntry {
+  margin: 0;
+  max-width: inherit;
+  overflow: hidden;
+  padding: 6px 8px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.searchSuggestionEntry > span.typed {
+  font-weight: bold;
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/searchSuggestionUI.js
@@ -0,0 +1,379 @@
+/* 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/. */
+
+"use strict";
+
+this.SearchSuggestionUIController = (function () {
+
+const MAX_DISPLAYED_SUGGESTIONS = 6;
+const SUGGESTION_ID_PREFIX = "searchSuggestion";
+const CSS_URI = "chrome://browser/content/searchSuggestionUI.css";
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * Creates a new object that manages search suggestions and their UI for a text
+ * box.
+ *
+ * The UI consists of an html:table that's inserted into the DOM after the given
+ * text box and styled so that it appears as a dropdown below the text box.
+ *
+ * @param inputElement
+ *        Search suggestions will be based on the text in this text box.
+ *        Assumed to be an html:input.  xul:textbox is untested but might work.
+ * @param tableParent
+ *        The suggestion table is appended as a child to this element.  Since
+ *        the table is absolutely positioned and its top and left values are set
+ *        to be relative to the top and left of the page, either the parent and
+ *        all its ancestors should not be positioned elements (i.e., their
+ *        positions should be "static"), or the parent's position should be the
+ *        top left of the page.
+ * @param onClick
+ *        A function that's called when a search suggestion is clicked.  Ideally
+ *        we could call submit() on inputElement's ancestor form, but that
+ *        doesn't trigger submit listeners.
+ * @param idPrefix
+ *        The IDs of elements created by the object will be prefixed with this
+ *        string.
+ */
+function SearchSuggestionUIController(inputElement, tableParent, onClick=null,
+                                      idPrefix="") {
+  this.input = inputElement;
+  this.onClick = onClick;
+  this._idPrefix = idPrefix;
+
+  let tableID = idPrefix + "searchSuggestionTable";
+  this.input.autocomplete = "off";
+  this.input.setAttribute("aria-autocomplete", "true");
+  this.input.setAttribute("aria-controls", tableID);
+  tableParent.appendChild(this._makeTable(tableID));
+
+  this.input.addEventListener("keypress", this);
+  this.input.addEventListener("input", this);
+  this.input.addEventListener("focus", this);
+  this.input.addEventListener("blur", this);
+  window.addEventListener("ContentSearchService", this);
+
+  this._stickyInputValue = "";
+  this._hideSuggestions();
+}
+
+SearchSuggestionUIController.prototype = {
+
+  // The timeout (ms) of the remote suggestions.  Corresponds to
+  // SearchSuggestionController.remoteTimeout.  Uses
+  // SearchSuggestionController's default timeout if falsey.
+  remoteTimeout: undefined,
+
+  get engineName() {
+    return this._engineName;
+  },
+
+  set engineName(val) {
+    this._engineName = val;
+    if (val && document.activeElement == this.input) {
+      this._speculativeConnect();
+    }
+  },
+
+  get selectedIndex() {
+    for (let i = 0; i < this._table.children.length; i++) {
+      let row = this._table.children[i];
+      if (row.classList.contains("selected")) {
+        return i;
+      }
+    }
+    return -1;
+  },
+
+  set selectedIndex(idx) {
+    // Update the table's rows, and the input when there is a selection.
+    this._table.removeAttribute("aria-activedescendant");
+    for (let i = 0; i < this._table.children.length; i++) {
+      let row = this._table.children[i];
+      if (i == idx) {
+        row.classList.add("selected");
+        row.firstChild.setAttribute("aria-selected", "true");
+        this._table.setAttribute("aria-activedescendant", row.firstChild.id);
+        this.input.value = this.suggestionAtIndex(i);
+      }
+      else {
+        row.classList.remove("selected");
+        row.firstChild.setAttribute("aria-selected", "false");
+      }
+    }
+
+    // Update the input when there is no selection.
+    if (idx < 0) {
+      this.input.value = this._stickyInputValue;
+    }
+  },
+
+  get numSuggestions() {
+    return this._table.children.length;
+  },
+
+  suggestionAtIndex: function (idx) {
+    let row = this._table.children[idx];
+    return row ? row.textContent : null;
+  },
+
+  deleteSuggestionAtIndex: function (idx) {
+    // Only form history suggestions can be deleted.
+    if (this.isFormHistorySuggestionAtIndex(idx)) {
+      let suggestionStr = this.suggestionAtIndex(idx);
+      this._sendMsg("RemoveFormHistoryEntry", suggestionStr);
+      this._table.children[idx].remove();
+      this.selectedIndex = -1;
+    }
+  },
+
+  isFormHistorySuggestionAtIndex: function (idx) {
+    let row = this._table.children[idx];
+    return row && row.classList.contains("formHistory");
+  },
+
+  addInputValueToFormHistory: function () {
+    this._sendMsg("AddFormHistoryEntry", this.input.value);
+  },
+
+  handleEvent: function (event) {
+    this["_on" + event.type[0].toUpperCase() + event.type.substr(1)](event);
+  },
+
+  _onInput: function () {
+    if (this.input.value) {
+      this._getSuggestions();
+    }
+    else {
+      this._stickyInputValue = "";
+      this._hideSuggestions();
+    }
+    this.selectedIndex = -1;
+  },
+
+  _onKeypress: function (event) {
+    let selectedIndexDelta = 0;
+    switch (event.keyCode) {
+    case event.DOM_VK_UP:
+      if (this.numSuggestions) {
+        selectedIndexDelta = -1;
+      }
+      break;
+    case event.DOM_VK_DOWN:
+      if (this.numSuggestions) {
+        selectedIndexDelta = 1;
+      }
+      else {
+        this._getSuggestions();
+      }
+      break;
+    case event.DOM_VK_RIGHT:
+      // Allow normal caret movement until the caret is at the end of the input.
+      if (this.input.selectionStart != this.input.selectionEnd ||
+          this.input.selectionEnd != this.input.value.length) {
+        return;
+      }
+      // else, fall through
+    case event.DOM_VK_RETURN:
+      if (this.selectedIndex >= 0) {
+        this.input.value = this.suggestionAtIndex(this.selectedIndex);
+      }
+      this._stickyInputValue = this.input.value;
+      this._hideSuggestions();
+      break;
+    case event.DOM_VK_DELETE:
+      if (this.selectedIndex >= 0) {
+        this.deleteSuggestionAtIndex(this.selectedIndex);
+      }
+      break;
+    default:
+      return;
+    }
+
+    if (selectedIndexDelta) {
+      // Update the selection.
+      let newSelectedIndex = this.selectedIndex + selectedIndexDelta;
+      if (newSelectedIndex < -1) {
+        newSelectedIndex = this.numSuggestions - 1;
+      }
+      else if (this.numSuggestions <= newSelectedIndex) {
+        newSelectedIndex = -1;
+      }
+      this.selectedIndex = newSelectedIndex;
+
+      // Prevent the input's caret from moving.
+      event.preventDefault();
+    }
+  },
+
+  _onFocus: function () {
+    this._speculativeConnect();
+  },
+
+  _onBlur: function () {
+    this._hideSuggestions();
+  },
+
+  _onMousemove: function (event) {
+    // It's important to listen for mousemove, not mouseover or mouseenter.  The
+    // latter two are triggered when the user is typing and the mouse happens to
+    // be over the suggestions popup.
+    this.selectedIndex = this._indexOfTableRowOrDescendent(event.target);
+  },
+
+  _onMousedown: function (event) {
+    let idx = this._indexOfTableRowOrDescendent(event.target);
+    let suggestion = this.suggestionAtIndex(idx);
+    this._stickyInputValue = suggestion;
+    this.input.value = suggestion;
+    this._hideSuggestions();
+    if (this.onClick) {
+      this.onClick.call(null);
+    }
+  },
+
+  _onContentSearchService: function (event) {
+    let methodName = "_onMsg" + event.detail.type;
+    if (methodName in this) {
+      this[methodName](event.detail.data);
+    }
+  },
+
+  _onMsgSuggestions: function (suggestions) {
+    // Ignore the suggestions if their search string or engine doesn't match
+    // ours.  Due to the async nature of message passing, this can easily happen
+    // when the user types quickly.
+    if (this._stickyInputValue != suggestions.searchString ||
+        this.engineName != suggestions.engineName) {
+      return;
+    }
+
+    // Empty the table.
+    while (this._table.firstElementChild) {
+      this._table.firstElementChild.remove();
+    }
+
+    // Position and size the table.
+    let { left, bottom } = this.input.getBoundingClientRect();
+    this._table.style.left = (left + window.scrollX) + "px";
+    this._table.style.top = (bottom + window.scrollY) + "px";
+    this._table.style.minWidth = this.input.offsetWidth + "px";
+    this._table.style.maxWidth = (window.innerWidth - left - 40) + "px";
+
+    // Add the suggestions to the table.
+    let searchWords =
+      new Set(suggestions.searchString.trim().toLowerCase().split(/\s+/));
+    for (let i = 0; i < MAX_DISPLAYED_SUGGESTIONS; i++) {
+      let type, idx;
+      if (i < suggestions.formHistory.length) {
+        [type, idx] = ["formHistory", i];
+      }
+      else {
+        let j = i - suggestions.formHistory.length;
+        if (j < suggestions.remote.length) {
+          [type, idx] = ["remote", j];
+        }
+        else {
+          break;
+        }
+      }
+      this._table.appendChild(this._makeTableRow(type, suggestions[type][idx],
+                                                 i, searchWords));
+    }
+
+    this._table.hidden = false;
+    this.input.setAttribute("aria-expanded", "true");
+  },
+
+  _speculativeConnect: function () {
+    if (this.engineName) {
+      this._sendMsg("SpeculativeConnect", this.engineName);
+    }
+  },
+
+  _makeTableRow: function (type, suggestionStr, currentRow, searchWords) {
+    let row = document.createElementNS(HTML_NS, "tr");
+    row.classList.add("searchSuggestionRow");
+    row.classList.add(type);
+    row.setAttribute("role", "presentation");
+    row.addEventListener("mousemove", this);
+    row.addEventListener("mousedown", this);
+
+    let entry = document.createElementNS(HTML_NS, "td");
+    entry.classList.add("searchSuggestionEntry");
+    entry.setAttribute("role", "option");
+    entry.id = this._idPrefix + SUGGESTION_ID_PREFIX + currentRow;
+    entry.setAttribute("aria-selected", "false");
+
+    let suggestionWords = suggestionStr.trim().toLowerCase().split(/\s+/);
+    for (let i = 0; i < suggestionWords.length; i++) {
+      let word = suggestionWords[i];
+      let wordSpan = document.createElementNS(HTML_NS, "span");
+      if (searchWords.has(word)) {
+        wordSpan.classList.add("typed");
+      }
+      wordSpan.textContent = word;
+      entry.appendChild(wordSpan);
+      if (i < suggestionWords.length - 1) {
+        entry.appendChild(document.createTextNode(" "));
+      }
+    }
+
+    row.appendChild(entry);
+    return row;
+  },
+
+  _getSuggestions: function () {
+    this._stickyInputValue = this.input.value;
+    if (this.engineName) {
+      this._sendMsg("GetSuggestions", {
+        engineName: this.engineName,
+        searchString: this.input.value,
+        remoteTimeout: this.remoteTimeout,
+      });
+    }
+  },
+
+  _hideSuggestions: function () {
+    this.input.setAttribute("aria-expanded", "false");
+    this._table.hidden = true;
+    while (this._table.firstElementChild) {
+      this._table.firstElementChild.remove();
+    }
+    this.selectedIndex = -1;
+  },
+
+  _indexOfTableRowOrDescendent: function (row) {
+    while (row && row.localName != "tr") {
+      row = row.parentNode;
+    }
+    if (!row) {
+      throw new Error("Element is not a row");
+    }
+    return row.rowIndex;
+  },
+
+  _makeTable: function (id) {
+    this._table = document.createElementNS(HTML_NS, "table");
+    this._table.id = id;
+    this._table.hidden = true;
+    this._table.dir = "auto";
+    this._table.classList.add("searchSuggestionTable");
+    this._table.setAttribute("role", "listbox");
+    return this._table;
+  },
+
+  _sendMsg: function (type, data=null) {
+    dispatchEvent(new CustomEvent("ContentSearchClient", {
+      detail: {
+        type: type,
+        data: data,
+      },
+    }));
+  },
+};
+
+return SearchSuggestionUIController;
+})();
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -59,16 +59,18 @@ support-files =
   head.js
   healthreport_testRemoteCommands.html
   moz.png
   offlineQuotaNotification.cacheManifest
   offlineQuotaNotification.html
   page_style_sample.html
   print_postdata.sjs
   redirect_bug623155.sjs
+  searchSuggestionEngine.sjs
+  searchSuggestionEngine.xml
   test-mixedcontent-securityerrors.html
   test_bug435035.html
   test_bug462673.html
   test_bug628179.html
   test_bug839103.html
   test_bug959531.html
   test_wyciwyg_copying.html
   title_test.svg
@@ -361,16 +363,20 @@ skip-if = true  # disabled until the tre
                 # it ever is (bug 480169)
 [browser_save_link-perwindowpb.js]
 skip-if = e10s # Bug ?????? - test directly manipulates content (event.target)
 [browser_save_private_link_perwindowpb.js]
 skip-if = e10s # e10s: Bug 933103 - mochitest's EventUtils.synthesizeMouse functions not e10s friendly
 [browser_save_video.js]
 skip-if = e10s # Bug ?????? - test directly manipulates content (event.target)
 [browser_scope.js]
+[browser_searchSuggestionUI.js]
+support-files =
+  searchSuggestionUI.html
+  searchSuggestionUI.js
 [browser_selectTabAtIndex.js]
 skip-if = e10s # Bug ?????? - no idea! "Accel+9 selects expected tab - Got 0, expected 9"
 [browser_star_hsts.js]
 skip-if = e10s # Bug ?????? - timeout after logging "Error: Channel closing: too late to send/recv, messages will be lost"
 [browser_subframe_favicons_not_used.js]
 [browser_tabDrop.js]
 [browser_tabMatchesInAwesomebar_perwindowpb.js]
 skip-if = e10s # Bug 918634 - swapFrameLoaders not implemented for e10s (test uses gBrowser.swapBrowsersAndCloseOther)
--- a/browser/base/content/test/general/browser_aboutHome.js
+++ b/browser/base/content/test/general/browser_aboutHome.js
@@ -91,17 +91,19 @@ let gTests = [
 
 // Disabled on Linux for intermittent issues with FHR, see Bug 945667.
 {
   desc: "Check that performing a search fires a search event and records to " +
         "Firefox Health Report.",
   setup: function () { },
   run: function () {
     // Skip this test on Linux.
-    if (navigator.platform.indexOf("Linux") == 0) { return; }
+    if (navigator.platform.indexOf("Linux") == 0) {
+      return Promise.resolve();
+    }
 
     try {
       let cm = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager);
       cm.getCategoryEntry("healthreport-js-provider-default", "SearchesProvider");
     } catch (ex) {
       // Health Report disabled, or no SearchesProvider.
       return Promise.resolve();
     }
@@ -366,16 +368,63 @@ let gTests = [
       check(5);
     });
 
     browser.loadURI("https://example.com/browser/browser/base/content/test/general/test_bug959531.html");
     return deferred.promise;
   }
 },
 
+{
+  // See browser_searchSuggestionUI.js for comprehensive content search
+  // suggestion UI tests.
+  desc: "Search suggestion smoke test",
+  setup: function() {},
+  run: function()
+  {
+    return Task.spawn(function* () {
+      // Add a test engine that provides suggestions and switch to it.
+      let engine = yield promiseNewEngine("searchSuggestionEngine.xml");
+      let promise = promiseBrowserAttributes(gBrowser.selectedTab);
+      Services.search.currentEngine = engine;
+      yield promise;
+
+      // Avoid intermittent failures.
+      gBrowser.contentWindow.wrappedJSObject.gSearchSuggestionController.remoteTimeout = 5000;
+
+      // Type an X in the search input.
+      let input = gBrowser.contentDocument.getElementById("searchText");
+      input.focus();
+      EventUtils.synthesizeKey("x", {});
+
+      // Wait for the search suggestions to become visible.
+      let table =
+        gBrowser.contentDocument.getElementById("searchSuggestionTable");
+      let deferred = Promise.defer();
+      let observer = new MutationObserver(() => {
+        if (input.getAttribute("aria-expanded") == "true") {
+          observer.disconnect();
+          ok(!table.hidden, "Search suggestion table unhidden");
+          deferred.resolve();
+        }
+      });
+      observer.observe(input, {
+        attributes: true,
+        attributeFilter: ["aria-expanded"],
+      });
+      yield deferred.promise;
+
+      // Empty the search input, causing the suggestions to be hidden.
+      EventUtils.synthesizeKey("a", { accelKey: true });
+      EventUtils.synthesizeKey("VK_DELETE", {});
+      ok(table.hidden, "Search suggestion table hidden");
+    });
+  }
+},
+
 ];
 
 function test()
 {
   waitForExplicitFinish();
   requestLongerTimeout(2);
   ignoreAllUncaughtExceptions();
 
@@ -538,8 +587,26 @@ function waitForLoad(cb) {
     if (browser.currentURI.spec == "about:blank")
       return;
     info("Page loaded: " + browser.currentURI.spec);
     browser.removeEventListener("load", listener, true);
 
     cb();
   }, true);
 }
+
+function promiseNewEngine(basename) {
+  info("Waiting for engine to be added: " + basename);
+  let addDeferred = Promise.defer();
+  let url = getRootDirectory(gTestPath) + basename;
+  Services.search.addEngine(url, Ci.nsISearchEngine.TYPE_MOZSEARCH, "", false, {
+    onSuccess: function (engine) {
+      info("Search engine added: " + basename);
+      registerCleanupFunction(() => Services.search.removeEngine(engine));
+      addDeferred.resolve(engine);
+    },
+    onError: function (errCode) {
+      ok(false, "addEngine failed with error code " + errCode);
+      addDeferred.reject();
+    },
+  });
+  return addDeferred.promise;
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_searchSuggestionUI.js
@@ -0,0 +1,305 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_PAGE_BASENAME = "searchSuggestionUI.html";
+const TEST_CONTENT_SCRIPT_BASENAME = "searchSuggestionUI.js";
+const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml";
+
+const TEST_MSG = "SearchSuggestionUIControllerTest";
+
+add_task(function* emptyInput() {
+  yield setUp();
+
+  let state = yield msg("key", { key: "x", waitForSuggestions: true });
+  checkState(state, "x", ["xfoo", "xbar"], -1);
+
+  state = yield msg("key", "VK_BACK_SPACE");
+  checkState(state, "", [], -1);
+
+  yield msg("reset");
+});
+
+add_task(function* blur() {
+  yield setUp();
+
+  let state = yield msg("key", { key: "x", waitForSuggestions: true });
+  checkState(state, "x", ["xfoo", "xbar"], -1);
+
+  state = yield msg("blur");
+  checkState(state, "x", [], -1);
+
+  yield msg("reset");
+});
+
+add_task(function* arrowKeys() {
+  yield setUp();
+
+  let state = yield msg("key", { key: "x", waitForSuggestions: true });
+  checkState(state, "x", ["xfoo", "xbar"], -1);
+
+  // Cycle down the suggestions starting from no selection.
+  state = yield msg("key", "VK_DOWN");
+  checkState(state, "xfoo", ["xfoo", "xbar"], 0);
+
+  state = yield msg("key", "VK_DOWN");
+  checkState(state, "xbar", ["xfoo", "xbar"], 1);
+
+  state = yield msg("key", "VK_DOWN");
+  checkState(state, "x", ["xfoo", "xbar"], -1);
+
+  // Cycle up starting from no selection.
+  state = yield msg("key", "VK_UP");
+  checkState(state, "xbar", ["xfoo", "xbar"], 1);
+
+  state = yield msg("key", "VK_UP");
+  checkState(state, "xfoo", ["xfoo", "xbar"], 0);
+
+  state = yield msg("key", "VK_UP");
+  checkState(state, "x", ["xfoo", "xbar"], -1);
+
+  yield msg("reset");
+});
+
+// The right arrow and return key function the same.
+function rightArrowOrReturn(keyName) {
+  return function* rightArrowOrReturnTest() {
+    yield setUp();
+
+    let state = yield msg("key", { key: "x", waitForSuggestions: true });
+    checkState(state, "x", ["xfoo", "xbar"], -1);
+
+    state = yield msg("key", "VK_DOWN");
+    checkState(state, "xfoo", ["xfoo", "xbar"], 0);
+
+    // This should make the xfoo suggestion sticky.  To make sure it sticks,
+    // trigger suggestions again and cycle through them by pressing Down until
+    // nothing is selected again.
+    state = yield msg("key", keyName);
+    checkState(state, "xfoo", [], -1);
+
+    state = yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
+    checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
+
+    state = yield msg("key", "VK_DOWN");
+    checkState(state, "xfoofoo", ["xfoofoo", "xfoobar"], 0);
+
+    state = yield msg("key", "VK_DOWN");
+    checkState(state, "xfoobar", ["xfoofoo", "xfoobar"], 1);
+
+    state = yield msg("key", "VK_DOWN");
+    checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
+
+    yield msg("reset");
+  };
+}
+
+add_task(rightArrowOrReturn("VK_RIGHT"));
+add_task(rightArrowOrReturn("VK_RETURN"));
+
+add_task(function* mouse() {
+  yield setUp();
+
+  let state = yield msg("key", { key: "x", waitForSuggestions: true });
+  checkState(state, "x", ["xfoo", "xbar"], -1);
+
+  // Mouse over the first suggestion.
+  state = yield msg("mousemove", 0);
+  checkState(state, "xfoo", ["xfoo", "xbar"], 0);
+
+  // Mouse over the second suggestion.
+  state = yield msg("mousemove", 1);
+  checkState(state, "xbar", ["xfoo", "xbar"], 1);
+
+  // Click the second suggestion.  This should make it sticky.  To make sure it
+  // sticks, trigger suggestions again and cycle through them by pressing Down
+  // until nothing is selected again.
+  state = yield msg("mousedown", 1);
+  checkState(state, "xbar", [], -1);
+
+  state = yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
+  checkState(state, "xbar", ["xbarfoo", "xbarbar"], -1);
+
+  state = yield msg("key", "VK_DOWN");
+  checkState(state, "xbarfoo", ["xbarfoo", "xbarbar"], 0);
+
+  state = yield msg("key", "VK_DOWN");
+  checkState(state, "xbarbar", ["xbarfoo", "xbarbar"], 1);
+
+  state = yield msg("key", "VK_DOWN");
+  checkState(state, "xbar", ["xbarfoo", "xbarbar"], -1);
+
+  yield msg("reset");
+});
+
+add_task(function* formHistory() {
+  yield setUp();
+
+  // Type an X and add it to form history.
+  let state = yield msg("key", { key: "x", waitForSuggestions: true });
+  checkState(state, "x", ["xfoo", "xbar"], -1);
+  yield msg("addInputValueToFormHistory");
+
+  // Wait for Satchel to say it's been added to form history.
+  let deferred = Promise.defer();
+  Services.obs.addObserver(function onAdd(subj, topic, data) {
+    if (data == "formhistory-add") {
+      executeSoon(() => deferred.resolve());
+    }
+  }, "satchel-storage-changed", false);
+  yield deferred.promise;
+
+  // Reset the input.
+  state = yield msg("reset");
+  checkState(state, "", [], -1);
+
+  // Type an X again.  The form history entry should appear.
+  state = yield msg("key", { key: "x", waitForSuggestions: true });
+  checkState(state, "x", [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
+             -1);
+
+  // Select the form history entry and delete it.
+  state = yield msg("key", "VK_DOWN");
+  checkState(state, "x", [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
+             0);
+
+  state = yield msg("key", "VK_DELETE");
+  checkState(state, "x", ["xfoo", "xbar"], -1);
+
+  // Wait for Satchel.
+  deferred = Promise.defer();
+  Services.obs.addObserver(function onAdd(subj, topic, data) {
+    if (data == "formhistory-remove") {
+      executeSoon(() => deferred.resolve());
+    }
+  }, "satchel-storage-changed", false);
+  yield deferred.promise;
+
+  // Reset the input.
+  state = yield msg("reset");
+  checkState(state, "", [], -1);
+
+  // Type an X again.  The form history entry should still be gone.
+  state = yield msg("key", { key: "x", waitForSuggestions: true });
+  checkState(state, "x", ["xfoo", "xbar"], -1);
+
+  yield msg("reset");
+});
+
+
+let gDidInitialSetUp = false;
+
+function setUp() {
+  return Task.spawn(function* () {
+    if (!gDidInitialSetUp) {
+      yield promiseNewEngine(TEST_ENGINE_BASENAME);
+      yield promiseTab();
+      gDidInitialSetUp = true;
+    }
+    yield msg("focus");
+  });
+}
+
+function msg(type, data=null) {
+  gMsgMan.sendAsyncMessage(TEST_MSG, {
+    type: type,
+    data: data,
+  });
+  let deferred = Promise.defer();
+  gMsgMan.addMessageListener(TEST_MSG, function onMsg(msg) {
+    gMsgMan.removeMessageListener(TEST_MSG, onMsg);
+    deferred.resolve(msg.data);
+  });
+  return deferred.promise;
+}
+
+function checkState(actualState, expectedInputVal, expectedSuggestions,
+                    expectedSelectedIdx) {
+  expectedSuggestions = expectedSuggestions.map(sugg => {
+    return typeof(sugg) == "object" ? sugg : {
+      str: sugg,
+      type: "remote",
+    };
+  });
+
+  let expectedState = {
+    selectedIndex: expectedSelectedIdx,
+    numSuggestions: expectedSuggestions.length,
+    suggestionAtIndex: expectedSuggestions.map(s => s.str),
+    isFormHistorySuggestionAtIndex:
+      expectedSuggestions.map(s => s.type == "formHistory"),
+
+    tableHidden: expectedSuggestions.length == 0,
+    tableChildrenLength: expectedSuggestions.length,
+    tableChildren: expectedSuggestions.map((s, i) => {
+      let expectedClasses = new Set([s.type]);
+      if (i == expectedSelectedIdx) {
+        expectedClasses.add("selected");
+      }
+      return {
+        textContent: s.str,
+        classes: expectedClasses,
+      };
+    }),
+
+    inputValue: expectedInputVal,
+    ariaExpanded: expectedSuggestions.length == 0 ? "false" : "true",
+  };
+
+  SimpleTest.isDeeply(actualState, expectedState, "State");
+}
+
+var gMsgMan;
+
+function promiseTab() {
+  let deferred = Promise.defer();
+  let tab = gBrowser.addTab();
+  registerCleanupFunction(() => gBrowser.removeTab(tab));
+  gBrowser.selectedTab = tab;
+  let pageURL = getRootDirectory(gTestPath) + TEST_PAGE_BASENAME;
+  tab.linkedBrowser.addEventListener("load", function onLoad(event) {
+    tab.linkedBrowser.removeEventListener("load", onLoad, true);
+    gMsgMan = tab.linkedBrowser.messageManager;
+    gMsgMan.sendAsyncMessage("ContentSearch", {
+      type: "AddToWhitelist",
+      data: [pageURL],
+    });
+    promiseMsg("ContentSearch", "AddToWhitelistAck", gMsgMan).then(() => {
+      let jsURL = getRootDirectory(gTestPath) + TEST_CONTENT_SCRIPT_BASENAME;
+      gMsgMan.loadFrameScript(jsURL, false);
+      deferred.resolve();
+    });
+  }, true, true);
+  openUILinkIn(pageURL, "current");
+  return deferred.promise;
+}
+
+function promiseMsg(name, type, msgMan) {
+  let deferred = Promise.defer();
+  info("Waiting for " + name + " message " + type + "...");
+  msgMan.addMessageListener(name, function onMsg(msg) {
+    info("Received " + name + " message " + msg.data.type + "\n");
+    if (msg.data.type == type) {
+      msgMan.removeMessageListener(name, onMsg);
+      deferred.resolve(msg);
+    }
+  });
+  return deferred.promise;
+}
+
+function promiseNewEngine(basename) {
+  info("Waiting for engine to be added: " + basename);
+  let addDeferred = Promise.defer();
+  let url = getRootDirectory(gTestPath) + basename;
+  Services.search.addEngine(url, Ci.nsISearchEngine.TYPE_MOZSEARCH, "", false, {
+    onSuccess: function (engine) {
+      info("Search engine added: " + basename);
+      registerCleanupFunction(() => Services.search.removeEngine(engine));
+      addDeferred.resolve(engine);
+    },
+    onError: function (errCode) {
+      ok(false, "addEngine failed with error code " + errCode);
+      addDeferred.reject();
+    },
+  });
+  return addDeferred.promise;
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/searchSuggestionEngine.sjs
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(req, resp) {
+  let suffixes = ["foo", "bar"];
+  let data = [req.queryString, suffixes.map(s => req.queryString + s)];
+  resp.setHeader("Content-Type", "application/json", false);
+  resp.write(JSON.stringify(data));
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/searchSuggestionEngine.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Any copyright is dedicated to the Public Domain.
+   - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName>
+<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/base/content/test/general/searchSuggestionEngine.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://browser-searchSuggestionEngine.com/searchSuggestionEngine" rel="searchform"/>
+</SearchPlugin>
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/searchSuggestionUI.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<!-- Any copyright is dedicated to the Public Domain.
+   - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<html>
+<head>
+<meta charset="utf-8">
+<script type="application/javascript;version=1.8"
+        src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js">
+</script>
+<script type="application/javascript;version=1.8"
+        src="chrome://browser/content/searchSuggestionUI.js">
+</script>
+</head>
+<body>
+
+<input>
+
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/searchSuggestionUI.js
@@ -0,0 +1,138 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+(function () {
+
+const TEST_MSG = "SearchSuggestionUIControllerTest";
+const ENGINE_NAME = "browser_searchSuggestionEngine searchSuggestionEngine.xml";
+
+let input = content.document.querySelector("input");
+let gController =
+  new content.SearchSuggestionUIController(input, input.parentNode);
+gController.engineName = ENGINE_NAME;
+gController.remoteTimeout = 5000;
+
+addMessageListener(TEST_MSG, msg => {
+  messageHandlers[msg.data.type](msg.data.data);
+});
+
+let messageHandlers = {
+
+  key: function (arg) {
+    let keyName = typeof(arg) == "string" ? arg : arg.key;
+    content.synthesizeKey(keyName, {});
+    let wait = arg.waitForSuggestions ? waitForSuggestions : cb => cb();
+    wait(ack);
+  },
+
+  focus: function () {
+    gController.input.focus();
+    ack();
+  },
+
+  blur: function () {
+    gController.input.blur();
+    ack();
+  },
+
+  mousemove: function (suggestionIdx) {
+    // Copied from widget/tests/test_panel_mouse_coords.xul and
+    // browser/base/content/test/newtab/head.js
+    let row = gController._table.children[suggestionIdx];
+    let rect = row.getBoundingClientRect();
+    let left = content.mozInnerScreenX + rect.left;
+    let x = left + rect.width / 2;
+    let y = content.mozInnerScreenY + rect.top + rect.height / 2;
+
+    let utils = content.SpecialPowers.getDOMWindowUtils(content);
+    let scale = utils.screenPixelsPerCSSPixel;
+
+    let widgetToolkit = content.SpecialPowers.
+                        Cc["@mozilla.org/xre/app-info;1"].
+                        getService(content.SpecialPowers.Ci.nsIXULRuntime).
+                        widgetToolkit;
+    let nativeMsg = widgetToolkit == "cocoa" ? 5 : // NSMouseMoved
+                    widgetToolkit == "windows" ? 1 : // MOUSEEVENTF_MOVE
+                    3; // GDK_MOTION_NOTIFY
+
+    row.addEventListener("mousemove", function onMove() {
+      row.removeEventListener("mousemove", onMove);
+      ack();
+    });
+    utils.sendNativeMouseEvent(x * scale, y * scale, nativeMsg, 0, null);
+  },
+
+  mousedown: function (suggestionIdx) {
+    gController.onClick = () => {
+      gController.onClick = null;
+      ack();
+    };
+    let row = gController._table.children[suggestionIdx];
+    content.sendMouseEvent({ type: "mousedown" }, row);
+  },
+
+  addInputValueToFormHistory: function () {
+    gController.addInputValueToFormHistory();
+    ack();
+  },
+
+  reset: function () {
+    // Reset both the input and suggestions by select all + delete.
+    gController.input.focus();
+    content.synthesizeKey("a", { accelKey: true });
+    content.synthesizeKey("VK_DELETE", {});
+    ack();
+  },
+};
+
+function ack() {
+  sendAsyncMessage(TEST_MSG, currentState());
+}
+
+function waitForSuggestions(cb) {
+  let observer = new content.MutationObserver(() => {
+    if (gController.input.getAttribute("aria-expanded") == "true") {
+      observer.disconnect();
+      cb();
+    }
+  });
+  observer.observe(gController.input, {
+    attributes: true,
+    attributeFilter: ["aria-expanded"],
+  });
+}
+
+function currentState() {
+  let state = {
+    selectedIndex: gController.selectedIndex,
+    numSuggestions: gController.numSuggestions,
+    suggestionAtIndex: [],
+    isFormHistorySuggestionAtIndex: [],
+
+    tableHidden: gController._table.hidden,
+    tableChildrenLength: gController._table.children.length,
+    tableChildren: [],
+
+    inputValue: gController.input.value,
+    ariaExpanded: gController.input.getAttribute("aria-expanded"),
+  };
+
+  for (let i = 0; i < gController.numSuggestions; i++) {
+    state.suggestionAtIndex.push(gController.suggestionAtIndex(i));
+    state.isFormHistorySuggestionAtIndex.push(
+      gController.isFormHistorySuggestionAtIndex(i));
+  }
+
+  for (let child of gController._table.children) {
+    state.tableChildren.push({
+      textContent: child.textContent,
+      classes: new Set(child.className.split(/\s+/)),
+    });
+  }
+
+  return state;
+}
+
+})();
--- a/browser/base/jar.mn
+++ b/browser/base/jar.mn
@@ -113,16 +113,18 @@ browser.jar:
 #endif
         content/browser/safeMode.css                  (content/safeMode.css)
         content/browser/safeMode.js                   (content/safeMode.js)
         content/browser/safeMode.xul                  (content/safeMode.xul)
 *       content/browser/sanitize.js                   (content/sanitize.js)
 *       content/browser/sanitize.xul                  (content/sanitize.xul)
 *       content/browser/sanitizeDialog.js             (content/sanitizeDialog.js)
         content/browser/sanitizeDialog.css            (content/sanitizeDialog.css)
+        content/browser/searchSuggestionUI.js         (content/searchSuggestionUI.js)
+        content/browser/searchSuggestionUI.css        (content/searchSuggestionUI.css)
         content/browser/tabbrowser.css                (content/tabbrowser.css)
 *       content/browser/tabbrowser.xml                (content/tabbrowser.xml)
 *       content/browser/urlbarBindings.xml            (content/urlbarBindings.xml)
 *       content/browser/utilityOverlay.js             (content/utilityOverlay.js)
         content/browser/web-panels.js                 (content/web-panels.js)
 *       content/browser/web-panels.xul                (content/web-panels.xul)
 *       content/browser/baseMenuOverlay.xul           (content/baseMenuOverlay.xul)
 *       content/browser/nsContextMenu.js              (content/nsContextMenu.js)
--- a/browser/modules/ContentSearch.jsm
+++ b/browser/modules/ContentSearch.jsm
@@ -8,79 +8,111 @@ this.EXPORTED_SYMBOLS = [
   "ContentSearch",
 ];
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
+  "resource://gre/modules/FormHistory.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+  "resource://gre/modules/PrivateBrowsingUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController",
+  "resource://gre/modules/SearchSuggestionController.jsm");
 
 const INBOUND_MESSAGE = "ContentSearch";
 const OUTBOUND_MESSAGE = INBOUND_MESSAGE;
 
 /**
  * ContentSearch receives messages named INBOUND_MESSAGE and sends messages
  * named OUTBOUND_MESSAGE.  The data of each message is expected to look like
  * { type, data }.  type is the message's type (or subtype if you consider the
  * type of the message itself to be INBOUND_MESSAGE), and data is data that is
  * specific to the type.
  *
  * Inbound messages have the following types:
  *
+ *   AddFormHistoryEntry
+ *     Adds an entry to the search form history.
+ *     data: the entry, a string
+ *   GetSuggestions
+ *     Retrieves an array of search suggestions given a search string.
+ *     data: { engineName, searchString, [remoteTimeout] }
  *   GetState
- *      Retrieves the current search engine state.
- *      data: null
+ *     Retrieves the current search engine state.
+ *     data: null
  *   ManageEngines
- *      Opens the search engine management window.
- *      data: null
+ *     Opens the search engine management window.
+ *     data: null
+ *   RemoveFormHistoryEntry
+ *     Removes an entry from the search form history.
+ *     data: the entry, a string
  *   Search
- *      Performs a search.
- *      data: an object { engineName, searchString, whence }
+ *     Performs a search.
+ *     data: { engineName, searchString, whence }
  *   SetCurrentEngine
- *      Sets the current engine.
- *      data: the name of the engine
+ *     Sets the current engine.
+ *     data: the name of the engine
+ *   SpeculativeConnect
+ *     Speculatively connects to an engine.
+ *     data: the name of the engine
  *
  * Outbound messages have the following types:
  *
  *   CurrentEngine
- *     Sent when the current engine changes.
+ *     Broadcast when the current engine changes.
  *     data: see _currentEngineObj
  *   CurrentState
- *     Sent when the current search state changes.
+ *     Broadcast when the current search state changes.
  *     data: see _currentStateObj
  *   State
  *     Sent in reply to GetState.
  *     data: see _currentStateObj
+ *   Suggestions
+ *     Sent in reply to GetSuggestions.
+ *     data: see _onMessageGetSuggestions
  */
 
 this.ContentSearch = {
 
   // Inbound events are queued and processed in FIFO order instead of handling
   // them immediately, which would result in non-FIFO responses due to the
   // asynchrononicity added by converting image data URIs to ArrayBuffers.
   _eventQueue: [],
   _currentEvent: null,
 
+  // This is used to handle search suggestions.  It maps xul:browsers to objects
+  // { controller, previousFormHistoryResult }.  See _onMessageGetSuggestions.
+  _suggestionMap: new WeakMap(),
+
   init: function () {
     Cc["@mozilla.org/globalmessagemanager;1"].
       getService(Ci.nsIMessageListenerManager).
       addMessageListener(INBOUND_MESSAGE, this);
     Services.obs.addObserver(this, "browser-search-engine-modified", false);
   },
 
   receiveMessage: function (msg) {
     // Add a temporary event handler that exists only while the message is in
     // the event queue.  If the message's source docshell changes browsers in
     // the meantime, then we need to update msg.target.  event.detail will be
     // the docshell's new parent <xul:browser> element.
-    msg.handleEvent = function (event) {
-      this.target.removeEventListener("SwapDocShells", this, true);
-      this.target = event.detail;
-      this.target.addEventListener("SwapDocShells", this, true);
+    msg.handleEvent = event => {
+      let browserData = this._suggestionMap.get(msg.target);
+      if (browserData) {
+        this._suggestionMap.delete(msg.target);
+        this._suggestionMap.set(event.detail, browserData);
+      }
+      msg.target.removeEventListener("SwapDocShells", msg, true);
+      msg.target = event.detail;
+      msg.target.addEventListener("SwapDocShells", msg, true);
     };
     msg.target.addEventListener("SwapDocShells", msg, true);
 
     this._eventQueue.push({
       type: "Message",
       data: msg,
     });
     this._processEventQueue();
@@ -101,16 +133,19 @@ this.ContentSearch = {
   _processEventQueue: Task.async(function* () {
     if (this._currentEvent || !this._eventQueue.length) {
       return;
     }
     this._currentEvent = this._eventQueue.shift();
     try {
       yield this["_on" + this._currentEvent.type](this._currentEvent.data);
     }
+    catch (err) {
+      Cu.reportError(err);
+    }
     finally {
       this._currentEvent = null;
       this._processEventQueue();
     }
   }),
 
   _onMessage: Task.async(function* (msg) {
     let methodName = "_onMessage" + msg.data.type;
@@ -123,27 +158,21 @@ this.ContentSearch = {
 
   _onMessageGetState: function (msg, data) {
     return this._currentStateObj().then(state => {
       this._reply(msg, "State", state);
     });
   },
 
   _onMessageSearch: function (msg, data) {
-    let expectedDataProps = [
+    this._ensureDataHasProperties(data, [
       "engineName",
       "searchString",
       "whence",
-    ];
-    for (let prop of expectedDataProps) {
-      if (!(prop in data)) {
-        Cu.reportError("Message data missing required property: " + prop);
-        return Promise.resolve();
-      }
-    }
+    ]);
     let browserWin = msg.target.ownerDocument.defaultView;
     let engine = Services.search.getEngineByName(data.engineName);
     browserWin.BrowserSearch.recordSearchInHealthReport(engine, data.whence);
     let submission = engine.getSubmission(data.searchString, "", data.whence);
     browserWin.loadURI(submission.uri.spec, null, submission.postData);
     return Promise.resolve();
   },
 
@@ -165,29 +194,129 @@ this.ContentSearch = {
       browserWin.setTimeout(function () {
         browserWin.openDialog("chrome://browser/content/search/engineManager.xul",
           "_blank", "chrome,dialog,modal,centerscreen,resizable");
       }, 0);
     }
     return Promise.resolve();
   },
 
+  _onMessageGetSuggestions: Task.async(function* (msg, data) {
+    this._ensureDataHasProperties(data, [
+      "engineName",
+      "searchString",
+    ]);
+
+    let engine = Services.search.getEngineByName(data.engineName);
+    if (!engine) {
+      throw new Error("Unknown engine name: " + data.engineName);
+    }
+
+    let browserData = this._suggestionDataForBrowser(msg.target, true);
+    let { controller } = browserData;
+    let ok = SearchSuggestionController.engineOffersSuggestions(engine);
+    controller.maxLocalResults = ok ? 2 : 6;
+    controller.maxRemoteResults = ok ? 6 : 0;
+    controller.remoteTimeout = data.remoteTimeout || undefined;
+    let priv = PrivateBrowsingUtils.isWindowPrivate(msg.target.contentWindow);
+    // fetch() rejects its promise if there's a pending request, but since we
+    // process our event queue serially, there's never a pending request.
+    let suggestions = yield controller.fetch(data.searchString, priv, engine);
+
+    // Keep the form history result so RemoveFormHistoryEntry can remove entries
+    // from it.  Keeping only one result isn't foolproof because the client may
+    // try to remove an entry from one set of suggestions after it has requested
+    // more but before it's received them.  In that case, the entry may not
+    // appear in the new suggestions.  But that should happen rarely.
+    browserData.previousFormHistoryResult = suggestions.formHistoryResult;
+
+    this._reply(msg, "Suggestions", {
+      engineName: data.engineName,
+      searchString: suggestions.term,
+      formHistory: suggestions.local,
+      remote: suggestions.remote,
+    });
+  }),
+
+  _onMessageAddFormHistoryEntry: function (msg, entry) {
+    // There are some tests that use about:home and newtab that trigger a search
+    // and then immediately close the tab.  In those cases, the browser may have
+    // been destroyed by the time we receive this message, and as a result
+    // contentWindow is undefined.
+    if (!msg.target.contentWindow ||
+        PrivateBrowsingUtils.isWindowPrivate(msg.target.contentWindow)) {
+      return Promise.resolve();
+    }
+    let browserData = this._suggestionDataForBrowser(msg.target, true);
+    FormHistory.update({
+      op: "bump",
+      fieldname: browserData.controller.formHistoryParam,
+      value: entry,
+    }, {
+      handleCompletion: () => {},
+      handleError: err => {
+        Cu.reportError("Error adding form history entry: " + err);
+      },
+    });
+    return Promise.resolve();
+  },
+
+  _onMessageRemoveFormHistoryEntry: function (msg, entry) {
+    let browserData = this._suggestionDataForBrowser(msg.target);
+    if (browserData && browserData.previousFormHistoryResult) {
+      let { previousFormHistoryResult } = browserData;
+      for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
+        if (previousFormHistoryResult.getValueAt(i) == entry) {
+          previousFormHistoryResult.removeValueAt(i, true);
+          break;
+        }
+      }
+    }
+    return Promise.resolve();
+  },
+
+  _onMessageSpeculativeConnect: function (msg, engineName) {
+    let engine = Services.search.getEngineByName(engineName);
+    if (!engine) {
+      throw new Error("Unknown engine name: " + engineName);
+    }
+    if (msg.target.contentWindow) {
+      engine.speculativeConnect({
+        window: msg.target.contentWindow,
+      });
+    }
+  },
+
   _onObserve: Task.async(function* (data) {
     if (data == "engine-current") {
       let engine = yield this._currentEngineObj();
       this._broadcast("CurrentEngine", engine);
     }
     else if (data != "engine-default") {
       // engine-default is always sent with engine-current and isn't otherwise
       // relevant to content searches.
       let state = yield this._currentStateObj();
       this._broadcast("CurrentState", state);
     }
   }),
 
+  _suggestionDataForBrowser: function (browser, create=false) {
+    let data = this._suggestionMap.get(browser);
+    if (!data && create) {
+      // Since one SearchSuggestionController instance is meant to be used per
+      // autocomplete widget, this means that we assume each xul:browser has at
+      // most one such widget.
+      data = {
+        controller: new SearchSuggestionController(),
+      };
+      this._suggestionMap.set(browser, data);
+    }
+    return data;
+  },
+
   _reply: function (msg, type, data) {
     // We reply asyncly to messages, and by the time we reply the browser we're
     // responding to may have been destroyed.  messageManager is null then.
     if (msg.target.messageManager) {
       msg.target.messageManager.sendAsyncMessage(...this._msgArgs(type, data));
     }
   },
 
@@ -248,16 +377,24 @@ this.ContentSearch = {
       xhr.send();
     }
     catch (err) {
       return Promise.resolve(null);
     }
     return deferred.promise;
   },
 
+  _ensureDataHasProperties: function (data, requiredProperties) {
+    for (let prop of requiredProperties) {
+      if (!(prop in data)) {
+        throw new Error("Message data missing required property: " + prop);
+      }
+    }
+  },
+
   _initService: function () {
     if (!this._initServicePromise) {
       let deferred = Promise.defer();
       this._initServicePromise = deferred.promise;
       Services.search.init(() => deferred.resolve());
     }
     return this._initServicePromise;
   },
--- a/browser/modules/test/browser.ini
+++ b/browser/modules/test/browser.ini
@@ -4,16 +4,18 @@ support-files =
   image.png
   uitour.*
 
 [browser_BrowserUITelemetry_buckets.js]
 [browser_ContentSearch.js]
 support-files =
   contentSearch.js
   contentSearchBadImage.xml
+  contentSearchSuggestions.sjs
+  contentSearchSuggestions.xml
 [browser_NetworkPrioritizer.js]
 skip-if = e10s # Bug 666804 - Support NetworkPrioritizer in e10s
 [browser_SignInToWebsite.js]
 skip-if = e10s # Bug 941426 - SignIntoWebsite.jsm not e10s friendly
 [browser_UITour.js]
 skip-if = os == "linux" || e10s # Intermittent failures, bug 951965
 [browser_UITour2.js]
 skip-if = e10s # Bug 941428 - UITour.jsm not e10s friendly
--- a/browser/modules/test/browser_ContentSearch.js
+++ b/browser/modules/test/browser_ContentSearch.js
@@ -166,16 +166,102 @@ add_task(function* badImage() {
     data: expectedCurrentState,
   });
   // Removing the engine triggers a final CurrentState message.  Wait for it so
   // it doesn't trip up subsequent tests.
   Services.search.removeEngine(engine);
   yield waitForTestMsg("CurrentState");
 });
 
+add_task(function* GetSuggestions_AddFormHistoryEntry_RemoveFormHistoryEntry() {
+  yield addTab();
+
+  // Add the test engine that provides suggestions.
+  let vals = yield waitForNewEngine("contentSearchSuggestions.xml", 0);
+  let engine = vals[0];
+
+  let searchStr = "browser_ContentSearch.js-suggestions-";
+
+  // Add a form history suggestion and wait for Satchel to notify about it.
+  gMsgMan.sendAsyncMessage(TEST_MSG, {
+    type: "AddFormHistoryEntry",
+    data: searchStr + "form",
+  });
+  let deferred = Promise.defer();
+  Services.obs.addObserver(function onAdd(subj, topic, data) {
+    if (data == "formhistory-add") {
+      executeSoon(() => deferred.resolve());
+    }
+  }, "satchel-storage-changed", false);
+  yield deferred.promise;
+
+  // Send GetSuggestions using the test engine.  Its suggestions should appear
+  // in the remote suggestions in the Suggestions response below.
+  gMsgMan.sendAsyncMessage(TEST_MSG, {
+    type: "GetSuggestions",
+    data: {
+      engineName: engine.name,
+      searchString: searchStr,
+      remoteTimeout: 5000,
+    },
+  });
+
+  // Check the Suggestions response.
+  let msg = yield waitForTestMsg("Suggestions");
+  checkMsg(msg, {
+    type: "Suggestions",
+    data: {
+      engineName: engine.name,
+      searchString: searchStr,
+      formHistory: [searchStr + "form"],
+      remote: [searchStr + "foo", searchStr + "bar"],
+    },
+  });
+
+  // Delete the form history suggestion and wait for Satchel to notify about it.
+  gMsgMan.sendAsyncMessage(TEST_MSG, {
+    type: "RemoveFormHistoryEntry",
+    data: searchStr + "form",
+  });
+  deferred = Promise.defer();
+  Services.obs.addObserver(function onRemove(subj, topic, data) {
+    if (data == "formhistory-remove") {
+      executeSoon(() => deferred.resolve());
+    }
+  }, "satchel-storage-changed", false);
+  yield deferred.promise;
+
+  // Send GetSuggestions again.
+  gMsgMan.sendAsyncMessage(TEST_MSG, {
+    type: "GetSuggestions",
+    data: {
+      engineName: engine.name,
+      searchString: searchStr,
+      remoteTimeout: 5000,
+    },
+  });
+
+  // The formHistory suggestions in the Suggestions response should be empty.
+  msg = yield waitForTestMsg("Suggestions");
+  checkMsg(msg, {
+    type: "Suggestions",
+    data: {
+      engineName: engine.name,
+      searchString: searchStr,
+      formHistory: [],
+      remote: [searchStr + "foo", searchStr + "bar"],
+    },
+  });
+
+  // Finally, clean up by removing the test engine.
+  Services.search.removeEngine(engine);
+  yield waitForTestMsg("CurrentState");
+});
+
+
 function checkMsg(actualMsg, expectedMsgData) {
   SimpleTest.isDeeply(actualMsg.data, expectedMsgData, "Checking message");
 }
 
 function waitForMsg(name, type) {
   let deferred = Promise.defer();
   info("Waiting for " + name + " message " + type + "...");
   gMsgMan.addMessageListener(name, function onMsg(msg) {
@@ -221,17 +307,17 @@ function waitForNewEngine(basename, numI
   return Promise.all([addDeferred.promise].concat(eventPromises));
 }
 
 function addTab() {
   let deferred = Promise.defer();
   let tab = gBrowser.addTab();
   gBrowser.selectedTab = tab;
   tab.linkedBrowser.addEventListener("load", function load() {
-    tab.removeEventListener("load", load, true);
+    tab.linkedBrowser.removeEventListener("load", load, true);
     let url = getRootDirectory(gTestPath) + TEST_CONTENT_SCRIPT_BASENAME;
     gMsgMan = tab.linkedBrowser.messageManager;
     gMsgMan.sendAsyncMessage(CONTENT_SEARCH_MSG, {
       type: "AddToWhitelist",
       data: ["about:blank"],
     });
     waitForMsg(CONTENT_SEARCH_MSG, "AddToWhitelistAck").then(() => {
       gMsgMan.loadFrameScript(url, false);
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/contentSearchSuggestions.sjs
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(req, resp) {
+  let suffixes = ["foo", "bar"];
+  let data = [req.queryString, suffixes.map(s => req.queryString + s)];
+  resp.setHeader("Content-Type", "application/json", false);
+  resp.write(JSON.stringify(data));
+}
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/contentSearchSuggestions.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_ContentSearch contentSearchSuggestions.xml</ShortName>
+<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/modules/test/contentSearchSuggestions.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchSuggestions" rel="searchform"/>
+</SearchPlugin>
--- a/toolkit/components/search/SearchSuggestionController.jsm
+++ b/toolkit/components/search/SearchSuggestionController.jsm
@@ -41,16 +41,26 @@ this.SearchSuggestionController.prototyp
    */
   maxLocalResults: 7,
 
   /**
    * The maximum number of remote search engine results to return.
    */
   maxRemoteResults: 10,
 
+  /**
+   * The maximum time (ms) to wait before giving up on a remote suggestions.
+   */
+  remoteTimeout: REMOTE_TIMEOUT,
+
+  /**
+   * The additional parameter used when searching form history.
+   */
+  formHistoryParam: DEFAULT_FORM_HISTORY_PARAM,
+
   // Private properties
   /**
    * The last form history result used to improve the performance of subsequent searches.
    * This shouldn't be used for any other purpose as it is never cleared and therefore could be stale.
    */
   _formHistoryResult: null,
 
   /**
@@ -163,17 +173,17 @@ this.SearchSuggestionController.prototyp
       // Implements nsIAutoCompleteSearch
       onSearchResult: (search, result) => {
         this._formHistoryResult = result;
 
         if (this._request) {
           this._remoteResultTimer = Cc["@mozilla.org/timer;1"].
                                     createInstance(Ci.nsITimer);
           this._remoteResultTimer.initWithCallback(this._onRemoteTimeout.bind(this),
-                                                   REMOTE_TIMEOUT,
+                                                   this.remoteTimeout || REMOTE_TIMEOUT,
                                                    Ci.nsITimer.TYPE_ONE_SHOT);
         }
 
         switch (result.searchResult) {
           case Ci.nsIAutoCompleteResult.RESULT_SUCCESS:
           case Ci.nsIAutoCompleteResult.RESULT_NOMATCH:
             if (result.searchString !== this._searchString) {
               deferredFormHistory.resolve("Unexpected response, this._searchString does not match form history response");
@@ -194,17 +204,18 @@ this.SearchSuggestionController.prototyp
             deferredFormHistory.resolve("Form History returned RESULT_FAILURE or RESULT_IGNORED");
             break;
         }
       },
     };
 
     let formHistory = Cc["@mozilla.org/autocomplete/search;1?name=form-history"].
                       createInstance(Ci.nsIAutoCompleteSearch);
-    formHistory.startSearch(searchTerm, DEFAULT_FORM_HISTORY_PARAM, this._formHistoryResult,
+    formHistory.startSearch(searchTerm, this.formHistoryParam || DEFAULT_FORM_HISTORY_PARAM,
+                            this._formHistoryResult,
                             acSearchObserver);
     return deferredFormHistory;
   },
 
   /**
    * Fetch suggestions from the search engine over the network.
    */
   _fetchRemote: function(searchTerm, engine, privateMode) {
@@ -342,8 +353,18 @@ this.SearchSuggestionController.prototyp
     if (this._remoteResultTimer) {
       this._remoteResultTimer.cancel();
       this._remoteResultTimer = null;
     }
     this._deferredRemoteResult = null;
     this._searchString = null;
   },
 };
+
+/**
+ * Determines whether the given engine offers search suggestions.
+ *
+ * @param {nsISearchEngine} engine - The search engine
+ * @return {boolean} True if the engine offers suggestions and false otherwise.
+ */
+this.SearchSuggestionController.engineOffersSuggestions = function(engine) {
+ return engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON);
+};