From 32aae6660083e53c2580edd5ebc2b2c844d7fd93 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 26 Aug 2026 00:52:39 +0000 Subject: [PATCH 1/9] Make child insert/remove restyles fine-grained based on ElementSelectorFlags --- packages/blitz-dom/src/document.rs | 74 +++++++++++++++++++++++ packages/blitz-dom/src/mutator.rs | 94 ++++++++++-------------------- 2 files changed, 106 insertions(+), 62 deletions(-) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 3f5d79184..c59349165 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -1522,6 +1522,80 @@ impl BaseDocument { cb(&mut self.nodes[node_id]); } + /// Restyle the children of a container after a child insertion or removal, + /// based on the container's [`ElementSelectorFlags`] (mirroring Gecko's + /// `RestyleManager::RestyleForInsertOrChange` / `RestyleForRemove`): + /// + /// - `HAS_EMPTY_SELECTOR`: the container itself may match `:empty`, so restyle it. + /// - `HAS_SLOW_SELECTOR` (`:nth-last-child` etc): restyle all children. + /// - `HAS_SLOW_SELECTOR_LATER_SIBLINGS` (`:nth-child` etc): restyle children + /// at or after the change position. + /// - `HAS_EDGE_CHILD_SELECTOR` (`:first-child`/`:last-child`/`:only-child`): + /// restyle the first and last element children. + /// + /// `change_idx` is the index at which a child was inserted, or the index the + /// removed child used to occupy (i.e. the index of the first child whose + /// sibling indices may have changed). Must be called *after* the child list + /// has been updated. + pub(crate) fn restyle_for_child_insert_or_remove( + &mut self, + parent_id: NodeId, + change_idx: usize, + ) { + let parent = &self.nodes[parent_id]; + if !parent.flags.is_in_document() { + return; + } + let flags = parent.selector_flags().get(); + + let restyle_all = flags.contains(ElementSelectorFlags::HAS_SLOW_SELECTOR); + let restyle_later = flags.contains(ElementSelectorFlags::HAS_SLOW_SELECTOR_LATER_SIBLINGS); + let restyle_edges = flags.contains(ElementSelectorFlags::HAS_EDGE_CHILD_SELECTOR); + let restyle_self = flags.contains(ElementSelectorFlags::HAS_EMPTY_SELECTOR); + + if !(restyle_all || restyle_later || restyle_edges || restyle_self) { + return; + } + + if restyle_self { + if let Some(mut data) = self.nodes[parent_id] + .stylo_element_data_opt_mut() + .and_then(|s| s.get_mut()) + { + data.hint |= RestyleHint::RESTYLE_SELF; + } + } + + if restyle_all || restyle_later || restyle_edges { + let children = self.nodes[parent_id].children.clone(); + let is_element = |id: &NodeId| self.nodes[*id].is_element(); + let first_element = children.iter().copied().find(is_element); + let last_element = children.iter().rev().copied().find(is_element); + for (idx, child_id) in children.iter().copied().enumerate() { + if !self.nodes[child_id].is_element() { + continue; + } + let is_edge = Some(child_id) == first_element || Some(child_id) == last_element; + let affected = restyle_all + || (restyle_later && idx >= change_idx) + || (restyle_edges && is_edge); + if !affected { + continue; + } + if let Some(mut data) = self.nodes[child_id] + .stylo_element_data_opt_mut() + .and_then(|s| s.get_mut()) + { + data.hint |= RestyleHint::restyle_subtree(); + } + } + } + + // Mark ancestors dirty so the style traversal visits the restyled nodes. + self.nodes[parent_id].set_dirty_descendants(); + self.nodes[parent_id].mark_ancestors_dirty(); + } + // Takes (x, y) co-ordinates (relative to the ) pub fn hit(&self, x: f32, y: f32) -> Option { self.hit_with_scrollbar(x, y).0 diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index db5ea828c..ff96bfd24 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -245,18 +245,6 @@ impl DocumentMutator<'_> { } node.mark_damaged(); - // TODO: make this fine grained / conditional based on ElementSelectorFlags - let parent = node.parent; - if let Some(parent_id) = parent { - let parent = &mut self.doc.nodes[parent_id]; - if let Some(mut data) = parent - .stylo_element_data_opt_mut() - .and_then(|s| s.get_mut()) - { - data.hint |= RestyleHint::restyle_subtree(); - } - } - // Mark ancestors dirty so the style traversal visits this subtree. // Without this, the traversal may skip nodes with pending RestyleHint/damage // because it uses dirty_descendants flags to determine which subtrees to visit. @@ -512,7 +500,12 @@ impl DocumentMutator<'_> { parent.insert_damage(ALL_DAMAGE); // Mark ancestors dirty so the style traversal visits this subtree. parent.mark_ancestors_dirty(); + let old_idx = parent.index_of_child(node_id); parent.children.retain(|id| *id != node_id); + if let Some(old_idx) = old_idx { + self.doc + .restyle_for_child_insert_or_remove(parent_id, old_idx); + } self.maybe_record_node(parent_id); } } @@ -538,21 +531,14 @@ impl DocumentMutator<'_> { if let Some(parent_id) = node.as_ref().and_then(|node| node.parent) { let parent = &mut self.doc.nodes[parent_id]; parent.insert_damage(ALL_DAMAGE); - let parent_is_in_doc = parent.flags.is_in_document(); - - // TODO: make this fine grained / conditional based on ElementSelectorFlags - if parent_is_in_doc { - if let Some(mut data) = parent - .stylo_element_data_opt_mut() - .and_then(|s| s.get_mut()) - { - data.hint |= RestyleHint::restyle_subtree(); - } - // Mark ancestors dirty so the style traversal visits this subtree. - parent.mark_ancestors_dirty(); - } - + // Mark ancestors dirty so the style traversal visits this subtree. + parent.mark_ancestors_dirty(); + let old_idx = parent.index_of_child(node_id); parent.children.retain(|id| *id != node_id); + if let Some(old_idx) = old_idx { + self.doc + .restyle_for_child_insert_or_remove(parent_id, old_idx); + } self.maybe_record_node(parent_id); } @@ -563,17 +549,8 @@ impl DocumentMutator<'_> { let parent = &mut self.doc.nodes[node_id]; let parent_is_in_doc = parent.flags.is_in_document(); - // TODO: make this fine grained / conditional based on ElementSelectorFlags - if parent_is_in_doc { - if let Some(mut data) = parent - .stylo_element_data_opt_mut() - .and_then(|s| s.get_mut()) - { - data.hint |= RestyleHint::restyle_subtree(); - } - // Mark ancestors dirty so the style traversal visits this subtree. - parent.mark_ancestors_dirty(); - } + // Mark ancestors dirty so the style traversal visits this subtree. + parent.mark_ancestors_dirty(); let children = mem::take(&mut parent.children); self.mutations_occurred |= parent_is_in_doc && !children.is_empty(); @@ -581,6 +558,7 @@ impl DocumentMutator<'_> { self.process_removed_subtree(child_id); let _ = self.doc.drop_node_ignoring_parent(child_id); } + self.doc.restyle_for_child_insert_or_remove(node_id, 0); self.maybe_record_node(node_id); } @@ -644,40 +622,32 @@ impl DocumentMutator<'_> { let old_parent = &mut self.doc.nodes[old_parent_id]; old_parent.insert_damage(ALL_DAMAGE); - - // TODO: make this fine grained / conditional based on ElementSelectorFlags - if child_was_in_doc { - if let Some(mut data) = old_parent - .stylo_element_data_opt_mut() - .and_then(|s| s.get_mut()) - { - data.hint |= RestyleHint::restyle_subtree(); - } - // Mark ancestors dirty so the style traversal visits this subtree. - old_parent.mark_ancestors_dirty(); - } - + // Mark ancestors dirty so the style traversal visits this subtree. + old_parent.mark_ancestors_dirty(); + let old_idx = old_parent.index_of_child(child_id); old_parent.children.retain(|id| *id != child_id); + if let Some(old_idx) = old_idx { + self.doc + .restyle_for_child_insert_or_remove(old_parent_id, old_idx); + } self.maybe_record_node(old_parent_id); } let new_parent = &mut self.doc.nodes[parent_id]; new_parent.insert_damage(ALL_DAMAGE); - - // TODO: make this fine grained / conditional based on ElementSelectorFlags - if new_parent_is_in_document { - if let Some(mut data) = new_parent - .stylo_element_data_opt_mut() - .and_then(|s| s.get_mut()) - { - data.hint |= RestyleHint::restyle_subtree(); - } - // Mark ancestors dirty so the style traversal visits this subtree. - new_parent.mark_ancestors_dirty(); - } + // Mark ancestors dirty so the style traversal visits this subtree. + new_parent.mark_ancestors_dirty(); insert_children_fn(new_parent, child_ids); + if let Some(insert_idx) = child_ids + .first() + .and_then(|id| self.doc.nodes[parent_id].index_of_child(*id)) + { + self.doc + .restyle_for_child_insert_or_remove(parent_id, insert_idx); + } + for child_id in child_ids.iter().copied() { let child = &mut self.doc.nodes[child_id]; let child_was_in_doc = child.flags.is_in_document(); From e1e97e0634096d6c32210e64e2c792e844985eb9 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 26 Aug 2026 01:02:45 +0000 Subject: [PATCH 2/9] Fix edge-child and moved-node restyles; add child mutation invalidation tests --- packages/blitz-dom/src/document.rs | 22 ++- packages/blitz-dom/src/mutator.rs | 10 + .../tests/child_invalidation.rs | 176 ++++++++++++++++++ 3 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 packages/blitz-test-harness/tests/child_invalidation.rs diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index c59349165..0f57b27da 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -29,7 +29,10 @@ use cursor_icon::CursorIcon; use linebender_resource_handle::Blob; use markup5ever::{LocalName, local_name}; use parley::{FontContext, PlainEditorDriver}; -use selectors::{Element, matching::QuirksMode}; +use selectors::{ + Element, + matching::{ElementSelectorFlags, QuirksMode}, +}; use smallvec::SmallVec; use std::any::Any; use std::cell::RefCell; @@ -1571,11 +1574,26 @@ impl BaseDocument { let is_element = |id: &NodeId| self.nodes[*id].is_element(); let first_element = children.iter().copied().find(is_element); let last_element = children.iter().rev().copied().find(is_element); + // The nearest element children on either side of the change may have + // gained or lost edge-child status (e.g. the previous last child + // after an append, or the previous first child after a prepend). + let before_change = children[..change_idx.min(children.len())] + .iter() + .rev() + .copied() + .find(is_element); + let after_change = children[change_idx.min(children.len())..] + .iter() + .copied() + .find(is_element); for (idx, child_id) in children.iter().copied().enumerate() { if !self.nodes[child_id].is_element() { continue; } - let is_edge = Some(child_id) == first_element || Some(child_id) == last_element; + let is_edge = Some(child_id) == first_element + || Some(child_id) == last_element + || Some(child_id) == before_change + || Some(child_id) == after_change; let affected = restyle_all || (restyle_later && idx >= change_idx) || (restyle_edges && is_edge); diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index ff96bfd24..4a5812fde 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -657,6 +657,16 @@ impl DocumentMutator<'_> { self.process_added_subtree(child_id); } else if !new_parent_is_in_document && child_was_in_doc { self.process_removed_subtree(child_id); + } else if new_parent_is_in_document && child_was_in_doc { + // The node was *moved* within the document: its ancestors (and + // therefore any styles inherited from or matched via them) may + // have changed, so its whole subtree must be restyled. + let child = &mut self.doc.nodes[child_id]; + if let Some(mut data) = child.stylo_element_data_opt_mut().and_then(|s| s.get_mut()) + { + data.hint |= RestyleHint::restyle_subtree(); + } + self.doc.nodes[child_id].mark_ancestors_dirty(); } } diff --git a/packages/blitz-test-harness/tests/child_invalidation.rs b/packages/blitz-test-harness/tests/child_invalidation.rs new file mode 100644 index 000000000..209290944 --- /dev/null +++ b/packages/blitz-test-harness/tests/child_invalidation.rs @@ -0,0 +1,176 @@ +//! Tests that structural selectors (`:nth-child`, `:last-child`, `:empty`, ...) +//! are correctly invalidated when children are inserted or removed. + +use blitz_dom::BaseDocument; +use blitz_dom::qual_name; +use blitz_html::HtmlDocument; +use blitz_test_harness::Harness; +use blitz_traits::node_id::NodeId; + +const RED: [f32; 3] = [1.0, 0.0, 0.0]; +const BLACK: [f32; 3] = [0.0, 0.0, 0.0]; + +fn color_of(doc: &BaseDocument, node_id: NodeId) -> [f32; 3] { + let styles = doc.get_node(node_id).unwrap().primary_styles().unwrap(); + let color = styles.clone_color(); + [color.components.0, color.components.1, color.components.2] +} + +fn colors(harness: &Harness, selector: &str) -> Vec<[f32; 3]> { + let doc = harness.base(); + harness + .query_all(selector) + .into_iter() + .map(|id| color_of(&doc, id)) + .collect() +} + +#[test] +fn nth_child_invalidates_on_sibling_insertion_and_removal() { + let mut harness = Harness::from_html( + r#" + +
    +
  • one
  • +
  • two
  • +
