Bug 1282070 - repaint the modal highlight mask when the page resizes or changes size due to added/ removed content. r?jaws draft
authorMike de Boer <mdeboer@mozilla.com>
Tue, 09 Aug 2016 17:02:50 +0200
changeset 398625 0aea3f14ca10f0bd53cde7ce494754049c118f1f
parent 398612 f76b9d417a3678f840f498598d215b41f5c6616a
child 527710 c8a5021f339cd5d90719b46fae58434587f23ae1
push id25585
push usermdeboer@mozilla.com
push dateTue, 09 Aug 2016 15:03:18 +0000
reviewersjaws
bugs1282070
milestone51.0a1
Bug 1282070 - repaint the modal highlight mask when the page resizes or changes size due to added/ removed content. r?jaws MozReview-Commit-ID: LSWkNNiTDsQ
toolkit/modules/Finder.jsm
toolkit/modules/FinderHighlighter.jsm
toolkit/modules/FinderIterator.jsm
toolkit/modules/tests/xpcshell/test_FinderIterator.js
--- a/toolkit/modules/Finder.jsm
+++ b/toolkit/modules/Finder.jsm
@@ -128,21 +128,25 @@ Finder.prototype = {
     if (!aSearchString || !Clipboard.supportsFindClipboard())
       return;
 
     ClipboardHelper.copyStringToClipboard(aSearchString,
                                           Ci.nsIClipboard.kFindClipboard);
   },
 
   set caseSensitive(aSensitive) {
+    if (this._fastFind.caseSensitive === aSensitive)
+      return;
     this._fastFind.caseSensitive = aSensitive;
     this.iterator.reset();
   },
 
   set entireWord(aEntireWord) {
+    if (this._fastFind.entireWord === aEntireWord)
+      return;
     this._fastFind.entireWord = aEntireWord;
     this.iterator.reset();
   },
 
   get highlighter() {
     if (this._highlighter)
       return this._highlighter;
 
@@ -363,70 +367,89 @@ Finder.prototype = {
         controller.scrollLine(false);
         break;
       case Ci.nsIDOMKeyEvent.DOM_VK_DOWN:
         controller.scrollLine(true);
         break;
     }
   },
 
-  _notifyMatchesCount: function(result) {
+  _notifyMatchesCount: function(result = this._currentMatchesCountResult) {
+    // The `_currentFound` property is only used for internal bookkeeping.
+    delete result._currentFound;
+    if (result.total == this._currentMatchLimit)
+      result.total = -1;
+
     for (let l of this._listeners) {
       try {
         l.onMatchesCountResult(result);
       } catch (ex) {}
     }
+
+    this._currentMatchesCountResult = null;
   },
 
   requestMatchesCount: function(aWord, aMatchLimit, aLinksOnly) {
     if (this._lastFindResult == Ci.nsITypeAheadFind.FIND_NOTFOUND ||
         this.searchString == "" || !aWord) {
       this._notifyMatchesCount({
         total: 0,
         current: 0
       });
       return;
     }
 
     let window = this._getWindow();
-    let result = {
+    this._currentFoundRange = this._fastFind.getFoundRange();
+    this._currentMatchLimit = aMatchLimit;
+
+    this._currentMatchesCountResult = {
       total: 0,
       current: 0,
       _currentFound: false
     };
-    let foundRange = this._fastFind.getFoundRange();
 
     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);
-        }
-      },
+      listener: this,
       useCache: true,
       word: aWord
-    }).then(() => {
-      // The `_currentFound` property is only used for internal bookkeeping.
-      delete result._currentFound;
+    }).then(this._notifyMatchesCount.bind(this));
+  },
+
+  // Start of FinderIterator listener
+
+  onIteratorBeforeRestart() {},
+
+  onIteratorRangeFound(range) {
+    let result = this._currentMatchesCountResult;
+    if (!result)
+      return;
 
-      if (result.total == aMatchLimit)
-        result.total = -1;
+    ++result.total;
+    if (!result._currentFound) {
+      ++result.current;
+      result._currentFound = (this._currentFoundRange &&
+        range.startContainer == this._currentFoundRange.startContainer &&
+        range.startOffset == this._currentFoundRange.startOffset &&
+        range.endContainer == this._currentFoundRange.endContainer &&
+        range.endOffset == this._currentFoundRange.endOffset);
+    }
+  },
 
