LibWeb: Implement 'State-preserving atomic move integration'

This was recently added to both the HTML and DOM specifications,
introducing the new moveBefore DOM API, as well as the new internal
'removing steps'.

See:

 * 432e8fb
 * eaf2ac7
This commit is contained in:
Shannon Booth 2025-03-08 12:45:26 +13:00 committed by Andrew Kaster
commit 31a3bc3681
Notes: github-actions[bot] 2025-04-26 14:46:43 +00:00
39 changed files with 1383 additions and 12 deletions

View file

@ -8,6 +8,7 @@
#include <AK/AnyOf.h>
#include <AK/Debug.h>
#include <AK/StringBuilder.h>
#include <LibJS/Runtime/NativeFunction.h>
#include <LibUnicode/CharacterTypes.h>
#include <LibUnicode/Locale.h>
#include <LibWeb/Animations/Animation.h>
@ -1284,6 +1285,11 @@ void Element::removed_from(Node* old_parent, Node& old_root)
}
}
void Element::moved_from(GC::Ptr<Node> old_parent)
{
Base::moved_from(old_parent);
}
void Element::children_changed(ChildrenChangedMetadata const* metadata)
{
Node::children_changed(metadata);
@ -2531,22 +2537,54 @@ void Element::enqueue_a_custom_element_upgrade_reaction(HTML::CustomElementDefin
enqueue_an_element_on_the_appropriate_element_queue();
}
// https://html.spec.whatwg.org/multipage/custom-elements.html#enqueue-a-custom-element-callback-reaction
void Element::enqueue_a_custom_element_callback_reaction(FlyString const& callback_name, GC::RootVector<JS::Value> arguments)
{
// 1. Let definition be element's custom element definition.
auto& definition = m_custom_element_definition;
// 2. Let callback be the value of the entry in definition's lifecycle callbacks with key callbackName.
auto callback_iterator = definition->lifecycle_callbacks().find(callback_name);
GC::Ptr<Web::WebIDL::CallbackType> callback;
if (auto callback_iterator = definition->lifecycle_callbacks().find(callback_name); callback_iterator != definition->lifecycle_callbacks().end())
callback = callback_iterator->value;
// 3. If callbackName is "connectedMoveCallback" and callback is null:
if (callback_name == HTML::CustomElementReactionNames::connectedMoveCallback && !callback) {
// 1. Let disconnectedCallback be the value of the entry in definition's lifecycle callbacks with key "disconnectedCallback".
GC::Ptr<WebIDL::CallbackType> disconnected_callback;
if (auto it = definition->lifecycle_callbacks().find(HTML::CustomElementReactionNames::disconnectedCallback); it != definition->lifecycle_callbacks().end())
disconnected_callback = it->value;
// 2. Let connectedCallback be the value of the entry in definition's lifecycle callbacks with key "connectedCallback".
GC::Ptr<WebIDL::CallbackType> connected_callback;
if (auto it = definition->lifecycle_callbacks().find(HTML::CustomElementReactionNames::connectedCallback); it != definition->lifecycle_callbacks().end())
connected_callback = it->value;
// 3. If connectedCallback and disconnectedCallback are null, then return.
if (!connected_callback && !disconnected_callback)
return;
// 4. Set callback to the following steps:
auto steps = JS::NativeFunction::create(realm(), [this, disconnected_callback, connected_callback](JS::VM&) {
GC::RootVector<JS::Value> no_arguments { heap() };
// 1. If disconnectedCallback is not null, then call disconnectedCallback with no arguments.
if (disconnected_callback)
(void)WebIDL::invoke_callback(*disconnected_callback, this, WebIDL::ExceptionBehavior::Report, no_arguments);
// 2. If connectedCallback is not null, then call connectedCallback with no arguments.
if (connected_callback)
(void)WebIDL::invoke_callback(*connected_callback, this, WebIDL::ExceptionBehavior::Report, no_arguments);
return JS::js_undefined(); }, 0, FlyString {}, &realm());
callback = realm().heap().allocate<WebIDL::CallbackType>(steps, realm());
}
// 3. If callback is null, then return.
if (callback_iterator == definition->lifecycle_callbacks().end())
if (!callback)
return;
if (!callback_iterator->value)
return;
// 4. If callbackName is "attributeChangedCallback", then:
// 5. If callbackName is "attributeChangedCallback":
if (callback_name == HTML::CustomElementReactionNames::attributeChangedCallback) {
// 1. Let attributeName be the first element of args.
VERIFY(!arguments.is_empty());
@ -2559,10 +2597,10 @@ void Element::enqueue_a_custom_element_callback_reaction(FlyString const& callba
return;
}
// 5. Add a new callback reaction to element's custom element reaction queue, with callback function callback and arguments args.
ensure_custom_element_reaction_queue().append(CustomElementCallbackReaction { .callback = callback_iterator->value, .arguments = move(arguments) });
// 6. Add a new callback reaction to element's custom element reaction queue, with callback function callback and arguments args.
ensure_custom_element_reaction_queue().append(CustomElementCallbackReaction { .callback = callback, .arguments = move(arguments) });
// 6. Enqueue an element on the appropriate element queue given element.
// 7. Enqueue an element on the appropriate element queue given element.
enqueue_an_element_on_the_appropriate_element_queue();
}

View file

@ -483,6 +483,8 @@ protected:
virtual void inserted() override;
virtual void removed_from(Node* old_parent, Node& old_root) override;
virtual void moved_from(GC::Ptr<Node> old_parent) override;
virtual void children_changed(ChildrenChangedMetadata const*) override;
virtual i32 default_tab_index_value() const;

View file

@ -3,6 +3,7 @@
* Copyright (c) 2021-2022, Linus Groh <linusg@serenityos.org>
* Copyright (c) 2021, Luke Wilde <lukew@serenityos.org>
* Copyright (c) 2024, Jelle Raaijmakers <jelle@ladybird.org>
* Copyright (c) 2025, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -1175,6 +1176,190 @@ WebIDL::ExceptionOr<GC::Ref<Node>> Node::clone_node(Document* document, bool sub
return GC::Ref { *copy };
}
// https://dom.spec.whatwg.org/#move
WebIDL::ExceptionOr<void> Node::move_node(Node& new_parent, Node* child)
{
// 1. If newParents shadow-including root is not the same as nodes shadow-including root, then throw a "HierarchyRequestError" DOMException.
if (&new_parent.shadow_including_root() != &shadow_including_root())
return WebIDL::HierarchyRequestError::create(realm(), "New parent is not in the same shadow tree"_string);
// NOTE: This has the side effect of ensuring that a move is only performed if newParents connected is nodes connected.
// 2. If node is a host-including inclusive ancestor of newParent, then throw a "HierarchyRequestError" DOMException.
if (is_host_including_inclusive_ancestor_of(new_parent))
return WebIDL::HierarchyRequestError::create(realm(), "New parent is an ancestor of this node"_string);
// 3. If child is non-null and its parent is not newParent, then throw a "NotFoundError" DOMException.
if (child && child->parent() != &new_parent)
return WebIDL::NotFoundError::create(realm(), "Child does not belong to the new parent"_string);
// 4. If node is not an Element or a CharacterData node, then throw a "HierarchyRequestError" DOMException.
if (!is<Element>(*this) && !is<CharacterData>(*this))
return WebIDL::HierarchyRequestError::create(realm(), "Invalid node type for insertion"_string);
// 5. If node is a Text node and newParent is a document, then throw a "HierarchyRequestError" DOMException.
if (is<Text>(*this) && is<Document>(new_parent))
return WebIDL::HierarchyRequestError::create(realm(), "Invalid node type for insertion"_string);
// 6. If newParent is a document, node is an Element node, and either newParent has an element child, child is a doctype,
// or child is non-null and a doctype is following child then throw a "HierarchyRequestError" DOMException.
if (is<Document>(new_parent) && is<Element>(*this)) {
if (new_parent.has_child_of_type<Element>() || is<DocumentType>(child) || (child && child->has_following_node_of_type_in_tree_order<DocumentType>()))
return WebIDL::HierarchyRequestError::create(realm(), "Invalid node type for insertion"_string);
}
// 7. Let oldParent be nodes parent.
auto* old_parent = this->parent();
// 8. Assert: oldParent is non-null.
VERIFY(old_parent);
// 9. Run the live range pre-remove steps, given node.
live_range_pre_remove();
// 10. For each NodeIterator object iterator whose roots node document is nodes node document, run the NodeIterator pre-remove steps given node and iterator.
document().for_each_node_iterator([&](NodeIterator& node_iterator) {
node_iterator.run_pre_removing_steps(*this);
});
// 11. Let oldPreviousSibling be nodes previous sibling.
auto* old_previous_sibling = previous_sibling();
// 12. Let oldNextSibling be nodes next sibling.
auto* old_next_sibling = next_sibling();
if (old_parent->is_connected()) {
// Since the tree structure is about to change, we need to invalidate both style and layout.
// In the future, we should find a way to only invalidate the parts that actually need it.
old_parent->invalidate_style(StyleInvalidationReason::NodeRemove);
// NOTE: If we didn't have a layout node before, rebuilding the layout tree isn't gonna give us one
// after we've been removed from the DOM.
if (layout_node())
old_parent->set_needs_layout_tree_update(true, SetNeedsLayoutTreeUpdateReason::NodeRemove);
}
// 13. Remove node from oldParents children.
old_parent->remove_child_impl(*this);
// 14. If node is assigned, then run assign slottables for nodes assigned slot.
if (auto assigned_slot = assigned_slot_for_node(*this))
assign_slottables(*assigned_slot);
// 15. If oldParents root is a shadow root, and oldParent is a slot whose assigned nodes is empty, then run signal a slot change for oldParent.
auto& old_parent_root = old_parent->root();
if (old_parent_root.is_shadow_root() && is<HTML::HTMLSlotElement>(*old_parent)) {
auto& old_parent_slot = static_cast<HTML::HTMLSlotElement&>(*old_parent);
if (old_parent_slot.assigned_nodes_internal().is_empty())
signal_a_slot_change(old_parent_slot);
}
// 16. If node has an inclusive descendant that is a slot:
auto has_descendent_slot = false;
for_each_in_inclusive_subtree_of_type<HTML::HTMLSlotElement>([&](auto const&) {
has_descendent_slot = true;
return TraversalDecision::Break;
});
if (has_descendent_slot) {
// 1. Run assign slottables for a tree with oldParents root.
assign_slottables_for_a_tree(old_parent_root);
// 2. Run assign slottables for a tree with node.
assign_slottables_for_a_tree(*this);
}
// 17. If child is non-null:
if (child) {
// 1. For each live range whose start node is newParent and start offset is greater than childs index, increase its start offset by 1.
for (auto& range : Range::live_ranges()) {
if (range->start_container() == &new_parent && range->start_offset() > child->index())
range->increase_start_offset({}, 1);
}
// 2. For each live range whose end node is newParent and end offset is greater than childs index, increase its end offset by 1.
for (auto& range : Range::live_ranges()) {
if (range->end_container() == &new_parent && range->end_offset() > child->index())
range->increase_end_offset({}, 1);
}
}
// 18. Let newPreviousSibling be childs previous sibling if child is non-null, and newParents last child otherwise.
auto* new_previous_sibling = child ? child->previous_sibling() : new_parent.last_child();
// 19. If child is null, then append node to newParents children.
if (!child) {
new_parent.append_child_impl(*this);
}
// 20. Otherwise, insert node into newParents children before childs index.
else {
new_parent.insert_before_impl(*this, child);
}
new_parent.invalidate_style(StyleInvalidationReason::NodeInsertBefore);
if (is_connected()) {
new_parent.set_needs_layout_tree_update(true, SetNeedsLayoutTreeUpdateReason::NodeInsertBefore);
}
// 21. If newParent is a shadow host whose shadow roots slot assignment is "named" and node is a slottable, then assign a slot for node.
if (is<Element>(new_parent) && is<Element>(*this)) {
auto& this_element = static_cast<Element&>(*this);
auto& new_parent_element = static_cast<Element&>(new_parent);
auto is_named_shadow_host = new_parent_element.is_shadow_host()
&& new_parent_element.shadow_root()->slot_assignment() == Bindings::SlotAssignmentMode::Named;
if (is_named_shadow_host && this_element.is_slottable())
assign_a_slot(this_element.as_slottable());
}
// 22. If newParents root is a shadow root, and newParent is a slot whose assigned nodes is empty, then run signal a slot change for newParent.
if (new_parent.root().is_shadow_root() && is<HTML::HTMLSlotElement>(new_parent)) {
auto& new_parent_slot = static_cast<HTML::HTMLSlotElement&>(new_parent);
if (new_parent_slot.assigned_nodes_internal().is_empty())
signal_a_slot_change(new_parent_slot);
}
// 23. Run assign slottables for a tree with nodes root.
assign_slottables_for_a_tree(root());
// 24. For each shadow-including inclusive descendant inclusiveDescendant of node, in shadow-including tree order:
for_each_shadow_including_inclusive_descendant([this, &new_parent, old_parent](Node& inclusive_descendant) {
// 1. If inclusiveDescendant is node, then run the moving steps with inclusiveDescendant and oldParent. Otherwise, run the moving
// steps with inclusiveDescendant and null.
if (&inclusive_descendant == this)
inclusive_descendant.moved_from(*old_parent);
else
inclusive_descendant.moved_from(nullptr);
// NOTE: Because the move algorithm is a separate primitive from insert and remove, it does not invoke the traditional insertion steps or
// removing steps for inclusiveDescendant.
// 2. If inclusiveDescendant is custom and newParent is connected, then enqueue a custom element callback reaction with inclusiveDescendant,
// callback name "connectedMoveCallback", and « ».
if (is<DOM::Element>(inclusive_descendant)) {
auto& element = static_cast<DOM::Element&>(inclusive_descendant);
if (element.is_custom() && new_parent.is_connected()) {
GC::RootVector<JS::Value> empty_arguments { vm().heap() };
element.enqueue_a_custom_element_callback_reaction(HTML::CustomElementReactionNames::connectedMoveCallback, move(empty_arguments));
}
}
return TraversalDecision::Continue;
});
// 25. Queue a tree mutation record for oldParent with « », « node », oldPreviousSibling, and oldNextSibling.
old_parent->queue_tree_mutation_record({}, { *this }, old_previous_sibling, old_next_sibling);
// 26. Queue a tree mutation record for newParent with « node », « », newPreviousSibling, and child.
new_parent.queue_tree_mutation_record({ *this }, {}, new_previous_sibling, child);
document().bump_dom_tree_version();
return {};
}
// https://dom.spec.whatwg.org/#clone-a-single-node
WebIDL::ExceptionOr<GC::Ref<Node>> Node::clone_single_node(Document& document) const
{
@ -1488,6 +1673,11 @@ void Node::removed_from(Node*, Node&)
play_or_cancel_animations_after_display_property_change();
}
// https://dom.spec.whatwg.org/#concept-node-move-ext
void Node::moved_from(GC::Ptr<Node>)
{
}
ParentNode* Node::parent_or_shadow_host()
{
if (is<ShadowRoot>(*this))

View file

@ -240,6 +240,8 @@ public:
WebIDL::ExceptionOr<GC::Ref<Node>> clone_single_node(Document&) const;
WebIDL::ExceptionOr<GC::Ref<Node>> clone_node_binding(bool subtree);
WebIDL::ExceptionOr<void> move_node(Node& new_parent, Node* child);
// NOTE: This is intended for the JS bindings.
bool has_child_nodes() const { return has_children(); }
GC::Ref<NodeList> child_nodes();
@ -298,6 +300,8 @@ public:
virtual void inserted();
virtual void post_connection();
virtual void removed_from(Node* old_parent, Node& old_root);
virtual void moved_from(GC::Ptr<Node> old_parent);
struct ChildrenChangedMetadata {
enum class Type {
Inserted,

View file

@ -244,6 +244,22 @@ WebIDL::ExceptionOr<void> ParentNode::replace_children(Vector<Variant<GC::Root<N
return {};
}
// https://dom.spec.whatwg.org/#dom-parentnode-movebefore
WebIDL::ExceptionOr<void> ParentNode::move_before(GC::Ref<Node> node, GC::Ptr<Node> child)
{
// 1. Let referenceChild be child.
auto reference_child = child;
// 2. If referenceChild is node, then set referenceChild to nodes next sibling.
if (reference_child == node)
reference_child = node->next_sibling();
// 3. Move node into this before referenceChild.
TRY(node->move_node(*this, reference_child));
return {};
}
// https://dom.spec.whatwg.org/#dom-document-getelementsbyclassname
GC::Ref<HTMLCollection> ParentNode::get_elements_by_class_name(StringView class_names)
{

View file

@ -35,6 +35,7 @@ public:
WebIDL::ExceptionOr<void> prepend(Vector<Variant<GC::Root<Node>, String>> const& nodes);
WebIDL::ExceptionOr<void> append(Vector<Variant<GC::Root<Node>, String>> const& nodes);
WebIDL::ExceptionOr<void> replace_children(Vector<Variant<GC::Root<Node>, String>> const& nodes);
WebIDL::ExceptionOr<void> move_before(GC::Ref<Node> node, GC::Ptr<Node> child);
GC::Ref<HTMLCollection> get_elements_by_class_name(StringView);

View file

@ -12,6 +12,8 @@ interface mixin ParentNode {
[CEReactions, Unscopable] undefined append((Node or DOMString)... nodes);
[CEReactions, Unscopable] undefined replaceChildren((Node or DOMString)... nodes);
[CEReactions] undefined moveBefore(Node node, Node? child);
Element? querySelector(DOMString selectors);
[NewObject] NodeList querySelectorAll(DOMString selectors);
};

View file

@ -15,6 +15,7 @@ namespace Web::HTML::CustomElementReactionNames {
__ENUMERATE_CUSTOM_ELEMENT_REACTION_NAME(connectedCallback) \
__ENUMERATE_CUSTOM_ELEMENT_REACTION_NAME(disconnectedCallback) \
__ENUMERATE_CUSTOM_ELEMENT_REACTION_NAME(adoptedCallback) \
__ENUMERATE_CUSTOM_ELEMENT_REACTION_NAME(connectedMoveCallback) \
__ENUMERATE_CUSTOM_ELEMENT_REACTION_NAME(attributeChangedCallback) \
__ENUMERATE_CUSTOM_ELEMENT_REACTION_NAME(formAssociatedCallback) \
__ENUMERATE_CUSTOM_ELEMENT_REACTION_NAME(formDisabledCallback) \

View file

@ -199,14 +199,15 @@ JS::ThrowCompletionOr<void> CustomElementRegistry::define(String const& name, We
auto& prototype = prototype_value.as_object();
// 3. Let lifecycleCallbacks be the ordered map «[ "connectedCallback" → null, "disconnectedCallback" → null, "adoptedCallback" → null,
// "attributeChangedCallback" → null ]».
// "connectedMoveCallback" → null, "attributeChangedCallback" → null ]».
lifecycle_callbacks.set(CustomElementReactionNames::connectedCallback, {});
lifecycle_callbacks.set(CustomElementReactionNames::disconnectedCallback, {});
lifecycle_callbacks.set(CustomElementReactionNames::adoptedCallback, {});
lifecycle_callbacks.set(CustomElementReactionNames::connectedMoveCallback, {});
lifecycle_callbacks.set(CustomElementReactionNames::attributeChangedCallback, {});
// 4. For each callbackName of the keys of lifecycleCallbacks:
for (auto const& callback_name : { CustomElementReactionNames::connectedCallback, CustomElementReactionNames::disconnectedCallback, CustomElementReactionNames::adoptedCallback, CustomElementReactionNames::attributeChangedCallback }) {
for (auto const& callback_name : { CustomElementReactionNames::connectedCallback, CustomElementReactionNames::disconnectedCallback, CustomElementReactionNames::adoptedCallback, CustomElementReactionNames::connectedMoveCallback, CustomElementReactionNames::attributeChangedCallback }) {
// 1. Let callbackValue be ? Get(prototype, callbackName).
auto callback_value = TRY(prototype.get(callback_name));

View file

@ -116,6 +116,14 @@ void FormAssociatedElement::form_node_was_removed()
reset_form_owner();
}
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#association-of-controls-and-forms:attr-fae-form-2
void FormAssociatedElement::form_node_was_moved()
{
// When a listed form-associated element's form attribute is set, changed, or removed, then the user agent must reset the form owner of that element.
if (m_form && &form_associated_element_to_html_element().root() != &m_form->root())
reset_form_owner();
}
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#association-of-controls-and-forms:category-listed-3
void FormAssociatedElement::form_node_attribute_changed(FlyString const& name, Optional<String> const& value)
{

View file

@ -45,6 +45,13 @@ private:
form_associated_element_was_removed(old_parent); \
} \
\
virtual void moved_from(GC::Ptr<DOM::Node> old_parent) override \
{ \
ElementBaseClass::moved_from(old_parent); \
form_node_was_moved(); \
form_associated_element_was_moved(old_parent); \
} \
\
virtual void attribute_changed(FlyString const& name, Optional<String> const& old_value, Optional<String> const& value, Optional<FlyString> const& namespace_) override \
{ \
ElementBaseClass::attribute_changed(name, old_value, value, namespace_); \
@ -137,10 +144,12 @@ protected:
virtual void form_associated_element_was_inserted() { }
virtual void form_associated_element_was_removed(DOM::Node*) { }
virtual void form_associated_element_was_moved(GC::Ptr<DOM::Node>) { }
virtual void form_associated_element_attribute_changed(FlyString const&, Optional<String> const&, Optional<FlyString> const&) { }
void form_node_was_inserted();
void form_node_was_removed();
void form_node_was_moved();
void form_node_attribute_changed(FlyString const&, Optional<String> const&);
private:

View file

@ -48,7 +48,16 @@ void HTMLSourceElement::inserted()
// count this as a relevant mutation for child.
}
// https://html.spec.whatwg.org/multipage/embedded-content.html#the-source-element:html-element-removing-steps
// https://html.spec.whatwg.org/multipage/embedded-content.html#the-source-element:the-source-element-17
void HTMLSourceElement::moved_from(GC::Ptr<DOM::Node> old_parent)
{
Base::moved_from(old_parent);
// FIXME: 1. If oldParent is a picture element, then for each child of oldParent's children, if child is an img
// element, then count this as a relevant mutation for child.
}
// https://html.spec.whatwg.org/multipage/embedded-content.html#the-source-element:the-source-element-18
void HTMLSourceElement::removed_from(DOM::Node* old_parent, DOM::Node& old_root)
{
// The source HTML element removing steps, given removedNode and oldParent, are:

View file

@ -24,6 +24,7 @@ private:
virtual void inserted() override;
virtual void removed_from(DOM::Node* old_parent, DOM::Node& old_root) override;
virtual void moved_from(GC::Ptr<Node> old_parent) override;
};
}

View file

@ -0,0 +1,6 @@
<!DOCTYPE html>
<link rel="help" href="https://crbug.com/338071841">
<div id="p"><span></span><!-- comment --></div>
<script>
p.moveBefore(p.lastChild, p.firstChild);
</script>

View file

@ -0,0 +1,38 @@
Harness status: OK
Found 32 tests
31 Pass
1 Fail
Pass If node is a host-including inclusive ancestor of parent, then throw a HierarchyRequestError DOMException.
Pass If node is not a DocumentFragment, DocumentType, Element, Text, ProcessingInstruction, or Comment node, then throw a HierarchyRequestError DOMException.
Pass If node is a Text node and parent is a document, then throw a HierarchyRequestError DOMException.
Pass If node is a doctype and parent is not a document, then throw a HierarchyRequestError DOMException.
Pass If node is a DocumentFragment with multiple elements and parent is a document, then throw a HierarchyRequestError DOMException.
Pass If node is a DocumentFragment with an element and parent is a document with another element, then throw a HierarchyRequestError DOMException.
Pass If node is an Element and parent is a document with another element, then throw a HierarchyRequestError DOMException.
Pass If node is a doctype and parent is a document with another doctype, then throw a HierarchyRequestError DOMException.
Pass If node is a doctype and parent is a document with an element, then throw a HierarchyRequestError DOMException.
Pass Calling moveBefore with a non-Node first argument must throw TypeError.
Pass Calling moveBefore with second argument missing, or other than Node, null, or undefined, must throw TypeError.
Pass moveBefore() method does not exist on non-ParentNode Nodes
Pass moveBefore() on disconnected parent throws a HierarchyRequestError
Pass moveBefore() with disconnected target node throws a HierarchyRequestError
Fail moveBefore() on a cross-document target node throws a HierarchyRequestError
Pass moveBefore() into a Document throws a HierarchyRequestError
Pass moveBefore() CharacterData into a Document
Pass moveBefore() with node being an inclusive ancestor of parent throws a HierarchyRequestError
Pass moveBefore() with a non-{Element, CharacterData} throws a HierarchyRequestError
Pass moveBefore with an Element or CharacterData succeeds
Pass moveBefore on a paragraph's Text node child
Pass moveBefore with reference child whose parent is NOT the destination parent (context node) throws a NotFoundError.
Pass moveBefore() returns undefined
Pass Moving a node before itself should not move the node
Pass Moving a node from a disconnected container to a disconnected new parent without a shared ancestor throws a HIERARCHY_REQUEST_ERR
Pass Moving a node from a disconnected container to a disconnected new parent in the same tree succeeds
Pass Moving a node from a disconnected container to a disconnected new parent in the same tree succeeds,also across shadow-roots
Pass Moving a node from disconnected->connected throws a HIERARCHY_REQUEST_ERR
Pass Moving a node from connected->disconnected throws a HIERARCHY_REQUEST_ERR
Pass No custom element callbacks are run during disconnected moveBefore()
Pass Invalid node hierarchy with null old parent does not crash
Pass Move disconnected iframe does not crash

View file

@ -0,0 +1,11 @@
Harness status: OK
Found 6 tests
6 Pass
Pass the disconnected/connected callbacks should be called when no other callback is defined
Pass the element should stay connected during the callbacks
Pass When connectedMoveCallback is defined, it is called instead of disconnectedCallback/connectedCallback
Pass Reactions to atomic move are called in order of element, not in order of operation
Pass When connectedCallback is not defined, no crash
Pass When disconnectedCallback is not defined, no crash

View file

@ -0,0 +1,11 @@
Harness status: OK
Found 5 tests
3 Pass
2 Fail
Pass focus-within should be updated when reparenting focused element directly
Pass focus-within should be updated when reparenting an element that has focus within
Pass focus-within should remain the same when moving to the same parent
Fail :focus-within should be eventually up to date when moving to an inert subtree
Fail :focus-within should be eventually up to date when moving to a subtree that would become inert via style

View file

@ -0,0 +1,8 @@
Harness status: OK
Found 3 tests
3 Pass
Pass moveBefore still results in range startContainer snapping up to parent when startContainer is moved
Pass moveBefore still causes range startContainer to snap up to parent, when startContainer ancestor is moved
Pass moveBefore still causes range endContainer to snap up to parent, when endContainer ancestor is moved

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass moveBefore-shadow-inside

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass moveBefore() is allowed in ShadowRoots (i.e., connected DocumentFragments)

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass MutationEvents (if supported by the UA) are suppressed during `moveBefore()`

View file

@ -0,0 +1,7 @@
Harness status: OK
Found 2 tests
2 Pass
Pass [Connected move] MutationObserver removal + insertion is tracked by moveBefore()
Pass [Disconnected move] MutationObserver removal + insertion is tracked by moveBefore()

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Fail
Fail Element nonce content attribute is not cleared after move

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass Moving an object element does not crash

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass when reparenting an open popover, it shouldn't be closed automatically

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass Moving a style inside the document should not affect whether it's applied

View file

@ -0,0 +1,349 @@
<!DOCTYPE html>
<title>Node.moveBefore</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<div id="log"></div>
<!-- First test shared pre-insertion checks that work similarly for replaceChild
and moveBefore -->
<script>
var insertFunc = Node.prototype.moveBefore;
</script>
<script src="../../pre-insertion-validation-hierarchy.js"></script>
<script>
preInsertionValidateHierarchy("moveBefore");
test(function() {
// WebIDL: first argument.
assert_throws_js(TypeError, function() { document.body.moveBefore(null, null) })
assert_throws_js(TypeError, function() { document.body.moveBefore(null, document.body.firstChild) })
assert_throws_js(TypeError, function() { document.body.moveBefore({'a':'b'}, document.body.firstChild) })
}, "Calling moveBefore with a non-Node first argument must throw TypeError.")
test(function() {
// WebIDL: second argument.
assert_throws_js(TypeError, function() { document.body.moveBefore(document.createTextNode("child")) })
assert_throws_js(TypeError, function() { document.body.moveBefore(document.createTextNode("child"), {'a':'b'}) })
}, "Calling moveBefore with second argument missing, or other than Node, null, or undefined, must throw TypeError.")
test(() => {
assert_false("moveBefore" in document.doctype, "moveBefore() not on DocumentType");
assert_false("moveBefore" in document.createTextNode("text"), "moveBefore() not on TextNode");
assert_false("moveBefore" in new Comment("comment"), "moveBefore() not on CommentNode");
assert_false("moveBefore" in document.createProcessingInstruction("foo", "bar"), "moveBefore() not on ProcessingInstruction");
}, "moveBefore() method does not exist on non-ParentNode Nodes");
// Pre-move validity, step 1:
// "If either parent or node are not connected, then throw a
// "HierarchyRequestError" DOMException."
//
// https://whatpr.org/dom/1307.html#concept-node-ensure-pre-move-validity
test(t => {
const connectedTarget = document.body.appendChild(document.createElement('div'));
const disconnectedDestination = document.createElement('div');
t.add_cleanup(() => connectedTarget.remove());
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
disconnectedDestination.moveBefore(connectedTarget, null);
});
}, "moveBefore() on disconnected parent throws a HierarchyRequestError");
test(t => {
const connectedDestination = document.body.appendChild(document.createElement('div'));
const disconnectedTarget = document.createElement('div');
t.add_cleanup(() => connectedDestination.remove());
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
connectedDestination.moveBefore(disconnectedTarget, null);
});
}, "moveBefore() with disconnected target node throws a HierarchyRequestError");
// Pre-move validity, step 2:
// "If parents shadow-including root is not the same as nodes shadow-including
// "root, then throw a "HierarchyRequestError" DOMException."
//
// https://whatpr.org/dom/1307.html#concept-node-ensure-pre-move-validity
test(t => {
const iframe = document.createElement('iframe');
document.body.append(iframe);
const connectedCrossDocChild = iframe.contentDocument.createElement('div');
const connectedLocalParent = document.querySelector('div');
t.add_cleanup(() => iframe.remove());
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
connectedLocalParent.moveBefore(connectedCrossDocChild, null);
});
}, "moveBefore() on a cross-document target node throws a HierarchyRequestError");
// Pre-move validity, step 3:
// "If parent is not a Document, DocumentFragment, or Element node, then throw a
// "HierarchyRequestError" DOMException."
//
// https://whatpr.org/dom/1307.html#concept-node-ensure-pre-move-validity
test(t => {
const iframe = document.body.appendChild(document.createElement('iframe'));
const innerBody = iframe.contentDocument.querySelector('body');
assert_throws_dom("HIERARCHY_REQUEST_ERR", iframe.contentWindow.DOMException, () => {
// Moving the body into the same place that it already is, which is a valid
// action in the normal case, when moving an Element directly under the
// document. This is not `moveBefore()`-specific behavior; it is consistent
// with traditional Document insertion rules, just like `insertBefore()`.
iframe.contentDocument.moveBefore(innerBody, null);
});
}, "moveBefore() into a Document throws a HierarchyRequestError");
test(t => {
const iframe = document.body.appendChild(document.createElement('iframe'));
const comment = iframe.contentDocument.createComment("comment");
iframe.contentDocument.body.append(comment);
iframe.contentDocument.moveBefore(comment, null);
assert_equals(comment.parentNode, iframe.contentDocument);
}, "moveBefore() CharacterData into a Document");
// Pre-move validity, step 4:
// "If node is a host-including inclusive ancestor of parent, then throw a
// "HierarchyRequestError" DOMException."
//
// https://whatpr.org/dom/1307.html#concept-node-ensure-pre-move-validity
test(t => {
const parentDiv = document.body.appendChild(document.createElement('div'));
const childDiv = parentDiv.appendChild(document.createElement('div'));
t.add_cleanup(() => {
parentDiv.remove();
childDiv.remove();
});
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
parentDiv.moveBefore(parentDiv, null);
}, "parent moving itself");
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
childDiv.moveBefore(parentDiv, null);
}, "Moving parent into immediate child");
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
childDiv.moveBefore(document.body, null);
}, "Moving grandparent into grandchild");
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
document.body.moveBefore(document.documentElement, childDiv);
}, "Moving documentElement (<html>) into a deeper child");
}, "moveBefore() with node being an inclusive ancestor of parent throws a " +
"HierarchyRequestError");
// Pre-move validity, step 5:
// "If node is not an Element or a CharacterData node, then throw a
// "HierarchyRequestError" DOMException."
//
// https://whatpr.org/dom/1307.html#concept-node-ensure-pre-move-validity
test(t => {
assert_true(document.doctype.isConnected);
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
document.body.moveBefore(document.doctype, null);
}, "DocumentType throws");
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
document.body.moveBefore(new DocumentFragment(), null);
}, "DocumentFragment throws");
const doc = document.implementation.createHTMLDocument("title");
assert_true(doc.isConnected);
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
document.body.moveBefore(doc, null);
});
}, "moveBefore() with a non-{Element, CharacterData} throws a HierarchyRequestError");
promise_test(async t => {
const text = new Text("child text");
document.body.prepend(text);
const childElement = document.createElement('p');
document.body.prepend(childElement);
const comment = new Comment("comment");
document.body.prepend(comment);
t.add_cleanup(() => {
text.remove();
childElement.remove();
comment.remove();
});
// Wait until style is computed once, then continue after. This is necessary
// to reproduce a Chromium crash regression with moving Comment nodes in the
// DOM.
await new Promise(r => {
requestAnimationFrame(() => requestAnimationFrame(() => r()));
});
document.body.moveBefore(text, null);
assert_equals(document.body.lastChild, text);
document.body.moveBefore(childElement, null);
assert_equals(document.body.lastChild, childElement);
document.body.moveBefore(text, null);
assert_equals(document.body.lastChild, text);
document.body.moveBefore(comment, null);
assert_equals(document.body.lastChild, comment);
}, "moveBefore with an Element or CharacterData succeeds");
test(t => {
const p = document.createElement('p');
p.textContent = "Some content";
document.body.prepend(p);
const text_node = p.firstChild;
// The Text node is *inside* the paragraph.
assert_equals(text_node.textContent, "Some content");
assert_not_equals(document.body.lastChild, text_node);
t.add_cleanup(() => {
text_node.remove();
p.remove();
});
document.body.moveBefore(p.firstChild, null);
assert_equals(document.body.lastChild, text_node);
}, "moveBefore on a paragraph's Text node child");
// Pre-move validity, step 6:
// "If child is non-null and its parent is not parent, then throw a
// "NotFoundError" DOMException."
//
// https://whatpr.org/dom/1307.html#concept-node-ensure-pre-move-validity
test(t => {
const a = document.body.appendChild(document.createElement("div"));
const b = document.body.appendChild(document.createElement("div"));
const c = document.body.appendChild(document.createElement("div"));
t.add_cleanup(() => {
a.remove();
b.remove();
c.remove();
});
assert_throws_dom("NotFoundError", () => {
a.moveBefore(b, c);
});
}, "moveBefore with reference child whose parent is NOT the destination " +
"parent (context node) throws a NotFoundError.")
test(() => {
const a = document.body.appendChild(document.createElement("div"));
const b = document.createElement("div");
const c = document.createElement("div");
a.append(b);
a.append(c);
assert_array_equals(a.childNodes, [b, c]);
assert_equals(a.moveBefore(c, b), undefined, "moveBefore() returns undefined");
assert_array_equals(a.childNodes, [c, b]);
}, "moveBefore() returns undefined");
test(() => {
const a = document.body.appendChild(document.createElement("div"));
const b = document.createElement("div");
const c = document.createElement("div");
a.append(b);
a.append(c);
assert_array_equals(a.childNodes, [b, c]);
a.moveBefore(b, b);
assert_array_equals(a.childNodes, [b, c]);
a.moveBefore(c, c);
assert_array_equals(a.childNodes, [b, c]);
}, "Moving a node before itself should not move the node");
test(() => {
const disconnectedOrigin = document.createElement('div');
const disconnectedDestination = document.createElement('div');
const p = disconnectedOrigin.appendChild(document.createElement('p'));
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => {
disconnectedDestination.moveBefore(p, null);
});
}, "Moving a node from a disconnected container to a disconnected new parent " +
"without a shared ancestor throws a HIERARCHY_REQUEST_ERR");
test(() => {
const disconnectedOrigin = document.createElement('div');
const disconnectedDestination = disconnectedOrigin.appendChild(document.createElement('div'));
const p = disconnectedOrigin.appendChild(document.createElement('p'));
disconnectedDestination.moveBefore(p, null);
assert_equals(disconnectedDestination.firstChild, p, "<p> Was successfully moved");
}, "Moving a node from a disconnected container to a disconnected new parent in the same tree succeeds");
test(() => {
const disconnectedOrigin = document.createElement('div');
const disconnectedHost = disconnectedOrigin.appendChild(document.createElement('div'));
const p = disconnectedOrigin.appendChild(document.createElement('p'));
const shadow = disconnectedHost.attachShadow({mode: "closed"});
const disconnectedDestination = shadow.appendChild(document.createElement('div'));
disconnectedDestination.moveBefore(p, null);
assert_equals(disconnectedDestination.firstChild, p, "<p> Was successfully moved");
}, "Moving a node from a disconnected container to a disconnected new parent in the same tree succeeds," +
"also across shadow-roots");
test(() => {
const disconnectedOrigin = document.createElement('div');
const connectedDestination = document.body.appendChild(document.createElement('div'));
const p = disconnectedOrigin.appendChild(document.createElement('p'));
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => connectedDestination.moveBefore(p, null));
}, "Moving a node from disconnected->connected throws a HIERARCHY_REQUEST_ERR");
test(() => {
const connectedOrigin = document.body.appendChild(document.createElement('div'));
const disconnectedDestination = document.createElement('div');
const p = connectedOrigin.appendChild(document.createElement('p'));
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => disconnectedDestination.moveBefore(p, null));
}, "Moving a node from connected->disconnected throws a HIERARCHY_REQUEST_ERR");
promise_test(async t => {
let reactions = [];
const element_name = `ce-${performance.now()}`;
customElements.define(element_name,
class MockCustomElement extends HTMLElement {
connectedMoveCallback() { reactions.push("connectedMove"); }
connectedCallback() { reactions.push("connected"); }
disconnectedCallback() { reactions.push("disconnected"); }
});
const oldParent = document.createElement('div');
const newParent = oldParent.appendChild(document.createElement('div'));
const element = oldParent.appendChild(document.createElement(element_name));
t.add_cleanup(() => {
element.remove();
newParent.remove();
oldParent.remove();
});
// Wait a microtask to let any custom element reactions run (should be none,
// since the initial parent is disconnected).
await Promise.resolve();
newParent.moveBefore(element, null);
await Promise.resolve();
assert_array_equals(reactions, []);
}, "No custom element callbacks are run during disconnected moveBefore()");
// This is a regression test for a Chromium crash: https://crbug.com/388934346.
test(t => {
// This test caused a crash in Chromium because after the detection of invalid
// /node hierarchy, and throwing the JS error, we did not return from native
// code, and continued to operate on the node tree on bad assumptions.
const outer = document.createElement('div');
const div = outer.appendChild(document.createElement('div'));
assert_throws_dom("HIERARCHY_REQUEST_ERR", () => div.moveBefore(outer, null));
}, "Invalid node hierarchy with null old parent does not crash");
test(t => {
const outerDiv = document.createElement('div');
const innerDiv = outerDiv.appendChild(document.createElement('div'));
const iframe = innerDiv.appendChild(document.createElement('iframe'));
outerDiv.moveBefore(iframe, null);
}, "Move disconnected iframe does not crash");
</script>

