/* 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; } });