LibWeb: Avoid sibling walks for last-child invalidation

Track last-child and backward positional selector dependencies
separately on parent nodes. A last-child or only-child selector can only
change the element at the trailing edge, so insertions and removals can
invalidate that element directly instead of walking every previous
sibling.

Keep the previous-sibling walk for selectors such as nth-last-child and
last-of-type, where every previous element's from-end position may
change.
This commit is contained in:
Andreas Kling 2026-06-08 12:47:39 +02:00 committed by Andreas Kling
parent 62a5ee7ff9
commit bae4434780
5 changed files with 31 additions and 25 deletions

View file

@ -89,27 +89,21 @@ void invalidate_structurally_affected_siblings(DOM::Node& node, DOM::StyleInvali
}
if (is_insertion_or_removal) {
// OPTIMIZATION: Only walk previous siblings if the parent has been observed to contain a child that matches a
// pseudo-class whose match result can depend on siblings after that element. Otherwise, no
// previous sibling can possibly need invalidation due to this insertion or removal.
if (auto* parent_node = as_if<DOM::ParentNode>(node.parent()); parent_node && parent_node->has_child_affected_by_backward_structural_changes()) {
// OPTIMIZATION: :last-child / :only-child can only flip for the element transitioning into or out of the
// trailing position, so handle that directly. Only walk all previous siblings if the parent has
// children affected by selectors whose from-end index can change, such as :nth-last-child.
if (auto* parent_node = as_if<DOM::ParentNode>(node.parent()); parent_node && parent_node->has_child_affected_by_last_child_pseudo_class()) {
if (last_child_transition_target && last_child_transition_target->affected_by_last_child_pseudo_class())
mark_sibling_for_style_update(*last_child_transition_target);
}
if (auto* parent_node = as_if<DOM::ParentNode>(node.parent()); parent_node && parent_node->has_child_affected_by_backward_positional_pseudo_class()) {
auto& counters = node.document().style_invalidation_counters();
for (auto* sibling = node.previous_sibling(); sibling; sibling = sibling->previous_sibling()) {
++counters.previous_sibling_invalidation_walk_visits;
auto* element = as_if<DOM::Element>(sibling);
if (!element)
continue;
bool needs_mark = false;
if (element->affected_by_backward_positional_pseudo_class()) {
// :nth-last-child / :nth-last-of-type / :last-of-type / :only-of-type all need
// every previous sibling re-evaluated since their from-end indices shift.
needs_mark = true;
} else if (element->affected_by_last_child_pseudo_class() && element == last_child_transition_target) {
// :last-child and :only-child only flip for the element transitioning into/out of
// the trailing position.
needs_mark = true;
}
if (needs_mark)
if (element->affected_by_backward_positional_pseudo_class())
mark_sibling_for_style_update(*element);
}
}

View file

@ -161,7 +161,7 @@ void Element::set_affected_by_last_child_pseudo_class(bool value)
m_affected_by_last_child_pseudo_class = value;
if (value) {
if (auto* parent = as_if<ParentNode>(this->parent()))
parent->set_has_child_affected_by_backward_structural_changes(true);
parent->set_has_child_affected_by_last_child_pseudo_class(true);
}
}
@ -170,7 +170,7 @@ void Element::set_affected_by_backward_positional_pseudo_class(bool value)
m_affected_by_backward_positional_pseudo_class = value;
if (value) {
if (auto* parent = as_if<ParentNode>(this->parent()))
parent->set_has_child_affected_by_backward_structural_changes(true);
parent->set_has_child_affected_by_backward_positional_pseudo_class(true);
}
}

View file

@ -42,8 +42,11 @@ public:
GC::Ptr<Element> get_element_by_id(FlyString const& id) const;
bool has_child_affected_by_backward_structural_changes() const { return m_has_child_affected_by_backward_structural_changes; }
void set_has_child_affected_by_backward_structural_changes(bool value) { m_has_child_affected_by_backward_structural_changes = value; }
bool has_child_affected_by_last_child_pseudo_class() const { return m_has_child_affected_by_last_child_pseudo_class; }
void set_has_child_affected_by_last_child_pseudo_class(bool value) { m_has_child_affected_by_last_child_pseudo_class = value; }
bool has_child_affected_by_backward_positional_pseudo_class() const { return m_has_child_affected_by_backward_positional_pseudo_class; }
void set_has_child_affected_by_backward_positional_pseudo_class(bool value) { m_has_child_affected_by_backward_positional_pseudo_class = value; }
protected:
ParentNode(JS::Realm& realm, Document& document, NodeType type)
@ -60,7 +63,8 @@ protected:
private:
GC::Ptr<HTMLCollection> m_children;
bool m_has_child_affected_by_backward_structural_changes { false };
bool m_has_child_affected_by_last_child_pseudo_class { false };
bool m_has_child_affected_by_backward_positional_pseudo_class { false };
};
template<>

View file

@ -1,4 +1,5 @@
PASS: no backward-structural rule: append does not invalidate previous siblings | previousSiblingInvalidationWalkVisits=0
PASS: forward-only rule (:first-child): append does not invalidate previous siblings | previousSiblingInvalidationWalkVisits=0
PASS: backward-structural rule on unrelated subtree: append does not invalidate previous siblings | previousSiblingInvalidationWalkVisits=0
PASS: backward-structural rule on fixture: append DOES invalidate previous siblings | previousSiblingInvalidationWalkVisits=20
PASS: last-child rule on fixture: append targets previous last child without walking | previousSiblingInvalidationWalkVisits=0
PASS: nth-last-child rule on fixture: append DOES walk previous siblings | previousSiblingInvalidationWalkVisits=20

View file

@ -73,12 +73,19 @@
};
});
// Case 4: A backward-structural rule (:last-child) DOES apply to our fixture's children.
// The optimization correctly does not skip the walk — the previously-last child must be re-styled
// when a new last child is appended. This case exists to confirm the optimization does not over-apply.
runCase("backward-structural rule on fixture: append DOES invalidate previous siblings", () => {
// Case 4: A trailing-position rule (:last-child) DOES apply to our fixture's children.
// The previously-last child must be re-styled when a new last child is appended, but that does not
// require walking all previous siblings.
runCase("last-child rule on fixture: append targets previous last child without walking", () => {
const style = addStyle(document.head, "section > div:last-child { color: rebeccapurple; }");
return () => style.remove();
});
// Case 5: A backward-position rule (:nth-last-child) DOES apply to our fixture's children.
// Every previous child must be re-evaluated since each child's from-end index changes.
runCase("nth-last-child rule on fixture: append DOES walk previous siblings", () => {
const style = addStyle(document.head, "section > div:nth-last-child(odd) { color: rebeccapurple; }");
return () => style.remove();
});
});
</script>