diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index fa37a0b5a8..6726311a8c 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -2910,8 +2910,6 @@ html/browsers/windows/nested-browsing-contexts/window-top-null.html html/browsers/windows/noreferrer-window-name.html html/cross-origin-embedder-policy/coep-on-response-from-service-worker.https.html html/cross-origin-embedder-policy/service-worker-cache-storage.https.html -html/dom/aria-element-reflection-disconnected.html -html/dom/aria-element-reflection.html html/dom/documents/dom-tree-accessors/nameditem-01.html html/dom/documents/dom-tree-accessors/nameditem-02.html html/dom/documents/dom-tree-accessors/nameditem-04.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 7c4384665b..a1ba162e1d 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -5380,6 +5380,8 @@ html/cross-origin-embedder-policy/no-secure-context.html html/cross-origin-opener-policy/no-https.html html/dom/access-key-label.html html/dom/aria-attribute-reflection.html +html/dom/aria-element-reflection-disconnected.html +html/dom/aria-element-reflection.html html/dom/documents/dom-tree-accessors/Document.body.html html/dom/documents/dom-tree-accessors/Document.getElementsByClassName-null-undef.html html/dom/documents/dom-tree-accessors/Element.getElementsByClassName-null-undef.html diff --git a/moli-dom/src/native/element/control_state.rs b/moli-dom/src/native/element/control_state.rs index d56a320ee7..3dc7b210f9 100644 --- a/moli-dom/src/native/element/control_state.rs +++ b/moli-dom/src/native/element/control_state.rs @@ -1,7 +1,9 @@ use super::Attribute; use crate::forms::{InputValueSanitizationContext, sanitize_input_value_for_type_with_context}; +use crate::native::NativeNodeId; use indexmap::IndexSet; use moli_html_input_type::InputType; +use std::collections::HashMap; #[derive(Debug, Clone, PartialEq)] pub struct SelectedFile { @@ -123,6 +125,11 @@ impl ScriptElementState { } } +#[derive(Debug, Clone, Default)] +struct ExplicitAriaElementReferenceState { + references_by_attribute: HashMap>, +} + #[derive(Debug, Clone, Default)] pub struct ElementControlState { input_value: Option, @@ -164,9 +171,39 @@ pub struct ElementControlState { dialog_modal: bool, dialog_return_value: String, custom_states: IndexSet, + explicit_aria_element_references: Option>, } impl ElementControlState { + pub fn explicit_aria_element_references(&self, attribute: &str) -> Option<&[NativeNodeId]> { + self.explicit_aria_element_references + .as_deref()? + .references_by_attribute + .get(attribute) + .map(Vec::as_slice) + } + + pub fn set_explicit_aria_element_references( + &mut self, + attribute: &str, + references: Vec, + ) { + self.explicit_aria_element_references + .get_or_insert_with(Default::default) + .references_by_attribute + .insert(attribute.to_owned(), references); + } + + pub fn clear_explicit_aria_element_references(&mut self, attribute: &str) { + let Some(references) = self.explicit_aria_element_references.as_mut() else { + return; + }; + references.references_by_attribute.remove(attribute); + if references.references_by_attribute.is_empty() { + self.explicit_aria_element_references = None; + } + } + pub fn from_element_parts( namespace: &str, local_name: &str, diff --git a/moli-dom/src/native/element/mod.rs b/moli-dom/src/native/element/mod.rs index a24fbea99e..69c5339f72 100644 --- a/moli-dom/src/native/element/mod.rs +++ b/moli-dom/src/native/element/mod.rs @@ -59,6 +59,20 @@ fn initial_custom_element_state_for_identity( } } +fn is_aria_element_reference_attribute(name: &str) -> bool { + matches!( + name, + "aria-activedescendant" + | "aria-controls" + | "aria-describedby" + | "aria-details" + | "aria-errormessage" + | "aria-flowto" + | "aria-labelledby" + | "aria-owns" + ) +} + #[derive(Debug, Clone)] pub struct Element { local_name: LocalName, @@ -167,6 +181,27 @@ impl Element { self.prefix.as_ref().map(AsRef::as_ref) } + pub fn explicit_aria_element_references(&self, attribute: &str) -> Option<&[NativeNodeId]> { + self.control_state() + .explicit_aria_element_references(attribute) + } + + pub fn set_explicit_aria_element_references( + &mut self, + attribute: &str, + references: Vec, + ) { + self.control_state_mut() + .set_explicit_aria_element_references(attribute, references); + } + + fn synchronize_aria_element_reference_attribute(&mut self, namespace: &str, local_name: &str) { + if namespace.is_empty() && is_aria_element_reference_attribute(local_name) { + self.rare_data + .clear_explicit_aria_element_references(local_name); + } + } + pub fn set_prefix(&mut self, prefix: Option) -> bool { if self.prefix.as_deref() == prefix.as_deref() { return false; @@ -1205,6 +1240,7 @@ impl Element { prefix: Option, value: String, ) -> bool { + self.synchronize_aria_element_reference_attribute(&namespace, &local_name); let next_value = value.clone(); if let Some(index) = self .attributes @@ -1254,6 +1290,7 @@ impl Element { value: String, units: Vec, ) -> bool { + self.synchronize_aria_element_reference_attribute(&namespace, &local_name); let next_value = value.clone(); let value_utf16_units = utf16_units_contain_unpaired_surrogate(&units).then(|| units.into_boxed_slice()); @@ -1305,6 +1342,7 @@ impl Element { prefix: Option, value: String, ) -> bool { + self.synchronize_aria_element_reference_attribute(&namespace, &local_name); let next_value = value.clone(); if let Some(index) = self.attributes.iter().position(|attribute| { attribute.local_name() == local_name && attribute.namespace() == namespace @@ -1337,6 +1375,7 @@ impl Element { } pub fn remove_attribute(&mut self, name: &str) -> bool { + self.synchronize_aria_element_reference_attribute("", name); let Some(index) = self .attributes .iter() @@ -1353,6 +1392,7 @@ impl Element { } pub fn remove_attribute_ns(&mut self, namespace: &str, local_name: &str) -> bool { + self.synchronize_aria_element_reference_attribute(namespace, local_name); let Some(index) = self.attributes.iter().position(|attribute| { attribute.namespace() == namespace && attribute.local_name() == local_name }) else { diff --git a/moli-dom/src/native/element/rare_data.rs b/moli-dom/src/native/element/rare_data.rs index 92360bb8da..bda1c1ec18 100644 --- a/moli-dom/src/native/element/rare_data.rs +++ b/moli-dom/src/native/element/rare_data.rs @@ -118,6 +118,16 @@ impl ElementRareData { .get_or_insert_with(ElementControlState::default) } + pub(super) fn clear_explicit_aria_element_references(&mut self, attribute: &str) { + if let Some(control_state) = self + .payload + .as_deref_mut() + .and_then(|payload| payload.control_state.as_mut()) + { + control_state.clear_explicit_aria_element_references(attribute); + } + } + pub(super) fn custom_element_state(&self) -> CustomElementState { self.payload .as_deref() diff --git a/moli-dom/src/native/element/tests.rs b/moli-dom/src/native/element/tests.rs index 0160cf8af8..270f383a04 100644 --- a/moli-dom/src/native/element/tests.rs +++ b/moli-dom/src/native/element/tests.rs @@ -154,6 +154,47 @@ fn template_contents_uses_and_releases_rare_data() { assert!(!template.rare_data.is_materialized()); } +#[test] +fn aria_element_reference_state_is_owned_by_the_content_attribute() { + let mut element = Element::new_html("div"); + assert!(element.set_attribute( + "aria-controls".to_owned(), + String::new(), + None, + String::new(), + )); + element.set_explicit_aria_element_references("aria-controls", vec![NativeNodeId::new(7)]); + + assert!(!element.set_attribute( + "aria-controls".to_owned(), + String::new(), + None, + String::new(), + )); + assert_eq!( + element.explicit_aria_element_references("aria-controls"), + None + ); + + element.set_explicit_aria_element_references("aria-controls", vec![NativeNodeId::new(8)]); + assert!(element.set_attribute_ns( + "aria-controls".to_owned(), + "urn:example".to_owned(), + Some("x".to_owned()), + "foreign".to_owned(), + )); + assert_eq!( + element.explicit_aria_element_references("aria-controls"), + Some([NativeNodeId::new(8)].as_slice()) + ); + + assert!(element.remove_attribute("aria-controls")); + assert_eq!( + element.explicit_aria_element_references("aria-controls"), + None + ); +} + #[test] fn html_element_interface_name_covers_replay_tags() { assert_eq!(html_element_interface_name("meta"), "HTMLMetaElement"); diff --git a/moli-dom/src/native/host/mutation/attributes.rs b/moli-dom/src/native/host/mutation/attributes.rs index b7d408685a..2070ec44f5 100644 --- a/moli-dom/src/native/host/mutation/attributes.rs +++ b/moli-dom/src/native/host/mutation/attributes.rs @@ -3,6 +3,33 @@ use std::sync::Arc; use super::*; impl DomHost { + pub fn explicit_aria_element_references( + &self, + handle: DomHandle, + attribute: &str, + ) -> Option> { + self.node(handle)? + .as_element()? + .explicit_aria_element_references(attribute) + .map(<[DomHandle]>::to_vec) + } + + pub fn set_explicit_aria_element_references( + &mut self, + handle: DomHandle, + attribute: &str, + references: Vec, + ) -> bool { + let Some(element) = self + .node_mut(handle) + .and_then(|node| node.data_mut().as_element_mut()) + else { + return false; + }; + element.set_explicit_aria_element_references(attribute, references); + true + } + pub fn get_attribute(&self, handle: DomHandle, name: &str) -> Option { self.dom.get_attribute(handle, name) } diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index f254c96e8c..925c5e4e12 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -8,16 +8,16 @@ use super::super::{ v8_string, v8str, }, }; -use super::JsContextHost; use super::node::{ - element_name_for_owner_document, node_is_document, node_is_element, - node_runtime_and_handle_from_object, node_runtime_and_handle_from_object_or_detached, - node_text_content_getter_function, require_element_getter_receiver, - require_element_setter_receiver, set_text_content_in_reaction_scope, - throw_incompatible_getter_receiver, throw_incompatible_method_receiver, - throw_incompatible_setter_receiver, + current_or_live_delegate_node_arg_handle, element_name_for_owner_document, node_is_document, + node_is_element, node_runtime_and_handle_from_object, + node_runtime_and_handle_from_object_or_detached, node_text_content_getter_function, + require_element_getter_receiver, require_element_setter_receiver, + set_text_content_in_reaction_scope, throw_incompatible_getter_receiver, + throw_incompatible_method_receiver, throw_incompatible_setter_receiver, }; -use crate::{document_runtime::DomHandle, web_api_interfaces, webidl}; +use super::{JsContextHost, wrapped_handle_value}; +use crate::{custom_elements, document_runtime::DomHandle, web_api_interfaces, webidl}; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiTemplateValue}; mod activation; @@ -7527,14 +7527,217 @@ fn aria_string_attribute_setter_callback<'s>( rv.set_undefined(); } -fn aria_element_reference_slot(attribute: &str) -> String { - format!("__moliAriaElementReference:{attribute}") +fn aria_element_reference_array_cache_slot(attribute: &str) -> String { + format!("__moliAriaElementReferenceArrayCache:{attribute}") } fn aria_element_reference_is_singular(attribute: &str) -> bool { attribute == "aria-activedescendant" } +struct AriaElementReferenceValue<'s> { + object: v8::Local<'s, v8::Object>, + runtime_ptr: *mut JsContextHost, + handle: DomHandle, +} + +impl<'s> webidl::WebIdlConverter<'s> for AriaElementReferenceValue<'s> { + type Options = (); + + fn convert( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, + _context: webidl::Context, + _options: &Self::Options, + ) -> std::result::Result { + let object = v8::Local::::try_from(value).map_err(|_| { + webidl::WebIdlError::custom_message("ARIA element references must be Elements.") + })?; + let (runtime_ptr, handle) = node_runtime_and_handle_from_object_or_detached(scope, object) + .map_err(|_| { + webidl::WebIdlError::custom_message("ARIA element references must be Elements.") + })?; + if !node_is_element(unsafe { &*runtime_ptr }, handle) { + return Err(webidl::WebIdlError::custom_message( + "ARIA element references must be Elements.", + )); + } + Ok(Self { + object, + runtime_ptr, + handle, + }) + } +} + +fn aria_element_reference_handle_for_owner( + scope: &mut v8::PinScope<'_, '_>, + owner_runtime_ptr: *mut JsContextHost, + reference: AriaElementReferenceValue<'_>, +) -> Option { + current_or_live_delegate_node_arg_handle(scope, owner_runtime_ptr, reference.object.into()) + .filter(|handle| node_is_element(unsafe { &*owner_runtime_ptr }, *handle)) + .or_else(|| (reference.runtime_ptr == owner_runtime_ptr).then_some(reference.handle)) +} + +fn aria_element_reference_is_in_valid_scope( + runtime: &JsContextHost, + owner: DomHandle, + candidate: DomHandle, +) -> bool { + if !node_is_element(runtime, candidate) { + return false; + } + let Some(candidate_root) = runtime.dom_host().root_node_handle(candidate) else { + return false; + }; + let Some(mut owner_root) = runtime.dom_host().root_node_handle(owner) else { + return false; + }; + loop { + if candidate_root == owner_root { + return true; + } + if !runtime.dom_host().is_shadow_root(owner_root) { + return false; + } + let Some(host) = runtime.dom_host().shadow_root_host(owner_root) else { + return false; + }; + let Some(next_root) = runtime.dom_host().root_node_handle(host) else { + return false; + }; + owner_root = next_root; + } +} + +fn aria_element_by_id_including_disconnected( + runtime: &JsContextHost, + owner: DomHandle, + id: &str, +) -> Option { + if id.is_empty() { + return None; + } + let root = runtime.dom_host().root_node_handle(owner)?; + let mut stack = runtime + .dom_host() + .child_handles_reversed(root) + .collect::>(); + while let Some(candidate) = stack.pop() { + if node_is_element(runtime, candidate) + && runtime.dom_host().get_attribute(candidate, "id").as_deref() == Some(id) + { + return Some(candidate); + } + stack.extend(runtime.dom_host().child_handles_reversed(candidate)); + } + None +} + +fn aria_element_reference_content_handles( + runtime: &JsContextHost, + owner: DomHandle, + attribute: &str, +) -> Option> { + let value = runtime.dom_host().get_attribute(owner, attribute)?; + if aria_element_reference_is_singular(attribute) { + return Some( + aria_element_by_id_including_disconnected(runtime, owner, &value) + .into_iter() + .collect(), + ); + } + Some( + value + .split([' ', '\t', '\n', '\r', '\u{000c}']) + .filter(|token| !token.is_empty()) + .filter_map(|token| aria_element_by_id_including_disconnected(runtime, owner, token)) + .collect(), + ) +} + +fn aria_element_reference_handles( + runtime: &JsContextHost, + owner: DomHandle, + attribute: &str, +) -> Option> { + match runtime + .dom_host() + .explicit_aria_element_references(owner, attribute) + { + Some(references) => Some( + references + .into_iter() + .filter(|candidate| { + aria_element_reference_is_in_valid_scope(runtime, owner, *candidate) + }) + .collect(), + ), + None => aria_element_reference_content_handles(runtime, owner, attribute), + } +} + +fn aria_element_reference_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + runtime_ptr: *mut JsContextHost, + handle: DomHandle, +) -> Option> { + wrapped_handle_value(scope, runtime_ptr, handle) +} + +fn aria_element_reference_array_values<'s>( + scope: &mut v8::PinScope<'s, '_>, + runtime_ptr: *mut JsContextHost, + handles: Vec, +) -> Option>> { + handles + .into_iter() + .map(|handle| aria_element_reference_value(scope, runtime_ptr, handle)) + .collect() +} + +fn aria_cached_frozen_element_array<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, + attribute: &str, + values: &[v8::Local<'s, v8::Value>], +) -> v8::Local<'s, v8::Array> { + let slot = aria_element_reference_array_cache_slot(attribute); + if let Some(cached) = get_private_value(scope, receiver, &slot) + .and_then(|value| v8::Local::::try_from(value).ok()) + && cached.length() as usize == values.len() + { + let mut equal = true; + for (index, expected) in values.iter().copied().enumerate() { + if !cached + .get_index(scope, index as u32) + .is_some_and(|actual| actual.strict_equals(expected)) + { + equal = false; + break; + } + } + if equal { + return cached; + } + } + let array = v8::Array::new_with_elements(scope, values); + let _ = array.set_integrity_level(scope, v8::IntegrityLevel::Frozen); + set_private_value(scope, receiver, &slot, array.into()); + array +} + +fn clear_aria_element_reference_array_cache( + scope: &mut v8::PinScope<'_, '_>, + receiver: v8::Local<'_, v8::Object>, + attribute: &str, +) { + let slot = aria_element_reference_array_cache_slot(attribute); + let undefined = v8::undefined(scope); + set_private_value(scope, receiver, &slot, undefined.into()); +} + fn aria_element_reference_attribute_getter_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -7544,15 +7747,85 @@ fn aria_element_reference_attribute_getter_callback<'s>( rv.set_null(); return; }; - let slot = aria_element_reference_slot(&attribute); - if let Some(value) = get_private_value(scope, args.this(), &slot) { - rv.set(value); + let Some((runtime_ptr, owner)) = element_getter_receiver(scope, args.this(), &attribute) else { + return; + }; + let Some(handles) = aria_element_reference_handles(unsafe { &*runtime_ptr }, owner, &attribute) + else { + clear_aria_element_reference_array_cache(scope, args.this(), &attribute); + rv.set_null(); + return; + }; + if aria_element_reference_is_singular(&attribute) { + match handles + .into_iter() + .next() + .and_then(|handle| aria_element_reference_value(scope, runtime_ptr, handle)) + { + Some(value) => rv.set(value), + None => rv.set_null(), + } return; } - if aria_element_reference_is_singular(&attribute) { + let Some(values) = aria_element_reference_array_values(scope, runtime_ptr, handles) else { rv.set_null(); - } else { - rv.set(v8::Array::new(scope, 0).into()); + return; + }; + let array = aria_cached_frozen_element_array(scope, args.this(), &attribute, &values); + rv.set(array.into()); +} + +fn set_explicit_aria_element_references( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + owner: DomHandle, + attribute: &str, + handles: Vec, +) { + custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { + let _ = unsafe { &mut *runtime_ptr }.set_attribute_appending_to_current_reaction_queue( + scope, + runtime_ptr, + owner, + attribute, + "", + ); + let _ = unsafe { &mut *runtime_ptr } + .dom_host_mut() + .set_explicit_aria_element_references(owner, attribute, handles); + }); +} + +fn clear_explicit_aria_element_references( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + owner: DomHandle, + attribute: &str, +) { + custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { + let _ = unsafe { &mut *runtime_ptr }.remove_attribute_appending_to_current_reaction_queue( + scope, + runtime_ptr, + owner, + attribute, + ); + }); +} + +fn converted_aria_element_reference<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, +) -> Option> { + match webidl::convert::>( + scope, + value, + webidl::Context::argument("Element ARIA element reflection", 1), + ) { + Ok(reference) => Some(reference), + Err(error) => { + webidl::throw_error(scope, &error); + None + } } } @@ -7565,14 +7838,45 @@ fn aria_element_reference_attribute_setter_callback<'s>( rv.set_undefined(); return; }; - let slot = aria_element_reference_slot(&attribute); - set_private_value(scope, args.this(), &slot, args.get(0)); - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) - else { - rv.set_undefined(); + let Some((runtime_ptr, owner)) = element_setter_receiver(scope, args.this(), &attribute) else { return; }; - let _ = unsafe { &mut *runtime_ptr }.set_attribute(scope, runtime_ptr, handle, &attribute, ""); + let value = args.get(0); + if value.is_null_or_undefined() { + clear_explicit_aria_element_references(scope, runtime_ptr, owner, &attribute); + rv.set_undefined(); + return; + } + if aria_element_reference_is_singular(&attribute) { + let Some(reference) = converted_aria_element_reference(scope, value) else { + return; + }; + let handles = aria_element_reference_handle_for_owner(scope, runtime_ptr, reference) + .into_iter() + .collect(); + set_explicit_aria_element_references(scope, runtime_ptr, owner, &attribute, handles); + rv.set_undefined(); + return; + } + let sequence = match webidl::convert::>>( + scope, + value, + webidl::Context::argument("Element ARIA element reflection", 1), + ) { + Ok(sequence) => sequence, + Err(error) => { + webidl::throw_error(scope, &error); + return; + } + }; + let mut handles = Vec::with_capacity(sequence.0.len()); + for reference in sequence.0 { + if let Some(handle) = aria_element_reference_handle_for_owner(scope, runtime_ptr, reference) + && !handles.contains(&handle) + { + handles.push(handle); + } + } + set_explicit_aria_element_references(scope, runtime_ptr, owner, &attribute, handles); rv.set_undefined(); } diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/custom_elements.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/custom_elements.rs index 238cd2a3c8..6444411aaa 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/custom_elements.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/custom_elements.rs @@ -6063,32 +6063,102 @@ fn custom_element_popover_reflection_is_visible_on_html_element_prototype() { } #[test] -fn element_aria_element_reference_reflection_preserves_assigned_value() { +fn element_aria_element_reference_reflection_tracks_dom_owned_associations() { let mut vm = new_storage_test_vm("https://example.com/"); let result = vm .eval( - r#" + r##" (() => { - const target = document.createElement("div"); - const element = document.createElement("div"); - const defaultReferences = element.ariaDescribedByElements; - element.ariaControlsElements = [target]; - element.ariaActiveDescendantElement = target; + const probe = callback => { + try { + callback(); + return "ok"; + } catch (error) { + return error && error.name; + } + }; + const html = document.documentElement || + document.appendChild(document.createElement("html")); + const body = document.body || + html.appendChild(document.createElement("body")); + const container = document.createElement("div"); + container.innerHTML = ` + +

