fix(html): update auto directionality after mutations

This commit is contained in:
ldm0
2026-09-27 19:22:01 +08:00
parent 74d4e31ca0
commit 2ff201cfcf
7 changed files with 412 additions and 5 deletions
@@ -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");
}
@@ -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<DomHandle> {
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<DomHandle> {
host.owner_document_handle(handle)
}
@@ -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();
+58 -2
View File
@@ -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<NodeId> {
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]
+1 -1
View File
@@ -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::{
+2 -1
View File
@@ -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::{
+37
View File
@@ -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<NodeId> {
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<CssDirection> {
if let Some(element) = host.node(root).and_then(Node::as_element) {
if element.is_html_textarea() {