-      this._notifyMatchesCount(result);
-    });
+  onIteratorReset() {},
+
+  onIteratorRestart({ word, linksOnly }) {
+    this.requestMatchesCount(word, this._currentMatchLimit, linksOnly);
   },
 
+  // END of FinderIterator listener
+
   _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
    * top-level content document.
    */
--- a/toolkit/modules/FinderHighlighter.jsm
+++ b/toolkit/modules/FinderHighlighter.jsm
@@ -202,62 +202,71 @@ FinderHighlighter.prototype = {
    * @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, linksOnly) {
     let window = this.finder._getWindow();
     let controller = this.finder._getSelectionController(window);
     let doc = window.document;
-    let found = false;
-
-    this.clear();
+    this._found = false;
 
     if (!controller || !doc || !doc.documentElement) {
       // Without the selection controller,
       // we are unable to (un)highlight any matches
-      return found;
+      return this._found;
     }
 
     if (highlight) {
       yield this.iterator.start({
         linksOnly, word,
         finder: this.finder,
-        onRange: range => {
-          this.highlightRange(range, controller, window);
-          found = true;
-        },
+        listener: this,
         useCache: true
       });
     } else {
       this.hide(window);
-      this.clear();
-      this.iterator.reset();
 
       // Removing the highlighting always succeeds, so return true.
-      found = true;
+      this._found = true;
     }
 
-    return found;
+    return this._found;
   }),
 
+  // Start of FinderIterator listener
+
+  onIteratorBeforeRestart() {
+    this.clear();
+  },
+
+  onIteratorRangeFound(range) {
+    this.highlightRange(range);
+    this._found = true;
+  },
+
+  onIteratorReset() {
+    this.clear();
+  },
+
+  onIteratorRestart() {},
+
+  // END of FinderIterator listener
+
   /**
    * Add a range to the find selection, i.e. highlight it, and if it's inside an
    * editable node, track it.
    *
-   * @param {nsIDOMRange}            range      Range object to be highlighted
-   * @param {nsISelectionController} controller Selection controller of the
-   *                                            document that the range belongs
-   *                                            to
-   * @param {nsIDOMWindow}           window     Window object, whose DOM tree
-   *                                            is being traversed
+   * @param {nsIDOMRange} range Range object to be highlighted
    */
