Bug 1281421 - Merge the find counter and highlighter iterators into a FinderIterator singleton. r=jaws
authorMike de Boer <mdeboer@mozilla.com>
Thu, 28 Jul 2016 12:34:13 +0200
changeset 346913 e525e1000aa78fd54038156a59db4fd786d19e2a
parent 346912 5e41e5886c0f7e92c8adb338d8c939c0f25807ea
child 346914 276b214dde0de481b91b93e1d2a5654661b0cb44
push id6389
push userraliiev@mozilla.com
push dateMon, 19 Sep 2016 13:38:22 +0000
treeherdermozilla-beta@01d67bfe6c81 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1281421
milestone50.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1281421 - Merge the find counter and highlighter iterators into a FinderIterator singleton. r=jaws MozReview-Commit-ID: 9fhOKWIkOXN
toolkit/content/widgets/findbar.xml
toolkit/modules/Finder.jsm
toolkit/modules/FinderHighlighter.jsm
toolkit/modules/FinderIterator.jsm
toolkit/modules/RemoteFinder.jsm
toolkit/modules/moz.build
--- a/toolkit/content/widgets/findbar.xml
+++ b/toolkit/content/widgets/findbar.xml
@@ -548,17 +548,18 @@
 
           this._setHighlightAll(aHighlight, aFromPrefObserver);
 
           let word = this._findField.value;
           // Bug 429723. Don't attempt to highlight ""
           if (aHighlight && !word)
             return;
 
-          this.browser.finder.highlight(aHighlight, word);
+          this.browser.finder.highlight(aHighlight, word,
+            this._findMode == this.FIND_LINKS);
 
           // Update the matches count
           this._updateMatchesCount(this.nsITypeAheadFind.FIND_FOUND);
         ]]></body>
       </method>
 
       <!--
         - Updates the highlight-all mode of the findbar and its UI.