View file

@ -0,0 +1,133 @@
<!DOCTYPE html>
<title>Node.moveBefore custom element reactions</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<script>
;
</script>
<body>
<section id="section"></section>
<script>
promise_test(async t => {
const ce = document.getElementById("ce");
let reactions = [];
const element_name = `ce-${performance.now()}`;
customElements.define(element_name,
class MockCustomElement extends HTMLElement {
connectedCallback() { reactions.push("connected"); }
disconnectedCallback() { reactions.push("disconnected"); }
});
const element = document.createElement(element_name);
t.add_cleanup(() => element.remove());
document.body.append(element);
await Promise.resolve();
reactions = [];
document.getElementById("section").moveBefore(element, null);
await Promise.resolve();
assert_array_equals(reactions, ["disconnected", "connected"]);
}, "the disconnected/connected callbacks should be called when no other callback is defined");
promise_test(async t => {
const ce = document.getElementById("ce");
let reactions = [];
const element_name = `ce-${performance.now()}`;
customElements.define(element_name,
class MockCustomElement extends HTMLElement {
connectedCallback() { reactions.push(this.isConnected); }
disconnectedCallback() { reactions.push(this.isConnected); }
});
const element = document.createElement(element_name);
t.add_cleanup(() => element.remove());
document.body.append(element);
await Promise.resolve();
reactions = [];
document.getElementById("section").moveBefore(element, null);
await Promise.resolve();
assert_array_equals(reactions, [true, true]);
}, "the element should stay connected during the callbacks");
promise_test(async t => {
const ce = document.getElementById("ce");
let reactions = [];
const element_name = `ce-${performance.now()}`;
customElements.define(element_name,
class MockCustomElement extends HTMLElement {
connectedMoveCallback() { reactions.push("connectedMove"); }
connectedCallback() { reactions.push("connected"); }
disconnectedCallback() { reactions.push("disconnected"); }
});
const element = document.createElement(element_name);
t.add_cleanup(() => element.remove());
document.body.append(element);
await Promise.resolve();
reactions = [];
document.getElementById("section").moveBefore(element, null);
await Promise.resolve();
assert_array_equals(reactions, ["connectedMove"]);
}, "When connectedMoveCallback is defined, it is called instead of disconnectedCallback/connectedCallback");
promise_test(async t => {
const ce = document.getElementById("ce");
let reactions = [];
const outer_element_name = `ce-${performance.now()}-outer`;
const inner_element_name = `ce-${performance.now()}-inner`;
customElements.define(outer_element_name,
class MockCustomElement extends HTMLElement {
connectedCallback() { reactions.push("outer connected"); }
disconnectedCallback() { reactions.push("outer disconnected"); }
});
customElements.define(inner_element_name,
class MockCustomElement extends HTMLElement {
connectedCallback() { reactions.push("inner connected"); }
disconnectedCallback() { reactions.push("inner disconnected"); }
});
const outer = document.createElement(outer_element_name);
const inner = document.createElement(inner_element_name);
t.add_cleanup(() => outer.remove());
outer.append(inner);
document.body.append(outer);
await Promise.resolve();
reactions = [];
document.getElementById("section").moveBefore(outer, null);
await Promise.resolve();
assert_array_equals(reactions, ["outer disconnected", "outer connected", "inner disconnected", "inner connected"]);
}, "Reactions to atomic move are called in order of element, not in order of operation");
promise_test(async t => {
const ce = document.getElementById("ce");
let reactions = [];
const element_name = `ce-${performance.now()}`;
customElements.define(element_name,
class MockCustomElement extends HTMLElement {
disconnectedCallback() { reactions.push("disconnected"); }
});
const element = document.createElement(element_name);
t.add_cleanup(() => element.remove());
document.body.append(element);
await Promise.resolve();
reactions = [];
document.getElementById("section").moveBefore(element, null);
await Promise.resolve();
assert_array_equals(reactions, ["disconnected"]);
}, "When connectedCallback is not defined, no crash");
promise_test(async t => {
const ce = document.getElementById("ce");
let reactions = [];
const element_name = `ce-${performance.now()}`;
customElements.define(element_name,
class MockCustomElement extends HTMLElement {
connectedCallback() { reactions.push("connected"); }
});
const element = document.createElement(element_name);
t.add_cleanup(() => element.remove());
document.body.append(element);
await Promise.resolve();
reactions = [];
document.getElementById("section").moveBefore(element, null);
await Promise.resolve();
assert_array_equals(reactions, ["connected"]);
}, "When disconnectedCallback is not defined, no crash");
</script>
</body>

