reflow.js (14252B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 /** 8 * About the types of objects in this file: 9 * 10 * - ReflowActor: the actor class used for protocol purposes. 11 * Mostly empty, just gets an instance of LayoutChangesObserver and forwards 12 * its "reflows" events to clients. 13 * 14 * - LayoutChangesObserver: extends Observable and uses the ReflowObserver, to 15 * track reflows on the page. 16 * Used by the LayoutActor, but is also exported on the module, so can be used 17 * by any other actor that needs it. 18 * 19 * - Observable: A utility parent class, meant at being extended by classes that 20 * need a to observe something on the targetActor's windows. 21 * 22 * - Dedicated observers: There's only one of them for now: ReflowObserver which 23 * listens to reflow events via the docshell, 24 * These dedicated classes are used by the LayoutChangesObserver. 25 */ 26 27 const { Actor } = require("resource://devtools/shared/protocol.js"); 28 const { reflowSpec } = require("resource://devtools/shared/specs/reflow.js"); 29 30 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 31 32 /** 33 * The reflow actor tracks reflows and emits events about them. 34 */ 35 exports.ReflowActor = class ReflowActor extends Actor { 36 constructor(conn, targetActor) { 37 super(conn, reflowSpec); 38 39 this.targetActor = targetActor; 40 this._onReflow = this._onReflow.bind(this); 41 this.observer = getLayoutChangesObserver(targetActor); 42 this._isStarted = false; 43 } 44 45 destroy() { 46 this.stop(); 47 releaseLayoutChangesObserver(this.targetActor); 48 this.observer = null; 49 this.targetActor = null; 50 51 super.destroy(); 52 } 53 54 /** 55 * Start tracking reflows and sending events to clients about them. 56 * This is a oneway method, do not expect a response and it won't return a 57 * promise. 58 */ 59 start() { 60 if (!this._isStarted) { 61 this.observer.on("reflows", this._onReflow); 62 this._isStarted = true; 63 } 64 } 65 66 /** 67 * Stop tracking reflows and sending events to clients about them. 68 * This is a oneway method, do not expect a response and it won't return a 69 * promise. 70 */ 71 stop() { 72 if (this._isStarted) { 73 this.observer.off("reflows", this._onReflow); 74 this._isStarted = false; 75 } 76 } 77 78 _onReflow(reflows) { 79 if (this._isStarted) { 80 this.emit("reflows", reflows); 81 } 82 } 83 }; 84 85 /** 86 * Base class for all sorts of observers that need to listen to events on the 87 * targetActor's windows. 88 * 89 * @param {WindowGlobalTargetActor} targetActor 90 * @param {Function} callback Executed everytime the observer observes something 91 */ 92 class Observable { 93 constructor(targetActor, callback) { 94 this.targetActor = targetActor; 95 this.callback = callback; 96 97 this._onWindowReady = this._onWindowReady.bind(this); 98 this._onWindowDestroyed = this._onWindowDestroyed.bind(this); 99 100 this.targetActor.on("window-ready", this._onWindowReady); 101 this.targetActor.on("window-destroyed", this._onWindowDestroyed); 102 } 103 104 /** 105 * Is the observer currently observing 106 */ 107 isObserving = false; 108 109 /** 110 * Stop observing and detroy this observer instance 111 */ 112 destroy() { 113 if (this.isDestroyed) { 114 return; 115 } 116 this.isDestroyed = true; 117 118 this.stop(); 119 120 this.targetActor.off("window-ready", this._onWindowReady); 121 this.targetActor.off("window-destroyed", this._onWindowDestroyed); 122 123 this.callback = null; 124 this.targetActor = null; 125 } 126 127 /** 128 * Start observing whatever it is this observer is supposed to observe 129 */ 130 start() { 131 if (this.isObserving) { 132 return; 133 } 134 this.isObserving = true; 135 136 this._startListeners(this.targetActor.windows); 137 } 138 139 /** 140 * Stop observing 141 */ 142 stop() { 143 if (!this.isObserving) { 144 return; 145 } 146 this.isObserving = false; 147 148 if (!this.targetActor.isDestroyed() && this.targetActor.docShell) { 149 // It's only worth stopping if the targetActor is still active 150 this._stopListeners(this.targetActor.windows); 151 } 152 } 153 154 _onWindowReady({ window }) { 155 if (this.isObserving) { 156 this._startListeners([window]); 157 } 158 } 159 160 _onWindowDestroyed({ window }) { 161 if (this.isObserving) { 162 this._stopListeners([window]); 163 } 164 } 165 166 _startListeners() { 167 // To be implemented by sub-classes. 168 } 169 170 _stopListeners() { 171 // To be implemented by sub-classes. 172 } 173 174 /** 175 * To be called by sub-classes when something has been observed 176 */ 177 notifyCallback(...args) { 178 this.isObserving && this.callback && this.callback.apply(null, args); 179 } 180 } 181 182 /** 183 * The LayouChangesObserver will observe reflows as soon as it is started. 184 * Some devtools actors may cause reflows and it may be wanted to "hide" these 185 * reflows from the LayouChangesObserver consumers. 186 * If this is the case, such actors should require this module and use this 187 * global function to turn the ignore mode on and off temporarily. 188 * 189 * Note that if a node is provided, it will be used to force a sync reflow to 190 * make sure all reflows which occurred before switching the mode on or off are 191 * either observed or ignored depending on the current mode. 192 * 193 * @param {boolean} ignore 194 * @param {DOMNode} syncReflowNode The node to use to force a sync reflow 195 */ 196 var gIgnoreLayoutChanges = false; 197 exports.setIgnoreLayoutChanges = function (ignore, syncReflowNode) { 198 if (syncReflowNode) { 199 let forceSyncReflow = syncReflowNode.offsetWidth; // eslint-disable-line 200 } 201 gIgnoreLayoutChanges = ignore; 202 }; 203 204 class LayoutChangesObserver extends EventEmitter { 205 /** 206 * The LayoutChangesObserver class is instantiated only once per given tab 207 * and is used to track reflows and dom and style changes in that tab. 208 * The LayoutActor uses this class to send reflow events to its clients. 209 * 210 * This class isn't exported on the module because it shouldn't be instantiated 211 * to avoid creating several instances per tabs. 212 * Use `getLayoutChangesObserver(targetActor)` 213 * and `releaseLayoutChangesObserver(targetActor)` 214 * which are exported to get and release instances. 215 * 216 * The observer loops every EVENT_BATCHING_DELAY ms and checks if layout changes 217 * have happened since the last loop iteration. If there are, it sends the 218 * corresponding events: 219 * 220 * - "reflows", with an array of all the reflows that occured, 221 * - "resizes", with an array of all the resizes that occured, 222 * 223 * @param {WindowGlobalTargetActor} targetActor 224 */ 225 constructor(targetActor) { 226 super(); 227 228 this.targetActor = targetActor; 229 230 this._startEventLoop = this._startEventLoop.bind(this); 231 this._onReflow = this._onReflow.bind(this); 232 this._onResize = this._onResize.bind(this); 233 234 // Creating the various observers we're going to need 235 // For now, just the reflow observer, but later we can add markupMutation, 236 // styleSheetChanges and styleRuleChanges 237 this.reflowObserver = new ReflowObserver(this.targetActor, this._onReflow); 238 this.resizeObserver = new WindowResizeObserver( 239 this.targetActor, 240 this._onResize 241 ); 242 } 243 244 /** 245 * How long does this observer waits before emitting batched events. 246 * The lower the value, the more event packets will be sent to clients, 247 * potentially impacting performance. 248 * The higher the value, the more time we'll wait, this is better for 249 * performance but has an effect on how soon changes are shown in the toolbox. 250 */ 251 EVENT_BATCHING_DELAY = 300; 252 253 /** 254 * Destroying this instance of LayoutChangesObserver will stop the batched 255 * events from being sent. 256 */ 257 destroy() { 258 this.isObserving = false; 259 260 this.reflowObserver.destroy(); 261 this.reflows = null; 262 263 this.resizeObserver.destroy(); 264 this.hasResized = false; 265 266 this.targetActor = null; 267 } 268 269 start() { 270 if (this.isObserving) { 271 return; 272 } 273 this.isObserving = true; 274 275 this.reflows = []; 276 this.hasResized = false; 277 278 this._startEventLoop(); 279 280 this.reflowObserver.start(); 281 this.resizeObserver.start(); 282 } 283 284 stop() { 285 if (!this.isObserving) { 286 return; 287 } 288 this.isObserving = false; 289 290 this._stopEventLoop(); 291 292 this.reflows = []; 293 this.hasResized = false; 294 295 this.reflowObserver.stop(); 296 this.resizeObserver.stop(); 297 } 298 299 /** 300 * Start the event loop, which regularly checks if there are any observer 301 * events to be sent as batched events 302 * Calls itself in a loop. 303 */ 304 _startEventLoop() { 305 // Avoid emitting events if the targetActor has been detached (may happen 306 // during shutdown) 307 if (!this.targetActor || this.targetActor.isDestroyed()) { 308 return; 309 } 310 311 // Send any reflows we have 312 if (this.reflows && this.reflows.length) { 313 this.emit("reflows", this.reflows); 314 this.reflows = []; 315 } 316 317 // Send any resizes we have 318 if (this.hasResized) { 319 this.emit("resize"); 320 this.hasResized = false; 321 } 322 323 this.eventLoopTimer = this._setTimeout( 324 this._startEventLoop, 325 this.EVENT_BATCHING_DELAY 326 ); 327 } 328 329 _stopEventLoop() { 330 this._clearTimeout(this.eventLoopTimer); 331 } 332 333 // Exposing set/clearTimeout here to let tests override them if needed 334 _setTimeout(cb, ms) { 335 return setTimeout(cb, ms); 336 } 337 _clearTimeout(t) { 338 return clearTimeout(t); 339 } 340 341 /** 342 * Executed whenever a reflow is observed. Only stacks the reflow in the 343 * reflows array. 344 * The EVENT_BATCHING_DELAY loop will take care of it later. 345 * 346 * @param {number} start When the reflow started 347 * @param {number} end When the reflow ended 348 * @param {boolean} isInterruptible 349 */ 350 _onReflow(start, end, isInterruptible) { 351 if (gIgnoreLayoutChanges) { 352 return; 353 } 354 355 // XXX: when/if bug 997092 gets fixed, we will be able to know which 356 // elements have been reflowed, which would be a nice thing to add here. 357 this.reflows.push({ 358 start, 359 end, 360 isInterruptible, 361 }); 362 } 363 364 /** 365 * Executed whenever a resize is observed. Only store a flag saying that a 366 * resize occured. 367 * The EVENT_BATCHING_DELAY loop will take care of it later. 368 */ 369 _onResize() { 370 if (gIgnoreLayoutChanges) { 371 return; 372 } 373 374 this.hasResized = true; 375 } 376 } 377 exports.LayoutChangesObserver = LayoutChangesObserver; 378 379 /** 380 * Get a LayoutChangesObserver instance for a given window. This function makes 381 * sure there is only one instance per window. 382 * 383 * @param {WindowGlobalTargetActor} targetActor 384 * @return {LayoutChangesObserver} 385 */ 386 var observedWindows = new Map(); 387 function getLayoutChangesObserver(targetActor) { 388 const observerData = observedWindows.get(targetActor); 389 if (observerData) { 390 observerData.refCounting++; 391 return observerData.observer; 392 } 393 394 const obs = new LayoutChangesObserver(targetActor); 395 observedWindows.set(targetActor, { 396 observer: obs, 397 // counting references allows to stop the observer when no targetActor owns an 398 // instance. 399 refCounting: 1, 400 }); 401 obs.start(); 402 return obs; 403 } 404 exports.getLayoutChangesObserver = getLayoutChangesObserver; 405 406 /** 407 * Release a LayoutChangesObserver instance that was retrieved by 408 * getLayoutChangesObserver. This is required to ensure the targetActor reference 409 * is removed and the observer is eventually stopped and destroyed. 410 * 411 * @param {WindowGlobalTargetActor} targetActor 412 */ 413 function releaseLayoutChangesObserver(targetActor) { 414 const observerData = observedWindows.get(targetActor); 415 if (!observerData) { 416 return; 417 } 418 419 observerData.refCounting--; 420 if (!observerData.refCounting) { 421 observerData.observer.destroy(); 422 observedWindows.delete(targetActor); 423 } 424 } 425 exports.releaseLayoutChangesObserver = releaseLayoutChangesObserver; 426 427 /** 428 * Reports any reflow that occurs in the targetActor's docshells. 429 * 430 * @augments Observable 431 * @param {WindowGlobalTargetActor} targetActor 432 * @param {Function} callback Executed everytime a reflow occurs 433 */ 434 class ReflowObserver extends Observable { 435 constructor(targetActor, callback) { 436 super(targetActor, callback); 437 } 438 439 _startListeners(windows) { 440 for (const window of windows) { 441 window.docShell.addWeakReflowObserver(this); 442 } 443 } 444 445 _stopListeners(windows) { 446 for (const window of windows) { 447 try { 448 window.docShell.removeWeakReflowObserver(this); 449 } catch (e) { 450 // Corner cases where a global has already been freed may happen, in 451 // which case, no need to remove the observer. 452 } 453 } 454 } 455 456 reflow(start, end) { 457 this.notifyCallback(start, end, false); 458 } 459 460 reflowInterruptible(start, end) { 461 this.notifyCallback(start, end, true); 462 } 463 } 464 465 ReflowObserver.prototype.QueryInterface = ChromeUtils.generateQI([ 466 "nsIReflowObserver", 467 "nsISupportsWeakReference", 468 ]); 469 470 /** 471 * Reports window resize events on the targetActor's windows. 472 * 473 * @augments Observable 474 * @param {WindowGlobalTargetActor} targetActor 475 * @param {Function} callback Executed everytime a resize occurs 476 */ 477 class WindowResizeObserver extends Observable { 478 constructor(targetActor, callback) { 479 super(targetActor, callback); 480 481 this.onNavigate = this.onNavigate.bind(this); 482 this.onResize = this.onResize.bind(this); 483 484 this.targetActor.on("navigate", this.onNavigate); 485 } 486 487 _startListeners() { 488 this._abortController = new AbortController(); 489 this.listenerTarget.addEventListener("resize", this.onResize, { 490 signal: this._abortController.signal, 491 }); 492 } 493 494 _stopListeners() { 495 if (this._abortController) { 496 this._abortController.abort(); 497 this._abortController = null; 498 } 499 } 500 501 onNavigate() { 502 if (this.isObserving) { 503 this._stopListeners(); 504 this._startListeners(); 505 } 506 } 507 508 onResize() { 509 this.notifyCallback(); 510 } 511 512 destroy() { 513 if (this.targetActor) { 514 this.targetActor.off("navigate", this.onNavigate); 515 } 516 this._stopListeners(); 517 super.destroy(); 518 } 519 520 get listenerTarget() { 521 // For the rootActor, return its window. 522 if (this.targetActor.isRootActor) { 523 return this.targetActor.window; 524 } 525 526 // Otherwise, get the targetActor's chromeEventHandler. 527 return this.targetActor.chromeEventHandler; 528 } 529 }