--- a/toolkit/modules/Finder.jsm
+++ b/toolkit/modules/Finder.jsm
@@ -35,17 +35,26 @@ function Finder(docShell) {
   this._highlighter = null;
 
   docShell.QueryInterface(Ci.nsIInterfaceRequestor)
           .getInterface(Ci.nsIWebProgress)
           .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
 }
 
 Finder.prototype = {
+  get iterator() {
+    if (this._iterator)
+      return this._iterator;
+    this._iterator = Cu.import("resource://gre/modules/FinderIterator.jsm", null).FinderIterator;
+    return this._iterator;
+  },
+
   destroy: function() {
+    if (this._iterator)
+      this._iterator.reset();
     if (this._highlighter) {
       this._highlighter.clear();
       this._highlighter.hide();
     }
     this.listeners = [];
     this._docShell.QueryInterface(Ci.nsIInterfaceRequestor)
       .getInterface(Ci.nsIWebProgress)
       .removeProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
@@ -82,16 +91,22 @@ Finder.prototype = {
 
       linkURL = TextToSubURIService.unEscapeURIForUI(docCharset, foundLink.href);
     }
 
     options.linkURL = linkURL;
     options.rect = this._getResultRect();
     options.searchString = this._searchString;
 
+    if (!this.iterator.continueRunning({
+      linksOnly: options.linksOnly,
+      word: options.searchString
+    })) {
+      this.iterator.stop();
+    }
     this.highlighter.update(options);
 
     for (let l of this._listeners) {
       try {
         l.onFindResult(options);
       } catch (ex) {}
     }
   },
@@ -145,17 +160,18 @@ Finder.prototype = {
   fastFind: function (aSearchString, aLinksOnly, aDrawOutline) {
     this._lastFindResult = this._fastFind.find(aSearchString, aLinksOnly);
     let searchString = this._fastFind.searchString;
     this._notify({
       searchString,
       result: this._lastFindResult,
       findBackwards: false,
       findAgain: false,
-      drawOutline: aDrawOutline
+      drawOutline: aDrawOutline,
+      linksOnly: aLinksOnly
     });
   },
 
   /**
    * Repeat the previous search. Should only be called after a previous
    * call to Finder.fastFind.
    *
    * @param aFindBackwards Controls the search direction:
@@ -165,18 +181,19 @@ Finder.prototype = {
    */
   findAgain: function (aFindBackwards, aLinksOnly, aDrawOutline) {
     this._lastFindResult = this._fastFind.findAgain(aFindBackwards, aLinksOnly);
     let searchString = this._fastFind.searchString;
     this._notify({
       searchString,
       result: this._lastFindResult,
       findBackwards: aFindBackwards,
-      fidnAgain: true,
-      drawOutline: aDrawOutline
+      findAgain: true,
+      drawOutline: aDrawOutline,
+      linksOnly: aLinksOnly
     });
   },
 
   /**
    * Forcibly set the search string of the find clipboard to the currently
    * selected text in the window, on supported platforms (i.e. OSX).
    */
   setSearchStringToSelection: function() {
@@ -185,20 +202,18 @@ Finder.prototype = {
     // Empty strings are rather useless to search for.
     if (!searchString.length)
       return null;
 
     this.clipboardSearchString = searchString;
     return searchString;
   },
 
-  highlight: Task.async(function* (aHighlight, aWord) {
-    this.highlighter.maybeAbort();
-
-    let found = yield this.highlighter.highlight(aHighlight, aWord, null);
+  highlight: Task.async(function* (aHighlight, aWord, aLinksOnly) {
+    let found = yield this.highlighter.highlight(aHighlight, aWord, null, aLinksOnly);
     this.highlighter.notifyFinished(aHighlight);
     if (aHighlight) {
       let result = found ? Ci.nsITypeAheadFind.FIND_FOUND
                          : Ci.nsITypeAheadFind.FIND_NOTFOUND;
       this._notify({
         searchString: aWord,
         result,
         findBackwards: false,
@@ -351,189 +366,58 @@ Finder.prototype = {
       try {
         l.onMatchesCountResult(result);
       } catch (ex) {}
     }
   },
 
   requestMatchesCount: function(aWord, aMatchLimit, aLinksOnly) {
     if (this._lastFindResult == Ci.nsITypeAheadFind.FIND_NOTFOUND ||
-        this.searchString == "") {
-      return this._notifyMatchesCount({
+        this.searchString == "" || !aWord) {
+      this._notifyMatchesCount({
         total: 0,
         current: 0
       });
-    }
-    let window = this._getWindow();
-    let result = this._countMatchesInWindow(aWord, aMatchLimit, aLinksOnly, window);
-
-    // Count matches in (i)frames AFTER searching through the main window.
-    for (let frame of result._framesToCount) {
-      // We've reached our limit; no need to do more work.
-      if (result.total == -1 || result.total == aMatchLimit)
-        break;
-      this._countMatchesInWindow(aWord, aMatchLimit, aLinksOnly, frame, result);
+      return;
     }
 
-    // The `_currentFound` and `_framesToCount` properties are only used for
-    // internal bookkeeping between recursive calls.
-    delete result._currentFound;
-    delete result._framesToCount;
-
-    this._notifyMatchesCount(result);
-    return undefined;
-  },
-
-  /**
-   * Counts the number of matches for the searched word in the passed window's
-   * content.
-   * @param aWord
-   *        the word to search for.
-   * @param aMatchLimit
-   *        the maximum number of matches shown (for speed reasons).
-   * @param aLinksOnly
-   *        whether we should only search through links.
-   * @param aWindow
-   *        the window to search in. Passing undefined will search the
-   *        current content window. Optional.
-   * @param aStats
-   *        the Object that is returned by this function. It may be passed as an
-   *        argument here in the case of a recursive call.
-   * @returns an object stating the number of matches and a vector for the current match.
-   */
-  _countMatchesInWindow: function(aWord, aMatchLimit, aLinksOnly, aWindow = null, aStats = null) {
-    aWindow = aWindow || this._getWindow();
-    aStats = aStats || {
+    let window = this._getWindow();
+    let result = {
       total: 0,
       current: 0,
-      _framesToCount: new Set(),
       _currentFound: false
     };
-
-    // If we already reached our max, there's no need to do more work!
-    if (aStats.total == -1 || aStats.total == aMatchLimit) {
-      aStats.total = -1;
-      return aStats;
-    }
-
-    this._collectFrames(aWindow, aStats);
-
     let foundRange = this._fastFind.getFoundRange();
 
-    for(let range of this._findIterator(aWord, aWindow)) {
-      if (!aLinksOnly || this._rangeStartsInLink(range)) {
-        ++aStats.total;
-        if (!aStats._currentFound) {
-          ++aStats.current;
-          aStats._currentFound = (foundRange &&
+    this.iterator.start({
+      finder: this,
+      limit: aMatchLimit,
+      linksOnly: aLinksOnly,
+      onRange: range => {
+        ++result.total;
+        if (!result._currentFound) {
+          ++result.current;
+          result._currentFound = (foundRange &&
             range.startContainer == foundRange.startContainer &&
             range.startOffset == foundRange.startOffset &&
             range.endContainer == foundRange.endContainer &&
             range.endOffset == foundRange.endOffset);
         }
-      }
-      if (aStats.total == aMatchLimit) {
-        aStats.total = -1;
-        break;
-      }
-    }
-
-    return aStats;
-  },
-
-  /**
-   * Basic wrapper around nsIFind that provides a generator yielding
-   * a range each time an occurence of `aWord` string is found.
-   *
-   * @param aWord
-   *        the word to search for.
-   * @param aWindow
-   *        the window to search in.
-   */
-  _findIterator: function* (aWord, aWindow) {
-    let doc = aWindow.document;
-    let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ?
-               doc.body : doc.documentElement;
-
-    if (!body)
-      return;
-
-    let searchRange = doc.createRange();
-    searchRange.selectNodeContents(body);
-
-    let startPt = searchRange.cloneRange();
-    startPt.collapse(true);
-
-    let endPt = searchRange.cloneRange();
-    endPt.collapse(false);
-
-    let retRange = null;
-
-    let finder = Cc["@mozilla.org/embedcomp/rangefind;1"]
-                   .createInstance()
-                   .QueryInterface(Ci.nsIFind);
-    finder.caseSensitive = this._fastFind.caseSensitive;
-    finder.entireWord = this._fastFind.entireWord;
+      },
+      useCache: true,
+      word: aWord
+    }).then(() => {
+      // The `_currentFound` property is only used for internal bookkeeping.
+      delete result._currentFound;
 
-    while ((retRange = finder.Find(aWord, searchRange, startPt, endPt))) {
-      yield retRange;
-      startPt = retRange.cloneRange();
-      startPt.collapse(false);
-    }
-  },
+      if (result.total == aMatchLimit)
+        result.total = -1;
 
-  /**
-   * Helper method for `_countMatchesInWindow` that recursively collects all
-   * visible (i)frames inside a window.
-   *
-   * @param aWindow
-   *        the window to extract the (i)frames from.
-   * @param aStats
-   *        Object that contains a Set called '_framesToCount'
-   */
-  _collectFrames: function(aWindow, aStats) {
-    if (!aWindow.frames || !aWindow.frames.length)
-      return;
-    // Casting `aWindow.frames` to an Iterator doesn't work, so we're stuck with
-    // a plain, old for-loop.
-    for (let i = 0, l = aWindow.frames.length; i < l; ++i) {
-      let frame = aWindow.frames[i];
-      // Don't count matches in hidden frames.
-      let frameEl = frame && frame.frameElement;
-      if (!frameEl)
-        continue;
-      // Construct a range around the frame element to check its visiblity.
-      let range = aWindow.document.createRange();
-      range.setStart(frameEl, 0);
-      range.setEnd(frameEl, 0);
-      if (!this._fastFind.isRangeVisible(range, this._getDocShell(range), true))
-        continue;
-      // All good, so add it to the set to count later.
-      if (!aStats._framesToCount.has(frame))
-        aStats._framesToCount.add(frame);
-      this._collectFrames(frame, aStats);
-    }
-  },
-
-  /**
-   * Helper method to extract the docShell reference from a Window or Range object.
-   *
-   * @param aWindowOrRange
-   *        Window object to query. May also be a Range, from which the owner
-   *        window will be queried.
-   * @returns nsIDocShell
-   */
-  _getDocShell: function(aWindowOrRange) {
-    let window = aWindowOrRange;
-    // Ranges may also be passed in, so fetch its window.
-    if (aWindowOrRange instanceof Ci.nsIDOMRange)
-      window = aWindowOrRange.startContainer.ownerDocument.defaultView;
-    return window.QueryInterface(Ci.nsIInterfaceRequestor)
-                 .getInterface(Ci.nsIWebNavigation)
-                 .QueryInterface(Ci.nsIDocShell);
+      this._notifyMatchesCount(result);
+    });
   },
 
   _getWindow: function () {
     return this._docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow);
   },
 
   /**
    * Get the bounding selection rect in CSS px relative to the origin of the
@@ -639,61 +523,26 @@ Finder.prototype = {
                           .QueryInterface(Ci.nsIDocShell);
 
     let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
                              .getInterface(Ci.nsISelectionDisplay)
                              .QueryInterface(Ci.nsISelectionController);
     return controller;
   },
 
-  /**
-   * Determines whether a range is inside a link.
-   * @param aRange
-   *        the range to check
-   * @returns true if the range starts in a link
-   */
-  _rangeStartsInLink: function(aRange) {
-    let isInsideLink = false;
-    let node = aRange.startContainer;
-
-    if (node.nodeType == node.ELEMENT_NODE) {
-      if (node.hasChildNodes) {
-        let childNode = node.item(aRange.startOffset);
-        if (childNode)
-          node = childNode;
-      }
-    }
-
-    const XLink_NS = "http://www.w3.org/1999/xlink";
-    const HTMLAnchorElement = (node.ownerDocument || node).defaultView.HTMLAnchorElement;
-    do {
-      if (node instanceof HTMLAnchorElement) {
-        isInsideLink = node.hasAttribute("href");
-        break;
-      } else if (typeof node.hasAttributeNS == "function" &&
-                 node.hasAttributeNS(XLink_NS, "href")) {
-        isInsideLink = (node.getAttributeNS(XLink_NS, "type") == "simple");
-        break;
-      }
-
-      node = node.parentNode;
-    } while (node);
-
-    return isInsideLink;
-  },
-
   // Start of nsIWebProgressListener implementation.
 
   onLocationChange: function(aWebProgress, aRequest, aLocation, aFlags) {
     if (!aWebProgress.isTopLevel)
       return;
 
     // Avoid leaking if we change the page.
     this._previousLink = null;
     this.highlighter.onLocationChange();
+    this.iterator.reset();
   },
 
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
                                          Ci.nsISupportsWeakReference])
 };
 
 function GetClipboardSearchString(aLoadContext) {
   let searchString = "";
--- a/toolkit/modules/FinderHighlighter.jsm
+++ b/toolkit/modules/FinderHighlighter.jsm
@@ -9,17 +9,16 @@ this.EXPORTED_SYMBOLS = ["FinderHighligh
 const { interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Color", "resource://gre/modules/Color.jsm");
 
-const kHighlightIterationSizeMax = 100;
 const kModalHighlightRepaintFreqMs = 10;
 const kModalHighlightPref = "findbar.modalHighlight";
 const kFontPropsCSS = ["color", "font-family", "font-kerning", "font-size",
   "font-size-adjust", "font-stretch", "font-variant", "font-weight", "letter-spacing",
   "text-emphasis", "text-orientation", "text-transform", "word-spacing"];
 const kFontPropsCamelCase = kFontPropsCSS.map(prop => {
   let parts = prop.split("-");
   return parts.shift() + parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("");
@@ -96,16 +95,23 @@ const kXULNS = "http://www.mozilla.org/k
  * @param {Finder} finder Finder.jsm instance
  */
 function FinderHighlighter(finder) {
   this.finder = finder;
   this._modal = Services.prefs.getBoolPref(kModalHighlightPref);
 }
 
 FinderHighlighter.prototype = {
+  get iterator() {
+    if (this._iterator)
+      return this._iterator;
+    this._iterator = Cu.import("resource://gre/modules/FinderIterator.jsm", null).FinderIterator;
+    return this._iterator;
+  },
+
   get modalStyleSheet() {
     if (!this._modalStyleSheet) {
       this._modalStyleSheet = kModalStyle.replace(/(\.|#)findbar-/g,
         "$1" + kModalIdPrefix + "-findbar-");
     }
     return this._modalStyleSheet;
   },
 
@@ -126,83 +132,52 @@ FinderHighlighter.prototype = {
     for (let l of this.finder._listeners) {
       try {
         l.onHighlightFinished(highlight);
       } catch (ex) {}
     }
   },
 
   /**
-   * Whilst the iterator is running, it's possible to abort it. This may be useful
-   * if the word to highlight was updated in the meantime.
-   */
-  maybeAbort() {
-    this.clear();
-    if (!this._abortHighlight) {
-      return;
-    }
-    this._abortHighlight();
-  },
-
-  /**
-   * Uses the iterator in Finder.jsm to find all the words to highlight and makes
-   * sure not to block the thread whilst running.
-   *
-   * @param {String}       word    Needle to search for and highlight when found
-   * @param {nsIDOMWindow} window  Window object, whose DOM tree should be traversed
-   * @param {Function}     onFind  Callback invoked for each found occurrence
-   * @yield {Promise} that resolves once the iterator has finished
-   */
-  iterator: Task.async(function* (word, window, onFind) {
-    let count = 0;
-    for (let range of this.finder._findIterator(word, window)) {
-      onFind(range);
-      if (++count >= kHighlightIterationSizeMax) {
-        count = 0;
-        // Sleep for the rest of this cycle.
-        yield new Promise(resolve => resolve());
-      }
-    }
-  }),
-
-  /**
    * Toggle highlighting all occurrences of a word in a page. This method will
    * be called recursively for each (i)frame inside a page.
    *
-   * @param {Booolean}     highlight Whether highlighting should be turned on
-   * @param {String}       word      Needle to search for and highlight when found
-   * @param {nsIDOMWindow} window    Window object, whose DOM tree should be traversed
-   * @yield {Promise} that resolves once the operation has finished
+   * @param {Booolean} highlight Whether highlighting should be turned on
+   * @param {String}   word      Needle to search for and highlight when found
+   * @param {Boolean}  linksOnly Only consider nodes that are links for the search
+   * @yield {Promise}  that resolves once the operation has finished
    */
-  highlight: Task.async(function* (highlight, word, window) {
-    let finderWindow = this.finder._getWindow();
-    window = window || finderWindow;
-    let found = false;
-    for (let i = 0; window.frames && i < window.frames.length; i++) {
-      if (yield this.highlight(highlight, word, window.frames[i])) {
-        found = true;
-      }
-    }
-
+  highlight: Task.async(function* (highlight, word, linksOnly) {
+    let window = this.finder._getWindow();
     let controller = this.finder._getSelectionController(window);
     let doc = window.document;
+    let found = false;
+
+    this.clear();
+
     if (!controller || !doc || !doc.documentElement) {
       // Without the selection controller,
       // we are unable to (un)highlight any matches
       return found;
     }
 
     if (highlight) {
-      yield this.iterator(word, window, range => {
-        this.highlightRange(range, controller, finderWindow);
-        found = true;
+      yield this.iterator.start({
+        linksOnly, word,
+        finder: this.finder,
+        onRange: range => {
+          this.highlightRange(range, controller, window);
+          found = true;
+        },
+        useCache: true
       });
     } else {
       this.hide(window);
       this.clear();
+      this.iterator.reset();
 
       // Removing the highlighting always succeeds, so return true.
       found = true;
     }
 
     return found;
   }),
 
@@ -553,17 +528,16 @@ FinderHighlighter.prototype = {
     for (let dims of range.getClientRects()) {
       rects.add({
         height: dims.bottom - dims.top,
         width: dims.right - dims.left,
         y: dims.top + scrollY,
         x: dims.left + scrollX
       });
     }
-    range.collapse();
 
     if (!this._modalHighlightRectsMap)
       this._modalHighlightRectsMap = new Map();
     this._modalHighlightRectsMap.set(range, rects);
 
     this.show(window);
     // We don't repaint the mask right away, but pass it off to a render loop of
     // sorts.
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/FinderIterator.jsm
@@ -0,0 +1,489 @@
+/* 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.EXPORTED_SYMBOLS = ["FinderIterator"];
+
+const { interfaces: Ci, classes: Cc, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+const kIterationSizeMax = 100;
+
+/**
+ * FinderIterator singleton. See the documentation for the `start()` method to
+ * learn more.
+ */
+this.FinderIterator = {
+  _currentParams: null,
+  _listeners: new Map(),
+  _catchingUp: new Set(),
+  _previousParams: null,
+  _previousRanges: [],
+  _spawnId: 0,
+  ranges: [],
+  running: false,
+
+  /**
+   * Start iterating the active Finder docShell, using the options below. When
+   * it already started at the request of another consumer, we first yield the
+   * results we already collected before continuing onward to yield fresh results.
+   * We make sure to pause every `kIterationSizeMax` iterations to make sure we
+   * don't block the host process too long. In the case of a break like this, we
+   * yield `undefined`, instead of a range.
+   * Upon re-entrance after a break, we check if `stop()` was called during the
+   * break and if so, we stop iterating.
+   * Results are also passed to the `onRange` callback method, along with a flag
+   * that specifies if the result comes from the cache or is fresh. The callback
+   * also adheres to the `limit` flag.
+   * The returned promise is resolved when 1) the limit is reached, 2) when all
+   * the ranges have been found or 3) when `stop()` is called whilst iterating.
+   *
+   * @param {Finder}   options.finder      Currently active Finder instance
+   * @param {Number}   [options.limit]     Limit the amount of results to be
+   *                                       passed back. Optional, defaults to no
+   *                                       limit.
+   * @param {Boolean}  [options.linksOnly] Only yield ranges that are inside a
+   *                                       hyperlink (used by QuickFind).
+   *                                       Optional, defaults to `false`.
+   * @param {Function} options.onRange     Callback invoked when a range is found
+   * @param {Boolean}  [options.useCache]  Whether to allow results already
+   *                                       present in the cache or demand fresh.
+   *                                       Optional, defaults to `false`.
+   * @param {String}   options.word        Word to search for
+   * @return {Promise}
+   */
+  start({ finder, limit, linksOnly, onRange, useCache, word }) {
+    // Take care of default values for non-required options.
+    if (typeof limit != "number")
+      limit = -1;
+    if (typeof linksOnly != "boolean")
+      linksOnly = false;
+    if (typeof useCache != "boolean")
+      useCache = false;
+
+    // Validate the options.
+    if (!finder)
+      throw new Error("Missing required option 'finder'");
+    if (!word)
+      throw new Error("Missing required option 'word'");
+    if (typeof onRange != "function")
+      throw new TypeError("Missing valid, required option 'onRange'");
+
+    // Don't add the same listener twice.
+    if (this._listeners.has(onRange))
+      throw new Error("Already listening to iterator results");
+
+    let window = finder._getWindow();
+    let resolver;
+    let promise = new Promise(resolve => resolver = resolve);
+    let iterParams = { linksOnly, useCache, word };
+
+    this._listeners.set(onRange, { limit, onEnd: resolver });
+
+    // If we're not running anymore and we're requesting the previous result, use it.
+    if (!this.running && this._previousResultAvailable(iterParams)) {
+      this._yieldPreviousResult(onRange, window);
+      return promise;
+    }
+
+    if (this.running) {
+      // Double-check if we're not running the iterator with a different set of
+      // parameters, otherwise throw an error with the most common reason.
+      if (!this._areParamsEqual(this._currentParams, iterParams))
+        throw new Error(`We're currently iterating over '${this._currentParams.word}', not '${word}'`);
+
+      // if we're still running, yield the set we have built up this far.
+      this._yieldIntermediateResult(onRange, window);
+
+      return promise;
+    }
+
+    // Start!
+    this.running = true;
+    this._currentParams = iterParams;
+    this._findAllRanges(finder, window, ++this._spawnId);
+
+    return promise;
+  },
+
+  /**
+   * Stop the currently running iterator as soon as possible and optionally cache
+   * the result for later.
+   *
+   * @param {Boolean} [cachePrevious] Whether to save the result for later.
+   *                                  Optional.
+   */
+  stop(cachePrevious = false) {
+    if (!this.running)
+      return;
+
+    if (cachePrevious) {
+      this._previousRanges = [].concat(this.ranges);
+      this._previousParams = Object.assign({}, this._currentParams);
+    } else {
+      this._previousRanges = [];
+      this._previousParams = null;
+    }
+
+    this._catchingUp.clear();
+    this._currentParams = null;
+    this.ranges = [];
+    this.running = false;
+
+    for (let [, { onEnd }] of this._listeners)
+      onEnd();
+    this._listeners.clear();
+  },
+
+  /**
+   * Reset the internal state of the iterator. Typically this would be called
+   * when the docShell is not active anymore, which makes the current and cached
+   * previous result invalid.
+   * If the iterator is running, it will be stopped as soon as possible.
+   */
+  reset() {
+    this._catchingUp.clear();
+    this._currentParams = this._previousParams = null;
+    this._previousRanges = [];
+    this.ranges = [];
+    this.running = false;
+
+    for (let [, { onEnd }] of this._listeners)
+      onEnd();
+    this._listeners.clear();
+  },
+
+  /**
+   * Check if the currently running iterator parameters are the same as the ones
+   * passed through the arguments. When `true`, we can keep it running as-is and
+   * the consumer should stop the iterator when `false`.
+   *
+   * @param  {Boolean} options.linksOnly Whether to search for the word to be
+   *                                     present in links only
+   * @param  {String}  options.word      The word being searched for
+   * @return {Boolean}
+   */
+  continueRunning({ linksOnly, word }) {
+    return (this.running &&
+      this._currentParams.linksOnly === linksOnly &&
+      this._currentParams.word == word);
+  },
+
+  /**
+   * Internal; check if an iteration request is available in the previous result
+   * that we cached.
+   *
+   * @param  {Boolean} options.linksOnly Whether to search for the word to be
+   *                                     present in links only
+   * @param  {Boolean} options.useCache  Whether the consumer wants to use the
+   *                                     cached previous result at all
+   * @param  {String}  options.word      The word being searched for
+   * @return {Boolean}
+   */
+  _previousResultAvailable({ linksOnly, useCache, word }) {
+    return !!(useCache &&
+      this._areParamsEqual(this._previousParams, { word, linksOnly }) &&
+      this._previousRanges.length);
+  },
+
+  /**
+   * Internal; compare if two sets of iterator parameters are equivalent.
+   *
+   * @param  {Object} paramSet1 First set of params (left hand side)
+   * @param  {Object} paramSet2 Second set of params (right hand side)
+   * @return {Boolean}
+   */
+  _areParamsEqual(paramSet1, paramSet2) {
+    return (!!paramSet1 && !!paramSet2 &&
+      paramSet1.linksOnly === paramSet2.linksOnly &&
+      paramSet1.word == paramSet2.word);
+  },
+
+  /**
+   * Internal; iterate over a predefined set of ranges that have been collected
+   * before.
+   * Also here, we make sure to pause every `kIterationSizeMax` iterations to
+   * make sure we don't block the host process too long. In the case of a break
+   * like this, we yield `undefined`, instead of a range.
+   *
+   * @param {Function}     onRange     Callback invoked when a range is found
+   * @param {Array}        rangeSource Set of ranges to iterate over
+   * @param {nsIDOMWindow} window      The window object is only really used
+   *                                   for access to `setTimeout`
+   * @yield {nsIDOMRange}
+   */
+  _yieldResult: function* (onRange, rangeSource, window) {
+    // We keep track of the number of iterations to allow a short pause between
+    // every `kIterationSizeMax` number of iterations.
+    let iterCount = 0;
+    let { limit, onEnd } = this._listeners.get(onRange);
+    let ranges = rangeSource.slice(0, limit > -1 ? limit : undefined);
+    for (let range of ranges) {
+      try {
+        range.startContainer;
+      } catch (ex) {
+        // Don't yield dead objects, so use the escape hatch.
+        if (ex.message.includes("dead object"))
+          return;
+      }
+
+      // Pass a flag that is `true` when we're returning the result from a
+      // cached previous iteration.
+      onRange(range, !this.running);
+      yield range;
+
+      if (++iterCount >= kIterationSizeMax) {
+        iterCount = 0;
+        // Make sure to save the current limit for later.
+        this._listeners.set(onRange, { limit, onEnd });
+        // Sleep for the rest of this cycle.
+        yield new Promise(resolve => window.setTimeout(resolve, 0));
+        // After a sleep, the set of ranges may have updated.
+        ranges = rangeSource.slice(0, limit > -1 ? limit : undefined);
+      }
+
+      if (limit !== -1 && --limit === 0) {
+        // We've reached our limit; no need to do more work.
+        this._listeners.delete(onRange);
+        onEnd();
+        return;
+      }
+    }
+
+    // Save the updated limit globally.
+    this._listeners.set(onRange, { limit, onEnd });
+  },
+
+  /**
+   * Internal; iterate over the set of previously found ranges. Meanwhile it'll
+   * mark the listener as 'catching up', meaning it will not receive fresh
+   * results from a running iterator.
+   *
+   * @param {Function}     onRange Callback invoked when a range is found
+   * @param {nsIDOMWindow} window  The window object is only really used
+   *                               for access to `setTimeout`
+   * @yield {nsIDOMRange}
+   */
+  _yieldPreviousResult: Task.async(function* (onRange, window) {
+    this._catchingUp.add(onRange);
+    yield* this._yieldResult(onRange, this._previousRanges, window);
+    this._catchingUp.delete(onRange);
+    let { onEnd } = this._listeners.get(onRange);
+    if (onEnd) {
+      onEnd();
+      this._listeners.delete(onRange);
+    }
+  }),
+
+  /**
+   * Internal; iterate over the set of already found ranges. Meanwhile it'll
+   * mark the listener as 'catching up', meaning it will not receive fresh
+   * results from the running iterator.
+   *
+   * @param {Function}     onRange Callback invoked when a range is found
+   * @param {nsIDOMWindow} window  The window object is only really used
+   *                               for access to `setTimeout`
+   * @yield {nsIDOMRange}
+   */
+  _yieldIntermediateResult: Task.async(function* (onRange, window) {
+    this._catchingUp.add(onRange);
+    yield* this._yieldResult(onRange, this.ranges, window);
+    this._catchingUp.delete(onRange);
+  }),
+
+  /**
+   * Internal; see the documentation of the start() method above.
+   *
+   * @param {Finder}       finder  Currently active Finder instance
+   * @param {nsIDOMWindow} window  The window to search in
+   * @param {Number}       spawnId Since `stop()` is synchronous and this method
+   *                               is not, this identifier is used to learn if
+   *                               it's supposed to still continue after a pause.
+   * @yield {nsIDOMRange}
+   */
+  _findAllRanges: Task.async(function* (finder, window, spawnId) {
+    // First we collect all frames we need to search through, whilst making sure
+    // that the parent window gets dibs.
+    let frames = [window].concat(this._collectFrames(window, finder));
+    let { linksOnly, word } = this._currentParams;
+    let iterCount = 0;
+    for (let frame of frames) {
+      for (let range of this._iterateDocument(word, frame, finder)) {
+        // Between iterations, for example after a sleep of one cycle, we could
+        // have gotten the signal to stop iterating. Make sure we do here.
+        if (!this.running || spawnId !== this._spawnId)
+          return;
+
+        // Deal with links-only mode here.
+        if (linksOnly && this._rangeStartsInLink(range))
+          continue;
+
+        this.ranges.push(range);
+
+        // Call each listener with the range we just found.
+        for (let [onRange, { limit, onEnd }] of this._listeners) {
+          if (this._catchingUp.has(onRange))
+            continue;
+
+          onRange(range);
+
+          if (limit !== -1 && --limit === 0) {
+            // We've reached our limit; no need to do more work for this listener.
+            this._listeners.delete(onRange);
+            onEnd();
+            continue;
+          }
+
+          // Save the updated limit globally.
+          this._listeners.set(onRange, { limit, onEnd });
+        }
+
+        yield range;
+
+        if (++iterCount >= kIterationSizeMax) {
+          iterCount = 0;
+          // Sleep for the rest of this cycle.
+          yield new Promise(resolve => window.setTimeout(resolve, 0));
+        }
+      }
+    }
+
+    // When the iterating has finished, make sure we reset and save the state
+    // properly.
+    this.stop(true);
+  }),
+
+  /**
+   * Internal; basic wrapper around nsIFind that provides a generator yielding
+   * a range each time an occurence of `word` string is found.
+   *
+   * @param {String}       word   The word to search for
+   * @param {nsIDOMWindow} window The window to search in
+   * @param {Finder}       finder The Finder instance
+   * @yield {nsIDOMRange}
+   */
+  _iterateDocument: function* (word, window, finder) {
+    let doc = window.document;
+    let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ?
+               doc.body : doc.documentElement;
+
+    if (!body)
+      return;
+
+    let searchRange = doc.createRange();
+    searchRange.selectNodeContents(body);
+
+    let startPt = searchRange.cloneRange();
+    startPt.collapse(true);
+
+    let endPt = searchRange.cloneRange();
+    endPt.collapse(false);
+
+    let retRange = null;
+
+    let nsIFind = Cc["@mozilla.org/embedcomp/rangefind;1"]
+                    .createInstance()
+                    .QueryInterface(Ci.nsIFind);
+    nsIFind.caseSensitive = finder._fastFind.caseSensitive;
+    nsIFind.entireWord = finder._fastFind.entireWord;
+
+    while ((retRange = nsIFind.Find(word, searchRange, startPt, endPt))) {
+      yield retRange;
+      startPt = retRange.cloneRange();
+      startPt.collapse(false);
+    }
+  },
+
+  /**
+   * Internal; helper method for the iterator that recursively collects all
+   * visible (i)frames inside a window.
+   *
+   * @param  {nsIDOMWindow} window The window to extract the (i)frames from
+   * @param  {Finder}       finder The Finder instance
+   * @return {Array}        Stack of frames to iterate over
+   */
+  _collectFrames(window, finder) {
+    let frames = [];
+    if (!window.frames || !window.frames.length)
+      return frames;
+
+    // Casting `window.frames` to an Iterator doesn't work, so we're stuck with
+    // a plain, old for-loop.
+    for (let i = 0, l = window.frames.length; i < l; ++i) {
+      let frame = window.frames[i];
+      // Don't count matches in hidden frames.
+      let frameEl = frame && frame.frameElement;
+      if (!frameEl)
+        continue;
+      // Construct a range around the frame element to check its visiblity.
+      let range = window.document.createRange();
+      range.setStart(frameEl, 0);
+      range.setEnd(frameEl, 0);
+      if (!finder._fastFind.isRangeVisible(range, this._getDocShell(range), true))
+        continue;
+      // All conditions pass, so push the current frame and its children on the
+      // stack.
+      frames.push(frame, ...this._collectFrames(frame, finder));
+    }
+
+    return frames;
+  },
+
+  /**
+   * Internal; helper method to extract the docShell reference from a Window or
+   * Range object.
+   *
+   * @param  {nsIDOMRange} windowOrRange Window object to query. May also be a
+   *                                     Range, from which the owner window will
+   *                                     be queried.
+   * @return {nsIDocShell}
+   */
+  _getDocShell(windowOrRange) {
+    let window = windowOrRange;
+    // Ranges may also be passed in, so fetch its window.
+    if (windowOrRange instanceof Ci.nsIDOMRange)
+      window = windowOrRange.startContainer.ownerDocument.defaultView;
+    return window.QueryInterface(Ci.nsIInterfaceRequestor)
+                 .getInterface(Ci.nsIWebNavigation)
+                 .QueryInterface(Ci.nsIDocShell);
+  },
+
+  /**
+   * Internal; determines whether a range is inside a link.
+   *
+   * @param  {nsIDOMRange} range the range to check
+   * @return {Boolean}     True if the range starts in a link
+   */
+  _rangeStartsInLink(range) {
+    let isInsideLink = false;
+    let node = range.startContainer;
+
+    if (node.nodeType == node.ELEMENT_NODE) {
+      if (node.hasChildNodes) {
+        let childNode = node.item(range.startOffset);
+        if (childNode)
+          node = childNode;
+      }
+    }
+
+    const XLink_NS = "http://www.w3.org/1999/xlink";
+    const HTMLAnchorElement = (node.ownerDocument || node).defaultView.HTMLAnchorElement;
+    do {
+      if (node instanceof HTMLAnchorElement) {
+        isInsideLink = node.hasAttribute("href");
+        break;
+      } else if (typeof node.hasAttributeNS == "function" &&
+                 node.hasAttributeNS(XLink_NS, "href")) {
+        isInsideLink = (node.getAttributeNS(XLink_NS, "type") == "simple");
+        break;
+      }
+
+      node = node.parentNode;
+    } while (node);
+
+    return isInsideLink;
+  }
+};
--- a/toolkit/modules/RemoteFinder.jsm
+++ b/toolkit/modules/RemoteFinder.jsm
@@ -145,19 +145,20 @@ RemoteFinder.prototype = {
 
   findAgain: function (aFindBackwards, aLinksOnly, aDrawOutline) {
     this._browser.messageManager.sendAsyncMessage("Finder:FindAgain",
                                                   { findBackwards: aFindBackwards,
                                                     linksOnly: aLinksOnly,
                                                     drawOutline: aDrawOutline });
   },
 
-  highlight: function (aHighlight, aWord) {
+  highlight: function (aHighlight, aWord, aLinksOnly) {
     this._browser.messageManager.sendAsyncMessage("Finder:Highlight",
                                                   { highlight: aHighlight,
+                                                    linksOnly: aLinksOnly,
                                                     word: aWord });
   },
 
   enableSelection: function () {
     this._browser.messageManager.sendAsyncMessage("Finder:EnableSelection");
   },
 
   removeSelection: function () {
@@ -287,17 +288,17 @@ RemoteFinderListener.prototype = {
         this._finder.fastFind(data.searchString, data.linksOnly, data.drawOutline);
         break;
 
       case "Finder:FindAgain":
         this._finder.findAgain(data.findBackwards, data.linksOnly, data.drawOutline);
         break;
 
       case "Finder:Highlight":
-        this._finder.highlight(data.highlight, data.word);
+        this._finder.highlight(data.highlight, data.word, data.linksOnly);
         break;
 
       case "Finder:EnableSelection":
         this._finder.enableSelection();
         break;
 
       case "Finder:RemoveSelection":
         this._finder.removeSelection();
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -35,16 +35,17 @@ EXTRA_JS_MODULES += [
     'Color.jsm',
     'Console.jsm',
     'debug.js',
     'DeferredTask.jsm',
     'Deprecated.jsm',
     'FileUtils.jsm',
     'Finder.jsm',
     'FinderHighlighter.jsm',
+    'FinderIterator.jsm',
     'Geometry.jsm',
     'GMPInstallManager.jsm',
     'GMPUtils.jsm',
     'Http.jsm',
     'InlineSpellChecker.jsm',
     'InlineSpellCheckerContent.jsm',
     'Integration.jsm',
     'LoadContextInfo.jsm',