Bug 1356532: Part 3 - Add layout query helper that forces a single reflow before paint. r=florian draft
authorKris Maglione <maglione.k@gmail.com>
Sat, 26 Aug 2017 15:08:35 -0700
changeset 656128 8f7e7e77a775c4f3a27ede3df5c1f07fc82e98cf
parent 656127 88f47d0399e33cec779c2172c17c89975e7f5315
child 656129 1a587441fb6591bbf139ab3dd0256e11ad41a205
push id77076
push usermaglione.k@gmail.com
push dateWed, 30 Aug 2017 19:02:00 +0000
reviewersflorian
bugs1356532
milestone57.0a1
Bug 1356532: Part 3 - Add layout query helper that forces a single reflow before paint. r=florian This is mostly a stopgap until code that relies on synchronous layout flushes can be rewriteen to rely on the layout engine instead. But it allows existing code to be reworked so that only one synchronous layout flush is required per frame, and tries to perform them during idle slices when possible. MozReview-Commit-ID: ClFGn5i983A
toolkit/modules/BrowserUtils.jsm
--- a/toolkit/modules/BrowserUtils.jsm
+++ b/toolkit/modules/BrowserUtils.jsm
@@ -11,50 +11,155 @@ const {interfaces: Ci, utils: Cu, classe
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
   "resource://gre/modules/PlacesUtils.jsm");
 
 Cu.importGlobalProperties(["URL"]);
 
-let reflowObservers = new WeakMap();
+let reflowSchedulers = new WeakMap();
 
