From 2ff201cfcfdbd7338fbb6f82dbea411d05cd4758 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 16 Jul 2026 01:22:42 +0800 Subject: [PATCH] fix(html): update auto directionality after mutations --- .../cross_document_and_animations.rs | 76 +++++++++ .../src/style_engine/runtime_invalidation.rs | 93 ++++++++++- .../src/style_engine/tests/char_child.rs | 146 ++++++++++++++++++ moli-selector/src/lib.rs | 60 ++++++- moli-selector/src/stylo.rs | 2 +- moli-selector/src/stylo/query.rs | 3 +- moli-selector/src/stylo/query/pseudo.rs | 37 +++++ 7 files changed, 412 insertions(+), 5 deletions(-) diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/cross_document_and_animations.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/cross_document_and_animations.rs index 652d89ae3..525a09948 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/cross_document_and_animations.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/cross_document_and_animations.rs @@ -2092,3 +2092,79 @@ fn slotted_nodes_inherit_css_direction_from_slot_without_changing_html_direction assert_eq!(result, "true:rtl:rgb(1, 2, 3)|true:ltr:ltr"); } + +#[test] +fn computed_direction_tracks_textarea_auto_value() { + let mut vm = new_storage_test_vm("https://textarea-auto-direction.test/"); + + let result = vm + .eval( + r#" +(() => { + if (!document.documentElement) { + document.appendChild(document.createElement('html')); + } + if (!document.body) { + document.documentElement.appendChild(document.createElement('body')); + } + + const textarea = document.createElement('textarea'); + textarea.dir = 'auto'; + document.body.appendChild(textarea); + const empty = `${textarea.matches(':dir(ltr)')}:${getComputedStyle(textarea).direction}`; + textarea.value = '\u05ea'; + const rtl = `${textarea.matches(':dir(rtl)')}:${getComputedStyle(textarea).direction}`; + textarea.value = 'A'; + const ltr = `${textarea.matches(':dir(ltr)')}:${getComputedStyle(textarea).direction}`; + + return `${empty}|${rtl}|${ltr}`; +})() +"#, + ) + .expect("textarea dir=auto value direction should evaluate"); + + assert_eq!(result, "true:ltr|true:rtl|true:ltr"); +} + +#[test] +fn computed_direction_tracks_dir_auto_tree_mutations() { + let mut vm = new_storage_test_vm("https://dir-auto-tree-mutation.test/"); + + let result = vm + .eval( + r#" +(() => { + if (!document.documentElement) { + document.appendChild(document.createElement('html')); + } + if (!document.head) { + document.documentElement.appendChild(document.createElement('head')); + } + if (!document.body) { + document.documentElement.appendChild(document.createElement('body')); + } + document.head.appendChild(document.createElement('style')).textContent = + '#source:dir(rtl) + #target { display: none; }'; + + const source = document.createElement('div'); + source.id = 'source'; + source.dir = 'auto'; + const target = document.createElement('div'); + target.id = 'target'; + document.body.append(source, target); + + const before = `${getComputedStyle(source).direction}:${getComputedStyle(target).display}`; + const text = document.createTextNode('\u0627\u062e\u062a\u0628\u0631'); + source.appendChild(text); + const afterAppend = `${getComputedStyle(source).direction}:${getComputedStyle(target).display}`; + text.data = 'A'; + const afterText = `${getComputedStyle(source).direction}:${getComputedStyle(target).display}`; + + return `${before}|${afterAppend}|${afterText}`; +})() +"#, + ) + .expect("dir=auto tree mutation direction should evaluate"); + + assert_eq!(result, "ltr:block|rtl:none|ltr:block"); +} diff --git a/moli-renderer-v8/src/style_engine/runtime_invalidation.rs b/moli-renderer-v8/src/style_engine/runtime_invalidation.rs index 43ff0e2a7..afdc13e50 100644 --- a/moli-renderer-v8/src/style_engine/runtime_invalidation.rs +++ b/moli-renderer-v8/src/style_engine/runtime_invalidation.rs @@ -1,7 +1,10 @@ use std::{collections::HashMap, sync::Arc}; use dom::ElementState as StyloElementState; -use moli_selector::StyloStyleSourceScope as StyleSourceScope; +use indexmap::IndexSet; +use moli_selector::{ + StyloStyleSourceScope as StyleSourceScope, html_auto_directionality_invalidation_root, +}; use crate::{ document_runtime::DomHandle, dom::native::DomHost, protocol_types::EmulatedMediaOverrides, @@ -51,6 +54,12 @@ impl MoliStyleEngine { emulated_media: &EmulatedMediaOverrides, viewport: StyleViewport, ) { + self.invalidate_html_auto_directionality_for_mutations( + host, + effects, + emulated_media, + viewport, + ); let profile_enabled = moli_trace::cpu_profile_enabled(); let total_started = profile_enabled.then(std::time::Instant::now); let grouping_started = profile_enabled.then(std::time::Instant::now); @@ -126,6 +135,44 @@ impl MoliStyleEngine { } } + fn invalidate_html_auto_directionality_for_mutations( + &mut self, + host: &DomHost, + effects: &[StyleMutationEffect], + emulated_media: &EmulatedMediaOverrides, + viewport: StyleViewport, + ) { + for root in html_auto_directionality_roots_for_mutations(host, effects) { + let Some(document) = owner_document_for_handle(host, root) else { + continue; + }; + let world = self.world_for_document(document); + if !world_has_style_state(self, &world) { + continue; + } + // Tree and character-data mutation effects do not retain the old + // resolved direction. Present the retained invalidator with the + // conservative opposite direction so it checks both sides of + // :dir() dependencies; the scoped cache cleanup below separately + // rebuilds the HTML directionality presentation hint. + let old_state = self + .retained_current_element_state(host, root) + .map(synthetic_opposite_directionality_state); + if let Some(old_state) = old_state { + self.invalidate_for_element_state_change_with_old_state_and_viewport( + host, + root, + StyloElementState::LTR | StyloElementState::RTL, + Some(old_state), + emulated_media, + viewport, + ); + } + self.invalidation_cleanup_for_world(&world) + .invalidate_subtrees(host, [root]); + } + } + fn style_invalidation_without_cached_styles_is_complete( &self, effects: &[StyleMutationEffect], @@ -702,6 +749,50 @@ fn owner_document_for_mutation_effect( } } +fn html_auto_directionality_roots_for_mutations( + host: &DomHost, + effects: &[StyleMutationEffect], +) -> IndexSet { + let mut roots = IndexSet::new(); + let mut record_root = |start| { + if let Some(root) = html_auto_directionality_invalidation_root(host, start) { + roots.insert(root); + } + }; + for effect in effects { + match effect { + StyleMutationEffect::ChildList { parent, .. } => record_root(*parent), + StyleMutationEffect::CharacterData { node } => { + if let Some(parent) = host.parent_node(*node) { + record_root(parent); + } + } + StyleMutationEffect::SlotAssignment { slot, .. } => record_root(*slot), + StyleMutationEffect::Attribute { element, name, .. } if name == "dir" => { + record_root(*element); + if let Some(parent) = host.parent_node(*element) { + record_root(parent); + } + } + StyleMutationEffect::Attribute { .. } + | StyleMutationEffect::ConnectedSubtrees { .. } + | StyleMutationEffect::DisconnectedSubtrees { .. } => {} + } + } + roots +} + +fn synthetic_opposite_directionality_state(mut state: StyloElementState) -> StyloElementState { + let opposite = if state.contains(StyloElementState::RTL) { + StyloElementState::LTR + } else { + StyloElementState::RTL + }; + state.remove(StyloElementState::LTR | StyloElementState::RTL); + state.insert(opposite); + state +} + fn owner_document_for_handle(host: &DomHost, handle: DomHandle) -> Option { host.owner_document_handle(handle) } diff --git a/moli-renderer-v8/src/style_engine/tests/char_child.rs b/moli-renderer-v8/src/style_engine/tests/char_child.rs index 7e3d53576..12a8c7829 100644 --- a/moli-renderer-v8/src/style_engine/tests/char_child.rs +++ b/moli-renderer-v8/src/style_engine/tests/char_child.rs @@ -1,5 +1,151 @@ use super::*; +#[test] +fn child_list_under_dir_auto_invalidates_directionality_subtree() { + let mut host = test_host(); + let document = host.document_handle(); + let auto = host.create_element("div"); + let descendant = host.create_element("span"); + let unrelated = host.create_element("aside"); + + assert!(host.set_attribute(auto, "dir", "auto")); + assert!(host.append_child(document, auto)); + assert!(host.append_child(auto, descendant)); + assert!(host.append_child(document, unrelated)); + + let mut engine = MoliStyleEngine::new(); + let document_url = url::Url::parse("https://example.test/").unwrap(); + let inputs = FullStyleWorldSnapshot::default(); + for handle in [auto, descendant, unrelated] { + assert_eq!( + engine.computed_style_property_value( + &host, + &document_url, + handle, + "direction", + None, + &inputs, + None, + ), + Some("ltr".into()) + ); + } + + let text = host.create_text_node("\u{0627}\u{062e}\u{062a}\u{0628}\u{0631}"); + assert!(host.append_child(descendant, text)); + let media = crate::protocol_types::EmulatedMediaOverrides::default(); + engine.invalidate_for_mutations( + &host, + &[StyleMutationEffect::ChildList { + parent: descendant, + added_nodes: vec![text], + removed_nodes: Vec::new(), + removed_element_snapshots: Vec::new(), + previous_sibling: None, + next_sibling: None, + }], + &media, + ); + + assert!(!engine.computed_style_cache_contains_handle_for_document_for_test(document, auto)); + assert!( + engine.computed_style_cache_contains_handle_for_document_for_test(document, descendant), + "lazy subtree invalidation keeps descendants published until observation" + ); + assert!( + engine.retained_style_invalidation_root_count_for_document_for_test(document) > 0, + "directionality invalidation must retain a lazy subtree root" + ); + assert!(engine.computed_style_cache_contains_handle_for_document_for_test(document, unrelated)); + assert_eq!( + engine.computed_style_property_value( + &host, + &document_url, + auto, + "direction", + None, + &inputs, + None, + ), + Some("rtl".into()) + ); + assert_eq!( + engine.computed_style_property_value( + &host, + &document_url, + descendant, + "direction", + None, + &inputs, + None, + ), + Some("rtl".into()) + ); +} + +#[test] +fn character_data_under_dir_auto_invalidates_dir_pseudo_sibling_dependency() { + let mut host = test_host(); + let document = host.document_handle(); + let auto = host.create_element("div"); + let text = host.create_text_node("A"); + let target = host.create_element("div"); + + assert!(host.set_attribute(auto, "id", "source")); + assert!(host.set_attribute(auto, "dir", "auto")); + assert!(host.set_attribute(target, "id", "target")); + assert!(host.append_child(document, auto)); + assert!(host.append_child(auto, text)); + assert!(host.append_child(document, target)); + + let mut engine = MoliStyleEngine::new(); + let document_url = url::Url::parse("https://example.test/").unwrap(); + let source = StyloStylesheetSource::new( + "#source:dir(rtl) + #target { display: none; }".into(), + document_url.clone(), + ); + engine.set_document_adopted_style_sheet_sources(document, vec![source.clone()]); + let mut inputs = FullStyleWorldSnapshot::default(); + inputs.document_stylesheet_sources.push(source); + assert_eq!( + engine.computed_style_property_value( + &host, + &document_url, + target, + "display", + None, + &inputs, + None, + ), + Some("block".into()) + ); + + host.node_mut(text) + .and_then(|node| node.data_mut().as_text_mut()) + .expect("text node") + .set_data("\u{0627}\u{062e}\u{062a}\u{0628}\u{0631}"); + let media = crate::protocol_types::EmulatedMediaOverrides::default(); + engine.invalidate_for_mutations( + &host, + &[StyleMutationEffect::CharacterData { node: text }], + &media, + ); + engine.drain_pending_style_invalidations_for_document_for_test(&host, document); + + assert_eq!( + engine.computed_style_property_value( + &host, + &document_url, + target, + "display", + None, + &inputs, + None, + ), + Some("none".into()) + ); +} + #[test] fn character_data_without_structural_selector_preserves_cache_entries() { let mut host = test_host(); diff --git a/moli-selector/src/lib.rs b/moli-selector/src/lib.rs index 245fc4720..b8a21d3c2 100644 --- a/moli-selector/src/lib.rs +++ b/moli-selector/src/lib.rs @@ -279,6 +279,10 @@ pub fn html_directionality(host: &DomHost, handle: NodeId) -> CssDirection { stylo::html_directionality(host, handle) } +pub fn html_auto_directionality_invalidation_root(host: &DomHost, start: NodeId) -> Option { + stylo::html_auto_directionality_invalidation_root(host, start) +} + pub fn validate_supports_selector_condition_argument(selector: &str) -> Result<(), SelectorError> { stylo::validate_supports_selector_condition_argument(selector) } @@ -374,8 +378,9 @@ fn serialize_cssom_style_rule_selector_text( mod tests { use super::{ QueryEngine, StyleRuleNamespaceContext, StyleRuleSelectorContext, StyloDomStyleAdapter, - canonicalize_cssom_style_rule_selector_text, normalize_scope_end_selector_list, - normalize_scope_selector_list, validate_supports_selector_condition_argument, + canonicalize_cssom_style_rule_selector_text, html_auto_directionality_invalidation_root, + normalize_scope_end_selector_list, normalize_scope_selector_list, + validate_supports_selector_condition_argument, }; use crate::dom::native::{DomHost, NativeDom}; use crate::stylo::{ @@ -1551,6 +1556,46 @@ mod tests { assert!(engine.matches_host(&host, parent, ":dir(rtl)").unwrap()); } + #[test] + fn dir_auto_mutation_root_stops_at_contained_text_boundaries() { + let url = url::Url::parse("https://example.test/").unwrap(); + let mut host = DomHost::from_dom(NativeDom::new_html(url)); + host.reset_html_document_shell(); + let body = host.document_body_handle().unwrap(); + + let auto = host.create_element("div"); + let transparent = host.create_element("span"); + let explicit = host.create_element("section"); + let explicit_child = host.create_element("span"); + let bdi = host.create_element("bdi"); + let script = host.create_element("script"); + assert!(host.set_attribute(auto, "dir", "auto")); + assert!(host.set_attribute(explicit, "dir", "rtl")); + assert!(host.append_child(body, auto)); + assert!(host.append_child(auto, transparent)); + assert!(host.append_child(auto, explicit)); + assert!(host.append_child(explicit, explicit_child)); + assert!(host.append_child(auto, bdi)); + assert!(host.append_child(auto, script)); + + assert_eq!( + html_auto_directionality_invalidation_root(&host, transparent), + Some(auto) + ); + assert_eq!( + html_auto_directionality_invalidation_root(&host, explicit_child), + None + ); + assert_eq!( + html_auto_directionality_invalidation_root(&host, bdi), + Some(bdi) + ); + assert_eq!( + html_auto_directionality_invalidation_root(&host, script), + None + ); + } + #[test] fn dom_api_selectors_dir_auto_tracks_clean_and_dirty_textarea_values() { let url = url::Url::parse("https://example.test/").unwrap(); @@ -1628,6 +1673,17 @@ mod tests { assert!(host.set_attribute(auto, "type", "hidden")); assert!(!engine.matches_host(&host, auto, ":dir(ltr)").unwrap()); assert!(engine.matches_host(&host, auto, ":dir(rtl)").unwrap()); + + let textarea = host.create_element("textarea"); + assert!(host.set_attribute(textarea, "dir", "auto")); + assert!(host.set_input_value(textarea, "\u{05ea}")); + assert!(host.append_child(body, textarea)); + assert!(!engine.matches_host(&host, textarea, ":dir(ltr)").unwrap()); + assert!(engine.matches_host(&host, textarea, ":dir(rtl)").unwrap()); + + assert!(host.set_input_value(textarea, "A")); + assert!(engine.matches_host(&host, textarea, ":dir(ltr)").unwrap()); + assert!(!engine.matches_host(&host, textarea, ":dir(rtl)").unwrap()); } #[test] diff --git a/moli-selector/src/stylo.rs b/moli-selector/src/stylo.rs index 31d5b86d6..3074300cf 100644 --- a/moli-selector/src/stylo.rs +++ b/moli-selector/src/stylo.rs @@ -86,8 +86,8 @@ pub use invalidation::{ stylo_stylesheet_source_scope_fallback_roots, }; pub use presentation::is_svg_presentation_attribute_name; -pub(crate) use query::html_directionality; use query::{QueryDocument, QueryElement, QueryNode}; +pub(crate) use query::{html_auto_directionality_invalidation_root, html_directionality}; #[cfg(test)] pub(crate) use selector_parse::validate_supports_selector_list; pub(crate) use selector_parse::{ diff --git a/moli-selector/src/stylo/query.rs b/moli-selector/src/stylo/query.rs index 51cedfa82..b49e98021 100644 --- a/moli-selector/src/stylo/query.rs +++ b/moli-selector/src/stylo/query.rs @@ -19,7 +19,8 @@ mod element; // platform semantics, while the element adapter should stay mostly structural. mod pseudo; pub(crate) use pseudo::{ - flat_tree_heading_descendants, heading_state_for_element, html_directionality, + flat_tree_heading_descendants, heading_state_for_element, + html_auto_directionality_invalidation_root, html_directionality, }; use style::{ diff --git a/moli-selector/src/stylo/query/pseudo.rs b/moli-selector/src/stylo/query/pseudo.rs index b441eee3c..0c6fa9d29 100644 --- a/moli-selector/src/stylo/query/pseudo.rs +++ b/moli-selector/src/stylo/query/pseudo.rs @@ -714,6 +714,43 @@ pub(crate) fn html_directionality(host: &DomHost, handle: NodeId) -> CssDirectio CssDirection::Ltr } +/// Return the nearest auto-directionality element whose resolved direction may +/// depend on a mutation below `start`. +/// +/// Contained-text auto directionality stops at the same HTML boundaries, so a +/// mutation below an explicit `dir`, `bdi`, `script`, `style`, or `textarea` +/// element must not invalidate an outer `dir=auto` element. +pub(crate) fn html_auto_directionality_invalidation_root( + host: &DomHost, + start: NodeId, +) -> Option { + let mut current = Some(start); + while let Some(handle) = current { + let node = host.node(handle)?; + if let Some(element) = node.as_element() + && element.namespace() == "http://www.w3.org/1999/xhtml" + { + let dir = element.attribute("dir"); + let has_auto_direction = dir.is_some_and(|value| value.eq_ignore_ascii_case("auto")) + || (element.is_html_element("bdi") + && !dir.is_some_and(|value| { + normalized_direction(value).is_some() || value.eq_ignore_ascii_case("auto") + })); + if has_auto_direction { + return Some(handle); + } + if element.is_html_element("bdi") + || dir.and_then(normalized_direction).is_some() + || matches!(element.local_name(), "script" | "style" | "textarea") + { + return None; + } + } + current = node.parent_node(); + } + None +} + fn auto_direction_for_element(host: &DomHost, root: NodeId) -> Option { if let Some(element) = host.node(root).and_then(Node::as_element) { if element.is_html_textarea() {