summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/inspector/walker.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/inspector/walker.js')
-rw-r--r--devtools/server/actors/inspector/walker.js2764
1 files changed, 2764 insertions, 0 deletions
diff --git a/devtools/server/actors/inspector/walker.js b/devtools/server/actors/inspector/walker.js
new file mode 100644
index 0000000000..f8da1385e9
--- /dev/null
+++ b/devtools/server/actors/inspector/walker.js
@@ -0,0 +1,2764 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { walkerSpec } = require("resource://devtools/shared/specs/walker.js");
+
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+const {
+ EXCLUDED_LISTENER,
+} = require("resource://devtools/server/actors/inspector/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ "nodeFilterConstants",
+ "resource://devtools/shared/dom-node-filter-constants.js"
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "getFrameElement",
+ "isAfterPseudoElement",
+ "isBeforePseudoElement",
+ "isDirectShadowHostChild",
+ "isMarkerPseudoElement",
+ "isFrameBlockedByCSP",
+ "isFrameWithChildTarget",
+ "isShadowHost",
+ "isShadowRoot",
+ "loadSheet",
+ ],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "throttle",
+ "resource://devtools/shared/throttle.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "allAnonymousContentTreeWalkerFilter",
+ "findGridParentContainerForNode",
+ "isNodeDead",
+ "noAnonymousContentTreeWalkerFilter",
+ "nodeDocument",
+ "standardTreeWalkerFilter",
+ ],
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "CustomElementWatcher",
+ "resource://devtools/server/actors/inspector/custom-element-watcher.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["DocumentWalker", "SKIP_TO_SIBLING"],
+ "resource://devtools/server/actors/inspector/document-walker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["NodeActor", "NodeListActor"],
+ "resource://devtools/server/actors/inspector/node.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "NodePicker",
+ "resource://devtools/server/actors/inspector/node-picker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "LayoutActor",
+ "resource://devtools/server/actors/layout.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["getLayoutChangesObserver", "releaseLayoutChangesObserver"],
+ "resource://devtools/server/actors/reflow.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WalkerSearch",
+ "resource://devtools/server/actors/utils/walker-search.js",
+ true
+);
+
+// ContentDOMReference requires ChromeUtils, which isn't available in worker context.
+const lazy = {};
+if (!isWorker) {
+ loader.lazyGetter(
+ lazy,
+ "ContentDOMReference",
+ () =>
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ContentDOMReference.sys.mjs",
+ {
+ // ContentDOMReference needs to be retrieved from the shared global
+ // since it is a shared singleton.
+ loadInDevToolsLoader: false,
+ }
+ ).ContentDOMReference
+ );
+}
+
+loader.lazyServiceGetter(
+ this,
+ "eventListenerService",
+ "@mozilla.org/eventlistenerservice;1",
+ "nsIEventListenerService"
+);
+
+// Minimum delay between two "new-mutations" events.
+const MUTATIONS_THROTTLING_DELAY = 100;
+// List of mutation types that should -not- be throttled.
+const IMMEDIATE_MUTATIONS = ["pseudoClassLock"];
+
+const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__";
+
+// The possible completions to a ':' with added score to give certain values
+// some preference.
+const PSEUDO_SELECTORS = [
+ [":active", 1],
+ [":hover", 1],
+ [":focus", 1],
+ [":visited", 0],
+ [":link", 0],
+ [":first-letter", 0],
+ [":first-child", 2],
+ [":before", 2],
+ [":after", 2],
+ [":lang(", 0],
+ [":not(", 3],
+ [":first-of-type", 0],
+ [":last-of-type", 0],
+ [":only-of-type", 0],
+ [":only-child", 2],
+ [":nth-child(", 3],
+ [":nth-last-child(", 0],
+ [":nth-of-type(", 0],
+ [":nth-last-of-type(", 0],
+ [":last-child", 2],
+ [":root", 0],
+ [":empty", 0],
+ [":target", 0],
+ [":enabled", 0],
+ [":disabled", 0],
+ [":checked", 1],
+ ["::selection", 0],
+ ["::marker", 0],
+];
+
+const HELPER_SHEET =
+ "data:text/css;charset=utf-8," +
+ encodeURIComponent(`
+ .__fx-devtools-hide-shortcut__ {
+ visibility: hidden !important;
+ }
+`);
+
+/**
+ * We only send nodeValue up to a certain size by default. This stuff
+ * controls that size.
+ */
+exports.DEFAULT_VALUE_SUMMARY_LENGTH = 50;
+var gValueSummaryLength = exports.DEFAULT_VALUE_SUMMARY_LENGTH;
+
+exports.getValueSummaryLength = function () {
+ return gValueSummaryLength;
+};
+
+exports.setValueSummaryLength = function (val) {
+ gValueSummaryLength = val;
+};
+
+/**
+ * Server side of the DOM walker.
+ */
+class WalkerActor extends Actor {
+ /**
+ * Create the WalkerActor
+ * @param {DevToolsServerConnection} conn
+ * The server connection.
+ * @param {TargetActor} targetActor
+ * The top-level Actor for this tab.
+ * @param {Object} options
+ * - {Boolean} showAllAnonymousContent: Show all native anonymous content
+ */
+ constructor(conn, targetActor, options) {
+ super(conn, walkerSpec);
+ this.targetActor = targetActor;
+ this.rootWin = targetActor.window;
+ this.rootDoc = this.rootWin.document;
+
+ // Map of already created node actors, keyed by their corresponding DOMNode.
+ this._nodeActorsMap = new Map();
+
+ this._pendingMutations = [];
+ this._activePseudoClassLocks = new Set();
+ this._mutationBreakpoints = new WeakMap();
+ this._anonParents = new WeakMap();
+ this.customElementWatcher = new CustomElementWatcher(
+ targetActor.chromeEventHandler
+ );
+
+ // In this map, the key-value pairs are the overflow causing elements and their
+ // respective ancestor scrollable node actor.
+ this.overflowCausingElementsMap = new Map();
+
+ this.showAllAnonymousContent = options.showAllAnonymousContent;
+
+ this.walkerSearch = new WalkerSearch(this);
+
+ // Nodes which have been removed from the client's known
+ // ownership tree are considered "orphaned", and stored in
+ // this set.
+ this._orphaned = new Set();
+
+ // The client can tell the walker that it is interested in a node
+ // even when it is orphaned with the `retainNode` method. This
+ // list contains orphaned nodes that were so retained.
+ this._retainedOrphans = new Set();
+
+ this.onSubtreeModified = this.onSubtreeModified.bind(this);
+ this.onSubtreeModified[EXCLUDED_LISTENER] = true;
+ this.onNodeRemoved = this.onNodeRemoved.bind(this);
+ this.onNodeRemoved[EXCLUDED_LISTENER] = true;
+ this.onAttributeModified = this.onAttributeModified.bind(this);
+ this.onAttributeModified[EXCLUDED_LISTENER] = true;
+
+ this.onMutations = this.onMutations.bind(this);
+ this.onSlotchange = this.onSlotchange.bind(this);
+ this.onShadowrootattached = this.onShadowrootattached.bind(this);
+ this.onAnonymousrootcreated = this.onAnonymousrootcreated.bind(this);
+ this.onAnonymousrootremoved = this.onAnonymousrootremoved.bind(this);
+ this.onFrameLoad = this.onFrameLoad.bind(this);
+ this.onFrameUnload = this.onFrameUnload.bind(this);
+ this.onCustomElementDefined = this.onCustomElementDefined.bind(this);
+ this._throttledEmitNewMutations = throttle(
+ this._emitNewMutations.bind(this),
+ MUTATIONS_THROTTLING_DELAY
+ );
+
+ targetActor.on("will-navigate", this.onFrameUnload);
+ targetActor.on("window-ready", this.onFrameLoad);
+
+ this.customElementWatcher.on(
+ "element-defined",
+ this.onCustomElementDefined
+ );
+
+ // Keep a reference to the chromeEventHandler for the current targetActor, to make
+ // sure we will be able to remove the listener during the WalkerActor destroy().
+ this.chromeEventHandler = targetActor.chromeEventHandler;
+ // shadowrootattached is a chrome-only event. We enable it below.
+ this.chromeEventHandler.addEventListener(
+ "shadowrootattached",
+ this.onShadowrootattached
+ );
+ // anonymousrootcreated is a chrome-only event. We enable it below.
+ this.chromeEventHandler.addEventListener(
+ "anonymousrootcreated",
+ this.onAnonymousrootcreated
+ );
+ this.chromeEventHandler.addEventListener(
+ "anonymousrootremoved",
+ this.onAnonymousrootremoved
+ );
+ for (const { document } of this.targetActor.windows) {
+ document.devToolsAnonymousAndShadowEventsEnabled = true;
+ }
+
+ // Ensure that the root document node actor is ready and
+ // managed.
+ this.rootNode = this.document();
+
+ this.layoutChangeObserver = getLayoutChangesObserver(this.targetActor);
+ this._onReflows = this._onReflows.bind(this);
+ this.layoutChangeObserver.on("reflows", this._onReflows);
+ this._onResize = this._onResize.bind(this);
+ this.layoutChangeObserver.on("resize", this._onResize);
+
+ this._onEventListenerChange = this._onEventListenerChange.bind(this);
+ eventListenerService.addListenerChangeListener(this._onEventListenerChange);
+ }
+
+ get nodePicker() {
+ if (!this._nodePicker) {
+ this._nodePicker = new NodePicker(this, this.targetActor);
+ }
+
+ return this._nodePicker;
+ }
+
+ watchRootNode() {
+ if (this.rootNode) {
+ this.emit("root-available", this.rootNode);
+ }
+ }
+
+ /**
+ * Callback for eventListenerService.addListenerChangeListener
+ * @param nsISimpleEnumerator changesEnum
+ * enumerator of nsIEventListenerChange
+ */
+ _onEventListenerChange(changesEnum) {
+ for (const current of changesEnum.enumerate(Ci.nsIEventListenerChange)) {
+ const target = current.target;
+
+ if (this._nodeActorsMap.has(target)) {
+ const actor = this.getNode(target);
+ const mutation = {
+ type: "events",
+ target: actor.actorID,
+ hasEventListeners: actor._hasEventListeners,
+ };
+ this.queueMutation(mutation);
+ }
+ }
+ }
+
+ // Returns the JSON representation of this object over the wire.
+ form() {
+ return {
+ actor: this.actorID,
+ root: this.rootNode.form(),
+ traits: {},
+ };
+ }
+
+ toString() {
+ return "[WalkerActor " + this.actorID + "]";
+ }
+
+ getDocumentWalker(node, skipTo) {
+ // Allow native anon content (like <video> controls) if preffed on
+ const filter = this.showAllAnonymousContent
+ ? allAnonymousContentTreeWalkerFilter
+ : standardTreeWalkerFilter;
+
+ return new DocumentWalker(node, this.rootWin, {
+ filter,
+ skipTo,
+ showAnonymousContent: true,
+ });
+ }
+
+ destroy() {
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+ super.destroy();
+ try {
+ this.clearPseudoClassLocks();
+ this._activePseudoClassLocks = null;
+
+ this.overflowCausingElementsMap.clear();
+ this.overflowCausingElementsMap = null;
+
+ this._hoveredNode = null;
+ this.rootWin = null;
+ this.rootDoc = null;
+ this.rootNode = null;
+ this.layoutHelpers = null;
+ this._orphaned = null;
+ this._retainedOrphans = null;
+ this._nodeActorsMap = null;
+
+ this.targetActor.off("will-navigate", this.onFrameUnload);
+ this.targetActor.off("window-ready", this.onFrameLoad);
+ this.customElementWatcher.off(
+ "element-defined",
+ this.onCustomElementDefined
+ );
+
+ this.chromeEventHandler.removeEventListener(
+ "shadowrootattached",
+ this.onShadowrootattached
+ );
+ this.chromeEventHandler.removeEventListener(
+ "anonymousrootcreated",
+ this.onAnonymousrootcreated
+ );
+ this.chromeEventHandler.removeEventListener(
+ "anonymousrootremoved",
+ this.onAnonymousrootremoved
+ );
+
+ // This attribute is just for devtools, so we can unset once we're done.
+ for (const { document } of this.targetActor.windows) {
+ document.devToolsAnonymousAndShadowEventsEnabled = false;
+ }
+
+ this.onFrameLoad = null;
+ this.onFrameUnload = null;
+
+ this.customElementWatcher.destroy();
+ this.customElementWatcher = null;
+
+ this.walkerSearch.destroy();
+
+ if (this._nodePicker) {
+ this._nodePicker.destroy();
+ this._nodePicker = null;
+ }
+
+ this.layoutChangeObserver.off("reflows", this._onReflows);
+ this.layoutChangeObserver.off("resize", this._onResize);
+ this.layoutChangeObserver = null;
+ releaseLayoutChangesObserver(this.targetActor);
+
+ eventListenerService.removeListenerChangeListener(
+ this._onEventListenerChange
+ );
+
+ this.onMutations = null;
+
+ this.layoutActor = null;
+ this.targetActor = null;
+ this.chromeEventHandler = null;
+
+ this.emit("destroyed");
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ release() {}
+
+ unmanage(actor) {
+ if (actor instanceof NodeActor) {
+ if (
+ this._activePseudoClassLocks &&
+ this._activePseudoClassLocks.has(actor)
+ ) {
+ this.clearPseudoClassLocks(actor);
+ }
+
+ this.customElementWatcher.unmanageNode(actor);
+
+ this._nodeActorsMap.delete(actor.rawNode);
+ }
+ super.unmanage(actor);
+ }
+
+ /**
+ * Determine if the walker has come across this DOM node before.
+ * @param {DOMNode} rawNode
+ * @return {Boolean}
+ */
+ hasNode(rawNode) {
+ return this._nodeActorsMap.has(rawNode);
+ }
+
+ /**
+ * If the walker has come across this DOM node before, then get the
+ * corresponding node actor.
+ * @param {DOMNode} rawNode
+ * @return {NodeActor}
+ */
+ getNode(rawNode) {
+ return this._nodeActorsMap.get(rawNode);
+ }
+
+ /**
+ * Internal helper that will either retrieve the existing NodeActor for the
+ * provided node or create the actor on the fly if it doesn't exist.
+ * This method should only be called when we are sure that the node should be
+ * known by the client and that the parent node is already known.
+ *
+ * Otherwise prefer `getNode` to only retrieve known actors or `attachElement`
+ * to create node actors recursively.
+ *
+ * @param {DOMNode} node
+ * The node for which we want to create or get an actor
+ * @return {NodeActor} The corresponding NodeActor
+ */
+ _getOrCreateNodeActor(node) {
+ let actor = this.getNode(node);
+ if (actor) {
+ return actor;
+ }
+
+ actor = new NodeActor(this, node);
+
+ // Add the node actor as a child of this walker actor, assigning
+ // it an actorID.
+ this.manage(actor);
+ this._nodeActorsMap.set(node, actor);
+
+ if (node.nodeType === Node.DOCUMENT_NODE) {
+ actor.watchDocument(node, this.onMutations);
+ }
+
+ if (isShadowRoot(actor.rawNode)) {
+ actor.watchDocument(node.ownerDocument, this.onMutations);
+ actor.watchSlotchange(this.onSlotchange);
+ }
+
+ this.customElementWatcher.manageNode(actor);
+
+ return actor;
+ }
+
+ /**
+ * When a custom element is defined, send a customElementDefined mutation for all the
+ * NodeActors using this tag name.
+ */
+ onCustomElementDefined({ name, actors }) {
+ actors.forEach(actor =>
+ this.queueMutation({
+ target: actor.actorID,
+ type: "customElementDefined",
+ customElementLocation: actor.getCustomElementLocation(),
+ })
+ );
+ }
+
+ _onReflows(reflows) {
+ // Going through the nodes the walker knows about, see which ones have had their
+ // containerType, display, scrollable or overflow state changed and send events if any.
+ const containerTypeChanges = [];
+ const displayTypeChanges = [];
+ const scrollableStateChanges = [];
+
+ const currentOverflowCausingElementsMap = new Map();
+
+ for (const [node, actor] of this._nodeActorsMap) {
+ if (Cu.isDeadWrapper(node)) {
+ continue;
+ }
+
+ const displayType = actor.displayType;
+ const isDisplayed = actor.isDisplayed;
+
+ if (
+ displayType !== actor.currentDisplayType ||
+ isDisplayed !== actor.wasDisplayed
+ ) {
+ displayTypeChanges.push(actor);
+
+ // Updating the original value
+ actor.currentDisplayType = displayType;
+ actor.wasDisplayed = isDisplayed;
+ }
+
+ const isScrollable = actor.isScrollable;
+ if (isScrollable !== actor.wasScrollable) {
+ scrollableStateChanges.push(actor);
+ actor.wasScrollable = isScrollable;
+ }
+
+ if (isScrollable) {
+ this.updateOverflowCausingElements(
+ actor,
+ currentOverflowCausingElementsMap
+ );
+ }
+
+ const containerType = actor.containerType;
+ if (containerType !== actor.currentContainerType) {
+ containerTypeChanges.push(actor);
+ actor.currentContainerType = containerType;
+ }
+ }
+
+ // Get the NodeActor for each node in the symmetric difference of
+ // currentOverflowCausingElementsMap and this.overflowCausingElementsMap
+ const overflowStateChanges = [...currentOverflowCausingElementsMap.keys()]
+ .filter(node => !this.overflowCausingElementsMap.has(node))
+ .concat(
+ [...this.overflowCausingElementsMap.keys()].filter(
+ node => !currentOverflowCausingElementsMap.has(node)
+ )
+ )
+ .filter(node => this.hasNode(node))
+ .map(node => this.getNode(node));
+
+ this.overflowCausingElementsMap = currentOverflowCausingElementsMap;
+
+ if (overflowStateChanges.length) {
+ this.emit("overflow-change", overflowStateChanges);
+ }
+
+ if (displayTypeChanges.length) {
+ this.emit("display-change", displayTypeChanges);
+ }
+
+ if (scrollableStateChanges.length) {
+ this.emit("scrollable-change", scrollableStateChanges);
+ }
+
+ if (containerTypeChanges.length) {
+ this.emit("container-type-change", containerTypeChanges);
+ }
+ }
+
+ /**
+ * When the browser window gets resized, relay the event to the front.
+ */
+ _onResize() {
+ this.emit("resize");
+ }
+
+ /**
+ * Ensures that the node is attached and it can be accessed from the root.
+ *
+ * @param {(Node|NodeActor)} nodes The nodes
+ * @return {Object} An object compatible with the disconnectedNode type.
+ */
+ attachElement(node) {
+ const { nodes, newParents } = this.attachElements([node]);
+ return {
+ node: nodes[0],
+ newParents,
+ };
+ }
+
+ /**
+ * Ensures that the nodes are attached and they can be accessed from the root.
+ *
+ * @param {(Node[]|NodeActor[])} nodes The nodes
+ * @return {Object} An object compatible with the disconnectedNodeArray type.
+ */
+ attachElements(nodes) {
+ const nodeActors = [];
+ const newParents = new Set();
+ for (let node of nodes) {
+ if (!(node instanceof NodeActor)) {
+ // If an anonymous node was passed in and we aren't supposed to know
+ // about it, then use the closest ancestor.
+ if (!this.showAllAnonymousContent) {
+ while (
+ node &&
+ standardTreeWalkerFilter(node) != nodeFilterConstants.FILTER_ACCEPT
+ ) {
+ node = this.rawParentNode(node);
+ }
+ if (!node) {
+ continue;
+ }
+ }
+
+ node = this._getOrCreateNodeActor(node);
+ }
+
+ this.ensurePathToRoot(node, newParents);
+ // If nodes may be an array of raw nodes, we're sure to only have
+ // NodeActors with the following array.
+ nodeActors.push(node);
+ }
+
+ return {
+ nodes: nodeActors,
+ newParents: [...newParents],
+ };
+ }
+
+ /**
+ * Return the document node that contains the given node,
+ * or the root node if no node is specified.
+ * @param NodeActor node
+ * The node whose document is needed, or null to
+ * return the root.
+ */
+ document(node) {
+ const doc = isNodeDead(node) ? this.rootDoc : nodeDocument(node.rawNode);
+ return this._getOrCreateNodeActor(doc);
+ }
+
+ /**
+ * Return the documentElement for the document containing the
+ * given node.
+ * @param NodeActor node
+ * The node whose documentElement is requested, or null
+ * to use the root document.
+ */
+ documentElement(node) {
+ const elt = isNodeDead(node)
+ ? this.rootDoc.documentElement
+ : nodeDocument(node.rawNode).documentElement;
+ return this._getOrCreateNodeActor(elt);
+ }
+
+ parentNode(node) {
+ const parent = this.rawParentNode(node);
+ if (parent) {
+ return this._getOrCreateNodeActor(parent);
+ }
+
+ return null;
+ }
+
+ rawParentNode(node) {
+ const rawNode = node instanceof NodeActor ? node.rawNode : node;
+ if (rawNode == this.rootDoc) {
+ return null;
+ }
+ return InspectorUtils.getParentForNode(rawNode, /* anonymous = */ true);
+ }
+
+ /**
+ * If the given NodeActor only has a single text node as a child with a text
+ * content small enough to be inlined, return that child's NodeActor.
+ *
+ * @param NodeActor node
+ */
+ inlineTextChild({ rawNode }) {
+ // Quick checks to prevent creating a new walker if possible.
+ if (
+ isMarkerPseudoElement(rawNode) ||
+ isBeforePseudoElement(rawNode) ||
+ isAfterPseudoElement(rawNode) ||
+ isShadowHost(rawNode) ||
+ rawNode.nodeType != Node.ELEMENT_NODE ||
+ !!rawNode.children.length ||
+ isFrameWithChildTarget(this.targetActor, rawNode) ||
+ isFrameBlockedByCSP(rawNode)
+ ) {
+ return undefined;
+ }
+
+ const children = this._rawChildren(rawNode, /* includeAssigned = */ true);
+ const firstChild = children[0];
+
+ // Bail out if:
+ // - more than one child
+ // - unique child is not a text node
+ // - unique child is a text node, but is too long to be inlined
+ // - we are a slot -> these are always represented on their own lines with
+ // a link to the original node.
+ // - we are a flex item -> these are always shown on their own lines so they can be
+ // selected by the flexbox inspector.
+ const isAssignedToSlot =
+ firstChild &&
+ rawNode.nodeName === "SLOT" &&
+ isDirectShadowHostChild(firstChild);
+
+ const isFlexItem = !!firstChild?.parentFlexElement;
+
+ if (
+ !firstChild ||
+ children.length > 1 ||
+ firstChild.nodeType !== Node.TEXT_NODE ||
+ firstChild.nodeValue.length > gValueSummaryLength ||
+ isAssignedToSlot ||
+ isFlexItem
+ ) {
+ return undefined;
+ }
+
+ return this._getOrCreateNodeActor(firstChild);
+ }
+
+ /**
+ * Mark a node as 'retained'.
+ *
+ * A retained node is not released when `releaseNode` is called on its
+ * parent, or when a parent is released with the `cleanup` option to
+ * `getMutations`.
+ *
+ * When a retained node's parent is released, a retained mode is added to
+ * the walker's "retained orphans" list.
+ *
+ * Retained nodes can be deleted by providing the `force` option to
+ * `releaseNode`. They will also be released when their document
+ * has been destroyed.
+ *
+ * Retaining a node makes no promise about its children; They can
+ * still be removed by normal means.
+ */
+ retainNode(node) {
+ node.retained = true;
+ }
+
+ /**
+ * Remove the 'retained' mark from a node. If the node was a
+ * retained orphan, release it.
+ */
+ unretainNode(node) {
+ node.retained = false;
+ if (this._retainedOrphans.has(node)) {
+ this._retainedOrphans.delete(node);
+ this.releaseNode(node);
+ }
+ }
+
+ /**
+ * Release actors for a node and all child nodes.
+ */
+ releaseNode(node, options = {}) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ if (node.retained && !options.force) {
+ this._retainedOrphans.add(node);
+ return;
+ }
+
+ if (node.retained) {
+ // Forcing a retained node to go away.
+ this._retainedOrphans.delete(node);
+ }
+
+ for (const child of this._rawChildren(node.rawNode)) {
+ const childActor = this.getNode(child);
+ if (childActor) {
+ this.releaseNode(childActor, options);
+ }
+ }
+
+ node.destroy();
+ }
+
+ /**
+ * Add any nodes between `node` and the walker's root node that have not
+ * yet been seen by the client.
+ */
+ ensurePathToRoot(node, newParents = new Set()) {
+ if (!node) {
+ return newParents;
+ }
+ let parent = this.rawParentNode(node);
+ while (parent) {
+ let parentActor = this.getNode(parent);
+ if (parentActor) {
+ // This parent did exist, so the client knows about it.
+ return newParents;
+ }
+ // This parent didn't exist, so hasn't been seen by the client yet.
+ parentActor = this._getOrCreateNodeActor(parent);
+ newParents.add(parentActor);
+ parent = this.rawParentNode(parentActor);
+ }
+ return newParents;
+ }
+
+ /**
+ * Return the number of children under the provided NodeActor.
+ *
+ * @param NodeActor node
+ * See JSDoc for children()
+ * @param object options
+ * See JSDoc for children()
+ * @return Number the number of children
+ */
+ countChildren(node, options = {}) {
+ return this._getChildren(node, options).nodes.length;
+ }
+
+ /**
+ * Return children of the given node. By default this method will return
+ * all children of the node, but there are options that can restrict this
+ * to a more manageable subset.
+ *
+ * @param NodeActor node
+ * The node whose children you're curious about.
+ * @param object options
+ * Named options:
+ * `maxNodes`: The set of nodes returned by the method will be no longer
+ * than maxNodes.
+ * `start`: If a node is specified, the list of nodes will start
+ * with the given child. Mutally exclusive with `center`.
+ * `center`: If a node is specified, the given node will be as centered
+ * as possible in the list, given how close to the ends of the child
+ * list it is. Mutually exclusive with `start`.
+ *
+ * @returns an object with three items:
+ * hasFirst: true if the first child of the node is included in the list.
+ * hasLast: true if the last child of the node is included in the list.
+ * nodes: Array of NodeActor representing the nodes returned by the request.
+ */
+ children(node, options = {}) {
+ const { hasFirst, hasLast, nodes } = this._getChildren(node, options);
+ return {
+ hasFirst,
+ hasLast,
+ nodes: nodes.map(n => this._getOrCreateNodeActor(n)),
+ };
+ }
+
+ /**
+ * Returns the raw children of the DOM node, with anon content filtered as needed
+ * @param Node rawNode.
+ * @param boolean includeAssigned
+ * Whether <slot> assigned children should be returned. See
+ * HTMLSlotElement.assignedNodes().
+ * @returns Array<Node> the list of children.
+ */
+ _rawChildren(rawNode, includeAssigned) {
+ const filter = this.showAllAnonymousContent
+ ? allAnonymousContentTreeWalkerFilter
+ : standardTreeWalkerFilter;
+ const ret = [];
+ const children = InspectorUtils.getChildrenForNode(
+ rawNode,
+ /* anonymous = */ true,
+ includeAssigned
+ );
+ for (const child of children) {
+ if (filter(child) == nodeFilterConstants.FILTER_ACCEPT) {
+ ret.push(child);
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Return chidlren of the given node. Contrary to children children(), this method only
+ * returns DOMNodes. Therefore it will not create NodeActor wrappers and will not
+ * update the nodeActors map for the discovered nodes either. This makes this method
+ * safe to call when you are not sure if the discovered nodes will be communicated to
+ * the client.
+ *
+ * @param NodeActor node
+ * See JSDoc for children()
+ * @param object options
+ * See JSDoc for children()
+ * @return an object with three items:
+ * hasFirst: true if the first child of the node is included in the list.
+ * hasLast: true if the last child of the node is included in the list.
+ * nodes: Array of DOMNodes.
+ */
+ // eslint-disable-next-line complexity
+ _getChildren(node, options = {}) {
+ if (isNodeDead(node) || isFrameBlockedByCSP(node.rawNode)) {
+ return { hasFirst: true, hasLast: true, nodes: [] };
+ }
+
+ if (options.center && options.start) {
+ throw Error("Can't specify both 'center' and 'start' options.");
+ }
+
+ let maxNodes = options.maxNodes || -1;
+ if (maxNodes == -1) {
+ maxNodes = Number.MAX_VALUE;
+ }
+
+ let nodes = this._rawChildren(node.rawNode, /* includeAssigned = */ true);
+ let hasFirst = true;
+ let hasLast = true;
+ if (nodes.length > maxNodes) {
+ let startIndex;
+ if (options.center) {
+ const centerIndex = nodes.indexOf(options.center.rawNode);
+ const backwardCount = Math.floor(maxNodes / 2);
+ // If centering would hit the end, just read the last maxNodes nodes.
+ if (centerIndex - backwardCount + maxNodes >= nodes.length) {
+ startIndex = nodes.length - maxNodes;
+ } else {
+ startIndex = Math.max(0, centerIndex - backwardCount);
+ }
+ } else if (options.start) {
+ startIndex = Math.max(0, nodes.indexOf(options.start.rawNode));
+ } else {
+ startIndex = 0;
+ }
+ const endIndex = Math.min(startIndex + maxNodes, nodes.length);
+ hasFirst = startIndex == 0;
+ hasLast = endIndex >= nodes.length;
+ nodes = nodes.slice(startIndex, endIndex);
+ }
+
+ return { hasFirst, hasLast, nodes };
+ }
+
+ /**
+ * Get the next sibling of a given node. Getting nodes one at a time
+ * might be inefficient, be careful.
+ */
+ nextSibling(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ const sibling = walker.nextSibling();
+ return sibling ? this._getOrCreateNodeActor(sibling) : null;
+ }
+
+ /**
+ * Get the previous sibling of a given node. Getting nodes one at a time
+ * might be inefficient, be careful.
+ */
+ previousSibling(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ const sibling = walker.previousSibling();
+ return sibling ? this._getOrCreateNodeActor(sibling) : null;
+ }
+
+ /**
+ * Helper function for the `children` method: Read forward in the sibling
+ * list into an array with `count` items, including the current node.
+ */
+ _readForward(walker, count) {
+ const ret = [];
+
+ let node = walker.currentNode;
+ do {
+ if (!walker.isSkippedNode(node)) {
+ // The walker can be on a node that would be filtered out if it didn't find any
+ // other node to fallback to.
+ ret.push(node);
+ }
+ node = walker.nextSibling();
+ } while (node && --count);
+ return ret;
+ }
+
+ /**
+ * Return the first node in the document that matches the given selector.
+ * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelector
+ *
+ * @param NodeActor baseNode
+ * @param string selector
+ */
+ querySelector(baseNode, selector) {
+ if (isNodeDead(baseNode)) {
+ return {};
+ }
+
+ const node = baseNode.rawNode.querySelector(selector);
+ if (!node) {
+ return {};
+ }
+
+ return this.attachElement(node);
+ }
+
+ /**
+ * Return a NodeListActor with all nodes that match the given selector.
+ * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelectorAll
+ *
+ * @param NodeActor baseNode
+ * @param string selector
+ */
+ querySelectorAll(baseNode, selector) {
+ let nodeList = null;
+
+ try {
+ nodeList = baseNode.rawNode.querySelectorAll(selector);
+ } catch (e) {
+ // Bad selector. Do nothing as the selector can come from a searchbox.
+ }
+
+ return new NodeListActor(this, nodeList);
+ }
+
+ /**
+ * Get a list of nodes that match the given selector in all known frames of
+ * the current content page.
+ * @param {String} selector.
+ * @return {Array}
+ */
+ _multiFrameQuerySelectorAll(selector) {
+ let nodes = [];
+
+ for (const { document } of this.targetActor.windows) {
+ try {
+ nodes = [...nodes, ...document.querySelectorAll(selector)];
+ } catch (e) {
+ // Bad selector. Do nothing as the selector can come from a searchbox.
+ }
+ }
+
+ return nodes;
+ }
+
+ /**
+ * Get a list of nodes that match the given XPath in all known frames of
+ * the current content page.
+ * @param {String} xPath.
+ * @return {Array}
+ */
+ _multiFrameXPath(xPath) {
+ const nodes = [];
+
+ for (const window of this.targetActor.windows) {
+ const document = window.document;
+ try {
+ const result = document.evaluate(
+ xPath,
+ document.documentElement,
+ null,
+ window.XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
+ null
+ );
+
+ for (let i = 0; i < result.snapshotLength; i++) {
+ nodes.push(result.snapshotItem(i));
+ }
+ } catch (e) {
+ // Bad XPath. Do nothing as the XPath can come from a searchbox.
+ }
+ }
+
+ return nodes;
+ }
+
+ /**
+ * Return a NodeListActor with all nodes that match the given XPath in all
+ * frames of the current content page.
+ * @param {String} xPath
+ */
+ multiFrameXPath(xPath) {
+ return new NodeListActor(this, this._multiFrameXPath(xPath));
+ }
+
+ /**
+ * Search the document for a given string.
+ * Results will be searched with the walker-search module (searches through
+ * tag names, attribute names and values, and text contents).
+ *
+ * @returns {searchresult}
+ * - {NodeList} list
+ * - {Array<Object>} metadata. Extra information with indices that
+ * match up with node list.
+ */
+ search(query) {
+ const results = this.walkerSearch.search(query);
+ const nodeList = new NodeListActor(
+ this,
+ results.map(r => r.node)
+ );
+
+ return {
+ list: nodeList,
+ metadata: [],
+ };
+ }
+
+ /**
+ * Returns a list of matching results for CSS selector autocompletion.
+ *
+ * @param string query
+ * The selector query being completed
+ * @param string completing
+ * The exact token being completed out of the query
+ * @param string selectorState
+ * One of "pseudo", "id", "tag", "class", "null"
+ */
+ // eslint-disable-next-line complexity
+ getSuggestionsForQuery(query, completing, selectorState) {
+ const sugs = {
+ classes: new Map(),
+ tags: new Map(),
+ ids: new Map(),
+ };
+ let result = [];
+ let nodes = null;
+ // Filtering and sorting the results so that protocol transfer is miminal.
+ switch (selectorState) {
+ case "pseudo":
+ result = PSEUDO_SELECTORS.filter(item => {
+ return item[0].startsWith(":" + completing);
+ });
+ break;
+
+ case "class":
+ if (!query) {
+ nodes = this._multiFrameQuerySelectorAll("[class]");
+ } else {
+ nodes = this._multiFrameQuerySelectorAll(query);
+ }
+ for (const node of nodes) {
+ for (const className of node.classList) {
+ sugs.classes.set(className, (sugs.classes.get(className) | 0) + 1);
+ }
+ }
+ sugs.classes.delete("");
+ sugs.classes.delete(HIDDEN_CLASS);
+ for (const [className, count] of sugs.classes) {
+ if (className.startsWith(completing)) {
+ result.push(["." + CSS.escape(className), count, selectorState]);
+ }
+ }
+ break;
+
+ case "id":
+ if (!query) {
+ nodes = this._multiFrameQuerySelectorAll("[id]");
+ } else {
+ nodes = this._multiFrameQuerySelectorAll(query);
+ }
+ for (const node of nodes) {
+ sugs.ids.set(node.id, (sugs.ids.get(node.id) | 0) + 1);
+ }
+ for (const [id, count] of sugs.ids) {
+ if (id.startsWith(completing) && id !== "") {
+ result.push(["#" + CSS.escape(id), count, selectorState]);
+ }
+ }
+ break;
+
+ case "tag":
+ if (!query) {
+ nodes = this._multiFrameQuerySelectorAll("*");
+ } else {
+ nodes = this._multiFrameQuerySelectorAll(query);
+ }
+ for (const node of nodes) {
+ const tag = node.localName;
+ sugs.tags.set(tag, (sugs.tags.get(tag) | 0) + 1);
+ }
+ for (const [tag, count] of sugs.tags) {
+ if (new RegExp("^" + completing + ".*", "i").test(tag)) {
+ result.push([tag, count, selectorState]);
+ }
+ }
+
+ // For state 'tag' (no preceding # or .) and when there's no query (i.e.
+ // only one word) then search for the matching classes and ids
+ if (!query) {
+ result = [
+ ...result,
+ ...this.getSuggestionsForQuery(null, completing, "class")
+ .suggestions,
+ ...this.getSuggestionsForQuery(null, completing, "id").suggestions,
+ ];
+ }
+
+ break;
+
+ case "null":
+ nodes = this._multiFrameQuerySelectorAll(query);
+ for (const node of nodes) {
+ sugs.ids.set(node.id, (sugs.ids.get(node.id) | 0) + 1);
+ const tag = node.localName;
+ sugs.tags.set(tag, (sugs.tags.get(tag) | 0) + 1);
+ for (const className of node.classList) {
+ sugs.classes.set(className, (sugs.classes.get(className) | 0) + 1);
+ }
+ }
+ for (const [tag, count] of sugs.tags) {
+ tag && result.push([tag, count]);
+ }
+ for (const [id, count] of sugs.ids) {
+ id && result.push(["#" + id, count]);
+ }
+ sugs.classes.delete("");
+ sugs.classes.delete(HIDDEN_CLASS);
+ for (const [className, count] of sugs.classes) {
+ className && result.push(["." + className, count]);
+ }
+ }
+
+ // Sort by count (desc) and name (asc)
+ result = result.sort((a, b) => {
+ // Computed a sortable string with first the inverted count, then the name
+ let sortA = 10000 - a[1] + a[0];
+ let sortB = 10000 - b[1] + b[0];
+
+ // Prefixing ids, classes and tags, to group results
+ const firstA = a[0].substring(0, 1);
+ const firstB = b[0].substring(0, 1);
+
+ const getSortKeyPrefix = firstLetter => {
+ if (firstLetter === "#") {
+ return "2";
+ }
+ if (firstLetter === ".") {
+ return "1";
+ }
+ return "0";
+ };
+
+ sortA = getSortKeyPrefix(firstA) + sortA;
+ sortB = getSortKeyPrefix(firstB) + sortB;
+
+ // String compare
+ return sortA.localeCompare(sortB);
+ });
+
+ result = result.slice(0, 25);
+
+ return {
+ query,
+ suggestions: result,
+ };
+ }
+
+ /**
+ * Add a pseudo-class lock to a node.
+ *
+ * @param NodeActor node
+ * @param string pseudo
+ * A pseudoclass: ':hover', ':active', ':focus', ':focus-within'
+ * @param options
+ * Options object:
+ * `parents`: True if the pseudo-class should be added
+ * to parent nodes.
+ * `enabled`: False if the pseudo-class should be locked
+ * to 'off'. Defaults to true.
+ *
+ * @returns An empty packet. A "pseudoClassLock" mutation will
+ * be queued for any changed nodes.
+ */
+ addPseudoClassLock(node, pseudo, options = {}) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ // There can be only one node locked per pseudo, so dismiss all existing
+ // ones
+ for (const locked of this._activePseudoClassLocks) {
+ if (InspectorUtils.hasPseudoClassLock(locked.rawNode, pseudo)) {
+ this._removePseudoClassLock(locked, pseudo);
+ }
+ }
+
+ const enabled = options.enabled === undefined || options.enabled;
+ this._addPseudoClassLock(node, pseudo, enabled);
+
+ if (!options.parents) {
+ return;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ let cur;
+ while ((cur = walker.parentNode())) {
+ const curNode = this._getOrCreateNodeActor(cur);
+ this._addPseudoClassLock(curNode, pseudo, enabled);
+ }
+ }
+
+ _queuePseudoClassMutation(node) {
+ this.queueMutation({
+ target: node.actorID,
+ type: "pseudoClassLock",
+ pseudoClassLocks: node.writePseudoClassLocks(),
+ });
+ }
+
+ _addPseudoClassLock(node, pseudo, enabled) {
+ if (node.rawNode.nodeType !== Node.ELEMENT_NODE) {
+ return false;
+ }
+ InspectorUtils.addPseudoClassLock(node.rawNode, pseudo, enabled);
+ this._activePseudoClassLocks.add(node);
+ this._queuePseudoClassMutation(node);
+ return true;
+ }
+
+ hideNode(node) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ loadSheet(node.rawNode.ownerGlobal, HELPER_SHEET);
+ node.rawNode.classList.add(HIDDEN_CLASS);
+ }
+
+ unhideNode(node) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ node.rawNode.classList.remove(HIDDEN_CLASS);
+ }
+
+ /**
+ * Remove a pseudo-class lock from a node.
+ *
+ * @param NodeActor node
+ * @param string pseudo
+ * A pseudoclass: ':hover', ':active', ':focus', ':focus-within'
+ * @param options
+ * Options object:
+ * `parents`: True if the pseudo-class should be removed
+ * from parent nodes.
+ *
+ * @returns An empty response. "pseudoClassLock" mutations
+ * will be emitted for any changed nodes.
+ */
+ removePseudoClassLock(node, pseudo, options = {}) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ this._removePseudoClassLock(node, pseudo);
+
+ // Remove pseudo class for children as we don't want to allow
+ // turning it on for some childs without setting it on some parents
+ for (const locked of this._activePseudoClassLocks) {
+ if (
+ node.rawNode.contains(locked.rawNode) &&
+ InspectorUtils.hasPseudoClassLock(locked.rawNode, pseudo)
+ ) {
+ this._removePseudoClassLock(locked, pseudo);
+ }
+ }
+
+ if (!options.parents) {
+ return;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ let cur;
+ while ((cur = walker.parentNode())) {
+ const curNode = this._getOrCreateNodeActor(cur);
+ this._removePseudoClassLock(curNode, pseudo);
+ }
+ }
+
+ _removePseudoClassLock(node, pseudo) {
+ if (node.rawNode.nodeType != Node.ELEMENT_NODE) {
+ return false;
+ }
+ InspectorUtils.removePseudoClassLock(node.rawNode, pseudo);
+ if (!node.writePseudoClassLocks()) {
+ this._activePseudoClassLocks.delete(node);
+ }
+
+ this._queuePseudoClassMutation(node);
+ return true;
+ }
+
+ /**
+ * Clear all the pseudo-classes on a given node or all nodes.
+ * @param {NodeActor} node Optional node to clear pseudo-classes on
+ */
+ clearPseudoClassLocks(node) {
+ if (node && isNodeDead(node)) {
+ return;
+ }
+
+ if (node) {
+ InspectorUtils.clearPseudoClassLocks(node.rawNode);
+ this._activePseudoClassLocks.delete(node);
+ this._queuePseudoClassMutation(node);
+ } else {
+ for (const locked of this._activePseudoClassLocks) {
+ InspectorUtils.clearPseudoClassLocks(locked.rawNode);
+ this._activePseudoClassLocks.delete(locked);
+ this._queuePseudoClassMutation(locked);
+ }
+ }
+ }
+
+ /**
+ * Get a node's innerHTML property.
+ */
+ innerHTML(node) {
+ let html = "";
+ if (!isNodeDead(node)) {
+ html = node.rawNode.innerHTML;
+ }
+ return new LongStringActor(this.conn, html);
+ }
+
+ /**
+ * Set a node's innerHTML property.
+ *
+ * @param {NodeActor} node The node.
+ * @param {string} value The piece of HTML content.
+ */
+ setInnerHTML(node, value) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ const rawNode = node.rawNode;
+ if (
+ rawNode.nodeType !== rawNode.ownerDocument.ELEMENT_NODE &&
+ rawNode.nodeType !== rawNode.ownerDocument.DOCUMENT_FRAGMENT_NODE
+ ) {
+ throw new Error("Can only change innerHTML to element or fragment nodes");
+ }
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.innerHTML = value;
+ }
+
+ /**
+ * Get a node's outerHTML property.
+ *
+ * @param {NodeActor} node The node.
+ */
+ outerHTML(node) {
+ let outerHTML = "";
+ if (!isNodeDead(node)) {
+ outerHTML = node.rawNode.outerHTML;
+ }
+ return new LongStringActor(this.conn, outerHTML);
+ }
+
+ /**
+ * Set a node's outerHTML property.
+ *
+ * @param {NodeActor} node The node.
+ * @param {string} value The piece of HTML content.
+ */
+ setOuterHTML(node, value) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ const rawNode = node.rawNode;
+ const doc = nodeDocument(rawNode);
+ const win = doc.defaultView;
+ let parser;
+ if (!win) {
+ throw new Error("The window object shouldn't be null");
+ } else {
+ // We create DOMParser under window object because we want a content
+ // DOMParser, which means all the DOM objects created by this DOMParser
+ // will be in the same DocGroup as rawNode.parentNode. Then the newly
+ // created nodes can be adopted into rawNode.parentNode.
+ parser = new win.DOMParser();
+ }
+
+ const mimeType = rawNode.tagName === "svg" ? "image/svg+xml" : "text/html";
+ const parsedDOM = parser.parseFromString(value, mimeType);
+ const parentNode = rawNode.parentNode;
+
+ // Special case for head and body. Setting document.body.outerHTML
+ // creates an extra <head> tag, and document.head.outerHTML creates
+ // an extra <body>. So instead we will call replaceChild with the
+ // parsed DOM, assuming that they aren't trying to set both tags at once.
+ if (rawNode.tagName === "BODY") {
+ if (parsedDOM.head.innerHTML === "") {
+ parentNode.replaceChild(parsedDOM.body, rawNode);
+ } else {
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.outerHTML = value;
+ }
+ } else if (rawNode.tagName === "HEAD") {
+ if (parsedDOM.body.innerHTML === "") {
+ parentNode.replaceChild(parsedDOM.head, rawNode);
+ } else {
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.outerHTML = value;
+ }
+ } else if (node.isDocumentElement()) {
+ // Unable to set outerHTML on the document element. Fall back by
+ // setting attributes manually. Then replace all the child nodes.
+ const finalAttributeModifications = [];
+ const attributeModifications = {};
+ for (const attribute of rawNode.attributes) {
+ attributeModifications[attribute.name] = null;
+ }
+ for (const attribute of parsedDOM.documentElement.attributes) {
+ attributeModifications[attribute.name] = attribute.value;
+ }
+ for (const key in attributeModifications) {
+ finalAttributeModifications.push({
+ attributeName: key,
+ newValue: attributeModifications[key],
+ });
+ }
+ node.modifyAttributes(finalAttributeModifications);
+
+ rawNode.replaceChildren(...parsedDOM.firstElementChild.childNodes);
+ } else {
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.outerHTML = value;
+ }
+ }
+
+ /**
+ * Insert adjacent HTML to a node.
+ *
+ * @param {Node} node
+ * @param {string} position One of "beforeBegin", "afterBegin", "beforeEnd",
+ * "afterEnd" (see Element.insertAdjacentHTML).
+ * @param {string} value The HTML content.
+ */
+ insertAdjacentHTML(node, position, value) {
+ if (isNodeDead(node)) {
+ return { node: [], newParents: [] };
+ }
+
+ const rawNode = node.rawNode;
+ const isInsertAsSibling =
+ position === "beforeBegin" || position === "afterEnd";
+
+ // Don't insert anything adjacent to the document element.
+ if (isInsertAsSibling && node.isDocumentElement()) {
+ throw new Error("Can't insert adjacent element to the root.");
+ }
+
+ const rawParentNode = rawNode.parentNode;
+ if (!rawParentNode && isInsertAsSibling) {
+ throw new Error("Can't insert as sibling without parent node.");
+ }
+
+ // We can't use insertAdjacentHTML, because we want to return the nodes
+ // being created (so the front can remove them if the user undoes
+ // the change). So instead, use Range.createContextualFragment().
+ const range = rawNode.ownerDocument.createRange();
+ if (position === "beforeBegin" || position === "afterEnd") {
+ range.selectNode(rawNode);
+ } else {
+ range.selectNodeContents(rawNode);
+ }
+ // eslint-disable-next-line no-unsanitized/method
+ const docFrag = range.createContextualFragment(value);
+ const newRawNodes = Array.from(docFrag.childNodes);
+ switch (position) {
+ case "beforeBegin":
+ rawParentNode.insertBefore(docFrag, rawNode);
+ break;
+ case "afterEnd":
+ // Note: if the second argument is null, rawParentNode.insertBefore
+ // behaves like rawParentNode.appendChild.
+ rawParentNode.insertBefore(docFrag, rawNode.nextSibling);
+ break;
+ case "afterBegin":
+ rawNode.insertBefore(docFrag, rawNode.firstChild);
+ break;
+ case "beforeEnd":
+ rawNode.appendChild(docFrag);
+ break;
+ default:
+ throw new Error(
+ "Invalid position value. Must be either " +
+ "'beforeBegin', 'beforeEnd', 'afterBegin' or 'afterEnd'."
+ );
+ }
+
+ return this.attachElements(newRawNodes);
+ }
+
+ /**
+ * Duplicate a specified node
+ *
+ * @param {NodeActor} node The node to duplicate.
+ */
+ duplicateNode({ rawNode }) {
+ const clonedNode = rawNode.cloneNode(true);
+ rawNode.parentNode.insertBefore(clonedNode, rawNode.nextSibling);
+ }
+
+ /**
+ * Test whether a node is a document or a document element.
+ *
+ * @param {NodeActor} node The node to remove.
+ * @return {boolean} True if the node is a document or a document element.
+ */
+ isDocumentOrDocumentElementNode(node) {
+ return (
+ (node.rawNode.ownerDocument &&
+ node.rawNode.ownerDocument.documentElement === this.rawNode) ||
+ node.rawNode.nodeType === Node.DOCUMENT_NODE
+ );
+ }
+
+ /**
+ * Removes a node from its parent node.
+ *
+ * @param {NodeActor} node The node to remove.
+ * @returns The node's nextSibling before it was removed.
+ */
+ removeNode(node) {
+ if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) {
+ throw Error("Cannot remove document, document elements or dead nodes.");
+ }
+
+ const nextSibling = this.nextSibling(node);
+ node.rawNode.remove();
+ // Mutation events will take care of the rest.
+ return nextSibling;
+ }
+
+ /**
+ * Removes an array of nodes from their parent node.
+ *
+ * @param {NodeActor[]} nodes The nodes to remove.
+ */
+ removeNodes(nodes) {
+ // Check that all nodes are valid before processing the removals.
+ for (const node of nodes) {
+ if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) {
+ throw Error("Cannot remove document, document elements or dead nodes");
+ }
+ }
+
+ for (const node of nodes) {
+ node.rawNode.remove();
+ // Mutation events will take care of the rest.
+ }
+ }
+
+ /**
+ * Insert a node into the DOM.
+ */
+ insertBefore(node, parent, sibling) {
+ if (
+ isNodeDead(node) ||
+ isNodeDead(parent) ||
+ (sibling && isNodeDead(sibling))
+ ) {
+ return;
+ }
+
+ const rawNode = node.rawNode;
+ const rawParent = parent.rawNode;
+ const rawSibling = sibling ? sibling.rawNode : null;
+
+ // Don't bother inserting a node if the document position isn't going
+ // to change. This prevents needless iframes reloading and mutations.
+ if (rawNode.parentNode === rawParent) {
+ let currentNextSibling = this.nextSibling(node);
+ currentNextSibling = currentNextSibling
+ ? currentNextSibling.rawNode
+ : null;
+
+ if (rawNode === rawSibling || currentNextSibling === rawSibling) {
+ return;
+ }
+ }
+
+ rawParent.insertBefore(rawNode, rawSibling);
+ }
+
+ /**
+ * Editing a node's tagname actually means creating a new node with the same
+ * attributes, removing the node and inserting the new one instead.
+ * This method does not return anything as mutation events are taking care of
+ * informing the consumers about changes.
+ */
+ editTagName(node, tagName) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const oldNode = node.rawNode;
+
+ // Create a new element with the same attributes as the current element and
+ // prepare to replace the current node with it.
+ let newNode;
+ try {
+ newNode = nodeDocument(oldNode).createElement(tagName);
+ } catch (x) {
+ // Failed to create a new element with that tag name, ignore the change,
+ // and signal the error to the front.
+ return Promise.reject(
+ new Error("Could not change node's tagName to " + tagName)
+ );
+ }
+
+ const attrs = oldNode.attributes;
+ for (let i = 0; i < attrs.length; i++) {
+ newNode.setAttribute(attrs[i].name, attrs[i].value);
+ }
+
+ // Insert the new node, and transfer the old node's children.
+ oldNode.parentNode.insertBefore(newNode, oldNode);
+ while (oldNode.firstChild) {
+ newNode.appendChild(oldNode.firstChild);
+ }
+
+ oldNode.remove();
+ return null;
+ }
+
+ /**
+ * Gets the state of the mutation breakpoint types for this actor.
+ *
+ * @param {NodeActor} node The node to get breakpoint info for.
+ */
+ getMutationBreakpoints(node) {
+ let bps;
+ if (!isNodeDead(node)) {
+ bps = this._breakpointInfoForNode(node.rawNode);
+ }
+
+ return (
+ bps || {
+ subtree: false,
+ removal: false,
+ attribute: false,
+ }
+ );
+ }
+
+ /**
+ * Set the state of some subset of mutation breakpoint types for this actor.
+ *
+ * @param {NodeActor} node The node to set breakpoint info for.
+ * @param {Object} bps A subset of the breakpoints for this actor that
+ * should be updated to new states.
+ */
+ setMutationBreakpoints(node, bps) {
+ if (isNodeDead(node)) {
+ return;
+ }
+ const rawNode = node.rawNode;
+
+ if (
+ rawNode.ownerDocument &&
+ rawNode.getRootNode({ composed: true }) != rawNode.ownerDocument
+ ) {
+ // We only allow watching for mutations on nodes that are attached to
+ // documents. That allows us to clean up our mutation listeners when all
+ // of the watched nodes have been removed from the document.
+ return;
+ }
+
+ // This argument has nullable fields so we want to only update boolean
+ // field values.
+ const bpsForNode = Object.keys(bps).reduce((obj, bp) => {
+ if (typeof bps[bp] === "boolean") {
+ obj[bp] = bps[bp];
+ }
+ return obj;
+ }, {});
+
+ this._updateMutationBreakpointState("api", rawNode, {
+ ...this.getMutationBreakpoints(node),
+ ...bpsForNode,
+ });
+ }
+
+ /**
+ * Update the mutation breakpoint state for the given DOM node.
+ *
+ * @param {Node} rawNode The DOM node.
+ * @param {Object} bpsForNode The state of each mutation bp type we support.
+ */
+ _updateMutationBreakpointState(mutationReason, rawNode, bpsForNode) {
+ const rawDoc = rawNode.ownerDocument || rawNode;
+
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(
+ rawDoc,
+ true /* createIfNeeded */
+ );
+ let originalBpsForNode = this._breakpointInfoForNode(rawNode);
+
+ if (!bpsForNode && !originalBpsForNode) {
+ return;
+ }
+
+ bpsForNode = bpsForNode || {};
+ originalBpsForNode = originalBpsForNode || {};
+
+ if (Object.values(bpsForNode).some(Boolean)) {
+ docMutationBreakpoints.nodes.set(rawNode, bpsForNode);
+ } else {
+ docMutationBreakpoints.nodes.delete(rawNode);
+ }
+ if (originalBpsForNode.subtree && !bpsForNode.subtree) {
+ docMutationBreakpoints.counts.subtree -= 1;
+ } else if (!originalBpsForNode.subtree && bpsForNode.subtree) {
+ docMutationBreakpoints.counts.subtree += 1;
+ }
+
+ if (originalBpsForNode.removal && !bpsForNode.removal) {
+ docMutationBreakpoints.counts.removal -= 1;
+ } else if (!originalBpsForNode.removal && bpsForNode.removal) {
+ docMutationBreakpoints.counts.removal += 1;
+ }
+
+ if (originalBpsForNode.attribute && !bpsForNode.attribute) {
+ docMutationBreakpoints.counts.attribute -= 1;
+ } else if (!originalBpsForNode.attribute && bpsForNode.attribute) {
+ docMutationBreakpoints.counts.attribute += 1;
+ }
+
+ this._updateDocumentMutationListeners(rawDoc);
+
+ const actor = this.getNode(rawNode);
+ if (actor) {
+ this.queueMutation({
+ target: actor.actorID,
+ type: "mutationBreakpoint",
+ mutationBreakpoints: this.getMutationBreakpoints(actor),
+ mutationReason,
+ });
+ }
+ }
+
+ /**
+ * Controls whether this DOM document has event listeners attached for
+ * handling of DOM mutation breakpoints.
+ *
+ * @param {Document} rawDoc The DOM document.
+ */
+ _updateDocumentMutationListeners(rawDoc) {
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(rawDoc);
+ if (!docMutationBreakpoints) {
+ rawDoc.devToolsWatchingDOMMutations = false;
+ return;
+ }
+
+ const anyBreakpoint =
+ docMutationBreakpoints.counts.subtree > 0 ||
+ docMutationBreakpoints.counts.removal > 0 ||
+ docMutationBreakpoints.counts.attribute > 0;
+
+ rawDoc.devToolsWatchingDOMMutations = anyBreakpoint;
+
+ if (docMutationBreakpoints.counts.subtree > 0) {
+ this.chromeEventHandler.addEventListener(
+ "devtoolschildinserted",
+ this.onSubtreeModified,
+ true /* capture */
+ );
+ } else {
+ this.chromeEventHandler.removeEventListener(
+ "devtoolschildinserted",
+ this.onSubtreeModified,
+ true /* capture */
+ );
+ }
+
+ if (anyBreakpoint) {
+ this.chromeEventHandler.addEventListener(
+ "devtoolschildremoved",
+ this.onNodeRemoved,
+ true /* capture */
+ );
+ } else {
+ this.chromeEventHandler.removeEventListener(
+ "devtoolschildremoved",
+ this.onNodeRemoved,
+ true /* capture */
+ );
+ }
+
+ if (docMutationBreakpoints.counts.attribute > 0) {
+ this.chromeEventHandler.addEventListener(
+ "devtoolsattrmodified",
+ this.onAttributeModified,
+ true /* capture */
+ );
+ } else {
+ this.chromeEventHandler.removeEventListener(
+ "devtoolsattrmodified",
+ this.onAttributeModified,
+ true /* capture */
+ );
+ }
+ }
+
+ _breakOnMutation(mutationType, targetNode, ancestorNode, action) {
+ this.targetActor.threadActor.pauseForMutationBreakpoint(
+ mutationType,
+ targetNode,
+ ancestorNode,
+ action
+ );
+ }
+
+ _mutationBreakpointsForDoc(rawDoc, createIfNeeded = false) {
+ let docMutationBreakpoints = this._mutationBreakpoints.get(rawDoc);
+ if (!docMutationBreakpoints && createIfNeeded) {
+ docMutationBreakpoints = {
+ counts: {
+ subtree: 0,
+ removal: 0,
+ attribute: 0,
+ },
+ nodes: new Map(),
+ };
+ this._mutationBreakpoints.set(rawDoc, docMutationBreakpoints);
+ }
+ return docMutationBreakpoints;
+ }
+
+ _breakpointInfoForNode(target) {
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(
+ target.ownerDocument || target
+ );
+ return (
+ (docMutationBreakpoints && docMutationBreakpoints.nodes.get(target)) ||
+ null
+ );
+ }
+
+ onNodeRemoved(evt) {
+ const mutationBpInfo = this._breakpointInfoForNode(evt.target);
+ const hasNodeRemovalEvent = mutationBpInfo?.removal;
+
+ this._clearMutationBreakpointsFromSubtree(evt.target);
+ if (hasNodeRemovalEvent) {
+ this._breakOnMutation("nodeRemoved", evt.target);
+ } else {
+ this.onSubtreeModified(evt);
+ }
+ }
+
+ onAttributeModified(evt) {
+ const mutationBpInfo = this._breakpointInfoForNode(evt.target);
+ if (mutationBpInfo?.attribute) {
+ this._breakOnMutation("attributeModified", evt.target);
+ }
+ }
+
+ onSubtreeModified(evt) {
+ const action = evt.type === "devtoolschildinserted" ? "add" : "remove";
+ let node = evt.target;
+ if (node.isNativeAnonymous && !this.showAllAnonymousContent) {
+ return;
+ }
+ while ((node = node.parentNode) !== null) {
+ const mutationBpInfo = this._breakpointInfoForNode(node);
+ if (mutationBpInfo?.subtree) {
+ this._breakOnMutation("subtreeModified", evt.target, node, action);
+ break;
+ }
+ }
+ }
+
+ _clearMutationBreakpointsFromSubtree(targetNode) {
+ const targetDoc = targetNode.ownerDocument || targetNode;
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(targetDoc);
+ if (!docMutationBreakpoints || docMutationBreakpoints.nodes.size === 0) {
+ // Bail early for performance. If the doc has no mutation BPs, there is
+ // no reason to iterate through the children looking for things to detach.
+ return;
+ }
+
+ // The walker is not limited to the subtree of the argument node, so we
+ // need to ensure that we stop walking when we leave the subtree.
+ const nextWalkerSibling = this._getNextTraversalSibling(targetNode);
+
+ const walker = new DocumentWalker(targetNode, this.rootWin, {
+ filter: noAnonymousContentTreeWalkerFilter,
+ skipTo: SKIP_TO_SIBLING,
+ });
+
+ do {
+ this._updateMutationBreakpointState("detach", walker.currentNode, null);
+ } while (walker.nextNode() && walker.currentNode !== nextWalkerSibling);
+ }
+
+ _getNextTraversalSibling(targetNode) {
+ const walker = new DocumentWalker(targetNode, this.rootWin, {
+ filter: noAnonymousContentTreeWalkerFilter,
+ skipTo: SKIP_TO_SIBLING,
+ });
+
+ while (!walker.nextSibling()) {
+ if (!walker.parentNode()) {
+ // If we try to step past the walker root, there is no next sibling.
+ return null;
+ }
+ }
+ return walker.currentNode;
+ }
+
+ /**
+ * Get any pending mutation records. Must be called by the client after
+ * the `new-mutations` notification is received. Returns an array of
+ * mutation records.
+ *
+ * Mutation records have a basic structure:
+ *
+ * {
+ * type: attributes|characterData|childList,
+ * target: <domnode actor ID>,
+ * }
+ *
+ * And additional attributes based on the mutation type:
+ *
+ * `attributes` type:
+ * attributeName: <string> - the attribute that changed
+ * attributeNamespace: <string> - the attribute's namespace URI, if any.
+ * newValue: <string> - The new value of the attribute, if any.
+ *
+ * `characterData` type:
+ * newValue: <string> - the new nodeValue for the node
+ *
+ * `childList` type is returned when the set of children for a node
+ * has changed. Includes extra data, which can be used by the client to
+ * maintain its ownership subtree.
+ *
+ * added: array of <domnode actor ID> - The list of actors *previously
+ * seen by the client* that were added to the target node.
+ * removed: array of <domnode actor ID> The list of actors *previously
+ * seen by the client* that were removed from the target node.
+ * inlineTextChild: If the node now has a single text child, it will
+ * be sent here.
+ *
+ * Actors that are included in a MutationRecord's `removed` but
+ * not in an `added` have been removed from the client's ownership
+ * tree (either by being moved under a node the client has seen yet
+ * or by being removed from the tree entirely), and is considered
+ * 'orphaned'.
+ *
+ * Keep in mind that if a node that the client hasn't seen is moved
+ * into or out of the target node, it will not be included in the
+ * removedNodes and addedNodes list, so if the client is interested
+ * in the new set of children it needs to issue a `children` request.
+ */
+ getMutations(options = {}) {
+ const pending = this._pendingMutations || [];
+ this._pendingMutations = [];
+ this._waitingForGetMutations = false;
+
+ if (options.cleanup) {
+ for (const node of this._orphaned) {
+ // Release the orphaned node. Nodes or children that have been
+ // retained will be moved to this._retainedOrphans.
+ this.releaseNode(node);
+ }
+ this._orphaned = new Set();
+ }
+
+ return pending;
+ }
+
+ queueMutation(mutation) {
+ if (!this.actorID || this._destroyed) {
+ // We've been destroyed, don't bother queueing this mutation.
+ return;
+ }
+
+ // Add the mutation to the list of mutations to be retrieved next.
+ this._pendingMutations.push(mutation);
+
+ // Bail out if we already emitted a new-mutations event and are waiting for a client
+ // to retrieve them.
+ if (this._waitingForGetMutations) {
+ return;
+ }
+
+ if (IMMEDIATE_MUTATIONS.includes(mutation.type)) {
+ this._emitNewMutations();
+ } else {
+ /**
+ * If many mutations are fired at the same time, clients might sequentially request
+ * children/siblings for updated nodes, which can be costly. By throttling the calls
+ * to getMutations, duplicated mutations will be ignored.
+ */
+ this._throttledEmitNewMutations();
+ }
+ }
+
+ _emitNewMutations() {
+ if (!this.actorID || this._destroyed) {
+ // Bail out if the actor was destroyed after throttling this call.
+ return;
+ }
+
+ if (this._waitingForGetMutations || !this._pendingMutations.length) {
+ // Bail out if we already fired the new-mutation event or if no mutations are
+ // waiting to be retrieved.
+ return;
+ }
+
+ this._waitingForGetMutations = true;
+ this.emit("new-mutations");
+ }
+
+ /**
+ * Handles mutations from the DOM mutation observer API.
+ *
+ * @param array[MutationRecord] mutations
+ * See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#MutationRecord
+ */
+ onMutations(mutations) {
+ // Notify any observers that want *all* mutations (even on nodes that aren't
+ // referenced). This is not sent over the protocol so can only be used by
+ // scripts running in the server process.
+ this.emit("any-mutation");
+
+ for (const change of mutations) {
+ const targetActor = this.getNode(change.target);
+ if (!targetActor) {
+ continue;
+ }
+ const targetNode = change.target;
+ const type = change.type;
+ const mutation = {
+ type,
+ target: targetActor.actorID,
+ };
+
+ if (type === "attributes") {
+ mutation.attributeName = change.attributeName;
+ mutation.attributeNamespace = change.attributeNamespace || undefined;
+ mutation.newValue = targetNode.hasAttribute(mutation.attributeName)
+ ? targetNode.getAttribute(mutation.attributeName)
+ : null;
+ } else if (type === "characterData") {
+ mutation.newValue = targetNode.nodeValue;
+ this._maybeQueueInlineTextChildMutation(change, targetNode);
+ } else if (type === "childList") {
+ // Get the list of removed and added actors that the client has seen
+ // so that it can keep its ownership tree up to date.
+ const removedActors = [];
+ const addedActors = [];
+ for (const removed of change.removedNodes) {
+ const removedActor = this.getNode(removed);
+ if (!removedActor) {
+ // If the client never encountered this actor we don't need to
+ // mention that it was removed.
+ continue;
+ }
+ // While removed from the tree, nodes are saved as orphaned.
+ this._orphaned.add(removedActor);
+ removedActors.push(removedActor.actorID);
+ }
+ for (const added of change.addedNodes) {
+ const addedActor = this.getNode(added);
+ if (!addedActor) {
+ // If the client never encounted this actor we don't need to tell
+ // it about its addition for ownership tree purposes - if the
+ // client wants to see the new nodes it can ask for children.
+ continue;
+ }
+ // The actor is reconnected to the ownership tree, unorphan
+ // it and let the client know so that its ownership tree is up
+ // to date.
+ this._orphaned.delete(addedActor);
+ addedActors.push(addedActor.actorID);
+ }
+
+ mutation.numChildren = targetActor.numChildren;
+ mutation.removed = removedActors;
+ mutation.added = addedActors;
+
+ const inlineTextChild = this.inlineTextChild(targetActor);
+ if (inlineTextChild) {
+ mutation.inlineTextChild = inlineTextChild.form();
+ }
+ }
+ this.queueMutation(mutation);
+ }
+ }
+
+ /**
+ * Check if the provided mutation could change the way the target element is
+ * inlined with its parent node. If it might, a custom mutation of type
+ * "inlineTextChild" will be queued.
+ *
+ * @param {MutationRecord} mutation
+ * A characterData type mutation
+ */
+ _maybeQueueInlineTextChildMutation(mutation) {
+ const { oldValue, target } = mutation;
+ const newValue = target.nodeValue;
+ const limit = gValueSummaryLength;
+
+ if (
+ (oldValue.length <= limit && newValue.length <= limit) ||
+ (oldValue.length > limit && newValue.length > limit)
+ ) {
+ // Bail out if the new & old values are both below/above the size limit.
+ return;
+ }
+
+ const parentActor = this.getNode(target.parentNode);
+ if (!parentActor || parentActor.rawNode.children.length) {
+ // If the parent node has other children, a character data mutation will
+ // not change anything regarding inlining text nodes.
+ return;
+ }
+
+ const inlineTextChild = this.inlineTextChild(parentActor);
+ this.queueMutation({
+ type: "inlineTextChild",
+ target: parentActor.actorID,
+ inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined,
+ });
+ }
+
+ onSlotchange(event) {
+ const target = event.target;
+ const targetActor = this.getNode(target);
+ if (!targetActor) {
+ return;
+ }
+
+ this.queueMutation({
+ type: "slotchange",
+ target: targetActor.actorID,
+ });
+ }
+
+ /**
+ * Fires when an anonymous root is created.
+ * This is needed because regular mutation observers don't fire on some kinds
+ * of NAC creation. We want to treat this like a regular insertion.
+ */
+ onAnonymousrootcreated(event) {
+ const root = event.target;
+ const parent = this.rawParentNode(root);
+ if (!parent) {
+ // These events are async. The node might have been removed already, in
+ // which case there's nothing to do anymore.
+ return;
+ }
+ // By the time onAnonymousrootremoved fires, the node is already detached
+ // from its parent, so we need to remember it by hand.
+ this._anonParents.set(root, parent);
+ this.onMutations([
+ {
+ type: "childList",
+ target: parent,
+ addedNodes: [root],
+ removedNodes: [],
+ },
+ ]);
+ }
+
+ /**
+ * @see onAnonymousrootcreated
+ */
+ onAnonymousrootremoved(event) {
+ const root = event.target;
+ const parent = this._anonParents.get(root);
+ if (!parent) {
+ return;
+ }
+ this._anonParents.delete(root);
+ this.onMutations([
+ {
+ type: "childList",
+ target: parent,
+ addedNodes: [],
+ removedNodes: [root],
+ },
+ ]);
+ }
+
+ onShadowrootattached(event) {
+ const actor = this.getNode(event.target);
+ if (!actor) {
+ return;
+ }
+
+ const mutation = {
+ type: "shadowRootAttached",
+ target: actor.actorID,
+ };
+ this.queueMutation(mutation);
+ }
+
+ onFrameLoad({ window, isTopLevel }) {
+ // By the time we receive the DOMContentLoaded event, we might have been destroyed
+ if (this._destroyed) {
+ return;
+ }
+ const { readyState } = window.document;
+ if (readyState != "interactive" && readyState != "complete") {
+ // The document is not loaded, so we want to register to fire again when the
+ // DOM has been loaded.
+ window.addEventListener(
+ "DOMContentLoaded",
+ this.onFrameLoad.bind(this, { window, isTopLevel }),
+ { once: true }
+ );
+ return;
+ }
+
+ window.document.shadowRootAttachedEventEnabled = true;
+
+ if (isTopLevel) {
+ // If we initialize the inspector while the document is loading,
+ // we may already have a root document set in the constructor.
+ if (
+ this.rootDoc &&
+ this.rootDoc !== window.document &&
+ !Cu.isDeadWrapper(this.rootDoc) &&
+ this.rootDoc.defaultView
+ ) {
+ this.onFrameUnload({ window: this.rootDoc.defaultView });
+ }
+ // Update all DOM objects references to target the new document.
+ this.rootWin = window;
+ this.rootDoc = window.document;
+ this.rootNode = this.document();
+ this.emit("root-available", this.rootNode);
+ } else {
+ const frame = getFrameElement(window);
+ const frameActor = this.getNode(frame);
+ if (frameActor) {
+ // If the parent frame is in the map of known node actors, create the
+ // actor for the new document and emit a root-available event.
+ const documentActor = this._getOrCreateNodeActor(window.document);
+ this.emit("root-available", documentActor);
+ }
+ }
+ }
+
+ // Returns true if domNode is in window or a subframe.
+ _childOfWindow(window, domNode) {
+ while (domNode) {
+ const win = nodeDocument(domNode).defaultView;
+ if (win === window) {
+ return true;
+ }
+ domNode = getFrameElement(win);
+ }
+ return false;
+ }
+
+ onFrameUnload({ window }) {
+ // Any retained orphans that belong to this document
+ // or its children need to be released, and a mutation sent
+ // to notify of that.
+ const releasedOrphans = [];
+
+ for (const retained of this._retainedOrphans) {
+ if (
+ Cu.isDeadWrapper(retained.rawNode) ||
+ this._childOfWindow(window, retained.rawNode)
+ ) {
+ this._retainedOrphans.delete(retained);
+ releasedOrphans.push(retained.actorID);
+ this.releaseNode(retained, { force: true });
+ }
+ }
+
+ if (releasedOrphans.length) {
+ this.queueMutation({
+ target: this.rootNode.actorID,
+ type: "unretained",
+ nodes: releasedOrphans,
+ });
+ }
+
+ const doc = window.document;
+ const documentActor = this.getNode(doc);
+ if (!documentActor) {
+ return;
+ }
+
+ // Removing a frame also removes any mutation breakpoints set on that
+ // document so that clients can clear their set of active breakpoints.
+ const mutationBps = this._mutationBreakpointsForDoc(doc);
+ const nodes = mutationBps ? Array.from(mutationBps.nodes.keys()) : [];
+ for (const node of nodes) {
+ this._updateMutationBreakpointState("unload", node, null);
+ }
+
+ this.emit("root-destroyed", documentActor);
+
+ // Cleanup root doc references if we just unloaded the top level root
+ // document.
+ if (this.rootDoc === doc) {
+ this.rootDoc = null;
+ this.rootNode = null;
+ }
+
+ // Release the actor for the unloaded document.
+ this.releaseNode(documentActor, { force: true });
+ }
+
+ /**
+ * Check if a node is attached to the DOM tree of the current page.
+ * @param {Node} rawNode
+ * @return {Boolean} false if the node is removed from the tree or within a
+ * document fragment
+ */
+ _isInDOMTree(rawNode) {
+ let walker;
+ try {
+ walker = this.getDocumentWalker(rawNode);
+ } catch (e) {
+ // The DocumentWalker may throw NS_ERROR_ILLEGAL_VALUE when the node isn't found as a legit children of its parent
+ // ex: <iframe> manually added as immediate child of another <iframe>
+ if (e.name == "NS_ERROR_ILLEGAL_VALUE") {
+ return false;
+ }
+ throw e;
+ }
+ let current = walker.currentNode;
+
+ // Reaching the top of tree
+ while (walker.parentNode()) {
+ current = walker.currentNode;
+ }
+
+ // The top of the tree is a fragment or is not rootDoc, hence rawNode isn't
+ // attached
+ if (
+ current.nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
+ current !== this.rootDoc
+ ) {
+ return false;
+ }
+
+ // Otherwise the top of the tree is rootDoc, hence rawNode is in rootDoc
+ return true;
+ }
+
+ /**
+ * @see _isInDomTree
+ */
+ isInDOMTree(node) {
+ if (isNodeDead(node)) {
+ return false;
+ }
+ return this._isInDOMTree(node.rawNode);
+ }
+
+ /**
+ * Given a windowID return the NodeActor for the corresponding frameElement,
+ * unless it's the root window
+ */
+ getNodeActorFromWindowID(windowID) {
+ let win;
+
+ try {
+ win = Services.wm.getOuterWindowWithId(windowID);
+ } catch (e) {
+ // ignore
+ }
+
+ if (!win) {
+ return {
+ error: "noWindow",
+ message: "The related docshell is destroyed or not found",
+ };
+ } else if (!win.frameElement) {
+ // the frame element of the root document is privileged & thus
+ // inaccessible, so return the document body/element instead
+ return this.attachElement(
+ win.document.body || win.document.documentElement
+ );
+ }
+
+ return this.attachElement(win.frameElement);
+ }
+
+ /**
+ * Given a contentDomReference return the NodeActor for the corresponding frameElement.
+ */
+ getNodeActorFromContentDomReference(contentDomReference) {
+ let rawNode = lazy.ContentDOMReference.resolve(contentDomReference);
+ if (!rawNode || !this._isInDOMTree(rawNode)) {
+ return null;
+ }
+
+ // This is a special case for the document object whereby it is considered
+ // as document.documentElement (the <html> node)
+ if (rawNode.defaultView && rawNode === rawNode.defaultView.document) {
+ rawNode = rawNode.documentElement;
+ }
+
+ return this.attachElement(rawNode);
+ }
+
+ /**
+ * Given a StyleSheet resource ID, commonly used in the style-editor, get its
+ * ownerNode and return the corresponding walker's NodeActor.
+ * Note that getNodeFromActor was added later and can now be used instead.
+ */
+ getStyleSheetOwnerNode(resourceId) {
+ const manager = this.targetActor.getStyleSheetsManager();
+ const ownerNode = manager.getOwnerNode(resourceId);
+ return this.attachElement(ownerNode);
+ }
+
+ /**
+ * This method can be used to retrieve NodeActor for DOM nodes from other
+ * actors in a way that they can later be highlighted in the page, or
+ * selected in the inspector.
+ * If an actor has a reference to a DOM node, and the UI needs to know about
+ * this DOM node (and possibly select it in the inspector), the UI should
+ * first retrieve a reference to the walkerFront:
+ *
+ * // Make sure the inspector/walker have been initialized first.
+ * const inspectorFront = await toolbox.target.getFront("inspector");
+ * // Retrieve the walker.
+ * const walker = inspectorFront.walker;
+ *
+ * And then call this method:
+ *
+ * // Get the nodeFront from my actor, passing the ID and properties path.
+ * walker.getNodeFromActor(myActorID, ["element"]).then(nodeFront => {
+ * // Use the nodeFront, e.g. select the node in the inspector.
+ * toolbox.getPanel("inspector").selection.setNodeFront(nodeFront);
+ * });
+ *
+ * @param {String} actorID The ID for the actor that has a reference to the
+ * DOM node.
+ * @param {Array} path Where, on the actor, is the DOM node stored. If in the
+ * scope of the actor, the node is available as `this.data.node`, then this
+ * should be ["data", "node"].
+ * @return {NodeActor} The attached NodeActor, or null if it couldn't be
+ * found.
+ */
+ getNodeFromActor(actorID, path) {
+ const actor = this.conn.getActor(actorID);
+ if (!actor) {
+ return null;
+ }
+
+ let obj = actor;
+ for (const name of path) {
+ if (!(name in obj)) {
+ return null;
+ }
+ obj = obj[name];
+ }
+
+ return this.attachElement(obj);
+ }
+
+ /**
+ * Returns an instance of the LayoutActor that is used to retrieve CSS layout-related
+ * information.
+ *
+ * @return {LayoutActor}
+ */
+ getLayoutInspector() {
+ if (!this.layoutActor) {
+ this.layoutActor = new LayoutActor(this.conn, this.targetActor, this);
+ }
+
+ return this.layoutActor;
+ }
+
+ /**
+ * Returns the parent grid DOMNode of the given node if it exists, otherwise, it
+ * returns null.
+ */
+ getParentGridNode(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const parentGridNode = findGridParentContainerForNode(node.rawNode);
+ return parentGridNode ? this._getOrCreateNodeActor(parentGridNode) : null;
+ }
+
+ /**
+ * Returns the offset parent DOMNode of the given node if it exists, otherwise, it
+ * returns null.
+ */
+ getOffsetParent(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const offsetParent = node.rawNode.offsetParent;
+
+ if (!offsetParent) {
+ return null;
+ }
+
+ return this._getOrCreateNodeActor(offsetParent);
+ }
+
+ getEmbedderElement(browsingContextID) {
+ const browsingContext = BrowsingContext.get(browsingContextID);
+ let rawNode = browsingContext.embedderElement;
+ if (!this._isInDOMTree(rawNode)) {
+ return null;
+ }
+
+ // This is a special case for the document object whereby it is considered
+ // as document.documentElement (the <html> node)
+ if (rawNode.defaultView && rawNode === rawNode.defaultView.document) {
+ rawNode = rawNode.documentElement;
+ }
+
+ return this.attachElement(rawNode);
+ }
+
+ pick(doFocus, isLocalTab) {
+ this.nodePicker.pick(doFocus, isLocalTab);
+ }
+
+ cancelPick() {
+ this.nodePicker.cancelPick();
+ }
+
+ clearPicker() {
+ this.nodePicker.resetHoveredNodeReference();
+ }
+
+ /**
+ * Given a scrollable node, find its descendants which are causing overflow in it and
+ * add their raw nodes to the map as keys with the scrollable element as the values.
+ *
+ * @param {NodeActor} scrollableNode A scrollable node.
+ * @param {Map} map The map to which the overflow causing elements are added.
+ */
+ updateOverflowCausingElements(scrollableNode, map) {
+ if (
+ isNodeDead(scrollableNode) ||
+ scrollableNode.rawNode.nodeType !== Node.ELEMENT_NODE
+ ) {
+ return;
+ }
+
+ const overflowCausingChildren = [
+ ...InspectorUtils.getOverflowingChildrenOfElement(scrollableNode.rawNode),
+ ];
+
+ for (let overflowCausingChild of overflowCausingChildren) {
+ // overflowCausingChild is a Node, but not necessarily an Element.
+ // So, get the containing Element
+ if (overflowCausingChild.nodeType !== Node.ELEMENT_NODE) {
+ overflowCausingChild = overflowCausingChild.parentElement;
+ }
+ map.set(overflowCausingChild, scrollableNode);
+ }
+ }
+
+ /**
+ * Returns an array of the overflow causing elements' NodeActor for the given node.
+ *
+ * @param {NodeActor} node The scrollable node.
+ * @return {Array<NodeActor>} An array of the overflow causing elements.
+ */
+ getOverflowCausingElements(node) {
+ if (
+ isNodeDead(node) ||
+ node.rawNode.nodeType !== Node.ELEMENT_NODE ||
+ !node.isScrollable
+ ) {
+ return [];
+ }
+
+ const overflowCausingElements = [
+ ...InspectorUtils.getOverflowingChildrenOfElement(node.rawNode),
+ ].map(overflowCausingChild => {
+ if (overflowCausingChild.nodeType !== Node.ELEMENT_NODE) {
+ overflowCausingChild = overflowCausingChild.parentElement;
+ }
+
+ return overflowCausingChild;
+ });
+
+ return this.attachElements(overflowCausingElements);
+ }
+
+ /**
+ * Return the scrollable ancestor node which has overflow because of the given node.
+ *
+ * @param {NodeActor} overflowCausingNode
+ */
+ getScrollableAncestorNode(overflowCausingNode) {
+ if (
+ isNodeDead(overflowCausingNode) ||
+ !this.overflowCausingElementsMap.has(overflowCausingNode.rawNode)
+ ) {
+ return null;
+ }
+
+ return this.overflowCausingElementsMap.get(overflowCausingNode.rawNode);
+ }
+}
+
+exports.WalkerActor = WalkerActor;