-  highlightRange(range, controller, window) {
+  highlightRange(range) {
     let node = range.startContainer;
     let editableNode = this._getEditableNode(node);
+    let window = node.ownerDocument.defaultView;
+    let controller = this.finder._getSelectionController(window);
     if (editableNode) {
       controller = editableNode.editor.selectionController;
     }
 
     if (this._modal) {
       this._modalHighlight(range, controller, window);
     } else {
       let findSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
@@ -437,16 +446,18 @@ FinderHighlighter.prototype = {
 
   /**
    * When the current page is refreshed or navigated away from, the CanvasFrame
    * contents is not valid anymore, i.e. all anonymous content is destroyed.
    * We need to clear the references we keep, which'll make sure we redraw
    * everything when the user starts to find in page again.
    */
   onLocationChange() {
+    this.clear();
+
     if (!this._modalHighlightOutline)
       return;
 
     if (kDebug)
       this._modalHighlightOutline.remove();
     try {
       this.finder._getWindow().document
         .removeAnonymousContent(this._modalHighlightOutline);
@@ -728,23 +739,24 @@ FinderHighlighter.prototype = {
   _repaintHighlightAllMask(window, paintContent = true) {
     let document = window.document;
 
     const kMaskId = kModalIdPrefix + "-findbar-modalHighlight-outlineMask";
     let maskNode = document.createElement("div");
 
     // Make sure the dimmed mask node takes the full width and height that's available.
     let {width, height} = this._getWindowDimensions(window);
+    this._lastWindowDimensions = { width, height };
     maskNode.setAttribute("id", kMaskId);
     maskNode.setAttribute("class", kMaskId + (kDebug ? ` ${kModalIdPrefix}-findbar-debug` : ""));
     maskNode.setAttribute("style", `width: ${width}px; height: ${height}px;`);
     if (this._brightText)
       maskNode.setAttribute("brighttext", "true");
 
-    if (paintContent) {
+    if (paintContent || this._modalHighlightAllMask) {
       // Create a DOM node for each rectangle representing the ranges we found.
       let maskContent = [];
       const kRectClassName = kModalIdPrefix + "-findbar-modalHighlight-rect";
       if (this._modalHighlightRectsMap) {
         for (let [range, rects] of this._modalHighlightRectsMap) {
           for (let rect of rects) {
             maskContent.push(`<div class="${kRectClassName}" style="top: ${rect.y}px;
               left: ${rect.x}px; height: ${rect.height}px; width: ${rect.width}px;"></div>`);
@@ -782,22 +794,47 @@ FinderHighlighter.prototype = {
   },
 
   /**
    * Doing a full repaint each time a range is delivered by the highlight iterator
    * is way too costly, thus we pipe the frequency down to every
    * `kModalHighlightRepaintFreqMs` milliseconds.
    *
    * @param {nsIDOMWindow} window
+   * @param {Boolean}      contentChanged Whether the documents' content changed
+   *                                      in the meantime. This happens when the
+   *                                      DOM is updated whilst the page is loaded.
    */
-  _scheduleRepaintOfMask(window) {
+  _scheduleRepaintOfMask(window, contentChanged = false) {
     if (this._modalRepaintScheduler)
       window.clearTimeout(this._modalRepaintScheduler);
-    this._modalRepaintScheduler = window.setTimeout(
-      this._repaintHighlightAllMask.bind(this, window), kModalHighlightRepaintFreqMs);
+
+    // When we request to repaint unconditionally, we mean to call
+    // `_repaintHighlightAllMask()` right after the timeout.
+    if (!this._unconditionalRepaintRequested)
+      this._unconditionalRepaintRequested = !contentChanged;
+
+    this._modalRepaintScheduler = window.setTimeout(() => {
+      if (this._unconditionalRepaintRequested) {
+        this._unconditionalRepaintRequested = false;
+        this._repaintHighlightAllMask(window);
+        return;
+      }
+
+      let { width, height } = this._getWindowDimensions(window);
+      if (!this._modalHighlightRectsMap ||
+          (Math.abs(this._lastWindowDimensions.width - width) < 5 &&
+           Math.abs(this._lastWindowDimensions.height - height) < 5)) {
+        return;
+      }
+
+      this.iterator.restart(this.finder);
+      this._lastWindowDimensions = { width, height };
+      this._repaintHighlightAllMask(window);
+    }, kModalHighlightRepaintFreqMs);
   },
 
   /**
    * The outline that shows/ highlights the current found range is styled and
    * animated using CSS. This style can be found in `kModalStyle`, but to have it
    * applied on any DOM node we insert using the AnonymousContent API we need to
    * inject an agent sheet into the document.
    *
@@ -826,36 +863,36 @@ FinderHighlighter.prototype = {
    *
    * @param {nsIDOMWindow} window
    */
   _addModalHighlightListeners(window) {
     if (this._highlightListeners)
       return;
 
     this._highlightListeners = [
-      this._scheduleRepaintOfMask.bind(this, window),
+      this._scheduleRepaintOfMask.bind(this, window, true),
       this.hide.bind(this, window, null)
     ];
-    window.addEventListener("DOMContentLoaded", this._highlightListeners[0]);
+    let target = this.iterator._getDocShell(window).chromeEventHandler;
+    target.addEventListener("MozAfterPaint", this._highlightListeners[0]);
     window.addEventListener("click", this._highlightListeners[1]);
-    window.addEventListener("resize", this._highlightListeners[1]);
   },
 
   /**
    * Remove event listeners from content.
    *
    * @param {nsIDOMWindow} window
    */
   _removeModalHighlightListeners(window) {
     if (!this._highlightListeners)
       return;
 
-    window.removeEventListener("DOMContentLoaded", this._highlightListeners[0]);
+    let target = this.iterator._getDocShell(window).chromeEventHandler;
+    target.removeEventListener("MozAfterPaint", this._highlightListeners[0]);
     window.removeEventListener("click", this._highlightListeners[1]);
-    window.removeEventListener("resize", this._highlightListeners[1]);
 
     this._highlightListeners = null;
   },
 
   /**
    * For a given node returns its editable parent or null if there is none.
    * It's enough to check if node is a text node and its parent's parent is
    * instance of nsIDOMNSEditableElement.
--- a/toolkit/modules/FinderIterator.jsm
+++ b/toolkit/modules/FinderIterator.jsm
@@ -37,78 +37,87 @@ this.FinderIterator = {
    * 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.
+   * Results are also passed to the `listener.onIteratorRangeFound` 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 {Object}   options.listener    Listener object that implements the
+   *                                       following callback functions:
+   *                                        - onIteratorBeforeRestart({Object} iterParams);
+   *                                        - onIteratorRangeFound({nsIDOMRange} range);
+   *                                        - onIteratorReset();
+   *                                        - onIteratorRestart({Object} iterParams);
    * @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 }) {
+  start({ finder, limit, linksOnly, listener, 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'");
+    if (typeof listener != "object")
+      throw new TypeError("Missing valid, required option 'listener'");
 
-    // Don't add the same listener twice.
-    if (this._listeners.has(onRange))
-      throw new Error("Already listening to iterator results");
+    // If the listener was added before, make sure the promise is resolved before
+    // we replace it with another.
+    if (this._listeners.has(listener)) {
+      let { onEnd } = this._listeners.get(listener);
+      if (onEnd)
+        onEnd();
+    }
 
     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 });
+    this._listeners.set(listener, { 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);
+      this._yieldPreviousResult(listener, 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);
+      this._yieldIntermediateResult(listener, window);
 
       return promise;
     }
 
     // Start!
     this.running = true;
     this._currentParams = iterParams;
     this._findAllRanges(finder, window, ++this._spawnId);
@@ -137,32 +146,54 @@ this.FinderIterator = {
 
     this._catchingUp.clear();
     this._currentParams = null;
     this.ranges = [];
     this.running = false;
 
     for (let [, { onEnd }] of this._listeners)
       onEnd();
-    this._listeners.clear();
+  },
+
+  /**
+   * Stops the iteration that currently running, if it is, and start a new one
+   * with the exact same params as before.
+   *
+   * @param {Finder} finder Currently active Finder instance
+   */
+  restart(finder) {
+    // Capture current iterator params before we stop the show.
+    let iterParams = this.params;
+    if (!iterParams.word)
+      return;
+    this.stop();
+
+    // Restart manually.
+    this.running = true;
+    this._currentParams = iterParams;
+
+    this._notifyListeners("beforeRestart", iterParams);
+    this._findAllRanges(finder, finder._getWindow(), ++this._spawnId);
+    this._notifyListeners("restart", iterParams);
   },
 
   /**
    * 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;
 
+    this._notifyListeners("reset");
     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
@@ -175,16 +206,34 @@ this.FinderIterator = {
    */
   continueRunning({ linksOnly, word }) {
     return (this.running &&
       this._currentParams.linksOnly === linksOnly &&
       this._currentParams.word == word);
   },
 
   /**
+   * Safely notify all registered listeners that an event has occurred.
+   *
+   * @param  {String}     callback Name of the callback to invoke
+   * @param  {...[mixed]} params   Arbitrary amount of arguments that will be
+   *                               passed to the callback in-order.
+   */
+  _notifyListeners(callback, ...params) {
+    callback = "onIterator" + callback.charAt(0).toUpperCase() + callback.substr(1);
+    for (let [listener] of this._listeners) {
+      try {
+        listener[callback](...params);
+      } catch (ex) {
+        Cu.reportError("FinderIterator Error: " + ex);
+      }
+    }
+  },
+
+  /**
    * 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
@@ -211,99 +260,97 @@ this.FinderIterator = {
 
   /**
    * 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 {Object}       listener    Listener object
    * @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) {
+  _yieldResult: function* (listener, 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 { limit, onEnd } = this._listeners.get(listener);
     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);
+      listener.onIteratorRangeFound(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 });
+        this._listeners.set(listener, { 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);
+        this._listeners.delete(listener);
         onEnd();
         return;
       }
     }
 
     // Save the updated limit globally.
-    this._listeners.set(onRange, { limit, onEnd });
+    this._listeners.set(listener, { 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`
+   * @param {Object}       listener Listener object
+   * @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) {
+  _yieldPreviousResult: Task.async(function* (listener, window) {
+    this._catchingUp.add(listener);
+    yield* this._yieldResult(listener, this._previousRanges, window);
+    this._catchingUp.delete(listener);
+    let { onEnd } = this._listeners.get(listener);
+    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`
+   * @param {Object}       listener Listener object
+   * @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);
+  _yieldIntermediateResult: Task.async(function* (listener, window) {
+    this._catchingUp.add(listener);
+    yield* this._yieldResult(listener, this.ranges, window);
+    this._catchingUp.delete(listener);
   }),
 
   /**
    * 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
@@ -326,31 +373,31 @@ this.FinderIterator = {
 
         // 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))
+        for (let [listener, { limit, onEnd }] of this._listeners) {
+          if (this._catchingUp.has(listener))
             continue;
 
-          onRange(range);
+          listener.onIteratorRangeFound(range);
 
           if (limit !== -1 && --limit === 0) {
             // We've reached our limit; no need to do more work for this listener.
-            this._listeners.delete(onRange);
+            this._listeners.delete(listener);
             onEnd();
             continue;
           }
 
           // Save the updated limit globally.
-          this._listeners.set(onRange, { limit, onEnd });
+          this._listeners.set(listener, { limit, onEnd });
         }
 
         yield range;
 
         if (++iterCount >= kIterationSizeMax) {
           iterCount = 0;
           // Sleep for the rest of this cycle.
           yield new Promise(resolve => window.setTimeout(resolve, 0));
--- a/toolkit/modules/tests/xpcshell/test_FinderIterator.js
+++ b/toolkit/modules/tests/xpcshell/test_FinderIterator.js
@@ -38,96 +38,104 @@ function prepareIterator(findText, range
 add_task(function* test_start() {
   let findText = "test";
   let rangeCount = 300;
   prepareIterator(findText, rangeCount);
 
   let count = 0;
   yield FinderIterator.start({
     finder: gMockFinder,
-    onRange: range => {
-      ++count;
-      Assert.equal(range.toString(), findText, "Text content should match");
+    listener: {
+      onIteratorRangeFound(range) {
+        ++count;
+        Assert.equal(range.toString(), findText, "Text content should match");
+      }
     },
     word: findText
   });
 
   Assert.equal(rangeCount, count, "Amount of ranges yielded should match!");
   Assert.ok(!FinderIterator.running, "Running state should match");
   Assert.equal(FinderIterator._previousRanges.length, rangeCount, "Ranges cache should match");
+
+  FinderIterator.reset();
 });
 
 add_task(function* test_valid_arguments() {
   let findText = "foo";
   let rangeCount = 20;
   prepareIterator(findText, rangeCount);
 
   let count = 0;
 
   yield FinderIterator.start({
     finder: gMockFinder,
-    onRange: range => ++count,
+    listener: { onIteratorRangeFound(range) { ++count; } },
     word: findText
   });
 
   let params = FinderIterator._previousParams;
   Assert.ok(!params.linksOnly, "Default for linksOnly is false");
   Assert.ok(!params.useCache, "Default for useCache is false");
   Assert.equal(params.word, findText, "Words should match");
 
   count = 0;
   Assert.throws(() => FinderIterator.start({
-    onRange: range => ++count,
+    listener: { onIteratorRangeFound(range) { ++count; } },
     word: findText
   }), /Missing required option 'finder'/, "Should throw when missing an argument");
   FinderIterator.reset();
 
   Assert.throws(() => FinderIterator.start({
     finder: gMockFinder,
     word: findText
-  }), /Missing valid, required option 'onRange'/, "Should throw when missing an argument");
+  }), /Missing valid, required option 'listener'/, "Should throw when missing an argument");
   FinderIterator.reset();
 
   Assert.throws(() => FinderIterator.start({
     finder: gMockFinder,
-    onRange: range => ++count
+    listener: { onIteratorRangeFound(range) { ++count; } }
   }), /Missing required option 'word'/, "Should throw when missing an argument");
   FinderIterator.reset();
 
   Assert.equal(count, 0, "No ranges should've been counted");
+
+  FinderIterator.reset();
 });
 
 add_task(function* test_stop() {
   let findText = "bar";
   let rangeCount = 120;
   prepareIterator(findText, rangeCount);
 
   let count = 0;
   let whenDone = FinderIterator.start({
     finder: gMockFinder,
-    onRange: range => ++count,
+    listener: { onIteratorRangeFound(range) { ++count; } },
     word: findText
   });
 
   FinderIterator.stop();
 
   yield whenDone;
 
   Assert.equal(count, 100, "Number of ranges should match `kIterationSizeMax`");
+
+  FinderIterator.reset();
 });
 
 add_task(function* test_reset() {
   let findText = "tik";
   let rangeCount = 142;
   prepareIterator(findText, rangeCount);
 
   let count = 0;
   let whenDone = FinderIterator.start({
     finder: gMockFinder,
-    onRange: range => ++count,
+    listener: { onIteratorRangeFound(range) { ++count; } },
     word: findText
   });
 
   Assert.ok(FinderIterator.running, "Yup, running we are");
   Assert.equal(count, 100, "Number of ranges should match `kIterationSizeMax`");
   Assert.equal(FinderIterator.ranges.length, 100,
     "Number of ranges should match `kIterationSizeMax`");
 
@@ -146,28 +154,28 @@ add_task(function* test_parallel_starts(
   let findText = "tak";
   let rangeCount = 2143;
   prepareIterator(findText, rangeCount);
 
   // Start off the iterator.
   let count = 0;
   let whenDone = FinderIterator.start({
     finder: gMockFinder,
-    onRange: range => ++count,
+    listener: { onIteratorRangeFound(range) { ++count; } },
     word: findText
   });
 
   // Start again after a few milliseconds.
   yield new Promise(resolve => gMockWindow.setTimeout(resolve, 2));
   Assert.ok(FinderIterator.running, "We ought to be running here");
 
   let count2 = 0;
   let whenDone2 = FinderIterator.start({
     finder: gMockFinder,
-    onRange: range => ++count2,
+    listener: { onIteratorRangeFound(range) { ++count2; } },
     word: findText
   });
 
   // Let the iterator run for a little while longer before we assert the world.
   yield new Promise(resolve => gMockWindow.setTimeout(resolve, 10));
   FinderIterator.stop();
 
   Assert.ok(!FinderIterator.running, "Stop means stop");