mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-02-26 04:09:50 +00:00

You let me know if this seems appealing to you :) Differential Revision: https://phabricator.services.mozilla.com/D175382
462 lines
15 KiB
JavaScript
462 lines
15 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";
|
|
|
|
const {
|
|
FrontClassWithSpec,
|
|
types,
|
|
registerFront,
|
|
} = require("resource://devtools/shared/protocol.js");
|
|
const { walkerSpec } = require("resource://devtools/shared/specs/walker.js");
|
|
const {
|
|
safeAsyncMethod,
|
|
} = require("resource://devtools/shared/async-utils.js");
|
|
|
|
/**
|
|
* Client side of the DOM walker.
|
|
*/
|
|
class WalkerFront extends FrontClassWithSpec(walkerSpec) {
|
|
constructor(client, targetFront, parentFront) {
|
|
super(client, targetFront, parentFront);
|
|
this._isPicking = false;
|
|
this._orphaned = new Set();
|
|
this._retainedOrphans = new Set();
|
|
|
|
// Set to true if cleanup should be requested after every mutation list.
|
|
this.autoCleanup = true;
|
|
|
|
this._rootNodePromise = new Promise(
|
|
r => (this._rootNodePromiseResolve = r)
|
|
);
|
|
|
|
this._onRootNodeAvailable = this._onRootNodeAvailable.bind(this);
|
|
this._onRootNodeDestroyed = this._onRootNodeDestroyed.bind(this);
|
|
|
|
// pick/cancelPick requests can be triggered while the Walker is being destroyed.
|
|
this.pick = safeAsyncMethod(this.pick.bind(this), () => this.isDestroyed());
|
|
this.cancelPick = safeAsyncMethod(this.cancelPick.bind(this), () =>
|
|
this.isDestroyed()
|
|
);
|
|
|
|
this.before("new-mutations", this.onMutations.bind(this));
|
|
|
|
// Those events will be emitted if watchRootNode was called on the
|
|
// corresponding WalkerActor, which should be handled by the ResourceCommand
|
|
// as long as a consumer is watching for root-node resources.
|
|
// This should be fixed by using watchResources directly from the walker
|
|
// front, either with the ROOT_NODE resource, or with the DOCUMENT_EVENT
|
|
// resource. See Bug 1663973.
|
|
this.on("root-available", this._onRootNodeAvailable);
|
|
this.on("root-destroyed", this._onRootNodeDestroyed);
|
|
}
|
|
|
|
// Update the object given a form representation off the wire.
|
|
form(json) {
|
|
this.actorID = json.actor;
|
|
|
|
// The rootNode property should usually be provided via watchRootNode.
|
|
// However tests are currently using the walker front without explicitly
|
|
// calling watchRootNode, so we keep this assignment as a fallback.
|
|
this.rootNode = types.getType("domnode").read(json.root, this);
|
|
|
|
this.traits = json.traits;
|
|
}
|
|
|
|
/**
|
|
* Clients can use walker.rootNode to get the current root node of the
|
|
* walker, but during a reload the root node might be null. This
|
|
* method returns a promise that will resolve to the root node when it is
|
|
* set.
|
|
*/
|
|
async getRootNode() {
|
|
let rootNode = this.rootNode;
|
|
if (!rootNode) {
|
|
rootNode = await this._rootNodePromise;
|
|
}
|
|
|
|
return rootNode;
|
|
}
|
|
|
|
/**
|
|
* When reading an actor form off the wire, we want to hook it up to its
|
|
* parent or host front. The protocol guarantees that the parent will
|
|
* be seen by the client in either a previous or the current request.
|
|
* So if we've already seen this parent return it, otherwise create
|
|
* a bare-bones stand-in node. The stand-in node will be updated
|
|
* with a real form by the end of the deserialization.
|
|
*/
|
|
ensureDOMNodeFront(id) {
|
|
const front = this.getActorByID(id);
|
|
if (front) {
|
|
return front;
|
|
}
|
|
|
|
return types.getType("domnode").read({ actor: id }, this, "standin");
|
|
}
|
|
|
|
/**
|
|
* See the documentation for WalkerActor.prototype.retainNode for
|
|
* information on retained nodes.
|
|
*
|
|
* From the client's perspective, `retainNode` can fail if the node in
|
|
* question is removed from the ownership tree before the `retainNode`
|
|
* request reaches the server. This can only happen if the client has
|
|
* asked the server to release nodes but hasn't gotten a response
|
|
* yet: Either a `releaseNode` request or a `getMutations` with `cleanup`
|
|
* set is outstanding.
|
|
*
|
|
* If either of those requests is outstanding AND releases the retained
|
|
* node, this request will fail with noSuchActor, but the ownership tree
|
|
* will stay in a consistent state.
|
|
*
|
|
* Because the protocol guarantees that requests will be processed and
|
|
* responses received in the order they were sent, we get the right
|
|
* semantics by setting our local retained flag on the node only AFTER
|
|
* a SUCCESSFUL retainNode call.
|
|
*/
|
|
async retainNode(node) {
|
|
await super.retainNode(node);
|
|
node.retained = true;
|
|
}
|
|
|
|
async unretainNode(node) {
|
|
await super.unretainNode(node);
|
|
node.retained = false;
|
|
if (this._retainedOrphans.has(node)) {
|
|
this._retainedOrphans.delete(node);
|
|
this._releaseFront(node);
|
|
}
|
|
}
|
|
|
|
releaseNode(node, options = {}) {
|
|
// NodeFront.destroy will destroy children in the ownership tree too,
|
|
// mimicking what the server will do here.
|
|
const actorID = node.actorID;
|
|
this._releaseFront(node, !!options.force);
|
|
return super.releaseNode({ actorID });
|
|
}
|
|
|
|
async findInspectingNode() {
|
|
const response = await super.findInspectingNode();
|
|
return response.node;
|
|
}
|
|
|
|
async querySelector(queryNode, selector) {
|
|
const response = await super.querySelector(queryNode, selector);
|
|
return response.node;
|
|
}
|
|
|
|
async getNodeActorFromWindowID(windowID) {
|
|
const response = await super.getNodeActorFromWindowID(windowID);
|
|
return response ? response.node : null;
|
|
}
|
|
|
|
async getNodeActorFromContentDomReference(contentDomReference) {
|
|
const response = await super.getNodeActorFromContentDomReference(
|
|
contentDomReference
|
|
);
|
|
return response ? response.node : null;
|
|
}
|
|
|
|
async getStyleSheetOwnerNode(styleSheetActorID) {
|
|
const response = await super.getStyleSheetOwnerNode(styleSheetActorID);
|
|
return response ? response.node : null;
|
|
}
|
|
|
|
async getNodeFromActor(actorID, path) {
|
|
const response = await super.getNodeFromActor(actorID, path);
|
|
return response ? response.node : null;
|
|
}
|
|
|
|
_releaseFront(node, force) {
|
|
if (node.retained && !force) {
|
|
node.reparent(null);
|
|
this._retainedOrphans.add(node);
|
|
return;
|
|
}
|
|
|
|
if (node.retained) {
|
|
// Forcing a removal.
|
|
this._retainedOrphans.delete(node);
|
|
}
|
|
|
|
// Release any children
|
|
for (const child of node.treeChildren()) {
|
|
this._releaseFront(child, force);
|
|
}
|
|
|
|
// All children will have been removed from the node by this point.
|
|
node.reparent(null);
|
|
node.destroy();
|
|
}
|
|
|
|
/**
|
|
* Get any unprocessed mutation records and process them.
|
|
*/
|
|
// eslint-disable-next-line complexity
|
|
async getMutations(options = {}) {
|
|
const mutations = await super.getMutations(options);
|
|
const emitMutations = [];
|
|
for (const change of mutations) {
|
|
// The target is only an actorID, get the associated front.
|
|
const targetID = change.target;
|
|
const targetFront = this.getActorByID(targetID);
|
|
|
|
if (!targetFront) {
|
|
console.warn(
|
|
"Got a mutation for an unexpected actor: " +
|
|
targetID +
|
|
", please file a bug on bugzilla.mozilla.org!"
|
|
);
|
|
console.trace();
|
|
continue;
|
|
}
|
|
|
|
const emittedMutation = Object.assign(change, { target: targetFront });
|
|
|
|
if (change.type === "childList") {
|
|
// Update the ownership tree according to the mutation record.
|
|
const addedFronts = [];
|
|
const removedFronts = [];
|
|
for (const removed of change.removed) {
|
|
const removedFront = this.getActorByID(removed);
|
|
if (!removedFront) {
|
|
console.error(
|
|
"Got a removal of an actor we didn't know about: " + removed
|
|
);
|
|
continue;
|
|
}
|
|
// Remove from the ownership tree
|
|
removedFront.reparent(null);
|
|
|
|
// This node is orphaned unless we get it in the 'added' list
|
|
// eventually.
|
|
this._orphaned.add(removedFront);
|
|
removedFronts.push(removedFront);
|
|
}
|
|
for (const added of change.added) {
|
|
const addedFront = this.getActorByID(added);
|
|
if (!addedFront) {
|
|
console.error(
|
|
"Got an addition of an actor we didn't know " + "about: " + added
|
|
);
|
|
continue;
|
|
}
|
|
addedFront.reparent(targetFront);
|
|
|
|
// The actor is reconnected to the ownership tree, unorphan
|
|
// it.
|
|
this._orphaned.delete(addedFront);
|
|
addedFronts.push(addedFront);
|
|
}
|
|
|
|
// Before passing to users, replace the added and removed actor
|
|
// ids with front in the mutation record.
|
|
emittedMutation.added = addedFronts;
|
|
emittedMutation.removed = removedFronts;
|
|
|
|
// If this is coming from a DOM mutation, the actor's numChildren
|
|
// was passed in. Otherwise, it is simulated from a frame load or
|
|
// unload, so don't change the front's form.
|
|
if ("numChildren" in change) {
|
|
targetFront._form.numChildren = change.numChildren;
|
|
}
|
|
} else if (change.type === "shadowRootAttached") {
|
|
targetFront._form.isShadowHost = true;
|
|
} else if (change.type === "customElementDefined") {
|
|
targetFront._form.customElementLocation = change.customElementLocation;
|
|
} else if (change.type === "unretained") {
|
|
// Retained orphans were force-released without the intervention of
|
|
// client (probably a navigated frame).
|
|
for (const released of change.nodes) {
|
|
const releasedFront = this.getActorByID(released);
|
|
this._retainedOrphans.delete(released);
|
|
this._releaseFront(releasedFront, true);
|
|
}
|
|
} else {
|
|
targetFront.updateMutation(change);
|
|
}
|
|
|
|
// Update the inlineTextChild property of the target for a selected list of
|
|
// mutation types.
|
|
if (
|
|
change.type === "inlineTextChild" ||
|
|
change.type === "childList" ||
|
|
change.type === "shadowRootAttached"
|
|
) {
|
|
if (change.inlineTextChild) {
|
|
targetFront.inlineTextChild = types
|
|
.getType("domnode")
|
|
.read(change.inlineTextChild, this);
|
|
} else {
|
|
targetFront.inlineTextChild = undefined;
|
|
}
|
|
}
|
|
|
|
emitMutations.push(emittedMutation);
|
|
}
|
|
|
|
if (options.cleanup) {
|
|
for (const node of this._orphaned) {
|
|
// This will move retained nodes to this._retainedOrphans.
|
|
this._releaseFront(node);
|
|
}
|
|
this._orphaned = new Set();
|
|
}
|
|
|
|
this.emit("mutations", emitMutations);
|
|
}
|
|
|
|
/**
|
|
* Handle the `new-mutations` notification by fetching the
|
|
* available mutation records.
|
|
*/
|
|
onMutations() {
|
|
// Fetch and process the mutations.
|
|
this.getMutations({ cleanup: this.autoCleanup }).catch(() => {});
|
|
}
|
|
|
|
isLocal() {
|
|
return !!this.conn._transport._serverConnection;
|
|
}
|
|
|
|
async removeNode(node) {
|
|
const previousSibling = await this.previousSibling(node);
|
|
const nextSibling = await super.removeNode(node);
|
|
return {
|
|
previousSibling,
|
|
nextSibling,
|
|
};
|
|
}
|
|
|
|
async children(node, options) {
|
|
if (!node.useChildTargetToFetchChildren) {
|
|
return super.children(node, options);
|
|
}
|
|
const target = await node.connectToFrame();
|
|
|
|
// We had several issues in the past where `connectToFrame` was returning the same
|
|
// target as the owner document one, which led to the inspector being broken.
|
|
// Ultimately, we shouldn't get to this point (fix should happen in connectToFrame or
|
|
// on the server, e.g. for Bug 1752342), but at least this will serve as a safe guard
|
|
// so we don't freeze/crash the inspector.
|
|
if (
|
|
target == this.targetFront &&
|
|
Services.prefs.getBoolPref(
|
|
"devtools.testing.bypass-walker-children-iframe-guard",
|
|
false
|
|
) !== true
|
|
) {
|
|
console.warn("connectToFrame returned an unexpected target");
|
|
return {
|
|
nodes: [],
|
|
hasFirst: true,
|
|
hasLast: true,
|
|
};
|
|
}
|
|
|
|
const walker = (await target.getFront("inspector")).walker;
|
|
|
|
// Finally retrieve the NodeFront of the remote frame's document
|
|
const documentNode = await walker.getRootNode();
|
|
|
|
// Force reparenting through the remote frame boundary.
|
|
documentNode.reparent(node);
|
|
|
|
// And return the same kind of response `walker.children` returns
|
|
return {
|
|
nodes: [documentNode],
|
|
hasFirst: true,
|
|
hasLast: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ensure that the RootNode of this Walker has the right parent NodeFront.
|
|
*
|
|
* This method does nothing if we are on the top level target's WalkerFront,
|
|
* as the RootNode won't have any parent.
|
|
*
|
|
* Otherwise, if we are in an iframe's WalkerFront, we would expect the parent
|
|
* of the RootNode (i.e. the NodeFront for the document loaded within the iframe)
|
|
* to be the <iframe>'s NodeFront. Because of fission, the two NodeFront may refer
|
|
* to DOM Element running in distinct processes and so the NodeFront comes from
|
|
* two distinct Targets and two distinct WalkerFront.
|
|
* This is why we need this manual "reparent" code to do the glue between the
|
|
* two documents.
|
|
*/
|
|
async reparentRemoteFrame() {
|
|
const parentTarget = await this.targetFront.getParentTarget();
|
|
if (!parentTarget) {
|
|
return;
|
|
}
|
|
// Don't reparent if we are on the top target
|
|
if (parentTarget == this.targetFront) {
|
|
return;
|
|
}
|
|
// Get the NodeFront for the embedder element
|
|
// i.e. the <iframe> element which is hosting the document that
|
|
const parentWalker = (await parentTarget.getFront("inspector")).walker;
|
|
// As this <iframe> most likely runs in another process, we have to get it through the parent
|
|
// target's WalkerFront.
|
|
const parentNode = (
|
|
await parentWalker.getEmbedderElement(this.targetFront.browsingContextID)
|
|
).node;
|
|
|
|
// Finally, set this embedder element's node front as the
|
|
const documentNode = await this.getRootNode();
|
|
documentNode.reparent(parentNode);
|
|
}
|
|
|
|
_onRootNodeAvailable(rootNode) {
|
|
if (rootNode.isTopLevelDocument) {
|
|
this.rootNode = rootNode;
|
|
this._rootNodePromiseResolve(this.rootNode);
|
|
}
|
|
}
|
|
|
|
_onRootNodeDestroyed(rootNode) {
|
|
if (rootNode.isTopLevelDocument) {
|
|
this._rootNodePromise = new Promise(
|
|
r => (this._rootNodePromiseResolve = r)
|
|
);
|
|
this.rootNode = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the element picker on the debuggee target.
|
|
* @param {Boolean} doFocus - Optionally focus the content area once the picker is
|
|
* activated.
|
|
*/
|
|
pick(doFocus) {
|
|
if (this._isPicking) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
this._isPicking = true;
|
|
|
|
return super.pick(
|
|
doFocus,
|
|
this.targetFront.commands.descriptorFront.isLocalTab
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stop the element picker.
|
|
*/
|
|
cancelPick() {
|
|
if (!this._isPicking) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
this._isPicking = false;
|
|
return super.cancelPick();
|
|
}
|
|
}
|
|
|
|
exports.WalkerFront = WalkerFront;
|
|
registerFront(WalkerFront);
|