commit 8ddc54c4d94ddfa47ea301a9a4edda44a1796680
parent 6987f2e01b9982e2d694c98f1bf66425c249e639
Author: Masayuki Nakano <masayuki@d-toybox.com>
Date: Wed, 1 Oct 2025 10:30:09 +0000
Bug 1987569 - Make `nsINode::MoveBefore` notify DevTools of node removal r=smaug,dom-core
For the consistency with `nsINode::ReplaceOrInsertBefore()`, it should
notify DevTools of the node removal. Then, the user may modify the DOM
tree during the break. Therefore, it needs to check same things again
if the user modifies the DOM.
Differential Revision: https://phabricator.services.mozilla.com/D266947
Diffstat:
3 files changed, 710 insertions(+), 38 deletions(-)
diff --git a/dom/base/nsINode.cpp b/dom/base/nsINode.cpp
@@ -2391,53 +2391,62 @@ static bool IsDoctypeOrHasFollowingDoctype(nsINode* aNode) {
// https://dom.spec.whatwg.org/#dom-parentnode-movebefore
void nsINode::MoveBefore(nsINode& aNode, nsINode* aChild, ErrorResult& aRv) {
- nsINode* referenceChild = aChild;
- if (referenceChild == &aNode) {
- referenceChild = aNode.GetNextSibling();
- }
+ const auto ComputeReferenceChild = [&]() -> nsINode* {
+ return &aNode == aChild ? aNode.GetNextSibling() : aChild;
+ };
+ nsINode* referenceChild = ComputeReferenceChild();
// Move algorithm
// https://dom.spec.whatwg.org/#move
- // Step 1.
nsINode& newParent = *this;
- GetRootNodeOptions options;
- options.mComposed = true;
- if (newParent.GetRootNode(options) != aNode.GetRootNode(options)) {
- aRv.ThrowHierarchyRequestError("Different root node.");
- return;
- }
+ const auto EnsureValidMoveRequest = [&newParent](nsINode& aNode,
+ nsINode* aReferenceChild,
+ ErrorResult& aRv) -> void {
+ // Step 1.
+ GetRootNodeOptions options;
+ options.mComposed = true;
+ if (newParent.GetRootNode(options) != aNode.GetRootNode(options)) {
+ aRv.ThrowHierarchyRequestError("Different root node.");
+ return;
+ }
- // Step 2.
- if (nsContentUtils::ContentIsHostIncludingDescendantOf(&newParent, &aNode)) {
- aRv.ThrowHierarchyRequestError("Node is an ancestor of the new parent.");
- return;
- }
+ // Step 2.
+ if (nsContentUtils::ContentIsHostIncludingDescendantOf(&newParent,
+ &aNode)) {
+ aRv.ThrowHierarchyRequestError("Node is an ancestor of the new parent.");
+ return;
+ }
- // Step 3.
- if (referenceChild && referenceChild->GetParentNode() != &newParent) {
- aRv.ThrowNotFoundError("Wrong reference child.");
- return;
- }
+ // Step 3.
+ if (aReferenceChild && aReferenceChild->GetParentNode() != &newParent) {
+ aRv.ThrowNotFoundError("Wrong reference child.");
+ return;
+ }
- // Step 4.
- if (!aNode.IsElement() && !aNode.IsCharacterData()) {
- aRv.ThrowHierarchyRequestError("Wrong type of node.");
- return;
- }
+ // Step 4.
+ if (!aNode.IsElement() && !aNode.IsCharacterData()) {
+ aRv.ThrowHierarchyRequestError("Wrong type of node.");
+ return;
+ }
- // Step 5.
- if (aNode.IsText() && newParent.IsDocument()) {
- aRv.ThrowHierarchyRequestError(
- "Can't move a text node to be a child of a document.");
- return;
- }
+ // Step 5.
+ if (aNode.IsText() && newParent.IsDocument()) {
+ aRv.ThrowHierarchyRequestError(
+ "Can't move a text node to be a child of a document.");
+ return;
+ }
- // Step 6.
- if (newParent.IsDocument() && aNode.IsElement() &&
- (newParent.AsDocument()->GetRootElement() ||
- IsDoctypeOrHasFollowingDoctype(referenceChild))) {
- aRv.ThrowHierarchyRequestError(
- "Can't move an element to be a child of the document.");
+ // Step 6.
+ if (newParent.IsDocument() && aNode.IsElement() &&
+ (newParent.AsDocument()->GetRootElement() ||
+ IsDoctypeOrHasFollowingDoctype(aReferenceChild))) {
+ aRv.ThrowHierarchyRequestError(
+ "Can't move an element to be a child of the document.");
+ return;
+ }
+ };
+ EnsureValidMoveRequest(aNode, referenceChild, aRv);
+ if (MOZ_UNLIKELY(aRv.Failed())) {
return;
}
@@ -2447,6 +2456,27 @@ void nsINode::MoveBefore(nsINode& aNode, nsINode* aChild, ErrorResult& aRv) {
// Step 8.
MOZ_ASSERT(oldParent);
+ // For consistency with ReplaceOrInsertBefore(), we should allow DevTools to
+ // break on the removal of aNode.
+ if (MOZ_UNLIKELY(
+ aNode.MaybeNeedsToNotifyDevToolsOfNodeRemovalsInOwnerDoc())) {
+ nsMutationGuard guard;
+ nsContentUtils::NotifyDevToolsOfNodeRemoval(aNode);
+ // If the user modifies the DOM tree, let's check same things again.
+ if (MOZ_UNLIKELY(guard.Mutated(0))) {
+ referenceChild = ComputeReferenceChild();
+ // Step 1-6.
+ EnsureValidMoveRequest(aNode, referenceChild, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+ // Step 7.
+ oldParent = aNode.GetParentNode();
+ // Step 8.
+ MOZ_ASSERT(oldParent);
+ }
+ }
+
// Steps 9-12 happen implicitly in when triggering
// nsIMutationObserver notifications.
// Step 13, and UnbindFromTree runs step 14 and step 15 and step 16,
diff --git a/dom/base/test/mochitest.toml b/dom/base/test/mochitest.toml
@@ -1210,6 +1210,8 @@ support-files = [
"file_delazification_strategy.js",
]
+["test_devtoolschildremoved.html"]
+
["test_document.all_iteration.html"]
["test_document.all_unqualified.html"]
diff --git a/dom/base/test/test_devtoolschildremoved.html b/dom/base/test/test_devtoolschildremoved.html
@@ -0,0 +1,640 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>devtoolschildremoved event tests</title>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script src="/tests/SimpleTest/EventUtils.js"></script>
+<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(async () => {
+ await SpecialPowers.pushPrefEnv({ set: [["dom.movebefore.enabled", true]] });
+
+ // Unlock devtoolschildremoved events.
+ SpecialPowers.wrap(document).devToolsWatchingDOMMutations = true;
+ SimpleTest.registerCleanupFunction(() => {
+ SpecialPowers.wrap(document).devToolsWatchingDOMMutations = false;
+ });
+
+ function getDevToolsChildRemovedEvent(aTarget, aTestBody, aOnEvent) {
+ let event;
+ function getEvent(e) {
+ if (e.target == aTarget) {
+ event = e;
+ aOnEvent();
+ }
+ }
+ const chromeEventHandler = SpecialPowers.wrap(window).docShell.chromeEventHandler;
+ chromeEventHandler.addEventListener(
+ "devtoolschildremoved",
+ getEvent,
+ {
+ once: true,
+ capture: true,
+ }
+ );
+ try {
+ aTestBody();
+ } finally {
+ chromeEventHandler.removeEventListener(
+ "devtoolschildremoved",
+ getEvent,
+ {capture: true}
+ );
+ }
+ return event;
+ }
+
+ const srcContainer = document.getElementById("container");
+ const destContainer = document.getElementById("destContainer");
+ const refChild = document.getElementById("refChild");
+
+ // When a node is removed or moved from another place, "devtoolschildremoved"
+ // event whose target is the removed node should be fired.
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ span.remove();
+ },
+ () => {
+ is(
+ span.parentNode,
+ srcContainer,
+ "devtoolschildremoved when Node.remove() should be called before removed from the DOM"
+ );
+ }
+ );
+ isnot(event, undefined, "Node.remove() should cause a devtoolschildremoved event");
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ srcContainer.removeChild(span);
+ },
+ () => {
+ is(
+ span.parentNode,
+ srcContainer,
+ "devtoolschildremoved when Node.removeChild() should be called before removed from the DOM"
+ );
+ }
+ );
+ isnot(event, undefined, "Node.removeChild() should cause a devtoolschildremoved event");
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.appendChild(span);
+ },
+ () => {
+ is(
+ span.parentNode,
+ srcContainer,
+ "devtoolschildremoved when Node.appendChild() should be called before removed from the DOM"
+ );
+ }
+ );
+ isnot(event, undefined, "Node.appendChild() with connected node should cause a devtoolschildremoved event");
+ span.remove();
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.insertBefore(span, refChild);
+ },
+ () => {
+ is(
+ span.parentNode,
+ srcContainer,
+ "devtoolschildremoved when Node.insertBefore() should be called before removed from the DOM"
+ );
+ }
+ );
+ isnot(event, undefined, "Node.insertBefore() with connected node should cause a devtoolschildremoved event");
+ span.remove();
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.moveBefore(span, refChild);
+ },
+ () => {
+ is(
+ span.parentNode,
+ srcContainer,
+ "devtoolschildremoved when Node.moveBefore() should be called before removed from the DOM"
+ );
+ }
+ );
+ isnot(event, undefined, "Node.moveBefore() with connected node should cause a devtoolschildremoved event");
+ span.remove();
+ }
+
+ // While DevTools breaks on a removal of a node, users can modify the DOM.
+ // In such case, at least we should avoid to crash.
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.appendChild(span);
+ },
+ () => {
+ refChild.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The dest container is modified while Node.appendChild() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The moving <span> should stay in the src container when the dest container is modified during Node.appendChild()"
+ );
+ } finally {
+ span.remove();
+ destContainer.appendChild(refChild);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.insertBefore(span, refChild);
+ },
+ () => {
+ refChild.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The dest container is modified while Node.insertBefore() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The moving <span> should stay in the src container when the dest container is modified during Node.insertBefore()"
+ );
+ } finally {
+ span.remove();
+ destContainer.appendChild(refChild);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.moveBefore(span, refChild);
+ },
+ () => {
+ refChild.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The dest container is modified while Node.moveBefore() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The moving <span> should stay in the src container when the dest container is modified during Node.moveBefore()"
+ );
+ } finally {
+ span.remove();
+ destContainer.appendChild(refChild);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.appendChild(span);
+ },
+ () => {
+ destContainer.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The dest container is removed while Node.appendChild() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The moving <span> should stay in the src container when the dest container is removed during Node.appendChild()"
+ );
+ } finally {
+ span.remove();
+ document.body.appendChild(destContainer);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.insertBefore(span, refChild);
+ },
+ () => {
+ destContainer.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The dest container is removed while Node.insertBefore() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The moving <span> should stay in the src container when the dest container is removed during Node.insertBefore()"
+ );
+ } finally {
+ span.remove();
+ document.body.appendChild(destContainer);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.moveBefore(span, refChild);
+ },
+ () => {
+ destContainer.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The dest container is removed while Node.moveBefore() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The moving <span> should stay in the src container when the dest container is removed during Node.moveBefore()"
+ );
+ } finally {
+ span.remove();
+ document.body.appendChild(destContainer);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ span.remove();
+ },
+ () => {
+ span.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The removing <span> is removed while Node.remove() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ } finally {
+ is(
+ span.parentNode,
+ null,
+ "The removing <span> should not stay in the src container when it's removed during Node.remove()"
+ );
+ span.remove();
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ srcContainer.removeChild(span);
+ },
+ () => {
+ span.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The removing <span> is removed while Node.removeChild() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ } finally {
+ is(
+ span.parentNode,
+ null,
+ "The removing <span> should not stay in the src container when it's removed during Node.removeChild()"
+ );
+ span.remove();
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.appendChild(span);
+ },
+ () => {
+ span.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The moving <span> is removed while Node.appendChild() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ null,
+ "The moving <span> should not in the DOM when it's removed during Node.appendChild()"
+ );
+ } finally {
+ span.remove();
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.insertBefore(span, refChild);
+ },
+ () => {
+ span.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The moving <span> is removed while Node.insertBefore() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ null,
+ "The moving <span> should not in the DOM when it's removed during Node.insertBefore()"
+ );
+ } finally {
+ span.remove();
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.moveBefore(span, refChild);
+ },
+ () => {
+ span.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The moving <span> is removed while Node.moveBefore() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ null,
+ "The moving <span> should not in the DOM when it's removed during Node.moveBefore()"
+ );
+ } finally {
+ span.remove();
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ span.remove();
+ },
+ () => {
+ srcContainer.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The src container is removed while Node.remove() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The removing <span> should stay in the src container when the container is removed during Node.remove()"
+ );
+ } finally {
+ span.remove();
+ document.body.insertBefore(srcContainer, destContainer);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ srcContainer.removeChild(span);
+ },
+ () => {
+ srcContainer.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The src container is removed while Node.removeChild() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The removing <span> should stay in the src container when the container is removed during Node.removeChild()"
+ );
+ } finally {
+ span.remove();
+ document.body.insertBefore(srcContainer, destContainer);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.appendChild(span);
+ },
+ () => {
+ srcContainer.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The src container is removed while Node.appendChild() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The moving <span> should stay in the src container when the container is removed during Node.appendChild()"
+ );
+ } finally {
+ span.remove();
+ document.body.insertBefore(srcContainer, destContainer);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.insertBefore(span, refChild);
+ },
+ () => {
+ srcContainer.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The src container is removed while Node.insertBefore() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The moving <span> should stay in the src container when the container is removed during Node.insertBefore()"
+ );
+ } finally {
+ span.remove();
+ document.body.insertBefore(srcContainer, destContainer);
+ }
+ }
+
+ {
+ const span = document.createElement("span");
+ srcContainer.appendChild(span);
+ try {
+ const event = getDevToolsChildRemovedEvent(
+ span,
+ () => {
+ destContainer.moveBefore(span, refChild);
+ },
+ () => {
+ srcContainer.remove();
+ }
+ );
+ isnot(
+ event,
+ undefined,
+ "The src container is removed while Node.moveBefore() is called should cause a devtoolschildremoved event"
+ );
+ } catch (e) {
+ is(
+ span.parentNode,
+ srcContainer,
+ "The moving <span> should stay in the src container when the container is removed during Node.moveBefore()"
+ );
+ } finally {
+ span.remove();
+ document.body.insertBefore(srcContainer, destContainer);
+ }
+ }
+
+ SimpleTest.finish();
+});
+</script>
+</head>
+<body>
+ <div id="container"></div>
+ <div id="destContainer"><span id="refChild"></span></div>
+</body>
+</html>