View file

@ -0,0 +1,81 @@
<!DOCTYPE html>
<title>moveBefore should handle focus bubbling correctly</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<body>
<section id="old_parent">
<button id="button" tabindex="1">Button</button>
</section>
<section id="new_parent">
</section>
<section id="inert_parent" inert>
</section>
<section id="inert_when_not_empty_parent">
</section>
<style>
#inert_when_not_empty_parent:has(button) {
display: none;
}
</style>
<script>
function assert_focus_within(expected) {
const element_to_string = e => e.id || e.nodeName;
assert_array_equals(
Array.from(document.querySelectorAll(":focus-within"), element_to_string),
expected.map(element_to_string));
}
test(t => {
const old_parent = document.querySelector("#old_parent");
const button = document.querySelector("#button");
t.add_cleanup(() => old_parent.append(button));
button.focus();
assert_focus_within([document.documentElement, document.body, old_parent, button]);
new_parent.moveBefore(button, null);
assert_focus_within([document.documentElement, document.body, new_parent, button]);
}, "focus-within should be updated when reparenting focused element directly");
test(t => {
const old_parent = document.querySelector("#old_parent");
const button = document.querySelector("#button");
t.add_cleanup(() => document.body.append(old_parent));
button.focus();
new_parent.moveBefore(old_parent, null);
assert_focus_within([document.documentElement, document.body, new_parent, old_parent, button]);
}, "focus-within should be updated when reparenting an element that has focus within");
test(t => {
const old_parent = document.querySelector("#old_parent");
const button = document.querySelector("#button");
t.add_cleanup(() => old_parent.append(button));
button.focus();
old_parent.moveBefore(button, null);
assert_focus_within([document.documentElement, document.body, old_parent, button]);
}, "focus-within should remain the same when moving to the same parent");
promise_test(async t => {
const old_parent = document.querySelector("#old_parent");
const inert_parent= document.querySelector("#inert_parent");
const button = document.querySelector("#button");
t.add_cleanup(() => old_parent.append(button));
button.focus();
inert_parent.moveBefore(button, null);
assert_focus_within([document.documentElement, document.body, inert_parent, button]);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
assert_focus_within([]);
}, ":focus-within should be eventually up to date when moving to an inert subtree");
promise_test(async t => {
const old_parent = document.querySelector("#old_parent");
const inert_when_not_empty_parent = document.querySelector("#inert_when_not_empty_parent");
const button = document.querySelector("#button");
t.add_cleanup(() => old_parent.append(button));
button.focus();
inert_when_not_empty_parent.moveBefore(button, null);
assert_focus_within([document.documentElement, document.body, inert_when_not_empty_parent, button]);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
assert_focus_within([]);
}, ":focus-within should be eventually up to date when moving to a subtree that would become inert via style");
</script>

