walker.js (15367B)
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 const { 8 FrontClassWithSpec, 9 types, 10 registerFront, 11 } = require("resource://devtools/shared/protocol.js"); 12 const { walkerSpec } = require("resource://devtools/shared/specs/walker.js"); 13 const { 14 safeAsyncMethod, 15 } = require("resource://devtools/shared/async-utils.js"); 16 17 /** 18 * Client side of the DOM walker. 19 */ 20 class WalkerFront extends FrontClassWithSpec(walkerSpec) { 21 constructor(client, targetFront, parentFront) { 22 super(client, targetFront, parentFront); 23 this._isPicking = false; 24 this._orphaned = new Set(); 25 this._retainedOrphans = new Set(); 26 27 // Set to true if cleanup should be requested after every mutation list. 28 this.autoCleanup = true; 29 30 this._rootNodePromise = new Promise( 31 r => (this._rootNodePromiseResolve = r) 32 ); 33 34 this._onRootNodeAvailable = this._onRootNodeAvailable.bind(this); 35 this._onRootNodeDestroyed = this._onRootNodeDestroyed.bind(this); 36 37 // pick/cancelPick requests can be triggered while the Walker is being destroyed. 38 this.pick = safeAsyncMethod(this.pick.bind(this), () => this.isDestroyed()); 39 this.cancelPick = safeAsyncMethod(this.cancelPick.bind(this), () => 40 this.isDestroyed() 41 ); 42 43 this.before("new-mutations", this.onMutations.bind(this)); 44 45 // Those events will be emitted if watchRootNode was called on the 46 // corresponding WalkerActor, which should be handled by the ResourceCommand 47 // as long as a consumer is watching for root-node resources. 48 // This should be fixed by using watchResources directly from the walker 49 // front, either with the ROOT_NODE resource, or with the DOCUMENT_EVENT 50 // resource. See Bug 1663973. 51 this.on("root-available", this._onRootNodeAvailable); 52 this.on("root-destroyed", this._onRootNodeDestroyed); 53 } 54 55 // Update the object given a form representation off the wire. 56 form(json) { 57 this.actorID = json.actor; 58 59 // The rootNode property should usually be provided via watchRootNode. 60 // However tests are currently using the walker front without explicitly 61 // calling watchRootNode, so we keep this assignment as a fallback. 62 this.rootNode = types.getType("domnode").read(json.root, this); 63 64 // Bug 1861328: boolean set to true when color scheme can't be changed (happens when `privacy.resistFingerprinting` is set to true) 65 this.rfpCSSColorScheme = json.rfpCSSColorScheme; 66 67 this.traits = json.traits; 68 } 69 70 /** 71 * Clients can use walker.rootNode to get the current root node of the 72 * walker, but during a reload the root node might be null. This 73 * method returns a promise that will resolve to the root node when it is 74 * set. 75 */ 76 async getRootNode() { 77 let rootNode = this.rootNode; 78 if (!rootNode) { 79 rootNode = await this._rootNodePromise; 80 } 81 82 return rootNode; 83 } 84 85 /** 86 * When reading an actor form off the wire, we want to hook it up to its 87 * parent or host front. The protocol guarantees that the parent will 88 * be seen by the client in either a previous or the current request. 89 * So if we've already seen this parent return it, otherwise create 90 * a bare-bones stand-in node. The stand-in node will be updated 91 * with a real form by the end of the deserialization. 92 */ 93 ensureDOMNodeFront(id) { 94 const front = this.getActorByID(id); 95 if (front) { 96 return front; 97 } 98 99 return types.getType("domnode").read({ actor: id }, this, "standin"); 100 } 101 102 /** 103 * See the documentation for WalkerActor.prototype.retainNode for 104 * information on retained nodes. 105 * 106 * From the client's perspective, `retainNode` can fail if the node in 107 * question is removed from the ownership tree before the `retainNode` 108 * request reaches the server. This can only happen if the client has 109 * asked the server to release nodes but hasn't gotten a response 110 * yet: Either a `releaseNode` request or a `getMutations` with `cleanup` 111 * set is outstanding. 112 * 113 * If either of those requests is outstanding AND releases the retained 114 * node, this request will fail with noSuchActor, but the ownership tree 115 * will stay in a consistent state. 116 * 117 * Because the protocol guarantees that requests will be processed and 118 * responses received in the order they were sent, we get the right 119 * semantics by setting our local retained flag on the node only AFTER 120 * a SUCCESSFUL retainNode call. 121 */ 122 async retainNode(node) { 123 await super.retainNode(node); 124 node.retained = true; 125 } 126 127 async unretainNode(node) { 128 await super.unretainNode(node); 129 node.retained = false; 130 if (this._retainedOrphans.has(node)) { 131 this._retainedOrphans.delete(node); 132 this._releaseFront(node); 133 } 134 } 135 136 releaseNode(node, options = {}) { 137 // NodeFront.destroy will destroy children in the ownership tree too, 138 // mimicking what the server will do here. 139 const actorID = node.actorID; 140 this._releaseFront(node, !!options.force); 141 return super.releaseNode({ actorID }); 142 } 143 144 async findInspectingNode() { 145 const response = await super.findInspectingNode(); 146 return response.node; 147 } 148 149 async querySelector(queryNode, selector) { 150 const response = await super.querySelector(queryNode, selector); 151 return response.node; 152 } 153 154 async getIdrefNode(queryNode, id) { 155 const response = await super.getIdrefNode(queryNode, id); 156 return response.node; 157 } 158 159 async getNodeActorFromWindowID(windowID) { 160 const response = await super.getNodeActorFromWindowID(windowID); 161 return response ? response.node : null; 162 } 163 164 async getNodeActorFromContentDomReference(contentDomReference) { 165 const response = await super.getNodeActorFromContentDomReference( 166 contentDomReference 167 ); 168 return response ? response.node : null; 169 } 170 171 async getStyleSheetOwnerNode(styleSheetActorID) { 172 const response = await super.getStyleSheetOwnerNode(styleSheetActorID); 173 return response ? response.node : null; 174 } 175 176 async getNodeFromActor(actorID, path) { 177 const response = await super.getNodeFromActor(actorID, path); 178 return response ? response.node : null; 179 } 180 181 _releaseFront(node, force) { 182 if (node.retained && !force) { 183 node.reparent(null); 184 this._retainedOrphans.add(node); 185 return; 186 } 187 188 if (node.retained) { 189 // Forcing a removal. 190 this._retainedOrphans.delete(node); 191 } 192 193 // Release any children 194 for (const child of node.treeChildren()) { 195 this._releaseFront(child, force); 196 } 197 198 // All children will have been removed from the node by this point. 199 node.reparent(null); 200 node.destroy(); 201 } 202 203 /** 204 * Get any unprocessed mutation records and process them. 205 */ 206 // eslint-disable-next-line complexity 207 async getMutations(options = {}) { 208 const mutations = await super.getMutations(options); 209 const emitMutations = []; 210 for (const change of mutations) { 211 // The target is only an actorID, get the associated front. 212 const targetID = change.target; 213 const targetFront = this.getActorByID(targetID); 214 215 if (!targetFront) { 216 console.warn( 217 "Got a mutation for an unexpected actor: " + 218 targetID + 219 ", please file a bug on bugzilla.mozilla.org!" 220 ); 221 console.trace(); 222 continue; 223 } 224 225 const emittedMutation = Object.assign(change, { target: targetFront }); 226 227 if (change.type === "childList") { 228 // Update the ownership tree according to the mutation record. 229 const addedFronts = []; 230 const removedFronts = []; 231 for (const removed of change.removed) { 232 const removedFront = this.getActorByID(removed); 233 if (!removedFront) { 234 console.error( 235 "Got a removal of an actor we didn't know about: " + removed 236 ); 237 continue; 238 } 239 // Remove from the ownership tree 240 removedFront.reparent(null); 241 242 // This node is orphaned unless we get it in the 'added' list 243 // eventually. 244 this._orphaned.add(removedFront); 245 removedFronts.push(removedFront); 246 } 247 for (const added of change.added) { 248 const addedFront = this.getActorByID(added); 249 if (!addedFront) { 250 console.error( 251 "Got an addition of an actor we didn't know " + "about: " + added 252 ); 253 continue; 254 } 255 addedFront.reparent(targetFront); 256 257 // The actor is reconnected to the ownership tree, unorphan 258 // it. 259 this._orphaned.delete(addedFront); 260 addedFronts.push(addedFront); 261 } 262 263 // Before passing to users, replace the added and removed actor 264 // ids with front in the mutation record. 265 emittedMutation.added = addedFronts; 266 emittedMutation.removed = removedFronts; 267 268 // If this is coming from a DOM mutation, the actor's numChildren 269 // was passed in. Otherwise, it is simulated from a frame load or 270 // unload, so don't change the front's form. 271 if ("numChildren" in change) { 272 targetFront._form.numChildren = change.numChildren; 273 } 274 } else if (change.type === "shadowRootAttached") { 275 targetFront._form.isShadowHost = true; 276 } else if (change.type === "customElementDefined") { 277 targetFront._form.customElementLocation = change.customElementLocation; 278 } else if (change.type === "unretained") { 279 // Retained orphans were force-released without the intervention of 280 // client (probably a navigated frame). 281 for (const released of change.nodes) { 282 const releasedFront = this.getActorByID(released); 283 this._retainedOrphans.delete(released); 284 this._releaseFront(releasedFront, true); 285 } 286 } else { 287 targetFront.updateMutation(change); 288 } 289 290 // Update the inlineTextChild property of the target for a selected list of 291 // mutation types. 292 if ( 293 change.type === "inlineTextChild" || 294 change.type === "childList" || 295 change.type === "shadowRootAttached" 296 ) { 297 if (change.inlineTextChild) { 298 targetFront.inlineTextChild = types 299 .getType("domnode") 300 .read(change.inlineTextChild, this); 301 } else { 302 targetFront.inlineTextChild = undefined; 303 } 304 } 305 306 emitMutations.push(emittedMutation); 307 } 308 309 if (options.cleanup) { 310 for (const node of this._orphaned) { 311 // This will move retained nodes to this._retainedOrphans. 312 this._releaseFront(node); 313 } 314 this._orphaned = new Set(); 315 } 316 317 this.emit("mutations", emitMutations); 318 } 319 320 /** 321 * Handle the `new-mutations` notification by fetching the 322 * available mutation records. 323 */ 324 onMutations() { 325 // Fetch and process the mutations. 326 this.getMutations({ cleanup: this.autoCleanup }).catch(() => {}); 327 } 328 329 isLocal() { 330 return !!this.conn._transport._serverConnection; 331 } 332 333 async removeNode(node) { 334 const previousSibling = await this.previousSibling(node); 335 const nextSibling = await super.removeNode(node); 336 return { 337 previousSibling, 338 nextSibling, 339 }; 340 } 341 342 async children(node, options) { 343 if (!node.useChildTargetToFetchChildren) { 344 return super.children(node, options); 345 } 346 const target = await node.connectToFrame(); 347 348 // We had several issues in the past where `connectToFrame` was returning the same 349 // target as the owner document one, which led to the inspector being broken. 350 // Ultimately, we shouldn't get to this point (fix should happen in connectToFrame or 351 // on the server, e.g. for Bug 1752342), but at least this will serve as a safe guard 352 // so we don't freeze/crash the inspector. 353 if ( 354 target == this.targetFront && 355 Services.prefs.getBoolPref( 356 "devtools.testing.bypass-walker-children-iframe-guard", 357 false 358 ) !== true 359 ) { 360 console.warn("connectToFrame returned an unexpected target"); 361 return { 362 nodes: [], 363 hasFirst: true, 364 hasLast: true, 365 }; 366 } 367 368 const walker = (await target.getFront("inspector")).walker; 369 370 // Finally retrieve the NodeFront of the remote frame's document 371 const documentNode = await walker.getRootNode(); 372 373 // Force reparenting through the remote frame boundary. 374 documentNode.reparent(node); 375 376 // And return the same kind of response `walker.children` returns 377 return { 378 nodes: [documentNode], 379 hasFirst: true, 380 hasLast: true, 381 }; 382 } 383 384 /** 385 * Ensure that the RootNode of this Walker has the right parent NodeFront. 386 * 387 * This method does nothing if we are on the top level target's WalkerFront, 388 * as the RootNode won't have any parent. 389 * 390 * Otherwise, if we are in an iframe's WalkerFront, we would expect the parent 391 * of the RootNode (i.e. the NodeFront for the document loaded within the iframe) 392 * to be the <iframe>'s NodeFront. Because of fission, the two NodeFront may refer 393 * to DOM Element running in distinct processes and so the NodeFront comes from 394 * two distinct Targets and two distinct WalkerFront. 395 * This is why we need this manual "reparent" code to do the glue between the 396 * two documents. 397 */ 398 async reparentRemoteFrame() { 399 const parentTarget = await this.targetFront.getParentTarget(); 400 if (!parentTarget) { 401 return; 402 } 403 // Don't reparent if we are on the top target 404 if (parentTarget == this.targetFront) { 405 return; 406 } 407 // Get the NodeFront for the embedder element 408 // i.e. the <iframe> element which is hosting the document that 409 const parentWalker = (await parentTarget.getFront("inspector")).walker; 410 // As this <iframe> most likely runs in another process, we have to get it through the parent 411 // target's WalkerFront. 412 const parentNode = ( 413 await parentWalker.getEmbedderElement(this.targetFront.browsingContextID) 414 ).node; 415 416 // Finally, set this embedder element's node front as the 417 const documentNode = await this.getRootNode(); 418 documentNode.reparent(parentNode); 419 } 420 421 _onRootNodeAvailable(rootNode) { 422 if (rootNode.isTopLevelDocument) { 423 this.rootNode = rootNode; 424 this._rootNodePromiseResolve(this.rootNode); 425 } 426 } 427 428 _onRootNodeDestroyed(rootNode) { 429 if (rootNode.isTopLevelDocument) { 430 this._rootNodePromise = new Promise( 431 r => (this._rootNodePromiseResolve = r) 432 ); 433 this.rootNode = null; 434 } 435 } 436 437 /** 438 * Start the element picker on the debuggee target. 439 * 440 * @param {boolean} doFocus - Optionally focus the content area once the picker is 441 * activated. 442 */ 443 pick(doFocus) { 444 if (this._isPicking) { 445 return Promise.resolve(); 446 } 447 448 this._isPicking = true; 449 450 return super.pick( 451 doFocus, 452 this.targetFront.commands.descriptorFront.isLocalTab 453 ); 454 } 455 456 /** 457 * Stop the element picker. 458 */ 459 cancelPick() { 460 if (!this._isPicking) { 461 return Promise.resolve(); 462 } 463 464 this._isPicking = false; 465 return super.cancelPick(); 466 } 467 } 468 469 registerFront(WalkerFront);