diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 025247631f..f0dba38532 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -6904,6 +6904,7 @@ html/semantics/scripting-1/the-template-element/template-element/template-descen html/semantics/scripting-1/the-template-element/template-element/template-element-clone-into-inactive-document-crash.html html/semantics/scripting-1/the-template-element/template-element/template-set-inner-html-in-inactive-document-crash.html html/semantics/scripting-1/the-template-element/template-table-crash.html +html/semantics/sections/headingoffset-and-headingreset.html html/semantics/selectors/pseudo-classes/checked-type-change.html html/semantics/selectors/pseudo-classes/checked.html html/semantics/selectors/pseudo-classes/default.html diff --git a/moli-dom/src/native/element/mod.rs b/moli-dom/src/native/element/mod.rs index 06688a747e..21a9d1b178 100644 --- a/moli-dom/src/native/element/mod.rs +++ b/moli-dom/src/native/element/mod.rs @@ -230,6 +230,27 @@ impl Element { self.attribute_ns(namespace, local_name).is_some() } + /// Returns the reflected `headingOffset` value. + /// + /// HTML defines this unsigned-long reflection with `ReflectRange=(0, 8)`: + /// malformed and negative values fall back to zero, while larger parsed + /// values are clamped to eight. + pub fn heading_offset(&self) -> u32 { + self.attribute("headingoffset") + .and_then(parse_html_non_negative_integer) + .map(|value| value.min(8)) + .unwrap_or(0) + } + + /// Returns the reflected `headingReset` state. + /// + /// Modal dialogs are heading-offset boundaries even without an explicit + /// `headingreset` content attribute. + pub fn heading_reset(&self) -> bool { + self.has_attribute("headingreset") + || (self.is_html_element("dialog") && self.dialog_modal()) + } + pub fn custom_element_state(&self) -> CustomElementState { self.rare_data.custom_element_state() } @@ -1368,6 +1389,35 @@ impl Element { } } +fn parse_html_non_negative_integer(value: &str) -> Option { + let mut chars = value + .chars() + .skip_while(|ch| matches!(ch, '\t' | '\n' | '\u{000c}' | '\r' | ' ')); + let negative = match chars.clone().next() { + Some('+') => { + chars.next(); + false + } + Some('-') => { + chars.next(); + true + } + _ => false, + }; + + let mut value = 0_u32; + let mut had_digit = false; + for ch in chars { + let Some(digit) = ch.to_digit(10) else { + break; + }; + had_digit = true; + value = value.saturating_mul(10).saturating_add(digit); + } + + (had_digit && (!negative || value == 0)).then_some(value) +} + fn utf16_units_contain_unpaired_surrogate(units: &[u16]) -> bool { let mut index = 0; while index < units.len() { diff --git a/moli-dom/src/native/element/tests.rs b/moli-dom/src/native/element/tests.rs index d19b66f149..395cfe5c1c 100644 --- a/moli-dom/src/native/element/tests.rs +++ b/moli-dom/src/native/element/tests.rs @@ -367,6 +367,47 @@ fn input_type_change_sanitizes_without_dirtying_default_value() { } +#[test] +fn heading_reflections_parse_range_and_modal_state() { + let mut heading = Element::new_html("h1"); + assert_eq!(heading.heading_offset(), 0); + assert!(!heading.heading_reset()); + + for (raw, expected) in [ + ("3", 3), + (" +7tail", 7), + ("20", 8), + ("429496729600", 8), + ("-0", 0), + ("-3", 0), + ("invalid", 0), + ("\u{000b}7", 0), + ] { + assert!(heading.set_attribute( + "headingoffset".to_owned(), + String::new(), + None, + raw.to_owned(), + )); + assert_eq!(heading.heading_offset(), expected, "{raw:?}"); + } + + assert!(heading.set_attribute( + "headingreset".to_owned(), + String::new(), + None, + String::new(), + )); + assert!(heading.heading_reset()); + + let mut dialog = Element::new_html("dialog"); + assert!(!dialog.heading_reset()); + assert!(dialog.set_dialog_modal(true)); + assert!(dialog.heading_reset()); + assert!(dialog.set_dialog_modal(false)); + assert!(!dialog.heading_reset()); +} + #[test] fn script_element_state_distinguishes_dynamic_and_parser_created_scripts() { let dynamic = Element::new_html("script"); diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands.rs b/moli-renderer-v8/src/document_runtime/mutation_commands.rs index 0265e1eada..ba00b17b89 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -8,6 +8,7 @@ use crate::{ util::{utf16_split_units_lossy, utf16_units}, }; use moli_dom::native::{Element, Node, NodeType}; +use moli_selector::stylo_flat_tree_heading_descendants; use super::*; @@ -1470,6 +1471,17 @@ impl DocumentRuntime { self.push_validity_container_impacts(descendant, &mut impacts); } } + if matches!(attribute_name, "headingoffset" | "headingreset") + && state.intersects(StyloElementState::HEADING_LEVEL_BITS) + { + for descendant in stylo_flat_tree_heading_descendants(&self.dom_host, handle) { + push_state_impact( + &mut impacts, + descendant, + StyloElementState::HEADING_LEVEL_BITS, + ); + } + } impacts } @@ -2347,6 +2359,9 @@ fn style_state_impact_for_attribute(name: &str) -> StyloElementState { | StyloElementState::HAS_DIR_ATTR_RTL | StyloElementState::HAS_DIR_ATTR_LIKE_AUTO; } + if name == "headingoffset" || name == "headingreset" { + return StyloElementState::HEADING_LEVEL_BITS; + } StyloElementState::empty() } diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 54d2788b15..d70e60f4d6 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -17,8 +17,7 @@ use super::node::{ throw_incompatible_getter_receiver, throw_incompatible_method_receiver, throw_incompatible_setter_receiver, }; -use crate::document_runtime::DomHandle; -use crate::web_api_interfaces; +use crate::{document_runtime::DomHandle, web_api_interfaces, webidl}; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiTemplateValue}; mod activation; @@ -694,6 +693,20 @@ struct ElementPrototypeReflectionDeclaration { namespace_uri: (), #[webapi(accessor_property, enumerable, getter = element_prefix_getter_function)] prefix: (), + #[webapi( + accessor_property = "headingOffset", + enumerable, + getter = element_heading_offset_getter_function, + setter = element_heading_offset_setter_function + )] + heading_offset: (), + #[webapi( + accessor_property = "headingReset", + enumerable, + getter = element_heading_reset_getter_function, + setter = element_heading_reset_setter_function + )] + heading_reset: (), #[webapi( accessor_property = "innerHTML", enumerable, @@ -6714,6 +6727,91 @@ fn element_id_getter_function<'s>( set_element_string_return_value(scope, &mut rv, &value); } +fn element_heading_offset_getter_function<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some((runtime_ptr, handle)) = element_getter_receiver(scope, args.this(), "headingOffset") + else { + return; + }; + let value = unsafe { &*runtime_ptr } + .dom_host() + .node(handle) + .and_then(|node| node.as_element()) + .map(|element| element.heading_offset()) + .unwrap_or(0); + rv.set_uint32(value); +} + +fn element_heading_offset_setter_function<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some((runtime_ptr, handle)) = element_setter_receiver(scope, args.this(), "headingOffset") + else { + return; + }; + let value = match webidl::convert::( + scope, + args.get(0), + webidl::Context::member("Element", "headingOffset"), + ) { + Ok(value) => value.0, + Err(error) => { + webidl::throw_error(scope, &error); + return; + } + }; + let value = if value <= i32::MAX as u32 { value } else { 0 }; + set_reflected_attribute( + scope, + runtime_ptr, + handle, + "headingoffset", + &value.to_string(), + ); + rv.set_undefined(); +} + +fn element_heading_reset_getter_function<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some((runtime_ptr, handle)) = element_getter_receiver(scope, args.this(), "headingReset") + else { + return; + }; + let value = unsafe { &*runtime_ptr } + .dom_host() + .node(handle) + .and_then(|node| node.as_element()) + .is_some_and(|element| element.heading_reset()); + rv.set_bool(value); +} + +fn element_heading_reset_setter_function<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some((runtime_ptr, handle)) = element_setter_receiver(scope, args.this(), "headingReset") + else { + return; + }; + set_reflected_boolean_attribute( + scope, + runtime_ptr, + handle, + "headingreset", + args.get(0).boolean_value(scope), + ); + rv.set_undefined(); +} + fn element_id_setter_function<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, diff --git a/moli-renderer-v8/src/native_bridge/element/details_dialog.rs b/moli-renderer-v8/src/native_bridge/element/details_dialog.rs index fabb7a902b..762f8a8e83 100644 --- a/moli-renderer-v8/src/native_bridge/element/details_dialog.rs +++ b/moli-renderer-v8/src/native_bridge/element/details_dialog.rs @@ -7,6 +7,8 @@ use crate::{ util::v8_string, webidl, }; +use dom::ElementState as StyloElementState; +use moli_selector::stylo_flat_tree_heading_descendants; use super::super::{ JsContextHost, node::node_runtime_and_handle_from_object_or_detached, throw_dom_exception, @@ -234,10 +236,29 @@ fn dialog_set_open_state_for_handle( modal: bool, ) { set_reflected_boolean_attribute(scope, runtime_ptr, handle, "open", open); + let _ = set_dialog_modal_state(runtime_ptr, handle, open && modal); +} + +fn set_dialog_modal_state(runtime_ptr: *mut JsContextHost, handle: DomHandle, modal: bool) -> bool { + let old_heading_states = { + let runtime = unsafe { &*runtime_ptr }; + stylo_flat_tree_heading_descendants(runtime.dom_host(), handle) + .into_iter() + .map(|heading| (heading, runtime.retained_current_element_state(heading))) + .collect::>() + }; let runtime = unsafe { &mut *runtime_ptr }; - let _ = runtime - .dom_host_mut() - .set_dialog_modal(handle, open && modal); + if !runtime.dom_host_mut().set_dialog_modal(handle, modal) { + return false; + } + for (heading, old_state) in old_heading_states { + runtime.note_element_state_style_activity_with_old_state( + heading, + StyloElementState::HEADING_LEVEL_BITS, + old_state, + ); + } + true } fn dialog_is_modal(runtime: &JsContextHost, handle: DomHandle) -> bool { @@ -434,8 +455,8 @@ pub(in crate::native_bridge::element) fn close_dialog_element( } set_reflected_boolean_attribute(scope, runtime_ptr, handle, "open", false); + let _ = set_dialog_modal_state(runtime_ptr, handle, false); let runtime = unsafe { &mut *runtime_ptr }; - let _ = runtime.dom_host_mut().set_dialog_modal(handle, false); if let Some(return_value) = return_value { let _ = runtime .dom_host_mut() diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/dom_interfaces_and_collections.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/dom_interfaces_and_collections.rs index 4e58dccfb5..9fdb5b9293 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/dom_interfaces_and_collections.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/dom_interfaces_and_collections.rs @@ -2648,3 +2648,86 @@ fn detached_plain_document_all_matches_chromium_htmldda_surface() { r#"{"ownAll":false,"protoGetter":"function","allType":"undefined","loose":true,"strict":false,"bool":false,"string":"[object HTMLAllCollection]","tag":"[object HTMLAllCollection]","length":0,"noArgNull":true,"itemZeroNull":true,"itemMissNull":true,"itemMethodNull":true,"namedMethodNull":true}"# ); } + +#[test] +fn element_heading_reflections_drive_flat_tree_heading_matching() { + let mut vm = new_storage_test_vm("https://heading-offset.test/"); + + let result = vm + .eval( + r#" + (() => { + const descriptor = name => Object.getOwnPropertyDescriptor(Element.prototype, name); + const outcome = callback => { + try { + return `ok:${String(callback())}`; + } catch (error) { + return `throw:${error && error.name}`; + } + }; + + const parent = document.createElement("div"); + const heading = document.createElement("h1"); + parent.append(heading); + const mount = document.body || document.documentElement || + document.appendChild(document.createElement("html")); + mount.append(parent); + const initial = [heading.headingOffset, heading.headingReset, heading.matches(":heading(1)")]; + parent.headingOffset = 3; + const parentOffset = [parent.getAttribute("headingoffset"), heading.matches(":heading(4)")]; + heading.headingReset = true; + const reset = [heading.hasAttribute("headingreset"), heading.matches(":heading(1)")]; + heading.headingReset = false; + heading.headingOffset = 20; + const clamped = [heading.getAttribute("headingoffset"), heading.headingOffset, heading.matches(":heading(9)")]; + + const host = document.createElement("section"); + host.headingOffset = 1; + const root = host.attachShadow({ mode: "open" }); + const container = document.createElement("div"); + container.headingOffset = 1; + const slot = document.createElement("slot"); + container.append(slot); + root.append(container); + const slotted = document.createElement("h2"); + host.append(slotted); + mount.append(host); + + const modalParent = document.createElement("div"); + modalParent.headingOffset = 8; + const modal = document.createElement("dialog"); + const modalHeading = document.createElement("h1"); + modal.append(modalHeading); + modalParent.append(modal); + mount.append(modalParent); + const modalBefore = modal.headingReset; + modal.showModal(); + const modalState = [modalBefore, modal.headingReset, modalHeading.matches(":heading(1)")]; + modal.close(); + + return JSON.stringify({ + owner: [ + Object.prototype.hasOwnProperty.call(Element.prototype, "headingOffset"), + Object.prototype.hasOwnProperty.call(HTMLElement.prototype, "headingOffset"), + descriptor("headingOffset").enumerable, + descriptor("headingReset").enumerable + ], + initial, + parentOffset, + reset, + clamped, + slotted: slotted.matches(":heading(4)"), + modalState, + badGetter: outcome(() => descriptor("headingOffset").get.call({})), + badSetter: outcome(() => descriptor("headingReset").set.call({}, true)) + }); + })() + "#, + ) + .expect("heading reflection and selector probe should evaluate"); + + assert_eq!( + result, + r#"{"owner":[true,false,true,true],"initial":[0,false,true],"parentOffset":["3",true],"reset":[true,true],"clamped":["20",8,true],"slotted":true,"modalState":[false,true,true],"badGetter":"throw:TypeError","badSetter":"throw:TypeError"}"# + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/content_and_invalidation.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/content_and_invalidation.rs index 8be55835f2..de95ada7ab 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/content_and_invalidation.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/content_and_invalidation.rs @@ -2010,3 +2010,51 @@ fn focus_dependent_selector_invalidates_computed_style_cache() { "targeted focus invalidation should not bump the retained style generation" ); } + +#[test] +fn heading_offset_and_modal_reset_invalidate_live_computed_styles() { + let mut vm = new_parsed_test_vm( + "https://heading-state-invalidation.test/", + r#" + +

