Build conservative style invalidation metadata

This commit is contained in:
SGavrl committed 2026-08-03 20:26:24 +00:00
1 parent cfac7f2003
commit b8bdac2163
1 file changed
+838
+838
View File
@@ -15,6 +15,602 @@ use std::sync::Arc;
use crate::LayoutStyle; use crate::LayoutStyle;
/// The part of the tree whose selector match may change when a dependency on
/// one element changes. Multiple bits can be present for selectors which use
/// the same key in more than one compound.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct InvalidationReaches(u8);
impl InvalidationReaches {
pub const SELF: Self = Self(1 << 0);
pub const DESCENDANTS: Self = Self(1 << 1);
pub const SIBLINGS: Self = Self(1 << 2);
/// The selector needs a correctness-first fallback which phase 2 must not
/// narrow to a local traversal.
pub const CONSERVATIVE: Self = Self(1 << 3);
pub fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
}
/// One compiled rule's dependency on a selector key.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InvalidationDependency {
/// Stylesheet source order of the compiled rule.
pub rule_order: usize,
pub reaches: InvalidationReaches,
}
/// Selector dependencies retained alongside the compiled stylesheet.
///
/// This follows Gecko's conservative invalidation-map shape: mutations look
/// up the changed id/class/attribute/state and receive one or more traversal
/// reaches. Phase 1 only builds and exposes this metadata; it deliberately does
/// not alter mutation handling or skip any cascade work.
#[derive(Clone, Debug, Default)]
pub struct InvalidationMap {
ids: HashMap<String, Vec<InvalidationDependency>>,
classes: HashMap<String, Vec<InvalidationDependency>>,
attributes: HashMap<String, Vec<InvalidationDependency>>,
states: HashMap<String, Vec<InvalidationDependency>>,
conservative_rule_orders: Vec<usize>,
}
impl InvalidationMap {
pub fn id_dependencies(&self, id: &str) -> &[InvalidationDependency] {
self.ids.get(id).map(Vec::as_slice).unwrap_or(&[])
}
pub fn class_dependencies(&self, class: &str) -> &[InvalidationDependency] {
self.classes.get(class).map(Vec::as_slice).unwrap_or(&[])
}
pub fn attribute_dependencies(&self, attribute: &str) -> &[InvalidationDependency] {
self.attributes
.get(&attribute.to_ascii_lowercase())
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub fn state_dependencies(&self, state: &str) -> &[InvalidationDependency] {
self.states
.get(&state.to_ascii_lowercase())
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub fn conservative_rule_orders(&self) -> &[usize] {
&self.conservative_rule_orders
}
pub fn requires_conservative_invalidation(&self) -> bool {
!self.conservative_rule_orders.is_empty()
}
pub fn dependency_count(&self) -> usize {
self.ids
.values()
.chain(self.classes.values())
.chain(self.attributes.values())
.chain(self.states.values())
.map(Vec::len)
.sum()
}
fn push(
map: &mut HashMap<String, Vec<InvalidationDependency>>,
key: String,
rule_order: usize,
reaches: InvalidationReaches,
) {
let dependencies = map.entry(key).or_default();
if let Some(existing) = dependencies
.iter_mut()
.find(|dependency| dependency.rule_order == rule_order)
{
existing.reaches = existing.reaches.union(reaches);
} else {
dependencies.push(InvalidationDependency {
rule_order,
reaches,
});
}
}
fn push_id(&mut self, key: String, rule_order: usize, reaches: InvalidationReaches) {
Self::push(&mut self.ids, key, rule_order, reaches);
}
fn push_class(&mut self, key: String, rule_order: usize, reaches: InvalidationReaches) {
Self::push(&mut self.classes, key, rule_order, reaches);
}
fn push_attribute(&mut self, key: String, rule_order: usize, reaches: InvalidationReaches) {
Self::push(
&mut self.attributes,
key.to_ascii_lowercase(),
rule_order,
reaches,
);
}
fn push_state(&mut self, key: String, rule_order: usize, reaches: InvalidationReaches) {
Self::push(
&mut self.states,
key.to_ascii_lowercase(),
rule_order,
reaches,
);
}
fn mark_conservative(&mut self, rule_order: usize) {
if self.conservative_rule_orders.last().copied() != Some(rule_order)
&& !self.conservative_rule_orders.contains(&rule_order)
{
self.conservative_rule_orders.push(rule_order);
}
}
}
fn compose_invalidation_reach(
map: &mut InvalidationMap,
inner: InvalidationReaches,
outer: InvalidationReaches,
rule_order: usize,
) -> InvalidationReaches {
if outer == InvalidationReaches::SELF {
inner
} else if inner == InvalidationReaches::SELF || inner == outer {
outer
} else {
// A sibling traversal nested under an ancestor traversal (or vice
// versa) cannot be represented by the three simple phase-1 reaches.
map.mark_conservative(rule_order);
inner
.union(outer)
.union(InvalidationReaches::CONSERVATIVE)
}
}
fn consume_css_identifier(chars: &[char], mut index: usize) -> (String, usize) {
let mut value = String::new();
while let Some(&ch) = chars.get(index) {
if ch == '\\' {
index += 1;
let Some(&escaped) = chars.get(index) else {
break;
};
if escaped.is_ascii_hexdigit() {
let start = index;
while index < chars.len()
&& index - start < 6
&& chars[index].is_ascii_hexdigit()
{
index += 1;
}
let digits: String = chars[start..index].iter().collect();
if let Ok(codepoint) = u32::from_str_radix(&digits, 16) {
value.push(char::from_u32(codepoint).unwrap_or('\u{fffd}'));
}
if chars.get(index).is_some_and(|ch| ch.is_whitespace()) {
index += 1;
}
continue;
}
value.push(escaped);
index += 1;
continue;
}
if ch.is_alphanumeric() || ch == '_' || ch == '-' || !ch.is_ascii() {
value.push(ch);
index += 1;
} else {
break;
}
}
(value, index)
}
fn matching_delimiter(chars: &[char], open: usize, opening: char, closing: char) -> Option<usize> {
let mut depth = 1usize;
let mut quote = None;
let mut index = open + 1;
while index < chars.len() {
let ch = chars[index];
if ch == '\\' {
index = (index + 2).min(chars.len());
continue;
}
if let Some(active_quote) = quote {
if ch == active_quote {
quote = None;
}
index += 1;
continue;
}
if ch == '\'' || ch == '"' {
quote = Some(ch);
} else if ch == opening {
depth += 1;
} else if ch == closing {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += 1;
}
None
}
/// Split one complex selector into compounds and the traversal generated by a
/// dependency in each compound. Whitespace surrounding an explicit
/// combinator is not misread as an extra descendant combinator.
fn invalidation_compounds(selector: &str) -> (Vec<(String, InvalidationReaches)>, bool) {
let chars: Vec<char> = selector.chars().collect();
let mut compounds = Vec::new();
let mut start = 0usize;
let mut index = 0usize;
let mut paren_depth = 0usize;
let mut bracket_depth = 0usize;
let mut quote = None;
let mut malformed = false;
let push = |compound_start: usize,
end: usize,
reaches: InvalidationReaches,
compounds: &mut Vec<(String, InvalidationReaches)>| {
let compound: String = chars[compound_start..end].iter().collect();
let compound = compound.trim();
if !compound.is_empty() {
compounds.push((compound.to_string(), reaches));
true
} else {
false
}
};
while index < chars.len() {
let ch = chars[index];
if ch == '\\' {
index = (index + 2).min(chars.len());
continue;
}
if let Some(active_quote) = quote {
if ch == active_quote {
quote = None;
}
index += 1;
continue;
}
if ch == '\'' || ch == '"' {
quote = Some(ch);
index += 1;
continue;
}
match ch {
'(' => paren_depth += 1,
')' => {
if paren_depth == 0 {
malformed = true;
} else {
paren_depth -= 1;
}
}
'[' => bracket_depth += 1,
']' => {
if bracket_depth == 0 {
malformed = true;
} else {
bracket_depth -= 1;
}
}
'>' | '+' | '~' if paren_depth == 0 && bracket_depth == 0 => {
let reaches = if ch == '>' {
InvalidationReaches::DESCENDANTS
} else {
InvalidationReaches::SIBLINGS
};
if !push(start, index, reaches, &mut compounds) {
malformed = true;
}
index += 1;
while chars.get(index).is_some_and(|ch| ch.is_whitespace()) {
index += 1;
}
start = index;
continue;
}
',' if paren_depth == 0 && bracket_depth == 0 => malformed = true,
_ if ch.is_whitespace() && paren_depth == 0 && bracket_depth == 0 => {
let whitespace = index;
while chars.get(index).is_some_and(|ch| ch.is_whitespace()) {
index += 1;
}
if matches!(chars.get(index), Some('>') | Some('+') | Some('~')) {
continue;
}
if index < chars.len() {
if !push(
start,
whitespace,
InvalidationReaches::DESCENDANTS,
&mut compounds,
) {
malformed = true;
}
start = index;
continue;
}
break;
}
_ => {}
}
index += 1;
}
if paren_depth != 0 || bracket_depth != 0 || quote.is_some() {
malformed = true;
}
let tail: String = chars[start..].iter().collect();
let tail = tail.trim();
if !tail.is_empty() {
compounds.push((tail.to_string(), InvalidationReaches::SELF));
} else if compounds.is_empty() {
malformed = true;
}
let mut descendants_to_right = false;
for (_, reaches) in compounds.iter_mut().rev() {
if reaches.contains(InvalidationReaches::DESCENDANTS) {
descendants_to_right = true;
} else if descendants_to_right && reaches.contains(InvalidationReaches::SIBLINGS) {
// `.foo ~ .bar .child`: a change to `.foo` can affect descendants
// of following siblings. A flat Siblings reach is insufficient
// unless phase 2 also carries the remaining descendant path.
*reaches = reaches.union(InvalidationReaches::CONSERVATIVE);
}
}
(compounds, malformed)
}
fn nth_of_selector(arguments: &str) -> Option<&str> {
let chars: Vec<char> = arguments.chars().collect();
let mut bracket_depth = 0usize;
let mut paren_depth = 0usize;
let mut quote = None;
let mut index = 0usize;
while index + 1 < chars.len() {
let ch = chars[index];
if ch == '\\' {
index += 2;
continue;
}
if let Some(active_quote) = quote {
if ch == active_quote {
quote = None;
}
index += 1;
continue;
}
match ch {
'\'' | '"' => quote = Some(ch),
'[' => bracket_depth += 1,
']' => bracket_depth = bracket_depth.saturating_sub(1),
'(' => paren_depth += 1,
')' => paren_depth = paren_depth.saturating_sub(1),
_ => {}
}
if bracket_depth == 0
&& paren_depth == 0
&& (index == 0 || chars[index - 1].is_whitespace())
&& ch.eq_ignore_ascii_case(&'o')
&& chars[index + 1].eq_ignore_ascii_case(&'f')
&& chars.get(index + 2).is_none_or(|ch| ch.is_whitespace())
{
let byte_index = arguments
.char_indices()
.nth(index + 2)
.map_or(arguments.len(), |(byte, _)| byte);
return Some(arguments[byte_index..].trim());
}
index += 1;
}
None
}
fn note_compound_dependencies(
map: &mut InvalidationMap,
compound: &str,
reaches: InvalidationReaches,
rule_order: usize,
) {
let chars: Vec<char> = compound.chars().collect();
let mut index = 0usize;
while index < chars.len() {
match chars[index] {
'#' => {
let (id, next) = consume_css_identifier(&chars, index + 1);
if id.is_empty() {
map.mark_conservative(rule_order);
} else {
map.push_id(id, rule_order, reaches);
}
index = next.max(index + 1);
}
'.' => {
let (class, next) = consume_css_identifier(&chars, index + 1);
if class.is_empty() {
map.mark_conservative(rule_order);
} else {
map.push_class(class, rule_order, reaches);
}
index = next.max(index + 1);
}
'[' => {
let Some(close) = matching_delimiter(&chars, index, '[', ']') else {
map.mark_conservative(rule_order);
return;
};
let mut name_index = index + 1;
while chars.get(name_index).is_some_and(|ch| ch.is_whitespace()) {
name_index += 1;
}
if chars.get(name_index) == Some(&'*') || chars.get(name_index) == Some(&'|') {
name_index += 1;
}
let (first, first_end) = consume_css_identifier(&chars, name_index);
let (attribute, end) = if chars.get(first_end) == Some(&'|') {
consume_css_identifier(&chars, first_end + 1)
} else {
(first, first_end)
};
if attribute.is_empty() || end > close {
map.mark_conservative(rule_order);
} else {
map.push_attribute(attribute, rule_order, reaches);
}
index = close + 1;
}
':' => {
if chars.get(index + 1) == Some(&':') {
let (_, next) = consume_css_identifier(&chars, index + 2);
index = next.max(index + 2);
continue;
}
let (name, next) = consume_css_identifier(&chars, index + 1);
let name = name.to_ascii_lowercase();
if name.is_empty() {
map.mark_conservative(rule_order);
index += 1;
continue;
}
if chars.get(next) != Some(&'(') {
map.push_state(name.clone(), rule_order, reaches);
if matches!(
name.as_str(),
"root"
| "scope"
| "target"
| "link"
| "any-link"
| "visited"
| "empty"
| "first-child"
| "last-child"
| "only-child"
| "first-of-type"
| "last-of-type"
| "only-of-type"
) {
// These change when nodes are inserted, removed, or
// reordered. Phase 2 has no tree-structural mutation
// lookup yet, so keep the complete cascade fallback.
map.mark_conservative(rule_order);
}
index = next;
continue;
}
let Some(close) = matching_delimiter(&chars, next, '(', ')') else {
map.mark_conservative(rule_order);
return;
};
let arguments: String = chars[next + 1..close].iter().collect();
match name.as_str() {
"is" | "where" | "not" => {
for alternative in split_selector_list(&arguments) {
note_selector_dependencies(
map,
alternative.trim(),
reaches,
rule_order,
);
}
}
"has" => {
// Relative selectors invalidate anchors upwards, which
// Self/Descendants/Siblings cannot express soundly.
map.mark_conservative(rule_order);
for alternative in split_selector_list(&arguments) {
note_selector_dependencies(
map,
alternative.trim(),
InvalidationReaches::CONSERVATIVE,
rule_order,
);
}
}
"nth-child" | "nth-last-child" | "nth-of-type" | "nth-last-of-type" => {
map.push_state(name, rule_order, reaches);
// Structural index changes and `of <complex-selector>`
// need sibling-wide bookkeeping not present in phase 1.
map.mark_conservative(rule_order);
if let Some(of_selector) = nth_of_selector(&arguments) {
for alternative in split_selector_list(of_selector) {
note_selector_dependencies(
map,
alternative.trim(),
InvalidationReaches::CONSERVATIVE,
rule_order,
);
}
}
}
_ => {
// A compiled functional pseudo outside the explicitly
// modeled set may hide selector or document state.
map.push_state(name, rule_order, reaches);
map.mark_conservative(rule_order);
}
}
index = close + 1;
}
'\\' => index = (index + 2).min(chars.len()),
_ => index += 1,
}
}
}
fn note_selector_dependencies(
map: &mut InvalidationMap,
selector: &str,
outer_reaches: InvalidationReaches,
rule_order: usize,
) {
let (compounds, malformed) = invalidation_compounds(selector);
if malformed {
map.mark_conservative(rule_order);
}
for (compound, local_reaches) in compounds {
if local_reaches.contains(InvalidationReaches::CONSERVATIVE) {
map.mark_conservative(rule_order);
}
let reaches = compose_invalidation_reach(map, local_reaches, outer_reaches, rule_order);
note_compound_dependencies(map, &compound, reaches, rule_order);
}
}
fn note_selector_for_invalidation(
map: &mut InvalidationMap,
selector: &str,
rule_order: usize,
) {
note_selector_dependencies(map, selector, InvalidationReaches::SELF, rule_order);
}
fn selector_requires_conservative_tracking(selector: &str) -> bool {
let selector = selector.to_ascii_lowercase();
selector.contains(":has(")
|| selector.contains(":nth-child(")
|| selector.contains(":nth-last-child(")
|| selector.contains(":nth-of-type(")
|| selector.contains(":nth-last-of-type(")
|| selector.contains(":target")
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
struct ContainerConditionId(u32); struct ContainerConditionId(u32);
@@ -754,6 +1350,7 @@ fn evaluate_container_query_expr(
/// An indexed set of author rules ready for fast per-element matching. /// An indexed set of author rules ready for fast per-element matching.
pub struct Stylesheet { pub struct Stylesheet {
rules: Vec<Rule>, rules: Vec<Rule>,
invalidation_map: InvalidationMap,
registered_custom_properties: HashMap<String, RegisteredCustomProperty>, registered_custom_properties: HashMap<String, RegisteredCustomProperty>,
/// Index zero is the unconditional sentinel. /// Index zero is the unconditional sentinel.
container_conditions: Vec<ContainerConditionNode>, container_conditions: Vec<ContainerConditionNode>,
@@ -852,6 +1449,12 @@ impl StylesheetCache {
} }
impl Stylesheet { impl Stylesheet {
/// Dependency metadata for conservative incremental-style invalidation.
/// Building this map does not itself enable incremental cascade skipping.
pub fn invalidation_map(&self) -> &InvalidationMap {
&self.invalidation_map
}
/// Until Stage B supplies completed container geometry, preserved /// Until Stage B supplies completed container geometry, preserved
/// conditional rules remain inactive rather than using viewport geometry. /// conditional rules remain inactive rather than using viewport geometry.
fn container_condition_is_active( fn container_condition_is_active(
@@ -926,6 +1529,7 @@ impl Stylesheet {
) -> Self { ) -> Self {
let mut sheet = Stylesheet { let mut sheet = Stylesheet {
rules: Vec::new(), rules: Vec::new(),
invalidation_map: InvalidationMap::default(),
registered_custom_properties: HashMap::new(), registered_custom_properties: HashMap::new(),
container_conditions: vec![ContainerConditionNode { container_conditions: vec![ContainerConditionNode {
parent: ContainerConditionId::NONE, parent: ContainerConditionId::NONE,
@@ -1001,6 +1605,11 @@ impl Stylesheet {
let sel_trim = selector.trim(); let sel_trim = selector.trim();
if let Some(base) = strip_pseudo_element(sel_trim, "before") { if let Some(base) = strip_pseudo_element(sel_trim, "before") {
if let Some(sel) = tree.compile_rule_selector(base) { if let Some(sel) = tree.compile_rule_selector(base) {
note_selector_for_invalidation(
&mut sheet.invalidation_map,
base,
order,
);
let (normal_decls, important_decls) = let (normal_decls, important_decls) =
crate::style::partition_declarations(&decls); crate::style::partition_declarations(&decls);
let normal_flags = declaration_stream_flags(&normal_decls); let normal_flags = declaration_stream_flags(&normal_decls);
@@ -1018,12 +1627,27 @@ impl Stylesheet {
container_condition_id, container_condition_id,
layer, layer,
}); });
} else if selector_requires_conservative_tracking(base) {
// Keep correctness metadata for relative/structural
// syntax that the current selector matcher cannot yet
// compile. Once matcher support lands, phase 2 must not
// silently treat the rule as locally invalidatable.
note_selector_for_invalidation(
&mut sheet.invalidation_map,
base,
order,
);
} }
order += 1; order += 1;
continue; continue;
} }
if let Some(base) = strip_pseudo_element(sel_trim, "after") { if let Some(base) = strip_pseudo_element(sel_trim, "after") {
if let Some(sel) = tree.compile_rule_selector(base) { if let Some(sel) = tree.compile_rule_selector(base) {
note_selector_for_invalidation(
&mut sheet.invalidation_map,
base,
order,
);
let (normal_decls, important_decls) = let (normal_decls, important_decls) =
crate::style::partition_declarations(&decls); crate::style::partition_declarations(&decls);
let normal_flags = declaration_stream_flags(&normal_decls); let normal_flags = declaration_stream_flags(&normal_decls);
@@ -1041,13 +1665,32 @@ impl Stylesheet {
container_condition_id, container_condition_id,
layer, layer,
}); });
} else if selector_requires_conservative_tracking(base) {
note_selector_for_invalidation(
&mut sheet.invalidation_map,
base,
order,
);
} }
order += 1; order += 1;
continue; continue;
} }
let Some(sel) = tree.compile_rule_selector(&selector) else { let Some(sel) = tree.compile_rule_selector(&selector) else {
if selector_requires_conservative_tracking(&selector) {
note_selector_for_invalidation(
&mut sheet.invalidation_map,
&selector,
order,
);
order += 1;
}
continue; continue;
}; };
note_selector_for_invalidation(
&mut sheet.invalidation_map,
&selector,
order,
);
let (normal_decls, important_decls) = crate::style::partition_declarations(&decls); let (normal_decls, important_decls) = crate::style::partition_declarations(&decls);
let specificity = sel.specificity(); let specificity = sel.specificity();
let normal_flags = declaration_stream_flags(&normal_decls); let normal_flags = declaration_stream_flags(&normal_decls);
@@ -4831,6 +5474,201 @@ fn split_media_query_list(query: &str) -> Vec<&str> {
mod tests { mod tests {
use super::*; use super::*;
fn test_invalidation_map(css: &str) -> InvalidationMap {
let tree = obscura_dom::parse_html("<html><body></body></html>");
Stylesheet::parse(&tree, &[css.to_string()])
.invalidation_map()
.clone()
}
fn dependencies_reach(
dependencies: &[InvalidationDependency],
reaches: InvalidationReaches,
) -> bool {
dependencies
.iter()
.any(|dependency| dependency.reaches.contains(reaches))
}
#[test]
fn invalidation_map_classifies_compound_reach_without_false_negatives() {
let map = test_invalidation_map(
r#"
#self { color:red }
#ancestor .subject { color:red }
#parent > .child { color:red }
#previous + .adjacent { color:red }
#earlier ~ .later { color:red }
.repeat .repeat { color:red }
"#,
);
assert!(dependencies_reach(
map.id_dependencies("self"),
InvalidationReaches::SELF,
));
assert!(dependencies_reach(
map.id_dependencies("ancestor"),
InvalidationReaches::DESCENDANTS,
));
assert!(dependencies_reach(
map.id_dependencies("parent"),
InvalidationReaches::DESCENDANTS,
));
assert!(dependencies_reach(
map.id_dependencies("previous"),
InvalidationReaches::SIBLINGS,
));
assert!(dependencies_reach(
map.id_dependencies("earlier"),
InvalidationReaches::SIBLINGS,
));
let repeated = map.class_dependencies("repeat");
assert_eq!(repeated.len(), 1, "same-rule dependencies must merge");
assert!(repeated[0].reaches.contains(InvalidationReaches::SELF));
assert!(repeated[0]
.reaches
.contains(InvalidationReaches::DESCENDANTS));
assert!(!map.requires_conservative_invalidation());
}
#[test]
fn invalidation_map_indexes_attributes_states_functions_and_escaped_keys() {
let map = test_invalidation_map(
r#"
[data-theme] .panel:hover { color:red }
:is(.alpha, #beta) > button:focus-visible { color:red }
button:not([disabled]) { color:red }
.sm\:hover\:px-2[data-KIND] { color:red }
.group:focus-within .icon { color:red }
"#,
);
assert!(dependencies_reach(
map.attribute_dependencies("DATA-THEME"),
InvalidationReaches::DESCENDANTS,
));
assert!(dependencies_reach(
map.class_dependencies("panel"),
InvalidationReaches::SELF,
));
assert!(dependencies_reach(
map.state_dependencies("hover"),
InvalidationReaches::SELF,
));
assert!(dependencies_reach(
map.class_dependencies("alpha"),
InvalidationReaches::DESCENDANTS,
));
assert!(dependencies_reach(
map.id_dependencies("beta"),
InvalidationReaches::DESCENDANTS,
));
assert!(dependencies_reach(
map.state_dependencies("focus-visible"),
InvalidationReaches::SELF,
));
assert!(dependencies_reach(
map.attribute_dependencies("disabled"),
InvalidationReaches::SELF,
));
assert!(dependencies_reach(
map.class_dependencies("sm:hover:px-2"),
InvalidationReaches::SELF,
));
assert!(dependencies_reach(
map.attribute_dependencies("data-kind"),
InvalidationReaches::SELF,
));
assert!(dependencies_reach(
map.state_dependencies("focus-within"),
InvalidationReaches::DESCENDANTS,
));
assert!(!map.requires_conservative_invalidation());
}
#[test]
fn invalidation_map_marks_relative_and_nth_dependencies_conservative() {
let map = test_invalidation_map(
r#"
.card:has(> .badge) { color:red }
.row:has(+ .notice) .label { color:red }
.item:nth-child(2n of .eligible) { color:red }
.typed:nth-of-type(odd) { color:red }
.trigger ~ .branch .leaf { color:red }
:root { color:red }
a:link, a:visited, :target { color:red }
"#,
);
assert!(map.requires_conservative_invalidation());
assert!(map.conservative_rule_orders().len() >= 3);
assert!(dependencies_reach(
map.class_dependencies("badge"),
InvalidationReaches::CONSERVATIVE,
));
assert!(dependencies_reach(
map.class_dependencies("notice"),
InvalidationReaches::CONSERVATIVE,
));
assert!(dependencies_reach(
map.class_dependencies("eligible"),
InvalidationReaches::CONSERVATIVE,
));
assert!(!map.state_dependencies("nth-child").is_empty());
assert!(!map.state_dependencies("nth-of-type").is_empty());
let trigger = map.class_dependencies("trigger");
assert!(dependencies_reach(
trigger,
InvalidationReaches::SIBLINGS,
));
assert!(dependencies_reach(
trigger,
InvalidationReaches::CONSERVATIVE,
));
assert!(dependencies_reach(
map.class_dependencies("branch"),
InvalidationReaches::DESCENDANTS,
));
for state in ["root", "link", "visited", "target"] {
assert!(
!map.state_dependencies(state).is_empty(),
"missing conservative state dependency for :{state}",
);
}
}
#[test]
fn invalidation_map_includes_generated_content_host_dependencies() {
let map = test_invalidation_map(
r#"
.toolbar[data-open] > .button:hover::before { content:"x" }
#status::after { content:"ok" }
"#,
);
assert!(dependencies_reach(
map.class_dependencies("toolbar"),
InvalidationReaches::DESCENDANTS,
));
assert!(dependencies_reach(
map.attribute_dependencies("data-open"),
InvalidationReaches::DESCENDANTS,
));
assert!(dependencies_reach(
map.class_dependencies("button"),
InvalidationReaches::SELF,
));
assert!(dependencies_reach(
map.state_dependencies("hover"),
InvalidationReaches::SELF,
));
assert!(dependencies_reach(
map.id_dependencies("status"),
InvalidationReaches::SELF,
));
}
fn condition_arena_root() -> Vec<ContainerConditionNode> { fn condition_arena_root() -> Vec<ContainerConditionNode> {
vec![ContainerConditionNode { vec![ContainerConditionNode {
parent: ContainerConditionId::NONE, parent: ContainerConditionId::NONE,