-function ReflowObserver(doc) {
+function ReflowScheduler(doc) {
   this._doc = doc;
+  this._win = doc.defaultView;
+
+  reflowSchedulers.set(this._doc, this);
 
-  doc.docShell.addWeakReflowObserver(this);
-  reflowObservers.set(this._doc, this);
+  this.frameCallbacks = [];
+  this.reflowCallbacks = [];
 
-  this.callbacks = [];
+  this._onAnimationFrame = this._onAnimationFrame.bind(this);
+  this._onIdle = this._onIdle.bind(this);
+
+  this._frameRequest = null;
+  this._idleRequest = null;
+  this._reflowRequested = false;
 }
 
-ReflowObserver.prototype = {
+ReflowScheduler.get = function(doc) {
+  let scheduler = reflowSchedulers.get(doc);
+  if (!scheduler) {
+    scheduler = new ReflowScheduler(doc);
+    reflowSchedulers.set(doc, scheduler);
+  }
+  return scheduler;
+}
+
+ReflowScheduler.prototype = {
   QueryInterface: XPCOMUtils.generateQI(["nsIReflowObserver", "nsISupportsWeakReference"]),
 
   _onReflow() {
-    reflowObservers.delete(this._doc);
     this._doc.docShell.removeWeakReflowObserver(this);
+    this._reflowRequested = false;
 
-    for (let callback of this.callbacks) {
+    if (this._idleRequest) {
+      this._win.cancelIdleCallback(this._idleRequest);
+      this._idleRequest = null;
+    }
+
+    for (let callback of this.reflowCallbacks.slice()) {
       try {
         callback();
       } catch (e) {
         Cu.reportError(e);
       }
     }
   },
 
+  _onIdle() {
+    this._idleRequest = null;
+    this.forceReflow();
+
+    // If we have no remaining animation frame callbacks, cancel the
+    // animation frame request.
+    if (this._frameRequest && !this.frameCallbacks.length) {
+      this._win.cancelAnimationFrame(this._frameRequest);
+      this._frameRequest = null;
+    }
+  },
+
+  _onAnimationFrame() {
+    // If we still have an idle callback registered at this point, that
+    // means we have reflow observers that need to run before the next
+    // paint. So force a reflow now, and run all pending callbacks in a
+    // group.
+    if (this._idleRequest) {
+      this._win.cancelIdleCallback(this._idleRequest);
+      this._idleRequest = null;
+
+      this.forceReflow();
+
+      // Flush the Promise microtask queue so waiting tasks have a
+      // chance to schedule animation frame callbacks.
+      let utils = this._doc.defaultView.QueryInterface(Ci.nsIInterfaceRequestor)
+                      .getInterface(Ci.nsIDOMWindowUtils);
+      utils.performMicroTaskCheckpoint();
+    }
+
+    this._frameRequest = null;
+
+    for (let callback of this.frameCallbacks.slice()) {
+      try {
+        callback();
+      } catch (e) {
+        Cu.reportError(e);
+      }
+    }
+  },
+
+  forceReflow() {
+    // We should only ever call this when a layout flush is required, so
+    // just querying layout should be enough to force a flush.
+    void this._doc.documentElement.clientTop;
+
+    if (this._idleRequest) {
+      Cu.reportError(new Error("Unexpected idle callback registered"));
+      this._idleRequest = null;
+    }
+  },
+
   reflow() {
     this._onReflow();
   },
 
   reflowInterruptible() {
     this._onReflow();
   },
+
+  addReflowCallback(callback) {
+    this.reflowCallbacks.push(callback);
+    if (!this._reflowRequested) {
+      this._doc.docShell.addWeakReflowObserver(this);
+      this._reflowRequested = true;
+    }
+  },
+
+  addSlowReflowCallback(callback) {
+    this.addReflowCallback(callback);
+
+    // Add both idle and animation frame callbacks. If the idle callback
+    // fires first, we force a reflow immediately, and cancel our
+    // animation frame request. If the callback is still pending by the
+    // start of the next animation frame, we force a reflow then, and
+    // give pending promise tasks a chance to run.
+    if (!this._idleRequest) {
+      this._idleRequest = this._win.requestIdleCallback(this._onIdle);
+    }
+    if (!this._frameRequest) {
+      this._frameRequest = this._win.requestAnimationFrame(this._onAnimationFrame);
+    }
+  },
+
+  addFrameCallback(callback) {
+    this.frameCallbacks.push(callback);
+    if (!this._frameRequest) {
+      this._frameRequest = this._win.requestAnimationFrame(this._onAnimationFrame);
+    }
+  },
 };
 
 const FLUSH_TYPES = {
   "style": Ci.nsIDOMWindowUtils.FLUSH_STYLE,
   "layout": Ci.nsIDOMWindowUtils.FLUSH_LAYOUT,
   "display": Ci.nsIDOMWindowUtils.FLUSH_DISPLAY,
 };
 
@@ -675,24 +780,19 @@ this.BrowserUtils = {
    * The function *must not trigger any reflows*, or make any changes
    * which would require a layout flush.
    *
    * @param {Document} doc
    * @param {function} callback
    * @returns {Promise}
    */
   promiseReflowed(doc, callback) {
-    let observer = reflowObservers.get(doc);
-    if (!observer) {
-      observer = new ReflowObserver(doc);
-      reflowObservers.set(doc, observer);
-    }
-
+    let scheduler = ReflowScheduler.get(doc);
     return new Promise((resolve, reject) => {
-      observer.callbacks.push(() => {
+      scheduler.addReflowCallback(() => {
         try {
           resolve(callback());
         } catch (e) {
           reject(e);
         }
       });
     });
   },
@@ -722,30 +822,83 @@ this.BrowserUtils = {
     if (!utils.needsFlush(FLUSH_TYPES[flushType])) {
       return callback();
     }
 
     return this.promiseReflowed(doc, callback);
   },
 
   /**
+   * Like `promiseLayoutFlushed`, but guarantees that if a reflow is
+   * required, it will happen before the next paint, and that any
+   * pending promise handlers will likewise run before the paint. When
+   * combined with `promiseAnimationFrame, this allows multiple
+   * operations that depend on querying the layout state before paint to
+   * be grouped so that only a single extra layout flush is required.
+   *
+   * Please *USE THIS ONLY AS A LAST RESORT*. If you need to query the
+   * layout of something that hasn't painted yet, you almost certainly
+   * have better options. Forcing multiple reflows for a single
+   * animation frame will always be bad for responsiveness.
+   *
+   * @param {Document} doc
+   * @param {string} flushType
+   *        As in `promiseLayoutFlushed`.
+   * @param {function} callback
+   * @returns {Promise}
+   */
+  async promiseSlowLayoutFlush(doc, flushType, callback) {
+    let utils = doc.defaultView.QueryInterface(Ci.nsIInterfaceRequestor)
+                   .getInterface(Ci.nsIDOMWindowUtils);
+
+    if (!utils.needsFlush(FLUSH_TYPES[flushType])) {
+      return callback();
+    }
+
+    let scheduler = ReflowScheduler.get(doc);
+    return new Promise((resolve, reject) => {
+      scheduler.addSlowReflowCallback(() => {
+        try {
+          resolve(callback());
+        } catch (e) {
+          reject(e);
+        }
+      });
+    });
+  },
+
+  /**
    * Calls the given callback on the next animation frame for the given
    * window, and resolves with its return value after it's been
    * executed.
    *
-   * The callback function may alter the DOM freely, but *must not query the document's style or
-   * layout state*.
+   * The callback function may alter the DOM freely, but *must not query
+   * the document's style or layout state*.
+   *
+   * This has an important difference from `window.requestAnimationFrame`
+   * when used alongside `promiseSlowLayoutFlush`:
+   *
+   * Sometimes `promiseSlowLayoutFlush` needs to force a layout flush
+   * from an animation frame callback before calling its callbacks. If
+   * those callbacks call `window.requestAnimationFrame` in that
+   * circumstance, the new animation frame callbacks will run in the
+   * *next* animation frame, after the current frame is painted.
+   * Animation frame callbacks added via `promiseAnimationFrame`,
+   * however, will run in the *same* animation frame, after all layout
+   * flush callbacks have been called and their pending promise
+   * microtasks processed.
    *
    * @param {Window} win
    * @param {function} callback
    * @returns {Promise}
    */
   promiseAnimationFrame(win, callback) {
     return new Promise((resolve, reject) => {
-      win.requestAnimationFrame(timestamp => {
+      let scheduler = ReflowScheduler.get(win.document);
+      scheduler.addFrameCallback(timestamp => {
         try {
           resolve(callback(timestamp));
         } catch (e) {
           reject(e);
         }
       });
     });
   },