Build conservative style invalidation metadata
This commit is contained in:
1 parent
cfac7f2003
commit
b8bdac2163
1 file changed
+838
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user