`; + body.appendChild(container); + const element = container.querySelector("input"); + const first = container.querySelector("#first"); + const second = container.querySelector("#second"); + const initialDescriptions = element.ariaDescribedByElements; + const initialActive = element.ariaActiveDescendantElement; + + container.remove(); + const disconnectedDescriptions = element.ariaDescribedByElements; + const disconnectedActive = element.ariaActiveDescendantElement; + body.appendChild(container); + + const assigned = [first, second]; + element.ariaControlsElements = assigned; + assigned.pop(); + const explicitControlsAttributeBlank = + element.getAttribute("aria-controls") === ""; + const controls = element.ariaControlsElements; + const cachedControls = element.ariaControlsElements; + container.remove(); + const disconnectedControls = element.ariaControlsElements; + body.appendChild(container); + + element.setAttribute("aria-controls", "second"); + const contentAttributeControls = element.ariaControlsElements; + element.removeAttribute("aria-controls"); + + element.ariaActiveDescendantElement = first; + const explicitActive = element.ariaActiveDescendantElement; + element.ariaActiveDescendantElement = null; + + const missing = document.createElement("div"); + const reactions = []; + class AriaReferenceElement extends HTMLElement { + static observedAttributes = ["aria-controls"]; + attributeChangedCallback() { + reactions.push(this.ariaControlsElements?.[0] === first); + } + } + customElements.define("aria-reference-element", AriaReferenceElement); + const custom = document.createElement("aria-reference-element"); + body.appendChild(custom); + custom.ariaControlsElements = [first]; return [ - Array.isArray(defaultReferences), - defaultReferences.length, - element.getAttribute("aria-controls") === "", - element.ariaControlsElements[0] === target, - element.getAttribute("aria-activedescendant") === "", - element.ariaActiveDescendantElement === target + missing.ariaDescribedByElements === null, + initialDescriptions.length === 2 && initialDescriptions[0] === first && initialDescriptions[1] === second, + initialActive === first, + disconnectedDescriptions.length === 2 && disconnectedDescriptions[0] === first && disconnectedDescriptions[1] === second, + disconnectedActive === first, + explicitControlsAttributeBlank, + controls.length === 2 && controls[0] === first && controls[1] === second, + Object.isFrozen(controls), + controls === cachedControls, + disconnectedControls === controls, + contentAttributeControls.length === 1 && contentAttributeControls[0] === second, + element.ariaControlsElements === null, + explicitActive === first, + element.ariaActiveDescendantElement === null, + !element.hasAttribute("aria-activedescendant"), + probe(() => { element.ariaActiveDescendantElement = "not an element"; }), + probe(() => { element.ariaControlsElements = [first, 1]; }), + probe(() => { element.ariaControlsElements = first; }), + reactions.length === 1 && reactions[0] ].join("|"); })() - "#, + "##, ) .expect("ARIA element reference reflection probe should evaluate"); - assert_eq!(result, "true|0|true|true|true|true"); + assert_eq!( + result, + "true|true|true|true|true|true|true|true|true|true|true|true|true|true|true|TypeError|TypeError|TypeError|true" + ); } #[test] diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs index 6e542f0def..bff89aba6a 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs @@ -2677,7 +2677,11 @@ fn detached_reflected_element_attributes_use_owner_prototype_accessors() { assert(section.getAttribute("aria-label") === "Proxy label", "proxy aria-label attribute"); const controls = [div]; section.ariaControlsElements = controls; - assert(section.ariaControlsElements === controls, "proxy ariaControlsElements"); + const reflectedControls = section.ariaControlsElements; + assert(reflectedControls !== controls, "proxy ariaControlsElements snapshots input"); + assert(reflectedControls.length === 1 && reflectedControls[0] === div, "proxy ariaControlsElements"); + assert(Object.isFrozen(reflectedControls), "proxy ariaControlsElements frozen"); + assert(section.ariaControlsElements === reflectedControls, "proxy ariaControlsElements cached"); assert(section.getAttribute("aria-controls") === "", "proxy aria-controls attribute"); assert(!own(section, "ariaControlsElements"), "ariaControlsElements should stay inherited after set"); assert(section.contentEditable === "plaintext-only", "proxy contentEditable"); @@ -2709,7 +2713,7 @@ fn detached_reflected_element_attributes_use_owner_prototype_accessors() { assert(section.attributes.getNamedItem("data-x").value === "1", "proxy attributes after delete"); assert(section.shadowRoot === root, "proxy shadowRoot after delete"); assert(section.ariaLabel === "Proxy label", "proxy ariaLabel after delete"); - assert(section.ariaControlsElements === controls, "proxy ariaControlsElements after delete"); + assert(section.ariaControlsElements === reflectedControls, "proxy ariaControlsElements after delete"); assert(section.contentEditable === "plaintext-only", "proxy contentEditable after delete"); assert(section.isContentEditable === true, "proxy isContentEditable after delete"); assert(section.getHTML() === "x", "proxy getHTML after delete");