+ "#, + ); + assert_eq!(colors(&harness, "li"), [BLACK, RED]); + + let first_li = harness.node("li"); + { + let mut doc = harness.base_mut(); + let mut mutator = doc.mutate(); + let new_li = mutator.create_element(qual_name!("li", html), Vec::new()); + mutator.insert_nodes_before(first_li, &[new_li]); + } + harness.pump(); + assert_eq!(colors(&harness, "li"), [BLACK, RED, BLACK]); + + let inserted_li = harness.node("li"); + harness.base_mut().mutate().remove_node(inserted_li); + harness.pump(); + assert_eq!(colors(&harness, "li"), [BLACK, RED]); +} + +#[test] +fn nth_last_child_invalidates_on_sibling_append() { + let mut harness = Harness::from_html( + r#" + +
    +
  • one
  • +
  • two
  • +
+ "#, + ); + assert_eq!(colors(&harness, "li"), [RED, BLACK]); + + // Appending shifts the nth-last-child index of *earlier* siblings. + let ul = harness.node("ul"); + { + let mut doc = harness.base_mut(); + let mut mutator = doc.mutate(); + let new_li = mutator.create_element(qual_name!("li", html), Vec::new()); + mutator.append_children(ul, &[new_li]); + } + harness.pump(); + assert_eq!(colors(&harness, "li"), [BLACK, RED, BLACK]); +} + +#[test] +fn last_child_invalidates_on_sibling_append_and_removal() { + let mut harness = Harness::from_html( + r#" + +
    +
  • one
  • +
  • two
  • +
