diff options
Diffstat (limited to 'devtools/server/actors/reflow.js')
-rw-r--r-- | devtools/server/actors/reflow.js | 514 |
1 files changed, 514 insertions, 0 deletions
diff --git a/devtools/server/actors/reflow.js b/devtools/server/actors/reflow.js new file mode 100644 index 000000000..0ebe00207 --- /dev/null +++ b/devtools/server/actors/reflow.js @@ -0,0 +1,514 @@ +/* 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"; + +/** + * About the types of objects in this file: + * + * - ReflowActor: the actor class used for protocol purposes. + * Mostly empty, just gets an instance of LayoutChangesObserver and forwards + * its "reflows" events to clients. + * + * - LayoutChangesObserver: extends Observable and uses the ReflowObserver, to + * track reflows on the page. + * Used by the LayoutActor, but is also exported on the module, so can be used + * by any other actor that needs it. + * + * - Observable: A utility parent class, meant at being extended by classes that + * need a to observe something on the tabActor's windows. + * + * - Dedicated observers: There's only one of them for now: ReflowObserver which + * listens to reflow events via the docshell, + * These dedicated classes are used by the LayoutChangesObserver. + */ + +const {Ci} = require("chrome"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const protocol = require("devtools/shared/protocol"); +const {method, Arg} = protocol; +const events = require("sdk/event/core"); +const Heritage = require("sdk/core/heritage"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {reflowSpec} = require("devtools/shared/specs/reflow"); + +/** + * The reflow actor tracks reflows and emits events about them. + */ +var ReflowActor = exports.ReflowActor = protocol.ActorClassWithSpec(reflowSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.tabActor = tabActor; + this._onReflow = this._onReflow.bind(this); + this.observer = getLayoutChangesObserver(tabActor); + this._isStarted = false; + }, + + /** + * The reflow actor is the first (and last) in its hierarchy to use + * protocol.js so it doesn't have a parent protocol actor that takes care of + * its lifetime. So it needs a disconnect method to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + destroy: function () { + this.stop(); + releaseLayoutChangesObserver(this.tabActor); + this.observer = null; + this.tabActor = null; + + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Start tracking reflows and sending events to clients about them. + * This is a oneway method, do not expect a response and it won't return a + * promise. + */ + start: function () { + if (!this._isStarted) { + this.observer.on("reflows", this._onReflow); + this._isStarted = true; + } + }, + + /** + * Stop tracking reflows and sending events to clients about them. + * This is a oneway method, do not expect a response and it won't return a + * promise. + */ + stop: function () { + if (this._isStarted) { + this.observer.off("reflows", this._onReflow); + this._isStarted = false; + } + }, + + _onReflow: function (event, reflows) { + if (this._isStarted) { + events.emit(this, "reflows", reflows); + } + } +}); + +/** + * Base class for all sorts of observers that need to listen to events on the + * tabActor's windows. + * @param {TabActor} tabActor + * @param {Function} callback Executed everytime the observer observes something + */ +function Observable(tabActor, callback) { + this.tabActor = tabActor; + this.callback = callback; + + this._onWindowReady = this._onWindowReady.bind(this); + this._onWindowDestroyed = this._onWindowDestroyed.bind(this); + + events.on(this.tabActor, "window-ready", this._onWindowReady); + events.on(this.tabActor, "window-destroyed", this._onWindowDestroyed); +} + +Observable.prototype = { + /** + * Is the observer currently observing + */ + isObserving: false, + + /** + * Stop observing and detroy this observer instance + */ + destroy: function () { + if (this.isDestroyed) { + return; + } + this.isDestroyed = true; + + this.stop(); + + events.off(this.tabActor, "window-ready", this._onWindowReady); + events.off(this.tabActor, "window-destroyed", this._onWindowDestroyed); + + this.callback = null; + this.tabActor = null; + }, + + /** + * Start observing whatever it is this observer is supposed to observe + */ + start: function () { + if (this.isObserving) { + return; + } + this.isObserving = true; + + this._startListeners(this.tabActor.windows); + }, + + /** + * Stop observing + */ + stop: function () { + if (!this.isObserving) { + return; + } + this.isObserving = false; + + if (this.tabActor.attached && this.tabActor.docShell) { + // It's only worth stopping if the tabActor is still attached + this._stopListeners(this.tabActor.windows); + } + }, + + _onWindowReady: function ({window}) { + if (this.isObserving) { + this._startListeners([window]); + } + }, + + _onWindowDestroyed: function ({window}) { + if (this.isObserving) { + this._stopListeners([window]); + } + }, + + _startListeners: function (windows) { + // To be implemented by sub-classes. + }, + + _stopListeners: function (windows) { + // To be implemented by sub-classes. + }, + + /** + * To be called by sub-classes when something has been observed + */ + notifyCallback: function (...args) { + this.isObserving && this.callback && this.callback.apply(null, args); + } +}; + +/** + * The LayouChangesObserver will observe reflows as soon as it is started. + * Some devtools actors may cause reflows and it may be wanted to "hide" these + * reflows from the LayouChangesObserver consumers. + * If this is the case, such actors should require this module and use this + * global function to turn the ignore mode on and off temporarily. + * + * Note that if a node is provided, it will be used to force a sync reflow to + * make sure all reflows which occurred before switching the mode on or off are + * either observed or ignored depending on the current mode. + * + * @param {Boolean} ignore + * @param {DOMNode} syncReflowNode The node to use to force a sync reflow + */ +var gIgnoreLayoutChanges = false; +exports.setIgnoreLayoutChanges = function (ignore, syncReflowNode) { + if (syncReflowNode) { + let forceSyncReflow = syncReflowNode.offsetWidth; + } + gIgnoreLayoutChanges = ignore; +}; + +/** + * The LayoutChangesObserver class is instantiated only once per given tab + * and is used to track reflows and dom and style changes in that tab. + * The LayoutActor uses this class to send reflow events to its clients. + * + * This class isn't exported on the module because it shouldn't be instantiated + * to avoid creating several instances per tabs. + * Use `getLayoutChangesObserver(tabActor)` + * and `releaseLayoutChangesObserver(tabActor)` + * which are exported to get and release instances. + * + * The observer loops every EVENT_BATCHING_DELAY ms and checks if layout changes + * have happened since the last loop iteration. If there are, it sends the + * corresponding events: + * + * - "reflows", with an array of all the reflows that occured, + * - "resizes", with an array of all the resizes that occured, + * + * @param {TabActor} tabActor + */ +function LayoutChangesObserver(tabActor) { + this.tabActor = tabActor; + + this._startEventLoop = this._startEventLoop.bind(this); + this._onReflow = this._onReflow.bind(this); + this._onResize = this._onResize.bind(this); + + // Creating the various observers we're going to need + // For now, just the reflow observer, but later we can add markupMutation, + // styleSheetChanges and styleRuleChanges + this.reflowObserver = new ReflowObserver(this.tabActor, this._onReflow); + this.resizeObserver = new WindowResizeObserver(this.tabActor, this._onResize); + + EventEmitter.decorate(this); +} + +exports.LayoutChangesObserver = LayoutChangesObserver; + +LayoutChangesObserver.prototype = { + /** + * How long does this observer waits before emitting batched events. + * The lower the value, the more event packets will be sent to clients, + * potentially impacting performance. + * The higher the value, the more time we'll wait, this is better for + * performance but has an effect on how soon changes are shown in the toolbox. + */ + EVENT_BATCHING_DELAY: 300, + + /** + * Destroying this instance of LayoutChangesObserver will stop the batched + * events from being sent. + */ + destroy: function () { + this.isObserving = false; + + this.reflowObserver.destroy(); + this.reflows = null; + + this.resizeObserver.destroy(); + this.hasResized = false; + + this.tabActor = null; + }, + + start: function () { + if (this.isObserving) { + return; + } + this.isObserving = true; + + this.reflows = []; + this.hasResized = false; + + this._startEventLoop(); + + this.reflowObserver.start(); + this.resizeObserver.start(); + }, + + stop: function () { + if (!this.isObserving) { + return; + } + this.isObserving = false; + + this._stopEventLoop(); + + this.reflows = []; + this.hasResized = false; + + this.reflowObserver.stop(); + this.resizeObserver.stop(); + }, + + /** + * Start the event loop, which regularly checks if there are any observer + * events to be sent as batched events + * Calls itself in a loop. + */ + _startEventLoop: function () { + // Avoid emitting events if the tabActor has been detached (may happen + // during shutdown) + if (!this.tabActor || !this.tabActor.attached) { + return; + } + + // Send any reflows we have + if (this.reflows && this.reflows.length) { + this.emit("reflows", this.reflows); + this.reflows = []; + } + + // Send any resizes we have + if (this.hasResized) { + this.emit("resize"); + this.hasResized = false; + } + + this.eventLoopTimer = this._setTimeout(this._startEventLoop, + this.EVENT_BATCHING_DELAY); + }, + + _stopEventLoop: function () { + this._clearTimeout(this.eventLoopTimer); + }, + + // Exposing set/clearTimeout here to let tests override them if needed + _setTimeout: function (cb, ms) { + return setTimeout(cb, ms); + }, + _clearTimeout: function (t) { + return clearTimeout(t); + }, + + /** + * Executed whenever a reflow is observed. Only stacks the reflow in the + * reflows array. + * The EVENT_BATCHING_DELAY loop will take care of it later. + * @param {Number} start When the reflow started + * @param {Number} end When the reflow ended + * @param {Boolean} isInterruptible + */ + _onReflow: function (start, end, isInterruptible) { + if (gIgnoreLayoutChanges) { + return; + } + + // XXX: when/if bug 997092 gets fixed, we will be able to know which + // elements have been reflowed, which would be a nice thing to add here. + this.reflows.push({ + start: start, + end: end, + isInterruptible: isInterruptible + }); + }, + + /** + * Executed whenever a resize is observed. Only store a flag saying that a + * resize occured. + * The EVENT_BATCHING_DELAY loop will take care of it later. + */ + _onResize: function () { + if (gIgnoreLayoutChanges) { + return; + } + + this.hasResized = true; + } +}; + +/** + * Get a LayoutChangesObserver instance for a given window. This function makes + * sure there is only one instance per window. + * @param {TabActor} tabActor + * @return {LayoutChangesObserver} + */ +var observedWindows = new Map(); +function getLayoutChangesObserver(tabActor) { + let observerData = observedWindows.get(tabActor); + if (observerData) { + observerData.refCounting ++; + return observerData.observer; + } + + let obs = new LayoutChangesObserver(tabActor); + observedWindows.set(tabActor, { + observer: obs, + // counting references allows to stop the observer when no tabActor owns an + // instance. + refCounting: 1 + }); + obs.start(); + return obs; +} +exports.getLayoutChangesObserver = getLayoutChangesObserver; + +/** + * Release a LayoutChangesObserver instance that was retrieved by + * getLayoutChangesObserver. This is required to ensure the tabActor reference + * is removed and the observer is eventually stopped and destroyed. + * @param {TabActor} tabActor + */ +function releaseLayoutChangesObserver(tabActor) { + let observerData = observedWindows.get(tabActor); + if (!observerData) { + return; + } + + observerData.refCounting --; + if (!observerData.refCounting) { + observerData.observer.destroy(); + observedWindows.delete(tabActor); + } +} +exports.releaseLayoutChangesObserver = releaseLayoutChangesObserver; + +/** + * Reports any reflow that occurs in the tabActor's docshells. + * @extends Observable + * @param {TabActor} tabActor + * @param {Function} callback Executed everytime a reflow occurs + */ +function ReflowObserver(tabActor, callback) { + Observable.call(this, tabActor, callback); +} + +ReflowObserver.prototype = Heritage.extend(Observable.prototype, { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIReflowObserver, + Ci.nsISupportsWeakReference]), + + _startListeners: function (windows) { + for (let window of windows) { + let docshell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + docshell.addWeakReflowObserver(this); + } + }, + + _stopListeners: function (windows) { + for (let window of windows) { + try { + let docshell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + docshell.removeWeakReflowObserver(this); + } catch (e) { + // Corner cases where a global has already been freed may happen, in + // which case, no need to remove the observer. + } + } + }, + + reflow: function (start, end) { + this.notifyCallback(start, end, false); + }, + + reflowInterruptible: function (start, end) { + this.notifyCallback(start, end, true); + } +}); + +/** + * Reports window resize events on the tabActor's windows. + * @extends Observable + * @param {TabActor} tabActor + * @param {Function} callback Executed everytime a resize occurs + */ +function WindowResizeObserver(tabActor, callback) { + Observable.call(this, tabActor, callback); + this.onResize = this.onResize.bind(this); +} + +WindowResizeObserver.prototype = Heritage.extend(Observable.prototype, { + _startListeners: function () { + this.listenerTarget.addEventListener("resize", this.onResize); + }, + + _stopListeners: function () { + this.listenerTarget.removeEventListener("resize", this.onResize); + }, + + onResize: function () { + this.notifyCallback(); + }, + + get listenerTarget() { + // For the rootActor, return its window. + if (this.tabActor.isRootActor) { + return this.tabActor.window; + } + + // Otherwise, get the tabActor's chromeEventHandler. + return this.tabActor.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell) + .chromeEventHandler; + } +}); |