gecko-dev/devtools/server/actors/reflow.js

516 строки
14 KiB
JavaScript

/* 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 targetActor'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 ChromeUtils = require("ChromeUtils");
const protocol = require("devtools/shared/protocol");
const EventEmitter = require("devtools/shared/event-emitter");
const { reflowSpec } = require("devtools/shared/specs/reflow");
/**
* The reflow actor tracks reflows and emits events about them.
*/
exports.ReflowActor = protocol.ActorClassWithSpec(reflowSpec, {
initialize: function(conn, targetActor) {
protocol.Actor.prototype.initialize.call(this, conn);
this.targetActor = targetActor;
this._onReflow = this._onReflow.bind(this);
this.observer = getLayoutChangesObserver(targetActor);
this._isStarted = false;
},
destroy: function() {
this.stop();
releaseLayoutChangesObserver(this.targetActor);
this.observer = null;
this.targetActor = 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(reflows) {
if (this._isStarted) {
this.emit("reflows", reflows);
}
},
});
/**
* Base class for all sorts of observers that need to listen to events on the
* targetActor's windows.
* @param {BrowsingContextTargetActor} targetActor
* @param {Function} callback Executed everytime the observer observes something
*/
function Observable(targetActor, callback) {
this.targetActor = targetActor;
this.callback = callback;
this._onWindowReady = this._onWindowReady.bind(this);
this._onWindowDestroyed = this._onWindowDestroyed.bind(this);
this.targetActor.on("window-ready", this._onWindowReady);
this.targetActor.on("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();
this.targetActor.off("window-ready", this._onWindowReady);
this.targetActor.off("window-destroyed", this._onWindowDestroyed);
this.callback = null;
this.targetActor = null;
},
/**
* Start observing whatever it is this observer is supposed to observe
*/
start: function() {
if (this.isObserving) {
return;
}
this.isObserving = true;
this._startListeners(this.targetActor.windows);
},
/**
* Stop observing
*/
stop: function() {
if (!this.isObserving) {
return;
}
this.isObserving = false;
if (this.targetActor.attached && this.targetActor.docShell) {
// It's only worth stopping if the targetActor is still attached
this._stopListeners(this.targetActor.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; // eslint-disable-line
}
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(targetActor)`
* and `releaseLayoutChangesObserver(targetActor)`
* 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 {BrowsingContextTargetActor} targetActor
*/
function LayoutChangesObserver(targetActor) {
this.targetActor = targetActor;
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.targetActor, this._onReflow);
this.resizeObserver = new WindowResizeObserver(
this.targetActor,
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.targetActor = 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 targetActor has been detached (may happen
// during shutdown)
if (!this.targetActor || !this.targetActor.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 {BrowsingContextTargetActor} targetActor
* @return {LayoutChangesObserver}
*/
var observedWindows = new Map();
function getLayoutChangesObserver(targetActor) {
const observerData = observedWindows.get(targetActor);
if (observerData) {
observerData.refCounting++;
return observerData.observer;
}
const obs = new LayoutChangesObserver(targetActor);
observedWindows.set(targetActor, {
observer: obs,
// counting references allows to stop the observer when no targetActor 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 targetActor reference
* is removed and the observer is eventually stopped and destroyed.
* @param {BrowsingContextTargetActor} targetActor
*/
function releaseLayoutChangesObserver(targetActor) {
const observerData = observedWindows.get(targetActor);
if (!observerData) {
return;
}
observerData.refCounting--;
if (!observerData.refCounting) {
observerData.observer.destroy();
observedWindows.delete(targetActor);
}
}
exports.releaseLayoutChangesObserver = releaseLayoutChangesObserver;
/**
* Reports any reflow that occurs in the targetActor's docshells.
* @extends Observable
* @param {BrowsingContextTargetActor} targetActor
* @param {Function} callback Executed everytime a reflow occurs
*/
class ReflowObserver extends Observable {
constructor(targetActor, callback) {
super(targetActor, callback);
}
_startListeners(windows) {
for (const window of windows) {
window.docShell.addWeakReflowObserver(this);
}
}
_stopListeners(windows) {
for (const window of windows) {
try {
window.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(start, end) {
this.notifyCallback(start, end, false);
}
reflowInterruptible(start, end) {
this.notifyCallback(start, end, true);
}
}
ReflowObserver.prototype.QueryInterface = ChromeUtils.generateQI([
Ci.nsIReflowObserver,
Ci.nsISupportsWeakReference,
]);
/**
* Reports window resize events on the targetActor's windows.
* @extends Observable
* @param {BrowsingContextTargetActor} targetActor
* @param {Function} callback Executed everytime a resize occurs
*/
class WindowResizeObserver extends Observable {
constructor(targetActor, callback) {
super(targetActor, callback);
this.onNavigate = this.onNavigate.bind(this);
this.onResize = this.onResize.bind(this);
this.targetActor.on("navigate", this.onNavigate);
}
_startListeners() {
this.listenerTarget.addEventListener("resize", this.onResize);
}
_stopListeners() {
this.listenerTarget.removeEventListener("resize", this.onResize);
}
onNavigate() {
if (this.isObserving) {
this._stopListeners();
this._startListeners();
}
}
onResize() {
this.notifyCallback();
}
destroy() {
this.targetActor.off("navigate", this.onNavigate);
}
get listenerTarget() {
// For the rootActor, return its window.
if (this.targetActor.isRootActor) {
return this.targetActor.window;
}
// Otherwise, get the targetActor's chromeEventHandler.
return this.targetActor.chromeEventHandler;
}
}