diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 3f5d79184..50fe4fbec 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; @@ -1522,6 +1525,171 @@ 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 + /// 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 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`): + /// 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(); + + // 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); + + if !(restyle_all || restyle_later || restyle_edges || restyle_self) { + 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 + // `RestyleForEmptyChange`). + self.restyle_for_empty_change(parent_id); + return; + } + + 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 { + // 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); + // 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 + || Some(child_id) == before_change + || Some(child_id) == after_change; + let affected = (restyle_later && idx >= change_idx) || (restyle_edges && is_edge); + if !affected { + continue; + } + if let Some(mut data) = self.nodes[child_id] + .try_stylo_element_data_mut() + .and_then(|s| s.get_mut()) + { + data.hint |= RestyleHint::restyle_subtree(); + } + } + self.nodes[parent_id].children = children; + } + + // 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(); + } + + /// 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] + .try_stylo_element_data_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) { + // 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() { + if !self.nodes[sibling_id].is_element() { + continue; + } + if let Some(mut data) = self.nodes[sibling_id] + .try_stylo_element_data_mut() + .and_then(|s| s.get_mut()) + { + data.hint |= RestyleHint::restyle_subtree(); + } + } + } + self.nodes[grandparent_id].children = siblings; + 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 513d59752..77f82f47e 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), @@ -245,18 +271,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 - .try_stylo_element_data_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 +526,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 +557,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 - .try_stylo_element_data_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 +575,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 - .try_stylo_element_data_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 +584,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); } @@ -634,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 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(); @@ -642,42 +647,57 @@ impl DocumentMutator<'_> { continue; }; - 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 - .try_stylo_element_data_mut() - .and_then(|s| s.get_mut()) + 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(); } - // Mark ancestors dirty so the style traversal visits this subtree. - old_parent.mark_ancestors_dirty(); + any_reparented_in_doc = true; } + let old_parent = &mut self.doc.nodes[old_parent_id]; + old_parent.insert_damage(ALL_DAMAGE); + // 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 - .try_stylo_element_data_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(); + 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(); 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(); diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index 0e05e0f0f..724e26c00 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 new file mode 100644 index 000000000..4c2279975 --- /dev/null +++ b/packages/blitz-test-harness/tests/child_invalidation.rs @@ -0,0 +1,267 @@ +//! 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#" + + + "#, + ); + 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#" + + + "#, + ); + 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#" + + + "#, + ); + 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 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( + 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 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( + 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]); +}