+ "#, + ); + assert_eq!(colors(&harness, "li"), [BLACK, RED]); + + let ul = harness.node("ul"); + { + let mut doc = harness.base_mut(); + let mut mutator = doc.mutate(); + let new_li = mutator.create_element(qual_name!("li", html), Vec::new()); + mutator.append_children(ul, &[new_li]); + } + harness.pump(); + assert_eq!(colors(&harness, "li"), [BLACK, BLACK, RED]); + + // Removing the new last child makes "two" the last child again. + let last_li = *harness.query_all("li").last().unwrap(); + harness.base_mut().mutate().remove_node(last_li); + harness.pump(); + assert_eq!(colors(&harness, "li"), [BLACK, RED]); +} + +#[test] +fn moved_node_is_restyled_for_new_ancestors() { + let mut harness = Harness::from_html( + r#" + +
text
+
+ "#, + ); + assert_eq!(colors(&harness, "span"), [RED]); + + // Moving the span from #a to #b changes its ancestors, so it must be restyled. + let span = harness.node("span"); + let b = harness.node("#b"); + harness.base_mut().mutate().append_children(b, &[span]); + harness.pump(); + assert_eq!(colors(&harness, "span"), [BLACK]); + + // And back again. + let a = harness.node("#a"); + harness.base_mut().mutate().append_children(a, &[span]); + harness.pump(); + assert_eq!(colors(&harness, "span"), [RED]); +} + +#[test] +fn empty_invalidates_on_child_insertion_and_removal() { + let mut harness = Harness::from_html( + r#" + +
+ "#, + ); + let container = harness.node("#container"); + assert_eq!(colors(&harness, "#container"), [RED]); + + let child = { + let mut doc = harness.base_mut(); + let mut mutator = doc.mutate(); + let child = mutator.create_element(qual_name!("span", html), Vec::new()); + mutator.append_children(container, &[child]); + child + }; + harness.pump(); + assert_eq!(colors(&harness, "#container"), [BLACK]); + + harness.base_mut().mutate().remove_node(child); + harness.pump(); + assert_eq!(colors(&harness, "#container"), [RED]); +} From c14cb652fbe0671d1651ec46ac80e69c3a4d8745 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 26 Aug 2026 01:08:55 +0000 Subject: [PATCH 3/9] Handle :empty for text content changes and spec-correct empty text nodes --- packages/blitz-dom/src/document.rs | 53 +++++++++++++-- packages/blitz-dom/src/mutator.rs | 26 +++++++ packages/blitz-dom/src/stylo.rs | 6 +- .../tests/child_invalidation.rs | 67 +++++++++++++++++++ 4 files changed, 144 insertions(+), 8 deletions(-) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 0f57b27da..9d5cbe822 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -1529,7 +1529,9 @@ impl BaseDocument { /// based on the container's [`ElementSelectorFlags`] (mirroring Gecko's /// `RestyleManager::RestyleForInsertOrChange` / `RestyleForRemove`): /// - /// - `HAS_EMPTY_SELECTOR`: the container itself may match `:empty`, so restyle it. + /// - `HAS_EMPTY_SELECTOR`: the container itself may match `:empty`, so restyle + /// its subtree (and its later siblings if the grandparent has + /// `HAS_SLOW_SELECTOR_LATER_SIBLINGS`, for `:empty ~ E`). /// - `HAS_SLOW_SELECTOR` (`:nth-last-child` etc): restyle all children. /// - `HAS_SLOW_SELECTOR_LATER_SIBLINGS` (`:nth-child` etc): restyle children /// at or after the change position. @@ -1561,12 +1563,11 @@ impl BaseDocument { } if restyle_self { - if let Some(mut data) = self.nodes[parent_id] - .stylo_element_data_opt_mut() - .and_then(|s| s.get_mut()) - { - data.hint |= RestyleHint::RESTYLE_SELF; - } + // Restyling the container's whole subtree covers everything the + // other flags would, so we're done (matching Gecko's + // `RestyleForEmptyChange`). + self.restyle_for_empty_change(parent_id); + return; } if restyle_all || restyle_later || restyle_edges { @@ -1614,6 +1615,44 @@ impl BaseDocument { self.nodes[parent_id].mark_ancestors_dirty(); } + /// Restyle a container whose `:empty` status may have changed (mirroring + /// Gecko's `RestyleManager::RestyleForEmptyChange`): restyle the container's + /// subtree, and in the `:empty + E` / `:empty ~ E` cases (grandparent has + /// `HAS_SLOW_SELECTOR_LATER_SIBLINGS`) also restyle its later siblings. + pub(crate) fn restyle_for_empty_change(&mut self, container_id: NodeId) { + if let Some(mut data) = self.nodes[container_id] + .stylo_element_data_opt_mut() + .and_then(|s| s.get_mut()) + { + data.hint |= RestyleHint::restyle_subtree(); + } + + if let Some(grandparent_id) = self.nodes[container_id].parent { + let grandparent_flags = self.nodes[grandparent_id].selector_flags().get(); + if grandparent_flags.contains(ElementSelectorFlags::HAS_SLOW_SELECTOR_LATER_SIBLINGS) { + let siblings = self.nodes[grandparent_id].children.clone(); + let container_idx = siblings.iter().position(|id| *id == container_id); + if let Some(container_idx) = container_idx { + for sibling_id in siblings[container_idx + 1..].iter().copied() { + if !self.nodes[sibling_id].is_element() { + continue; + } + if let Some(mut data) = self.nodes[sibling_id] + .stylo_element_data_opt_mut() + .and_then(|s| s.get_mut()) + { + data.hint |= RestyleHint::restyle_subtree(); + } + } + } + self.nodes[grandparent_id].set_dirty_descendants(); + } + } + + self.nodes[container_id].set_dirty_descendants(); + self.nodes[container_id].mark_ancestors_dirty(); + } + // Takes (x, y) co-ordinates (relative to the ) pub fn hit(&self, x: f32, y: f32) -> Option { self.hit_with_scrollbar(x, y).0 diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index 4a5812fde..1ad3b3b3b 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -12,6 +12,7 @@ use crate::{ Attribute, BaseDocument, Document, ElementData, Node, NodeData, QualName, local_name, qual_name, }; use blitz_traits::shell::Viewport; +use selectors::matching::ElementSelectorFlags; use style::Atom; use style::invalidation::element::restyle_hints::RestyleHint; use style::stylesheets::OriginSet; @@ -175,6 +176,7 @@ impl DocumentMutator<'_> { let changed = text.content != value; if changed { + let empty_changed = text.content.is_empty() != value.is_empty(); self.mutations_occurred |= node_is_in_document; text.content.clear(); text.content.push_str(value); @@ -189,6 +191,17 @@ impl DocumentMutator<'_> { if let Some(parent_id) = parent_id { let parent = &mut self.doc.nodes[parent_id]; parent.insert_damage(ALL_DAMAGE); + + // A text node becoming empty/non-empty can change whether the + // parent matches `:empty`. + if empty_changed + && parent + .selector_flags() + .get() + .contains(ElementSelectorFlags::HAS_EMPTY_SELECTOR) + { + self.doc.restyle_for_empty_change(parent_id); + } } self.maybe_record_node(parent_id); @@ -204,10 +217,23 @@ impl DocumentMutator<'_> { let node = &mut self.doc.nodes[node_id]; node.insert_damage(ALL_DAMAGE); node.mark_ancestors_dirty(); + let parent_id = node.parent; match node.text_data_mut() { Some(data) => { + let empty_changed = data.content.is_empty() && !text.is_empty(); data.content += text; self.mutations_occurred |= node_is_in_document; + if empty_changed { + if let Some(parent_id) = parent_id { + if self.doc.nodes[parent_id] + .selector_flags() + .get() + .contains(ElementSelectorFlags::HAS_EMPTY_SELECTOR) + { + self.doc.restyle_for_empty_change(parent_id); + } + } + } Ok(()) } None => Err(AppendTextErr::NotTextNode), diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index 6a6e3414a..bb19f1962 100644 --- a/packages/blitz-dom/src/stylo.rs +++ b/packages/blitz-dom/src/stylo.rs @@ -543,7 +543,11 @@ impl selectors::Element for BlitzNode<'_> { } fn is_empty(&self) -> bool { - self.dom_children().next().is_none() + self.dom_children().all(|child| match &child.data { + NodeData::Text(data) => data.content.is_empty(), + NodeData::Comment { .. } => true, + _ => false, + }) } fn is_root(&self) -> bool { diff --git a/packages/blitz-test-harness/tests/child_invalidation.rs b/packages/blitz-test-harness/tests/child_invalidation.rs index 209290944..f3cf2bb1a 100644 --- a/packages/blitz-test-harness/tests/child_invalidation.rs +++ b/packages/blitz-test-harness/tests/child_invalidation.rs @@ -118,6 +118,73 @@ fn last_child_invalidates_on_sibling_append_and_removal() { assert_eq!(colors(&harness, "li"), [BLACK, RED]); } +#[test] +fn empty_descendant_rule_invalidates_on_child_insertion() { + let mut harness = Harness::from_html( + r#" + +
+
+ "#, + ); + assert_eq!(colors(&harness, "#a"), [RED]); + assert_eq!(colors(&harness, "#b"), [RED]); + + // Inserting a child into #a should invalidate both #a's own `:empty` + // style and the sibling `#a:empty + #b` rule. + let a = harness.node("#a"); + { + let mut doc = harness.base_mut(); + let mut mutator = doc.mutate(); + let child = mutator.create_element(qual_name!("span", html), Vec::new()); + mutator.append_children(a, &[child]); + } + harness.pump(); + assert_eq!(colors(&harness, "#a"), [BLACK]); + assert_eq!(colors(&harness, "#b"), [BLACK]); +} + +#[test] +fn empty_invalidates_on_text_content_change() { + let mut harness = Harness::from_html( + r#" + +
+ "#, + ); + let a = harness.node("#a"); + assert_eq!(colors(&harness, "#a"), [RED]); + + // Insert an empty text node: #a should still match `:empty`. + let text = { + let mut doc = harness.base_mut(); + let mut mutator = doc.mutate(); + let text = mutator.create_text_node(""); + mutator.append_children(a, &[text]); + text + }; + harness.pump(); + assert_eq!(colors(&harness, "#a"), [RED]); + + // Giving the text node content makes #a non-empty. + harness.base_mut().mutate().set_node_text(text, "hello"); + harness.pump(); + assert_eq!(colors(&harness, "#a"), [BLACK]); + + // And clearing it makes #a `:empty` again. + harness.base_mut().mutate().set_node_text(text, ""); + harness.pump(); + assert_eq!(colors(&harness, "#a"), [RED]); +} + #[test] fn moved_node_is_restyled_for_new_ancestors() { let mut harness = Harness::from_html( From aeefc3526d71342b242a51df46ea358851f262a8 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 26 Aug 2026 01:15:40 +0000 Subject: [PATCH 4/9] Restyle whole container for HAS_SLOW_SELECTOR like Gecko --- packages/blitz-dom/src/document.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 20577df81..a33683c31 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -1532,7 +1532,9 @@ impl BaseDocument { /// - `HAS_EMPTY_SELECTOR`: the container itself may match `:empty`, so restyle /// its subtree (and its later siblings if the grandparent has /// `HAS_SLOW_SELECTOR_LATER_SIBLINGS`, for `:empty ~ E`). - /// - `HAS_SLOW_SELECTOR` (`:nth-last-child` etc): restyle all children. + /// - `HAS_SLOW_SELECTOR` (`:nth-last-child` etc): restyle the whole container + /// (all children would need restyling anyway, so a single subtree hint on + /// the container is cheaper, matching Gecko's `RestyleWholeContainer`). /// - `HAS_SLOW_SELECTOR_LATER_SIBLINGS` (`:nth-child` etc): restyle children /// at or after the change position. /// - `HAS_EDGE_CHILD_SELECTOR` (`:first-child`/`:last-child`/`:only-child`): @@ -1570,7 +1572,20 @@ impl BaseDocument { return; } - if restyle_all || restyle_later || restyle_edges { + if restyle_all { + // Restyling the container is the most we can do here, so we're done. + if let Some(mut data) = self.nodes[parent_id] + .try_stylo_element_data_mut() + .and_then(|s| s.get_mut()) + { + data.hint |= RestyleHint::restyle_subtree(); + } + self.nodes[parent_id].set_dirty_descendants(); + self.nodes[parent_id].mark_ancestors_dirty(); + return; + } + + if restyle_later || restyle_edges { let children = self.nodes[parent_id].children.clone(); let is_element = |id: &NodeId| self.nodes[*id].is_element(); let first_element = children.iter().copied().find(is_element); @@ -1595,9 +1610,7 @@ impl BaseDocument { || Some(child_id) == last_element || Some(child_id) == before_change || Some(child_id) == after_change; - let affected = restyle_all - || (restyle_later && idx >= change_idx) - || (restyle_edges && is_edge); + let affected = (restyle_later && idx >= change_idx) || (restyle_edges && is_edge); if !affected { continue; } From a7e8983a62fa7a695b9b4a63399677750285c6f6 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 26 Aug 2026 01:22:02 +0000 Subject: [PATCH 5/9] Skip child restyle walk when container already pending subtree restyle --- packages/blitz-dom/src/document.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index a33683c31..adac2d40e 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -1564,6 +1564,19 @@ impl BaseDocument { return; } + // If the container is already pending a whole-subtree restyle then every + // child is already covered; skip the sibling walk. The hint is cleared + // when the traversal restyles the container, so this dedupes repeated + // mutations within a single style flush (avoiding quadratic behavior + // when e.g. removing all children one by one). + if self.nodes[parent_id] + .try_stylo_element_data() + .and_then(|s| s.get()) + .is_some_and(|data| data.hint.contains(RestyleHint::restyle_subtree())) + { + return; + } + if restyle_self { // Restyling the container's whole subtree covers everything the // other flags would, so we're done (matching Gecko's From 40aba128c2cb97747401f4e1cd060091e11e4484 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 26 Aug 2026 01:22:37 +0000 Subject: [PATCH 6/9] Escalate changes at container start to whole-container restyle --- packages/blitz-dom/src/document.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index adac2d40e..bc599a27c 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -1555,7 +1555,14 @@ impl BaseDocument { } let flags = parent.selector_flags().get(); - let restyle_all = flags.contains(ElementSelectorFlags::HAS_SLOW_SELECTOR); + // It is somewhat common to remove all nodes in a container from the + // beginning. Going through the later-siblings code-path for that would + // be quadratic, so escalate changes at the start of the container to a + // whole-container restyle instead (which the pending-subtree-restyle + // check below then dedupes), matching Gecko's `ContentWillBeRemoved`. + let restyle_all = flags.contains(ElementSelectorFlags::HAS_SLOW_SELECTOR) + || (flags.contains(ElementSelectorFlags::HAS_SLOW_SELECTOR_LATER_SIBLINGS) + && change_idx == 0); let restyle_later = flags.contains(ElementSelectorFlags::HAS_SLOW_SELECTOR_LATER_SIBLINGS); let restyle_edges = flags.contains(ElementSelectorFlags::HAS_EDGE_CHILD_SELECTOR); let restyle_self = flags.contains(ElementSelectorFlags::HAS_EMPTY_SELECTOR); From 99b6403ef5fc7249c608a68e4c74018c12651dc5 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 26 Aug 2026 10:52:41 +0000 Subject: [PATCH 7/9] Skip moved-subtree restyle for same-parent moves --- packages/blitz-dom/src/mutator.rs | 21 ++++++++++++---- .../tests/child_invalidation.rs | 24 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index 2a1495c5a..ec55208ac 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -638,6 +638,7 @@ impl DocumentMutator<'_> { // would remove both the old and the newly-inserted entries from the // parent's child list, and anchor indices would be computed against a // child list that still contains the moved nodes. + let mut reparented: Vec = Vec::new(); for child_id in child_ids.iter().copied() { let child = &mut self.doc.nodes[child_id]; let child_was_in_doc = child.flags.is_in_document(); @@ -645,6 +646,9 @@ impl DocumentMutator<'_> { let Some(old_parent_id) = child.parent.take() else { continue; }; + if old_parent_id != parent_id { + reparented.push(child_id); + } let old_parent = &mut self.doc.nodes[old_parent_id]; old_parent.insert_damage(ALL_DAMAGE); @@ -683,10 +687,19 @@ impl DocumentMutator<'_> { self.process_added_subtree(child_id); } else if !new_parent_is_in_document && child_was_in_doc { self.process_removed_subtree(child_id); - } else if new_parent_is_in_document && child_was_in_doc { - // The node was *moved* within the document: its ancestors (and - // therefore any styles inherited from or matched via them) may - // have changed, so its whole subtree must be restyled. + } else if new_parent_is_in_document + && child_was_in_doc + && reparented.contains(&child_id) + { + // The node was moved to a *different* parent within the + // document: its ancestors (and therefore any styles inherited + // from or matched via them) may have changed, so its whole + // subtree must be restyled. Moves within the same parent don't + // need this: sibling-dependent selectors set + // HAS_SLOW_SELECTOR(_LATER_SIBLINGS)/HAS_EDGE_CHILD_SELECTOR on + // the parent, so `restyle_for_child_insert_or_remove` above + // already restyles the moved node when anything could depend on + // sibling position. let child = &mut self.doc.nodes[child_id]; if let Some(mut data) = child.try_stylo_element_data_mut().and_then(|s| s.get_mut()) { diff --git a/packages/blitz-test-harness/tests/child_invalidation.rs b/packages/blitz-test-harness/tests/child_invalidation.rs index f3cf2bb1a..4c2279975 100644 --- a/packages/blitz-test-harness/tests/child_invalidation.rs +++ b/packages/blitz-test-harness/tests/child_invalidation.rs @@ -213,6 +213,30 @@ fn moved_node_is_restyled_for_new_ancestors() { assert_eq!(colors(&harness, "span"), [RED]); } +#[test] +fn reordered_node_is_restyled_for_sibling_selectors() { + let mut harness = Harness::from_html( + r#" + +
+
+
+
+ "#, + ); + assert_eq!(colors(&harness, "#a"), [BLACK]); + + // Moving #a after #b (same parent) makes `.marker ~ div` match it. + let a = harness.node("#a"); + let main = harness.node("main"); + harness.base_mut().mutate().append_children(main, &[a]); + harness.pump(); + assert_eq!(colors(&harness, "#a"), [RED]); +} + #[test] fn empty_invalidates_on_child_insertion_and_removal() { let mut harness = Harness::from_html( From 360eb40b95cef846963594272ee76a234f834510 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 26 Aug 2026 10:58:19 +0000 Subject: [PATCH 8/9] Set moved-subtree restyle hint during detach to avoid allocation --- packages/blitz-dom/src/mutator.rs | 45 ++++++++++++++++--------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index ec55208ac..77f82f47e 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -638,7 +638,7 @@ impl DocumentMutator<'_> { // would remove both the old and the newly-inserted entries from the // parent's child list, and anchor indices would be computed against a // child list that still contains the moved nodes. - let mut reparented: Vec = Vec::new(); + let mut any_reparented_in_doc = false; for child_id in child_ids.iter().copied() { let child = &mut self.doc.nodes[child_id]; let child_was_in_doc = child.flags.is_in_document(); @@ -646,8 +646,23 @@ impl DocumentMutator<'_> { let Some(old_parent_id) = child.parent.take() else { continue; }; - if old_parent_id != parent_id { - reparented.push(child_id); + + if new_parent_is_in_document && child_was_in_doc && old_parent_id != parent_id { + // The node is being moved to a *different* parent within the + // document: its ancestors (and therefore any styles inherited + // from or matched via them) may have changed, so its whole + // subtree must be restyled. Moves within the same parent don't + // need this: sibling-dependent selectors set + // HAS_SLOW_SELECTOR(_LATER_SIBLINGS)/HAS_EDGE_CHILD_SELECTOR on + // the parent, so `restyle_for_child_insert_or_remove` already + // restyles the moved node when anything could depend on + // sibling position. + let child = &mut self.doc.nodes[child_id]; + if let Some(mut data) = child.try_stylo_element_data_mut().and_then(|s| s.get_mut()) + { + data.hint |= RestyleHint::restyle_subtree(); + } + any_reparented_in_doc = true; } let old_parent = &mut self.doc.nodes[old_parent_id]; @@ -665,6 +680,11 @@ impl DocumentMutator<'_> { let new_parent = &mut self.doc.nodes[parent_id]; new_parent.insert_damage(ALL_DAMAGE); + if any_reparented_in_doc { + // Ensure the style traversal descends to the moved children's + // pending restyle hints. + new_parent.set_dirty_descendants(); + } // Mark ancestors dirty so the style traversal visits this subtree. new_parent.mark_ancestors_dirty(); @@ -687,25 +707,6 @@ impl DocumentMutator<'_> { self.process_added_subtree(child_id); } else if !new_parent_is_in_document && child_was_in_doc { self.process_removed_subtree(child_id); - } else if new_parent_is_in_document - && child_was_in_doc - && reparented.contains(&child_id) - { - // The node was moved to a *different* parent within the - // document: its ancestors (and therefore any styles inherited - // from or matched via them) may have changed, so its whole - // subtree must be restyled. Moves within the same parent don't - // need this: sibling-dependent selectors set - // HAS_SLOW_SELECTOR(_LATER_SIBLINGS)/HAS_EDGE_CHILD_SELECTOR on - // the parent, so `restyle_for_child_insert_or_remove` above - // already restyles the moved node when anything could depend on - // sibling position. - let child = &mut self.doc.nodes[child_id]; - if let Some(mut data) = child.try_stylo_element_data_mut().and_then(|s| s.get_mut()) - { - data.hint |= RestyleHint::restyle_subtree(); - } - self.doc.nodes[child_id].mark_ancestors_dirty(); } } From 67389e61b8731c4057681e2eb267cbdce2946365 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 26 Aug 2026 11:07:22 +0000 Subject: [PATCH 9/9] Avoid child-list clones in child invalidation helpers --- packages/blitz-dom/src/document.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index bc599a27c..50fe4fbec 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -1606,7 +1606,8 @@ impl BaseDocument { } if restyle_later || restyle_edges { - let children = self.nodes[parent_id].children.clone(); + // Take the child list to avoid allocating; put it back once done. + let children = std::mem::take(&mut self.nodes[parent_id].children); let is_element = |id: &NodeId| self.nodes[*id].is_element(); let first_element = children.iter().copied().find(is_element); let last_element = children.iter().rev().copied().find(is_element); @@ -1641,6 +1642,7 @@ impl BaseDocument { data.hint |= RestyleHint::restyle_subtree(); } } + self.nodes[parent_id].children = children; } // Mark ancestors dirty so the style traversal visits the restyled nodes. @@ -1663,7 +1665,8 @@ impl BaseDocument { if let Some(grandparent_id) = self.nodes[container_id].parent { let grandparent_flags = self.nodes[grandparent_id].selector_flags().get(); if grandparent_flags.contains(ElementSelectorFlags::HAS_SLOW_SELECTOR_LATER_SIBLINGS) { - let siblings = self.nodes[grandparent_id].children.clone(); + // Take the child list to avoid allocating; put it back once done. + let siblings = std::mem::take(&mut self.nodes[grandparent_id].children); let container_idx = siblings.iter().position(|id| *id == container_id); if let Some(container_idx) = container_idx { for sibling_id in siblings[container_idx + 1..].iter().copied() { @@ -1678,6 +1681,7 @@ impl BaseDocument { } } } + self.nodes[grandparent_id].children = siblings; self.nodes[grandparent_id].set_dirty_descendants(); } }