View file

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<script src='../../../../resources/testharness.js'></script>
<script src='../../../../resources/testharnessreport.js'></script>
</head>
<body>
</body>
<script>
'use strict';
test(t => {
document.body.innerHTML = `
<div id=old_parent>
<span id=start>RangeStartTarget</span>
<span id=middle>Middle</span>
<span id=end>RangeEndTarget</span>
</div>`;
const range = new Range();
range.setStart(start, 0);
range.setEnd(end, 0);
assert_true(range.intersectsNode(middle), "Intersection before move");
// Moves `start` span to the very bottom of the container.
old_parent.moveBefore(start, null);
// In an ordinary removal, when a node whose descendant is the start (or end)
// of a live range is removed, the range's start is set to the removed node's
// parent. For now, the same thing happens during `moveBefore()`.
assert_equals(range.startContainer, old_parent, "startContainer updates during move");
assert_equals(range.endContainer, end, "endContainer does not update after move");
assert_true(range.intersectsNode(middle), "adjusted range still intersects " +
"middle node after move");
}, "moveBefore still results in range startContainer snapping up to parent " +
"when startContainer is moved");
test(t => {
document.body.innerHTML = `
<div id=old_parent>
<div id=movable_div>
<span id=start>RangeStartTarget</span>
<span id=middle>Middle</span>
</div>
<span id=end>RangeEndTarget</span>
</div>
<div id=new_parent></div>`;
const range = new Range();
range.setStart(start, 0);
range.setEnd(end, 0);
assert_true(range.intersectsNode(middle), "Intersection before move");
new_parent.moveBefore(movable_div, null);
assert_equals(range.startContainer, old_parent, "startContainer still " +
"updates during move, to snap to parent");
assert_equals(range.endContainer, end, "endContainer does not update after move");
assert_false(range.intersectsNode(middle), "range no longer intersects " +
"middle node after move, since middle node was moved outside of the range");
}, "moveBefore still causes range startContainer to snap up to parent, when " +
"startContainer ancestor is moved");
test(t => {
document.body.innerHTML = `
<div id=old_parent>
<span id=start>RangeStartTarget</span>
<div id=movable_div>
<span id=end>RangeEndTarget</span>
</div>
</div>
<div id=new_parent>
<span id=middle>Middle</span>
</div>`;
const range = new Range();
range.setStart(start, 0);
range.setEnd(end, 0);
assert_false(range.intersectsNode(middle), "No intersection before move");
new_parent.moveBefore(movable_div, null);
assert_equals(range.startContainer, start, "startContainer does not update " +
"after move");
assert_equals(range.endContainer, old_parent, "endContainer still snaps up " +
"to parent after move");
assert_false(range.intersectsNode(middle), "adjusted range still does not " +
"intersect middle node after move");
}, "moveBefore still causes range endContainer to snap up to parent, when " +
"endContainer ancestor is moved");
</script>
</html>

