From 27502a2f737f5100360c0e9eea0975e9ac0ebfe4 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Wed, 19 Aug 2026 01:39:49 +0200 Subject: [PATCH 01/39] LibWeb: Repair Rust style test fixtures Four Rust style tests fail on origin/master because their fixtures omit facts or expectations required by the behavior they exercise. Give guard rules their actual specificity, enable answer completion where required, publish tag facts for deactivation, and expect routes to consolidate before late exact-entry grouping. --- Libraries/LibWeb/Rust/src/css/style/tests.rs | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index d5ed6062c3e4..40b9522153eb 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -1351,9 +1351,14 @@ fn nested_document() -> (StyleEngine, Vec) { } fn add_guard_target_rule(engine: &mut StyleEngine, guard: StyleAtomID, target: StyleAtomID) -> RuleID { - let program = engine.programs.add(test_selector_program( + let program = engine.programs.add(test_selector_program_with_metadata( ".guard .target", &[("guard", guard), ("target", target)], + Some(Specificity { + classes: 2, + ..Specificity::default() + }), + None, )); let sheet = engine.add_sheet(StyleSheetObjectID(1), CascadeOrigin::Author); @@ -1373,9 +1378,14 @@ fn add_guard_target_rule_in_sheet( guard: StyleAtomID, target: StyleAtomID, ) -> RuleID { - let program = engine.programs.add(test_selector_program( + let program = engine.programs.add(test_selector_program_with_metadata( ".guard .target", &[("guard", guard), ("target", target)], + Some(Specificity { + classes: 2, + ..Specificity::default() + }), + None, )); let sheet = engine.add_sheet(sheet_object, CascadeOrigin::Author); engine.attach_sheet(sheet, TreeScopeID::DOCUMENT); @@ -4300,7 +4310,7 @@ fn an_identity_only_published_prefix_answer_is_returned_in_cascade_order() { } discard_transaction(&mut engine); - engine.begin_published_match_answer_completion_batch(nodes[0], false); + engine.begin_published_match_answer_completion_batch(nodes[0], true); assert_eq!( engine .match_element_for_cascade(nodes[2]) @@ -7018,6 +7028,9 @@ fn rule_deactivation_reaches_only_nodes_where_the_rule_won() { engine.set_rule_declared_properties(toggled, &[(1, false)], true); let winner = add_target_rule(&mut engine, StyleSheetObjectID(2), overriding); engine.set_rule_declared_properties(winner, &[(1, true)], true); + for &node in &nodes { + set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + } for node in [nodes[1], nodes[2]] { add_feature(&mut engine, node, FeatureKey::Class(target)); } @@ -7080,7 +7093,8 @@ fn local_routes_for_one_exact_entry_are_compared_once() { assert_eq!(planned, vec![nodes[3].raw()]); assert_eq!( engine.counters().get(Counter::GroupedExactSelectorRoutes) - grouped_before, - 1 + 0, + "routes consolidate before late exact-entry grouping" ); assert_eq!(engine.memory().bytes_in_category(MemoryCategory::BatchScratch), 0); } From 3b3ab8e0f2151ae6af946fc39360b59318526013 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sun, 16 Aug 2026 18:23:30 +0200 Subject: [PATCH 02/39] LibWeb: Record canonical longhand tables once in style captures Style captures recorded a fresh longhand value pointer for every publication, even when the engine had canonicalized to an older, value-equal table. Replay then compared opaque pointer tokens as live values and crashed on the first such publication. Record each canonical longhand table once per document engine and reference it afterwards. Drop the source-slot sidecar, which replay no longer needs. --- Libraries/LibWeb/Rust/src/bin/style_replay.rs | 92 +++++++++++-------- .../Rust/src/css/computed_longhand_table.rs | 9 -- Libraries/LibWeb/Rust/src/css/style/bridge.rs | 37 ++++---- .../LibWeb/Rust/src/css/style/computed.rs | 22 +++++ .../LibWeb/Rust/src/css/style/publication.rs | 13 +++ .../Rust/src/css/style/record_replay.rs | 2 +- Libraries/LibWeb/Rust/src/css/style_value.rs | 31 +++++-- 7 files changed, 134 insertions(+), 72 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/bin/style_replay.rs b/Libraries/LibWeb/Rust/src/bin/style_replay.rs index f22112562e20..392901c909c9 100644 --- a/Libraries/LibWeb/Rust/src/bin/style_replay.rs +++ b/Libraries/LibWeb/Rust/src/bin/style_replay.rs @@ -94,6 +94,8 @@ fn run() -> Result<(), Box> { let mut detailed_counter_reader = None; let mut recorded_style_record_payloads = Vec::>>>>::new(); let mut recorded_style_record_views = Vec::>>>>::new(); + let mut computed_longhand_tables = + Vec::>>::new(); let mut computed_group_payloads = Vec::new(); let mut animation_overlay_payloads = Vec::new(); let mut inheritance_dependent_properties = Vec::new(); @@ -135,9 +137,11 @@ fn run() -> Result<(), Box> { if live_engines.len() <= index { live_engines.resize(index + 1, None); match_answer_identity_mappings.resize_with(index + 1, MatchAnswerIdentityMapping::default); + computed_longhand_tables.resize_with(index + 1, Vec::new); } live_engines[index] = Some(engine); match_answer_identity_mappings[index] = MatchAnswerIdentityMapping::default(); + computed_longhand_tables[index].clear(); engine_count += 1; } EventKind::SetComputedGroupDependencyMasks => { @@ -155,11 +159,12 @@ fn run() -> Result<(), Box> { } EventKind::DestroyGraph => { let engine_id = event.payload.read_u64()?; - let engine = usize::try_from(engine_id) - .ok() - .and_then(|index| live_engines.get_mut(index)) + let engine_index = usize::try_from(engine_id)?; + let engine = live_engines + .get_mut(engine_index) .and_then(Option::take) .ok_or_else(|| format!("engine {engine_id} was destroyed without being live"))?; + release_computed_longhand_tables(&mut computed_longhand_tables[engine_index]); accumulate_memory_pressure(&mut memory_pressure, unsafe { bridge::replay_memory_pressure_snapshot(engine) }); @@ -711,7 +716,7 @@ fn run() -> Result<(), Box> { } } EventKind::PublishComputedGroups => { - let engine = read_engine(&mut event.payload, &live_engines)?; + let (engine_index, engine) = read_engine_indexed(&mut event.payload, &live_engines)?; let node = event.payload.read_u32()?; let pseudo_kind = event.payload.read_u8()?; let group_count = event.payload.read_length()?; @@ -759,38 +764,45 @@ fn run() -> Result<(), Box> { let longhand_table = match event.payload.read_bool()? { false => std::ptr::null_mut(), true => { - let table = - libweb_rust::css::computed_longhand_table::rust_computed_longhand_table_create(); - let stored_value_count = event.payload.read_length()?; - let mut stored_values = Vec::with_capacity(stored_value_count); - for _ in 0..stored_value_count { - let property = event.payload.read_u16()?; - let token = event.payload.read_u64()?; - let flags = event.payload.read_u8()?; - stored_values.push((property, bridge::replay_style_value(token, flags))); - } - let source_slot_count = event.payload.read_length()?; - let mut source_slots = std::collections::HashMap::new(); - for _ in 0..source_slot_count { - let property = event.payload.read_u16()?; - let slot = event.payload.read_u32()?; - source_slots.insert(property, slot); - } - for (property, value) in stored_values { - let slot = source_slots.get(&property).map_or(-1, |&slot| i64::from(slot)); + let identity = usize::try_from(event.payload.read_u32()?)?; + let record_definition = event.payload.read_bool()?; + let tables = &mut computed_longhand_tables[engine_index]; + if record_definition { + if tables.len() <= identity { + tables.resize(identity + 1, None); + } + if tables[identity].is_some() { + return Err(format!("computed longhand table {identity} was defined twice").into()); + } + let table = + libweb_rust::css::computed_longhand_table::rust_computed_longhand_table_create(); + let stored_value_count = event.payload.read_length()?; + for _ in 0..stored_value_count { + let property = event.payload.read_u16()?; + let token = event.payload.read_u64()?; + let flags = event.payload.read_u8()?; + let value = bridge::replay_style_value(token, flags); + unsafe { + libweb_rust::css::computed_longhand_table::rust_computed_longhand_table_set( + table, + property, + value.cast(), + -1, + ); + } + } unsafe { - libweb_rust::css::computed_longhand_table::rust_computed_longhand_table_set( + libweb_rust::css::computed_longhand_table::rust_computed_longhand_table_freeze( table, - property, - value.cast(), - slot, ); } + tables[identity] = Some(table); } - unsafe { - libweb_rust::css::computed_longhand_table::rust_computed_longhand_table_freeze(table); - } - table + tables + .get(identity) + .copied() + .flatten() + .ok_or_else(|| format!("computed longhand table {identity} was not defined"))? } }; let expected = bridge::FfiStyleRecordDelta { @@ -824,13 +836,6 @@ fn run() -> Result<(), Box> { longhand_table.cast_const().cast(), ) }; - if !longhand_table.is_null() { - unsafe { - libweb_rust::css::computed_longhand_table::rust_computed_longhand_table_release( - longhand_table, - ); - } - } if actual != expected { return Err(format!( "computed style publication diverged for node {node}: expected {expected:?}, got {actual:?}" @@ -930,6 +935,9 @@ fn run() -> Result<(), Box> { event.payload.finish()?; } let live_at_process_exit = live_engines.iter().flatten().count(); + for tables in &mut computed_longhand_tables { + release_computed_longhand_tables(tables); + } for engine in live_engines.into_iter().flatten() { accumulate_memory_pressure(&mut memory_pressure, unsafe { bridge::replay_memory_pressure_snapshot(engine) @@ -1576,6 +1584,14 @@ fn read_engine_indexed( Ok((index, pointer)) } +fn release_computed_longhand_tables( + tables: &mut Vec>, +) { + for table in tables.drain(..).flatten() { + unsafe { libweb_rust::css::computed_longhand_table::rust_computed_longhand_table_release(table) }; + } +} + fn style_record_replay_index(style_record: u64) -> Result { const ANIMATION_OVERLAY_TAG: u64 = 1 << 63; diff --git a/Libraries/LibWeb/Rust/src/css/computed_longhand_table.rs b/Libraries/LibWeb/Rust/src/css/computed_longhand_table.rs index cccf32a97620..1f64e3ae0a89 100644 --- a/Libraries/LibWeb/Rust/src/css/computed_longhand_table.rs +++ b/Libraries/LibWeb/Rust/src/css/computed_longhand_table.rs @@ -405,15 +405,6 @@ impl ComputedLonghandTable { &self.value_view } - /// The complete style-sheet-context sidecar, in property order. - pub(crate) fn source_slot_entries(&self) -> impl Iterator + '_ { - self.source_slots.iter().enumerate().filter_map(|(index, &slot)| { - u32::try_from(slot) - .ok() - .map(|slot| (FIRST_LONGHAND_PROPERTY_ID + index as u16, slot)) - }) - } - pub(crate) fn is_frozen(&self) -> bool { self.frozen } diff --git a/Libraries/LibWeb/Rust/src/css/style/bridge.rs b/Libraries/LibWeb/Rust/src/css/style/bridge.rs index 8db7add7bb27..bd01e097ebf7 100644 --- a/Libraries/LibWeb/Rust/src/css/style/bridge.rs +++ b/Libraries/LibWeb/Rust/src/css/style/bridge.rs @@ -2059,24 +2059,25 @@ pub unsafe extern "C" fn style_engine_publish_computed_groups( .as_ref() }; payload.write_bool(longhand_table.is_some()); - if let Some(table) = longhand_table { - let stored_values = table - .value_pointers() - .iter() - .enumerate() - .filter(|(_, value)| !value.is_null()) - .collect::>(); - payload.write_length(stored_values.len()); - for (index, &value) in stored_values { - payload.write_u16(crate::css::property_metadata::FIRST_LONGHAND_PROPERTY_ID + index as u16); - payload.write_u64(pointer_token(value)); - payload.write_u8(crate::css::style_value::style_value_dependency_flags(value.cast())); - } - let source_slots = table.source_slot_entries().collect::>(); - payload.write_length(source_slots.len()); - for (property, slot) in source_slots { - payload.write_u16(property); - payload.write_u32(slot); + if longhand_table.is_some() { + let (identity, canonical_values) = engine + .recording_computed_longhand_table(result.new_style_record) + .expect("a published style record must retain its longhand table"); + payload.write_u32(identity); + let record_definition = engine.recording_first_response(2, u64::from(identity)); + payload.write_bool(record_definition); + if record_definition { + let stored_values = canonical_values + .iter() + .enumerate() + .filter(|(_, value)| !value.is_null()) + .collect::>(); + payload.write_length(stored_values.len()); + for (index, &value) in stored_values { + payload.write_u16(crate::css::property_metadata::FIRST_LONGHAND_PROPERTY_ID + index as u16); + payload.write_u64(pointer_token(value)); + payload.write_u8(crate::css::style_value::style_value_dependency_flags(value.cast())); + } } } payload.write_u64(result.old_style_record); diff --git a/Libraries/LibWeb/Rust/src/css/style/computed.rs b/Libraries/LibWeb/Rust/src/css/style/computed.rs index 1b6d6082404a..83f52aab5886 100644 --- a/Libraries/LibWeb/Rust/src/css/style/computed.rs +++ b/Libraries/LibWeb/Rust/src/css/style/computed.rs @@ -1934,6 +1934,28 @@ impl ComputedGroupSets { ) } + #[cfg(feature = "style-recording")] + pub(crate) fn recording_longhand_table(&self, raw_style_record: u64) -> Option<(u32, &[*const c_void])> { + let final_style_record = FinalStyleRecordID(raw_style_record); + let base_style_record = match final_style_record.base_record() { + Some(style_record) => style_record, + None => { + let slot = *self.animation_overlay_slots_by_record.get(&final_style_record)?; + self.animation_overlay_slots[slot as usize].as_ref()?.base_style_record + } + }; + let identity = self + .style_records + .get_index(base_style_record.raw() as usize - 1)? + .longhand_table?; + Some(( + identity.0, + self.computed_longhand_tables + .get_index(identity.0 as usize)? + .value_view(), + )) + } + pub(crate) fn style_record_view(&self, raw_style_record: u64) -> Option> { let final_style_record = FinalStyleRecordID(raw_style_record); let (base_style_record, payloads, animation_overlay_identity, animated_properties) = diff --git a/Libraries/LibWeb/Rust/src/css/style/publication.rs b/Libraries/LibWeb/Rust/src/css/style/publication.rs index 86a3753f5225..6bed3cc3a551 100644 --- a/Libraries/LibWeb/Rust/src/css/style/publication.rs +++ b/Libraries/LibWeb/Rust/src/css/style/publication.rs @@ -157,6 +157,19 @@ impl StyleEngine { } } + pub(crate) fn recording_computed_longhand_table( + &self, + style_record: u64, + ) -> Option<(u32, &[*const std::ffi::c_void])> { + #[cfg(feature = "style-recording")] + return self.computed_group_sets.recording_longhand_table(style_record); + #[cfg(not(feature = "style-recording"))] + { + let _ = style_record; + None + } + } + pub(crate) fn style_record_view(&self, style_record: u64) -> Option> { self.computed_group_sets.style_record_view(style_record) } diff --git a/Libraries/LibWeb/Rust/src/css/style/record_replay.rs b/Libraries/LibWeb/Rust/src/css/style/record_replay.rs index 40b8c93ddfcd..54170d4fe891 100644 --- a/Libraries/LibWeb/Rust/src/css/style/record_replay.rs +++ b/Libraries/LibWeb/Rust/src/css/style/record_replay.rs @@ -30,7 +30,7 @@ use std::sync::Mutex; use std::sync::OnceLock; const MAGIC: [u8; 8] = *b"SGREPLAY"; -const FORMAT_VERSION: u64 = 3; +const FORMAT_VERSION: u64 = 5; const EVENT_HEADER_SIZE: usize = 3 * size_of::(); const PAYLOAD_ALIGNMENT: usize = 8; diff --git a/Libraries/LibWeb/Rust/src/css/style_value.rs b/Libraries/LibWeb/Rust/src/css/style_value.rs index 18dc1dd36c54..9741c406292e 100644 --- a/Libraries/LibWeb/Rust/src/css/style_value.rs +++ b/Libraries/LibWeb/Rust/src/css/style_value.rs @@ -18,21 +18,21 @@ use std::ffi::c_void; use std::sync::Arc; -#[cfg(feature = "style-recording")] +#[cfg(any(test, feature = "style-recording"))] use std::cell::RefCell; -#[cfg(feature = "style-recording")] +#[cfg(any(test, feature = "style-recording"))] use std::collections::HashMap; use crate::abort_on_panic; pub(crate) use crate::css::retained_fly_string::{RetainedUtf16FlyString, RetainedUtf16FlyStringList}; -#[cfg(feature = "style-recording")] +#[cfg(any(test, feature = "style-recording"))] thread_local! { static REPLAY_STYLE_VALUES: RefCell> = RefCell::new(HashMap::new()); } -#[cfg(feature = "style-recording")] +#[cfg(any(test, feature = "style-recording"))] pub(crate) fn register_replay_style_value(token: u64, dependency_flags: u8) -> *const StyleValueData { let pointer = usize::try_from(token).expect("style-value token exceeds usize"); assert!(pointer != 0, "style-value tokens are nonzero"); @@ -44,9 +44,9 @@ pub(crate) fn register_replay_style_value(token: u64, dependency_flags: u8) -> * } fn replay_style_value_dependency_flags(value: *const StyleValueData) -> Option { - #[cfg(feature = "style-recording")] + #[cfg(any(test, feature = "style-recording"))] return REPLAY_STYLE_VALUES.with(|values| values.borrow().get(&(value as usize)).copied()); - #[cfg(not(feature = "style-recording"))] + #[cfg(not(any(test, feature = "style-recording")))] { let _ = value; None @@ -3678,6 +3678,11 @@ pub unsafe extern "C" fn rust_style_value_equals(first: *const StyleValueData, s if first.is_null() || second.is_null() { return false; } + // Replay pointers are opaque identity tokens rather than readable StyleValueData. + if replay_style_value_dependency_flags(first).is_some() || replay_style_value_dependency_flags(second).is_some() + { + return false; + } unsafe { *first == *second } }) } @@ -3858,6 +3863,20 @@ mod substitution_clone_tests { } } +#[cfg(test)] +mod replay_tests { + use super::*; + + #[test] + fn distinct_replay_tokens_compare_unequal_without_being_dereferenced() { + let first = register_replay_style_value(0x1234, 0); + let second = register_replay_style_value(0x5678, 0); + + assert!(unsafe { rust_style_value_equals(first, first) }); + assert!(!unsafe { rust_style_value_equals(first, second) }); + } +} + /// Whether a value's computed color depends on the element's used currentcolor: the /// currentcolor keyword itself, or a color function, color-mix(), contrast-color() or /// light-dark() whose nested colors do. From e5f5259e42ca4bd9896db6d58c17e10cea3a49d3 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sun, 16 Aug 2026 11:43:26 +0200 Subject: [PATCH 03/39] LibWeb: Use one match answer identity The catalog interned exact match answers and their cascade-compacted form in one table, but named the same integer with two newtypes, MatchAnswerID and CascadeInputID, and converted between them at fourteen call sites. Nothing distinguished the two identities except which reference count was held. Keep MatchAnswerID as the only answer identity. Whether an answer is exact or compacted is a property of its payload, not a second identity type, so the conversions and their bookkeeping go away. --- .../LibWeb/Rust/src/css/style/catalog.rs | 41 +++++++-------- Libraries/LibWeb/Rust/src/css/style/flush.rs | 6 +-- .../LibWeb/Rust/src/css/style/matching.rs | 50 +++++++++---------- Libraries/LibWeb/Rust/src/css/style/tests.rs | 20 ++++---- 4 files changed, 56 insertions(+), 61 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/catalog.rs b/Libraries/LibWeb/Rust/src/css/style/catalog.rs index 5eee726e75bf..e64ca599985d 100644 --- a/Libraries/LibWeb/Rust/src/css/style/catalog.rs +++ b/Libraries/LibWeb/Rust/src/css/style/catalog.rs @@ -17,11 +17,6 @@ impl super::intern_table::InternIdentity for MatchAnswerID { } } -define_id! { - /// Identity of the cascade-compacted rule input published to style computation. - default pub(super) struct CascadeInputID(pub(super)); -} - pub(super) struct MatchAnswerCatalogEntry { pub(super) answer: Rc<[RetainedRuleMatch]>, pub(super) prefix_references: u32, @@ -325,7 +320,7 @@ impl MatchAnswerCatalog { pub(super) struct PrefixAnswer { pub(super) matches: MatchAnswerID, pub(super) winner_group: Option<(u64, CascadeStateID)>, - pub(super) cascade_input: CascadeInputID, + pub(super) cascade_input: MatchAnswerID, pub(super) cascade_winner_inventory_is_complete: bool, } @@ -515,7 +510,7 @@ impl PrefixAnswerCache { key: PrefixAnswerKey, answer: &[RuleMatch], winner_group: Option<(u64, CascadeStateID)>, - cascade_input: CascadeInputID, + cascade_input: MatchAnswerID, cascade_winner_inventory_is_complete: bool, ) { self.with_payload_accounting(catalog, |cache, catalog| { @@ -720,7 +715,7 @@ pub(super) fn merge_retained_match_answers(answer: &mut Vec, pub(super) struct RetainedMatchAnswers { pub(super) column: Vec, - pub(super) cascade_input_column: Vec, + pub(super) cascade_input_column: Vec, pub(super) cascade_input_memory: MemoryLease, pub(super) residency: MemoryLease, } @@ -902,7 +897,7 @@ pub(super) struct RetainedAnswerPatch { #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub(super) struct RetainedAnswerDeltaMemoKey { pub(super) old_answer: MatchAnswerID, - pub(super) old_cascade_input: CascadeInputID, + pub(super) old_cascade_input: MatchAnswerID, pub(super) delta_count: usize, pub(super) delta_digest: u64, } @@ -917,7 +912,7 @@ pub(super) struct RetainedAnswerDeltaTransition { pub(super) new_answer: MatchAnswerID, /// The compact identity after the transition. Equal to the asker's old identity exactly when /// the transition stopped. - pub(super) new_cascade_input: CascadeInputID, + pub(super) new_cascade_input: MatchAnswerID, /// The winner state the cohort's first member settled after applying the same deltas, with /// its program version, when one was retained. Emitting replays assign it by column store. pub(super) winner_state: Option<(CascadeStateID, ProgramVersion)>, @@ -937,7 +932,7 @@ pub(super) struct RetainedAnswerPatchOutcome { pub(super) struct IncrementalCascadeAnswer { pub(super) node: StyleNodeID, - pub(super) cascade_input: CascadeInputID, + pub(super) cascade_input: MatchAnswerID, pub(super) matches: Option>, pub(super) cascade_winners_are_complete: bool, } @@ -1118,14 +1113,14 @@ impl RetainedMatchAnswers { &mut self, catalog: &mut MatchAnswerCatalog, node: StyleNodeID, - cascade_input: CascadeInputID, + cascade_input: MatchAnswerID, memory: &mut MemoryController, ) { let Some(index) = node.element_index().map(|index| index as usize) else { return; }; if self.cascade_input_column.len() <= index { - self.cascade_input_column.resize(index + 1, CascadeInputID::default()); + self.cascade_input_column.resize(index + 1, MatchAnswerID::default()); let current = self.cascade_input_capacity_bytes(catalog); self.cascade_input_memory.resize_required_to(memory, current); } @@ -1133,10 +1128,10 @@ impl RetainedMatchAnswers { if previous == cascade_input { return; } - if previous != CascadeInputID::default() { - catalog.release_cascade(MatchAnswerID(previous.0)); + if previous != MatchAnswerID::default() { + catalog.release_cascade(previous); } - catalog.retain_cascade(MatchAnswerID(cascade_input.0)); + catalog.retain_cascade(cascade_input); let current = self.cascade_input_capacity_bytes(catalog); self.cascade_input_memory.resize_required_to(memory, current); } @@ -1149,19 +1144,19 @@ impl RetainedMatchAnswers { return; }; let previous = std::mem::take(slot); - if previous != CascadeInputID::default() { - catalog.release_cascade(MatchAnswerID(previous.0)); + if previous != MatchAnswerID::default() { + catalog.release_cascade(previous); self.cascade_input_memory .shrink_to(self.cascade_input_capacity_bytes(catalog)); } } - pub(super) fn cascade_input_lookup(&self, node: StyleNodeID) -> Lookup<&CascadeInputID, StyleNodeID> { + pub(super) fn cascade_input_lookup(&self, node: StyleNodeID) -> Lookup<&MatchAnswerID, StyleNodeID> { let Some(index) = node.element_index().map(|index| index as usize) else { return Lookup::Missing(node); }; match self.cascade_input_column.get(index) { - Some(cascade_input) if *cascade_input != CascadeInputID::default() => Lookup::Known(cascade_input), + Some(cascade_input) if *cascade_input != MatchAnswerID::default() => Lookup::Known(cascade_input), _ => Lookup::Missing(node), } } @@ -1311,7 +1306,7 @@ pub(super) type RoutePruningStateCache = HashMap<(DispatchKey, u64), Option, + pub(super) cascade_input: Option, pub(super) matches: Option>, pub(super) cascade_winners_are_complete: bool, pub(super) observed: bool, @@ -1319,7 +1314,7 @@ pub(super) struct PublishedMatchAnswer { pub(super) struct PublishedMatchAnswers { pub(super) entries: Vec, - pub(super) shared_payloads: HashMap>, + pub(super) shared_payloads: HashMap>, pub(super) memory: MemoryLease, pub(super) match_element_calls_at_publication: u64, pub(super) discard_unobserved_retained_answers: bool, @@ -1400,7 +1395,7 @@ impl PublishedMatchAnswers { self.entries.push(entry); let added_bytes = (self.entries.capacity() - entries_capacity_before) * size_of::() + (self.shared_payloads.capacity() - shared_payload_capacity_before) - * (size_of::() + size_of::>() + 1) + * (size_of::() + size_of::>() + 1) + added_payload_bytes; let added_bytes = added_bytes as u64; self.memory.grow_required(memory, added_bytes); diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index 7c212b531e2f..cfed44646abd 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -995,7 +995,7 @@ impl StyleEngine { { let published_node_bytes = (published_nodes.capacity() * size_of::()) as u64; let previous_cascade_input_bytes = - (previous_cascade_inputs.capacity() * size_of::>()) as u64; + (previous_cascade_inputs.capacity() * size_of::>()) as u64; let identity_repair_node_bytes = (identity_repair_nodes.capacity() * size_of::()) as u64; self.memory .reserve_required(MemoryCategory::BatchScratch, published_node_bytes); @@ -1034,7 +1034,7 @@ impl StyleEngine { // Exact retained answers are interned across elements. Once one sufficiently // expensive answer has been compacted in this completion batch, other elements // without element declarations can share its cascade input and winner identities. - let mut completed_retained_answers: HashMap = + let mut completed_retained_answers: HashMap = HashMap::default(); let mut completed_retained_answer_bytes = 0_u64; let share_cascade_completions = published_nodes.len() >= MIN_SHARED_CASCADE_COMPLETION_BATCH; @@ -1102,7 +1102,7 @@ impl StyleEngine { published_answer.cascade_winners_are_complete, )); let added_bytes = ((completed_retained_answers.capacity() - capacity_before) - * (size_of::() + size_of::<(StyleNodeID, CascadeInputID, bool)>() + 1)) + * (size_of::() + size_of::<(StyleNodeID, MatchAnswerID, bool)>() + 1)) as u64; self.memory.reserve_required(MemoryCategory::BatchScratch, added_bytes); completed_retained_answer_bytes += added_bytes; diff --git a/Libraries/LibWeb/Rust/src/css/style/matching.rs b/Libraries/LibWeb/Rust/src/css/style/matching.rs index a54615fd9713..dcc7fdb16290 100644 --- a/Libraries/LibWeb/Rust/src/css/style/matching.rs +++ b/Libraries/LibWeb/Rust/src/css/style/matching.rs @@ -24,7 +24,7 @@ fn verify_against_cold( impl StyleEngine { fn retained_answer_delta_memo_key( old_answer: MatchAnswerID, - old_cascade_input: CascadeInputID, + old_cascade_input: MatchAnswerID, deltas: &[SelectorTruthDelta], ) -> RetainedAnswerDeltaMemoKey { let mut hasher = fast_hash::fast_hasher(); @@ -1323,7 +1323,7 @@ impl StyleEngine { .any(|scope| scope != TreeScopeID::DOCUMENT) } - pub(super) fn publish_cascade_input(&mut self, node: StyleNodeID, cascade_input: CascadeInputID) { + pub(super) fn publish_cascade_input(&mut self, node: StyleNodeID, cascade_input: MatchAnswerID) { if !self.match_answer_is_comparable_across_elements(node) { self.retained_match_answers .forget_cascade_input(&mut self.match_answers, node); @@ -2017,7 +2017,7 @@ impl StyleEngine { node: StyleNodeID, patch: &mut RetainedAnswerPatch, retained: &[RetainedRuleMatch], - old_cascade_input: CascadeInputID, + old_cascade_input: MatchAnswerID, ) -> Option { if patch.cascade_update_properties.is_empty() || patch.requires_full_match { return None; @@ -2045,7 +2045,7 @@ impl StyleEngine { }) { return None; } - let old_compact = Rc::clone(self.match_answers.answer(MatchAnswerID(old_cascade_input.0))?); + let old_compact = Rc::clone(self.match_answers.answer(old_cascade_input)?); let updates = self.exact_cascade_winner_updates_for_properties_with_scratch( node, &exact_answer, @@ -2297,7 +2297,7 @@ impl StyleEngine { patch: &mut RetainedAnswerPatch, old_identity: MatchAnswerID, retained: &[RetainedRuleMatch], - old_cascade_input: CascadeInputID, + old_cascade_input: MatchAnswerID, deltas: &[SelectorTruthDelta], ) -> Option { if !patch.cascade_update_properties.is_empty() { @@ -2770,7 +2770,7 @@ impl StyleEngine { self.publish_cascade_input(node, cascade_input); } - pub(super) fn intern_cascade_input(&mut self, matches: &[RuleMatch]) -> CascadeInputID { + pub(super) fn intern_cascade_input(&mut self, matches: &[RuleMatch]) -> MatchAnswerID { // The catalog converts matches to RetainedRuleMatch before canonicalizing them. That // representation already omits the element identity and absolute cascade rank, so cloning, // normalizing and sorting RuleMatch here would canonicalize fields the catalog discards. @@ -2781,7 +2781,7 @@ impl StyleEngine { } else { Counter::MatchAnswerSignatures }); - CascadeInputID(identity.0) + identity } pub fn match_element(&mut self, node: StyleNodeID) -> Result, Incomplete> { @@ -2873,11 +2873,11 @@ impl StyleEngine { &mut self, node: StyleNodeID, source: StyleNodeID, - cascade_input: CascadeInputID, + cascade_input: MatchAnswerID, orders: RetainedAnswerCascadeOrders<'_>, cascade_winners_are_complete: bool, ) -> Option { - let compact = Rc::clone(self.match_answers.answer(MatchAnswerID(cascade_input.0))?); + let compact = Rc::clone(self.match_answers.answer(cascade_input)?); let mut matches = compact .iter() .copied() @@ -2917,12 +2917,12 @@ impl StyleEngine { pub(super) fn shared_cascade_completion_is_profitable( &self, answer: MatchAnswerID, - cascade_input: CascadeInputID, + cascade_input: MatchAnswerID, ) -> bool { let Some(full) = self.match_answers.answer(answer) else { return false; }; - let Some(compact) = self.match_answers.answer(MatchAnswerID(cascade_input.0)) else { + let Some(compact) = self.match_answers.answer(cascade_input) else { return false; }; let declaration_count = |answer: &[RetainedRuleMatch]| { @@ -2938,9 +2938,9 @@ impl StyleEngine { /// closure. The active traversal owns the current cascade orders for retained answers, so lend /// them to the same completion path used before publication. A retained miss runs exact /// matching here, before the closure node is handed to style computation. - pub(super) fn retained_closure_cascade_input(&self, node: StyleNodeID) -> Option { + pub(super) fn retained_closure_cascade_input(&self, node: StyleNodeID) -> Option { let cascade_input = *self.retained_match_answers.cascade_input_lookup(node).sparse().ok()?; - let retained = self.match_answers.answer(MatchAnswerID(cascade_input.0))?; + let retained = self.match_answers.answer(cascade_input)?; if !matches!( self.winner_groups .token_for(WinnerGroupKey::current(node, self.program.version())), @@ -2967,14 +2967,14 @@ impl StyleEngine { Some(cascade_input) } - pub(super) fn retained_cascade_input_is_exact(&mut self, node: StyleNodeID, cascade_input: CascadeInputID) -> bool { + pub(super) fn retained_cascade_input_is_exact(&mut self, node: StyleNodeID, cascade_input: MatchAnswerID) -> bool { let Ok((full_answer, verification_winner_groups)) = self.exact_cascade_answer_for_verification(node) else { return false; }; let prepared_answer = prepare_retained_match_answer(full_answer.into_iter()); let answer_is_equal = self .match_answers - .answer(MatchAnswerID(cascade_input.0)) + .answer(cascade_input) .is_some_and(|retained_answer| retained_answer.as_ref() == prepared_answer); let winner_rows_are_equal = self.winner_groups.node_rows_are_semantically_equal( &verification_winner_groups, @@ -2986,8 +2986,8 @@ impl StyleEngine { /// Whether any entry of the answer observes sibling or positional relations, whose truth is /// maintained state in the prefix automaton. - pub(super) fn answer_observes_sibling_relations(&self, input: CascadeInputID) -> bool { - let Some(rows) = self.match_answers.answer(MatchAnswerID(input.0)) else { + pub(super) fn answer_observes_sibling_relations(&self, input: MatchAnswerID) -> bool { + let Some(rows) = self.match_answers.answer(input) else { return true; }; rows.iter().any(|row| { @@ -3008,8 +3008,8 @@ impl StyleEngine { pub(super) fn answer_transition_cannot_change_cascade( &mut self, node: StyleNodeID, - previous_input: CascadeInputID, - current_input: CascadeInputID, + previous_input: MatchAnswerID, + current_input: MatchAnswerID, ) -> bool { // Identity equality is NOT a proof: a stale retained answer compares equal to itself. if previous_input == current_input { @@ -3024,11 +3024,11 @@ impl StyleEngine { self.counters.bump(Counter::TransitionProofGenerationGap); return false; } - let Some(previous_rows) = self.match_answers.answer(MatchAnswerID(previous_input.0)) else { + let Some(previous_rows) = self.match_answers.answer(previous_input) else { self.counters.bump(Counter::TransitionProofMissingAnswer); return false; }; - let Some(current_rows) = self.match_answers.answer(MatchAnswerID(current_input.0)) else { + let Some(current_rows) = self.match_answers.answer(current_input) else { self.counters.bump(Counter::TransitionProofMissingAnswer); return false; }; @@ -3118,7 +3118,7 @@ impl StyleEngine { true } - pub(super) fn verify_retained_cascade_input(&mut self, node: StyleNodeID, cascade_input: CascadeInputID) { + pub(super) fn verify_retained_cascade_input(&mut self, node: StyleNodeID, cascade_input: MatchAnswerID) { assert!( self.retained_cascade_input_is_exact(node, cascade_input), "retained cascade identity stop diverged from exact matching" @@ -3216,7 +3216,7 @@ impl StyleEngine { { let mut answer = Vec::new(); if self - .append_catalog_answer(MatchAnswerID(cascade_input.0), node, None, &mut answer) + .append_catalog_answer(cascade_input, node, None, &mut answer) .is_some() { // The catalog canonicalizes rule identities independently of cascade rank. @@ -3303,7 +3303,7 @@ impl StyleEngine { .map(<[RuleMatch]>::len); let compact_len = cascade_input .filter(|_| materialized_len.is_none()) - .and_then(|identity| self.match_answers.answer(MatchAnswerID(identity.0))) + .and_then(|identity| self.match_answers.answer(identity)) .map(|matches| matches.len()); if let Some(len) = materialized_len { self.published_match_answers.mark_observed(node); @@ -3552,7 +3552,7 @@ impl StyleEngine { &mut self, node: StyleNodeID, compact_for_cascade: bool, - mut compact_answer: Option<&mut Option>, + mut compact_answer: Option<&mut Option>, mut cascade_winners_are_complete: Option<&mut bool>, ) -> Result, Incomplete> { if compact_for_cascade diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index 40b9522153eb..8ab37ab21a6a 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -259,7 +259,7 @@ fn published_match_answer(node: u32, cascade_input: Option, match_count: us }; PublishedMatchAnswer { node: StyleNodeID::element(node), - cascade_input: cascade_input.map(CascadeInputID), + cascade_input: cascade_input.map(MatchAnswerID), matches: Some(vec![rule_match; match_count].into_boxed_slice()), cascade_winners_are_complete: true, observed: false, @@ -490,7 +490,7 @@ fn an_evicted_prefix_answer_is_a_typed_missing_key() { prefix_contribution: contribution, non_prefix_matches: non_prefix, }; - answers.remember(&mut catalog, key, &[], None, CascadeInputID(1), true); + answers.remember(&mut catalog, key, &[], None, MatchAnswerID(1), true); answers.settle_memory(&catalog, &mut memory); assert!(answers.retain(&mut memory)); assert!(matches!( @@ -558,7 +558,7 @@ fn cascade_input_catalog_entries_follow_retained_column_lifetimes() { for _ in 0..128 { let identity = catalog.intern(&[]); - answers.remember_cascade_input(&mut catalog, node, CascadeInputID(identity.0), &mut memory); + answers.remember_cascade_input(&mut catalog, node, identity, &mut memory); answers.forget(&mut catalog, node); } @@ -567,7 +567,7 @@ fn cascade_input_catalog_entries_follow_retained_column_lifetimes() { assert!(catalog.answers.live_is_empty()); assert_eq!( memory.bytes_in_category(MemoryCategory::MatchAnswerIdentity), - (answers.cascade_input_column.capacity() * size_of::()) as u64 + (answers.cascade_input_column.capacity() * size_of::()) as u64 ); } @@ -628,19 +628,19 @@ fn retained_match_answer_payloads_are_evictable_without_losing_identity() { ..retained }; assert_eq!(catalog.intern(&[same_retained_match]), identity); - answers.remember_cascade_input(&mut catalog, node, CascadeInputID(cascade_input.0), &mut memory); + answers.remember_cascade_input(&mut catalog, node, cascade_input, &mut memory); answers.evict(&mut catalog); assert!(catalog.retained_answer(identity).is_none()); assert!(matches!(answers.lookup(node), Lookup::Missing(gap) if gap == node)); assert!(matches!( answers.cascade_input_lookup(node), - Lookup::Known(retained) if *retained == CascadeInputID(cascade_input.0) + Lookup::Known(retained) if *retained == cascade_input )); assert_eq!(memory.bytes_in_category(MemoryCategory::RetainedMatchAnswer), 0); assert_eq!( memory.bytes_in_category(MemoryCategory::MatchAnswerIdentity), - (answers.cascade_input_column.capacity() * size_of::() + cascade_payload_bytes) as u64 + (answers.cascade_input_column.capacity() * size_of::() + cascade_payload_bytes) as u64 ); } @@ -2300,7 +2300,7 @@ fn an_evicted_retained_match_answer_falls_back_to_cold_matching() { )); assert!(matches!( engine.retained_match_answers.cascade_input_lookup(nodes[1]), - Lookup::Known(cascade_input) if *cascade_input != CascadeInputID::default() + Lookup::Known(cascade_input) if *cascade_input != MatchAnswerID::default() )); engine.set_layer_order(TreeScopeID::DOCUMENT, &[theme, base]); @@ -2822,7 +2822,7 @@ fn selector_list_entry_deltas_fall_back_when_the_compact_winner_is_insufficient( &mut patch, MatchAnswerID::default(), &retained, - CascadeInputID::default(), + MatchAnswerID::default(), &[delta(0, SetChange::Added)], ) .is_none(), @@ -2835,7 +2835,7 @@ fn selector_list_entry_deltas_fall_back_when_the_compact_winner_is_insufficient( &mut patch, MatchAnswerID::default(), &retained, - CascadeInputID::default(), + MatchAnswerID::default(), &[delta(1, SetChange::Removed)], ) .is_none(), From 5925433d83d18afa8b5a203cae784be029143f24 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sun, 16 Aug 2026 12:26:54 +0200 Subject: [PATCH 04/39] LibWeb: Intern prefix states by content Prefix automaton states were interned by construction: the base state plus its delta. Two states with identical content reached through different bases got different identities, so the transition memo fragmented by construction path and every downstream identity compare was only a fast positive that needed a content walk to confirm. Intern states by their content digest instead, comparing content on a hash hit under a private comparison epoch. Equal contents share one identity however they were built, memo hits rise, and identity equality means content equality. Reuse the epoch marks directly when checking a hash collision, without allocating temporary comparison vectors while the state-construction scratch is borrowed. --- Libraries/LibWeb/Rust/src/css/style/prefix.rs | 223 ++++++++++++------ 1 file changed, 150 insertions(+), 73 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/prefix.rs b/Libraries/LibWeb/Rust/src/css/style/prefix.rs index 3d5c31c1a97d..34ade68760cf 100644 --- a/Libraries/LibWeb/Rust/src/css/style/prefix.rs +++ b/Libraries/LibWeb/Rust/src/css/style/prefix.rs @@ -895,23 +895,15 @@ fn step_hash(step: PrefixStepID) -> u64 { (u64::from(step.0) ^ 0x9E37_79B9_7F4A_7C15).wrapping_mul(0x2545_F491_4F6C_DD1D) } -/// Structural identity of a delta state: the base it extends plus its own payload. Two -/// semantically equal states built over different bases intern separately, which is bounded and -/// acceptable; content comparisons therefore never rely on state identity alone. -fn state_structural_hash( - base: u32, - additions_len: u32, - additions_hash: u64, +/// Content identity of a prefix state. The two digests are fast lookup keys only; interning still +/// compares the complete persisting and expiring sets on a hit. +fn state_content_key( + descendant_len: u32, + descendant_hash: u64, expiring_len: u32, expiring_hash: u32, -) -> u64 { - let mut hasher = fast_hasher(); - base.hash(&mut hasher); - additions_len.hash(&mut hasher); - additions_hash.hash(&mut hasher); - expiring_len.hash(&mut hasher); - expiring_hash.hash(&mut hasher); - hasher.finish() +) -> (u32, u64, u32, u32) { + (descendant_len, descendant_hash, expiring_len, expiring_hash) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -966,6 +958,11 @@ const UNKNOWN_TRANSITION: PrefixTransition = PrefixTransition { define_id! { struct PrefixStateID(); } +struct PrefixStateCandidates { + first: PrefixStateID, + collisions: Vec, +} + impl super::intern_table::InternIdentity for PrefixStateID { fn index(self) -> usize { self.0 as usize - 1 @@ -1031,7 +1028,8 @@ pub(super) struct PrefixStates { states: Vec, /// Every state's delta payload: its persisting additions followed by its expiring steps. delta_steps: Vec>, - states_by_hash: super::intern_table::InternTable, + states_by_hash: HashMap<(u32, u64, u32, u32), PrefixStateCandidates>, + states_by_hash_collision_bytes: u64, match_offsets: Vec, match_entries: Vec, truth_offsets: Vec, @@ -1049,6 +1047,8 @@ pub(super) struct PrefixStates { /// Per-element positional truth retained only for automata that test it. positional_bits_by_element: Column, candidate_epoch: EpochColumn, + comparison_epoch: u32, + comparison_marks: EpochColumn, compound_epoch: EpochColumn, compound_answer: Vec, output_epoch: EpochColumn, @@ -1499,7 +1499,8 @@ impl PrefixStates { Self { states: vec![PrefixState::default()], delta_steps: Vec::new(), - states_by_hash: super::intern_table::InternTable::default(), + states_by_hash: HashMap::default(), + states_by_hash_collision_bytes: 0, match_offsets: vec![0, 0], match_entries: Vec::new(), truth_offsets: vec![0, 0], @@ -1519,6 +1520,8 @@ impl PrefixStates { local_facts_by_element: Column::default(), positional_bits_by_element: Column::default(), candidate_epoch: EpochColumn::default(), + comparison_epoch: 0, + comparison_marks: EpochColumn::default(), compound_epoch: EpochColumn::default(), compound_answer: Vec::new(), output_epoch: EpochColumn::default(), @@ -1641,9 +1644,9 @@ impl PrefixStates { /// Whether two states hold the same active steps, viewed through an optional selection. /// - /// Structural interning means distinct identities can still be content-equal, so identity is - /// only a fast positive. The persisting hash is a fast negative when no selection filters - /// the view; the full check compares both persisting sets through dense epoch marks, with the + /// A selection can make states with different complete contents equal, so identity is only a + /// fast positive here. The persisting hash is a fast negative when no selection filters the + /// view; the full check compares both persisting sets through dense epoch marks, with the /// expiring parts compared directly. fn selected_states_equal(&mut self, left: u32, right: u32, selection: Option<&PrefixSelection>) -> bool { if left == right { @@ -1657,21 +1660,10 @@ impl PrefixStates { { return false; } - advance_epoch( - &mut self.epoch, - 2, - &mut [ - &mut self.candidate_epoch, - &mut self.compound_epoch, - &mut self.output_epoch, - &mut self.match_epoch, - &mut self.parent_persisting_epoch, - &mut self.previous_persisting_epoch, - ], - ); - let left_epoch = self.epoch - 1; - let right_epoch = self.epoch; - let mut compare_epoch = std::mem::take(&mut self.candidate_epoch); + advance_epoch(&mut self.comparison_epoch, 2, &mut [&mut self.comparison_marks]); + let left_epoch = self.comparison_epoch - 1; + let right_epoch = self.comparison_epoch; + let mut compare_epoch = std::mem::take(&mut self.comparison_marks); compare_epoch.ensure_len(self.automaton_step_count); let mut left_count = 0; let mut current = left; @@ -1728,14 +1720,57 @@ impl PrefixStates { None => left_expiring.eq(right_expiring), }; } - self.candidate_epoch = compare_epoch; - self.compare_left.clear(); - self.compare_left.reserve(left_count); - self.compare_right.clear(); - self.compare_right.reserve(right_count); + self.comparison_marks = compare_epoch; equal } + /// Find the canonical state with `probe`'s complete contents. Copy each candidate identity out + /// before comparison because the equality check borrows the state scratch mutably. + fn find_equal_state(&mut self, key: (u32, u64, u32, u32), probe: u32) -> Option { + let first = self.states_by_hash.get(&key)?.first.0; + if self.states_have_equal_contents(first, probe) { + return Some(first); + } + let collision_count = self.states_by_hash[&key].collisions.len(); + for index in 0..collision_count { + let candidate = self.states_by_hash[&key].collisions[index].0; + if self.states_have_equal_contents(candidate, probe) { + return Some(candidate); + } + } + None + } + + fn states_have_equal_contents(&mut self, left: u32, right: u32) -> bool { + let left_contents = &self.states[left as usize]; + let right_contents = &self.states[right as usize]; + if left_contents.base == right_contents.base + && self.additions_in(left) == self.additions_in(right) + && self.expiring_in(left) == self.expiring_in(right) + { + return true; + } + self.selected_states_equal(left, right, None) + } + + fn remember_state(&mut self, key: (u32, u64, u32, u32), state: u32) { + match self.states_by_hash.entry(key) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(PrefixStateCandidates { + first: PrefixStateID(state), + collisions: Vec::new(), + }); + } + std::collections::hash_map::Entry::Occupied(mut entry) => { + let collisions = &mut entry.get_mut().collisions; + let old_capacity = collisions.capacity(); + collisions.push(PrefixStateID(state)); + self.states_by_hash_collision_bytes += + ((collisions.capacity() - old_capacity) * size_of::()) as u64; + } + } + } + fn transition_of(&self, node: StyleNodeID) -> PrefixTransitionLookup { let Some(index) = node.element_index().map(|index| index as usize) else { return PrefixTransitionLookup::Missing(PrefixTransitionGap::MissingTransition(node)); @@ -2642,15 +2677,13 @@ impl PrefixStates { if self.states.len() > 1 && self.states_by_hash.is_empty() { for state in 1..self.states.len() { let contents = &self.states[state]; - let base_hash = self.states[contents.base as usize].descendant_hash; - let hash = state_structural_hash( - contents.base, - contents.additions_len, - contents.descendant_hash.wrapping_sub(base_hash), + let key = state_content_key( + contents.descendant_len, + contents.descendant_hash, contents.expiring_len, contents.expiring_hash, ); - self.states_by_hash.insert_identity(hash, PrefixStateID(state as u32)); + self.remember_state(key, state as u32); } } if self.match_offsets.len() > 2 && self.match_sets_by_hash.is_empty() { @@ -3072,18 +3105,10 @@ impl PrefixStates { } let additions_len = u32::try_from(additions.len()).expect("selector prefix state payload overflow"); let expiring_len = u32::try_from(expiring.len()).expect("selector prefix state payload overflow"); - let hash = state_structural_hash(base_state, additions_len, additions_hash, expiring_len, expiring_hash); - if let Some(candidate) = self.states_by_hash.find(hash, |candidate, ()| { - self.states[candidate.0 as usize].base == base_state - && self.additions_in(candidate.0) == additions - && self.expiring_in(candidate.0) == expiring - }) { - return candidate.0; - } - let base = &self.states[base_state as usize]; let descendant_len = base.descendant_len + additions_len; let descendant_hash = base.descendant_hash.wrapping_add(additions_hash); + let key = state_content_key(descendant_len, descendant_hash, expiring_len, expiring_hash); let state = u32::try_from(self.states.len()).expect("selector prefix state space exhausted"); let payload_start = u32::try_from(self.delta_steps.len()).expect("selector prefix state payload overflow"); self.append_delta_steps(additions); @@ -3101,7 +3126,13 @@ impl PrefixStates { true => state, false => UNKNOWN_STATE, }); - self.states_by_hash.insert_identity(hash, PrefixStateID(state)); + if let Some(candidate) = self.find_equal_state(key, state) { + self.states.pop(); + self.descendant_only.pop(); + self.delta_steps.truncate(payload_start as usize); + return candidate; + } + self.remember_state(key, state); state } @@ -3157,21 +3188,9 @@ impl PrefixStates { .checked_add(additions_len) .expect("selector prefix state payload overflow"); let descendant_hash = new_base.descendant_hash.wrapping_add(additions_hash); - let hash = state_structural_hash( - new_base_state, - additions_len, - additions_hash, - expiring_len, - expiring_hash, - ); - if let Some(candidate) = self.states_by_hash.find(hash, |candidate, ()| { - self.states[candidate.0 as usize].base == new_base_state - && self.additions_in(candidate.0) == self.additions_in(source_state) - && self.expiring_in(candidate.0) == self.expiring_in(source_state) - }) { - return Some(candidate.0); - } + let key = state_content_key(descendant_len, descendant_hash, expiring_len, expiring_hash); let state = u32::try_from(self.states.len()).expect("selector prefix state space exhausted"); + let delta_steps_len = self.delta_steps.len(); self.skip_delta_steps(additions_len as usize, expiring_len as usize); self.states.push(PrefixState { base: new_base_state, @@ -3186,7 +3205,13 @@ impl PrefixStates { true => state, false => UNKNOWN_STATE, }); - self.states_by_hash.insert_identity(hash, PrefixStateID(state)); + if let Some(candidate) = self.find_equal_state(key, state) { + self.states.pop(); + self.descendant_only.pop(); + self.delta_steps.truncate(delta_steps_len); + return Some(candidate); + } + self.remember_state(key, state); Some(state) } @@ -3344,7 +3369,8 @@ impl PrefixStates { self.states = states; self.delta_steps = delta_steps; self.descendant_only = descendant_only; - self.states_by_hash = super::intern_table::InternTable::default(); + self.states_by_hash = HashMap::default(); + self.states_by_hash_collision_bytes = 0; self.transitions = old_transitions .into_iter() .filter_map(|(key, transition)| { @@ -3411,6 +3437,7 @@ impl PrefixStates { // their complete allocations back because none of their contents cross this boundary. self.transition_by_row = Vec::new(); self.candidate_epoch = EpochColumn::default(); + self.comparison_marks = EpochColumn::default(); self.compound_epoch = EpochColumn::default(); self.compound_answer = Vec::new(); self.output_epoch = EpochColumn::default(); @@ -3450,6 +3477,7 @@ impl PrefixStates { self.local_facts_by_element, self.positional_bits_by_element, self.candidate_epoch, + self.comparison_marks, self.compound_epoch, self.output_epoch, self.match_epoch, @@ -3469,9 +3497,11 @@ impl PrefixStates { self.ancestor_chain, ]; cached []; - nested []; + nested [self.states_by_hash_collision_bytes]; skip [ self.local_fact_interner, + self.comparison_epoch, + self.states_by_hash_collision_bytes, self.new_descendant_hash, self.new_child_hash, self.new_following_hash, @@ -4197,6 +4227,53 @@ mod tests { assert_eq!(states.expiring_in(rebased), [expiring_step]); } + #[test] + fn equal_prefix_state_contents_share_an_identity_across_base_chains() { + let first = PrefixStepID(1); + let second = PrefixStepID(2); + let mut states = PrefixStates::new(0); + states.automaton_step_count = 3; + let mut counters = Counters::new(); + let first_base = states.intern_extended_state(0, &[first], step_hash(first), &[], 0, &mut counters); + let second_base = states.intern_extended_state(0, &[second], step_hash(second), &[], 0, &mut counters); + + let first_then_second = + states.intern_extended_state(first_base, &[second], step_hash(second), &[], 0, &mut counters); + let second_then_first = + states.intern_extended_state(second_base, &[first], step_hash(first), &[], 0, &mut counters); + + assert_eq!(first_then_second, second_then_first); + } + + #[test] + fn prefix_state_interning_checks_equal_length_and_hash_collisions() { + let first = PrefixStepID(1); + let second = PrefixStepID(2); + let third = PrefixStepID(3); + let fourth = PrefixStepID(4); + let mut states = PrefixStates::new(0); + states.automaton_step_count = 5; + let mut counters = Counters::new(); + let first_base = states.intern_extended_state(0, &[first], step_hash(first), &[], 0, &mut counters); + let second_base = states.intern_extended_state(0, &[second], step_hash(second), &[], 0, &mut counters); + let collision_hash = step_hash(first).wrapping_add(step_hash(second)); + let collision = states.intern_extended_state(0, &[third, fourth], collision_hash, &[], 0, &mut counters); + let first_then_second = + states.intern_extended_state(first_base, &[second], step_hash(second), &[], 0, &mut counters); + + assert_ne!(collision, first_then_second); + let key = state_content_key(2, collision_hash, 0, 0); + assert_eq!(states.states_by_hash[&key].first, PrefixStateID(collision)); + assert_eq!( + states.states_by_hash[&key].collisions, + [PrefixStateID(first_then_second)] + ); + + let second_then_first = + states.intern_extended_state(second_base, &[first], step_hash(first), &[], 0, &mut counters); + assert_eq!(second_then_first, first_then_second); + } + #[test] fn prefix_state_equality_compares_structural_sets_without_sorting() { let first = PrefixStepID(1); From 69a0333167cad061da548863dfb964b7323c388e Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sun, 16 Aug 2026 12:51:27 +0200 Subject: [PATCH 05/39] LibWeb: Name selector entries and feature keys once One selector entry was named as (program, entry) by the compiler, as a dispatch row per scope, as a transpose route per input, and as (rule, program, entry) triples in truth deltas; one class fact traveled through six key vocabularies from input to posting. Every hop was a match arm or a hash probe, and every flush rebuilt translation tables between them. Give each attached selector entry one dense EntryID and each feature one (kind, atom) key, and use them in dispatch, routing, prefix matching, truth deltas and postings alike. Rows sharing an entry are expanded through the dispatch and filtered by prefix admission, while selector truth stays per entry so shared entries keep their deltas. Allocate recycled entry ids in ascending entry-index order so retained and delta answers share one canonical sort order. --- .../Rust/src/css/style/batch_matcher.rs | 192 ++++-- Libraries/LibWeb/Rust/src/css/style/bridge.rs | 20 +- .../LibWeb/Rust/src/css/style/catalog.rs | 14 +- .../LibWeb/Rust/src/css/style/compiler.rs | 10 +- .../Rust/src/css/style/differential_tests.rs | 8 +- Libraries/LibWeb/Rust/src/css/style/flush.rs | 70 +- Libraries/LibWeb/Rust/src/css/style/impact.rs | 26 +- Libraries/LibWeb/Rust/src/css/style/index.rs | 437 +++++-------- .../Rust/src/css/style/input_routing.rs | 34 +- Libraries/LibWeb/Rust/src/css/style/inputs.rs | 81 ++- .../LibWeb/Rust/src/css/style/matching.rs | 146 +++-- Libraries/LibWeb/Rust/src/css/style/mod.rs | 20 +- .../LibWeb/Rust/src/css/style/ordering.rs | 20 +- .../LibWeb/Rust/src/css/style/planning.rs | 186 +++--- Libraries/LibWeb/Rust/src/css/style/prefix.rs | 98 +-- .../LibWeb/Rust/src/css/style/program.rs | 4 + .../Rust/src/css/style/program_updates.rs | 33 +- .../Rust/src/css/style/relative_selector.rs | 6 +- .../LibWeb/Rust/src/css/style/routing.rs | 266 ++++---- .../LibWeb/Rust/src/css/style/selector.rs | 203 ++++-- .../Rust/src/css/style/selector/replay.rs | 60 +- Libraries/LibWeb/Rust/src/css/style/tests.rs | 611 ++++++++++-------- .../LibWeb/Rust/src/css/style/transaction.rs | 14 +- .../shared-entry-selector-truth.txt | 3 + .../shared-entry-selector-truth.html | 27 + 25 files changed, 1401 insertions(+), 1188 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/css/style-engine/shared-entry-selector-truth.txt create mode 100644 Tests/LibWeb/Text/input/css/style-engine/shared-entry-selector-truth.html diff --git a/Libraries/LibWeb/Rust/src/css/style/batch_matcher.rs b/Libraries/LibWeb/Rust/src/css/style/batch_matcher.rs index c462bace3006..b8a4d32c2f3b 100644 --- a/Libraries/LibWeb/Rust/src/css/style/batch_matcher.rs +++ b/Libraries/LibWeb/Rust/src/css/style/batch_matcher.rs @@ -36,6 +36,7 @@ use super::prefix::PrefixStates; use super::prefix::PrefixTransitionGap; use super::prefix::PrefixTransitionLookup; use super::program::CascadeOrigin; +use super::program::EntryID; use super::program::RuleID; use super::program::SelectorProgramID; use super::program::StyleSheetProgram; @@ -272,6 +273,10 @@ pub(super) fn insert_scope_rule( _ => StyleAtomID::NONE, }; let template = super::index::DispatchEntry { + identity: programs.entry_id( + selector_program, + u32::try_from(index).expect("selector entry space exhausted"), + ), rule, program: selector_program, entry: u32::try_from(index).expect("selector entry space exhausted"), @@ -307,14 +312,7 @@ pub(super) fn insert_scope_rule( // automaton, taxing each transition and widening every tree // flush's re-compare frontier, without any route ever being // subsumed in return. - dispatch.add_prefix_entry( - programs, - selector_program, - u32::try_from(index).expect("selector entry space exhausted"), - &chain, - dispatch_entry, - author, - ); + dispatch.add_prefix_entry(programs, selector_program, &chain, dispatch_entry, author); } } else { // NB: A branch copy carries no required attribute value: a disjunction branch's @@ -775,31 +773,35 @@ pub(super) fn append_prefix_matches( dispatch: &RuleDispatch, program: &StyleSheetProgram, programs: &SelectorPrograms, - matches: &[super::index::DispatchEntryID], + matches: &[EntryID], counters: &mut Counters, count_emission: CountRuleMatchEmission, ) { let mut completed = None; for &matched in matches { - let candidate = dispatch.entry(matched); - if !program.rule_can_decide(candidate.rule) { - continue; + for candidate in dispatch.entries_for_identity(matched) { + if !candidate.prefix_matched { + continue; + } + if !program.rule_can_decide(candidate.rule) { + continue; + } + let compiled = programs.get(candidate.program); + let entry = &compiled.entries()[candidate.entry as usize]; + append_matched_entry( + out, + 0, + node, + scope, + candidate, + compiled, + entry, + u32::MAX, + &mut completed, + counters, + count_emission, + ); } - let compiled = programs.get(candidate.program); - let entry = &compiled.entries()[candidate.entry as usize]; - append_matched_entry( - out, - 0, - node, - scope, - candidate, - compiled, - entry, - u32::MAX, - &mut completed, - counters, - count_emission, - ); } } @@ -1216,40 +1218,46 @@ impl<'a> BatchMatcher<'a> { if let Some((states, prefix_matches)) = prefix_matches { used_prefixes = true; cascade_pruning_blocked = self.cascade_only - && states - .matches_in(prefix_matches) - .iter() - .any(|&matched| self.dispatch.cascade_pruning_blocker(self.dispatch.entry(matched))); + && states.matches_in(prefix_matches).iter().any(|&matched| { + self.dispatch + .entries_for_identity(matched) + .filter(|candidate| candidate.prefix_matched) + .any(|candidate| self.dispatch.cascade_pruning_blocker(candidate)) + }); if let Some(deferred) = deferred_prefix_matches { *deferred = Some(prefix_matches); } else { for &matched in states.matches_in(prefix_matches) { - let candidate = self.dispatch.entry(matched); - if !self.rule_filter_admits(candidate.rule, candidate.program) { - continue; - } - if !self.program.rule_can_decide(candidate.rule) { - continue; - } - let candidate_index = candidate.cascade_order as usize; - if completed.as_deref().is_some_and(|completed| completed[candidate_index]) { - continue; + for candidate in self.dispatch.entries_for_identity(matched) { + if !candidate.prefix_matched { + continue; + } + if !self.rule_filter_admits(candidate.rule, candidate.program) { + continue; + } + if !self.program.rule_can_decide(candidate.rule) { + continue; + } + let candidate_index = candidate.cascade_order as usize; + if completed.as_deref().is_some_and(|completed| completed[candidate_index]) { + continue; + } + let compiled = self.programs.get(candidate.program); + let entry = &compiled.entries()[candidate.entry as usize]; + append_matched_entry( + out, + start, + node, + self.scope, + candidate, + compiled, + entry, + u32::MAX, + &mut completed, + counters, + self.count_rule_match_emission(), + ); } - let compiled = self.programs.get(candidate.program); - let entry = &compiled.entries()[candidate.entry as usize]; - append_matched_entry( - out, - start, - node, - self.scope, - candidate, - compiled, - entry, - u32::MAX, - &mut completed, - counters, - self.count_rule_match_emission(), - ); } } } @@ -1507,6 +1515,8 @@ mod tests { use super::super::index::StateSet; use super::super::index::StyleAtomID; use super::super::memory::DeviceClass; + use super::super::planning::SelectorTruthChanges; + use super::super::planning::record_match_set_difference; use super::super::program::CascadeOrigin; use super::super::program::RuleKind; use super::super::program::StyleSheetObjectID; @@ -1658,6 +1668,76 @@ mod tests { assert_eq!(document.matched_nodes(&matches, descendant), vec![2]); } + #[test] + fn shared_selector_entries_join_every_rule_after_prefix_matching() { + let mut document = Document::new(); + let mut builder = SelectorProgramBuilder::new(); + class_rule(CLASS_ITEM)(&mut builder); + let selector_program = document.programs.add(builder.finish()); + let mut rules = Vec::new(); + for _ in 0..2 { + let rule = document.program.append_rule(document.sheet(), None, RuleKind::Style); + let mut version = document.program.rule_version(rule); + version.selector_program = Some(selector_program); + document.program.replace_rule_version(rule, version); + rules.push(rule); + } + + let matches = document.run(); + assert_eq!(document.matched_nodes(&matches, rules[0]), vec![1, 3]); + assert_eq!(document.matched_nodes(&matches, rules[1]), vec![1, 3]); + assert_eq!(document.counters.get(Counter::CandidateChecks), 0); + } + + #[test] + fn shared_prefix_identity_emits_only_rows_admitted_to_the_automaton() { + let mut document = Document::new(); + let user_agent_sheet = document + .program + .add_sheet(StyleSheetObjectID(2), CascadeOrigin::UserAgent); + document.program.attach_sheet(user_agent_sheet, TreeScopeID::DOCUMENT); + + let mut builder = SelectorProgramBuilder::new(); + let any = builder.push_feature(FeatureTest::AnyElement); + let first_of_type = builder.push(SelectorOp::NthPosition(NthPosition { + step: 0, + offset: 1, + from_end: false, + of_selector: None, + of_type: true, + })); + let root = builder.push_compound(&[any, first_of_type]); + builder.push_entry(root); + let selector_program = document.programs.add(builder.finish()); + + let author_sheet = document.sheet(); + let mut add_rule = |sheet| { + let rule = document.program.append_rule(sheet, None, RuleKind::Style); + let mut version = document.program.rule_version(rule); + version.selector_program = Some(selector_program); + document.program.replace_rule_version(rule, version); + rule + }; + let user_agent_rule = add_rule(user_agent_sheet); + let author_rule = add_rule(author_sheet); + + let matches = document.run(); + assert_eq!(document.matched_nodes(&matches, user_agent_rule), vec![0, 1, 2]); + assert_eq!(document.matched_nodes(&matches, author_rule), vec![0, 1, 2]); + + let dispatch = build_scope_dispatch(&document.program, &document.programs, TreeScopeID::DOCUMENT); + let entry = document.programs.entry_id(selector_program, 0); + let rows: Vec<_> = dispatch.entries_for_identity(entry).collect(); + assert_eq!(rows.len(), 2); + assert_eq!(rows.iter().filter(|row| row.prefix_matched).count(), 1); + + let mut changes = SelectorTruthChanges::default(); + record_match_set_difference(&mut changes, true, document.nodes[0], &[], &[entry], &dispatch); + let mut changed_rules: Vec<_> = changes.deltas.as_slice().iter().map(|delta| delta.rule).collect(); + changed_rules.sort_unstable(); + assert_eq!(changed_rules, vec![user_agent_rule, author_rule]); + } + #[test] fn prefix_states_keep_descendants_but_expire_children() { let mut document = Document::new(); diff --git a/Libraries/LibWeb/Rust/src/css/style/bridge.rs b/Libraries/LibWeb/Rust/src/css/style/bridge.rs index bd01e097ebf7..a44d3f097612 100644 --- a/Libraries/LibWeb/Rust/src/css/style/bridge.rs +++ b/Libraries/LibWeb/Rust/src/css/style/bridge.rs @@ -36,8 +36,8 @@ use super::cascade::CascadeOperator; use super::compiler::ImplicitScopeRoot; use super::compiler::NamespaceScope; use super::compiler::ScopeChain; -use super::index::FeatureKey; use super::index::FeatureValue; +use super::index::LocalFeatureKey; use super::index::StyleAtomID; use super::memory::DeviceClass; #[cfg(feature = "style-recording")] @@ -527,14 +527,14 @@ impl FfiCascadeOrigin { } } -fn decode_feature_key(delta: &FfiLocalFeatureDelta) -> FeatureKey { +fn decode_feature_key(delta: &FfiLocalFeatureDelta) -> LocalFeatureKey { match delta.feature_kind { - FfiFeatureKind::TagName => FeatureKey::TagName, - FfiFeatureKind::FoldedTagName => FeatureKey::FoldedTagName, - FfiFeatureKind::Emptiness => FeatureKey::Emptiness, - FfiFeatureKind::Id => FeatureKey::Id, - FfiFeatureKind::Class => FeatureKey::Class(StyleAtomID(delta.name_atom)), - FfiFeatureKind::Attribute => FeatureKey::Attribute(StyleAtomID(delta.name_atom)), + FfiFeatureKind::TagName => LocalFeatureKey::TagName, + FfiFeatureKind::FoldedTagName => LocalFeatureKey::FoldedTagName, + FfiFeatureKind::Emptiness => LocalFeatureKey::Emptiness, + FfiFeatureKind::Id => LocalFeatureKey::Id, + FfiFeatureKind::Class => LocalFeatureKey::Class(StyleAtomID(delta.name_atom)), + FfiFeatureKind::Attribute => LocalFeatureKey::Attribute(StyleAtomID(delta.name_atom)), } } @@ -2663,7 +2663,7 @@ mod tests { assert_eq!(transaction.inputs.len(), 2); assert!(transaction.inputs.iter().all(|input| matches!( input.key, - InputKey::TreeRelations(node) | InputKey::LocalFeature(node, FeatureKey::ArrivingFacts) if node == root + InputKey::TreeRelations(node) | InputKey::LocalFeature(node, LocalFeatureKey::ArrivingFacts) if node == root ))); engine.release_transaction(transaction); @@ -2697,7 +2697,7 @@ mod tests { .iter() .filter(|input| matches!( input.key, - InputKey::TreeRelations(node) | InputKey::LocalFeature(node, FeatureKey::ArrivingFacts) if node == later + InputKey::TreeRelations(node) | InputKey::LocalFeature(node, LocalFeatureKey::ArrivingFacts) if node == later )) .count(), 2 diff --git a/Libraries/LibWeb/Rust/src/css/style/catalog.rs b/Libraries/LibWeb/Rust/src/css/style/catalog.rs index e64ca599985d..54f13dc1392f 100644 --- a/Libraries/LibWeb/Rust/src/css/style/catalog.rs +++ b/Libraries/LibWeb/Rust/src/css/style/catalog.rs @@ -903,10 +903,16 @@ pub(super) struct RetainedAnswerDeltaMemoKey { } pub(super) struct RetainedAnswerDeltaMemoEntry { - pub(super) deltas: Vec<(RuleID, SelectorProgramID, u32, SetChange)>, + pub(super) deltas: Vec<(RuleID, EntryID, SetChange)>, pub(super) transition: RetainedAnswerDeltaTransition, } +impl RetainedAnswerDeltaMemoEntry { + pub(super) fn capacity_bytes(&self) -> u64 { + (self.deltas.capacity() * size_of::<(RuleID, EntryID, SetChange)>()) as u64 + } +} + #[derive(Clone, Copy)] pub(super) struct RetainedAnswerDeltaTransition { pub(super) new_answer: MatchAnswerID, @@ -953,11 +959,7 @@ impl RetainedAnswerPatch { self.cascade_compaction_workspace.capacity_bytes(), self.delta_memo .values() - .map(|entry| { - (entry.deltas.capacity() - * size_of::<(RuleID, SelectorProgramID, u32, SetChange)>()) - as u64 - }) + .map(RetainedAnswerDeltaMemoEntry::capacity_bytes) .sum::(), ]; skip []; diff --git a/Libraries/LibWeb/Rust/src/css/style/compiler.rs b/Libraries/LibWeb/Rust/src/css/style/compiler.rs index 24c8c279f318..4b545084bbd5 100644 --- a/Libraries/LibWeb/Rust/src/css/style/compiler.rs +++ b/Libraries/LibWeb/Rust/src/css/style/compiler.rs @@ -1371,13 +1371,13 @@ impl<'a> SelectorCompiler<'a> { } /// The positive indexable feature a witness search can drive from, if the compound has one. - fn driving_feature_of(&self, compound: SelectorNodeID) -> Option { + fn driving_feature_of(&self, compound: SelectorNodeID) -> Option { let program = self.builder.program(); let feature_of = |test: FeatureTest| match test { - FeatureTest::Class(class) => Some(super::index::FeatureKey::Class(class)), - FeatureTest::Id(_) => Some(super::index::FeatureKey::Id), - FeatureTest::Attribute(attribute) => Some(super::index::FeatureKey::Attribute(attribute.folded)), - FeatureTest::TagName(_) => Some(super::index::FeatureKey::TagName), + FeatureTest::Class(class) => Some(super::index::LocalFeatureKey::Class(class)), + FeatureTest::Id(_) => Some(super::index::LocalFeatureKey::Id), + FeatureTest::Attribute(attribute) => Some(super::index::LocalFeatureKey::Attribute(attribute.folded)), + FeatureTest::TagName(_) => Some(super::index::LocalFeatureKey::TagName), // Neither enumerates a candidate set: every element is in some namespace, so driving a // witness search from one would be driving it from the whole document. FeatureTest::AnyElement | FeatureTest::Namespace(_) => None, diff --git a/Libraries/LibWeb/Rust/src/css/style/differential_tests.rs b/Libraries/LibWeb/Rust/src/css/style/differential_tests.rs index 2cd6abe2d828..b9323a5a8f0a 100644 --- a/Libraries/LibWeb/Rust/src/css/style/differential_tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/differential_tests.rs @@ -19,8 +19,8 @@ use super::computed::ComputedMetadataInput; use super::computed::ComputedReconstructionMetadataInput; use super::fast_hash::fast_hasher; use super::index::DispatchCandidateWorkspace; -use super::index::FeatureKey; use super::index::FeatureValue; +use super::index::LocalFeatureKey; use super::index::StyleAtomID; use super::instrumentation::Counters; use super::memory::DeviceClass; @@ -135,7 +135,7 @@ impl Workload { let mut classes = vec![Vec::new(); nodes.len()]; for (index, &node) in nodes.iter().enumerate() { engine.record_input( - InputKey::LocalFeature(node, FeatureKey::TagName), + InputKey::LocalFeature(node, LocalFeatureKey::TagName), InputValue::Feature(FeatureValue::Absent), InputValue::Feature(FeatureValue::Atom(tag_atom(node))), ); @@ -146,7 +146,7 @@ impl Workload { } classes[index].push(class); engine.record_input( - InputKey::LocalFeature(node, FeatureKey::Class(class_atom(class))), + InputKey::LocalFeature(node, LocalFeatureKey::Class(class_atom(class))), InputValue::Feature(FeatureValue::Absent), InputValue::Feature(FeatureValue::Present), ); @@ -264,7 +264,7 @@ impl Workload { (FeatureValue::Absent, FeatureValue::Present) }; self.engine.record_input( - InputKey::LocalFeature(node, FeatureKey::Class(class_atom(class))), + InputKey::LocalFeature(node, LocalFeatureKey::Class(class_atom(class))), InputValue::Feature(old), InputValue::Feature(new), ); diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index cfed44646abd..52e6f5c8a5db 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -34,7 +34,7 @@ impl StyleEngine { && transaction.inputs.iter().all(|input| match input.key { InputKey::LocalFeature( _, - FeatureKey::Language | FeatureKey::Directionality | FeatureKey::HeadingLevel, + LocalFeatureKey::Language | LocalFeatureKey::Directionality | LocalFeatureKey::HeadingLevel, ) => false, InputKey::LocalFeature(..) | InputKey::State(..) @@ -111,20 +111,20 @@ impl StyleEngine { input.key, InputKey::LocalFeature( _, - FeatureKey::Id - | FeatureKey::Class(_) - | FeatureKey::CustomState(_) - | FeatureKey::Attribute(_) + LocalFeatureKey::Id + | LocalFeatureKey::Class(_) + | LocalFeatureKey::CustomState(_) + | LocalFeatureKey::Attribute(_) ) ) }) && transaction.inputs.iter().all(|input| { - let InputKey::LocalFeature(node, FeatureKey::Attribute(_)) = input.key else { + let InputKey::LocalFeature(node, LocalFeatureKey::Attribute(_)) = input.key else { return true; }; transaction.inputs.iter().any(|candidate| { matches!( candidate.key, - InputKey::LocalFeature(candidate_node, FeatureKey::Id | FeatureKey::Class(_)) + InputKey::LocalFeature(candidate_node, LocalFeatureKey::Id | LocalFeatureKey::Class(_)) if candidate_node == node ) }) @@ -492,7 +492,7 @@ impl StyleEngine { // the witness of a relational selector whose anchor is outside it. let nested_fact_arrival_without_relational_selectors = matches!( input.key, - InputKey::LocalFeature(node, FeatureKey::ArrivingFacts) + InputKey::LocalFeature(node, LocalFeatureKey::ArrivingFacts) if nested_arrivals.binary_search(&node).is_ok() && self.routing.relational_routes().is_empty() ); @@ -789,7 +789,7 @@ impl StyleEngine { let mut previous_cascade_inputs = Vec::new(); let mut exact_cascade_stop_nodes = DeltaBatch::default(); let mut exact_cascade_confirmation_nodes = DeltaBatch::default(); - let mut attribution_scratch: Vec<(RuleID, SelectorProgramID)> = Vec::new(); + let mut attribution_scratch: Vec<(RuleID, EntryID)> = Vec::new(); let mut attribution_sweep = AttributionSweep::default(); regions.for_each_batch(&compiled_regions, |node| { #[cfg(test)] @@ -864,7 +864,8 @@ impl StyleEngine { has_upquery = true; SelectorTruthPatch::Full }; - let rule_is_safe = |rule: RuleID, program: SelectorProgramID| { + let rule_is_safe = |rule: RuleID, entry: EntryID| { + let (program, _) = self.programs.entry_location(entry); self.program.declarations_are_complete_for(rule) && self.program.rule_version(rule).selector_program == Some(program) && !self.programs.get(program).contains_relational_selector() @@ -872,24 +873,24 @@ impl StyleEngine { let node_has_safe_exact_cascade_provenance = match truth_patch { SelectorTruthPatch::Full => false, SelectorTruthPatch::Direct(deltas) => { - deltas.iter().all(|delta| rule_is_safe(delta.rule, delta.program)) + deltas.iter().all(|delta| rule_is_safe(delta.rule, delta.entry)) } SelectorTruthPatch::Refresh { deltas, refreshes } => { - deltas.iter().all(|delta| rule_is_safe(delta.rule, delta.program)) - && refreshes.iter().all(|refresh| { - refresh.rule.is_some_and(|(rule, program)| rule_is_safe(rule, program)) - }) + deltas.iter().all(|delta| rule_is_safe(delta.rule, delta.entry)) + && refreshes + .iter() + .all(|refresh| refresh.rule.is_some_and(|(rule, entry)| rule_is_safe(rule, entry))) } SelectorTruthPatch::Attributed { deltas, refreshes, rules, } => { - deltas.iter().all(|delta| rule_is_safe(delta.rule, delta.program)) - && refreshes.iter().all(|refresh| { - refresh.rule.is_some_and(|(rule, program)| rule_is_safe(rule, program)) - }) - && rules.iter().all(|&(rule, program)| rule_is_safe(rule, program)) + deltas.iter().all(|delta| rule_is_safe(delta.rule, delta.entry)) + && refreshes + .iter() + .all(|refresh| refresh.rule.is_some_and(|(rule, entry)| rule_is_safe(rule, entry))) + && rules.iter().all(|&(rule, entry)| rule_is_safe(rule, entry)) } }; can_confirm_exact_cascade = transaction_supports_retained_cascade_stops @@ -1270,13 +1271,10 @@ impl StyleEngine { for node in published_nodes.iter().copied() { let pseudo_inputs_may_have_changed = pseudo_inputs_may_have_changed || !selector_truth_changes.refreshes_for(node).is_empty() - || selector_truth_changes.deltas_for(node).iter().any(|delta| { - self.programs - .get(delta.program) - .entries() - .get(delta.entry as usize) - .is_some_and(|entry| entry.pseudo_element.is_some()) - }); + || selector_truth_changes + .deltas_for(node) + .iter() + .any(|delta| self.programs.entry(delta.entry).1.pseudo_element.is_some()); let answer = published_match_answers .lookup(node) .expect("each accepted style reaction has a published match answer"); @@ -1410,13 +1408,13 @@ impl StyleEngine { return None; }; let key = match feature { - FeatureKey::Class(atom) => DispatchKey::Class(atom), - FeatureKey::Part(atom) => DispatchKey::Part(atom), - FeatureKey::CustomState(atom) => DispatchKey::CustomState(atom), + LocalFeatureKey::Class(atom) => DispatchKey::Class(atom), + LocalFeatureKey::Part(atom) => DispatchKey::Part(atom), + LocalFeatureKey::CustomState(atom) => DispatchKey::CustomState(atom), // Emptiness is not a feature a compound dispatches on, so nothing is in flux for it. - FeatureKey::Emptiness => return None, - FeatureKey::Attribute(atom) => DispatchKey::AttributeName(atom), - FeatureKey::Id => match (input.old, input.new) { + LocalFeatureKey::Emptiness => return None, + LocalFeatureKey::Attribute(atom) => DispatchKey::AttributeName(atom), + LocalFeatureKey::Id => match (input.old, input.new) { (InputValue::Feature(FeatureValue::Atom(atom)), _) | (_, InputValue::Feature(FeatureValue::Atom(atom))) => DispatchKey::Id(atom), _ => return None, @@ -1424,15 +1422,15 @@ impl StyleEngine { // A resolved language or directionality carries its own value, like an ID. // Neither a language nor a part exposure is dispatched on its value, so nothing is in // flux for either. - FeatureKey::Language | FeatureKey::PartExposure | FeatureKey::HeadingLevel => return None, - FeatureKey::Directionality => match (input.old, input.new) { + LocalFeatureKey::Language | LocalFeatureKey::PartExposure | LocalFeatureKey::HeadingLevel => return None, + LocalFeatureKey::Directionality => match (input.old, input.new) { (InputValue::Feature(FeatureValue::Atom(atom)), _) | (_, InputValue::Feature(FeatureValue::Atom(atom))) => DispatchKey::Directionality(atom), _ => return None, }, // A tag never changes, so it is never in flux. Neither is the folded key an arrival's // facts are journalled under: every fact it stands for holds on the element now. - FeatureKey::TagName | FeatureKey::FoldedTagName | FeatureKey::ArrivingFacts => return None, + LocalFeatureKey::TagName | LocalFeatureKey::FoldedTagName | LocalFeatureKey::ArrivingFacts => return None, }; Some((node, key)) } diff --git a/Libraries/LibWeb/Rust/src/css/style/impact.rs b/Libraries/LibWeb/Rust/src/css/style/impact.rs index 114badcbe1f8..e795d3033976 100644 --- a/Libraries/LibWeb/Rust/src/css/style/impact.rs +++ b/Libraries/LibWeb/Rust/src/css/style/impact.rs @@ -26,8 +26,8 @@ use super::column::PagedColumn; use super::column::PagedColumnPage; use super::instrumentation::Counter; use super::instrumentation::Counters; +use super::program::EntryID; use super::program::RuleID; -use super::program::SelectorProgramID; use super::selector::InverseStep; use super::tree::StyleNodeID; use super::tree::StyleNodeTree; @@ -903,7 +903,7 @@ pub(super) struct PatchCover { intervals: Vec<(u32, u32, u32)>, /// Running maximum interval end per sorted prefix, bounding the leftward stab walk. prefix_max_end: Vec, - keys: Vec<(RuleID, SelectorProgramID)>, + keys: Vec<(RuleID, EntryID)>, } /// The normalized impact plan for one transaction. @@ -925,7 +925,7 @@ pub struct ImpactRegions { /// Rule-attributed emissions in original extent. A route naming its exact entry can prove /// that within its region only that rule's truth moved, so a node covered exclusively by /// attributed regions patches against the union of its covering attributions. - attributions: Vec<(ImpactRegion, (RuleID, SelectorProgramID))>, + attributions: Vec<(ImpactRegion, (RuleID, EntryID))>, } impl ImpactRegions { @@ -1053,7 +1053,7 @@ impl ImpactRegions { /// Recorded in original extent, before merging, so patch narrowing can union the covering /// attributions per node; duplicate adds of the same extent by unattributed routes still /// force full re-derivation because every unattributed add is recorded independently. - pub fn add_attributed(&mut self, region: ImpactRegion, key: (RuleID, SelectorProgramID), counters: &mut Counters) { + pub fn add_attributed(&mut self, region: ImpactRegion, key: (RuleID, EntryID), counters: &mut Counters) { if region != ImpactRegion::Empty { self.attributions.push((region, key)); } @@ -1064,12 +1064,7 @@ impl ImpactRegions { /// candidates are already planned owes the patch only the fact that its rule may have moved /// inside its extent; this carries that fact symbolically, one row per route instead of one /// row per (node, rule), and the per-node union is rebuilt lazily by `covering_attributions`. - pub fn attribute_extent( - &mut self, - region: ImpactRegion, - key: (RuleID, SelectorProgramID), - _counters: &mut Counters, - ) { + pub fn attribute_extent(&mut self, region: ImpactRegion, key: (RuleID, EntryID), _counters: &mut Counters) { if region != ImpactRegion::Empty { self.attributions.push((region, key)); } @@ -1413,7 +1408,7 @@ impl ImpactRegions { /// carrying rule keys. Attributed regions the topology cannot canonicalize demote to the /// full trigger, which is always sound. pub(super) fn compile_patch_cover(&self, tree: &StyleNodeTree, document_root: Option) -> PatchCover { - let mut keys: Vec<(RuleID, SelectorProgramID)> = Vec::new(); + let mut keys: Vec<(RuleID, EntryID)> = Vec::new(); let mut intervals: Vec<(u32, u32, u32)> = Vec::new(); let mut demoted: Vec = Vec::new(); let mut scratch: Vec = Vec::new(); @@ -1469,7 +1464,7 @@ impl ImpactRegions { cover: &PatchCover, sweep: &mut AttributionSweep, node: StyleNodeID, - out: &mut Vec<(RuleID, SelectorProgramID)>, + out: &mut Vec<(RuleID, EntryID)>, ) -> bool { out.clear(); if cover.intervals.is_empty() { @@ -1509,12 +1504,7 @@ impl ImpactRegions { true } - fn stab_covering_attributions( - &self, - cover: &PatchCover, - position: u32, - out: &mut Vec<(RuleID, SelectorProgramID)>, - ) { + fn stab_covering_attributions(&self, cover: &PatchCover, position: u32, out: &mut Vec<(RuleID, EntryID)>) { let mut index = cover.intervals.partition_point(|&(start, _, _)| start <= position); while index > 0 { index -= 1; diff --git a/Libraries/LibWeb/Rust/src/css/style/index.rs b/Libraries/LibWeb/Rust/src/css/style/index.rs index 4587a86926bc..f475bef2186d 100644 --- a/Libraries/LibWeb/Rust/src/css/style/index.rs +++ b/Libraries/LibWeb/Rust/src/css/style/index.rs @@ -36,6 +36,7 @@ use super::memory::MemoryLease; use super::partial_view::Lookup; use super::prefix::PrefixAutomaton; use super::program::DeclaredProperty; +use super::program::EntryID; use super::program::RuleID; use super::program::SelectorProgramID; use super::transaction::ElementDeclarationKind; @@ -63,7 +64,7 @@ impl StyleAtomID { /// Attribute presence and attribute value share one key: changing an attribute changes both facts /// at once, and splitting them would let a consumer handle one and miss the other. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum FeatureKey { +pub enum LocalFeatureKey { /// The element's qualified name, as a single interned tag/namespace atom. TagName, /// The ASCII-lowercase folding of that name, recorded only when it differs from it. A type @@ -663,6 +664,7 @@ impl StyleNodeFacts { DispatchKey::State(state) => self.states_of(row).contains(state), DispatchKey::Heading => self.heading_level_of(row) != 0, DispatchKey::Universal => true, + _ => unreachable!("non-dispatch feature key"), } } @@ -784,7 +786,6 @@ const MAX_POSTING_CHUNK: usize = 256; /// One feature's candidate set: chunked and sorted by `StyleNodeID`. #[derive(Default)] pub(super) struct Posting { - id: PostingID, chunks: Vec>, length: usize, } @@ -799,11 +800,6 @@ impl Posting { self.length } - #[must_use] - pub(super) fn id(&self) -> PostingID { - self.id - } - fn chunk_for(&self, node: StyleNodeID) -> usize { match self.chunks.binary_search_by(|chunk| chunk[0].cmp(&node)) { Ok(index) => index, @@ -900,32 +896,32 @@ impl Posting { .iter() .map(|chunk| chunk.capacity() * size_of::()) .sum::()]; - skip [self.id, self.length]; + skip [self.length]; } } } -/// A selector posting's key: a matchable feature and the atom that identifies it. +/// One compact key for selector routing, dispatch, and postings. /// -/// Distinct from [`FeatureKey`], which names a *fact of an element* - "this element's tag" - where -/// a posting names a *set of elements* - "the elements whose tag is div". The fact needs no atom -/// because its value carries one; the posting needs one because it is the value. +/// Every atom-bearing variant has the same `(kind: u8, atom: u32)` representation. Variants without +/// an atom retain the distinctions needed by routing and dispatch without introducing another key +/// vocabulary or a conversion between equal semantic features. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum SelectorPostingKey { +#[repr(u8)] +pub enum FeatureKey { Part(StyleAtomID), - /// An element in one custom state, which `:state()` tests. CustomState(StyleAtomID), - Tag(StyleAtomID), + TagName(StyleAtomID), Id(StyleAtomID), Class(StyleAtomID), AttributeName(StyleAtomID), - /// An element with this resolved directionality. Directionality(StyleAtomID), -} - -/// A dependency posting's key: computed-style use which lets a named rule find its consumers. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum DependencyPostingKey { + Root, + State(StateFact), + Heading, + Universal, + Structural, + Language, /// An element whose style resolution called a custom function. /// /// Which function it called is not reported by the substitution machinery, so an `@function` rule @@ -953,24 +949,26 @@ pub enum DependencyPostingKey { AnimationName(StyleAtomID), } -/// The shared physical posting store's disjoint selector and dependency vocabularies. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum PostingKey { - Selector(SelectorPostingKey), - Dependency(DependencyPostingKey), -} - -define_id! { - /// Dense identity of one live feature posting for the current posting generation. - default pub(super) struct PostingID(); -} - -impl PostingID { - pub(super) fn index(self) -> usize { - self.0 as usize +impl FeatureKey { + #[must_use] + pub fn has_selector_posting(self) -> bool { + matches!( + self, + Self::Part(_) + | Self::CustomState(_) + | Self::TagName(_) + | Self::Id(_) + | Self::Class(_) + | Self::AttributeName(_) + | Self::Directionality(_) + ) } } +pub type SelectorPostingKey = FeatureKey; +pub type DependencyPostingKey = FeatureKey; +pub type PostingKey = FeatureKey; + /// Candidate sets for observed element features. /// /// This is acceleration and nothing more. Evicting a posting never changes which transpose entry @@ -982,7 +980,6 @@ impl PostingID { /// removed. pub struct FeaturePostings { postings: HashMap, - dense_ids_are_current: bool, residency: MemoryLease, missing: HashSet, benefit_hits: Cell, @@ -993,7 +990,6 @@ impl Default for FeaturePostings { fn default() -> Self { Self { postings: HashMap::default(), - dense_ids_are_current: true, residency: MemoryLease::new(MemoryCategory::FeaturePosting), missing: HashSet::default(), benefit_hits: Cell::new(0), @@ -1018,10 +1014,7 @@ impl FeaturePostings { } let posting = match self.postings.entry(key) { std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), - std::collections::hash_map::Entry::Vacant(entry) => { - self.dense_ids_are_current = false; - entry.insert(Posting::default()) - } + std::collections::hash_map::Entry::Vacant(entry) => entry.insert(Posting::default()), }; let Some(growth) = posting.insert(node) else { return true; @@ -1061,17 +1054,6 @@ impl FeaturePostings { result } - /// Assign transaction-local direct-column identities to the current live postings. - pub(super) fn ensure_dense_ids(&mut self) { - if self.dense_ids_are_current { - return; - } - for (index, posting) in self.postings.values_mut().enumerate() { - posting.id = PostingID(u32::try_from(index).expect("feature posting identity space exhausted")); - } - self.dense_ids_are_current = true; - } - #[cfg(test)] pub(crate) fn evict(&mut self, key: PostingKey) { self.remember_missing(key); @@ -1098,7 +1080,6 @@ impl FeaturePostings { let Some(posting) = self.postings.remove(&key) else { return; }; - self.dense_ids_are_current = false; self.residency .shrink_to(self.residency.bytes() - posting.capacity_bytes()); } @@ -1112,7 +1093,6 @@ impl FeaturePostings { let released = self.postings.values().map(Posting::capacity_bytes).sum::(); self.residency.shrink_to(self.residency.bytes() - released); self.postings = HashMap::default(); - self.dense_ids_are_current = true; } #[must_use] @@ -1130,7 +1110,6 @@ impl FeaturePostings { let released = self.missing_capacity_bytes(); self.missing = HashSet::default(); self.residency.shrink_to(self.residency.bytes() - released); - self.dense_ids_are_current = false; } #[must_use] @@ -1156,30 +1135,8 @@ impl FeaturePostings { } } -/// The rightmost distinguishing feature of one selector entry: the dispatch key a candidate is -/// probed against. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum DispatchKey { - /// `::part(label)`: a part name the element exposes. - Part(StyleAtomID), - /// `:state(name)`: a custom state the element is in. - CustomState(StyleAtomID), - Id(StyleAtomID), - Class(StyleAtomID), - AttributeName(StyleAtomID), - TagName(StyleAtomID), - /// `:dir(value)`: the directionality the element must resolve to. - Directionality(StyleAtomID), - /// `:root`: the one element of the document that has no parent. - Root, - /// One pseudo-class state the subject must be in. Most of them hold on almost no element, so - /// this is where a `:hover` or a `:link` rule belongs rather than in front of every candidate. - State(StateFact), - /// `:heading` and `:heading(n)`: the subject is a heading of some level. - Heading, - /// The entry has no selective rightmost feature, so every candidate has to consider it. - Universal, -} +/// The rightmost distinguishing feature of one selector entry. +pub type DispatchKey = FeatureKey; /// One lossy bit for a dispatch key. /// @@ -1205,6 +1162,7 @@ fn dispatch_key_hash(key: DispatchKey) -> u64 { DispatchKey::State(fact) => (9, fact as u64), DispatchKey::Heading => (10, 0), DispatchKey::Universal => (11, 0), + _ => unreachable!("non-dispatch feature key"), }; let mut hash = value ^ kind.wrapping_mul(0x9e37_79b9_7f4a_7c15); hash = (hash ^ (hash >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); @@ -1215,6 +1173,8 @@ fn dispatch_key_hash(key: DispatchKey) -> u64 { /// One attached selector entry, reachable from its dispatch key. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DispatchEntry { + /// Document identity of the compiled selector entry this scope-local rule row joins. + pub identity: EntryID, pub rule: RuleID, pub program: SelectorProgramID, /// Index into the selector program's entry list. @@ -1243,11 +1203,11 @@ pub struct DispatchEntry { } define_id! { - /// Stable identity of one entry in a selector dispatch. - pub(super) struct DispatchEntryID(); + /// Physical row in one scope-local selector dispatch. + pub(super) struct DispatchRow(); } -impl DispatchEntryID { +impl DispatchRow { pub(super) fn from_index(index: usize) -> Self { Self(u32::try_from(index).expect("dispatch entry space exhausted")) } @@ -1270,7 +1230,7 @@ struct CascadeEntryData { #[derive(Default)] pub struct DispatchCandidateWorkspace { seen_at_epoch: EpochColumn, - candidates: Vec, + candidates: Vec, epoch: u32, } @@ -1299,7 +1259,7 @@ impl DispatchCandidateWorkspace { advance_epoch(&mut self.epoch, 1, &mut [&mut self.seen_at_epoch]); } - fn admit(&mut self, id: DispatchEntryID) -> bool { + fn admit(&mut self, id: DispatchRow) -> bool { self.seen_at_epoch.mark(id.index(), self.epoch) } @@ -1361,19 +1321,19 @@ struct AncestorDispatchTopology { #[derive(Default)] struct RuleDispatchTopology { - buckets: HashMap>, + buckets: HashMap>, /// Universal-subject entries that have no exact parent requirement. - universal_without_parent_filter: Vec, + universal_without_parent_filter: Vec, /// Universal-subject entries indexed by the one feature their parent must carry. - universal_by_parent: HashMap>, + universal_by_parent: HashMap>, /// The same parent-filtered entries as a conservative fallback when parent facts are absent. - universal_with_parent_filter: Vec, + universal_with_parent_filter: Vec, /// Entries the top-down prefix automaton cannot answer, indexed separately so its successful /// path does not enumerate the old exact candidates merely to discard them. - non_prefix_buckets: HashMap>, - non_prefix_universal_without_parent_filter: Vec, - non_prefix_universal_by_parent: HashMap>, - non_prefix_universal_with_parent_filter: Vec, + non_prefix_buckets: HashMap>, + non_prefix_universal_without_parent_filter: Vec, + non_prefix_universal_by_parent: HashMap>, + non_prefix_universal_with_parent_filter: Vec, ancestors: Rc, prefixes: PrefixAutomaton, } @@ -1384,6 +1344,7 @@ pub(super) struct AncestorDispatchTopologyID(*const AncestorDispatchTopology); #[derive(Default)] pub struct RuleDispatch { entries: Vec, + entry_rows: Vec>, /// Direct cascade-order projection for every rule represented in this dispatch. Rule /// identities are program indices, so retained answers can restore an entry's order without /// searching the dispatch's much larger candidate table. Sparse pages keep a scope containing @@ -1433,6 +1394,7 @@ impl RuleDispatch { } Self { entries, + entry_rows: template.entry_rows.clone(), cascade_order_rule_pages: Vec::new(), cascade_orders_by_rule_entry: Vec::new(), cascade_properties: Vec::new(), @@ -1496,15 +1458,19 @@ impl RuleDispatch { self.topology_mut().ancestors = Rc::clone(&template.topology.ancestors); } - pub(super) fn insert(&mut self, key: DispatchKey, mut entry: DispatchEntry) -> DispatchEntryID { + pub(super) fn insert(&mut self, key: DispatchKey, mut entry: DispatchEntry) -> DispatchRow { entry.required_ancestor_index = entry.required_ancestor.map(|required| { let topology = self.topology_mut(); let ancestors = Rc::get_mut(&mut topology.ancestors).expect("a shared ancestor topology is immutable"); let next = u32::try_from(ancestors.key_indices.len()).expect("ancestor requirement space exhausted"); *ancestors.key_indices.entry(required).or_insert(next) }); - let id = DispatchEntryID::from_index(self.entries.len()); + let id = DispatchRow::from_index(self.entries.len()); self.entries.push(entry); + if self.entry_rows.len() <= entry.identity.0 as usize { + self.entry_rows.resize_with(entry.identity.0 as usize + 1, Vec::new); + } + self.entry_rows[entry.identity.0 as usize].push(id); self.topology_mut().buckets.entry(key).or_default().push(id); if key == DispatchKey::Universal { self.index_universal_entry(id); @@ -1516,23 +1482,20 @@ impl RuleDispatch { &mut self, programs: &super::selector::SelectorPrograms, program: SelectorProgramID, - selector_entry: u32, chain: &[super::selector::SelectorPrefixStep], - entry: DispatchEntryID, + row: DispatchRow, structural_tests_admissible: bool, ) { // Registration can refuse a chain whose structural tests would overflow the automaton's // truth bit space or whose origin does not admit them; the entry then stays a candidate // for the exact evaluator. - if self.topology_mut().prefixes.add_entry( - programs, - program, - selector_entry, - chain, - entry, - structural_tests_admissible, - ) { - self.entries[entry.index()].prefix_matched = true; + let entry = self.entries[row.index()].identity; + if self + .topology_mut() + .prefixes + .add_entry(programs, program, chain, entry, structural_tests_admissible) + { + self.entries[row.index()].prefix_matched = true; } } @@ -1546,9 +1509,12 @@ impl RuleDispatch { &self.topology.prefixes } - #[must_use] - pub(super) fn entry(&self, id: DispatchEntryID) -> DispatchEntry { - self.entries[id.index()] + pub(super) fn entries_for_identity(&self, entry: EntryID) -> impl Iterator + '_ { + self.entry_rows + .get(entry.0 as usize) + .into_iter() + .flatten() + .map(|&row| self.entries[row.index()]) } #[must_use] @@ -1583,7 +1549,7 @@ impl RuleDispatch { }) } - fn index_universal_entry(&mut self, id: DispatchEntryID) { + fn index_universal_entry(&mut self, id: DispatchRow) { let entry = self.entries[id.index()]; let topology = self.topology_mut(); match entry.required_parent { @@ -1655,7 +1621,7 @@ impl RuleDispatch { /// those copies are one selector entry. They therefore share one rank, which is what the /// candidate walk deduplicates them by. pub fn assign_cascade_order(&mut self, mut priority_of: impl FnMut(DispatchEntry) -> K) { - let mut ordered: Vec<(K, RuleID, SelectorProgramID, u32, DispatchEntryID)> = self + let mut ordered: Vec<(K, RuleID, SelectorProgramID, u32, DispatchRow)> = self .entries .iter() .copied() @@ -1666,7 +1632,7 @@ impl RuleDispatch { entry.rule, entry.program, entry.entry, - DispatchEntryID::from_index(index), + DispatchRow::from_index(index), ) }) .collect(); @@ -1704,7 +1670,7 @@ impl RuleDispatch { } fn rebuild_cascade_order_projection(&mut self) { - let mut entries_by_identity: Vec<_> = (0..self.entries.len()).map(DispatchEntryID::from_index).collect(); + let mut entries_by_identity: Vec<_> = (0..self.entries.len()).map(DispatchRow::from_index).collect(); entries_by_identity.sort_unstable_by_key(|&id| { let entry = self.entries[id.index()]; (entry.rule, entry.program, entry.entry) @@ -1834,7 +1800,7 @@ impl RuleDispatch { self.cascade_pruning_blocker_for_order(entry.cascade_order) } - fn bucket_ids(&self, key: DispatchKey) -> &[DispatchEntryID] { + fn bucket_ids(&self, key: DispatchKey) -> &[DispatchRow] { self.topology.buckets.get(&key).map_or(&[], Vec::as_slice) } @@ -1891,7 +1857,7 @@ impl RuleDispatch { }; let subject_bloom = facts.dispatch_bloom_of(row, is_document_root); { - let mut offer = |id: DispatchEntryID, attribute_value: Option| { + let mut offer = |id: DispatchRow, attribute_value: Option| { let entry = self.entries[id.index()]; if !entry.required_attribute_value.is_none() && attribute_value != Some(entry.required_attribute_value) { @@ -1967,6 +1933,7 @@ impl RuleDispatch { let scope_bytes = capacity_bytes! { shallow [ self.entries, + self.entry_rows, self.cascade_order_rule_pages, self.cascade_orders_by_rule_entry, self.cascade_properties, @@ -1980,6 +1947,11 @@ impl RuleDispatch { .flatten() .map(|page| size_of_val(page.as_ref())) .sum::(), + self + .entry_rows + .iter() + .map(|rows| rows.capacity() * size_of::()) + .sum::(), ]; skip []; }; @@ -2000,22 +1972,22 @@ impl RuleDispatch { self.topology .buckets .values() - .map(|bucket| bucket.capacity() * size_of::()) + .map(|bucket| bucket.capacity() * size_of::()) .sum::(), self.topology .universal_by_parent .values() - .map(|bucket| bucket.capacity() * size_of::()) + .map(|bucket| bucket.capacity() * size_of::()) .sum::(), self.topology .non_prefix_buckets .values() - .map(|bucket| bucket.capacity() * size_of::()) + .map(|bucket| bucket.capacity() * size_of::()) .sum::(), self.topology .non_prefix_universal_by_parent .values() - .map(|bucket| bucket.capacity() * size_of::()) + .map(|bucket| bucket.capacity() * size_of::()) .sum::(), self.topology.prefixes.capacity_bytes(), ]; @@ -2348,10 +2320,13 @@ impl ElementFactStore { for node in self.rows.live_nodes() { let row = self.rows.row_of(node).expect("a live node must have a fact row"); for (atom, key) in [ - (self.rows.tag_of(row), SelectorPostingKey::Tag(self.rows.tag_of(row))), + ( + self.rows.tag_of(row), + SelectorPostingKey::TagName(self.rows.tag_of(row)), + ), ( self.rows.folded_tag_of(row), - SelectorPostingKey::Tag(self.rows.folded_tag_of(row)), + SelectorPostingKey::TagName(self.rows.folded_tag_of(row)), ), (self.rows.id_of(row), SelectorPostingKey::Id(self.rows.id_of(row))), ( @@ -2362,31 +2337,31 @@ impl ElementFactStore { if atom.is_none() { continue; } - if !self.rebuild_missing_posting(&mut rebuilt, PostingKey::Selector(key), node, memory) { + if !self.rebuild_missing_posting(&mut rebuilt, key, node, memory) { return; } } for &part in self.rows.parts_of(row) { - let key = PostingKey::Selector(SelectorPostingKey::Part(part)); + let key = SelectorPostingKey::Part(part); if !self.rebuild_missing_posting(&mut rebuilt, key, node, memory) { return; } } for &state in self.rows.custom_states_of(row) { - let key = PostingKey::Selector(SelectorPostingKey::CustomState(state)); + let key = SelectorPostingKey::CustomState(state); if !self.rebuild_missing_posting(&mut rebuilt, key, node, memory) { return; } } for &class in self.rows.classes_of(row) { - let key = PostingKey::Selector(SelectorPostingKey::Class(class)); + let key = SelectorPostingKey::Class(class); if !self.rebuild_missing_posting(&mut rebuilt, key, node, memory) { return; } } for attribute in self.rows.attributes_of(row) { for name in self.attribute_name_keys(attribute.name) { - let key = PostingKey::Selector(SelectorPostingKey::AttributeName(name)); + let key = SelectorPostingKey::AttributeName(name); if !self.rebuild_missing_posting(&mut rebuilt, key, node, memory) { return; } @@ -2394,14 +2369,13 @@ impl ElementFactStore { } if let Some(metadata) = self.metadata_of(node) { for &name in &metadata.animation_names { - let key = PostingKey::Dependency(DependencyPostingKey::AnimationName(name)); + let key = DependencyPostingKey::AnimationName(name); if !self.rebuild_missing_posting(&mut rebuilt, key, node, memory) { return; } } if metadata.custom_property_set != 0 { - let key = - PostingKey::Dependency(DependencyPostingKey::CustomPropertySet(metadata.custom_property_set)); + let key = DependencyPostingKey::CustomPropertySet(metadata.custom_property_set); if !self.rebuild_missing_posting(&mut rebuilt, key, node, memory) { return; } @@ -2409,12 +2383,9 @@ impl ElementFactStore { for (uses, key) in [ ( metadata.uses_unnamed_custom_properties, - PostingKey::Dependency(DependencyPostingKey::AnyCustomProperty), - ), - ( - metadata.uses_custom_functions, - PostingKey::Dependency(DependencyPostingKey::AnyCustomFunction), + DependencyPostingKey::AnyCustomProperty, ), + (metadata.uses_custom_functions, DependencyPostingKey::AnyCustomFunction), ] { if uses && !self.rebuild_missing_posting(&mut rebuilt, key, node, memory) { return; @@ -2529,12 +2500,10 @@ impl ElementFactStore { let previous = std::mem::replace(&mut facts.tag, tag); if previous != tag { if !previous.is_none() { - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::Tag(previous)), node); + self.postings.remove(SelectorPostingKey::TagName(previous), node); } if !tag.is_none() { - self.postings - .insert(PostingKey::Selector(SelectorPostingKey::Tag(tag)), node, memory); + self.postings.insert(SelectorPostingKey::TagName(tag), node, memory); } } } @@ -2548,12 +2517,10 @@ impl ElementFactStore { let previous = std::mem::replace(&mut facts.folded_tag, folded); if previous != folded { if !previous.is_none() { - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::Tag(previous)), node); + self.postings.remove(SelectorPostingKey::TagName(previous), node); } if !folded.is_none() { - self.postings - .insert(PostingKey::Selector(SelectorPostingKey::Tag(folded)), node, memory); + self.postings.insert(SelectorPostingKey::TagName(folded), node, memory); } } } @@ -2563,12 +2530,10 @@ impl ElementFactStore { let previous = std::mem::replace(&mut facts.id, id); if previous != id { if !previous.is_none() { - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::Id(previous)), node); + self.postings.remove(SelectorPostingKey::Id(previous), node); } if !id.is_none() { - self.postings - .insert(PostingKey::Selector(SelectorPostingKey::Id(id)), node, memory); + self.postings.insert(SelectorPostingKey::Id(id), node, memory); } } } @@ -2631,6 +2596,7 @@ impl ElementFactStore { | DispatchKey::State(_) | DispatchKey::Heading | DispatchKey::Universal => return None, + _ => return None, }) } @@ -2749,15 +2715,11 @@ impl ElementFactStore { let previous = std::mem::replace(&mut facts.directionality, directionality); if previous != directionality { if !previous.is_none() { - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::Directionality(previous)), node); + self.postings.remove(SelectorPostingKey::Directionality(previous), node); } if !directionality.is_none() { - self.postings.insert( - PostingKey::Selector(SelectorPostingKey::Directionality(directionality)), - node, - memory, - ); + self.postings + .insert(SelectorPostingKey::Directionality(directionality), node, memory); } } } @@ -2767,13 +2729,11 @@ impl ElementFactStore { match (present, facts.classes.binary_search(&class)) { (true, Err(index)) => { facts.classes.insert(index, class); - self.postings - .insert(PostingKey::Selector(SelectorPostingKey::Class(class)), node, memory); + self.postings.insert(SelectorPostingKey::Class(class), node, memory); } (false, Ok(index)) => { facts.classes.remove(index); - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::Class(class)), node); + self.postings.remove(SelectorPostingKey::Class(class), node); } _ => {} } @@ -2795,17 +2755,13 @@ impl ElementFactStore { } for &name in &previous { if !sorted.contains(&name) { - self.postings - .remove(PostingKey::Dependency(DependencyPostingKey::AnimationName(name)), node); + self.postings.remove(DependencyPostingKey::AnimationName(name), node); } } for name in &sorted { if !previous.contains(name) { - self.postings.insert( - PostingKey::Dependency(DependencyPostingKey::AnimationName(*name)), - node, - memory, - ); + self.postings + .insert(DependencyPostingKey::AnimationName(*name), node, memory); } } self.metadata_mut(node).animation_names = sorted; @@ -2822,14 +2778,11 @@ impl ElementFactStore { } self.metadata_mut(node).uses_custom_functions = uses; match uses { - true => self.postings.insert( - PostingKey::Dependency(DependencyPostingKey::AnyCustomFunction), - node, - memory, - ), + true => self + .postings + .insert(DependencyPostingKey::AnyCustomFunction, node, memory), false => { - self.postings - .remove(PostingKey::Dependency(DependencyPostingKey::AnyCustomFunction), node); + self.postings.remove(DependencyPostingKey::AnyCustomFunction, node); true } }; @@ -2846,14 +2799,11 @@ impl ElementFactStore { } self.metadata_mut(node).uses_unnamed_custom_properties = uses; match uses { - true => self.postings.insert( - PostingKey::Dependency(DependencyPostingKey::AnyCustomProperty), - node, - memory, - ), + true => self + .postings + .insert(DependencyPostingKey::AnyCustomProperty, node, memory), false => { - self.postings - .remove(PostingKey::Dependency(DependencyPostingKey::AnyCustomProperty), node); + self.postings.remove(DependencyPostingKey::AnyCustomProperty, node); true } }; @@ -2885,17 +2835,12 @@ impl ElementFactStore { } self.metadata_mut(node).custom_property_set = set; if previous != 0 { - self.postings.remove( - PostingKey::Dependency(DependencyPostingKey::CustomPropertySet(previous)), - node, - ); + self.postings + .remove(DependencyPostingKey::CustomPropertySet(previous), node); } if set != 0 { - self.postings.insert( - PostingKey::Dependency(DependencyPostingKey::CustomPropertySet(set)), - node, - memory, - ); + self.postings + .insert(DependencyPostingKey::CustomPropertySet(set), node, memory); } } @@ -2929,10 +2874,7 @@ impl ElementFactStore { }; let mut nodes = Vec::new(); for &set in sets { - match self - .postings - .lookup(PostingKey::Dependency(DependencyPostingKey::CustomPropertySet(set))) - { + match self.postings.lookup(DependencyPostingKey::CustomPropertySet(set)) { Lookup::Known(posting) => nodes.extend(posting.candidates()), Lookup::KnownAbsent => {} Lookup::Missing(gap) => return Err(gap), @@ -2962,11 +2904,8 @@ impl ElementFactStore { (true, Err(index)) => { facts.attributes.insert(index, (name, value)); for key in keys { - self.postings.insert( - PostingKey::Selector(SelectorPostingKey::AttributeName(key)), - node, - memory, - ); + self.postings + .insert(SelectorPostingKey::AttributeName(key), node, memory); } } (false, Ok(index)) => { @@ -2975,8 +2914,7 @@ impl ElementFactStore { // answers to it, so only the names nothing implies any more are dropped. for key in keys { if key == name || !self.node_answers_to_attribute_name(node, key) { - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::AttributeName(key)), node); + self.postings.remove(SelectorPostingKey::AttributeName(key), node); } } } @@ -3098,31 +3036,25 @@ impl ElementFactStore { .checked_add(row_bytes) .expect("primary fact byte count overflow"); if !facts.tag.is_none() { - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::Tag(facts.tag)), node); + self.postings.remove(SelectorPostingKey::TagName(facts.tag), node); } if !facts.folded_tag.is_none() { self.postings - .remove(PostingKey::Selector(SelectorPostingKey::Tag(facts.folded_tag)), node); + .remove(SelectorPostingKey::TagName(facts.folded_tag), node); } if !facts.id.is_none() { - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::Id(facts.id)), node); + self.postings.remove(SelectorPostingKey::Id(facts.id), node); } if !facts.directionality.is_none() { - self.postings.remove( - PostingKey::Selector(SelectorPostingKey::Directionality(facts.directionality)), - node, - ); + self.postings + .remove(SelectorPostingKey::Directionality(facts.directionality), node); } for class in facts.classes { - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::Class(class)), node); + self.postings.remove(SelectorPostingKey::Class(class), node); } for (name, _) in facts.attributes { for key in self.attribute_name_keys(name) { - self.postings - .remove(PostingKey::Selector(SelectorPostingKey::AttributeName(key)), node); + self.postings.remove(SelectorPostingKey::AttributeName(key), node); } } if let Some(metadata) = node @@ -3131,22 +3063,19 @@ impl ElementFactStore { .and_then(Option::take) { for name in metadata.animation_names { - self.postings - .remove(PostingKey::Dependency(DependencyPostingKey::AnimationName(name)), node); + self.postings.remove(DependencyPostingKey::AnimationName(name), node); } if metadata.custom_property_set != 0 { self.postings.remove( - PostingKey::Dependency(DependencyPostingKey::CustomPropertySet(metadata.custom_property_set)), + DependencyPostingKey::CustomPropertySet(metadata.custom_property_set), node, ); } if metadata.uses_unnamed_custom_properties { - self.postings - .remove(PostingKey::Dependency(DependencyPostingKey::AnyCustomProperty), node); + self.postings.remove(DependencyPostingKey::AnyCustomProperty, node); } if metadata.uses_custom_functions { - self.postings - .remove(PostingKey::Dependency(DependencyPostingKey::AnyCustomFunction), node); + self.postings.remove(DependencyPostingKey::AnyCustomFunction, node); } } } @@ -3565,7 +3494,7 @@ mod tests { fn a_posting_stays_sorted_across_chunk_splits() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); let mut postings = FeaturePostings::new(); - let key = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(1))); + let key = SelectorPostingKey::Class(StyleAtomID(1)); // Insert in order and across enough members to force several append-only chunks. for index in 1..2000_u32 { @@ -3595,7 +3524,7 @@ mod tests { fn removing_the_last_member_reclaims_the_posting() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); let mut postings = FeaturePostings::new(); - let key = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(1))); + let key = SelectorPostingKey::Class(StyleAtomID(1)); assert!(matches!(postings.lookup(key), Lookup::KnownAbsent)); for index in 1..500_u32 { postings.insert(key, StyleNodeID::element(index), &mut memory); @@ -3610,30 +3539,6 @@ mod tests { assert_eq!(memory.bytes_in_category(MemoryCategory::FeaturePosting), 0); } - #[test] - fn live_postings_receive_compact_transaction_identities() { - let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); - let mut postings = FeaturePostings::new(); - let first = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(1))); - let second = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(2))); - let third = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(3))); - let node = StyleNodeID::element(1); - - postings.insert(first, node, &mut memory); - postings.insert(second, node, &mut memory); - postings.ensure_dense_ids(); - postings.remove(first, node); - postings.insert(third, node, &mut memory); - postings.ensure_dense_ids(); - let mut ids = [ - known_posting(&postings, second).id().index(), - known_posting(&postings, third).id().index(), - ]; - ids.sort_unstable(); - - assert_eq!(ids, [0, 1]); - } - #[test] fn local_dispatch_keys_read_the_authoritative_element_row() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); @@ -3709,22 +3614,14 @@ mod tests { facts.commit_pending(&mut memory); for key in [name, forms.local, forms.folded_name, forms.folded_local] { - assert!( - known_posting( - &facts.postings, - PostingKey::Selector(SelectorPostingKey::AttributeName(key)) - ) - .contains(node) - ); + assert!(known_posting(&facts.postings, SelectorPostingKey::AttributeName(key)).contains(node)); } facts.forget(node); for key in [name, forms.local, forms.folded_name, forms.folded_local] { assert!(matches!( - facts - .postings - .lookup(PostingKey::Selector(SelectorPostingKey::AttributeName(key))), + facts.postings.lookup(SelectorPostingKey::AttributeName(key)), Lookup::KnownAbsent )); } @@ -3948,7 +3845,7 @@ mod tests { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); memory.set_tier3_limit_for_test(0); let mut postings = FeaturePostings::new(); - let key = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(1))); + let key = SelectorPostingKey::Class(StyleAtomID(1)); // A backgrounded document has no Tier-3 budget at all. assert!(!postings.insert(key, StyleNodeID::element(1), &mut memory)); @@ -3963,7 +3860,7 @@ mod tests { fn a_refused_posting_growth_releases_its_previous_charge() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); let mut postings = FeaturePostings::new(); - let key = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(1))); + let key = SelectorPostingKey::Class(StyleAtomID(1)); assert!(postings.insert(key, StyleNodeID::element(1), &mut memory)); let admitted_bytes = memory.bytes_in_category(MemoryCategory::FeaturePosting); @@ -3984,7 +3881,7 @@ mod tests { for feature in 1..20_u32 { for index in 1..50_u32 { postings.insert( - PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(feature))), + SelectorPostingKey::Class(StyleAtomID(feature)), StyleNodeID::element(index), &mut memory, ); @@ -3993,10 +3890,10 @@ mod tests { assert!(memory.bytes_in_category(MemoryCategory::FeaturePosting) > 0); postings.evict_all(); assert_eq!(postings.feature_count(), 0); - let key = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(1))); + let key = SelectorPostingKey::Class(StyleAtomID(1)); assert!(matches!(postings.lookup(key), Lookup::Missing(gap) if gap == key)); assert!(matches!( - postings.lookup(PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(100)))), + postings.lookup(SelectorPostingKey::Class(StyleAtomID(100))), Lookup::KnownAbsent )); assert_eq!( @@ -4025,14 +3922,14 @@ mod tests { facts.set_class(node, new_class, true, &mut memory); facts.set_animation_names(node, &[new_animation], &mut memory); facts.commit_pending(&mut memory); - let new_class_key = PostingKey::Selector(SelectorPostingKey::Class(new_class)); + let new_class_key = SelectorPostingKey::Class(new_class); assert!(matches!(facts.postings().lookup(new_class_key), Lookup::Missing(gap) if gap == new_class_key)); memory.set_tier3_limit_for_test(u64::MAX); facts.commit_pending(&mut memory); - let old_class_key = PostingKey::Selector(SelectorPostingKey::Class(old_class)); - let old_animation_key = PostingKey::Dependency(DependencyPostingKey::AnimationName(old_animation)); - let new_animation_key = PostingKey::Dependency(DependencyPostingKey::AnimationName(new_animation)); + let old_class_key = SelectorPostingKey::Class(old_class); + let old_animation_key = DependencyPostingKey::AnimationName(old_animation); + let new_animation_key = DependencyPostingKey::AnimationName(new_animation); assert!(matches!(facts.postings().lookup(old_class_key), Lookup::KnownAbsent)); assert!(matches!( facts.postings().lookup(old_animation_key), @@ -4046,8 +3943,8 @@ mod tests { fn evicting_one_posting_preserves_exact_absence_for_other_keys() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); let mut postings = FeaturePostings::new(); - let evicted = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(1))); - let absent = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(2))); + let evicted = SelectorPostingKey::Class(StyleAtomID(1)); + let absent = SelectorPostingKey::Class(StyleAtomID(2)); postings.insert(evicted, StyleNodeID::element(1), &mut memory); postings.evict(evicted); @@ -4111,6 +4008,7 @@ mod tests { fn cascade_order_projection_handles_duplicate_entries_and_sparse_rules() { let mut dispatch = RuleDispatch::new(); let entry = |rule: u32, selector_entry: u32, multi_key| DispatchEntry { + identity: EntryID(rule * 10 + selector_entry), rule: RuleID(rule), program: SelectorProgramID(rule), entry: selector_entry, @@ -4166,6 +4064,7 @@ mod tests { fn a_candidate_probes_only_the_buckets_its_own_facts_name() { let mut dispatch = RuleDispatch::new(); let entry = |rule: u32| DispatchEntry { + identity: EntryID(rule), rule: RuleID(rule), program: SelectorProgramID(rule), entry: 0, @@ -4225,6 +4124,7 @@ mod tests { fn attribute_aliases_emit_each_candidate_once_without_hiding_value_matches() { let mut dispatch = RuleDispatch::new(); let entry = |rule: u32, required_attribute_value| DispatchEntry { + identity: EntryID(rule), rule: RuleID(rule), program: SelectorProgramID(rule), entry: 0, @@ -4297,6 +4197,7 @@ mod tests { fn a_universal_subject_probes_only_the_bucket_its_parent_names() { let mut dispatch = RuleDispatch::new(); let entry = |rule: u32, required_parent| DispatchEntry { + identity: EntryID(rule), rule: RuleID(rule), program: SelectorProgramID(rule), entry: 0, @@ -4371,6 +4272,7 @@ mod tests { fn a_local_subject_bucket_respects_its_parent_requirement() { let mut dispatch = RuleDispatch::new(); let entry = |rule: u32, required_parent| DispatchEntry { + identity: EntryID(rule), rule: RuleID(rule), program: SelectorProgramID(rule), entry: 0, @@ -4563,6 +4465,11 @@ mod tests { assert_eq!(size_of::(), 8); } + #[test] + fn selector_feature_keys_are_eight_bytes() { + assert_eq!(size_of::(), 8); + } + #[test] fn clearing_keeps_the_arena_for_the_next_transaction() { let mut facts = StyleNodeFacts::new(); diff --git a/Libraries/LibWeb/Rust/src/css/style/input_routing.rs b/Libraries/LibWeb/Rust/src/css/style/input_routing.rs index 8604ae00e665..86a1d0a5fae9 100644 --- a/Libraries/LibWeb/Rust/src/css/style/input_routing.rs +++ b/Libraries/LibWeb/Rust/src/css/style/input_routing.rs @@ -10,14 +10,12 @@ //! normalized transaction input publishes the corresponding keys so planning can ask whether an //! attached selector observes that input. -use super::index::FeatureKey; use super::index::FeatureValue; +use super::index::LocalFeatureKey; use super::selector::RoutingKey; -use super::selector::ValueStateKind; use super::transaction::InputKey; use super::transaction::InputValue; use super::transaction::NormalizedInput; -use crate::css::style::StyleAtomID; /// The routing keys one normalized input publishes. #[must_use] @@ -50,7 +48,7 @@ fn for_each_routing_key(input: &NormalizedInput, mut publish: impl FnMut(Routing /// change truth. A class or attribute key already names its atom, and one attribute mutation /// changes presence and value together, so the attribute name covers both. fn for_each_feature_routing_key( - feature: FeatureKey, + feature: LocalFeatureKey, old: InputValue, new: InputValue, mut publish: impl FnMut(RoutingKey), @@ -60,7 +58,7 @@ fn for_each_feature_routing_key( _ => None, }; match feature { - FeatureKey::TagName | FeatureKey::FoldedTagName => { + LocalFeatureKey::TagName | LocalFeatureKey::FoldedTagName => { if let Some(atom) = atom_of(old) { publish(RoutingKey::TagName(atom)); } @@ -68,7 +66,7 @@ fn for_each_feature_routing_key( publish(RoutingKey::TagName(atom)); } } - FeatureKey::Id => { + LocalFeatureKey::Id => { if let Some(atom) = atom_of(old) { publish(RoutingKey::Id(atom)); } @@ -76,26 +74,26 @@ fn for_each_feature_routing_key( publish(RoutingKey::Id(atom)); } } - FeatureKey::Class(class) => publish(RoutingKey::Class(class)), - FeatureKey::Part(part) => publish(RoutingKey::Part(part)), - FeatureKey::CustomState(state) => publish(RoutingKey::ValueState(ValueStateKind::CustomState, state)), + LocalFeatureKey::Class(class) => publish(RoutingKey::Class(class)), + LocalFeatureKey::Part(part) => publish(RoutingKey::Part(part)), + LocalFeatureKey::CustomState(state) => publish(RoutingKey::CustomState(state)), // Routed to the element directly rather than through a transpose route. - FeatureKey::PartExposure => {} + LocalFeatureKey::PartExposure => {} // Every `:lang()` entry registers under one key, because a range is not an atom. - FeatureKey::Language => publish(RoutingKey::ValueState(ValueStateKind::Language, StyleAtomID::NONE)), - FeatureKey::Directionality => { + LocalFeatureKey::Language => publish(RoutingKey::Language), + LocalFeatureKey::Directionality => { if let Some(atom) = atom_of(old) { - publish(RoutingKey::ValueState(ValueStateKind::Directionality, atom)); + publish(RoutingKey::Directionality(atom)); } if let Some(atom) = atom_of(new) { - publish(RoutingKey::ValueState(ValueStateKind::Directionality, atom)); + publish(RoutingKey::Directionality(atom)); } } - FeatureKey::HeadingLevel => publish(RoutingKey::Structural), - FeatureKey::Emptiness => publish(RoutingKey::Structural), + LocalFeatureKey::HeadingLevel => publish(RoutingKey::Structural), + LocalFeatureKey::Emptiness => publish(RoutingKey::Structural), // The facts an arrival folded onto one key are read back off the element by the engine, // which has the fact store this function does not. - FeatureKey::ArrivingFacts => {} - FeatureKey::Attribute(name) => publish(RoutingKey::AttributeName(name)), + LocalFeatureKey::ArrivingFacts => {} + LocalFeatureKey::Attribute(name) => publish(RoutingKey::AttributeName(name)), } } diff --git a/Libraries/LibWeb/Rust/src/css/style/inputs.rs b/Libraries/LibWeb/Rust/src/css/style/inputs.rs index 7efa309324ae..54166be4e652 100644 --- a/Libraries/LibWeb/Rust/src/css/style/inputs.rs +++ b/Libraries/LibWeb/Rust/src/css/style/inputs.rs @@ -256,6 +256,7 @@ impl StyleEngine { } pub(super) fn add_routing_rule(&mut self, rule: RuleID, program: SelectorProgramID) { + self.programs.settle_memory(&mut self.memory); // A detached sheet's routes were shed, and reattachment restores the current routes of // every live rule in the sheet, so routes added for a rule edited while its sheet is // detached would come back twice. The exclusion covers the edit until the sheet reattaches. @@ -266,7 +267,7 @@ impl StyleEngine { return; } let routing = Rc::get_mut(&mut self.routing).expect("routing program is shared outside a planning epoch"); - routing.add_rule(rule, program, self.programs.get(program)); + routing.add_rule(rule, program, &self.programs); routing.settle_memory(&mut self.memory); } @@ -739,26 +740,26 @@ impl StyleEngine { pub(super) fn apply_to_facts_without_settling(&mut self, key: InputKey, new: InputValue) { match (key, new) { (InputKey::LocalFeature(node, feature), InputValue::Feature(value)) => match feature { - FeatureKey::TagName => { + LocalFeatureKey::TagName => { if let FeatureValue::Atom(atom) = value { self.facts.set_tag(node, atom, &mut self.memory); } } - FeatureKey::PartExposure => self.facts.set_part_exposure( + LocalFeatureKey::PartExposure => self.facts.set_part_exposure( node, match value { FeatureValue::Atom(atom) => atom, _ => StyleAtomID::NONE, }, ), - FeatureKey::Language => self.facts.set_language( + LocalFeatureKey::Language => self.facts.set_language( node, match value { FeatureValue::Atom(atom) => atom, _ => StyleAtomID::NONE, }, ), - FeatureKey::Directionality => self.facts.set_directionality( + LocalFeatureKey::Directionality => self.facts.set_directionality( node, match value { FeatureValue::Atom(atom) => atom, @@ -766,14 +767,14 @@ impl StyleEngine { }, &mut self.memory, ), - FeatureKey::HeadingLevel => self.facts.set_heading_level( + LocalFeatureKey::HeadingLevel => self.facts.set_heading_level( node, match value { FeatureValue::Number(level) => level as u8, _ => 0, }, ), - FeatureKey::FoldedTagName => self.facts.set_folded_tag( + LocalFeatureKey::FoldedTagName => self.facts.set_folded_tag( node, match value { FeatureValue::Atom(atom) => atom, @@ -783,11 +784,11 @@ impl StyleEngine { ), // Parts and custom states are published as complete sets after their individual // journal deltas have been recorded. Arrival is only a routing key. - FeatureKey::Part(_) | FeatureKey::CustomState(_) | FeatureKey::ArrivingFacts => {} + LocalFeatureKey::Part(_) | LocalFeatureKey::CustomState(_) | LocalFeatureKey::ArrivingFacts => {} // A text node is not a style node, so nothing in the tree can say it is there. // `Present` on this key means the element is empty. - FeatureKey::Emptiness => self.facts.set_has_text_content(node, !value.holds()), - FeatureKey::Id => self.facts.set_id( + LocalFeatureKey::Emptiness => self.facts.set_has_text_content(node, !value.holds()), + LocalFeatureKey::Id => self.facts.set_id( node, match value { FeatureValue::Atom(atom) => atom, @@ -795,8 +796,8 @@ impl StyleEngine { }, &mut self.memory, ), - FeatureKey::Class(class) => self.facts.set_class(node, class, value.holds(), &mut self.memory), - FeatureKey::Attribute(name) => { + LocalFeatureKey::Class(class) => self.facts.set_class(node, class, value.holds(), &mut self.memory), + LocalFeatureKey::Attribute(name) => { // The value's atom rides on the same delta. Presence is what routing reads; the // value is what an exact test compares, and a cold pass has no DOM to ask. let atom = match value { @@ -820,7 +821,7 @@ impl StyleEngine { // per element rather than one per fact. Routing reads the facts back off the element. self.counters.bump(Counter::ArrivingNodeFactsFolded); self.journal.record( - InputKey::LocalFeature(node, FeatureKey::ArrivingFacts), + InputKey::LocalFeature(node, LocalFeatureKey::ArrivingFacts), InputValue::Feature(FeatureValue::Absent), InputValue::Feature(FeatureValue::Present), &mut self.memory, @@ -849,7 +850,7 @@ impl StyleEngine { #[must_use] pub(super) fn node_whose_arrival_carries(&self, key: InputKey) -> Option { let node = match key { - InputKey::LocalFeature(node, feature) if feature != FeatureKey::ArrivingFacts => node, + InputKey::LocalFeature(node, feature) if feature != LocalFeatureKey::ArrivingFacts => node, InputKey::State(node, _) => node, _ => return None, }; @@ -886,18 +887,18 @@ impl StyleEngine { } } if !self.facts.language_of(node).is_none() { - keys.push(RoutingKey::ValueState(ValueStateKind::Language, StyleAtomID::NONE)); + keys.push(RoutingKey::Language); } let directionality = self.facts.directionality_of(node); if !directionality.is_none() { - keys.push(RoutingKey::ValueState(ValueStateKind::Directionality, directionality)); + keys.push(RoutingKey::Directionality(directionality)); } if let Some(row) = self.facts.primary().row_of(node) { for &part in self.facts.primary().parts_of(row) { keys.push(RoutingKey::Part(part)); } for &state in self.facts.primary().custom_states_of(row) { - keys.push(RoutingKey::ValueState(ValueStateKind::CustomState, state)); + keys.push(RoutingKey::CustomState(state)); } } for fact in self.facts.states_of_node(node).facts() { @@ -1104,23 +1105,19 @@ impl StyleEngine { if self.facts.parts_of(node) != parts { let previous: Vec = self.facts.parts_of(node).to_vec(); for part in previous.iter().filter(|part| !parts.contains(part)) { - self.facts - .postings_mut() - .remove(PostingKey::Selector(SelectorPostingKey::Part(*part)), node); + self.facts.postings_mut().remove(SelectorPostingKey::Part(*part), node); self.record_input( - InputKey::LocalFeature(node, FeatureKey::Part(*part)), + InputKey::LocalFeature(node, LocalFeatureKey::Part(*part)), InputValue::Feature(FeatureValue::Present), InputValue::Feature(FeatureValue::Absent), ); } for part in parts.iter().filter(|part| !previous.contains(part)) { - self.facts.postings_mut().insert( - PostingKey::Selector(SelectorPostingKey::Part(*part)), - node, - &mut self.memory, - ); + self.facts + .postings_mut() + .insert(SelectorPostingKey::Part(*part), node, &mut self.memory); self.record_input( - InputKey::LocalFeature(node, FeatureKey::Part(*part)), + InputKey::LocalFeature(node, LocalFeatureKey::Part(*part)), InputValue::Feature(FeatureValue::Absent), InputValue::Feature(FeatureValue::Present), ); @@ -1149,7 +1146,7 @@ impl StyleEngine { return; } self.record_input( - InputKey::LocalFeature(node, FeatureKey::PartExposure), + InputKey::LocalFeature(node, LocalFeatureKey::PartExposure), InputValue::Feature(Self::atom_or_absent(previous)), InputValue::Feature(Self::atom_or_absent(exposure)), ); @@ -1176,7 +1173,7 @@ impl StyleEngine { return; } self.record_input( - InputKey::LocalFeature(node, FeatureKey::HeadingLevel), + InputKey::LocalFeature(node, LocalFeatureKey::HeadingLevel), InputValue::Feature(FeatureValue::Number(u32::from(previous))), InputValue::Feature(FeatureValue::Number(u32::from(level))), ); @@ -1203,7 +1200,7 @@ impl StyleEngine { return; } self.record_input( - InputKey::LocalFeature(node, FeatureKey::Language), + InputKey::LocalFeature(node, LocalFeatureKey::Language), InputValue::Feature(Self::atom_or_absent(previous)), InputValue::Feature(Self::atom_or_absent(language)), ); @@ -1216,7 +1213,7 @@ impl StyleEngine { return; } self.record_input( - InputKey::LocalFeature(node, FeatureKey::Directionality), + InputKey::LocalFeature(node, LocalFeatureKey::Directionality), InputValue::Feature(Self::atom_or_absent(previous)), InputValue::Feature(Self::atom_or_absent(directionality)), ); @@ -1235,21 +1232,19 @@ impl StyleEngine { for state in previous.iter().filter(|state| !states.contains(state)) { self.facts .postings_mut() - .remove(PostingKey::Selector(SelectorPostingKey::CustomState(*state)), node); + .remove(SelectorPostingKey::CustomState(*state), node); self.record_input( - InputKey::LocalFeature(node, FeatureKey::CustomState(*state)), + InputKey::LocalFeature(node, LocalFeatureKey::CustomState(*state)), InputValue::Feature(FeatureValue::Present), InputValue::Feature(FeatureValue::Absent), ); } for state in states.iter().filter(|state| !previous.contains(state)) { - self.facts.postings_mut().insert( - PostingKey::Selector(SelectorPostingKey::CustomState(*state)), - node, - &mut self.memory, - ); + self.facts + .postings_mut() + .insert(SelectorPostingKey::CustomState(*state), node, &mut self.memory); self.record_input( - InputKey::LocalFeature(node, FeatureKey::CustomState(*state)), + InputKey::LocalFeature(node, LocalFeatureKey::CustomState(*state)), InputValue::Feature(FeatureValue::Absent), InputValue::Feature(FeatureValue::Present), ); @@ -1296,7 +1291,7 @@ impl StyleEngine { let programs = &self.programs; let routing = Rc::get_mut(&mut self.routing).expect("routing program is shared outside a planning epoch"); for (rule, program) in rules { - routing.add_rule(rule, program, programs.get(program)); + routing.add_rule(rule, program, programs); } routing.settle_memory(&mut self.memory); } @@ -1560,8 +1555,10 @@ impl StyleEngine { } let mut regions = Vec::new(); for key in keys { - let posting = posting_for_dispatch_key(key)?; - match self.facts.postings().lookup(posting) { + if !key.has_selector_posting() { + return None; + } + match self.facts.postings().lookup(key) { Lookup::Known(posting) => { for relative in posting.candidates() { regions.push(region(relative)); diff --git a/Libraries/LibWeb/Rust/src/css/style/matching.rs b/Libraries/LibWeb/Rust/src/css/style/matching.rs index dcc7fdb16290..cf0ae8a41cbf 100644 --- a/Libraries/LibWeb/Rust/src/css/style/matching.rs +++ b/Libraries/LibWeb/Rust/src/css/style/matching.rs @@ -29,7 +29,7 @@ impl StyleEngine { ) -> RetainedAnswerDeltaMemoKey { let mut hasher = fast_hash::fast_hasher(); for delta in deltas { - (delta.rule, delta.program, delta.entry, delta.change).hash(&mut hasher); + (delta.rule, delta.entry, delta.change).hash(&mut hasher); } RetainedAnswerDeltaMemoKey { old_answer, @@ -43,9 +43,11 @@ impl StyleEngine { entry: &RetainedAnswerDeltaMemoEntry, deltas: &[SelectorTruthDelta], ) -> bool { - entry.deltas.iter().copied().eq(deltas + entry + .deltas .iter() - .map(|delta| (delta.rule, delta.program, delta.entry, delta.change))) + .copied() + .eq(deltas.iter().map(|delta| (delta.rule, delta.entry, delta.change))) } fn remember_retained_answer_delta_transition( @@ -61,7 +63,7 @@ impl StyleEngine { entry.insert(RetainedAnswerDeltaMemoEntry { deltas: deltas .iter() - .map(|delta| (delta.rule, delta.program, delta.entry, delta.change)) + .map(|delta| (delta.rule, delta.entry, delta.change)) .collect(), transition, }); @@ -1480,7 +1482,10 @@ impl StyleEngine { } let mut incidences = Vec::new(); for (entry_index, entry) in compiled.entries().iter().enumerate() { - let posting_key = posting_for_dispatch_key(compiled.dispatch_key(entry))?; + let posting_key = compiled.dispatch_key(entry); + if !posting_key.has_selector_posting() { + return None; + } let candidates: Vec<_> = match self.facts.postings().lookup(posting_key) { Lookup::Known(posting) => posting.candidates().collect(), Lookup::KnownAbsent => continue, @@ -1494,10 +1499,10 @@ impl StyleEngine { } let mut carries_required = true; for required in subject_required { - let Some(required) = posting_for_dispatch_key(*required) else { + if !required.has_selector_posting() { continue; - }; - match self.facts.postings().lookup(required) { + } + match self.facts.postings().lookup(*required) { Lookup::Known(posting) => carries_required &= posting.contains(node), Lookup::KnownAbsent => carries_required = false, Lookup::Missing(_) => return None, @@ -1510,19 +1515,18 @@ impl StyleEngine { continue; } if entry.has_prefix_chain() - && dispatch.prefixes().contains_entry( + && dispatch.prefixes().contains_entry(self.programs.entry_id( program, u32::try_from(entry_index).expect("selector entry identity space exhausted"), - ) + )) { let retained_prefix_match = { let caches = self.prefix_caches.borrow(); caches.states.lookup(scope_program).sparse().ok().and_then(|states| { states.retained_matches_for(node).map(|matches| { - matches.iter().any(|&matched| { - let candidate = dispatch.entry(matched); - candidate.program == program && candidate.entry == entry_index as u32 - }) + matches + .iter() + .any(|&matched| matched == self.programs.entry_id(program, entry_index as u32)) }) }) }; @@ -1838,8 +1842,8 @@ impl StyleEngine { continue; } let keys = match input.key { - InputKey::LocalFeature(_, FeatureKey::PartExposure) => return None, - InputKey::TreeRelations(_) | InputKey::LocalFeature(_, FeatureKey::ArrivingFacts) => { + InputKey::LocalFeature(_, LocalFeatureKey::PartExposure) => return None, + InputKey::TreeRelations(_) | InputKey::LocalFeature(_, LocalFeatureKey::ArrivingFacts) => { // A subtree arriving, leaving, or moving cannot change a resident answer // through a descendant or child compound: a non-subject compound matching // inside the moved subtree puts the subject inside it too, and the plan @@ -1857,16 +1861,17 @@ impl StyleEngine { for &route in tree_mutation_routes { let rule = self.routing.rule_of(route); let point = self.routing.route(route); + let (program, _) = self.programs.entry_location(point.entry); if self.program.rule_can_decide(rule) - && self.program.rule_version(rule).selector_program == Some(point.program) + && self.program.rule_version(rule).selector_program == Some(program) { - affected_current_rules.insert((rule, point.program)); + affected_current_rules.insert((rule, program)); } } } continue; } - InputKey::LocalFeature(_, FeatureKey::Attribute(name)) => { + InputKey::LocalFeature(_, LocalFeatureKey::Attribute(name)) => { let mut keys = routing_keys_for_input(input); for other in self.facts.attribute_name_keys(name) { if other != name { @@ -1892,10 +1897,11 @@ impl StyleEngine { for &route in self.routing.routes_for(key) { let rule = self.routing.rule_of(route); let point = self.routing.route(route); + let (program, _) = self.programs.entry_location(point.entry); if self.program.rule_can_decide(rule) - && self.program.rule_version(rule).selector_program == Some(point.program) + && self.program.rule_version(rule).selector_program == Some(program) { - affected_current_rules.insert((rule, point.program)); + affected_current_rules.insert((rule, program)); } } } @@ -2145,9 +2151,7 @@ impl StyleEngine { } } for delta in deltas { - let Some(entry) = self.programs.get(delta.program).entries().get(delta.entry as usize) else { - return false; - }; + let entry = self.programs.entry(delta.entry).1; if !self.rule_has_complete_element_winners(delta.rule, entry) { return false; } @@ -2199,8 +2203,9 @@ impl StyleEngine { ) -> Option<(Vec, u64)> { if !patch.requires_full_match { for delta in deltas { - let entries = self.programs.get(delta.program).entries(); - let changed_entry = entries.get(delta.entry as usize)?; + let (program, selector_entry) = self.programs.entry_location(delta.entry); + let entries = self.programs.get(program).entries(); + let changed_entry = entries.get(selector_entry as usize)?; let retained_winner = retained.iter().find(|retained_entry| { retained_entry.rule == delta.rule && self @@ -2212,17 +2217,18 @@ impl StyleEngine { }); match delta.change { SetChange::Added - if retained_winner - .is_some_and(|winner| (winner.program, winner.entry) != (delta.program, delta.entry)) => + if retained_winner.is_some_and(|winner| { + self.programs.entry_id(winner.program, winner.entry) != delta.entry + }) => { return None; } SetChange::Removed - if retained_winner - .is_some_and(|winner| (winner.program, winner.entry) == (delta.program, delta.entry)) - && entries.iter().enumerate().any(|(index, entry)| { - index != delta.entry as usize && entry.pseudo_element == changed_entry.pseudo_element - }) => + if retained_winner.is_some_and(|winner| { + self.programs.entry_id(winner.program, winner.entry) == delta.entry + }) && entries.iter().enumerate().any(|(index, entry)| { + index != selector_entry as usize && entry.pseudo_element == changed_entry.pseudo_element + }) => { return None; } @@ -2231,7 +2237,8 @@ impl StyleEngine { } } - let retained_key = |entry: &RetainedRuleMatch| (entry.rule, entry.program, entry.entry); + let retained_key = |entry: &RetainedRuleMatch| (entry.rule, self.programs.entry_id(entry.program, entry.entry)); + debug_assert!(retained.is_sorted_by_key(retained_key)); let mut answer = Vec::with_capacity(retained.len().saturating_add(deltas.len())); let mut retained_index = 0; let mut delta_index = 0; @@ -2239,15 +2246,9 @@ impl StyleEngine { while delta_index < deltas.len() { let delta = deltas[delta_index]; debug_assert_eq!(delta.node, node); - let key = (delta.rule, delta.program, delta.entry); + let key = (delta.rule, delta.entry); let mut weight = 0_i32; - while delta_index < deltas.len() - && ( - deltas[delta_index].rule, - deltas[delta_index].program, - deltas[delta_index].entry, - ) == key - { + while delta_index < deltas.len() && (deltas[delta_index].rule, deltas[delta_index].entry) == key { weight += match deltas[delta_index].change { SetChange::Added => 1, SetChange::Removed => -1, @@ -2269,14 +2270,15 @@ impl StyleEngine { if held.is_some() { return None; } - let entry = self.programs.get(delta.program).entries().get(delta.entry as usize)?; + let (program, selector_entry) = self.programs.entry_location(delta.entry); + let entry = self.programs.get(program).entries().get(selector_entry as usize)?; if entry.scope_root.is_some() { return None; } answer.push(RetainedRuleMatch { rule: delta.rule, - program: delta.program, - entry: delta.entry, + program, + entry: selector_entry, tree_scope: TreeScopeID::DOCUMENT, scope_proximity: u32::MAX, }); @@ -2582,15 +2584,29 @@ impl StyleEngine { // its own narrowing. The filtered evaluation shrinks accordingly. let narrowed_keys: Option> = match truth_patch { SelectorTruthPatch::Full => None, - SelectorTruthPatch::Direct(deltas) => { - Some(deltas.iter().map(|delta| (delta.rule, delta.program)).collect()) - } + SelectorTruthPatch::Direct(deltas) => Some( + deltas + .iter() + .map(|delta| (delta.rule, self.programs.entry_location(delta.entry).0)) + .collect(), + ), SelectorTruthPatch::Refresh { deltas, refreshes } => Some(match delta_base.is_some() { - true => refreshes.iter().filter_map(|refresh| refresh.rule).collect(), + true => refreshes + .iter() + .filter_map(|refresh| { + refresh + .rule + .map(|(rule, entry)| (rule, self.programs.entry_location(entry).0)) + }) + .collect(), false => deltas .iter() - .map(|delta| (delta.rule, delta.program)) - .chain(refreshes.iter().filter_map(|refresh| refresh.rule)) + .map(|delta| (delta.rule, self.programs.entry_location(delta.entry).0)) + .chain(refreshes.iter().filter_map(|refresh| { + refresh + .rule + .map(|(rule, entry)| (rule, self.programs.entry_location(entry).0)) + })) .collect(), }), SelectorTruthPatch::Attributed { @@ -2600,14 +2616,30 @@ impl StyleEngine { } => Some(match delta_base.is_some() { true => refreshes .iter() - .filter_map(|refresh| refresh.rule) - .chain(rules.iter().copied()) + .filter_map(|refresh| { + refresh + .rule + .map(|(rule, entry)| (rule, self.programs.entry_location(entry).0)) + }) + .chain( + rules + .iter() + .map(|&(rule, entry)| (rule, self.programs.entry_location(entry).0)), + ) .collect(), false => deltas .iter() - .map(|delta| (delta.rule, delta.program)) - .chain(refreshes.iter().filter_map(|refresh| refresh.rule)) - .chain(rules.iter().copied()) + .map(|delta| (delta.rule, self.programs.entry_location(delta.entry).0)) + .chain(refreshes.iter().filter_map(|refresh| { + refresh + .rule + .map(|(rule, entry)| (rule, self.programs.entry_location(entry).0)) + })) + .chain( + rules + .iter() + .map(|&(rule, entry)| (rule, self.programs.entry_location(entry).0)), + ) .collect(), }), } @@ -2685,7 +2717,7 @@ impl StyleEngine { // A refresh is a typed request for exact old/new truth, not an alternate match-answer // update path. Turn the repaired relation into signed entry deltas and apply those through // the same authoritative operator as routes which had complete facts during planning. - let repair_deltas = repaired_selector_truth_deltas(node, &retained, &mut patched_answer); + let repair_deltas = repaired_selector_truth_deltas(node, &retained, &mut patched_answer, &self.programs); if let Some((repair_deltas, changed)) = repair_deltas.as_deref().and_then(|repair_deltas| { self.apply_retained_match_answer_deltas( node, diff --git a/Libraries/LibWeb/Rust/src/css/style/mod.rs b/Libraries/LibWeb/Rust/src/css/style/mod.rs index fa37f7d98e39..27c1f1d368d8 100644 --- a/Libraries/LibWeb/Rust/src/css/style/mod.rs +++ b/Libraries/LibWeb/Rust/src/css/style/mod.rs @@ -208,12 +208,11 @@ use impact::TransactionTopology; use impact::choose_plan; use index::DependencyPostingKey; use index::DispatchCandidateWorkspace; -use index::DispatchEntryID; use index::DispatchKey; use index::ElementFactStore; -use index::FeatureKey; use index::FeaturePostings; use index::FeatureValue; +use index::LocalFeatureKey; use index::PostingKey; use index::RuleDispatch; use index::SelectorPostingKey; @@ -239,6 +238,7 @@ use prefix::PrefixTransitionLookup; use program::CascadeLayerID; use program::DeclarationBlockID; use program::DeclaredProperty; +use program::EntryID; use program::RuleID; use program::RuleKind; use program::RuleVersion; @@ -272,7 +272,6 @@ use selector::SelectorPrograms; use selector::SiblingSequenceGeometry; use selector::Specificity; use selector::SubjectPosition; -use selector::ValueStateKind; use specified_value::SpecifiedValues; use transaction::ElementDeclarationKind; use transaction::InputKey; @@ -403,21 +402,6 @@ fn anchor_region_for(axis: RelativeAxis) -> Option ImpactRegi } } -fn posting_for_dispatch_key(key: DispatchKey) -> Option { - match key { - DispatchKey::Part(atom) => Some(PostingKey::Selector(SelectorPostingKey::Part(atom))), - DispatchKey::CustomState(atom) => Some(PostingKey::Selector(SelectorPostingKey::CustomState(atom))), - DispatchKey::Id(atom) => Some(PostingKey::Selector(SelectorPostingKey::Id(atom))), - DispatchKey::Class(atom) => Some(PostingKey::Selector(SelectorPostingKey::Class(atom))), - DispatchKey::AttributeName(atom) => Some(PostingKey::Selector(SelectorPostingKey::AttributeName(atom))), - DispatchKey::TagName(atom) => Some(PostingKey::Selector(SelectorPostingKey::Tag(atom))), - DispatchKey::Directionality(atom) => Some(PostingKey::Selector(SelectorPostingKey::Directionality(atom))), - // These have no posting to enumerate from, so a caller that needs one widens, exactly as it - // does for the universal bucket these used to sit in. - DispatchKey::Root | DispatchKey::State(_) | DispatchKey::Heading | DispatchKey::Universal => None, - } -} - /// Which way a child sequence changed for one element. #[derive(Clone, Copy, PartialEq, Eq)] enum SequenceSide { diff --git a/Libraries/LibWeb/Rust/src/css/style/ordering.rs b/Libraries/LibWeb/Rust/src/css/style/ordering.rs index fb026a98083e..42e9a3bf1af4 100644 --- a/Libraries/LibWeb/Rust/src/css/style/ordering.rs +++ b/Libraries/LibWeb/Rust/src/css/style/ordering.rs @@ -731,9 +731,7 @@ impl StyleEngine { ) -> bool { let mut targets = Vec::new(); for delta in deltas { - let Some(entry) = self.programs.get(delta.program).entries().get(delta.entry as usize) else { - return false; - }; + let entry = self.programs.entry(delta.entry).1; if !targets.contains(&entry.pseudo_element) { targets.push(entry.pseudo_element); } @@ -765,15 +763,7 @@ impl StyleEngine { let mut repair_properties = Vec::new(); let mut updates = Vec::new(); for delta in deltas { - let Some(entry) = self - .programs - .get(delta.program) - .entries() - .get(delta.entry as usize) - .copied() - else { - return false; - }; + let entry = *self.programs.entry(delta.entry).1; if entry.pseudo_element != pseudo { continue; } @@ -793,7 +783,7 @@ impl StyleEngine { continue; } let Some(matched) = matches.iter().find(|matched| { - matched.rule == delta.rule && matched.program == delta.program && matched.entry == delta.entry + matched.rule == delta.rule && self.programs.entry_id(matched.program, matched.entry) == delta.entry }) else { return false; }; @@ -1251,7 +1241,7 @@ impl StyleEngine { if excluded_sheets.contains(&self.program.rule_sheet(rule)) { continue; } - rebuilt_routing.add_rule(rule, program, self.programs.get(program)); + rebuilt_routing.add_rule(rule, program, &self.programs); } let mut previous_routing = std::mem::replace(&mut self.routing, Rc::new(rebuilt_routing)); Rc::get_mut(&mut previous_routing) @@ -1291,7 +1281,7 @@ impl StyleEngine { excluded_sheets.insert(sheet); continue; } - rebuilt_routing.add_rule(rule, program, self.programs.get(program)); + rebuilt_routing.add_rule(rule, program, &self.programs); } self.sheets_excluded_from_routing = excluded_sheets; let mut previous_routing = std::mem::replace(&mut self.routing, Rc::new(rebuilt_routing)); diff --git a/Libraries/LibWeb/Rust/src/css/style/planning.rs b/Libraries/LibWeb/Rust/src/css/style/planning.rs index b41b2f077f58..478de5c81ddb 100644 --- a/Libraries/LibWeb/Rust/src/css/style/planning.rs +++ b/Libraries/LibWeb/Rust/src/css/style/planning.rs @@ -5,11 +5,11 @@ */ use super::capacity::capacity_bytes; -use super::column::Column; use super::column::EpochColumn; use super::column::PagedColumn; use super::column::PagedColumnPage; use super::column::advance_epoch; +use super::program::EntryID; use super::sorted_merge::SortedMergeEntry; use super::sorted_merge::merge_sorted_by; use super::*; @@ -32,20 +32,42 @@ pub(super) enum RetainedWinnerProbe { AllResident { resident_count: usize }, } +pub(super) struct RemainingPosting { + nodes: Vec, + plan_generation: u64, + pruned_nodes: Vec, +} + +#[derive(Default)] +pub(super) struct RemainingPostingDirectory { + entries: Vec<(PostingKey, RemainingPosting)>, +} + +impl RemainingPostingDirectory { + fn entry(&mut self, key: PostingKey) -> Option<&mut RemainingPosting> { + if let Some(index) = self.entries.iter().position(|(candidate, _)| *candidate == key) { + return Some(&mut self.entries[index].1); + } + None + } + + fn capacity_bytes(&self) -> u64 { + (self.entries.capacity() * size_of::<(PostingKey, RemainingPosting)>()) as u64 + } + + fn insert(&mut self, key: PostingKey, posting: RemainingPosting) -> &mut RemainingPosting { + self.entries.push((key, posting)); + &mut self.entries.last_mut().unwrap().1 + } +} + pub(super) struct ImpactPlanningWorkspace { pub(super) batches: HashMap, Rc>, // Fact postings cannot change while one transaction is being planned, and the exact-node plan // only grows. Removing planned nodes here is therefore permanent for the lifetime of this // workspace: later routes see the posting members that can still contribute, not the same // already-planned prefix over and over. - pub(super) remaining_postings: Column>>, - // The exact-node plan generation each remaining posting was last filtered against. Routes - // commonly share a posting without adding a node between them; those routes can consume the - // already-filtered posting without rescanning every member. - pub(super) remaining_posting_plan_generations: Column, - // The nodes each remaining posting has dropped as already planned. A later route consuming - // the cached posting never sees them, so it reports its rule against this list instead. - pub(super) pruned_postings: Column>, + pub(super) remaining_postings: RemainingPostingDirectory, pub(super) memory: MemoryLease, pub(super) nested_memory: MemoryLease, } @@ -54,9 +76,7 @@ impl Default for ImpactPlanningWorkspace { fn default() -> Self { Self { batches: HashMap::default(), - remaining_postings: Column::default(), - remaining_posting_plan_generations: Column::new(|| u64::MAX), - pruned_postings: Column::default(), + remaining_postings: RemainingPostingDirectory::default(), memory: MemoryLease::new(MemoryCategory::BatchScratch), nested_memory: MemoryLease::new(MemoryCategory::BatchScratch), } @@ -84,8 +104,7 @@ pub(super) fn exact_entry_changed(result: ExactEntryResult) -> bool { pub(super) struct SelectorTruthDelta { pub(super) node: StyleNodeID, pub(super) rule: RuleID, - pub(super) program: SelectorProgramID, - pub(super) entry: u32, + pub(super) entry: EntryID, pub(super) change: SetChange, /// Activation changes the active-match join, not selector truth itself. pub(super) selector_truth_changed: bool, @@ -94,13 +113,13 @@ pub(super) struct SelectorTruthDelta { #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(super) struct SelectorTruthRefresh { pub(super) node: StyleNodeID, - pub(super) rule: Option<(RuleID, SelectorProgramID)>, + pub(super) rule: Option<(RuleID, EntryID)>, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(super) struct AlreadyPlannedSelectorTruthCandidate { pub(super) node: StyleNodeID, - pub(super) exact_entry: Option<(RuleID, SelectorProgramID, u32)>, + pub(super) exact_entry: Option<(RuleID, EntryID)>, pub(super) exact_tree_evaluation: Option, } @@ -182,7 +201,7 @@ pub(super) enum SelectorTruthPatch<'a> { Attributed { deltas: &'a [SelectorTruthDelta], refreshes: &'a [SelectorTruthRefresh], - rules: &'a [(RuleID, SelectorProgramID)], + rules: &'a [(RuleID, EntryID)], }, } @@ -214,11 +233,7 @@ impl StyleEngine { } /// Record that a route needs exact selector truth refreshed for a planned node. - pub(super) fn record_selector_truth_refresh( - &mut self, - node: StyleNodeID, - rule: Option<(RuleID, SelectorProgramID)>, - ) { + pub(super) fn record_selector_truth_refresh(&mut self, node: StyleNodeID, rule: Option<(RuleID, EntryID)>) { if self.selector_truth_changes_active { self.selector_truth_changes .refreshes @@ -235,7 +250,7 @@ impl StyleEngine { if !self.selector_truth_changes_active { return; } - let Some((rule, program, entry)) = site.exact_entry else { + let Some((rule, entry)) = site.exact_entry else { self.record_selector_truth_refresh(node, site.refresh_rule); return; }; @@ -244,14 +259,13 @@ impl StyleEngine { self.selector_truth_changes.deltas.push(SelectorTruthDelta { node, rule, - program, entry, change: kind, selector_truth_changed: true, }); } Lookup::KnownAbsent => {} - Lookup::Missing(_) => self.record_selector_truth_refresh(node, Some((rule, program))), + Lookup::Missing(_) => self.record_selector_truth_refresh(node, Some((rule, entry))), } } @@ -324,23 +338,28 @@ pub(super) fn record_match_set_difference( changes: &mut SelectorTruthChanges, active: bool, node: StyleNodeID, - old_matches: &[DispatchEntryID], - new_matches: &[DispatchEntryID], + old_matches: &[EntryID], + new_matches: &[EntryID], dispatch: &RuleDispatch, ) { if !active { return; } - let mut record = |entry: DispatchEntryID, kind| { - let candidate = &dispatch.entries()[entry.index()]; - changes.deltas.push(SelectorTruthDelta { - node, - rule: candidate.rule, - program: candidate.program, - entry: candidate.entry, - change: kind, - selector_truth_changed: true, - }); + let mut record = |entry: EntryID, kind| { + let mut previous_rule = None; + for candidate in dispatch.entries_for_identity(entry) { + if previous_rule == Some(candidate.rule) { + continue; + } + previous_rule = Some(candidate.rule); + changes.deltas.push(SelectorTruthDelta { + node, + rule: candidate.rule, + entry, + change: kind, + selector_truth_changed: true, + }); + } }; debug_assert!(old_matches.is_sorted()); debug_assert!(new_matches.is_sorted()); @@ -358,6 +377,7 @@ pub(super) fn repaired_selector_truth_deltas( node: StyleNodeID, old_matches: &[RetainedRuleMatch], new_matches: &mut [RuleMatch], + programs: &SelectorPrograms, ) -> Option> { const LINEAR_SEARCH_COMPARISONS: usize = 16; let retained_key = |entry: &RetainedRuleMatch| (entry.rule, entry.program, entry.entry); @@ -386,8 +406,7 @@ pub(super) fn repaired_selector_truth_deltas( deltas.push(SelectorTruthDelta { node, rule: old.rule, - program: old.program, - entry: old.entry, + entry: programs.entry_id(old.program, old.entry), change: SetChange::Removed, selector_truth_changed: true, }); @@ -396,8 +415,7 @@ pub(super) fn repaired_selector_truth_deltas( deltas.push(SelectorTruthDelta { node, rule: new.rule, - program: new.program, - entry: new.entry, + entry: programs.entry_id(new.program, new.entry), change: SetChange::Added, selector_truth_changed: true, }); @@ -419,8 +437,7 @@ pub(super) fn repaired_selector_truth_deltas( deltas.push(SelectorTruthDelta { node, rule: entry.rule, - program: entry.program, - entry: entry.entry, + entry: programs.entry_id(entry.program, entry.entry), change: SetChange::Removed, selector_truth_changed: true, }); @@ -434,8 +451,7 @@ pub(super) fn repaired_selector_truth_deltas( deltas.push(SelectorTruthDelta { node, rule: entry.rule, - program: entry.program, - entry: entry.entry, + entry: programs.entry_id(entry.program, entry.entry), change: SetChange::Added, selector_truth_changed: true, }); @@ -463,18 +479,11 @@ impl SelectorTruthChanges { let mut write = 0; while read < deltas.len() { let first = deltas[read]; - let key = (first.node, first.rule, first.program, first.entry); + let key = (first.node, first.rule, first.entry); let mut added = false; let mut removed = false; let mut selector_truth_changed = false; - while read < deltas.len() - && ( - deltas[read].node, - deltas[read].rule, - deltas[read].program, - deltas[read].entry, - ) == key - { + while read < deltas.len() && (deltas[read].node, deltas[read].rule, deltas[read].entry) == key { match deltas[read].change { SetChange::Added => added = true, SetChange::Removed => removed = true, @@ -500,7 +509,6 @@ impl SelectorTruthChanges { deltas[write] = SelectorTruthDelta { node: first.node, rule: first.rule, - program: first.program, entry: first.entry, change, selector_truth_changed, @@ -552,12 +560,9 @@ impl ImpactPlanningWorkspace { capacity_bytes! { shallow [ self.batches, - self.remaining_postings, - self.remaining_posting_plan_generations, - self.pruned_postings, ]; cached [self.nested_memory.bytes()]; - nested []; + nested [self.remaining_postings.capacity_bytes()]; skip [self.memory]; } } @@ -601,29 +606,33 @@ impl ImpactPlanningWorkspace { Lookup::KnownAbsent => return Ok((0, false, 0, 0, 0)), Lookup::Missing(gap) => return Err(gap), }; - let id = posting.id().index(); - self.remaining_postings.ensure(id); - self.pruned_postings.ensure(id); - self.remaining_posting_plan_generations.ensure(id); - let was_present = self.remaining_postings[id].is_some(); + let was_present = self.remaining_postings.entry(key).is_some(); let copied = if !was_present { posting.len() } else { 0 }; - if !was_present { + let remaining = if was_present { + self.remaining_postings.entry(key).unwrap() + } else { let candidates: Vec = posting.candidates().collect(); self.nested_memory .grow_committed((candidates.capacity() * size_of::()) as u64); - self.remaining_postings[id] = Some(candidates); - } - - let posting = self.remaining_postings[id].as_mut().unwrap(); + self.remaining_postings.insert( + key, + RemainingPosting { + nodes: candidates, + plan_generation: u64::MAX, + pruned_nodes: Vec::new(), + }, + ) + }; + let posting = &mut remaining.nodes; let mut inspected = 0; let mut pruned = 0; let plan_generation = plan.exact_node_generation(); - if self.remaining_posting_plan_generations[id] != plan_generation { - let pruned_this_call = &mut self.pruned_postings[id]; + if remaining.plan_generation != plan_generation { + let pruned_this_call = &mut remaining.pruned_nodes; let pruned_capacity_before = pruned_this_call.capacity(); let previous_pruned_length = pruned_this_call.len(); let point_removed = plan.for_each_exact_node_added_after( - self.remaining_posting_plan_generations[id], + remaining.plan_generation, MAX_POINT_REMOVED_EXACT_NODES, |node| { if let Ok(index) = posting.binary_search(&node) { @@ -646,7 +655,7 @@ impl ImpactPlanningWorkspace { }); pruned = previous_length - posting.len(); } - self.remaining_posting_plan_generations[id] = plan_generation; + remaining.plan_generation = plan_generation; self.nested_memory.grow_committed( ((pruned_this_call.capacity() - pruned_capacity_before) * size_of::()) as u64, ); @@ -655,7 +664,7 @@ impl ImpactPlanningWorkspace { // an earlier route are invisible to this one too. Without a consumer the history is // still kept, but nothing is copied or walked. if let Some(pruned_nodes) = pruned_nodes { - pruned_nodes.extend_from_slice(&self.pruned_postings[id]); + pruned_nodes.extend_from_slice(&remaining.pruned_nodes); } candidates.extend_from_slice(posting); Ok(( @@ -1179,9 +1188,9 @@ impl SiblingEntry { path: routing.path_of(self.route), waypoints: routing.waypoints_of(self.route), in_flux: None, - exact_entry: exact_tree_evaluation.map(|_| (routing.rule_of(self.route), point.program, point.entry)), + exact_entry: exact_tree_evaluation.map(|_| (routing.rule_of(self.route), point.entry)), exact_tree_evaluation, - refresh_rule: Some((routing.rule_of(self.route), point.program)), + refresh_rule: Some((routing.rule_of(self.route), point.entry)), } } @@ -1210,9 +1219,9 @@ impl SiblingEntry { path: &path[1..], waypoints, in_flux: None, - exact_entry: Some((routing.rule_of(self.route), point.program, point.entry)), + exact_entry: Some((routing.rule_of(self.route), point.entry)), exact_tree_evaluation, - refresh_rule: Some((routing.rule_of(self.route), point.program)), + refresh_rule: Some((routing.rule_of(self.route), point.entry)), } } } @@ -1301,10 +1310,7 @@ impl SequenceEntryIndex { // A relative positional input is a possible witness, so its originating compound // cannot reject it from the final tree alone. Non-resident dispatch keys likewise // cannot be used to enumerate all possible origins. - if point.anchor.is_some() - || origin.is_empty() - || origin.iter().any(|&key| posting_for_dispatch_key(key).is_none()) - { + if point.anchor.is_some() || origin.is_empty() || origin.iter().any(|key| !key.has_selector_posting()) { group.unindexed.push(entry_index); continue; } @@ -1352,8 +1358,8 @@ impl SequenceEntry { in_flux: None, exact_entry: self .can_compare_exactly - .then_some((routing.rule_of(self.route), point.program, point.entry)), - refresh_rule: Some((routing.rule_of(self.route), point.program)), + .then_some((routing.rule_of(self.route), point.entry)), + refresh_rule: Some((routing.rule_of(self.route), point.entry)), exact_tree_evaluation: self .can_compare_exactly .then_some(ExactTreeEvaluation::BeforeSiblingRelations), @@ -1373,7 +1379,7 @@ pub(super) fn relational_route_site(routing: &RoutingRegistry, route: RouteID) - in_flux: None, exact_entry: None, exact_tree_evaluation: None, - refresh_rule: Some((routing.rule_of(route), point.program)), + refresh_rule: Some((routing.rule_of(route), point.entry)), } } @@ -1390,21 +1396,19 @@ pub(super) struct RoutingSite<'a> { pub(super) path: &'a [InverseStep], pub(super) waypoints: &'a [DispatchKey], pub(super) in_flux: Option<(StyleNodeID, DispatchKey)>, - pub(super) exact_entry: Option<(RuleID, SelectorProgramID, u32)>, + pub(super) exact_entry: Option<(RuleID, EntryID)>, pub(super) exact_tree_evaluation: Option, /// The one rule this route can move when it cannot name an exact entry, for refresh and /// patch-cover attribution. A route that knows neither poisons covered answers to full /// re-derivation. - pub(super) refresh_rule: Option<(RuleID, SelectorProgramID)>, + pub(super) refresh_rule: Option<(RuleID, EntryID)>, } impl RoutingSite<'_> { /// The rule this route attributes its coverage to, from the exact entry when it has one. #[must_use] - pub(super) fn attribution(&self) -> Option<(RuleID, SelectorProgramID)> { - self.exact_entry - .map(|(rule, program, _)| (rule, program)) - .or(self.refresh_rule) + pub(super) fn attribution(&self) -> Option<(RuleID, EntryID)> { + self.exact_entry.or(self.refresh_rule) } } diff --git a/Libraries/LibWeb/Rust/src/css/style/prefix.rs b/Libraries/LibWeb/Rust/src/css/style/prefix.rs index 34ade68760cf..09501786d093 100644 --- a/Libraries/LibWeb/Rust/src/css/style/prefix.rs +++ b/Libraries/LibWeb/Rust/src/css/style/prefix.rs @@ -30,7 +30,6 @@ use super::ScopeProgramID; use super::column::Column; use super::column::EpochColumn; use super::column::advance_epoch; -use super::index::DispatchEntryID; use super::index::DispatchKey; use super::index::StyleNodeFacts; use super::instrumentation::Counter; @@ -40,6 +39,7 @@ use super::memory::MemoryCategory; use super::memory::MemoryController; use super::memory::MemoryLease; use super::partial_view::Lookup; +use super::program::EntryID; use super::program::SelectorProgramID; use super::selector::AttributeOperator; use super::selector::FeatureTest; @@ -75,8 +75,7 @@ impl PrefixProducerCache { &mut self, route: RouteID, prefixes: &PrefixAutomaton, - program: SelectorProgramID, - entry: u32, + entry: EntryID, inverse_path_length: usize, ) -> &[PrefixProducer] { if self.ranges.len() <= route.index() { @@ -84,7 +83,7 @@ impl PrefixProducerCache { } let range = self.ranges[route.index()].get_or_insert_with(|| { let start = u32::try_from(self.producers.len()).expect("prefix producer space exhausted"); - prefixes.append_route_producers(program, entry, inverse_path_length, &mut self.producers); + prefixes.append_route_producers(entry, inverse_path_length, &mut self.producers); let end = u32::try_from(self.producers.len()).expect("prefix producer space exhausted"); start..end }); @@ -197,7 +196,7 @@ struct PrefixStepOutputBuilder { descendant_successors: Vec, adjacent_successors: Vec, following_successors: Vec, - terminals: Vec, + terminals: Vec, } #[derive(Clone, Copy)] @@ -219,13 +218,13 @@ struct PrefixOutput { #[derive(Clone)] struct PrefixEntryPath { - terminal: DispatchEntryID, + terminal: EntryID, steps: Box<[PrefixStepID]>, } #[derive(Clone)] struct PrefixEntryPaths { - key: (SelectorProgramID, u32), + key: EntryID, paths: Vec, } @@ -250,7 +249,7 @@ pub(super) struct PrefixAutomaton { /// Runtime lookup is a packed immutable table sorted by selector entry. entry_paths: Vec, /// Builder-only index discarded when the immutable table is finished. - entry_path_indices: HashMap<(SelectorProgramID, u32), usize>, + entry_path_indices: HashMap, entry_paths_finished: bool, /// The producer of each non-root step, retained in the storage formerly used by the finished /// dispatch-order builder so warm removal edits can find shadowing local output in O(1). @@ -312,9 +311,9 @@ impl PrefixAutomaton { let mut builder = PrefixStepOutputBuilder::default(); for output in self.outputs_for(step) { match output.kind { - PrefixOutputKind::UniqueTerminal | PrefixOutputKind::SharedTerminal => builder - .terminals - .push(DispatchEntryID::from_index(output.target as usize)), + PrefixOutputKind::UniqueTerminal | PrefixOutputKind::SharedTerminal => { + builder.terminals.push(EntryID(output.target)); + } PrefixOutputKind::Child => builder.child_successors.push(PrefixStepID(output.target)), PrefixOutputKind::Descendant => builder.descendant_successors.push(PrefixStepID(output.target)), PrefixOutputKind::NextSibling => builder.adjacent_successors.push(PrefixStepID(output.target)), @@ -372,9 +371,8 @@ impl PrefixAutomaton { &mut self, programs: &SelectorPrograms, program_id: SelectorProgramID, - selector_entry: u32, chain: &[SelectorPrefixStep], - entry: DispatchEntryID, + entry: EntryID, structural_tests_admissible: bool, ) -> bool { assert!(!self.entry_paths_finished, "cannot add to a finished prefix automaton"); @@ -407,6 +405,9 @@ impl PrefixAutomaton { if self.positional_tests.len() + new_positional_tests.len() > 32 { return false; } + if self.entry_path_indices.contains_key(&entry) { + return true; + } if chain.iter().any(|step| { matches!( step.axis, @@ -528,7 +529,7 @@ impl PrefixAutomaton { self.step_output_builders[terminal_step.0 as usize] .terminals .push(entry); - let key = (program_id, selector_entry); + let key = entry; let index = match self.entry_path_indices.get(&key).copied() { Some(index) => index, None => { @@ -623,7 +624,7 @@ impl PrefixAutomaton { bucket.end_step = order + 1; } assert_eq!(self.steps.len(), self.step_output_builders.len()); - let mut terminal_producers = HashMap::::default(); + let mut terminal_producers = HashMap::::default(); for (step_index, builder) in self.step_output_builders.iter().enumerate() { let step_index = u32::try_from(step_index).expect("selector prefix step space exhausted"); for &terminal in &builder.terminals { @@ -646,7 +647,7 @@ impl PrefixAutomaton { step.output_start = u32::try_from(self.outputs.len()).expect("selector prefix output space exhausted"); self.outputs .extend(builder.terminals.into_iter().map(|terminal| PrefixOutput { - target: u32::try_from(terminal.index()).expect("dispatch entry space exhausted"), + target: terminal.0, kind: match terminal_producers[&terminal] == step_index { true => PrefixOutputKind::UniqueTerminal, false => PrefixOutputKind::SharedTerminal, @@ -718,19 +719,19 @@ impl PrefixAutomaton { &self.features[start..start + len as usize] } - fn paths_for(&self, key: (SelectorProgramID, u32)) -> Option<&[PrefixEntryPath]> { + fn paths_for(&self, key: EntryID) -> Option<&[PrefixEntryPath]> { assert!(self.entry_paths_finished, "cannot query an unfinished prefix automaton"); let index = self.entry_paths.binary_search_by_key(&key, |entry| entry.key).ok()?; Some(&self.entry_paths[index].paths) } - pub(super) fn contains_entry(&self, program: SelectorProgramID, entry: u32) -> bool { - self.paths_for((program, entry)).is_some() + pub(super) fn contains_entry(&self, entry: EntryID) -> bool { + self.paths_for(entry).is_some() } pub(super) fn select_entries( &self, - entries: impl IntoIterator, + entries: impl IntoIterator, terminal_count: usize, ) -> PrefixSelection { let mut selection = PrefixSelection { @@ -742,7 +743,7 @@ impl PrefixAutomaton { continue; }; for path in paths { - selection.terminals[path.terminal.index()] = true; + selection.terminals[path.terminal.0 as usize] = true; for step in &path.steps { if !selection.steps[step.0 as usize] { selection.steps[step.0 as usize] = true; @@ -755,12 +756,11 @@ impl PrefixAutomaton { pub(super) fn append_route_producers( &self, - program: SelectorProgramID, - entry: u32, + entry: EntryID, inverse_path_length: usize, into: &mut Vec, ) -> bool { - let Some(paths) = self.paths_for((program, entry)) else { + let Some(paths) = self.paths_for(entry) else { return false; }; let mut found = false; @@ -811,7 +811,7 @@ impl PrefixAutomaton { + builder.adjacent_successors.capacity() + builder.following_successors.capacity()) * size_of::() - + builder.terminals.capacity() * size_of::() + + builder.terminals.capacity() * size_of::() }) .sum::(), self @@ -842,8 +842,8 @@ impl PrefixSelection { self.steps[step.0 as usize] } - fn contains_terminal(&self, terminal: DispatchEntryID) -> bool { - self.terminals[terminal.index()] + fn contains_terminal(&self, terminal: EntryID) -> bool { + self.terminals[terminal.0 as usize] } pub(super) fn capacity_bytes(&self) -> u64 { @@ -1031,7 +1031,7 @@ pub(super) struct PrefixStates { states_by_hash: HashMap<(u32, u64, u32, u32), PrefixStateCandidates>, states_by_hash_collision_bytes: u64, match_offsets: Vec, - match_entries: Vec, + match_entries: Vec, truth_offsets: Vec, truth_steps: Vec, truth_sets_by_hash: super::intern_table::InternTable, @@ -1061,7 +1061,7 @@ pub(super) struct PrefixStates { candidates: Vec, compare_left: Vec, compare_right: Vec, - output_matches: Vec, + output_matches: Vec, output_matched_steps: Vec, new_descendant: Vec, new_child: Vec, @@ -1182,9 +1182,9 @@ struct PrefixLocalOutputDeltas { pub(super) struct PrefixDeltaArena { steps: Vec, deltas: Vec, - matches: Vec, + matches: Vec, scratch: [Vec; 8], - match_scratch: [Vec; 2], + match_scratch: [Vec; 2], signed_scratch: Vec<(PrefixStepID, i8)>, } @@ -1203,7 +1203,7 @@ impl PrefixDeltaArena { &self.steps[start..start + span.len as usize] } - fn append_matches(&mut self, matches: &[DispatchEntryID]) -> PrefixMatchSpan { + fn append_matches(&mut self, matches: &[EntryID]) -> PrefixMatchSpan { let start = u32::try_from(self.matches.len()).expect("selector prefix match delta arena overflow"); self.matches.extend_from_slice(matches); PrefixMatchSpan { @@ -1212,7 +1212,7 @@ impl PrefixDeltaArena { } } - fn get_matches(&self, span: PrefixMatchSpan) -> &[DispatchEntryID] { + fn get_matches(&self, span: PrefixMatchSpan) -> &[EntryID] { let start = span.start as usize; &self.matches[start..start + span.len as usize] } @@ -1366,7 +1366,7 @@ impl PrefixDeltaArena { cached []; nested [ self.scratch.iter().map(Vec::capacity).sum::() * size_of::(), - self.match_scratch.iter().map(Vec::capacity).sum::() * size_of::(), + self.match_scratch.iter().map(Vec::capacity).sum::() * size_of::(), ]; skip []; } @@ -1572,14 +1572,14 @@ impl PrefixStates { } } - pub(super) fn matches_in(&self, matches: PrefixMatchSetID) -> &[DispatchEntryID] { + pub(super) fn matches_in(&self, matches: PrefixMatchSetID) -> &[EntryID] { let index = matches.0 as usize; &self.match_entries[self.match_offsets[index] as usize..self.match_offsets[index + 1] as usize] } /// Read the exact terminal matches already retained for one element without extending the /// prefix relation. A missing transition is not an empty answer. - pub(super) fn retained_matches_for(&self, node: StyleNodeID) -> Option<&[DispatchEntryID]> { + pub(super) fn retained_matches_for(&self, node: StyleNodeID) -> Option<&[EntryID]> { match self.transition_of(node) { PrefixTransitionLookup::Known(transition) => Some(self.matches_in(self.matches_of(transition))), PrefixTransitionLookup::Missing(_) => None, @@ -2062,7 +2062,7 @@ impl PrefixStates { outputs_changed = true; match output.kind { PrefixOutputKind::UniqueTerminal => { - let terminal = DispatchEntryID::from_index(output.target as usize); + let terminal = EntryID(output.target); if evaluation .selection .is_none_or(|selection| selection.contains_terminal(terminal)) @@ -2072,7 +2072,7 @@ impl PrefixStates { } } PrefixOutputKind::SharedTerminal => { - let terminal = DispatchEntryID::from_index(output.target as usize); + let terminal = EntryID(output.target); if evaluation .selection .is_none_or(|selection| selection.contains_terminal(terminal)) @@ -2788,7 +2788,7 @@ impl PrefixStates { for output in automaton.outputs_for(step) { match output.kind { PrefixOutputKind::UniqueTerminal | PrefixOutputKind::SharedTerminal => { - let terminal = DispatchEntryID::from_index(output.target as usize); + let terminal = EntryID(output.target); if evaluation .selection .is_none_or(|selection| selection.contains_terminal(terminal)) @@ -3005,8 +3005,8 @@ impl PrefixStates { } } - fn admit_match(&mut self, entry: DispatchEntryID) { - let index = entry.index(); + fn admit_match(&mut self, entry: EntryID) { + let index = entry.0 as usize; if self.match_epoch.mark(index, self.epoch) { self.output_matches.push(entry); } @@ -3758,7 +3758,7 @@ fn transition_for( PrefixTransitionLookup::Known(result) } -fn selected_matches_equal(left: &[DispatchEntryID], right: &[DispatchEntryID], selection: &PrefixSelection) -> bool { +fn selected_matches_equal(left: &[EntryID], right: &[EntryID], selection: &PrefixSelection) -> bool { left.iter() .copied() .filter(|&entry| selection.contains_terminal(entry)) @@ -4417,8 +4417,8 @@ mod tests { automaton.step_predecessors.push(u32::MAX); automaton.step_output_builders.push(PrefixStepOutputBuilder::default()); } - let unique = DispatchEntryID::from_index(0); - let shared = DispatchEntryID::from_index(1); + let unique = EntryID(0); + let shared = EntryID(1); automaton.step_output_builders[0] .terminals .extend([unique, unique, shared]); @@ -4483,9 +4483,9 @@ mod tests { .resize(2, PrefixStepOutputBuilder::default()); automaton.step_output_builders[0].child_successors.push(PrefixStepID(1)); automaton.entry_paths.push(PrefixEntryPaths { - key: (SelectorProgramID(0), 0), + key: EntryID(0), paths: vec![PrefixEntryPath { - terminal: DispatchEntryID::from_index(0), + terminal: EntryID(0), steps: vec![PrefixStepID(0), PrefixStepID(1)].into_boxed_slice(), }], }); @@ -4514,9 +4514,9 @@ mod tests { fn sparse_match_delta_applies_additions_and_removals() { let mut states = PrefixStates::new(0); let mut counters = Counters::new(); - let removed = DispatchEntryID::from_index(0); - let retained = DispatchEntryID::from_index(1); - let added = DispatchEntryID::from_index(2); + let removed = EntryID(0); + let retained = EntryID(1); + let added = EntryID(2); states.output_matches.extend([removed, retained]); let matches = states.intern_output_matches(&mut counters); let old_result = states.intern_result(matches, PrefixTruthSetID::default()); diff --git a/Libraries/LibWeb/Rust/src/css/style/program.rs b/Libraries/LibWeb/Rust/src/css/style/program.rs index 561a00d2534a..6822e8c7af7a 100644 --- a/Libraries/LibWeb/Rust/src/css/style/program.rs +++ b/Libraries/LibWeb/Rust/src/css/style/program.rs @@ -58,6 +58,10 @@ define_id! { /// Immutable identity of a compiled selector program. pub struct SelectorProgramID(pub); } +define_id! { + /// Dense document identity of one entry in a compiled selector program. + pub struct EntryID(pub); +} /// One longhand property a rule declares, and whether it declares it important. Importance is part /// of the identity because it moves the declaration to a different rung of the cascade, not because diff --git a/Libraries/LibWeb/Rust/src/css/style/program_updates.rs b/Libraries/LibWeb/Rust/src/css/style/program_updates.rs index 60ec7d2af4f3..9e012a9099f2 100644 --- a/Libraries/LibWeb/Rust/src/css/style/program_updates.rs +++ b/Libraries/LibWeb/Rust/src/css/style/program_updates.rs @@ -910,20 +910,23 @@ impl StyleEngine { } #[must_use] - pub(super) fn fact_value_is_reconstructible(key: FeatureKey, value: FeatureValue) -> bool { + pub(super) fn fact_value_is_reconstructible(key: LocalFeatureKey, value: FeatureValue) -> bool { match key { - FeatureKey::Class(_) | FeatureKey::Part(_) | FeatureKey::CustomState(_) | FeatureKey::Emptiness => { + LocalFeatureKey::Class(_) + | LocalFeatureKey::Part(_) + | LocalFeatureKey::CustomState(_) + | LocalFeatureKey::Emptiness => { matches!(value, FeatureValue::Absent | FeatureValue::Present) } - FeatureKey::TagName - | FeatureKey::FoldedTagName - | FeatureKey::Id - | FeatureKey::Attribute(_) - | FeatureKey::PartExposure - | FeatureKey::Language - | FeatureKey::Directionality => matches!(value, FeatureValue::Absent | FeatureValue::Atom(_)), - FeatureKey::HeadingLevel => matches!(value, FeatureValue::Number(_)), - FeatureKey::ArrivingFacts => false, + LocalFeatureKey::TagName + | LocalFeatureKey::FoldedTagName + | LocalFeatureKey::Id + | LocalFeatureKey::Attribute(_) + | LocalFeatureKey::PartExposure + | LocalFeatureKey::Language + | LocalFeatureKey::Directionality => matches!(value, FeatureValue::Absent | FeatureValue::Atom(_)), + LocalFeatureKey::HeadingLevel => matches!(value, FeatureValue::Number(_)), + LocalFeatureKey::ArrivingFacts => false, } } @@ -937,7 +940,7 @@ impl StyleEngine { if routing_keys_for_input(input).into_iter().any(route_is_prefix) { return true; } - let InputKey::LocalFeature(_, FeatureKey::Attribute(name)) = input.key else { + let InputKey::LocalFeature(_, LocalFeatureKey::Attribute(name)) = input.key else { return false; }; self.facts @@ -959,7 +962,7 @@ impl StyleEngine { && transaction.inputs.iter().all(|input| { matches!( input.key, - InputKey::LocalFeature(_, FeatureKey::ArrivingFacts) + InputKey::LocalFeature(_, LocalFeatureKey::ArrivingFacts) | InputKey::LocalFeature(..) | InputKey::State(..) | InputKey::RuleField(_, RuleField::Activation | RuleField::Declarations | RuleField::Layer) @@ -968,7 +971,7 @@ impl StyleEngine { | InputKey::CascadeTopology(_) | InputKey::ElementDeclaration(..) | InputKey::ElementStyleInput(..) - ) && !matches!(input.key, InputKey::LocalFeature(_, FeatureKey::ArrivingFacts)) + ) && !matches!(input.key, InputKey::LocalFeature(_, LocalFeatureKey::ArrivingFacts)) }); let mut roots = Vec::new(); let mut departures = Vec::new(); @@ -1000,7 +1003,7 @@ impl StyleEngine { } } ( - InputKey::LocalFeature(_, FeatureKey::ArrivingFacts) + InputKey::LocalFeature(_, LocalFeatureKey::ArrivingFacts) | InputKey::ElementDeclaration(..) | InputKey::ElementStyleInput(..), _, diff --git a/Libraries/LibWeb/Rust/src/css/style/relative_selector.rs b/Libraries/LibWeb/Rust/src/css/style/relative_selector.rs index ea35d4435f08..04b6753750e3 100644 --- a/Libraries/LibWeb/Rust/src/css/style/relative_selector.rs +++ b/Libraries/LibWeb/Rust/src/css/style/relative_selector.rs @@ -15,7 +15,7 @@ use super::fast_hash::FastMap as HashMap; use super::capacity::capacity_bytes; -use super::index::FeatureKey; +use super::index::LocalFeatureKey; use super::partial_view::Lookup; use super::program::SelectorProgramID; use super::selector::Incomplete; @@ -69,7 +69,7 @@ impl RelativeAxis { pub struct RelativeQuery { pub axis: RelativeAxis, pub compound: SelectorNodeID, - pub driving_feature: Option, + pub driving_feature: Option, /// False for a query that needs the direct evaluator: a selector list, more than one axis, an /// internal combinator, a structural or nested relational operator, or a scope-crossing /// construct. Complex is not unsupported; it is exact and retains no witness. @@ -403,7 +403,7 @@ mod tests { use super::super::memory::MemoryController; use super::*; - const ERROR_CLASS: FeatureKey = FeatureKey::Class(StyleAtomID(1)); + const ERROR_CLASS: LocalFeatureKey = LocalFeatureKey::Class(StyleAtomID(1)); /// `root > [card > [a, b, c], other]` struct Fixture { diff --git a/Libraries/LibWeb/Rust/src/css/style/routing.rs b/Libraries/LibWeb/Rust/src/css/style/routing.rs index ef4628f903a8..51b9c1dc2a49 100644 --- a/Libraries/LibWeb/Rust/src/css/style/routing.rs +++ b/Libraries/LibWeb/Rust/src/css/style/routing.rs @@ -55,7 +55,7 @@ impl StyleEngine { // An element's part exposure moving is that element becoming addressable from somewhere it // was not, or ceasing to be. No selector names the exposure, so there is nothing to // transpose: the region is the element whose reach moved. - if let InputKey::LocalFeature(node, FeatureKey::PartExposure) = input.key { + if let InputKey::LocalFeature(node, LocalFeatureKey::PartExposure) = input.key { self.record_selector_truth_refresh(node, None); regions.add(ImpactRegion::Node(node), &mut self.counters); return; @@ -79,13 +79,13 @@ impl StyleEngine { } let in_flux = Self::feature_in_flux(input); - let is_arrival = matches!(input.key, InputKey::LocalFeature(_, FeatureKey::ArrivingFacts)); + let is_arrival = matches!(input.key, InputKey::LocalFeature(_, LocalFeatureKey::ArrivingFacts)); let keys = match input.key { - InputKey::LocalFeature(node, FeatureKey::ArrivingFacts) => self.routing_keys_of_arriving_facts(node), + InputKey::LocalFeature(node, LocalFeatureKey::ArrivingFacts) => self.routing_keys_of_arriving_facts(node), // A `[*|x]` rule registers under the any-namespace form of the name, which the pure // mapping cannot produce: it is a property of the atom rather than of the input. An // attribute change has to reach those rules as well as the ones naming its own namespace. - InputKey::LocalFeature(_, FeatureKey::Attribute(name)) => { + InputKey::LocalFeature(_, LocalFeatureKey::Attribute(name)) => { let mut keys = routing_keys_for_input(input); for other in self.facts.attribute_name_keys(name) { if other != name { @@ -119,12 +119,13 @@ impl StyleEngine { continue; } let point = routing.route(route); + let (selector_program, selector_entry) = self.programs.entry_location(point.entry); // A rule that was given a new selector list keeps its identity, so the routes // of the program it left behind are still registered under it. They describe a // selector the rule no longer has, and following them would route a mutation // through a selector nobody wrote. - if self.program.rule_version(routing.rule_of(route)).selector_program != Some(point.program) - && !programs_in_flux.contains(&point.program) + if self.program.rule_version(routing.rule_of(route)).selector_program != Some(selector_program) + && !programs_in_flux.contains(&selector_program) { continue; } @@ -139,18 +140,18 @@ impl StyleEngine { let path = routing.path_of(route); let exact_tree_evaluation = if is_arrival && tree_routing.use_exact { if tree_routing.has_before_sibling_relations - && self.entry_can_use_before_sibling_relations(point.program, point.entry) + && self.entry_can_use_before_sibling_relations(selector_program, selector_entry) { Some( if matches!(path.first(), Some(InverseStep::FollowingSiblings)) - && self.entry_is_monotone_in_the_tree(point.program, point.entry) + && self.entry_is_monotone_in_the_tree(selector_program, selector_entry) { ExactTreeEvaluation::MonotonicArrival } else { ExactTreeEvaluation::BeforeSiblingRelations }, ) - } else if self.entry_is_monotone_in_the_tree(point.program, point.entry) { + } else if self.entry_is_monotone_in_the_tree(selector_program, selector_entry) { Some(ExactTreeEvaluation::Arrival) } else { None @@ -165,8 +166,8 @@ impl StyleEngine { path, waypoints: routing.waypoints_of(route), in_flux, - exact_entry: Some((rule, point.program, point.entry)), - refresh_rule: Some((rule, point.program)), + exact_entry: Some((rule, point.entry)), + refresh_rule: Some((rule, point.entry)), // One side decides for a monotonic tree change, and only where nothing the // change carries can turn the entry the other way. A negation, `:empty` and a // positional test each can, and each is answered on both sides as usual. @@ -176,7 +177,7 @@ impl StyleEngine { // A relational input resolves its anchors first. Folding the anchor step into // the path would compose "the ancestors of the changed node" with whatever the // outer selector adds, and ancestors-then-descendants is the document. - Some(anchor) => self.route_from_anchors(node, point.program, anchor, &site, regions), + Some(anchor) => self.route_from_anchors(node, selector_program, anchor, &site, regions), None => { let region = ImpactRegion::follow(node, path, &self.tree); let is_sibling_route = exact_tree_evaluation.is_some() @@ -201,7 +202,6 @@ impl StyleEngine { let producers = prefix_producer_cache.producers_for_route( route, prefix_dispatch.prefixes(), - point.program, point.entry, routing.path_of(route).len(), ); @@ -450,13 +450,14 @@ impl StyleEngine { continue; } let point = routing.route(route); - if self.program.rule_version(rule).selector_program != Some(point.program) { + let (program, _) = self.programs.entry_location(point.entry); + if self.program.rule_version(rule).selector_program != Some(program) { continue; } let Some(anchor) = point.anchor else { continue; }; - live.push((route, point.program, anchor)); + live.push((route, program, anchor)); } for &(route, program, anchor) in &live { @@ -526,7 +527,10 @@ impl StyleEngine { for &(route, program, anchor) in live { let site = relational_route_site(routing, route); - let anchor_posting = posting_for_dispatch_key(anchor.anchor_dispatch); + let anchor_posting = anchor + .anchor_dispatch + .has_selector_posting() + .then_some(anchor.anchor_dispatch); // An element publishes every fact that holds on it as it connects, so a query resting // on one is reached from that fact and not from here. Only a query resting on none - a // negation, `*` - is invisible to an arrival, and only then are the anchors around an @@ -795,11 +799,11 @@ impl StyleEngine { let one_sided = !old_can_match && new_can_match && tree_routing.use_exact - && self.entry_is_monotone_in_the_tree(point.program, point.entry); + && self.entry_is_monotone_in_the_tree_id(point.entry); let exact = match ( tree_routing.use_exact && tree_routing.has_before_sibling_relations - && self.entry_can_use_before_sibling_relations(point.program, point.entry), + && self.entry_can_use_before_sibling_relations_id(point.entry), one_sided, ) { (true, _) => Some(ExactTreeEvaluation::BeforeSiblingRelations), @@ -858,10 +862,10 @@ impl StyleEngine { let region = ImpactRegion::follow(node, path, &self.tree); let exact_tree_evaluation = (tree_routing.use_exact && tree_routing.has_before_sibling_relations - && self.entry_can_use_before_sibling_relations(point.program, point.entry)) + && self.entry_can_use_before_sibling_relations_id(point.entry)) .then(|| { if matches!(path.first(), Some(InverseStep::FollowingSiblings)) - && self.entry_is_monotone_in_the_tree(point.program, point.entry) + && self.entry_is_monotone_in_the_tree_id(point.entry) { ExactTreeEvaluation::MonotonicArrival } else { @@ -890,7 +894,8 @@ impl StyleEngine { continue; } let point = self.routing.route(route); - if self.program.rule_version(rule).selector_program != Some(point.program) { + let (program, _) = self.programs.entry_location(point.entry); + if self.program.rule_version(rule).selector_program != Some(program) { continue; } entries.push(SiblingEntry { route }); @@ -989,13 +994,13 @@ impl StyleEngine { let departure_can_be_compared_exactly = tree_routing.use_exact && tree_routing.has_before_sibling_relations && !state_moved - && self.entry_can_use_before_sibling_relations(point.program, point.entry); + && self.entry_can_use_before_sibling_relations_id(point.entry); // The waypoints are the compounds the path passes through, checked by walking back from // a candidate. That walk arrives at the place the element had, which no longer leads to // it, so the candidate stands on its own features. let exact_tree_evaluation = departure_can_be_compared_exactly.then(|| { if matches!(path.first(), Some(InverseStep::FollowingSiblings)) - && self.entry_is_monotone_in_the_tree(point.program, point.entry) + && self.entry_is_monotone_in_the_tree_id(point.entry) { ExactTreeEvaluation::MonotonicDeparture } else { @@ -1145,19 +1150,20 @@ impl StyleEngine { continue; } let point = routing.route(route); - if self.program.rule_version(rule).selector_program != Some(point.program) { + let (selector_program, _) = self.programs.entry_location(point.entry); + if self.program.rule_version(rule).selector_program != Some(selector_program) { continue; } let operator = self .programs - .get(point.program) + .get(selector_program) .node(point.structural_node.expect("structural route has no operator")); if !matches!(operator, SelectorOp::Empty | SelectorOp::NthPosition(_)) { continue; } let can_compare_exactly = tree_routing.use_exact && tree_routing.has_before_sibling_relations - && self.entry_can_use_before_sibling_relations(point.program, point.entry); + && self.entry_can_use_before_sibling_relations_id(point.entry); entries.push(SequenceEntry { route, operator, @@ -1187,7 +1193,7 @@ impl StyleEngine { for &empty_index in &entry_index.empty { let route = entries[empty_index].route; let point = self.routing.route(route); - self.record_selector_truth_refresh(parent, Some((self.routing.rule_of(route), point.program))); + self.record_selector_truth_refresh(parent, Some((self.routing.rule_of(route), point.entry))); } regions.add(ImpactRegion::Node(parent), &mut self.counters); } @@ -1216,7 +1222,7 @@ impl StyleEngine { let routed_region_bytes = (routed_regions.capacity() * size_of::()) as u64; pending_inner_bytes += routed_region_bytes; let point = routing.route(entry.route); - if sequences_are_document_scoped && dispatch.prefixes().contains_entry(point.program, point.entry) { + if sequences_are_document_scoped && dispatch.prefixes().contains_entry(point.entry) { deferred.entries.push(entry.clone()); deferred.regions.push(std::mem::take(routed_regions)); deferred.nested_memory.grow_committed(routed_region_bytes); @@ -1305,10 +1311,13 @@ impl StyleEngine { in_flux: None, exact_entry: None, exact_tree_evaluation: None, - refresh_rule: Some((routing.rule_of(entry.route), point.program)), + refresh_rule: Some((routing.rule_of(entry.route), point.entry)), }; match point.anchor { - Some(anchor) => self.route_from_anchors(parent, point.program, anchor, &site, regions), + Some(anchor) => { + let (program, _) = self.programs.entry_location(point.entry); + self.route_from_anchors(parent, program, anchor, &site, regions); + } None => { let region = ImpactRegion::follow(parent, path, &self.tree); self.push_pending_region(&mut pending_regions[entry_index], region); @@ -1411,7 +1420,10 @@ impl StyleEngine { // witness, not a subject: its anchors have to be resolved before the path // continues. match point.anchor { - Some(anchor) => engine.route_from_anchors(child, point.program, anchor, &site, regions), + Some(anchor) => { + let (program, _) = engine.programs.entry_location(point.entry); + engine.route_from_anchors(child, program, anchor, &site, regions); + } None => { let region = ImpactRegion::follow(child, path, &engine.tree); engine.push_pending_region(&mut pending_regions[entry_index], region); @@ -1505,7 +1517,10 @@ impl StyleEngine { self.counters .add(Counter::RelationalAnchorsConsidered, anchors.len() as u64); - let anchor_posting = posting_for_dispatch_key(anchor.anchor_dispatch); + let anchor_posting = anchor + .anchor_dispatch + .has_selector_posting() + .then_some(anchor.anchor_dispatch); for candidate in anchors { // An ancestor that does not carry the anchor compound's feature is not an anchor of // this query at all. @@ -1551,11 +1566,11 @@ impl StyleEngine { site: &RoutingSite<'_>, regions: &mut ImpactRegions, ) { - let Some(posting) = posting_for_dispatch_key(anchor.witness_dispatch) else { + if !anchor.witness_dispatch.has_selector_posting() { self.add_narrowed_region(ImpactRegion::Document, site, regions); return; - }; - let candidates: Vec = match self.facts.postings().lookup(posting) { + } + let candidates: Vec = match self.facts.postings().lookup(anchor.witness_dispatch) { Lookup::Known(posting) => posting.candidates().collect(), Lookup::KnownAbsent => Vec::new(), Lookup::Missing(_) => { @@ -1675,11 +1690,12 @@ impl StyleEngine { return false; } self.facts.carries_local_dispatch_key(node, key).unwrap_or_else(|| { - posting_for_dispatch_key(key).is_none_or(|key| match self.facts.postings().lookup(key) { - Lookup::Known(posting) => posting.contains(node), - Lookup::KnownAbsent => false, - Lookup::Missing(_) => true, - }) + !key.has_selector_posting() + || match self.facts.postings().lookup(key) { + Lookup::Known(posting) => posting.contains(node), + Lookup::KnownAbsent => false, + Lookup::Missing(_) => true, + } }) } @@ -1848,6 +1864,12 @@ impl StyleEngine { .is_some_and(|entry| entry.is_monotone_under_arrivals()) } + #[must_use] + fn entry_is_monotone_in_the_tree_id(&self, entry: EntryID) -> bool { + let (program, index) = self.programs.entry_location(entry); + self.entry_is_monotone_in_the_tree(program, index) + } + #[must_use] pub(super) fn entry_can_use_before_sibling_relations(&self, program: SelectorProgramID, entry: u32) -> bool { let compiled = self.programs.get(program); @@ -1857,6 +1879,12 @@ impl StyleEngine { .is_some_and(|entry| entry.can_use_before_sibling_relations() && entry.observes_sibling_relation()) } + #[must_use] + fn entry_can_use_before_sibling_relations_id(&self, entry: EntryID) -> bool { + let (program, index) = self.programs.entry_location(entry); + self.entry_can_use_before_sibling_relations(program, index) + } + pub(super) fn candidate_changes_exact_tree( &mut self, node: StyleNodeID, @@ -1923,9 +1951,10 @@ impl StyleEngine { node: StyleNodeID, site: &RoutingSite<'_>, ) -> ExactEntryResult { - let Some((rule, program, entry)) = site.exact_entry else { + let Some((rule, entry_id)) = site.exact_entry else { return Lookup::Missing(ExactEntryGap); }; + let (program, entry) = self.programs.entry_location(entry_id); if self.transaction_fact_view.is_none() { return Lookup::Missing(ExactEntryGap); } @@ -2141,7 +2170,8 @@ impl StyleEngine { self.memory .reserve_required(MemoryCategory::BatchScratch, charged_bytes); - let (rule, program, entry) = site.exact_entry.unwrap(); + let (rule, entry_id) = site.exact_entry.unwrap(); + let (program, entry) = self.programs.entry_location(entry_id); let sheet = self.program.rule_sheet(rule); let compiled = self.programs.get(program); if compiled.can_leave_its_scope() @@ -2239,16 +2269,13 @@ impl StyleEngine { } } - let has_usable_subject = - !site.subject.is_empty() && site.subject.iter().all(|&key| posting_for_dispatch_key(key).is_some()); + let has_usable_subject = !site.subject.is_empty() && site.subject.iter().all(|key| key.has_selector_posting()); if !self.selector_truth_changes_active && has_usable_subject - && site.subject.iter().all(|&key| { - !matches!( - self.facts.postings().lookup(posting_for_dispatch_key(key).unwrap()), - Lookup::Missing(_) - ) - }) + && site + .subject + .iter() + .all(|&key| !matches!(self.facts.postings().lookup(key), Lookup::Missing(_))) && routed_regions .iter() .all(|region| matches!(region, ImpactRegion::Node(_))) @@ -2257,14 +2284,13 @@ impl StyleEngine { return; } let cardinality = if has_usable_subject { - site.subject - .iter() - .filter_map(|&key| posting_for_dispatch_key(key)) - .try_fold(0_usize, |total, posting| match self.facts.postings().lookup(posting) { + site.subject.iter().copied().try_fold(0_usize, |total, posting| { + match self.facts.postings().lookup(posting) { Lookup::Known(posting) => Some(total.saturating_add(posting.len())), Lookup::KnownAbsent => Some(total), Lookup::Missing(_) => None, - }) + } + }) } else { None }; @@ -2310,10 +2336,9 @@ impl StyleEngine { Some(compiled_regions) }; - self.facts.postings_mut().ensure_dense_ids(); let mut candidates = Vec::new(); let mut pruned_nodes = Vec::new(); - for posting in site.subject.iter().filter_map(|&key| posting_for_dispatch_key(key)) { + for &posting in site.subject { let Ok((_, reused, copied, inspected, _)) = workspace.extend_remaining_posting( posting, self.facts.postings(), @@ -2482,7 +2507,7 @@ impl StyleEngine { .program .sheet_origin(self.program.rule_sheet(routing.rule_of(route))) == CascadeOrigin::Author - && dispatch.prefixes().contains_entry(point.program, point.entry) + && dispatch.prefixes().contains_entry(point.entry) } pub(super) fn add_prefix_convergence_regions( @@ -2561,7 +2586,7 @@ impl StyleEngine { touch(&self.tree, relations.parent); } } - InputKey::LocalFeature(node, FeatureKey::TagName | FeatureKey::FoldedTagName) + InputKey::LocalFeature(node, LocalFeatureKey::TagName | LocalFeatureKey::FoldedTagName) if has_of_type_tests => { touch(&self.tree, self.tree.parent(node)); @@ -2674,10 +2699,11 @@ impl StyleEngine { let mut inexact_answer_regions = Vec::new(); pending.retain(|key, routed_regions| { let route = routing.route(key.route); + let (program, entry) = self.programs.entry_location(route.entry); let keep = !self.prefix_route_cannot_change_any_retained_winner( routing.rule_of(key.route), - route.program, - route.entry, + program, + entry, transaction, ); if !keep { @@ -2744,11 +2770,11 @@ impl StyleEngine { break; } for dispatch in subject { - let Some(posting) = posting_for_dispatch_key(*dispatch) else { + if !dispatch.has_selector_posting() { subjects_are_indexed = false; break; - }; - let candidates = match self.facts.postings().lookup(posting) { + } + let candidates = match self.facts.postings().lookup(*dispatch) { Lookup::Known(posting) => posting.candidates(), Lookup::KnownAbsent => continue, Lookup::Missing(_) => { @@ -2786,9 +2812,9 @@ impl StyleEngine { let selection = dispatch.prefixes().select_entries( eligible_keys.iter().map(|key| { let route = routing.route(key.route); - (route.program, route.entry) + route.entry }), - dispatch.entry_count(), + self.programs.entry_capacity(), ); let had_retained_prefix_states = self.prefix_caches.borrow().states.is_retained(); let mut local_prefix_producers: HashMap> = HashMap::default(); @@ -3198,9 +3224,9 @@ impl StyleEngine { return false; } let dispatch_key = compiled.dispatch_key(entry); - let Some(posting) = posting_for_dispatch_key(dispatch_key) else { + if !dispatch_key.has_selector_posting() { return false; - }; + } // Rules dispatched under one key all walk the same posting, and the winner column is // stable for the routing pass, so the distinct-state collection is shared through a // generation-keyed cache: the first asker pays the posting walk, and every further rule @@ -3216,7 +3242,7 @@ impl StyleEngine { Some(None) => return false, None => { let mut collected: Vec = Vec::new(); - let complete = match self.facts.postings().lookup(posting) { + let complete = match self.facts.postings().lookup(dispatch_key) { Lookup::Known(posting) => posting.candidates().all(|node| { self.winner_groups .lookup(WinnerGroupKey::current(node, winner_program_version)) @@ -3333,7 +3359,7 @@ impl StyleEngine { if input_routes_on_key(input, key) { return true; } - let (InputKey::LocalFeature(_, FeatureKey::Attribute(changed)), RoutingKey::AttributeName(required)) = + let (InputKey::LocalFeature(_, LocalFeatureKey::Attribute(changed)), RoutingKey::AttributeName(required)) = (input.key, key) else { return false; @@ -3383,7 +3409,7 @@ impl StyleEngine { && transaction .inputs .iter() - .all(|input| !matches!(input.key, InputKey::LocalFeature(_, FeatureKey::ArrivingFacts))) + .all(|input| !matches!(input.key, InputKey::LocalFeature(_, LocalFeatureKey::ArrivingFacts))) && self.selector_incidence_is_current && self .transaction_fact_view @@ -3408,9 +3434,9 @@ impl StyleEngine { path: routing.path_of(route), waypoints: routing.waypoints_of(route), in_flux: None, - exact_entry: Some((rule, point.program, point.entry)), + exact_entry: Some((rule, point.entry)), exact_tree_evaluation: key.exact_tree_evaluation, - refresh_rule: Some((rule, point.program)), + refresh_rule: Some((rule, point.entry)), }; self.discard_regions_covered_by_subtree(routed_regions, &site, regions); self.add_narrowed_regions_with_workspace(routed_regions, &site, regions, workspace); @@ -3421,7 +3447,6 @@ impl StyleEngine { let point = routing.route(entry.key.route); ( routing.rule_of(entry.key.route), - point.program, point.entry, entry.key.exact_tree_evaluation, ) @@ -3430,12 +3455,12 @@ impl StyleEngine { while first < pending.entries.len() { let key = pending.entries[first].key; let point = routing.route(key.route); - let exact_entry = (routing.rule_of(key.route), point.program, point.entry); + let exact_entry = (routing.rule_of(key.route), point.entry); let mut end = first + 1; while end < pending.entries.len() { let next = pending.entries[end].key; let next_point = routing.route(next.route); - if (routing.rule_of(next.route), next_point.program, next_point.entry) != exact_entry + if (routing.rule_of(next.route), next_point.entry) != exact_entry || next.exact_tree_evaluation != key.exact_tree_evaluation { break; @@ -3489,9 +3514,9 @@ impl StyleEngine { &[] }, in_flux: None, - exact_entry: Some((rule, point.program, point.entry)), + exact_entry: Some((rule, point.entry)), exact_tree_evaluation: key.exact_tree_evaluation, - refresh_rule: Some((rule, point.program)), + refresh_rule: Some((rule, point.entry)), }; self.discard_regions_covered_by_subtree(routed_regions, &site, regions); self.add_narrowed_regions_with_workspace(routed_regions, &site, regions, workspace); @@ -3539,7 +3564,7 @@ impl StyleEngine { SiblingRouteKind::Departure | SiblingRouteKind::ExactUnion => { let point = routing.route(entry.route); let exact_entry = (key.kind == SiblingRouteKind::ExactUnion || key.exact_tree_evaluation.is_some()) - .then_some((routing.rule_of(entry.route), point.program, point.entry)); + .then_some((routing.rule_of(entry.route), point.entry)); RoutingSite { subject: routing.subject_dispatch_of(entry.route), subject_required: routing.subject_required_of(entry.route), @@ -3553,7 +3578,7 @@ impl StyleEngine { in_flux: None, exact_entry, exact_tree_evaluation: key.exact_tree_evaluation, - refresh_rule: Some((routing.rule_of(entry.route), point.program)), + refresh_rule: Some((routing.rule_of(entry.route), point.entry)), } } }; @@ -3871,24 +3896,18 @@ impl StyleEngine { } if let Some(name) = version.declared_name { let consumers = match version.kind { - RuleKind::Keyframes => match self - .facts - .postings() - .lookup(PostingKey::Dependency(DependencyPostingKey::AnimationName(name))) - { - Lookup::Known(posting) => Ok(posting.candidates().collect()), - Lookup::KnownAbsent => Ok(Vec::new()), - Lookup::Missing(gap) => Err(gap), - }, + RuleKind::Keyframes => { + match self.facts.postings().lookup(DependencyPostingKey::AnimationName(name)) { + Lookup::Known(posting) => Ok(posting.candidates().collect()), + Lookup::KnownAbsent => Ok(Vec::new()), + Lookup::Missing(gap) => Err(gap), + } + } // A registration reaches every element whose own cascade declares the name, and // also the elements whose custom-property use could not be named, because one of // them may be using this very property. RuleKind::Property => self.facts.custom_property_candidates(name).and_then(|mut nodes| { - match self - .facts - .postings() - .lookup(PostingKey::Dependency(DependencyPostingKey::AnyCustomProperty)) - { + match self.facts.postings().lookup(DependencyPostingKey::AnyCustomProperty) { Lookup::Known(posting) => nodes.extend(posting.candidates()), Lookup::KnownAbsent => {} Lookup::Missing(gap) => return Err(gap), @@ -3939,26 +3958,23 @@ impl StyleEngine { // not reported by the substitution machinery, so it is all of them - bounded by having // called a function at all, rather than by the document. if version.kind == RuleKind::Function { - let consumers: Vec = match self - .facts - .postings() - .lookup(PostingKey::Dependency(DependencyPostingKey::AnyCustomFunction)) - { - Lookup::Known(posting) => posting.candidates().collect(), - Lookup::KnownAbsent => Vec::new(), - Lookup::Missing(_) => match self.regions_reachable_for_named_consumers(&scopes) { - Some(reachable) => { - for region in reachable { - regions.add_if_not_covered(region, &self.tree, &mut self.counters); + let consumers: Vec = + match self.facts.postings().lookup(DependencyPostingKey::AnyCustomFunction) { + Lookup::Known(posting) => posting.candidates().collect(), + Lookup::KnownAbsent => Vec::new(), + Lookup::Missing(_) => match self.regions_reachable_for_named_consumers(&scopes) { + Some(reachable) => { + for region in reachable { + regions.add_if_not_covered(region, &self.tree, &mut self.counters); + } + continue; } - continue; - } - None => { - regions.widen_to_document(&mut self.counters); - return; - } - }, - }; + None => { + regions.widen_to_document(&mut self.counters); + return; + } + }, + }; for node in consumers { regions.add_if_not_covered(ImpactRegion::Node(node), &self.tree, &mut self.counters); } @@ -4116,8 +4132,7 @@ impl StyleEngine { self.selector_truth_changes.deltas.push(SelectorTruthDelta { node, rule, - program: selector_program, - entry: incidence.entry, + entry: self.programs.entry_id(selector_program, incidence.entry), change, selector_truth_changed: false, }); @@ -4169,8 +4184,10 @@ impl StyleEngine { ) }) { - let rejected = match posting_for_dispatch_key(compiled.dispatch_key(entry)) - .map(|key| self.facts.postings().lookup(key)) + let dispatch = compiled.dispatch_key(entry); + let rejected = match dispatch + .has_selector_posting() + .then(|| self.facts.postings().lookup(dispatch)) { Some(Lookup::Known(posting)) => { if self.selector_truth_changes_active { @@ -4198,8 +4215,10 @@ impl StyleEngine { ); continue; } - let posting_key = posting_for_dispatch_key(compiled.dispatch_key(entry)); - let posting = posting_key.map(|key| self.facts.postings().lookup(key)); + let dispatch = compiled.dispatch_key(entry); + let posting = dispatch + .has_selector_posting() + .then(|| self.facts.postings().lookup(dispatch)); match posting { Some(Lookup::Known(posting)) => { // A subject bounded to a prefix or suffix of its sibling sequence is not @@ -4283,7 +4302,14 @@ impl StyleEngine { for &(rule, _) in program_rules { self.selector_truth_changes.refreshes.push(SelectorTruthRefresh { node, - rule: Some((rule, selector_program)), + rule: Some(( + rule, + self.programs.entry_id( + selector_program, + u32::try_from(entry_index) + .expect("selector program entry space exhausted"), + ), + )), }); } } diff --git a/Libraries/LibWeb/Rust/src/css/style/selector.rs b/Libraries/LibWeb/Rust/src/css/style/selector.rs index d1b218cc8bf2..8465c2ed3e67 100644 --- a/Libraries/LibWeb/Rust/src/css/style/selector.rs +++ b/Libraries/LibWeb/Rust/src/css/style/selector.rs @@ -32,6 +32,7 @@ use super::column::PagedColumnPage; use super::fast_hash::FastMap as HashMap; use super::fast_hash::fast_hasher; use super::index::DispatchKey; +use super::index::FeatureKey; use super::index::StyleAtomID; use super::index::StyleNodeFacts; use super::instrumentation::Counter; @@ -49,6 +50,7 @@ use super::memory::MemoryCategory; use super::memory::MemoryController; use super::memory::MemoryLease; use super::partial_view::Lookup; +use super::program::EntryID; use super::program::RuleID; use super::program::SelectorProgramID; use super::relative_selector::RelationalWitnessKey; @@ -2147,25 +2149,6 @@ fn dispatch_key_for_feature(test: FeatureTest) -> DispatchKey { } } -/// The semantic input represented by a dispatch key, when one can move independently. -#[must_use] -fn routing_key_for_dispatch(key: DispatchKey) -> Option { - match key { - DispatchKey::TagName(tag) => Some(RoutingKey::TagName(tag)), - DispatchKey::Id(id) => Some(RoutingKey::Id(id)), - DispatchKey::Class(class) => Some(RoutingKey::Class(class)), - DispatchKey::AttributeName(attribute) => Some(RoutingKey::AttributeName(attribute)), - DispatchKey::State(state) => Some(RoutingKey::State(state)), - DispatchKey::Part(part) => Some(RoutingKey::Part(part)), - DispatchKey::CustomState(state) => Some(RoutingKey::ValueState(ValueStateKind::CustomState, state)), - DispatchKey::Directionality(direction) => { - Some(RoutingKey::ValueState(ValueStateKind::Directionality, direction)) - } - DispatchKey::Root | DispatchKey::Heading => Some(RoutingKey::Structural), - DispatchKey::Universal => None, - } -} - /// How many keys a compound may carry before checking them all costs more than the rejections save. const MAX_DISPATCH_KEYS: usize = 8; @@ -2199,6 +2182,7 @@ fn dispatch_selectivity(key: DispatchKey) -> u8 { DispatchKey::State(_) => 5, DispatchKey::Heading => 5, DispatchKey::Universal => 6, + _ => unreachable!("non-dispatch feature key"), } } @@ -2209,6 +2193,9 @@ fn dispatch_selectivity(key: DispatchKey) -> u8 { pub struct SelectorPrograms { programs: Vec>, vacant_programs: Vec, + entry_ids_by_program: Vec>>, + entry_locations: Vec>, + vacant_entries: Vec, /// Open-addressed structural interning table. Reclamation rebuilds it, so lookup never has to /// carry tombstones for vacant program identities. program_index: Vec>, @@ -2225,6 +2212,9 @@ impl Default for SelectorPrograms { Self { programs: Vec::new(), vacant_programs: Vec::new(), + entry_ids_by_program: Vec::new(), + entry_locations: Vec::new(), + vacant_entries: Vec::new(), program_index: Vec::new(), program_memory: MemoryLease::new(MemoryCategory::RuleProgram), memory: MemoryLease::new(MemoryCategory::RuleProgram), @@ -2256,6 +2246,7 @@ impl SelectorPrograms { } } + let entry_count = program.entries().len(); let id = self.vacant_programs.pop().unwrap_or_else(|| { SelectorProgramID(u32::try_from(self.programs.len()).expect("selector program space exhausted")) }); @@ -2265,6 +2256,33 @@ impl SelectorPrograms { } else { self.programs[id.0 as usize] = Some(program); } + let recycled_count = entry_count.min(self.vacant_entries.len()); + let mut recycled_entries = self + .vacant_entries + .split_off(self.vacant_entries.len() - recycled_count); + recycled_entries.sort_unstable(); + let mut recycled_entries = recycled_entries.into_iter(); + let entries: Box<[EntryID]> = (0..entry_count) + .map(|index| { + let entry = recycled_entries.next().unwrap_or_else(|| { + EntryID(u32::try_from(self.entry_locations.len()).expect("selector entry space exhausted")) + }); + let location = Some(( + id, + u32::try_from(index).expect("selector program entry space exhausted"), + )); + if entry.0 as usize == self.entry_locations.len() { + self.entry_locations.push(location); + } else { + self.entry_locations[entry.0 as usize] = location; + } + entry + }) + .collect(); + if self.entry_ids_by_program.len() <= id.0 as usize { + self.entry_ids_by_program.resize_with(id.0 as usize + 1, || None); + } + self.entry_ids_by_program[id.0 as usize] = Some(entries); self.program_index[bucket] = Some(id); (id, true) } @@ -2301,6 +2319,30 @@ impl SelectorPrograms { .expect("a selector program identity must remain live while referenced") } + #[must_use] + pub fn entry_id(&self, program: SelectorProgramID, entry: u32) -> EntryID { + self.entry_ids_by_program[program.0 as usize] + .as_ref() + .expect("a live selector program must have entry identities")[entry as usize] + } + + #[must_use] + pub fn entry_location(&self, entry: EntryID) -> (SelectorProgramID, u32) { + self.entry_locations[entry.0 as usize].expect("a referenced selector entry identity must remain live") + } + + #[must_use] + pub fn entry(&self, entry: EntryID) -> (&SelectorProgram, &SelectorEntry) { + let (program, index) = self.entry_location(entry); + let program = self.get(program); + (program, &program.entries()[index as usize]) + } + + #[must_use] + pub fn entry_capacity(&self) -> usize { + self.entry_locations.len() + } + pub fn sweep_unreferenced(&mut self, referenced: &[bool]) { self.vacant_programs.clear(); for (index, slot) in self.programs.iter_mut().enumerate() { @@ -2311,6 +2353,12 @@ impl SelectorPrograms { if let Some(program) = slot.take() { self.program_memory.shrink_committed(program.capacity_bytes()); } + if let Some(entries) = self.entry_ids_by_program[index].take() { + for entry in entries { + self.entry_locations[entry.0 as usize] = None; + self.vacant_entries.push(entry); + } + } self.vacant_programs.push(SelectorProgramID( u32::try_from(index).expect("selector program space exhausted"), )); @@ -2347,9 +2395,22 @@ impl SelectorPrograms { #[must_use] pub fn capacity_bytes(&self) -> u64 { capacity_bytes! { - shallow [self.programs, self.vacant_programs, self.program_index]; + shallow [ + self.programs, + self.vacant_programs, + self.entry_ids_by_program, + self.entry_locations, + self.vacant_entries, + self.program_index, + ]; cached [self.program_memory.bytes()]; - nested []; + nested [self + .entry_ids_by_program + .iter() + .flatten() + .map(|entries| size_of_val(entries.as_ref())) + .sum::() + ]; skip [self.memory]; } } @@ -2405,22 +2466,8 @@ pub enum InverseStep { SlotAssignees, } -/// The semantic input a transpose route is routed from. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum RoutingKey { - TagName(StyleAtomID), - Id(StyleAtomID), - Class(StyleAtomID), - /// Attribute presence and value share one key, because one mutation changes both. - AttributeName(StyleAtomID), - State(StateFact), - /// The entry depends on the shape of a child sequence rather than on any local fact. - Structural, - /// One exposed part name. - Part(StyleAtomID), - /// One parameterized state and the value it tests. - ValueState(ValueStateKind, StyleAtomID), -} +/// The semantic feature a transpose route is routed from. +pub type RoutingKey = FeatureKey; /// How a possible witness reaches the anchors whose relational truth it can flip. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -2471,8 +2518,8 @@ pub struct RelativeAnchor { /// How to get from one semantic input in a selector entry to the subjects it can affect. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TransposeRoute { - pub program: SelectorProgramID, - pub entry: u32, + pub rule: RuleID, + pub entry: EntryID, /// Whether the selector entry can be answered by the top-down prefix automaton. pub has_prefix_chain: bool, /// Whether every prefix compound reads only facts on its own element. @@ -2558,8 +2605,7 @@ impl RouteID { #[derive(Clone, Copy, PartialEq, Eq, Hash)] struct RouteDescriptor<'a> { rule: RuleID, - program: SelectorProgramID, - entry: u32, + entry: EntryID, structural_node: Option, subject_dispatch: &'a [DispatchKey], subject_required: &'a [DispatchKey], @@ -2653,7 +2699,8 @@ impl SelectorProgram { match self.node(id) { SelectorOp::Feature(test) => { - if let Some(key) = routing_key_for_dispatch(dispatch_key_for_feature(test)) { + let key = dispatch_key_for_feature(test); + if key != RoutingKey::Universal { emit(walk, key, visit); } else if anchor.is_some() { // `*` names no fact, so as a subject it needs no key: the only thing that can @@ -2797,7 +2844,15 @@ impl SelectorProgram { // The compounds of the chain carry the keys the query is reachable by. SelectorOp::RelativeAnchorInstance => {} SelectorOp::ValueState { kind, value } => { - emit(walk, RoutingKey::ValueState(kind.routing_kind(), value), visit); + emit( + walk, + match kind.routing_kind() { + ValueStateKind::Directionality => RoutingKey::Directionality(value), + ValueStateKind::CustomState => RoutingKey::CustomState(value), + ValueStateKind::Language => unreachable!("language has its own selector operator"), + }, + visit, + ); } // The subject is reached through what the rule writes inside the scope, so that is the // path. An element becoming or ceasing to be a scoping root moves the scope of @@ -2817,11 +2872,7 @@ impl SelectorProgram { } // A range is not a name, so every `:lang()` registers under one key and a resolved // language moving reaches all of them. - SelectorOp::Language { .. } => emit( - walk, - RoutingKey::ValueState(ValueStateKind::Language, StyleAtomID::NONE), - visit, - ), + SelectorOp::Language { .. } => emit(walk, RoutingKey::Language, visit), SelectorOp::Heading(_) => emit(walk, RoutingKey::Structural, visit), } } @@ -2934,7 +2985,6 @@ pub struct RoutingRegistry { sibling_first: Vec, /// Sibling-first routes indexed by a distinguishing feature of their left compound. sibling_first_by_origin: HashMap>, - rules: Vec, by_input: HashMap>, arrival_by_input: HashMap>, canonical_routes: HashMap, @@ -2952,7 +3002,6 @@ impl Default for RoutingRegistry { relational: Vec::new(), sibling_first: Vec::new(), sibling_first_by_origin: HashMap::default(), - rules: Vec::new(), by_input: HashMap::default(), arrival_by_input: HashMap::default(), canonical_routes: HashMap::default(), @@ -2995,7 +3044,6 @@ impl RoutingRegistry { } let RouteDescriptor { rule, - program, entry, structural_node, subject_dispatch, @@ -3025,7 +3073,7 @@ impl RoutingRegistry { let waypoint_offset = u32::try_from(self.keys.len()).expect("dispatch key space exhausted"); self.keys.extend_from_slice(waypoints); self.routes.push(TransposeRoute { - program, + rule, entry, has_prefix_chain: selector_entry.has_prefix_chain(), prefix_chain_has_only_local_facts: selector_entry.prefix_chain_has_only_local_facts(), @@ -3047,7 +3095,6 @@ impl RoutingRegistry { waypoint_offset, waypoint_length: u32::try_from(waypoints.len()).expect("dispatch key space exhausted"), }); - self.rules.push(rule); if anchor.is_some() { self.relational.push(route); } @@ -3090,8 +3137,7 @@ impl RoutingRegistry { fn route_descriptor(&self, route: RouteID) -> RouteDescriptor<'_> { let point = self.route(route); RouteDescriptor { - rule: self.rule_of(route), - program: point.program, + rule: point.rule, entry: point.entry, structural_node: point.structural_node, subject_dispatch: self.subject_dispatch_of(route), @@ -3143,7 +3189,7 @@ impl RoutingRegistry { #[must_use] pub fn rule_of(&self, route: RouteID) -> RuleID { - self.rules[route.index()] + self.route(route).rule } #[must_use] @@ -3222,8 +3268,10 @@ impl RoutingRegistry { } /// Add every transpose route of one attached rule. - pub fn add_rule(&mut self, rule: RuleID, program: SelectorProgramID, compiled: &SelectorProgram) { + pub fn add_rule(&mut self, rule: RuleID, program: SelectorProgramID, programs: &SelectorPrograms) { + let compiled = programs.get(program); for entry in 0..compiled.entries().len() { + let entry_id = programs.entry_id(program, u32::try_from(entry).expect("selector entry space exhausted")); let selector_entry = compiled.entries()[entry]; let subject_dispatch = compiled.subject_dispatch_keys(entry); let subject_required = compiled.subject_required_keys(entry); @@ -3233,8 +3281,7 @@ impl RoutingRegistry { site.key, RouteDescriptor { rule, - program, - entry: u32::try_from(entry).expect("selector entry space exhausted"), + entry: entry_id, structural_node: (site.key == RoutingKey::Structural).then_some(site.node), subject_dispatch, subject_required, @@ -3260,7 +3307,6 @@ impl RoutingRegistry { self.relational, self.sibling_first, self.sibling_first_by_origin, - self.rules, self.paths, self.keys, self.by_input, @@ -5531,7 +5577,7 @@ fn starts_with(value: &[u16], literal: &[u16], insensitive: bool) -> bool { #[cfg(test)] mod tests { use super::super::index::AttributeFact; - use super::super::index::FeatureKey; + use super::super::index::LocalFeatureKey; use super::super::index::StateSet; use super::super::memory::DeviceClass; use super::super::memory::MemoryController; @@ -6096,7 +6142,7 @@ mod tests { let has_descendant = builder.push_relative_exists(RelativeQuery { axis: RelativeAxis::Descendant, compound: descendant, - driving_feature: Some(FeatureKey::Class(CLASS_THEME)), + driving_feature: Some(LocalFeatureKey::Class(CLASS_THEME)), simple: true, witness_is_below_the_axis: false, match_in_shadow_tree: false, @@ -6123,7 +6169,7 @@ mod tests { builder.push_relative_exists(RelativeQuery { axis: RelativeAxis::FollowingSibling, compound: item, - driving_feature: Some(FeatureKey::Class(CLASS_ITEM)), + driving_feature: Some(LocalFeatureKey::Class(CLASS_ITEM)), simple: true, witness_is_below_the_axis: false, match_in_shadow_tree: false, @@ -6572,6 +6618,7 @@ mod tests { fn the_registry_returns_only_the_routes_that_mention_an_input() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); let mut registry = RoutingRegistry::new(); + let mut programs = SelectorPrograms::new(); let hovered = single_entry(|builder| { let class = builder.push_feature(FeatureTest::Class(CLASS_ITEM)); @@ -6599,10 +6646,14 @@ mod tests { builder.push_compound(&[theme, has]) }); - registry.add_rule(RuleID(1), SelectorProgramID(0), &hovered); - registry.add_rule(RuleID(2), SelectorProgramID(1), &unrelated); - registry.add_rule(RuleID(3), SelectorProgramID(2), &sibling); - registry.add_rule(RuleID(4), SelectorProgramID(3), &relational); + let hovered = programs.add(hovered); + let unrelated = programs.add(unrelated); + let sibling = programs.add(sibling); + let relational = programs.add(relational); + registry.add_rule(RuleID(1), hovered, &programs); + registry.add_rule(RuleID(2), unrelated, &programs); + registry.add_rule(RuleID(3), sibling, &programs); + registry.add_rule(RuleID(4), relational, &programs); registry.settle_memory(&mut memory); // A hover change reaches only the rule that mentions it. @@ -6635,7 +6686,9 @@ mod tests { builder.push_compound(&[target, preceding]) }); let mut registry = RoutingRegistry::new(); - registry.add_rule(RuleID(1), SelectorProgramID(0), &sibling); + let mut programs = SelectorPrograms::new(); + let sibling = programs.add(sibling); + registry.add_rule(RuleID(1), sibling, &programs); let item_routes = registry.routes_for(RoutingKey::Class(CLASS_ITEM)); let theme_routes = registry.routes_for(RoutingKey::Class(CLASS_THEME)); @@ -6661,7 +6714,9 @@ mod tests { builder.push_compound(&[empty, first]) }); let mut registry = RoutingRegistry::new(); - registry.add_rule(RuleID(1), SelectorProgramID(0), &structural); + let mut programs = SelectorPrograms::new(); + let structural = programs.add(structural); + registry.add_rule(RuleID(1), structural, &programs); let routes = registry.routes_for(RoutingKey::Structural); assert_eq!(routes.len(), 2); @@ -6701,6 +6756,8 @@ mod tests { let first = programs.add(make_program()); let second = programs.add(make_program()); assert_eq!(first, second); + let entry = programs.entry_id(first, 0); + assert_eq!(entry, programs.entry_id(second, 0)); assert_eq!(programs.len(), 1); let different = programs.add(single_entry(|builder| { @@ -6716,6 +6773,7 @@ mod tests { let first = programs.add(single_entry(|builder| { builder.push_feature(FeatureTest::Class(StyleAtomID(7))) })); + let first_entry = programs.entry_id(first, 0); let retained = programs.add(single_entry(|builder| { builder.push_feature(FeatureTest::Class(StyleAtomID(8))) })); @@ -6729,12 +6787,14 @@ mod tests { builder.push_feature(FeatureTest::Class(StyleAtomID(9))) })); assert_eq!(reused, first); + assert_eq!(programs.entry_id(reused, 0), first_entry); assert_eq!(programs.len(), 2); } #[test] fn the_registry_stays_proportional_to_selector_input_incidence() { let mut registry = RoutingRegistry::new(); + let mut programs = SelectorPrograms::new(); for index in 0..200_u32 { let program = single_entry(|builder| { let class = builder.push_feature(FeatureTest::Class(StyleAtomID(1000 + index))); @@ -6742,7 +6802,8 @@ mod tests { let ancestor = builder.push(SelectorOp::Ancestor(theme)); builder.push_compound(&[class, ancestor]) }); - registry.add_rule(RuleID(index), SelectorProgramID(index), &program); + let program = programs.add(program); + registry.add_rule(RuleID(index), program, &programs); } // Two inputs per rule, and the shared ancestor class collects all two hundred entries. @@ -6801,7 +6862,7 @@ mod tests { let has = builder.push_relative_exists(RelativeQuery { axis: RelativeAxis::NextSibling, compound: selected, - driving_feature: Some(super::super::index::FeatureKey::Class(CLASS_ITEM)), + driving_feature: Some(super::super::index::LocalFeatureKey::Class(CLASS_ITEM)), simple: true, witness_is_below_the_axis: false, match_in_shadow_tree: false, diff --git a/Libraries/LibWeb/Rust/src/css/style/selector/replay.rs b/Libraries/LibWeb/Rust/src/css/style/selector/replay.rs index eb97b616947c..cf99351aebbf 100644 --- a/Libraries/LibWeb/Rust/src/css/style/selector/replay.rs +++ b/Libraries/LibWeb/Rust/src/css/style/selector/replay.rs @@ -24,7 +24,7 @@ use super::SelectorProgram; use super::Specificity; use super::TagTest; use super::ValueStateTestKind; -use crate::css::style::index::FeatureKey; +use crate::css::style::index::LocalFeatureKey; use crate::css::style::index::StyleAtomID; use crate::css::style::record_replay::Error; use crate::css::style::record_replay::PayloadReader; @@ -461,39 +461,39 @@ fn read_relative_query(payload: &mut PayloadReader) -> Result payload.write_u8(0), - FeatureKey::FoldedTagName => payload.write_u8(1), - FeatureKey::Id => payload.write_u8(2), - FeatureKey::Class(atom) => write_atom(3, atom, payload), - FeatureKey::Part(atom) => write_atom(4, atom, payload), - FeatureKey::CustomState(atom) => write_atom(5, atom, payload), - FeatureKey::Emptiness => payload.write_u8(6), - FeatureKey::Attribute(atom) => write_atom(7, atom, payload), - FeatureKey::Language => payload.write_u8(8), - FeatureKey::Directionality => payload.write_u8(9), - FeatureKey::PartExposure => payload.write_u8(10), - FeatureKey::ArrivingFacts => payload.write_u8(11), - FeatureKey::HeadingLevel => payload.write_u8(12), + LocalFeatureKey::TagName => payload.write_u8(0), + LocalFeatureKey::FoldedTagName => payload.write_u8(1), + LocalFeatureKey::Id => payload.write_u8(2), + LocalFeatureKey::Class(atom) => write_atom(3, atom, payload), + LocalFeatureKey::Part(atom) => write_atom(4, atom, payload), + LocalFeatureKey::CustomState(atom) => write_atom(5, atom, payload), + LocalFeatureKey::Emptiness => payload.write_u8(6), + LocalFeatureKey::Attribute(atom) => write_atom(7, atom, payload), + LocalFeatureKey::Language => payload.write_u8(8), + LocalFeatureKey::Directionality => payload.write_u8(9), + LocalFeatureKey::PartExposure => payload.write_u8(10), + LocalFeatureKey::ArrivingFacts => payload.write_u8(11), + LocalFeatureKey::HeadingLevel => payload.write_u8(12), } } -fn read_feature_key(payload: &mut PayloadReader) -> Result { +fn read_feature_key(payload: &mut PayloadReader) -> Result { Ok(match payload.read_u8()? { - 0 => FeatureKey::TagName, - 1 => FeatureKey::FoldedTagName, - 2 => FeatureKey::Id, - 3 => FeatureKey::Class(read_atom(payload)?), - 4 => FeatureKey::Part(read_atom(payload)?), - 5 => FeatureKey::CustomState(read_atom(payload)?), - 6 => FeatureKey::Emptiness, - 7 => FeatureKey::Attribute(read_atom(payload)?), - 8 => FeatureKey::Language, - 9 => FeatureKey::Directionality, - 10 => FeatureKey::PartExposure, - 11 => FeatureKey::ArrivingFacts, - 12 => FeatureKey::HeadingLevel, + 0 => LocalFeatureKey::TagName, + 1 => LocalFeatureKey::FoldedTagName, + 2 => LocalFeatureKey::Id, + 3 => LocalFeatureKey::Class(read_atom(payload)?), + 4 => LocalFeatureKey::Part(read_atom(payload)?), + 5 => LocalFeatureKey::CustomState(read_atom(payload)?), + 6 => LocalFeatureKey::Emptiness, + 7 => LocalFeatureKey::Attribute(read_atom(payload)?), + 8 => LocalFeatureKey::Language, + 9 => LocalFeatureKey::Directionality, + 10 => LocalFeatureKey::PartExposure, + 11 => LocalFeatureKey::ArrivingFacts, + 12 => LocalFeatureKey::HeadingLevel, value => { return Err(Error::InvalidTag { category: "feature key", @@ -629,7 +629,7 @@ mod tests { relative_queries: vec![RelativeQuery { axis: RelativeAxis::NextSiblingSubtree, compound: SelectorNodeID(2), - driving_feature: Some(FeatureKey::Class(StyleAtomID(9))), + driving_feature: Some(LocalFeatureKey::Class(StyleAtomID(9))), simple: true, witness_is_below_the_axis: true, match_in_shadow_tree: true, diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index 8ab37ab21a6a..33454d4d589c 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -216,7 +216,7 @@ fn prepare_empty_transaction_fact_view(engine: &mut StyleEngine, root: StyleNode engine.transaction_fact_view = Some(view); } -fn add_feature(engine: &mut StyleEngine, node: StyleNodeID, feature: FeatureKey) { +fn add_feature(engine: &mut StyleEngine, node: StyleNodeID, feature: LocalFeatureKey) { engine.record_input( InputKey::LocalFeature(node, feature), InputValue::Feature(FeatureValue::Absent), @@ -224,7 +224,7 @@ fn add_feature(engine: &mut StyleEngine, node: StyleNodeID, feature: FeatureKey) ); } -fn remove_feature(engine: &mut StyleEngine, node: StyleNodeID, feature: FeatureKey) { +fn remove_feature(engine: &mut StyleEngine, node: StyleNodeID, feature: LocalFeatureKey) { engine.record_input( InputKey::LocalFeature(node, feature), InputValue::Feature(FeatureValue::Present), @@ -232,7 +232,7 @@ fn remove_feature(engine: &mut StyleEngine, node: StyleNodeID, feature: FeatureK ); } -fn set_atom_feature(engine: &mut StyleEngine, node: StyleNodeID, feature: FeatureKey, atom: StyleAtomID) { +fn set_atom_feature(engine: &mut StyleEngine, node: StyleNodeID, feature: LocalFeatureKey, atom: StyleAtomID) { engine.record_input( InputKey::LocalFeature(node, feature), InputValue::Feature(FeatureValue::Absent), @@ -269,9 +269,11 @@ fn published_match_answer(node: u32, cascade_input: Option, match_count: us #[test] fn repaired_selector_truth_deltas_do_not_depend_on_retained_order() { let node = StyleNodeID::element(1); + let mut programs = SelectorPrograms::new(); + let program = programs.add(test_selector_program("*", &[])); let retained = [3, 1, 2, 5, 4].map(|rule| RetainedRuleMatch { rule: RuleID(rule), - program: SelectorProgramID(1), + program, entry: 0, tree_scope: TreeScopeID::DOCUMENT, scope_proximity: u32::MAX, @@ -280,7 +282,7 @@ fn repaired_selector_truth_deltas_do_not_depend_on_retained_order() { node, pseudo_element: None, rule: RuleID(rule), - program: SelectorProgramID(1), + program, entry: 0, cascade_order: rule, specificity: Specificity::default(), @@ -289,7 +291,7 @@ fn repaired_selector_truth_deltas_do_not_depend_on_retained_order() { }); assert_eq!( - planning::repaired_selector_truth_deltas(node, &retained, &mut current), + planning::repaired_selector_truth_deltas(node, &retained, &mut current, &programs), Some(Vec::new()) ); } @@ -302,6 +304,26 @@ fn verification_gates_only_execute_checks() { let _: () = verify_published_style_transaction(|| {}); } +#[test] +fn retained_answer_delta_memo_accounts_its_tuple_capacity() { + let mut deltas = Vec::with_capacity(7); + deltas.push((RuleID(1), EntryID(2), SetChange::Added)); + let entry = RetainedAnswerDeltaMemoEntry { + deltas, + transition: RetainedAnswerDeltaTransition { + new_answer: MatchAnswerID(3), + new_cascade_input: MatchAnswerID(4), + winner_state: None, + winners_updated: false, + }, + }; + + assert_eq!( + entry.capacity_bytes(), + (entry.deltas.capacity() * size_of::<(RuleID, EntryID, SetChange)>()) as u64 + ); +} + #[test] fn published_match_answer_accounting_stays_exact_incrementally() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); @@ -419,12 +441,11 @@ fn delta_batch_keeps_singletons_inline_and_consolidates_larger_batches() { fn selector_truth_changes_consolidate_by_semantic_key() { let node = StyleNodeID::element(1); let rule = RuleID(2); - let program = SelectorProgramID(3); + let entry = EntryID(3); let delta = |entry, change| SelectorTruthDelta { node, rule, - program, - entry, + entry: EntryID(entry), change, selector_truth_changed: true, }; @@ -438,11 +459,11 @@ fn selector_truth_changes_consolidate_by_semantic_key() { changes.deltas.push(delta(2, SetChange::Removed)); changes.refreshes.push(SelectorTruthRefresh { node, - rule: Some((rule, program)), + rule: Some((rule, entry)), }); changes.refreshes.push(SelectorTruthRefresh { node, - rule: Some((rule, program)), + rule: Some((rule, entry)), }); let mut counters = Counters::new(); @@ -884,16 +905,16 @@ fn an_evicted_feature_posting_is_missing_instead_of_empty() { let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); - let key = PostingKey::Selector(SelectorPostingKey::Class(target)); + let key = SelectorPostingKey::Class(target); assert!(matches!(engine.facts.postings().lookup(key), Lookup::Known(_))); engine.facts.postings_mut().evict(key); assert!(matches!(engine.facts.postings().lookup(key), Lookup::Missing(gap) if gap == key)); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![nodes[3].raw()]); @@ -925,7 +946,7 @@ fn part_names_are_derived_from_their_host_pairs() { fn a_planned_node_never_returns_to_a_remaining_posting() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); let mut postings = FeaturePostings::new(); - let key = PostingKey::Selector(SelectorPostingKey::Class(StyleAtomID(1))); + let key = SelectorPostingKey::Class(StyleAtomID(1)); let nodes = [ StyleNodeID::element(1), StyleNodeID::element(2), @@ -934,8 +955,6 @@ fn a_planned_node_never_returns_to_a_remaining_posting() { for node in nodes { assert!(postings.insert(key, node, &mut memory)); } - postings.ensure_dense_ids(); - let mut counters = Counters::new(); let mut plan = ImpactRegions::new(); plan.add(ImpactRegion::Node(nodes[1]), &mut counters); @@ -1087,11 +1106,11 @@ fn routing_phases_share_remaining_postings_for_one_transaction() { )), ); if leaf_index == 0 { - add_feature(&mut engine, leaf, FeatureKey::Class(target)); + add_feature(&mut engine, leaf, LocalFeatureKey::Class(target)); } } for class in [guard, also] { - add_feature(&mut engine, container, FeatureKey::Class(class)); + add_feature(&mut engine, container, LocalFeatureKey::Class(class)); } } discard_transaction(&mut engine); @@ -1104,7 +1123,7 @@ fn routing_phases_share_remaining_postings_for_one_transaction() { None, Some(relations(Some(nodes[0].raw()), None, Some(containers[0].raw()))), ); - remove_feature(&mut engine, containers[0], FeatureKey::Class(guard)); + remove_feature(&mut engine, containers[0], LocalFeatureKey::Class(guard)); let builds_before = engine.counters().get(Counter::RemainingPostingBuilds); let reuses_before = engine.counters().get(Counter::RemainingPostingReuses); @@ -1141,7 +1160,7 @@ fn a_document_root_arrival_is_already_a_whole_document_plan() { fn a_non_bulk_document_root_arrival_publishes_style_reactions() { let (mut engine, nodes) = linear_document(); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } let mut published = Vec::new(); @@ -1171,7 +1190,7 @@ fn a_document_program_plan_skips_dom_routing() { version.declaration_block = Some(DeclarationBlockID(2)); engine.replace_rule_version(rule, version); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let routed_before = engine.counters().get(Counter::RoutedEntryPoints); let mut planned = Vec::new(); assert!(!engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -1188,7 +1207,7 @@ fn a_document_program_plan_skips_dom_routing() { fn a_program_change_does_not_repeat_an_arriving_subtree() { let (mut engine, nodes) = linear_document(); let target = StyleAtomID(200); - add_feature(&mut engine, nodes[2], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let mut raw = [0_u32; 4]; @@ -1200,7 +1219,7 @@ fn a_program_change_does_not_repeat_an_arriving_subtree() { _ => relations(Some(arriving[index - 1].raw()), None, None), }; engine.record_tree_delta(node, None, Some(relations)); - add_feature(&mut engine, node, FeatureKey::Class(target)); + add_feature(&mut engine, node, LocalFeatureKey::Class(target)); } add_target_rule(&mut engine, StyleSheetObjectID(1), target); let mut planned = Vec::new(); @@ -1227,7 +1246,7 @@ fn a_rare_program_candidate_inside_an_arrival_is_already_covered() { None, Some(relations(Some(nodes[0].raw()), Some(nodes[3].raw()), None)), ); - add_feature(&mut engine, arriving, FeatureKey::Class(target)); + add_feature(&mut engine, arriving, LocalFeatureKey::Class(target)); add_target_rule(&mut engine, StyleSheetObjectID(1), target); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -1250,7 +1269,7 @@ fn a_program_region_inside_an_arrival_is_already_covered() { ); engine.record_tree_delta(arriving[1], None, Some(relations(Some(arriving[0].raw()), None, None))); let guard = StyleAtomID(200); - add_feature(&mut engine, arriving[0], FeatureKey::Class(guard)); + add_feature(&mut engine, arriving[0], LocalFeatureKey::Class(guard)); add_guard_universal_rule(&mut engine, guard); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -1345,7 +1364,7 @@ fn nested_document() -> (StyleEngine, Vec) { ); } for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } (engine, nodes) } @@ -1419,7 +1438,7 @@ fn add_has_descendant_rule(engine: &mut StyleEngine, anchor: StyleAtomID, witnes let has_witness = builder.push_relative_exists(relative_selector::RelativeQuery { axis: RelativeAxis::Descendant, compound: witness_test, - driving_feature: Some(FeatureKey::Class(witness)), + driving_feature: Some(LocalFeatureKey::Class(witness)), simple: true, witness_is_below_the_axis: false, match_in_shadow_tree: false, @@ -1450,12 +1469,12 @@ fn a_nested_arrival_routes_relational_facts_from_the_outer_subtree() { add_has_descendant_rule(&mut engine, anchor, witness); engine.record_tree_delta(nodes[0], None, Some(relations(None, None, None))); - add_feature(&mut engine, nodes[0], FeatureKey::Class(anchor)); + add_feature(&mut engine, nodes[0], LocalFeatureKey::Class(anchor)); discard_transaction(&mut engine); engine.record_tree_delta(nodes[1], None, Some(relations(Some(nodes[0].raw()), None, None))); engine.record_tree_delta(nodes[2], None, Some(relations(Some(nodes[1].raw()), None, None))); - add_feature(&mut engine, nodes[2], FeatureKey::Class(witness)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(witness)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -1469,7 +1488,7 @@ fn add_has_sibling_rule(engine: &mut StyleEngine, anchor: StyleAtomID, witness: let has_witness = builder.push_relative_exists(relative_selector::RelativeQuery { axis, compound: witness_test, - driving_feature: Some(FeatureKey::Class(witness)), + driving_feature: Some(LocalFeatureKey::Class(witness)), simple: matches!(axis, RelativeAxis::NextSibling | RelativeAxis::FollowingSibling), witness_is_below_the_axis: false, match_in_shadow_tree: false, @@ -1501,7 +1520,7 @@ fn a_batch_of_arrivals_routes_a_following_sibling_anchor_once() { engine.record_tree_delta(nodes[0], None, Some(relations(None, None, None))); engine.record_tree_delta(nodes[1], None, Some(relations(Some(raw[0]), None, None))); - add_feature(&mut engine, nodes[1], FeatureKey::Class(anchor)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(anchor)); discard_transaction(&mut engine); for index in 2..6 { @@ -1535,7 +1554,7 @@ fn a_batch_of_departures_routes_a_following_sibling_anchor_once() { Some(relations(Some(raw[0]), Some(raw[index - 1]), None)), ); } - add_feature(&mut engine, nodes[1], FeatureKey::Class(anchor)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(anchor)); discard_transaction(&mut engine); // Two departures from one sequence, and the first one's recorded neighbour leaves right @@ -1569,7 +1588,7 @@ fn an_arrival_within_the_adjacent_reach_routes_the_anchor() { engine.record_tree_delta(nodes[0], None, Some(relations(None, None, None))); engine.record_tree_delta(nodes[1], None, Some(relations(Some(raw[0]), None, None))); engine.record_tree_delta(nodes[2], None, Some(relations(Some(raw[0]), Some(raw[1]), None))); - add_feature(&mut engine, nodes[1], FeatureKey::Class(anchor)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(anchor)); discard_transaction(&mut engine); // An element landing right beside the anchor breaks `.a:has(+ .w)` however little it @@ -1597,7 +1616,7 @@ fn an_arrival_beyond_the_adjacent_reach_routes_no_anchor() { engine.record_tree_delta(nodes[0], None, Some(relations(None, None, None))); engine.record_tree_delta(nodes[1], None, Some(relations(Some(raw[0]), None, None))); engine.record_tree_delta(nodes[2], None, Some(relations(Some(raw[0]), Some(raw[1]), None))); - add_feature(&mut engine, nodes[1], FeatureKey::Class(anchor)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(anchor)); discard_transaction(&mut engine); // An adjacent chain reaches one step back, so an element landing two steps past the @@ -1623,7 +1642,7 @@ fn a_departed_subtree_routes_sibling_subtree_anchors_above_its_parent() { engine.record_tree_delta(nodes[2], None, Some(relations(Some(raw[0]), Some(raw[1]), None))); engine.record_tree_delta(nodes[3], None, Some(relations(Some(raw[2]), None, None))); engine.record_tree_delta(nodes[4], None, Some(relations(Some(raw[2]), Some(raw[3]), None))); - add_feature(&mut engine, nodes[1], FeatureKey::Class(anchor)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(anchor)); discard_transaction(&mut engine); engine.record_tree_delta(nodes[4], Some(relations(Some(raw[2]), Some(raw[3]), None)), None); @@ -1648,7 +1667,7 @@ fn a_retained_witness_carries_an_anchor_through_its_lifecycle() { engine.record_tree_delta(nodes[2], None, Some(relations(Some(raw[1]), None, None))); engine.record_tree_delta(nodes[3], None, Some(relations(Some(raw[1]), Some(raw[2]), None))); for (node, class) in [(nodes[1], anchor), (nodes[2], witness)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -1656,7 +1675,7 @@ fn a_retained_witness_carries_an_anchor_through_its_lifecycle() { // A second witness appearing cannot flip an anchor that is already true, so the retained // witness answers for it and nothing is routed. - add_feature(&mut engine, nodes[3], FeatureKey::Class(witness)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(witness)); let mut planned = Vec::new(); engine.take_style_transaction(nodes[0], |_, _, reactions| { planned.extend(reactions.iter().map(|reaction| reaction.style_node)); @@ -1666,7 +1685,7 @@ fn a_retained_witness_carries_an_anchor_through_its_lifecycle() { // The witness the entry does not name losing the feature cannot flip the anchor either, // and the retained witness proves it without any evaluation of the anchor. - remove_feature(&mut engine, nodes[3], FeatureKey::Class(witness)); + remove_feature(&mut engine, nodes[3], LocalFeatureKey::Class(witness)); let mut planned = Vec::new(); engine.take_style_transaction(nodes[0], |_, _, reactions| { planned.extend(reactions.iter().map(|reaction| reaction.style_node)); @@ -1676,7 +1695,7 @@ fn a_retained_witness_carries_an_anchor_through_its_lifecycle() { // The retained witness losing the feature is exactly what the entry cannot vouch past: // the anchor is routed, recomputes to false, and the entry is cleared. - remove_feature(&mut engine, nodes[2], FeatureKey::Class(witness)); + remove_feature(&mut engine, nodes[2], LocalFeatureKey::Class(witness)); let mut planned = Vec::new(); engine.take_style_transaction(nodes[0], |_, _, reactions| { planned.extend(reactions.iter().map(|reaction| reaction.style_node)); @@ -1689,7 +1708,7 @@ fn a_retained_witness_carries_an_anchor_through_its_lifecycle() { // A witness returning is routed rather than skipped: false-to-true discovery never // consults a witness, it creates one. - add_feature(&mut engine, nodes[2], FeatureKey::Class(witness)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(witness)); let mut planned = Vec::new(); engine.take_style_transaction(nodes[0], |_, _, reactions| { planned.extend(reactions.iter().map(|reaction| reaction.style_node)); @@ -1716,7 +1735,7 @@ fn a_retained_witness_absorbs_arrivals_into_a_watched_sequence() { engine.record_tree_delta(nodes[2], None, Some(relations(Some(raw[0]), Some(raw[1]), None))); engine.record_tree_delta(nodes[3], None, Some(relations(Some(raw[0]), Some(raw[2]), None))); for (node, class) in [(nodes[1], anchor), (nodes[2], other), (nodes[3], witness)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); assert!(!engine.match_element(nodes[1]).unwrap().is_empty()); @@ -1756,7 +1775,7 @@ fn an_element_landing_between_an_anchor_and_its_adjacent_witness_routes_it() { engine.record_tree_delta(nodes[1], None, Some(relations(Some(raw[0]), None, None))); engine.record_tree_delta(nodes[2], None, Some(relations(Some(raw[0]), Some(raw[1]), None))); for (node, class) in [(nodes[1], anchor), (nodes[2], witness)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); assert!(!engine.match_element(nodes[1]).unwrap().is_empty()); @@ -1768,7 +1787,7 @@ fn an_element_landing_between_an_anchor_and_its_adjacent_witness_routes_it() { None, Some(relations(Some(raw[0]), Some(raw[1]), Some(raw[2]))), ); - add_feature(&mut engine, nodes[3], FeatureKey::Class(other)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(other)); let mut planned = Vec::new(); engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes)); assert_eq!(engine.counters().get(Counter::RelationalAnchorsSkippedByWitness), 0); @@ -1825,7 +1844,7 @@ fn clearing_state_while_departing_routes_following_siblings() { InputValue::State(false), InputValue::State(true), ); - add_feature(&mut engine, nodes[2], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); engine.begin_adaptive_cold_matching_batch(nodes[0]); engine.match_element(nodes[2]).unwrap(); @@ -2097,7 +2116,7 @@ fn cascade_directed_matching_equals_compacting_the_exact_answer() { let winner = add_target_rule(&mut engine, StyleSheetObjectID(2), target); engine.set_rule_declared_properties(lower, &[(1, false)], true); engine.set_rule_declared_properties(winner, &[(1, false)], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact = engine.match_element(nodes[1]).unwrap(); @@ -2124,7 +2143,7 @@ fn an_incomplete_matching_rule_blocks_cascade_directed_pruning() { engine.set_rule_declared_properties(lower, &[(1, false)], true); engine.set_rule_declared_properties(winner, &[(1, false)], true); engine.set_rule_declared_properties(incomplete, &[(2, false)], false); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact = engine.match_element(nodes[1]).unwrap(); @@ -2194,7 +2213,7 @@ fn a_layer_reorder_patches_the_retained_compact_answer() { engine.set_rule_layer(theme_rule, theme); engine.set_rule_in_a_layer(base_rule); engine.set_rule_in_a_layer(theme_rule); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); @@ -2246,7 +2265,7 @@ fn an_unused_layer_priority_shift_stops_before_recomputation() { engine.set_rule_layer(theme_rule, theme); engine.set_rule_in_a_layer(base_rule); engine.set_rule_in_a_layer(theme_rule); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); @@ -2286,7 +2305,7 @@ fn an_evicted_retained_match_answer_falls_back_to_cold_matching() { engine.set_rule_layer(theme_rule, theme); engine.set_rule_in_a_layer(base_rule); engine.set_rule_in_a_layer(theme_rule); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); @@ -2337,7 +2356,7 @@ fn an_evicted_answer_payload_repairs_to_its_retained_identity() { let winning_rule = add_target_rule(&mut engine, StyleSheetObjectID(2), target); engine.set_rule_declared_properties(losing_rule, &[(1, false)], true); engine.set_rule_declared_properties(winning_rule, &[(1, false)], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); @@ -2376,7 +2395,7 @@ fn an_exact_unchanged_cascade_stops_before_style_recomputation() { let value = SpecifiedValueID(101); engine.set_rule_declared_properties_with_values(first_rule, &[(1, false, value)], true); engine.set_rule_declared_properties_with_values(second_rule, &[(1, false, value)], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(first_class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(first_class)); discard_transaction(&mut engine); let old_answer = engine.match_element_for_cascade(nodes[1]).unwrap(); @@ -2384,8 +2403,8 @@ fn an_exact_unchanged_cascade_stops_before_style_recomputation() { assert_eq!(old_answer[0].rule, first_rule); publish_current_cascade_as_computed(&mut engine, nodes[1]); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(first_class)); - add_feature(&mut engine, nodes[1], FeatureKey::Class(second_class)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(first_class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(second_class)); let stops_before = engine.counters().get(Counter::PublishedExactCascadeStops); let mut planned = Vec::new(); assert!(engine.take_style_transaction(nodes[0], |_, _, reactions| { @@ -2411,13 +2430,13 @@ fn a_changed_exact_cascade_is_still_published_for_recomputation() { let second_rule = add_target_rule(&mut engine, StyleSheetObjectID(2), second_class); engine.set_rule_declared_properties_with_values(first_rule, &[(1, false, SpecifiedValueID(101))], true); engine.set_rule_declared_properties_with_values(second_rule, &[(1, false, SpecifiedValueID(102))], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(first_class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(first_class)); discard_transaction(&mut engine); engine.match_element_for_cascade(nodes[1]).unwrap(); publish_current_cascade_as_computed(&mut engine, nodes[1]); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(first_class)); - add_feature(&mut engine, nodes[1], FeatureKey::Class(second_class)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(first_class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(second_class)); let mut planned = Vec::new(); assert!(engine.take_style_transaction(nodes[0], |_, _, reactions| { planned.extend(reactions.iter().map(|reaction| reaction.style_node)); @@ -2434,7 +2453,7 @@ fn program_and_local_routes_merge_retained_answer_attribution() { let departing_rule = add_target_rule(&mut engine, StyleSheetObjectID(1), departing_class); engine.set_rule_declared_properties(departing_rule, &[(1, false)], true); for class in [departing_class, arriving_class] { - add_feature(&mut engine, nodes[1], FeatureKey::Class(class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -2443,7 +2462,7 @@ fn program_and_local_routes_merge_retained_answer_attribution() { assert_eq!(old_answer[0].rule, departing_rule); publish_current_cascade_as_computed(&mut engine, nodes[1]); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(departing_class)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(departing_class)); let program = engine.programs.add(test_class_selector_program( ".arriving", @@ -2517,7 +2536,7 @@ fn retained_answer_patching_evaluates_narrow_affected_rules_directly() { let unrelated_rule = add_target_rule(&mut engine, StyleSheetObjectID(2), unrelated_class); engine.set_rule_declared_properties(matching_rule, &[(1, false)], true); engine.set_rule_declared_properties(unrelated_rule, &[(2, false)], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(matching_class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(matching_class)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); @@ -2564,14 +2583,14 @@ fn retained_answer_patching_applies_complete_signed_deltas_without_matching() { let target = StyleAtomID(200); let rule = add_target_rule(&mut engine, StyleSheetObjectID(1), target); engine.set_rule_declared_properties(rule, &[(1, false)], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); let compact_answer = engine.matches_for_cascade(exact_answer.clone(), false, Some(nodes[1])); engine.remember_retained_match_answer(nodes[1], &exact_answer); engine.remember_cascade_input(nodes[1], &compact_answer); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); let program = engine.program.rule_version(rule).selector_program.unwrap(); let mut patch = engine.prepare_retained_answer_patch(RetainedAnswerPatchSelection { affected: vec![RetainedAnswerPatchSelectionRule { @@ -2593,8 +2612,7 @@ fn retained_answer_patching_applies_complete_signed_deltas_without_matching() { SelectorTruthPatch::Direct(&[SelectorTruthDelta { node: nodes[1], rule, - program, - entry: 0, + entry: engine.programs.entry_id(program, 0), change: SetChange::Removed, selector_truth_changed: true, }]), @@ -2613,8 +2631,7 @@ fn retained_answer_patching_applies_complete_signed_deltas_without_matching() { &[SelectorTruthDelta { node: nodes[1], rule, - program, - entry: 0, + entry: engine.programs.entry_id(program, 0), change: SetChange::Added, selector_truth_changed: true, }], @@ -2628,6 +2645,82 @@ fn retained_answer_patching_applies_complete_signed_deltas_without_matching() { )); } +#[test] +fn recycled_selector_entries_keep_delta_answers_canonical() { + let (mut engine, nodes) = linear_document(); + let discarded = engine.programs.add(test_class_selector_program( + ".discarded-a, .discarded-b", + &[("discarded-a", StyleAtomID(300)), ("discarded-b", StyleAtomID(301))], + None, + )); + assert_eq!(engine.programs.get(discarded).entries().len(), 2); + engine.programs.sweep_unreferenced(&[false]); + + let first = StyleAtomID(200); + let second = StyleAtomID(201); + let (rule, program) = add_selector_list_rule(&mut engine, first, second); + engine.set_rule_declared_properties(rule, &[(1, false)], true); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(first)); + discard_transaction(&mut engine); + + let retained = engine.match_element(nodes[1]).unwrap(); + let compact = engine.matches_for_cascade(retained.clone(), false, Some(nodes[1])); + engine.remember_retained_match_answer(nodes[1], &retained); + engine.remember_cascade_input(nodes[1], &compact); + let old_identity = match engine.retained_match_answers.lookup(nodes[1]) { + Lookup::Known(identity) => *identity, + _ => panic!("initial answer must be retained"), + }; + let old_cascade_input = match engine.retained_match_answers.cascade_input_lookup(nodes[1]) { + Lookup::Known(identity) => *identity, + _ => panic!("initial cascade input must be retained"), + }; + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(second)); + engine.facts.commit_pending(&mut engine.memory); + + let retained = prepare_retained_match_answer(retained.into_iter()); + let mut patch = engine.prepare_retained_answer_patch(RetainedAnswerPatchSelection { + affected: vec![RetainedAnswerPatchSelectionRule { + rule, + program, + evaluate: true, + }], + requires_full_match: true, + ..Default::default() + }); + engine + .apply_retained_match_answer_deltas( + nodes[1], + &mut patch, + old_identity, + &retained, + old_cascade_input, + &[SelectorTruthDelta { + node: nodes[1], + rule, + entry: engine.programs.entry_id(program, 1), + change: SetChange::Added, + selector_truth_changed: true, + }], + ) + .unwrap(); + let patched_identity = match engine.retained_match_answers.lookup(nodes[1]) { + Lookup::Known(identity) => *identity, + _ => panic!("patched answer must be retained"), + }; + let patched = match engine.retained_match_answer(nodes[1]) { + Lookup::Known(answer) => answer, + _ => panic!("patched answer payload must be retained"), + } + .to_vec(); + assert!(patched.is_sorted()); + + let mut cold = patched.clone(); + cold.sort_unstable(); + let cold_identity = engine.match_answers.intern_prepared(cold); + assert_eq!(patched_identity, cold_identity); +} + #[test] fn retained_answer_patching_matches_only_unresolved_rules_after_signed_deltas() { let (mut engine, nodes) = linear_document(); @@ -2654,15 +2747,15 @@ fn retained_answer_patching_matches_only_unresolved_rules_after_signed_deltas() engine.set_rule_declared_properties(delta_rule, &[(1, false)], true); engine.set_rule_declared_properties(second_delta_rule, &[(2, false)], true); engine.set_rule_declared_properties(refresh_rule, &[(3, false)], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(refresh_target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(refresh_target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); let compact_answer = engine.matches_for_cascade(exact_answer.clone(), false, None); engine.remember_retained_match_answer(nodes[1], &exact_answer); engine.remember_cascade_input(nodes[1], &compact_answer); - add_feature(&mut engine, nodes[1], FeatureKey::Class(delta_target)); - add_feature(&mut engine, nodes[1], FeatureKey::Class(second_delta_target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(delta_target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(second_delta_target)); engine.facts.commit_pending(&mut engine.memory); let mut patch = engine.prepare_retained_answer_patch(RetainedAnswerPatchSelection { affected: vec![ @@ -2699,23 +2792,21 @@ fn retained_answer_patching_matches_only_unresolved_rules_after_signed_deltas() SelectorTruthDelta { node: nodes[1], rule: delta_rule, - program: delta_program, - entry: 0, + entry: engine.programs.entry_id(delta_program, 0), change: SetChange::Added, selector_truth_changed: true, }, SelectorTruthDelta { node: nodes[1], rule: second_delta_rule, - program: second_delta_program, - entry: 0, + entry: engine.programs.entry_id(second_delta_program, 0), change: SetChange::Added, selector_truth_changed: true, }, ], refreshes: &[SelectorTruthRefresh { node: nodes[1], - rule: Some((refresh_rule, refresh_program)), + rule: Some((refresh_rule, engine.programs.entry_id(refresh_program, 0))), }], }, ) @@ -2744,7 +2835,7 @@ fn retained_answer_patching_preserves_incomplete_cascade_winners() { engine.set_rule_declared_properties(winning_rule, &[(2, false)], true); engine.set_rule_gated_by_container_query(gated_rule); for class in [target, winning_target] { - add_feature(&mut engine, nodes[1], FeatureKey::Class(class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -2773,8 +2864,7 @@ fn retained_answer_patching_preserves_incomplete_cascade_winners() { SelectorTruthPatch::Direct(&[SelectorTruthDelta { node: nodes[1], rule: winning_rule, - program: winning_program, - entry: 0, + entry: engine.programs.entry_id(winning_program, 0), change: SetChange::Removed, selector_truth_changed: true, }]), @@ -2806,11 +2896,14 @@ fn selector_list_entry_deltas_fall_back_when_the_compact_winner_is_insufficient( tree_scope: TreeScopeID::DOCUMENT, scope_proximity: u32::MAX, }]; - let delta = |entry, change| SelectorTruthDelta { + let entries = [ + engine.programs.entry_id(program, 0), + engine.programs.entry_id(program, 1), + ]; + let delta = |entry: usize, change| SelectorTruthDelta { node: nodes[1], rule, - program, - entry, + entry: entries[entry], change, selector_truth_changed: true, }; @@ -2850,14 +2943,14 @@ fn retained_answer_repair_returns_signed_selector_truth() { let target = StyleAtomID(200); let rule = add_target_rule(&mut engine, StyleSheetObjectID(1), target); engine.set_rule_declared_properties(rule, &[(1, false)], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); let compact_answer = engine.matches_for_cascade(exact_answer.clone(), false, None); engine.remember_retained_match_answer(nodes[1], &exact_answer); engine.remember_cascade_input(nodes[1], &compact_answer); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let program = engine.program.rule_version(rule).selector_program.unwrap(); let mut patch = engine.prepare_retained_answer_patch(RetainedAnswerPatchSelection { @@ -2884,7 +2977,7 @@ fn retained_answer_repair_returns_signed_selector_truth() { deltas: &[], refreshes: &[SelectorTruthRefresh { node: nodes[1], - rule: Some((rule, program)), + rule: Some((rule, engine.programs.entry_id(program, 0))), }], }, ) @@ -2912,15 +3005,15 @@ fn already_planned_routes_attribute_their_extent() { let guard = StyleAtomID(200); let target = StyleAtomID(201); let rule = add_guard_target_rule(&mut engine, guard, target); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[3]).unwrap(); engine.remember_retained_match_answer(nodes[3], &exact_answer); engine.remember_cascade_input(nodes[3], &exact_answer); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut transaction = engine.take_transaction(); let mut regions = ImpactRegions::with_topology(&engine.tree, nodes[0]); regions.add(ImpactRegion::Node(nodes[3]), &mut engine.counters); @@ -2934,7 +3027,7 @@ fn already_planned_routes_attribute_their_extent() { path: &[], waypoints: &[], in_flux: None, - exact_entry: Some((rule, program, 0)), + exact_entry: Some((rule, engine.programs.entry_id(program, 0))), exact_tree_evaluation: None, refresh_rule: None, }; @@ -2952,7 +3045,7 @@ fn already_planned_routes_attribute_their_extent() { let mut sweep = AttributionSweep::default(); let mut covering = Vec::new(); assert!(regions.covering_attributions(&cover, &mut sweep, nodes[3], &mut covering)); - assert_eq!(covering, vec![(rule, program)]); + assert_eq!(covering, vec![(rule, engine.programs.entry_id(program, 0))]); engine.selector_truth_changes = SelectorTruthChanges::default(); engine.record_already_planned_selector_truth(nodes[3], &site); @@ -2977,7 +3070,7 @@ fn routes_covered_by_an_attributed_subtree_still_attribute_their_extent() { let mut regions = ImpactRegions::with_topology(&engine.tree, nodes[0]); regions.add_attributed( ImpactRegion::Subtree(nodes[0]), - (rule_a, program_a), + (rule_a, engine.programs.entry_id(program_a, 0)), &mut engine.counters, ); engine.selector_truth_changes_active = true; @@ -2991,7 +3084,7 @@ fn routes_covered_by_an_attributed_subtree_still_attribute_their_extent() { path: &[], waypoints: &[], in_flux: None, - exact_entry: Some((rule_b, program_b, 0)), + exact_entry: Some((rule_b, engine.programs.entry_id(program_b, 0))), exact_tree_evaluation: None, refresh_rule: None, }; @@ -3004,7 +3097,13 @@ fn routes_covered_by_an_attributed_subtree_still_attribute_their_extent() { let mut sweep = AttributionSweep::default(); let mut covering = Vec::new(); assert!(regions.covering_attributions(&cover, &mut sweep, nodes[3], &mut covering)); - assert_eq!(covering, vec![(rule_a, program_a), (rule_b, program_b)]); + assert_eq!( + covering, + vec![ + (rule_a, engine.programs.entry_id(program_a, 0)), + (rule_b, engine.programs.entry_id(program_b, 0)), + ] + ); // A dropped route that names no rule must poison instead: a node region by refresh, a // wider region by joining the full re-derivation cover. @@ -3055,7 +3154,7 @@ fn already_planned_routes_skip_truth_without_a_retained_answer() { path: &[], waypoints: &[], in_flux: None, - exact_entry: Some((rule, program, 0)), + exact_entry: Some((rule, engine.programs.entry_id(program, 0))), exact_tree_evaluation: None, refresh_rule: None, }; @@ -3195,8 +3294,9 @@ fn author_revert_retains_and_repairs_its_user_agent_continuation() { let removed = SelectorTruthDelta { node: nodes[0], rule: user_agent, - program: user_agent_match.program, - entry: user_agent_match.entry, + entry: engine + .programs + .entry_id(user_agent_match.program, user_agent_match.entry), change: SetChange::Removed, selector_truth_changed: true, }; @@ -3362,7 +3462,7 @@ fn rule_declaration_edits_repair_only_their_property_inventory() { let target = StyleAtomID(200); let rule = add_target_rule(&mut engine, StyleSheetObjectID(1), target); engine.set_rule_declared_properties_with_values(rule, &[(1, false, SpecifiedValueID(101))], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); @@ -3403,7 +3503,7 @@ fn rule_declaration_repair_falls_back_when_winner_retention_is_refused() { let target = StyleAtomID(200); let rule = add_target_rule(&mut engine, StyleSheetObjectID(1), target); engine.set_rule_declared_properties_with_values(rule, &[(1, false, SpecifiedValueID(101))], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let exact_answer = engine.match_element(nodes[1]).unwrap(); @@ -3497,8 +3597,7 @@ fn pseudo_winner_deltas_update_only_their_sparse_cascade_row() { let removed = SelectorTruthDelta { node: nodes[0], rule: higher, - program: higher_match.program, - entry: higher_match.entry, + entry: engine.programs.entry_id(higher_match.program, higher_match.entry), change: SetChange::Removed, selector_truth_changed: true, }; @@ -3794,8 +3893,8 @@ fn candidate_narrowing_requires_the_whole_subject_compound() { let tag_div = StyleAtomID(101); let class_target = StyleAtomID(200); for (node, tag) in [(nodes[1], tag_span), (nodes[2], tag_div)] { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); - add_feature(&mut engine, node, FeatureKey::Class(class_target)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); + add_feature(&mut engine, node, LocalFeatureKey::Class(class_target)); } discard_transaction(&mut engine); prepare_empty_transaction_fact_view(&mut engine, nodes[0]); @@ -3824,8 +3923,8 @@ fn candidate_narrowing_requires_the_whole_subject_compound() { fn exact_node_narrowing_does_not_enumerate_the_subject_posting() { let (mut engine, nodes) = linear_document(); let class_target = StyleAtomID(200); - add_feature(&mut engine, nodes[1], FeatureKey::Class(class_target)); - add_feature(&mut engine, nodes[2], FeatureKey::Class(class_target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(class_target)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(class_target)); discard_transaction(&mut engine); prepare_empty_transaction_fact_view(&mut engine, nodes[0]); @@ -3855,8 +3954,8 @@ fn exact_node_narrowing_does_not_enumerate_the_subject_posting() { fn exact_node_narrowing_preserves_posting_history_for_truth_patches() { let (mut engine, nodes) = linear_document(); let class_target = StyleAtomID(200); - add_feature(&mut engine, nodes[1], FeatureKey::Class(class_target)); - add_feature(&mut engine, nodes[2], FeatureKey::Class(class_target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(class_target)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(class_target)); discard_transaction(&mut engine); prepare_empty_transaction_fact_view(&mut engine, nodes[0]); engine.selector_truth_changes_active = true; @@ -3893,7 +3992,7 @@ fn a_required_key_in_flux_admits_both_sides_of_the_change() { let (mut engine, nodes) = linear_document(); let class_target = StyleAtomID(200); discard_transaction(&mut engine); - add_feature(&mut engine, nodes[1], FeatureKey::Class(class_target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(class_target)); let mut transaction = engine.take_transaction(); let view = engine.transaction_fact_view_for(&mut transaction, nodes[0], &ImpactRegions::new()); engine.release_transaction(transaction); @@ -3934,9 +4033,9 @@ fn an_attribute_in_flux_includes_every_name_form() { folded_local: folded_local_name, }, ); - add_feature(&mut engine, nodes[1], FeatureKey::Attribute(qualified_name)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Attribute(qualified_name)); discard_transaction(&mut engine); - remove_feature(&mut engine, nodes[1], FeatureKey::Attribute(qualified_name)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Attribute(qualified_name)); let transaction = engine.take_transaction(); let features = engine.feature_delta_for(&transaction); @@ -3976,9 +4075,9 @@ fn an_alternate_ancestor_witness_keeps_a_candidate_out_of_the_plan() { let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for node in [nodes[1], nodes[2]] { - add_feature(&mut engine, node, FeatureKey::Class(guard)); + add_feature(&mut engine, node, LocalFeatureKey::Class(guard)); } - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); assert!(engine.begin_cold_matching_batch(nodes[0])); @@ -3987,7 +4086,7 @@ fn an_alternate_ancestor_witness_keeps_a_candidate_out_of_the_plan() { } engine.end_cold_matching_batch(); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert!(planned.is_empty()); @@ -4002,10 +4101,10 @@ fn an_empty_sparse_route_does_not_compile_its_region() { let guard = StyleAtomID(200); let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); discard_transaction(&mut engine); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert!(planned.is_empty()); @@ -4017,8 +4116,8 @@ fn overlapping_prefix_changes_are_evaluated_once() { let guard = StyleAtomID(200); let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); assert!(engine.begin_cold_matching_batch(nodes[0])); @@ -4027,8 +4126,8 @@ fn overlapping_prefix_changes_are_evaluated_once() { } engine.end_cold_matching_batch(); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); - add_feature(&mut engine, nodes[2], FeatureKey::Class(target)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(target)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![nodes[3].raw()]); @@ -4044,7 +4143,7 @@ fn retained_prefix_transitions_supply_invalidation_and_matching() { let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4055,7 +4154,7 @@ fn retained_prefix_transitions_supply_invalidation_and_matching() { engine.end_cold_matching_batch(); assert!(engine.memory().bytes_in_category(MemoryCategory::PrefixTransitionCache) > 0); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![nodes[3].raw()]); @@ -4063,7 +4162,7 @@ fn retained_prefix_transitions_supply_invalidation_and_matching() { assert!(engine.match_element(nodes[3]).unwrap().is_empty()); engine.end_cold_matching_batch(); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); planned.clear(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![nodes[3].raw()]); @@ -4077,7 +4176,7 @@ fn covered_prefix_changes_forget_only_the_covered_subtree() { let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4087,7 +4186,7 @@ fn covered_prefix_changes_forget_only_the_covered_subtree() { assert!(engine.memory().bytes_in_category(MemoryCategory::PrefixTransitionCache) > 0); assert!(engine.memory().bytes_in_category(MemoryCategory::PrefixAnswerCache) > 0); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut transaction = engine.take_transaction(); let mut regions = ImpactRegions::new(); engine.transaction_fact_view = Some(engine.transaction_fact_view_for(&mut transaction, nodes[0], ®ions)); @@ -4164,14 +4263,14 @@ fn selective_matching_completes_a_bounded_prefix_transition_window() { ); } for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } let guard = StyleAtomID(200); let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[0], guard), (*nodes.last().unwrap(), target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4183,7 +4282,7 @@ fn selective_matching_completes_a_bounded_prefix_transition_window() { assert_eq!(engine.match_element(*nodes.last().unwrap()).unwrap().len(), 1); engine.end_cold_matching_batch(); - remove_feature(&mut engine, *nodes.last().unwrap(), FeatureKey::Class(target)); + remove_feature(&mut engine, *nodes.last().unwrap(), LocalFeatureKey::Class(target)); let cache_hits_before = engine.counters().get(Counter::PrefixTransitionCacheHits); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -4203,7 +4302,7 @@ fn a_prefix_upquery_retains_every_transition_on_its_ancestor_chain() { let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4244,7 +4343,7 @@ fn partial_match_answer_completion_shares_prefix_states_between_nodes() { } } for (node, class) in [(nodes[1], guard), (nodes[2], target), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4272,7 +4371,7 @@ fn a_cached_prefix_answer_is_returned_in_cascade_order() { } } for (node, class) in [(nodes[1], guard), (nodes[2], target), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4306,7 +4405,7 @@ fn an_identity_only_published_prefix_answer_is_returned_in_cascade_order() { } } for (node, class) in [(nodes[1], guard), (nodes[2], target), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4368,7 +4467,7 @@ fn shared_retained_answer_completion_reuses_compact_cascade_state() { } } for node in [nodes[2], nodes[3]] { - add_feature(&mut engine, node, FeatureKey::Class(target)); + add_feature(&mut engine, node, LocalFeatureKey::Class(target)); } discard_transaction(&mut engine); @@ -4431,7 +4530,7 @@ fn closure_identity_stop_verification_is_observer_only() { } } for (node, class) in [(nodes[1], guard), (nodes[2], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4481,7 +4580,7 @@ fn a_cached_prefix_answer_preserves_incomplete_cascade_winners() { engine.set_rule_declared_properties(rule, &[(1, false)], true); engine.set_rule_gated_by_container_query(rule); for (node, class) in [(nodes[1], guard), (nodes[2], target), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4529,14 +4628,14 @@ fn retained_prefix_convergence_amortizes_missing_subtrees() { ); } for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } let guard = StyleAtomID(200); let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[0], guard), (*nodes.last().unwrap(), target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4545,7 +4644,7 @@ fn retained_prefix_convergence_amortizes_missing_subtrees() { engine.match_element(nodes[0]).unwrap(); engine.end_cold_matching_batch(); - remove_feature(&mut engine, nodes[0], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[0], LocalFeatureKey::Class(guard)); let upqueries_before = engine.counters().get(Counter::PrefixConvergenceUpqueries); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -4555,7 +4654,7 @@ fn retained_prefix_convergence_amortizes_missing_subtrees() { PREFIX_TRANSITION_CACHE_COMPLETION_BUDGET as u64 ); - add_feature(&mut engine, nodes[0], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[0], LocalFeatureKey::Class(guard)); let upqueries_before = engine.counters().get(Counter::PrefixConvergenceUpqueries); planned.clear(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -4574,7 +4673,7 @@ fn equivalent_prefix_contributions_share_a_cascade_answer() { let rule = add_guard_target_rule(&mut engine, guard, target); engine.set_rule_declared_properties(rule, &[(1, false)], true); for (node, class) in [(nodes[1], guard), (nodes[2], target), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4603,7 +4702,7 @@ fn element_declarations_refuse_selector_only_prefix_answer_reuse() { let rule = add_guard_target_rule(&mut engine, guard, target); engine.set_rule_declared_properties(rule, &[(1, false)], true); for (node, class) in [(nodes[1], guard), (nodes[2], target), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } for (node, value) in [(nodes[2], SpecifiedValueID(102)), (nodes[3], SpecifiedValueID(103))] { engine.set_element_declared_properties( @@ -4642,7 +4741,7 @@ fn retained_prefix_transitions_are_discarded_under_pressure() { let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4659,7 +4758,7 @@ fn retained_prefix_transitions_are_discarded_under_pressure() { ); assert_eq!(engine.memory().bytes_in_category(MemoryCategory::BatchScratch), 0); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![nodes[3].raw()]); @@ -4707,7 +4806,7 @@ fn reused_element_identity_does_not_inherit_a_prefix_transition() { let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -4728,7 +4827,7 @@ fn reused_element_identity_does_not_inherit_a_prefix_transition() { assert_eq!(reused_raw[0], nodes[3].raw()); let reused = StyleNodeID::from_raw(reused_raw[0]).unwrap(); engine.record_tree_delta(reused, None, Some(old_relations)); - add_feature(&mut engine, reused, FeatureKey::Class(target)); + add_feature(&mut engine, reused, LocalFeatureKey::Class(target)); planned.clear(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -4743,8 +4842,8 @@ fn unobserved_inputs_do_not_seed_prefix_convergence() { add_guard_target_rule(&mut engine, guard, target); discard_transaction(&mut engine); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); - add_feature(&mut engine, nodes[2], FeatureKey::Class(StyleAtomID(202))); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(StyleAtomID(202))); let mut transaction = engine.take_transaction(); let view = engine.transaction_fact_view_for(&mut transaction, nodes[0], &ImpactRegions::new()); assert_eq!(view.before.as_ref().unwrap().row_count(), 2); @@ -4759,7 +4858,7 @@ fn state_input_commits_after_its_old_row_is_snapshotted() { let class = StyleAtomID(200); discard_transaction(&mut engine); - add_feature(&mut engine, nodes[1], FeatureKey::Class(class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(class)); engine.record_input( InputKey::State(nodes[1], StateFact::Hover), InputValue::State(false), @@ -4789,7 +4888,7 @@ fn selector_queries_advance_current_facts_without_losing_the_transaction_before_ let class = StyleAtomID(200); discard_transaction(&mut engine); - add_feature(&mut engine, nodes[1], FeatureKey::Class(class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(class)); engine.prepare_selector_query(); let current = engine.facts.primary(); let current_row = current.row_of(nodes[1]).unwrap(); @@ -4831,8 +4930,8 @@ fn prefix_transition_uses_arrival_region_coverage() { Some(relations(Some(nodes[2].raw()), Some(nodes[3].raw()), None)), ); engine.record_tree_delta(nested_arrival, None, Some(relations(Some(arrival.raw()), None, None))); - add_feature(&mut engine, nested_arrival, FeatureKey::Class(guard)); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + add_feature(&mut engine, nested_arrival, LocalFeatureKey::Class(guard)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let transaction = engine.take_transaction(); let mut regions = ImpactRegions::with_topology(&engine.tree, nodes[0]); @@ -4851,8 +4950,8 @@ fn prefix_convergence_skips_an_already_dirty_arrival() { let guard = StyleAtomID(200); let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); assert!(engine.begin_cold_matching_batch(nodes[0])); @@ -4869,8 +4968,8 @@ fn prefix_convergence_skips_an_already_dirty_arrival() { None, Some(relations(Some(nodes[2].raw()), Some(nodes[3].raw()), None)), ); - add_feature(&mut engine, arrival, FeatureKey::ArrivingFacts); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + add_feature(&mut engine, arrival, LocalFeatureKey::ArrivingFacts); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![nodes[3].raw(), arrival.raw()]); @@ -4889,7 +4988,7 @@ fn a_parent_change_has_no_prefix_fact_transition() { Some(relations(Some(nodes[2].raw()), None, None)), Some(relations(Some(nodes[0].raw()), Some(nodes[1].raw()), None)), ); - add_feature(&mut engine, nodes[1], FeatureKey::Class(StyleAtomID(200))); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(StyleAtomID(200))); let transaction = engine.take_transaction(); assert!( engine @@ -4907,9 +5006,9 @@ fn an_alternate_preceding_sibling_keeps_a_candidate_out_of_the_plan() { let target = StyleAtomID(201); add_guard_sibling_target_rule(&mut engine, guard, target); for node in [nodes[1], nodes[2]] { - add_feature(&mut engine, node, FeatureKey::Class(guard)); + add_feature(&mut engine, node, LocalFeatureKey::Class(guard)); } - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); engine.record_tree_delta( @@ -4935,10 +5034,10 @@ fn an_adjacent_replacement_that_preserves_truth_stays_out_of_the_plan() { add_two_class_adjacent_target_rule(&mut engine, guard, also, target); for node in [nodes[1], nodes[2]] { for class in [guard, also] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } } - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); engine.record_tree_delta( @@ -4963,9 +5062,9 @@ fn an_old_adjacent_chain_is_compared_with_its_replacement_chain() { let target = StyleAtomID(201); add_double_guard_adjacent_target_rule(&mut engine, guard, target); for node in [nodes[1], nodes[2]] { - add_feature(&mut engine, node, FeatureKey::Class(guard)); + add_feature(&mut engine, node, LocalFeatureKey::Class(guard)); } - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); engine.record_tree_delta( @@ -5076,8 +5175,8 @@ fn positional_answers_stay_cold_equivalent_across_sequence_mutations() { 0 => tag_a, _ => tag_b, }; - set_atom_feature(engine, node, FeatureKey::TagName, tag); - add_feature(engine, node, FeatureKey::Class(class)); + set_atom_feature(engine, node, LocalFeatureKey::TagName, tag); + add_feature(engine, node, LocalFeatureKey::Class(class)); }; record_facts(&mut engine, root, class_theme); record_facts(&mut engine, container, class_theme); @@ -5217,7 +5316,7 @@ fn positional_answers_stay_cold_equivalent_across_sequence_mutations() { // A pure fact flush for good measure: dropping `.item` changes the node's own answers // without moving any position. - remove_feature(&mut engine, model[1], FeatureKey::Class(class_item)); + remove_feature(&mut engine, model[1], LocalFeatureKey::Class(class_item)); flush_and_check(&mut engine, &model, "a class removal"); // A grandchild arrival flips its parent's `:empty` while the parent records no input @@ -5278,10 +5377,10 @@ fn positional_test_overflow_refuses_admission_without_erasing_answers() { engine.record_tree_delta(child, None, Some(relations(Some(container.raw()), previous, next))); } for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); } for &child in children { - add_feature(&mut engine, child, FeatureKey::Class(class_item)); + add_feature(&mut engine, child, LocalFeatureKey::Class(class_item)); } discard_transaction(&mut engine); @@ -5324,9 +5423,9 @@ fn consecutive_departures_reconstruct_one_old_sibling_sequence() { ); } for node in &nodes[1..4] { - add_feature(&mut engine, *node, FeatureKey::Class(guard)); + add_feature(&mut engine, *node, LocalFeatureKey::Class(guard)); } - add_feature(&mut engine, nodes[4], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[4], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); engine.record_tree_delta( @@ -5376,9 +5475,9 @@ fn converging_departure_routes_are_folded_before_exact_tree_evaluation() { ); } for node in &nodes[1..4] { - add_feature(&mut engine, *node, FeatureKey::Class(guard)); + add_feature(&mut engine, *node, LocalFeatureKey::Class(guard)); } - add_feature(&mut engine, nodes[4], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[4], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); engine.record_tree_delta( @@ -5470,12 +5569,12 @@ fn a_departed_sibling_reaches_only_the_following_sibling_forest() { engine.record_tree_delta(target_node, None, Some(relations(Some(parent.raw()), None, None))); } for node in [nodes[1], nodes[4], nodes[6]] { - add_feature(&mut engine, node, FeatureKey::Class(container)); + add_feature(&mut engine, node, LocalFeatureKey::Class(container)); } for node in [nodes[2], nodes[5], nodes[7]] { - add_feature(&mut engine, node, FeatureKey::Class(target)); + add_feature(&mut engine, node, LocalFeatureKey::Class(target)); } - add_feature(&mut engine, nodes[3], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(guard)); discard_transaction(&mut engine); engine.record_tree_delta( @@ -5513,7 +5612,7 @@ fn a_stationary_general_sibling_ignores_its_new_immediate_neighbour() { let target = StyleAtomID(201); add_guard_sibling_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[2], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -5557,7 +5656,7 @@ fn a_stationary_predecessor_is_not_a_moved_place() { let target = StyleAtomID(201); add_guard_sibling_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[2], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -5573,7 +5672,7 @@ fn a_stationary_predecessor_is_not_a_moved_place() { Some(nodes[2].raw()), )), ); - set_atom_feature(&mut engine, inserted, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, inserted, LocalFeatureKey::TagName, StyleAtomID(100)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![inserted.raw()]); @@ -5602,7 +5701,7 @@ fn an_arriving_adjacent_sibling_that_fails_its_compound_changes_nothing() { add_two_class_adjacent_target_rule(&mut engine, guard, also, target); // The arriving predecessor carries the class the entry dispatches on and not the one it // also requires, so it cannot satisfy the compound and the target's answer cannot move. - add_feature(&mut engine, nodes[2], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let mut raw = [0_u32; 1]; @@ -5617,7 +5716,7 @@ fn an_arriving_adjacent_sibling_that_fails_its_compound_changes_nothing() { Some(nodes[2].raw()), )), ); - add_feature(&mut engine, inserted, FeatureKey::Class(guard)); + add_feature(&mut engine, inserted, LocalFeatureKey::Class(guard)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![inserted.raw()]); @@ -5630,14 +5729,14 @@ fn an_arriving_sibling_with_an_existing_witness_restyles_only_itself() { let target = StyleAtomID(201); add_guard_sibling_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); let mut raw = [0_u32; 1]; engine.allocate_style_nodes(&mut raw); let inserted = StyleNodeID::from_raw(raw[0]).unwrap(); - add_feature(&mut engine, inserted, FeatureKey::Class(guard)); + add_feature(&mut engine, inserted, LocalFeatureKey::Class(guard)); discard_transaction(&mut engine); engine.record_tree_delta( @@ -5649,7 +5748,7 @@ fn an_arriving_sibling_with_an_existing_witness_restyles_only_itself() { Some(nodes[3].raw()), )), ); - add_feature(&mut engine, inserted, FeatureKey::ArrivingFacts); + add_feature(&mut engine, inserted, LocalFeatureKey::ArrivingFacts); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -5664,13 +5763,13 @@ fn exact_candidate_checks_restore_every_old_fact_in_the_transaction() { let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for node in [nodes[1], nodes[2]] { - add_feature(&mut engine, node, FeatureKey::Class(guard)); + add_feature(&mut engine, node, LocalFeatureKey::Class(guard)); } - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); for node in [nodes[1], nodes[2]] { - remove_feature(&mut engine, node, FeatureKey::Class(guard)); + remove_feature(&mut engine, node, LocalFeatureKey::Class(guard)); } let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); @@ -5685,10 +5784,10 @@ fn a_sibling_entry_ask_seeds_its_left_context() { let target = StyleAtomID(201); add_guard_sibling_target_rule(&mut engine, guard, target); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); } for (node, class) in [(nodes[2], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -5716,10 +5815,10 @@ fn a_general_sibling_fact_miss_is_batched_before_matching_restarts() { let target = StyleAtomID(201); add_guard_sibling_target_fallback_rule(&mut engine, guard, target); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); } for (node, class) in [(nodes[2], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -5754,9 +5853,9 @@ fn a_descendant_fact_miss_is_batched_before_matching_restarts() { ); } for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); } - add_feature(&mut engine, nodes[0], FeatureKey::Class(anchor)); + add_feature(&mut engine, nodes[0], LocalFeatureKey::Class(anchor)); discard_transaction(&mut engine); let evaluations_before = engine.counters().get(Counter::ColdNodesEvaluated); @@ -5782,10 +5881,10 @@ fn a_broad_matching_batch_shares_facts_between_element_asks() { let target = StyleAtomID(201); add_guard_sibling_target_rule(&mut engine, guard, target); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); } for (node, class) in [(nodes[2], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -5842,7 +5941,7 @@ fn a_broad_matching_batch_includes_shadow_scope_roots() { engine.set_shadow_root(*host, *shadow_root); engine.set_tree_scope_root(scope, *shadow_root); for node in [*document_root, *host, *child] { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } discard_transaction(&mut engine); @@ -5904,7 +6003,7 @@ fn independent_sibling_paths_request_their_fact_ranges_together() { Some(relations(Some(nodes[2].raw()), Some(nodes[3].raw()), None)), ); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); } for (node, class) in [ (nodes[1], outer_guard), @@ -5912,7 +6011,7 @@ fn independent_sibling_paths_request_their_fact_ranges_together() { (nodes[3], inner_guard), (nodes[4], target), ] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -5939,10 +6038,10 @@ fn completed_cold_candidates_are_not_replayed_after_a_fact_miss() { add_two_class_target_rule(&mut engine, StyleSheetObjectID(3), target, missing); add_guard_sibling_target_fallback_rule(&mut engine, guard, target); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); } for (node, class) in [(nodes[2], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -5971,10 +6070,10 @@ fn a_selector_list_merges_matches_retained_across_retries() { let extra = StyleAtomID(202); add_retrying_selector_list_rule(&mut engine, guard, target, extra); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); } for (node, class) in [(nodes[2], guard), (nodes[3], target), (nodes[3], extra)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); @@ -5999,9 +6098,9 @@ fn a_positional_fact_miss_is_batched_before_matching_restarts() { let target = StyleAtomID(201); add_nth_of_type_target_rule(&mut engine, target, 0, 2); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); } - add_feature(&mut engine, nodes[2], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let evaluations_before = engine.counters().get(Counter::ColdNodesEvaluated); @@ -6072,10 +6171,10 @@ fn typed_nth_of_type_document() -> (StyleEngine, Vec) { (nodes[2], other_namespace), (nodes[3], first_namespace), ] { - set_atom_feature(&mut engine, node, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, tag); engine.set_element_namespace(node, namespace); } - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); (engine, nodes) } @@ -6127,7 +6226,7 @@ fn insert_typed_child(engine: &mut StyleEngine, nodes: &[StyleNodeID], namespace Some(nodes[3].raw()), )), ); - set_atom_feature(engine, inserted, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(engine, inserted, LocalFeatureKey::TagName, StyleAtomID(100)); engine.set_element_namespace(inserted, namespace); inserted } @@ -6160,16 +6259,16 @@ fn an_arrival_rejects_an_unchanged_of_type_position() { let target = StyleAtomID(201); add_nth_of_type_target_rule(&mut engine, target, 4, 0); for &(node, node_tag) in &[(nodes[1], tag), (nodes[2], StyleAtomID(101)), (nodes[3], tag)] { - set_atom_feature(&mut engine, node, FeatureKey::TagName, node_tag); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, node_tag); engine.set_element_namespace(node, namespace); } - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); let mut raw = [0_u32; 1]; engine.allocate_style_nodes(&mut raw); let inserted = StyleNodeID::from_raw(raw[0]).unwrap(); - set_atom_feature(&mut engine, inserted, FeatureKey::TagName, tag); + set_atom_feature(&mut engine, inserted, LocalFeatureKey::TagName, tag); engine.set_element_namespace(inserted, namespace); discard_transaction(&mut engine); @@ -6182,7 +6281,7 @@ fn an_arrival_rejects_an_unchanged_of_type_position() { Some(nodes[3].raw()), )), ); - add_feature(&mut engine, inserted, FeatureKey::ArrivingFacts); + add_feature(&mut engine, inserted, LocalFeatureKey::ArrivingFacts); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![inserted.raw()]); @@ -6215,7 +6314,7 @@ fn nth_target_document() -> (StyleEngine, Vec) { let target = StyleAtomID(201); add_nth_target_rule(&mut engine, target, 3, 0); for &node in &nodes[1..] { - add_feature(&mut engine, node, FeatureKey::Class(target)); + add_feature(&mut engine, node, LocalFeatureKey::Class(target)); } discard_transaction(&mut engine); (engine, nodes) @@ -6291,7 +6390,7 @@ fn exact_planning_shares_current_relation_indexes_with_matching() { None, Some(relations(Some(nodes[0].raw()), None, Some(nodes[1].raw()))), ); - set_atom_feature(&mut engine, inserted, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, inserted, LocalFeatureKey::TagName, StyleAtomID(100)); assert!(engine.take_style_transaction(nodes[0], |_, _, _| {})); engine.begin_adaptive_cold_matching_batch(nodes[0]); @@ -6307,7 +6406,7 @@ fn scoped_planning_does_not_prepare_a_complete_matching_batch() { add_target_rule(&mut engine, StyleSheetObjectID(1), target); discard_transaction(&mut engine); - add_feature(&mut engine, nodes[2], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(target)); let inspected_before = engine .counters() @@ -6335,13 +6434,13 @@ fn a_published_local_reaction_names_its_semantic_provenance() { let (mut engine, nodes) = nested_document(); let target = StyleAtomID(201); add_target_rule(&mut engine, StyleSheetObjectID(1), target); - add_feature(&mut engine, nodes[2], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); engine.begin_adaptive_cold_matching_batch(nodes[0]); assert_eq!(engine.match_element_for_cascade(nodes[2]).unwrap().len(), 1); engine.end_cold_matching_batch(); - remove_feature(&mut engine, nodes[2], FeatureKey::Class(target)); + remove_feature(&mut engine, nodes[2], LocalFeatureKey::Class(target)); let direct_before = engine.counters().get(Counter::PlannedNodesWithDirectAction); let signed_before = engine.counters().get(Counter::PlannedNodesWithSignedDelta); @@ -6425,7 +6524,7 @@ fn published_match_answers_name_transaction_program_and_identity() { engine.set_rule_declared_properties(rule, &[(1, false)], true); discard_transaction(&mut engine); - add_feature(&mut engine, nodes[1], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); let expected_program_version = engine.program.version(); let mut published = Vec::new(); @@ -6521,7 +6620,7 @@ fn a_shadow_root_routes_without_being_published_as_a_style_output() { engine.record_tree_delta(shadow_root, None, Some(relations(Some(host.raw()), None, None))); engine.set_tree_scope_root(TreeScopeID(1), shadow_root); for node in [host, shadow_root] { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } discard_transaction(&mut engine); @@ -6551,13 +6650,17 @@ fn exact_planning_carries_preorder_topology_into_matching() { let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); for offset in 0..31 { - add_feature(&mut engine, nodes[0], FeatureKey::Attribute(StyleAtomID(300 + offset))); + add_feature( + &mut engine, + nodes[0], + LocalFeatureKey::Attribute(StyleAtomID(300 + offset)), + ); } let mut planned = Vec::new(); assert!(engine.take_style_transaction(nodes[0], |_, _, reactions| { @@ -6596,12 +6699,12 @@ fn sequence_routing_rejects_children_outside_the_positional_compound() { Some(relations(Some(nodes[0].raw()), Some(previous.raw()), None)), ); engine.record_tree_delta(descendant, None, Some(relations(Some(container.raw()), None, None))); - add_feature(&mut engine, descendant, FeatureKey::Class(target)); + add_feature(&mut engine, descendant, LocalFeatureKey::Class(target)); } for class in [guard, also] { - add_feature(&mut engine, nodes[2], FeatureKey::Class(class)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(class)); } - add_feature(&mut engine, nodes[4], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[4], LocalFeatureKey::Class(guard)); discard_transaction(&mut engine); engine.record_tree_delta( @@ -6620,11 +6723,11 @@ fn an_exact_batch_filters_a_featureless_subject() { let guard = StyleAtomID(200); add_guard_universal_rule(&mut engine, guard); for node in [nodes[1], nodes[2]] { - add_feature(&mut engine, node, FeatureKey::Class(guard)); + add_feature(&mut engine, node, LocalFeatureKey::Class(guard)); } discard_transaction(&mut engine); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![nodes[2].raw()]); @@ -6637,8 +6740,8 @@ fn ancestor_requirement_scratch_is_released_after_document_matching() { let guard = StyleAtomID(200); let target = StyleAtomID(201); add_guard_target_rule(&mut engine, guard, target); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); assert_eq!(engine.match_document(nodes[0]), Ok(1)); @@ -6693,11 +6796,11 @@ fn identical_sheet_sets_share_a_scope_program() { ); engine.set_shadow_root(host, root); engine.set_tree_scope_root(scope, root); - set_atom_feature(&mut engine, child, FeatureKey::TagName, StyleAtomID(100)); - add_feature(&mut engine, child, FeatureKey::Class(StyleAtomID(200))); + set_atom_feature(&mut engine, child, LocalFeatureKey::TagName, StyleAtomID(100)); + add_feature(&mut engine, child, LocalFeatureKey::Class(StyleAtomID(200))); } for node in [*document_root, *first_host, *second_host] { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } let program = engine @@ -6860,8 +6963,8 @@ fn a_scope_dispatch_can_extend_a_finished_prefix_template() { assert_eq!(extended.entries(), cold.entries()); assert_eq!(extended.ancestor_dispatch_shape(), cold.ancestor_dispatch_shape()); - assert!(extended.prefixes().contains_entry(base, 0)); - assert!(extended.prefixes().contains_entry(suffix, 0)); + assert!(extended.prefixes().contains_entry(programs.entry_id(base, 0))); + assert!(extended.prefixes().contains_entry(programs.entry_id(suffix, 0))); } #[test] @@ -7002,9 +7105,9 @@ fn rule_activation_reaches_only_current_selector_matches() { let guard = StyleAtomID(200); let target = StyleAtomID(201); let rule = add_guard_target_rule(&mut engine, guard, target); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); for node in [nodes[0], nodes[3]] { - add_feature(&mut engine, node, FeatureKey::Class(target)); + add_feature(&mut engine, node, LocalFeatureKey::Class(target)); } discard_transaction(&mut engine); @@ -7029,12 +7132,12 @@ fn rule_deactivation_reaches_only_nodes_where_the_rule_won() { let winner = add_target_rule(&mut engine, StyleSheetObjectID(2), overriding); engine.set_rule_declared_properties(winner, &[(1, true)], true); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } for node in [nodes[1], nodes[2]] { - add_feature(&mut engine, node, FeatureKey::Class(target)); + add_feature(&mut engine, node, LocalFeatureKey::Class(target)); } - add_feature(&mut engine, nodes[1], FeatureKey::Class(overriding)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(overriding)); discard_transaction(&mut engine); for &node in &nodes { @@ -7074,7 +7177,7 @@ fn local_routes_for_one_exact_entry_are_compared_once() { version.declaration_block = Some(DeclarationBlockID(1)); engine.replace_rule_version(rule, version); engine.set_rule_declared_properties(rule, &[(1, false)], true); - add_feature(&mut engine, nodes[3], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); for &node in &nodes { @@ -7086,8 +7189,8 @@ fn local_routes_for_one_exact_entry_are_compared_once() { } let grouped_before = engine.counters().get(Counter::GroupedExactSelectorRoutes); - add_feature(&mut engine, nodes[1], FeatureKey::Class(first)); - add_feature(&mut engine, nodes[1], FeatureKey::Class(second)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(first)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(second)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!(planned, vec![nodes[3].raw()]); @@ -7119,7 +7222,7 @@ fn rule_activation_exactly_matches_a_refused_prefix_chain() { } engine.record_tree_delta(nodes[35], None, Some(relations(Some(nodes[34].raw()), None, None))); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } let target = StyleAtomID(300); let sheet = engine.add_sheet(StyleSheetObjectID(1), CascadeOrigin::Author); @@ -7140,11 +7243,11 @@ fn rule_activation_exactly_matches_a_refused_prefix_chain() { version.declaration_block = Some(DeclarationBlockID(index + 1)); engine.replace_rule_version(rule, version); engine.set_rule_declared_properties(rule, &[(index as u16 + 1, false)], true); - add_feature(&mut engine, nodes[index as usize + 1], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[index as usize + 1], LocalFeatureKey::Class(guard)); refused_rule = Some(rule); refused_program = Some(program); } - add_feature(&mut engine, nodes[35], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[35], LocalFeatureKey::Class(target)); let refused_rule = refused_rule.unwrap(); let refused_program = refused_program.unwrap(); engine.set_rule_conditions_hold(refused_rule, false); @@ -7152,7 +7255,11 @@ fn rule_activation_exactly_matches_a_refused_prefix_chain() { let (_, dispatch) = engine.ranked_scope_program(TreeScopeID::DOCUMENT); assert!(!dispatch.prefixes().is_empty()); - assert!(!dispatch.prefixes().contains_entry(refused_program, 0)); + assert!( + !dispatch + .prefixes() + .contains_entry(engine.programs.entry_id(refused_program, 0)) + ); assert!(engine.begin_cold_matching_batch(nodes[0])); for &node in &nodes { engine.match_element(node).unwrap(); @@ -7182,12 +7289,12 @@ fn rule_activation_uses_the_fact_side_where_the_rule_contributes() { let target = StyleAtomID(201); let rule = add_guard_target_rule(&mut engine, guard, target); for (node, class) in [(nodes[1], guard), (nodes[3], target)] { - add_feature(&mut engine, node, FeatureKey::Class(class)); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); } discard_transaction(&mut engine); engine.set_rule_conditions_hold(rule, false); - remove_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); let mut planned = Vec::new(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!( @@ -7197,7 +7304,7 @@ fn rule_activation_uses_the_fact_side_where_the_rule_contributes() { ); engine.set_rule_conditions_hold(rule, true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); planned.clear(); assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); assert_eq!( @@ -7215,9 +7322,9 @@ fn a_sheet_transition_reaches_only_selector_matches() { let target = StyleAtomID(201); let rule = add_guard_target_rule(&mut engine, guard, target); let sheet = engine.program.rule_sheet(rule); - add_feature(&mut engine, nodes[1], FeatureKey::Class(guard)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(guard)); for node in [nodes[0], nodes[3]] { - add_feature(&mut engine, node, FeatureKey::Class(target)); + add_feature(&mut engine, node, LocalFeatureKey::Class(target)); } discard_transaction(&mut engine); @@ -7246,9 +7353,9 @@ fn any_matching_selector_list_entry_keeps_an_activation_candidate() { let absent_guard = StyleAtomID(201); let target = StyleAtomID(202); let rule = add_guard_target_selector_list_rule(&mut engine, matching_guard, absent_guard, target); - add_feature(&mut engine, nodes[1], FeatureKey::Class(matching_guard)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(matching_guard)); for node in [nodes[0], nodes[3]] { - add_feature(&mut engine, node, FeatureKey::Class(target)); + add_feature(&mut engine, node, LocalFeatureKey::Class(target)); } discard_transaction(&mut engine); @@ -7265,7 +7372,7 @@ fn incomplete_selector_facts_keep_an_activation_candidate() { let guard = StyleAtomID(200); let target = StyleAtomID(201); let rule = add_guard_sibling_target_rule(&mut engine, guard, target); - add_feature(&mut engine, nodes[2], FeatureKey::Class(target)); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(target)); discard_transaction(&mut engine); engine.set_rule_conditions_hold(rule, false); @@ -7772,16 +7879,16 @@ fn an_added_rule_that_loses_everywhere_confirms_without_cold_matching() { let loser = add_guard_target_rule_in_sheet(&mut engine, StyleSheetObjectID(3), guard_class, target_class); engine.set_rule_declared_properties(loser, &[(1, false)], true); for &node in &nodes { - set_atom_feature(&mut engine, node, FeatureKey::TagName, StyleAtomID(100)); + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); } - add_feature(&mut engine, nodes[1], FeatureKey::Class(target_class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target_class)); discard_transaction(&mut engine); let old_answer = engine.match_element_for_cascade(nodes[1]).unwrap(); assert!(old_answer.iter().any(|entry| entry.rule == important)); publish_current_cascade_as_computed(&mut engine, nodes[1]); - add_feature(&mut engine, nodes[0], FeatureKey::Class(guard_class)); + add_feature(&mut engine, nodes[0], LocalFeatureKey::Class(guard_class)); let stops_before = engine.counters().get(Counter::PublishedExactCascadeStops); let proofs_before = engine.counters().get(Counter::TransitionProofConfirmed); let mut planned = Vec::new(); @@ -7814,7 +7921,7 @@ fn answer_transitions_refuse_equality_removals_and_winning_additions() { engine.set_rule_declared_properties(base, &[(1, false)], true); let winner = add_target_rule(&mut engine, StyleSheetObjectID(2), toggled_class); engine.set_rule_declared_properties(winner, &[(1, false)], true); - add_feature(&mut engine, nodes[1], FeatureKey::Class(anchor_class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(anchor_class)); discard_transaction(&mut engine); let exact = engine.match_element(nodes[1]).unwrap(); @@ -7829,7 +7936,7 @@ fn answer_transitions_refuse_equality_removals_and_winning_additions() { // Equality is never a proof: a stale retained answer compares equal to itself. assert!(!engine.answer_transition_cannot_change_cascade(nodes[1], before_input, before_input)); - add_feature(&mut engine, nodes[1], FeatureKey::Class(toggled_class)); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(toggled_class)); discard_transaction(&mut engine); let exact = engine.match_element(nodes[1]).unwrap(); let with = engine.matches_for_cascade(exact.clone(), false, Some(nodes[1])); diff --git a/Libraries/LibWeb/Rust/src/css/style/transaction.rs b/Libraries/LibWeb/Rust/src/css/style/transaction.rs index 0223503ad00e..f1c8f72bc90d 100644 --- a/Libraries/LibWeb/Rust/src/css/style/transaction.rs +++ b/Libraries/LibWeb/Rust/src/css/style/transaction.rs @@ -12,8 +12,8 @@ use super::fast_hash::FastMap as HashMap; use super::capacity::capacity_bytes; -use super::index::FeatureKey; use super::index::FeatureValue; +use super::index::LocalFeatureKey; use super::index::StyleNodeFacts; use super::instrumentation::Counter; use super::instrumentation::Counters; @@ -181,7 +181,7 @@ define_input_kinds! { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum InputKey { TreeRelations(StyleNodeID), - LocalFeature(StyleNodeID, FeatureKey), + LocalFeature(StyleNodeID, LocalFeatureKey), State(StyleNodeID, StateFact), ElementDeclaration(StyleNodeID, ElementDeclarationKind), /// A non-selector style input owned by the element changed. This is an edge-triggered action, @@ -622,7 +622,7 @@ impl NormalizationJournal { return true; } match input.key { - InputKey::LocalFeature(_, FeatureKey::ArrivingFacts) => false, + InputKey::LocalFeature(_, LocalFeatureKey::ArrivingFacts) => false, InputKey::LocalFeature(..) | InputKey::State(..) => { counters.bump(Counter::ArrivingNodeFactsFolded); false @@ -631,7 +631,7 @@ impl NormalizationJournal { } }); inputs.extend(arriving_nodes.into_iter().map(|node| NormalizedInput { - key: InputKey::LocalFeature(node, FeatureKey::ArrivingFacts), + key: InputKey::LocalFeature(node, LocalFeatureKey::ArrivingFacts), old: InputValue::Feature(FeatureValue::Absent), new: InputValue::Feature(FeatureValue::Present), })); @@ -797,7 +797,7 @@ mod tests { }) }; self.record( - InputKey::LocalFeature(StyleNodeID::element(node), FeatureKey::Class(StyleAtomID(class))), + InputKey::LocalFeature(StyleNodeID::element(node), LocalFeatureKey::Class(StyleAtomID(class))), value(old), value(new), ); @@ -930,7 +930,7 @@ mod tests { let mut fixture = JournalFixture::new(); let node = StyleNodeID::element(5); fixture.record( - InputKey::LocalFeature(node, FeatureKey::TagName), + InputKey::LocalFeature(node, LocalFeatureKey::TagName), InputValue::Feature(FeatureValue::Absent), InputValue::Feature(FeatureValue::Atom(StyleAtomID(1))), ); @@ -954,7 +954,7 @@ mod tests { && input.new == relations(1) })); assert!(transaction.inputs.iter().any(|input| { - input.key == InputKey::LocalFeature(node, FeatureKey::ArrivingFacts) + input.key == InputKey::LocalFeature(node, LocalFeatureKey::ArrivingFacts) && input.old == InputValue::Feature(FeatureValue::Absent) && input.new == InputValue::Feature(FeatureValue::Present) })); diff --git a/Tests/LibWeb/Text/expected/css/style-engine/shared-entry-selector-truth.txt b/Tests/LibWeb/Text/expected/css/style-engine/shared-entry-selector-truth.txt new file mode 100644 index 000000000000..b8733d553359 --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/style-engine/shared-entry-selector-truth.txt @@ -0,0 +1,3 @@ +initial: block, rgb(0, 0, 0) +incremental: list-item, rgb(255, 0, 0) +cold: list-item, rgb(255, 0, 0) diff --git a/Tests/LibWeb/Text/input/css/style-engine/shared-entry-selector-truth.html b/Tests/LibWeb/Text/input/css/style-engine/shared-entry-selector-truth.html new file mode 100644 index 000000000000..26b2c739d7f9 --- /dev/null +++ b/Tests/LibWeb/Text/input/css/style-engine/shared-entry-selector-truth.html @@ -0,0 +1,27 @@ + + + +
+ From 842b32575f6a52304dfe3a77518d050d2d5f01c9 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sun, 16 Aug 2026 14:02:18 +0200 Subject: [PATCH 06/39] LibWeb: Stage tree relations as before/after rows Tree changes were staged in two hash maps and committed before routing, so the pre-transaction child order had to be reconstructed by a topological sort per touched parent, which silently disabled exact tree routing on ambiguity. Departures were collected separately and missed nodes that arrived and departed within one transaction. Stage every touched relation row as a frozen before value and a last-writer after value, indexed by element identity, and read old sibling rows from the pairs directly. Same-transaction arrivals that leave again remain visible to the later fact barrier, and the reconstruction and its ambiguity bail are gone. --- .../LibWeb/Rust/src/css/style/fast_hash.rs | 43 ---- Libraries/LibWeb/Rust/src/css/style/flush.rs | 227 +++++------------- Libraries/LibWeb/Rust/src/css/style/inputs.rs | 53 ++-- Libraries/LibWeb/Rust/src/css/style/mod.rs | 11 +- .../LibWeb/Rust/src/css/style/ordering.rs | 4 +- Libraries/LibWeb/Rust/src/css/style/tests.rs | 67 ++---- Libraries/LibWeb/Rust/src/css/style/tree.rs | 221 +++++++++++++++++ 7 files changed, 330 insertions(+), 296 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/fast_hash.rs b/Libraries/LibWeb/Rust/src/css/style/fast_hash.rs index 307a90e034c0..87949688a4a7 100644 --- a/Libraries/LibWeb/Rust/src/css/style/fast_hash.rs +++ b/Libraries/LibWeb/Rust/src/css/style/fast_hash.rs @@ -9,8 +9,6 @@ use std::collections::HashMap; use std::collections::HashSet; use std::hash::BuildHasher; -use std::hash::BuildHasherDefault; -use std::hash::Hasher; use foldhash::fast::FixedState; pub use foldhash::fast::FoldHasher as FastHasher; @@ -21,44 +19,3 @@ pub(crate) fn fast_hasher() -> FastHasher { pub type FastMap = HashMap; pub type FastSet = HashSet; - -// NB: Staged tree rows feed slot assignment order, which is observable through the recorded flat- -// tree callback. Keep its established iteration until that callback has a separately authorized -// canonical-order migration; all lookup-only engine tables use FixedState above. -#[derive(Default)] -pub(super) struct StableIterationHasher(u64); - -impl StableIterationHasher { - #[inline] - fn fold(&mut self, value: u64) { - self.0 = (self.0.rotate_left(26) ^ value).wrapping_mul(0x2545_f491_4f6c_dd1d); - } -} - -impl Hasher for StableIterationHasher { - fn finish(&self) -> u64 { - let mut mixed = self.0; - mixed ^= mixed >> 32; - mixed = mixed.wrapping_mul(0x2545_f491_4f6c_dd1d); - mixed ^ (mixed >> 29) - } - - fn write(&mut self, bytes: &[u8]) { - let mut chunks = bytes.chunks_exact(size_of::()); - for chunk in &mut chunks { - self.fold(u64::from_le_bytes(chunk.try_into().unwrap())); - } - let remainder = chunks.remainder(); - if !remainder.is_empty() { - let mut tail = [0_u8; size_of::()]; - tail[..remainder.len()].copy_from_slice(remainder); - self.fold(u64::from_le_bytes(tail) | ((remainder.len() as u64) << 56)); - } - } - - fn write_u32(&mut self, value: u32) { - self.fold(u64::from(value)); - } -} - -pub(super) type StableIterationMap = HashMap>; diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index 52e6f5c8a5db..dff6894d2dfe 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -317,7 +317,7 @@ impl StyleEngine { && exact_tree_routing_is_selective(arriving_nodes.len() + departing_nodes, connected_element_count); let mut transaction_fact_view = self.transaction_fact_view_for(&mut transaction, root, ®ions); if use_exact_tree_routing { - let _ = self.populate_before_sibling_relations(&mut transaction_fact_view, &transaction, &arriving_nodes); + let _ = self.install_before_sibling_geometry(&mut transaction_fact_view); } let has_before_sibling_relations = transaction_fact_view.before_sibling_relations_available; self.prefix_caches.borrow_mut().states.mark_previous(); @@ -1536,192 +1536,73 @@ impl StyleEngine { } #[must_use] - /// Reconstruct the old child sequences touched by this transaction's tree deltas. - /// - /// The normalized transaction holds each touched node's start-of-transaction relations, so - /// the old sequences are recoverable without replay: current children that did not move - /// keep their relative order, nodes that arrived were not there, and every node that - /// started under a parent names the neighbours it had then. Every reconstructed sequence - /// must admit exactly one topological order of those facts; ambiguity or contradiction - /// leaves the whole before side unavailable rather than guessing. - pub(super) fn populate_before_sibling_relations( - &self, - view: &mut TransactionFactView, - transaction: &StyleTransaction, - arriving_nodes: &[StyleNodeID], - ) -> bool { - // Nodes whose position genuinely moved, with their start and current relations. A delta - // that only renamed a neighbour leaves the node in place, so it stays with the current - // sequence order below. - let mut moved: Vec<(StyleNodeID, Option, Option)> = Vec::new(); - let mut absent: Vec = Vec::new(); - for input in &transaction.inputs { - let (InputKey::TreeRelations(node), InputValue::TreeRelations(old), InputValue::TreeRelations(new)) = - (input.key, input.old, input.new) - else { - continue; - }; - match (old, new) { - (Some(mut normalized), Some(new_relations)) => { - normalized.previous_element_sibling = new_relations.previous_element_sibling; - if normalized != new_relations { - moved.push((node, old, new)); - } - } - (Some(_), None) => moved.push((node, old, new)), - (None, Some(_)) => { - moved.push((node, old, new)); - absent.push(node); - } - // A node that arrived and departed within one transaction was absent on both - // sides; nothing places it, and the before side must not think it was there. - (None, None) => absent.push(node), - } - } - if moved.is_empty() && arriving_nodes.is_empty() { + /// Materialize old child sequences directly from the tree family's frozen before rows. + pub(super) fn install_before_sibling_geometry(&self, view: &mut TransactionFactView) -> bool { + let staged_rows = self.tree_staging.rows(); + if staged_rows.is_empty() { view.clear_before_sibling_relations(); return false; } - moved.sort_unstable_by_key(|&(node, ..)| node); - // The arriving set is authoritative for who was absent at the start even if - // normalization cancels that node's tree-relations entry. - absent.extend_from_slice(arriving_nodes); - absent.sort_unstable(); - absent.dedup(); - let mut parents: Vec = Vec::new(); - for &(_, old, new) in &moved { - if let Some(old_relations) = old { - parents.extend(old_relations.parent); + let mut parents = Vec::new(); + for &(_, before, after) in &staged_rows { + if let Some(relations) = before { + parents.extend(relations.parent); } - if let Some(new_relations) = new { - parents.extend(new_relations.parent); + if let Some(relations) = after { + parents.extend(relations.parent); } } - // A cancelled arrival entry loses its parent with it, but the node is live now, so the - // live tree still names the sequence its arrival disturbed. - for &node in arriving_nodes { - parents.extend(self.tree.parent(node)); - } + parents.extend( + self.tree_staging + .first_children() + .into_iter() + .map(|(parent, _, _)| parent), + ); parents.sort_unstable(); parents.dedup(); - for &node in &absent { - view.mark_before_absent(node); - } - for &parent in &parents { - if self - .reconstruct_before_sibling_sequence(view, parent, &moved, &absent) - .is_none() - { - view.clear_before_sibling_relations(); - return false; + for &(node, before, _) in &staged_rows { + if before.is_none() { + view.mark_before_absent(node); } } - view.finish_before_sibling_relations(); - true - } - /// Rebuild one parent's start-of-transaction child sequence from ordering facts, requiring - /// a unique topological order. - pub(super) fn reconstruct_before_sibling_sequence( - &self, - view: &mut TransactionFactView, - parent: StyleNodeID, - moved: &[(StyleNodeID, Option, Option)], - absent: &[StyleNodeID], - ) -> Option<()> { - // Current children that did not move preserve their relative order from the start of - // the transaction, so consecutive keepers yield ordering facts even across the places - // arrivals now occupy or departures used to. - let mut nodes: Vec = Vec::new(); - let mut edges: Vec<(StyleNodeID, StyleNodeID)> = Vec::new(); - let mut previous_kept: Option = None; - for child in self.tree.children(parent) { - if moved.binary_search_by_key(&child, |&(node, ..)| node).is_ok() || absent.binary_search(&child).is_ok() { - continue; - } - if let Some(previous) = previous_kept { - edges.push((previous, child)); - } - previous_kept = Some(child); - nodes.push(child); - } - // Every node that started under this parent joins with the neighbours it had then, - // whether it departed, relocated within the sequence, or moved to another parent. - // A recorded relation is the state at the node's first touch, not at the start of the - // transaction, so a neighbour that was not there at the start carries no ordering fact - // and is dropped; orderings between start-present nodes hold because insertions do not - // reorder them, and a reordering contradicts the reordered node's own start edges, - // which the unique-order requirement below turns into a bail. - let started_under_parent = |node| { - if absent.binary_search(&node).is_ok() { - return false; - } - match moved.binary_search_by_key(&node, |&(candidate, ..)| candidate) { - Ok(index) => moved[index].1.is_some_and(|relations| relations.parent == Some(parent)), - Err(_) => self.tree.parent(node) == Some(parent), - } - }; - for &(node, old, _) in moved { - let Some(relations) = old else { - continue; - }; - if relations.parent != Some(parent) || absent.binary_search(&node).is_ok() { - continue; - } - nodes.push(node); - if let Some(previous) = relations.previous_element_sibling - && started_under_parent(previous) - { - nodes.push(previous); - edges.push((previous, node)); - } - if let Some(next) = relations.next_element_sibling - && started_under_parent(next) - { - nodes.push(next); - edges.push((node, next)); - } - } - nodes.sort_unstable_by_key(|node| node.raw()); - nodes.dedup(); - edges.sort_unstable(); - edges.dedup(); - - let mut indegrees = vec![0_u32; nodes.len()]; - for &(before, after) in &edges { - if before == after { - return None; - } - let after_index = nodes.binary_search(&after).ok()?; - indegrees[after_index] = indegrees[after_index].checked_add(1)?; - } - - let mut sequence = Vec::with_capacity(nodes.len()); - let mut available = None; - for (index, &indegree) in indegrees.iter().enumerate() { - if indegree == 0 && available.replace(index).is_some() { - return None; - } - } - while sequence.len() < nodes.len() { - let next_index = available.take()?; - let next = nodes[next_index]; - sequence.push(next); - let successor_start = edges.partition_point(|&(before, _)| before < next); - let successor_end = - successor_start + edges[successor_start..].partition_point(|&(before, _)| before == next); - for &(_, after) in &edges[successor_start..successor_end] { - let after_index = nodes.binary_search(&after).ok()?; - let indegree = indegrees.get_mut(after_index)?; - *indegree = indegree.checked_sub(1)?; - if *indegree == 0 && available.replace(after_index).is_some() { - return None; - } + let maximum_sequence_length = self.tree.connected_element_count() as usize + staged_rows.len() + 1; + for parent in parents { + let resident_first = self + .tree + .is_live(parent) + .then(|| self.tree.first_element_child(parent)) + .flatten(); + let mut child = self + .tree_staging + .before_first_child(parent, resident_first) + .or_else(|| { + staged_rows.iter().find_map(|&(node, before, _)| { + before + .is_some_and(|relations| { + relations.parent == Some(parent) && relations.previous_element_sibling.is_none() + }) + .then_some(node) + }) + }); + let mut sequence = Vec::new(); + while let Some(node) = child { + assert!( + sequence.len() < maximum_sequence_length, + "frozen before-side child sequence must be acyclic" + ); + sequence.push(node); + let resident = self.tree.is_live(node).then(|| self.settled_tree_relations(node)); + child = self + .tree_staging + .before_relations(node, resident) + .and_then(|relations| relations.next_element_sibling); } + view.insert_before_sibling_sequence(parent, sequence); } - view.insert_before_sibling_sequence(parent, sequence); - Some(()) + view.finish_before_sibling_relations(); + true } } diff --git a/Libraries/LibWeb/Rust/src/css/style/inputs.rs b/Libraries/LibWeb/Rust/src/css/style/inputs.rs index 54166be4e652..0a44c4af5c93 100644 --- a/Libraries/LibWeb/Rust/src/css/style/inputs.rs +++ b/Libraries/LibWeb/Rust/src/css/style/inputs.rs @@ -33,9 +33,8 @@ impl StyleEngine { journal: NormalizationJournal::new(), initial_tree_batch_applied: false, initial_tree_bulk_load_is_pending: false, - pending_tree_rows: StableIterationMap::default(), - pending_first_children: HashMap::default(), - pending_tree_memory: MemoryLease::new(MemoryCategory::NormalizationJournal), + tree_staging: TreeRelationStaging::default(), + tree_staging_memory: MemoryLease::new(MemoryCategory::NormalizationJournal), pending_rule_conditions: PendingField::default(), pending_sheet_conditions: PendingField::default(), pending_sheet_enabled: PendingField::default(), @@ -660,7 +659,7 @@ impl StyleEngine { #[must_use] pub fn has_pending_transaction(&self) -> bool { !self.journal.is_empty() - || !self.pending_tree_rows.is_empty() + || !self.tree_staging.is_empty() || !self.pending_rule_conditions.is_empty() || !self.pending_sheet_conditions.is_empty() || !self.pending_sheet_enabled.is_empty() @@ -927,7 +926,7 @@ impl StyleEngine { old_if_unstaged: Option, new: Option, ) { - let old = self.pending_tree_rows.get(&node).copied().unwrap_or(old_if_unstaged); + let old = self.tree_staging.current_row(node, old_if_unstaged); if old == new { return; } @@ -936,30 +935,26 @@ impl StyleEngine { InputValue::TreeRelations(old), InputValue::TreeRelations(new), ); - self.pending_tree_rows.insert(node, new); + self.tree_staging.stage_row(node, old_if_unstaged, new); } pub(super) fn stage_connected_tree_row(&mut self, node: StyleNodeID, update: impl FnOnce(&mut TreeRelations)) { let old = self - .pending_tree_rows - .get(&node) - .copied() - .unwrap_or_else(|| Some(self.settled_tree_relations(node))); + .tree_staging + .current_row(node, Some(self.settled_tree_relations(node))); let mut new = old.expect("a pending neighbour must remain connected"); update(&mut new); self.stage_tree_row(node, old, Some(new)); } pub(super) fn set_pending_first_child(&mut self, parent: StyleNodeID, child: Option) { - self.pending_first_children.insert(parent, child); + self.tree_staging + .stage_first_child(parent, self.tree.first_element_child(parent), child); } - pub(super) fn settle_pending_tree_memory(&mut self) { - let bytes = (self.pending_tree_rows.capacity() - * (size_of::() + size_of::>() + 1) - + self.pending_first_children.capacity() - * (size_of::() + size_of::>() + 1)) as u64; - self.pending_tree_memory.resize_required_to(&mut self.memory, bytes); + pub(super) fn settle_tree_staging_memory(&mut self) { + let bytes = self.tree_staging.capacity_bytes(); + self.tree_staging_memory.resize_required_to(&mut self.memory, bytes); } /// Stage one structural delta and the neighbour rows it derives. @@ -998,19 +993,18 @@ impl StyleEngine { } } self.stage_tree_row(node, old, new); - self.settle_pending_tree_memory(); + self.settle_tree_staging_memory(); } /// Install final staged relation rows at the transaction barrier. pub(super) fn apply_staged_tree_deltas(&mut self) { - let pending_rows = std::mem::take(&mut self.pending_tree_rows); - let pending_first_children = std::mem::take(&mut self.pending_first_children); - self.pending_tree_memory.resize_required_to(&mut self.memory, 0); - if pending_rows.is_empty() { + if self.tree_staging.is_empty() || self.tree_staging.is_applied() { return; } + let pending_rows = self.tree_staging.dirty_rows(); + let pending_first_children = self.tree_staging.dirty_first_children(); - for (&node, &relations) in &pending_rows { + for &(node, _, relations) in &pending_rows { let Some(relations) = relations else { self.tree.set_parent(node, None); self.tree.set_next_element_sibling(node, None); @@ -1031,11 +1025,11 @@ impl StyleEngine { self.tree .set_assigned_slot(node, relations.assigned_slot, &mut self.memory); } - for (parent, child) in pending_first_children { - self.tree.set_first_element_child(parent, child); + for (parent, _, child) in &pending_first_children { + self.tree.set_first_element_child(*parent, *child); } - for (node, relations) in pending_rows { - if relations.is_some() { + for &(node, _, relations) in &pending_rows { + if relations.is_some() || !self.tree.is_live(node) { continue; } self.winner_groups.remove(node); @@ -1052,10 +1046,11 @@ impl StyleEngine { live_animation_overlays_after as u64, ); self.tree.retire_element(node, &mut self.memory); - // The facts stay until the transaction has been routed. What a departure reaches is - // decided by the features the element had, and routing runs after the barrier. + // The facts stay until routing finishes because they determine which selectors the + // departure can reach. self.departed.push(node); } + self.tree_staging.mark_applied(); self.publish_budget_inputs(); } diff --git a/Libraries/LibWeb/Rust/src/css/style/mod.rs b/Libraries/LibWeb/Rust/src/css/style/mod.rs index 27c1f1d368d8..4ed7c39fba04 100644 --- a/Libraries/LibWeb/Rust/src/css/style/mod.rs +++ b/Libraries/LibWeb/Rust/src/css/style/mod.rs @@ -142,7 +142,6 @@ use catalog::*; use column::Column; use fast_hash::FastMap as HashMap; use fast_hash::FastSet as HashSet; -use fast_hash::StableIterationMap; use planning::*; use std::cell::RefCell; use std::collections::VecDeque; @@ -295,6 +294,7 @@ use transaction_view::TransactionFactSide; use transaction_view::TransactionFactView; use tree::StyleNodeID; use tree::StyleNodeTree; +use tree::TreeRelationStaging; use tree::TreeScopeID; /// A candidate source at most this large is always worth enumerating, whatever share of the @@ -483,9 +483,8 @@ pub struct StyleEngine { initial_tree_bulk_load_is_pending: bool, /// Final relation rows staged until the next observation boundary. Moving one node updates its /// affected neighbours here, so those derived changes need no separate journal ingress. - pending_tree_rows: StableIterationMap>, - pending_first_children: HashMap>, - pending_tree_memory: MemoryLease, + tree_staging: TreeRelationStaging, + tree_staging_memory: MemoryLease, /// Final activation flags staged until the program commit barrier. pending_rule_conditions: PendingField, pending_sheet_conditions: PendingField, @@ -526,9 +525,7 @@ pub struct StyleEngine { sheet_rule_replacement: Option, /// Unmatched old rule sequences retained until the transaction boundary, indexed by sheet. pending_sheet_rule_replacements: Column>, - /// Elements that left the tree in the transaction being assembled. Their facts are what says - /// which selectors their departure can reach, so the rows outlive the mutation and are dropped - /// once the transaction that carries it has been routed. + /// Elements that left during the transaction. Their facts remain available through routing. departed: Vec, match_workspace: MatchEvaluationWorkspace, /// Scratch for the fact rows one exact candidate evaluation covers, reused across candidates. diff --git a/Libraries/LibWeb/Rust/src/css/style/ordering.rs b/Libraries/LibWeb/Rust/src/css/style/ordering.rs index 42e9a3bf1af4..215c2e112dc9 100644 --- a/Libraries/LibWeb/Rust/src/css/style/ordering.rs +++ b/Libraries/LibWeb/Rust/src/css/style/ordering.rs @@ -1207,8 +1207,10 @@ impl StyleEngine { /// Release a drained transaction's scratch charge. pub fn release_transaction(&mut self, transaction: StyleTransaction) { transaction.release(&mut self.memory); - self.rules_with_incomplete_old_declarations.clear(); self.forget_departed_elements(); + self.tree_staging.clear(); + self.tree_staging_memory.resize_required_to(&mut self.memory, 0); + self.rules_with_incomplete_old_declarations.clear(); self.sweep_selector_programs(); self.shed_routing_for_detached_sheets(); } diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index 33454d4d589c..bdbc5c80c80b 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -5496,49 +5496,6 @@ fn converging_departure_routes_are_folded_before_exact_tree_evaluation() { assert_eq!(engine.memory().bytes_in_category(MemoryCategory::BatchScratch), 0); } -#[test] -fn cyclic_departure_snapshots_do_not_form_before_sibling_relations() { - let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); - let mut raw = [0_u32; 3]; - engine.allocate_style_nodes(&mut raw); - let nodes: Vec = raw.iter().map(|&raw| StyleNodeID::from_raw(raw).unwrap()).collect(); - let mut transaction = StyleTransaction::default(); - transaction.inputs.extend([ - NormalizedInput { - key: InputKey::TreeRelations(nodes[1]), - old: InputValue::TreeRelations(Some(relations(Some(nodes[0].raw()), None, Some(nodes[2].raw())))), - new: InputValue::TreeRelations(None), - }, - NormalizedInput { - key: InputKey::TreeRelations(nodes[2]), - old: InputValue::TreeRelations(Some(relations(Some(nodes[0].raw()), None, Some(nodes[1].raw())))), - new: InputValue::TreeRelations(None), - }, - ]); - - let mut view = engine.transaction_fact_view_for(&mut transaction, nodes[0], &ImpactRegions::new()); - assert!(!engine.populate_before_sibling_relations(&mut view, &transaction, &[])); -} - -#[test] -fn ambiguous_departure_snapshots_do_not_guess_an_old_sibling_order() { - let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); - let mut raw = [0_u32; 3]; - engine.allocate_style_nodes(&mut raw); - let nodes: Vec = raw.iter().map(|&raw| StyleNodeID::from_raw(raw).unwrap()).collect(); - let mut transaction = StyleTransaction::default(); - transaction - .inputs - .extend(nodes[1..].iter().map(|&node| NormalizedInput { - key: InputKey::TreeRelations(node), - old: InputValue::TreeRelations(Some(relations(Some(nodes[0].raw()), None, None))), - new: InputValue::TreeRelations(None), - })); - - let mut view = engine.transaction_fact_view_for(&mut transaction, nodes[0], &ImpactRegions::new()); - assert!(!engine.populate_before_sibling_relations(&mut view, &transaction, &[])); -} - #[test] fn a_departed_sibling_reaches_only_the_following_sibling_forest() { let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); @@ -5975,6 +5932,30 @@ fn departing_scope_roots_are_removed_from_the_reverse_scope_index() { assert_eq!(engine.scope_roots[scope.0 as usize], None); } +#[test] +fn an_element_arriving_and_departing_in_one_transaction_is_forgotten() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let mut raw = [0_u32; 1]; + engine.allocate_style_nodes(&mut raw); + let node = StyleNodeID::from_raw(raw[0]).unwrap(); + let scope = TreeScopeID(1); + let class = StyleAtomID(100); + + engine.record_tree_delta(node, None, Some(TreeRelations::detached(scope))); + engine.set_tree_scope_root(scope, node); + add_feature(&mut engine, node, LocalFeatureKey::Class(class)); + engine.record_tree_delta(node, Some(TreeRelations::detached(scope)), None); + discard_transaction(&mut engine); + + assert!(engine.facts.is_empty()); + assert!(matches!( + engine.facts.postings().lookup(SelectorPostingKey::Class(class)), + Lookup::KnownAbsent + )); + assert_eq!(engine.scope_by_root.get(&node), None); + assert_eq!(engine.scope_roots[scope.0 as usize], None); +} + #[test] fn independent_sibling_paths_request_their_fact_ranges_together() { let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); diff --git a/Libraries/LibWeb/Rust/src/css/style/tree.rs b/Libraries/LibWeb/Rust/src/css/style/tree.rs index 47c02dd1c5e1..ef74d051018b 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tree.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tree.rs @@ -32,6 +32,7 @@ use super::column::RemovablePagedColumnPage; use super::index::StyleAtomID; use super::memory::MemoryCategory; use super::memory::MemoryController; +use super::transaction::TreeRelations; /// Document-local identity of an element. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -158,6 +159,195 @@ impl SegmentedNodeColumn { } } +#[derive(Clone, Copy)] +struct StagedTreeValue { + before: T, + after: T, + dirty: bool, +} + +/// Transaction-local before/after rows for the tree relation family. +/// +/// Pages are addressed by dense element identity. The touched lists exist only to drain populated +/// rows without scanning the document-wide page directory at the commit barrier. +#[derive(Default)] +pub(super) struct TreeRelationStaging { + rows: SegmentedNodeColumn>>, + touched_rows: Vec, + dirty_rows: Vec, + first_children: SegmentedNodeColumn>>, + touched_first_children: Vec, + dirty_first_children: Vec, + applied: bool, +} + +type StagedTreeRows = Vec<(StyleNodeID, Option, Option)>; +type StagedFirstChildren = Vec<(StyleNodeID, Option, Option)>; + +impl TreeRelationStaging { + pub(super) fn is_empty(&self) -> bool { + self.touched_rows.is_empty() && self.touched_first_children.is_empty() + } + + pub(super) fn is_applied(&self) -> bool { + self.applied + } + + pub(super) fn current_row(&self, node: StyleNodeID, unstaged: Option) -> Option { + self.rows.get(node).map_or(unstaged, |pair| pair.after) + } + + pub(super) fn stage_row(&mut self, node: StyleNodeID, before: Option, after: Option) { + self.applied = false; + match self.rows.get(node) { + Some(mut pair) => { + pair.after = after; + if !pair.dirty { + pair.dirty = true; + self.dirty_rows.push(node); + } + self.rows.insert(node, pair); + } + None => { + self.rows.insert( + node, + StagedTreeValue { + before, + after, + dirty: true, + }, + ); + self.touched_rows.push(node); + self.dirty_rows.push(node); + } + } + } + + pub(super) fn stage_first_child( + &mut self, + parent: StyleNodeID, + before: Option, + after: Option, + ) { + self.applied = false; + match self.first_children.get(parent) { + Some(mut pair) => { + pair.after = after; + if !pair.dirty { + pair.dirty = true; + self.dirty_first_children.push(parent); + } + self.first_children.insert(parent, pair); + } + None => { + self.first_children.insert( + parent, + StagedTreeValue { + before, + after, + dirty: true, + }, + ); + self.touched_first_children.push(parent); + self.dirty_first_children.push(parent); + } + } + } + + pub(super) fn rows(&self) -> StagedTreeRows { + self.touched_rows + .iter() + .copied() + .map(|node| { + let pair = self.rows.get(node).expect("touched tree row must be staged"); + (node, pair.before, pair.after) + }) + .collect() + } + + pub(super) fn first_children(&self) -> StagedFirstChildren { + self.touched_first_children + .iter() + .copied() + .map(|parent| { + let pair = self + .first_children + .get(parent) + .expect("touched first-child row must be staged"); + (parent, pair.before, pair.after) + }) + .collect() + } + + pub(super) fn dirty_rows(&self) -> StagedTreeRows { + let mut rows: StagedTreeRows = self + .dirty_rows + .iter() + .copied() + .map(|node| { + let pair = self.rows.get(node).expect("dirty tree row must be staged"); + (node, pair.before, pair.after) + }) + .collect(); + rows.sort_unstable_by_key(|&(node, _, _)| node); + rows + } + + pub(super) fn dirty_first_children(&self) -> StagedFirstChildren { + self.dirty_first_children + .iter() + .copied() + .map(|parent| { + let pair = self + .first_children + .get(parent) + .expect("dirty first-child row must be staged"); + (parent, pair.before, pair.after) + }) + .collect() + } + + pub(super) fn before_relations(&self, node: StyleNodeID, resident: Option) -> Option { + self.rows.get(node).map_or(resident, |pair| pair.before) + } + + pub(super) fn before_first_child(&self, parent: StyleNodeID, resident: Option) -> Option { + self.first_children.get(parent).map_or(resident, |pair| pair.before) + } + + pub(super) fn mark_applied(&mut self) { + for &node in &self.dirty_rows { + let mut pair = self.rows.get(node).expect("touched tree row must be staged"); + pair.dirty = false; + self.rows.insert(node, pair); + } + for &parent in &self.dirty_first_children { + let mut pair = self + .first_children + .get(parent) + .expect("touched first-child row must be staged"); + pair.dirty = false; + self.first_children.insert(parent, pair); + } + self.dirty_rows.clear(); + self.dirty_first_children.clear(); + self.applied = true; + } + + pub(super) fn clear(&mut self) { + *self = Self::default(); + } + + pub(super) fn capacity_bytes(&self) -> u64 { + self.rows.capacity_bytes() + + self.first_children.capacity_bytes() + + (self.touched_rows.capacity() * size_of::()) as u64 + + (self.dirty_rows.capacity() * size_of::()) as u64 + + (self.touched_first_children.capacity() * size_of::()) as u64 + + (self.dirty_first_children.capacity() * size_of::()) as u64 + } +} + /// Sparse shadow relations, allocated only for documents that have shadow trees. /// /// These are the facts the flat tree is derived from rather than a second child list. Storing @@ -774,6 +964,37 @@ mod tests { use super::super::memory::DeviceClass; use super::*; + #[test] + fn tree_staging_keeps_exact_before_and_after_rows_across_apply() { + let node = StyleNodeID::element(1); + let parent = StyleNodeID::element(2); + let final_first_child = StyleNodeID::element(4); + let before = Some(TreeRelations::detached(TreeScopeID::DOCUMENT)); + let mut first_after = before.unwrap(); + first_after.parent = Some(parent); + let mut final_after = first_after; + final_after.assigned_slot = Some(StyleNodeID::element(3)); + let mut staging = TreeRelationStaging::default(); + + staging.stage_row(node, before, Some(first_after)); + staging.stage_first_child(parent, None, Some(node)); + staging.mark_applied(); + assert!(staging.dirty_rows().is_empty()); + assert!(staging.dirty_first_children().is_empty()); + staging.stage_row(node, Some(first_after), Some(final_after)); + staging.stage_first_child(parent, Some(node), Some(final_first_child)); + + assert_eq!(staging.current_row(node, None), Some(final_after)); + assert_eq!(staging.before_relations(node, Some(final_after)), before); + assert_eq!(staging.before_first_child(parent, Some(final_first_child)), None); + assert!(!staging.is_applied()); + let rows = staging.rows(); + let first_children = staging.first_children(); + assert_eq!(rows, vec![(node, before, Some(final_after))]); + assert_eq!(first_children, vec![(parent, None, Some(final_first_child))]); + assert!(!staging.is_empty()); + } + /// Builds `parent -> [children]` shapes without repeating relation bookkeeping in every test. struct TreeFixture { memory: MemoryController, From 567b67312e32d56445a650b34ab49c7e3638b953 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sun, 16 Aug 2026 14:27:22 +0200 Subject: [PATCH 07/39] LibWeb: Stage element facts as before/after rows Staged element facts lived in a hash map of full heap rows, with a separate before-side snapshot, an opposite-side batch materialized by copying rows, and a commit-then-uncommit dance for selector queries in the middle of a transaction. Every fact reader forked between the committed and the primary store depending on when the flush committed. Keep one paged staging column of before/after row pairs per touched element, drained through a dirty list, with a single commit point before planning and sparse before rows for the transaction. Readers resolve a row by side; the opposite-side batches, the rollback path and the two commit orders are removed, and barrier work is proportional to the dirty rows. Distinguish dirty element rows from any staged input, move retained-answer patch coverage across the new barrier, and treat only a before-side non-resident view as composite so after-side transition memos continue to use resident row identities. --- .../LibWeb/Rust/src/css/style/catalog.rs | 6 - Libraries/LibWeb/Rust/src/css/style/flush.rs | 23 +- Libraries/LibWeb/Rust/src/css/style/index.rs | 662 +++++++++++++----- Libraries/LibWeb/Rust/src/css/style/inputs.rs | 4 - .../LibWeb/Rust/src/css/style/matching.rs | 27 +- Libraries/LibWeb/Rust/src/css/style/mod.rs | 2 - .../LibWeb/Rust/src/css/style/ordering.rs | 31 +- Libraries/LibWeb/Rust/src/css/style/prefix.rs | 166 +++-- .../Rust/src/css/style/program_updates.rs | 28 +- .../LibWeb/Rust/src/css/style/routing.rs | 224 ++---- .../LibWeb/Rust/src/css/style/selector.rs | 97 ++- Libraries/LibWeb/Rust/src/css/style/tests.rs | 13 +- .../Rust/src/css/style/transaction_view.rs | 60 +- 13 files changed, 745 insertions(+), 598 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/catalog.rs b/Libraries/LibWeb/Rust/src/css/style/catalog.rs index 54f13dc1392f..ca9107d85db7 100644 --- a/Libraries/LibWeb/Rust/src/css/style/catalog.rs +++ b/Libraries/LibWeb/Rust/src/css/style/catalog.rs @@ -1536,12 +1536,6 @@ pub(super) struct DiagnosticPlanCapture { pub(super) scoped: bool, } -#[derive(Clone, Copy, PartialEq, Eq)] -pub(super) enum BeforeFactRetention { - Retain, - Omit, -} - pub(crate) struct RecordedAtomMappings { pub atoms: Vec<(u64, u32)>, pub qualified_atoms: Vec<(u32, u32, u32)>, diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index dff6894d2dfe..5af491b7f77c 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -22,29 +22,13 @@ impl StyleEngine { } self.discard_prepared_batch_matching_traversal(); self.discard_published_match_answers(); - self.facts.restore_prepared_selector_query_input(&mut self.memory); let document_root_arrival_is_pending = self.journal.pending_old(InputKey::TreeRelations(root)) == Some(InputValue::TreeRelations(None)); let initial_tree_was_bulk_loaded = self.initial_tree_bulk_load_is_pending && document_root_arrival_is_pending; let publish_document_root_arrival = document_root_arrival_is_pending; let mut transaction = self.drain_transaction(); - let plan_before_commit = !transaction.is_empty() - && !transaction.has_coarsened_markers() - && transaction.inputs.iter().all(|input| match input.key { - InputKey::LocalFeature( - _, - LocalFeatureKey::Language | LocalFeatureKey::Directionality | LocalFeatureKey::HeadingLevel, - ) => false, - InputKey::LocalFeature(..) - | InputKey::State(..) - | InputKey::ElementDeclaration(..) - | InputKey::ElementStyleInput(..) => true, - _ => false, - }); - if !plan_before_commit { - self.commit_pending(&mut transaction, BeforeFactRetention::Retain); - } + self.apply_staged(&mut transaction); if transaction.is_empty() { self.release_transaction(transaction); return true; @@ -636,9 +620,6 @@ impl StyleEngine { .as_ref() .map(|_| regions.compile_patch_cover(&self.tree, Some(root))); self.resolve_already_planned_selector_truth(®ions, patch_cover.as_ref().map(|cover| &cover.full)); - if plan_before_commit { - self.commit_pending(&mut transaction, BeforeFactRetention::Omit); - } let program_base_version = transaction.program_base_version; // A scoped plan consumes retained match answers or packs its missing rows adaptively. Do // not walk and clone the complete required fact store merely to prepare for that bounded @@ -989,7 +970,7 @@ impl StyleEngine { for node in self.elements_under(root) { self.facts.ensure_row(node); } - self.facts.commit_pending(&mut self.memory); + self.facts.apply_staged(&mut self.memory); self.counters = counters; } let publish_style_answers = true; diff --git a/Libraries/LibWeb/Rust/src/css/style/index.rs b/Libraries/LibWeb/Rust/src/css/style/index.rs index f475bef2186d..6a564245966f 100644 --- a/Libraries/LibWeb/Rust/src/css/style/index.rs +++ b/Libraries/LibWeb/Rust/src/css/style/index.rs @@ -23,6 +23,9 @@ use super::AncestorDispatchShape; use super::capacity::capacity_bytes; use super::column::Column; use super::column::EpochColumn; +use super::column::PagedColumn; +use super::column::PagedColumnPage; +use super::column::RemovablePagedColumnPage; use super::column::advance_epoch; use super::fast_hash::FastMap as HashMap; use super::fast_hash::FastSet as HashSet; @@ -2132,8 +2135,7 @@ pub struct ElementFactStore { /// Required primary arrangement. Element identity selects the current immutable packed row; /// mutations append one replacement per touched node when their publication batch settles. rows: StyleNodeFacts, - pending_rows: HashMap, - selector_query_before_rows: HashMap, + pending_rows: StagedFactRows, metadata: Column>, /// Logical row bytes still reachable through the directory, and bytes carried only by stale /// rows. Their ratio decides when rebuilding costs less than retaining append garbage. @@ -2144,6 +2146,7 @@ pub struct ElementFactStore { postings: FeaturePostings, memory: MemoryLease, memory_dirty: bool, + settled_non_apply_capacity_bytes: u64, /// One entry per distinct set of declared custom property names, and the sets each name is in. /// A theme is one set however many elements it decides for, which is what keeps the index /// proportional to the stylesheet rather than to the document times the stylesheet. @@ -2170,7 +2173,7 @@ pub struct ElementFactStore { } #[derive(Clone, Debug, Default, PartialEq, Eq)] -struct PendingFactRow { +struct StagedFactRow { tag: StyleAtomID, /// The ASCII-lowercase folding of `tag`, held only when it differs from it. Type selectors /// dispatch on their folded form, so an element whose local name is not already lowercase is @@ -2199,7 +2202,7 @@ struct PendingFactRow { attributes: Vec<(StyleAtomID, StyleAtomID)>, } -impl PendingFactRow { +impl StagedFactRow { fn capacity_bytes(&self) -> u64 { capacity_bytes! { shallow [self.custom_states, self.parts, self.classes, self.attributes]; @@ -2221,6 +2224,278 @@ impl PendingFactRow { } } +#[derive(Default)] +struct StagedFactRows { + rows: PagedColumn, + entries: Vec>, + touched: Vec<(StyleNodeID, u32)>, + dirty: Vec<(StyleNodeID, u32)>, + live_count: usize, + dirty_count: usize, + capacity_bytes: u64, +} + +const PENDING_FACT_ROWS_PAGE_SHIFT: usize = 6; +const PENDING_FACT_ROWS_PAGE_SIZE: usize = 1 << PENDING_FACT_ROWS_PAGE_SHIFT; + +struct StagedFactRowsPage { + entries: [u32; PENDING_FACT_ROWS_PAGE_SIZE], +} + +impl Default for StagedFactRowsPage { + fn default() -> Self { + Self { + entries: [NO_ROW; PENDING_FACT_ROWS_PAGE_SIZE], + } + } +} + +impl PagedColumnPage for StagedFactRowsPage { + type Value = u32; + + const SHIFT: usize = PENDING_FACT_ROWS_PAGE_SHIFT; + + fn get(&self, index: usize) -> Option { + (self.entries[index] != NO_ROW).then_some(self.entries[index]) + } + + fn insert(&mut self, index: usize, value: Self::Value) { + self.entries[index] = value; + } +} + +impl RemovablePagedColumnPage for StagedFactRowsPage { + fn remove(&mut self, index: usize) -> Option { + let previous = std::mem::replace(&mut self.entries[index], NO_ROW); + (previous != NO_ROW).then_some(previous) + } +} + +struct StagedFactRowPair { + before: StagedFactRow, + after: StagedFactRow, + dirty: bool, +} + +impl StagedFactRows { + fn index(node: StyleNodeID) -> usize { + node.element_index().expect("only elements carry fact staging") as usize + } + + fn is_empty(&self) -> bool { + self.live_count == 0 + } + + fn has_dirty(&self) -> bool { + self.dirty_count != 0 + } + + fn len(&self) -> usize { + self.live_count + } + + fn contains(&self, node: StyleNodeID) -> bool { + self.get(node).is_some() + } + + fn get(&self, node: StyleNodeID) -> Option<&StagedFactRow> { + let entry = self.rows.get(Self::index(node))? as usize; + Some(&self.entries.get(entry)?.as_ref()?.after) + } + + fn edit(&mut self, node: StyleNodeID, edit: impl FnOnce(&mut StagedFactRow) -> R) -> Option { + let entry = self.rows.get(Self::index(node))? as usize; + let is_dirty = self.entries.get(entry)?.as_ref()?.dirty; + if !is_dirty { + let previous_dirty_capacity = self.dirty.capacity(); + self.entries[entry] + .as_mut() + .expect("mapped fact staging entry must be live") + .dirty = true; + self.dirty_count += 1; + self.dirty.push((node, entry as u32)); + self.capacity_bytes = self + .capacity_bytes + .checked_add( + u64::try_from(self.dirty.capacity() - previous_dirty_capacity) + .expect("fact staging dirty capacity exceeds u64") + .checked_mul(size_of::<(StyleNodeID, u32)>() as u64) + .expect("fact staging dirty byte count overflow"), + ) + .expect("fact staging byte count overflow"); + } + let pair = self.entries[entry] + .as_mut() + .expect("mapped fact staging entry must be live"); + let previous_payload_bytes = pair.after.capacity_bytes(); + let result = edit(&mut pair.after); + self.capacity_bytes = self + .capacity_bytes + .checked_sub(previous_payload_bytes) + .and_then(|bytes| bytes.checked_add(pair.after.capacity_bytes())) + .expect("fact staging byte count overflow"); + Some(result) + } + + fn before(&self, node: StyleNodeID) -> Option<&StagedFactRow> { + let entry = self.rows.get(Self::index(node))? as usize; + Some(&self.entries.get(entry)?.as_ref()?.before) + } + + fn insert_pair(&mut self, node: StyleNodeID, before: StagedFactRow, after: StagedFactRow) { + let index = Self::index(node); + assert!(self.rows.get(index).is_none()); + let previous_storage_bytes = self.storage_capacity_bytes(); + let payload_bytes = before + .capacity_bytes() + .checked_add(after.capacity_bytes()) + .expect("fact staging byte count overflow"); + let entry = u32::try_from(self.entries.len()).expect("fact staging entry overflow"); + self.entries.push(Some(StagedFactRowPair { + before, + after, + dirty: true, + })); + self.rows.insert(index, entry); + self.touched.push((node, entry)); + self.dirty.push((node, entry)); + self.live_count += 1; + self.dirty_count += 1; + self.capacity_bytes = self + .capacity_bytes + .checked_sub(previous_storage_bytes) + .and_then(|bytes| bytes.checked_add(self.storage_capacity_bytes())) + .and_then(|bytes| bytes.checked_add(payload_bytes)) + .expect("fact staging byte count overflow"); + } + + fn insert(&mut self, node: StyleNodeID, row: StagedFactRow) { + let index = Self::index(node); + if let Some(entry) = self.rows.get(index) { + let previous_storage_bytes = self.storage_capacity_bytes(); + let pair = self.entries[entry as usize] + .as_mut() + .expect("mapped fact staging entry must be live"); + let previous_payload_bytes = pair.after.capacity_bytes(); + let replacement_payload_bytes = row.capacity_bytes(); + pair.after = row; + if !pair.dirty { + pair.dirty = true; + self.dirty_count += 1; + self.dirty.push((node, entry)); + } + self.capacity_bytes = self + .capacity_bytes + .checked_sub(previous_storage_bytes) + .and_then(|bytes| bytes.checked_add(self.storage_capacity_bytes())) + .and_then(|bytes| bytes.checked_sub(previous_payload_bytes)) + .and_then(|bytes| bytes.checked_add(replacement_payload_bytes)) + .expect("fact staging byte count overflow"); + return; + } + self.insert_pair(node, row.clone(), row); + } + + fn remove(&mut self, node: StyleNodeID) -> Option { + let entry = self.rows.remove(Self::index(node))? as usize; + let pair = self.entries.get_mut(entry)?.take()?; + self.live_count -= 1; + self.dirty_count -= usize::from(pair.dirty); + self.capacity_bytes = self + .capacity_bytes + .checked_sub(pair.before.capacity_bytes()) + .and_then(|bytes| bytes.checked_sub(pair.after.capacity_bytes())) + .expect("fact staging byte count underflow"); + Some(pair.after) + } + + fn keys(&self) -> impl Iterator + '_ { + self.touched + .iter() + .filter_map(|&(node, entry)| (self.rows.get(Self::index(node)) == Some(entry)).then_some(node)) + } + + fn values(&self) -> impl Iterator { + self.keys().filter_map(|node| self.get(node)) + } + + fn dirty_rows(&self) -> Vec<(StyleNodeID, StagedFactRow)> { + self.dirty + .iter() + .filter_map(|&(node, entry)| { + if self.rows.get(Self::index(node)) != Some(entry) { + return None; + } + let pair = self.entries.get(entry as usize)?.as_ref()?; + pair.dirty.then(|| (node, pair.after.clone())) + }) + .collect() + } + + fn mark_applied(&mut self) { + for &(node, entry) in &self.dirty { + if self.rows.get(Self::index(node)) == Some(entry) + && let Some(pair) = self.entries[entry as usize].as_mut() + { + pair.dirty = false; + } + } + self.dirty.clear(); + self.dirty_count = 0; + } + + fn clear(&mut self) { + for &(node, _) in &self.touched { + self.rows.remove(Self::index(node)); + } + const RETAINED_ENTRY_CAPACITY_RATIO: usize = 4; + const MIN_RETAINED_ENTRY_CAPACITY: usize = 64; + let transaction_high_water = self.entries.len(); + if self.entries.capacity() + > transaction_high_water + .saturating_mul(RETAINED_ENTRY_CAPACITY_RATIO) + .max(MIN_RETAINED_ENTRY_CAPACITY) + { + self.entries.shrink_to(transaction_high_water); + } + self.entries.clear(); + self.touched.clear(); + self.dirty.clear(); + self.live_count = 0; + self.dirty_count = 0; + self.capacity_bytes = self.storage_capacity_bytes(); + } + + fn capacity_bytes(&self) -> u64 { + self.capacity_bytes + } + + fn storage_capacity_bytes(&self) -> u64 { + capacity_bytes! { + shallow [self.entries, self.touched, self.dirty]; + cached [self.rows.capacity_bytes()]; + nested []; + skip [self.live_count, self.dirty_count, self.capacity_bytes]; + } + } + + #[cfg(test)] + fn recomputed_capacity_bytes(&self) -> u64 { + self.storage_capacity_bytes() + + self + .touched + .iter() + .filter_map(|&(node, entry)| { + if self.rows.get(Self::index(node)) != Some(entry) { + return None; + } + self.entries.get(entry as usize)?.as_ref() + }) + .map(|pair| pair.before.capacity_bytes() + pair.after.capacity_bytes()) + .sum::() + } +} + #[derive(Default)] struct ElementFactMetadata { /// The animation names this element's computed style references, sorted. @@ -2252,16 +2527,16 @@ impl ElementFactMetadata { impl Default for ElementFactStore { fn default() -> Self { - Self { + let mut store = Self { rows: StyleNodeFacts::new(), - pending_rows: HashMap::default(), - selector_query_before_rows: HashMap::default(), + pending_rows: StagedFactRows::default(), metadata: Column::default(), primary_live_bytes: 0, primary_stale_bytes: 0, postings: FeaturePostings::default(), memory: MemoryLease::new(MemoryCategory::StyleNodeMapping), memory_dirty: false, + settled_non_apply_capacity_bytes: 0, custom_property_name_sets: super::intern_table::InternTable::default(), custom_property_name_set_vacancies: Vec::new(), custom_property_set_ids_by_name: HashMap::default(), @@ -2269,7 +2544,9 @@ impl Default for ElementFactStore { attribute_value_texts: HashMap::default(), attribute_name_locals: HashMap::default(), element_declared_properties: ElementDeclarationRows::default(), - } + }; + store.settled_non_apply_capacity_bytes = store.capacity_bytes() - store.apply_capacity_bytes(); + store } } @@ -2399,26 +2676,25 @@ impl ElementFactStore { #[must_use] pub fn primary(&self) -> &StyleNodeFacts { assert!( - !self.has_pending_input(), - "cannot evaluate facts while pending input is uncommitted" + !self.has_dirty_staging(), + "cannot evaluate facts while fact staging is unapplied" ); &self.rows } - /// The committed arrangement while a staged transaction is being planned. #[must_use] - pub fn committed(&self) -> &StyleNodeFacts { - &self.rows + pub fn has_dirty_staging(&self) -> bool { + self.pending_rows.has_dirty() } #[must_use] - pub fn has_pending_input(&self) -> bool { + pub fn has_staged_input(&self) -> bool { !self.pending_rows.is_empty() } #[must_use] pub fn parts_of(&self, node: StyleNodeID) -> &[StyleAtomID] { - if let Some(row) = self.pending_rows.get(&node) { + if let Some(row) = self.pending_rows.get(node) { return &row.parts; } self.rows.row_of(node).map_or(&[], |row| self.rows.parts_of(row)) @@ -2426,7 +2702,7 @@ impl ElementFactStore { #[must_use] pub fn custom_states_of(&self, node: StyleNodeID) -> &[StyleAtomID] { - if let Some(row) = self.pending_rows.get(&node) { + if let Some(row) = self.pending_rows.get(node) { return &row.custom_states; } self.rows @@ -2446,11 +2722,11 @@ impl ElementFactStore { self.metadata.get(node.element_index()? as usize)?.as_ref() } - fn snapshot_row(&self, node: StyleNodeID) -> PendingFactRow { + fn snapshot_row(&self, node: StyleNodeID) -> StagedFactRow { let Some(row) = self.rows.row_of(node) else { - return PendingFactRow::default(); + return StagedFactRow::default(); }; - PendingFactRow { + StagedFactRow { tag: self.rows.tag_of(row), folded_tag: self.rows.folded_tag_of(row), id: self.rows.id_of(row), @@ -2473,13 +2749,13 @@ impl ElementFactStore { } } - fn pending_row_mut(&mut self, node: StyleNodeID) -> &mut PendingFactRow { + fn edit_pending_row(&mut self, node: StyleNodeID, edit: impl FnOnce(&mut StagedFactRow) -> R) -> R { self.memory_dirty = true; - if !self.pending_rows.contains_key(&node) { + if !self.pending_rows.contains(node) { let row = self.snapshot_row(node); self.pending_rows.insert(node, row); } - self.pending_rows.get_mut(&node).unwrap() + self.pending_rows.edit(node, edit).unwrap() } /// Give a node a row of its own without putting a feature in it. @@ -2489,15 +2765,14 @@ impl ElementFactStore { /// selector that reads it - a relative anchor bound at the root, the leftmost step of a chain /// that lands there - asks for a row that never arrives and the whole match gives up. pub fn ensure_row(&mut self, node: StyleNodeID) { - if self.rows.row_of(node).is_none() && !self.pending_rows.contains_key(&node) { + if self.rows.row_of(node).is_none() && !self.pending_rows.contains(node) { self.memory_dirty = true; - self.pending_rows.insert(node, PendingFactRow::default()); + self.pending_rows.insert(node, StagedFactRow::default()); } } pub fn set_tag(&mut self, node: StyleNodeID, tag: StyleAtomID, memory: &mut MemoryController) { - let facts = self.pending_row_mut(node); - let previous = std::mem::replace(&mut facts.tag, tag); + let previous = self.edit_pending_row(node, |facts| std::mem::replace(&mut facts.tag, tag)); if previous != tag { if !previous.is_none() { self.postings.remove(SelectorPostingKey::TagName(previous), node); @@ -2512,9 +2787,10 @@ impl ElementFactStore { /// equal to the name itself carries no information and is dropped, so the posting is never /// inserted twice for one element. pub fn set_folded_tag(&mut self, node: StyleNodeID, folded: StyleAtomID, memory: &mut MemoryController) { - let facts = self.pending_row_mut(node); - let folded = if folded == facts.tag { StyleAtomID::NONE } else { folded }; - let previous = std::mem::replace(&mut facts.folded_tag, folded); + let (previous, folded) = self.edit_pending_row(node, |facts| { + let folded = if folded == facts.tag { StyleAtomID::NONE } else { folded }; + (std::mem::replace(&mut facts.folded_tag, folded), folded) + }); if previous != folded { if !previous.is_none() { self.postings.remove(SelectorPostingKey::TagName(previous), node); @@ -2526,8 +2802,7 @@ impl ElementFactStore { } pub fn set_id(&mut self, node: StyleNodeID, id: StyleAtomID, memory: &mut MemoryController) { - let facts = self.pending_row_mut(node); - let previous = std::mem::replace(&mut facts.id, id); + let previous = self.edit_pending_row(node, |facts| std::mem::replace(&mut facts.id, id)); if previous != id { if !previous.is_none() { self.postings.remove(SelectorPostingKey::Id(previous), node); @@ -2610,7 +2885,7 @@ impl ElementFactStore { #[must_use] pub fn states_of_node(&self, node: StyleNodeID) -> StateSet { - if let Some(row) = self.pending_rows.get(&node) { + if let Some(row) = self.pending_rows.get(node) { return row.states; } self.rows @@ -2626,7 +2901,7 @@ impl ElementFactStore { } pub fn set_part_exposure(&mut self, node: StyleNodeID, exposure: StyleAtomID) { - self.pending_row_mut(node).part_exposure = exposure; + self.edit_pending_row(node, |facts| facts.part_exposure = exposure); } #[must_use] @@ -2645,7 +2920,7 @@ impl ElementFactStore { #[must_use] pub fn directionality_of(&self, node: StyleNodeID) -> StyleAtomID { - if let Some(row) = self.pending_rows.get(&node) { + if let Some(row) = self.pending_rows.get(node) { return row.directionality; } self.rows @@ -2654,7 +2929,7 @@ impl ElementFactStore { } pub fn set_has_text_content(&mut self, node: StyleNodeID, has_text_content: bool) { - self.pending_row_mut(node).has_text_content = has_text_content; + self.edit_pending_row(node, |facts| facts.has_text_content = has_text_content); } /// An element's heading level follows from what it is, so it is published as it arrives and @@ -2678,7 +2953,7 @@ impl ElementFactStore { } pub fn set_heading_level(&mut self, node: StyleNodeID, level: u8) { - self.pending_row_mut(node).heading_level = level; + self.edit_pending_row(node, |facts| facts.heading_level = level); } #[must_use] @@ -2696,13 +2971,13 @@ impl ElementFactStore { } pub fn set_language(&mut self, node: StyleNodeID, language: StyleAtomID) { - self.pending_row_mut(node).language = language; + self.edit_pending_row(node, |facts| facts.language = language); } /// An element's namespace is fixed when it is created, so this is published once, on arrival, /// and is never an input that moves. pub fn set_namespace(&mut self, node: StyleNodeID, namespace: StyleAtomID) { - self.pending_row_mut(node).namespace = namespace; + self.edit_pending_row(node, |facts| facts.namespace = namespace); } pub fn set_directionality( @@ -2711,8 +2986,9 @@ impl ElementFactStore { directionality: StyleAtomID, memory: &mut MemoryController, ) { - let facts = self.pending_row_mut(node); - let previous = std::mem::replace(&mut facts.directionality, directionality); + let previous = self.edit_pending_row(node, |facts| { + std::mem::replace(&mut facts.directionality, directionality) + }); if previous != directionality { if !previous.is_none() { self.postings.remove(SelectorPostingKey::Directionality(previous), node); @@ -2725,17 +3001,23 @@ impl ElementFactStore { } pub fn set_class(&mut self, node: StyleNodeID, class: StyleAtomID, present: bool, memory: &mut MemoryController) { - let facts = self.pending_row_mut(node); - match (present, facts.classes.binary_search(&class)) { + let changed = self.edit_pending_row(node, |facts| match (present, facts.classes.binary_search(&class)) { (true, Err(index)) => { facts.classes.insert(index, class); - self.postings.insert(SelectorPostingKey::Class(class), node, memory); + true } (false, Ok(index)) => { facts.classes.remove(index); + true + } + _ => false, + }); + if changed { + if present { + self.postings.insert(SelectorPostingKey::Class(class), node, memory); + } else { self.postings.remove(SelectorPostingKey::Class(class), node); } - _ => {} } } @@ -2897,35 +3179,44 @@ impl ElementFactStore { // names are postings rather than facts, so they say only that at least one attribute of the // element answers to them. let keys = self.attribute_name_keys(name); - let facts = self.pending_row_mut(node); - let found = facts.attributes.binary_search_by_key(&name, |entry| entry.0); - match (present, found) { - (true, Ok(index)) => facts.attributes[index].1 = value, - (true, Err(index)) => { - facts.attributes.insert(index, (name, value)); - for key in keys { - self.postings - .insert(SelectorPostingKey::AttributeName(key), node, memory); + let changed = self.edit_pending_row(node, |facts| { + let found = facts.attributes.binary_search_by_key(&name, |entry| entry.0); + match (present, found) { + (true, Ok(index)) => { + facts.attributes[index].1 = value; + false + } + (true, Err(index)) => { + facts.attributes.insert(index, (name, value)); + true } + (false, Ok(index)) => { + facts.attributes.remove(index); + true + } + (false, Err(_)) => false, } - (false, Ok(index)) => { - facts.attributes.remove(index); - // A shared name stays true of the element while another of its attributes still - // answers to it, so only the names nothing implies any more are dropped. - for key in keys { - if key == name || !self.node_answers_to_attribute_name(node, key) { - self.postings.remove(SelectorPostingKey::AttributeName(key), node); - } + }); + if changed && present { + for key in keys { + self.postings + .insert(SelectorPostingKey::AttributeName(key), node, memory); + } + } else if changed { + // A shared name stays true of the element while another of its attributes still + // answers to it, so only the names nothing implies any more are dropped. + for key in keys { + if key == name || !self.node_answers_to_attribute_name(node, key) { + self.postings.remove(SelectorPostingKey::AttributeName(key), node); } } - (false, Err(_)) => {} } } /// Whether any attribute the node still carries is indexed under `key`. #[must_use] fn node_answers_to_attribute_name(&self, node: StyleNodeID, key: StyleAtomID) -> bool { - self.pending_rows.get(&node).is_some_and(|facts| { + self.pending_rows.get(node).is_some_and(|facts| { facts .attributes .iter() @@ -3001,26 +3292,27 @@ impl ElementFactStore { } pub fn set_state(&mut self, node: StyleNodeID, fact: StateFact, value: bool) { - let facts = self.pending_row_mut(node); - if value { - facts.states.insert(fact); - } else { - facts.states.remove(fact); - } + self.edit_pending_row(node, |facts| { + if value { + facts.states.insert(fact); + } else { + facts.states.remove(fact); + } + }); } pub fn set_parts(&mut self, node: StyleNodeID, parts: &[StyleAtomID]) { - self.pending_row_mut(node).parts = parts.to_vec(); + self.edit_pending_row(node, |facts| facts.parts = parts.to_vec()); } pub fn set_custom_states(&mut self, node: StyleNodeID, states: &[StyleAtomID]) { - self.pending_row_mut(node).custom_states = states.to_vec(); + self.edit_pending_row(node, |facts| facts.custom_states = states.to_vec()); } pub fn forget(&mut self, node: StyleNodeID) { self.memory_dirty = true; self.element_declared_properties.remove(node); - self.pending_rows.remove(&node); + self.pending_rows.remove(node); let Some(row) = self.rows.row_of(node) else { return; }; @@ -3099,11 +3391,7 @@ impl ElementFactStore { } } } - for row in self - .pending_rows - .values() - .chain(self.selector_query_before_rows.values()) - { + for row in self.pending_rows.values() { if !row.language.is_none() { live_languages.insert(row.language, ()); } @@ -3152,7 +3440,7 @@ impl ElementFactStore { #[must_use] #[cfg(test)] pub fn covers(&self, node: StyleNodeID) -> bool { - self.rows.row_of(node).is_some() || self.pending_rows.contains_key(&node) + self.rows.row_of(node).is_some() || self.pending_rows.contains(node) } /// Pack the facts of a bounded set of style nodes into one batch. @@ -3192,16 +3480,6 @@ impl ElementFactStore { } fn auxiliary_capacity_bytes(&self) -> u64 { - let pending_payloads = self - .pending_rows - .values() - .map(PendingFactRow::capacity_bytes) - .sum::(); - let selector_query_before_payloads = self - .selector_query_before_rows - .values() - .map(PendingFactRow::capacity_bytes) - .sum::(); let metadata_payloads = self .metadata .iter() @@ -3231,8 +3509,6 @@ impl ElementFactStore { capacity_bytes! { shallow [ - self.pending_rows, - self.selector_query_before_rows, self.custom_property_name_sets, self.custom_property_name_set_vacancies, self.custom_property_set_ids_by_name, @@ -3242,8 +3518,7 @@ impl ElementFactStore { ]; cached []; nested [ - pending_payloads, - selector_query_before_payloads, + self.pending_rows.capacity_bytes(), metadata_payloads, custom_property_name_payloads, custom_property_name_index_payloads, @@ -3254,6 +3529,12 @@ impl ElementFactStore { } } + fn apply_capacity_bytes(&self) -> u64 { + self.rows.capacity_bytes() + + self.pending_rows.capacity_bytes() + + self.element_declared_properties.capacity_bytes() + } + #[must_use] pub fn capacity_bytes(&self) -> u64 { capacity_bytes! { @@ -3271,7 +3552,6 @@ impl ElementFactStore { self.memory, self.memory_dirty, self.pending_rows, - self.selector_query_before_rows, self.custom_property_name_sets, self.custom_property_name_set_vacancies, self.custom_property_set_ids_by_name, @@ -3282,13 +3562,13 @@ impl ElementFactStore { } } - /// Commit every pending fact-row edit to the authoritative arrangement. - pub fn commit_pending(&mut self, memory: &mut MemoryController) { + /// Commit every fact-staging edit to the authoritative arrangement. + pub fn apply_staged(&mut self, memory: &mut MemoryController) { if !self.memory_dirty { self.rebuild_missing_postings(memory); return; } - let pending_rows = std::mem::take(&mut self.pending_rows); + let pending_rows = self.pending_rows.dirty_rows(); for (node, facts) in pending_rows { let replaced_bytes = self .rows @@ -3314,8 +3594,9 @@ impl ElementFactStore { .checked_add(replaced_bytes) .expect("primary fact byte count overflow"); } + self.pending_rows.mark_applied(); if self.primary_stale_bytes > self.primary_live_bytes { - let live: Vec<(StyleNodeID, PendingFactRow)> = self + let live: Vec<(StyleNodeID, StagedFactRow)> = self .rows .live_nodes() .map(|node| (node, self.snapshot_row(node))) @@ -3340,60 +3621,35 @@ impl ElementFactStore { .sum::() ); } - let current = self.capacity_bytes(); + let apply_capacity_bytes = self.apply_capacity_bytes(); + let current = self + .settled_non_apply_capacity_bytes + .checked_add(apply_capacity_bytes) + .expect("element fact byte count overflow"); self.memory.resize_required_to(memory, current); self.memory_dirty = false; self.rebuild_missing_postings(memory); } pub fn prepare_selector_query(&mut self, memory: &mut MemoryController) { - let mut pending_nodes = Vec::with_capacity(self.pending_rows.len()); - for &node in self.pending_rows.keys() { - pending_nodes.push(node); - } - for node in pending_nodes { - if !self.selector_query_before_rows.contains_key(&node) { - let before = self.snapshot_row(node); - self.selector_query_before_rows.insert(node, before); - } - } - self.commit_pending(memory); - } - - /// Put the saved before rows back in the resident arrangement and stage the query-visible rows as the after side. - pub fn restore_prepared_selector_query_input(&mut self, memory: &mut MemoryController) { - if self.selector_query_before_rows.is_empty() { - return; - } - - let mut after_rows = std::mem::take(&mut self.pending_rows); - for &node in self.selector_query_before_rows.keys() { - after_rows.entry(node).or_insert_with(|| self.snapshot_row(node)); - } - - self.pending_rows = std::mem::take(&mut self.selector_query_before_rows); - self.memory_dirty = true; - self.commit_pending(memory); - self.pending_rows = after_rows; - self.memory_dirty = !self.pending_rows.is_empty(); - let current = self.capacity_bytes(); - self.memory.resize_required_to(memory, current); + self.apply_staged(memory); } /// Snapshot the committed rows which pending local facts will replace at the barrier. #[must_use] - pub fn before_pending_facts(&self) -> StyleNodeFacts { + pub fn staged_before_facts(&self) -> StyleNodeFacts { let mut nodes = Vec::with_capacity(self.pending_rows.len()); - for &node in self.pending_rows.keys() { + for node in self.pending_rows.keys() { nodes.push(node); } nodes.sort_unstable(); let mut before = StyleNodeFacts::new(); for node in nodes { - let facts = self.snapshot_row(node); append_fact_row( node, - &facts, + self.pending_rows + .before(node) + .expect("fact staging node must have a before row"), &self.attribute_value_texts, &self.attribute_name_locals, &self.language_texts, @@ -3403,32 +3659,17 @@ impl ElementFactStore { before } - /// Pack the final rows of the pending transaction without committing them. - #[must_use] - pub fn pending_facts(&self) -> StyleNodeFacts { - let mut nodes = Vec::with_capacity(self.pending_rows.len()); - for &node in self.pending_rows.keys() { - nodes.push(node); - } - nodes.sort_unstable(); - let mut after = StyleNodeFacts::new(); - for node in nodes { - append_fact_row( - node, - &self.pending_rows[&node], - &self.attribute_value_texts, - &self.attribute_name_locals, - &self.language_texts, - &mut after, - ); - } - after + pub fn release_staging(&mut self, memory: &mut MemoryController) { + self.pending_rows.clear(); + let current = self.capacity_bytes(); + self.memory.resize_required_to(memory, current); + self.settled_non_apply_capacity_bytes = current - self.apply_capacity_bytes(); } } fn append_fact_row( node: StyleNodeID, - facts: &PendingFactRow, + facts: &StagedFactRow, attribute_value_texts: &HashMap>, attribute_name_locals: &HashMap, language_texts: &HashMap>, @@ -3565,7 +3806,7 @@ mod tests { ); facts.set_attribute(node, attribute, StyleAtomID(42), true, &mut memory); facts.set_directionality(node, directionality, &mut memory); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); for key in [ DispatchKey::TagName(tag), @@ -3592,7 +3833,7 @@ mod tests { let node = StyleNodeID::element(1); facts.set_directionality(node, StyleAtomID(2), &mut memory); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); facts.set_directionality(node, StyleAtomID(4), &mut memory); assert_eq!(facts.directionality_of(node), StyleAtomID(4)); @@ -3611,7 +3852,7 @@ mod tests { }; facts.note_attribute_name_forms(name, forms); facts.set_attribute(node, name, StyleAtomID(20), true, &mut memory); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); for key in [name, forms.local, forms.folded_name, forms.folded_local] { assert!(known_posting(&facts.postings, SelectorPostingKey::AttributeName(key)).contains(node)); @@ -3636,7 +3877,7 @@ mod tests { facts.set_tag(first, StyleAtomID(10), &mut memory); facts.set_tag(later, StyleAtomID(20), &mut memory); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); assert_eq!(facts.len(), 2); assert_eq!(facts.rows.row_by_element_index.len(), 65); assert_eq!(facts.tag_of_node(first), StyleAtomID(10)); @@ -3646,7 +3887,7 @@ mod tests { assert_eq!(facts.len(), 1); assert!(!facts.covers(first)); facts.ensure_row(first); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); assert_eq!(facts.len(), 2); assert_eq!(facts.tag_of_node(first), StyleAtomID::NONE); } @@ -3663,9 +3904,9 @@ mod tests { facts.set_tag(node, StyleAtomID(10), &mut memory); facts.set_class(node, first_class, true, &mut memory); - assert!(facts.has_pending_input()); - facts.commit_pending(&mut memory); - assert!(!facts.has_pending_input()); + assert!(facts.has_dirty_staging()); + facts.apply_staged(&mut memory); + assert!(!facts.has_dirty_staging()); let initial_generation = facts.primary().generation(); assert_eq!(facts.primary().row_count(), 1); assert_eq!(facts.primary().stale_rows(), 0); @@ -3675,7 +3916,7 @@ mod tests { facts.set_state(node, StateFact::Hover, true); facts.set_parts(node, &[part]); facts.set_custom_states(node, &[custom_state]); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); assert_eq!(facts.primary().generation(), initial_generation); assert_eq!(facts.primary().row_count(), 2); assert_eq!(facts.primary().stale_rows(), 1); @@ -3687,7 +3928,7 @@ mod tests { assert_eq!(facts.primary().custom_states_of(row), &[custom_state]); facts.set_class(node, first_class, false, &mut memory); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); assert_ne!(facts.primary().generation(), initial_generation); assert_eq!(facts.primary().row_count(), 1); assert_eq!(facts.primary().stale_rows(), 0); @@ -3743,7 +3984,8 @@ mod tests { let text_catalogs = facts.capacity_bytes(); facts.set_custom_property_names(node, &[StyleAtomID(16), StyleAtomID(17)], &mut memory); assert!(facts.capacity_bytes() > text_catalogs); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); + facts.release_staging(&mut memory); assert_eq!( memory.bytes_in_category(MemoryCategory::StyleNodeMapping), facts.capacity_bytes() @@ -3767,7 +4009,7 @@ mod tests { facts.set_attribute_value_text(attribute_value, &[index as u16]); facts.set_attribute(node, attribute_name, attribute_value, true, &mut memory); facts.set_custom_property_names(node, &[custom_property], &mut memory); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); facts.forget(node); facts.sweep_auxiliary_catalogs(); @@ -3833,7 +4075,7 @@ mod tests { facts.element_declared_properties.get(later, svg), (&[declared(4, false, 40)][..], true) ); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); assert_eq!( memory.bytes_in_category(MemoryCategory::StyleNodeMapping), facts.capacity_bytes() @@ -3914,19 +4156,19 @@ mod tests { facts.ensure_row(node); facts.set_class(node, old_class, true, &mut memory); facts.set_animation_names(node, &[old_animation], &mut memory); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); facts.postings_mut().evict_all(); memory.set_tier3_limit_for_test(0); facts.set_class(node, old_class, false, &mut memory); facts.set_class(node, new_class, true, &mut memory); facts.set_animation_names(node, &[new_animation], &mut memory); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); let new_class_key = SelectorPostingKey::Class(new_class); assert!(matches!(facts.postings().lookup(new_class_key), Lookup::Missing(gap) if gap == new_class_key)); memory.set_tier3_limit_for_test(u64::MAX); - facts.commit_pending(&mut memory); + facts.apply_staged(&mut memory); let old_class_key = SelectorPostingKey::Class(old_class); let old_animation_key = DependencyPostingKey::AnimationName(old_animation); let new_animation_key = DependencyPostingKey::AnimationName(new_animation); @@ -3965,7 +4207,8 @@ mod tests { store.set_class(node, StyleAtomID(name), true, &mut memory); store.set_attribute(node, StyleAtomID(name), StyleAtomID::NONE, true, &mut memory); } - store.commit_pending(&mut memory); + store.apply_staged(&mut memory); + store.release_staging(&mut memory); assert_eq!( memory.bytes_in_category(MemoryCategory::StyleNodeMapping), store.capacity_bytes() @@ -3979,7 +4222,8 @@ mod tests { store.set_class(node, StyleAtomID(name), false, &mut memory); store.set_attribute(node, StyleAtomID(name), StyleAtomID::NONE, false, &mut memory); } - store.commit_pending(&mut memory); + store.apply_staged(&mut memory); + store.release_staging(&mut memory); assert_eq!( memory.bytes_in_category(MemoryCategory::StyleNodeMapping), store.capacity_bytes() @@ -3988,7 +4232,8 @@ mod tests { for index in 1..40_u32 { store.forget(StyleNodeID::element(index)); - store.commit_pending(&mut memory); + store.apply_staged(&mut memory); + store.release_staging(&mut memory); assert_eq!( memory.bytes_in_category(MemoryCategory::StyleNodeMapping), store.capacity_bytes() @@ -3997,7 +4242,8 @@ mod tests { assert!(store.is_empty()); // The retained column capacities remain charged after every live row is forgotten. - store.commit_pending(&mut memory); + store.apply_staged(&mut memory); + store.release_staging(&mut memory); assert_eq!( memory.bytes_in_category(MemoryCategory::StyleNodeMapping), store.capacity_bytes() @@ -4438,11 +4684,13 @@ mod tests { store.set_attribute_value_text(old, &old_text); store.set_attribute_value_text(new, &new_text); store.set_attribute(node, name, old, true, &mut memory); - store.commit_pending(&mut memory); + store.apply_staged(&mut memory); + store.release_staging(&mut memory); store.set_attribute(node, name, new, true, &mut memory); - let before = store.before_pending_facts(); - let after = store.pending_facts(); + let before = store.staged_before_facts(); + store.apply_staged(&mut memory); + let after = store.primary(); let old_attribute = before.attribute_of(before.row_of(node).unwrap(), name).unwrap(); assert_eq!(old_attribute.value, old); @@ -4452,6 +4700,66 @@ mod tests { assert_eq!(after.text_of(new_attribute), Some(new_text.as_slice())); } + #[test] + fn pending_fact_rows_use_paged_element_identity_slots() { + let first = StyleNodeID::element(1); + let distant = StyleNodeID::element(64); + let mut rows = StagedFactRows::default(); + let mut before = StagedFactRow::default(); + before.tag = StyleAtomID(1); + let mut after = StagedFactRow::default(); + after.tag = StyleAtomID(2); + + rows.insert(distant, before); + rows.insert(first, StagedFactRow::default()); + rows.insert(distant, after); + + assert!(rows.has_dirty()); + assert_eq!(rows.rows.page_count(), 2); + assert_eq!(rows.entries.len(), 2); + assert_eq!(rows.keys().collect::>(), vec![distant, first]); + assert_eq!(rows.len(), 2); + assert_eq!(rows.before(distant).unwrap().tag, StyleAtomID(1)); + assert_eq!(rows.get(distant).unwrap().tag, StyleAtomID(2)); + rows.mark_applied(); + assert!(!rows.has_dirty()); + rows.edit(first, |row| row.tag = StyleAtomID(3)).unwrap(); + assert!(rows.has_dirty()); + rows.remove(distant); + assert!(rows.has_dirty()); + assert_eq!(rows.keys().collect::>(), vec![first]); + rows.remove(first); + assert!(!rows.has_dirty()); + rows.insert(first, StagedFactRow::default()); + assert_eq!(rows.keys().collect::>(), vec![first]); + assert_eq!(rows.dirty_rows().len(), 1); + assert_eq!(rows.capacity_bytes(), rows.recomputed_capacity_bytes()); + + let directory_capacity = rows.rows.directory_capacity(); + let entry_capacity = rows.entries.capacity(); + rows.clear(); + assert!(rows.is_empty()); + assert_eq!(rows.rows.page_count(), 2); + assert_eq!(rows.rows.directory_capacity(), directory_capacity); + assert_eq!(rows.entries.capacity(), entry_capacity); + } + + #[test] + fn pending_fact_rows_release_oversized_entry_arenas() { + let mut rows = StagedFactRows::default(); + for index in 1..=1024 { + rows.insert(StyleNodeID::element(index), StagedFactRow::default()); + } + rows.clear(); + let peak_capacity = rows.entries.capacity(); + + rows.insert(StyleNodeID::element(1), StagedFactRow::default()); + rows.clear(); + + assert!(rows.entries.capacity() < peak_capacity); + assert_eq!(rows.capacity_bytes(), rows.recomputed_capacity_bytes()); + } + #[test] fn states_pack_into_one_word() { let mut states = StateSet::default(); diff --git a/Libraries/LibWeb/Rust/src/css/style/inputs.rs b/Libraries/LibWeb/Rust/src/css/style/inputs.rs index 0a44c4af5c93..ad907d35cdb8 100644 --- a/Libraries/LibWeb/Rust/src/css/style/inputs.rs +++ b/Libraries/LibWeb/Rust/src/css/style/inputs.rs @@ -54,7 +54,6 @@ impl StyleEngine { routing_needs_detachment_sweep: false, sheet_rule_replacement: None, pending_sheet_rule_replacements: Column::default(), - departed: Vec::new(), match_workspace: MatchEvaluationWorkspace::default(), exact_covered_scratch: Vec::new(), next_style_transaction_version: StyleTransactionVersion(1), @@ -1046,9 +1045,6 @@ impl StyleEngine { live_animation_overlays_after as u64, ); self.tree.retire_element(node, &mut self.memory); - // The facts stay until routing finishes because they determine which selectors the - // departure can reach. - self.departed.push(node); } self.tree_staging.mark_applied(); self.publish_budget_inputs(); diff --git a/Libraries/LibWeb/Rust/src/css/style/matching.rs b/Libraries/LibWeb/Rust/src/css/style/matching.rs index cf0ae8a41cbf..c1db8b15eeda 100644 --- a/Libraries/LibWeb/Rust/src/css/style/matching.rs +++ b/Libraries/LibWeb/Rust/src/css/style/matching.rs @@ -1146,22 +1146,12 @@ impl StyleEngine { // The compound of a retainable query reads only facts the witness itself publishes, so one // current-side row decides it exactly. A node the store has no row for reports a miss, and // the miss routes conservatively rather than deciding anything. - self.ensure_transaction_fact_rows(&[witness]); - let resident_facts = if self - .transaction_fact_view - .as_ref() - .is_some_and(|view| view.resident_side == TransactionFactSide::Before) - { - self.facts.committed() - } else { - self.facts.primary() - }; - let current_facts = self - .transaction_fact_view - .as_ref() - .and_then(|view| view.facts(TransactionFactSide::After, resident_facts)) - .unwrap_or(resident_facts); - if current_facts.row_of(witness).is_none() { + let resident_facts = self.facts.primary(); + let transaction_fact_view = self.transaction_fact_view.as_ref(); + let current_row = transaction_fact_view + .and_then(|view| view.row_of(TransactionFactSide::After, resident_facts, witness)) + .or_else(|| resident_facts.row_of(witness).map(|row| (resident_facts, row))); + if current_row.is_none() { return Lookup::Missing(RelationalWitnessGap::IncompleteFacts { key, witness, @@ -1169,7 +1159,10 @@ impl StyleEngine { }); } let program = self.programs.get(program_id); - let evaluator = MatchEvaluator::new(&self.tree, current_facts); + let mut evaluator = MatchEvaluator::new(&self.tree, resident_facts); + if let Some(view) = transaction_fact_view { + evaluator = evaluator.with_transaction_fact_view(view, TransactionFactSide::After); + } match evaluator.matches_selector_node(program, query.compound, witness, &mut self.counters) { Ok(true) => Lookup::Known(witness), Ok(false) => { diff --git a/Libraries/LibWeb/Rust/src/css/style/mod.rs b/Libraries/LibWeb/Rust/src/css/style/mod.rs index 4ed7c39fba04..94ec25ec26a9 100644 --- a/Libraries/LibWeb/Rust/src/css/style/mod.rs +++ b/Libraries/LibWeb/Rust/src/css/style/mod.rs @@ -525,8 +525,6 @@ pub struct StyleEngine { sheet_rule_replacement: Option, /// Unmatched old rule sequences retained until the transaction boundary, indexed by sheet. pending_sheet_rule_replacements: Column>, - /// Elements that left during the transaction. Their facts remain available through routing. - departed: Vec, match_workspace: MatchEvaluationWorkspace, /// Scratch for the fact rows one exact candidate evaluation covers, reused across candidates. exact_covered_scratch: Vec, diff --git a/Libraries/LibWeb/Rust/src/css/style/ordering.rs b/Libraries/LibWeb/Rust/src/css/style/ordering.rs index 215c2e112dc9..76464badee72 100644 --- a/Libraries/LibWeb/Rust/src/css/style/ordering.rs +++ b/Libraries/LibWeb/Rust/src/css/style/ordering.rs @@ -1129,14 +1129,13 @@ impl StyleEngine { /// Normalize the pending inputs without advancing the committed snapshot. pub(super) fn drain_transaction(&mut self) -> StyleTransaction { - self.facts.restore_prepared_selector_query_input(&mut self.memory); self.initial_tree_bulk_load_is_pending = false; self.finalize_pending_sheet_rule_replacements(); self.journal.take_transaction(&mut self.memory, &mut self.counters) } /// Advance staged program and tree state to the transaction's final snapshot. - pub(super) fn commit_pending_structural_state(&mut self) { + pub(super) fn apply_staged_structural_state(&mut self) { self.apply_pending_program_activation(); self.apply_pending_rule_declarations(); self.apply_pending_rule_versions(); @@ -1150,15 +1149,11 @@ impl StyleEngine { } /// Advance staged local facts to the transaction's final snapshot. - pub(super) fn commit_pending_facts( - &mut self, - transaction: &mut StyleTransaction, - before_facts: BeforeFactRetention, - ) { - if before_facts == BeforeFactRetention::Retain && self.facts.has_pending_input() { - transaction.install_before_facts(self.facts.before_pending_facts(), &mut self.memory); + pub(super) fn apply_staged_facts(&mut self, transaction: &mut StyleTransaction) { + if self.facts.has_staged_input() { + transaction.install_before_facts(self.facts.staged_before_facts(), &mut self.memory); } - self.facts.commit_pending(&mut self.memory); + self.facts.apply_staged(&mut self.memory); } /// Finish the transaction metadata which depends on committed program state. @@ -1190,9 +1185,9 @@ impl StyleEngine { } /// Advance every staged input family to the transaction's final snapshot. - pub(super) fn commit_pending(&mut self, transaction: &mut StyleTransaction, before_facts: BeforeFactRetention) { - self.commit_pending_structural_state(); - self.commit_pending_facts(transaction, before_facts); + pub(super) fn apply_staged(&mut self, transaction: &mut StyleTransaction) { + self.apply_staged_structural_state(); + self.apply_staged_facts(transaction); self.finish_pending_commit(transaction); } @@ -1200,7 +1195,7 @@ impl StyleEngine { /// drains here first, so normalization never combines changes across an observation boundary. pub fn take_transaction(&mut self) -> StyleTransaction { let mut transaction = self.drain_transaction(); - self.commit_pending(&mut transaction, BeforeFactRetention::Retain); + self.apply_staged(&mut transaction); transaction } @@ -1210,6 +1205,7 @@ impl StyleEngine { self.forget_departed_elements(); self.tree_staging.clear(); self.tree_staging_memory.resize_required_to(&mut self.memory, 0); + self.facts.release_staging(&mut self.memory); self.rules_with_incomplete_old_declarations.clear(); self.sweep_selector_programs(); self.shed_routing_for_detached_sheets(); @@ -1315,7 +1311,12 @@ impl StyleEngine { /// left behind here would be read as the next occupant's. Dropping it at the transaction /// boundary keeps the row alive for exactly as long as routing needs it. pub(super) fn forget_departed_elements(&mut self) { - let departed = std::mem::take(&mut self.departed); + let departed: Vec<_> = self + .tree_staging + .rows() + .into_iter() + .filter_map(|(node, _, after)| after.is_none().then_some(node)) + .collect(); if departed.is_empty() { return; } diff --git a/Libraries/LibWeb/Rust/src/css/style/prefix.rs b/Libraries/LibWeb/Rust/src/css/style/prefix.rs index 09501786d093..1127f30be709 100644 --- a/Libraries/LibWeb/Rust/src/css/style/prefix.rs +++ b/Libraries/LibWeb/Rust/src/css/style/prefix.rs @@ -45,6 +45,7 @@ use super::selector::AttributeOperator; use super::selector::FeatureTest; use super::selector::Incomplete; use super::selector::MatchEvaluator; +use super::selector::MatchFactRow; use super::selector::NamespaceTest; use super::selector::PrefixStructuralTest; use super::selector::RouteID; @@ -999,13 +1000,18 @@ impl LocalFactInterner { return self.identities[slot].0; } counters.bump(Counter::PrefixLocalFactIdentityMisses); + let identity = self.mint_identity(); + let slot = LocalFactSlot(u32::try_from(self.identities.len()).expect("local fact table exceeds u32 indexing")); + self.identities.insert(hash, slot, (identity, row)); + identity + } + + fn mint_identity(&mut self) -> u32 { let identity = self.next_identity; self.next_identity = self .next_identity .checked_add(1) .expect("local fact identity space exhausted"); - let slot = LocalFactSlot(u32::try_from(self.identities.len()).expect("local fact table exceeds u32 indexing")); - self.identities.insert(hash, slot, (identity, row)); identity } @@ -1078,7 +1084,7 @@ pub(super) struct PrefixStates { /// The batch row space `transition_by_row` and the local-fact representatives were built /// against; see StyleNodeFacts::generation. facts_generation: u64, - ancestor_chain: Vec<(StyleNodeID, u32)>, + ancestor_chain: Vec, epoch: u32, complete: bool, automaton_step_count: usize, @@ -1387,10 +1393,8 @@ impl<'a, 'b> PrefixEvaluation<'a, 'b> { .matches_nth(self.programs.get(program), nth, node, counters) } PrefixStructuralTest::Empty => { - let Some(row) = self.facts.row_of(node) else { - return Err(Incomplete::MissingFacts(node)); - }; - Ok(self.tree.first_element_child(node).is_none() && !self.facts.has_text_content_of(row)) + let row = self.row_of(node)?; + Ok(self.tree.first_element_child(node).is_none() && !row.facts.has_text_content_of(row.row)) } } } @@ -1401,7 +1405,7 @@ impl<'a, 'b> PrefixEvaluation<'a, 'b> { step: PrefixStepID, counters: &mut Counters, ) -> Result { - let row = self.facts.row_of(node).ok_or(Incomplete::MissingFacts(node))?; + let row = self.row_of(node)?; let compound = &self.automaton.compounds[self.automaton.steps[step.0 as usize].compound.0 as usize]; match &compound.predicate { PrefixPredicate::Features { @@ -1421,7 +1425,7 @@ impl<'a, 'b> PrefixEvaluation<'a, 'b> { .automaton .features_for(*feature_start, *feature_len) .iter() - .all(|&feature| matches_feature(self.facts, row, feature))) + .all(|&feature| matches_feature(row.facts, row.row, feature))) } PrefixPredicate::Program { program, local } => { self.evaluator @@ -1437,7 +1441,7 @@ impl<'a, 'b> PrefixEvaluation<'a, 'b> { step: PrefixStepID, counters: &mut Counters, ) -> Result { - let row = self.facts.row_of(node).ok_or(Incomplete::MissingFacts(node))?; + let row = self.row_of(node)?; let compound = &self.automaton.compounds[self.automaton.steps[step.0 as usize].compound.0 as usize]; match &compound.predicate { PrefixPredicate::Features { @@ -1450,7 +1454,7 @@ impl<'a, 'b> PrefixEvaluation<'a, 'b> { .automaton .features_for(*feature_start, *feature_len) .iter() - .all(|&feature| matches_feature(self.facts, row, feature)), + .all(|&feature| matches_feature(row.facts, row.row, feature)), ), PrefixPredicate::Program { program, local } => { self.evaluator @@ -1491,6 +1495,14 @@ impl<'a, 'b> PrefixEvaluation<'a, 'b> { selection, } } + + fn row_of(&self, node: StyleNodeID) -> Result, Incomplete> { + self.evaluator.row_of(node) + } + + fn facts_are_composite(&self) -> bool { + !self.evaluator.serves_only_resident_rows() + } } impl PrefixStates { @@ -2257,7 +2269,7 @@ impl PrefixStates { &mut self, evaluation: &PrefixEvaluation<'_, '_>, node: StyleNodeID, - row: u32, + row: MatchFactRow<'_>, old: PrefixTransition, entering: EnteringStates, entering_deltas: PrefixEnteringDeltas, @@ -2323,7 +2335,9 @@ impl PrefixStates { self.set_positional_bits(node, positional_bits); } self.set_entering(node, entering); - self.transition_by_row[row as usize] = transition; + if !evaluation.facts_are_composite() { + self.transition_by_row[row.row as usize] = transition; + } self.set_transition(node, transition); Some(transition) } @@ -2346,8 +2360,11 @@ impl PrefixStates { counters: &mut Counters, ) -> PrefixTransitionLookup { self.prepare_rows(evaluation.facts.generation(), evaluation.facts.row_count()); - let Some(row) = evaluation.facts.row_of(node) else { - return PrefixTransitionLookup::Missing(PrefixTransitionGap::Incomplete(Incomplete::MissingFacts(node))); + let row = match evaluation.row_of(node) { + Ok(row) => row, + Err(incomplete) => { + return PrefixTransitionLookup::Missing(PrefixTransitionGap::Incomplete(incomplete)); + } }; let old = match self.transition_of(node) { PrefixTransitionLookup::Known(transition) => Some(transition), @@ -2372,7 +2389,13 @@ impl PrefixStates { 0 }; let local_facts = if local_facts_changed || old.is_none() { - let identity = self.local_fact_interner.intern(evaluation.facts, row, counters); + let identity = match evaluation.facts_are_composite() { + true => { + counters.bump(Counter::PrefixLocalFactIdentityMisses); + self.local_fact_interner.mint_identity() + } + false => self.local_fact_interner.intern(row.facts, row.row, counters), + }; self.set_local_facts(node, identity); identity } else { @@ -2429,18 +2452,19 @@ impl PrefixStates { { self.collect_active(active, &mut active_candidates); } - let mut append_roots = |facts: &StyleNodeFacts| { - let Some(row) = facts.row_of(node) else { + let mut append_roots = |evaluation: &PrefixEvaluation<'_, '_>| { + let Ok(row) = evaluation.row_of(node) else { return; }; - facts.for_each_dispatch_probe(row, evaluation.tree.parent(node).is_none(), |key, _| { - if let Some(bucket) = evaluation.automaton.bucket(key) { - active_candidates.extend_from_slice(&bucket.root_steps); - } - }); + row.facts + .for_each_dispatch_probe(row.row, evaluation.tree.parent(node).is_none(), |key, _| { + if let Some(bucket) = evaluation.automaton.bucket(key) { + active_candidates.extend_from_slice(&bucket.root_steps); + } + }); }; - append_roots(old_evaluation.facts); - append_roots(evaluation.facts); + append_roots(old_evaluation); + append_roots(evaluation); active_candidates.retain(|step| { local_affected_candidates .binary_search_by_key(step, |producer| producer.step) @@ -2455,8 +2479,8 @@ impl PrefixStates { affected_candidates.retain(|&step| { local_affected_candidates .is_some_and(|producers| producers.binary_search_by_key(&step, |producer| producer.step).is_ok()) - || evaluation.facts.carries_dispatch_key( - row, + || row.facts.carries_dispatch_key( + row.row, evaluation.automaton.compounds[evaluation.automaton.steps[step.0 as usize].compound.0 as usize] .dispatch_key, is_document_root, @@ -2532,7 +2556,7 @@ impl PrefixStates { PrefixTransitionLookup::Known(transition) => transition, PrefixTransitionLookup::Missing(gap) => return PrefixTransitionLookup::Missing(gap), }; - surface.remember_transition(node, row, transition); + surface.remember_transition(evaluation, node, row, transition); (transition, origin == PrefixTransitionOrigin::Computed) }; @@ -2718,7 +2742,7 @@ impl PrefixStates { &mut self, evaluation: &PrefixEvaluation<'_, '_>, entering: EnteringStates, - row: u32, + row: MatchFactRow<'_>, node: StyleNodeID, is_document_root: bool, positional_bits: u32, @@ -2732,11 +2756,9 @@ impl PrefixStates { self.begin_transition(automaton); self.enter_states(entering); - evaluation - .facts - .for_each_dispatch_probe(row, is_document_root, |key, _| { - self.offer_key(automaton, entering, key, evaluation.selection, counters); - }); + row.facts.for_each_dispatch_probe(row.row, is_document_root, |key, _| { + self.offer_key(automaton, entering, key, evaluation.selection, counters); + }); for candidate_index in 0..self.candidates.len() { let step_id = self.candidates[candidate_index]; @@ -2756,7 +2778,7 @@ impl PrefixStates { && automaton .features_for(*feature_start, *feature_len) .iter() - .all(|&feature| matches_feature(evaluation.facts, row, feature)) + .all(|&feature| matches_feature(row.facts, row.row, feature)) } PrefixPredicate::Program { program, local } => match evaluation.evaluator.matches_prefix_local( *program, @@ -3540,25 +3562,27 @@ impl PrefixTransitionSurface<'_> { } } - fn push_ancestor(&mut self, node: StyleNodeID, row: u32) { + fn push_ancestor(&mut self, node: StyleNodeID) { match self { - Self::Retained(states) => states.ancestor_chain.push((node, row)), + Self::Retained(states) => states.ancestor_chain.push(node), } } - fn pop_ancestor(&mut self) -> Option<(StyleNodeID, u32)> { + fn pop_ancestor(&mut self) -> Option { match self { Self::Retained(states) => states.ancestor_chain.pop(), } } - fn known_transition(&self, facts: &StyleNodeFacts, node: StyleNodeID) -> Option { - let row = facts.row_of(node)? as usize; - let known = match self { - Self::Retained(states) => states.transition_by_row[row], - }; - if known.state != UNKNOWN_STATE { - return Some(known); + fn known_transition(&self, evaluation: &PrefixEvaluation<'_, '_>, node: StyleNodeID) -> Option { + if !evaluation.facts_are_composite() { + let row = evaluation.facts.row_of(node)? as usize; + let known = match self { + Self::Retained(states) => states.transition_by_row[row], + }; + if known.state != UNKNOWN_STATE { + return Some(known); + } } match self { Self::Retained(states) => match states.transition_of(node) { @@ -3570,16 +3594,22 @@ impl PrefixTransitionSurface<'_> { fn local_fact_identity( &mut self, - facts: &StyleNodeFacts, + evaluation: &PrefixEvaluation<'_, '_>, node: StyleNodeID, - row: u32, + row: MatchFactRow<'_>, counters: &mut Counters, ) -> u32 { match self { Self::Retained(states) => match states.local_facts_of(node) { Some(identity) => identity, None => { - let identity = states.local_fact_interner.intern(facts, row, counters); + let identity = match evaluation.facts_are_composite() { + true => { + counters.bump(Counter::PrefixLocalFactIdentityMisses); + states.local_fact_interner.mint_identity() + } + false => states.local_fact_interner.intern(row.facts, row.row, counters), + }; states.set_local_facts(node, identity); identity } @@ -3601,10 +3631,18 @@ impl PrefixTransitionSurface<'_> { } } - fn remember_transition(&mut self, node: StyleNodeID, row: u32, transition: PrefixTransition) { + fn remember_transition( + &mut self, + evaluation: &PrefixEvaluation<'_, '_>, + node: StyleNodeID, + row: MatchFactRow<'_>, + transition: PrefixTransition, + ) { match self { Self::Retained(states) => { - states.transition_by_row[row as usize] = transition; + if !evaluation.facts_are_composite() { + states.transition_by_row[row.row as usize] = transition; + } // Retained coverage is ancestor-closed: a missing parent therefore proves that no // descendant below it can hold a transition that this update would leave stale. states.set_transition(node, transition); @@ -3628,7 +3666,7 @@ impl PrefixTransitionSurface<'_> { &mut self, evaluation: &PrefixEvaluation<'_, '_>, node: StyleNodeID, - row: u32, + row: MatchFactRow<'_>, inputs: TransitionInputs, counters: &mut Counters, ) -> PrefixTransitionLookup<(PrefixTransition, PrefixTransitionOrigin)> { @@ -3684,16 +3722,16 @@ fn transition_for( surface.clear_ancestor_chain(); let mut current = node; loop { - let Some(row) = evaluation.facts.row_of(current) else { - return PrefixTransitionLookup::Missing(PrefixTransitionGap::Incomplete(Incomplete::MissingFacts(current))); - }; - if let Some(known) = surface.known_transition(evaluation.facts, current) { + if let Err(incomplete) = evaluation.row_of(current) { + return PrefixTransitionLookup::Missing(PrefixTransitionGap::Incomplete(incomplete)); + } + if let Some(known) = surface.known_transition(evaluation, current) { if surface.ancestor_chain_is_empty() { return PrefixTransitionLookup::Known(known); } break; } - surface.push_ancestor(current, row); + surface.push_ancestor(current); if has_sibling_steps && let Some(previous) = evaluation.tree.previous_element_sibling(current) { current = previous; continue; @@ -3708,10 +3746,16 @@ fn transition_for( } let mut result = UNKNOWN_TRANSITION; - while let Some((node, row)) = surface.pop_ancestor() { + while let Some(node) = surface.pop_ancestor() { + let row = match evaluation.row_of(node) { + Ok(row) => row, + Err(incomplete) => { + return PrefixTransitionLookup::Missing(PrefixTransitionGap::Incomplete(incomplete)); + } + }; let parent_state = match evaluation.tree.parent(node) { Some(parent) if Some(parent) != evaluation.shadow_root => { - match surface.known_transition(evaluation.facts, parent) { + match surface.known_transition(evaluation, parent) { Some(transition) => transition.state, None => return PrefixTransitionLookup::Missing(PrefixTransitionGap::MissingTransition(parent)), } @@ -3720,7 +3764,7 @@ fn transition_for( }; let previous_state = if has_sibling_steps { match evaluation.tree.previous_element_sibling(node) { - Some(previous) => match surface.known_transition(evaluation.facts, previous) { + Some(previous) => match surface.known_transition(evaluation, previous) { Some(transition) => transition.right, None => { return PrefixTransitionLookup::Missing(PrefixTransitionGap::MissingTransition(previous)); @@ -3731,7 +3775,7 @@ fn transition_for( } else { 0 }; - let local_facts = surface.local_fact_identity(evaluation.facts, node, row, counters); + let local_facts = surface.local_fact_identity(evaluation, node, row, counters); let positional_bits = match evaluation.positional_bits(node, counters) { Ok(bits) => bits, Err(incomplete) => return PrefixTransitionLookup::Missing(PrefixTransitionGap::Incomplete(incomplete)), @@ -3753,7 +3797,7 @@ fn transition_for( PrefixTransitionLookup::Known((transition, _)) => transition, PrefixTransitionLookup::Missing(gap) => return PrefixTransitionLookup::Missing(gap), }; - surface.remember_transition(node, row, result); + surface.remember_transition(evaluation, node, row, result); } PrefixTransitionLookup::Known(result) } diff --git a/Libraries/LibWeb/Rust/src/css/style/program_updates.rs b/Libraries/LibWeb/Rust/src/css/style/program_updates.rs index 9e012a9099f2..a8e930792873 100644 --- a/Libraries/LibWeb/Rust/src/css/style/program_updates.rs +++ b/Libraries/LibWeb/Rust/src/css/style/program_updates.rs @@ -1083,14 +1083,9 @@ impl StyleEngine { let workspace_after = self.match_workspace.capacity_bytes(); self.memory .release(MemoryCategory::BatchScratch, workspace_before - workspace_after); - let plan_before_commit = self.facts.has_pending_input(); let classification = self.classify_transaction_facts(transaction, arrival_regions); let before_facts = transaction.take_before_facts(&mut self.memory); - let local_facts_are_shared = - !plan_before_commit && before_facts.is_none() && !transaction.has_coarsened_markers(); - let before = (!plan_before_commit && !local_facts_are_shared && !transaction.has_coarsened_markers()) - .then(|| before_facts.expect("a changed local fact transaction retained its old side")); - let after = plan_before_commit.then(|| self.facts.pending_facts()); + let before = (!transaction.has_coarsened_markers()).then_some(before_facts).flatten(); TransactionFactView { root, moved_features: if !transaction.has_coarsened_markers() { @@ -1099,7 +1094,6 @@ impl StyleEngine { FeatureFluxColumn::default() }, before, - after, before_sibling_geometry: SiblingSequenceGeometry::default(), before_sibling_sequence_by_parent: Vec::new(), before_sibling_parents_by_sequence: Vec::new(), @@ -1107,26 +1101,6 @@ impl StyleEngine { before_sibling_relations_available: false, prefix: classification.prefix, retained_truth_available: classification.retained_truth_available, - resident_side: if plan_before_commit { - TransactionFactSide::Before - } else { - TransactionFactSide::After - }, - local_facts_are_shared, - opposite_fully_materialized: false, - } - } - - /// The fact arrangement resident on the side from which this planning epoch started. - pub(super) fn planning_facts(&self) -> &StyleNodeFacts { - if self - .transaction_fact_view - .as_ref() - .is_some_and(|view| view.resident_side == TransactionFactSide::Before) - { - self.facts.committed() - } else { - self.facts.primary() } } diff --git a/Libraries/LibWeb/Rust/src/css/style/routing.rs b/Libraries/LibWeb/Rust/src/css/style/routing.rs index 51b9c1dc2a49..c7846e5ed461 100644 --- a/Libraries/LibWeb/Rust/src/css/style/routing.rs +++ b/Libraries/LibWeb/Rust/src/css/style/routing.rs @@ -956,8 +956,8 @@ impl StyleEngine { let before_states = self .transaction_fact_view .as_ref() - .and_then(|view| view.facts(TransactionFactSide::Before, self.planning_facts())) - .and_then(|facts| facts.row_of(node).map(|row| facts.states_of(row))); + .and_then(|view| view.row_of(TransactionFactSide::Before, self.facts.primary(), node)) + .map(|(facts, row)| facts.states_of(row)); if let Some(before_states) = before_states { for state in before_states.facts() { for &route in routing.sibling_first_routes_for_origin(DispatchKey::State(state)) { @@ -1107,11 +1107,11 @@ impl StyleEngine { if !directionality.is_none() { callback(DispatchKey::Directionality(directionality)); } - if let Some(row) = self.planning_facts().row_of(node) { - for &state in self.planning_facts().custom_states_of(row) { + if let Some(row) = self.facts.primary().row_of(node) { + for &state in self.facts.primary().custom_states_of(row) { callback(DispatchKey::CustomState(state)); } - for &part in self.planning_facts().parts_of(row) { + for &part in self.facts.primary().parts_of(row) { callback(DispatchKey::Part(part)); } } @@ -1663,7 +1663,7 @@ impl StyleEngine { return true; } let is_root = self.tree.parent(node).is_none(); - let resident_facts = self.planning_facts(); + let resident_facts = self.facts.primary(); if resident_facts .row_of(node) .is_some_and(|row| resident_facts.carries_dispatch_key(row, key, is_root)) @@ -1673,15 +1673,8 @@ impl StyleEngine { if self .transaction_fact_view .as_ref() - .and_then(|view| match view.resident_side { - TransactionFactSide::Before => view.after.as_ref(), - TransactionFactSide::After => view.before.as_ref(), - }) - .and_then(|facts| { - facts - .row_of(node) - .map(|row| facts.carries_dispatch_key(row, key, is_root)) - }) + .and_then(|view| view.row_of(TransactionFactSide::Before, resident_facts, node)) + .map(|(facts, row)| facts.carries_dispatch_key(row, key, is_root)) == Some(true) { return true; @@ -1803,54 +1796,6 @@ impl StyleEngine { true } - /// Pack the old fact side for the whole transaction subtree, once per transaction. - /// - /// The activation filter enumerates posting candidates across the document, so it reads - /// arbitrary old-side rows; the new side is always the primary document arrangement. - pub(super) fn materialize_transaction_fact_view_fully(&mut self) { - let Some(transition) = self.transaction_fact_view.as_mut() else { - return; - }; - if transition.opposite_fully_materialized { - return; - } - transition.opposite_fully_materialized = true; - let opposite = match transition.resident_side { - TransactionFactSide::Before => &mut transition.after, - TransactionFactSide::After => &mut transition.before, - }; - let Some(opposite) = opposite.as_mut() else { - return; - }; - let bytes_before = opposite.capacity_bytes(); - self.facts.materialize_missing( - self.tree.preorder(transition.root).chain(self.departed.iter().copied()), - opposite, - ); - let bytes_after = opposite.capacity_bytes(); - self.memory - .reserve_required(MemoryCategory::BatchScratch, bytes_after - bytes_before); - } - - /// Grow the transaction's shared old-side batch to cover `covered`. - pub(super) fn ensure_transaction_fact_rows(&mut self, covered: &[StyleNodeID]) { - let Some(transition) = self.transaction_fact_view.as_mut() else { - return; - }; - let opposite = match transition.resident_side { - TransactionFactSide::Before => &mut transition.after, - TransactionFactSide::After => &mut transition.before, - }; - let Some(opposite) = opposite.as_mut() else { - return; - }; - let bytes_before = opposite.capacity_bytes(); - self.facts.materialize_missing(covered.iter().copied(), opposite); - let bytes_after = opposite.capacity_bytes(); - self.memory - .reserve_required(MemoryCategory::BatchScratch, bytes_after - bytes_before); - } - /// Whether an entry is monotonic under a positive tree arrival. /// /// An arriving element is routed from the facts it publishes, so the new side alone decides @@ -1907,9 +1852,6 @@ impl StyleEngine { .and_then(|_| self.retained_entry_matches(node, program, entry)), }; let change = loop { - if old_matches.is_none() { - self.ensure_transaction_fact_rows(&covered); - } let result = { let compiled = self.programs.get(program); let exact_entry = &compiled.entries()[entry as usize]; @@ -1993,76 +1935,27 @@ impl StyleEngine { .as_ref() .filter(|view| view.retained_truth_available) .and_then(|_| self.retained_entry_matches(node, program, entry)); - let resident_facts = if self - .transaction_fact_view - .as_ref() - .is_some_and(|view| view.resident_side == TransactionFactSide::Before) - { - self.facts.committed() - } else { - self.facts.primary() - }; - if retained_old_matches.is_none() - && self - .transaction_fact_view - .as_ref() - .and_then(|view| view.facts(TransactionFactSide::Before, resident_facts)) - .is_none() - { - return Lookup::Missing(ExactEntryGap); - } - let mut covered = std::mem::take(&mut self.exact_covered_scratch); covered.clear(); covered.push(node); let mut sibling_window = INITIAL_SIBLING_FACT_WINDOW; let changed = loop { - if retained_old_matches.is_none() - || self - .transaction_fact_view - .as_ref() - .is_some_and(|view| view.resident_side == TransactionFactSide::Before) - { - self.ensure_transaction_fact_rows(&covered); - } let result = { - let resident_facts = if self - .transaction_fact_view - .as_ref() - .is_some_and(|view| view.resident_side == TransactionFactSide::Before) - { - self.facts.committed() - } else { - self.facts.primary() - }; + let resident_facts = self.facts.primary(); let view = self .transaction_fact_view .as_ref() .expect("exact evaluation has a transaction fact view"); - let old_facts = view - .facts(TransactionFactSide::Before, resident_facts) - .expect("old facts were checked before exact evaluation"); - let new_facts = view - .facts(TransactionFactSide::After, resident_facts) - .expect("exact evaluation has after facts"); let compiled = self.programs.get(program); let exact_entry = &compiled.entries()[entry as usize]; - let new_matches = MatchEvaluator::new(&self.tree, new_facts).matches_entry_for_program( - program, - compiled, - exact_entry, - node, - &mut self.counters, - ); + let new_matches = MatchEvaluator::new(&self.tree, resident_facts) + .with_transaction_fact_view(view, TransactionFactSide::After) + .matches_entry_for_program(program, compiled, exact_entry, node, &mut self.counters); let old_matches = match retained_old_matches { Some(old_matches) => Ok(old_matches), - None => MatchEvaluator::new(&self.tree, old_facts).matches_entry_for_program( - program, - compiled, - exact_entry, - node, - &mut self.counters, - ), + None => MatchEvaluator::new(&self.tree, resident_facts) + .with_transaction_fact_view(view, TransactionFactSide::Before) + .matches_entry_for_program(program, compiled, exact_entry, node, &mut self.counters), }; (old_matches, new_matches) }; @@ -2853,32 +2746,11 @@ impl StyleEngine { // warm across departure-bearing flushes. let can_reuse = retained.lookup_mut(scope_program).sparse().is_ok(); if can_reuse { - if self - .transaction_fact_view - .as_ref() - .is_some_and(|view| view.resident_side == TransactionFactSide::Before) - { - self.materialize_transaction_fact_view_fully(); - } - let resident_facts = if self - .transaction_fact_view - .as_ref() - .is_some_and(|view| view.resident_side == TransactionFactSide::Before) - { - self.facts.committed() - } else { - self.facts.primary() - }; - let new_facts = self - .transaction_fact_view - .as_ref() - .and_then(|view| view.facts(TransactionFactSide::After, resident_facts)) - .expect("prefix planning has after facts"); - let old_facts = self + let view = self .transaction_fact_view .as_ref() - .and_then(|view| view.facts(TransactionFactSide::Before, resident_facts)) - .expect("prefix planning has before facts"); + .expect("prefix planning has a transaction fact view"); + let resident_facts = self.facts.primary(); self.counters.bump(Counter::PrefixTransitionCacheHits); let nodes_in_preorder = regions.sort_nodes_for_top_down_walk(&mut pending_nodes, &self.tree); if automaton_has_sibling_steps && !nodes_in_preorder { @@ -2889,21 +2761,23 @@ impl StyleEngine { let mut visited = Vec::new(); let mut changed_nodes = Vec::new(); let mut prefix_delta_arena = PrefixDeltaArena::default(); - let old_evaluator = MatchEvaluator::new(&self.tree, old_facts); + let old_evaluator = MatchEvaluator::new(&self.tree, resident_facts) + .with_transaction_fact_view(view, TransactionFactSide::Before); let old_evaluation = PrefixEvaluation::new( dispatch.prefixes(), &self.tree, - old_facts, + resident_facts, &self.programs, &old_evaluator, None, None, ); - let new_evaluator = MatchEvaluator::new(&self.tree, new_facts); + let new_evaluator = MatchEvaluator::new(&self.tree, resident_facts) + .with_transaction_fact_view(view, TransactionFactSide::After); let new_evaluation = PrefixEvaluation::new( dispatch.prefixes(), &self.tree, - new_facts, + resident_facts, &self.programs, &new_evaluator, None, @@ -4143,33 +4017,11 @@ impl StyleEngine { first_program_rule = end_program_rule; continue; } - if can_filter_exactly { - self.materialize_transaction_fact_view_fully(); - } - let activation_fact_sides = can_filter_exactly - .then(|| { - let view = self - .transaction_fact_view - .as_ref() - .expect("planning has a transaction fact view"); - match (input.old, input.new) { - (InputValue::Flag(false), InputValue::Flag(true)) => { - Some((None, view.facts(TransactionFactSide::After, self.facts.primary()))) - } - (InputValue::Flag(true), InputValue::Flag(false)) => view - .facts(TransactionFactSide::Before, self.facts.primary()) - .map(|before| (Some(before), None)), - _ => view - .facts(TransactionFactSide::Before, self.facts.primary()) - .map(|before| { - ( - Some(before), - view.facts(TransactionFactSide::After, self.facts.primary()), - ) - }), - } - }) - .flatten(); + let activation_fact_sides = can_filter_exactly.then_some(match (input.old, input.new) { + (InputValue::Flag(false), InputValue::Flag(true)) => (None, Some(TransactionFactSide::After)), + (InputValue::Flag(true), InputValue::Flag(false)) => (Some(TransactionFactSide::Before), None), + _ => (Some(TransactionFactSide::Before), Some(TransactionFactSide::After)), + }); let compiled = self.programs.get(selector_program); for (entry_index, entry) in compiled.entries().iter().enumerate() { if scopes.as_slice() == [TreeScopeID::DOCUMENT] @@ -4254,15 +4106,17 @@ impl StyleEngine { if !self.node_is_within_subject_position(node, position) { continue; } - if let Some((old_facts, new_facts)) = activation_fact_sides { + if let Some((old_side, new_side)) = activation_fact_sides { let mut may_match = false; - for facts in [old_facts, new_facts].into_iter().flatten() { - match MatchEvaluator::new(&self.tree, facts).matches_entry( - compiled, - entry, - node, - &mut self.counters, - ) { + for side in [old_side, new_side].into_iter().flatten() { + let view = self + .transaction_fact_view + .as_ref() + .expect("exact activation filtering has a transaction fact view"); + match MatchEvaluator::new(&self.tree, self.facts.primary()) + .with_transaction_fact_view(view, side) + .matches_entry(compiled, entry, node, &mut self.counters) + { Ok(false) => {} Ok(true) | Err(_) => { may_match = true; diff --git a/Libraries/LibWeb/Rust/src/css/style/selector.rs b/Libraries/LibWeb/Rust/src/css/style/selector.rs index 8465c2ed3e67..de20ecd61781 100644 --- a/Libraries/LibWeb/Rust/src/css/style/selector.rs +++ b/Libraries/LibWeb/Rust/src/css/style/selector.rs @@ -3517,6 +3517,12 @@ impl Iterator for SiblingChildren<'_> { } /// Evaluates match programs against the live tree and a batch of local facts. +#[derive(Clone, Copy)] +pub(super) struct MatchFactRow<'a> { + pub(super) facts: &'a StyleNodeFacts, + pub(super) row: u32, +} + pub struct MatchEvaluator<'a> { tree: &'a StyleNodeTree, facts: &'a StyleNodeFacts, @@ -4519,9 +4525,9 @@ impl<'a> MatchEvaluator<'a> { fn node_carries_dispatch_key(&self, key: DispatchKey, node: StyleNodeID) -> Result { let row = self.row_of(node)?; - Ok(self + Ok(row .facts - .carries_dispatch_key(row, key, self.tree.parent(node).is_none())) + .carries_dispatch_key(row.row, key, self.tree.parent(node).is_none())) } #[inline] @@ -4760,7 +4766,7 @@ impl<'a> MatchEvaluator<'a> { let reaches = |name: StyleAtomID| match pairs.is_empty() { // With no pairing recorded the element is addressable only under the names it carries, // and all of them reach the host of the tree it stands in. - true => self.facts.parts_of(row).contains(&name), + true => row.facts.parts_of(row.row).contains(&name), false => pairs .iter() .any(|&(exposed, exposed_to)| exposed == name && exposed_to == host), @@ -4841,7 +4847,7 @@ impl<'a> MatchEvaluator<'a> { SelectorOp::Language { first, count } => { counters.bump(Counter::StateTests); let row = self.row_of(node)?; - let tag = self.facts.language_tag_of(row); + let tag = row.facts.language_tag_of(row.row); // An element with no resolved language matches no range at all, not even `*`. Ok(!tag.is_empty() && program @@ -4851,7 +4857,7 @@ impl<'a> MatchEvaluator<'a> { SelectorOp::State(fact) => { counters.bump(Counter::StateTests); let row = self.row_of(node)?; - Ok(self.facts.states_of(row).contains(fact)) + Ok(row.facts.states_of(row.row).contains(fact)) } SelectorOp::And { first, count } => self.matches_compound(program, first, count, node, counters), SelectorOp::Or { first, count } => { @@ -4983,7 +4989,7 @@ impl<'a> MatchEvaluator<'a> { } SelectorOp::Part(part) => { let row = self.row_of(node)?; - Ok(self.facts.parts_of(row).contains(&part)) + Ok(row.facts.parts_of(row.row).contains(&part)) } // The host the part is exposed to, which is what the rule's outer compound describes. // @@ -5108,7 +5114,7 @@ impl<'a> MatchEvaluator<'a> { // Element children are style nodes and the tree answers for them. A text or comment // child is not, so the element publishes whether it holds one. let row = self.row_of(node)?; - Ok(self.tree.first_element_child(node).is_none() && !self.facts.has_text_content_of(row)) + Ok(self.tree.first_element_child(node).is_none() && !row.facts.has_text_content_of(row.row)) } SelectorOp::IsNode(named) => { counters.bump(Counter::StructuralTests); @@ -5131,14 +5137,14 @@ impl<'a> MatchEvaluator<'a> { counters.bump(Counter::StateTests); let row = self.row_of(node)?; Ok(match kind { - ValueStateTestKind::Directionality => self.facts.directionality_of(row) == value, - ValueStateTestKind::CustomState => self.facts.custom_states_of(row).contains(&value), + ValueStateTestKind::Directionality => row.facts.directionality_of(row.row) == value, + ValueStateTestKind::CustomState => row.facts.custom_states_of(row.row).contains(&value), }) } SelectorOp::Heading(levels) => { counters.bump(Counter::StructuralTests); let row = self.row_of(node)?; - let level = self.facts.heading_level_of(row); + let level = row.facts.heading_level_of(row.row); Ok((1..=9).contains(&level) && levels & (1 << (level - 1)) != 0) } // The existential answer: does any candidate on the query's axis satisfy its compound. @@ -5233,21 +5239,21 @@ impl<'a> MatchEvaluator<'a> { let row = self.row_of(node)?; Ok(match test { FeatureTest::AnyElement => true, - FeatureTest::Namespace(NamespaceTest::None) => self.facts.namespace_of(row) == StyleAtomID::NONE, - FeatureTest::Namespace(NamespaceTest::Named(namespace)) => self.facts.namespace_of(row) == namespace, - FeatureTest::TagName(tag) => tag.matches(self.facts.tag_of(row), self.facts.namespace_of(row)), - FeatureTest::Id(id) => self.facts.id_of(row) == id, - FeatureTest::Class(class) => self.facts.classes_of(row).contains(&class), + FeatureTest::Namespace(NamespaceTest::None) => row.facts.namespace_of(row.row) == StyleAtomID::NONE, + FeatureTest::Namespace(NamespaceTest::Named(namespace)) => row.facts.namespace_of(row.row) == namespace, + FeatureTest::TagName(tag) => tag.matches(row.facts.tag_of(row.row), row.facts.namespace_of(row.row)), + FeatureTest::Id(id) => row.facts.id_of(row.row) == id, + FeatureTest::Class(class) => row.facts.classes_of(row.row).contains(&class), FeatureTest::Attribute(test) => { let insensitive = match test.case { AttributeCase::Sensitive => false, AttributeCase::Insensitive => true, - AttributeCase::InsensitiveForNamespace(namespace) => self.facts.namespace_of(row) == namespace, + AttributeCase::InsensitiveForNamespace(namespace) => row.facts.namespace_of(row.row) == namespace, }; // `[*|x]` names one attribute per namespace the element carries `x` in, and the // test holds when any of them satisfies it. self.attributes_named_by(row, test) - .any(|attribute| self.matches_attribute_value(program, test, attribute, insensitive)) + .any(|attribute| self.matches_attribute_value(program, test, row.facts, attribute, insensitive)) } }) } @@ -5267,23 +5273,32 @@ impl<'a> MatchEvaluator<'a> { /// /// There can be more than one: `[*|x]` names the attribute called `x` in each namespace the /// element carries it in, and they publish the same any-namespace atom. - fn attributes_named_by(&self, row: u32, test: AttributeTest) -> impl Iterator { + fn attributes_named_by( + &self, + row: MatchFactRow<'a>, + test: AttributeTest, + ) -> impl Iterator { // Whether this subject folds attribute names at all, which is one namespace comparison for // the whole test rather than one per attribute. - let folds = !test.fold_in_namespace.is_none() && self.facts.namespace_of(row) == test.fold_in_namespace; - self.facts.attributes_of(row).iter().copied().filter(move |attribute| { - let (written, folded) = match test.any_namespace { - true => (attribute.local, attribute.folded_local), - false => (attribute.name, attribute.folded_name), - }; - written == test.name || (folds && folded == test.folded) - }) + let folds = !test.fold_in_namespace.is_none() && row.facts.namespace_of(row.row) == test.fold_in_namespace; + row.facts + .attributes_of(row.row) + .iter() + .copied() + .filter(move |attribute| { + let (written, folded) = match test.any_namespace { + true => (attribute.local, attribute.folded_local), + false => (attribute.name, attribute.folded_name), + }; + written == test.name || (folds && folded == test.folded) + }) } fn matches_attribute_value( &self, program: &SelectorProgram, test: AttributeTest, + facts: &StyleNodeFacts, attribute: super::index::AttributeFact, insensitive: bool, ) -> bool { @@ -5301,7 +5316,7 @@ impl<'a> MatchEvaluator<'a> { } let literal = program.literal(test.value_offset, test.value_length); - let Some(value) = self.facts.text_of(attribute) else { + let Some(value) = facts.text_of(attribute) else { return false; }; match test.operator { @@ -5357,7 +5372,7 @@ impl<'a> MatchEvaluator<'a> { let subject_type = match position.of_type { true => { let row = self.row_of(node)?; - Some((self.facts.tag_of(row), self.facts.namespace_of(row))) + Some((row.facts.tag_of(row.row), row.facts.namespace_of(row.row))) } false => None, }; @@ -5485,7 +5500,7 @@ impl<'a> MatchEvaluator<'a> { } Err(incomplete) => return Err(incomplete), }; - let sibling_type = (self.facts.tag_of(row), self.facts.namespace_of(row)); + let sibling_type = (row.facts.tag_of(row.row), row.facts.namespace_of(row.row)); let next_type_id = u32::try_from(totals.len()).expect("sibling type space exhausted"); let type_id = *type_ids.entry(sibling_type).or_insert_with(|| { totals.push(0); @@ -5531,15 +5546,29 @@ impl<'a> MatchEvaluator<'a> { match (subject_type, position.of_selector) { (Some((tag, namespace)), _) => { let row = self.row_of(sibling)?; - Ok(self.facts.tag_of(row) == tag && self.facts.namespace_of(row) == namespace) + Ok(row.facts.tag_of(row.row) == tag && row.facts.namespace_of(row.row) == namespace) } (None, Some(selector)) => self.matches_node(program, selector, sibling, counters), (None, None) => Ok(true), } } - fn row_of(&self, node: StyleNodeID) -> Result { - self.facts.row_of(node).ok_or(Incomplete::MissingFacts(node)) + pub(super) fn row_of(&self, node: StyleNodeID) -> Result, Incomplete> { + let row = match self.transaction_fact_view { + Some((view, side)) => view.row_of(side, self.facts, node), + None => self.facts.row_of(node).map(|row| (self.facts, row)), + }; + row.map(|(facts, row)| MatchFactRow { facts, row }) + .ok_or(Incomplete::MissingFacts(node)) + } + + /// Whether every row this evaluator serves comes from the resident arrangement. Only a + /// before-side view with retained before rows can serve a row from somewhere else. + pub(super) fn serves_only_resident_rows(&self) -> bool { + match self.transaction_fact_view { + Some((view, TransactionFactSide::Before)) => view.before.is_none(), + _ => true, + } } } @@ -6042,11 +6071,7 @@ mod tests { before_sibling_relations_available: false, prefix: None, retained_truth_available: false, - resident_side: TransactionFactSide::After, - local_facts_are_shared: false, before: None, - after: None, - opposite_fully_materialized: false, }; view.insert_before_sibling_sequence( fixture.nodes[0], diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index bdbc5c80c80b..ca32fb901aa7 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -2503,7 +2503,7 @@ fn an_exact_unchanged_custom_state_cascade_stops_before_style_recomputation() { engine.set_rule_declared_properties_with_values(second_rule, &[(1, false, value)], true); discard_transaction(&mut engine); engine.facts.set_custom_states(nodes[1], &[]); - engine.facts.commit_pending(&mut engine.memory); + engine.facts.apply_staged(&mut engine.memory); let old_answer = engine.match_element_for_cascade(nodes[1]).unwrap(); assert_eq!(old_answer.len(), 1); @@ -2676,7 +2676,7 @@ fn recycled_selector_entries_keep_delta_answers_canonical() { _ => panic!("initial cascade input must be retained"), }; add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(second)); - engine.facts.commit_pending(&mut engine.memory); + engine.facts.apply_staged(&mut engine.memory); let retained = prepare_retained_match_answer(retained.into_iter()); let mut patch = engine.prepare_retained_answer_patch(RetainedAnswerPatchSelection { @@ -2756,7 +2756,7 @@ fn retained_answer_patching_matches_only_unresolved_rules_after_signed_deltas() engine.remember_cascade_input(nodes[1], &compact_answer); add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(delta_target)); add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(second_delta_target)); - engine.facts.commit_pending(&mut engine.memory); + engine.facts.apply_staged(&mut engine.memory); let mut patch = engine.prepare_retained_answer_patch(RetainedAnswerPatchSelection { affected: vec![ RetainedAnswerPatchSelectionRule { @@ -4057,11 +4057,7 @@ fn an_attribute_in_flux_includes_every_name_form() { before_sibling_relations_available: false, prefix: None, retained_truth_available: false, - resident_side: TransactionFactSide::After, - local_facts_are_shared: false, before: None, - after: None, - opposite_fully_materialized: false, }); assert!(!engine.moved_features_of(nodes[1]).is_empty()); assert!(engine.moved_features_of(nodes[2]).is_empty()); @@ -4865,7 +4861,7 @@ fn state_input_commits_after_its_old_row_is_snapshotted() { InputValue::State(true), ); assert!(engine.facts.states_of_node(nodes[1]).contains(StateFact::Hover)); - let committed = engine.facts.before_pending_facts(); + let committed = engine.facts.staged_before_facts(); let committed_row = committed.row_of(nodes[1]).unwrap(); assert!(!committed.states_of(committed_row).contains(StateFact::Hover)); assert!(!committed.carries_dispatch_key(committed_row, DispatchKey::Class(class), false)); @@ -4874,7 +4870,6 @@ fn state_input_commits_after_its_old_row_is_snapshotted() { assert!(engine.facts.states_of_node(nodes[1]).contains(StateFact::Hover)); let view = engine.transaction_fact_view_for(&mut transaction, nodes[0], &ImpactRegions::new()); engine.transaction_fact_view = Some(view); - engine.ensure_transaction_fact_rows(&[nodes[1]]); let before = engine.transaction_fact_view.as_ref().unwrap().before.as_ref().unwrap(); let row = before.row_of(nodes[1]).unwrap(); assert!(!before.states_of(row).contains(StateFact::Hover)); diff --git a/Libraries/LibWeb/Rust/src/css/style/transaction_view.rs b/Libraries/LibWeb/Rust/src/css/style/transaction_view.rs index ce25b30625c7..51a16307ff95 100644 --- a/Libraries/LibWeb/Rust/src/css/style/transaction_view.rs +++ b/Libraries/LibWeb/Rust/src/css/style/transaction_view.rs @@ -158,10 +158,8 @@ pub(super) enum TransactionFactSide { /// The local facts and tree relations available while planning one transaction. /// -/// One semantic side remains in the resident arrangement. The other is a sparse batch grown to -/// the rows evaluation touches. Tree and program transactions currently plan after commit and -/// reconstruct their before side; eligible local facts plan before commit and materialize their -/// staged after side instead. +/// The after side lives in the resident arrangement. Changed local facts retain sparse before rows; +/// unchanged nodes fall back to the resident arrangement on both sides. pub(super) struct TransactionFactView { pub(super) root: StyleNodeID, /// Every (node, key) local feature moved by this transaction, including all attribute name @@ -175,15 +173,8 @@ pub(super) struct TransactionFactView { pub(super) before_sibling_relations_available: bool, pub(super) prefix: Option, pub(super) retained_truth_available: bool, - /// Which semantic side is still resident in the engine's primary fact arrangement. - pub(super) resident_side: TransactionFactSide, - /// Whether both semantic sides share the resident local-fact arrangement. - pub(super) local_facts_are_shared: bool, - /// Shared before-side exact-evaluation batch when the after side is resident. + /// Sparse before-side rows for the local facts changed by this transaction. pub(super) before: Option, - /// Shared after-side exact-evaluation batch when the before side is resident. - pub(super) after: Option, - pub(super) opposite_fully_materialized: bool, } impl TransactionFactView { @@ -201,32 +192,29 @@ impl TransactionFactView { self.before_sibling_geometry.capacity_bytes(), self.prefix.as_ref().map_or(0, PrefixFactTransition::capacity_bytes), self.before.as_ref().map_or(0, StyleNodeFacts::capacity_bytes), - self.after.as_ref().map_or(0, StyleNodeFacts::capacity_bytes), ]; skip [ self.root, self.before_sibling_relations_available, self.retained_truth_available, - self.resident_side, - self.local_facts_are_shared, - self.opposite_fully_materialized, ]; } } #[must_use] - pub(super) fn facts<'a>( + pub(super) fn row_of<'a>( &'a self, side: TransactionFactSide, resident: &'a StyleNodeFacts, - ) -> Option<&'a StyleNodeFacts> { - if side == self.resident_side || self.local_facts_are_shared { - return Some(resident); - } - match side { - TransactionFactSide::Before => self.before.as_ref(), - TransactionFactSide::After => self.after.as_ref(), + node: StyleNodeID, + ) -> Option<(&'a StyleNodeFacts, u32)> { + if side == TransactionFactSide::Before + && let Some(facts) = self.before.as_ref() + && let Some(row) = facts.row_of(node) + { + return Some((facts, row)); } + resident.row_of(node).map(|row| (resident, row)) } } @@ -253,6 +241,7 @@ impl PrefixFactTransition { #[cfg(test)] mod tests { + use super::super::index::StateSet; use super::super::index::StyleAtomID; use super::*; @@ -298,9 +287,11 @@ mod tests { #[test] fn an_unchanged_local_fact_arrangement_serves_both_semantic_sides() { - let resident = StyleNodeFacts::new(); + let node = StyleNodeID::element(1); + let mut resident = StyleNodeFacts::new(); + resident.push_row(node, StyleAtomID(100), StyleAtomID::NONE, StateSet::default(), &[], &[]); let view = TransactionFactView { - root: StyleNodeID::element(1), + root: node, moved_features: FeatureFluxColumn::default(), before_sibling_geometry: SiblingSequenceGeometry::default(), before_sibling_sequence_by_parent: Vec::new(), @@ -309,20 +300,13 @@ mod tests { before_sibling_relations_available: false, prefix: None, retained_truth_available: false, - resident_side: TransactionFactSide::After, - local_facts_are_shared: true, before: None, - after: None, - opposite_fully_materialized: false, }; - assert!(std::ptr::eq( - view.facts(TransactionFactSide::Before, &resident).unwrap(), - &resident - )); - assert!(std::ptr::eq( - view.facts(TransactionFactSide::After, &resident).unwrap(), - &resident - )); + for side in [TransactionFactSide::Before, TransactionFactSide::After] { + let (facts, row) = view.row_of(side, &resident, node).unwrap(); + assert!(std::ptr::eq(facts, &resident)); + assert_eq!(facts.tag_of(row), StyleAtomID(100)); + } } } From 24fc0b49d5eeab58c7adfef52c10a73b52adde83 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sun, 16 Aug 2026 15:46:43 +0200 Subject: [PATCH 08/39] LibWeb: Stage program fields densely with one commit point Rule versions, sheet membership, layer ranks and activation flags were staged in fifteen pending maps, applied by seven passes at commit, with a fast path for rules created since the last barrier that bypassed all of them. Routing then re-derived which sheets, programs and rules were in flux from the journal. Stage program fields as dense before/after rows owned by one staging object, commit them together, and derive the program routing deltas and departures from the pairs. The pending maps, the committed-rule fast path and the journal scans go away. Preserve the any-input predicate as the unified staging object absorbs pending element rows. --- .../LibWeb/Rust/src/css/style/catalog.rs | 1 + Libraries/LibWeb/Rust/src/css/style/flush.rs | 96 +----- Libraries/LibWeb/Rust/src/css/style/index.rs | 122 ++++--- Libraries/LibWeb/Rust/src/css/style/inputs.rs | 67 ++-- Libraries/LibWeb/Rust/src/css/style/mod.rs | 271 ++++++++++++--- .../LibWeb/Rust/src/css/style/ordering.rs | 29 +- .../LibWeb/Rust/src/css/style/program.rs | 14 + .../Rust/src/css/style/program_updates.rs | 315 +++++++++--------- .../LibWeb/Rust/src/css/style/routing.rs | 27 +- Libraries/LibWeb/Rust/src/css/style/tests.rs | 49 ++- 10 files changed, 558 insertions(+), 433 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/catalog.rs b/Libraries/LibWeb/Rust/src/css/style/catalog.rs index ca9107d85db7..46d92454ec2d 100644 --- a/Libraries/LibWeb/Rust/src/css/style/catalog.rs +++ b/Libraries/LibWeb/Rust/src/css/style/catalog.rs @@ -1517,6 +1517,7 @@ pub(super) struct PendingRuleDeclarationChange { pub(super) new_properties: Vec, } +#[derive(Clone)] pub(super) struct PendingRuleDeclarations { pub(super) declared: Vec, pub(super) complete: bool, diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index 5af491b7f77c..08632448cae5 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -28,7 +28,7 @@ impl StyleEngine { let publish_document_root_arrival = document_root_arrival_is_pending; let mut transaction = self.drain_transaction(); - self.apply_staged(&mut transaction); + self.apply_staged_transaction(&mut transaction); if transaction.is_empty() { self.release_transaction(transaction); return true; @@ -317,14 +317,11 @@ impl StyleEngine { // Routing reads the program as it stands now, but the inputs happened before it did. // A sheet that went away in this same transaction was still deciding when the mutations // ahead of it in the journal were recorded, so its rules cannot be skipped as inactive. - let sheets_in_flux = Self::sheets_in_flux(&transaction); - let programs_in_flux = Self::programs_in_flux(&transaction); + let program_delta = self.program_staging.delta(); let retained_winners_are_current = transaction .inputs .iter() .all(|input| matches!(input.key, InputKey::LocalFeature(..) | InputKey::State(..))); - let rules_arriving = Self::rules_arriving(&transaction); - let scopes_departed = Self::scopes_departed(&transaction); if self.selector_incidence_is_current { let programs: Vec<_> = transaction .inputs @@ -417,8 +414,8 @@ impl StyleEngine { self.route_program_input( input, transaction.program_joins_for(input.key), - &rules_arriving, - &scopes_departed, + &program_delta.arriving_rules, + &program_delta.departed_scopes, ProgramRoutingContext { resident_nodes: (!outer_arrivals.is_empty()).then_some(resident_nodes.as_slice()), winner_program_version: transaction.program_base_version, @@ -437,8 +434,8 @@ impl StyleEngine { self.route_program_input( input, transaction.program_joins_for(input.key), - &rules_arriving, - &scopes_departed, + &program_delta.arriving_rules, + &program_delta.departed_scopes, ProgramRoutingContext { resident_nodes: (!outer_arrivals.is_empty()).then_some(resident_nodes.as_slice()), winner_program_version: transaction.program_base_version, @@ -485,8 +482,8 @@ impl StyleEngine { } self.route_input( input, - &sheets_in_flux, - &programs_in_flux, + &program_delta.sheets, + &program_delta.selector_programs, tree_routing, &sibling_entries, &mut sibling_candidates, @@ -523,7 +520,7 @@ impl StyleEngine { if !regions.covers_document() { deferred_sequence_routes = self.route_sequence_changes( &sequences, - &sheets_in_flux, + &program_delta.sheets, tree_routing, &mut regions, &mut planning_workspace, @@ -1416,66 +1413,6 @@ impl StyleEngine { Some((node, key)) } - /// The selector programs a rule had earlier in this transaction and no longer has. - /// - /// A route is registered under the program that compiled it, and one belonging to a - /// program the rule has given up describes a selector nobody wrote - except while the change that - /// replaced it is still in the transaction, because the inputs recorded ahead of it happened while - /// that selector was the one in effect. A class removed in the same transaction as a - /// `selectorText` edit has to route through the selector it was removed from. - pub(super) fn programs_in_flux(transaction: &StyleTransaction) -> Vec { - let mut programs: Vec = transaction - .inputs - .iter() - .filter_map(|input| match (input.key, input.old) { - (InputKey::RuleField(_, RuleField::Selector), InputValue::SelectorProgram(program)) => program, - _ => None, - }) - .collect(); - programs.sort_unstable_by_key(|program| program.0); - programs.dedup(); - programs - } - - /// The rules that came into existence in this transaction. - /// - /// A rule that arrived and does not decide now decided nothing at all: both its existence and - /// its activation describe something that was never in the cascade. Knowing that needs the - /// transaction rather than the program, because the program only says where things stand. - pub(super) fn rules_arriving(transaction: &StyleTransaction) -> Vec { - let mut rules: Vec = transaction - .inputs - .iter() - .filter_map(|input| match (input.key, input.new) { - (InputKey::RuleField(rule, RuleField::Existence), InputValue::Flag(true)) => Some(rule), - _ => None, - }) - .collect(); - rules.sort_unstable_by_key(|rule| rule.0); - rules.dedup(); - rules - } - - /// The scopes each sheet was attached to when this transaction began. - /// - /// A sheet's rules reach the elements its scopes hold, and that has to be the scopes as of the - /// input rather than as of routing: re-inserting a ` + + diff --git a/Tests/LibWeb/Text/input/css/style-engine/retained-winner-priority-pseudo-target.html b/Tests/LibWeb/Text/input/css/style-engine/retained-winner-priority-pseudo-target.html new file mode 100644 index 000000000000..899e68a12112 --- /dev/null +++ b/Tests/LibWeb/Text/input/css/style-engine/retained-winner-priority-pseudo-target.html @@ -0,0 +1,20 @@ + + + +
+ From f45e717e031be31805b92bafee919bab260c0e39 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 04:31:32 +0200 Subject: [PATCH 23/39] LibWeb: Index nodes by winning rule Deleting a winning rule is the non-invertible cascade case, and the engine only knew whether a rule won somewhere, so a removal routed through old-selector evaluation to find its nodes. Keep a bounded posting of winning nodes per rule beside the reference count and route narrow removals through it. Matches that lost to the rule are refreshed in one pass per transaction, so no retained answer keeps a deactivated rule row. State why the retained row count proves coverage after arrivals and departures cross the commit barrier. --- .../LibWeb/Rust/src/css/style/cascade.rs | 150 +++++++++++++++++- .../LibWeb/Rust/src/css/style/catalog.rs | 7 +- Libraries/LibWeb/Rust/src/css/style/flush.rs | 13 ++ .../LibWeb/Rust/src/css/style/planning.rs | 2 +- .../LibWeb/Rust/src/css/style/routing.rs | 45 ++++-- Libraries/LibWeb/Rust/src/css/style/tests.rs | 4 + ...-rule-removal-refreshes-losing-matches.txt | 6 + ...rule-removal-refreshes-losing-matches.html | 56 +++++++ 8 files changed, 261 insertions(+), 22 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/css/style-engine/narrow-rule-removal-refreshes-losing-matches.txt create mode 100644 Tests/LibWeb/Text/input/css/style-engine/narrow-rule-removal-refreshes-losing-matches.html diff --git a/Libraries/LibWeb/Rust/src/css/style/cascade.rs b/Libraries/LibWeb/Rust/src/css/style/cascade.rs index d7109a8a7093..fec84c0d2aab 100644 --- a/Libraries/LibWeb/Rust/src/css/style/cascade.rs +++ b/Libraries/LibWeb/Rust/src/css/style/cascade.rs @@ -604,6 +604,7 @@ pub(super) enum WinnerGroupTokenGap { const WINNER_RULE_PAGE_SHIFT: usize = 6; const WINNER_RULE_PAGE_SIZE: usize = 1 << WINNER_RULE_PAGE_SHIFT; +const WINNER_RULE_NODE_LIMIT: usize = 32; struct WinnerRuleIndexPage { entries: [u32; WINNER_RULE_PAGE_SIZE], @@ -640,9 +641,16 @@ impl RemovablePagedColumnPage for WinnerRuleIndexPage { } #[derive(Clone, Copy)] +struct WinnerRuleNodeReference { + node: StyleNodeID, + references: u32, +} + +#[derive(Clone)] struct WinnerRuleReferenceEntry { rule: RuleID, references: u64, + nodes: Option>, } /// A safe dense-and-sparse set of rules whose declarations currently win somewhere. @@ -655,6 +663,7 @@ struct WinnerRuleReferences { entries: Vec, accounted_dense_len: usize, accounted_dense_capacity: usize, + posting_bytes: u64, } impl Clone for WinnerRuleReferences { @@ -665,11 +674,17 @@ impl Clone for WinnerRuleReferences { ..Self::default() }; clone.entries.reserve(self.entries.len()); - for &entry in &self.entries { + for entry in &self.entries { let index = u32::try_from(clone.entries.len()).expect("winner rule inventory exhausted"); - clone.entries.push(entry); + clone.entries.push(entry.clone()); clone.indices.insert(entry.rule.0 as usize, index); } + clone.posting_bytes = clone + .entries + .iter() + .filter_map(|entry| entry.nodes.as_ref()) + .map(|nodes| (nodes.capacity() * size_of::()) as u64) + .sum(); clone } } @@ -682,7 +697,11 @@ impl WinnerRuleReferences { return; } let index = u32::try_from(self.entries.len()).expect("winner rule inventory exhausted"); - self.entries.push(WinnerRuleReferenceEntry { rule, references: 1 }); + self.entries.push(WinnerRuleReferenceEntry { + rule, + references: 1, + nodes: Some(Vec::new()), + }); let (previous, _) = self.indices.insert(rule.0 as usize, index); debug_assert!(previous.is_none()); } @@ -697,6 +716,13 @@ impl WinnerRuleReferences { if self.entries[index].references != 0 { return; } + let released_posting_bytes = self.entries[index].nodes.as_ref().map_or(0, |nodes| { + (nodes.capacity() * size_of::()) as u64 + }); + self.posting_bytes = self + .posting_bytes + .checked_sub(released_posting_bytes) + .expect("winner rule node posting byte count underflow"); self.indices.remove(rule.0 as usize); self.entries.swap_remove(index); if let Some(moved) = self.entries.get(index) { @@ -708,6 +734,56 @@ impl WinnerRuleReferences { self.indices.get(rule.0 as usize).is_some() } + fn retain_node(&mut self, rule: RuleID, node: StyleNodeID) { + let index = self + .indices + .get(rule.0 as usize) + .expect("a winning node must reference a retained winner rule") as usize; + let Some(nodes) = &mut self.entries[index].nodes else { + return; + }; + let capacity_before = nodes.capacity(); + match nodes.binary_search_by_key(&node, |reference| reference.node) { + Ok(index) => nodes[index].references += 1, + Err(_) if nodes.len() == WINNER_RULE_NODE_LIMIT => self.entries[index].nodes = None, + Err(index) => nodes.insert(index, WinnerRuleNodeReference { node, references: 1 }), + } + let capacity_after = self.entries[index].nodes.as_ref().map_or(0, Vec::capacity); + self.posting_bytes = self + .posting_bytes + .checked_sub((capacity_before * size_of::()) as u64) + .and_then(|bytes| bytes.checked_add((capacity_after * size_of::()) as u64)) + .expect("winner rule node posting byte count overflow"); + } + + fn release_node(&mut self, rule: RuleID, node: StyleNodeID) { + let index = self + .indices + .get(rule.0 as usize) + .expect("a released winning node must reference a retained winner rule") as usize; + let Some(nodes) = &mut self.entries[index].nodes else { + return; + }; + let node_index = nodes + .binary_search_by_key(&node, |reference| reference.node) + .expect("a released winning node must be retained"); + nodes[node_index].references -= 1; + if nodes[node_index].references == 0 { + nodes.remove(node_index); + } + } + + fn nodes(&self, rule: RuleID) -> Option + '_> { + let index = self.indices.get(rule.0 as usize)? as usize; + Some( + self.entries[index] + .nodes + .as_ref()? + .iter() + .map(|reference| reference.node), + ) + } + fn account_for_dense_rule(&mut self, rule: RuleID) { let required = (rule.0 as usize) .checked_add(1) @@ -720,6 +796,15 @@ impl WinnerRuleReferences { } self.accounted_dense_len = required; } + + #[cfg(test)] + fn measured_posting_bytes(&self) -> u64 { + self.entries + .iter() + .filter_map(|entry| entry.nodes.as_ref()) + .map(|nodes| (nodes.capacity() * size_of::()) as u64) + .sum() + } } impl ShallowCapacityBytes for WinnerRuleReferences { @@ -728,7 +813,7 @@ impl ShallowCapacityBytes for WinnerRuleReferences { // correctness-neutral caches stay warm, so making this inventory physically smaller must // not give an extreme page a larger effective cache budget. let dense_bytes = (self.accounted_dense_capacity * size_of::()) as u64; - dense_bytes.max(self.indices.capacity_bytes() + self.entries.shallow_capacity_bytes()) + dense_bytes.max(self.indices.capacity_bytes() + self.entries.shallow_capacity_bytes() + self.posting_bytes) } } @@ -1358,6 +1443,7 @@ impl WinnerGroups { return true; } if let Some((previous, previous_version)) = self.column[index] { + self.update_winner_rule_node_references(previous, node, false); self.release_state(previous); if previous_version == self.newest_program_version { self.newest_version_row_count -= 1; @@ -1371,6 +1457,7 @@ impl WinnerGroups { } self.column[index] = Some((state, program_version)); self.retain_state(state); + self.update_winner_rule_node_references(state, node, true); if program_version == self.newest_program_version { self.newest_version_row_count += 1; } @@ -1409,6 +1496,7 @@ impl WinnerGroups { state: (state, program_version), priority_current: true, }; + self.update_winner_rule_node_references(previous, node, false); self.release_state(previous); } else { let capacity_before = self.pseudo_rows_by_node[index].capacity(); @@ -1421,6 +1509,7 @@ impl WinnerGroups { ((self.pseudo_rows_by_node[index].capacity() - capacity_before) * size_of::()) as u64; } self.retain_state(state); + self.update_winner_rule_node_references(state, node, true); true } @@ -1429,6 +1518,7 @@ impl WinnerGroups { return; }; if let Some((previous, program_version)) = self.column.get_mut(index).and_then(Option::take) { + self.update_winner_rule_node_references(previous, node, false); self.release_state(previous); self.row_count -= 1; if program_version == self.newest_program_version { @@ -1440,6 +1530,7 @@ impl WinnerGroups { let rows = std::mem::take(rows); self.pseudo_row_capacity_bytes -= (rows.capacity() * size_of::()) as u64; for row in rows { + self.update_winner_rule_node_references(row.state.0, node, false); self.release_state(row.state.0); } } @@ -1499,11 +1590,26 @@ impl WinnerGroups { } } + fn update_winner_rule_node_references(&mut self, state: CascadeStateID, node: StyleNodeID, retain: bool) { + let winner_rule_references = &mut self.winner_rule_references; + for &rule in &self.state_winning_rules[state.0 as usize] { + if retain { + winner_rule_references.retain_node(rule, node); + } else { + winner_rule_references.release_node(rule, node); + } + } + } + #[must_use] pub fn rule_is_a_winner(&self, rule: RuleID) -> bool { self.winner_rule_references.contains(rule) } + pub fn winning_nodes(&self, rule: RuleID) -> Option + '_> { + self.winner_rule_references.nodes(rule) + } + fn priority_is_current(&self, index: usize) -> bool { self.priority_current.contains(index) } @@ -1570,6 +1676,9 @@ impl WinnerGroups { required: program_version, }); } + // Departed nodes lose their rows at the commit barrier and arriving nodes have no row yet. + // Therefore the resident row count names exactly the connected elements that must already + // have a winner row, and covering it proves there is no connected-element gap. if self.newest_version_row_count < row_count { return Lookup::Missing(WinnerGroupCoverageGap::InsufficientProgramRows { retained: self.newest_version_row_count, @@ -2363,6 +2472,16 @@ mod tests { assert!(groups.set(sparse_node, sparse, ProgramVersion(1))); assert!(groups.rule_is_a_winner(RuleID(100_000))); assert_eq!(groups.winner_rule_references.entries.len(), 3); + assert_ne!(groups.winner_rule_references.posting_bytes, 0); + assert_eq!( + groups.winner_rule_references.posting_bytes, + groups.winner_rule_references.measured_posting_bytes() + ); + let cloned_references = groups.winner_rule_references.clone(); + assert_eq!( + cloned_references.posting_bytes, + cloned_references.measured_posting_bytes() + ); assert!(groups.set(first_node, second, ProgramVersion(1))); groups.remove(second_node); @@ -2374,6 +2493,7 @@ mod tests { groups.remove(sparse_node); assert!(!groups.rule_is_a_winner(RuleID(100_000))); assert!(groups.winner_rule_references.entries.is_empty()); + assert_eq!(groups.winner_rule_references.posting_bytes, 0); } #[test] @@ -2400,6 +2520,28 @@ mod tests { )); } + #[test] + fn broad_winner_rule_node_postings_fall_back_to_the_rule_count() { + let mut groups = WinnerGroups::new(); + let state = groups.intern_sorted(&[winner(1, 1, 3)], None); + for index in 1..=WINNER_RULE_NODE_LIMIT + 1 { + assert!(groups.set( + StyleNodeID::element(u32::try_from(index).unwrap()), + state, + ProgramVersion(1), + )); + } + + assert!(groups.rule_is_a_winner(RuleID(3))); + assert!(groups.winning_nodes(RuleID(3)).is_none()); + assert_eq!(groups.winner_rule_references.posting_bytes, 0); + + for index in 1..=WINNER_RULE_NODE_LIMIT + 1 { + groups.remove(StyleNodeID::element(u32::try_from(index).unwrap())); + } + assert!(!groups.rule_is_a_winner(RuleID(3))); + } + #[test] fn pseudo_cascade_states_are_sparse_and_independent_from_the_element_column() { let mut groups = WinnerGroups::new(); diff --git a/Libraries/LibWeb/Rust/src/css/style/catalog.rs b/Libraries/LibWeb/Rust/src/css/style/catalog.rs index 5891194a6737..d14658fa29a8 100644 --- a/Libraries/LibWeb/Rust/src/css/style/catalog.rs +++ b/Libraries/LibWeb/Rust/src/css/style/catalog.rs @@ -1232,17 +1232,18 @@ impl RetainedMatchAnswers { } } - pub(super) fn for_each_answer_containing_rule( + pub(super) fn for_each_answer_containing_any_rule( &self, catalog: &MatchAnswerCatalog, - rule: RuleID, + rules: &[RuleID], mut visit: impl FnMut(StyleNodeID), ) { + debug_assert!(rules.is_sorted()); self.for_each_answer_node(|node| { let index = node.element_index().unwrap() as usize; if catalog .retained_answer(self.column[index]) - .is_some_and(|answer| answer.iter().any(|matched| matched.rule == rule)) + .is_some_and(|answer| answer.iter().any(|matched| rules.binary_search(&matched.rule).is_ok())) { visit(node); } diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index fa0301421353..a7fa22cc8155 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -413,6 +413,7 @@ impl StyleEngine { scopes.sort_unstable(); scopes.dedup(); } + let mut removed_rules_requiring_refresh = Vec::new(); for input in transaction .inputs .iter() @@ -428,6 +429,7 @@ impl StyleEngine { winner_program_version: transaction.program_base_version, document_root: root, attachment_scopes: None, + removed_rules_requiring_refresh: &mut removed_rules_requiring_refresh, }, &mut regions, ); @@ -448,6 +450,7 @@ impl StyleEngine { winner_program_version: transaction.program_base_version, document_root: root, attachment_scopes: Some(scopes), + removed_rules_requiring_refresh: &mut removed_rules_requiring_refresh, }, &mut regions, ); @@ -456,6 +459,16 @@ impl StyleEngine { } } } + if self.selector_truth_changes_active && !removed_rules_requiring_refresh.is_empty() { + removed_rules_requiring_refresh.sort_unstable(); + removed_rules_requiring_refresh.dedup(); + let refreshes = &mut self.selector_truth_changes.refreshes; + self.retained_match_answers.for_each_answer_containing_any_rule( + &self.match_answers, + &removed_rules_requiring_refresh, + |node| refreshes.push(SelectorTruthRefresh { node, rule: None }), + ); + } let prefix_producer_admission = if !regions.covers_document() && collect_pending_prefix_producers { Some(self.ranked_scope_program(TreeScopeID::DOCUMENT)) } else { diff --git a/Libraries/LibWeb/Rust/src/css/style/planning.rs b/Libraries/LibWeb/Rust/src/css/style/planning.rs index e4b447849233..9a8159008ae6 100644 --- a/Libraries/LibWeb/Rust/src/css/style/planning.rs +++ b/Libraries/LibWeb/Rust/src/css/style/planning.rs @@ -1450,12 +1450,12 @@ impl TreeRoutingMode<'_> { } } -#[derive(Clone, Copy)] pub(super) struct ProgramRoutingContext<'a> { pub(super) resident_nodes: Option<&'a [StyleNodeID]>, pub(super) winner_program_version: Option, pub(super) document_root: StyleNodeID, pub(super) attachment_scopes: Option<&'a [TreeScopeID]>, + pub(super) removed_rules_requiring_refresh: &'a mut Vec, } /// Which exact tree comparison a routed candidate can use. diff --git a/Libraries/LibWeb/Rust/src/css/style/routing.rs b/Libraries/LibWeb/Rust/src/css/style/routing.rs index 11451b78576f..88349e74248e 100644 --- a/Libraries/LibWeb/Rust/src/css/style/routing.rs +++ b/Libraries/LibWeb/Rust/src/css/style/routing.rs @@ -3674,6 +3674,7 @@ impl StyleEngine { winner_program_version, document_root, attachment_scopes, + removed_rules_requiring_refresh, } = context; let key = input.key; if program_joins.is_empty() { @@ -3867,7 +3868,7 @@ impl StyleEngine { InputValue::Flag(false) ) ); - if removes_contribution + let winner_inventory_is_complete = removes_contribution && self.program.declarations_are_complete_for(rule) && compiled.entries().iter().all(|entry| entry.pseudo_element.is_none()) && winner_program_version.is_some_and(|version| { @@ -3875,21 +3876,37 @@ impl StyleEngine { self.winner_groups.coverage_at_least(version, resident_count), Lookup::Known(()) ) + }); + if winner_inventory_is_complete { + if !self.winner_groups.rule_is_a_winner(rule) { + if self.selector_truth_changes_active { + removed_rules_requiring_refresh.push(rule); + } + self.counters.bump(Counter::ProgramCandidatesRejectedByCascade); + continue; + } + let winning_nodes = (!compiled.can_leave_its_scope() + && compiled + .entries() + .iter() + .all(|entry| compiled.dispatch_key(entry).has_selector_posting())) + .then(|| { + self.winner_groups.winning_nodes(rule).map(|nodes| { + nodes + .filter(|&node| scopes.binary_search(&self.tree.tree_scope(node)).is_ok()) + .collect::>() + }) }) - && !self.winner_groups.rule_is_a_winner(rule) - { - if self.selector_truth_changes_active { - let refreshes = &mut self.selector_truth_changes.refreshes; - self.retained_match_answers.for_each_answer_containing_rule( - &self.match_answers, - rule, - |node| { - refreshes.push(SelectorTruthRefresh { node, rule: None }); - }, - ); + .flatten(); + if let Some(nodes) = winning_nodes { + if self.selector_truth_changes_active { + removed_rules_requiring_refresh.push(rule); + } + for node in nodes { + regions.add_if_not_covered(ImpactRegion::Node(node), &self.tree, &mut self.counters); + } + continue; } - self.counters.bump(Counter::ProgramCandidatesRejectedByCascade); - continue; } programs.push((rule, selector_program)); } diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index d10080f36b7b..1f6c8901e3c3 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -7332,6 +7332,10 @@ fn rule_deactivation_reaches_only_nodes_where_the_rule_won() { engine.remember_cascade_input(node, &compact); publish_current_cascade_as_computed(&mut engine, node); } + assert_eq!( + engine.winner_groups.winning_nodes(toggled).unwrap().collect::>(), + vec![nodes[2]] + ); engine.set_rule_conditions_hold(toggled, false); let mut planned = Vec::new(); diff --git a/Tests/LibWeb/Text/expected/css/style-engine/narrow-rule-removal-refreshes-losing-matches.txt b/Tests/LibWeb/Text/expected/css/style-engine/narrow-rule-removal-refreshes-losing-matches.txt new file mode 100644 index 000000000000..848439647283 --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/style-engine/narrow-rule-removal-refreshes-losing-matches.txt @@ -0,0 +1,6 @@ +initial winner: rgb(255, 0, 0) +initial loser: rgb(0, 0, 255) +both matches refreshed: true +inactive winner: rgb(0, 0, 0) +inactive loser: rgb(0, 0, 255) +after losing declaration removal: rgb(0, 0, 0) diff --git a/Tests/LibWeb/Text/input/css/style-engine/narrow-rule-removal-refreshes-losing-matches.html b/Tests/LibWeb/Text/input/css/style-engine/narrow-rule-removal-refreshes-losing-matches.html new file mode 100644 index 000000000000..82586096a078 --- /dev/null +++ b/Tests/LibWeb/Text/input/css/style-engine/narrow-rule-removal-refreshes-losing-matches.html @@ -0,0 +1,56 @@ + + + From 05f40fc5e15eb380aa9e4a45cbd0d9a54f1faeb8 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 04:57:05 +0200 Subject: [PATCH 24/39] LibWeb: Consolidate computed pseudo rows and pack publication columns Computed publication kept five hash maps keyed by (node, pseudo) that were scanned whole on removal and on every pseudo cascade check, plus nine parallel Option columns per element. Keep one sparse per-node pseudo row table with bit-packed presence, and pack the element publication state into u32 struct-of-arrays columns with shared flags. Keep the style-record identity in its own Option column because that representation already packs into four bytes. --- .../LibWeb/Rust/src/css/style/computed.rs | 695 +++++++++++------- 1 file changed, 420 insertions(+), 275 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/computed.rs b/Libraries/LibWeb/Rust/src/css/style/computed.rs index 415cfa60dce2..5635c293af01 100644 --- a/Libraries/LibWeb/Rust/src/css/style/computed.rs +++ b/Libraries/LibWeb/Rust/src/css/style/computed.rs @@ -335,7 +335,196 @@ struct PublishedComputedInputs { reconstruction_metadata: ComputedReconstructionMetadataID, style_record: StyleRecordID, animation_overlay_slot: Option, - cascade_state: Option<(u64, CascadeStateID)>, +} + +#[derive(Clone, Copy)] +struct PseudoComputedRow { + kind: u8, + flags: u8, + assignment: Option, + cascade_versions: [u64; 3], + cascade_states: [CascadeStateID; 3], +} + +#[derive(Default)] +struct PublishedComputedColumns { + groups: Vec, + inherited_groups: Vec, + custom_properties: Vec, + fixed_metadata: Vec, + reconstruction_metadata: Vec, + animation_overlay_slots: Vec, + cascade_versions: Vec, + cascade_states: Vec, + flags: Vec, +} + +impl PublishedComputedColumns { + const ASSIGNED: u8 = 1; + const INHERITED_GROUP_SWAP_ELIGIBLE: u8 = 1 << 1; + const HAS_CASCADE_STATE: u8 = 1 << 2; + + fn ensure(&mut self, index: usize) { + if self.flags.len() > index { + return; + } + let len = index + .checked_add(1) + .expect("computed publication column space exhausted"); + self.groups.resize(len, 0); + self.inherited_groups.resize(len, 0); + self.custom_properties.resize(len, 0); + self.fixed_metadata.resize(len, 0); + self.reconstruction_metadata.resize(len, 0); + self.animation_overlay_slots.resize(len, 0); + self.cascade_versions.resize(len, 0); + self.cascade_states.resize(len, 0); + self.flags.resize(len, 0); + } + + fn is_assigned(&self, index: usize) -> bool { + self.flags.get(index).is_some_and(|flags| flags & Self::ASSIGNED != 0) + } + + fn groups(&self, index: usize) -> Option { + self.is_assigned(index).then(|| ComputedGroupSetID(self.groups[index])) + } + + fn inherited_groups(&self, index: usize) -> Option { + self.is_assigned(index) + .then(|| InheritedGroupSetID(self.inherited_groups[index])) + } + + fn custom_properties(&self, index: usize) -> Option { + self.is_assigned(index) + .then(|| CustomPropertyEnvironmentID(self.custom_properties[index])) + } + + fn fixed_metadata(&self, index: usize) -> Option { + self.is_assigned(index) + .then(|| ComputedFixedMetadataID(self.fixed_metadata[index])) + } + + fn reconstruction_metadata(&self, index: usize) -> Option { + self.is_assigned(index) + .then(|| ComputedReconstructionMetadataID(self.reconstruction_metadata[index])) + } + + fn animation_overlay_slot(&self, index: usize) -> Option { + let encoded = *self.animation_overlay_slots.get(index)?; + encoded.checked_sub(1) + } + + fn set_animation_overlay_slot(&mut self, index: usize, slot: Option) { + self.animation_overlay_slots[index] = slot.map_or(0, |slot| { + slot.checked_add(1) + .expect("animation overlay slot identity space exhausted") + }); + } + + fn inherited_group_swap_eligible(&self, index: usize) -> bool { + self.flags + .get(index) + .is_some_and(|flags| flags & Self::INHERITED_GROUP_SWAP_ELIGIBLE != 0) + } + + fn cascade_state(&self, index: usize) -> Option<(u64, CascadeStateID)> { + self.flags + .get(index) + .is_some_and(|flags| flags & Self::HAS_CASCADE_STATE != 0) + .then(|| (self.cascade_versions[index], CascadeStateID(self.cascade_states[index]))) + } + + fn replace_cascade_state( + &mut self, + index: usize, + state: Option<(u64, CascadeStateID)>, + ) -> Option<(u64, CascadeStateID)> { + let previous = self.cascade_state(index); + if let Some((version, state)) = state { + self.cascade_versions[index] = version; + self.cascade_states[index] = state.0; + self.flags[index] |= Self::HAS_CASCADE_STATE; + } else if let Some(flags) = self.flags.get_mut(index) { + *flags &= !Self::HAS_CASCADE_STATE; + } + previous + } + + fn publish(&mut self, index: usize, inputs: PublishedComputedInputs, inherited_group_swap_eligible: bool) { + self.ensure(index); + self.groups[index] = inputs.groups.0; + self.inherited_groups[index] = inputs.inherited_groups.0; + self.custom_properties[index] = inputs.custom_properties.0; + self.fixed_metadata[index] = inputs.fixed_metadata.0; + self.reconstruction_metadata[index] = inputs.reconstruction_metadata.0; + self.set_animation_overlay_slot(index, inputs.animation_overlay_slot); + self.flags[index] = (self.flags[index] & Self::HAS_CASCADE_STATE) + | Self::ASSIGNED + | if inherited_group_swap_eligible { + Self::INHERITED_GROUP_SWAP_ELIGIBLE + } else { + 0 + }; + } + + fn remove(&mut self, index: usize) -> Option { + let overlay = self.animation_overlay_slot(index); + if let Some(flags) = self.flags.get_mut(index) { + *flags = 0; + self.animation_overlay_slots[index] = 0; + } + overlay + } +} + +impl PseudoComputedRow { + const PUBLISHED: u8 = 1; + const CURRENT_CASCADE: usize = 0; + const RETAINED_CASCADE: usize = 1; + const PENDING_CASCADE: usize = 2; + + fn new(kind: u8) -> Self { + Self { + kind, + flags: 0, + assignment: None, + cascade_versions: [0; 3], + cascade_states: [CascadeStateID(0); 3], + } + } + + fn is_published(&self) -> bool { + self.flags & Self::PUBLISHED != 0 + } + + fn set_published(&mut self, published: bool) { + self.flags = (self.flags & !Self::PUBLISHED) | if published { Self::PUBLISHED } else { 0 }; + } + + fn cascade_state(&self, index: usize) -> Option<(u64, CascadeStateID)> { + (self.flags & (1 << (index + 1)) != 0).then_some((self.cascade_versions[index], self.cascade_states[index])) + } + + fn replace_cascade_state( + &mut self, + index: usize, + state: Option<(u64, CascadeStateID)>, + ) -> Option<(u64, CascadeStateID)> { + let previous = self.cascade_state(index); + if let Some((version, state)) = state { + self.cascade_versions[index] = version; + self.cascade_states[index] = state; + self.flags |= 1 << (index + 1); + } else { + self.flags &= !(1 << (index + 1)); + } + previous + } + + fn is_empty(&self) -> bool { + self.flags == 0 && self.assignment.is_none() + } } pub struct ComputedGroupPublication { @@ -407,33 +596,23 @@ impl ComputedStyleTarget { pub struct ComputedGroupSets { groups: InternTable, sets: InternTable, - column: Vec>, inherited_sets: InternTable>, - inherited_column: Vec>, custom_property_environments: InternTable, - custom_property_environment_column: Vec>, computed_fixed_metadata: InternTable, - computed_fixed_metadata_column: Vec>, computed_reconstruction_metadata: InternTable, - computed_reconstruction_metadata_column: Vec>, computed_longhand_tables: InternTable, style_records: InternTable, style_record_column: Vec>, + columns: PublishedComputedColumns, // Recyclable animation overlays are deliberately separate from the permanent base records // above. Dense element assignments and sparse pseudo assignments pin at most one slot each. animation_overlay_slots: Vec>, animation_overlay_slots_by_record: HashMap, free_animation_overlay_slots: Vec, - animation_overlay_column: Vec>, - inherited_group_swap_eligible_column: Vec, live_animation_overlay_assignments: usize, next_animation_overlay_generation: u64, - cascade_state_column: Vec>, - pseudo_cascade_state_rows: HashMap<(StyleNodeID, u8), (u64, CascadeStateID)>, - pseudo_retained_cascade_rows: HashMap<(StyleNodeID, u8), (u64, CascadeStateID)>, - pseudo_assignments: HashMap<(StyleNodeID, u8), PublishedComputedInputs>, - pending_cascade_states: HashMap<(StyleNodeID, u8), (u64, CascadeStateID)>, - pseudo_kinds_by_node: HashMap>, + pending_cascade_states: HashMap, + pseudo_rows_by_node: HashMap>, group_set_nested_memory: MemoryLease, reconstruction_nested_memory: MemoryLease, animation_overlay_nested_memory: MemoryLease, @@ -445,31 +624,21 @@ impl Default for ComputedGroupSets { Self { groups: InternTable::default(), sets: InternTable::default(), - column: Vec::new(), inherited_sets: InternTable::default(), - inherited_column: Vec::new(), custom_property_environments: InternTable::default(), - custom_property_environment_column: Vec::new(), computed_fixed_metadata: InternTable::default(), - computed_fixed_metadata_column: Vec::new(), computed_reconstruction_metadata: InternTable::default(), - computed_reconstruction_metadata_column: Vec::new(), computed_longhand_tables: InternTable::default(), style_records: InternTable::default(), style_record_column: Vec::new(), + columns: PublishedComputedColumns::default(), animation_overlay_slots: Vec::new(), animation_overlay_slots_by_record: HashMap::default(), free_animation_overlay_slots: Vec::new(), - animation_overlay_column: Vec::new(), - inherited_group_swap_eligible_column: Vec::new(), live_animation_overlay_assignments: 0, next_animation_overlay_generation: 0, - cascade_state_column: Vec::new(), - pseudo_cascade_state_rows: HashMap::default(), - pseudo_retained_cascade_rows: HashMap::default(), - pseudo_assignments: HashMap::default(), pending_cascade_states: HashMap::default(), - pseudo_kinds_by_node: HashMap::default(), + pseudo_rows_by_node: HashMap::default(), group_set_nested_memory: MemoryLease::new(MemoryCategory::ComputedGroupSet), reconstruction_nested_memory: MemoryLease::new(MemoryCategory::ComputedReconstructionMetadata), animation_overlay_nested_memory: MemoryLease::new(MemoryCategory::AnimationOverlayRecord), @@ -479,10 +648,68 @@ impl Default for ComputedGroupSets { } impl ComputedGroupSets { + fn pseudo_rows(&self, node: StyleNodeID) -> &[PseudoComputedRow] { + self.pseudo_rows_by_node.get(&node).map_or(&[], Box::as_ref) + } + + fn pseudo_row(&self, node: StyleNodeID, kind: u8) -> Option<&PseudoComputedRow> { + self.pseudo_rows(node).iter().find(|row| row.kind == kind) + } + + fn pseudo_row_mut(&mut self, node: StyleNodeID, kind: u8) -> Option<&mut PseudoComputedRow> { + self.pseudo_rows_by_node + .get_mut(&node) + .and_then(|rows| rows.iter_mut().find(|row| row.kind == kind)) + } + + fn ensure_pseudo_row(&mut self, node: StyleNodeID, kind: u8) -> &mut PseudoComputedRow { + let row_index = self + .pseudo_rows_by_node + .get(&node) + .and_then(|rows| rows.iter().position(|row| row.kind == kind)); + let row_index = row_index.unwrap_or_else(|| { + let mut rows = self + .pseudo_rows_by_node + .remove(&node) + .map_or_else(Vec::new, |rows| rows.into_vec()); + rows.push(PseudoComputedRow::new(kind)); + self.pseudo_assignment_nested_memory + .grow_committed(size_of::() as u64); + let row_index = rows.len() - 1; + self.pseudo_rows_by_node.insert(node, rows.into_boxed_slice()); + row_index + }); + &mut self + .pseudo_rows_by_node + .get_mut(&node) + .expect("pseudo row entry is live")[row_index] + } + + fn remove_empty_pseudo_row(&mut self, node: StyleNodeID, kind: u8) { + let Some(row_index) = self + .pseudo_rows_by_node + .get(&node) + .and_then(|rows| rows.iter().position(|row| row.kind == kind && row.is_empty())) + else { + return; + }; + let mut rows = self + .pseudo_rows_by_node + .remove(&node) + .expect("pseudo row entry is live") + .into_vec(); + rows.remove(row_index); + self.pseudo_assignment_nested_memory + .shrink_committed(size_of::() as u64); + if !rows.is_empty() { + self.pseudo_rows_by_node.insert(node, rows.into_boxed_slice()); + } + } + pub(super) fn assigned_style_record(&self, node: StyleNodeID) -> Option { let index = node.element_index()? as usize; let style_record = *self.style_record_column.get(index)?.as_ref()?; - Some(self.final_style_record(style_record, self.animation_overlay_column[index])) + Some(self.final_style_record(style_record, self.columns.animation_overlay_slot(index))) } fn intern_group_set(&mut self, groups: Vec) -> (ComputedGroupSetID, bool) { @@ -631,19 +858,9 @@ impl ComputedGroupSets { } let index = node.element_index()? as usize; let parent_index = parent.element_index()? as usize; - if self - .inherited_group_swap_eligible_column - .get(index) - .copied() - .unwrap_or(0) - == 0 - || self.animation_overlay_column.get(index).copied().flatten().is_some() - || self - .animation_overlay_column - .get(parent_index) - .copied() - .flatten() - .is_some() + if !self.columns.inherited_group_swap_eligible(index) + || self.columns.animation_overlay_slot(index).is_some() + || self.columns.animation_overlay_slot(parent_index).is_some() || self.assigned_pseudo_kinds(node).next().is_some() { return None; @@ -652,7 +869,7 @@ impl ComputedGroupSets { let old_style_record = *self.style_record_column.get(index)?.as_ref()?; let old_record = *self.style_records.get_index(old_style_record.raw() as usize - 1)?; let old_group_set = self.sets.get_index(old_record.groups.0 as usize)?; - let parent_inherited = *self.inherited_column.get(parent_index)?.as_ref()?; + let parent_inherited = self.columns.inherited_groups(parent_index)?; let parent_groups = self.inherited_sets.get_index(parent_inherited.0 as usize)?; if parent_groups.len() != INHERITED_GROUP_COUNT || old_group_set.identities.len() < INHERITED_GROUP_COUNT { return None; @@ -693,8 +910,8 @@ impl ComputedGroupSets { longhand_table, }; let new_style_record = self.intern_style_record(new_record).0; - self.column[index] = Some(group_set); - self.inherited_column[index] = Some(parent_inherited); + self.columns.groups[index] = group_set.0; + self.columns.inherited_groups[index] = parent_inherited.0; self.style_record_column[index] = Some(new_style_record); Some(( FinalStyleRecordID::base(old_style_record), @@ -919,14 +1136,12 @@ impl ComputedGroupSets { let previous_group_set = target.and_then(|target| { let ComputedStyleTarget { node, pseudo_kind } = target; if target.is_pseudo() { - self.pseudo_assignments - .get(&(node, pseudo_kind)) + self.pseudo_row(node, pseudo_kind) + .and_then(|row| row.assignment) .map(|inputs| inputs.groups) } else { node.element_index() - .and_then(|index| self.column.get(index as usize)) - .copied() - .flatten() + .and_then(|index| self.columns.groups(index as usize)) } }); let mut new_groups = 0; @@ -1062,8 +1277,8 @@ impl ComputedGroupSets { let previous_longhand_table = target.and_then(|target| { let ComputedStyleTarget { node, pseudo_kind } = target; let style_record = if target.is_pseudo() { - self.pseudo_assignments - .get(&(node, pseudo_kind)) + self.pseudo_row(node, pseudo_kind) + .and_then(|row| row.assignment) .map(|inputs| inputs.style_record) } else { node.element_index() @@ -1168,8 +1383,7 @@ impl ComputedGroupSets { previous_style_record_identity, animation_overlay_publication, ) = if let Some(ComputedStyleTarget { node, pseudo_kind }) = target.filter(|target| target.is_pseudo()) { - let key = (node, pseudo_kind); - let previous = self.pseudo_assignments.get(&key).copied(); + let previous = self.pseudo_row(node, pseudo_kind).and_then(|row| row.assignment); let previous_style_record_identity = previous .map(|previous| self.final_style_record(previous.style_record, previous.animation_overlay_slot)); let animation_overlay_publication = self.update_animation_overlay( @@ -1179,26 +1393,17 @@ impl ComputedGroupSets { &mut animated_properties, animation_overlay_payloads, ); - if previous.is_none() { - let kinds = self.pseudo_kinds_by_node.entry(node).or_default(); - let capacity_before = kinds.capacity(); - kinds.push(pseudo_kind); - self.pseudo_assignment_nested_memory - .grow_committed((kinds.capacity() - capacity_before) as u64); - } - self.pseudo_assignments.insert( - key, - PublishedComputedInputs { - groups: identity, - inherited_groups: inherited_identity, - custom_properties: custom_property_environment_identity, - fixed_metadata: computed_fixed_metadata_identity, - reconstruction_metadata: computed_reconstruction_metadata_identity, - style_record: style_record_identity, - animation_overlay_slot: animation_overlay_publication.slot, - cascade_state: previous.and_then(|previous| previous.cascade_state), - }, - ); + let row = self.ensure_pseudo_row(node, pseudo_kind); + row.set_published(true); + row.assignment = Some(PublishedComputedInputs { + groups: identity, + inherited_groups: inherited_identity, + custom_properties: custom_property_environment_identity, + fixed_metadata: computed_fixed_metadata_identity, + reconstruction_metadata: computed_reconstruction_metadata_identity, + style_record: style_record_identity, + animation_overlay_slot: animation_overlay_publication.slot, + }); ( previous.is_none_or(|previous| previous.groups != identity), previous.is_none_or(|previous| previous.inherited_groups != inherited_identity), @@ -1212,43 +1417,42 @@ impl ComputedGroupSets { ) } else if let Some(ComputedStyleTarget { node, .. }) = target { let index = node.element_index().expect("only elements publish computed groups") as usize; - if self.column.len() <= index { - self.column.resize(index + 1, None); - self.inherited_column.resize(index + 1, None); - self.custom_property_environment_column.resize(index + 1, None); - self.computed_fixed_metadata_column.resize(index + 1, None); - self.computed_reconstruction_metadata_column.resize(index + 1, None); + self.columns.ensure(index); + if self.style_record_column.len() <= index { self.style_record_column.resize(index + 1, None); - self.animation_overlay_column.resize(index + 1, None); - self.inherited_group_swap_eligible_column.resize(index + 1, 0); - self.cascade_state_column.resize(index + 1, None); } let previous_style_record_identity = self.style_record_column[index] - .map(|style_record| self.final_style_record(style_record, self.animation_overlay_column[index])); + .map(|style_record| self.final_style_record(style_record, self.columns.animation_overlay_slot(index))); let animation_overlay_publication = self.update_animation_overlay( - self.animation_overlay_column[index], + self.columns.animation_overlay_slot(index), style_record_identity, animation_overlay_identity, &mut animated_properties, animation_overlay_payloads, ); let changed = ( - self.column[index] != Some(identity), - self.inherited_column[index] != Some(inherited_identity), - self.custom_property_environment_column[index] != Some(custom_property_environment_identity), - self.computed_fixed_metadata_column[index] != Some(computed_fixed_metadata_identity), - self.computed_reconstruction_metadata_column[index] != Some(computed_reconstruction_metadata_identity), + self.columns.groups(index) != Some(identity), + self.columns.inherited_groups(index) != Some(inherited_identity), + self.columns.custom_properties(index) != Some(custom_property_environment_identity), + self.columns.fixed_metadata(index) != Some(computed_fixed_metadata_identity), + self.columns.reconstruction_metadata(index) != Some(computed_reconstruction_metadata_identity), previous_style_record_identity, animation_overlay_publication, ); - self.column[index] = Some(identity); - self.inherited_column[index] = Some(inherited_identity); - self.custom_property_environment_column[index] = Some(custom_property_environment_identity); - self.computed_fixed_metadata_column[index] = Some(computed_fixed_metadata_identity); - self.computed_reconstruction_metadata_column[index] = Some(computed_reconstruction_metadata_identity); + self.columns.publish( + index, + PublishedComputedInputs { + groups: identity, + inherited_groups: inherited_identity, + custom_properties: custom_property_environment_identity, + fixed_metadata: computed_fixed_metadata_identity, + reconstruction_metadata: computed_reconstruction_metadata_identity, + style_record: style_record_identity, + animation_overlay_slot: animation_overlay_publication.slot, + }, + inherited_group_swap_eligible, + ); self.style_record_column[index] = Some(style_record_identity); - self.animation_overlay_column[index] = animation_overlay_publication.slot; - self.inherited_group_swap_eligible_column[index] = u8::from(inherited_group_swap_eligible); changed } else { assert_eq!( @@ -1309,8 +1513,8 @@ impl ComputedGroupSets { let final_style_record = FinalStyleRecordID(raw_style_record); let requested_style_record_identity = final_style_record.base_record()?; let previous_base_style_record_identity = if target.is_pseudo() { - self.pseudo_assignments - .get(&(target.node, target.pseudo_kind)) + self.pseudo_row(target.node, target.pseudo_kind) + .and_then(|row| row.assignment) .map(|assignment| assignment.style_record) } else { target @@ -1345,8 +1549,9 @@ impl ComputedGroupSets { previous_style_record_identity, animation_overlay_publication, ) = if target.is_pseudo() { - let key = (target.node, target.pseudo_kind); - let previous = self.pseudo_assignments.get(&key).copied(); + let previous = self + .pseudo_row(target.node, target.pseudo_kind) + .and_then(|row| row.assignment); let previous_style_record_identity = previous .map(|previous| self.final_style_record(previous.style_record, previous.animation_overlay_slot)); let mut animated_properties = None; @@ -1357,26 +1562,17 @@ impl ComputedGroupSets { &mut animated_properties, &[], ); - if previous.is_none() { - let kinds = self.pseudo_kinds_by_node.entry(target.node).or_default(); - let capacity_before = kinds.capacity(); - kinds.push(target.pseudo_kind); - self.pseudo_assignment_nested_memory - .grow_committed((kinds.capacity() - capacity_before) as u64); - } - self.pseudo_assignments.insert( - key, - PublishedComputedInputs { - groups: record.groups, - inherited_groups: inherited_identity, - custom_properties: record.custom_properties, - fixed_metadata: record.fixed_metadata, - reconstruction_metadata: record.reconstruction_metadata, - style_record: style_record_identity, - animation_overlay_slot: None, - cascade_state: previous.and_then(|previous| previous.cascade_state), - }, - ); + let row = self.ensure_pseudo_row(target.node, target.pseudo_kind); + row.set_published(true); + row.assignment = Some(PublishedComputedInputs { + groups: record.groups, + inherited_groups: inherited_identity, + custom_properties: record.custom_properties, + fixed_metadata: record.fixed_metadata, + reconstruction_metadata: record.reconstruction_metadata, + style_record: style_record_identity, + animation_overlay_slot: None, + }); ( previous.is_none_or(|previous| previous.groups != record.groups), previous.is_none_or(|previous| previous.inherited_groups != inherited_identity), @@ -1391,44 +1587,43 @@ impl ComputedGroupSets { .node .element_index() .expect("only elements publish computed groups") as usize; - if self.column.len() <= index { - self.column.resize(index + 1, None); - self.inherited_column.resize(index + 1, None); - self.custom_property_environment_column.resize(index + 1, None); - self.computed_fixed_metadata_column.resize(index + 1, None); - self.computed_reconstruction_metadata_column.resize(index + 1, None); + self.columns.ensure(index); + if self.style_record_column.len() <= index { self.style_record_column.resize(index + 1, None); - self.animation_overlay_column.resize(index + 1, None); - self.inherited_group_swap_eligible_column.resize(index + 1, 0); - self.cascade_state_column.resize(index + 1, None); } let previous_style_record_identity = self.style_record_column[index] - .map(|style_record| self.final_style_record(style_record, self.animation_overlay_column[index])); + .map(|style_record| self.final_style_record(style_record, self.columns.animation_overlay_slot(index))); let mut animated_properties = None; let animation_overlay_publication = self.update_animation_overlay( - self.animation_overlay_column[index], + self.columns.animation_overlay_slot(index), style_record_identity, 0, &mut animated_properties, &[], ); let changed = ( - self.column[index] != Some(record.groups), - self.inherited_column[index] != Some(inherited_identity), - self.custom_property_environment_column[index] != Some(record.custom_properties), - self.computed_fixed_metadata_column[index] != Some(record.fixed_metadata), - self.computed_reconstruction_metadata_column[index] != Some(record.reconstruction_metadata), + self.columns.groups(index) != Some(record.groups), + self.columns.inherited_groups(index) != Some(inherited_identity), + self.columns.custom_properties(index) != Some(record.custom_properties), + self.columns.fixed_metadata(index) != Some(record.fixed_metadata), + self.columns.reconstruction_metadata(index) != Some(record.reconstruction_metadata), previous_style_record_identity, animation_overlay_publication, ); - self.column[index] = Some(record.groups); - self.inherited_column[index] = Some(inherited_identity); - self.custom_property_environment_column[index] = Some(record.custom_properties); - self.computed_fixed_metadata_column[index] = Some(record.fixed_metadata); - self.computed_reconstruction_metadata_column[index] = Some(record.reconstruction_metadata); + self.columns.publish( + index, + PublishedComputedInputs { + groups: record.groups, + inherited_groups: inherited_identity, + custom_properties: record.custom_properties, + fixed_metadata: record.fixed_metadata, + reconstruction_metadata: record.reconstruction_metadata, + style_record: style_record_identity, + animation_overlay_slot: None, + }, + inherited_group_swap_eligible, + ); self.style_record_column[index] = Some(style_record_identity); - self.animation_overlay_column[index] = None; - self.inherited_group_swap_eligible_column[index] = u8::from(inherited_group_swap_eligible); changed }; @@ -1503,57 +1698,64 @@ impl ComputedGroupSets { } pub fn set_pending_cascade_state(&mut self, target: ComputedStyleTarget, state: (u64, CascadeStateID)) { - self.pending_cascade_states - .insert((target.node, target.pseudo_kind), state); + if !target.is_pseudo() { + self.pending_cascade_states.insert(target.node, state); + return; + } + self.ensure_pseudo_row(target.node, target.pseudo_kind) + .replace_cascade_state(PseudoComputedRow::PENDING_CASCADE, Some(state)); } pub fn take_pending_cascade_state(&mut self, target: ComputedStyleTarget) -> Option<(u64, CascadeStateID)> { - self.pending_cascade_states.remove(&(target.node, target.pseudo_kind)) + if !target.is_pseudo() { + return self.pending_cascade_states.remove(&target.node); + } + let state = self + .pseudo_row_mut(target.node, target.pseudo_kind) + .and_then(|row| row.replace_cascade_state(PseudoComputedRow::PENDING_CASCADE, None)); + self.remove_empty_pseudo_row(target.node, target.pseudo_kind); + state } #[must_use] pub fn cascade_state(&self, target: ComputedStyleTarget) -> Option<(u64, CascadeStateID)> { if target.is_pseudo() { return self - .pseudo_cascade_state_rows - .get(&(target.node, target.pseudo_kind)) - .copied(); + .pseudo_row(target.node, target.pseudo_kind)? + .cascade_state(PseudoComputedRow::CURRENT_CASCADE); } target .node .element_index() - .and_then(|index| self.cascade_state_column.get(index as usize).copied().flatten()) + .and_then(|index| self.columns.cascade_state(index as usize)) } pub fn pseudo_retained_cascade_states( &self, node: StyleNodeID, ) -> impl Iterator + '_ { - self.pseudo_retained_cascade_rows - .iter() - .filter_map(move |(&(row_node, pseudo_kind), &state)| (row_node == node).then_some((pseudo_kind, state))) + self.pseudo_rows(node).iter().filter_map(|row| { + row.cascade_state(PseudoComputedRow::RETAINED_CASCADE) + .map(|state| (row.kind, state)) + }) } #[must_use] pub fn pseudo_retained_cascade_state(&self, node: StyleNodeID, pseudo_kind: u8) -> Option<(u64, CascadeStateID)> { - self.pseudo_retained_cascade_rows.get(&(node, pseudo_kind)).copied() + self.pseudo_row(node, pseudo_kind)? + .cascade_state(PseudoComputedRow::RETAINED_CASCADE) } /// The pseudo-element kinds this node holds published computed styles for. pub fn assigned_pseudo_kinds(&self, node: StyleNodeID) -> impl Iterator + '_ { - self.pseudo_kinds_by_node - .get(&node) - .into_iter() - .flat_map(|kinds| kinds.iter().copied()) + self.pseudo_rows(node) + .iter() + .filter_map(|row| row.is_published().then_some(row.kind)) } #[cfg(test)] pub fn record_pseudo_kind_for_test(&mut self, node: StyleNodeID, pseudo_kind: u8) { - let kinds = self.pseudo_kinds_by_node.entry(node).or_default(); - let capacity_before = kinds.capacity(); - kinds.push(pseudo_kind); - self.pseudo_assignment_nested_memory - .grow_committed((kinds.capacity() - capacity_before) as u64); + self.ensure_pseudo_row(node, pseudo_kind).set_published(true); } fn specified_value_dependency_mask( @@ -1562,16 +1764,14 @@ impl ComputedGroupSets { depends_on_input: impl Fn(&RetainedStyleValueData) -> bool, ) -> Option { let reconstruction_metadata = if target.is_pseudo() { - self.pseudo_assignments - .get(&(target.node, target.pseudo_kind)) + self.pseudo_row(target.node, target.pseudo_kind) + .and_then(|row| row.assignment) .map(|assignment| assignment.reconstruction_metadata) } else { target .node .element_index() - .and_then(|index| self.computed_reconstruction_metadata_column.get(index as usize)) - .copied() - .flatten() + .and_then(|index| self.columns.reconstruction_metadata(index as usize)) }?; let mask = self.computed_reconstruction_metadata[reconstruction_metadata.0 as usize] .inheritance_dependent_values @@ -1590,16 +1790,14 @@ impl ComputedGroupSets { depends_on_input: impl Fn(&RetainedStyleValueData) -> bool, ) -> Option<[u64; 6]> { let reconstruction_metadata = if target.is_pseudo() { - self.pseudo_assignments - .get(&(target.node, target.pseudo_kind)) + self.pseudo_row(target.node, target.pseudo_kind) + .and_then(|row| row.assignment) .map(|assignment| assignment.reconstruction_metadata) } else { target .node .element_index() - .and_then(|index| self.computed_reconstruction_metadata_column.get(index as usize)) - .copied() - .flatten() + .and_then(|index| self.columns.reconstruction_metadata(index as usize)) }?; let mut properties = [0u64; 6]; for entry in @@ -1666,88 +1864,54 @@ impl ComputedGroupSets { cascade_state: (u64, CascadeStateID), ) -> Option<(u64, CascadeStateID)> { if target.is_pseudo() { - let assignment = self - .pseudo_assignments - .get_mut(&(target.node, target.pseudo_kind)) - .expect("a pseudo style must be published before its cascade state is bound"); - assignment.cascade_state = Some(cascade_state); - return self - .pseudo_cascade_state_rows - .insert((target.node, target.pseudo_kind), cascade_state); + let row = self.ensure_pseudo_row(target.node, target.pseudo_kind); + assert!( + row.assignment.is_some(), + "a pseudo style must be published before its cascade state is bound" + ); + return row.replace_cascade_state(PseudoComputedRow::CURRENT_CASCADE, Some(cascade_state)); } let index = target .node .element_index() .expect("only elements publish computed groups") as usize; - debug_assert!(index < self.cascade_state_column.len()); - self.cascade_state_column[index].replace(cascade_state) + debug_assert!(index < self.columns.flags.len()); + self.columns.replace_cascade_state(index, Some(cascade_state)) } /// Forget the exact cascade state behind a style published through a path that did not run the /// cascade, so a later comparison cannot use the state behind an older publication. pub fn clear_cascade_state(&mut self, target: ComputedStyleTarget) { if target.is_pseudo() { - self.pseudo_cascade_state_rows - .remove(&(target.node, target.pseudo_kind)); - self.pseudo_retained_cascade_rows - .remove(&(target.node, target.pseudo_kind)); - if let Some(assignment) = self.pseudo_assignments.get_mut(&(target.node, target.pseudo_kind)) { - assignment.cascade_state = None; + if let Some(row) = self.pseudo_row_mut(target.node, target.pseudo_kind) { + row.replace_cascade_state(PseudoComputedRow::CURRENT_CASCADE, None); + row.replace_cascade_state(PseudoComputedRow::RETAINED_CASCADE, None); } + self.remove_empty_pseudo_row(target.node, target.pseudo_kind); return; } let Some(index) = target.node.element_index().map(|index| index as usize) else { return; }; - if let Some(slot) = self.cascade_state_column.get_mut(index) { - *slot = None; - } + self.columns.replace_cascade_state(index, None); } pub fn remove(&mut self, node: StyleNodeID) { let Some(index) = node.element_index().map(|index| index as usize) else { return; }; - if let Some(slot) = self.column.get_mut(index) { - *slot = None; - } - if let Some(slot) = self.inherited_column.get_mut(index) { - *slot = None; - } - if let Some(slot) = self.custom_property_environment_column.get_mut(index) { - *slot = None; - } - if let Some(slot) = self.computed_fixed_metadata_column.get_mut(index) { - *slot = None; - } - if let Some(slot) = self.computed_reconstruction_metadata_column.get_mut(index) { - *slot = None; - } if let Some(slot) = self.style_record_column.get_mut(index) { *slot = None; } - if let Some(slot) = self.animation_overlay_column.get_mut(index).and_then(Option::take) { + if let Some(slot) = self.columns.remove(index) { self.release_animation_overlay_assignment(slot); } - if let Some(slot) = self.inherited_group_swap_eligible_column.get_mut(index) { - *slot = 0; - } - if let Some(slot) = self.cascade_state_column.get_mut(index) { - *slot = None; - } - self.pending_cascade_states - .retain(|(pending_node, _), _| *pending_node != node); - self.pseudo_cascade_state_rows - .retain(|(row_node, _), _| *row_node != node); - self.pseudo_retained_cascade_rows - .retain(|(row_node, _), _| *row_node != node); - if let Some(kinds) = self.pseudo_kinds_by_node.remove(&node) { + self.pending_cascade_states.remove(&node); + if let Some(rows) = self.pseudo_rows_by_node.remove(&node) { self.pseudo_assignment_nested_memory - .shrink_committed(kinds.capacity() as u64); - for pseudo_kind in kinds { - if let Some(removed) = self.pseudo_assignments.remove(&(node, pseudo_kind)) - && let Some(slot) = removed.animation_overlay_slot - { + .shrink_committed(size_of_val(rows.as_ref()) as u64); + for row in rows.into_vec() { + if let Some(slot) = row.assignment.and_then(|assignment| assignment.animation_overlay_slot) { self.release_animation_overlay_assignment(slot); } } @@ -1755,32 +1919,22 @@ impl ComputedGroupSets { } pub fn remove_pseudo(&mut self, node: StyleNodeID, pseudo_kind: u8) -> Option { - let removed = self.pseudo_assignments.remove(&(node, pseudo_kind))?; + let row = self.pseudo_row_mut(node, pseudo_kind)?; + let removed = row.assignment.take()?; + row.set_published(false); + row.replace_cascade_state(PseudoComputedRow::PENDING_CASCADE, None); let final_style_record = self.final_style_record(removed.style_record, removed.animation_overlay_slot); if let Some(slot) = removed.animation_overlay_slot { self.release_animation_overlay_assignment(slot); } - self.pending_cascade_states.remove(&(node, pseudo_kind)); - let mut remove_node_entry = false; - if let Some(kinds) = self.pseudo_kinds_by_node.get_mut(&node) { - kinds.retain(|kind| *kind != pseudo_kind); - remove_node_entry = kinds.is_empty(); - } - if remove_node_entry { - let kinds = self - .pseudo_kinds_by_node - .remove(&node) - .expect("pseudo-kind index entry is live"); - self.pseudo_assignment_nested_memory - .shrink_committed(kinds.capacity() as u64); - } + self.remove_empty_pseudo_row(node, pseudo_kind); Some(final_style_record) } pub fn observe_absent_pseudo_cascade_state(&mut self, target: ComputedStyleTarget, state: (u64, CascadeStateID)) { debug_assert!(target.is_pseudo()); - self.pseudo_cascade_state_rows - .insert((target.node, target.pseudo_kind), state); + self.ensure_pseudo_row(target.node, target.pseudo_kind) + .replace_cascade_state(PseudoComputedRow::CURRENT_CASCADE, Some(state)); } pub fn observe_pseudo_retained_cascade_state( @@ -1789,13 +1943,9 @@ impl ComputedGroupSets { state: Option<(u64, CascadeStateID)>, ) { debug_assert!(target.is_pseudo()); - if let Some(state) = state { - self.pseudo_retained_cascade_rows - .insert((target.node, target.pseudo_kind), state); - } else { - self.pseudo_retained_cascade_rows - .remove(&(target.node, target.pseudo_kind)); - } + self.ensure_pseudo_row(target.node, target.pseudo_kind) + .replace_cascade_state(PseudoComputedRow::RETAINED_CASCADE, state); + self.remove_empty_pseudo_row(target.node, target.pseudo_kind); } #[must_use] @@ -1804,9 +1954,9 @@ impl ComputedGroupSets { shallow [ self.groups, self.sets, - self.column, + self.columns.groups, self.inherited_sets, - self.inherited_column, + self.columns.inherited_groups, ]; cached [self.group_set_nested_memory.bytes()]; nested []; @@ -1817,7 +1967,7 @@ impl ComputedGroupSets { #[must_use] pub fn custom_property_environment_capacity_bytes(&self) -> u64 { capacity_bytes! { - shallow [self.custom_property_environments, self.custom_property_environment_column]; + shallow [self.custom_property_environments, self.columns.custom_properties]; cached []; nested []; skip []; @@ -1829,7 +1979,7 @@ impl ComputedGroupSets { capacity_bytes! { shallow [ self.computed_fixed_metadata, - self.computed_fixed_metadata_column, + self.columns.fixed_metadata, ]; cached []; nested []; @@ -1842,7 +1992,7 @@ impl ComputedGroupSets { capacity_bytes! { shallow [ self.computed_reconstruction_metadata, - self.computed_reconstruction_metadata_column, + self.columns.reconstruction_metadata, self.computed_longhand_tables, ]; cached [self.reconstruction_nested_memory.bytes()]; @@ -1857,8 +2007,9 @@ impl ComputedGroupSets { shallow [ self.style_records, self.style_record_column, - self.inherited_group_swap_eligible_column, - self.cascade_state_column, + self.columns.cascade_versions, + self.columns.cascade_states, + self.columns.flags, self.pending_cascade_states, ]; cached []; @@ -1874,7 +2025,7 @@ impl ComputedGroupSets { self.animation_overlay_slots, self.animation_overlay_slots_by_record, self.free_animation_overlay_slots, - self.animation_overlay_column, + self.columns.animation_overlay_slots, ]; cached [self.animation_overlay_nested_memory.bytes()]; nested []; @@ -2050,12 +2201,7 @@ impl ComputedGroupSets { #[must_use] pub fn pseudo_assignment_capacity_bytes(&self) -> u64 { capacity_bytes! { - shallow [ - self.pseudo_assignments, - self.pseudo_kinds_by_node, - self.pseudo_cascade_state_rows, - self.pseudo_retained_cascade_rows, - ]; + shallow [self.pseudo_rows_by_node]; cached [self.pseudo_assignment_nested_memory.bytes()]; nested []; skip []; @@ -2410,10 +2556,13 @@ mod tests { sets.style_records.reserve(1); sets.pending_cascade_states.reserve(1); sets.animation_overlay_slots_by_record.reserve(1); - sets.pseudo_assignments.reserve(1); - sets.pseudo_kinds_by_node.reserve(1); - sets.pseudo_cascade_state_rows.reserve(1); - sets.pseudo_retained_cascade_rows.reserve(1); + sets.pseudo_rows_by_node.reserve(1); + sets.pseudo_rows_by_node.insert( + StyleNodeID::element(1), + vec![PseudoComputedRow::new(1)].into_boxed_slice(), + ); + sets.pseudo_assignment_nested_memory + .grow_committed(size_of::() as u64); let accounted = sets.capacity_bytes() + sets.custom_property_environment_capacity_bytes() @@ -2431,16 +2580,12 @@ mod tests { + sets.computed_longhand_tables.capacity_bytes() as usize + sets.style_records.capacity_bytes() as usize + sets.pending_cascade_states.capacity() - * (size_of::<(StyleNodeID, u8)>() + size_of::<(u64, CascadeStateID)>() + 1) + * (size_of::() + size_of::<(u64, CascadeStateID)>() + 1) + sets.animation_overlay_slots_by_record.capacity() * (size_of::() + size_of::() + 1) - + sets.pseudo_assignments.capacity() - * (size_of::<(StyleNodeID, u8)>() + size_of::() + 1) - + sets.pseudo_kinds_by_node.capacity() * (size_of::() + size_of::>() + 1) - + sets.pseudo_cascade_state_rows.capacity() - * (size_of::<(StyleNodeID, u8)>() + size_of::<(u64, CascadeStateID)>() + 1) - + sets.pseudo_retained_cascade_rows.capacity() - * (size_of::<(StyleNodeID, u8)>() + size_of::<(u64, CascadeStateID)>() + 1); + + sets.pseudo_rows_by_node.capacity() + * (size_of::() + size_of::>() + 1) + + size_of_val(sets.pseudo_rows_by_node[&StyleNodeID::element(1)].as_ref()); assert_eq!(accounted, expected as u64); } From dd8b4b9c94a85f9990e5535b0a7def4a689b4118 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 05:12:30 +0200 Subject: [PATCH 25/39] LibWeb: Reclaim unreachable computed records Computed sets, groups, tables, and style records were append-only for the document lifetime. Style churn retained every superseded payload and made stale numeric identities look valid forever. Mark element and pseudo assignments, overlays, and explicit C++ pins. Retire unreachable records with generation-tagged identities. Captures stay monotone because their dictionaries name computed identities, so recording suppresses reclamation. Keep generated layout records pinned while their DOM pseudo can replace its record before reconstruction, rebind payload-equal updates, and preserve a detached node's prior pin when its pseudo is cleared. Document teardown ownership and count transient computed-style view pins. --- Libraries/LibWeb/CSS/ComputedValues.cpp | 2 +- Libraries/LibWeb/CSS/StyleComputer.cpp | 67 +- Libraries/LibWeb/CSS/StyleComputer.h | 33 +- Libraries/LibWeb/DOM/PseudoElement.cpp | 2 +- Libraries/LibWeb/Internals/Internals.cpp | 16 + Libraries/LibWeb/Internals/Internals.h | 2 + Libraries/LibWeb/Internals/Internals.idl | 2 + Libraries/LibWeb/Layout/Node.cpp | 16 +- Libraries/LibWeb/Layout/Node.h | 4 + Libraries/LibWeb/Layout/TreeBuilder.cpp | 24 + Libraries/LibWeb/Rust/src/bin/style_replay.rs | 15 +- .../LibWeb/Rust/src/css/style/computed.rs | 786 +++++++++++++++--- Libraries/LibWeb/Rust/src/css/style/flush.rs | 1 + .../Rust/src/css/style/instrumentation.rs | 2 + .../LibWeb/Rust/src/css/style/intern_table.rs | 40 +- .../LibWeb/Rust/src/css/style/publication.rs | 15 +- .../computed-group-identities.txt | 1 + .../computed-record-reclamation.txt | 6 + .../computed-style-record-view-pins.txt | 1 + .../layout-and-paint-consume-style-record.txt | 3 + .../computed-group-identities.html | 12 +- .../computed-record-reclamation.html | 57 ++ .../computed-style-record-view-pins.html | 12 + ...layout-and-paint-consume-style-record.html | 14 + 24 files changed, 965 insertions(+), 168 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/css/style-engine/computed-record-reclamation.txt create mode 100644 Tests/LibWeb/Text/expected/css/style-engine/computed-style-record-view-pins.txt create mode 100644 Tests/LibWeb/Text/input/css/style-engine/computed-record-reclamation.html create mode 100644 Tests/LibWeb/Text/input/css/style-engine/computed-style-record-view-pins.html diff --git a/Libraries/LibWeb/CSS/ComputedValues.cpp b/Libraries/LibWeb/CSS/ComputedValues.cpp index 218481b5f0d0..93833acfccad 100644 --- a/Libraries/LibWeb/CSS/ComputedValues.cpp +++ b/Libraries/LibWeb/CSS/ComputedValues.cpp @@ -892,7 +892,7 @@ void ComputedValues::borrow_style_record_payloads(ReadonlySpan payl } ComputedStyleRecordView::ComputedStyleRecordView(StyleEngineFFI::FfiStyleRecordView const& view, StyleComputer const& style_computer, StyleRecordID style_record_identity) - : m_style_computer(view.animation_overlay_identity != 0 ? &style_computer : nullptr) + : m_style_computer(&style_computer) , m_style_record_identity(style_record_identity) { VERIFY(view.present); diff --git a/Libraries/LibWeb/CSS/StyleComputer.cpp b/Libraries/LibWeb/CSS/StyleComputer.cpp index 0a427b36547f..8a07862e6f76 100644 --- a/Libraries/LibWeb/CSS/StyleComputer.cpp +++ b/Libraries/LibWeb/CSS/StyleComputer.cpp @@ -151,7 +151,45 @@ StyleComputer::StyleComputer(DOM::Document& document) { } -StyleComputer::~StyleComputer() = default; +void StyleComputer::finalize() +{ + Base::finalize(); + clear_style_sharing_cache(); +} + +void StyleComputer::clear_style_sharing_cache() const +{ + for (auto const& bucket : m_style_sharing_cache) { + for (auto const& entry : bucket.value) { + if (entry.explicitly_inherited_non_inherited_property && !!entry.parent_style_record_identity) + unpin_style_record(entry.parent_style_record_identity); + if (entry.style_record_identity.has_value()) + unpin_style_record(*entry.style_record_identity); + } + } + m_style_sharing_cache.clear(); + m_style_sharing_cache_entry_count = 0; +} + +void StyleComputer::prepare_for_style_engine_transaction() const +{ + ++m_style_sharing_transaction_generation; + if (m_style_sharing_cache_entry_count > maximum_persistent_style_sharing_entries) + clear_style_sharing_cache(); + m_computed_style_invalidation_cache.clear(); + m_style_engine_cascade_input_cache.clear(); + m_inherited_style_group_swaps.clear(); + sweep_custom_property_environments(); +} + +void StyleComputer::drop_style_sharing_cache() const +{ + clear_style_sharing_cache(); + m_computed_style_invalidation_cache.clear(); + m_style_engine_cascade_input_cache.clear(); + m_inherited_style_group_swaps.clear(); + sweep_custom_property_environments(); +} ComputedStyleRecordView StyleComputer::computed_style_record_view(StyleRecordID style_record_identity) const { @@ -160,8 +198,8 @@ ComputedStyleRecordView StyleComputer::computed_style_record_view(StyleRecordID auto view = m_style_engine.style_record_view(style_record_identity); if (!view.present) return {}; - if (view.animation_overlay_identity != 0) - pin_style_record(style_record_identity); + pin_style_record(style_record_identity); + ++m_computed_style_record_view_pin_count; return ComputedStyleRecordView { view, *this, style_record_identity }; } @@ -175,18 +213,12 @@ void const* StyleComputer::style_record_payloads(StyleRecordID style_record_iden void StyleComputer::pin_style_record(StyleRecordID style_record_identity) const { VERIFY(style_record_identity); - static constexpr u64 animation_overlay_tag = 1ull << 63; - if ((style_record_identity.value() & animation_overlay_tag) == 0) - return; const_cast(*this).m_style_engine.pin_style_record(style_record_identity); } void StyleComputer::unpin_style_record(StyleRecordID style_record_identity) const { VERIFY(style_record_identity); - static constexpr u64 animation_overlay_tag = 1ull << 63; - if ((style_record_identity.value() & animation_overlay_tag) == 0) - return; const_cast(*this).m_style_engine.unpin_style_record(style_record_identity); } @@ -3625,8 +3657,8 @@ StyleEngine::StyleRecordDelta StyleComputer::publish_computed_style_inputs(DOM:: { auto publication = record_computed_style_inputs(Optional { abstract_element }, values, abstract_element.element().style_node_id()); if (!abstract_element.pseudo_element().has_value()) { - if (auto* record = abstract_element.element().style_input_record(); record && record->bind_next_published_style) { - record->computed_style_record = publication.new_style_record; + if (auto* record = abstract_element.element().style_input_record()) { + record->computed_style_record = record->bind_next_published_style ? publication.new_style_record : StyleRecordID {}; record->bind_next_published_style = false; } } @@ -3725,6 +3757,8 @@ NonnullRefPtr StyleComputer::materialize_style_record(DOM: auto& entry = bucket->last(); VERIFY(entry.values.ptr() == values.ptr()); VERIFY(entry.custom_property_data.ptr() == abstract_element.custom_property_data().ptr()); + VERIFY(!entry.style_record_identity.has_value()); + pin_style_record(publication.new_style_record); entry.style_record_identity = publication.new_style_record; } if (style_record_delta.has_value()) @@ -3749,8 +3783,8 @@ NonnullRefPtr StyleComputer::materialize_style_record(DOM: inherited_group_swap_eligible); if (!!publication.new_style_record) { if (!abstract_element.pseudo_element().has_value()) { - if (auto* record = abstract_element.element().style_input_record(); record && record->bind_next_published_style) { - record->computed_style_record = publication.new_style_record; + if (auto* record = abstract_element.element().style_input_record()) { + record->computed_style_record = record->bind_next_published_style ? publication.new_style_record : StyleRecordID {}; record->bind_next_published_style = false; } } @@ -3833,6 +3867,8 @@ NonnullRefPtr StyleComputer::build_and_share_computed_valu pinned_style_input_values = element.style_input_record()->pinned_values; cascade_declares_custom_properties = element.style_input_record()->cascade_declares_custom_properties; } + if (sharing.explicitly_inherited_non_inherited_property && !!sharing.parent_style_record_identity) + pin_style_record(sharing.parent_style_record_identity); m_style_sharing_cache.ensure(key_hash).append({ .key = move(sharing.key), .pinned_parent_groups = move(sharing.pinned_parent_groups), @@ -3883,6 +3919,8 @@ RefPtr StyleComputer::compute_pseudo_element_style_if_need auto& entry = bucket->last(); VERIFY(entry.values.ptr() == values.ptr()); VERIFY(entry.custom_property_data.ptr() == abstract_element.custom_property_data().ptr()); + VERIFY(!entry.style_record_identity.has_value()); + pin_style_record(publication.new_style_record); entry.style_record_identity = publication.new_style_record; } if (style_record_delta.has_value()) @@ -4743,8 +4781,7 @@ RefPtr StyleComputer::compute_style_impl(DOM::AbstractE auto materialize_style_record_view = [&](StyleEngine::StyleRecordView const& view, StyleRecordID identity) -> OwnPtr { if (!view.present) return {}; - if (view.animation_overlay_identity != 0) - pin_style_record(identity); + pin_style_record(identity); return make(view, *this, identity); }; inheritance_parent_style = materialize_style_record_view(inheritance_parent_style_record, inheritance_parent_style_record_identity); diff --git a/Libraries/LibWeb/CSS/StyleComputer.h b/Libraries/LibWeb/CSS/StyleComputer.h index e8082bd91650..d75764323691 100644 --- a/Libraries/LibWeb/CSS/StyleComputer.h +++ b/Libraries/LibWeb/CSS/StyleComputer.h @@ -47,6 +47,8 @@ class WEB_API StyleComputer final : public GC::Cell { GC_DECLARE_ALLOCATOR(StyleComputer); public: + static constexpr bool OVERRIDES_FINALIZE = true; + static void for_each_property_expanding_shorthands(PropertyID, StyleValue const&, Function const& set_longhand_property); static NonnullRefPtr get_non_animated_inherit_value(PropertyID, DOM::AbstractElement); struct AnimatedInheritValue { @@ -58,7 +60,7 @@ class WEB_API StyleComputer final : public GC::Cell { static Optional user_agent_style_sheet_source(Utf16View name); explicit StyleComputer(DOM::Document&); - ~StyleComputer(); + virtual ~StyleComputer() override = default; DOM::Document& document() { return m_document; } DOM::Document const& document() const { return m_document; } @@ -100,30 +102,10 @@ class WEB_API StyleComputer final : public GC::Cell { // Drop caches whose keys contain inputs that are stable only within one engine transaction. // Style sharing has a self-validating key and survives ordinary transaction boundaries. - void prepare_for_style_engine_transaction() const - { - ++m_style_sharing_transaction_generation; - if (m_style_sharing_cache_entry_count > maximum_persistent_style_sharing_entries) { - m_style_sharing_cache.clear(); - m_style_sharing_cache_entry_count = 0; - sweep_custom_property_environments(); - } - m_computed_style_invalidation_cache.clear(); - m_style_engine_cascade_input_cache.clear(); - m_inherited_style_group_swaps.clear(); - m_custom_property_environments.clear(); - } + void prepare_for_style_engine_transaction() const; // Forget every style one element computed on another's behalf. See m_style_sharing_cache. - void drop_style_sharing_cache() const - { - m_style_sharing_cache.clear(); - m_style_sharing_cache_entry_count = 0; - m_computed_style_invalidation_cache.clear(); - m_style_engine_cascade_input_cache.clear(); - m_inherited_style_group_swaps.clear(); - sweep_custom_property_environments(); - } + void drop_style_sharing_cache() const; struct ComputedStyleInvalidation { RequiredInvalidationAfterStyleChange invalidation; @@ -150,6 +132,7 @@ class WEB_API StyleComputer final : public GC::Cell { [[nodiscard]] void const* style_record_payloads(StyleRecordID) const; void pin_style_record(StyleRecordID) const; void unpin_style_record(StyleRecordID) const; + [[nodiscard]] u64 computed_style_record_view_pin_count() const { return m_computed_style_record_view_pin_count; } // Two elements whose cascade declares the same custom properties against the same inherited // environment hold the same environment, so they are given one object rather than an object @@ -211,6 +194,8 @@ class WEB_API StyleComputer final : public GC::Cell { void for_each_provisional_transition_effect(DOM::AbstractElement const&, Function const&) const; private: + virtual void finalize() override; + virtual void visit_edges(Visitor&) override; [[nodiscard]] StyleEngine::StyleRecordDelta record_computed_style_inputs(Optional, ComputedValues const&, StyleNodeID style_node_id) const; @@ -300,6 +285,7 @@ class WEB_API StyleComputer final : public GC::Cell { }; private: + void clear_style_sharing_cache() const; [[nodiscard]] NonnullRefPtr build_and_share_computed_values(NonnullRefPtr, DOM::AbstractElement, StyleScope const&, StyleSharingCandidate&) const; [[nodiscard]] static Vector, 4> author_context_shadow_roots(DOM::AbstractElement); @@ -551,6 +537,7 @@ class WEB_API StyleComputer final : public GC::Cell { CSSPixelRect m_viewport_rect; mutable StyleEngine m_style_engine; + mutable u64 m_computed_style_record_view_pin_count { 0 }; Vector> m_style_nodes; TreeScopeID m_next_tree_scope; Vector m_non_author_style_sheets; diff --git a/Libraries/LibWeb/DOM/PseudoElement.cpp b/Libraries/LibWeb/DOM/PseudoElement.cpp index 30ad8c722e41..ab46fce3c721 100644 --- a/Libraries/LibWeb/DOM/PseudoElement.cpp +++ b/Libraries/LibWeb/DOM/PseudoElement.cpp @@ -83,7 +83,7 @@ void SyntheticPseudoElement::clear_computed_style(RefPtrpin_style_record_for_detachment(); } - replace_style_record(0); + m_style_record_identity = 0; } void SyntheticPseudoElement::refresh_computed_style(CSS::StyleRecordID style_record_identity) diff --git a/Libraries/LibWeb/Internals/Internals.cpp b/Libraries/LibWeb/Internals/Internals.cpp index b0cd833bc0eb..60f8e7a2b93f 100644 --- a/Libraries/LibWeb/Internals/Internals.cpp +++ b/Libraries/LibWeb/Internals/Internals.cpp @@ -1177,6 +1177,10 @@ GC::Ref Internals::style_engine_counters() JS::Value(static_cast(value)), JS::default_attributes); } + object->define_direct_property( + "computedStyleRecordViewPins"_utf16_fly_string, + JS::Value(static_cast(window().associated_document().style_computer().computed_style_record_view_pin_count())), + JS::default_attributes); return object; } @@ -1192,6 +1196,18 @@ u64 Internals::layout_style_record_identity(DOM::Element& element) return layout_node ? layout_node->style_record_identity().value() : 0; } +u64 Internals::before_style_record_identity(DOM::Element& element) +{ + return element.style_record_identity(CSS::PseudoElement::Before).value(); +} + +u64 Internals::before_layout_style_record_identity(DOM::Element& element) +{ + element.document().update_layout(DOM::UpdateLayoutReason::Debugging); + auto const* layout_node = element.pseudo_element_unsafe_layout_node(CSS::PseudoElement::Before); + return layout_node ? layout_node->style_record_identity().value() : 0; +} + u64 Internals::paint_style_record_identity(DOM::Element& element) { element.document().update_layout(DOM::UpdateLayoutReason::Debugging); diff --git a/Libraries/LibWeb/Internals/Internals.h b/Libraries/LibWeb/Internals/Internals.h index 3c0f5f6e3ee3..a894f97153aa 100644 --- a/Libraries/LibWeb/Internals/Internals.h +++ b/Libraries/LibWeb/Internals/Internals.h @@ -179,6 +179,8 @@ class WEB_API Internals final : public InternalsBase { GC::Ref style_engine_counters(); u64 style_record_identity(DOM::Element&); u64 layout_style_record_identity(DOM::Element&); + u64 before_style_record_identity(DOM::Element&); + u64 before_layout_style_record_identity(DOM::Element&); u64 paint_style_record_identity(DOM::Element&); u64 layout_node_identity(DOM::Node&); double style_engine_match_document(); diff --git a/Libraries/LibWeb/Internals/Internals.idl b/Libraries/LibWeb/Internals/Internals.idl index 3dfd6593cab3..eb58e7934af9 100644 --- a/Libraries/LibWeb/Internals/Internals.idl +++ b/Libraries/LibWeb/Internals/Internals.idl @@ -174,6 +174,8 @@ interface Internals { object styleEngineCounters(); unsigned long long styleRecordIdentity(Element element); unsigned long long layoutStyleRecordIdentity(Element element); + unsigned long long beforeStyleRecordIdentity(Element element); + unsigned long long beforeLayoutStyleRecordIdentity(Element element); unsigned long long paintStyleRecordIdentity(Element element); // Stable for the lifetime of a layout node, and zero when the DOM node has no principal // layout node. Useful for asserting that incremental tree builds preserve unaffected nodes. diff --git a/Libraries/LibWeb/Layout/Node.cpp b/Libraries/LibWeb/Layout/Node.cpp index 3f441a4b06bd..6970948734b1 100644 --- a/Libraries/LibWeb/Layout/Node.cpp +++ b/Libraries/LibWeb/Layout/Node.cpp @@ -844,6 +844,8 @@ NodeWithStyle::NodeWithStyle(DOM::Document& document, GC::Ptr node, C publish_style_record_to_node_data(); synchronize_table_span_data(); enroll_for_arena_replaced_content_facts_sync_if_eligible(); + if (m_owned_computed_values) + pin_style_record_for_cxx_consumers(); } CSS::ComputedValues const& NodeWithStyle::owned_computed_values() const @@ -956,6 +958,11 @@ void NodeWithStyle::apply_style(CSS::StyleRecordID style_record_identity) enroll_for_arena_replaced_content_facts_sync_if_eligible(); propagate_style_to_anonymous_wrappers(); attach_style_resources(); + // A pseudo layout node can outlive replacement of the DOM pseudo's record until the layout + // tree is rebuilt. Root its record across that gap, including metadata-only style changes that + // keep the existing layout node. + if (is_generated_for_pseudo_element()) + pin_style_record_for_cxx_consumers(); } void NodeWithStyle::attach_style_resources() @@ -1287,6 +1294,8 @@ void NodeWithStyle::set_computed_values(NonnullRefPtr set_flag(RustFFI::NodeFlag::InsetsUseAnchorFunctions, computed_values->inset_properties_contain_anchor_functions()); publish_style_record_to_node_data(); enroll_for_arena_replaced_content_facts_sync_if_eligible(); + if (m_owned_computed_values) + pin_style_record_for_cxx_consumers(); if (changes_layout_affecting_style) bump_fragment_cache_epoch_of_self_and_ancestors(); @@ -1301,13 +1310,14 @@ void NodeWithStyle::set_style_record_identity(CSS::StyleRecordID style_record_id { // A detached or layout-derived record is independent of its DOM target's record. A // rendering consequence replaces and re-derives it explicitly through apply_style(). - if (m_style_record_owner || m_owned_computed_values) + if (m_owned_computed_values) return; if (m_style_record_identity == style_record_identity) { publish_style_record_to_node_data(); return; } + bool should_repin_style_record = m_style_record_owner; auto new_record_view = document().style_computer().computed_style_record_view(style_record_identity); VERIFY(new_record_view); bool changes_layout_affecting_style = false; @@ -1330,6 +1340,8 @@ void NodeWithStyle::set_style_record_identity(CSS::StyleRecordID style_record_id set_flag(RustFFI::NodeFlag::InsetsUseAnchorFunctions, new_record_view->inset_properties_contain_anchor_functions()); publish_style_record_to_node_data(); enroll_for_arena_replaced_content_facts_sync_if_eligible(); + if (should_repin_style_record) + pin_style_record_for_cxx_consumers(); if (changes_layout_affecting_style) bump_fragment_cache_epoch_of_self_and_ancestors(); @@ -1337,7 +1349,7 @@ void NodeWithStyle::set_style_record_identity(CSS::StyleRecordID style_record_id void NodeWithStyle::pin_style_record_for_cxx_consumers() { - if (m_owned_computed_values || m_style_record_owner) + if (m_style_record_owner) return; VERIFY(m_style_record_identity); diff --git a/Libraries/LibWeb/Layout/Node.h b/Libraries/LibWeb/Layout/Node.h index 6698ede2784c..dfba4158cefe 100644 --- a/Libraries/LibWeb/Layout/Node.h +++ b/Libraries/LibWeb/Layout/Node.h @@ -848,6 +848,10 @@ class WEB_API NodeWithStyle : public Node { CSS::ComputedValues const& owned_computed_values() const; RefPtr m_owned_computed_values; CSS::StyleRecordID m_style_record_identity; + // Layout nodes are ref-counted rather than GC cells, so this owner cannot be a traced GC::Ptr. + // Document::tear_down_layout_tree() must drop the layout root, which destroys these nodes and + // unpins their records before this root is cleared. Every document destruction path goes + // through that teardown. GC::Root m_style_record_owner; Vector> m_image_observers; Vector> m_cursor_style_values; diff --git a/Libraries/LibWeb/Layout/TreeBuilder.cpp b/Libraries/LibWeb/Layout/TreeBuilder.cpp index 291057482b61..4cf7de27f5fe 100644 --- a/Libraries/LibWeb/Layout/TreeBuilder.cpp +++ b/Libraries/LibWeb/Layout/TreeBuilder.cpp @@ -485,6 +485,7 @@ static RustFFI::FfiComputedContentType ffi_computed_content_type(CSS::ComputedCo struct PseudoElementFrame { CSS::StyleRecordID style_record_identity; + GC::Ptr style_record_owner; CSS::Display display; RefPtr replacement_image; BlockContainer* originating_list_box { nullptr }; @@ -528,6 +529,13 @@ RustFFI::FfiPseudoTreeBuilderCallbacks LayoutTreeBuildBridge::make_ffi_pseudo_tr auto& storage = *static_cast(builder_pointer)->m_pseudo_element_frames; VERIFY(storage.active_frame_count > 0); VERIFY(storage.frames[storage.active_frame_count - 1].ptr() == frame_pointer); + auto& frame = *storage.frames[storage.active_frame_count - 1]; + if (!!frame.style_record_identity) { + VERIFY(frame.style_record_owner); + frame.style_record_owner->unpin_style_record(frame.style_record_identity); + frame.style_record_identity = {}; + frame.style_record_owner = nullptr; + } --storage.active_frame_count; }, .initialize = [](void* frame_pointer, void* element_pointer, RustFFI::FfiPseudoElement ffi_pseudo) -> RustFFI::FfiPseudoElementFacts { VERIFY(frame_pointer); @@ -535,9 +543,15 @@ RustFFI::FfiPseudoTreeBuilderCallbacks LayoutTreeBuildBridge::make_ffi_pseudo_tr auto& frame = *static_cast(frame_pointer); auto& element = *static_cast(element_pointer); auto pseudo_element = css_pseudo_element(ffi_pseudo); + VERIFY(!frame.style_record_identity); + VERIFY(!frame.style_record_owner); if (auto existing_pseudo = element.get_synthetic_pseudo_element(pseudo_element); existing_pseudo.has_value() && existing_pseudo->layout_node()) existing_pseudo->set_layout_node(nullptr); frame.style_record_identity = element.style_record_identity(pseudo_element); + if (!!frame.style_record_identity) { + frame.style_record_owner = &element.document().style_computer(); + frame.style_record_owner->pin_style_record(frame.style_record_identity); + } frame.replacement_image = nullptr; frame.originating_list_box = nullptr; frame.layout_node = nullptr; @@ -798,6 +812,7 @@ struct PrincipalNodeFrame { RefPtr layout_node; RefPtr anonymous_computed_values; CSS::StyleRecordID style_record_identity; + GC::Ptr style_record_owner; }; struct LayoutTreeBuildBridge::PrincipalNodeFrameStorage { @@ -1016,6 +1031,7 @@ RustFFI::FfiDomTreeBuilderCallbacks LayoutTreeBuildBridge::make_ffi_dom_tree_bui frame.old_layout_node = node.unsafe_layout_node(); frame.layout_node = nullptr; frame.anonymous_computed_values = nullptr; + VERIFY(!frame.style_record_owner); frame.style_record_identity = 0; return { .frame = &frame, @@ -1030,6 +1046,11 @@ RustFFI::FfiDomTreeBuilderCallbacks LayoutTreeBuildBridge::make_ffi_dom_tree_bui VERIFY(storage.active_frame_count > 0); VERIFY(storage.frames[storage.active_frame_count - 1].ptr() == frame_pointer); auto& frame = *storage.frames[storage.active_frame_count - 1]; + if (!!frame.style_record_identity) { + VERIFY(frame.style_record_owner); + frame.style_record_owner->unpin_style_record(frame.style_record_identity); + frame.style_record_owner = nullptr; + } frame.old_layout_node = nullptr; frame.layout_node = nullptr; frame.anonymous_computed_values = nullptr; @@ -1053,6 +1074,9 @@ RustFFI::FfiDomTreeBuilderCallbacks LayoutTreeBuildBridge::make_ffi_dom_tree_bui update_style_if_needed_for_layout_tree_bypass_path(element); } frame.style_record_identity = element.style_record_identity(); + VERIFY(frame.style_record_identity); + frame.style_record_owner = &element.document().style_computer(); + frame.style_record_owner->pin_style_record(frame.style_record_identity); auto computed_values = element.computed_style(); VERIFY(computed_values); return { diff --git a/Libraries/LibWeb/Rust/src/bin/style_replay.rs b/Libraries/LibWeb/Rust/src/bin/style_replay.rs index 7d9d1ea1045e..2e6731d2c0fa 100644 --- a/Libraries/LibWeb/Rust/src/bin/style_replay.rs +++ b/Libraries/LibWeb/Rust/src/bin/style_replay.rs @@ -1663,8 +1663,9 @@ fn release_computed_longhand_tables( fn style_record_replay_index(style_record: u64) -> Result { const ANIMATION_OVERLAY_TAG: u64 = 1 << 63; + const BASE_IDENTITY_MASK: u64 = u32::MAX as u64; - let identity = style_record & !ANIMATION_OVERLAY_TAG; + let identity = style_record & BASE_IDENTITY_MASK; let namespace = u64::from(style_record & ANIMATION_OVERLAY_TAG != 0); usize::try_from(identity * 2 + namespace) } @@ -2472,6 +2473,18 @@ mod tests { assert_eq!(amplification["stages"]["selector"]["touched_rows"], 10); assert_eq!(amplification["stages"]["selector"]["amplification"], 2.5); } + + #[test] + fn style_record_replay_indices_ignore_base_generations() { + let identity = 17; + let first_generation = identity; + let later_generation = (29_u64 << 32) | identity; + let overlay = (1_u64 << 63) | identity; + + assert_eq!(style_record_replay_index(first_generation).unwrap(), 34); + assert_eq!(style_record_replay_index(later_generation).unwrap(), 34); + assert_eq!(style_record_replay_index(overlay).unwrap(), 35); + } } /// A read-only memory mapping of one capture file. diff --git a/Libraries/LibWeb/Rust/src/css/style/computed.rs b/Libraries/LibWeb/Rust/src/css/style/computed.rs index 5635c293af01..590ee39bd318 100644 --- a/Libraries/LibWeb/Rust/src/css/style/computed.rs +++ b/Libraries/LibWeb/Rust/src/css/style/computed.rs @@ -23,6 +23,7 @@ use std::num::NonZeroU32; use super::capacity::capacity_bytes; use super::cascade::CascadeStateID; +use super::column::BitColumn; use super::fast_hash::FastMap as HashMap; use super::fast_hash::fast_hasher; use super::intern_table::InternIdentity; @@ -134,6 +135,7 @@ struct ComputedFixedMetadata { counter_style_environment_identity: u64, } +#[repr(C)] pub(crate) struct InheritanceDependentValue { pub property: u16, pub value: RetainedStyleValueData, @@ -143,10 +145,26 @@ struct ComputedReconstructionMetadata { property_importance: Box<[u8]>, property_inheritance: Box<[u8]>, inheritance_dependent_values: Box<[InheritanceDependentValue]>, - inheritance_dependent_value_view: Box<[super::bridge::FfiInheritanceDependentValue]>, raw_cascaded_font_size: Option, } +impl ComputedReconstructionMetadata { + fn inheritance_dependent_value_view(&self) -> &[super::bridge::FfiInheritanceDependentValue] { + const { + assert!(size_of::() == size_of::()); + assert!( + align_of::() == align_of::() + ); + } + unsafe { + std::slice::from_raw_parts( + self.inheritance_dependent_values.as_ptr().cast(), + self.inheritance_dependent_values.len(), + ) + } + } +} + /// The interned form of one drive's computed longhand table: one retained /// data pointer per longhand (null where the drive stored no value), which is /// also the borrowed span the record view hands out. Provenance (the @@ -271,9 +289,14 @@ pub struct FinalStyleRecordID(u64); impl FinalStyleRecordID { const ANIMATION_OVERLAY_TAG: u64 = 1 << 63; + const MAX_BASE_GENERATION: u32 = (1 << 31) - 1; - fn base(style_record: StyleRecordID) -> Self { - Self(style_record.raw().into()) + fn base(style_record: StyleRecordID, generation: u32) -> Self { + assert!( + generation <= Self::MAX_BASE_GENERATION, + "base style-record generation space exhausted" + ); + Self((u64::from(generation) << 32) | u64::from(style_record.raw())) } fn animation_overlay(generation: u64) -> Self { @@ -290,8 +313,14 @@ impl FinalStyleRecordID { if self.0 & Self::ANIMATION_OVERLAY_TAG != 0 { return None; } - let raw = u32::try_from(self.0).ok()?; - Some(StyleRecordID(NonZeroU32::new(raw)?)) + if self.0 as u32 == 0 { + return None; + } + Some(StyleRecordID(NonZeroU32::new(self.0 as u32)?)) + } + + fn base_generation(self) -> u32 { + (self.0 >> 32) as u32 } } @@ -321,7 +350,7 @@ struct ComputedGroup { } struct ComputedGroupSet { - identities: Box<[ComputedGroupID]>, + identity_hash: u64, payloads: Box<[*const c_void]>, canonical_longhand_table: Option, } @@ -478,6 +507,29 @@ impl PublishedComputedColumns { } } +struct ComputedReachability { + groups: Vec, + sets: Vec, + inherited_sets: Vec, + custom_property_environments: Vec, + fixed_metadata: Vec, + reconstruction_metadata: Vec, + longhand_tables: Vec, + style_records: Vec, +} + +impl ComputedReachability { + fn mark(marks: &mut [bool], identity: Identity) { + marks[identity.index()] = true; + } +} + +#[derive(Clone, Copy)] +pub(super) struct ComputedGroupRetention { + pub retained: usize, + pub reachable: usize, +} + impl PseudoComputedRow { const PUBLISHED: u8 = 1; const CURRENT_CASCADE: usize = 0; @@ -602,7 +654,10 @@ pub struct ComputedGroupSets { computed_reconstruction_metadata: InternTable, computed_longhand_tables: InternTable, style_records: InternTable, + style_record_liveness: BitColumn, + style_record_generations: Vec, style_record_column: Vec>, + base_style_record_pins: HashMap, columns: PublishedComputedColumns, // Recyclable animation overlays are deliberately separate from the permanent base records // above. Dense element assignments and sparse pseudo assignments pin at most one slot each. @@ -617,6 +672,8 @@ pub struct ComputedGroupSets { reconstruction_nested_memory: MemoryLease, animation_overlay_nested_memory: MemoryLease, pseudo_assignment_nested_memory: MemoryLease, + style_records_interned_since_reclamation: usize, + next_reclamation_after: usize, } impl Default for ComputedGroupSets { @@ -630,7 +687,10 @@ impl Default for ComputedGroupSets { computed_reconstruction_metadata: InternTable::default(), computed_longhand_tables: InternTable::default(), style_records: InternTable::default(), + style_record_liveness: BitColumn::default(), + style_record_generations: Vec::new(), style_record_column: Vec::new(), + base_style_record_pins: HashMap::default(), columns: PublishedComputedColumns::default(), animation_overlay_slots: Vec::new(), animation_overlay_slots_by_record: HashMap::default(), @@ -643,11 +703,32 @@ impl Default for ComputedGroupSets { reconstruction_nested_memory: MemoryLease::new(MemoryCategory::ComputedReconstructionMetadata), animation_overlay_nested_memory: MemoryLease::new(MemoryCategory::AnimationOverlayRecord), pseudo_assignment_nested_memory: MemoryLease::new(MemoryCategory::ComputedPseudoAssignment), + style_records_interned_since_reclamation: 0, + next_reclamation_after: 1024, } } } impl ComputedGroupSets { + fn group_identity(&self, index: usize, payload: *const c_void) -> ComputedGroupID { + let key = (index, payload as usize); + self.groups + .find(content_hash(key), |_identity, group| { + (group.index, group.payload as usize) == key + }) + .expect("computed group-set payload names a live group") + } + + fn group_identities(&self, set: ComputedGroupSetID) -> Vec { + self.sets[set] + .payloads + .iter() + .copied() + .enumerate() + .map(|(index, payload)| self.group_identity(index, payload)) + .collect() + } + fn pseudo_rows(&self, node: StyleNodeID) -> &[PseudoComputedRow] { self.pseudo_rows_by_node.get(&node).map_or(&[], Box::as_ref) } @@ -712,26 +793,33 @@ impl ComputedGroupSets { Some(self.final_style_record(style_record, self.columns.animation_overlay_slot(index))) } - fn intern_group_set(&mut self, groups: Vec) -> (ComputedGroupSetID, bool) { - let hash = content_hash(&groups); - if let Some(identity) = self.sets.find(hash, |_identity, set| set.identities.as_ref() == groups) { + fn intern_group_set(&mut self, groups: &[ComputedGroupID]) -> (ComputedGroupSetID, bool) { + let hash = content_hash(groups); + if let Some(identity) = self.sets.find(hash, |_identity, set| { + set.payloads.len() == groups.len() + && set + .payloads + .iter() + .zip(groups) + .all(|(&payload, &identity)| payload == self.groups[identity].payload) + }) { return (identity, false); } - let identity = - ComputedGroupSetID(u32::try_from(self.sets.len()).expect("computed group-set identity space exhausted")); + let identity = self.sets.take_free_identity().unwrap_or_else(|| { + ComputedGroupSetID(u32::try_from(self.sets.len()).expect("computed group-set identity space exhausted")) + }); let payloads = groups .iter() .map(|identity| self.groups[*identity].payload) .collect::>() .into_boxed_slice(); - let identities = groups.into_boxed_slice(); self.group_set_nested_memory - .grow_committed((size_of_val(identities.as_ref()) + size_of_val(payloads.as_ref())) as u64); + .grow_committed(size_of_val(payloads.as_ref()) as u64); self.sets.insert( hash, identity, ComputedGroupSet { - identities, + identity_hash: hash, payloads, canonical_longhand_table: None, }, @@ -739,6 +827,26 @@ impl ComputedGroupSets { (identity, true) } + fn intern_inherited_group_set(&mut self, groups: &[ComputedGroupID]) -> (InheritedGroupSetID, bool) { + let hash = content_hash(groups); + if let Some(identity) = self + .inherited_sets + .find(hash, |_identity, candidate| candidate.as_ref() == groups) + { + return (identity, false); + } + let identity = self.inherited_sets.take_free_identity().unwrap_or_else(|| { + InheritedGroupSetID( + u32::try_from(self.inherited_sets.len()).expect("inherited group-set identity space exhausted"), + ) + }); + let groups: Box<[ComputedGroupID]> = groups.into(); + self.group_set_nested_memory + .grow_committed(size_of_val(groups.as_ref()) as u64); + self.inherited_sets.insert(hash, identity, groups); + (identity, true) + } + fn intern_style_record(&mut self, record: StyleRecord) -> (StyleRecordID, bool) { let hash = content_hash(record); if let Some(identity) = self @@ -747,18 +855,47 @@ impl ComputedGroupSets { { return (identity, false); } - let raw = u32::try_from( - self.style_records - .len() + let identity = self.style_records.take_free_identity().unwrap_or_else(|| { + let raw = u32::try_from( + self.style_records + .len() + .checked_add(1) + .expect("base style-record identity space exhausted"), + ) + .expect("base style-record identity space exhausted"); + StyleRecordID(NonZeroU32::new(raw).expect("base style-record identities are nonzero")) + }); + if identity.index() == self.style_record_generations.len() { + self.style_record_generations.push(0); + } else { + let generation = &mut self.style_record_generations[identity.index()]; + *generation = generation .checked_add(1) - .expect("base style-record identity space exhausted"), - ) - .expect("base style-record identity space exhausted"); - let identity = StyleRecordID(NonZeroU32::new(raw).expect("base style-record identities are nonzero")); + .filter(|&generation| generation <= FinalStyleRecordID::MAX_BASE_GENERATION) + .expect("base style-record generation space exhausted"); + } self.style_records.insert(hash, identity, record); + let (changed, _) = self.style_record_liveness.set(identity.index(), true); + assert!(changed, "new base style-record identity must not already be live"); + self.style_records_interned_since_reclamation = self + .style_records_interned_since_reclamation + .checked_add(1) + .expect("style-record reclamation growth count overflow"); (identity, true) } + fn style_record_is_live(&self, identity: StyleRecordID) -> bool { + self.style_record_liveness.contains(identity.index()) + } + + fn style_record_generation_is_live(&self, identity: StyleRecordID, generation: u32) -> bool { + self.style_record_is_live(identity) && self.style_record_generations.get(identity.index()) == Some(&generation) + } + + fn final_base_style_record(&self, identity: StyleRecordID) -> FinalStyleRecordID { + FinalStyleRecordID::base(identity, self.style_record_generations[identity.index()]) + } + /// Interns the values of one drive's frozen computed longhand table, so /// equal value tuples share one identity and one retained copy. A /// value-equal previous table keeps its identity even when the fresh @@ -785,10 +922,12 @@ impl ComputedGroupSets { }) { return identity; } - let identity = ComputedLonghandTableID( - u32::try_from(self.computed_longhand_tables.len()) - .expect("computed longhand-table identity space exhausted"), - ); + let identity = self.computed_longhand_tables.take_free_identity().unwrap_or_else(|| { + ComputedLonghandTableID( + u32::try_from(self.computed_longhand_tables.len()) + .expect("computed longhand-table identity space exhausted"), + ) + }); let retained = unsafe { crate::css::computed_longhand_table::rust_computed_longhand_table_retain(table) }; self.reconstruction_nested_memory .grow_committed(size_of_val(values) as u64); @@ -820,10 +959,12 @@ impl ComputedGroupSets { }) { return identity; } - let identity = ComputedLonghandTableID( - u32::try_from(self.computed_longhand_tables.len()) - .expect("computed longhand-table identity space exhausted"), - ); + let identity = self.computed_longhand_tables.take_free_identity().unwrap_or_else(|| { + ComputedLonghandTableID( + u32::try_from(self.computed_longhand_tables.len()) + .expect("computed longhand-table identity space exhausted"), + ) + }); let value_view: Box<[*const c_void]> = values .iter() .map(|&value| match value.is_null() { @@ -867,17 +1008,17 @@ impl ComputedGroupSets { } let old_style_record = *self.style_record_column.get(index)?.as_ref()?; - let old_record = *self.style_records.get_index(old_style_record.raw() as usize - 1)?; + let old_record = *self.style_records.get_index(old_style_record.index())?; let old_group_set = self.sets.get_index(old_record.groups.0 as usize)?; let parent_inherited = self.columns.inherited_groups(parent_index)?; let parent_groups = self.inherited_sets.get_index(parent_inherited.0 as usize)?; - if parent_groups.len() != INHERITED_GROUP_COUNT || old_group_set.identities.len() < INHERITED_GROUP_COUNT { + if parent_groups.len() != INHERITED_GROUP_COUNT || old_group_set.payloads.len() < INHERITED_GROUP_COUNT { return None; } - let mut groups = old_group_set.identities.to_vec(); + let mut groups = self.group_identities(old_record.groups); groups[..INHERITED_GROUP_COUNT].copy_from_slice(parent_groups); - let group_set = self.intern_group_set(groups).0; + let group_set = self.intern_group_set(&groups).0; // The swap is only taken for a fully inheriting element, so every // inherited-by-default longhand's value is the parent's; the swapped // record's table is the old one with those slots replaced by the @@ -886,7 +1027,7 @@ impl ComputedGroupSets { // in practice) publish without one. let parent_style_record = self.style_record_column.get(parent_index).copied().flatten(); let parent_table = parent_style_record - .and_then(|record| self.style_records.get_index(record.raw() as usize - 1)) + .and_then(|record| self.style_records.get_index(record.index())) .and_then(|record| record.longhand_table); let longhand_table = match (old_record.longhand_table, parent_table) { (Some(old_table), Some(parent_table)) => { @@ -914,8 +1055,8 @@ impl ComputedGroupSets { self.columns.inherited_groups[index] = parent_inherited.0; self.style_record_column[index] = Some(new_style_record); Some(( - FinalStyleRecordID::base(old_style_record), - FinalStyleRecordID::base(new_style_record), + self.final_base_style_record(old_style_record), + self.final_base_style_record(new_style_record), )) } @@ -1022,7 +1163,7 @@ impl ComputedGroupSets { } return AnimationOverlayPublication { slot: None, - final_style_record: FinalStyleRecordID::base(base_style_record), + final_style_record: self.final_base_style_record(base_style_record), slot_allocated: false, slot_released: current_slot.is_some(), record_updated: false, @@ -1096,7 +1237,7 @@ impl ComputedGroupSets { animation_overlay_slot: Option, ) -> FinalStyleRecordID { animation_overlay_slot.map_or_else( - || FinalStyleRecordID::base(base_style_record), + || self.final_base_style_record(base_style_record), |slot| { self.animation_overlay_slots[slot as usize] .as_ref() @@ -1175,7 +1316,9 @@ impl ComputedGroupSets { continue; } let key = (index, payload as usize); - let previous_identity = previous_group_set.and_then(|set| self.sets[set].identities.get(index).copied()); + let previous_identity = previous_group_set + .and_then(|set| self.sets[set].payloads.get(index).copied()) + .map(|payload| self.group_identity(index, payload)); let previous_equal_identity = previous_identity .filter(|identity| style_group_payloads_equal(index, payload, self.groups[*identity].payload)); let identity = match previous_equal_identity { @@ -1191,9 +1334,11 @@ impl ComputedGroupSets { Some(identity) => identity, None => { retain_group_payload(index, payload); - let identity = ComputedGroupID( - u32::try_from(self.groups.len()).expect("computed group identity space exhausted"), - ); + let identity = self.groups.take_free_identity().unwrap_or_else(|| { + ComputedGroupID( + u32::try_from(self.groups.len()).expect("computed group identity space exhausted"), + ) + }); self.groups .insert(content_hash(key), identity, ComputedGroup { index, payload }); self.group_set_nested_memory @@ -1206,26 +1351,10 @@ impl ComputedGroupSets { groups.push(identity); } - let (identity, new_group_set) = self.intern_group_set(groups); + let (identity, new_group_set) = self.intern_group_set(&groups); - let inherited_groups = &self.sets[identity].identities[..inherited_group_count]; - let inherited_hash = content_hash(inherited_groups); - let existing_inherited = self - .inherited_sets - .find(inherited_hash, |_identity, groups| groups.as_ref() == inherited_groups); - let (inherited_identity, new_inherited_group_set) = match existing_inherited { - Some(identity) => (identity, false), - None => { - let identity = InheritedGroupSetID( - u32::try_from(self.inherited_sets.len()).expect("inherited group-set identity space exhausted"), - ); - let inherited_groups: Box<[ComputedGroupID]> = inherited_groups.into(); - self.group_set_nested_memory - .grow_committed(size_of_val(inherited_groups.as_ref()) as u64); - self.inherited_sets.insert(inherited_hash, identity, inherited_groups); - (identity, true) - } - }; + let (inherited_identity, new_inherited_group_set) = + self.intern_inherited_group_set(&groups[..inherited_group_count]); let custom_property_environment_hash = content_hash(custom_property_environment); let (custom_property_environment_identity, new_custom_property_environment) = match self @@ -1235,10 +1364,15 @@ impl ComputedGroupSets { }) { Some(identity) => (identity, false), None => { - let identity = CustomPropertyEnvironmentID( - u32::try_from(self.custom_property_environments.len()) - .expect("custom-property environment identity space exhausted"), - ); + let identity = self + .custom_property_environments + .take_free_identity() + .unwrap_or_else(|| { + CustomPropertyEnvironmentID( + u32::try_from(self.custom_property_environments.len()) + .expect("custom-property environment identity space exhausted"), + ) + }); self.custom_property_environments.insert( custom_property_environment_hash, identity, @@ -1259,10 +1393,12 @@ impl ComputedGroupSets { { Some(identity) => (identity, false), None => { - let identity = ComputedFixedMetadataID( - u32::try_from(self.computed_fixed_metadata.len()) - .expect("computed fixed-metadata identity space exhausted"), - ); + let identity = self.computed_fixed_metadata.take_free_identity().unwrap_or_else(|| { + ComputedFixedMetadataID( + u32::try_from(self.computed_fixed_metadata.len()) + .expect("computed fixed-metadata identity space exhausted"), + ) + }); self.computed_fixed_metadata .insert(content_hash(metadata), identity, metadata); (identity, true) @@ -1286,9 +1422,7 @@ impl ComputedGroupSets { .copied() .flatten() }?; - self.style_records - .get_index(style_record.raw() as usize - 1)? - .longhand_table + self.style_records.get_index(style_record.index())?.longhand_table }); let canonical_longhand_table = self.sets[identity].canonical_longhand_table; let longhand_table_identity = longhand_table @@ -1323,10 +1457,15 @@ impl ComputedGroupSets { match existing_reconstruction { Some(identity) => (identity, false), None => { - let identity = ComputedReconstructionMetadataID( - u32::try_from(self.computed_reconstruction_metadata.len()) - .expect("computed reconstruction-metadata identity space exhausted"), - ); + let identity = self + .computed_reconstruction_metadata + .take_free_identity() + .unwrap_or_else(|| { + ComputedReconstructionMetadataID( + u32::try_from(self.computed_reconstruction_metadata.len()) + .expect("computed reconstruction-metadata identity space exhausted"), + ) + }); let inheritance_dependent_values: Box<[InheritanceDependentValue]> = inheritance_dependent_values .into_iter() .map(|(property, value)| InheritanceDependentValue { @@ -1334,28 +1473,18 @@ impl ComputedGroupSets { value: retain_style_value(value), }) .collect(); - let inheritance_dependent_value_view: Box<[super::bridge::FfiInheritanceDependentValue]> = - inheritance_dependent_values - .iter() - .map(|entry| super::bridge::FfiInheritanceDependentValue { - property: entry.property, - value: entry.value.pointer().cast(), - }) - .collect(); let raw_cascaded_font_size = (!reconstruction_metadata.raw_cascaded_font_size.is_null()) .then(|| retain_style_value(reconstruction_metadata.raw_cascaded_font_size)); let metadata = ComputedReconstructionMetadata { property_importance: reconstruction_metadata.property_importance.into(), property_inheritance: reconstruction_metadata.property_inheritance.into(), inheritance_dependent_values, - inheritance_dependent_value_view, raw_cascaded_font_size, }; self.reconstruction_nested_memory.grow_committed( (metadata.property_importance.len() + metadata.property_inheritance.len() - + size_of_val(metadata.inheritance_dependent_values.as_ref()) - + size_of_val(metadata.inheritance_dependent_value_view.as_ref())) + + size_of_val(metadata.inheritance_dependent_values.as_ref())) as u64, ); self.computed_reconstruction_metadata @@ -1468,7 +1597,7 @@ impl ComputedGroupSets { None, AnimationOverlayPublication { slot: None, - final_style_record: FinalStyleRecordID::base(style_record_identity), + final_style_record: self.final_base_style_record(style_record_identity), slot_allocated: false, slot_released: false, record_updated: false, @@ -1512,6 +1641,10 @@ impl ComputedGroupSets { ) -> Option { let final_style_record = FinalStyleRecordID(raw_style_record); let requested_style_record_identity = final_style_record.base_record()?; + assert!( + self.style_record_generation_is_live(requested_style_record_identity, final_style_record.base_generation()), + "base style-record is not live" + ); let previous_base_style_record_identity = if target.is_pseudo() { self.pseudo_row(target.node, target.pseudo_kind) .and_then(|row| row.assignment) @@ -1531,13 +1664,10 @@ impl ComputedGroupSets { let style_record_identity = previous_base_style_record_identity .filter(|&previous| self.style_records_equal_by_value(previous, requested_style_record_identity)) .unwrap_or(requested_style_record_identity); - let record = *self.style_records.get_index(style_record_identity.raw() as usize - 1)?; - let inherited_groups = &self.sets[record.groups].identities[..inherited_group_count]; - let inherited_identity = self - .inherited_sets - .find(content_hash(inherited_groups), |_identity, groups| { - groups.as_ref() == inherited_groups - })?; + let record = *self.style_records.get_index(style_record_identity.index())?; + let group_identities = self.group_identities(record.groups); + let inherited_groups = &group_identities[..inherited_group_count]; + let (inherited_identity, new_inherited_group_set) = self.intern_inherited_group_set(inherited_groups); let is_pseudo = target.is_pseudo(); let ( @@ -1633,7 +1763,7 @@ impl ComputedGroupSets { new_groups: 0, canonical_output_groups_reused: 0, new_group_set: false, - new_inherited_group_set: false, + new_inherited_group_set, new_custom_property_environment: false, new_computed_fixed_metadata: false, new_computed_reconstruction_metadata: false, @@ -1657,10 +1787,10 @@ impl ComputedGroupSets { if first == second { return true; } - let Some(first) = self.style_records.get_index(first.raw() as usize - 1) else { + let Some(first) = self.style_records.get_index(first.index()) else { return false; }; - let Some(second) = self.style_records.get_index(second.raw() as usize - 1) else { + let Some(second) = self.style_records.get_index(second.index()) else { return false; }; if first.custom_properties != second.custom_properties @@ -1669,17 +1799,14 @@ impl ComputedGroupSets { { return false; } - let first_groups = &self.sets[first.groups].identities; - let second_groups = &self.sets[second.groups].identities; + let first_groups = &self.sets[first.groups].payloads; + let second_groups = &self.sets[second.groups].payloads; if first_groups.len() != second_groups.len() || first_groups .iter() .zip(second_groups) .enumerate() - .any(|(index, (&first, &second))| { - first != second - && !style_group_payloads_equal(index, self.groups[first].payload, self.groups[second].payload) - }) + .any(|(index, (&first, &second))| first != second && !style_group_payloads_equal(index, first, second)) { return false; } @@ -2006,7 +2133,10 @@ impl ComputedGroupSets { capacity_bytes! { shallow [ self.style_records, + self.style_record_liveness, + self.style_record_generations, self.style_record_column, + self.base_style_record_pins, self.columns.cascade_versions, self.columns.cascade_states, self.columns.flags, @@ -2038,16 +2168,272 @@ impl ComputedGroupSets { self.live_animation_overlay_assignments } + fn reachability(&self) -> ComputedReachability { + let mut reachable = ComputedReachability { + groups: vec![false; self.groups.len()], + sets: vec![false; self.sets.len()], + inherited_sets: vec![false; self.inherited_sets.len()], + custom_property_environments: vec![false; self.custom_property_environments.len()], + fixed_metadata: vec![false; self.computed_fixed_metadata.len()], + reconstruction_metadata: vec![false; self.computed_reconstruction_metadata.len()], + longhand_tables: vec![false; self.computed_longhand_tables.len()], + style_records: vec![false; self.style_records.len()], + }; + + { + let mut mark_style_record = |identity: StyleRecordID| { + ComputedReachability::mark(&mut reachable.style_records, identity); + let record = self.style_records.get(identity); + ComputedReachability::mark(&mut reachable.sets, record.groups); + ComputedReachability::mark(&mut reachable.custom_property_environments, record.custom_properties); + ComputedReachability::mark(&mut reachable.fixed_metadata, record.fixed_metadata); + ComputedReachability::mark(&mut reachable.reconstruction_metadata, record.reconstruction_metadata); + if let Some(longhand_table) = record.longhand_table { + ComputedReachability::mark(&mut reachable.longhand_tables, longhand_table); + } + }; + for &identity in self.style_record_column.iter().flatten() { + mark_style_record(identity); + } + for rows in self.pseudo_rows_by_node.values() { + for assignment in rows.iter().filter_map(|row| row.assignment) { + mark_style_record(assignment.style_record); + } + } + for overlay in self.animation_overlay_slots.iter().flatten() { + mark_style_record(overlay.base_style_record); + } + for &identity in self.base_style_record_pins.keys() { + mark_style_record(identity); + } + } + + for index in 0..self.columns.flags.len() { + if !self.columns.is_assigned(index) { + continue; + } + ComputedReachability::mark(&mut reachable.sets, self.columns.groups(index).unwrap()); + ComputedReachability::mark( + &mut reachable.inherited_sets, + self.columns.inherited_groups(index).unwrap(), + ); + ComputedReachability::mark( + &mut reachable.custom_property_environments, + self.columns.custom_properties(index).unwrap(), + ); + ComputedReachability::mark( + &mut reachable.fixed_metadata, + self.columns.fixed_metadata(index).unwrap(), + ); + ComputedReachability::mark( + &mut reachable.reconstruction_metadata, + self.columns.reconstruction_metadata(index).unwrap(), + ); + } + for rows in self.pseudo_rows_by_node.values() { + for assignment in rows.iter().filter_map(|row| row.assignment) { + ComputedReachability::mark(&mut reachable.sets, assignment.groups); + ComputedReachability::mark(&mut reachable.inherited_sets, assignment.inherited_groups); + ComputedReachability::mark( + &mut reachable.custom_property_environments, + assignment.custom_properties, + ); + ComputedReachability::mark(&mut reachable.fixed_metadata, assignment.fixed_metadata); + ComputedReachability::mark( + &mut reachable.reconstruction_metadata, + assignment.reconstruction_metadata, + ); + } + } + for (index, is_reachable) in reachable.sets.iter().copied().enumerate() { + if !is_reachable { + continue; + } + for (group_index, &payload) in self.sets[index].payloads.iter().enumerate() { + ComputedReachability::mark(&mut reachable.groups, self.group_identity(group_index, payload)); + } + } + for (index, is_reachable) in reachable.inherited_sets.iter().copied().enumerate() { + if !is_reachable { + continue; + } + for &group in &self.inherited_sets[index] { + ComputedReachability::mark(&mut reachable.groups, group); + } + } + reachable + } + + pub(super) fn reclaim_unreachable(&mut self) -> ComputedGroupRetention { + let reachable = self.reachability(); + let retention = ComputedGroupRetention { + retained: self.groups.live_identities().count(), + reachable: reachable.groups.iter().filter(|&&reachable| reachable).count(), + }; + if replaying_style_groups() { + return retention; + } + + let mut unreachable_style_records = self + .style_records + .live_identities() + .filter(|identity| !reachable.style_records[identity.index()]) + .collect::>(); + unreachable_style_records.sort_unstable_by_key(|identity| std::cmp::Reverse(identity.index())); + for identity in unreachable_style_records { + let record = *self.style_records.get(identity); + if self.style_record_generations[identity.index()] == FinalStyleRecordID::MAX_BASE_GENERATION { + self.style_records.remove_identity(content_hash(record), identity); + } else { + self.style_records.retire_identity(content_hash(record), identity); + } + let (changed, _) = self.style_record_liveness.set(identity.index(), false); + assert!(changed, "retired base style-record identity must be live"); + } + for identity in self.sets.live_identities().collect::>() { + if reachable.sets[identity.index()] { + if let Some(longhand_table) = self.sets[identity].canonical_longhand_table + && !reachable.longhand_tables[longhand_table.index()] + { + self.sets[identity].canonical_longhand_table = None; + } + continue; + } + let set = std::mem::replace( + self.sets.get_mut(identity), + ComputedGroupSet { + identity_hash: 0, + payloads: Box::default(), + canonical_longhand_table: None, + }, + ); + self.group_set_nested_memory + .shrink_committed(size_of_val(set.payloads.as_ref()) as u64); + self.sets.retire_identity(set.identity_hash, identity); + } + for identity in self.inherited_sets.live_identities().collect::>() { + if reachable.inherited_sets[identity.index()] { + continue; + } + let groups = std::mem::take(self.inherited_sets.get_mut(identity)); + self.group_set_nested_memory + .shrink_committed(size_of_val(groups.as_ref()) as u64); + self.inherited_sets.retire_identity(content_hash(&groups), identity); + } + for identity in self.custom_property_environments.live_identities().collect::>() { + if reachable.custom_property_environments[identity.index()] { + continue; + } + let environment = *self.custom_property_environments.get(identity); + self.custom_property_environments + .retire_identity(content_hash(environment), identity); + } + for identity in self.computed_fixed_metadata.live_identities().collect::>() { + if reachable.fixed_metadata[identity.index()] { + continue; + } + let metadata = *self.computed_fixed_metadata.get(identity); + self.computed_fixed_metadata + .retire_identity(content_hash(metadata), identity); + } + for identity in self + .computed_reconstruction_metadata + .live_identities() + .collect::>() + { + if reachable.reconstruction_metadata[identity.index()] { + continue; + } + let metadata = self.computed_reconstruction_metadata.get(identity); + let inheritance_dependent_values = metadata + .inheritance_dependent_values + .iter() + .map(|entry| (entry.property, entry.value.pointer().cast())) + .collect::>(); + let hash = reconstruction_metadata_hash( + &metadata.property_importance, + &metadata.property_inheritance, + &inheritance_dependent_values, + metadata + .raw_cascaded_font_size + .as_ref() + .map_or(std::ptr::null(), |value| value.pointer().cast()), + ); + let metadata = std::mem::replace( + self.computed_reconstruction_metadata.get_mut(identity), + ComputedReconstructionMetadata { + property_importance: Box::default(), + property_inheritance: Box::default(), + inheritance_dependent_values: Box::default(), + raw_cascaded_font_size: None, + }, + ); + self.reconstruction_nested_memory.shrink_committed( + (metadata.property_importance.len() + + metadata.property_inheritance.len() + + size_of_val(metadata.inheritance_dependent_values.as_ref())) as u64, + ); + self.computed_reconstruction_metadata.retire_identity(hash, identity); + } + for identity in self.computed_longhand_tables.live_identities().collect::>() { + if reachable.longhand_tables[identity.index()] { + continue; + } + let hash = longhand_table_hash(self.computed_longhand_tables[identity].value_view()); + let table = std::mem::replace( + self.computed_longhand_tables.get_mut(identity), + RetainedLonghandTable { + storage: RetainedLonghandTableStorage::Values(Box::default()), + }, + ); + self.reconstruction_nested_memory + .shrink_committed(size_of_val(table.value_view()) as u64); + self.computed_longhand_tables.retire_identity(hash, identity); + } + for identity in self.groups.live_identities().collect::>() { + if reachable.groups[identity.index()] { + continue; + } + let group = std::mem::replace( + self.groups.get_mut(identity), + ComputedGroup { + index: usize::MAX, + payload: std::ptr::null(), + }, + ); + self.groups + .retire_identity(content_hash((group.index, group.payload as usize)), identity); + self.group_set_nested_memory + .shrink_committed(retained_group_payload_bytes(group.index, group.payload) as u64); + release_group_payload(group.index, group.payload); + } + retention + } + + pub(super) fn reclaim_unreachable_if_needed(&mut self) -> Option { + if self.style_records_interned_since_reclamation < self.next_reclamation_after { + return None; + } + self.style_records_interned_since_reclamation = 0; + let retention = self.reclaim_unreachable(); + self.next_reclamation_after = self.style_records.live_identities().count().max(1024); + Some(retention) + } + pub fn style_record_payloads(&self, raw_style_record: u64) -> Option<&[*const c_void]> { + let final_style_record = FinalStyleRecordID(raw_style_record); if raw_style_record & FinalStyleRecordID::ANIMATION_OVERLAY_TAG != 0 { - let style_record = FinalStyleRecordID(raw_style_record); + let style_record = final_style_record; let slot = *self.animation_overlay_slots_by_record.get(&style_record)?; let record = self.animation_overlay_slots[slot as usize].as_ref()?; return (!record.payloads.is_empty()).then_some(record.payloads.as_ref()); } - let raw_style_record = u32::try_from(raw_style_record).ok()?; - let record_index = raw_style_record.checked_sub(1)? as usize; - let record = self.style_records.get_index(record_index)?; + let style_record = final_style_record.base_record()?; + assert!( + self.style_record_generation_is_live(style_record, final_style_record.base_generation()), + "base style-record is not live" + ); + let record = self.style_records.get_index(style_record.index())?; Some(&self.sets[record.groups].payloads) } @@ -2055,17 +2441,26 @@ impl ComputedGroupSets { pub(crate) fn recording_group_identities(&self, raw_style_record: u64) -> Option> { let final_style_record = FinalStyleRecordID(raw_style_record); let base_style_record = match final_style_record.base_record() { - Some(style_record) => style_record, + Some(style_record) => { + assert!( + self.style_record_generation_is_live(style_record, final_style_record.base_generation()), + "base style-record is not live" + ); + style_record + } None => { let slot = *self.animation_overlay_slots_by_record.get(&final_style_record)?; self.animation_overlay_slots[slot as usize].as_ref()?.base_style_record } }; - let record = self.style_records.get_index(base_style_record.raw() as usize - 1)?; + assert!( + self.style_record_is_live(base_style_record), + "base style-record is not live" + ); + let record = self.style_records.get_index(base_style_record.index())?; Some( - self.sets[record.groups] - .identities - .iter() + self.group_identities(record.groups) + .into_iter() .map(|identity| identity.0) .collect(), ) @@ -2089,15 +2484,25 @@ impl ComputedGroupSets { pub(crate) fn recording_longhand_table(&self, raw_style_record: u64) -> Option<(u32, &[*const c_void])> { let final_style_record = FinalStyleRecordID(raw_style_record); let base_style_record = match final_style_record.base_record() { - Some(style_record) => style_record, + Some(style_record) => { + assert!( + self.style_record_generation_is_live(style_record, final_style_record.base_generation()), + "base style-record is not live" + ); + style_record + } None => { let slot = *self.animation_overlay_slots_by_record.get(&final_style_record)?; self.animation_overlay_slots[slot as usize].as_ref()?.base_style_record } }; + assert!( + self.style_record_is_live(base_style_record), + "base style-record is not live" + ); let identity = self .style_records - .get_index(base_style_record.raw() as usize - 1)? + .get_index(base_style_record.index())? .longhand_table?; Some(( identity.0, @@ -2111,7 +2516,11 @@ impl ComputedGroupSets { let final_style_record = FinalStyleRecordID(raw_style_record); let (base_style_record, payloads, animation_overlay_identity, animated_properties) = if let Some(style_record) = final_style_record.base_record() { - let record = self.style_records.get_index(style_record.raw() as usize - 1)?; + assert!( + self.style_record_generation_is_live(style_record, final_style_record.base_generation()), + "base style-record is not live" + ); + let record = self.style_records.get_index(style_record.index())?; ( style_record, self.sets[record.groups].payloads.as_ref(), @@ -2128,7 +2537,11 @@ impl ComputedGroupSets { overlay.animated_properties.pointer(), ) }; - let record = self.style_records.get_index(base_style_record.raw() as usize - 1)?; + assert!( + self.style_record_is_live(base_style_record), + "base style-record is not live" + ); + let record = self.style_records.get_index(base_style_record.index())?; let base_payloads = self.sets[record.groups].payloads.as_ref(); let fixed_metadata = self .computed_fixed_metadata @@ -2145,7 +2558,7 @@ impl ComputedGroupSets { base_payloads, property_importance: &reconstruction_metadata.property_importance, property_inheritance: &reconstruction_metadata.property_inheritance, - inheritance_dependent_values: &reconstruction_metadata.inheritance_dependent_value_view, + inheritance_dependent_values: reconstruction_metadata.inheritance_dependent_value_view(), longhand_values, raw_cascaded_font_size: reconstruction_metadata .raw_cascaded_font_size @@ -2161,7 +2574,13 @@ impl ComputedGroupSets { pub fn pin_style_record(&mut self, raw_style_record: u64) { let final_style_record = FinalStyleRecordID(raw_style_record); - if final_style_record.base_record().is_some() { + if let Some(style_record) = final_style_record.base_record() { + assert!( + self.style_record_generation_is_live(style_record, final_style_record.base_generation()), + "base style-record is not live" + ); + let pin_count = self.base_style_record_pins.entry(style_record).or_default(); + *pin_count = pin_count.checked_add(1).expect("base style-record pin count overflow"); return; } let slot = *self @@ -2179,7 +2598,19 @@ impl ComputedGroupSets { pub fn unpin_style_record(&mut self, raw_style_record: u64) { let final_style_record = FinalStyleRecordID(raw_style_record); - if final_style_record.base_record().is_some() { + if let Some(style_record) = final_style_record.base_record() { + assert!( + self.style_record_generation_is_live(style_record, final_style_record.base_generation()), + "base style-record is not live" + ); + let pin_count = self + .base_style_record_pins + .get_mut(&style_record) + .expect("base style-record is pinned"); + *pin_count = pin_count.checked_sub(1).expect("base style-record is pinned"); + if *pin_count == 0 { + self.base_style_record_pins.remove(&style_record); + } return; } let slot = *self @@ -2267,7 +2698,8 @@ impl ComputedGroupSets { impl Drop for ComputedGroupSets { fn drop(&mut self) { - for group in &self.groups { + for identity in self.groups.live_identities() { + let group = self.groups.get(identity); release_group_payload(group.index, group.payload); } } @@ -2543,6 +2975,117 @@ mod tests { assert_eq!(sets.live_animation_overlay_records(), 0); } + #[test] + fn base_style_record_pins_are_counted_until_the_last_view_releases() { + let mut sets = ComputedGroupSets::default(); + let publication = sets.publish(None, &[], 0, 0, metadata(0, 0, 0, &[], &[])); + let style_record = publication.style_record_identity; + let base_style_record = style_record.base_record().unwrap(); + + sets.pin_style_record(style_record.raw()); + sets.pin_style_record(style_record.raw()); + assert_eq!(sets.base_style_record_pins.len(), 1); + assert_eq!(sets.base_style_record_pins[&base_style_record], 2); + + sets.unpin_style_record(style_record.raw()); + assert_eq!(sets.base_style_record_pins[&base_style_record], 1); + sets.unpin_style_record(style_record.raw()); + assert!(sets.base_style_record_pins.is_empty()); + } + + #[test] + fn computed_record_reclamation_preserves_roots_while_reusing_record_slots() { + let mut sets = ComputedGroupSets::default(); + let node = StyleNodeID::element(1); + let target = ComputedStyleTarget::new(node, u8::MAX); + let pinned = sets.publish(Some(target), &[], 0, 1, metadata(0, 0, 0, &[], &[])); + sets.pin_style_record(pinned.style_record_identity.raw()); + + for environment in 2..128 { + sets.publish(Some(target), &[], 0, environment, metadata(0, 0, 0, &[], &[])); + } + let current = sets.assigned_style_record(node).unwrap().base_record().unwrap(); + let dense_record_count = sets.style_records.len(); + let retention = sets.reclaim_unreachable(); + + assert_eq!(retention.retained, retention.reachable); + assert_eq!(sets.style_records.live_len(), 2); + assert!(sets.style_records.live_identities().any(|identity| identity == current)); + assert!( + sets.style_records + .live_identities() + .any(|identity| identity == pinned.style_record_identity.base_record().unwrap()) + ); + + for environment in 128..253 { + sets.publish(Some(target), &[], 0, environment, metadata(0, 0, 0, &[], &[])); + } + assert_eq!(sets.style_records.len(), dense_record_count); + + sets.unpin_style_record(pinned.style_record_identity.raw()); + sets.reclaim_unreachable(); + assert_eq!(sets.style_records.live_len(), 1); + } + + #[test] + fn computed_record_reclamation_reuses_the_lowest_identity_first() { + let mut sets = ComputedGroupSets::default(); + let first = sets.publish(None, &[], 0, 1, metadata(0, 0, 0, &[], &[])); + sets.publish(None, &[], 0, 2, metadata(0, 0, 0, &[], &[])); + sets.publish(None, &[], 0, 3, metadata(0, 0, 0, &[], &[])); + sets.reclaim_unreachable(); + + let replacement = sets.publish(None, &[], 0, 4, metadata(0, 0, 0, &[], &[])); + assert_eq!( + replacement.style_record_identity.base_record(), + first.style_record_identity.base_record() + ); + } + + #[test] + fn computed_record_reclamation_retires_an_exhausted_identity() { + let mut sets = ComputedGroupSets::default(); + let exhausted = sets.publish(None, &[], 0, 1, metadata(0, 0, 0, &[], &[])); + let exhausted = exhausted.style_record_identity.base_record().unwrap(); + sets.style_record_generations[exhausted.index()] = FinalStyleRecordID::MAX_BASE_GENERATION; + sets.reclaim_unreachable(); + + let replacement = sets.publish(None, &[], 0, 2, metadata(0, 0, 0, &[], &[])); + assert_ne!(replacement.style_record_identity.base_record(), Some(exhausted)); + } + + #[test] + #[should_panic(expected = "base style-record is not live")] + fn a_retired_base_style_record_cannot_be_viewed() { + let mut sets = ComputedGroupSets::default(); + let publication = sets.publish(None, &[], 0, 1, metadata(0, 0, 0, &[], &[])); + let retired_final = publication.style_record_identity; + let retired = retired_final.base_record().unwrap(); + sets.reclaim_unreachable(); + let replacement = sets.publish(None, &[], 0, 2, metadata(0, 0, 0, &[], &[])); + let replacement_final = replacement.style_record_identity; + let replacement = replacement_final.base_record().unwrap(); + assert_eq!(retired.index(), replacement.index()); + assert_ne!(retired_final.base_generation(), replacement_final.base_generation()); + let _ = sets.style_record_view(publication.style_record_identity.raw()); + } + + #[test] + #[should_panic(expected = "base style-record is not live")] + fn a_retired_base_style_record_cannot_be_pinned() { + let mut sets = ComputedGroupSets::default(); + let publication = sets.publish(None, &[], 0, 1, metadata(0, 0, 0, &[], &[])); + let retired_final = publication.style_record_identity; + let retired = retired_final.base_record().unwrap(); + sets.reclaim_unreachable(); + let replacement = sets.publish(None, &[], 0, 2, metadata(0, 0, 0, &[], &[])); + let replacement_final = replacement.style_record_identity; + let replacement = replacement_final.base_record().unwrap(); + assert_eq!(retired.index(), replacement.index()); + assert_ne!(retired_final.base_generation(), replacement_final.base_generation()); + sets.pin_style_record(publication.style_record_identity.raw()); + } + #[test] fn computed_group_set_capacity_includes_hash_table_control_bytes() { let mut sets = ComputedGroupSets::default(); @@ -2579,6 +3122,7 @@ mod tests { + sets.computed_reconstruction_metadata.capacity_bytes() as usize + sets.computed_longhand_tables.capacity_bytes() as usize + sets.style_records.capacity_bytes() as usize + + sets.style_record_generations.capacity() * size_of::() + sets.pending_cascade_states.capacity() * (size_of::() + size_of::<(u64, CascadeStateID)>() + 1) + sets.animation_overlay_slots_by_record.capacity() diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index a7fa22cc8155..3f44aa522723 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -14,6 +14,7 @@ impl StyleEngine { root: StyleNodeID, mut emit: impl FnMut(StyleTransactionVersion, ProgramVersion, &[PublishedStyleDeltaRecord]), ) -> bool { + self.reclaim_computed_memory_if_needed(); self.sync_tier3_benefit_observations(); let tier3_evictions = self.memory.finish_tier3_quota_period(); for &category in &TIER3_REFUSAL_CATEGORIES { diff --git a/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs b/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs index 7034e0d32500..c11e90b72d45 100644 --- a/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs +++ b/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs @@ -161,6 +161,8 @@ define_counters! { SpecifiedValuesReused => "specifiedValuesReused", ComputedGroupNodeHandlesPublished => "computedGroupNodeHandlesPublished", ComputedGroupsReused => "computedGroupsReused", + ComputedGroupsRetained => "computedGroupsRetained", + ComputedGroupsReachable => "computedGroupsReachable", ComputedGroupSetsReused => "computedGroupSetsReused", InheritedGroupNodeHandlesPublished => "inheritedGroupNodeHandlesPublished", InheritedGroupSetsReused => "inheritedGroupSetsReused", diff --git a/Libraries/LibWeb/Rust/src/css/style/intern_table.rs b/Libraries/LibWeb/Rust/src/css/style/intern_table.rs index c23e82d6230f..058e6ba5176f 100644 --- a/Libraries/LibWeb/Rust/src/css/style/intern_table.rs +++ b/Libraries/LibWeb/Rust/src/css/style/intern_table.rs @@ -34,6 +34,7 @@ struct HashedIdentity { pub(super) struct InternTable { entries: Vec, identities: HashTable>, + free_identities: Vec, } impl Default for InternTable { @@ -41,6 +42,7 @@ impl Default for InternTable { Self { entries: Vec::new(), identities: HashTable::new(), + free_identities: Vec::new(), } } } @@ -92,6 +94,14 @@ impl InternTable { self.entries.iter_mut() } + pub(super) fn live_identities(&self) -> impl Iterator + '_ { + self.identities.iter().map(|candidate| candidate.identity) + } + + pub(super) fn take_free_identity(&mut self) -> Option { + self.free_identities.pop() + } + pub(super) fn shrink_to_fit(&mut self) { self.entries.shrink_to_fit(); self.identities.shrink_to_fit(|candidate| candidate.hash); @@ -143,23 +153,34 @@ impl InternTable ShallowCapacityBytes for InternTable { fn shallow_capacity_bytes(&self) -> u64 { self.entries.shallow_capacity_bytes() + (self.identities.capacity() * (size_of::>() + 1)) as u64 + + self.free_identities.shallow_capacity_bytes() } } impl Clone for InternTable { fn clone(&self) -> Self { let entries = self.entries.clone(); + let free_identities = self.free_identities.clone(); let mut identities = HashTable::with_capacity(self.identities.len()); for candidate in &self.identities { identities.insert_unique(candidate.hash, *candidate, |candidate| candidate.hash); } - Self { entries, identities } + Self { + entries, + identities, + free_identities, + } } } @@ -225,4 +246,21 @@ mod tests { ); assert_eq!(table.find(8, |_identity, _payload| true), None); } + + #[test] + fn retired_identities_can_replace_their_dense_payload() { + let mut table = InternTable::default(); + let identity = TestIdentity(0); + table.insert(7, identity, "first"); + + table.retire_identity(7, identity); + assert_eq!(table.find(7, |_identity, _payload| true), None); + let recycled = table.take_free_identity().unwrap(); + assert_eq!(recycled, identity); + + table.insert(9, recycled, "second"); + assert_eq!(table.len(), 1); + assert_eq!(table.find(9, |_identity, payload| *payload == "second"), Some(identity)); + assert_eq!(table.live_identities().collect::>(), vec![identity]); + } } diff --git a/Libraries/LibWeb/Rust/src/css/style/publication.rs b/Libraries/LibWeb/Rust/src/css/style/publication.rs index 558321ba4534..a09f7d35956e 100644 --- a/Libraries/LibWeb/Rust/src/css/style/publication.rs +++ b/Libraries/LibWeb/Rust/src/css/style/publication.rs @@ -208,9 +208,22 @@ impl StyleEngine { ); } + pub(super) fn reclaim_computed_memory_if_needed(&mut self) { + // Recording dictionaries are keyed by computed identities. Reusing an identity for new + // semantics would make later events refer to the first definition replay saw for it. + if self.recording_id().is_none() + && let Some(retention) = self.computed_group_sets.reclaim_unreachable_if_needed() + { + self.counters + .set(Counter::ComputedGroupsRetained, retention.retained as u64); + self.counters + .set(Counter::ComputedGroupsReachable, retention.reachable as u64); + } + self.settle_computed_memory(); + } + pub(crate) fn unpin_style_record(&mut self, style_record: u64) { self.computed_group_sets.unpin_style_record(style_record); - self.settle_computed_memory(); } pub(super) fn publish_computed_groups_impl( diff --git a/Tests/LibWeb/Text/expected/css/style-engine/computed-group-identities.txt b/Tests/LibWeb/Text/expected/css/style-engine/computed-group-identities.txt index 0e2009b0e3a0..1efc13ff9e8d 100644 --- a/Tests/LibWeb/Text/expected/css/style-engine/computed-group-identities.txt +++ b/Tests/LibWeb/Text/expected/css/style-engine/computed-group-identities.txt @@ -12,6 +12,7 @@ computed reconstruction metadata tuples are shared: true base style record handles cover the elements: true base style records are shared: true pseudo assignments are published sparsely: true +custom property environments survive transaction boundaries: true pseudo assignments are removed sparsely: true ordinary item: rgb(20, 24, 28) distinct non-inherited item: rgb(60, 64, 68) diff --git a/Tests/LibWeb/Text/expected/css/style-engine/computed-record-reclamation.txt b/Tests/LibWeb/Text/expected/css/style-engine/computed-record-reclamation.txt new file mode 100644 index 000000000000..eaeaedd59af8 --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/style-engine/computed-record-reclamation.txt @@ -0,0 +1,6 @@ +first sweep found unreachable groups: true +second sweep found unreachable groups: true +reclaimed group slots stay bounded: true +current style survives reclamation: true +stale input record survives reclamation: true +cached style record survives reclamation: true diff --git a/Tests/LibWeb/Text/expected/css/style-engine/computed-style-record-view-pins.txt b/Tests/LibWeb/Text/expected/css/style-engine/computed-style-record-view-pins.txt new file mode 100644 index 000000000000..5d9f70b6b8fa --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/style-engine/computed-style-record-view-pins.txt @@ -0,0 +1 @@ +computed style record view pins increased: true diff --git a/Tests/LibWeb/Text/expected/css/style-engine/layout-and-paint-consume-style-record.txt b/Tests/LibWeb/Text/expected/css/style-engine/layout-and-paint-consume-style-record.txt index ff23c5aa7679..bd9911d44eda 100644 --- a/Tests/LibWeb/Text/expected/css/style-engine/layout-and-paint-consume-style-record.txt +++ b/Tests/LibWeb/Text/expected/css/style-engine/layout-and-paint-consume-style-record.txt @@ -8,3 +8,6 @@ paint consumes metadata-only record: true style change publishes a new record: true layout consumes changed record: true paint consumes changed record: true +pseudo layout consumes changed record: true +pseudo metadata-only change publishes a new record: true +pseudo layout consumes metadata-only record: true diff --git a/Tests/LibWeb/Text/input/css/style-engine/computed-group-identities.html b/Tests/LibWeb/Text/input/css/style-engine/computed-group-identities.html index 508e3f709259..ce5bdfd846d9 100644 --- a/Tests/LibWeb/Text/input/css/style-engine/computed-group-identities.html +++ b/Tests/LibWeb/Text/input/css/style-engine/computed-group-identities.html @@ -45,11 +45,19 @@ println(`base style record handles cover the elements: ${after.styleRecordNodeHandlesPublished - before.styleRecordNodeHandlesPublished >= 100}`); println(`base style records are shared: ${after.styleRecordsReused - before.styleRecordsReused >= 98}`); println(`pseudo assignments are published sparsely: ${after.computedPseudoAssignmentsPublished - before.computedPseudoAssignmentsPublished >= 50}`); + + const laterItem = document.createElement("div"); + laterItem.className = "item even"; + root.appendChild(laterItem); + internals.updateStyle(); + const afterLaterItem = internals.styleEngineCounters(); + println(`custom property environments survive transaction boundaries: ${afterLaterItem.customPropertyEnvironmentsReused > after.customPropertyEnvironmentsReused}`); + document.styleSheets[0].deleteRule(3); internals.updateStyle(); const withoutPseudos = internals.styleEngineCounters(); - println(`pseudo assignments are removed sparsely: ${withoutPseudos.computedPseudoAssignmentsRemoved - after.computedPseudoAssignmentsRemoved >= 50}`); + println(`pseudo assignments are removed sparsely: ${withoutPseudos.computedPseudoAssignmentsRemoved - afterLaterItem.computedPseudoAssignmentsRemoved >= 50}`); println(`ordinary item: ${getComputedStyle(root.firstElementChild).color}`); - println(`distinct non-inherited item: ${getComputedStyle(root.lastElementChild).backgroundColor}`); + println(`distinct non-inherited item: ${getComputedStyle(root.children[99]).backgroundColor}`); }); diff --git a/Tests/LibWeb/Text/input/css/style-engine/computed-record-reclamation.html b/Tests/LibWeb/Text/input/css/style-engine/computed-record-reclamation.html new file mode 100644 index 000000000000..41e159a1618f --- /dev/null +++ b/Tests/LibWeb/Text/input/css/style-engine/computed-record-reclamation.html @@ -0,0 +1,57 @@ + + + +
+
+
+
+
+
+
+ diff --git a/Tests/LibWeb/Text/input/css/style-engine/computed-style-record-view-pins.html b/Tests/LibWeb/Text/input/css/style-engine/computed-style-record-view-pins.html new file mode 100644 index 000000000000..65c17cb4b7c6 --- /dev/null +++ b/Tests/LibWeb/Text/input/css/style-engine/computed-style-record-view-pins.html @@ -0,0 +1,12 @@ + + +
+ diff --git a/Tests/LibWeb/Text/input/css/style-engine/layout-and-paint-consume-style-record.html b/Tests/LibWeb/Text/input/css/style-engine/layout-and-paint-consume-style-record.html index bedb50f44f69..88d3e8b2acc5 100644 --- a/Tests/LibWeb/Text/input/css/style-engine/layout-and-paint-consume-style-record.html +++ b/Tests/LibWeb/Text/input/css/style-engine/layout-and-paint-consume-style-record.html @@ -4,9 +4,13 @@ .shared { color: red; --token: initial; } .metadata { color: red; --token: changed; } .changed { color: blue; --token: changed; } + .pseudo::before { content: "before"; color: red; --token: initial; } + .pseudo.changed::before { content: "before"; color: blue; --token: initial; } + .pseudo.changed.metadata::before { content: "before"; color: blue; --token: changed; }
+
From 00c257b2f318524c460e7b3d9fd9a1da6ce6503f Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 06:10:48 +0200 Subject: [PATCH 26/39] LibWeb: Batch intrinsic element arrivals An arriving element published its namespace, language, directionality, heading level, slot-ness and custom states through individual boundary calls after its tree delta, on the order of twenty crossings for a plain div with a class. Carry those intrinsic facts in one arrival row family applied with the tree deltas of the same batch, and report the boundary calls in replay. --- Libraries/LibWeb/CSS/StyleEngineBridge.cpp | 34 +++ Libraries/LibWeb/CSS/StyleEngineBridge.h | 4 + Libraries/LibWeb/CSS/StyleEngineInput.cpp | 25 ++- Libraries/LibWeb/Rust/src/bin/style_replay.rs | 53 ++++- Libraries/LibWeb/Rust/src/css/style/bridge.rs | 198 ++++++++++++++++-- Libraries/LibWeb/Rust/src/css/style/inputs.rs | 50 +++++ .../Rust/src/css/style/record_replay.rs | 2 +- 7 files changed, 343 insertions(+), 23 deletions(-) diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp index 0278e09ad505..1df25bbc5095 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp @@ -243,6 +243,20 @@ StyleAtomID StyleEngine::intern_text_atom(Utf16View text) return intern_atom(Utf16FlyString::from_utf16(text).to_ascii_lowercase()); } +StyleAtomID StyleEngine::intern_language_atom(Utf16View text) +{ + auto atom = intern_text_atom(text); + if (atom == 0) + return atom; + + Vector code_units; + code_units.ensure_capacity(text.length_in_code_units()); + for (size_t i = 0; i < text.length_in_code_units(); ++i) + code_units.unchecked_append(text.code_unit_at(i)); + StyleEngineFFI::style_engine_set_element_language(m_impl, 0, atom.value(), code_units.data(), code_units.size()); + return atom; +} + StyleAtomID StyleEngine::intern_case_sensitive_text_atom(Utf16View text) { return intern_atom(Utf16FlyString::from_utf16(text)); @@ -293,6 +307,19 @@ void StyleEngine::record_tree_delta(StyleEngineFFI::FfiTreeDelta const& delta) m_tree_deltas.append(delta); } +void StyleEngine::record_element_arrival(StyleEngineFFI::FfiElementArrival arrival, ReadonlySpan custom_states) +{ + request_frame_for_first_recorded_input(*this, m_style_computer); + VERIFY(m_arrival_custom_state_atoms.size() <= NumericLimits::max()); + VERIFY(custom_states.size() <= NumericLimits::max()); + VERIFY(m_arrival_custom_state_atoms.size() + custom_states.size() <= NumericLimits::max()); + arrival.custom_state_offset = static_cast(m_arrival_custom_state_atoms.size()); + arrival.custom_state_count = static_cast(custom_states.size()); + for (auto state : custom_states) + m_arrival_custom_state_atoms.append(state.value()); + m_element_arrivals.append(arrival); +} + void StyleEngine::record_local_feature_delta(StyleEngineFFI::FfiLocalFeatureDelta const& delta) { request_frame_for_first_recorded_input(*this, m_style_computer); @@ -361,6 +388,7 @@ void StyleEngine::record_benchmark_marker(Utf16View name) bool StyleEngine::has_recorded_input() const { return !m_tree_deltas.is_empty() + || !m_element_arrivals.is_empty() || !m_local_feature_deltas.is_empty() || !m_state_deltas.is_empty() || !m_element_declaration_deltas.is_empty() @@ -377,6 +405,10 @@ void StyleEngine::submit_recorded_input() InputTransaction transaction { .tree_deltas = m_tree_deltas.data(), .tree_delta_count = m_tree_deltas.size(), + .element_arrivals = m_element_arrivals.data(), + .element_arrival_count = m_element_arrivals.size(), + .arrival_custom_state_atoms = m_arrival_custom_state_atoms.data(), + .arrival_custom_state_atom_count = m_arrival_custom_state_atoms.size(), .local_feature_deltas = m_local_feature_deltas.data(), .local_feature_delta_count = m_local_feature_deltas.size(), .state_deltas = m_state_deltas.data(), @@ -389,6 +421,8 @@ void StyleEngine::submit_recorded_input() apply_transaction(transaction); m_tree_deltas.clear_with_capacity(); + m_element_arrivals.clear_with_capacity(); + m_arrival_custom_state_atoms.clear_with_capacity(); m_local_feature_deltas.clear_with_capacity(); m_state_deltas.clear_with_capacity(); m_element_declaration_deltas.clear_with_capacity(); diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.h b/Libraries/LibWeb/CSS/StyleEngineBridge.h index 8d0c6fb1c9af..6829fe19ab48 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.h +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.h @@ -146,6 +146,7 @@ class WEB_API StyleEngine { // A name both a selector and the DOM produce as text, with no interned identity on either side: // a language subtag and a `:dir()` keyword. Matched ASCII case-insensitively. StyleAtomID intern_text_atom(Utf16View); + StyleAtomID intern_language_atom(Utf16View); // The same, without the ASCII folding, for names compared literally such as namespace URIs. StyleAtomID intern_case_sensitive_text_atom(Utf16View); @@ -156,6 +157,7 @@ class WEB_API StyleEngine { // Deltas accumulate here and cross in one flat batch per style flush, never one call per // element. void record_tree_delta(StyleEngineFFI::FfiTreeDelta const&); + void record_element_arrival(StyleEngineFFI::FfiElementArrival, ReadonlySpan custom_states); void record_local_feature_delta(StyleEngineFFI::FfiLocalFeatureDelta const&); void record_state_delta(StyleEngineFFI::FfiStateDelta const&); void record_element_declaration_delta(StyleEngineFFI::FfiElementDeclarationDelta const&); @@ -239,6 +241,8 @@ class WEB_API StyleEngine { u32 m_declaration_block_version { 1 }; Vector m_tree_deltas; + Vector m_element_arrivals; + Vector m_arrival_custom_state_atoms; Vector m_local_feature_deltas; Vector m_state_deltas; Vector m_element_declaration_deltas; diff --git a/Libraries/LibWeb/CSS/StyleEngineInput.cpp b/Libraries/LibWeb/CSS/StyleEngineInput.cpp index 64cefceed2ec..747f795c2d60 100644 --- a/Libraries/LibWeb/CSS/StyleEngineInput.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineInput.cpp @@ -308,10 +308,10 @@ template static void publish_element_selector_features(StyleEngine& style_engine, DOM::Element& element, StyleNodeID node, PublishFeature publish_feature, PublishEmptiness publish_emptiness, InvalidateLanguageCache invalidate_language_cache) { // Slot identity and namespace never change during an element's lifetime. - if (is(element)) - style_engine.set_element_is_slot(node, true); + auto is_slot = is(element); + StyleAtomID namespace_atom; if (auto const& namespace_uri = element.namespace_uri(); namespace_uri.has_value() && !namespace_uri->is_empty()) - style_engine.set_element_namespace(node, style_engine.intern_case_sensitive_text_atom(namespace_uri->view())); + namespace_atom = style_engine.intern_case_sensitive_text_atom(namespace_uri->view()); publish_feature(StyleEngineFFI::FfiFeatureKind::TagName, StyleAtomID {}, StyleEngineFFI::FfiFeatureValueKind::Atom, style_engine.intern_atom(element.local_name())); if (auto folded_name = element.local_name().to_ascii_lowercase(); folded_name != element.local_name()) @@ -342,21 +342,32 @@ static void publish_element_selector_features(StyleEngine& style_engine, DOM::El } auto const language = element.lang_view(); - style_engine.set_element_language(node, language.has_value() ? style_engine.intern_text_atom(*language) : 0, language.value_or({})); + auto language_atom = language.has_value() ? style_engine.intern_language_atom(*language) : StyleAtomID {}; auto const directionality = element.directionality() == DOM::Element::Directionality::Rtl ? "rtl"sv : "ltr"sv; - style_engine.set_element_directionality(node, style_engine.intern_text_atom(Utf16View { directionality })); + auto directionality_atom = style_engine.intern_text_atom(Utf16View { directionality }); if (invalidate_language_cache == InvalidateLanguageCache::Yes) element.invalidate_lang_value(); GC::Ptr heading = as_if(element); - style_engine.set_element_heading_level(node, static_cast(min(heading ? heading->heading_level() : 0, 255u))); + auto heading_level = static_cast(min(heading ? heading->heading_level() : 0, 255u)); Vector custom_states; if (auto states = element.custom_state_set()) { for (auto const& state : states->states()) custom_states.append(style_engine.intern_atom(state)); } - style_engine.set_element_custom_states(node, custom_states); + style_engine.record_element_arrival({ + .node = node.value(), + .namespace_atom = namespace_atom.value(), + .language_atom = language_atom.value(), + .directionality_atom = directionality_atom.value(), + .custom_state_offset = 0, + .custom_state_count = 0, + .heading_level = heading_level, + .is_slot = is_slot, + .reserved = 0, + }, + custom_states); } void populate_isolated_selector_query_engine(StyleEngine& style_engine, DOM::ParentNode& root, Function, StyleNodeID)> const& publish_identity) diff --git a/Libraries/LibWeb/Rust/src/bin/style_replay.rs b/Libraries/LibWeb/Rust/src/bin/style_replay.rs index 2e6731d2c0fa..954ec402a345 100644 --- a/Libraries/LibWeb/Rust/src/bin/style_replay.rs +++ b/Libraries/LibWeb/Rust/src/bin/style_replay.rs @@ -23,6 +23,7 @@ use std::time::Instant; use libweb_rust::css::style::bridge; use libweb_rust::css::style::bridge::FfiCascadeOrigin; +use libweb_rust::css::style::bridge::FfiElementArrival; use libweb_rust::css::style::bridge::FfiElementDeclarationDelta; use libweb_rust::css::style::bridge::FfiElementDeclarationKind; use libweb_rust::css::style::bridge::FfiElementStyleInput; @@ -86,6 +87,12 @@ fn run() -> Result<(), Box> { let mut engine_count = 0_u64; let mut event_count = 0_u64; let mut intern_atom_boundary_calls = 0_u64; + let mut element_arrivals = 0_u64; + let mut element_fact_calls = 0_u64; + let mut element_declaration_calls = 0_u64; + let mut element_animation_name_calls = 0_u64; + let mut attribute_value_text_queries = 0_u64; + let mut attribute_value_text_publications = 0_u64; let mut selector_program_sharing = SelectorProgramSharing::default(); let mut flush_count = 0_u64; let mut presence_degraded_publication_comparisons = 0_u64; @@ -93,6 +100,7 @@ fn run() -> Result<(), Box> { let mut selected_flush_count = 0_u64; let mut selected_boundary_time = Duration::ZERO; let mut active_phase = None; + let mut last_benchmark_marker = String::new(); let mut phase_times = BTreeMap::::new(); let mut pending_changed_rows = FastMap::::default(); let mut amplification = AmplificationLedger::default(); @@ -118,6 +126,19 @@ fn run() -> Result<(), Box> { break; }; event_count += 1; + match event.kind { + EventKind::SetElementNamespace + | EventKind::SetElementLanguage + | EventKind::SetElementDirectionality + | EventKind::SetElementCustomStates + | EventKind::SetElementIsSlot + | EventKind::SetElementHeadingLevel => element_fact_calls += 1, + EventKind::SetElementDeclaredProperties => element_declaration_calls += 1, + EventKind::SetElementAnimationNames => element_animation_name_calls += 1, + EventKind::HasAttributeValueText => attribute_value_text_queries += 1, + EventKind::SetAttributeValueText => attribute_value_text_publications += 1, + _ => {} + } match event.kind { kind if replay_generated_boundary_event(kind, &mut event.payload, &live_engines)? => {} EventKind::CreateGraph => { @@ -193,6 +214,12 @@ fn run() -> Result<(), Box> { let engine = read_engine(&mut event.payload, &live_engines)?; // The row arrays are consumed in place from the mapped log; nothing is copied. let tree = event.payload.read_raw_slice::()?; + element_arrivals += tree + .iter() + .filter(|delta| !delta.old_connected && delta.new_connected) + .count() as u64; + let arrivals = event.payload.read_raw_slice::()?; + let arrival_custom_state_atoms = event.payload.read_u32_vec()?; let features = event.payload.read_raw_slice::()?; let states = event.payload.read_raw_slice::()?; let declarations = event.payload.read_raw_slice::()?; @@ -204,6 +231,10 @@ fn run() -> Result<(), Box> { let transaction = FfiStyleInputTransaction { tree_deltas: tree.as_ptr(), tree_delta_count: tree.len(), + element_arrivals: arrivals.as_ptr(), + element_arrival_count: arrivals.len(), + arrival_custom_state_atoms: arrival_custom_state_atoms.as_ptr(), + arrival_custom_state_atom_count: arrival_custom_state_atoms.len(), local_feature_deltas: features.as_ptr(), local_feature_delta_count: features.len(), state_deltas: states.as_ptr(), @@ -497,6 +528,7 @@ fn run() -> Result<(), Box> { EventKind::BenchmarkMarker => { let _engine = read_engine(&mut event.payload, &live_engines)?; let name = String::from_utf16(&event.payload.read_u16_vec()?)?; + last_benchmark_marker.clone_from(&name); if let Some(marker) = BenchmarkMarker::parse(&name) { encountered_subtests.insert(format!("{}/{}", marker.suite, marker.test)); active_phase = marker.next_phase(); @@ -849,7 +881,7 @@ fn run() -> Result<(), Box> { }; if actual != expected { return Err(format!( - "computed style publication diverged for node {node}: expected {expected:?}, got {actual:?}" + "computed style publication diverged at event {event_count} after {last_benchmark_marker:?} in {active_phase:?} for node {node}: expected {expected:?}, got {actual:?}" ) .into()); } @@ -998,6 +1030,16 @@ fn run() -> Result<(), Box> { if options.detailed_counters { println!("boundary counters:"); println!(" internAtomCalls: {intern_atom_boundary_calls}"); + println!(" elementArrivals: {element_arrivals}"); + println!( + " elementFactCalls: {} ({:.2}/arrival)", + element_fact_calls, + element_fact_calls as f64 / element_arrivals.max(1) as f64 + ); + println!(" elementDeclarationCalls: {element_declaration_calls}"); + println!(" elementAnimationNameCalls: {element_animation_name_calls}"); + println!(" attributeValueTextQueries: {attribute_value_text_queries}"); + println!(" attributeValueTextPublications: {attribute_value_text_publications}"); println!( " selectorProgramCompilations: {}", selector_program_sharing.compilations @@ -1022,6 +1064,13 @@ fn run() -> Result<(), Box> { "presence_degraded_exact_cascade_publication_comparisons": presence_degraded_publication_comparisons, "boundary_counters": { "intern_atom_calls": intern_atom_boundary_calls, + "element_arrivals": element_arrivals, + "element_fact_calls": element_fact_calls, + "element_fact_calls_per_arrival": element_fact_calls as f64 / element_arrivals.max(1) as f64, + "element_declaration_calls": element_declaration_calls, + "element_animation_name_calls": element_animation_name_calls, + "attribute_value_text_queries": attribute_value_text_queries, + "attribute_value_text_publications": attribute_value_text_publications, "selector_program_compilations": selector_program_sharing.compilations, "selector_programs_distinct_documents": selector_program_sharing.document_distinct_count(), "selector_programs_distinct_process": selector_program_sharing.process_hashes.len(), @@ -1196,7 +1245,7 @@ impl Options { } } -#[derive(Clone)] +#[derive(Clone, Debug)] struct ActivePhase { suite: String, test: String, diff --git a/Libraries/LibWeb/Rust/src/css/style/bridge.rs b/Libraries/LibWeb/Rust/src/css/style/bridge.rs index 6ed132b5adf9..e5a1ff5e8e29 100644 --- a/Libraries/LibWeb/Rust/src/css/style/bridge.rs +++ b/Libraries/LibWeb/Rust/src/css/style/bridge.rs @@ -353,6 +353,22 @@ pub struct FfiTreeDelta { pub new_relations: FfiTreeRelations, } +/// Selector-visible facts which exist when one style node first joins the tree. Variable-width +/// custom states occupy one shared atom column and are named by this row's offset and count. +#[derive(Clone, Copy)] +#[repr(C)] +pub struct FfiElementArrival { + pub node: u32, + pub namespace_atom: u32, + pub language_atom: u32, + pub directionality_atom: u32, + pub custom_state_offset: u32, + pub custom_state_count: u32, + pub heading_level: u8, + pub is_slot: bool, + pub reserved: u16, +} + /// Which local fact a feature delta describes. #[derive(Clone, Copy, PartialEq, Eq)] #[repr(u8)] @@ -466,10 +482,11 @@ pub struct FfiElementStyleInput { pub inherited_style_groups: u8, } -// SAFETY: the five transaction row types are pointer-free repr(C) with alignment four, and their +// SAFETY: the six transaction row types are pointer-free repr(C) with alignment four, and their // enum fields are recorded only from live FFI values, so raw bytes round-trip on the capturing // host (the RawRecord contract). unsafe impl super::record_replay::RawRecord for FfiTreeDelta {} +unsafe impl super::record_replay::RawRecord for FfiElementArrival {} unsafe impl super::record_replay::RawRecord for FfiLocalFeatureDelta {} unsafe impl super::record_replay::RawRecord for FfiStateDelta {} unsafe impl super::record_replay::RawRecord for FfiElementDeclarationDelta {} @@ -481,6 +498,10 @@ unsafe impl super::record_replay::RawRecord for FfiElementStyleInput {} pub struct FfiStyleInputTransaction { pub tree_deltas: *const FfiTreeDelta, pub tree_delta_count: usize, + pub element_arrivals: *const FfiElementArrival, + pub element_arrival_count: usize, + pub arrival_custom_state_atoms: *const u32, + pub arrival_custom_state_atom_count: usize, pub local_feature_deltas: *const FfiLocalFeatureDelta, pub local_feature_delta_count: usize, pub state_deltas: *const FfiStateDelta, @@ -679,11 +700,13 @@ impl StyleEngine { pub fn apply_transaction_batch( &mut self, tree_deltas: &[FfiTreeDelta], + arrival_columns: (&[FfiElementArrival], &[u32]), local_feature_deltas: &[FfiLocalFeatureDelta], state_deltas: &[FfiStateDelta], element_declaration_deltas: &[FfiElementDeclarationDelta], element_style_inputs: &[FfiElementStyleInput], ) { + let (element_arrivals, arrival_custom_state_atoms) = arrival_columns; let largest_element_index = tree_deltas .iter() .filter_map(|delta| StyleNodeID::from_raw(delta.node)?.element_index()) @@ -747,6 +770,35 @@ impl StyleEngine { .unwrap_or(false) }; + if !element_arrivals.is_empty() { + for arrival in element_arrivals { + let Some(node) = StyleNodeID::from_raw(arrival.node) else { + debug_assert!(false, "an element arrival named an invalid style node"); + continue; + }; + let Some(custom_state_end) = arrival.custom_state_offset.checked_add(arrival.custom_state_count) else { + debug_assert!(false, "an element arrival custom-state range overflowed"); + continue; + }; + let Ok(custom_state_range) = usize::try_from(arrival.custom_state_offset) + .and_then(|start| usize::try_from(custom_state_end).map(|end| start..end)) + else { + debug_assert!(false, "an element arrival custom-state range exceeded usize"); + continue; + }; + let Some(custom_states) = arrival_custom_state_atoms.get(custom_state_range) else { + debug_assert!( + false, + "an element arrival named custom states outside the shared atom column" + ); + continue; + }; + let custom_states = custom_states.iter().copied().map(StyleAtomID).collect::>(); + self.record_element_arrival(node, arrival, &custom_states, node_is_arriving(node)); + } + self.settle_batched_inputs(); + } + for delta in local_feature_deltas { let Some(node) = StyleNodeID::from_raw(delta.node) else { continue; @@ -944,6 +996,13 @@ pub unsafe extern "C" fn style_engine_apply_transaction(engine: *mut c_void, tra let engine = unsafe { &mut *engine.cast::() }; // SAFETY: the caller vouches that each pointer covers its stated count for this call. let tree = unsafe { borrow(transaction.tree_deltas, transaction.tree_delta_count) }; + let arrivals = unsafe { borrow(transaction.element_arrivals, transaction.element_arrival_count) }; + let arrival_custom_state_atoms = unsafe { + borrow( + transaction.arrival_custom_state_atoms, + transaction.arrival_custom_state_atom_count, + ) + }; let features = unsafe { borrow(transaction.local_feature_deltas, transaction.local_feature_delta_count) }; let states = unsafe { borrow(transaction.state_deltas, transaction.state_delta_count) }; let declarations = unsafe { @@ -954,9 +1013,18 @@ pub unsafe extern "C" fn style_engine_apply_transaction(engine: *mut c_void, tra }; let element_style_inputs = unsafe { borrow(transaction.element_style_inputs, transaction.element_style_input_count) }; - engine.apply_transaction_batch(tree, features, states, declarations, element_style_inputs); + engine.apply_transaction_batch( + tree, + (arrivals, arrival_custom_state_atoms), + features, + states, + declarations, + element_style_inputs, + ); engine.record_boundary_call(EventKind::ApplyTransaction, |payload| { write_recording_tree_deltas(tree, payload); + payload.write_raw_slice(arrivals); + payload.write_u32_slice(arrival_custom_state_atoms); payload.write_raw_slice(features); write_recording_state_deltas(states, payload); payload.write_raw_slice(declarations); @@ -1282,9 +1350,6 @@ pub unsafe extern "C" fn style_engine_set_element_language( text_length: usize, ) { abort_on_panic(|| { - let Some(node) = StyleNodeID::from_raw(node) else { - return; - }; let engine = unsafe { &mut *engine.cast::() }; let text = match language != 0 && !text.is_null() { true => unsafe { std::slice::from_raw_parts(text, text_length) }, @@ -1295,14 +1360,17 @@ pub unsafe extern "C" fn style_engine_set_element_language( // once per language rather than once per element. engine.set_element_language_text(StyleAtomID(language), text); } - engine.set_element_language(node, StyleAtomID(language)); + if let Some(node) = StyleNodeID::from_raw(node) { + engine.set_element_language(node, StyleAtomID(language)); + } engine.record_boundary_call(EventKind::SetElementLanguage, |payload| { - payload.write_u32(node.raw()); + payload.write_u32(node); payload.write_u32(language); payload.write_u16_slice(text); }); }); } + /// # Safety /// `pointers` must be null or point to `count` live `RustSelector` pointers. /// How many scope roots and limits each enclosing `@scope` contributed, outermost first. @@ -2663,7 +2731,7 @@ mod tests { }, }, ]; - engine.apply_transaction_batch(&initial_tree, &[], &[], &[], &[]); + engine.apply_transaction_batch(&initial_tree, (&[], &[]), &[], &[], &[], &[]); let root = StyleNodeID::from_raw(nodes[0]).unwrap(); let child = StyleNodeID::from_raw(nodes[1]).unwrap(); @@ -2697,7 +2765,7 @@ mod tests { ..no_relations() }, }]; - engine.apply_transaction_batch(&later_arrival, &[], &[], &[], &[]); + engine.apply_transaction_batch(&later_arrival, (&[], &[]), &[], &[], &[], &[]); let transaction = engine.take_transaction(); assert_eq!(transaction.inputs.len(), 3); let later = StyleNodeID::from_raw(nodes[3]).unwrap(); @@ -2721,6 +2789,110 @@ mod tests { assert_eq!(engine.counters().get(Counter::InitialBulkTreeRows), 3); } + #[test] + fn element_arrival_rows_install_intrinsic_facts() { + assert_eq!(size_of::(), 28); + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let mut nodes = [0_u32; 2]; + engine.allocate_style_nodes(&mut nodes); + let tree = [ + FfiTreeDelta { + node: nodes[0], + old_connected: false, + new_connected: true, + old_relations: no_relations(), + new_relations: no_relations(), + }, + FfiTreeDelta { + node: nodes[1], + old_connected: false, + new_connected: true, + old_relations: no_relations(), + new_relations: FfiTreeRelations { + parent: nodes[0], + ..no_relations() + }, + }, + ]; + let arrivals = [ + FfiElementArrival { + node: nodes[0], + namespace_atom: 11, + language_atom: 12, + directionality_atom: 13, + custom_state_offset: 0, + custom_state_count: 2, + heading_level: 4, + is_slot: true, + reserved: 0, + }, + FfiElementArrival { + node: nodes[1], + namespace_atom: 21, + language_atom: 22, + directionality_atom: 23, + custom_state_offset: 2, + custom_state_count: 1, + heading_level: 0, + is_slot: false, + reserved: 0, + }, + ]; + engine.apply_transaction_batch(&tree, (&arrivals, &[31, 32, 33]), &[], &[], &[], &[]); + let transaction = engine.take_transaction(); + + let root = StyleNodeID::from_raw(nodes[0]).unwrap(); + let child = StyleNodeID::from_raw(nodes[1]).unwrap(); + assert_eq!(engine.facts.namespace_of(root), StyleAtomID(11)); + assert_eq!(engine.facts.language_of(root), StyleAtomID(12)); + assert_eq!(engine.facts.directionality_of(root), StyleAtomID(13)); + assert_eq!(engine.facts.heading_level_of(root), 4); + assert!(engine.facts.is_slot(root)); + assert_eq!(engine.facts.custom_states_of(root), &[StyleAtomID(31), StyleAtomID(32)]); + assert_eq!(engine.facts.namespace_of(child), StyleAtomID(21)); + assert_eq!(engine.facts.custom_states_of(child), &[StyleAtomID(33)]); + engine.release_transaction(transaction); + } + + fn arrival_for(node: u32, custom_state_offset: u32, custom_state_count: u32) -> FfiElementArrival { + FfiElementArrival { + node, + namespace_atom: 1, + language_atom: 2, + directionality_atom: 3, + custom_state_offset, + custom_state_count, + heading_level: 0, + is_slot: false, + reserved: 0, + } + } + + #[test] + #[should_panic(expected = "an element arrival named an invalid style node")] + fn malformed_element_arrival_rejects_an_invalid_node() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + engine.apply_transaction_batch(&[], (&[arrival_for(0, 0, 0)], &[]), &[], &[], &[], &[]); + } + + #[test] + #[should_panic(expected = "an element arrival custom-state range overflowed")] + fn malformed_element_arrival_rejects_an_overflowing_custom_state_range() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let mut nodes = [0]; + engine.allocate_style_nodes(&mut nodes); + engine.apply_transaction_batch(&[], (&[arrival_for(nodes[0], u32::MAX, 1)], &[]), &[], &[], &[], &[]); + } + + #[test] + #[should_panic(expected = "an element arrival named custom states outside the shared atom column")] + fn malformed_element_arrival_rejects_an_out_of_bounds_custom_state_range() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let mut nodes = [0]; + engine.allocate_style_nodes(&mut nodes); + engine.apply_transaction_batch(&[], (&[arrival_for(nodes[0], 0, 2)], &[1]), &[], &[], &[], &[]); + } + #[test] fn initial_tree_bulk_load_publishes_match_answers_before_traversal() { let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); @@ -2764,7 +2936,7 @@ mod tests { new_kind: FfiFeatureValueKind::Atom, new_atom: 1, }); - engine.apply_transaction_batch(&initial_tree, &initial_features, &[], &[], &[]); + engine.apply_transaction_batch(&initial_tree, (&[], &[]), &initial_features, &[], &[], &[]); let root = StyleNodeID::from_raw(nodes[0]).unwrap(); let mut published = Vec::new(); @@ -2842,7 +3014,7 @@ mod tests { }, }, ]; - engine.apply_transaction_batch(&arrival, &[], &[], &[], &[]); + engine.apply_transaction_batch(&arrival, (&[], &[]), &[], &[], &[], &[]); let settled = engine.take_transaction(); engine.release_transaction(settled); @@ -2883,7 +3055,7 @@ mod tests { reaction: crate::css::style::transaction::STYLE_REACTION_RECOMPUTE_STYLE, inherited_style_groups: 0, }]; - engine.apply_transaction_batch(&tree, &features, &states, &declarations, &style_inputs); + engine.apply_transaction_batch(&tree, (&[], &[]), &features, &states, &declarations, &style_inputs); let transaction = engine.take_transaction(); let node0 = StyleNodeID::from_raw(nodes[0]).unwrap(); @@ -2907,7 +3079,7 @@ mod tests { #[test] fn a_batch_of_no_deltas_costs_nothing() { let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); - engine.apply_transaction_batch(&[], &[], &[], &[], &[]); + engine.apply_transaction_batch(&[], (&[], &[]), &[], &[], &[], &[]); let transaction = engine.take_transaction(); assert!(transaction.is_empty()); engine.release_transaction(transaction); diff --git a/Libraries/LibWeb/Rust/src/css/style/inputs.rs b/Libraries/LibWeb/Rust/src/css/style/inputs.rs index 0d6797c8cc7b..74d919c7d823 100644 --- a/Libraries/LibWeb/Rust/src/css/style/inputs.rs +++ b/Libraries/LibWeb/Rust/src/css/style/inputs.rs @@ -707,6 +707,56 @@ impl StyleEngine { ); } + /// Install the fixed facts carried by one element arrival. The tree row already routes the + /// arriving element, so facts which can change later update their columns without adding one + /// journal entry apiece. + pub(crate) fn record_element_arrival( + &mut self, + node: StyleNodeID, + arrival: &super::bridge::FfiElementArrival, + custom_states: &[StyleAtomID], + arriving_node: bool, + ) { + debug_assert!(arriving_node); + self.facts.set_namespace(node, StyleAtomID(arrival.namespace_atom)); + self.facts.set_is_slot(node, arrival.is_slot); + let mut publish_feature = |feature, value| { + self.record_batched_input( + InputKey::LocalFeature(node, feature), + InputValue::Feature(FeatureValue::Absent), + InputValue::Feature(value), + arriving_node, + ); + }; + if arrival.language_atom != 0 { + publish_feature( + LocalFeatureKey::Language, + FeatureValue::Atom(StyleAtomID(arrival.language_atom)), + ); + } + if arrival.directionality_atom != 0 { + publish_feature( + LocalFeatureKey::Directionality, + FeatureValue::Atom(StyleAtomID(arrival.directionality_atom)), + ); + } + if arrival.heading_level != 0 { + publish_feature( + LocalFeatureKey::HeadingLevel, + FeatureValue::Number(u32::from(arrival.heading_level)), + ); + } + for &state in custom_states { + self.record_batched_input( + InputKey::LocalFeature(node, LocalFeatureKey::CustomState(state)), + InputValue::Feature(FeatureValue::Absent), + InputValue::Feature(FeatureValue::Present), + arriving_node, + ); + } + self.facts.set_custom_states(node, custom_states, &mut self.memory); + } + pub(crate) fn settle_batched_inputs(&mut self) { if !self.journal.contains_only_element_style_inputs() { self.discard_prepared_batch_matching_traversal(); diff --git a/Libraries/LibWeb/Rust/src/css/style/record_replay.rs b/Libraries/LibWeb/Rust/src/css/style/record_replay.rs index c2c4beff6a6a..12628d5c626b 100644 --- a/Libraries/LibWeb/Rust/src/css/style/record_replay.rs +++ b/Libraries/LibWeb/Rust/src/css/style/record_replay.rs @@ -30,7 +30,7 @@ use std::sync::Mutex; use std::sync::OnceLock; const MAGIC: [u8; 8] = *b"SGREPLAY"; -const FORMAT_VERSION: u64 = 6; +const FORMAT_VERSION: u64 = 7; const EVENT_HEADER_SIZE: usize = 3 * size_of::(); const PAYLOAD_ALIGNMENT: usize = 8; From 4669e2ea106e0ccceb78f79f78439dad6565c28f Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 07:20:31 +0200 Subject: [PATCH 27/39] LibWeb: Avoid redundant boundary publications Presentational hints were republished on every computation, animation names on every changed record, language text once per arriving element, and an empty inline declaration block for every element without one; each republish interned its values before the engine deduplicated it. Publish hints and animation names only when they changed since the last publication that crossed, language text once per atom per document, and skip empty initial inline blocks. --- Libraries/LibWeb/CSS/StyleComputer.cpp | 4 ++- Libraries/LibWeb/CSS/StyleEngineBridge.cpp | 10 ++++--- Libraries/LibWeb/CSS/StyleEngineBridge.h | 1 + Libraries/LibWeb/CSS/StyleEngineInput.cpp | 15 +++++----- Libraries/LibWeb/CSS/StyleEngineInput.h | 2 +- Libraries/LibWeb/DOM/Element.cpp | 30 ++++++++++++++++++-- Libraries/LibWeb/DOM/Element.h | 10 ++++++- Libraries/LibWeb/Rust/src/css/style/index.rs | 15 +++++----- 8 files changed, 63 insertions(+), 24 deletions(-) diff --git a/Libraries/LibWeb/CSS/StyleComputer.cpp b/Libraries/LibWeb/CSS/StyleComputer.cpp index 8a07862e6f76..a762c2178aeb 100644 --- a/Libraries/LibWeb/CSS/StyleComputer.cpp +++ b/Libraries/LibWeb/CSS/StyleComputer.cpp @@ -2440,7 +2440,9 @@ static Vector collect_presentational_hint_properties(DOM::Abstrac // Which properties a hint decides is a fact about the element, and this is the one place that // knows it: mapping the attributes needs the element fully built, and for a table cell it needs // the table's computed style, so it cannot be done when the element arrives. - record_element_presentational_hint_properties(element, properties); + if (element.presentational_hint_properties_need_publication(properties) + && record_element_presentational_hint_properties(element, properties)) + element.did_publish_presentational_hint_properties(properties); return properties; } diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp index 1df25bbc5095..e37ef6e00b32 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp @@ -246,7 +246,7 @@ StyleAtomID StyleEngine::intern_text_atom(Utf16View text) StyleAtomID StyleEngine::intern_language_atom(Utf16View text) { auto atom = intern_text_atom(text); - if (atom == 0) + if (atom == 0 || text.is_empty() || m_published_language_atoms.set(atom) != AK::HashSetResult::InsertedNewEntry) return atom; Vector code_units; @@ -283,9 +283,11 @@ void StyleEngine::set_element_language(StyleNodeID node, StyleAtomID language, U // A language range is not a name, so `:lang()` compares against the tag itself rather than // against the atom. The text is recorded once per language, not once per element. Vector code_units; - code_units.ensure_capacity(tag.length_in_code_units()); - for (size_t i = 0; i < tag.length_in_code_units(); ++i) - code_units.unchecked_append(tag.code_unit_at(i)); + if (language != 0 && !tag.is_empty() && m_published_language_atoms.set(language) == AK::HashSetResult::InsertedNewEntry) { + code_units.ensure_capacity(tag.length_in_code_units()); + for (size_t i = 0; i < tag.length_in_code_units(); ++i) + code_units.unchecked_append(tag.code_unit_at(i)); + } StyleEngineFFI::style_engine_set_element_language(m_impl, node.value(), language.value(), code_units.data(), code_units.size()); } diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.h b/Libraries/LibWeb/CSS/StyleEngineBridge.h index 6829fe19ab48..619d808990de 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.h +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.h @@ -235,6 +235,7 @@ class WEB_API StyleEngine { GC::Ptr m_style_computer; HashMap m_atoms; + HashTable m_published_language_atoms; HashTable m_nodes_with_pending_initial_features; HashTable m_nodes_awaiting_first_style_computation; size_t m_element_match_capacity { 64 }; diff --git a/Libraries/LibWeb/CSS/StyleEngineInput.cpp b/Libraries/LibWeb/CSS/StyleEngineInput.cpp index 747f795c2d60..5378163ca2a5 100644 --- a/Libraries/LibWeb/CSS/StyleEngineInput.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineInput.cpp @@ -528,7 +528,8 @@ static void record_element_initial_features(DOM::Element& element) if (!element.part_names().is_empty()) record_element_parts_changed(element); - record_element_inline_style_properties(element); + if (auto const inline_style = element.inline_style(); inline_style && (!inline_style->properties().is_empty() || !inline_style->custom_properties().is_empty())) + record_element_inline_style_properties(element); } void record_element_moved(DOM::Element& element, DOM::Node* old_parent, DOM::Element* old_previous_sibling, DOM::Element* old_next_sibling) @@ -962,12 +963,11 @@ struct DeclaredPropertyColumns { bool declarations_are_complete; }; -static void publish_element_declared_properties(DOM::Element& element, StyleEngineFFI::FfiElementDeclarationKind kind, ReadonlySpan style_properties, bool declarations_are_complete = true) +static bool publish_element_declared_properties(DOM::Element& element, StyleEngineFFI::FfiElementDeclarationKind kind, ReadonlySpan style_properties, bool declarations_are_complete = true) { auto* style_engine = style_engine_for(element); - if (!style_engine || element.style_node_id() == no_style_node || has_pending_initial_features(element)) { - return; - } + if (!style_engine || element.style_node_id() == no_style_node || has_pending_initial_features(element)) + return false; DeclaredPropertyColumns columns(style_properties.size(), declarations_are_complete); for (auto const& property : style_properties) { @@ -977,6 +977,7 @@ static void publish_element_declared_properties(DOM::Element& element, StyleEngi columns.append(property, ExpandShorthands::Yes); } style_engine->set_element_declared_properties(element.style_node_id(), kind, columns.properties, columns.important, columns.operators, columns.values, columns.original_values, columns.declarations_are_complete); + return true; } // An element can arrive with a style attribute already written, so this is published on arrival as @@ -998,9 +999,9 @@ static void record_element_inline_style_properties(DOM::Element& element) // bordered table for that reason. The cascade builds the block anyway, so this costs the call. // // SVG presentation attributes map through the same hook, so they are published under this kind too. -void record_element_presentational_hint_properties(DOM::Element& element, ReadonlySpan hints) +bool record_element_presentational_hint_properties(DOM::Element& element, ReadonlySpan hints) { - publish_element_declared_properties(element, StyleEngineFFI::FfiElementDeclarationKind::PresentationalHint, hints); + return publish_element_declared_properties(element, StyleEngineFFI::FfiElementDeclarationKind::PresentationalHint, hints); } void record_element_declarations_changed(DOM::Element& element, ElementDeclarationKind kind, bool had_declarations, bool has_declarations) diff --git a/Libraries/LibWeb/CSS/StyleEngineInput.h b/Libraries/LibWeb/CSS/StyleEngineInput.h index f0fe7fd81ed7..3c3081567420 100644 --- a/Libraries/LibWeb/CSS/StyleEngineInput.h +++ b/Libraries/LibWeb/CSS/StyleEngineInput.h @@ -69,7 +69,7 @@ WEB_API void record_element_custom_states_changed(DOM::Element&); // publishes the value it now resolves to. WEB_API void record_element_language_and_directionality(DOM::Element&); WEB_API void record_element_directionality(DOM::Element&); -WEB_API void record_element_presentational_hint_properties(DOM::Element&, ReadonlySpan); +WEB_API bool record_element_presentational_hint_properties(DOM::Element&, ReadonlySpan); WEB_API void record_element_animation_names(DOM::Element&, ReadonlySpan); WEB_API void record_element_custom_property_names(DOM::Element&, ReadonlySpan, bool uses_unnamed, bool uses_custom_functions); diff --git a/Libraries/LibWeb/DOM/Element.cpp b/Libraries/LibWeb/DOM/Element.cpp index 725d307c7a20..4eac3e6b662d 100644 --- a/Libraries/LibWeb/DOM/Element.cpp +++ b/Libraries/LibWeb/DOM/Element.cpp @@ -1322,6 +1322,27 @@ void Element::apply_presentational_hints(Vector& properties) } } +bool Element::presentational_hint_properties_need_publication(ReadonlySpan properties) const +{ + if (m_published_presentational_hint_properties.size() == properties.size()) { + bool properties_are_unchanged = true; + for (size_t index = 0; index < properties.size(); ++index) + properties_are_unchanged &= m_published_presentational_hint_properties[index] == properties[index]; + if (properties_are_unchanged) + return false; + } + + return true; +} + +void Element::did_publish_presentational_hint_properties(ReadonlySpan properties) +{ + m_published_presentational_hint_properties.clear(); + m_published_presentational_hint_properties.ensure_capacity(properties.size()); + for (auto const& property : properties) + m_published_presentational_hint_properties.unchecked_append(property); +} + void Element::run_attribute_change_steps(Utf16FlyString const& local_name, Optional const& old_value, Optional const& value, Optional const& namespace_) { attribute_changed(local_name, old_value, value, namespace_); @@ -1986,14 +2007,17 @@ CSS::RequiredInvalidationAfterStyleChange Element::apply_style_engine_reaction(b // Which animations an element references is an index StyleEngine keeps, in the same shape as the // anchor-name registry above: nothing about selector matching can say it, and without it a // `@keyframes` rule cannot find the elements running the animation it describes. - { + auto indexable_animation_names = [](CSS::ComputedValues const& style) { Vector animation_names; - for (auto const& animation_name : new_style->animation_names()) { + for (auto const& animation_name : style.animation_names()) { if (animation_name.syntax != CSS::ComputedAnimationNameSyntax::None) animation_names.append(animation_name.name); } + return animation_names; + }; + auto animation_names = indexable_animation_names(*new_style); + if (old_computed_values ? indexable_animation_names(*old_computed_values) != animation_names : !animation_names.is_empty()) CSS::record_element_animation_names(*this, animation_names); - } // Which custom properties this element declares or references decides which `@property` // registrations reach it. Declaring one matters because registration changes how it computes; // referencing one matters because registration gives it a value where it had none. diff --git a/Libraries/LibWeb/DOM/Element.h b/Libraries/LibWeb/DOM/Element.h index 9ac192acc9dc..6858b9f2e61f 100644 --- a/Libraries/LibWeb/DOM/Element.h +++ b/Libraries/LibWeb/DOM/Element.h @@ -314,13 +314,20 @@ class WEB_API Element // The element's StyleEngine identity, or 0 while it has none. Disconnected and never-styled // elements keep 0, which is what makes them free. [[nodiscard]] CSS::StyleNodeID style_node_id() const { return m_style_node_id; } - void set_style_node_id(CSS::StyleNodeID style_node_id) { m_style_node_id = style_node_id; } + void set_style_node_id(CSS::StyleNodeID style_node_id) + { + if (m_style_node_id != style_node_id) + m_published_presentational_hint_properties.clear(); + m_style_node_id = style_node_id; + } // https://html.spec.whatwg.org/multipage/embedded-content-other.html#dimension-attributes virtual bool supports_dimension_attributes() const { return false; } virtual bool is_presentational_hint(Utf16FlyString const&) const { return false; } virtual void apply_presentational_hints(Vector&) const; + bool presentational_hint_properties_need_publication(ReadonlySpan) const; + void did_publish_presentational_hint_properties(ReadonlySpan); void run_attribute_change_steps(Utf16FlyString const& local_name, Optional const& old_value, Optional const& value, Optional const& namespace_); @@ -881,6 +888,7 @@ class WEB_API Element RefPtr m_custom_property_data; OwnPtr m_style_input_record; PublishedCustomPropertyNames m_published_custom_property_names; + Vector m_published_presentational_hint_properties; void register_element_reference_pseudo_element(CSS::PseudoElement type, GC::Ref element); SyntheticPseudoElement& ensure_synthetic_pseudo_element(CSS::PseudoElement) const; diff --git a/Libraries/LibWeb/Rust/src/css/style/index.rs b/Libraries/LibWeb/Rust/src/css/style/index.rs index 157a521d2788..3021b628d4de 100644 --- a/Libraries/LibWeb/Rust/src/css/style/index.rs +++ b/Libraries/LibWeb/Rust/src/css/style/index.rs @@ -4171,11 +4171,9 @@ impl ElementFactStore { pub fn sweep_auxiliary_catalogs(&mut self) { self.memory_dirty = true; let attribute_catalogs = Rc::make_mut(&mut self.attribute_catalogs); - for (index, text) in attribute_catalogs.language_texts.indexed_iter_mut() { - if self.language_live_counts.get(index).copied().unwrap_or(0) == 0 { - *text = None; - } - } + // Language spellings cross the C++ boundary once per atom and remain available for the + // document lifetime. Unlike attribute values, the set is small and has no demand gate to + // republish a spelling after reclamation. for (index, text) in attribute_catalogs.value_texts.indexed_iter_mut() { if self.attribute_value_live_counts.get(index).copied().unwrap_or(0) == 0 { *text = None; @@ -4786,7 +4784,7 @@ mod tests { } #[test] - fn detached_element_churn_reuses_auxiliary_catalog_storage() { + fn detached_element_churn_reuses_reclaimable_auxiliary_catalog_storage() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); let mut facts = ElementFactStore::new(); let node = StyleNodeID::element(1); @@ -4806,7 +4804,10 @@ mod tests { facts.forget(node); facts.sweep_auxiliary_catalogs(); - assert!(facts.rows.attribute_catalogs.language_texts.iter().all(Option::is_none)); + assert_eq!( + facts.rows.attribute_catalogs.language_texts.get(language.0 as usize), + Some(&Some(vec![index as u16])) + ); assert!( facts .rows From 154ffab3f55343ebc06660dc3cfefff4e64dd764 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 07:54:23 +0200 Subject: [PATCH 28/39] LibWeb: Publish attribute text on selector demand The value text of every distinct attribute, including the style attribute, was pushed to the engine and pinned as a FlyString for the document's life, although only substring and token operators read text. Publish text only for attribute names some attached selector or query applies such an operator to, in both written and folded forms, memoize the published name forms, and record the demand probes so replay stays deterministic. During demand expansion, test each name before reinterning its value so unrelated attributes do not hash their text again. Exercise demanded name forms remaining published after element churn. --- Libraries/LibWeb/CSS/StyleComputer.cpp | 8 ++ Libraries/LibWeb/CSS/StyleComputer.h | 1 + Libraries/LibWeb/CSS/StyleEngineBridge.cpp | 95 ++++++++++++++++++- Libraries/LibWeb/CSS/StyleEngineBridge.h | 20 +++- Libraries/LibWeb/CSS/StyleEngineInput.cpp | 72 +++++--------- Libraries/LibWeb/CSS/StyleEngineInput.h | 2 + Libraries/LibWeb/DOM/SelectorQuery.cpp | 11 ++- .../LibWeb/Rust/StyleEngineBoundary.json | 6 +- Libraries/LibWeb/Rust/src/bin/style_replay.rs | 9 ++ Libraries/LibWeb/Rust/src/css/style/index.rs | 35 +++---- Libraries/LibWeb/Rust/src/css/style/inputs.rs | 30 +++++- Libraries/LibWeb/Rust/src/css/style/mod.rs | 2 + .../LibWeb/Rust/src/css/style/selector.rs | 17 ++++ .../attribute-value-text-demand.txt | 8 ++ .../attribute-value-text-demand.html | 36 +++++++ 15 files changed, 269 insertions(+), 83 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/css/style-engine/attribute-value-text-demand.txt create mode 100644 Tests/LibWeb/Text/input/css/style-engine/attribute-value-text-demand.html diff --git a/Libraries/LibWeb/CSS/StyleComputer.cpp b/Libraries/LibWeb/CSS/StyleComputer.cpp index a762c2178aeb..6ecd5ad9d37f 100644 --- a/Libraries/LibWeb/CSS/StyleComputer.cpp +++ b/Libraries/LibWeb/CSS/StyleComputer.cpp @@ -260,6 +260,14 @@ void StyleComputer::prepare_elements_for_style_computation() } } +void StyleComputer::for_each_style_node(Function callback) const +{ + for (auto element : m_style_nodes) { + if (element) + callback(*element); + } +} + void StyleComputer::visit_edges(Visitor& visitor) { Base::visit_edges(visitor); diff --git a/Libraries/LibWeb/CSS/StyleComputer.h b/Libraries/LibWeb/CSS/StyleComputer.h index d75764323691..e9a9c00a96e2 100644 --- a/Libraries/LibWeb/CSS/StyleComputer.h +++ b/Libraries/LibWeb/CSS/StyleComputer.h @@ -338,6 +338,7 @@ class WEB_API StyleComputer final : public GC::Cell { void unregister_style_node(StyleNodeID style_node_id); [[nodiscard]] GC::Ptr element_for_style_node(StyleNodeID style_node_id) const; void prepare_elements_for_style_computation(); + void for_each_style_node(Function) const; // Style scopes are numbered per document, with zero naming the document's own scope. A scope is // never reused, so a sheet detached with an identity that has been retired detaches nothing diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp index e37ef6e00b32..c396b70f8444 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp @@ -262,20 +262,94 @@ StyleAtomID StyleEngine::intern_case_sensitive_text_atom(Utf16View text) return intern_atom(Utf16FlyString::from_utf16(text)); } -StyleAtomID StyleEngine::intern_attribute_value(Utf16View value) +// The name an attribute is published under, and the any-namespace name it shares. +// +// Three selectors ask three different questions of an attribute called `x`. `[ns|x]` reaches only +// the one in that namespace, `[x]` reaches only the one in no namespace - which is what the bare +// local name is - and `[*|x]` reaches whichever of them the element carries. The first two name +// exactly one of an element's attributes, so they are the key: an element can hold `x` in several +// namespaces at once, and each is a fact with its own value. `[*|x]` asks about all of them +// together, so the shared form is published as an identity of the name rather than as a fact of its +// own, and one entry per attribute answers all three. +StyleAtomID StyleEngine::intern_attribute_name(Utf16FlyString const& local_name, Optional const& namespace_uri) +{ + auto local = intern_atom(local_name); + auto namespace_atom = !namespace_uri.has_value() || namespace_uri->is_empty() + ? StyleAtomID {} + : intern_case_sensitive_text_atom(namespace_uri->view()); + auto& names_by_namespace = m_attribute_name_atoms.ensure(local, [] { return HashMap {}; }); + if (auto name = names_by_namespace.get(namespace_atom); name.has_value()) + return name.release_value(); + + auto in_namespace = [&](StyleAtomID name) { + if (namespace_atom == 0) + return name; + return intern_qualified_atom(namespace_atom, name); + }; + auto any_namespace = intern_qualified_atom(StyleEngine::any_namespace, local); + auto name = in_namespace(local); + + StyleAtomID folded_name; + StyleAtomID folded_local; + if (auto folded = local_name.to_ascii_lowercase(); folded != local_name) { + auto folded_atom = intern_atom(folded); + folded_name = in_namespace(folded_atom); + folded_local = intern_qualified_atom(StyleEngine::any_namespace, folded_atom); + } + + note_attribute_name_forms(name, any_namespace, folded_name, folded_local); + names_by_namespace.set(namespace_atom, name); + return name; +} + +StyleAtomID StyleEngine::intern_attribute_value(StyleAtomID name, Utf16View value) +{ + auto atom = intern_case_sensitive_text_atom(value); + if (!attribute_name_requires_value_text(name)) + return atom; + + publish_attribute_value_text(atom, value); + return atom; +} + +void StyleEngine::backfill_attribute_value_text_if_required(StyleAtomID name, Utf16View value) { + if (!attribute_name_requires_value_text(name)) + return; + auto atom = intern_case_sensitive_text_atom(value); + publish_attribute_value_text(atom, value); +} + +void StyleEngine::publish_attribute_value_text(StyleAtomID atom, Utf16View value) +{ // The engine holds one copy of the text per currently used value. Ask whether it survived // reclamation before copying it out of the attribute's representation again. if (StyleEngineFFI::style_engine_has_attribute_value_text(m_impl, atom.value())) - return atom; + return; Vector code_units; code_units.ensure_capacity(value.length_in_code_units()); for (size_t i = 0; i < value.length_in_code_units(); ++i) code_units.unchecked_append(value.code_unit_at(i)); StyleEngineFFI::style_engine_set_attribute_value_text(m_impl, atom.value(), code_units.data(), code_units.size()); - return atom; +} + +bool StyleEngine::refresh_attribute_value_text_requirements() +{ + auto version = StyleEngineFFI::style_engine_attribute_value_text_requirements_version(m_impl); + if (version == m_attribute_value_text_requirements_version) + return false; + m_attribute_value_text_requirements_version = version; + m_attribute_names_requiring_value_text.clear(); + return true; +} + +bool StyleEngine::attribute_name_requires_value_text(StyleAtomID name) +{ + return m_attribute_names_requiring_value_text.ensure(name, [&] { + return StyleEngineFFI::style_engine_attribute_name_requires_value_text(m_impl, name.value()); + }); } void StyleEngine::set_element_language(StyleNodeID node, StyleAtomID language, Utf16View tag) @@ -401,8 +475,11 @@ void StyleEngine::submit_recorded_input() { if (m_style_computer) publish_pending_element_features(*this, *m_style_computer); - if (!has_recorded_input()) + if (!has_recorded_input()) { + if (refresh_attribute_value_text_requirements() && m_style_computer) + publish_required_attribute_value_texts(*this, *m_style_computer); return; + } InputTransaction transaction { .tree_deltas = m_tree_deltas.data(), @@ -429,6 +506,11 @@ void StyleEngine::submit_recorded_input() m_state_deltas.clear_with_capacity(); m_element_declaration_deltas.clear_with_capacity(); m_element_style_inputs.clear_with_capacity(); + + // Selector demand can arrive while the program change and element facts are still staged. + // Refresh after applying the fact batch, then backfill values before matching observes it. + if (refresh_attribute_value_text_requirements() && m_style_computer) + publish_required_attribute_value_texts(*this, *m_style_computer); } void StyleEngine::apply_transaction(InputTransaction const& transaction) @@ -511,7 +593,10 @@ bool StyleEngine::consume_published_match_answer(StyleNodeID node, Vector selectors) { - return StyleEngineFFI::style_engine_compile_selector_query(m_impl, selectors.data(), selectors.size()); + auto* query = StyleEngineFFI::style_engine_compile_selector_query(m_impl, selectors.data(), selectors.size()); + if (refresh_attribute_value_text_requirements() && m_style_computer) + publish_required_attribute_value_texts(*this, *m_style_computer); + return query; } void StyleEngine::destroy_selector_query(void* query) diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.h b/Libraries/LibWeb/CSS/StyleEngineBridge.h index 619d808990de..7bdf6e8881fb 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.h +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.h @@ -150,9 +150,17 @@ class WEB_API StyleEngine { // The same, without the ASCII folding, for names compared literally such as namespace URIs. StyleAtomID intern_case_sensitive_text_atom(Utf16View); - // Interns an attribute value and records what it spells, so a value operator can test it - // without a DOM to ask. Values repeat heavily, so the text crosses once per distinct value. - StyleAtomID intern_attribute_value(Utf16View); + // Interns the exact identity an attribute fact uses and memoizes its namespace and folded + // forms. Demand expansion revisits every live attribute, so these forms must not cross the + // boundary again merely to recover an already published name. + StyleAtomID intern_attribute_name(Utf16FlyString const& local_name, Optional const& namespace_uri); + + // Interns an attribute value and records what it spells when a selector for this name needs + // text. Values repeat heavily, so demanded text crosses once per distinct value. + StyleAtomID intern_attribute_value(StyleAtomID name, Utf16View value); + // Demand expansion already has every value identity. Check the name before interning the text + // so attributes no selector reads do not pay another string hash. + void backfill_attribute_value_text_if_required(StyleAtomID name, Utf16View value); // Deltas accumulate here and cross in one flat batch per style flush, never one call per // element. @@ -230,12 +238,18 @@ class WEB_API StyleEngine { bool read_matches(StyleNodeID, Vector&, Optional); void apply_transaction(InputTransaction const&); void submit_recorded_input(); + bool refresh_attribute_value_text_requirements(); + [[nodiscard]] bool attribute_name_requires_value_text(StyleAtomID); + void publish_attribute_value_text(StyleAtomID, Utf16View); void* m_impl { nullptr }; GC::Ptr m_style_computer; HashMap m_atoms; HashTable m_published_language_atoms; + HashMap> m_attribute_name_atoms; + HashMap m_attribute_names_requiring_value_text; + u64 m_attribute_value_text_requirements_version { 0 }; HashTable m_nodes_with_pending_initial_features; HashTable m_nodes_awaiting_first_style_computation; size_t m_element_match_capacity { 64 }; diff --git a/Libraries/LibWeb/CSS/StyleEngineInput.cpp b/Libraries/LibWeb/CSS/StyleEngineInput.cpp index 5378163ca2a5..01baf920597e 100644 --- a/Libraries/LibWeb/CSS/StyleEngineInput.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineInput.cpp @@ -181,42 +181,6 @@ static StyleEngineFFI::FfiTreeRelations detached_relations() }; } -// The name an attribute is published under, and the any-namespace name it shares. -// -// Three selectors ask three different questions of an attribute called `x`. `[ns|x]` reaches only -// the one in that namespace, `[x]` reaches only the one in no namespace - which is what the bare -// local name is - and `[*|x]` reaches whichever of them the element carries. The first two name -// exactly one of an element's attributes, so they are the key: an element can hold `x` in several -// namespaces at once, and each is a fact with its own value. `[*|x]` asks about all of them -// together, so the shared form is published as an identity of the name rather than as a fact of its -// own, and one entry per attribute answers all three. -static StyleAtomID attribute_name_atom(StyleEngine& style_engine, Utf16FlyString const& local_name, Optional const& namespace_uri) -{ - auto local = style_engine.intern_atom(local_name); - auto any_namespace = style_engine.intern_qualified_atom(StyleEngine::any_namespace, local); - auto in_namespace = [&](StyleAtomID name) { - if (!namespace_uri.has_value() || namespace_uri->is_empty()) - return name; - return style_engine.intern_qualified_atom(style_engine.intern_case_sensitive_text_atom(namespace_uri->view()), name); - }; - auto name = in_namespace(local); - - // An attribute name is matched ASCII case-insensitively against an HTML element in an HTML - // document, and a selector dispatches on the folded form, so an attribute whose own name is not - // already lowercase has to answer to that form as well. Only a non-HTML element can hold one: - // both the parser and `setAttribute` fold the name for an HTML element in an HTML document. - StyleAtomID folded_name; - StyleAtomID folded_local; - if (auto folded = local_name.to_ascii_lowercase(); folded != local_name) { - auto folded_atom = style_engine.intern_atom(folded); - folded_name = in_namespace(folded_atom); - folded_local = style_engine.intern_qualified_atom(StyleEngine::any_namespace, folded_atom); - } - - style_engine.note_attribute_name_forms(name, any_namespace, folded_name, folded_local); - return name; -} - void record_element_connected(DOM::Element& element) { auto* style_engine = style_engine_for(element); @@ -321,7 +285,8 @@ static void publish_element_selector_features(StyleEngine& style_engine, DOM::El for (auto const& class_name : element.class_names()) publish_feature(StyleEngineFFI::FfiFeatureKind::Class, intern_id_or_class_atom(style_engine, element, class_name), StyleEngineFFI::FfiFeatureValueKind::Present, StyleAtomID {}); element.for_each_attribute([&](DOM::QualifiedName const& name, Utf16View value) { - publish_feature(StyleEngineFFI::FfiFeatureKind::Attribute, attribute_name_atom(style_engine, name.local_name(), name.namespace_()), StyleEngineFFI::FfiFeatureValueKind::Atom, style_engine.intern_attribute_value(value)); + auto name_atom = style_engine.intern_attribute_name(name.local_name(), name.namespace_()); + publish_feature(StyleEngineFFI::FfiFeatureKind::Attribute, name_atom, StyleEngineFFI::FfiFeatureValueKind::Atom, style_engine.intern_attribute_value(name_atom, value)); }); bool has_nonempty_text_child = false; @@ -370,14 +335,27 @@ static void publish_element_selector_features(StyleEngine& style_engine, DOM::El custom_states); } -void populate_isolated_selector_query_engine(StyleEngine& style_engine, DOM::ParentNode& root, Function, StyleNodeID)> const& publish_identity) +void publish_required_attribute_value_texts(StyleEngine& style_engine, StyleComputer& style_computer) { - style_engine.set_fold_id_and_class_name_case(root.document().in_quirks_mode()); + style_computer.for_each_style_node([&](DOM::Element& element) { + element.for_each_attribute([&](DOM::QualifiedName const& name, Utf16View value) { + auto name_atom = style_engine.intern_attribute_name(name.local_name(), name.namespace_()); + style_engine.backfill_attribute_value_text_if_required(name_atom, value); + }); + }); +} + +void configure_isolated_selector_query_engine(StyleEngine& style_engine, DOM::Document& document) +{ + style_engine.set_fold_id_and_class_name_case(document.in_quirks_mode()); style_engine.set_html_element_namespace( - root.document().document_type() == DOM::Document::Type::HTML + document.document_type() == DOM::Document::Type::HTML ? style_engine.intern_case_sensitive_text_atom(Namespace::HTML.view()) : 0); +} +void populate_isolated_selector_query_engine(StyleEngine& style_engine, DOM::ParentNode& root, Function, StyleNodeID)> const& publish_identity) +{ Optional non_element_root_identity; if (!is(root) && !is(root)) { non_element_root_identity = style_engine.allocate_style_node(); @@ -2217,17 +2195,19 @@ void record_element_attribute_changed(DOM::Element& element, Utf16FlyString cons if (name == HTML::AttributeNames::headingoffset || name == HTML::AttributeNames::headingreset) record_heading_levels_in_subtree(element); - // Both values cross as atoms, with their text recorded once per distinct value. This lets the - // match evaluator reconstruct either side of a transaction without asking the DOM, and two - // different values cannot cancel in the journal merely because both are present. + // Both values cross as atoms. Their text is recorded once per distinct value only when a + // compiled selector for this attribute uses an operator that cannot compare atom identities. + // This lets the match evaluator reconstruct either side of such a transaction without asking + // the DOM, and two different values cannot cancel in the journal merely because both are + // present. // The same name an arriving attribute publishes, with the same other forms noted alongside it. - // See `attribute_name_atom`. - auto atom = attribute_name_atom(*style_engine, name, namespace_uri); + // See `StyleEngine::intern_attribute_name`. + auto atom = style_engine->intern_attribute_name(name, namespace_uri); auto kind_of = [](Optional const& value) { return value.has_value() ? StyleEngineFFI::FfiFeatureValueKind::Atom : StyleEngineFFI::FfiFeatureValueKind::Absent; }; auto atom_of = [&](Optional const& value) { - return value.has_value() ? style_engine->intern_attribute_value(*value) : 0; + return value.has_value() ? style_engine->intern_attribute_value(atom, *value) : 0; }; auto old_kind = kind_of(old_value); auto old_atom = atom_of(old_value); diff --git a/Libraries/LibWeb/CSS/StyleEngineInput.h b/Libraries/LibWeb/CSS/StyleEngineInput.h index 3c3081567420..5b2db84699a9 100644 --- a/Libraries/LibWeb/CSS/StyleEngineInput.h +++ b/Libraries/LibWeb/CSS/StyleEngineInput.h @@ -28,10 +28,12 @@ class StyleEngine; WEB_API void record_element_connected(DOM::Element&); WEB_API void prepare_style_nodes_for_subtree(DOM::Node&); WEB_API void publish_pending_element_features(StyleEngine&, StyleComputer&); +WEB_API void publish_required_attribute_value_texts(StyleEngine&, StyleComputer&); // Populate an isolated engine with the current facts of a DOM tree. The callback receives the temporary identity // assigned to each element; no identity or transaction in the document's // resident engine is changed. +WEB_API void configure_isolated_selector_query_engine(StyleEngine&, DOM::Document&); WEB_API void populate_isolated_selector_query_engine(StyleEngine&, DOM::ParentNode&, Function, StyleNodeID)> const&); // Tell the document's engine whether this is an HTML document. Selectors compile against that fact, diff --git a/Libraries/LibWeb/DOM/SelectorQuery.cpp b/Libraries/LibWeb/DOM/SelectorQuery.cpp index 5ba0f720b45e..2bc5ce3b1ed0 100644 --- a/Libraries/LibWeb/DOM/SelectorQuery.cpp +++ b/Libraries/LibWeb/DOM/SelectorQuery.cpp @@ -24,15 +24,18 @@ class IsolatedSelectorQueryEngine { : m_engine(CSS::StyleEngine::DeviceClass::ForegroundDesktop) , m_has_document_root(is(root)) { - CSS::populate_isolated_selector_query_engine(m_engine, root, [&](GC::Ref element, CSS::StyleNodeID identity) { - m_identities.set(element, identity); - }); - + // Attribute-name and default value case behavior are compiled into the query, so publish + // the document kind before compiling rather than while facts are populated afterward. + CSS::configure_isolated_selector_query_engine(m_engine, root.document()); Vector selector_handles; selector_handles.ensure_capacity(selectors.size()); for (auto const& selector : selectors) selector_handles.unchecked_append(&selector->rust_selector()); m_query = m_engine.compile_selector_query(selector_handles); + + CSS::populate_isolated_selector_query_engine(m_engine, root, [&](GC::Ref element, CSS::StyleNodeID identity) { + m_identities.set(element, identity); + }); } ~IsolatedSelectorQueryEngine() diff --git a/Libraries/LibWeb/Rust/StyleEngineBoundary.json b/Libraries/LibWeb/Rust/StyleEngineBoundary.json index d68e43d095ba..80cd17bd26a6 100644 --- a/Libraries/LibWeb/Rust/StyleEngineBoundary.json +++ b/Libraries/LibWeb/Rust/StyleEngineBoundary.json @@ -80,7 +80,9 @@ [77, "StyleDeltaBatch"], [78, "SelectorQueryAtomMappings"], [79, "PrepareSelectorQuery"], - [80, "AddCounterStyleRule"] + [80, "AddCounterStyleRule"], + [81, "AttributeValueTextRequirementsVersion"], + [82, "AttributeNameRequiresValueText"] ], "operations": [ { "event": "SetFoldIdAndClassNameCase", "ffi": "style_engine_set_fold_id_and_class_name_case", "cpp": "set_fold_id_and_class_name_case", "return": "void", "args": [["fold", "bool"]], "body": "engine.set_fold_id_and_class_name_case(fold);" }, @@ -125,6 +127,8 @@ { "event": "SetRuleLayer", "ffi": "style_engine_set_rule_layer", "cpp": "set_rule_layer", "return": "void", "args": [["rule", "style_rule_id"], ["layer", "u32"]], "body": "if rule == 0 { return; } engine.set_rule_layer(RuleID(rule - 1), CascadeLayerID(layer));" }, { "event": "SetAttributeValueText", "ffi": "style_engine_set_attribute_value_text", "return": "void", "args": [["value", "style_atom"], ["text", "u16_slice"]], "body": "if value == 0 { return; } engine.set_attribute_value_text(StyleAtomID(value), text);" }, { "event": "HasAttributeValueText", "ffi": "style_engine_has_attribute_value_text", "return": "bool", "receiver": "const", "args": [["value", "style_atom"]], "body": "let result = engine.has_attribute_value_text(StyleAtomID(value));" }, + { "event": "AttributeValueTextRequirementsVersion", "ffi": "style_engine_attribute_value_text_requirements_version", "return": "u64", "receiver": "const", "replay": false, "args": [], "body": "let result = engine.attribute_value_text_requirements_version();" }, + { "event": "AttributeNameRequiresValueText", "ffi": "style_engine_attribute_name_requires_value_text", "return": "bool", "receiver": "const", "replay": false, "args": [["name", "style_atom"]], "body": "let result = engine.attribute_name_requires_value_text(StyleAtomID(name));" }, { "event": "DiscardStyleTransactionOutputs", "ffi": "style_engine_discard_style_transaction_outputs", "return": "void", "args": [], "body": "engine.discard_style_transaction_outputs();" }, { "event": "RecordLayerStatement", "ffi": "style_engine_record_layer_statement", "cpp": "record_layer_statement", "return": "void", "args": [["sheet", "sheet_id"]], "body": "if sheet != 0 { engine.record_layer_statement(SheetID(sheet - 1)); }" }, { "event": "AddPropertyRule", "ffi": "style_engine_add_property_rule", "cpp": "add_property_rule", "return": "style_rule_id", "args": [["sheet", "sheet_id"], ["before_rule", "style_rule_id"], ["name_atom", "style_atom"], ["has_initial_value", "bool"]], "body": "let result = if sheet == 0 { 0 } else { let before = (before_rule != 0).then(|| RuleID(before_rule - 1)); engine.add_property_rule(SheetID(sheet - 1), before, StyleAtomID(name_atom), has_initial_value).0 + 1 };" }, diff --git a/Libraries/LibWeb/Rust/src/bin/style_replay.rs b/Libraries/LibWeb/Rust/src/bin/style_replay.rs index 954ec402a345..27f786a4211b 100644 --- a/Libraries/LibWeb/Rust/src/bin/style_replay.rs +++ b/Libraries/LibWeb/Rust/src/bin/style_replay.rs @@ -973,6 +973,15 @@ fn run() -> Result<(), Box> { } } } + EventKind::AttributeValueTextRequirementsVersion => { + let _engine = read_engine(&mut event.payload, &live_engines)?; + let _recorded_version = event.payload.read_u64()?; + } + EventKind::AttributeNameRequiresValueText => { + let _engine = read_engine(&mut event.payload, &live_engines)?; + let _name = event.payload.read_u32()?; + let _recorded_result = event.payload.read_bool()?; + } _ => unreachable!("all boundary events are generated or handled explicitly"), } event.payload.finish()?; diff --git a/Libraries/LibWeb/Rust/src/css/style/index.rs b/Libraries/LibWeb/Rust/src/css/style/index.rs index 3021b628d4de..065c494585a0 100644 --- a/Libraries/LibWeb/Rust/src/css/style/index.rs +++ b/Libraries/LibWeb/Rust/src/css/style/index.rs @@ -4171,22 +4171,14 @@ impl ElementFactStore { pub fn sweep_auxiliary_catalogs(&mut self) { self.memory_dirty = true; let attribute_catalogs = Rc::make_mut(&mut self.attribute_catalogs); - // Language spellings cross the C++ boundary once per atom and remain available for the - // document lifetime. Unlike attribute values, the set is small and has no demand gate to - // republish a spelling after reclamation. + // Language spellings and attribute-name forms cross the C++ boundary once per atom and + // remain available for the document lifetime. Unlike attribute values, these small fixed + // catalogs have no demand gate to republish an entry after reclamation. for (index, text) in attribute_catalogs.value_texts.indexed_iter_mut() { if self.attribute_value_live_counts.get(index).copied().unwrap_or(0) == 0 { *text = None; } } - for position in 0..attribute_catalogs.name_forms.indices.len() { - let index = attribute_catalogs.name_forms.indices[position]; - if self.attribute_name_live_counts.get(index).copied().unwrap_or(0) == 0 { - attribute_catalogs - .name_forms - .insert(index, AttributeNameForms::default()); - } - } for sets in self.custom_property_set_ids_by_name.iter_mut() { *sets = Vec::new(); @@ -4794,9 +4786,14 @@ mod tests { let attribute_name = StyleAtomID(index * 4 + 1); let attribute_value = StyleAtomID(index * 4 + 2); let custom_property = StyleAtomID(index * 4 + 3); + let name_forms = AttributeNameForms { + local: StyleAtomID(index * 4 + 1000), + folded_name: StyleAtomID(index * 4 + 1001), + folded_local: StyleAtomID(index * 4 + 1002), + }; facts.set_language_text(language, &[index as u16]); facts.set_language(node, language); - facts.note_attribute_name_forms(attribute_name, AttributeNameForms::default()); + facts.note_attribute_name_forms(attribute_name, name_forms); facts.set_attribute_value_text(attribute_value, &[index as u16]); facts.set_attribute(node, attribute_name, attribute_value, true, &mut memory); facts.set_custom_property_names(node, &[custom_property], &mut memory); @@ -4808,17 +4805,9 @@ mod tests { facts.rows.attribute_catalogs.language_texts.get(language.0 as usize), Some(&Some(vec![index as u16])) ); - assert!( - facts - .rows - .attribute_catalogs - .name_forms - .indices - .iter() - .copied() - .all(|index| { - facts.rows.attribute_catalogs.name_forms.get(index) == Some(AttributeNameForms::default()) - }) + assert_eq!( + facts.rows.attribute_catalogs.name_forms.get(attribute_name.0 as usize), + Some(name_forms) ); assert!(facts.rows.attribute_catalogs.value_texts.iter().all(Option::is_none)); assert!(facts.custom_property_name_sets.index_is_empty()); diff --git a/Libraries/LibWeb/Rust/src/css/style/inputs.rs b/Libraries/LibWeb/Rust/src/css/style/inputs.rs index 74d919c7d823..606aa50cc2b5 100644 --- a/Libraries/LibWeb/Rust/src/css/style/inputs.rs +++ b/Libraries/LibWeb/Rust/src/css/style/inputs.rs @@ -93,6 +93,8 @@ impl StyleEngine { transaction_fact_view: None, facts: ElementFactStore::new(), programs, + attribute_value_text_names: HashSet::default(), + attribute_value_text_requirements_version: 0, selector_programs_need_sweep: false, routing: Rc::new(RoutingRegistry::new()), selector_truth_changes: SelectorTruthChanges::default(), @@ -519,6 +521,13 @@ impl StyleEngine { } } let compiled = compiler.finish(); + let mut requirements_changed = false; + for name in compiled.attribute_value_text_names() { + requirements_changed |= self.attribute_value_text_names.insert(name); + } + if requirements_changed { + self.attribute_value_text_requirements_version += 1; + } if let Some(reusable) = reusable && self.programs.get(reusable) == &compiled { @@ -551,7 +560,26 @@ impl StyleEngine { for selector in selectors { compiler.compile_for_query(selector); } - compiler.finish() + let program = compiler.finish(); + let mut requirements_changed = false; + for name in program.attribute_value_text_names() { + requirements_changed |= self.attribute_value_text_names.insert(name); + } + if requirements_changed { + self.attribute_value_text_requirements_version += 1; + } + program + } + + pub fn attribute_value_text_requirements_version(&self) -> u64 { + self.attribute_value_text_requirements_version + } + + #[must_use] + pub fn attribute_name_requires_value_text(&self, name: StyleAtomID) -> bool { + self.facts + .attribute_name_keys(name) + .any(|key| self.attribute_value_text_names.contains(&key)) } #[must_use] diff --git a/Libraries/LibWeb/Rust/src/css/style/mod.rs b/Libraries/LibWeb/Rust/src/css/style/mod.rs index 1529ac398b8c..d6c7732b7423 100644 --- a/Libraries/LibWeb/Rust/src/css/style/mod.rs +++ b/Libraries/LibWeb/Rust/src/css/style/mod.rs @@ -867,6 +867,8 @@ pub struct StyleEngine { transaction_fact_view: Option, facts: ElementFactStore, programs: SelectorPrograms, + attribute_value_text_names: HashSet, + attribute_value_text_requirements_version: u64, selector_programs_need_sweep: bool, routing: Rc, /// Exact selector changes and refresh requests emitted by the current transaction. diff --git a/Libraries/LibWeb/Rust/src/css/style/selector.rs b/Libraries/LibWeb/Rust/src/css/style/selector.rs index 663895f36e66..34f21c95a05c 100644 --- a/Libraries/LibWeb/Rust/src/css/style/selector.rs +++ b/Libraries/LibWeb/Rust/src/css/style/selector.rs @@ -646,6 +646,23 @@ pub struct SelectorProgram { } impl SelectorProgram { + /// Attribute names whose tests cannot be answered from the value atom alone. + pub fn attribute_value_text_names(&self) -> impl Iterator + '_ { + self.nodes + .iter() + .filter_map(|node| { + let SelectorOp::Feature(FeatureTest::Attribute(test)) = node else { + return None; + }; + (test.operator != AttributeOperator::Presence && test.value_atom.is_none()).then_some(test) + }) + .flat_map(|test| { + [Some(test.name), (test.folded != test.name).then_some(test.folded)] + .into_iter() + .flatten() + }) + } + #[must_use] pub fn node(&self, id: SelectorNodeID) -> SelectorOp { self.nodes[id.0 as usize] diff --git a/Tests/LibWeb/Text/expected/css/style-engine/attribute-value-text-demand.txt b/Tests/LibWeb/Text/expected/css/style-engine/attribute-value-text-demand.txt new file mode 100644 index 000000000000..d1d6044e59f7 --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/style-engine/attribute-value-text-demand.txt @@ -0,0 +1,8 @@ +before sheet: rgb(0, 0, 0) +late substring rule: rgb(255, 0, 0) +late insensitive rule: rgb(0, 128, 0) +uppercase href rule: 7px +uppercase data rule: 9px +connected style query: true +demanded attribute mutation: rgb(0, 0, 0) +isolated substring query: true diff --git a/Tests/LibWeb/Text/input/css/style-engine/attribute-value-text-demand.html b/Tests/LibWeb/Text/input/css/style-engine/attribute-value-text-demand.html new file mode 100644 index 000000000000..188b54890772 --- /dev/null +++ b/Tests/LibWeb/Text/input/css/style-engine/attribute-value-text-demand.html @@ -0,0 +1,36 @@ + + +
+ +
+ From 6d4d8fabf6667fc718aff91f6f0e71e1c01fe269 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 08:56:57 +0200 Subject: [PATCH 29/39] LibWeb: Retain style tree depth Subtree membership was an ancestor walk with no early rejection, and preorder walks climbed the parent chain to find every subtree end. Retain a depth column maintained by the same relation updates, recompute only staged arrivals and nodes whose installed parent changes, and let a changed ancestor cover changed descendants. Compare with the resident parent before installation so sibling-only staging and repeated mid-transaction applications cannot suppress required recomputation. Reject subtree tests on depth before walking. --- Documentation/Style/StyleEngine.md | 10 +- Libraries/LibWeb/Rust/src/css/style/inputs.rs | 24 ++- Libraries/LibWeb/Rust/src/css/style/tests.rs | 150 ++++++++++++++ Libraries/LibWeb/Rust/src/css/style/tree.rs | 184 +++++++++++++++++- 4 files changed, 354 insertions(+), 14 deletions(-) diff --git a/Documentation/Style/StyleEngine.md b/Documentation/Style/StyleEngine.md index a48d542b5fba..d359c73b5310 100644 --- a/Documentation/Style/StyleEngine.md +++ b/Documentation/Style/StyleEngine.md @@ -948,7 +948,7 @@ Intern pools use compact document-local handles and weak reclamation where possi ### 10.5 Per-node metadata budget -Mandatory per-node state is at most **six 32-bit words**: +Mandatory per-node state is at most **eight 32-bit words**: ```text StyleNodeID or its DOM mapping (an existing DOM identity can satisfy this without duplication) @@ -957,17 +957,19 @@ parent required relation column first element child required relation column next element sibling required relation column previous element sibling required relation column +depth required relation column +tree scope conditional relation column ``` ```text MandatoryNodeBytes(surface) = BaseMandatoryNodeBytes + ConditionalRelationBytes(surface) -BaseMandatoryNodeBytes <= 24 -ConditionalRelationBytes(surface) <= 8 +BaseMandatoryNodeBytes <= 28 +ConditionalRelationBytes(surface) <= 4 MandatoryNodeBytes(surface) <= 32 ``` -The engine asserts the relation-column budget in its own tests. The conditional allowance covers tree-scope identity, allocated only when the document requires it and no authoritative field can be exposed safely without duplication. Slot, part, pseudo, or future relation navigation must derive a compact identity or replace the physical representation; it cannot raise the 32-byte cap. (`StyleNodeID` is a `NonZeroU32`, so optional relation slots niche-pack into one word.) +The engine asserts the relation-column budget in its own tests. The conditional allowance covers tree-scope identity, allocated only when the document requires it and no authoritative field can be exposed safely without duplication. Depth rejects impossible ancestry checks immediately and bounds the remaining parent walk. Slot, part, pseudo, or future relation navigation must derive a compact identity or replace the physical representation; it cannot raise the 32-byte cap. (`StyleNodeID` is a `NonZeroU32`, so optional relation slots niche-pack into one word.) Optional context, winner, dependency, and witness handles live in sparse Tier-3 columns and do not consume a reserved word on every node. Shared live style payloads are reported separately. diff --git a/Libraries/LibWeb/Rust/src/css/style/inputs.rs b/Libraries/LibWeb/Rust/src/css/style/inputs.rs index 606aa50cc2b5..7a74093cf2cc 100644 --- a/Libraries/LibWeb/Rust/src/css/style/inputs.rs +++ b/Libraries/LibWeb/Rust/src/css/style/inputs.rs @@ -1069,16 +1069,27 @@ impl StyleEngine { } let staged_rows = self.tree_staging.dirty_rows(); let staged_first_children = self.tree_staging.dirty_first_children(); + // Depth changes only for arrivals and for nodes whose parent differs from the resident one, + // read before installation: the frozen before-side parent misses a move that a mid-transaction + // application already installed, and a sibling-only row must not count as a moved parent. + // A moved parent's subtree walk covers its moved descendants, so those are skipped below. + let depth_recompute_nodes = staged_rows + .iter() + .filter_map(|&(node, before, relations)| { + let relations = relations?; + (before.is_none() || self.tree.parent(node) != relations.parent).then_some(node) + }) + .collect::>(); for &(node, _, relations) in &staged_rows { let Some(relations) = relations else { - self.tree.set_parent(node, None); + self.tree.set_parent_without_updating_depth(node, None); self.tree.set_next_element_sibling(node, None); self.tree.set_previous_element_sibling(node, None); self.tree.set_assigned_slot(node, None, &mut self.memory); continue; }; - self.tree.set_parent(node, relations.parent); + self.tree.set_parent_without_updating_depth(node, relations.parent); self.tree.set_next_element_sibling(node, relations.next_element_sibling); self.tree .set_previous_element_sibling(node, relations.previous_element_sibling); @@ -1094,6 +1105,15 @@ impl StyleEngine { for (parent, _, child) in &staged_first_children { self.tree.set_first_element_child(*parent, *child); } + for &node in &depth_recompute_nodes { + let parent_is_recomputed = self + .tree + .parent(node) + .is_some_and(|parent| depth_recompute_nodes.binary_search(&parent).is_ok()); + if !parent_is_recomputed { + self.tree.recompute_subtree_depth(node); + } + } for &(node, _, relations) in &staged_rows { if relations.is_some() || !self.tree.is_live(node) { continue; diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index 1f6c8901e3c3..9d102c1adb56 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -1520,6 +1520,156 @@ fn a_departed_following_sibling_anchor_widens_when_its_old_next_sibling_relocate ); } +#[test] +fn sibling_only_tree_staging_does_not_recompute_the_sibling_subtree_depth() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let mut raw = [0_u32; 4]; + engine.allocate_style_nodes(&mut raw); + let nodes: Vec<_> = raw.into_iter().map(|raw| StyleNodeID::from_raw(raw).unwrap()).collect(); + engine.record_tree_delta(nodes[0], None, Some(relations(None, None, None))); + engine.record_tree_delta(nodes[1], None, Some(relations(Some(raw[0]), None, None))); + engine.record_tree_delta(nodes[2], None, Some(relations(Some(raw[1]), None, None))); + engine.record_tree_delta(nodes[3], None, Some(relations(Some(raw[2]), None, None))); + discard_transaction(&mut engine); + engine.tree.take_depth_recompute_visits(); + + let mut inserted_raw = [0_u32; 1]; + engine.allocate_style_nodes(&mut inserted_raw); + let inserted = StyleNodeID::from_raw(inserted_raw[0]).unwrap(); + engine.record_tree_delta(inserted, None, Some(relations(Some(raw[0]), None, Some(raw[1])))); + engine.apply_staged_tree_deltas(); + + assert_eq!(engine.tree.take_depth_recompute_visits(), 1); + assert_eq!(engine.tree.depth(nodes[3]), 3); +} + +#[test] +fn wrapping_existing_children_recomputes_depth_from_the_arriving_parent() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let mut raw = [0_u32; 4]; + engine.allocate_style_nodes(&mut raw); + let nodes: Vec<_> = raw.into_iter().map(|raw| StyleNodeID::from_raw(raw).unwrap()).collect(); + engine.record_tree_delta(nodes[0], None, Some(relations(None, None, None))); + engine.record_tree_delta(nodes[1], None, Some(relations(Some(raw[0]), None, Some(raw[2])))); + engine.record_tree_delta(nodes[2], None, Some(relations(Some(raw[0]), Some(raw[1]), None))); + engine.record_tree_delta(nodes[3], None, Some(relations(Some(raw[1]), None, None))); + discard_transaction(&mut engine); + + let mut wrapper_raw = [0_u32; 1]; + engine.allocate_style_nodes(&mut wrapper_raw); + let wrapper = StyleNodeID::from_raw(wrapper_raw[0]).unwrap(); + engine.record_tree_delta( + nodes[1], + Some(relations(Some(raw[0]), None, Some(raw[2]))), + Some(relations(Some(wrapper.raw()), None, Some(raw[2]))), + ); + engine.record_tree_delta( + nodes[2], + Some(relations(Some(raw[0]), Some(raw[1]), None)), + Some(relations(Some(wrapper.raw()), Some(raw[1]), None)), + ); + engine.record_tree_delta(wrapper, None, Some(relations(Some(raw[0]), None, None))); + engine.apply_staged_tree_deltas(); + + assert_eq!(engine.tree.depth(wrapper), 1); + assert_eq!(engine.tree.depth(nodes[1]), 2); + assert_eq!(engine.tree.depth(nodes[2]), 2); + assert_eq!(engine.tree.depth(nodes[3]), 3); +} + +#[test] +fn reparenting_between_equal_depth_parents_still_visits_the_subtree() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let mut raw = [0_u32; 5]; + engine.allocate_style_nodes(&mut raw); + let nodes: Vec<_> = raw.into_iter().map(|raw| StyleNodeID::from_raw(raw).unwrap()).collect(); + engine.record_tree_delta(nodes[0], None, Some(relations(None, None, None))); + engine.record_tree_delta(nodes[1], None, Some(relations(Some(raw[0]), None, Some(raw[2])))); + engine.record_tree_delta(nodes[2], None, Some(relations(Some(raw[0]), Some(raw[1]), None))); + engine.record_tree_delta(nodes[3], None, Some(relations(Some(raw[1]), None, None))); + engine.record_tree_delta(nodes[4], None, Some(relations(Some(raw[3]), None, None))); + discard_transaction(&mut engine); + engine.tree.take_depth_recompute_visits(); + + engine.record_tree_delta( + nodes[3], + Some(relations(Some(raw[1]), None, None)), + Some(relations(Some(raw[2]), None, None)), + ); + engine.apply_staged_tree_deltas(); + + assert_eq!(engine.tree.take_depth_recompute_visits(), 2); + assert_eq!(engine.tree.depth(nodes[3]), 2); + assert_eq!(engine.tree.depth(nodes[4]), 3); +} + +#[test] +fn reparenting_below_a_sibling_only_staged_parent_recomputes_depth() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let mut raw = [0_u32; 7]; + engine.allocate_style_nodes(&mut raw); + let nodes: Vec<_> = raw.into_iter().map(|raw| StyleNodeID::from_raw(raw).unwrap()).collect(); + let [root, a, b, m, p, c, n] = nodes.as_slice() else { + unreachable!(); + }; + engine.record_tree_delta(*root, None, Some(relations(None, None, None))); + engine.record_tree_delta(*a, None, Some(relations(Some(root.raw()), None, Some(b.raw())))); + engine.record_tree_delta(*b, None, Some(relations(Some(root.raw()), Some(a.raw()), None))); + engine.record_tree_delta(*m, None, Some(relations(Some(a.raw()), None, None))); + engine.record_tree_delta(*p, None, Some(relations(Some(m.raw()), None, None))); + engine.record_tree_delta(*c, None, Some(relations(Some(p.raw()), None, None))); + discard_transaction(&mut engine); + + engine.record_tree_delta( + *n, + None, + Some(relations(Some(root.raw()), Some(a.raw()), Some(b.raw()))), + ); + engine.record_tree_delta( + *p, + Some(relations(Some(m.raw()), None, None)), + Some(relations(Some(b.raw()), None, None)), + ); + engine.apply_staged_tree_deltas(); + + assert_eq!(engine.tree.depth(*p), 2); + assert_eq!(engine.tree.depth(*c), 3); +} + +#[test] +fn moving_back_after_an_intermediate_tree_apply_restores_depth() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let mut raw = [0_u32; 5]; + engine.allocate_style_nodes(&mut raw); + let nodes: Vec<_> = raw.into_iter().map(|raw| StyleNodeID::from_raw(raw).unwrap()).collect(); + let [root, x, q1, q2, p] = nodes.as_slice() else { + unreachable!(); + }; + engine.record_tree_delta(*root, None, Some(relations(None, None, None))); + engine.record_tree_delta(*x, None, Some(relations(Some(root.raw()), None, Some(q1.raw())))); + engine.record_tree_delta(*q1, None, Some(relations(Some(root.raw()), Some(x.raw()), None))); + engine.record_tree_delta(*q2, None, Some(relations(Some(q1.raw()), None, None))); + engine.record_tree_delta(*p, None, Some(relations(Some(x.raw()), None, None))); + discard_transaction(&mut engine); + + engine.record_tree_delta( + *p, + Some(relations(Some(x.raw()), None, None)), + Some(relations(Some(q2.raw()), None, None)), + ); + engine.apply_staged_tree_deltas(); + assert_eq!(engine.tree.depth(*p), 3); + + engine.record_tree_delta( + *p, + Some(relations(Some(q2.raw()), None, None)), + Some(relations(Some(x.raw()), None, None)), + ); + engine.apply_staged_tree_deltas(); + + assert_eq!(engine.tree.depth(*p), 2); +} + /// Builds `root -> [a, b, c]` through the same delta path C++ drives. fn linear_document() -> (StyleEngine, Vec) { let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); diff --git a/Libraries/LibWeb/Rust/src/css/style/tree.rs b/Libraries/LibWeb/Rust/src/css/style/tree.rs index 0e5b17c9856b..62fb9b4d9736 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tree.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tree.rs @@ -449,6 +449,7 @@ pub struct StyleNodeTree { first_element_child: Vec>, next_element_sibling: Vec>, previous_element_sibling: Vec>, + depth: Vec, // Conditional: allocated only for documents that need them. tree_scope: Option>, @@ -462,6 +463,9 @@ pub struct StyleNodeTree { /// Allocated only once a shadow tree exists. shadow: Option>, + + #[cfg(test)] + depth_recompute_visits: usize, } impl StyleNodeTree { @@ -472,18 +476,22 @@ impl StyleNodeTree { first_element_child: Vec::new(), next_element_sibling: Vec::new(), previous_element_sibling: Vec::new(), + depth: Vec::new(), tree_scope: None, live: BitColumn::default(), connected_element_count: 0, pending_reuse: Vec::new(), free_element_indexes: Vec::new(), shadow: None, + #[cfg(test)] + depth_recompute_visits: 0, }; // Slot 0 is never a valid identity; reserving it keeps column indexing direct. tree.parent.push(None); tree.first_element_child.push(None); tree.next_element_sibling.push(None); tree.previous_element_sibling.push(None); + tree.depth.push(0); tree.charge(memory, 0); tree } @@ -519,6 +527,7 @@ impl StyleNodeTree { self.first_element_child[index as usize] = None; self.next_element_sibling[index as usize] = None; self.previous_element_sibling[index as usize] = None; + self.depth[index as usize] = 0; if let Some(column) = self.tree_scope.as_mut() { column[index as usize] = TreeScopeID::DOCUMENT; } @@ -530,6 +539,7 @@ impl StyleNodeTree { self.first_element_child.push(None); self.next_element_sibling.push(None); self.previous_element_sibling.push(None); + self.depth.push(0); if let Some(column) = self.tree_scope.as_mut() { column.push(TreeScopeID::DOCUMENT); } @@ -561,6 +571,7 @@ impl StyleNodeTree { self.first_element_child[index as usize] = None; self.next_element_sibling[index as usize] = None; self.previous_element_sibling[index as usize] = None; + self.depth[index as usize] = 0; self.connected_element_count -= 1; self.pending_reuse.push(index); self.charge(memory, before); @@ -582,10 +593,79 @@ impl StyleNodeTree { // -- Relation maintenance ---------------------------------------------------------------- pub fn set_parent(&mut self, node: StyleNodeID, parent: Option) { + let depth = parent.map_or(0, |parent| { + self.depth(parent).checked_add(1).expect("style tree depth exhausted") + }); + self.set_subtree_depth(node, depth); let index = self.live_element_index(node); self.parent[index] = parent; } + fn set_subtree_depth(&mut self, node: StyleNodeID, depth: u32) { + let index = self.live_element_index(node); + let previous_depth = self.depth[index]; + if depth != previous_depth { + let adjustment = i64::from(depth) - i64::from(previous_depth); + let mut next = Some(node); + while let Some(descendant) = next { + let descendant_index = self.live_element_index(descendant); + self.depth[descendant_index] = u32::try_from(i64::from(self.depth[descendant_index]) + adjustment) + .expect("style tree depth exhausted"); + next = self.first_element_child[descendant_index].or_else(|| { + let mut candidate = descendant; + loop { + if candidate == node { + return None; + } + let candidate_index = self.element_index(candidate); + if let Some(sibling) = self.next_element_sibling[candidate_index] { + return Some(sibling); + } + candidate = self.parent[candidate_index]?; + } + }); + } + } + } + + pub(super) fn set_parent_without_updating_depth(&mut self, node: StyleNodeID, parent: Option) { + let index = self.live_element_index(node); + self.parent[index] = parent; + } + + /// Update one final staged subtree after all parent and sibling columns are installed. + pub(super) fn recompute_subtree_depth(&mut self, root: StyleNodeID) { + let mut next = Some(root); + while let Some(node) = next { + #[cfg(test)] + { + self.depth_recompute_visits += 1; + } + let index = self.live_element_index(node); + self.depth[index] = self.parent[index].map_or(0, |parent| { + self.depth(parent).checked_add(1).expect("style tree depth exhausted") + }); + next = self.first_element_child[index].or_else(|| { + let mut candidate = node; + loop { + if candidate == root { + return None; + } + let candidate_index = self.element_index(candidate); + if let Some(sibling) = self.next_element_sibling[candidate_index] { + return Some(sibling); + } + candidate = self.parent[candidate_index]?; + } + }); + } + } + + #[cfg(test)] + pub(super) fn take_depth_recompute_visits(&mut self) -> usize { + core::mem::take(&mut self.depth_recompute_visits) + } + pub fn set_first_element_child(&mut self, node: StyleNodeID, child: Option) { let index = self.live_element_index(node); self.first_element_child[index] = child; @@ -804,6 +884,11 @@ impl StyleNodeTree { } } + #[must_use] + pub fn depth(&self, node: StyleNodeID) -> u32 { + self.depth[self.element_index(node)] + } + /// The preceding element sibling, served from a resident column. /// /// This column earned its four bytes per element by measurement: the scan it replaced walked @@ -842,11 +927,23 @@ impl StyleNodeTree { } /// Whether `node` lies inside the subtree rooted at `root`. Because `StyleNodeID` is not a - /// tree-order label, a subtree impact region is not a numeric interval, so proving membership - /// costs one relation step per level. Those steps are served from resident columns. + /// tree-order label, a subtree impact region is not a numeric interval. The depth column rejects + /// impossible membership immediately and bounds the remaining parent walk exactly. #[must_use] pub fn is_in_subtree_of(&self, node: StyleNodeID, root: StyleNodeID) -> bool { - node == root || self.ancestors(node).any(|ancestor| ancestor == root) + let node_depth = self.depth(node); + let root_depth = self.depth(root); + if node_depth < root_depth { + return false; + } + let mut candidate = node; + for _ in root_depth..node_depth { + let Some(parent) = self.parent(candidate) else { + return false; + }; + candidate = parent; + } + candidate == root } // -- Accounting -------------------------------------------------------------------------- @@ -860,6 +957,7 @@ impl StyleNodeTree { self.first_element_child, self.next_element_sibling, self.previous_element_sibling, + self.depth, self.pending_reuse, self.free_element_indexes, ]; @@ -994,6 +1092,22 @@ mod tests { assert!(!staging.is_empty()); } + #[test] + fn dirty_tree_rows_are_sorted_by_node_identity() { + let low = StyleNodeID::element(1); + let high = StyleNodeID::element(70); + let relations = Some(TreeRelations::detached(TreeScopeID::DOCUMENT)); + let mut staging = TreeRelationStaging::default(); + + staging.stage_row(high, None, relations); + staging.stage_row(low, None, relations); + + assert_eq!( + staging.dirty_rows(), + vec![(low, None, relations), (high, None, relations)] + ); + } + /// Builds `parent -> [children]` shapes without repeating relation bookkeeping in every test. struct TreeFixture { memory: MemoryController, @@ -1080,11 +1194,59 @@ mod tests { assert_eq!(fixture.tree.previous_element_sibling(first), None); assert_eq!(fixture.tree.previous_element_sibling(third), Some(second)); + assert_eq!(fixture.tree.depth(root), 0); + assert_eq!(fixture.tree.depth(second), 1); + assert_eq!(fixture.tree.depth(grandchild), 2); + assert!(fixture.tree.is_in_subtree_of(grandchild, root)); assert!(fixture.tree.is_in_subtree_of(root, root)); assert!(!fixture.tree.is_in_subtree_of(first, second)); } + #[test] + fn moving_a_subtree_updates_every_descendant_depth() { + let mut fixture = TreeFixture::new(); + let root = fixture.element(); + let first = fixture.element(); + let second = fixture.element(); + let child = fixture.element(); + let grandchild = fixture.element(); + fixture.attach_children(root, &[first, second]); + fixture.attach_children(first, &[child]); + fixture.attach_children(child, &[grandchild]); + + fixture.attach_children(second, &[first]); + + assert_eq!(fixture.tree.depth(first), 2); + assert_eq!(fixture.tree.depth(child), 3); + assert_eq!(fixture.tree.depth(grandchild), 4); + assert!(fixture.tree.is_in_subtree_of(grandchild, second)); + assert!(!fixture.tree.is_in_subtree_of(second, first)); + } + + #[test] + fn staged_parent_changes_recompute_depth_after_final_links_are_installed() { + let mut fixture = TreeFixture::new(); + let root = fixture.element(); + let parent = fixture.element(); + let child = fixture.element(); + fixture.attach_children(root, &[parent]); + fixture.attach_children(parent, &[child]); + + fixture.tree.set_parent_without_updating_depth(child, Some(root)); + fixture.tree.set_parent_without_updating_depth(parent, Some(child)); + fixture.tree.set_first_element_child(root, Some(child)); + fixture.tree.set_first_element_child(child, Some(parent)); + fixture.tree.set_first_element_child(parent, None); + fixture.tree.set_next_element_sibling(parent, None); + fixture.tree.set_next_element_sibling(child, None); + fixture.tree.recompute_subtree_depth(child); + + assert_eq!(fixture.tree.depth(root), 0); + assert_eq!(fixture.tree.depth(child), 1); + assert_eq!(fixture.tree.depth(parent), 2); + } + #[test] fn a_retired_identity_is_not_reused_before_the_epoch_retires() { let mut fixture = TreeFixture::new(); @@ -1324,13 +1486,19 @@ mod tests { #[test] fn per_node_relation_state_fits_the_mandatory_budget() { - // Rust stores four relation columns plus the style-record handle per node. + // Rust stores four relation columns, depth, and the style-record handle per node. The DOM + // owns the style-node identity mapping counted by the documented surface budget. const STYLE_RECORD_ID_BYTES: usize = 4; - let required = 4 * size_of::>() + STYLE_RECORD_ID_BYTES; + const STYLE_NODE_ID_BYTES: usize = 4; + let required = 4 * size_of::>() + size_of::() + STYLE_RECORD_ID_BYTES; assert_eq!(size_of::>(), 4); - assert!(required <= 20, "phase-1 mandatory node bytes exceeded: {required}"); + assert!(required <= 24, "mandatory engine node bytes exceeded: {required}"); - let conditional = size_of::() + size_of::>(); - assert!(conditional <= 8, "conditional relation bytes exceeded: {conditional}"); + let conditional = size_of::(); + assert!( + STYLE_NODE_ID_BYTES + required + conditional <= 32, + "mandatory node surface bytes exceeded: {}", + STYLE_NODE_ID_BYTES + required + conditional + ); } } From cdf4e32778ef239b7ed9e08c5510cfa7ea8923f9 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 10:32:08 +0200 Subject: [PATCH 30/39] LibWeb: Skip unchanged attribute origin routes An attribute value change routed every selector mentioning the attribute name, presence tests included, and enumerated and exact-checked every subject in the region although the origin compound was true on both sides. Decide presence and interned-equality tests directly and evaluate broad value operators on both fact sides before firing a route, so routes whose origin truth did not flip are skipped. Cover the fire direction for presence, exact-atom, and text-operator truth changes. --- .../Rust/src/css/style/input_routing.rs | 5 +- .../Rust/src/css/style/instrumentation.rs | 2 + .../LibWeb/Rust/src/css/style/routing.rs | 90 +++++++- .../LibWeb/Rust/src/css/style/selector.rs | 66 ++++-- Libraries/LibWeb/Rust/src/css/style/tests.rs | 193 ++++++++++++++++++ 5 files changed, 334 insertions(+), 22 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/input_routing.rs b/Libraries/LibWeb/Rust/src/css/style/input_routing.rs index 86a1d0a5fae9..d08ecc402c21 100644 --- a/Libraries/LibWeb/Rust/src/css/style/input_routing.rs +++ b/Libraries/LibWeb/Rust/src/css/style/input_routing.rs @@ -45,8 +45,9 @@ fn for_each_routing_key(input: &NormalizedInput, mut publish: impl FnMut(Routing /// The routing keys one local-feature change publishes. /// /// A tag or ID change publishes both its old and new atom, because a selector mentioning either can -/// change truth. A class or attribute key already names its atom, and one attribute mutation -/// changes presence and value together, so the attribute name covers both. +/// change truth. A class or attribute key already names its atom. Attribute routes share the name +/// lookup, then classify presence and value truth from the selector node without another directory +/// probe. fn for_each_feature_routing_key( feature: LocalFeatureKey, old: InputValue, diff --git a/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs b/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs index c11e90b72d45..d535f35cbcc5 100644 --- a/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs +++ b/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs @@ -196,6 +196,8 @@ define_counters! { ExactRegionBatchIntervals => "exactRegionBatchIntervals", ExactRegionBatchNodes => "exactRegionBatchNodes", RoutedEntryPoints => "routedEntryPoints", + OriginTruthRoutesFired => "originTruthRoutesFired", + OriginTruthRoutesSkipped => "originTruthRoutesSkipped", ArrivingNodeFactsFolded => "arrivingNodeFactsFolded", SheetChangeCandidatesRejected => "sheetChangeCandidatesRejected", RelationalAnchorsConsidered => "relationalAnchorsConsidered", diff --git a/Libraries/LibWeb/Rust/src/css/style/routing.rs b/Libraries/LibWeb/Rust/src/css/style/routing.rs index 88349e74248e..6de1ca5e8e36 100644 --- a/Libraries/LibWeb/Rust/src/css/style/routing.rs +++ b/Libraries/LibWeb/Rust/src/css/style/routing.rs @@ -8,6 +8,68 @@ use super::column::advance_epoch; use super::*; impl StyleEngine { + /// Whether the locally evaluable compound containing an input changed truth on that element. + fn route_origin_truth_flipped( + &mut self, + program: SelectorProgramID, + origin: selector::SelectorNodeID, + node: StyleNodeID, + ) -> Option { + let view = self.transaction_fact_view.as_ref()?; + let compiled = self.programs.get(program); + if !compiled.selector_node_reads_only_local_facts(origin) { + return None; + } + let resident_facts = self.facts.primary(); + let old = MatchEvaluator::new(&self.tree, resident_facts) + .with_transaction_fact_view(view, TransactionFactSide::Before) + .matches_selector_node(compiled, origin, node, &mut self.counters) + .ok()?; + let new = MatchEvaluator::new(&self.tree, resident_facts) + .with_transaction_fact_view(view, TransactionFactSide::After) + .matches_selector_node(compiled, origin, node, &mut self.counters) + .ok()?; + Some(old != new) + } + + /// Whether one attribute input changed the route's origin test. Presence and case-sensitive + /// interned equality are answered directly; text operators use the selector evaluator. + fn route_attribute_origin_truth_flipped( + &mut self, + input: &NormalizedInput, + program: SelectorProgramID, + origin: selector::SelectorNodeID, + node: StyleNodeID, + ) -> Option { + let InputKey::LocalFeature(_, LocalFeatureKey::Attribute(_)) = input.key else { + return None; + }; + let compiled = self.programs.get(program); + if let SelectorOp::Feature(selector::FeatureTest::Attribute(test)) = compiled.node(origin) { + let value = |input: InputValue| match input { + InputValue::Feature(value) => Some(value), + _ => None, + }; + let old = value(input.old)?; + let new = value(input.new)?; + if test.operator == selector::AttributeOperator::Presence { + return Some(old.holds() != new.holds()); + } + if test.operator == selector::AttributeOperator::Exact + && test.case == selector::AttributeCase::Sensitive + && !test.value_atom.is_none() + { + let matches = |value: FeatureValue| match value { + FeatureValue::Absent => Some(false), + FeatureValue::Atom(atom) => Some(atom == test.value_atom), + _ => None, + }; + return Some(matches(old)? != matches(new)?); + } + } + self.route_origin_truth_flipped(program, origin, node) + } + /// Route one non-program input to the region its transpose routes reach. /// /// The region a path folds to is often much wider than the subjects that can actually change: @@ -135,6 +197,24 @@ impl StyleEngine { { continue; } + let path = routing.path_of(route); + if !is_arrival + && point.anchor.is_none() + && !path.is_empty() + && matches!(input.key, InputKey::LocalFeature(_, LocalFeatureKey::Attribute(_))) + { + let truth_flipped = self.route_attribute_origin_truth_flipped( + input, + selector_program, + point.selector_node.expect("attribute route has no selector node"), + node, + ); + if truth_flipped == Some(false) { + self.counters.bump(Counter::OriginTruthRoutesSkipped); + continue; + } + self.counters.bump(Counter::OriginTruthRoutesFired); + } // The element the input happened to must satisfy the rest of the compound the input // occurs in, or this route cannot be reached from it at all. if !self.node_carries_any(routing.origin_dispatch_of(route), node, in_flux) { @@ -143,7 +223,6 @@ impl StyleEngine { if !self.node_carries_all(routing.origin_required_of(route), node, in_flux) { continue; } - let path = routing.path_of(route); let exact_tree_evaluation = if is_arrival && tree_routing.use_exact { if tree_routing.has_before_sibling_relations && self.entry_can_use_before_sibling_relations(selector_program, selector_entry) @@ -1133,7 +1212,7 @@ impl StyleEngine { let operator = self .programs .get(selector_program) - .node(point.structural_node.expect("structural route has no operator")); + .node(point.selector_node.expect("structural route has no operator")); if !matches!(operator, SelectorOp::Empty | SelectorOp::NthPosition(_)) { continue; } @@ -3213,9 +3292,10 @@ impl StyleEngine { if input_routes_on_key(input, key) { return true; } - let (InputKey::LocalFeature(_, LocalFeatureKey::Attribute(changed)), RoutingKey::AttributeName(required)) = - (input.key, key) - else { + let InputKey::LocalFeature(_, LocalFeatureKey::Attribute(changed)) = input.key else { + return false; + }; + let RoutingKey::AttributeName(required) = key else { return false; }; self.facts diff --git a/Libraries/LibWeb/Rust/src/css/style/selector.rs b/Libraries/LibWeb/Rust/src/css/style/selector.rs index 34f21c95a05c..ca728664f286 100644 --- a/Libraries/LibWeb/Rust/src/css/style/selector.rs +++ b/Libraries/LibWeb/Rust/src/css/style/selector.rs @@ -1809,6 +1809,27 @@ impl SelectorProgram { } } + /// Whether one selector IR node can be evaluated from only the changed element's fact row. + /// + /// Routing uses this for origin compounds. Tree position, relative queries, shadow-tree + /// crossings, and scope bindings stay on the conservative path. + pub(super) fn selector_node_reads_only_local_facts(&self, id: SelectorNodeID) -> bool { + match self.node(id) { + SelectorOp::Feature(_) + | SelectorOp::State(_) + | SelectorOp::Part(_) + | SelectorOp::ValueState { .. } + | SelectorOp::Language { .. } + | SelectorOp::Heading(_) => true, + SelectorOp::And { first, count } | SelectorOp::Or { first, count } => self + .operands(first, count) + .iter() + .all(|&operand| self.selector_node_reads_only_local_facts(operand)), + SelectorOp::Where(inner) | SelectorOp::Not(inner) => self.selector_node_reads_only_local_facts(inner), + _ => false, + } + } + /// Whether nothing an element publishes as it arrives can turn this entry's answer off. /// /// An arriving element is routed from the facts it publishes, and for most shapes those facts @@ -2686,8 +2707,9 @@ pub struct RelativeAnchor { pub struct TransposeRoute { pub rule: RuleID, pub entry: EntryID, - /// The IR node containing a structural operator, which distinguishes how the route is planned. - pub structural_node: Option, + /// The structural operator for a structural route, or the locally evaluable origin compound for + /// an attribute route. + pub selector_node: Option, /// Set when the input is a possible relational witness, in which case the path applies from an /// anchor rather than from the changed node. pub anchor: Option, @@ -2872,7 +2894,7 @@ const NO_ROUTE_INDEX: u32 = u32::MAX; struct RouteHeader { rule: RuleID, entry: EntryID, - structural_node: u32, + selector_node: u32, anchor_index: u32, } @@ -2976,7 +2998,7 @@ pub(super) struct LiveRelationalRoute { struct RouteDescriptor<'a> { rule: RuleID, entry: EntryID, - structural_node: Option, + selector_node: Option, origin_dispatch: &'a [DispatchKey], origin_required: &'a [DispatchKey], parent_dispatch: &'a [DispatchKey], @@ -3055,6 +3077,7 @@ impl SelectorProgram { visit(TransposeSite { key, node: id, + origin: enclosing, path: &walk.applied_path, anchor, origin_dispatch: &walk.origin_dispatch, @@ -3286,6 +3309,8 @@ pub struct TransposeSite<'a> { pub key: RoutingKey, /// The IR node the input occurs at. pub node: SelectorNodeID, + /// The compound whose truth controls whether the input can reach this route. + pub origin: SelectorNodeID, /// The inverse path from the input to the entry's subjects. pub path: &'a [InverseStep], /// Set when the input is a possible relational witness. @@ -3428,7 +3453,7 @@ impl RoutingRegistry { let RouteDescriptor { rule, entry, - structural_node, + selector_node, origin_dispatch, origin_required, parent_dispatch, @@ -3454,15 +3479,15 @@ impl RoutingRegistry { self.anchors.push(anchor); index }); - let structural_node = structural_node.map_or(NO_ROUTE_INDEX, |node| { - assert_ne!(node.0, NO_ROUTE_INDEX, "structural selector node space exhausted"); + let selector_node = selector_node.map_or(NO_ROUTE_INDEX, |node| { + assert_ne!(node.0, NO_ROUTE_INDEX, "selector node space exhausted"); node.0 }); self.routes.push( RouteHeader { rule, entry, - structural_node, + selector_node, anchor_index: anchor_index.unwrap_or(NO_ROUTE_INDEX), }, RouteRanges { @@ -3524,7 +3549,7 @@ impl RoutingRegistry { RouteDescriptor { rule: point.rule, entry: point.entry, - structural_node: point.structural_node, + selector_node: point.selector_node, origin_dispatch: self.origin_dispatch_of(route), origin_required: self.origin_required_of(route), parent_dispatch: self.parent_dispatch_of(route), @@ -3571,8 +3596,7 @@ impl RoutingRegistry { TransposeRoute { rule: header.rule, entry: header.entry, - structural_node: (header.structural_node != NO_ROUTE_INDEX) - .then_some(SelectorNodeID(header.structural_node)), + selector_node: (header.selector_node != NO_ROUTE_INDEX).then_some(SelectorNodeID(header.selector_node)), anchor: (header.anchor_index != NO_ROUTE_INDEX).then(|| self.anchors[header.anchor_index as usize]), } } @@ -3630,7 +3654,7 @@ impl RoutingRegistry { let (program, entry) = programs.entry_location(point.entry); let compiled = programs.get(program); let selector_entry = compiled.entries()[entry as usize]; - let operator = compiled.node(point.structural_node.expect("structural route has no operator")); + let operator = compiled.node(point.selector_node.expect("structural route has no operator")); if !matches!(operator, SelectorOp::Empty | SelectorOp::NthPosition(_)) { continue; } @@ -3887,7 +3911,19 @@ impl RoutingRegistry { RouteDescriptor { rule, entry: entry_id, - structural_node: (site.key == RoutingKey::Structural).then_some(site.node), + selector_node: match compiled.node(site.node) { + SelectorOp::Feature(FeatureTest::Attribute(attribute)) + if attribute.operator == AttributeOperator::Presence + || (attribute.operator == AttributeOperator::Exact + && attribute.case == AttributeCase::Sensitive + && !attribute.value_atom.is_none()) => + { + Some(site.node) + } + SelectorOp::Feature(FeatureTest::Attribute(_)) => Some(site.origin), + _ if site.key == RoutingKey::Structural => Some(site.node), + _ => None, + }, origin_dispatch: site.origin_dispatch, origin_required: site.origin_required, parent_dispatch: site.parent_dispatch, @@ -7398,8 +7434,8 @@ mod tests { let routes = registry.routes_for(RoutingKey::Structural); assert_eq!(routes.len(), 2); assert_ne!( - registry.route(routes[0]).structural_node, - registry.route(routes[1]).structural_node + registry.route(routes[0]).selector_node, + registry.route(routes[1]).selector_node ); } diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index 9d102c1adb56..8e2ef059f07a 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -271,6 +271,199 @@ fn discard_transaction(engine: &mut StyleEngine) { engine.release_transaction(transaction); } +#[test] +fn attribute_changes_publish_one_name_route() { + let node = StyleNodeID::element(1); + let name = StyleAtomID(10); + let old_value = StyleAtomID(20); + let new_value = StyleAtomID(21); + let changed = NormalizedInput { + key: InputKey::LocalFeature(node, LocalFeatureKey::Attribute(name)), + old: InputValue::Feature(FeatureValue::Atom(old_value)), + new: InputValue::Feature(FeatureValue::Atom(new_value)), + }; + + assert_eq!(routing_keys_for_input(&changed), [RoutingKey::AttributeName(name)]); + + let added = NormalizedInput { + old: InputValue::Feature(FeatureValue::Absent), + new: InputValue::Feature(FeatureValue::Atom(new_value)), + ..changed + }; + assert_eq!(routing_keys_for_input(&added), [RoutingKey::AttributeName(name)]); +} + +#[test] +fn unchanged_attribute_value_truth_skips_descendant_routing() { + let (mut engine, nodes) = nested_document(); + let attribute_name = StyleAtomID(200); + let old_value = StyleAtomID(201); + let target = StyleAtomID(202); + let new_value = StyleAtomID(203); + + let mut builder = selector::SelectorProgramBuilder::new(); + let (value_offset, value_length) = builder.push_literal(&[u16::from(b'a')]); + let attribute = builder.push_feature(selector::FeatureTest::Attribute(selector::AttributeTest { + name: attribute_name, + any_namespace: false, + folded: attribute_name, + fold_in_namespace: StyleAtomID::NONE, + operator: selector::AttributeOperator::Prefix, + value_atom: StyleAtomID::NONE, + value_offset, + value_length, + case: selector::AttributeCase::Sensitive, + })); + let ancestor = builder.push_ancestor(attribute); + let target_test = builder.push_feature(selector::FeatureTest::Class(target)); + let subject = builder.push_compound(&[target_test, ancestor]); + builder.push_entry(subject); + let program = engine.programs.add(builder.finish()); + let sheet = engine.add_sheet(StyleSheetObjectID(1), CascadeOrigin::Author); + engine.attach_sheet(sheet, TreeScopeID::DOCUMENT); + let rule = engine.append_rule(sheet, None, RuleKind::Style); + engine.add_routing_rule(rule, program); + let mut version = engine.program.rule_version(rule); + version.selector_program = Some(program); + version.declaration_block = Some(DeclarationBlockID(1)); + engine.replace_rule_version(rule, version); + + engine.set_attribute_value_text(old_value, &[u16::from(b'a'), u16::from(b'b')]); + engine.set_attribute_value_text(new_value, &[u16::from(b'a'), u16::from(b'c')]); + engine.record_input( + InputKey::LocalFeature(nodes[1], LocalFeatureKey::Attribute(attribute_name)), + InputValue::Feature(FeatureValue::Absent), + InputValue::Feature(FeatureValue::Atom(old_value)), + ); + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); + discard_transaction(&mut engine); + + engine.record_input( + InputKey::LocalFeature(nodes[1], LocalFeatureKey::Attribute(attribute_name)), + InputValue::Feature(FeatureValue::Atom(old_value)), + InputValue::Feature(FeatureValue::Atom(new_value)), + ); + let skipped_before = engine.counters().get(Counter::OriginTruthRoutesSkipped); + let mut planned = Vec::new(); + assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); + assert!(planned.is_empty()); + assert_eq!( + engine.counters().get(Counter::OriginTruthRoutesSkipped) - skipped_before, + 1 + ); +} + +fn assert_attribute_origin_route_fires( + operator: selector::AttributeOperator, + value_atom: StyleAtomID, + literal: &[u16], + old: FeatureValue, + new: FeatureValue, + old_text: Option<&[u16]>, + new_text: Option<&[u16]>, +) { + let (mut engine, nodes) = nested_document(); + let attribute_name = StyleAtomID(200); + let target = StyleAtomID(201); + let mut builder = selector::SelectorProgramBuilder::new(); + let (value_offset, value_length) = builder.push_literal(literal); + let attribute = builder.push_feature(selector::FeatureTest::Attribute(selector::AttributeTest { + name: attribute_name, + any_namespace: false, + folded: attribute_name, + fold_in_namespace: StyleAtomID::NONE, + operator, + value_atom, + value_offset, + value_length, + case: selector::AttributeCase::Sensitive, + })); + let ancestor = builder.push_ancestor(attribute); + let target_test = builder.push_feature(selector::FeatureTest::Class(target)); + let subject = builder.push_compound(&[target_test, ancestor]); + builder.push_entry(subject); + let program = engine.programs.add(builder.finish()); + let sheet = engine.add_sheet(StyleSheetObjectID(1), CascadeOrigin::Author); + engine.attach_sheet(sheet, TreeScopeID::DOCUMENT); + let rule = engine.append_rule(sheet, None, RuleKind::Style); + engine.add_routing_rule(rule, program); + let mut version = engine.program.rule_version(rule); + version.selector_program = Some(program); + version.declaration_block = Some(DeclarationBlockID(1)); + engine.replace_rule_version(rule, version); + + if let (FeatureValue::Atom(atom), Some(text)) = (old, old_text) { + engine.set_attribute_value_text(atom, text); + } + if let (FeatureValue::Atom(atom), Some(text)) = (new, new_text) { + engine.set_attribute_value_text(atom, text); + } + if old != FeatureValue::Absent { + engine.record_input( + InputKey::LocalFeature(nodes[1], LocalFeatureKey::Attribute(attribute_name)), + InputValue::Feature(FeatureValue::Absent), + InputValue::Feature(old), + ); + } + add_feature(&mut engine, nodes[3], LocalFeatureKey::Class(target)); + discard_transaction(&mut engine); + + engine.record_input( + InputKey::LocalFeature(nodes[1], LocalFeatureKey::Attribute(attribute_name)), + InputValue::Feature(old), + InputValue::Feature(new), + ); + let fired_before = engine.counters().get(Counter::OriginTruthRoutesFired); + let skipped_before = engine.counters().get(Counter::OriginTruthRoutesSkipped); + let mut planned = Vec::new(); + assert!(engine.take_style_transaction_nodes(nodes[0], |nodes| planned.extend_from_slice(nodes))); + assert!(planned.contains(&nodes[3].raw())); + assert_eq!(engine.counters().get(Counter::OriginTruthRoutesFired) - fired_before, 1); + assert_eq!( + engine.counters().get(Counter::OriginTruthRoutesSkipped) - skipped_before, + 0 + ); +} + +#[test] +fn attribute_presence_truth_flip_fires_descendant_routing() { + assert_attribute_origin_route_fires( + selector::AttributeOperator::Presence, + StyleAtomID::NONE, + &[], + FeatureValue::Absent, + FeatureValue::Atom(StyleAtomID(202)), + None, + None, + ); +} + +#[test] +fn attribute_exact_value_truth_flip_fires_descendant_routing() { + assert_attribute_origin_route_fires( + selector::AttributeOperator::Exact, + StyleAtomID(203), + &[], + FeatureValue::Atom(StyleAtomID(202)), + FeatureValue::Atom(StyleAtomID(203)), + None, + None, + ); +} + +#[test] +fn attribute_text_truth_flip_fires_descendant_routing() { + assert_attribute_origin_route_fires( + selector::AttributeOperator::Prefix, + StyleAtomID::NONE, + &[u16::from(b'x')], + FeatureValue::Atom(StyleAtomID(202)), + FeatureValue::Atom(StyleAtomID(203)), + Some(&[u16::from(b'a'), u16::from(b'b')]), + Some(&[u16::from(b'x'), u16::from(b'c')]), + ); +} + fn published_match_answer(node: u32, cascade_input: Option, match_count: usize) -> PublishedMatchAnswer { let rule_match = RuleMatch { node: StyleNodeID::element(node), From 993184642576b6c37eb31c8347d40a888cbc9e10 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 10:53:02 +0200 Subject: [PATCH 31/39] LibWeb: Remove per-answer copies and sorts on the patch path Interning a match answer allocated and sorted even on a hit, retained answers were materialized through a per-traversal copy of the dispatch rank table, and patch attribution keys were deduplicated by a linear scan. Intern small answers without allocation, read ranks from the retained answer's dispatch, and index attribution keys. --- .../LibWeb/Rust/src/css/style/catalog.rs | 60 +++++------- Libraries/LibWeb/Rust/src/css/style/flush.rs | 29 +++--- Libraries/LibWeb/Rust/src/css/style/impact.rs | 9 +- Libraries/LibWeb/Rust/src/css/style/index.rs | 27 ------ .../LibWeb/Rust/src/css/style/matching.rs | 92 +++++-------------- Libraries/LibWeb/Rust/src/css/style/tests.rs | 7 +- 6 files changed, 67 insertions(+), 157 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/catalog.rs b/Libraries/LibWeb/Rust/src/css/style/catalog.rs index d14658fa29a8..142e1c859ccd 100644 --- a/Libraries/LibWeb/Rust/src/css/style/catalog.rs +++ b/Libraries/LibWeb/Rust/src/css/style/catalog.rs @@ -132,10 +132,27 @@ impl MatchAnswerCatalog { } pub(super) fn intern(&mut self, answer: &[RuleMatch]) -> MatchAnswerID { - let mut answer: Vec = + const INLINE_ANSWER_LENGTH: usize = 16; + if let Some(first) = answer.first().copied() + && answer.len() <= INLINE_ANSWER_LENGTH + { + let first = RetainedRuleMatch::from_rule_match(first); + let mut prepared = [first; INLINE_ANSWER_LENGTH]; + for (output, matched) in prepared.iter_mut().zip(answer.iter().copied()) { + *output = RetainedRuleMatch::from_rule_match(matched); + } + let prepared = &mut prepared[..answer.len()]; + prepared.sort_unstable(); + let hash = hash_retained_rule_matches(prepared); + if let Some(identity) = self.identity(prepared, hash) { + return identity; + } + return self.insert_new(prepared.to_vec(), hash); + } + let mut prepared: Vec = answer.iter().copied().map(RetainedRuleMatch::from_rule_match).collect(); - answer.sort_unstable(); - self.intern_prepared(answer) + prepared.sort_unstable(); + self.intern_prepared(prepared) } pub(super) fn intern_prepared(&mut self, answer: Vec) -> MatchAnswerID { @@ -898,40 +915,6 @@ pub(super) struct RetainedAnswerPatchRule { pub(super) program: SelectorProgramID, } -pub(super) struct RetainedAnswerCascadeOrder { - pub(super) rule: RuleID, - pub(super) program: SelectorProgramID, - pub(super) entry: u32, - pub(super) cascade_order: u32, -} - -#[derive(Clone, Copy)] -pub(super) enum RetainedAnswerCascadeOrders<'a> { - Dispatch(&'a RuleDispatch), - Snapshot(&'a [RetainedAnswerCascadeOrder]), -} - -impl RetainedAnswerCascadeOrders<'_> { - pub(super) fn cascade_order_for_entry( - self, - rule: RuleID, - program: SelectorProgramID, - selector_entry: u32, - ) -> Option { - match self { - Self::Dispatch(dispatch) => dispatch.cascade_order_for_entry(rule, program, selector_entry), - Self::Snapshot(orders) => { - let index = orders - .binary_search_by_key(&(rule, program, selector_entry), |order| { - (order.rule, order.program, order.entry) - }) - .ok()?; - Some(orders[index].cascade_order) - } - } - } -} - pub(super) struct RetainedAnswerPatch { pub(super) rules: Vec, /// Whether this transaction can reorder rules relative to each other (layer or sheet order). @@ -1300,8 +1283,7 @@ pub(super) struct BatchMatchingTraversal { pub(super) batch: Option, pub(super) topology: Option, pub(super) reuse_retained_match_answers: bool, - pub(super) retained_answer_cascade_orders: Vec, - pub(super) retained_answer_cascade_order_bytes: u64, + pub(super) retained_answer_dispatch: Option>, pub(super) ancestor_requirements: AncestorRequirementsCache, pub(super) prefix_caches: Rc>, pub(super) match_workspace: MatchEvaluationWorkspace, diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index 3f44aa522723..984773bd733c 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -735,12 +735,11 @@ impl StyleEngine { .map_or(0, RetainedAnswerPatch::capacity_bytes); self.memory .reserve_required(MemoryCategory::BatchScratch, retained_answer_patch_scratch_bytes); - let (published_retained_answer_orders, published_retained_answer_order_bytes) = - if retained_answer_patch.is_none() && reuse_retained_match_answers { - self.retained_answer_cascade_orders_for_traversal(true) - } else { - (Vec::new(), 0) - }; + let published_retained_answer_dispatch = if retained_answer_patch.is_none() && reuse_retained_match_answers { + self.retained_answer_dispatch_for_traversal(true) + } else { + None + }; let compiled_regions = regions.compile_union(regions.regions(), &self.tree, Some(root)); if let Some(base_version) = program_base_version { let current_version = self.program.version(); @@ -1011,14 +1010,10 @@ impl StyleEngine { if !reuse_active_batch_matching_traversal { self.begin_published_match_answer_completion_batch(root, prefer_complete_batch); } - let retained_answer_orders = retained_answer_patch + let retained_answer_dispatch = retained_answer_patch .as_ref() - .map(|patch| RetainedAnswerCascadeOrders::Dispatch(patch.dispatch.as_ref())) - .or_else(|| { - (!published_retained_answer_orders.is_empty()).then_some(RetainedAnswerCascadeOrders::Snapshot( - published_retained_answer_orders.as_slice(), - )) - }); + .map(|patch| patch.dispatch.as_ref()) + .or(published_retained_answer_dispatch.as_deref()); patch_preserved_nodes.sort_unstable(); patch_preserved_nodes.dedup(); patch_processed_nodes.sort_unstable(); @@ -1049,7 +1044,7 @@ impl StyleEngine { .ok() .and(retained_cascade_input); let retained_answer_identity = (share_cascade_completions - && retained_answer_orders.is_some() + && retained_answer_dispatch.is_some() && self.match_answer_is_comparable_across_elements(node) && self.has_no_element_declarations(node)) .then(|| self.retained_match_answers.answer_identity(node)) @@ -1079,12 +1074,12 @@ impl StyleEngine { node, source, cascade_input, - retained_answer_orders.unwrap(), + retained_answer_dispatch.unwrap(), cascade_winners_are_complete, ) }) .unwrap_or_else(|| { - self.complete_published_match_answer(node, retained_answer_orders) + self.complete_published_match_answer(node, retained_answer_dispatch) .expect("a connected style reaction must have complete selector facts") }); if let Some(identity) = retained_answer_identity @@ -1252,8 +1247,6 @@ impl StyleEngine { } self.memory .release(MemoryCategory::BatchScratch, final_retained_answer_patch_scratch_bytes); - self.memory - .release(MemoryCategory::BatchScratch, published_retained_answer_order_bytes); self.memory .release(MemoryCategory::BatchScratch, selector_truth_change_bytes); self.memory diff --git a/Libraries/LibWeb/Rust/src/css/style/impact.rs b/Libraries/LibWeb/Rust/src/css/style/impact.rs index e795d3033976..b7d95ebae172 100644 --- a/Libraries/LibWeb/Rust/src/css/style/impact.rs +++ b/Libraries/LibWeb/Rust/src/css/style/impact.rs @@ -1409,6 +1409,7 @@ impl ImpactRegions { /// full trigger, which is always sound. pub(super) fn compile_patch_cover(&self, tree: &StyleNodeTree, document_root: Option) -> PatchCover { let mut keys: Vec<(RuleID, EntryID)> = Vec::new(); + let mut key_indices: super::HashMap<(RuleID, EntryID), u32> = super::HashMap::default(); let mut intervals: Vec<(u32, u32, u32)> = Vec::new(); let mut demoted: Vec = Vec::new(); let mut scratch: Vec = Vec::new(); @@ -1419,11 +1420,13 @@ impl ImpactRegions { demoted.push(region); continue; } - let key_index = match keys.iter().position(|&existing| existing == key) { - Some(index) => index as u32, + let key_index = match key_indices.get(&key).copied() { + Some(index) => index, None => { + let index = u32::try_from(keys.len()).expect("patch attribution key space exhausted"); keys.push(key); - (keys.len() - 1) as u32 + key_indices.insert(key, index); + index } }; for interval in &scratch { diff --git a/Libraries/LibWeb/Rust/src/css/style/index.rs b/Libraries/LibWeb/Rust/src/css/style/index.rs index 065c494585a0..b198c0f53e73 100644 --- a/Libraries/LibWeb/Rust/src/css/style/index.rs +++ b/Libraries/LibWeb/Rust/src/css/style/index.rs @@ -2154,33 +2154,6 @@ impl RuleDispatch { &self.entries } - pub(super) fn cascade_orders_in_identity_order( - &self, - ) -> impl Iterator + '_ { - self.cascade_order_rule_pages - .iter() - .enumerate() - .flat_map(move |(page_index, page)| { - page.as_deref().into_iter().flat_map(move |page| { - page.iter() - .enumerate() - .filter(|(_, rule)| rule.entry_count != 0) - .flat_map(move |(slot, rule)| { - (0..rule.entry_count).map(move |entry| { - let rule_index = page_index * CASCADE_ORDER_RULE_PAGE_SIZE + slot; - let order_index = rule.entry_start as usize + entry as usize; - ( - RuleID(u32::try_from(rule_index).expect("rule identity space exhausted")), - rule.program, - entry, - self.cascade_orders_by_rule_entry[order_index], - ) - }) - }) - }) - }) - } - fn index_universal_entry(&mut self, id: DispatchRow) { let entry = self.entries[id.index()]; let topology = self.topology_mut(); diff --git a/Libraries/LibWeb/Rust/src/css/style/matching.rs b/Libraries/LibWeb/Rust/src/css/style/matching.rs index a5b2d135940e..10f21e853d17 100644 --- a/Libraries/LibWeb/Rust/src/css/style/matching.rs +++ b/Libraries/LibWeb/Rust/src/css/style/matching.rs @@ -213,26 +213,11 @@ impl StyleEngine { None } - pub(super) fn retained_answer_cascade_orders_for_traversal( + pub(super) fn retained_answer_dispatch_for_traversal( &mut self, reuse_retained_match_answers: bool, - ) -> (Vec, u64) { - if !reuse_retained_match_answers { - return (Vec::new(), 0); - } - let (_, dispatch) = self.ranked_scope_program(TreeScopeID::DOCUMENT); - let orders: Vec = dispatch - .cascade_orders_in_identity_order() - .map(|(rule, program, entry, cascade_order)| RetainedAnswerCascadeOrder { - rule, - program, - entry, - cascade_order, - }) - .collect(); - let bytes = (orders.capacity() * size_of::()) as u64; - self.memory.reserve_required(MemoryCategory::BatchScratch, bytes); - (orders, bytes) + ) -> Option> { + reuse_retained_match_answers.then(|| self.ranked_scope_program(TreeScopeID::DOCUMENT).1) } /// Share current facts and selector work while a scoped plan completes typed answer misses. @@ -275,8 +260,7 @@ impl StyleEngine { batch, topology: None, reuse_retained_match_answers: false, - retained_answer_cascade_orders: Vec::new(), - retained_answer_cascade_order_bytes: 0, + retained_answer_dispatch: None, ancestor_requirements: AncestorRequirementsCache::default(), prefix_caches: Rc::clone(&self.prefix_caches), match_workspace: MatchEvaluationWorkspace::default(), @@ -352,15 +336,13 @@ impl StyleEngine { caches.states.make_scratch(&mut self.memory); caches.answers.make_scratch(&mut self.memory); } - let (retained_answer_cascade_orders, retained_answer_cascade_order_bytes) = - self.retained_answer_cascade_orders_for_traversal(reuse_retained_match_answers); + let retained_answer_dispatch = self.retained_answer_dispatch_for_traversal(reuse_retained_match_answers); Box::new(BatchMatchingTraversal { root, batch: Some(batch), topology, reuse_retained_match_answers, - retained_answer_cascade_orders, - retained_answer_cascade_order_bytes, + retained_answer_dispatch, ancestor_requirements: AncestorRequirementsCache::default(), prefix_caches: Rc::clone(&self.prefix_caches), match_workspace, @@ -450,15 +432,13 @@ impl StyleEngine { caches.states.make_scratch(&mut self.memory); caches.answers.make_scratch(&mut self.memory); } - let (retained_answer_cascade_orders, retained_answer_cascade_order_bytes) = - self.retained_answer_cascade_orders_for_traversal(reuse_retained_match_answers); + let retained_answer_dispatch = self.retained_answer_dispatch_for_traversal(reuse_retained_match_answers); self.batch_matching_traversal = Some(Box::new(BatchMatchingTraversal { root, batch: None, topology, reuse_retained_match_answers, - retained_answer_cascade_orders, - retained_answer_cascade_order_bytes, + retained_answer_dispatch, ancestor_requirements: AncestorRequirementsCache::default(), prefix_caches: Rc::clone(&self.prefix_caches), match_workspace, @@ -575,10 +555,6 @@ impl StyleEngine { MemoryCategory::BatchScratch, traversal.cascade_compaction_workspace_bytes, ); - self.memory.release( - MemoryCategory::BatchScratch, - traversal.retained_answer_cascade_order_bytes, - ); self.discard_published_match_answers(); } } @@ -2813,24 +2789,21 @@ impl StyleEngine { pub(super) fn complete_published_match_answer( &mut self, node: StyleNodeID, - retained_answer_cascade_orders: Option>, + retained_answer_dispatch: Option<&RuleDispatch>, ) -> Result { if !self.match_answer_is_retainable(node) { self.retained_match_answers.forget_answer(&mut self.match_answers, node); } let tree_scope = self.tree.tree_scope(node); let scoped_dispatch = (tree_scope != TreeScopeID::DOCUMENT).then(|| self.ranked_scope_program(tree_scope).1); - let retained_answer_cascade_orders = scoped_dispatch - .as_deref() - .map(RetainedAnswerCascadeOrders::Dispatch) - .or(retained_answer_cascade_orders); - let retained_answer = retained_answer_cascade_orders.and_then(|orders| { + let retained_answer_dispatch = scoped_dispatch.as_deref().or(retained_answer_dispatch); + let retained_answer = retained_answer_dispatch.and_then(|dispatch| { let retained = Rc::clone(self.retained_match_answer(node).sparse().ok()?); let exact_answer = retained .iter() .copied() .map(|entry| { - let cascade_order = orders.cascade_order_for_entry(entry.rule, entry.program, entry.entry)?; + let cascade_order = dispatch.cascade_order_for_entry(entry.rule, entry.program, entry.entry)?; entry.materialize(node, &self.programs, cascade_order) }) .collect::>>()?; @@ -2883,7 +2856,7 @@ impl StyleEngine { node: StyleNodeID, source: StyleNodeID, cascade_input: MatchAnswerID, - orders: RetainedAnswerCascadeOrders<'_>, + dispatch: &RuleDispatch, cascade_winners_are_complete: bool, ) -> Option { let compact = Rc::clone(self.match_answers.answer(cascade_input)?); @@ -2891,7 +2864,7 @@ impl StyleEngine { .iter() .copied() .map(|entry| { - let cascade_order = orders.cascade_order_for_entry(entry.rule, entry.program, entry.entry)?; + let cascade_order = dispatch.cascade_order_for_entry(entry.rule, entry.program, entry.entry)?; entry.materialize(node, &self.programs, cascade_order) }) .collect::>>()?; @@ -3137,11 +3110,11 @@ impl StyleEngine { } pub fn complete_published_match_answers_for_closure(&mut self, nodes: &[StyleNodeID]) -> Result<(), Incomplete> { - let retained_answer_cascade_orders = self + let retained_answer_dispatch = self .batch_matching_traversal - .as_mut() - .map(|traversal| std::mem::take(&mut traversal.retained_answer_cascade_orders)); - let result = (|| { + .as_ref() + .and_then(|traversal| traversal.retained_answer_dispatch.clone()); + (|| { let mut completed = 0; for &node in nodes { if self.published_match_answers.lookup(node).is_some() { @@ -3163,12 +3136,7 @@ impl StyleEngine { observed: false, } } else { - self.complete_published_match_answer( - node, - retained_answer_cascade_orders - .as_deref() - .map(RetainedAnswerCascadeOrders::Snapshot), - )? + self.complete_published_match_answer(node, retained_answer_dispatch.as_deref())? }; self.published_match_answers .push(answer, &mut self.memory, &mut self.counters); @@ -3178,14 +3146,7 @@ impl StyleEngine { self.counters .add(Counter::PublishedMatchAnswerClosureCompletions, completed); Ok(()) - })(); - if let Some(retained_answer_cascade_orders) = retained_answer_cascade_orders { - self.batch_matching_traversal - .as_mut() - .expect("closure completion keeps the matching traversal alive") - .retained_answer_cascade_orders = retained_answer_cascade_orders; - } - result + })() } /// Compare the complete element cascade behind the published base style with the current exact @@ -3251,21 +3212,18 @@ impl StyleEngine { return None; } let exact_answer = self.retained_match_answer(node).sparse().ok().and_then(|answer| { - let orders = &self + let dispatch = self .batch_matching_traversal .as_ref() .expect("a retained answer is consumed only inside a traversal") - .retained_answer_cascade_orders; + .retained_answer_dispatch + .as_deref()?; answer .iter() .copied() .map(|entry| { - let index = orders - .binary_search_by_key(&(entry.rule, entry.program, entry.entry), |order| { - (order.rule, order.program, order.entry) - }) - .ok()?; - entry.materialize(node, &self.programs, orders[index].cascade_order) + let cascade_order = dispatch.cascade_order_for_entry(entry.rule, entry.program, entry.entry)?; + entry.materialize(node, &self.programs, cascade_order) }) .collect::>>() }); diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index 8e2ef059f07a..80e7131dda03 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -5020,8 +5020,9 @@ fn shared_retained_answer_completion_reuses_compact_cascade_state() { ); let (_, dispatch) = engine.ranked_scope_program(TreeScopeID::DOCUMENT); - let orders = RetainedAnswerCascadeOrders::Dispatch(&dispatch); - let first = engine.complete_published_match_answer(nodes[2], Some(orders)).unwrap(); + let first = engine + .complete_published_match_answer(nodes[2], Some(&dispatch)) + .unwrap(); let cascade_input = first.cascade_input.unwrap(); assert!(engine.shared_cascade_completion_is_profitable(first_identity, cascade_input)); let compaction_rows = engine.counters().get(Counter::CascadeMatchesBeforeCompaction); @@ -5031,7 +5032,7 @@ fn shared_retained_answer_completion_reuses_compact_cascade_state() { nodes[3], nodes[2], cascade_input, - orders, + &dispatch, first.cascade_winners_are_complete, ) .unwrap(); From b0da631a88e413e2b16fb4187e1f51012007bd43 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 11:27:01 +0200 Subject: [PATCH 32/39] LibWeb: Gate fixed-cost flush stages on their deltas Every flush rebuilt the sibling routing workspace and rescanned the whole winner column to advance the program version, whatever the transaction contained. Retain the workspace by program generation and skip the winner scan when the plan already covers the document. --- .../LibWeb/Rust/src/css/style/cascade.rs | 13 +++++++++---- Libraries/LibWeb/Rust/src/css/style/flush.rs | 19 +++++++++---------- .../LibWeb/Rust/src/css/style/selector.rs | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/cascade.rs b/Libraries/LibWeb/Rust/src/css/style/cascade.rs index fec84c0d2aab..dea7201f08cc 100644 --- a/Libraries/LibWeb/Rust/src/css/style/cascade.rs +++ b/Libraries/LibWeb/Rust/src/css/style/cascade.rs @@ -1694,6 +1694,14 @@ impl WinnerGroups { Lookup::Known(()) } + /// Start retained winner coverage for a new program version with no proven rows. + pub fn begin_program_version(&mut self, version: ProgramVersion) { + if version > self.newest_program_version { + self.newest_program_version = version; + self.newest_version_row_count = 0; + } + } + /// Advance rows whose retained winners were proven unchanged by a program transaction. pub fn advance_program_version_where( &mut self, @@ -1705,10 +1713,7 @@ impl WinnerGroups { return; } debug_assert!(to > from); - if to > self.newest_program_version { - self.newest_program_version = to; - self.newest_version_row_count = 0; - } + self.begin_program_version(to); for (index, slot) in self.column.iter_mut().enumerate().skip(1) { let Some((_, version)) = slot else { continue; diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index 984773bd733c..b2081874ebe4 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -373,10 +373,7 @@ impl StyleEngine { }; let routing_for_siblings = Rc::clone(&self.routing); let sibling_entries = routing_for_siblings.live_sibling_entries(&self.program, &self.programs); - let mut sibling_candidates = SiblingCandidateWorkspace::new(&sibling_entries); - let sibling_entry_scratch_bytes = sibling_candidates.capacity_bytes(); - self.memory - .reserve_required(MemoryCategory::BatchScratch, sibling_entry_scratch_bytes); + let mut sibling_candidates = routing_for_siblings.live_sibling_workspace(&self.program, &self.programs); let mut pending_routes = PendingRoutes::new(); let mut pending_sibling_routes = PendingSiblingRoutes::new(); let mut pending_prefix_producers = Vec::new(); @@ -594,8 +591,6 @@ impl StyleEngine { .release(MemoryCategory::BatchScratch, pending_table_scratch_bytes); self.memory .release(MemoryCategory::BatchScratch, sequence_scratch_bytes); - self.memory - .release(MemoryCategory::BatchScratch, sibling_entry_scratch_bytes); self.memory.release(MemoryCategory::BatchScratch, arrival_scratch_bytes); let mut match_workspace = std::mem::take(&mut self.match_workspace); let before = match_workspace.capacity_bytes(); @@ -743,10 +738,14 @@ impl StyleEngine { let compiled_regions = regions.compile_union(regions.regions(), &self.tree, Some(root)); if let Some(base_version) = program_base_version { let current_version = self.program.version(); - self.winner_groups - .advance_program_version_where(base_version, current_version, |node| { - !regions.batch_contains_node(&compiled_regions, node) - }); + if regions.covers_document() { + self.winner_groups.begin_program_version(current_version); + } else { + self.winner_groups + .advance_program_version_where(base_version, current_version, |node| { + !regions.batch_contains_node(&compiled_regions, node) + }); + } } let mut node_count = 0; diff --git a/Libraries/LibWeb/Rust/src/css/style/selector.rs b/Libraries/LibWeb/Rust/src/css/style/selector.rs index ca728664f286..25b66f5aba9b 100644 --- a/Libraries/LibWeb/Rust/src/css/style/selector.rs +++ b/Libraries/LibWeb/Rust/src/css/style/selector.rs @@ -56,6 +56,7 @@ use super::memory::MemoryLease; use super::partial_view::Lookup; use super::planning::SequenceEntry; use super::planning::SequenceEntryIndex; +use super::planning::SiblingCandidateWorkspace; use super::program::EntryID; use super::program::RuleID; use super::program::SelectorProgramID; @@ -3386,6 +3387,7 @@ pub struct RoutingRegistry { route_liveness: RefCell, live_relational_routes: RefCell>, live_sibling_entries: RefCell>, + live_sibling_workspace: RefCell, live_sequence_entries: RefCell>, live_sequence_index: RefCell, route_liveness_version: Cell>, @@ -3411,6 +3413,7 @@ impl Default for RoutingRegistry { route_liveness: RefCell::new(BitColumn::default()), live_relational_routes: RefCell::new(Vec::new()), live_sibling_entries: RefCell::new(Vec::new()), + live_sibling_workspace: RefCell::new(SiblingCandidateWorkspace::new(&[])), live_sequence_entries: RefCell::new(Vec::new()), live_sequence_index: RefCell::new(SequenceEntryIndex::default()), route_liveness_version: Cell::new(None), @@ -3644,6 +3647,7 @@ impl RoutingRegistry { .filter(|route| liveness.contains(route.index())) .map(|route| SiblingEntry { route }), ); + *self.live_sibling_workspace.borrow_mut() = SiblingCandidateWorkspace::new(&live_sibling_entries); let mut live_sequence_entries = self.live_sequence_entries.borrow_mut(); live_sequence_entries.clear(); for &route in self.routes_for(RoutingKey::Structural) { @@ -3699,6 +3703,16 @@ impl RoutingRegistry { Ref::map(self.live_sibling_entries.borrow(), Vec::as_slice) } + #[must_use] + pub(super) fn live_sibling_workspace( + &self, + program: &StyleSheetProgram, + programs: &SelectorPrograms, + ) -> std::cell::RefMut<'_, SiblingCandidateWorkspace> { + self.refresh_route_liveness(program, programs); + self.live_sibling_workspace.borrow_mut() + } + #[must_use] pub(super) fn live_sequence_entries( &self, @@ -3959,6 +3973,7 @@ impl RoutingRegistry { + self.live_relational_routes.borrow().capacity() as u64 * size_of::() as u64 + self.live_sibling_entries.borrow().capacity() as u64 * size_of::() as u64 + + self.live_sibling_workspace.borrow().capacity_bytes() + self.live_sequence_entries.borrow().capacity() as u64 * size_of::() as u64 + self.live_sequence_index.borrow().capacity_bytes() ]; From 71c7d8ef95f87d4e1b2dda00c2e2cab34f019941 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 11:43:40 +0200 Subject: [PATCH 33/39] LibWeb: Index dense-keyed style maps as columns Scope roots and detached-sheet routing exclusions were hash maps keyed by dense identities. Store them as columns. Define bit-column equality with zero padding so different allocation lengths still represent the same logical set. --- Libraries/LibWeb/Rust/src/css/style/column.rs | 32 +++++++++++++++++++ Libraries/LibWeb/Rust/src/css/style/flush.rs | 2 +- Libraries/LibWeb/Rust/src/css/style/inputs.rs | 10 +++--- .../LibWeb/Rust/src/css/style/matching.rs | 20 +++++------- Libraries/LibWeb/Rust/src/css/style/mod.rs | 6 ++-- .../LibWeb/Rust/src/css/style/ordering.rs | 12 +++---- Libraries/LibWeb/Rust/src/css/style/tests.rs | 10 +++--- Libraries/LibWeb/Rust/src/css/style/tree.rs | 8 ++--- 8 files changed, 65 insertions(+), 35 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/column.rs b/Libraries/LibWeb/Rust/src/css/style/column.rs index c1cb66bae056..f8f1ccb9c7c3 100644 --- a/Libraries/LibWeb/Rust/src/css/style/column.rs +++ b/Libraries/LibWeb/Rust/src/css/style/column.rs @@ -91,6 +91,17 @@ pub(super) struct BitColumn { words: Vec, } +impl PartialEq for BitColumn { + fn eq(&self, other: &Self) -> bool { + let common_length = self.words.len().min(other.words.len()); + self.words[..common_length] == other.words[..common_length] + && self.words[common_length..].iter().all(|word| *word == 0) + && other.words[common_length..].iter().all(|word| *word == 0) + } +} + +impl Eq for BitColumn {} + impl ShallowCapacityBytes for BitColumn { fn shallow_capacity_bytes(&self) -> u64 { self.capacity_bytes() @@ -320,3 +331,24 @@ pub(super) fn advance_epoch(epoch: &mut u32, step: u32, columns: &mut [&mut Epoc } *epoch } + +#[cfg(test)] +mod tests { + use super::BitColumn; + + #[test] + fn bit_column_equality_ignores_trailing_zero_words() { + let mut left = BitColumn::default(); + left.set(1, true); + left.set(70, true); + left.set(70, false); + + let mut right = BitColumn::default(); + right.set(1, true); + + assert!(left == right); + + right.set(70, true); + assert!(left != right); + } +} diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index b2081874ebe4..c06018e6b6cc 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -934,7 +934,7 @@ impl StyleEngine { // and a live shadow root is a synthetic relation node rather than a style output. // Neither has a C++ element to consume a record; every live element whose style either // can affect is another member of the region. - let is_scope_root = self.scope_by_root.contains_key(&node); + let is_scope_root = self.scope_by_root.get(node).is_some(); if !self.tree.is_live(node) || is_scope_root { return; } diff --git a/Libraries/LibWeb/Rust/src/css/style/inputs.rs b/Libraries/LibWeb/Rust/src/css/style/inputs.rs index 7a74093cf2cc..c81474f1bffd 100644 --- a/Libraries/LibWeb/Rust/src/css/style/inputs.rs +++ b/Libraries/LibWeb/Rust/src/css/style/inputs.rs @@ -51,7 +51,7 @@ impl StyleEngine { tree_staging: TreeRelationStaging::default(), tree_staging_memory: MemoryLease::new(MemoryCategory::NormalizationJournal), program_staging: ProgramStaging::default(), - sheets_excluded_from_routing: HashSet::default(), + sheets_excluded_from_routing: BitColumn::default(), routing_needs_detachment_sweep: false, sheet_rule_replacement: None, match_workspace: MatchEvaluationWorkspace::default(), @@ -103,7 +103,7 @@ impl StyleEngine { relational_witnesses: RefCell::new(RelationalWitnesses::default()), relational_witness_residency: MemoryLease::new(MemoryCategory::RetainedWitness), scope_roots: Column::default(), - scope_by_root: HashMap::default(), + scope_by_root: SegmentedNodeColumn::default(), scope_programs: intern_table::InternTable::default(), vacant_scope_programs: Vec::new(), scope_dispatch_templates: HashMap::default(), @@ -273,7 +273,7 @@ impl StyleEngine { // detached would come back twice. The exclusion covers the edit until the sheet reattaches. if self .sheets_excluded_from_routing - .contains(&self.program.rule_sheet(rule)) + .contains(self.program.rule_sheet(rule).0 as usize) { return; } @@ -1346,7 +1346,7 @@ impl StyleEngine { /// it reattaches. The registry must be whole before the attachment's transaction plans, so /// this runs at recording time rather than waiting for the next sweep. fn restore_routing_for_reattached_sheet(&mut self, sheet: SheetID) { - if !self.sheets_excluded_from_routing.remove(&sheet) { + if !self.sheets_excluded_from_routing.set(sheet.0 as usize, false).0 { return; } let rules: Vec<(RuleID, SelectorProgramID)> = self @@ -1483,7 +1483,7 @@ impl StyleEngine { if let Some(previous_root) = self.scope_roots.get(index).copied().flatten() && previous_root != root { - self.scope_by_root.remove(&previous_root); + self.scope_by_root.remove(previous_root); } self.scope_roots.insert(index, Some(root)); self.scope_by_root.insert(root, tree_scope); diff --git a/Libraries/LibWeb/Rust/src/css/style/matching.rs b/Libraries/LibWeb/Rust/src/css/style/matching.rs index 10f21e853d17..427cb7a40dc8 100644 --- a/Libraries/LibWeb/Rust/src/css/style/matching.rs +++ b/Libraries/LibWeb/Rust/src/css/style/matching.rs @@ -3430,12 +3430,10 @@ impl StyleEngine { fn exact_match_answer(&mut self, node: StyleNodeID) -> Result, Incomplete> { let scope = self.tree.tree_scope(node); - let inner_scope = self.tree.shadow_root_of(node).and_then(|shadow_root| { - self.scope_by_root - .get(&shadow_root) - .copied() - .filter(|&inner| inner != scope) - }); + let inner_scope = self + .tree + .shadow_root_of(node) + .and_then(|shadow_root| self.scope_by_root.get(shadow_root).filter(|&inner| inner != scope)); let slotted_scopes: Vec<_> = self .scopes_slotted_into(node) .filter(|&slotted| slotted != scope && Some(slotted) != inner_scope) @@ -3540,12 +3538,10 @@ impl StyleEngine { let scope = self.tree.tree_scope(node); // A host stands outside the tree its own shadow root opens, and `:host` inside that tree // names it, so the tree's own rules are asked of it as well. - let inner_scope = self.tree.shadow_root_of(node).and_then(|shadow_root| { - self.scope_by_root - .get(&shadow_root) - .copied() - .filter(|&inner| inner != scope) - }); + let inner_scope = self + .tree + .shadow_root_of(node) + .and_then(|shadow_root| self.scope_by_root.get(shadow_root).filter(|&inner| inner != scope)); // A slotted element stands outside the tree it is slotted into, and `::slotted()` inside // that tree names it, so that tree's rules are asked of it as well. A slot is itself a // slottable, so an element can be re-slotted through several trees, and each of them names diff --git a/Libraries/LibWeb/Rust/src/css/style/mod.rs b/Libraries/LibWeb/Rust/src/css/style/mod.rs index d6c7732b7423..ab6e64374e9e 100644 --- a/Libraries/LibWeb/Rust/src/css/style/mod.rs +++ b/Libraries/LibWeb/Rust/src/css/style/mod.rs @@ -141,6 +141,7 @@ pub mod tree; use atoms::DocumentAtoms; use catalog::*; +use column::BitColumn; use column::Column; use fast_hash::FastMap as HashMap; use fast_hash::FastSet as HashSet; @@ -296,6 +297,7 @@ use transaction_view::FeatureFluxColumn; use transaction_view::PrefixFactTransition; use transaction_view::TransactionFactSide; use transaction_view::TransactionFactView; +use tree::SegmentedNodeColumn; use tree::StyleNodeID; use tree::StyleNodeTree; use tree::TreeRelationStaging; @@ -779,7 +781,7 @@ pub struct StyleEngine { /// Sheets whose rules currently have no entry points in the routing registry. A detached /// sheet's rules decide nothing, so routing every input past their entry points is pure cost /// that grows with every sheet that ever came and went. - sheets_excluded_from_routing: HashSet, + sheets_excluded_from_routing: BitColumn, /// Whether a sheet detached since the last routing shed, so the registry may hold entry /// points for rules that can no longer decide. routing_needs_detachment_sweep: bool, @@ -888,7 +890,7 @@ pub struct StyleEngine { scope_roots: Column>, /// The inverse of `scope_roots`. Departing ordinary elements vastly outnumber departing scope /// roots, so retirement must ask this index instead of scanning every historical tree scope. - scope_by_root: HashMap, + scope_by_root: SegmentedNodeColumn, /// The immutable selector dispatch of each distinct effective sheet set and encapsulation /// depth. Concrete scopes retain only its dense identity. scope_programs: intern_table::InternTable>, diff --git a/Libraries/LibWeb/Rust/src/css/style/ordering.rs b/Libraries/LibWeb/Rust/src/css/style/ordering.rs index d6483c1cb83d..9057b090b1f7 100644 --- a/Libraries/LibWeb/Rust/src/css/style/ordering.rs +++ b/Libraries/LibWeb/Rust/src/css/style/ordering.rs @@ -1249,11 +1249,11 @@ impl StyleEngine { } self.routing_needs_detachment_sweep = false; let live_rules = self.program.live_selector_programs().collect::>(); - let mut excluded_sheets: HashSet = HashSet::default(); + let mut excluded_sheets = BitColumn::default(); for &(rule, _) in &live_rules { let sheet = self.program.rule_sheet(rule); if !self.program.sheet_is_attached_somewhere(sheet) { - excluded_sheets.insert(sheet); + excluded_sheets.set(sheet.0 as usize, true); } } if excluded_sheets == self.sheets_excluded_from_routing { @@ -1261,7 +1261,7 @@ impl StyleEngine { } let mut rebuilt_routing = RoutingRegistry::new(); for &(rule, program) in &live_rules { - if excluded_sheets.contains(&self.program.rule_sheet(rule)) { + if excluded_sheets.contains(self.program.rule_sheet(rule).0 as usize) { continue; } rebuilt_routing.add_rule(rule, program, &self.programs); @@ -1295,13 +1295,13 @@ impl StyleEngine { } let mut rebuilt_routing = RoutingRegistry::new(); - let mut excluded_sheets: HashSet = HashSet::default(); + let mut excluded_sheets = BitColumn::default(); for &(rule, program) in &live_rules { // A detached sheet's rules keep no routing entry points; see // `shed_routing_for_detached_sheets`. let sheet = self.program.rule_sheet(rule); if !self.program.sheet_is_attached_somewhere(sheet) { - excluded_sheets.insert(sheet); + excluded_sheets.set(sheet.0 as usize, true); continue; } rebuilt_routing.add_rule(rule, program, &self.programs); @@ -1348,7 +1348,7 @@ impl StyleEngine { for node in departed { self.facts.forget(node); self.retained_match_answers.forget(&mut self.match_answers, node); - if let Some(tree_scope) = self.scope_by_root.remove(&node) { + if let Some(tree_scope) = self.scope_by_root.remove(node) { self.scope_roots[tree_scope.0 as usize] = None; } } diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index 80e7131dda03..2c6f751b2a82 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -6466,17 +6466,17 @@ fn departing_scope_roots_are_removed_from_the_reverse_scope_index() { engine.record_tree_delta(first_root, None, Some(TreeRelations::detached(scope))); engine.set_tree_scope_root(scope, first_root); - assert_eq!(engine.scope_by_root.get(&first_root), Some(&scope)); + assert_eq!(engine.scope_by_root.get(first_root), Some(scope)); engine.record_tree_delta(second_root, None, Some(TreeRelations::detached(scope))); engine.set_tree_scope_root(scope, second_root); - assert!(!engine.scope_by_root.contains_key(&first_root)); - assert_eq!(engine.scope_by_root.get(&second_root), Some(&scope)); + assert_eq!(engine.scope_by_root.get(first_root), None); + assert_eq!(engine.scope_by_root.get(second_root), Some(scope)); discard_transaction(&mut engine); engine.record_tree_delta(second_root, Some(TreeRelations::detached(scope)), None); discard_transaction(&mut engine); - assert!(!engine.scope_by_root.contains_key(&second_root)); + assert_eq!(engine.scope_by_root.get(second_root), None); assert_eq!(engine.scope_roots[scope.0 as usize], None); } @@ -6500,7 +6500,7 @@ fn an_element_arriving_and_departing_in_one_transaction_is_forgotten() { engine.facts.postings().lookup(SelectorPostingKey::Class(class)), Lookup::KnownAbsent )); - assert_eq!(engine.scope_by_root.get(&node), None); + assert_eq!(engine.scope_by_root.get(node), None); assert_eq!(engine.scope_roots[scope.0 as usize], None); } diff --git a/Libraries/LibWeb/Rust/src/css/style/tree.rs b/Libraries/LibWeb/Rust/src/css/style/tree.rs index 62fb9b4d9736..a0b757ff38fe 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tree.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tree.rs @@ -128,7 +128,7 @@ impl RemovablePagedColumnPage for SegmentedNodePage { /// /// The page directory makes an absent column segment cost one pointer rather than one value per /// document node. -struct SegmentedNodeColumn(PagedColumn>); +pub(super) struct SegmentedNodeColumn(PagedColumn>); impl Default for SegmentedNodeColumn { fn default() -> Self { @@ -137,19 +137,19 @@ impl Default for SegmentedNodeColumn { } impl SegmentedNodeColumn { - fn get(&self, node: StyleNodeID) -> Option { + pub(super) fn get(&self, node: StyleNodeID) -> Option { let index = node.element_index()? as usize; self.0.get(index) } - fn insert(&mut self, node: StyleNodeID, value: T) -> Option { + pub(super) fn insert(&mut self, node: StyleNodeID, value: T) -> Option { let index = node .element_index() .expect("conditional tree relations connect DOM nodes") as usize; self.0.insert(index, value).0 } - fn remove(&mut self, node: StyleNodeID) -> Option { + pub(super) fn remove(&mut self, node: StyleNodeID) -> Option { let index = node.element_index()? as usize; self.0.remove(index) } From 6200384a434fbad19f0f2a5847ae1ac1aca981c9 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 12:02:27 +0200 Subject: [PATCH 34/39] LibWeb: Share and flatten rule dispatch storage Every scope program cloned its 56-byte dispatch entries, kept three copies of the cascade rank and four mirror bucket structures for non-prefix candidates, probed a hash map per dispatch key of the element twice, iterated all 43 state facts per probe, and charged none of it. Share the selector-derived entry metadata across scopes with an 8-byte per-scope binding, keep flat all-row and non-prefix row directories addressed by atom range pages, derive the universal fallback, iterate only set state bits, keep the subject bloom per fact row so candidates are rejected before publication, and charge the dispatch to the program category. --- .../Rust/src/css/style/differential_tests.rs | 5 +- Libraries/LibWeb/Rust/src/css/style/index.rs | 875 ++++++++++++++---- .../LibWeb/Rust/src/css/style/matching.rs | 11 +- Libraries/LibWeb/Rust/src/css/style/tests.rs | 47 +- 4 files changed, 743 insertions(+), 195 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/differential_tests.rs b/Libraries/LibWeb/Rust/src/css/style/differential_tests.rs index b9323a5a8f0a..c9b4d1dfd8df 100644 --- a/Libraries/LibWeb/Rust/src/css/style/differential_tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/differential_tests.rs @@ -322,9 +322,8 @@ fn retained_matches(engine: &mut StyleEngine, node: StyleNodeID) -> Option = dispatch - .entries() - .iter() + let mut orders: Vec<_> = (0..dispatch.entry_count()) + .map(|index| dispatch.entry_at(index)) .map(|entry| (entry.rule, entry.program, entry.entry, entry.cascade_order)) .collect(); orders.sort_unstable_by_key(|&(rule, program, entry, _)| (rule, program, entry)); diff --git a/Libraries/LibWeb/Rust/src/css/style/index.rs b/Libraries/LibWeb/Rust/src/css/style/index.rs index b198c0f53e73..12060356df5e 100644 --- a/Libraries/LibWeb/Rust/src/css/style/index.rs +++ b/Libraries/LibWeb/Rust/src/css/style/index.rs @@ -149,7 +149,18 @@ pub struct StateSet(pub u64); impl StateSet { /// The facts this set holds, for routing an element that announced them all at once. pub fn facts(self) -> impl Iterator { - StateFact::ALL.into_iter().filter(move |&fact| self.contains(fact)) + let valid_bits = 1_u64 + .checked_shl(u32::try_from(StateFact::ALL.len()).expect("state fact count exceeds u32")) + .map_or(u64::MAX, |limit| limit - 1); + let mut bits = self.0 & valid_bits; + std::iter::from_fn(move || { + if bits == 0 { + return None; + } + let index = bits.trailing_zeros() as usize; + bits &= bits - 1; + Some(StateFact::ALL[index]) + }) } #[must_use] @@ -212,6 +223,15 @@ impl PagedCopyColumn { self.indices.push(index); } } + + fn indexed_iter(&self) -> impl Iterator + '_ { + self.indices.iter().copied().map(|index| { + ( + index, + self.values.get(index).expect("paged column index must remain present"), + ) + }) + } } impl ShallowCapacityBytes for PagedCopyColumn { @@ -396,6 +416,8 @@ pub struct StyleNodeFacts { rare_facts: PagedColumn, nodes: Vec, row_by_element_index: Vec, + /// Bloom summary of every dispatch key carried by a row, excluding document-root status. + dispatch_bloom: Vec, /// Rows no longer reachable through the element mapping. The primary arrangement repoints an /// element to a freshly packed row when its facts move; the old row stays as garbage until /// its measured carrying cost makes one rebuild cheaper than retaining it. @@ -532,6 +554,8 @@ impl StyleNodeFacts { .push(PayloadHandle::appended_to(&mut self.classes, classes)); self.attribute_handles .push(PayloadHandle::appended_to(&mut self.attributes, attributes)); + self.dispatch_bloom.push(0); + self.dispatch_bloom[row as usize] = self.compute_dispatch_bloom_of(row); self.map_row(node, row); } @@ -584,6 +608,8 @@ impl StyleNodeFacts { offset: attribute_start, length: u32::try_from(self.attributes.len()).expect("attribute payload overflow") - attribute_start, }); + self.dispatch_bloom.push(0); + self.dispatch_bloom[row as usize] = self.compute_dispatch_bloom_of(row); self.map_row(node, row); } @@ -779,6 +805,7 @@ impl StyleNodeFacts { .resize(self.class_handles.len().max(length), PayloadHandle::default()); self.attribute_handles .resize(self.attribute_handles.len().max(length), PayloadHandle::default()); + self.dispatch_bloom.resize(self.dispatch_bloom.len().max(length), 0); self.tag[row] = facts.tag; self.folded_tag[row] = facts.folded_tag; @@ -859,6 +886,7 @@ impl StyleNodeFacts { self.attribute_handles[row] = PayloadHandle::appended_to(&mut self.attributes, &attributes); } self.resident.set(row, true); + self.dispatch_bloom[row] = self.compute_dispatch_bloom_of(row as u32); stale_payload_bytes } @@ -867,6 +895,9 @@ impl StyleNodeFacts { pub fn set_row_folded_tag(&mut self, folded: StyleAtomID) { let row = self.folded_tag.len() - 1; self.folded_tag[row] = folded; + if !folded.is_none() { + self.dispatch_bloom[row] |= dispatch_bloom_bit(DispatchKey::TagName(folded)); + } } /// Set the namespace of the row just pushed. @@ -916,6 +947,20 @@ impl StyleNodeFacts { // Custom states are appended for the last row only, which is the order rows are built in. self.custom_state_handles[row as usize] = PayloadHandle::appended_to(&mut self.custom_states, custom_states); self.part_handles[row as usize] = PayloadHandle::appended_to(&mut self.parts, parts); + let mut bloom = self.dispatch_bloom[row as usize]; + if !directionality.is_none() { + bloom |= dispatch_bloom_bit(DispatchKey::Directionality(directionality)); + } + if heading_level != 0 { + bloom |= dispatch_bloom_bit(DispatchKey::Heading); + } + for &state in custom_states { + bloom |= dispatch_bloom_bit(DispatchKey::CustomState(state)); + } + for &part in parts { + bloom |= dispatch_bloom_bit(DispatchKey::Part(part)); + } + self.dispatch_bloom[row as usize] = bloom; } #[must_use] @@ -1091,12 +1136,22 @@ impl StyleNodeFacts { } #[must_use] - pub fn dispatch_bloom_of(&self, row: u32, is_document_root: bool) -> u64 { + fn compute_dispatch_bloom_of(&self, row: u32) -> u64 { let mut bloom = 0; - self.for_each_dispatch_key(row, is_document_root, |key| bloom |= dispatch_bloom_bit(key)); + self.for_each_dispatch_key(row, false, |key| bloom |= dispatch_bloom_bit(key)); bloom } + #[must_use] + pub fn dispatch_bloom_of(&self, row: u32, is_document_root: bool) -> u64 { + self.dispatch_bloom[row as usize] + | if is_document_root { + dispatch_bloom_bit(DispatchKey::Root) + } else { + 0 + } + } + #[must_use] pub fn carries_dispatch_key(&self, row: u32, key: DispatchKey, is_root: bool) -> bool { match key { @@ -1238,6 +1293,7 @@ impl StyleNodeFacts { self.nodes.clear(); self.stale_rows = 0; self.live_rows = 0; + self.dispatch_bloom.clear(); self.tag.clear(); self.folded_tag.clear(); self.id.clear(); @@ -1267,6 +1323,7 @@ impl StyleNodeFacts { self.resident, self.nodes, self.row_by_element_index, + self.dispatch_bloom, self.tag, self.folded_tag, self.id, @@ -1834,6 +1891,61 @@ pub struct DispatchEntry { pub multi_key: bool, } +/// Selector-derived half of a dispatch row, shared by scopes with the same topology. +#[derive(Clone, Copy)] +struct DispatchEntryMetadata { + identity: EntryID, + program: SelectorProgramID, + entry: u32, + required_attribute_value: StyleAtomID, + required_parent: Option, + required_ancestor: Option, + required_ancestor_index: Option, + required_subject_bloom: u64, + prefix_matched: bool, + multi_key: bool, +} + +#[derive(Clone, Copy)] +struct DispatchEntryBinding { + rule: RuleID, + cascade_order_index: u32, +} + +struct RuleDispatchEntries { + rows: Vec, + residency: MemoryLease, +} + +impl Clone for RuleDispatchEntries { + fn clone(&self) -> Self { + Self { + rows: self.rows.clone(), + residency: MemoryLease::new(MemoryCategory::RuleProgram), + } + } +} + +impl Default for RuleDispatchEntries { + fn default() -> Self { + Self { + rows: Vec::new(), + residency: MemoryLease::new(MemoryCategory::RuleProgram), + } + } +} + +impl RuleDispatchEntries { + fn capacity_bytes(&self) -> u64 { + capacity_bytes! { + shallow [self.rows]; + cached []; + nested []; + skip [self.residency]; + } + } +} + define_id! { /// Physical row in one scope-local selector dispatch. pub(super) struct DispatchRow(); @@ -1863,10 +1975,11 @@ struct CascadeEntryData { pub struct DispatchCandidateWorkspace { seen_at_epoch: EpochColumn, candidates: Vec, + cascade_sort: Vec<(Reverse<(bool, u32)>, DispatchRow)>, epoch: u32, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq)] pub enum CandidateEntries { All, NonPrefix, @@ -1881,6 +1994,7 @@ impl DispatchCandidateWorkspace { column }, candidates: Vec::with_capacity(entry_count), + cascade_sort: Vec::with_capacity(entry_count), epoch: 0, } } @@ -1888,6 +2002,7 @@ impl DispatchCandidateWorkspace { fn begin(&mut self, entry_count: usize) { self.seen_at_epoch.ensure_len(entry_count); self.candidates.clear(); + self.cascade_sort.clear(); advance_epoch(&mut self.epoch, 1, &mut [&mut self.seen_at_epoch]); } @@ -1898,7 +2013,7 @@ impl DispatchCandidateWorkspace { #[must_use] pub fn capacity_bytes(&self) -> u64 { capacity_bytes! { - shallow [self.seen_at_epoch, self.candidates]; + shallow [self.seen_at_epoch, self.candidates, self.cascade_sort]; cached []; nested []; skip [self.epoch]; @@ -1940,42 +2055,249 @@ impl<'a> AncestorDispatchFacts<'a> { } } +#[derive(Clone, Copy, Default)] +struct DispatchBucketRange { + start: u32, + length: u32, +} + +const DISPATCH_ATOM_KIND_COUNT: usize = 7; + +#[derive(Clone, Default)] +struct SegmentedDispatchBucketDirectory { + ranges: PagedCopyColumn, +} + +impl SegmentedDispatchBucketDirectory { + fn get(&self, index: usize) -> DispatchBucketRange { + self.ranges.get(index).unwrap_or_default() + } + + fn insert(&mut self, index: usize, range: DispatchBucketRange) { + self.ranges.insert(index, range); + } + + fn capacity_bytes(&self) -> u64 { + self.ranges.shallow_capacity_bytes() + } +} + +#[derive(Clone, Default)] +struct DispatchBucketDirectory { + atoms: [SegmentedDispatchBucketDirectory; DISPATCH_ATOM_KIND_COUNT], + states: Vec, + fixed: [DispatchBucketRange; 3], + rows: Vec, +} + +impl DispatchBucketDirectory { + fn build(mut buckets: HashMap>) -> Self { + let mut entries: Vec<_> = buckets.drain().collect(); + entries.sort_unstable_by_key(|&(key, _)| key); + let row_count = entries.iter().map(|(_, rows)| rows.len()).sum(); + let mut directory = Self { + states: vec![DispatchBucketRange::default(); StateFact::ALL.len()], + rows: Vec::with_capacity(row_count), + ..Self::default() + }; + for (key, rows) in entries { + let range = DispatchBucketRange { + start: u32::try_from(directory.rows.len()).expect("dispatch bucket space exhausted"), + length: u32::try_from(rows.len()).expect("dispatch bucket space exhausted"), + }; + directory.insert_range(key, range); + directory.rows.extend(rows); + } + directory + } + + fn get(&self, key: DispatchKey) -> &[DispatchRow] { + let range = self.range(key); + let start = range.start as usize; + &self.rows[start..start + range.length as usize] + } + + fn range(&self, key: DispatchKey) -> DispatchBucketRange { + if let Some((kind, atom)) = dispatch_atom_bucket(key) { + return self.atoms[kind].get(atom); + } + match key { + DispatchKey::State(state) => self.states.get(state as usize).copied().unwrap_or_default(), + DispatchKey::Root => self.fixed[0], + DispatchKey::Heading => self.fixed[1], + DispatchKey::Universal => self.fixed[2], + _ => unreachable!("non-dispatch feature key"), + } + } + + fn insert_range(&mut self, key: DispatchKey, range: DispatchBucketRange) { + if let Some((kind, atom)) = dispatch_atom_bucket(key) { + self.atoms[kind].insert(atom, range); + return; + } + match key { + DispatchKey::State(state) => self.states[state as usize] = range, + DispatchKey::Root => self.fixed[0] = range, + DispatchKey::Heading => self.fixed[1] = range, + DispatchKey::Universal => self.fixed[2] = range, + _ => unreachable!("non-dispatch feature key"), + } + } + + fn to_buckets(&self) -> HashMap> { + let mut buckets = HashMap::default(); + for (kind, directory) in self.atoms.iter().enumerate() { + for (atom, range) in directory.ranges.indexed_iter() { + if range.length != 0 { + buckets.insert(dispatch_atom_bucket_key(kind, atom), self.slice(range).to_vec()); + } + } + } + for (index, &range) in self.states.iter().enumerate() { + if range.length != 0 { + buckets.insert(DispatchKey::State(StateFact::ALL[index]), self.slice(range).to_vec()); + } + } + for (key, range) in [ + (DispatchKey::Root, self.fixed[0]), + (DispatchKey::Heading, self.fixed[1]), + (DispatchKey::Universal, self.fixed[2]), + ] { + if range.length != 0 { + buckets.insert(key, self.slice(range).to_vec()); + } + } + buckets + } + + fn filtered(&self, mut retain: impl FnMut(DispatchRow) -> bool) -> Self { + let mut buckets = self.to_buckets(); + buckets.retain(|_, rows| { + rows.retain(|&row| retain(row)); + !rows.is_empty() + }); + Self::build(buckets) + } + + fn slice(&self, range: DispatchBucketRange) -> &[DispatchRow] { + let start = range.start as usize; + &self.rows[start..start + range.length as usize] + } + + fn capacity_bytes(&self) -> u64 { + self.atoms + .iter() + .map(SegmentedDispatchBucketDirectory::capacity_bytes) + .sum::() + + (self.states.capacity() * size_of::()) as u64 + + (self.rows.capacity() * size_of::()) as u64 + } +} + +fn dispatch_atom_bucket(key: DispatchKey) -> Option<(usize, usize)> { + match key { + DispatchKey::Part(atom) => Some((0, atom.0 as usize)), + DispatchKey::CustomState(atom) => Some((1, atom.0 as usize)), + DispatchKey::TagName(atom) => Some((2, atom.0 as usize)), + DispatchKey::Id(atom) => Some((3, atom.0 as usize)), + DispatchKey::Class(atom) => Some((4, atom.0 as usize)), + DispatchKey::AttributeName(atom) => Some((5, atom.0 as usize)), + DispatchKey::Directionality(atom) => Some((6, atom.0 as usize)), + _ => None, + } +} + +fn dispatch_atom_bucket_key(kind: usize, atom: usize) -> DispatchKey { + let atom = StyleAtomID(u32::try_from(atom).expect("dispatch atom exceeds u32")); + match kind { + 0 => DispatchKey::Part(atom), + 1 => DispatchKey::CustomState(atom), + 2 => DispatchKey::TagName(atom), + 3 => DispatchKey::Id(atom), + 4 => DispatchKey::Class(atom), + 5 => DispatchKey::AttributeName(atom), + 6 => DispatchKey::Directionality(atom), + _ => unreachable!("dispatch atom kind out of range"), + } +} + /// Buckets attached selector entries by their rightmost distinguishing feature. /// /// A candidate probes only the buckets its own facts name, plus the universal bucket, so a document /// full of `.item` elements never considers a rule whose subject compound requires `#header`. This /// is program-derived dispatch rather than acceleration over elements: it is Tier 2, it is rebuilt /// with the program, and it is never evicted independently of it. -#[derive(Clone, Default)] struct AncestorDispatchTopology { key_indices: HashMap, + residency: MemoryLease, +} + +impl Clone for AncestorDispatchTopology { + fn clone(&self) -> Self { + Self { + key_indices: self.key_indices.clone(), + residency: MemoryLease::new(MemoryCategory::RuleProgram), + } + } +} + +impl Default for AncestorDispatchTopology { + fn default() -> Self { + Self { + key_indices: HashMap::default(), + residency: MemoryLease::new(MemoryCategory::RuleProgram), + } + } } -#[derive(Default)] struct RuleDispatchTopology { + /// Mutable construction form, consumed when the directory is finalized. buckets: HashMap>, + bucket_directory: DispatchBucketDirectory, + non_prefix_bucket_directory: DispatchBucketDirectory, /// Universal-subject entries that have no exact parent requirement. universal_without_parent_filter: Vec, /// Universal-subject entries indexed by the one feature their parent must carry. universal_by_parent: HashMap>, + universal_parent_directory: DispatchBucketDirectory, + non_prefix_universal_parent_directory: DispatchBucketDirectory, /// The same parent-filtered entries as a conservative fallback when parent facts are absent. universal_with_parent_filter: Vec, - /// Entries the top-down prefix automaton cannot answer, indexed separately so its successful - /// path does not enumerate the old exact candidates merely to discard them. - non_prefix_buckets: HashMap>, non_prefix_universal_without_parent_filter: Vec, - non_prefix_universal_by_parent: HashMap>, non_prefix_universal_with_parent_filter: Vec, + finalized: bool, ancestors: Rc, prefixes: PrefixAutomaton, + residency: MemoryLease, +} + +impl Default for RuleDispatchTopology { + fn default() -> Self { + Self { + buckets: HashMap::default(), + bucket_directory: DispatchBucketDirectory::default(), + non_prefix_bucket_directory: DispatchBucketDirectory::default(), + universal_without_parent_filter: Vec::new(), + universal_by_parent: HashMap::default(), + universal_parent_directory: DispatchBucketDirectory::default(), + non_prefix_universal_parent_directory: DispatchBucketDirectory::default(), + universal_with_parent_filter: Vec::new(), + non_prefix_universal_without_parent_filter: Vec::new(), + non_prefix_universal_with_parent_filter: Vec::new(), + finalized: false, + ancestors: Rc::new(AncestorDispatchTopology::default()), + prefixes: PrefixAutomaton::default(), + residency: MemoryLease::new(MemoryCategory::RuleProgram), + } + } } #[derive(Clone, Copy, PartialEq, Eq)] pub(super) struct AncestorDispatchTopologyID(*const AncestorDispatchTopology); -#[derive(Default)] pub struct RuleDispatch { - entries: Vec, + entries: Rc, + entry_bindings: Vec, entry_rows: Vec>, /// Direct cascade-order projection for every rule represented in this dispatch. Rule /// identities are program indices, so retained answers can restore an entry's order without @@ -1986,6 +2308,23 @@ pub struct RuleDispatch { cascade_properties: Vec, cascade_entries: Vec, topology: Rc, + residency: MemoryLease, +} + +impl Default for RuleDispatch { + fn default() -> Self { + Self { + entries: Rc::new(RuleDispatchEntries::default()), + entry_bindings: Vec::new(), + entry_rows: Vec::new(), + cascade_order_rule_pages: Vec::new(), + cascade_orders_by_rule_entry: Vec::new(), + cascade_properties: Vec::new(), + cascade_entries: Vec::new(), + topology: Rc::new(RuleDispatchTopology::default()), + residency: MemoryLease::new(MemoryCategory::RuleProgram), + } + } } const CASCADE_ORDER_RULE_PAGE_SIZE: usize = 256; @@ -2017,21 +2356,53 @@ impl RuleDispatch { Rc::get_mut(&mut self.topology).expect("a shared selector topology is immutable") } - pub(super) fn rebind_rules(template: &Self, rules: &[RuleID]) -> Self { - assert_eq!(template.entries.len(), rules.len()); - let mut entries = template.entries.clone(); - for (entry, &rule) in entries.iter_mut().zip(rules) { - entry.rule = rule; - entry.cascade_order = 0; + fn entries_mut(&mut self) -> &mut Vec { + &mut Rc::make_mut(&mut self.entries).rows + } + + fn entry(&self, row: DispatchRow) -> DispatchEntry { + let metadata = self.entries.rows[row.index()]; + let binding = self.entry_bindings[row.index()]; + let cascade_order = if binding.cascade_order_index == u32::MAX { + 0 + } else { + self.cascade_orders_by_rule_entry[binding.cascade_order_index as usize] + }; + DispatchEntry { + identity: metadata.identity, + rule: binding.rule, + program: metadata.program, + entry: metadata.entry, + cascade_order, + required_attribute_value: metadata.required_attribute_value, + required_parent: metadata.required_parent, + required_ancestor: metadata.required_ancestor, + required_ancestor_index: metadata.required_ancestor_index, + required_subject_bloom: metadata.required_subject_bloom, + prefix_matched: metadata.prefix_matched, + multi_key: metadata.multi_key, } + } + + pub(super) fn rebind_rules(template: &Self, rules: &[RuleID]) -> Self { + assert_eq!(template.entries.rows.len(), rules.len()); Self { - entries, + entries: Rc::clone(&template.entries), + entry_bindings: rules + .iter() + .copied() + .map(|rule| DispatchEntryBinding { + rule, + cascade_order_index: u32::MAX, + }) + .collect(), entry_rows: template.entry_rows.clone(), cascade_order_rule_pages: Vec::new(), cascade_orders_by_rule_entry: Vec::new(), cascade_properties: Vec::new(), cascade_entries: Vec::new(), topology: Rc::clone(&template.topology), + residency: MemoryLease::new(MemoryCategory::RuleProgram), } } @@ -2039,16 +2410,26 @@ impl RuleDispatch { let mut dispatch = Self::rebind_rules(template, rules); let topology = &template.topology; dispatch.topology = Rc::new(RuleDispatchTopology { - buckets: topology.buckets.clone(), + buckets: match topology.finalized { + true => topology.bucket_directory.to_buckets(), + false => topology.buckets.clone(), + }, + bucket_directory: DispatchBucketDirectory::default(), + non_prefix_bucket_directory: DispatchBucketDirectory::default(), universal_without_parent_filter: topology.universal_without_parent_filter.clone(), - universal_by_parent: topology.universal_by_parent.clone(), + universal_by_parent: match topology.finalized { + true => topology.universal_parent_directory.to_buckets(), + false => topology.universal_by_parent.clone(), + }, + universal_parent_directory: DispatchBucketDirectory::default(), + non_prefix_universal_parent_directory: DispatchBucketDirectory::default(), universal_with_parent_filter: topology.universal_with_parent_filter.clone(), - non_prefix_buckets: topology.non_prefix_buckets.clone(), - non_prefix_universal_without_parent_filter: topology.non_prefix_universal_without_parent_filter.clone(), - non_prefix_universal_by_parent: topology.non_prefix_universal_by_parent.clone(), - non_prefix_universal_with_parent_filter: topology.non_prefix_universal_with_parent_filter.clone(), + non_prefix_universal_without_parent_filter: Vec::new(), + non_prefix_universal_with_parent_filter: Vec::new(), + finalized: false, ancestors: Rc::new((*topology.ancestors).clone()), prefixes: topology.prefixes.clone(), + residency: MemoryLease::new(MemoryCategory::RuleProgram), }); dispatch.topology_mut().prefixes.prepare_to_extend(); dispatch @@ -2059,6 +2440,11 @@ impl RuleDispatch { Rc::ptr_eq(&self.topology, &other.topology) } + #[cfg(test)] + pub(super) fn shares_entries_with(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.entries, &other.entries) + } + pub(super) fn shares_ancestor_topology_with(&self, other: &Self) -> bool { Rc::ptr_eq(&self.topology.ancestors, &other.topology.ancestors) } @@ -2091,14 +2477,33 @@ impl RuleDispatch { } pub(super) fn insert(&mut self, key: DispatchKey, mut entry: DispatchEntry) -> DispatchRow { + debug_assert!( + !self.topology.finalized, + "extend a finalized dispatch through the extension path" + ); entry.required_ancestor_index = entry.required_ancestor.map(|required| { let topology = self.topology_mut(); let ancestors = Rc::get_mut(&mut topology.ancestors).expect("a shared ancestor topology is immutable"); let next = u32::try_from(ancestors.key_indices.len()).expect("ancestor requirement space exhausted"); *ancestors.key_indices.entry(required).or_insert(next) }); - let id = DispatchRow::from_index(self.entries.len()); - self.entries.push(entry); + let id = DispatchRow::from_index(self.entries.rows.len()); + self.entries_mut().push(DispatchEntryMetadata { + identity: entry.identity, + program: entry.program, + entry: entry.entry, + required_attribute_value: entry.required_attribute_value, + required_parent: entry.required_parent, + required_ancestor: entry.required_ancestor, + required_ancestor_index: entry.required_ancestor_index, + required_subject_bloom: entry.required_subject_bloom, + prefix_matched: entry.prefix_matched, + multi_key: entry.multi_key, + }); + self.entry_bindings.push(DispatchEntryBinding { + rule: entry.rule, + cascade_order_index: u32::MAX, + }); if self.entry_rows.len() <= entry.identity.0 as usize { self.entry_rows.resize_with(entry.identity.0 as usize + 1, Vec::new); } @@ -2121,18 +2526,21 @@ impl RuleDispatch { // Registration can refuse a chain whose structural tests would overflow the automaton's // truth bit space or whose origin does not admit them; the entry then stays a candidate // for the exact evaluator. - let entry = self.entries[row.index()].identity; + let entry = self.entries.rows[row.index()].identity; if self .topology_mut() .prefixes .add_entry(programs, program, chain, entry, structural_tests_admissible) { - self.entries[row.index()].prefix_matched = true; + self.entries_mut()[row.index()].prefix_matched = true; } } pub(super) fn finish_prefixes(&mut self) { + debug_assert!(!self.topology.finalized, "a dispatch can only be finalized once"); self.topology_mut().prefixes.finish(); + self.finalize_bucket_directories(); + self.rebuild_universal_with_parent_filter(); self.rebuild_non_prefix_index(); } @@ -2146,74 +2554,65 @@ impl RuleDispatch { .get(entry.0 as usize) .into_iter() .flatten() - .map(|&row| self.entries[row.index()]) + .map(|&row| self.entry(row)) } #[must_use] - pub(super) fn entries(&self) -> &[DispatchEntry] { - &self.entries + #[cfg(test)] + pub(super) fn entry_at(&self, index: usize) -> DispatchEntry { + self.entry(DispatchRow::from_index(index)) } fn index_universal_entry(&mut self, id: DispatchRow) { - let entry = self.entries[id.index()]; + let entry = self.entry(id); let topology = self.topology_mut(); match entry.required_parent { Some(parent) => { topology.universal_by_parent.entry(parent).or_default().push(id); - topology.universal_with_parent_filter.push(id); } None => topology.universal_without_parent_filter.push(id), } } - fn rebuild_universal_index(&mut self) { - let entries = self.bucket_ids(DispatchKey::Universal).to_vec(); + fn rebuild_universal_with_parent_filter(&mut self) { + let entries = self + .bucket_ids(DispatchKey::Universal, CandidateEntries::All) + .iter() + .copied() + .filter(|row| self.entries.rows[row.index()].required_parent.is_some()) + .collect(); + self.topology_mut().universal_with_parent_filter = entries; + } + + fn finalize_bucket_directories(&mut self) { let topology = self.topology_mut(); - topology.universal_without_parent_filter.clear(); - topology.universal_by_parent.clear(); - topology.universal_with_parent_filter.clear(); - for id in entries { - self.index_universal_entry(id); - } + topology.bucket_directory = DispatchBucketDirectory::build(std::mem::take(&mut topology.buckets)); + topology.universal_parent_directory = + DispatchBucketDirectory::build(std::mem::take(&mut topology.universal_by_parent)); + topology.finalized = true; } fn rebuild_non_prefix_index(&mut self) { - let mut non_prefix_buckets = HashMap::default(); - for (&key, entries) in &self.topology.buckets { - let entries: Vec<_> = entries - .iter() - .copied() - .filter(|entry| !self.entries[entry.index()].prefix_matched) - .collect(); - if !entries.is_empty() { - non_prefix_buckets.insert(key, entries); - } - } - - let entries = non_prefix_buckets - .get(&DispatchKey::Universal) - .cloned() - .unwrap_or_default(); + let non_prefix_entries: Vec<_> = self.entries.rows.iter().map(|entry| !entry.prefix_matched).collect(); let topology = self.topology_mut(); - topology.non_prefix_buckets = non_prefix_buckets; - topology.non_prefix_universal_without_parent_filter.clear(); - topology.non_prefix_universal_by_parent.clear(); - topology.non_prefix_universal_with_parent_filter.clear(); - for id in entries { - let entry = self.entries[id.index()]; - match entry.required_parent { - Some(parent) => { - let topology = self.topology_mut(); - topology - .non_prefix_universal_by_parent - .entry(parent) - .or_default() - .push(id); - topology.non_prefix_universal_with_parent_filter.push(id); - } - None => self.topology_mut().non_prefix_universal_without_parent_filter.push(id), - } - } + topology.non_prefix_bucket_directory = topology + .bucket_directory + .filtered(|row| non_prefix_entries[row.index()]); + topology.non_prefix_universal_parent_directory = topology + .universal_parent_directory + .filtered(|row| non_prefix_entries[row.index()]); + topology.non_prefix_universal_without_parent_filter = topology + .universal_without_parent_filter + .iter() + .copied() + .filter(|row| non_prefix_entries[row.index()]) + .collect(); + topology.non_prefix_universal_with_parent_filter = topology + .universal_with_parent_filter + .iter() + .copied() + .filter(|row| non_prefix_entries[row.index()]) + .collect(); } /// Assign a dense rank to the static cascade priority of every selector entry. @@ -2226,12 +2625,9 @@ impl RuleDispatch { /// those copies are one selector entry. They therefore share one rank, which is what the /// candidate walk deduplicates them by. pub fn assign_cascade_order(&mut self, mut priority_of: impl FnMut(DispatchEntry) -> K) { - let mut ordered: Vec<(K, RuleID, SelectorProgramID, u32, DispatchRow)> = self - .entries - .iter() - .copied() - .enumerate() - .map(|(index, entry)| { + let mut ordered: Vec<(K, RuleID, SelectorProgramID, u32, DispatchRow)> = (0..self.entry_count()) + .map(|index| { + let entry = self.entry(DispatchRow::from_index(index)); ( priority_of(entry), entry.rule, @@ -2243,6 +2639,7 @@ impl RuleDispatch { .collect(); ordered.sort_unstable(); + let mut cascade_orders_by_row = vec![0; self.entry_count()]; let mut group_start = 0; while group_start < ordered.len() { let mut group_end = group_start + 1; @@ -2256,40 +2653,45 @@ impl RuleDispatch { } let cascade_order = u32::try_from(group_end - 1).expect("dispatch entry space exhausted"); for ordered_entry in &ordered[group_start..group_end] { - self.entries[ordered_entry.4.index()].cascade_order = cascade_order; + cascade_orders_by_row[ordered_entry.4.index()] = cascade_order; } group_start = group_end; } - self.rebuild_cascade_order_projection(); + self.rebuild_cascade_order_projection(&cascade_orders_by_row); } pub(super) fn reuse_cascade_order(&mut self, template: &Self) { - assert_eq!(self.entries.len(), template.entries.len()); - for (entry, template_entry) in self.entries.iter_mut().zip(&template.entries) { + assert_eq!(self.entry_count(), template.entry_count()); + let mut cascade_orders_by_row = Vec::with_capacity(self.entry_count()); + for index in 0..self.entry_count() { + let entry = self.entry(DispatchRow::from_index(index)); + let template_entry = template.entry(DispatchRow::from_index(index)); assert_eq!(entry.program, template_entry.program); assert_eq!(entry.entry, template_entry.entry); - entry.cascade_order = template_entry.cascade_order; + cascade_orders_by_row.push(template_entry.cascade_order); } - self.rebuild_cascade_order_projection(); + self.rebuild_cascade_order_projection(&cascade_orders_by_row); } - fn rebuild_cascade_order_projection(&mut self) { - let mut entries_by_identity: Vec<_> = (0..self.entries.len()).map(DispatchRow::from_index).collect(); + fn rebuild_cascade_order_projection(&mut self, cascade_orders_by_row: &[u32]) { + assert_eq!(cascade_orders_by_row.len(), self.entry_count()); + let mut entries_by_identity: Vec<_> = (0..self.entry_count()).map(DispatchRow::from_index).collect(); entries_by_identity.sort_unstable_by_key(|&id| { - let entry = self.entries[id.index()]; - (entry.rule, entry.program, entry.entry) + let metadata = self.entries.rows[id.index()]; + (self.entry_bindings[id.index()].rule, metadata.program, metadata.entry) }); entries_by_identity.dedup_by_key(|id| { - let entry = self.entries[id.index()]; - (entry.rule, entry.program, entry.entry) + let metadata = self.entries.rows[id.index()]; + (self.entry_bindings[id.index()].rule, metadata.program, metadata.entry) }); self.cascade_order_rule_pages.clear(); self.cascade_orders_by_rule_entry.clear(); self.cascade_orders_by_rule_entry.reserve(entries_by_identity.len()); for id in entries_by_identity { - let entry = self.entries[id.index()]; - let rule_index = entry.rule.0 as usize; + let metadata = self.entries.rows[id.index()]; + let binding = self.entry_bindings[id.index()]; + let rule_index = binding.rule.0 as usize; let page_index = rule_index / CASCADE_ORDER_RULE_PAGE_SIZE; if self.cascade_order_rule_pages.len() <= page_index { self.cascade_order_rule_pages.resize_with(page_index + 1, || None); @@ -2298,23 +2700,30 @@ impl RuleDispatch { .get_or_insert_with(|| Box::new([CascadeOrderRule::default(); CASCADE_ORDER_RULE_PAGE_SIZE])); let rule = &mut page[rule_index % CASCADE_ORDER_RULE_PAGE_SIZE]; if rule.entry_count == 0 { - rule.program = entry.program; + rule.program = metadata.program; rule.entry_start = u32::try_from(self.cascade_orders_by_rule_entry.len()).expect("dispatch entry space exhausted"); } assert_eq!( - rule.program, entry.program, + rule.program, metadata.program, "one rule cannot have multiple selector programs" ); assert_eq!( - rule.entry_count, entry.entry, + rule.entry_count, metadata.entry, "a rule's selector entries must form a dense identity space" ); rule.entry_count = rule.entry_count.checked_add(1).expect("selector entry space exhausted"); - self.cascade_orders_by_rule_entry.push(entry.cascade_order); + self.cascade_orders_by_rule_entry + .push(cascade_orders_by_row[id.index()]); } - if Rc::strong_count(&self.topology) == 1 { - self.rebuild_universal_index(); + for (row, binding) in self.entry_bindings.iter_mut().enumerate() { + let metadata = self.entries.rows[row]; + let rule_index = binding.rule.0 as usize; + let page = self.cascade_order_rule_pages[rule_index / CASCADE_ORDER_RULE_PAGE_SIZE] + .as_deref() + .expect("a dispatch binding must have a cascade-order page"); + let rule = &page[rule_index % CASCADE_ORDER_RULE_PAGE_SIZE]; + binding.cascade_order_index = rule.entry_start + metadata.entry; } } @@ -2351,9 +2760,10 @@ impl RuleDispatch { self.cascade_properties.clear(); self.cascade_entries.clear(); self.cascade_entries - .resize(self.entries.len(), CascadeEntryData::default()); - let mut configured = vec![false; self.entries.len()]; - for &entry in &self.entries { + .resize(self.entry_count(), CascadeEntryData::default()); + let mut configured = vec![false; self.entry_count()]; + for index in 0..self.entry_count() { + let entry = self.entry(DispatchRow::from_index(index)); let order = entry.cascade_order as usize; if configured[order] { continue; @@ -2405,17 +2815,37 @@ impl RuleDispatch { self.cascade_pruning_blocker_for_order(entry.cascade_order) } - fn bucket_ids(&self, key: DispatchKey) -> &[DispatchRow] { + fn bucket_ids(&self, key: DispatchKey, entries: CandidateEntries) -> &[DispatchRow] { + if self.topology.finalized { + return match entries { + CandidateEntries::All => self.topology.bucket_directory.get(key), + CandidateEntries::NonPrefix => self.topology.non_prefix_bucket_directory.get(key), + }; + } + debug_assert!(entries == CandidateEntries::All); self.topology.buckets.get(&key).map_or(&[], Vec::as_slice) } + fn universal_parent_bucket_ids(&self, key: DispatchKey, entries: CandidateEntries) -> &[DispatchRow] { + if self.topology.finalized { + return match entries { + CandidateEntries::All => self.topology.universal_parent_directory.get(key), + CandidateEntries::NonPrefix => self.topology.non_prefix_universal_parent_directory.get(key), + }; + } + debug_assert!(entries == CandidateEntries::All); + self.topology.universal_by_parent.get(&key).map_or(&[], Vec::as_slice) + } + pub fn bucket(&self, key: DispatchKey) -> impl ExactSizeIterator + '_ { - self.bucket_ids(key).iter().map(|&id| self.entries[id.index()]) + self.bucket_ids(key, CandidateEntries::All) + .iter() + .map(|&id| self.entry(id)) } #[must_use] pub fn entry_count(&self) -> usize { - self.entries.len() + self.entries.rows.len() } #[must_use] @@ -2444,34 +2874,32 @@ impl RuleDispatch { descending_cascade_order: bool, workspace: &'a mut DispatchCandidateWorkspace, ) -> impl Iterator + 'a { - workspace.begin(self.entries.len()); - let (buckets, universal_without_parent_filter, universal_by_parent, universal_with_parent_filter) = - match entries { - CandidateEntries::All => ( - &self.topology.buckets, - &self.topology.universal_without_parent_filter, - &self.topology.universal_by_parent, - &self.topology.universal_with_parent_filter, - ), - CandidateEntries::NonPrefix => ( - &self.topology.non_prefix_buckets, - &self.topology.non_prefix_universal_without_parent_filter, - &self.topology.non_prefix_universal_by_parent, - &self.topology.non_prefix_universal_with_parent_filter, - ), - }; + workspace.begin(self.entry_count()); + debug_assert!(self.topology.finalized || entries == CandidateEntries::All); + let universal_without_parent_filter = match entries { + CandidateEntries::All => &self.topology.universal_without_parent_filter, + CandidateEntries::NonPrefix => &self.topology.non_prefix_universal_without_parent_filter, + }; + let universal_with_parent_filter = match entries { + CandidateEntries::All => &self.topology.universal_with_parent_filter, + CandidateEntries::NonPrefix => &self.topology.non_prefix_universal_with_parent_filter, + }; let subject_bloom = facts.dispatch_bloom_of(row, is_document_root); { let mut offer = |id: DispatchRow, attribute_value: Option| { - let entry = self.entries[id.index()]; - if !entry.required_attribute_value.is_none() && attribute_value != Some(entry.required_attribute_value) + let metadata = self.entries.rows[id.index()]; + if !metadata.required_attribute_value.is_none() + && attribute_value != Some(metadata.required_attribute_value) { return; } + if metadata.required_subject_bloom & subject_bloom != metadata.required_subject_bloom { + return; + } if !workspace.admit(id) { return; } - if !match (entry.required_parent, parent) { + if !match (metadata.required_parent, parent) { (None, _) => true, (Some(_), ParentDispatchFacts::NoElementParent) => false, (Some(required), ParentDispatchFacts::Known { row, is_document_root }) => { @@ -2481,10 +2909,7 @@ impl RuleDispatch { } { return; } - if entry.required_subject_bloom & subject_bloom != entry.required_subject_bloom { - return; - } - if !match (entry.required_ancestor_index, ancestors) { + if !match (metadata.required_ancestor_index, ancestors) { (Some(index), Some(ancestors)) => ancestors.contains(index), _ => true, } { @@ -2499,10 +2924,8 @@ impl RuleDispatch { match parent { ParentDispatchFacts::Known { row, is_document_root } => { facts.for_each_dispatch_probe(row, is_document_root, |key, _| { - if let Some(ids) = universal_by_parent.get(&key) { - for &id in ids { - offer(id, None); - } + for &id in self.universal_parent_bucket_ids(key, entries) { + offer(id, None); } }); } @@ -2517,27 +2940,38 @@ impl RuleDispatch { if key == DispatchKey::Universal { return; } - if let Some(ids) = buckets.get(&key) { - for &id in ids { - offer(id, attribute_value); - } + for &id in self.bucket_ids(key, entries) { + offer(id, attribute_value); } }); } if descending_cascade_order { - workspace.candidates.sort_unstable_by_key(|&id| { - let entry = self.entries[id.index()]; - Reverse((self.cascade_pruning_blocker(entry), entry.cascade_order)) - }); + workspace.cascade_sort.extend(workspace.candidates.iter().map(|&id| { + let binding = self.entry_bindings[id.index()]; + let cascade_order = if binding.cascade_order_index == u32::MAX { + 0 + } else { + self.cascade_orders_by_rule_entry[binding.cascade_order_index as usize] + }; + ( + Reverse((self.cascade_pruning_blocker_for_order(cascade_order), cascade_order)), + id, + ) + })); + // Rows with the same rank are copies of one selector entry and are deduplicated by + // cascade order at consumption, so their relative order is unobservable. + workspace.cascade_sort.sort_unstable_by_key(|&(key, _)| key); + for (candidate, &(_, sorted)) in workspace.candidates.iter_mut().zip(&workspace.cascade_sort) { + *candidate = sorted; + } } - workspace.candidates.iter().map(|&id| self.entries[id.index()]) + workspace.candidates.iter().map(|&id| self.entry(id)) } - #[must_use] - pub fn capacity_bytes(&self) -> u64 { - let scope_bytes = capacity_bytes! { + fn scope_capacity_bytes(&self) -> u64 { + capacity_bytes! { shallow [ - self.entries, + self.entry_bindings, self.entry_rows, self.cascade_order_rule_pages, self.cascade_orders_by_rule_entry, @@ -2558,47 +2992,76 @@ impl RuleDispatch { .map(|rows| rows.capacity() * size_of::()) .sum::(), ]; - skip []; - }; - let topology_bytes = capacity_bytes! { + skip [self.entries, self.residency]; + } + } + + fn topology_capacity_bytes(topology: &RuleDispatchTopology) -> u64 { + capacity_bytes! { shallow [ - self.topology.buckets, - self.topology.universal_without_parent_filter, - self.topology.universal_by_parent, - self.topology.universal_with_parent_filter, - self.topology.non_prefix_buckets, - self.topology.non_prefix_universal_without_parent_filter, - self.topology.non_prefix_universal_by_parent, - self.topology.non_prefix_universal_with_parent_filter, - self.topology.ancestors.key_indices, + topology.buckets, + topology.universal_without_parent_filter, + topology.universal_by_parent, + topology.universal_with_parent_filter, + topology.non_prefix_universal_without_parent_filter, + topology.non_prefix_universal_with_parent_filter, ]; cached []; nested [ - self.topology - .buckets - .values() + topology + .buckets + .values() .map(|bucket| bucket.capacity() * size_of::()) .sum::(), - self.topology - .universal_by_parent + topology + .universal_by_parent .values() .map(|bucket| bucket.capacity() * size_of::()) .sum::(), - self.topology - .non_prefix_buckets - .values() - .map(|bucket| bucket.capacity() * size_of::()) - .sum::(), - self.topology - .non_prefix_universal_by_parent - .values() - .map(|bucket| bucket.capacity() * size_of::()) - .sum::(), - self.topology.prefixes.capacity_bytes(), + topology.bucket_directory.capacity_bytes(), + topology.non_prefix_bucket_directory.capacity_bytes(), + topology.universal_parent_directory.capacity_bytes(), + topology.non_prefix_universal_parent_directory.capacity_bytes(), + topology.prefixes.capacity_bytes(), ]; - skip []; + skip [topology.ancestors, topology.finalized, topology.residency]; + } + } + + fn ancestor_capacity_bytes(ancestors: &AncestorDispatchTopology) -> u64 { + capacity_bytes! { + shallow [ancestors.key_indices]; + cached []; + nested []; + skip [ancestors.residency]; + } + } + + pub(super) fn settle_memory(&mut self, memory: &mut MemoryController) { + self.residency.resize_required_to(memory, self.scope_capacity_bytes()); + if let Some(entries) = Rc::get_mut(&mut self.entries) { + entries.residency.resize_required_to(memory, entries.capacity_bytes()); + } + let Some(topology) = Rc::get_mut(&mut self.topology) else { + return; + }; + topology + .residency + .resize_required_to(memory, Self::topology_capacity_bytes(topology)); + let Some(ancestors) = Rc::get_mut(&mut topology.ancestors) else { + return; }; - scope_bytes + topology_bytes + ancestors + .residency + .resize_required_to(memory, Self::ancestor_capacity_bytes(ancestors)); + } + + #[must_use] + pub fn capacity_bytes(&self) -> u64 { + self.scope_capacity_bytes() + + self.entries.capacity_bytes() + + Self::topology_capacity_bytes(&self.topology) + + Self::ancestor_capacity_bytes(&self.topology.ancestors) } } @@ -5124,6 +5587,53 @@ mod tests { ); } + #[test] + fn shared_dispatch_allocations_are_charged_once() { + let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); + let mut dispatch = RuleDispatch::new(); + dispatch.insert( + DispatchKey::Class(StyleAtomID(10)), + DispatchEntry { + identity: EntryID(1), + rule: RuleID(1), + program: SelectorProgramID(1), + entry: 0, + cascade_order: 0, + required_attribute_value: StyleAtomID::NONE, + required_parent: None, + required_ancestor: Some(DispatchKey::Class(StyleAtomID(20))), + required_ancestor_index: None, + required_subject_bloom: 0, + prefix_matched: false, + multi_key: false, + }, + ); + dispatch.settle_memory(&mut memory); + assert_eq!( + memory.bytes_in_category(MemoryCategory::RuleProgram), + dispatch.capacity_bytes() + ); + + let mut rebound = RuleDispatch::rebind_rules(&dispatch, &[RuleID(2)]); + rebound.settle_memory(&mut memory); + assert!(dispatch.shares_entries_with(&rebound)); + let shared_bytes = dispatch.entries.capacity_bytes() + + RuleDispatch::topology_capacity_bytes(&dispatch.topology) + + RuleDispatch::ancestor_capacity_bytes(&dispatch.topology.ancestors); + assert_eq!( + memory.bytes_in_category(MemoryCategory::RuleProgram), + dispatch.scope_capacity_bytes() + rebound.scope_capacity_bytes() + shared_bytes + ); + + drop(dispatch); + assert_eq!( + memory.bytes_in_category(MemoryCategory::RuleProgram), + rebound.scope_capacity_bytes() + shared_bytes + ); + drop(rebound); + assert_eq!(memory.bytes_in_category(MemoryCategory::RuleProgram), 0); + } + #[test] fn a_candidate_probes_only_the_buckets_its_own_facts_name() { let mut dispatch = RuleDispatch::new(); @@ -5288,6 +5798,7 @@ mod tests { entry(2, Some(DispatchKey::Class(StyleAtomID(11)))), ); dispatch.insert(DispatchKey::Universal, entry(3, None)); + dispatch.finish_prefixes(); let mut facts = StyleNodeFacts::new(); facts.push_row( @@ -5656,6 +6167,10 @@ mod tests { assert!(states.contains(StateFact::Hover)); assert!(states.contains(StateFact::Checked)); assert!(!states.contains(StateFact::Focus)); + assert_eq!( + states.facts().collect::>(), + vec![StateFact::Checked, StateFact::Hover] + ); states.remove(StateFact::Hover); assert!(!states.contains(StateFact::Hover)); assert_eq!(size_of::(), 8); diff --git a/Libraries/LibWeb/Rust/src/css/style/matching.rs b/Libraries/LibWeb/Rust/src/css/style/matching.rs index 427cb7a40dc8..c9de64e4eb8b 100644 --- a/Libraries/LibWeb/Rust/src/css/style/matching.rs +++ b/Libraries/LibWeb/Rust/src/css/style/matching.rs @@ -666,16 +666,16 @@ impl StyleEngine { .filter(|(candidate, _)| { !candidate.0.is_empty() && candidate.0.len() < shape.0.len() && shape.0.starts_with(&candidate.0) }) - .filter(|(_, template)| template.entries().len() >= rules.len().div_ceil(2)) - .max_by_key(|(_, template)| template.entries().len()) + .filter(|(_, template)| template.entry_count() >= rules.len().div_ceil(2)) + .max_by_key(|(_, template)| template.entry_count()) .map(|(candidate, template)| (candidate.0.len(), Rc::clone(template))) }); let mut dispatch = match (exact_template, extension_template.flatten()) { (Some(template), _) => RuleDispatch::rebind_rules(&template, &rules), (None, Some((prefix_len, template))) => { let mut dispatch = - RuleDispatch::rebind_rules_for_extension(&template, &rules[..template.entries().len()]); - let mut rule_index = template.entries().len(); + RuleDispatch::rebind_rules_for_extension(&template, &rules[..template.entry_count()]); + let mut rule_index = template.entry_count(); for &(selector_program, author) in &shape.0[prefix_len..] { insert_scope_rule( &mut dispatch, @@ -684,7 +684,7 @@ impl StyleEngine { selector_program, author, ); - rule_index = dispatch.entries().len(); + rule_index = dispatch.entry_count(); } assert_eq!(rule_index, rules.len()); dispatch.finish_prefixes(); @@ -731,6 +731,7 @@ impl StyleEngine { Some(declared.iter().map(|property| property.property).collect()) }, ); + dispatch.settle_memory(&mut self.memory); let dispatch = Rc::new(dispatch); self.scope_cascade_templates .entry(cascade_shape) diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index 2c6f751b2a82..4ec44a545a31 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -5,7 +5,10 @@ */ use super::batch_matcher::insert_scope_rule; +use super::index::CandidateEntries; +use super::index::ParentDispatchFacts; use super::index::SelectorPostingKey; +use super::index::StateSet; use super::instrumentation::Counter; use super::program::DeclarationBlockID; use super::program::SelectorProgramID; @@ -5751,9 +5754,8 @@ fn positional_answers_stay_cold_equivalent_across_sequence_mutations() { let _ = engine.match_element_for_purpose(node, true); } let (_, dispatch) = engine.ranked_scope_program(TreeScopeID::DOCUMENT); - let mut orders: Vec<(RuleID, SelectorProgramID, u32, u32)> = dispatch - .entries() - .iter() + let mut orders: Vec<(RuleID, SelectorProgramID, u32, u32)> = (0..dispatch.entry_count()) + .map(|index| dispatch.entry_at(index)) .map(|entry| (entry.rule, entry.program, entry.entry, entry.cascade_order)) .collect(); orders.sort_unstable_by_key(|&(rule, program, entry, _)| (rule, program, entry)); @@ -7438,14 +7440,16 @@ fn equivalent_sheet_programs_share_dispatch_topology() { let (_, second) = engine.ranked_scope_program(TreeScopeID(2)); assert!(!Rc::ptr_eq(&first, &second)); assert!(first.shares_topology_with(&second)); + assert!(first.shares_entries_with(&second)); assert_eq!(engine.scope_cascade_templates.len(), 1); - assert_eq!(first.entries()[0].cascade_order, second.entries()[0].cascade_order); - assert_eq!(first.entries()[0].rule, rules[0]); - assert_eq!(second.entries()[0].rule, rules[1]); + assert_eq!(first.entry_at(0).cascade_order, second.entry_at(0).cascade_order); + assert_eq!(first.entry_at(0).rule, rules[0]); + assert_eq!(second.entry_at(0).rule, rules[1]); engine.invalidate_scope_programs(); let (_, rebuilt) = engine.ranked_scope_program(TreeScopeID(1)); assert!(first.shares_topology_with(&rebuilt)); + assert!(first.shares_entries_with(&rebuilt)); let replacement_program = engine .programs @@ -7490,10 +7494,39 @@ fn a_scope_dispatch_can_extend_a_finished_prefix_template() { insert_scope_rule(&mut cold, &programs, rules[1], suffix, true); cold.finish_prefixes(); - assert_eq!(extended.entries(), cold.entries()); + assert_eq!(extended.entry_count(), cold.entry_count()); + for index in 0..extended.entry_count() { + assert_eq!(extended.entry_at(index), cold.entry_at(index)); + } assert_eq!(extended.ancestor_dispatch_shape(), cold.ancestor_dispatch_shape()); assert!(extended.prefixes().contains_entry(programs.entry_id(base, 0))); assert!(extended.prefixes().contains_entry(programs.entry_id(suffix, 0))); + + let mut facts = StyleNodeFacts::new(); + facts.push_row( + StyleNodeID::element(1), + StyleAtomID(1), + StyleAtomID::NONE, + StateSet::default(), + &[StyleAtomID(201)], + &[], + ); + let candidate_rules = |dispatch: &RuleDispatch| { + dispatch + .candidates_for( + &facts, + 0, + false, + ParentDispatchFacts::Unknown, + None, + CandidateEntries::All, + false, + &mut DispatchCandidateWorkspace::default(), + ) + .map(|entry| entry.rule) + .collect::>() + }; + assert_eq!(candidate_rules(&extended), candidate_rules(&cold)); } #[test] From d276e5bfac85957628bcc89b46dda2f4ba64448e Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 12:40:44 +0200 Subject: [PATCH 35/39] LibWeb: Cache selector dispatch metadata in the program Dispatch keys, required keys, parent and ancestor keys and the unified prefix chain were re-derived by IR walks on every scope dispatch build, allocating on the way. Compute them once in the immutable program at finish and reuse the cached prefix analysis when deriving entry properties. The cache is derived data and stays out of the program's semantic identity. --- .../Rust/src/css/style/batch_matcher.rs | 37 +++--- .../LibWeb/Rust/src/css/style/selector.rs | 115 ++++++++++++++---- .../Rust/src/css/style/selector/replay.rs | 10 +- 3 files changed, 109 insertions(+), 53 deletions(-) diff --git a/Libraries/LibWeb/Rust/src/css/style/batch_matcher.rs b/Libraries/LibWeb/Rust/src/css/style/batch_matcher.rs index ec9b790e6ab4..dd475c0e662d 100644 --- a/Libraries/LibWeb/Rust/src/css/style/batch_matcher.rs +++ b/Libraries/LibWeb/Rust/src/css/style/batch_matcher.rs @@ -24,7 +24,6 @@ use super::index::DispatchEntry; use super::index::DispatchKey; use super::index::ParentDispatchFacts; use super::index::RuleDispatch; -use super::index::StyleAtomID; use super::index::StyleNodeFacts; use super::instrumentation::Counter; use super::instrumentation::Counters; @@ -242,10 +241,10 @@ pub(super) fn scope_dispatch_shape_and_rules( for_each_scope_rule(program, tree_scope, take, |sheet, rule, selector_program| { shape.push((selector_program, program.sheet_origin(sheet) == CascadeOrigin::Author)); let compiled = programs.get(selector_program); - for (index, entry) in compiled.entries().iter().enumerate() { - let key = compiled.dispatch_key(entry); - let copies = if key == DispatchKey::Universal { - let mut branch_keys = compiled.subject_dispatch_keys(index).to_vec(); + for index in 0..compiled.entries().len() { + let metadata = compiled.dispatch_metadata(index); + let copies = if metadata.key == DispatchKey::Universal { + let mut branch_keys = metadata.subject_dispatch_keys().to_vec(); branch_keys.sort_unstable(); branch_keys.dedup(); branch_keys.len().max(1) @@ -301,18 +300,10 @@ pub(super) fn insert_scope_rule( author: bool, ) { let compiled = programs.get(selector_program); - for (index, entry) in compiled.entries().iter().enumerate() { - let key = compiled.dispatch_key(entry); - let bloom_of = |keys: &[DispatchKey]| { - keys.iter() - .copied() - .fold(0_u64, |bloom, key| bloom | super::index::dispatch_bloom_bit(key)) - }; - let subject_dispatch = compiled.subject_dispatch_keys(index); - let required_attribute_value = match key { - DispatchKey::AttributeName(name) => compiled.required_attribute_value(entry, name), - _ => StyleAtomID::NONE, - }; + for index in 0..compiled.entries().len() { + let metadata = compiled.dispatch_metadata(index); + let key = metadata.key; + let subject_dispatch = metadata.subject_dispatch_keys(); let template = super::index::DispatchEntry { identity: programs.entry_id( selector_program, @@ -322,11 +313,11 @@ pub(super) fn insert_scope_rule( program: selector_program, entry: u32::try_from(index).expect("selector entry space exhausted"), cascade_order: 0, - required_attribute_value, - required_parent: compiled.subject_parent_dispatch_key(index), - required_ancestor: compiled.subject_ancestor_dispatch_key(index), + required_attribute_value: metadata.required_attribute_value, + required_parent: metadata.required_parent, + required_ancestor: metadata.required_ancestor, required_ancestor_index: None, - required_subject_bloom: bloom_of(compiled.subject_required_keys(index)), + required_subject_bloom: metadata.required_subject_bloom, prefix_matched: false, multi_key: false, }; @@ -346,14 +337,14 @@ pub(super) fn insert_scope_rule( }; if branch_keys.is_empty() { let dispatch_entry = dispatch.insert(key, template); - if let Some(chain) = compiled.unified_chain(entry) { + if let Some(chain) = metadata.prefix_chain() { // NB: Structural truth bits are admitted for author rules only. The // convergence walk consumes only author routes, so a user-agent // positional chain would put its tests into every document's // automaton, taxing each transition and widening every tree // flush's re-compare frontier, without any route ever being // subsumed in return. - dispatch.add_prefix_entry(programs, selector_program, &chain, dispatch_entry, author); + dispatch.add_prefix_entry(programs, selector_program, chain, dispatch_entry, author); } } else { // NB: A branch copy carries no required attribute value: a disjunction branch's diff --git a/Libraries/LibWeb/Rust/src/css/style/selector.rs b/Libraries/LibWeb/Rust/src/css/style/selector.rs index 25b66f5aba9b..71490df022aa 100644 --- a/Libraries/LibWeb/Rust/src/css/style/selector.rs +++ b/Libraries/LibWeb/Rust/src/css/style/selector.rs @@ -36,6 +36,7 @@ use super::index::DispatchKey; use super::index::FeatureKey; use super::index::StyleAtomID; use super::index::StyleNodeFacts; +use super::index::dispatch_bloom_bit; use super::instrumentation::Counter; use super::instrumentation::Counters; use std::cell::Cell; @@ -588,6 +589,47 @@ struct SelectorEntryProperties { prefix_chain_has_only_local_facts: bool, } +/// Immutable routing facts derived from one selector entry's IR. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(super) struct SelectorDispatchMetadata { + pub key: DispatchKey, + pub required_attribute_value: StyleAtomID, + pub required_parent: Option, + pub required_ancestor: Option, + pub required_subject_bloom: u64, + subject_dispatch_keys: Box<[DispatchKey]>, + subject_required_keys: Box<[DispatchKey]>, + prefix_chain: Option>, +} + +/// Derived acceleration is not part of a selector program's semantic identity. +#[derive(Default)] +struct CachedDispatchMetadata(Vec); + +impl PartialEq for CachedDispatchMetadata { + fn eq(&self, _other: &Self) -> bool { + true + } +} + +impl Eq for CachedDispatchMetadata {} + +impl Hash for CachedDispatchMetadata { + fn hash(&self, _state: &mut H) {} +} + +impl SelectorDispatchMetadata { + #[must_use] + pub(super) fn subject_dispatch_keys(&self) -> &[DispatchKey] { + &self.subject_dispatch_keys + } + + #[must_use] + pub(super) fn prefix_chain(&self) -> Option<&[SelectorPrefixStep]> { + self.prefix_chain.as_deref() + } +} + #[derive(Clone, Copy, PartialEq, Eq)] enum DispatchRelation { Subject, @@ -639,10 +681,8 @@ pub struct SelectorProgram { relative_queries: Vec, /// Ranges into `text`, one per extended language range a `:lang()` names. language_ranges: Vec<(u32, u32)>, - /// Immutable dispatch analysis, retained so rebuilding scope dispatches does not repeatedly - /// allocate the same per-entry key sets. - subject_dispatch_keys: Vec>, - subject_required_keys: Vec>, + /// Immutable dispatch analysis, retained so rebuilding scope dispatches never walks the IR. + dispatch_metadata: CachedDispatchMetadata, can_leave_scope: bool, } @@ -764,19 +804,21 @@ impl SelectorProgram { self.entries, self.relative_queries, self.language_ranges, - self.subject_dispatch_keys, - self.subject_required_keys, + self.dispatch_metadata.0, ]; cached []; nested [ size_of::(), - self.subject_dispatch_keys - .iter() - .map(|keys| size_of_val(keys.as_ref()) as u64) - .sum::(), - self.subject_required_keys + self.dispatch_metadata.0 .iter() - .map(|keys| size_of_val(keys.as_ref()) as u64) + .map(|metadata| { + size_of_val(metadata.subject_dispatch_keys.as_ref()) as u64 + + size_of_val(metadata.subject_required_keys.as_ref()) as u64 + + metadata + .prefix_chain + .as_ref() + .map_or(0, |chain| size_of_val(chain.as_ref()) as u64) + }) .sum::(), ]; skip [self.can_leave_scope]; @@ -958,18 +1000,20 @@ impl SelectorProgramBuilder { #[must_use] pub fn finish(mut self) -> SelectorProgram { + self.program.cache_dispatch_metadata(); let properties: Vec = self .program .entries .iter() - .map(|entry| { - let unified_chain = self.program.unified_chain(entry); + .enumerate() + .map(|(index, entry)| { + let unified_chain = self.program.dispatch_metadata(index).prefix_chain(); SelectorEntryProperties { monotone_under_arrivals: self.program.entry_is_monotone_under_arrivals(entry.root), can_use_before_sibling_relations: self.program.entry_can_use_before_sibling_relations(entry.root), observes_sibling_relation: self.program.entry_observes_sibling_relation(entry.root), has_prefix_chain: unified_chain.is_some(), - prefix_chain_has_only_local_facts: unified_chain.as_ref().is_some_and(|chain| { + prefix_chain_has_only_local_facts: unified_chain.is_some_and(|chain| { // A canonical positional step counts as local here: its truth rides the // positional bits of every transition and completion key, so the retained // walk answers it exactly like an interned fact. @@ -988,22 +1032,41 @@ impl SelectorProgramBuilder { .entries .iter() .any(|entry| self.program.leaves_its_scope(entry.root)); - self.program.cache_subject_dispatch_analysis(); self.program } } impl SelectorProgram { - fn cache_subject_dispatch_analysis(&mut self) { - let (subject_dispatch_keys, subject_required_keys): (Vec<_>, Vec<_>) = (0..self.entries.len()) + fn cache_dispatch_metadata(&mut self) { + self.dispatch_metadata.0 = (0..self.entries.len()) .map(|entry| { - let dispatch = self.compute_subject_dispatch_keys(entry); - let required = self.compute_subject_required_keys(entry, &dispatch); - (dispatch.into_boxed_slice(), required.into_boxed_slice()) + let selector_entry = &self.entries[entry]; + let key = self.dispatch_key(selector_entry); + let subject_dispatch_keys = self.compute_subject_dispatch_keys(entry); + let subject_required_keys = self.compute_subject_required_keys(entry, &subject_dispatch_keys); + SelectorDispatchMetadata { + key, + required_attribute_value: match key { + DispatchKey::AttributeName(name) => self.required_attribute_value(selector_entry, name), + _ => StyleAtomID::NONE, + }, + required_parent: self.subject_parent_dispatch_key(entry), + required_ancestor: self.subject_ancestor_dispatch_key(entry), + required_subject_bloom: subject_required_keys + .iter() + .copied() + .fold(0_u64, |bloom, key| bloom | dispatch_bloom_bit(key)), + subject_dispatch_keys: subject_dispatch_keys.into_boxed_slice(), + subject_required_keys: subject_required_keys.into_boxed_slice(), + prefix_chain: self.unified_chain(selector_entry).map(Vec::into_boxed_slice), + } }) - .unzip(); - self.subject_dispatch_keys = subject_dispatch_keys; - self.subject_required_keys = subject_required_keys; + .collect(); + } + + #[must_use] + pub(super) fn dispatch_metadata(&self, entry: usize) -> &SelectorDispatchMetadata { + &self.dispatch_metadata.0[entry] } /// Decompose a selector whose tree relations are its linear chain over all four axes: @@ -1437,7 +1500,7 @@ impl SelectorProgram { /// The same, as the set of keys the subject can be reached by. Empty means it has none. #[must_use] pub fn subject_dispatch_keys(&self, entry: usize) -> &[DispatchKey] { - &self.subject_dispatch_keys[entry] + &self.dispatch_metadata.0[entry].subject_dispatch_keys } fn compute_subject_dispatch_keys(&self, entry: usize) -> Vec { @@ -1459,7 +1522,7 @@ impl SelectorProgram { /// the conservative direction: the candidate survives and the exact matcher settles it. #[must_use] pub fn subject_required_keys(&self, entry: usize) -> &[DispatchKey] { - &self.subject_required_keys[entry] + &self.dispatch_metadata.0[entry].subject_required_keys } fn compute_subject_required_keys(&self, entry: usize, dispatch: &[DispatchKey]) -> Vec { diff --git a/Libraries/LibWeb/Rust/src/css/style/selector/replay.rs b/Libraries/LibWeb/Rust/src/css/style/selector/replay.rs index cf99351aebbf..c98f085a3178 100644 --- a/Libraries/LibWeb/Rust/src/css/style/selector/replay.rs +++ b/Libraries/LibWeb/Rust/src/css/style/selector/replay.rs @@ -13,6 +13,7 @@ use super::AttributeCase; use super::AttributeOperator; use super::AttributeTest; +use super::CachedDispatchMetadata; use super::FeatureTest; use super::NamespaceTest; use super::NthPosition; @@ -97,11 +98,10 @@ pub fn read(payload: &mut PayloadReader) -> Result { entries, relative_queries, language_ranges, - subject_dispatch_keys: Vec::new(), - subject_required_keys: Vec::new(), + dispatch_metadata: CachedDispatchMetadata::default(), can_leave_scope: payload.read_bool()?, }; - program.cache_subject_dispatch_analysis(); + program.cache_dispatch_metadata(); Ok(program) } @@ -574,7 +574,7 @@ mod tests { #[test] fn semantic_program_round_trip() { - let program = SelectorProgram { + let mut program = SelectorProgram { nodes: vec![ SelectorOp::Feature(FeatureTest::Attribute(AttributeTest { name: StyleAtomID(1), @@ -635,8 +635,10 @@ mod tests { match_in_shadow_tree: true, }], language_ranges: vec![(0, 2)], + dispatch_metadata: CachedDispatchMetadata::default(), can_leave_scope: true, }; + program.cache_dispatch_metadata(); let mut output = Vec::new(); let mut writer = LogWriter::new(&mut output).unwrap(); From 1214c7ca77b3f33707ed2aa62049934623bc5959 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 13:15:27 +0200 Subject: [PATCH 36/39] LibWeb: Own preallocated style nodes and pending inputs per document Preallocated style nodes lived in a process-global set keyed by raw Element pointer, pending element style inputs were consumed by a linear scan per reaction, and subtree recording walked to the root per node to find the tree scope. Keep the preallocated set on the document's engine object, index pending inputs by node, carry the tree scope through the subtree walk, and say which side owns each retained style handle. --- Libraries/LibWeb/CSS/StyleComputer.cpp | 1 + Libraries/LibWeb/CSS/StyleEngineBridge.cpp | 39 +++++++--- Libraries/LibWeb/CSS/StyleEngineBridge.h | 6 ++ Libraries/LibWeb/CSS/StyleEngineInput.cpp | 89 ++++++++++++++++------ Libraries/LibWeb/CSS/StyleEngineInput.h | 4 +- Libraries/LibWeb/CSS/StyleInputRecord.h | 5 +- Libraries/LibWeb/DOM/Element.cpp | 14 +--- Libraries/LibWeb/DOM/Element.h | 6 +- Libraries/LibWeb/DOM/Node.cpp | 13 +--- 9 files changed, 114 insertions(+), 63 deletions(-) diff --git a/Libraries/LibWeb/CSS/StyleComputer.cpp b/Libraries/LibWeb/CSS/StyleComputer.cpp index 6ecd5ad9d37f..d46c1f233a03 100644 --- a/Libraries/LibWeb/CSS/StyleComputer.cpp +++ b/Libraries/LibWeb/CSS/StyleComputer.cpp @@ -235,6 +235,7 @@ void StyleComputer::unregister_style_node(StyleNodeID style_node_id) { if (style_node_id != 0 && style_node_id.value() < m_style_nodes.size()) { m_style_nodes[style_node_id.value()] = nullptr; + m_style_engine.cancel_preallocated_style_node(style_node_id); m_style_engine.consume_recorded_element_style_input_change(style_node_id); } } diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp index c396b70f8444..55ef8e683c72 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp @@ -414,11 +414,24 @@ void StyleEngine::record_element_declaration_delta(StyleEngineFFI::FfiElementDec m_element_declaration_deltas.append(delta); } +void StyleEngine::append_or_merge_element_style_input(StyleNodeID style_node, u8 reaction, u8 inherited_style_groups) +{ + if (auto existing = m_element_style_input_indices.find(style_node); existing != m_element_style_input_indices.end()) { + auto& input = m_element_style_inputs[existing->value]; + input.reaction |= reaction; + input.inherited_style_groups |= inherited_style_groups; + return; + } + + m_element_style_input_indices.set(style_node, m_element_style_inputs.size()); + m_element_style_inputs.append({ style_node.value(), reaction, inherited_style_groups }); +} + void StyleEngine::record_element_style_input_change(StyleNodeID style_node, u8 reaction, u8 inherited_style_groups) { if (style_node != 0 && reaction != 0) { request_frame_for_first_recorded_input(*this, m_style_computer); - m_element_style_inputs.append({ style_node.value(), reaction, inherited_style_groups }); + append_or_merge_element_style_input(style_node, reaction, inherited_style_groups); } } @@ -434,24 +447,29 @@ void StyleEngine::record_flat_tree_descendant_style_input_changes(StyleNodeID st request_frame_for_first_recorded_input(*this, m_style_computer); auto descendants = StyleEngineFFI::style_engine_flat_tree_descendants(m_impl, style_node.value()); for (auto descendant : ReadonlySpan { descendants.nodes, descendants.count }) - m_element_style_inputs.append({ descendant, reaction, inherited_style_groups }); + append_or_merge_element_style_input(StyleNodeID { descendant }, reaction, inherited_style_groups); StyleEngineFFI::style_engine_discard_flat_tree_descendants(m_impl); } void StyleEngine::consume_recorded_element_style_input_change(StyleNodeID style_node) { - m_element_style_inputs.remove_all_matching([&](auto const& input) { - return input.style_node == style_node.value(); - }); + auto existing = m_element_style_input_indices.find(style_node); + if (existing == m_element_style_input_indices.end()) + return; + + auto index = existing->value; + VERIFY(index < m_element_style_inputs.size()); + m_element_style_input_indices.remove(style_node); + auto last_input = m_element_style_inputs.take_last(); + if (index < m_element_style_inputs.size()) { + m_element_style_inputs[index] = last_input; + m_element_style_input_indices.set(StyleNodeID { last_input.style_node }, index); + } } bool StyleEngine::has_recorded_element_style_input_change(StyleNodeID style_node) const { - for (auto const& input : m_element_style_inputs) { - if (input.style_node == style_node.value()) - return true; - } - return false; + return m_element_style_input_indices.contains(style_node); } void StyleEngine::record_benchmark_marker(Utf16View name) { @@ -506,6 +524,7 @@ void StyleEngine::submit_recorded_input() m_state_deltas.clear_with_capacity(); m_element_declaration_deltas.clear_with_capacity(); m_element_style_inputs.clear_with_capacity(); + m_element_style_input_indices.clear_with_capacity(); // Selector demand can arrive while the program change and element facts are still staged. // Refresh after applying the fact batch, then backfill values before matching observes it. diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.h b/Libraries/LibWeb/CSS/StyleEngineBridge.h index 7bdf6e8881fb..43a55bcecea6 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.h +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.h @@ -64,6 +64,9 @@ class WEB_API StyleEngine { [[nodiscard]] bool has_deferred_element_initial_features(StyleNodeID style_node) const { return m_nodes_with_pending_initial_features.contains(style_node); } Vector take_deferred_element_initial_features(); HashTable take_elements_awaiting_first_style_computation(); + void mark_style_node_preallocated(StyleNodeID style_node, TreeScopeID tree_scope) { m_preallocated_style_nodes.set(style_node, tree_scope); } + Optional consume_preallocated_style_node(StyleNodeID style_node) { return m_preallocated_style_nodes.take(style_node); } + void cancel_preallocated_style_node(StyleNodeID style_node) { m_preallocated_style_nodes.remove(style_node); } [[nodiscard]] bool resize_parsed_substitution_cache(u64 bytes); void set_element_parts(StyleNodeID node, ReadonlySpan names, ReadonlySpan hosts); @@ -236,6 +239,7 @@ class WEB_API StyleEngine { using InputTransaction = StyleEngineFFI::FfiStyleInputTransaction; bool read_matches(StyleNodeID, Vector&, Optional); + void append_or_merge_element_style_input(StyleNodeID, u8 reaction, u8 inherited_style_groups); void apply_transaction(InputTransaction const&); void submit_recorded_input(); bool refresh_attribute_value_text_requirements(); @@ -252,6 +256,8 @@ class WEB_API StyleEngine { u64 m_attribute_value_text_requirements_version { 0 }; HashTable m_nodes_with_pending_initial_features; HashTable m_nodes_awaiting_first_style_computation; + HashMap m_preallocated_style_nodes; + HashMap m_element_style_input_indices; size_t m_element_match_capacity { 64 }; u32 m_declaration_block_version { 1 }; diff --git a/Libraries/LibWeb/CSS/StyleEngineInput.cpp b/Libraries/LibWeb/CSS/StyleEngineInput.cpp index 01baf920597e..ad6cdb357db8 100644 --- a/Libraries/LibWeb/CSS/StyleEngineInput.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineInput.cpp @@ -4,7 +4,6 @@ * SPDX-License-Identifier: BSD-2-Clause */ -#include #include #include #include @@ -53,12 +52,6 @@ static constexpr StyleNodeID no_style_node; // Shadow trees get their own scopes with the shadow surface; today everything names the document. static constexpr TreeScopeID document_tree_scope; -static HashTable& preallocated_style_elements() -{ - static NeverDestroyed> elements; - return *elements; -} - static bool has_pending_initial_features(DOM::Element const& element) { return element.document().style_computer().style_engine().has_deferred_element_initial_features(element.style_node_id()); @@ -128,6 +121,21 @@ static TreeScopeID tree_scope_of(DOM::Node& document_or_shadow_root) return shadow_root->style_engine_tree_scope(); } +template +static void for_each_shadow_including_inclusive_descendant_with_scope(DOM::Node& node, TreeScopeID tree_scope, Callback& callback) +{ + callback(node, tree_scope); + + if (auto* element = as_if(node); element && element->shadow_root()) { + auto& shadow_root = *element->shadow_root(); + auto shadow_scope = tree_scope_of(shadow_root); + for_each_shadow_including_inclusive_descendant_with_scope(shadow_root, shadow_scope, callback); + } + + for (auto* child = node.first_child(); child; child = child->next_sibling()) + for_each_shadow_including_inclusive_descendant_with_scope(*child, tree_scope, callback); +} + TreeScopeID style_engine_tree_scope_for(DOM::Node& document_or_shadow_root) { return tree_scope_of(document_or_shadow_root); @@ -144,7 +152,7 @@ static StyleNodeID style_tree_parent_of(DOM::Element& element, StyleEngine& styl return no_style_node; } -static StyleEngineFFI::FfiTreeRelations relations_of(DOM::Element& element, StyleEngine& style_engine) +static StyleEngineFFI::FfiTreeRelations relations_of(DOM::Element& element, StyleEngine& style_engine, TreeScopeID tree_scope) { auto assigned_slot = no_style_node; if (auto slot = element.assigned_slot_internal()) @@ -154,12 +162,17 @@ static StyleEngineFFI::FfiTreeRelations relations_of(DOM::Element& element, Styl .parent = style_tree_parent_of(element, style_engine).value(), .previous_element_sibling = identity_of(element.previous_element_sibling()).value(), .next_element_sibling = identity_of(element.next_element_sibling()).value(), - .tree_scope = tree_scope_of(element.root()).value(), + .tree_scope = tree_scope.value(), .assigned_slot = assigned_slot.value(), .reserved = 0, }; } +static StyleEngineFFI::FfiTreeRelations relations_of(DOM::Element& element, StyleEngine& style_engine) +{ + return relations_of(element, style_engine, tree_scope_of(element.root())); +} + static void record_feature( DOM::Element&, StyleEngineFFI::FfiFeatureKind, @@ -186,13 +199,17 @@ void record_element_connected(DOM::Element& element) auto* style_engine = style_engine_for(element); if (!style_engine) return; + Optional preallocated_tree_scope; if (element.style_node_id() == no_style_node) { element.set_style_node_id(style_engine->allocate_style_node()); element.document().style_computer().register_style_node(element.style_node_id(), element); - } else if (!preallocated_style_elements().remove(&element)) { - // Already connected as far as the engine is concerned. Re-recording an insertion would - // double-link the element into its sibling sequence. - return; + } else { + preallocated_tree_scope = style_engine->consume_preallocated_style_node(element.style_node_id()); + if (!preallocated_tree_scope.has_value()) { + // Already connected as far as the engine is concerned. Re-recording an insertion would + // double-link the element into its sibling sequence. + return; + } } // A shadow root that took its identity before its host had one is still waiting to be linked to // it. A sheet adopted into a shadow tree names that root, so the root can be identified first, @@ -205,7 +222,9 @@ void record_element_connected(DOM::Element& element) .old_connected = false, .new_connected = true, .old_relations = detached_relations(), - .new_relations = relations_of(element, *style_engine), + .new_relations = preallocated_tree_scope.has_value() + ? relations_of(element, *style_engine, *preallocated_tree_scope) + : relations_of(element, *style_engine), }); style_engine->defer_element_initial_features(element.style_node_id()); @@ -236,14 +255,20 @@ void prepare_style_nodes_for_subtree(DOM::Node& root) auto& style_computer = root.document().style_computer(); auto& style_engine = style_computer.style_engine(); Vector> elements; + Vector element_tree_scopes; Vector> shadow_roots; - root.for_each_shadow_including_inclusive_descendant([&](DOM::Node& node) { - if (auto* element = as_if(node); element && element->style_node_id() == no_style_node) + Vector shadow_root_tree_scopes; + auto collect = [&](DOM::Node& node, TreeScopeID tree_scope) { + if (auto* element = as_if(node); element && element->style_node_id() == no_style_node) { elements.append(element); - else if (auto* shadow_root = as_if(node); shadow_root && shadow_root->style_node_id() == no_style_node) + element_tree_scopes.append(tree_scope); + } else if (auto* shadow_root = as_if(node); shadow_root && shadow_root->style_node_id() == no_style_node) { shadow_roots.append(shadow_root); - return TraversalDecision::Continue; - }); + shadow_root_tree_scopes.append(tree_scope); + } + }; + auto root_tree_scope = tree_scope_of(root.root()); + for_each_shadow_including_inclusive_descendant_with_scope(root, root_tree_scope, collect); Vector identities; identities.resize(elements.size() + shadow_roots.size()); style_engine.allocate_style_nodes(identities.span()); @@ -251,13 +276,13 @@ void prepare_style_nodes_for_subtree(DOM::Node& root) auto& element = *elements[index]; element.set_style_node_id(identities[index]); style_computer.register_style_node(identities[index], element); - preallocated_style_elements().set(&element); + style_engine.mark_style_node_preallocated(element.style_node_id(), element_tree_scopes[index]); } for (size_t index = 0; index < shadow_roots.size(); ++index) { auto& shadow_root = *shadow_roots[index]; auto identity = identities[elements.size() + index]; shadow_root.set_style_node_id(identity); - style_engine.set_tree_scope_root(tree_scope_of(shadow_root), identity); + style_engine.set_tree_scope_root(shadow_root_tree_scopes[index], identity); } } @@ -596,7 +621,7 @@ void record_element_assigned_slot_changed(DOM::Element& element, DOM::Element* o }); } -void record_element_disconnecting(DOM::Element& element) +static void record_element_disconnecting(DOM::Element& element, TreeScopeID tree_scope) { auto* style_engine = style_engine_for(element); if (!style_engine) @@ -633,7 +658,7 @@ void record_element_disconnecting(DOM::Element& element) .node = node.value(), .old_connected = true, .new_connected = false, - .old_relations = relations_of(element, *style_engine), + .old_relations = relations_of(element, *style_engine, tree_scope), .new_relations = detached_relations(), }); @@ -1041,6 +1066,24 @@ void record_shadow_root_disconnecting(DOM::ShadowRoot& shadow_root) shadow_root.set_style_node_id(no_style_node); } +void record_subtree_disconnecting(DOM::Node& root) +{ + auto root_tree_scope = tree_scope_of(root.root()); + auto disconnect_element = [](DOM::Node& node, TreeScopeID tree_scope) { + if (auto* element = as_if(node)) + record_element_disconnecting(*element, tree_scope); + }; + for_each_shadow_including_inclusive_descendant_with_scope(root, root_tree_scope, disconnect_element); + + // Only once no element still names a shadow root as its parent can the root give up its own + // identity. + auto disconnect_shadow_root = [](DOM::Node& node, TreeScopeID) { + if (auto* shadow_root = as_if(node)) + record_shadow_root_disconnecting(*shadow_root); + }; + for_each_shadow_including_inclusive_descendant_with_scope(root, root_tree_scope, disconnect_shadow_root); +} + void record_shadow_root_connected(DOM::ShadowRoot& shadow_root) { auto* style_engine = style_engine_for(shadow_root); diff --git a/Libraries/LibWeb/CSS/StyleEngineInput.h b/Libraries/LibWeb/CSS/StyleEngineInput.h index 5b2db84699a9..7109d32273ea 100644 --- a/Libraries/LibWeb/CSS/StyleEngineInput.h +++ b/Libraries/LibWeb/CSS/StyleEngineInput.h @@ -41,8 +41,8 @@ WEB_API void populate_isolated_selector_query_engine(StyleEngine&, DOM::ParentNo // before the first sheet attaches, and has to say it itself. WEB_API void record_document_kind(DOM::Document&); -// Called while the node is still linked, so its old relations are still readable. -WEB_API void record_element_disconnecting(DOM::Element&); +// Called while the subtree is still linked, so its old relations are still readable. +WEB_API void record_subtree_disconnecting(DOM::Node&); // Report that an element moved without leaving the tree. `moveBefore()` keeps the element's state // and its identity, so nothing disconnects and nothing connects, and only its relations move. diff --git a/Libraries/LibWeb/CSS/StyleInputRecord.h b/Libraries/LibWeb/CSS/StyleInputRecord.h index fb1f6e203ef0..657e6d4c0067 100644 --- a/Libraries/LibWeb/CSS/StyleInputRecord.h +++ b/Libraries/LibWeb/CSS/StyleInputRecord.h @@ -43,8 +43,9 @@ struct StyleInputRecord { // Such a computation cannot be answered from the record, because what it read can move without // any word of it moving. bool read_beyond_the_record { true }; - // What that computation did besides producing values. A computation that is skipped still has - // to leave these marks, since nothing else will leave them for it. + // A snapshot of the element's authoritative dependency marks. The element clears its marks + // before deriving a replacement, so a computation skipped through this record must restore the + // previous marks that nothing else will leave. bool style_uses_attr_css_function { false }; bool style_uses_var_css_function { false }; bool style_uses_if_css_function { false }; diff --git a/Libraries/LibWeb/DOM/Element.cpp b/Libraries/LibWeb/DOM/Element.cpp index 4eac3e6b662d..e26aa148ad2a 100644 --- a/Libraries/LibWeb/DOM/Element.cpp +++ b/Libraries/LibWeb/DOM/Element.cpp @@ -2411,18 +2411,8 @@ void Element::set_shadow_root(GC::Ptr shadow_root) if (m_shadow_root == shadow_root) return; if (m_shadow_root) { - if (is_connected()) { - m_shadow_root->for_each_shadow_including_inclusive_descendant([](DOM::Node& descendant) { - if (auto* element = as_if(descendant)) - CSS::record_element_disconnecting(*element); - return TraversalDecision::Continue; - }); - m_shadow_root->for_each_shadow_including_inclusive_descendant([](DOM::Node& descendant) { - if (auto* shadow_root = as_if(descendant)) - CSS::record_shadow_root_disconnecting(*shadow_root); - return TraversalDecision::Continue; - }); - } + if (is_connected()) + CSS::record_subtree_disconnecting(*m_shadow_root); m_shadow_root->set_host(nullptr); m_shadow_root->set_is_connected(false); // NB: We don't need to run the removed steps if the children have already been disconnected (or were never diff --git a/Libraries/LibWeb/DOM/Element.h b/Libraries/LibWeb/DOM/Element.h index 6858b9f2e61f..f7c785e59536 100644 --- a/Libraries/LibWeb/DOM/Element.h +++ b/Libraries/LibWeb/DOM/Element.h @@ -882,8 +882,9 @@ class WEB_API Element GC::Ptr m_inline_style; GC::Ptr m_shadow_root; - // The authoritative StyleEngine record. C++ compatibility consumers borrow the record-owned - // computed-values view rather than retaining one complete style per element. + // A consumer handle mirroring StyleEngine's authoritative style-record column. C++ consumers + // borrow the record-owned computed-values view rather than retaining one complete style per + // element. CSS::StyleRecordID m_style_record_identity; RefPtr m_custom_property_data; OwnPtr m_style_input_record; @@ -905,6 +906,7 @@ class WEB_API Element bool m_is_being_activated : 1 { false }; bool m_in_top_layer : 1 { false }; bool m_rendered_in_top_layer : 1 { false }; + // Authoritative dependency marks left by this element's latest style computation. bool m_style_uses_attr_css_function : 1 { false }; bool m_style_uses_var_css_function : 1 { false }; bool m_style_uses_if_css_function : 1 { false }; diff --git a/Libraries/LibWeb/DOM/Node.cpp b/Libraries/LibWeb/DOM/Node.cpp index 5572e0a5540d..ec22873884ad 100644 --- a/Libraries/LibWeb/DOM/Node.cpp +++ b/Libraries/LibWeb/DOM/Node.cpp @@ -1106,18 +1106,7 @@ void Node::remove(bool suppress_observers) // NB: Recorded here rather than in removed_from(), because StyleEngine's tree delta carries // the old relations and this is the last point at which they are still readable. - for_each_shadow_including_inclusive_descendant([](Node& node) { - if (auto* element = as_if(node)) - CSS::record_element_disconnecting(*element); - return TraversalDecision::Continue; - }); - // Only once no element still names a shadow root as its parent can the root give up its - // own identity. - for_each_shadow_including_inclusive_descendant([](Node& node) { - if (auto* shadow_root = as_if(node)) - CSS::record_shadow_root_disconnecting(*shadow_root); - return TraversalDecision::Continue; - }); + CSS::record_subtree_disconnecting(*this); // A display: contents element has no principal layout node of its own, but removing it also removes // all of its children's boxes from the parent's layout subtree. From 784e0be8a7187de6a08ae0915337a5587526ce92 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Tue, 18 Aug 2026 03:22:02 +0200 Subject: [PATCH 37/39] LibWeb: Reclaim unused style atoms deterministically With process-global atoms, an atom no document mentions any more was retained for the process lifetime, together with its FlyString reference and catalog entries, so attribute-value churn grew without bound. Count live roots per document and reclaim unrooted atoms after marking pins, qualified-name components, and derived attribute-name forms to a fixed point, purging every atom-keyed catalog before an identity is freed. Sweep only inside take_style_transaction, on growth or after 256 pin releases, so each batch reaches C++ before an identity is reused; a direct flush release cannot deliver that batch and does not sweep, and a sweep waits while a matching traversal owns the primary view. Record every sweep so replay recycles the same identities, keep atom live counts in paged u32 columns. --- Libraries/LibWeb/CSS/CustomPropertyData.h | 6 +- Libraries/LibWeb/CSS/StyleEngineBridge.cpp | 25 ++ Libraries/LibWeb/CSS/StyleEngineBridge.h | 2 + Libraries/LibWeb/CSS/StyleEngineInput.cpp | 2 +- Libraries/LibWeb/Rust/src/bin/style_replay.rs | 67 ++++ Libraries/LibWeb/Rust/src/css/style/atoms.rs | 325 ++++++++++++++- Libraries/LibWeb/Rust/src/css/style/bridge.rs | 65 ++- Libraries/LibWeb/Rust/src/css/style/flush.rs | 6 +- Libraries/LibWeb/Rust/src/css/style/index.rs | 374 +++++++++++++++++- Libraries/LibWeb/Rust/src/css/style/inputs.rs | 15 +- .../Rust/src/css/style/instrumentation.rs | 6 + Libraries/LibWeb/Rust/src/css/style/mod.rs | 12 + .../LibWeb/Rust/src/css/style/ordering.rs | 83 +++- .../LibWeb/Rust/src/css/style/program.rs | 27 ++ .../Rust/src/css/style/record_replay.rs | 13 +- .../LibWeb/Rust/src/css/style/selector.rs | 79 ++++ Libraries/LibWeb/Rust/src/css/style/tests.rs | 371 +++++++++++++++++ Libraries/LibWeb/Rust/src/css/style/tree.rs | 19 + Tests/LibWeb/TestStyleEngineBridge.cpp | 68 ++++ 19 files changed, 1532 insertions(+), 33 deletions(-) diff --git a/Libraries/LibWeb/CSS/CustomPropertyData.h b/Libraries/LibWeb/CSS/CustomPropertyData.h index 687b2a34ec70..7fc118a40b17 100644 --- a/Libraries/LibWeb/CSS/CustomPropertyData.h +++ b/Libraries/LibWeb/CSS/CustomPropertyData.h @@ -54,9 +54,9 @@ class WEB_API CustomPropertyData : public RefCounted { // are process-global, but each document must acquire its own references, so the document that // asked is part of what the answer is good for. template - [[nodiscard]] ReadonlySpan declared_name_atoms(FlatPtr document_identity, InternName&& intern) const + [[nodiscard]] ReadonlySpan declared_name_atoms(FlatPtr document_identity, u64 atom_generation, InternName&& intern) const { - if (m_cached_name_atoms_document_identity == document_identity) + if (m_cached_name_atoms_document_identity == document_identity && m_cached_name_atoms_generation == atom_generation) return m_cached_declared_name_atoms.span(); m_cached_declared_name_atoms.clear_with_capacity(); m_cached_declared_name_atoms.ensure_capacity(m_declared_count); @@ -74,6 +74,7 @@ class WEB_API CustomPropertyData : public RefCounted { } m_cached_declared_name_atoms.shrink(unique); m_cached_name_atoms_document_identity = document_identity; + m_cached_name_atoms_generation = atom_generation; return m_cached_declared_name_atoms.span(); } @@ -122,6 +123,7 @@ class WEB_API CustomPropertyData : public RefCounted { size_t m_declared_count { 0 }; u64 m_identity { 0 }; mutable FlatPtr m_cached_name_atoms_document_identity { NumericLimits::max() }; + mutable u64 m_cached_name_atoms_generation { 0 }; mutable Vector m_cached_declared_name_atoms; mutable FlatPtr m_cached_inheritable_document_identity { NumericLimits::max() }; mutable size_t m_cached_inheritable_generation { NumericLimits::max() }; diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp index 55ef8e683c72..3ef2ccb3d317 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp @@ -560,6 +560,31 @@ StyleEngine::PublishedStyleTransaction StyleEngine::take_style_transaction(Style { submit_recorded_input(); auto view = StyleEngineFFI::style_engine_take_style_transaction(m_impl, root.value()); + if (view.reclaimed_style_atom_count != 0) { + HashTable reclaimed_atoms; + reclaimed_atoms.ensure_capacity(view.reclaimed_style_atom_count); + for (auto const& reclaimed : ReadonlySpan { view.reclaimed_style_atoms, view.reclaimed_style_atom_count }) { + auto atom_id = StyleAtomID { reclaimed.atom }; + reclaimed_atoms.set(atom_id); + m_published_language_atoms.remove(atom_id); + m_attribute_names_requiring_value_text.remove(atom_id); + if (reclaimed.raw == 0) + continue; + auto atom = m_atoms.take(reclaimed.raw); + VERIFY(atom.has_value()); + VERIFY(atom.release_value() == reclaimed.atom); + Utf16FlyString::unref_raw(reclaimed.raw); + } + m_attribute_name_atoms.remove_all_matching([&](StyleAtomID local, auto& names_by_namespace) { + if (reclaimed_atoms.contains(local)) + return true; + names_by_namespace.remove_all_matching([&](StyleAtomID namespace_atom, StyleAtomID name) { + return reclaimed_atoms.contains(namespace_atom) || reclaimed_atoms.contains(name); + }); + return names_by_namespace.is_empty(); + }); + ++m_atom_generation; + } return { .version = { view.transaction_version, view.program_version }, .reactions = { view.answers, view.count }, diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.h b/Libraries/LibWeb/CSS/StyleEngineBridge.h index 43a55bcecea6..770ceac5db29 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.h +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.h @@ -142,6 +142,7 @@ class WEB_API StyleEngine { // lookup on that word plus one reference to keep the name alive. No string is copied, and // neither side pays an ASCII or UTF-16 conversion for a fact a u32 comparison answers. StyleAtomID intern_atom(Utf16FlyString const&); + [[nodiscard]] u64 atom_generation() const { return m_atom_generation; } // The namespace `[*|x]` names, which is any of them. No interned namespace is zero, so this // keys a form of its own in the same table. static constexpr StyleAtomID any_namespace { 0 }; @@ -253,6 +254,7 @@ class WEB_API StyleEngine { HashTable m_published_language_atoms; HashMap> m_attribute_name_atoms; HashMap m_attribute_names_requiring_value_text; + u64 m_atom_generation { 1 }; u64 m_attribute_value_text_requirements_version { 0 }; HashTable m_nodes_with_pending_initial_features; HashTable m_nodes_awaiting_first_style_computation; diff --git a/Libraries/LibWeb/CSS/StyleEngineInput.cpp b/Libraries/LibWeb/CSS/StyleEngineInput.cpp index ad6cdb357db8..102b09f51686 100644 --- a/Libraries/LibWeb/CSS/StyleEngineInput.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineInput.cpp @@ -697,7 +697,7 @@ void record_element_custom_property_names(DOM::Element& element, CustomPropertyD return; auto atoms = data - ? data->declared_name_atoms(bit_cast(&element.document()), [&](Utf16FlyString const& name) { return style_engine->intern_atom(name); }) + ? data->declared_name_atoms(bit_cast(&element.document()), style_engine->atom_generation(), [&](Utf16FlyString const& name) { return style_engine->intern_atom(name); }) : ReadonlySpan {}; style_engine->set_element_custom_property_names(element.style_node_id(), atoms, uses_unnamed, uses_custom_functions); } diff --git a/Libraries/LibWeb/Rust/src/bin/style_replay.rs b/Libraries/LibWeb/Rust/src/bin/style_replay.rs index 27f786a4211b..e0aa9791ee05 100644 --- a/Libraries/LibWeb/Rust/src/bin/style_replay.rs +++ b/Libraries/LibWeb/Rust/src/bin/style_replay.rs @@ -370,8 +370,44 @@ fn run() -> Result<(), Box> { .get_or_insert_with(|| DetailedCounterReader::new(engine)) .read(engine) }); + if expected.style_atoms_swept { + unsafe { + bridge::style_engine_set_replay_reclaimed_style_atoms( + engine, + expected.reclaimed_atoms.as_ptr(), + expected.reclaimed_atoms.len(), + ); + } + } let start = Instant::now(); let actual_view = unsafe { bridge::style_engine_take_style_transaction(engine, root) }; + let actual_reclaimed_atoms = if actual_view.reclaimed_style_atom_count == 0 { + Vec::new() + } else { + unsafe { + std::slice::from_raw_parts( + actual_view.reclaimed_style_atoms, + actual_view.reclaimed_style_atom_count, + ) + } + .iter() + .map(|reclaimed| reclaimed.atom) + .collect::>() + }; + if actual_view.style_atoms_swept != expected.style_atoms_swept { + return Err(format!( + "style atom sweep diverged: expected {}, got {}", + expected.style_atoms_swept, actual_view.style_atoms_swept + ) + .into()); + } + if actual_reclaimed_atoms != expected.reclaimed_atoms { + return Err(format!( + "style atom reclamation diverged: expected {:?}, got {:?}", + expected.reclaimed_atoms, actual_reclaimed_atoms + ) + .into()); + } if actual_view.count != 0 { let answers = unsafe { std::slice::from_raw_parts(actual_view.answers, actual_view.count) }; replay_style_transaction_output( @@ -414,6 +450,8 @@ fn run() -> Result<(), Box> { result: actual_view.scoped, filter_calls: Vec::new(), emissions: context.actual_emissions, + style_atoms_swept: actual_view.style_atoms_swept, + reclaimed_atoms: actual_reclaimed_atoms, }; let mut actual_payload = PayloadWriter::default(); write_style_transaction_outputs(&actual, &mut actual_payload); @@ -1800,6 +1838,8 @@ struct StyleTransactionOutputs { result: bool, filter_calls: Vec<(Vec, Vec)>, emissions: Vec, + style_atoms_swept: bool, + reclaimed_atoms: Vec, } struct ReplayStyleTransactionContext { @@ -1943,11 +1983,15 @@ fn read_style_transaction_outputs( answers, }); } + let style_atoms_swept = payload.read_bool()?; + let reclaimed_atoms = payload.read_u32_vec()?; payload.finish()?; Ok(StyleTransactionOutputs { result, filter_calls, emissions, + style_atoms_swept, + reclaimed_atoms, }) } @@ -1975,6 +2019,8 @@ fn write_style_transaction_outputs(outputs: &StyleTransactionOutputs, payload: & payload.write_u8(answer.gap as u8); } } + payload.write_bool(outputs.style_atoms_swept); + payload.write_u32_slice(&outputs.reclaimed_atoms); } fn replay_atom_mappings(engine: *mut c_void, payload: &mut PayloadReader) -> Result<(), Box> { @@ -2532,6 +2578,27 @@ mod tests { assert_eq!(amplification["stages"]["selector"]["amplification"], 2.5); } + #[test] + fn style_transaction_outputs_round_trip_a_sweep_without_reclaims() { + let expected = StyleTransactionOutputs { + result: true, + filter_calls: Vec::new(), + emissions: Vec::new(), + style_atoms_swept: true, + reclaimed_atoms: Vec::new(), + }; + let mut payload = PayloadWriter::default(); + write_style_transaction_outputs(&expected, &mut payload); + + let actual = read_style_transaction_outputs(PayloadReader::new(payload.as_bytes())).unwrap(); + + assert!(actual.result); + assert!(actual.filter_calls.is_empty()); + assert!(actual.emissions.is_empty()); + assert!(actual.style_atoms_swept); + assert_eq!(actual.reclaimed_atoms, expected.reclaimed_atoms); + } + #[test] fn style_record_replay_indices_ignore_base_generations() { let identity = 17; diff --git a/Libraries/LibWeb/Rust/src/css/style/atoms.rs b/Libraries/LibWeb/Rust/src/css/style/atoms.rs index 80932b8dad21..70db4734b444 100644 --- a/Libraries/LibWeb/Rust/src/css/style/atoms.rs +++ b/Libraries/LibWeb/Rust/src/css/style/atoms.rs @@ -4,8 +4,13 @@ * SPDX-License-Identifier: BSD-2-Clause */ +use std::cell::Cell; +use std::cell::RefCell; use std::collections::BTreeSet; use std::collections::HashMap; +use std::collections::HashSet; +use std::hash::BuildHasher; +use std::rc::Rc; use std::sync::Mutex; use std::sync::OnceLock; @@ -178,27 +183,97 @@ enum AtomScope { pub(super) struct DocumentAtoms { raw: HashMap, + cpp_memoized_raws: HashSet, qualified: HashMap<(u32, u32), StyleAtomID>, scope: AtomScope, + pins: Rc, + #[cfg(test)] + available: BTreeSet, + #[cfg(test)] + next: u32, + sweep_at: usize, + reported_pin_releases: Cell, +} + +pub(super) struct PinnedAtoms { + atoms: Vec, + pins: Rc, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct ReclaimedStyleAtom { + pub raw: usize, + pub atom: StyleAtomID, +} + +#[derive(Default)] +struct AtomPins { + counts: RefCell>, + releases: Cell, +} + +pub(super) struct AtomSweepDecision { + pub should_sweep: bool, + pub skipped_pin_releases: u64, +} + +pub(super) const PIN_RELEASES_PER_SWEEP: u64 = 256; + +impl Drop for PinnedAtoms { + fn drop(&mut self) { + let mut pinned = self.pins.counts.borrow_mut(); + for atom in &self.atoms { + let count = pinned.get_mut(atom).expect("a pinned atom must have a live count"); + *count = count.checked_sub(1).expect("pinned atom count underflow"); + if *count == 0 { + pinned.remove(atom); + } + } + if !self.atoms.is_empty() { + self.pins.releases.set( + self.pins + .releases + .get() + .checked_add(1) + .expect("atom pin release count overflow"), + ); + } + } } impl DocumentAtoms { pub(super) fn for_live_engine() -> Self { Self { raw: HashMap::new(), + cpp_memoized_raws: HashSet::new(), qualified: HashMap::new(), #[cfg(test)] scope: AtomScope::Document, #[cfg(not(test))] scope: AtomScope::Process(RawAtomLifetime::RetainedFlyString), + pins: Rc::new(AtomPins::default()), + #[cfg(test)] + available: BTreeSet::new(), + #[cfg(test)] + next: 0, + sweep_at: 256, + reported_pin_releases: Cell::new(0), } } pub(super) fn for_replay() -> Self { Self { raw: HashMap::new(), + cpp_memoized_raws: HashSet::new(), qualified: HashMap::new(), scope: AtomScope::Process(RawAtomLifetime::OpaqueReplayToken), + pins: Rc::new(AtomPins::default()), + #[cfg(test)] + available: BTreeSet::new(), + #[cfg(test)] + next: 0, + sweep_at: 256, + reported_pin_releases: Cell::new(0), } } @@ -218,6 +293,11 @@ impl DocumentAtoms { atom } + pub(super) fn intern_cpp_raw(&mut self, raw: usize) -> StyleAtomID { + self.cpp_memoized_raws.insert(raw); + self.intern_raw(raw) + } + pub(super) fn intern_qualified(&mut self, namespace: StyleAtomID, name: StyleAtomID) -> StyleAtomID { let key = (namespace.0, name.0); if let Some(&atom) = self.qualified.get(&key) { @@ -236,10 +316,135 @@ impl DocumentAtoms { } #[cfg(test)] - fn allocate_document_atom(&self) -> StyleAtomID { - let next = - u32::try_from(self.raw.len() + self.qualified.len()).expect("document style atom space exhausted") + 1; - StyleAtomID(next) + fn allocate_document_atom(&mut self) -> StyleAtomID { + if let Some(atom) = self.available.pop_first() { + return StyleAtomID(atom); + } + self.next = self.next.checked_add(1).expect("document style atom space exhausted"); + StyleAtomID(self.next) + } + + pub(super) fn pin(&self, atoms: impl IntoIterator) -> PinnedAtoms { + let atoms = atoms.into_iter().filter(|atom| !atom.is_none()).collect::>(); + let mut pinned = self.pins.counts.borrow_mut(); + for &atom in &atoms { + *pinned.entry(atom).or_default() += 1; + } + drop(pinned); + PinnedAtoms { + atoms, + pins: Rc::clone(&self.pins), + } + } + + pub(super) fn sweep_decision(&self) -> AtomSweepDecision { + let growth_requires_sweep = self.raw.len() + self.qualified.len() >= self.sweep_at; + let pin_releases = self.pins.releases.get(); + let pin_releases_require_sweep = pin_releases >= PIN_RELEASES_PER_SWEEP; + let skipped_pin_releases = if growth_requires_sweep || pin_releases_require_sweep { + 0 + } else { + pin_releases + .checked_sub(self.reported_pin_releases.get()) + .expect("reported atom pin releases exceed releases") + }; + self.reported_pin_releases.set(pin_releases); + AtomSweepDecision { + should_sweep: growth_requires_sweep || pin_releases_require_sweep, + skipped_pin_releases, + } + } + + /// Add transient pins and the raw components of every live qualified name. + pub(super) fn mark_sweep_dependencies(&self, live: &mut HashSet) + where + S: BuildHasher, + { + live.extend(self.pins.counts.borrow().keys().copied()); + for (&(namespace, name), &qualified) in &self.qualified { + if live.contains(&qualified) { + if namespace != 0 { + live.insert(StyleAtomID(namespace)); + } + if name != 0 { + live.insert(StyleAtomID(name)); + } + } + } + } + + /// Return atoms not owned by semantic state after all derived dependencies have been marked. + /// Derived atom-keyed catalogs must forget these identities before finish_sweep makes their + /// integers available for reuse. + pub(super) fn reclaimable_for_sweep(&self, live: &HashSet) -> Vec + where + S: BuildHasher, + { + let mut reclaimable = self + .raw + .values() + .chain(self.qualified.values()) + .copied() + .filter(|atom| !live.contains(atom)) + .collect::>(); + reclaimable.sort_unstable_by_key(|atom| atom.0); + reclaimable + } + + pub(super) fn finish_sweep(&mut self, reclaimable: &[StyleAtomID]) -> Vec { + let reclaimable = reclaimable.iter().copied().collect::>(); + let mut raw = Vec::new(); + self.raw.retain(|&identity, &mut atom| { + if !reclaimable.contains(&atom) { + return true; + } + raw.push((identity, atom)); + false + }); + let mut qualified = Vec::new(); + self.qualified.retain(|&key, &mut atom| { + if !reclaimable.contains(&atom) { + return true; + } + qualified.push((key, atom)); + false + }); + + match self.scope { + #[cfg(test)] + AtomScope::Document => { + self.available.extend(reclaimable.iter().map(|atom| atom.0)); + } + AtomScope::Process(_) => { + let mut global = global_atoms() + .lock() + .expect("process-global style atom lock is poisoned"); + for &(key, atom) in &qualified { + global.release_qualified(key, atom); + } + for &(identity, atom) in &raw { + global.release_raw(identity, atom); + } + } + } + + self.sweep_at = self.raw.len() + self.qualified.len() + 256; + self.pins.releases.set(0); + self.reported_pin_releases.set(0); + let mut reclaimed = raw + .into_iter() + .map(|(raw, atom)| ReclaimedStyleAtom { + raw: if self.cpp_memoized_raws.remove(&raw) { raw } else { 0 }, + atom, + }) + .chain( + qualified + .into_iter() + .map(|(_, atom)| ReclaimedStyleAtom { raw: 0, atom }), + ) + .collect::>(); + reclaimed.sort_unstable_by_key(|entry| entry.atom.0); + reclaimed } #[cfg(feature = "style-recording")] @@ -276,6 +481,11 @@ mod tests { static GLOBAL_ATOM_TEST_LOCK: Mutex<()> = Mutex::new(()); + fn prepare_sweep(atoms: &DocumentAtoms, live: &mut HashSet) -> Vec { + atoms.mark_sweep_dependencies(live); + atoms.reclaimable_for_sweep(live) + } + #[test] fn synthetic_text_keys_do_not_retain_fly_strings() { assert_eq!( @@ -303,6 +513,31 @@ mod tests { assert_eq!(later.intern_raw(0x9abc), first_atom); } + #[test] + fn process_atoms_wait_for_every_raw_and_qualified_owner() { + let _test_lock = GLOBAL_ATOM_TEST_LOCK.lock().unwrap(); + let mut first = DocumentAtoms::for_replay(); + let mut second = DocumentAtoms::for_replay(); + let namespace = first.intern_raw(0x1000); + let name = first.intern_raw(0x2000); + let qualified = first.intern_qualified(namespace, name); + assert_eq!(second.intern_raw(0x1000), namespace); + assert_eq!(second.intern_raw(0x2000), name); + assert_eq!(second.intern_qualified(namespace, name), qualified); + + let first_reclaimable = prepare_sweep(&first, &mut HashSet::new()); + first.finish_sweep(&first_reclaimable); + assert_eq!(second.intern_raw(0x1000), namespace); + assert_eq!(second.intern_qualified(namespace, name), qualified); + + let second_reclaimable = prepare_sweep(&second, &mut HashSet::new()); + second.finish_sweep(&second_reclaimable); + let mut later = DocumentAtoms::for_replay(); + assert_eq!(later.intern_raw(0x3000), namespace); + assert_eq!(later.intern_raw(0x4000), name); + assert_eq!(later.intern_qualified(namespace, name), qualified); + } + #[test] fn qualified_atoms_share_the_global_identity_space() { let _test_lock = GLOBAL_ATOM_TEST_LOCK.lock().unwrap(); @@ -317,4 +552,86 @@ mod tests { second.intern_qualified(namespace, name) ); } + + #[test] + fn qualified_atoms_keep_their_component_atoms_live() { + let mut atoms = DocumentAtoms::for_live_engine(); + let namespace = atoms.intern_raw(0x1000); + let name = atoms.intern_raw(0x2000); + let qualified = atoms.intern_qualified(namespace, name); + let mut live = HashSet::from([qualified]); + assert!(prepare_sweep(&atoms, &mut live).is_empty()); + assert_eq!(live, HashSet::from([namespace, name, qualified])); + } + + #[test] + fn pins_delay_reclamation_until_their_owner_is_destroyed() { + let mut atoms = DocumentAtoms::for_live_engine(); + let atom = atoms.intern_cpp_raw(0x1000); + let pin = atoms.pin([atom]); + assert!(prepare_sweep(&atoms, &mut HashSet::new()).is_empty()); + drop(pin); + let decision = atoms.sweep_decision(); + assert!(!decision.should_sweep); + assert_eq!(decision.skipped_pin_releases, 1); + assert_eq!(atoms.sweep_decision().skipped_pin_releases, 0); + for _ in 1..PIN_RELEASES_PER_SWEEP { + drop(atoms.pin([atom])); + } + assert!(atoms.sweep_decision().should_sweep); + let reclaimable = prepare_sweep(&atoms, &mut HashSet::new()); + assert_eq!(reclaimable, [atom]); + assert_eq!( + atoms.finish_sweep(&reclaimable), + [ReclaimedStyleAtom { raw: 0x1000, atom }] + ); + } + + #[test] + fn document_atoms_reuse_reclaimed_identities_in_sorted_order() { + let mut atoms = DocumentAtoms::for_live_engine(); + let first = atoms.intern_cpp_raw(0x1000); + let second = atoms.intern_raw(0x2000); + let third = atoms.intern_cpp_raw(0x3000); + let reclaimable = prepare_sweep(&atoms, &mut HashSet::from([second])); + assert_eq!(reclaimable, [first, third]); + assert_eq!( + atoms.finish_sweep(&reclaimable), + [ + ReclaimedStyleAtom { + raw: 0x1000, + atom: first, + }, + ReclaimedStyleAtom { + raw: 0x3000, + atom: third, + }, + ] + ); + assert_eq!(atoms.intern_raw(0x4000), first); + assert_eq!(atoms.intern_raw(0x5000), third); + } + + #[test] + fn compiler_only_atoms_do_not_claim_a_cpp_memo_entry() { + let mut atoms = DocumentAtoms::for_live_engine(); + let atom = atoms.intern_raw(0x1000); + let reclaimable = prepare_sweep(&atoms, &mut HashSet::new()); + assert_eq!(atoms.finish_sweep(&reclaimable), [ReclaimedStyleAtom { raw: 0, atom }]); + } + + #[test] + fn repeated_churn_keeps_the_document_identity_space_bounded() { + let mut atoms = DocumentAtoms::for_live_engine(); + let mut highest = 0; + for round in 0..8 { + for offset in 0..256 { + highest = highest.max(atoms.intern_raw(0x1000 + round * 256 + offset).0); + } + let reclaimable = prepare_sweep(&atoms, &mut HashSet::new()); + assert_eq!(reclaimable.len(), 256); + atoms.finish_sweep(&reclaimable); + } + assert_eq!(highest, 256); + } } diff --git a/Libraries/LibWeb/Rust/src/css/style/bridge.rs b/Libraries/LibWeb/Rust/src/css/style/bridge.rs index e5a1ff5e8e29..2416ad291ae0 100644 --- a/Libraries/LibWeb/Rust/src/css/style/bridge.rs +++ b/Libraries/LibWeb/Rust/src/css/style/bridge.rs @@ -30,6 +30,8 @@ use crate::abort_on_panic as abort_on_boundary_panic; use crate::css::selector::CompiledSelector; use crate::css::selector::RustSelector; +use super::HashSet; +use super::PinnedAtoms; use super::StyleEngine; use super::batch_matcher::RuleMatch; use super::cascade::CascadeOperator; @@ -105,9 +107,18 @@ pub struct FfiStyleDelta { #[derive(Default)] pub(super) struct FfiStyleTransactionOutput { scoped: bool, + style_atoms_swept: bool, transaction_version: u64, program_version: u64, answers: Vec, + reclaimed_style_atoms: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(C)] +pub struct FfiReclaimedStyleAtom { + pub raw: usize, + pub atom: u32, } #[derive(Clone, Copy)] @@ -117,7 +128,10 @@ pub struct FfiStyleTransactionView { pub program_version: u64, pub answers: *const FfiStyleDelta, pub count: usize, + pub reclaimed_style_atoms: *const FfiReclaimedStyleAtom, + pub reclaimed_style_atom_count: usize, pub scoped: bool, + pub style_atoms_swept: bool, } #[derive(Clone, Copy)] @@ -159,7 +173,10 @@ impl Default for FfiStyleTransactionView { program_version: 0, answers: std::ptr::null(), count: 0, + reclaimed_style_atoms: std::ptr::null(), + reclaimed_style_atom_count: 0, scoped: false, + style_atoms_swept: false, } } } @@ -187,6 +204,11 @@ fn write_style_transaction_outputs( payload.write_u8(answer.gap as u8); } } + payload.write_bool(output.style_atoms_swept); + payload.write_length(output.reclaimed_style_atoms.len()); + for reclaimed in &output.reclaimed_style_atoms { + payload.write_u32(reclaimed.atom); + } } /// One final base-style assignment. Zero names the absent side of an insertion or removal. @@ -248,6 +270,7 @@ pub struct FfiStyleRecordView { struct SelectorQuery { program: SelectorProgram, + _atoms: PinnedAtoms, _memory: MemoryLease, } @@ -684,7 +707,8 @@ impl StyleEngine { } fn install_ffi_style_transaction_output(&mut self, output: FfiStyleTransactionOutput) { - let bytes = (output.answers.capacity() * size_of::()) as u64; + let bytes = (output.answers.capacity() * size_of::() + + output.reclaimed_style_atoms.capacity() * size_of::()) as u64; self.ffi_style_transaction_output = output; self.ffi_style_transaction_output_memory .resize_required_to(&mut self.memory, bytes); @@ -1430,6 +1454,9 @@ pub unsafe extern "C" fn style_engine_compile_selector_query( let engine = unsafe { &mut *engine.cast::() }; let selectors = unsafe { borrow_selectors(selectors, count) }; let program = engine.compile_selector_query(&selectors); + let mut atoms = HashSet::default(); + program.collect_atoms(&mut atoms); + let atoms = engine.atoms.pin(atoms); engine.record_boundary_call(EventKind::SelectorQueryAtomMappings, |payload| { write_recording_atom_mappings(engine, payload); }); @@ -1440,6 +1467,7 @@ pub unsafe extern "C" fn style_engine_compile_selector_query( memory.resize_required_to(&mut engine.memory, bytes); Box::into_raw(Box::new(SelectorQuery { program, + _atoms: atoms, _memory: memory, })) .cast() @@ -2512,6 +2540,14 @@ pub unsafe extern "C" fn style_engine_take_style_transaction( output.program_version = program_version.0; output.answers.extend_from_slice(answers); }); + output.reclaimed_style_atoms = std::mem::take(&mut engine.reclaimed_style_atoms) + .into_iter() + .map(|reclaimed| FfiReclaimedStyleAtom { + raw: reclaimed.raw, + atom: reclaimed.atom.0, + }) + .collect(); + output.style_atoms_swept = std::mem::take(&mut engine.style_atoms_swept); if engine.recording_id().is_some() { engine.record_boundary_call(EventKind::StyleDeltaBatch, |payload| { payload.write_u32(root.raw()); @@ -2520,6 +2556,7 @@ pub unsafe extern "C" fn style_engine_take_style_transaction( payload.write_bytes(outputs.as_bytes()); payload.write_u64(outputs.stable_digest()); }); + engine.forget_recording_atom_mappings(output.reclaimed_style_atoms.iter().map(|reclaimed| reclaimed.atom)); } engine.install_ffi_style_transaction_output(output); let output = &engine.ffi_style_transaction_output; @@ -2528,10 +2565,36 @@ pub unsafe extern "C" fn style_engine_take_style_transaction( program_version: output.program_version, answers: output.answers.as_ptr(), count: output.answers.len(), + reclaimed_style_atoms: output.reclaimed_style_atoms.as_ptr(), + reclaimed_style_atom_count: output.reclaimed_style_atoms.len(), scoped: output.scoped, + style_atoms_swept: output.style_atoms_swept, } }) } + +/// Installs the authoritative release order recorded for the next replay transaction. +/// +/// # Safety +/// `engine` must be live and `atoms` must name `count` readable atom identities. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn style_engine_set_replay_reclaimed_style_atoms( + engine: *mut c_void, + atoms: *const u32, + count: usize, +) { + abort_on_panic(|| { + let engine = unsafe { &mut *engine.cast::() }; + assert!(engine.replay_reclaimed_style_atoms.is_none()); + let atoms = if count == 0 { + &[] + } else { + assert!(!atoms.is_null()); + unsafe { std::slice::from_raw_parts(atoms, count) } + }; + engine.replay_reclaimed_style_atoms = Some(atoms.iter().copied().map(StyleAtomID).collect()); + }); +} /// Reads one counter by index, returning its stable name and writing its value and name length, or /// null once the index is past the end. C++ enumerates the counters this way rather than /// duplicating the list. The name is borrowed static UTF-8 and is not nul-terminated. diff --git a/Libraries/LibWeb/Rust/src/css/style/flush.rs b/Libraries/LibWeb/Rust/src/css/style/flush.rs index c06018e6b6cc..d4f3de16ab85 100644 --- a/Libraries/LibWeb/Rust/src/css/style/flush.rs +++ b/Libraries/LibWeb/Rust/src/css/style/flush.rs @@ -41,7 +41,7 @@ impl StyleEngine { let mut transaction = self.drain_transaction(); self.apply_staged_transaction(&mut transaction); if transaction.is_empty() { - self.release_transaction(transaction); + self.release_transaction_and_sweep_atoms(transaction); return true; } let preserves_selector_incidence = !transaction.has_coarsened_markers() @@ -183,7 +183,7 @@ impl StyleEngine { { self.discard_retained_prefix_caches(); self.retained_match_answers.evict(&mut self.match_answers); - self.release_transaction(transaction); + self.release_transaction_and_sweep_atoms(transaction); return false; } @@ -695,7 +695,7 @@ impl StyleEngine { let style_input_reaction_bytes = (style_input_reactions.capacity() * size_of::<(StyleNodeID, u8, u8)>()) as u64; self.memory .reserve_required(MemoryCategory::BatchScratch, style_input_reaction_bytes); - self.release_transaction(transaction); + self.release_transaction_and_sweep_atoms(transaction); // Releasing staging can compact primary payloads. Take the shared view afterwards so that // compaction does not need to copy the complete primary arrangement away from its view. if prepared_matching_batch_is_complete { diff --git a/Libraries/LibWeb/Rust/src/css/style/index.rs b/Libraries/LibWeb/Rust/src/css/style/index.rs index 12060356df5e..3546a6efc2cd 100644 --- a/Libraries/LibWeb/Rust/src/css/style/index.rs +++ b/Libraries/LibWeb/Rust/src/css/style/index.rs @@ -263,6 +263,11 @@ impl PagedOwnedColumn { self.values.get(handle) } + fn get_mut(&mut self, index: usize) -> Option<&mut T> { + let handle = self.handles.get(index)? as usize; + self.values.get_mut(handle) + } + fn entry(&mut self, index: usize) -> &mut T { let handle = match self.handles.get(index) { Some(handle) => handle, @@ -1536,6 +1541,28 @@ impl FeatureKey { | Self::Directionality(_) ) } + + fn atom(self) -> Option { + match self { + Self::Part(atom) + | Self::CustomState(atom) + | Self::TagName(atom) + | Self::Id(atom) + | Self::Class(atom) + | Self::AttributeName(atom) + | Self::Directionality(atom) + | Self::AnimationName(atom) => Some(atom), + Self::Root + | Self::State(_) + | Self::Heading + | Self::Universal + | Self::Structural + | Self::Language + | Self::AnyCustomFunction + | Self::AnyCustomProperty + | Self::CustomPropertySet(_) => None, + } + } } pub type SelectorPostingKey = FeatureKey; @@ -1822,6 +1849,24 @@ impl FeaturePostings { pub(super) fn take_benefit_lookups(&self) -> (u64, u64) { (self.benefit_hits.replace(0), self.benefit_misses.replace(0)) } + + fn forget_atoms(&mut self, atoms: &HashSet) { + let posting_keys = self + .postings + .keys() + .copied() + .filter(|key| key.atom().is_some_and(|atom| atoms.contains(&atom))) + .collect::>(); + for key in posting_keys { + self.remove_posting(key); + } + self.missing + .retain(|key| !key.atom().is_some_and(|atom| atoms.contains(&atom))); + self.cardinality_limited + .retain(|key| !key.atom().is_some_and(|atom| atoms.contains(&atom))); + self.grown_selector_postings + .retain(|key| !key.atom().is_some_and(|atom| atoms.contains(&atom))); + } } /// The rightmost distinguishing feature of one selector entry. @@ -3226,9 +3271,13 @@ pub struct ElementFactStore { custom_property_name_sets: super::intern_table::InternTable>, custom_property_name_set_vacancies: Vec, custom_property_set_ids_by_name: PagedOwnedColumn>, - language_live_counts: Column, - attribute_name_live_counts: Column, - attribute_value_live_counts: Column, + /// Authoritative semantic references from committed fact rows and per-element metadata. This + /// is indexed by atom so a lifetime sweep visits distinct identities rather than every live + /// element row. + atom_live_counts: PagedCopyColumn, + language_live_counts: PagedCopyColumn, + attribute_name_live_counts: PagedCopyColumn, + attribute_value_live_counts: PagedCopyColumn, custom_property_set_live_counts: Vec, /// Attribute-name forms and value text shared by the primary and each bounded fact batch. /// @@ -3606,9 +3655,10 @@ impl Default for ElementFactStore { custom_property_name_sets: super::intern_table::InternTable::default(), custom_property_name_set_vacancies: Vec::new(), custom_property_set_ids_by_name: PagedOwnedColumn::default(), - language_live_counts: Column::default(), - attribute_name_live_counts: Column::default(), - attribute_value_live_counts: Column::default(), + atom_live_counts: PagedCopyColumn::default(), + language_live_counts: PagedCopyColumn::default(), + attribute_name_live_counts: PagedCopyColumn::default(), + attribute_value_live_counts: PagedCopyColumn::default(), custom_property_set_live_counts: vec![0], element_declared_properties: ElementDeclarationRows::default(), }; @@ -3636,6 +3686,10 @@ impl ElementFactStore { self.attribute_catalog_copies } + pub(super) fn staging_is_empty(&self) -> bool { + self.staging.is_empty() + } + fn sync_attribute_catalogs(&mut self) { if Rc::ptr_eq(&self.rows.attribute_catalogs, &self.attribute_catalogs) { return; @@ -3644,25 +3698,28 @@ impl ElementFactStore { rows.attribute_catalogs = Rc::clone(&self.attribute_catalogs); } - fn increment_atom_count(counts: &mut Column, atom: StyleAtomID) { + fn increment_atom_count(counts: &mut PagedCopyColumn, atom: StyleAtomID) { if atom.is_none() { return; } - let count = counts.entry(atom.0 as usize); - *count = count.checked_add(1).expect("live fact atom count overflow"); + let index = atom.0 as usize; + let count = counts.get(index).unwrap_or(0); + counts.insert(index, count.checked_add(1).expect("live fact atom count overflow")); } - fn decrement_atom_count(counts: &mut Column, atom: StyleAtomID) { + fn decrement_atom_count(counts: &mut PagedCopyColumn, atom: StyleAtomID) { if atom.is_none() { return; } - let count = counts - .get_mut(atom.0 as usize) - .expect("a live fact atom must have a count"); - *count = count.checked_sub(1).expect("live fact atom count underflow"); + let index = atom.0 as usize; + let count = counts.get(index).expect("a live fact atom must have a count"); + counts.insert(index, count.checked_sub(1).expect("live fact atom count underflow")); } fn add_row_catalog_references(&mut self, facts: &StagedFactRow) { + Self::for_each_row_atom(facts, |atom| { + Self::increment_atom_count(&mut self.atom_live_counts, atom); + }); Self::increment_atom_count(&mut self.language_live_counts, facts.language); for &(name, value) in &facts.attributes { Self::increment_atom_count(&mut self.attribute_name_live_counts, name); @@ -3671,6 +3728,9 @@ impl ElementFactStore { } fn remove_row_catalog_references(&mut self, facts: &StagedFactRow) { + Self::for_each_row_atom(facts, |atom| { + Self::decrement_atom_count(&mut self.atom_live_counts, atom); + }); Self::decrement_atom_count(&mut self.language_live_counts, facts.language); for &(name, value) in &facts.attributes { Self::decrement_atom_count(&mut self.attribute_name_live_counts, name); @@ -3678,6 +3738,83 @@ impl ElementFactStore { } } + fn for_each_row_atom(facts: &StagedFactRow, mut visit: impl FnMut(StyleAtomID)) { + for atom in [ + facts.tag, + facts.folded_tag, + facts.id, + facts.language, + facts.namespace, + facts.part_exposure, + facts.directionality, + ] { + visit(atom); + } + for &atom in &facts.custom_states { + visit(atom); + } + for &atom in &facts.parts { + visit(atom); + } + for &atom in &facts.classes { + visit(atom); + } + for &(name, value) in &facts.attributes { + visit(name); + visit(value); + } + } + + pub(super) fn collect_atoms(&self, atoms: &mut HashSet) -> u64 { + let mut visited = 0_u64; + for (index, count) in self.atom_live_counts.indexed_iter() { + visited += 1; + if count != 0 { + atoms.insert(StyleAtomID(u32::try_from(index).expect("fact atom index exceeds u32"))); + } + } + for (index, count) in self.attribute_name_live_counts.indexed_iter() { + visited += 1; + if count == 0 { + continue; + } + let forms = self.attribute_name_forms(StyleAtomID( + u32::try_from(index).expect("attribute-name atom index exceeds u32"), + )); + atoms.extend( + [forms.local, forms.folded_name, forms.folded_local] + .into_iter() + .filter(|atom| !atom.is_none()), + ); + } + for (index, count) in self.custom_property_set_live_counts.iter().enumerate().skip(1) { + visited += 1; + if *count != 0 { + atoms.extend( + self.custom_property_name_sets + [CustomPropertyNameSetID(u32::try_from(index).expect("custom property set index exceeds u32"))] + .iter() + .copied(), + ); + } + } + visited + } + + pub(super) fn extend_live_attribute_name_forms(&self, atoms: &mut HashSet) { + for (index, forms) in self.attribute_catalogs.name_forms.indexed_iter() { + let name = StyleAtomID(u32::try_from(index).expect("attribute-name atom index exceeds u32")); + if !atoms.contains(&name) { + continue; + } + atoms.extend( + [forms.local, forms.folded_name, forms.folded_local] + .into_iter() + .filter(|atom| !atom.is_none()), + ); + } + } + #[must_use] #[cfg(test)] pub fn len(&self) -> usize { @@ -4209,12 +4346,14 @@ impl ElementFactStore { for &name in &previous { if !sorted.contains(&name) { self.postings.remove(DependencyPostingKey::AnimationName(name), node); + Self::decrement_atom_count(&mut self.atom_live_counts, name); } } for name in &sorted { if !previous.contains(name) { self.postings .insert(DependencyPostingKey::AnimationName(*name), node, memory); + Self::increment_atom_count(&mut self.atom_live_counts, *name); } } self.metadata_mut(node).animation_names = sorted; @@ -4588,6 +4727,7 @@ impl ElementFactStore { } for name in metadata.animation_names { self.postings.remove(DependencyPostingKey::AnimationName(name), node); + Self::decrement_atom_count(&mut self.atom_live_counts, name); } if metadata.custom_property_set != 0 { self.postings.remove( @@ -4604,14 +4744,25 @@ impl ElementFactStore { } } - pub fn sweep_auxiliary_catalogs(&mut self) { + /// Whether a borrowed primary view (an active or prepared traversal) shares the fact rows. + #[cfg(test)] + pub(super) fn primary_rows_are_shared(&self) -> bool { + Rc::strong_count(&self.rows) != 1 + } + + pub(super) fn sweep_auxiliary_catalogs_without_sync(&mut self) { + assert_eq!( + Rc::strong_count(&self.rows), + 1, + "auxiliary catalog sweeping requires unique primary rows" + ); self.memory_dirty = true; let attribute_catalogs = Rc::make_mut(&mut self.attribute_catalogs); - // Language spellings and attribute-name forms cross the C++ boundary once per atom and - // remain available for the document lifetime. Unlike attribute values, these small fixed - // catalogs have no demand gate to republish an entry after reclamation. + // Language spellings and attribute-name forms are retained until their atom is reclaimed; + // forget_atoms clears them at that authoritative boundary so a reused identity can publish + // different text. Attribute values can be dropped earlier when their last fact leaves. for (index, text) in attribute_catalogs.value_texts.indexed_iter_mut() { - if self.attribute_value_live_counts.get(index).copied().unwrap_or(0) == 0 { + if self.attribute_value_live_counts.get(index).unwrap_or(0) == 0 { *text = None; } } @@ -4638,6 +4789,45 @@ impl ElementFactStore { .push(id as u32); } } + } + + pub fn sweep_auxiliary_catalogs(&mut self) { + self.sweep_auxiliary_catalogs_without_sync(); + self.sync_attribute_catalogs(); + } + + /// Remove every derived row keyed by an identity before that identity can be reused. + pub(super) fn forget_atoms(&mut self, atoms: &[StyleAtomID]) { + if atoms.is_empty() { + return; + } + self.memory_dirty = true; + let atoms = atoms.iter().copied().collect::>(); + let catalogs = Rc::make_mut(&mut self.attribute_catalogs); + for atom in &atoms { + let index = atom.0 as usize; + if catalogs.name_forms.get(index).is_some() { + catalogs.name_forms.insert(index, AttributeNameForms::default()); + } + if let Some(text) = catalogs.value_texts.get_mut(index) { + *text = None; + } + if let Some(text) = catalogs.language_texts.get_mut(index) { + *text = None; + } + if let Some(sets) = self.custom_property_set_ids_by_name.get_mut(index) { + sets.clear(); + } + } + for (_, forms) in catalogs.name_forms.indexed_iter() { + assert!( + ![forms.local, forms.folded_name, forms.folded_local] + .into_iter() + .any(|atom| atoms.contains(&atom)), + "a live attribute name must keep all of its derived forms live" + ); + } + self.postings.forget_atoms(&atoms); self.sync_attribute_catalogs(); } @@ -4724,6 +4914,7 @@ impl ElementFactStore { self.custom_property_name_sets, self.custom_property_name_set_vacancies, self.custom_property_set_ids_by_name, + self.atom_live_counts, self.language_live_counts, self.attribute_name_live_counts, self.attribute_value_live_counts, @@ -4772,6 +4963,7 @@ impl ElementFactStore { self.custom_property_name_sets, self.custom_property_name_set_vacancies, self.custom_property_set_ids_by_name, + self.atom_live_counts, self.language_live_counts, self.attribute_name_live_counts, self.attribute_value_live_counts, @@ -5211,6 +5403,45 @@ mod tests { ); } + #[test] + fn live_fact_atom_roots_use_incremental_counts() { + let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); + let mut facts = ElementFactStore::new(); + let node = StyleNodeID::element(1); + facts.set_tag(node, StyleAtomID(1), &mut memory); + facts.set_folded_tag(node, StyleAtomID(2), &mut memory); + facts.set_id(node, StyleAtomID(3), &mut memory); + facts.set_language(node, StyleAtomID(4)); + facts.set_namespace(node, StyleAtomID(5)); + facts.set_part_exposure(node, StyleAtomID(6)); + facts.set_directionality(node, StyleAtomID(7), &mut memory); + facts.set_custom_states(node, &[StyleAtomID(8)], &mut memory); + facts.set_parts(node, &[StyleAtomID(9)], &mut memory); + facts.set_class(node, StyleAtomID(10), true, &mut memory); + facts.note_attribute_name_forms( + StyleAtomID(11), + AttributeNameForms { + local: StyleAtomID(13), + folded_name: StyleAtomID(14), + folded_local: StyleAtomID(15), + }, + ); + facts.set_attribute(node, StyleAtomID(11), StyleAtomID(12), true, &mut memory); + facts.set_animation_names(node, &[StyleAtomID(16)], &mut memory); + facts.set_custom_property_names(node, &[StyleAtomID(17)], &mut memory); + facts.apply_staged(&mut memory); + + let mut atoms = HashSet::default(); + let visited = facts.collect_atoms(&mut atoms); + assert_eq!(visited, 15); + assert_eq!(atoms, (1..=17).map(StyleAtomID).collect()); + + facts.forget(node); + let mut atoms = HashSet::default(); + facts.collect_atoms(&mut atoms); + assert!(atoms.is_empty()); + } + #[test] fn detached_element_churn_reuses_reclaimable_auxiliary_catalog_storage() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); @@ -6035,6 +6266,89 @@ mod tests { assert_eq!(after.text_of(new_attribute), Some(newest_text.as_slice())); } + #[test] + fn reclaimed_atoms_leave_no_catalog_or_posting_rows_for_reuse() { + let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); + let mut store = ElementFactStore::new(); + let atom = StyleAtomID(40); + store.note_attribute_name_forms( + atom, + AttributeNameForms { + local: StyleAtomID(41), + folded_name: StyleAtomID(42), + folded_local: StyleAtomID(43), + }, + ); + store.set_attribute_value_text(atom, &[1, 2, 3]); + store.set_language_text(atom, &[4, 5, 6]); + store + .postings + .insert(SelectorPostingKey::Class(atom), StyleNodeID::element(1), &mut memory); + + store.forget_atoms(&[atom, StyleAtomID(42)]); + + assert_eq!(store.attribute_name_forms(atom), AttributeNameForms::default()); + assert!(!store.has_attribute_value_text(atom)); + assert_eq!( + store.attribute_catalogs.language_texts.get(atom.0 as usize), + Some(&None) + ); + assert!(matches!( + store.postings.lookup(SelectorPostingKey::Class(atom)), + Lookup::KnownAbsent + )); + + store.note_attribute_name_forms( + atom, + AttributeNameForms { + local: StyleAtomID(60), + ..AttributeNameForms::default() + }, + ); + store.set_attribute_value_text(atom, &[7, 8]); + store.set_language_text(atom, &[9, 10]); + assert_eq!(store.attribute_name_forms(atom).local, StyleAtomID(60)); + assert_eq!( + store + .attribute_catalogs + .value_texts + .get(atom.0 as usize) + .and_then(Option::as_deref), + Some([7, 8].as_slice()) + ); + assert_eq!( + store + .attribute_catalogs + .language_texts + .get(atom.0 as usize) + .and_then(Option::as_deref), + Some([9, 10].as_slice()) + ); + } + + #[test] + fn live_attribute_names_keep_their_derived_forms_live() { + let mut store = ElementFactStore::new(); + let name = StyleAtomID(40); + let forms = AttributeNameForms { + local: StyleAtomID(41), + folded_name: StyleAtomID(42), + folded_local: StyleAtomID(43), + }; + store.note_attribute_name_forms(name, forms); + let mut live = HashSet::default(); + live.insert(name); + + store.extend_live_attribute_name_forms(&mut live); + + assert_eq!(live.len(), 4); + assert!( + [name, forms.local, forms.folded_name, forms.folded_local] + .into_iter() + .all(|atom| live.contains(&atom)) + ); + } + #[test] fn a_primary_fact_view_is_immutable_and_uncharged() { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); @@ -6100,6 +6414,28 @@ mod tests { assert_eq!(store.attribute_catalog_copies(), 1); } + #[test] + fn distant_process_atom_ids_allocate_only_touched_catalog_pages() { + let mut store = ElementFactStore::new(); + let atom = StyleAtomID(1_000_000); + store.note_attribute_name_forms(atom, AttributeNameForms::default()); + store.set_attribute_value_text(atom, &[1, 2, 3]); + store.set_language_text(atom, &[4, 5, 6]); + ElementFactStore::increment_atom_count(&mut store.atom_live_counts, atom); + store.custom_property_set_ids_by_name.entry(atom.0 as usize).push(1); + + assert_eq!(store.attribute_catalogs.name_forms.values.page_count(), 1); + assert_eq!(store.attribute_catalogs.value_texts.handles.page_count(), 1); + assert_eq!(store.attribute_catalogs.language_texts.handles.page_count(), 1); + assert_eq!(store.atom_live_counts.values.page_count(), 1); + assert_eq!(store.custom_property_set_ids_by_name.handles.page_count(), 1); + assert_eq!(size_of_val(&store.atom_live_counts.get(atom.0 as usize).unwrap()), 4); + + let mut directory = SegmentedDispatchBucketDirectory::default(); + directory.insert(atom.0 as usize, DispatchBucketRange { start: 1, length: 1 }); + assert_eq!(directory.ranges.values.page_count(), 1); + } + #[test] fn staged_fact_rows_use_paged_element_identity_slots() { let first = StyleNodeID::element(1); diff --git a/Libraries/LibWeb/Rust/src/css/style/inputs.rs b/Libraries/LibWeb/Rust/src/css/style/inputs.rs index c81474f1bffd..8ae533ea833e 100644 --- a/Libraries/LibWeb/Rust/src/css/style/inputs.rs +++ b/Libraries/LibWeb/Rust/src/css/style/inputs.rs @@ -112,6 +112,9 @@ impl StyleEngine { scope_program_by_scope: Column::default(), held_scope_program: None, atoms, + reclaimed_style_atoms: Vec::new(), + style_atoms_swept: false, + replay_reclaimed_style_atoms: None, fold_id_and_class_name_case: false, #[cfg(test)] diagnostic_plan_capture: None, @@ -197,6 +200,15 @@ impl StyleEngine { None } + pub(crate) fn forget_recording_atom_mappings(&self, atoms: impl IntoIterator) { + #[cfg(feature = "style-recording")] + if let Some(engine_id) = self.recording_id { + record_replay::forget_atom_mappings(engine_id, atoms); + } + #[cfg(not(feature = "style-recording"))] + let _ = atoms; + } + pub(crate) fn recording_atom_mappings(&self) -> RecordedAtomMappings { #[cfg(not(feature = "style-recording"))] return RecordedAtomMappings { @@ -288,7 +300,7 @@ impl StyleEngine { /// same word but assigning their own sequences would compare unequal for the same name, which /// is a silent failure to match rather than a loud one. pub fn intern_atom(&mut self, raw: usize) -> StyleAtomID { - self.atoms.intern_raw(raw) + self.atoms.intern_cpp_raw(raw) } /// The document-local atom for a name qualified by a namespace. @@ -1265,6 +1277,7 @@ impl StyleEngine { /// Record what a language atom spells, so `:lang()` can compare its ranges against the tag. pub fn set_element_language_text(&mut self, language: StyleAtomID, text: &[u16]) { + self.counters.bump(Counter::LanguageTextsPublished); self.facts.set_language_text(language, text); } diff --git a/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs b/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs index d535f35cbcc5..a85470c1b992 100644 --- a/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs +++ b/Libraries/LibWeb/Rust/src/css/style/instrumentation.rs @@ -55,6 +55,12 @@ define_counters! { NormalizedUniqueKeys => "normalizedUniqueKeys", JournalCancellations => "journalCancellations", CoarsenedScopeMarkers => "coarsenedScopeMarkers", + AtomSweeps => "atomSweeps", + AtomSweepsDeferredForActiveTraversal => "atomSweepsDeferredForActiveTraversal", + AtomSweepRootSlotsVisited => "atomSweepRootSlotsVisited", + AtomSweepPinReleasesSkipped => "atomSweepPinReleasesSkipped", + StyleAtomsReclaimed => "styleAtomsReclaimed", + LanguageTextsPublished => "languageTextsPublished", // Stylesheet program. StyleRulesCompiled => "styleRulesCompiled", diff --git a/Libraries/LibWeb/Rust/src/css/style/mod.rs b/Libraries/LibWeb/Rust/src/css/style/mod.rs index ab6e64374e9e..1a06f855beca 100644 --- a/Libraries/LibWeb/Rust/src/css/style/mod.rs +++ b/Libraries/LibWeb/Rust/src/css/style/mod.rs @@ -140,6 +140,8 @@ mod transaction_view; pub mod tree; use atoms::DocumentAtoms; +use atoms::PinnedAtoms; +use atoms::ReclaimedStyleAtom; use catalog::*; use column::BitColumn; use column::Column; @@ -920,6 +922,16 @@ pub struct StyleEngine { /// selector that names the namespace tests it. The owner retains one document reference to each /// global identity and releases it when this engine is destroyed. atoms: DocumentAtoms, + /// Identities released at transaction settlement. The FFI keeps this batch borrowed until C++ + /// has removed its matching fly-string references and atom-keyed memo entries. + reclaimed_style_atoms: Vec, + /// Whether transaction settlement performed an atom sweep, including a sweep that reclaimed + /// no identities. Recording consumes this alongside the release batch. + style_atoms_swept: bool, + /// Replay reconstructs semantic engine state but not transient C++ query handles. The recorded + /// release batch supplies their lifetime boundary while still requiring every released atom to + /// be reclaimable from replay's complete semantic root set. + replay_reclaimed_style_atoms: Option>, /// The HTML namespace when this is an HTML document, and none otherwise. Some attribute names /// compare their values ASCII case-insensitively on an HTML element in an HTML document. html_element_namespace: StyleAtomID, diff --git a/Libraries/LibWeb/Rust/src/css/style/ordering.rs b/Libraries/LibWeb/Rust/src/css/style/ordering.rs index 9057b090b1f7..87db0e92faa2 100644 --- a/Libraries/LibWeb/Rust/src/css/style/ordering.rs +++ b/Libraries/LibWeb/Rust/src/css/style/ordering.rs @@ -1236,6 +1236,83 @@ impl StyleEngine { self.shed_routing_for_detached_sheets(); } + /// Release a transaction taken through the bridge and reclaim atoms before the bridge installs + /// a new primary view. The returned reclamation batch lets C++ purge its atom memos before any + /// reclaimed identity can be reused. + pub(super) fn release_transaction_and_sweep_atoms(&mut self, transaction: StyleTransaction) { + self.release_transaction(transaction); + self.sweep_style_atoms(); + } + + pub(super) fn collect_live_style_atoms(&self) -> (HashSet, u64) { + assert!( + self.tree_staging.is_empty(), + "style atom sweeping requires settled tree staging" + ); + assert!( + self.facts.staging_is_empty(), + "style atom sweeping requires settled fact staging" + ); + let mut atoms = HashSet::default(); + let mut visited = self.tree.collect_atoms(&mut atoms); + visited += self.facts.collect_atoms(&mut atoms); + visited += self.program.collect_atoms(&mut atoms); + visited += self.programs.collect_atoms(&mut atoms); + if !self.html_element_namespace.is_none() { + atoms.insert(self.html_element_namespace); + } + (atoms, visited) + } + + pub(super) fn sweep_style_atoms(&mut self) { + let decision = self.atoms.sweep_decision(); + self.counters + .add(Counter::AtomSweepPinReleasesSkipped, decision.skipped_pin_releases); + if !decision.should_sweep && self.replay_reclaimed_style_atoms.is_none() { + return; + } + if self.batch_matching_traversal.is_some() { + self.counters.bump(Counter::AtomSweepsDeferredForActiveTraversal); + return; + } + let replay_reclaimed = self.replay_reclaimed_style_atoms.take(); + self.style_atoms_swept = true; + self.facts.sweep_auxiliary_catalogs_without_sync(); + let (mut live, visited) = self.collect_live_style_atoms(); + self.atoms.mark_sweep_dependencies(&mut live); + loop { + let previous_live_count = live.len(); + self.facts.extend_live_attribute_name_forms(&mut live); + self.atoms.mark_sweep_dependencies(&mut live); + if live.len() == previous_live_count { + break; + } + } + let mut reclaimable = self.atoms.reclaimable_for_sweep(&live); + if let Some(recorded) = replay_reclaimed { + assert!( + recorded.iter().all(|atom| reclaimable.contains(atom)), + "a recorded atom release still has a semantic replay owner" + ); + reclaimable = recorded; + } + self.facts.forget_atoms(&reclaimable); + let requirement_count = self.attribute_value_text_names.len(); + self.attribute_value_text_names + .retain(|atom| !reclaimable.contains(atom)); + if self.attribute_value_text_names.len() != requirement_count { + self.attribute_value_text_requirements_version += 1; + } + let reclaimed = self.atoms.finish_sweep(&reclaimable); + self.counters.bump(Counter::AtomSweeps); + self.counters.add(Counter::AtomSweepRootSlotsVisited, visited); + self.counters.add( + Counter::StyleAtomsReclaimed, + u64::try_from(reclaimed.len()).expect("reclaimed atom count exceeds u64"), + ); + self.reclaimed_style_atoms.extend(reclaimed); + } + /// Drop routing entry points for rules whose sheet is attached nowhere. /// /// The router already skips such rules one route at a time, but enumerating their entry points @@ -1352,7 +1429,11 @@ impl StyleEngine { self.scope_roots[tree_scope.0 as usize] = None; } } - self.facts.sweep_auxiliary_catalogs(); + // The catalog sweep needs unique primary rows, like the atom sweep; while a traversal + // borrows them the dead entries wait for the next boundary. + if self.batch_matching_traversal.is_none() { + self.facts.sweep_auxiliary_catalogs(); + } } /// The document budget is written in connected elements and compact program bytes, so it has to diff --git a/Libraries/LibWeb/Rust/src/css/style/program.rs b/Libraries/LibWeb/Rust/src/css/style/program.rs index 1c622ff12bae..10b516a3179b 100644 --- a/Libraries/LibWeb/Rust/src/css/style/program.rs +++ b/Libraries/LibWeb/Rust/src/css/style/program.rs @@ -251,6 +251,33 @@ pub struct StyleSheetProgram { } impl StyleSheetProgram { + pub(super) fn collect_atoms(&self, atoms: &mut HashSet) -> u64 { + let mut visited = 0_u64; + for rule in &self.rules { + if !rule.live { + continue; + } + visited += 1; + let version = self.rule_versions[rule.version_slot as usize]; + if let Some(name) = version.declared_name { + atoms.insert(name); + } + if version.layer != CascadeLayerID::UNLAYERED { + atoms.insert(StyleAtomID(version.layer.0)); + } + } + for ranks in self.layer_ranks.iter().flatten() { + visited += u64::try_from(ranks.len()).expect("layer rank count exceeds u64"); + atoms.extend( + ranks + .keys() + .filter(|&&layer| layer != CascadeLayerID::UNLAYERED) + .map(|layer| StyleAtomID(layer.0)), + ); + } + visited + } + #[must_use] pub fn new() -> Self { Self { diff --git a/Libraries/LibWeb/Rust/src/css/style/record_replay.rs b/Libraries/LibWeb/Rust/src/css/style/record_replay.rs index 12628d5c626b..57c79e82ea21 100644 --- a/Libraries/LibWeb/Rust/src/css/style/record_replay.rs +++ b/Libraries/LibWeb/Rust/src/css/style/record_replay.rs @@ -30,7 +30,7 @@ use std::sync::Mutex; use std::sync::OnceLock; const MAGIC: [u8; 8] = *b"SGREPLAY"; -const FORMAT_VERSION: u64 = 7; +const FORMAT_VERSION: u64 = 9; const EVENT_HEADER_SIZE: usize = 3 * size_of::(); const PAYLOAD_ALIGNMENT: usize = 8; @@ -563,6 +563,17 @@ pub(super) fn first_atom_mapping(engine_id: u64, atom: u32) -> bool { .insert((engine_id, atom)) } +pub(super) fn forget_atom_mappings(engine_id: u64, atoms: impl IntoIterator) { + let capture = CAPTURE + .get() + .and_then(Option::as_ref) + .expect("a recording engine must have a capture session"); + let mut capture = capture.lock().expect("StyleEngine replay recorder lock is poisoned"); + for atom in atoms { + capture.recorded_atoms.remove(&(engine_id, atom)); + } +} + /// Records the end of one document engine and makes every preceding event durable. pub(super) fn end_recording_stream(engine_id: Option) { let Some(engine_id) = engine_id else { diff --git a/Libraries/LibWeb/Rust/src/css/style/selector.rs b/Libraries/LibWeb/Rust/src/css/style/selector.rs index 71490df022aa..56ba723f4381 100644 --- a/Libraries/LibWeb/Rust/src/css/style/selector.rs +++ b/Libraries/LibWeb/Rust/src/css/style/selector.rs @@ -31,6 +31,7 @@ use super::column::Column; use super::column::PagedColumn; use super::column::PagedColumnPage; use super::fast_hash::FastMap as HashMap; +use super::fast_hash::FastSet as HashSet; use super::fast_hash::fast_hasher; use super::index::DispatchKey; use super::index::FeatureKey; @@ -687,6 +688,40 @@ pub struct SelectorProgram { } impl SelectorProgram { + pub(super) fn collect_atoms(&self, atoms: &mut HashSet) -> u64 { + let mut visited = 0_u64; + let mut insert = |atom: StyleAtomID| { + if !atom.is_none() { + atoms.insert(atom); + } + }; + for node in &self.nodes { + visited += 1; + match *node { + SelectorOp::Feature(FeatureTest::TagName(tag)) => { + insert(tag.written); + insert(tag.folded); + insert(tag.fold_in_namespace); + } + SelectorOp::Feature(FeatureTest::Id(atom) | FeatureTest::Class(atom)) + | SelectorOp::Part(atom) + | SelectorOp::ValueState { value: atom, .. } => insert(atom), + SelectorOp::Feature(FeatureTest::Attribute(attribute)) => { + insert(attribute.name); + insert(attribute.folded); + insert(attribute.fold_in_namespace); + insert(attribute.value_atom); + if let AttributeCase::InsensitiveForNamespace(namespace) = attribute.case { + insert(namespace); + } + } + SelectorOp::Feature(FeatureTest::Namespace(NamespaceTest::Named(namespace))) => insert(namespace), + _ => {} + } + } + visited + } + /// Attribute names whose tests cannot be answered from the value atom alone. pub fn attribute_value_text_names(&self) -> impl Iterator + '_ { self.nodes @@ -2435,6 +2470,14 @@ impl Default for SelectorPrograms { } impl SelectorPrograms { + pub(super) fn collect_atoms(&self, atoms: &mut HashSet) -> u64 { + self.programs + .iter() + .flatten() + .map(|program| program.program().collect_atoms(atoms)) + .sum() + } + #[must_use] pub fn new() -> Self { Self::default() @@ -6528,6 +6571,42 @@ mod tests { const ATTR_TYPE: StyleAtomID = StyleAtomID(31); const VALUE_TEXT: StyleAtomID = StyleAtomID(40); + #[test] + fn selector_program_atom_roots_cover_every_operator_payload() { + let program = SelectorProgram { + nodes: vec![ + SelectorOp::Feature(FeatureTest::TagName(TagTest { + written: StyleAtomID(1), + folded: StyleAtomID(2), + fold_in_namespace: StyleAtomID(3), + })), + SelectorOp::Feature(FeatureTest::Id(StyleAtomID(4))), + SelectorOp::Feature(FeatureTest::Class(StyleAtomID(5))), + SelectorOp::Feature(FeatureTest::Attribute(AttributeTest { + name: StyleAtomID(6), + any_namespace: false, + folded: StyleAtomID(7), + fold_in_namespace: StyleAtomID(8), + operator: AttributeOperator::Exact, + value_atom: StyleAtomID(9), + value_offset: 0, + value_length: 0, + case: AttributeCase::InsensitiveForNamespace(StyleAtomID(10)), + })), + SelectorOp::Feature(FeatureTest::Namespace(NamespaceTest::Named(StyleAtomID(11)))), + SelectorOp::Part(StyleAtomID(12)), + SelectorOp::ValueState { + kind: ValueStateTestKind::Directionality, + value: StyleAtomID(13), + }, + ], + ..SelectorProgram::default() + }; + let mut atoms = HashSet::default(); + assert_eq!(program.collect_atoms(&mut atoms), 7); + assert_eq!(atoms, (1..=13).map(StyleAtomID).collect()); + } + impl Fixture { fn new() -> Self { let mut memory = MemoryController::new(DeviceClass::ForegroundDesktop); diff --git a/Libraries/LibWeb/Rust/src/css/style/tests.rs b/Libraries/LibWeb/Rust/src/css/style/tests.rs index 4ec44a545a31..581dbf952235 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tests.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tests.rs @@ -8521,3 +8521,374 @@ fn answer_transitions_refuse_equality_removals_and_winning_additions() { // A removal can uncover a candidate provenance cannot always name; it must refuse too. assert!(!engine.answer_transition_cannot_change_cascade(nodes[1], with_winner, before_input)); } + +#[test] +fn query_pin_releases_are_rate_limited_and_counted() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let atom = engine.intern_atom(0x1234); + drop(engine.atoms.pin([atom])); + + engine.sweep_style_atoms(); + + assert_eq!(engine.counters().get(Counter::AtomSweeps), 0); + assert_eq!(engine.counters().get(Counter::AtomSweepPinReleasesSkipped), 1); + for _ in 1..atoms::PIN_RELEASES_PER_SWEEP { + drop(engine.atoms.pin([atom])); + } + + engine.sweep_style_atoms(); + + assert_eq!(engine.counters().get(Counter::AtomSweeps), 1); + assert_eq!(engine.counters().get(Counter::AtomSweepPinReleasesSkipped), 1); +} + +#[test] +fn releasing_a_flush_transaction_does_not_reclaim_atoms() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + for raw in 0x1000..0x1100 { + engine.intern_atom(raw); + } + + let transaction = engine.take_transaction(); + engine.release_transaction(transaction); + + assert!(engine.reclaimed_style_atoms.is_empty()); + assert_eq!(engine.counters().get(Counter::AtomSweeps), 0); +} + +#[test] +fn atom_sweep_waits_for_an_active_matching_traversal() { + let (mut engine, nodes) = nested_document(); + let target = StyleAtomID(200); + add_target_rule(&mut engine, StyleSheetObjectID(1), target); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(target)); + // The broad initial publication prepares the traversal over the primary view itself, so the + // active traversal shares the fact rows exactly as a first style pass in the browser does. + assert!(!engine.take_style_transaction(nodes[0], |_, _, _| {})); + assert!(engine.begin_cold_matching_batch(nodes[0])); + assert!(engine.facts.primary_rows_are_shared()); + for raw in 0x1000..0x1100 { + engine.intern_atom(raw); + } + engine.record_input( + InputKey::ElementStyleInput(nodes[1]), + InputValue::ElementStyleInput { + reaction: 0, + inherited_style_groups: 0, + }, + InputValue::ElementStyleInput { + reaction: transaction::STYLE_REACTION_RECOMPUTE_STYLE, + inherited_style_groups: 0, + }, + ); + + assert!(engine.take_style_transaction(nodes[0], |_, _, _| {})); + assert_eq!(engine.counters().get(Counter::AtomSweeps), 0); + assert_eq!(engine.counters().get(Counter::AtomSweepsDeferredForActiveTraversal), 1); + assert!(engine.reclaimed_style_atoms.is_empty()); + + engine.end_cold_matching_batch(); + assert!(engine.take_style_transaction(nodes[0], |_, _, _| {})); + assert_eq!(engine.counters().get(Counter::AtomSweeps), 1); + assert!(!engine.reclaimed_style_atoms.is_empty()); +} + +#[test] +fn replay_forces_a_recorded_atom_sweep_without_reclaims() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + engine.replay_reclaimed_style_atoms = Some(Vec::new()); + + engine.sweep_style_atoms(); + + assert_eq!(engine.counters().get(Counter::AtomSweeps), 1); + assert!(engine.style_atoms_swept); +} + +#[test] +fn replay_ffi_reclaims_the_non_empty_recorded_atom_set() { + let (mut engine, nodes) = linear_document(); + for &node in &nodes { + set_atom_feature(&mut engine, node, LocalFeatureKey::TagName, StyleAtomID(100)); + } + let reclaimable = engine.intern_atom(0x1000); + let recorded = [reclaimable.0]; + let engine_pointer = (&mut engine as *mut StyleEngine).cast(); + unsafe { + bridge::style_engine_set_replay_reclaimed_style_atoms(engine_pointer, recorded.as_ptr(), recorded.len()); + } + + let output = unsafe { bridge::style_engine_take_style_transaction(engine_pointer, nodes[0].raw()) }; + + assert!(output.style_atoms_swept); + assert_eq!(output.reclaimed_style_atom_count, 1); + let reclaimed = unsafe { *output.reclaimed_style_atoms }; + assert_eq!(reclaimed.atom, reclaimable.0); + assert_eq!(reclaimed.raw, 0x1000); +} + +#[test] +fn pinned_attribute_names_keep_all_noted_forms_live() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let name = engine.intern_atom(0x1000); + let any_namespace = engine.intern_qualified_atom(StyleAtomID::NONE, name); + let folded_name = engine.intern_atom(0x1001); + let folded_any_namespace = engine.intern_qualified_atom(StyleAtomID::NONE, folded_name); + engine.note_attribute_name_forms( + name, + index::AttributeNameForms { + local: any_namespace, + folded_name, + folded_local: folded_any_namespace, + }, + ); + let _pin = engine.atoms.pin([name]); + for raw in 0x2000..0x2100 { + engine.intern_atom(raw); + } + + engine.sweep_style_atoms(); + + let reclaimed = engine + .reclaimed_style_atoms + .iter() + .map(|reclaimed| reclaimed.atom) + .collect::>(); + assert!(!reclaimed.contains(&name)); + assert!(!reclaimed.contains(&any_namespace)); + assert!(!reclaimed.contains(&folded_name)); + assert!(!reclaimed.contains(&folded_any_namespace)); +} + +#[test] +fn qualified_attribute_programs_keep_local_name_forms_live() { + let mut engine = StyleEngine::new(DeviceClass::ForegroundDesktop); + let namespace = engine.intern_atom(0x1000); + let local_name = engine.intern_atom(0x1001); + let qualified_name = engine.intern_qualified_atom(namespace, local_name); + let any_namespace = engine.intern_qualified_atom(StyleAtomID::NONE, local_name); + engine.note_attribute_name_forms( + local_name, + index::AttributeNameForms { + local: any_namespace, + ..index::AttributeNameForms::default() + }, + ); + let mut builder = selector::SelectorProgramBuilder::new(); + let attribute = builder.push_feature(selector::FeatureTest::Attribute(selector::AttributeTest { + name: qualified_name, + any_namespace: false, + folded: qualified_name, + fold_in_namespace: StyleAtomID::NONE, + operator: selector::AttributeOperator::Presence, + value_atom: StyleAtomID::NONE, + value_offset: 0, + value_length: 0, + case: selector::AttributeCase::Sensitive, + })); + builder.push_entry(attribute); + let program = engine.programs.add(builder.finish()); + let sheet = engine.add_sheet(StyleSheetObjectID(1), CascadeOrigin::Author); + engine.attach_sheet(sheet, TreeScopeID::DOCUMENT); + let rule = engine.append_rule(sheet, None, RuleKind::Style); + engine.add_routing_rule(rule, program); + let mut version = engine.program.rule_version(rule); + version.selector_program = Some(program); + version.declaration_block = Some(DeclarationBlockID(1)); + engine.replace_rule_version(rule, version); + discard_transaction(&mut engine); + for raw in 0x2000..0x2100 { + engine.intern_atom(raw); + } + + engine.sweep_style_atoms(); + + let reclaimed = engine + .reclaimed_style_atoms + .iter() + .map(|reclaimed| reclaimed.atom) + .collect::>(); + assert!(!reclaimed.contains(&namespace)); + assert!(!reclaimed.contains(&local_name)); + assert!(!reclaimed.contains(&qualified_name)); + assert!(!reclaimed.contains(&any_namespace)); +} + +#[test] +fn engine_atom_sweeps_preserve_roots_and_purge_reused_identities() { + let (mut engine, nodes) = linear_document(); + let old_class = engine.intern_atom(0x1000); + let old_rule = add_target_rule(&mut engine, StyleSheetObjectID(1), old_class); + add_feature(&mut engine, nodes[1], LocalFeatureKey::Class(old_class)); + discard_transaction(&mut engine); + + for raw in 0x2000..0x2100 { + engine.intern_atom(raw); + } + engine.sweep_style_atoms(); + assert!( + engine + .reclaimed_style_atoms + .iter() + .all(|reclaimed| reclaimed.atom != old_class) + ); + engine.reclaimed_style_atoms.clear(); + + remove_feature(&mut engine, nodes[1], LocalFeatureKey::Class(old_class)); + discard_transaction(&mut engine); + assert!(engine.collect_live_style_atoms().0.contains(&old_class)); + engine.remove_rule(old_rule); + discard_transaction(&mut engine); + for raw in 0x3000..0x3100 { + engine.intern_atom(raw); + } + engine.sweep_style_atoms(); + assert!( + engine + .reclaimed_style_atoms + .iter() + .any(|reclaimed| reclaimed.atom == old_class) + ); + engine.reclaimed_style_atoms.clear(); + + let new_class = engine.intern_atom(0x4000); + assert_eq!(new_class, old_class); + assert!(engine.match_element(nodes[1]).unwrap().is_empty()); + let new_rule = add_target_rule(&mut engine, StyleSheetObjectID(2), new_class); + add_feature(&mut engine, nodes[2], LocalFeatureKey::Class(new_class)); + discard_transaction(&mut engine); + assert!(engine.match_element(nodes[1]).unwrap().is_empty()); + assert!( + engine + .match_element(nodes[2]) + .unwrap() + .iter() + .any(|matched| matched.rule == new_rule) + ); +} + +#[test] +fn engine_atom_reuse_replaces_catalog_text_and_name_forms() { + let (mut engine, nodes) = linear_document(); + let old_name = engine.intern_atom(0x1000); + let old_any_namespace = engine.intern_qualified_atom(StyleAtomID::NONE, old_name); + let old_value = engine.intern_atom(0x1001); + let old_language = engine.intern_atom(0x1002); + engine.note_attribute_name_forms( + old_name, + index::AttributeNameForms { + local: old_any_namespace, + ..index::AttributeNameForms::default() + }, + ); + engine.set_attribute_value_text(old_value, &[u16::from(b'o'), u16::from(b'l'), u16::from(b'd')]); + engine.set_element_language_text(old_language, &[u16::from(b'e'), u16::from(b'n')]); + engine.record_input( + InputKey::LocalFeature(nodes[1], LocalFeatureKey::Attribute(old_name)), + InputValue::Feature(FeatureValue::Absent), + InputValue::Feature(FeatureValue::Atom(old_value)), + ); + engine.set_element_language(nodes[1], old_language); + discard_transaction(&mut engine); + engine.record_input( + InputKey::LocalFeature(nodes[1], LocalFeatureKey::Attribute(old_name)), + InputValue::Feature(FeatureValue::Atom(old_value)), + InputValue::Feature(FeatureValue::Absent), + ); + engine.set_element_language(nodes[1], StyleAtomID::NONE); + discard_transaction(&mut engine); + for raw in 0x2000..0x2100 { + engine.intern_atom(raw); + } + engine.sweep_style_atoms(); + let reclaimed = engine + .reclaimed_style_atoms + .iter() + .map(|reclaimed| reclaimed.atom) + .collect::>(); + for atom in [old_name, old_any_namespace, old_value, old_language] { + assert!(reclaimed.contains(&atom)); + } + engine.reclaimed_style_atoms.clear(); + + let new_name = engine.intern_atom(0x3000); + let new_any_namespace = engine.intern_qualified_atom(StyleAtomID::NONE, new_name); + let new_value = engine.intern_atom(0x3001); + let new_language = engine.intern_atom(0x3002); + assert_eq!( + [new_name, new_any_namespace, new_value, new_language], + [old_name, old_any_namespace, old_value, old_language] + ); + engine.note_attribute_name_forms( + new_name, + index::AttributeNameForms { + local: new_any_namespace, + ..index::AttributeNameForms::default() + }, + ); + engine.set_attribute_value_text(new_value, &[u16::from(b'n'), u16::from(b'e'), u16::from(b'w')]); + engine.set_element_language_text(new_language, &[u16::from(b's'), u16::from(b'v')]); + + let mut builder = selector::SelectorProgramBuilder::new(); + let (value_offset, value_length) = builder.push_literal(&[u16::from(b'n'), u16::from(b'e')]); + let prefix = builder.push_feature(selector::FeatureTest::Attribute(selector::AttributeTest { + name: new_name, + any_namespace: false, + folded: new_name, + fold_in_namespace: StyleAtomID::NONE, + operator: selector::AttributeOperator::Prefix, + value_atom: StyleAtomID::NONE, + value_offset, + value_length, + case: selector::AttributeCase::Sensitive, + })); + let (first, count) = builder.push_language_ranges(&[&[u16::from(b's'), u16::from(b'v')]]); + let language = builder.push(selector::SelectorOp::Language { first, count }); + let any_namespace = builder.push_feature(selector::FeatureTest::Attribute(selector::AttributeTest { + name: new_any_namespace, + any_namespace: true, + folded: new_any_namespace, + fold_in_namespace: StyleAtomID::NONE, + operator: selector::AttributeOperator::Presence, + value_atom: StyleAtomID::NONE, + value_offset: 0, + value_length: 0, + case: selector::AttributeCase::Sensitive, + })); + let compound = builder.push_compound(&[prefix, language, any_namespace]); + builder.push_entry(compound); + let program = engine.programs.add(builder.finish()); + let sheet = engine.add_sheet(StyleSheetObjectID(1), CascadeOrigin::Author); + engine.attach_sheet(sheet, TreeScopeID::DOCUMENT); + let rule = engine.append_rule(sheet, None, RuleKind::Style); + engine.add_routing_rule(rule, program); + let mut version = engine.program.rule_version(rule); + version.selector_program = Some(program); + version.declaration_block = Some(DeclarationBlockID(1)); + engine.replace_rule_version(rule, version); + engine.record_input( + InputKey::LocalFeature(nodes[1], LocalFeatureKey::Attribute(new_name)), + InputValue::Feature(FeatureValue::Absent), + InputValue::Feature(FeatureValue::Atom(new_value)), + ); + engine.set_element_language(nodes[1], new_language); + discard_transaction(&mut engine); + + assert!(engine.has_attribute_value_text(new_value)); + assert_eq!(engine.facts.attribute_name_forms(new_name).local, new_any_namespace); + let primary = engine.facts.primary(); + let row = primary.row_of(nodes[1]).unwrap(); + let attribute = primary.attribute_of(row, new_name).unwrap(); + assert_eq!( + primary.text_of(attribute), + Some([u16::from(b'n'), u16::from(b'e'), u16::from(b'w')].as_slice()) + ); + assert_eq!(primary.attribute_name_forms(new_name).local, new_any_namespace); + + assert!( + engine + .match_element(nodes[1]) + .unwrap() + .iter() + .any(|matched| matched.rule == rule) + ); +} diff --git a/Libraries/LibWeb/Rust/src/css/style/tree.rs b/Libraries/LibWeb/Rust/src/css/style/tree.rs index a0b757ff38fe..b8ae0daad81c 100644 --- a/Libraries/LibWeb/Rust/src/css/style/tree.rs +++ b/Libraries/LibWeb/Rust/src/css/style/tree.rs @@ -22,6 +22,7 @@ //! mutation. use super::fast_hash::FastMap as HashMap; +use super::fast_hash::FastSet as HashSet; use std::num::NonZeroU32; use super::capacity::capacity_bytes; @@ -469,6 +470,18 @@ pub struct StyleNodeTree { } impl StyleNodeTree { + pub(super) fn collect_atoms(&self, atoms: &mut HashSet) -> u64 { + let Some(shadow) = &self.shadow else { + return 0; + }; + let mut visited = 0_u64; + for pairs in shadow.part_hosts.values() { + visited += u64::try_from(pairs.len()).expect("part host count exceeds u64"); + atoms.extend(pairs.iter().map(|&(atom, _)| atom)); + } + visited + } + #[must_use] pub fn new(memory: &mut MemoryController) -> Self { let mut tree = Self { @@ -1444,9 +1457,15 @@ mod tests { let pairs = [(StyleAtomID(7), element), (StyleAtomID(8), element)]; fixture.tree.set_part_hosts(element, &pairs, &mut fixture.memory); assert_eq!(fixture.tree.part_hosts_of(element), &pairs); + let mut atoms = HashSet::default(); + assert_eq!(fixture.tree.collect_atoms(&mut atoms), 2); + assert_eq!(atoms, [StyleAtomID(7), StyleAtomID(8)].into_iter().collect()); fixture.tree.set_part_hosts(element, &[], &mut fixture.memory); assert_eq!(fixture.tree.part_hosts_of(element), &[]); + atoms.clear(); + assert_eq!(fixture.tree.collect_atoms(&mut atoms), 0); + assert!(atoms.is_empty()); } #[test] diff --git a/Tests/LibWeb/TestStyleEngineBridge.cpp b/Tests/LibWeb/TestStyleEngineBridge.cpp index f0e76a831c19..28f8a7fbb80a 100644 --- a/Tests/LibWeb/TestStyleEngineBridge.cpp +++ b/Tests/LibWeb/TestStyleEngineBridge.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include #include #include @@ -38,6 +39,73 @@ TEST_CASE(interned_atoms_are_released_with_the_style_engine) EXPECT_EQ(Utf16FlyString::number_of_utf16_fly_strings(), initial_fly_string_count); } +TEST_CASE(unowned_atoms_are_released_in_one_transaction_batch) +{ + auto initial_fly_string_count = Utf16FlyString::number_of_utf16_fly_strings(); + Web::CSS::StyleEngine engine(Web::CSS::StyleEngine::DeviceClass::ForegroundDesktop); + auto root = engine.allocate_style_node(); + for (size_t index = 0; index < 256; ++index) { + auto name = MUST(String::formatted("style-engine-atom-churn-{}", index)); + engine.intern_atom(Utf16FlyString::from_utf8_without_validation(name)); + } + EXPECT_EQ(Utf16FlyString::number_of_utf16_fly_strings(), initial_fly_string_count + 256); + auto initial_generation = engine.atom_generation(); + (void)engine.take_style_transaction(root); + EXPECT_EQ(engine.atom_generation(), initial_generation + 1); + EXPECT_EQ(Utf16FlyString::number_of_utf16_fly_strings(), initial_fly_string_count); +} + +TEST_CASE(flush_does_not_recycle_atoms_before_the_bridge_can_forget_them) +{ + Web::CSS::StyleEngine engine(Web::CSS::StyleEngine::DeviceClass::ForegroundDesktop); + Vector names; + Vector atoms; + names.ensure_capacity(256); + atoms.ensure_capacity(256); + for (size_t index = 0; index < 256; ++index) { + auto name = Utf16FlyString::from_utf8_without_validation(MUST(String::formatted("style-engine-flush-atom-{}", index))); + atoms.unchecked_append(engine.intern_atom(name)); + names.unchecked_append(move(name)); + } + + engine.flush(); + auto new_atom = engine.intern_atom(Utf16FlyString::from_utf8_without_validation("style-engine-after-flush"sv)); + EXPECT(!atoms.contains_slow(new_atom)); + for (size_t index = 0; index < names.size(); ++index) + EXPECT_EQ(engine.intern_atom(names[index]), atoms[index]); +} + +static u64 counter_value(Web::CSS::StyleEngine const& engine, StringView expected_name) +{ + for (size_t index = 0;; ++index) { + StringView name; + u64 value = 0; + if (!engine.counter(index, name, value)) + VERIFY_NOT_REACHED(); + if (name == expected_name) + return value; + } +} + +TEST_CASE(reclaimed_language_atoms_republish_their_text) +{ + Web::CSS::StyleEngine engine(Web::CSS::StyleEngine::DeviceClass::ForegroundDesktop); + auto root = engine.allocate_style_node(); + auto language = Utf16FlyString::from_utf8_without_validation("reclaimed-language"sv); + engine.intern_language_atom(language.view()); + for (size_t index = 0; index < 255; ++index) { + auto name = MUST(String::formatted("style-engine-language-sweep-{}", index)); + engine.intern_atom(Utf16FlyString::from_utf8_without_validation(name)); + } + EXPECT_EQ(counter_value(engine, "languageTextsPublished"sv), 1ull); + + engine.flush(); + (void)engine.take_style_transaction(root); + engine.intern_language_atom(language.view()); + + EXPECT_EQ(counter_value(engine, "languageTextsPublished"sv), 2ull); +} + TEST_CASE(direct_inherited_style_deltas_only_absorb_covered_reactions) { using Web::CSS::StyleEngine; From 115b72e4e4a537d105f1d3a2eaf3a5ba8985ff2c Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 12:58:33 +0200 Subject: [PATCH 38/39] LibWeb: Reconcile the StyleEngine documentation Bring the design document in line with the engine as it stands: the fixed-width selector IR and transpose routes, flat route directories, the element-indexed fact store and packed publication columns, process-global atoms and shared programs, the six-array boundary transaction, the period-based Tier-3 budget, and the current flush stages. Keep container conditions distinct from the document-wide condition state published for media and supports rules. --- Documentation/Style/StyleEngine.md | 107 ++++++++++-------- Documentation/Style/StyleEngineTesting.md | 2 +- .../LibWeb/Rust/src/css/style/transaction.rs | 21 +++- 3 files changed, 78 insertions(+), 52 deletions(-) diff --git a/Documentation/Style/StyleEngine.md b/Documentation/Style/StyleEngine.md index d359c73b5310..94b0b68c6499 100644 --- a/Documentation/Style/StyleEngine.md +++ b/Documentation/Style/StyleEngine.md @@ -151,8 +151,8 @@ Conservative neither is executable; keep the routed region as-is * **Style read epoch**: a mutation-free interval in which style reads the authoritative live DOM and other versioned inputs. * **Style node**: the engine's compact identity for a DOM element. * **Style feature atom**: a compact identity for a selector-mentioned tag, ID, class, attribute, value, namespace, or state fact. It does not own duplicate string payload. -* **Match program**: bytecode answering whether a candidate subject matches a selector during one read epoch. -* **Transpose program**: bytecode that starts from a changed selector input and traverses inverse selector relations to enumerate a safe superset of subjects whose match truth can change. +* **Match program**: a logical-IR evaluator answering whether a candidate subject matches a selector during one read epoch. +* **Transpose program**: compiled inverse routes that start from a changed selector input and enumerate a safe superset of subjects whose match truth can change. * **Delta-routing registry**: required program state mapping a typed semantic input key to transpose entry points. * **Impact region**: the normalized union of subjects, relation ranges, and dependent scopes emitted by transpose programs for a transaction. * **Impact-region attribution**: the split between regions attributed to a specific rule and program (eligible for exact planning) and unattributed regions (evaluated conservatively). @@ -220,7 +220,7 @@ A removed style-node identity and any required relation tombstone stay valid unt Resident derived state supplies old selector truth when available. If it was evicted, discard the affected derived fragment, evaluate the final state from the live DOM, and compare with the last committed style output. -**Journal overflow.** The journal is byte-accounted against its own memory category. Its capacity allows four fine-grained entries per connected element and is clamped between 4 MiB and 32 MiB. When growth would cross that capacity, the most numerous input kind's fine-grained entries are replaced with a typed **complete-scope marker** covering the whole document for that kind, and later records of that kind are dropped outright. At flush a marker widens the impact region to the document, and the plan evaluates that region exactly (§3). A pathological script can force broad discovery work but cannot force unbounded journal memory. (Environment markers are exempted from disabling the retained-selector fast paths, since environment changes cannot alter selector incidence.) +**Journal overflow.** The journal is byte-accounted against its own memory category. Its capacity allows four fine-grained entries per connected element and is clamped between 4 MiB and 32 MiB. When growth would cross that capacity, the most numerous reconstructible fact, relation, program, or topology input kind's fine-grained entries are replaced with a typed **complete-scope marker** covering the whole document for that kind, and later records of that kind are dropped outright. Edge-triggered `ElementStyleInput` actions are never marker-coarsened: their reaction and inherited-group bits remain attached to every target and repeated actions on one target are unioned. At flush a marker widens the impact region to the document, and the plan evaluates that region exactly (§3). A pathological script can force broad discovery work but cannot discard an action whose target cannot be reconstructed from final facts. (Environment markers are exempted from disabling the retained-selector fast paths, since environment changes cannot alter selector incidence.) ### 4.5 Mutations during evaluation @@ -242,7 +242,8 @@ Do not blindly duplicate fields already on DOM elements. Persistent style-side s * a minimal style-data handle reachable from an element; * required evaluator-local relation-navigation state; -* sparse segmented columns for optional per-element information; +* directly indexed columns for optional per-element fact metadata; +* compact dense columns for live computed-style publication handles; * reusable temporary columnar batches for bounded-region evaluation; * indexes containing only style-relevant facts. @@ -251,14 +252,16 @@ parent required, dense first element child required, dense next element sibling required, dense previous element sibling required, dense +depth required, dense tree scope conditional, allocated only for multi-scope documents assigned slot allocated per segment shadow host allocated per segment -local feature-set identity optional, Tier 3 -style-record identity Tier 1 for observed content +fact metadata header optional payload in a dense directly indexed column +prefix local-fact identity optional, Tier 3 segmented column +computed publication handles compact dense columns, Tier 1 for observed content ``` -Optional columns are allocated per segment only when useful. A document with no shadow trees allocates no shadow relationship columns. +Shadow-relation and prefix-cache columns are allocated per segment only when useful. Fact metadata and computed publication use dense directly indexed columns whose vacant rows carry no external payload; they trade empty slots below the highest used identity for constant-time access. A document with no shadow trees allocates no shadow relationship columns. ### 5.3 Required relation navigation @@ -269,15 +272,15 @@ Concretely: for `.theme .item` and a class mutation on a container, the transpos Rules: * Dense `u32`, allocated for every connected style node, charged to **Tier 1**, counted against the mandatory per-node byte budget (§10.5). -* **Not semantically authoritative.** The exact cold evaluator can always read the live DOM. The columns are a derived projection that must agree with the live tree at every epoch boundary. -* Not evicted under ordinary memory pressure; Tier 3 is evicted first. If they cannot be allocated, evaluation stays exact through bounded live-fact batches. +* The DOM remains the Web-facing semantic owner, while these columns are the engine's committed evaluation projection. C++ must publish every relation mutation, and the columns must agree with the final live tree at every epoch boundary; the exact evaluator reads this projection rather than fetching the DOM in reverse. +* Not evicted under ordinary memory pressure; Tier 3 is evicted first. Their allocation is required engine state, and a missing committed relation row is an invariant violation rather than a cold-path signal. * Ownership follows the tree: the subsystem that mutates DOM structure publishes relation-column updates as part of the same `TreeDelta` that reports the mutation, so they cannot drift within a read epoch. Mismatch is an invariant violation, not a recoverable condition. Tree-scope navigation has the same traversal requirement once multiple scopes are supported, because scope-membership transposition works the same way. Allocate the dense tree-scope column only for documents that need it, and charge it to the conditional byte allowance (§10.5). Flat-tree operations derive from the sparse assigned-slot and shadow-host relations instead of duplicating a third parent column. ### 5.4 Temporary packing -Packing is an execution choice, not snapshot construction. Small or selective work queries the live tree directly. A large batch may gather only its requested columns and affected region into Tier-4 scratch when dense traversal, SIMD execution, or bridge amortization wins. Batches are generation checked, released after the transaction, and never authoritative. Whole-document cold evaluation streams bounded batches; it never allocates a complete flattened DOM copy. +Packing is an execution choice, not snapshot construction. Small or selective work queries the resident columns directly. A sufficiently large transaction compiles its affected regions into a preorder topology charged to Tier-4 scratch; broad matching holds an immutable shared view of the primary fact columns, while transaction-specific pre-images remain bounded overlays. Scratch is generation checked and released after the transaction. Whole-document cold evaluation never allocates a second complete flattened DOM or fact-store copy. ### 5.5 Multiple tree relations @@ -365,7 +368,7 @@ Compound operands transpose to the node whose local fact changed. Structural pse It covers tag, ID, class, attribute, namespace, state, tree relation, structural, scope/topology, and activation inputs. Selectors with no selective local atom go into a **typed** always-consulted slice for the input kinds that can affect them, not one global universal bucket. A child-list mutation must not consult programs that depend only on an environment predicate. -Construction and bytes are charged to program compilation and Tier 2. Sorted delta-coded program and entry-point IDs keep the structure proportional to selector-input incidence, not element count. +Construction and bytes are charged to program compilation and Tier 2. Each input key indexes one insertion-ordered route slice, and all slices are packed into a flat `RouteID` vector; route headers and their variable-length paths live in parallel columnar arenas. The structure is proportional to selector-input incidence, not element count. The registry is required, not optional: scanning every selector header for every ordinary mutation changes the hot-path asymptotics. Feature postings (Tier 3) may accelerate subject enumeration, but evicting them never changes which transpose entry points run. Program replacement keeps old registry entries alive through old-result removal and installs the new program and registry atomically in the next epoch. @@ -400,16 +403,16 @@ The exact batch plan consumes only a complete impact region. One selector semantics, three cooperating execution kernels. The boundaries are physical, so operators can move between them later without changing query meaning. -**Local-compound kernel**: the dominant hot path. Evaluates tag names, IDs, classes, attributes, namespaces, element states, and Boolean compound structure. Uses fixed-width interned atom IDs at the C++/Rust boundary, a rightmost-feature dispatch key, and compact fused bytecode. Candidate tests are ordered cheap/high-rejection first and short-circuit on failure. **A compound with no relational operator allocates no witness.** (Non-relational chains do participate in the retained prefix automaton below when admitted; that state is Tier-3 and evictable, never required.) +**Local-compound kernel**: the dominant hot path. Evaluates tag names, IDs, classes, attributes, namespaces, element states, and Boolean compound structure. Uses fixed-width interned atom IDs at the C++/Rust boundary, a rightmost-feature dispatch key, and an arena of fixed-width enum nodes with shared operand and literal tables. Candidate tests are ordered cheap/high-rejection first and short-circuit on failure. **A compound with no relational operator allocates no witness.** (Non-relational chains do participate in the retained prefix automaton below when admitted; that state is Tier-3 and evictable, never required.) -Selector-used features have two sparse directions with independently bounded sizes: +Selector feature keys have two sparse directions with independently bounded sizes: ```text feature atom -> candidate StyleNodeID posting Tier 3, optional feature atom -> transpose entry points Tier 2, required, program-proportional ``` -The first avoids scanning DOM facts and may be evicted. The second lets a class, attribute, tag, ID, or state mutation activate only bytecode mentioning the changed feature. If the posting is evicted, the routed transpose program reads authoritative DOM facts or uses an exact scope batch. No element-by-selector relation is ever required. +The first avoids scanning DOM facts and may be evicted. The second lets a class, attribute, tag, ID, or state mutation activate only routes mentioning the changed feature. If the posting is evicted, the routed transpose program reads authoritative DOM facts or uses an exact scope batch. No element-by-selector relation is ever required. **Directional-combinator kernel**: child, descendant, adjacent-sibling, and subsequent-sibling relationships consume the same local compound programs, under operation-specific schedules: @@ -418,19 +421,19 @@ The first avoids scanning DOM facts and may be evicted. The second lets a class, * *Mutation to an attached program*: start from changed local truths. Descendant and child effects run as a top-down prefix-state pass over the exact affected scope, warm-started from the retained prefix states of the previous transaction where those survived. * *Sibling effects*: adjacent-sibling inspects the immediately participating siblings; subsequent-sibling and structural effects use the same automaton's child-sequence machinery and stop at unchanged outputs. -These are schedules over the same bytecode. +These are schedules over the same logical IR. **The prefix automaton.** Selector-prefix truths are retained across transactions as a Tier-3 cache: interned prefix states (the "context tokens" entering each node), per-element entering states, per-element terminal match sets, and per-element positional truth bits. Registration is budgeted: an automaton carries at most 32 structural-test truth bits, and a chain that would overflow is refused whole and stays with its exact routes, so refusal degrades to exact matching, never to a wrong answer. The cache's bytes cycle through scratch during a flush and are retained at the end of a patched flush; it is prioritized above the retained answers it maintains (§10.3), and discarding it is always legal; the next flush rebuilds from exact evaluation. **Relational-query kernel**: `RelativeExists` reuses local compounds and directional traversal to answer an existential question per anchor. Witnesses belong only to this kernel. A document with no relational selector allocates no relational queues, per-node relational fields, or relational cache tables (the witness table is empty and unallocated, and relational routing early-returns on an empty route set). -Selector lists and Boolean functional pseudo-classes compile to compact branch bytecode around these kernels. Specificity and match metadata stay static program data rather than being reconstructed per candidate. +Selector lists and Boolean functional pseudo-classes compile to branch nodes in the same IR. Specificity, dispatch analysis, and match metadata stay static program data rather than being reconstructed per candidate or scope-dispatch build. ### 6.6 Local feature indexes -Index keys are semantic feature atoms, not strings: a tag/namespace pair, ID, class, attribute presence, attribute exact value with case mode, or state. Substring and token attribute operators drive from an attribute-name posting and run their exact value test in compound bytecode rather than demanding a posting per substring. +Selector posting keys are semantic feature atoms, not strings: tag name, ID, class, attribute name, directionality, part name, and custom state. Namespace, ordinary state, root, heading, and other fixed facts are read from the authoritative row when dispatch reaches a candidate. Every attribute operator drives from the attribute-name posting; exact equality can reject on its value atom before matching, while substring and token operators run their exact text test in the compound evaluator. -Representation: **chunked sorted postings**, sorted by `StyleNodeID`. The index contains only features used by an attached selector program; removing the last consumer makes it reclaimable. +Representation: **chunked sorted postings**, sorted by `StyleNodeID`. While the Tier-3 category is admitted, every published selector feature is maintained eagerly, whether or not the current program mentions it. Forgetting an element removes all of its posting memberships; memory pressure evicts the complete posting category, and later selective demand may rebuild its missing keys from authoritative facts. One key stops retaining a posting when its membership grows beyond the greater of 4096 elements and one quarter of the live elements. The key then reads as missing acceleration, so every consumer takes its exact fallback instead of retaining a near-document-sized secondary index. @@ -478,7 +481,7 @@ The cold evaluator and the incremental evaluator consume the same static specifi ### 6.10 Ephemeral selector consumers -Selectors passed to `querySelector()`, `querySelectorAll()`, `matches()`, and `closest()` are **match-only query programs**. They compile with the same selector semantics and evaluate with a bare match evaluator over the engine's fact store, but they are not attached style programs: no transpose bytecode, no routing registry entries, no rule identities, no witness reads or writes, no cascade state. Scope roots, shadow boundaries, relative-selector anchoring, and syntax failure are explicit compiler inputs. Result ownership belongs to the DOM API, not StyleEngine. +Selectors passed to `querySelector()`, `querySelectorAll()`, `matches()`, and `closest()` are **match-only query programs**. They compile with the same selector semantics and evaluate with a bare match evaluator over the engine's fact store, but they are not attached style programs: no transpose routes, no routing registry entries, no rule identities, no witness reads or writes, no cascade state. Scope roots, shadow boundaries, relative-selector anchoring, and syntax failure are explicit compiler inputs. Result ownership belongs to the DOM API, not StyleEngine. Two mechanisms make this correct and fast against a live document: @@ -598,9 +601,9 @@ Attachment connects a compiled program to one or more style scopes and is distin One compiled program attaches to multiple scopes without duplicating its logical selector or declaration representation. Scope-specific indexes and materializations stay separate where tree membership requires it. -Match and transpose bytecode contains document-local `StyleAtomID` values and is therefore **compiled per document**. Routing registries, attachments, order tokens, and all result materializations are always document-local. Authoritative parsed stylesheet resources may be shared across documents, but nothing carrying one document's IDs, generations, scopes, or privacy state crosses a document boundary. +Selector programs contain process-global `StyleAtomID` values and immutable equal programs are shared across document engines on the StyleEngine thread. Each document retains references to the global atoms it uses; the final document releases the underlying interned-string identity and makes the numeric atom reusable. Entry and rule identities, routing registries, attachments, order tokens, and all result materializations remain document-local, so no document generation, tree scope, or result state crosses the sharing boundary. -The memory lease for a shared selector payload binds to the document that first inserts it. That document reports the program bytes, later documents sharing the payload report zero, and the original charge remains until the payload's final document reference drops. +The memory lease for a shared selector payload belongs to the process-global selector-program table. Document controllers report only their local program identities and indexes; the shared table reports each immutable payload once and keeps that charge until the payload's final document reference drops. ### 8.5 Source order @@ -837,7 +840,7 @@ AND ScopeConditionTrue -> ActiveRuleMatches ``` -An environment change can toggle already-known matches without re-running selector logic. Representation: compact predicate bytecode plus one current result per attached group (Tier 3, evictable, reevaluated from typed environment inputs). Do not build a persistent inactive-rule match cache; activation evaluates the selector program through normal inputs, reusing a cached selector answer only when one is already available in the current traversal. +An environment change can toggle already-known matches without re-running selector logic. C++ evaluates media and supports conditions and publishes the resulting per-sheet or per-rule `conditions_hold` flips; container conditions are answered per element during matching instead. Rust retains the document-wide condition-program identity and committed Boolean in required program state. Do not build a persistent inactive-rule match cache; activation evaluates the selector program through normal inputs, reusing a cached selector answer only when one is already available in the current traversal. Environment inputs: viewport and page size; device pixel ratio; media features and preferences; document URL and target state; font selection and loading state; container sizes and styles; scroll and view timeline state; anchor-positioning inputs. Changes publish typed deltas to actual consumers. @@ -863,7 +866,7 @@ Memory is a primary constraint. Time improvements requiring unbounded retained s * **Tier 0: authoritative semantic inputs.** DOM, CSSOM, stylesheet programs, browser state. Reference it; do not duplicate it. * **Tier 1: minimal live style state.** Compact handles and shared payloads required to answer current observers. Not evictable without first proving the content has no observer. -* **Tier 2: shared semantic IR and intern pools.** Canonical selector, declaration, condition, cascade nodes; routing registry; transpose bytecode. Bounded relative to the compact parsed stylesheet program; cannot absorb selector-result state. +* **Tier 2: shared semantic IR and intern pools.** Canonical selector, declaration, condition, cascade nodes; routing registry; transpose route programs. Bounded relative to the compact parsed stylesheet program; cannot absorb selector-result state. * **Tier 3: acceleration materializations.** Indexes, match sets, inverse maps, witnesses, proofs, flattened environments. Strictly budgeted and fully evictable. * **Tier 4: transaction scratch.** Reusable arenas for flattened relation ranges, delta queues, batch evaluation, comparison. High-water marks monitored; shrinks after unusually large work. @@ -875,7 +878,7 @@ An interned object is not automatically Tier 2. Contexts, summaries, match answe | Active read-epoch header and generation | 1 | Reclaim after every reader retires | | Attached compact semantic rule program | 2 | Reclaim after detachment and epoch retirement | | Style feature atom mapping | 2 | Reclaim after last program/posting reference and epoch retirement | -| Delta-routing registry and transpose bytecode | 2 | Reclaim with the selector program and retired epochs | +| Delta-routing registry and transpose routes | 2 | Reclaim with the selector program and retired epochs | | Stable rule, attachment, and order nodes | 2 | Reclaim after semantic detachment and epoch retirement | | Memory controller and aggregate accounting | (uncharged) | Reclaim with the document | | `StyleNodeID` and DOM-to-style-node mapping | 1 | Reclaim after disconnection and epoch retirement | @@ -903,18 +906,16 @@ Owners account exact capacity at arena, slab, vector, bitmap, and table growth b ```text Tier3Limit = min( DeviceCap, - BaseAllowance - + NodeAllowance * ConnectedElementCount - + StylesheetAllowance * CompactStyleProgramBytes) + BaseAllowance + NodeAllowance * ConnectedElementCount) ``` -`CompactStyleProgramBytes` is the byte length of a minimal non-commoned encoding of the attached selectors, match and transpose bytecode, routing registry, declarations, and conditions, counted once for an explicitly shared constructed program. It **excludes** allocator padding, optional indexes, results, and StyleEngine's own capacity, so acceleration overhead can never inflate its own allowance. `ConnectedElementCount` counts connected styleable DOM elements in the live DOM at the read epoch, not pseudo style nodes, arena capacity, or retired generations. +`ConnectedElementCount` counts connected styleable DOM elements in the live DOM at the read epoch, not pseudo style nodes, arena capacity, or retired generations. Required selector and rule-program storage is charged honestly to Tier 2 but does not enlarge the optional Tier-3 pool; process-shared selector programs therefore need no per-document attribution rule in this budget. -| Configuration | Device cap | Base | Per connected style node | Per program byte | -| --- | ---: | ---: | ---: | ---: | -| Browser document | 64 MiB | 1 MiB | 2,048 bytes | 2.00 bytes | +| Configuration | Device cap | Base | Per connected style node | +| --- | ---: | ---: | ---: | +| Browser document | 64 MiB | 1 MiB | 2,048 bytes | -These are a ceiling for the complete set of optional views, not an expectation. Counters report actual capacity against the limit rather than treating the limit as a target. Ignoring the program term, the node coefficient reaches the device cap at 32,256 elements; above that the cap binds and larger documents run progressively colder, which is intended. +These are a ceiling for the complete set of optional views, not an expectation. Counters report actual capacity against the limit rather than treating the limit as a target. The node coefficient reaches the device cap at 32,256 elements; above that the cap binds and larger documents run progressively colder, which is intended. Tier 2 is required program state and is tracked without a cap. The cold-interpreted fallback that a cap would require does not exist; required program capacity must therefore never be refused or relabelled as Tier 3. @@ -971,7 +972,7 @@ MandatoryNodeBytes(surface) <= 32 The engine asserts the relation-column budget in its own tests. The conditional allowance covers tree-scope identity, allocated only when the document requires it and no authoritative field can be exposed safely without duplication. Depth rejects impossible ancestry checks immediately and bounds the remaining parent walk. Slot, part, pseudo, or future relation navigation must derive a compact identity or replace the physical representation; it cannot raise the 32-byte cap. (`StyleNodeID` is a `NonZeroU32`, so optional relation slots niche-pack into one word.) -Optional context, winner, dependency, and witness handles live in sparse Tier-3 columns and do not consume a reserved word on every node. Shared live style payloads are reported separately. +Optional context, winner, dependency, and witness handles use dense, bit-packed, or segmented Tier-3 columns according to their access and clearing patterns; they do not consume a reserved mandatory word on every node. Dense optional columns and shared live style payloads are reported separately rather than hidden in the 32-byte relation surface. **Tier 1 is required state, not free memory.** No duplicate complete computed styles. @@ -995,22 +996,30 @@ Memory pressure can therefore make a later flush slower and colder, but never re Execution is sequential. Style transactions are processed as dependency-ordered stages over homogeneous delta queues, not recursive pointer-chasing calls. The flush (`take_style_transaction`, flush.rs) runs: ```text -1. Normalize the journal into one transaction. -2. Commit staged tree and local-feature deltas into the fact store - (before routing, or after planning when the plan needs the before side). -3. Decide reuse: does the transaction preserve selector incidence, reach no +1. Reclaim unreachable computed payloads when due, finish the previous + Tier-3 quota period, evict selected complete categories, and open the next + period. +2. Finalize staged sheet-rule replacements, compact unshared routing + directories, and normalize the journal into one transaction. +3. Commit staged program, tree, and fact state to the final snapshot while + retaining the fact pre-images that the transaction needs. +4. Decide reuse: does the transaction preserve selector incidence, reach no selector, or qualify for retained-answer patching or exact-cascade stops? -4. Build impact regions (with a preorder topology above a size threshold). -5. Route program and cascade-topology inputs FIRST (they set the outer +5. Build impact regions (with a preorder topology above a size threshold) + and install the transaction fact view and previous prefix-state epoch. +6. Route program and cascade-topology inputs FIRST (they set the outer envelope), then local-feature/tree/state inputs, stopping early once the region covers the document. -6. Route sibling-sequence and relational (:has()) sequence changes; converge +7. Route sibling-sequence and relational (:has()) sequence changes; converge pending prefix routes. -7. Widen for markers, normalize regions, resolve already-planned truths. -8. Traverse: patch retained answers or complete published match answers; +8. Widen for markers, normalize regions, resolve already-planned truths, and + materialize every before-side fact still needed by retained-answer patching + or confirmation. Release the normalized transaction and its staged + pre-images only after that boundary. +9. Traverse: patch retained answers or complete published match answers; matched-rule production and winner-group updates happen together here, with exact-cascade stop and confirmation checks. -9. Publish: build the reaction records and emit them in one callback. +10. Publish: build the reaction records and emit them in one callback. ``` Computed-value construction is C++-side: the reaction batch drives StyleComputer, which builds computed properties and publishes each element's computed groups back to the engine for interning (§13). Layout, paint, animation, and accessibility consequences are produced by the C++ reaction application. @@ -1061,17 +1070,17 @@ StyleEngine lives in the existing LibWeb Rust crate and is authoritative for sel * selector matching, cascade winner resolution, and reaction planning; * match-answer identities, winner groups, memory accounting, and instrumentation. -Computed-value **construction** is C++-side: the reaction batch drives StyleComputer, which computes properties and builds computed-value groups, then publishes each element's groups back to the engine, where records are interned and become the shared authoritative representation. So the split is: Rust decides *what* must recompute and *which declarations win*; C++ computes *values*; Rust retains and interns the published result. C++ holds no copy of the engine's retained state; a fact has exactly one authoritative home. +Computed-value **construction** is C++-side: the reaction batch drives StyleComputer, which computes properties and builds computed-value groups, then publishes each element's groups back to the engine, where records are interned and become the shared authoritative representation. So the split is: Rust decides *what* must recompute and *which declarations win*; C++ computes *values*; Rust retains and interns the published result. DOM and CSSOM remain the semantic sources for element and program facts; Rust owns the committed evaluation projection and all retained derived state, and C++ must publish every source mutation needed to keep that projection current. **Identity allocation.** The engine mints `StyleNodeID` values (C++ requests them through an allocation call that accepts a batch); C++ owns DOM lifecycle and the DOM-to-style-node mapping. Rust treats IDs as opaque dense indexes. Reuse safety comes from two-phase retirement: retired indexes enter a pending pool and are released for reallocation only at a later safe boundary, never while a stale handle can observe them. -The engine likewise mints document-local `StyleAtomID` values for selector-mentioned tag, ID, class, attribute, value, namespace, and state identities, keyed by the exact interned-string identity C++ passes (a `Utf16FlyString`'s one-word raw identity, kept alive by the bridge for the atom's lifetime, so identity equality is exact and never hash-approximate). Interning on the engine side is what makes selector-name atoms and fact atoms comparable: one table assigns one sequence. Rust stores only the `u32` atom in compact bytecode and postings; a small number of text classes the engine must inspect byte-wise (attribute-value text for substring operators, language tags) are pushed to engine-side storage on demand and re-pushed if evicted. +The engine likewise mints process-global `StyleAtomID` values for selector-mentioned tag, ID, class, attribute, value, namespace, and state identities, keyed by the exact interned-string identity C++ passes (a `Utf16FlyString`'s one-word raw identity, retained until the last document reference releases it, so identity equality is exact and never hash-approximate). Interning on the engine side is what makes selector-name atoms and fact atoms comparable across shared programs: one process table assigns one sequence, while each document records and releases the atoms it uses. Rust stores only the `u32` atom in selector IR and postings; text the engine must inspect byte-wise (attribute values for broad string operators and language tags) is pushed to engine-side storage on demand and re-pushed if evicted. **Relation columns** are stored on the Rust side, keyed by `StyleNodeID`, and maintained from the journalled tree deltas within the same transaction that reports the mutation, so transpose traversal, impact-region membership, and sibling-sequence scans run entirely inside the evaluator. Two deliberate exceptions bypass per-node journalling: initial bulk load links the whole arriving tree directly (safe because the root arrival forces whole-document evaluation), and shadow host/root registration is applied directly at registration time. -**Forward transfer.** Element-fact input crosses as one flat immutable transaction per flush: five pointer-free fixed-width row arrays (tree relations, local features, state, element declarations, element style inputs) submitted in a single call. Program, sheet, rule, layer, and topology changes cross as individual generated boundary calls as they happen and are journalled engine-side into the same normalization journal. A small set of per-element scalar facts (namespace, language, directionality, heading level, custom states, slot-ness) also cross as individual calls at mutation time; the design intent remains that per-element chatter is the exception, not the shape of the boundary. +**Forward transfer.** C++ mutation helpers append element facts to retained input buffers rather than crossing FFI immediately. One flat immutable transaction per flush submits six pointer-free fixed-width row arrays (tree relations, element arrivals, local features, state, element declarations, and element style inputs) plus the arrival custom-state atom arena in a single call. Namespace, language, directionality, heading level, custom states, and slot-ness ride the element-arrival rows. Program, sheet, rule, layer, and topology changes still cross as individual generated boundary calls as they happen and are journalled engine-side into the same normalization journal. -**There is no reverse fact-fetch protocol.** The engine never asks C++ for a fact it lacks: C++ pushes every fact it owns eagerly as deltas, the engine stages them, and evaluation reads old/new values through a transaction-local fact view over the pending transaction plus the resident fact store. When a broad plan needs a complete fact table, the engine clones its own resident store (counted, charged to scratch): a Rust-to-Rust copy, no boundary round trip. The one reverse direction that exists is an enumeration callback letting C++ walk flat-tree descendants the engine knows about. +**There is no reverse fact-fetch protocol.** The engine never asks C++ for a fact it lacks: C++ pushes every fact it owns eagerly as deltas, the engine stages them, and evaluation reads old/new values through a transaction-local fact view over retained pre-images plus the resident fact store. A broad plan holds an immutable shared view of the resident primary columns and allocates only the transaction-specific overlays and indexes it needs; it does not clone the complete fact store or cross the boundary. The one reverse direction that exists is an enumeration callback for flat-tree descendants not represented by an engine-owned child list. **Computed style crosses as a handle.** The boundary transfers a shared style-group handle, never a copied per-property object. C++ consumers hold `StyleRecordID` and read through the record view; layout and paint consume the same handle rather than materializing a private complete style per element (anonymous layout-derived boxes are the exception and own their values). Records are pinned while any C++ consumer references them and reclaimed per the Tier-1 rules in §10.1. @@ -1087,7 +1096,7 @@ inputs.rs applying staged tree/fact input program.rs stylesheet program, identities, order tokens program_updates.rs ProgramDelta application compiler.rs selector compilation into the engine's programs -selector.rs, selector/ logical IR, match + transpose bytecode +selector.rs, selector/ logical IR, match evaluator + transpose routes relative_selector.rs RelativeExists queries and inverse anchor enumeration input_routing.rs typed input key -> routing keys routing.rs delta routing, impact-region construction @@ -1209,7 +1218,7 @@ The engine's job is to make broad costs correspond to real semantic uncertainty ## 16. Instrumentation -The engine maintains a large counter ledger (instrumentation.rs; ~160 counters), exposed to C++ and through the internals object, alongside a separate C++-side ledger of style-update and invalidation counters. The main families: +The engine maintains a large counter ledger (169 counters in `instrumentation.rs`), exposed to C++ and through the internals object, alongside a separate C++-side ledger of style-update and invalidation counters. The main families: * Input deltas by kind; journal cancellations, replacements, and coarsened scope markers. * Executed selector primitives in five buckets: local-feature tests, state tests, combinator steps, structural tests, relational tests. diff --git a/Documentation/Style/StyleEngineTesting.md b/Documentation/Style/StyleEngineTesting.md index 683b38068b39..654f11f3f471 100644 --- a/Documentation/Style/StyleEngineTesting.md +++ b/Documentation/Style/StyleEngineTesting.md @@ -36,7 +36,7 @@ A focused loop for style work: The gates are per-mechanism checks that run at their mechanism's site. Some re-derive incremental results through the exact cold evaluator and compare (`LIBWEB_VERIFY_STYLE_ANSWER_PATCH`, `LIBWEB_VERIFY_SELECTOR_TRUTH_DERIVATION`, `LIBWEB_VERIFY_CASCADE_WINNERS`); others assert structural properties (`LIBWEB_VERIFY_STYLE_PLAN_PROVENANCE`, `LIBWEB_VERIFY_PUBLISHED_STYLE_TRANSACTION`), and three C++-side gates cover input reuse, the computed closure, and the style-diff fast path (`LIBWEB_VERIFY_STYLE_INPUT_REUSE`, `LIBWEB_VERIFY_COMPUTED_CLOSURE`, `LIBWEB_VERIFY_STYLE_DIFF_FAST_PATH`). -Verification is **observer-only**: checks compare against private state, cannot publish into engine caches, and are exposed as unit-returning closures, so using a gate to steer engine behavior is a compile error. A verifier must never disable or bypass the fast path it is checking, and an incomplete comparison is a failure, not a skip. +Verification is **observer-only**: structural checks receive an immutable engine view, while checks that need the exact cold evaluator receive a dedicated verifier capability whose only operations perform comparisons. Neither API exposes cache publication or general engine mutation, and every gate returns unit, so a verifier cannot steer engine behavior. A verifier must never disable or bypass the fast path it is checking, and an incomplete comparison is a failure, not a skip. The five engine-side gates are engine inputs: recordings store their bit set, including bit 4 for `LIBWEB_VERIFY_SELECTOR_TRUTH_DERIVATION`, and replay refuses a capture under a different configuration. The three C++-side gates are outside the recorded surface. diff --git a/Libraries/LibWeb/Rust/src/css/style/transaction.rs b/Libraries/LibWeb/Rust/src/css/style/transaction.rs index d13493565e82..df2e8da55e6d 100644 --- a/Libraries/LibWeb/Rust/src/css/style/transaction.rs +++ b/Libraries/LibWeb/Rust/src/css/style/transaction.rs @@ -1085,13 +1085,30 @@ mod tests { fixture.element_style_input(10); fixture.element_style_input(20); - assert!(fixture.journal.markers().is_empty()); + // Reconstructible facts may still coarsen around edge-triggered actions. + for node in 30..200 { + fixture.class(node, 1, false, true); + } + fixture.element_style_input(200); + + assert_eq!( + fixture.journal.markers(), + &[CompleteScopeMarker { + kind: InputKind::LocalFeature + }] + ); let transaction = fixture.take(); assert_eq!( - transaction.inputs.iter().map(|input| input.key).collect::>(), + transaction + .inputs + .iter() + .filter(|input| input.key.kind() == InputKind::ElementStyleInput) + .map(|input| input.key) + .collect::>(), vec![ InputKey::ElementStyleInput(StyleNodeID::element(10)), InputKey::ElementStyleInput(StyleNodeID::element(20)), + InputKey::ElementStyleInput(StyleNodeID::element(200)), ] ); transaction.release(&mut fixture.memory); From 2dffe693ab1ad18262866409f46268395bbfadee Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Tue, 18 Aug 2026 13:46:40 +0200 Subject: [PATCH 39/39] LibWeb: Require longhand identity for style diff shortcut Equal style group payloads do not cover longhands whose groups only require an initial value, nor data stored outside group payloads. The shortcut could therefore suppress invalidation for a changed style. Require canonical longhand-table identity and equal resolved font lists before skipping the full diff. The style verifier covers the previously mismatched computed-style and interpolation cases. --- Libraries/LibWeb/DOM/Element.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Libraries/LibWeb/DOM/Element.cpp b/Libraries/LibWeb/DOM/Element.cpp index e26aa148ad2a..b7655345883c 100644 --- a/Libraries/LibWeb/DOM/Element.cpp +++ b/Libraries/LibWeb/DOM/Element.cpp @@ -1385,10 +1385,12 @@ static CSS::StyleComputer::ComputedStyleInvalidation compute_required_invalidati // NB: The adoption also makes an unchanged element keep sharing group storage with its // previous style generation, which future diffs turn into pure pointer compares. bool const all_group_payloads_shared = new_computed_values.adopt_identical_group_payloads(old_computed_values); - // The inheritance-dependent specified values live outside the group payloads, and swapping - // one for a concrete value with the same used color changes what descendants inherit, so - // equal payloads alone cannot prove the diff away. + // The computed longhand table, resolved font list and inheritance-dependent specified values + // live outside the group payloads, so equal payloads alone cannot prove the diff away. When + // all longhands are equal, adopting identical group payloads also adopts the previous table. bool const property_diff_can_be_skipped = all_group_payloads_shared + && old_computed_values.computed_longhand_values().data() == new_computed_values.computed_longhand_values().data() + && old_computed_values.font_list().equals(new_computed_values.font_list()) && !CSS::ComputedValues::either_carries_animated_overlay(old_computed_values, new_computed_values) && old_computed_values.inheritance_dependent_specified_values_equal(new_computed_values); static bool const verify_fast_path = getenv("LIBWEB_VERIFY_STYLE_DIFF_FAST_PATH") != nullptr;