+

+"#, + ); + + let result = vm + .eval( + r#" +(() => { + const parent = document.getElementById('parent'); + const target = document.getElementById('target'); + const modal = document.getElementById('modal'); + const modalHeading = document.getElementById('modal-heading'); + const targetStyle = getComputedStyle(target); + const modalStyle = getComputedStyle(modalHeading); + const values = [targetStyle.color, modalStyle.color]; + + parent.headingOffset = 3; + values.push(targetStyle.color); + target.headingReset = true; + values.push(targetStyle.color); + + modal.showModal(); + values.push(modalStyle.color); + modal.close(); + values.push(modalStyle.color); + return values.join('|'); +})() +"#, + ) + .expect("heading state invalidation probe should evaluate"); + + assert_eq!( + result, + "rgb(1, 2, 3)|rgb(9, 10, 11)|rgb(4, 5, 6)|rgb(1, 2, 3)|rgb(1, 2, 3)|rgb(9, 10, 11)" + ); +} diff --git a/moli-selector/src/lib.rs b/moli-selector/src/lib.rs index 65921821ef..1d0ce2d61c 100644 --- a/moli-selector/src/lib.rs +++ b/moli-selector/src/lib.rs @@ -59,8 +59,9 @@ pub use stylo::{ StyloStylesheetSourceScopeFallbackInput, is_svg_presentation_attribute_name, stylo_attribute_change_can_skip_fallback_without_dependency, stylo_attribute_change_can_use_retained_invalidator, stylo_element_dependency_snapshot, - stylo_fallback_roots_plan, stylo_focus_change_invalidation_roots, - stylo_focus_state_matches_handle, stylo_focus_within_state_matches_handle, + stylo_fallback_roots_plan, stylo_flat_tree_heading_descendants, + stylo_focus_change_invalidation_roots, stylo_focus_state_matches_handle, + stylo_focus_within_state_matches_handle, stylo_merge_retained_source_invalidation_fallback_kind, stylo_merge_retained_source_invalidation_kind, stylo_merge_source_dependency_request_requirement, stylo_removed_element_dependency_snapshots, @@ -773,10 +774,33 @@ mod tests { let ancestor = host.create_element("section"); let nested_h1 = host.create_element("h1"); let subject = host.create_element("div"); + let offset_parent = host.create_element("section"); + let offset_h1 = host.create_element("h1"); + let reset_boundary = host.create_element("section"); + let nested_offset = host.create_element("div"); + let reset_h1 = host.create_element("h1"); + let shadow_host = host.create_element("section"); + let shadow_h1 = host.create_element("h1"); + let slot_container = host.create_element("div"); + let slot = host.create_element("slot"); + let slotted_h2 = host.create_element("h2"); + let modal_parent = host.create_element("div"); + let modal = host.create_element("dialog"); + let modal_h1 = host.create_element("h1"); + let aria_h1 = host.create_element("h1"); assert!(host.set_attribute(role_heading, "role", "heading")); assert!(host.set_attribute(role_heading, "aria-level", "1")); assert!(host.set_attribute(ancestor, "id", "ancestor")); assert!(host.set_attribute(subject, "id", "subject")); + assert!(host.set_attribute(offset_parent, "headingoffset", "3")); + assert!(host.set_attribute(reset_boundary, "headingoffset", "2")); + assert!(host.set_attribute(reset_boundary, "headingreset", "")); + assert!(host.set_attribute(nested_offset, "headingoffset", "2")); + assert!(host.set_attribute(shadow_host, "headingoffset", "1")); + assert!(host.set_attribute(slot_container, "headingoffset", "1")); + assert!(host.set_attribute(modal_parent, "headingoffset", "8")); + assert!(host.set_attribute(aria_h1, "headingoffset", "8")); + assert!(host.set_attribute(aria_h1, "aria-level", "3")); assert!(host.append_child(body, h1)); assert!(host.append_child(body, h2)); assert!(host.append_child(body, h7)); @@ -784,6 +808,22 @@ mod tests { assert!(host.append_child(body, ancestor)); assert!(host.append_child(ancestor, nested_h1)); assert!(host.append_child(body, subject)); + assert!(host.append_child(body, offset_parent)); + assert!(host.append_child(offset_parent, offset_h1)); + assert!(host.append_child(body, reset_boundary)); + assert!(host.append_child(reset_boundary, nested_offset)); + assert!(host.append_child(nested_offset, reset_h1)); + assert!(host.append_child(body, shadow_host)); + let shadow_root = host.attach_shadow_root(shadow_host, "open").unwrap(); + assert!(host.append_child(shadow_root, shadow_h1)); + assert!(host.append_child(shadow_root, slot_container)); + assert!(host.append_child(slot_container, slot)); + assert!(host.append_child(shadow_host, slotted_h2)); + assert!(host.append_child(body, modal_parent)); + assert!(host.append_child(modal_parent, modal)); + assert!(host.append_child(modal, modal_h1)); + assert!(host.set_dialog_modal(modal, true)); + assert!(host.append_child(body, aria_h1)); let engine = QueryEngine; assert!(engine.matches_host(&host, h1, ":heading").unwrap()); @@ -809,6 +849,30 @@ mod tests { .matches_host(&host, subject, "#ancestor:has(:heading(1)) ~ #subject") .unwrap() ); + assert!( + engine + .matches_host(&host, offset_h1, ":heading(4)") + .unwrap() + ); + assert!(host.set_attribute(offset_parent, "headingoffset", "5")); + assert!( + engine + .matches_host(&host, offset_h1, ":heading(6)") + .unwrap() + ); + assert!(engine.matches_host(&host, reset_h1, ":heading(5)").unwrap()); + assert!( + engine + .matches_host(&host, shadow_h1, ":heading(2)") + .unwrap() + ); + assert!( + engine + .matches_host(&host, slotted_h2, ":heading(4)") + .unwrap() + ); + assert!(engine.matches_host(&host, modal_h1, ":heading(1)").unwrap()); + assert!(engine.matches_host(&host, aria_h1, ":heading(9)").unwrap()); assert!(engine.query_selector_host(&host, ":heading()").is_err()); assert!(engine.query_selector_host(&host, ":heading(2n)").is_err()); } diff --git a/moli-selector/src/stylo.rs b/moli-selector/src/stylo.rs index f22ed1e4f0..31d5b86d65 100644 --- a/moli-selector/src/stylo.rs +++ b/moli-selector/src/stylo.rs @@ -12,6 +12,15 @@ use crate::{ selector::SelectorError, }; +/// Returns heading elements below `root` in flat-tree order. +/// +/// Renderer state invalidation uses the same traversal as `:heading()` so a +/// `headingoffset` or `headingreset` mutation cannot leave computed styles on +/// shadow/slotted descendants stale. +pub fn stylo_flat_tree_heading_descendants(host: &DomHost, root: NodeId) -> Vec { + query::flat_tree_heading_descendants(host, root) +} + mod atoms; mod invalidation; mod presentation; diff --git a/moli-selector/src/stylo/invalidation.rs b/moli-selector/src/stylo/invalidation.rs index f4e1cb42f4..28b88c6b6e 100644 --- a/moli-selector/src/stylo/invalidation.rs +++ b/moli-selector/src/stylo/invalidation.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; -use dom::{ElementState, HEADING_LEVEL_OFFSET}; +use dom::ElementState; use indexmap::{IndexMap, IndexSet}; use selectors::{ OpaqueElement, @@ -72,7 +72,10 @@ use crate::{ NodeId, native::{ConnectedShadowRootSnapshot, DomHost, Element}, }, - stylo::style_traversal::{StyleDomHostBinding, StyleElement}, + stylo::{ + query::heading_state_for_element, + style_traversal::{StyleDomHostBinding, StyleElement}, + }, }; pub use style::moli_invalidation::{ @@ -559,7 +562,7 @@ pub fn stylo_element_dependency_snapshot( Some(StyloElementDependencySnapshot::new( handle, element.local_name().to_owned(), - stylo_retained_dependency_state_for_element(element), + stylo_retained_dependency_state_for_element(host, handle, element), element .attributes() .iter() @@ -1564,22 +1567,12 @@ fn ascii_whitespace_tokens(value: &str) -> Vec { tokens } -fn stylo_retained_dependency_state_for_element(element: &Element) -> ElementState { - let mut state = ElementState::empty(); - const HEADING_NAMES: [(&str, u64); 6] = [ - ("h1", 1), - ("h2", 2), - ("h3", 3), - ("h4", 4), - ("h5", 5), - ("h6", 6), - ]; - if let Some(level) = HEADING_NAMES - .iter() - .find_map(|(name, level)| element.is_html_element(name).then_some(*level)) - { - state |= ElementState::from_bits_retain(level << HEADING_LEVEL_OFFSET); - } +fn stylo_retained_dependency_state_for_element( + host: &DomHost, + handle: NodeId, + element: &Element, +) -> ElementState { + let mut state = heading_state_for_element(host, handle); if element.is_html_media() { state |= ElementState::PAUSED; if element.media_muted() { diff --git a/moli-selector/src/stylo/query.rs b/moli-selector/src/stylo/query.rs index c3123134a5..51cedfa828 100644 --- a/moli-selector/src/stylo/query.rs +++ b/moli-selector/src/stylo/query.rs @@ -18,7 +18,9 @@ mod element; // This logic is intentionally split from `element` because it changes with web // platform semantics, while the element adapter should stay mostly structural. mod pseudo; -pub(crate) use pseudo::html_directionality; +pub(crate) use pseudo::{ + flat_tree_heading_descendants, heading_state_for_element, html_directionality, +}; use style::{ context::QuirksMode, diff --git a/moli-selector/src/stylo/query/pseudo.rs b/moli-selector/src/stylo/query/pseudo.rs index ce304c473d..bc2f475aa8 100644 --- a/moli-selector/src/stylo/query/pseudo.rs +++ b/moli-selector/src/stylo/query/pseudo.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use crate::{CssDirection, first_strong_text_direction}; use dom::{ElementState, HEADING_LEVEL_OFFSET}; @@ -102,19 +104,7 @@ impl<'a> QueryElement<'a> { } pub(super) fn heading_state(self) -> ElementState { - const HEADING_NAMES: [(&str, u64); 6] = [ - ("h1", 1), - ("h2", 2), - ("h3", 3), - ("h4", 4), - ("h5", 5), - ("h6", 6), - ]; - HEADING_NAMES - .iter() - .find_map(|(name, level)| self.element().is_html_element(name).then_some(*level)) - .map(|level| ElementState::from_bits_retain(level << HEADING_LEVEL_OFFSET)) - .unwrap_or_else(ElementState::empty) + heading_state_for_element(self.host, self.handle) } pub(in crate::stylo) fn resolved_direction(self) -> CssDirection { @@ -597,6 +587,107 @@ impl<'a> QueryElement<'a> { } } +pub(crate) fn heading_state_for_element(host: &DomHost, handle: NodeId) -> ElementState { + let Some(base_level) = host + .node(handle) + .and_then(Node::as_element) + .and_then(heading_base_level) + else { + return ElementState::empty(); + }; + + let mut offset = 0_u32; + let mut current = Some(handle); + let mut visited = HashSet::new(); + while let Some(candidate) = current { + if !visited.insert(candidate) { + break; + } + if let Some(element) = host.node(candidate).and_then(Node::as_element) + && element.namespace() == "http://www.w3.org/1999/xhtml" + { + offset = offset.saturating_add(element.heading_offset()); + if element.heading_reset() { + break; + } + } + current = flat_tree_parent(host, candidate); + } + + let level = base_level.saturating_add(offset).min(9) as u64; + ElementState::from_bits_retain(level << HEADING_LEVEL_OFFSET) +} + +pub(crate) fn flat_tree_heading_descendants(host: &DomHost, root: NodeId) -> Vec { + let mut stack = flat_tree_children(host, root); + stack.reverse(); + let mut seen = HashSet::new(); + let mut headings = Vec::new(); + while let Some(candidate) = stack.pop() { + if !seen.insert(candidate) { + continue; + } + if host + .node(candidate) + .and_then(Node::as_element) + .and_then(heading_base_level) + .is_some() + { + headings.push(candidate); + } + let mut children = flat_tree_children(host, candidate); + children.reverse(); + stack.extend(children); + } + headings +} + +fn heading_base_level(element: &Element) -> Option { + ["h1", "h2", "h3", "h4", "h5", "h6"] + .iter() + .position(|name| element.is_html_element(name)) + .map(|index| index as u32 + 1) +} + +fn flat_tree_parent(host: &DomHost, handle: NodeId) -> Option { + if let Some(slot) = host.assigned_slot_for_node(handle) { + return Some(slot); + } + + let parent = host.node(handle).and_then(Node::parent_node)?; + if host.is_shadow_root(parent) { + return host.shadow_root_host(parent); + } + if host.is_html_element_named(parent, "slot") + && !host + .assigned_nodes_for_slot_with_options(parent, false) + .is_empty() + { + return None; + } + if host.shadow_root_handle(parent).is_some() + && host + .node(handle) + .is_some_and(|node| node.is_element() || node.is_text()) + { + return None; + } + Some(parent) +} + +fn flat_tree_children(host: &DomHost, handle: NodeId) -> Vec { + if host.is_html_element_named(handle, "slot") { + let assigned = host.assigned_nodes_for_slot_with_options(handle, false); + if !assigned.is_empty() { + return assigned; + } + } + if let Some(shadow_root) = host.shadow_root_handle(handle) { + return host.child_handles(shadow_root).collect(); + } + host.child_handles(handle).collect() +} + pub(crate) fn html_directionality(host: &DomHost, handle: NodeId) -> CssDirection { let mut current = Some(handle); while let Some(handle) = current {