View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<body>
<style>
section {
position: relative;
}
#item {
position: absolute;
inset: 0;
}
#section1 {
width: 200px;
height: 200px;
}
#section2 {
width: 300px;
height: 300px;
}
</style>
<section id="section1">
<div id="item">
<template shadowRootMode="open">
<style>
div {
position: absolute;
inset: 0;
background-color: var(--color, red);
}
</style>
<div></div>
</template>
</div>
</section>
<section id="section2">
</section>
<script>
promise_test(async () => {
const item = document.querySelector("#item");
document.querySelector("#section2").moveBefore(item, null);
await new Promise(resolve => requestAnimationFrame(() => resolve()));
assert_equals(item.shadowRoot.querySelector("div").getBoundingClientRect().width, 300);
});
</script>

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<body>
<div id=shadowTarget></div>
</body>
<script>
test(() => {
shadowTarget.attachShadow({mode: 'open'});
const child1 = document.createElement('p');
child1.textContent = 'Child1';
const child2 = document.createElement('p');
child2.textContent = 'Child2';
shadowTarget.shadowRoot.append(child1, child2);
shadowTarget.shadowRoot.moveBefore(child2, child1);
assert_equals(shadowTarget.shadowRoot.firstChild, child2, "Original lastChild is now firstChild");
assert_equals(shadowTarget.shadowRoot.lastChild, child1, "Original firstChild is now lastChild");
}, "moveBefore() is allowed in ShadowRoots (i.e., connected DocumentFragments)");
</script>

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<title>Mutation events are suppressed during moveBefore()</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<body>
<p id=reference>reference</p>
<p id=target>target</p>
<script>
const reference = document.querySelector('#reference');
const target = document.querySelector('#target');
test(() => {
target.addEventListener('DOMNodeInserted', () => assert_unreached('DOMNodeInserted not called'));
target.addEventListener('DOMNodeRemoved', () => assert_unreached('DOMNodeRemoved not called'));
document.body.moveBefore(target, reference);
}, "MutationEvents (if supported by the UA) are suppressed during `moveBefore()`");
</script>
</body>

View file

@ -0,0 +1,52 @@
<!DOCTYPE html>
<title>slotchanged event</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<body>
<div id=oldParent>
<p id=target></p>
</div>
<div id=newParent></div>
<script>
async function runTest(oldParent, target, newParent) {
const observations = [];
const observer = new MutationObserver(mutationList => observations.push(mutationList));
observer.observe(oldParent, {childList: true});
observer.observe(target, {childList: true});
observer.observe(newParent, {childList: true});
newParent.moveBefore(target, null);
// Wait for microtasks to settle.
await new Promise(resolve => queueMicrotask(resolve));
assert_equals(observations.length, 1, "MutationObserver has emitted a single mutation list");
assert_equals(observations[0].length, 2, "Mutation list has two MutationRecords");
const removalRecord = observations[0][0];
const insertionRecord = observations[0][1];
assert_equals(removalRecord.target, oldParent, "removalRecord target is correct");
assert_equals(removalRecord.removedNodes[0], target, "removedNodes contains the moved node");
assert_equals(insertionRecord.target, newParent, "insertionRecord target is correct");
assert_equals(insertionRecord.addedNodes[0], target, "addedNodes contains the moved node");
observer.disconnect();
}
promise_test(async t => {
await runTest(oldParent, target, newParent);
}, "[Connected move] MutationObserver removal + insertion is tracked by moveBefore()");
promise_test(async t => {
const oldParent = document.createElement('div');
const target = document.createElement('p');
const newParent = document.createElement('div');
// We must append `newParent` as well, since the origin and destination nodes
// must share the same shadow-including root.
oldParent.append(target, newParent);
await runTest(oldParent, target, newParent);
}, "[Disconnected move] MutationObserver removal + insertion is tracked by moveBefore()");
</script>

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<title>Nonce attribute is not cleared</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<body>
<section id="new_parent"></section>
<script>
test(t => {
const div = document.createElement('div');
document.body.append(div);
const kNonce = '8IBTHwOdqNKAWeKl7plt8g==';
div.setAttribute('nonce', kNonce);
assert_equals(div.getAttribute('nonce'), kNonce);
new_parent.moveBefore(div, null);
assert_equals(div.getAttribute('nonce'), kNonce);
new_parent.insertBefore(div, null);
assert_equals(div.getAttribute('nonce'), "");
}, "Element nonce content attribute is not cleared after move");
</script>

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<title>Object element moveBefore() regression test</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<body>
<b id="p"><object>
<script>
test(t => {
// Per https://crbug.com/373924127, simply moving an object element would
// crash, due to an internal subframe count mechanism getting out of sync.
p.moveBefore(p.lastChild, p.firstChild);
}, "Moving an object element does not crash");
</script>

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<title>moveBefore should not close a popover</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<body>
<section id="old_parent">
<div popover>
Popover
</div>
</section>
<section id="new_parent">
</section>
<script>
promise_test(async t => {
const popover = document.querySelector("div[popover]");
popover.showPopover();
await new Promise(resolve => requestAnimationFrame(() => resolve()));
assert_equals(document.querySelector(":popover-open"), popover);
document.querySelector("#new_parent").moveBefore(popover, null);
assert_equals(document.querySelector(":popover-open"), popover);
}, "when reparenting an open popover, it shouldn't be closed automatically");
</script>

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<script src='../../../../resources/testharness.js'></script>
<script src='../../../../resources/testharnessreport.js'></script>
<body>
</body>
<script>
'use strict';
promise_test(async t => {
const link = document.createElement("link");
link.href = "data:text/css,body{background: green}";
link.rel = "stylesheet";
t.add_cleanup(() => link.remove());
document.body.append(link);
const backgroundColorBefore = getComputedStyle(document.body).backgroundColor;
document.body.moveBefore(link, null);
assert_equals(getComputedStyle(document.body).backgroundColor, backgroundColorBefore);
}, "Moving a style inside the document should not affect whether it's applied");
</script>
</html>

View file

@ -0,0 +1,86 @@
/**
* Validations where `child` argument is irrelevant.
* @param {Function} methodName
*/
function preInsertionValidateHierarchy(methodName) {
function insert(parent, node) {
if (parent[methodName].length > 1) {
// This is for insertBefore(). We can't blindly pass `null` for all methods
// as doing so will move nodes before validation.
parent[methodName](node, null);
} else {
parent[methodName](node);
}
}
// Step 2
test(() => {
const doc = document.implementation.createHTMLDocument("title");
assert_throws_dom("HierarchyRequestError", () => insert(doc.body, doc.body));
assert_throws_dom("HierarchyRequestError", () => insert(doc.body, doc.documentElement));
}, "If node is a host-including inclusive ancestor of parent, then throw a HierarchyRequestError DOMException.");
// Step 4
test(() => {
const doc = document.implementation.createHTMLDocument("title");
const doc2 = document.implementation.createHTMLDocument("title2");
assert_throws_dom("HierarchyRequestError", () => insert(doc, doc2));
}, "If node is not a DocumentFragment, DocumentType, Element, Text, ProcessingInstruction, or Comment node, then throw a HierarchyRequestError DOMException.");
// Step 5, in case of inserting a text node into a document
test(() => {
const doc = document.implementation.createHTMLDocument("title");
assert_throws_dom("HierarchyRequestError", () => insert(doc, doc.createTextNode("text")));
}, "If node is a Text node and parent is a document, then throw a HierarchyRequestError DOMException.");
// Step 5, in case of inserting a doctype into a non-document
test(() => {
const doc = document.implementation.createHTMLDocument("title");
const doctype = doc.childNodes[0];
assert_throws_dom("HierarchyRequestError", () => insert(doc.createElement("a"), doctype));
}, "If node is a doctype and parent is not a document, then throw a HierarchyRequestError DOMException.")
// Step 6, in case of DocumentFragment including multiple elements
test(() => {
const doc = document.implementation.createHTMLDocument("title");
doc.documentElement.remove();
const df = doc.createDocumentFragment();
df.appendChild(doc.createElement("a"));
df.appendChild(doc.createElement("b"));
assert_throws_dom("HierarchyRequestError", () => insert(doc, df));
}, "If node is a DocumentFragment with multiple elements and parent is a document, then throw a HierarchyRequestError DOMException.");
// Step 6, in case of DocumentFragment has multiple elements when document already has an element
test(() => {
const doc = document.implementation.createHTMLDocument("title");
const df = doc.createDocumentFragment();
df.appendChild(doc.createElement("a"));
assert_throws_dom("HierarchyRequestError", () => insert(doc, df));
}, "If node is a DocumentFragment with an element and parent is a document with another element, then throw a HierarchyRequestError DOMException.");
// Step 6, in case of an element
test(() => {
const doc = document.implementation.createHTMLDocument("title");
const el = doc.createElement("a");
assert_throws_dom("HierarchyRequestError", () => insert(doc, el));
}, "If node is an Element and parent is a document with another element, then throw a HierarchyRequestError DOMException.");
// Step 6, in case of a doctype when document already has another doctype
test(() => {
const doc = document.implementation.createHTMLDocument("title");
const doctype = doc.childNodes[0].cloneNode();
doc.documentElement.remove();
assert_throws_dom("HierarchyRequestError", () => insert(doc, doctype));
}, "If node is a doctype and parent is a document with another doctype, then throw a HierarchyRequestError DOMException.");
// Step 6, in case of a doctype when document has an element
if (methodName !== "prepend") {
// Skip `.prepend` as this doesn't throw if `child` is an element
test(() => {
const doc = document.implementation.createHTMLDocument("title");
const doctype = doc.childNodes[0].cloneNode();
doc.childNodes[0].remove();
assert_throws_dom("HierarchyRequestError", () => insert(doc, doctype));
}, "If node is a doctype and parent is a document with an element, then throw a HierarchyRequestError DOMException.");
}
}