From 27008fc7317b2b7ae9a09fd7f052d29af1c4f5ed Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 29 Sep 2026 09:15:04 +0800 Subject: [PATCH] perf(forms): resolve named items from indexed candidates --- moli-dom/src/native/host/clone.rs | 3 + moli-dom/src/native/host/document.rs | 152 ++++++++---- moli-dom/src/native/host/query_index.rs | 73 ++++++ moli-dom/src/native/host/types.rs | 7 + moli-renderer-v8/src/native_bridge/element.rs | 35 ++- .../element/forms/form_element.rs | 234 ++++++++++++------ .../tests/dom_xhr/forms/named_lookup.rs | 161 +++++++++++- 7 files changed, 533 insertions(+), 132 deletions(-) diff --git a/moli-dom/src/native/host/clone.rs b/moli-dom/src/native/host/clone.rs index f21c7c7d7a..49ea83dcf7 100644 --- a/moli-dom/src/native/host/clone.rs +++ b/moli-dom/src/native/host/clone.rs @@ -584,6 +584,9 @@ impl DomHost { let _ = clone_element.set_media_network_state(u32::MAX); let _ = clone_element.set_media_network_state(element.media_network_state()); let _ = clone_element.set_media_error_code(element.media_error_code()); + // Cloning copies attributes directly, bypassing set_attribute's index updates. + // Detached clones must be queryable before any tree insertion refreshes them. + self.record_named_index_candidate(clone); } } diff --git a/moli-dom/src/native/host/document.rs b/moli-dom/src/native/host/document.rs index 1e645e7fbc..c890e7d32f 100644 --- a/moli-dom/src/native/host/document.rs +++ b/moli-dom/src/native/host/document.rs @@ -506,6 +506,50 @@ impl DomHost { } } + /// Compare nodes in the order of a traversal that visits shadow children + /// before light children. Ordinary document-position comparison treats + /// different shadow trees as disconnected and cannot order these queries. + pub fn compare_handles_in_shadow_including_tree_order( + &self, + left: DomHandle, + right: DomHandle, + ) -> std::cmp::Ordering { + let ancestors = |handle| { + let mut path = Vec::new(); + let mut current = Some(handle); + while let Some(handle) = current { + path.push(handle); + current = self + .parent_node(handle) + .or_else(|| self.shadow_root_host(handle)); + } + path.reverse(); + path + }; + let left_path = ancestors(left); + let right_path = ancestors(right); + let common = left_path + .iter() + .zip(&right_path) + .take_while(|(a, b)| a == b) + .count(); + if common == 0 { + return left.index().cmp(&right.index()); + } + match (left_path.get(common), right_path.get(common)) { + (Some(left), Some(right)) => { + if self.is_shadow_root(*left) { + std::cmp::Ordering::Less + } else if self.is_shadow_root(*right) { + std::cmp::Ordering::Greater + } else { + self.compare_handles_in_document_order(*left, *right) + } + } + _ => left_path.len().cmp(&right_path.len()), + } + } + pub fn is_connected(&self, handle: DomHandle) -> bool { self.node(handle) .is_some_and(|node| node.flags().connected()) @@ -824,22 +868,60 @@ impl DomHost { /// rather than caching misses (custom-element upgrades can change eligibility /// without changing an element's id or name). pub fn has_element_with_named_item_key(&self, key: &str) -> bool { + self.visit_element_candidates_by_id_or_name(key, |handle| { + if self + .node(handle) + .and_then(Node::as_element) + .is_some_and(|element| element.matches_named_item_key(key)) + { + std::ops::ControlFlow::Break(()) + } else { + std::ops::ControlFlow::Continue(()) + } + }) + .is_break() + } + + /// Visit current id/name candidates once each, without restricting document, + /// connectivity, or tree scope. Callers supply their eligibility rules and + /// sort accepted nodes if needed. Breaking avoids building a result list or + /// inspecting the remaining candidates for existence-only queries. + pub fn visit_element_candidates_by_id_or_name( + &self, + key: &str, + mut visit: impl FnMut(DomHandle) -> std::ops::ControlFlow, + ) -> std::ops::ControlFlow { if key.is_empty() { - return false; + return std::ops::ControlFlow::Continue(()); } self.ensure_id_index(); self.ensure_name_index(); - let matches = |index: &NamedElementIndex| { - index.handles_by_value.get(key).is_some_and(|handles| { - handles.iter().any(|handle| { - self.node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.matches_named_item_key(key)) + let ids = self.id_index.borrow(); + let names = self.name_index.borrow(); + let ids = ids + .as_ref() + .and_then(|index| index.handles_by_value.get(key)); + let names = names + .as_ref() + .and_then(|index| index.handles_by_value.get(key)); + let candidates = ids.into_iter().flat_map(NamedElementHandles::iter).chain( + names + .into_iter() + .flat_map(NamedElementHandles::iter) + .filter(|handle| ids.is_none_or(|ids| !ids.contains(handle))), + ); + for &handle in candidates { + if self + .node(handle) + .and_then(Node::as_element) + .is_some_and(|element| { + element.id() == Some(key) || element.name_attribute() == Some(key) }) - }) - }; - self.id_index.borrow().as_ref().is_some_and(matches) - || self.name_index.borrow().as_ref().is_some_and(matches) + { + visit(handle)?; + } + } + std::ops::ControlFlow::Continue(()) } pub fn element_handles_by_id_or_name_matching( @@ -847,41 +929,21 @@ impl DomHost { key: &str, mut accepts_name: impl FnMut(DomHandle) -> bool, ) -> Vec { - self.ensure_id_index(); - self.ensure_name_index(); - - let mut candidates = IndexSet::new(); - if let Some(handles) = self - .id_index - .borrow() - .as_ref() - .and_then(|index| index.handles_by_value.get(key)) - { - candidates.extend(handles.iter().copied()); - } - if let Some(handles) = self - .name_index - .borrow() - .as_ref() - .and_then(|index| index.handles_by_value.get(key)) - { - candidates.extend(handles.iter().copied()); - } - let document_handle = self.document_handle(); - let mut matches = candidates - .into_iter() - .filter(|handle| { - self.node(*handle).is_some_and(|node| { - node.flags().in_document_tree() - && node.owner_document() == Some(document_handle) - && node.as_element().is_some_and(|element| { - element.id() == Some(key) - || (element.name_attribute() == Some(key) && accepts_name(*handle)) - }) - }) - }) - .collect::>(); + let mut matches = Vec::new(); + let _ = self.visit_element_candidates_by_id_or_name(key, |handle| { + if self.node(handle).is_some_and(|node| { + node.flags().in_document_tree() + && node.owner_document() == Some(document_handle) + && node.as_element().is_some_and(|element| { + element.id() == Some(key) + || (element.name_attribute() == Some(key) && accepts_name(handle)) + }) + }) { + matches.push(handle); + } + std::ops::ControlFlow::<()>::Continue(()) + }); matches.sort_by(|left, right| self.compare_handles_in_document_order(*left, *right)); matches } diff --git a/moli-dom/src/native/host/query_index.rs b/moli-dom/src/native/host/query_index.rs index a3ba184c42..64eaf76c1d 100644 --- a/moli-dom/src/native/host/query_index.rs +++ b/moli-dom/src/native/host/query_index.rs @@ -541,6 +541,79 @@ mod tests { assert!(!host.has_element_with_named_item_key("")); } + #[test] + fn named_candidate_visits_are_unscoped_deduplicated_and_short_circuitable() { + use std::ops::ControlFlow; + let mut host = test_host(); + assert!(!host.has_element_with_named_item_key("shared")); + let detached = host.create_element("input"); + let shadow_host = host.create_element("div"); + let shadow = host.attach_shadow_root(shadow_host, "open").unwrap(); + let shadow_input = host.create_element("input"); + assert!(host.append_child(shadow, shadow_input)); + let other_document = host.create_detached_html_document(); + let other_input = host.create_element("input"); + assert!(host.append_child(other_document, other_input)); + for handle in [detached, shadow_input, other_input] { + assert!(host.set_attribute(handle, "id", "shared")); + assert!(host.set_attribute(handle, "name", "shared")); + } + let mut found = Vec::new(); + let result = host.visit_element_candidates_by_id_or_name("shared", |handle| { + found.push(handle); + ControlFlow::<()>::Continue(()) + }); + assert!(result.is_continue()); + assert_eq!(found.len(), 3); + for handle in [detached, shadow_input, other_input] { + assert!(found.contains(&handle)); + } + let mut visits = 0; + let first = host.visit_element_candidates_by_id_or_name("shared", |handle| { + visits += 1; + ControlFlow::Break(handle) + }); + assert!(first.is_break()); + assert_eq!(visits, 1); + assert!(host.set_attribute(detached, "id", "renamed")); + assert!(host.remove_attribute(detached, "name")); + let _ = host.visit_element_candidates_by_id_or_name("shared", |handle| { + assert_ne!(handle, detached); + ControlFlow::<()>::Continue(()) + }); + assert_eq!( + host.visit_element_candidates_by_id_or_name("renamed", ControlFlow::Break), + ControlFlow::Break(detached) + ); + assert!( + host.element_handles_by_id_or_name_matching("shared", |_| true) + .is_empty() + ); + } + + #[test] + fn named_candidates_track_detached_clones_and_imports_after_materialization() { + use std::ops::ControlFlow; + let mut host = test_host(); + let source = host.create_element("input"); + assert!(host.set_attribute(source, "id", "cloned")); + assert!(host.set_attribute(source, "name", "cloned")); + assert!(host.has_element_with_named_item_key("cloned")); + let clone = host.clone_node(source, false).unwrap(); + let document = host.create_detached_html_document(); + let imported = host.import_node(document, source, false).unwrap(); + let foreign = host.snapshot_document(); + let foreign_import = host + .import_foreign_node(document, &foreign, source, false) + .unwrap(); + let mut candidates = Vec::new(); + let _ = host.visit_element_candidates_by_id_or_name("cloned", |handle| { + candidates.push(handle); + ControlFlow::<()>::Continue(()) + }); + assert_eq!(candidates, vec![source, clone, imported, foreign_import]); + } + #[test] fn cached_tag_name_collection_reuses_query_until_mutation() { let mut host = test_host(); diff --git a/moli-dom/src/native/host/types.rs b/moli-dom/src/native/host/types.rs index da7d2f1bbe..c3ec35cc15 100644 --- a/moli-dom/src/native/host/types.rs +++ b/moli-dom/src/native/host/types.rs @@ -81,6 +81,13 @@ pub(super) enum NamedElementHandles { } impl NamedElementHandles { + pub(super) fn contains(&self, handle: &DomHandle) -> bool { + match self { + Self::One(current) => current == handle, + Self::Many(handles) => handles.contains(handle), + } + } + pub(super) fn insert(&mut self, handle: DomHandle) { match self { Self::One(current) if *current == handle => {} diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index dee039cb7f..a515eb59b1 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -36,28 +36,47 @@ mod focus; mod forms; #[cfg(test)] -std::thread_local! { - static FORM_LOOKUP_WORK: std::cell::Cell<(u64, u64)> = const { std::cell::Cell::new((0, 0)) }; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct FormLookupWork { + pub traversals: u64, + pub enumerations: u64, + pub inspected_nodes: u64, } #[cfg(test)] -pub(crate) fn take_form_lookup_work_for_test() -> (u64, u64) { - FORM_LOOKUP_WORK.with(|work| work.replace((0, 0))) +std::thread_local! { + static FORM_LOOKUP_WORK: std::cell::Cell = const { std::cell::Cell::new(FormLookupWork { traversals: 0, enumerations: 0, inspected_nodes: 0 }) }; +} + +#[cfg(test)] +pub(crate) fn take_form_lookup_work_for_test() -> FormLookupWork { + FORM_LOOKUP_WORK.with(|work| work.replace(FormLookupWork::default())) } #[cfg(test)] fn record_form_lookup_traversal_for_test() { FORM_LOOKUP_WORK.with(|work| { - let (traversals, enumerations) = work.get(); - work.set((traversals + 1, enumerations)); + let mut current = work.get(); + current.traversals += 1; + work.set(current); }); } #[cfg(test)] fn record_form_lookup_enumeration_for_test() { FORM_LOOKUP_WORK.with(|work| { - let (traversals, enumerations) = work.get(); - work.set((traversals, enumerations + 1)); + let mut current = work.get(); + current.enumerations += 1; + work.set(current); + }); +} + +#[cfg(test)] +fn record_form_lookup_node_for_test() { + FORM_LOOKUP_WORK.with(|work| { + let mut current = work.get(); + current.inspected_nodes += 1; + work.set(current); }); } mod geometry; diff --git a/moli-renderer-v8/src/native_bridge/element/forms/form_element.rs b/moli-renderer-v8/src/native_bridge/element/forms/form_element.rs index 5c28ea517a..792d300df8 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/form_element.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/form_element.rs @@ -3,6 +3,7 @@ use crate::custom_elements::is_form_associated_custom_element_handle; use crate::native_bridge::bridge::wrapped_handle_value_for_receiver; use crate::native_bridge::element::{html_element_getter_receiver, html_element_setter_receiver}; use moli_webapi_declare::DataPropertyDescriptorDeclaration; +use std::ops::ControlFlow; pub(in crate::native_bridge) fn form_action_getter_function<'s>( scope: &mut v8::PinScope<'s, '_>, @@ -272,35 +273,55 @@ pub(crate) fn form_control_elements( runtime: &JsContextHost, form_handle: DomHandle, ) -> Vec { - if runtime - .dom_host() - .is_html_element_named(form_handle, "fieldset") - { - return collect_form_control_elements_from(runtime, form_handle, false, None, false); - } - - if !runtime - .dom_host() - .is_html_element_named(form_handle, "form") - { + let Some(scope) = FormControlScope::new(runtime, form_handle) else { return Vec::new(); + }; + collect_form_control_elements_from(runtime, scope.root, false, scope.owner, false) +} + +#[derive(Clone, Copy)] +struct FormControlScope { + root: DomHandle, + owner: Option, +} + +impl FormControlScope { + fn new(runtime: &JsContextHost, form: DomHandle) -> Option { + let dom = runtime.dom_host(); + if dom.is_html_element_named(form, "fieldset") { + return Some(Self { + root: form, + owner: None, + }); + } + if !dom.is_html_element_named(form, "form") { + return None; + } + let root = if dom.is_connected(form) { + dom.owner_document_handle(form) + .unwrap_or_else(|| dom.document_handle()) + } else { + form + }; + Some(Self { + root, + owner: Some(form), + }) } - if !runtime.dom_host().is_connected(form_handle) { - return collect_form_control_elements_from( - runtime, - form_handle, - false, - Some(form_handle), - false, - ); + fn contains(self, runtime: &JsContextHost, candidate: DomHandle) -> bool { + let dom = runtime.dom_host(); + let mut current = dom.parent_node(candidate); + while let Some(handle) = current { + if handle == self.root { + return true; + } + current = dom + .parent_node(handle) + .or_else(|| dom.shadow_root_host(handle)); + } + false } - - let document_handle = runtime - .dom_host() - .owner_document_handle(form_handle) - .unwrap_or_else(|| runtime.dom_host().document_handle()); - collect_form_control_elements_from(runtime, document_handle, false, Some(form_handle), false) } pub(crate) fn form_data_control_elements( @@ -367,10 +388,9 @@ fn collect_form_control_elements_from( push_shadow_including_children(runtime, root, &mut stack); } while let Some(handle) = stack.pop() { - if is_form_control_handle(runtime, handle, include_image_inputs) - && form_handle - .is_none_or(|owner| form_associated_form_owner(runtime, handle) == Some(owner)) - { + #[cfg(test)] + crate::native_bridge::element::record_form_lookup_node_for_test(); + if form_control_matches_owner(runtime, handle, form_handle, include_image_inputs) { out.push(handle); } push_shadow_including_children(runtime, handle, &mut stack); @@ -425,6 +445,16 @@ fn is_form_control_handle( }) } +fn form_control_matches_owner( + runtime: &JsContextHost, + handle: DomHandle, + owner: Option, + include_image_inputs: bool, +) -> bool { + is_form_control_handle(runtime, handle, include_image_inputs) + && owner.is_none_or(|owner| form_associated_form_owner(runtime, handle) == Some(owner)) +} + pub(in crate::native_bridge) fn fieldset_elements_getter_function<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -745,9 +775,19 @@ fn form_has_named_item_or_past_name( form_handle: DomHandle, key: &str, ) -> bool { - !form_named_item_matches(runtime, form_handle, key) - .1 - .is_empty() + let Some(scope) = FormControlScope::new(runtime, form_handle) else { + return false; + }; + runtime + .dom_host() + .visit_element_candidates_by_id_or_name(key, |candidate| { + if form_named_candidate_source(runtime, scope, candidate, key).is_some() { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }) + .is_break() || runtime.form_past_named_item(form_handle, key).is_some() } @@ -945,21 +985,27 @@ fn form_named_item_matches( form_handle: DomHandle, key: &str, ) -> (LiveCollectionQueryKind, Vec) { - // Only a miss is conclusive. A hit still needs the existing form-owner, - // custom-element, shadow-tree and image-fallback rules below. The caller - // separately checks the past-names map even when this returns no matches. - if !runtime.dom_host().has_element_with_named_item_key(key) { + let Some(scope) = FormControlScope::new(runtime, form_handle) else { return (LiveCollectionQueryKind::FormControlsByName, Vec::new()); - } - let controls = form_named_control_matches(runtime, form_handle, key); - if controls.is_empty() { - ( - LiveCollectionQueryKind::FormImagesByName, - form_named_image_matches(runtime, form_handle, key), - ) - } else { - (LiveCollectionQueryKind::FormControlsByName, controls) - } + }; + let mut query_kind = LiveCollectionQueryKind::FormImagesByName; + let mut matches = Vec::new(); + let _ = runtime + .dom_host() + .visit_element_candidates_by_id_or_name(key, |candidate| { + if let Some(source) = form_named_candidate_source(runtime, scope, candidate, key) { + if source == LiveCollectionQueryKind::FormControlsByName && source != query_kind { + query_kind = source; + matches.clear(); + } + if source == query_kind { + matches.push(candidate); + } + } + ControlFlow::<()>::Continue(()) + }); + sort_form_named_matches(runtime, &mut matches); + (query_kind, matches) } pub(in crate::native_bridge) fn form_named_control_matches( @@ -967,16 +1013,12 @@ pub(in crate::native_bridge) fn form_named_control_matches( form_handle: DomHandle, key: &str, ) -> Vec { - form_control_elements(runtime, form_handle) - .into_iter() - .filter(|handle| { - runtime - .dom_host() - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.matches_named_item_key(key)) - }) - .collect() + form_named_matches_from_source( + runtime, + form_handle, + key, + LiveCollectionQueryKind::FormControlsByName, + ) } pub(in crate::native_bridge) fn form_named_image_matches( @@ -984,25 +1026,71 @@ pub(in crate::native_bridge) fn form_named_image_matches( form_handle: DomHandle, key: &str, ) -> Vec { - if !runtime - .dom_host() - .is_html_element_named(form_handle, "form") - { + form_named_matches_from_source( + runtime, + form_handle, + key, + LiveCollectionQueryKind::FormImagesByName, + ) +} + +fn form_named_matches_from_source( + runtime: &JsContextHost, + form_handle: DomHandle, + key: &str, + source: LiveCollectionQueryKind, +) -> Vec { + let Some(scope) = FormControlScope::new(runtime, form_handle) else { return Vec::new(); - } - runtime + }; + let mut matches = Vec::new(); + let _ = runtime .dom_host() - .elements_by_tag_name(form_handle, "img", false) - .into_iter() - .filter(|handle| nearest_form_ancestor(runtime, *handle) == Some(form_handle)) - .filter(|handle| { - runtime - .dom_host() - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.matches_named_item_key(key)) - }) - .collect() + .visit_element_candidates_by_id_or_name(key, |candidate| { + if form_named_candidate_source(runtime, scope, candidate, key) == Some(source) { + matches.push(candidate); + } + ControlFlow::<()>::Continue(()) + }); + sort_form_named_matches(runtime, &mut matches); + matches +} + +fn form_named_candidate_source( + runtime: &JsContextHost, + scope: FormControlScope, + candidate: DomHandle, + key: &str, +) -> Option { + #[cfg(test)] + crate::native_bridge::element::record_form_lookup_node_for_test(); + let element = runtime + .dom_host() + .node(candidate) + .and_then(Node::as_element)?; + if !element.matches_named_item_key(key) { + return None; + } + if form_control_matches_owner(runtime, candidate, scope.owner, false) + && scope.contains(runtime, candidate) + { + Some(LiveCollectionQueryKind::FormControlsByName) + } else if element.is_html_element("img") + && scope.owner.is_some() + && nearest_form_ancestor(runtime, candidate) == scope.owner + { + Some(LiveCollectionQueryKind::FormImagesByName) + } else { + None + } +} + +fn sort_form_named_matches(runtime: &JsContextHost, matches: &mut [DomHandle]) { + matches.sort_by(|left, right| { + runtime + .dom_host() + .compare_handles_in_shadow_including_tree_order(*left, *right) + }); } fn nearest_form_ancestor(runtime: &JsContextHost, handle: DomHandle) -> Option { diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs index 3c4d1d4a2b..4050c84561 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs @@ -1,5 +1,151 @@ use super::*; +#[test] +fn form_named_lookup_checks_only_index_candidates_and_existence_stops_early() { + use crate::native_bridge::element::take_form_lookup_work_for_test; + let mut vm = new_parsed_test_vm( + "https://form-index-work.test/", + "", + ); + vm.exec( + r#" + globalThis.indexForm = document.body.appendChild(document.createElement('form')); + globalThis.firstIndexed = indexForm.appendChild(document.createElement('input')); + firstIndexed.id = firstIndexed.name = 'indexedGroup'; + for (let i = 0; i < 63; ++i) { + indexForm.appendChild(document.createElement('input')).name = 'indexedGroup'; + } + globalThis.otherForm = document.body.appendChild(document.createElement('form')); + otherForm.appendChild(document.createElement('input')).name = 'otherOnly'; + for (let i = 0; i < 512; ++i) { + const div = document.body.appendChild(document.createElement('div')); + div.innerHTML = ''; + } + indexForm.absentPrimeKey; + "#, + None, + ) + .unwrap(); + take_form_lookup_work_for_test(); + assert_eq!(vm.eval("'indexedGroup' in indexForm").unwrap(), "true"); + let work = take_form_lookup_work_for_test(); + assert_eq!(work.inspected_nodes, 1); + assert_eq!((work.traversals, work.enumerations), (0, 0)); + assert_eq!( + vm.eval("indexForm.otherOnly === undefined").unwrap(), + "true" + ); + let work = take_form_lookup_work_for_test(); + assert_eq!(work.inspected_nodes, 1); + assert_eq!((work.traversals, work.enumerations), (0, 0)); + assert_eq!( + vm.eval("globalThis.indexedList = indexForm.indexedGroup; true") + .unwrap(), + "true" + ); + let work = take_form_lookup_work_for_test(); + assert_eq!( + work.inspected_nodes, 64, + "id/name duplicates must be inspected once" + ); + assert_eq!((work.traversals, work.enumerations), (0, 0)); + assert_eq!(vm.eval("indexedList.length").unwrap(), "64"); + let work = take_form_lookup_work_for_test(); + assert_eq!( + work.inspected_nodes, 64, + "live list evaluation must also use candidates" + ); + assert_eq!(work.traversals, 0); +} + +#[test] +fn form_named_lookup_existence_does_not_remember_past_names() { + let mut vm = new_parsed_test_vm( + "https://form-index-existence.test/", + "", + ); + assert_eq!(vm.eval(r#"(() => { + const form = document.body.appendChild(document.createElement('form')); + for (const tag of ['input', 'img']) { + const item = form.appendChild(document.createElement(tag)); + item.name = 'existenceOnly'; + if (!('existenceOnly' in form)) throw Error('missing supported name'); + item.name = 'renamed'; + if ('existenceOnly' in form || form.existenceOnly !== undefined) throw Error('query remembered past name'); + item.remove(); + } + return 'ok'; + })()"#).unwrap(), "ok"); +} + +#[test] +fn form_named_lookup_index_survives_parsing_import_and_control_type_changes() { + let mut vm = new_parsed_test_vm( + "https://form-index-import.test/", + "", + ); + assert_eq!(vm.eval(r#"(() => { + const empty = document.body.appendChild(document.createElement('form')); + empty.primeIndexes; + const parsed = new DOMParser().parseFromString('
', 'text/html'); + const original = parsed.querySelector('form'); + const imported = document.importNode(original, true); + for (const [mode, form] of [original, imported].entries()) { + const inputs = form.querySelectorAll('input'); + const list = form.shared; + if (!list) throw Error(mode + ': missing list: ' + form.outerHTML + '; elements=' + form.elements.length); + if (list.length !== 2 || list[0] !== inputs[0] || list[1] !== inputs[1]) throw Error('parsed/imported candidates'); + form.insertBefore(inputs[1], inputs[0]); + if (list[0] !== inputs[1]) throw Error('candidate insertion order leaked'); + inputs[1].type = 'image'; + if (list.length !== 1 || form.shared !== inputs[0]) throw Error('type eligibility'); + inputs[0].type = 'image'; + if (list.length !== 0 || form.shared.length !== 2 || form.shared[0].tagName !== 'IMG') throw Error('image fallback'); + document.body.appendChild(document.adoptNode(form)); + inputs[1].type = 'text'; + if (list.length !== 1 || list[0] !== inputs[1] || form.shared !== inputs[1]) throw Error('adoption and type restoration'); + } + return 'ok'; + })()"#).unwrap(), "ok"); +} + +#[test] +fn form_named_lookup_orders_shadow_reference_controls_and_filters_fieldset_scope() { + let mut vm = new_parsed_test_vm( + "https://form-index-shadow.test/", + "", + ); + assert_eq!(vm.eval(r#"(() => { + const host = document.body.appendChild(document.createElement('div')); + host.id = 'formHost'; + const root = host.attachShadow({mode: 'open', referenceTarget: 'form'}); + root.innerHTML = '
'; + const form = root.querySelector('form'); + const inside = form.querySelectorAll('input'); + const external = host.appendChild(document.createElement('input')); + external.setAttribute('form', 'formHost'); + external.name = 'shared'; + const before = document.body.insertBefore(document.createElement('input'), host); + before.setAttribute('form', 'formHost'); + before.name = 'shared'; + const list = form.shared; + function check(expected) { + if (list.length !== expected.length || expected.some((item, i) => list[i] !== item)) throw Error('shadow-including order'); + } + check([before, inside[0], inside[1], external]); + document.body.appendChild(before); + check([inside[0], inside[1], external, before]); + host.remove(); + check([inside[0], inside[1]]); + const fieldset = document.body.appendChild(document.createElement('fieldset')); + fieldset.innerHTML = '
'; + const fieldsetInputs = fieldset.querySelectorAll('input'); + const fieldsetList = fieldset.elements; + if (fieldsetList.length !== 2 || fieldsetList[1] !== fieldsetInputs[1] || fieldsetList.namedItem('shared') !== fieldsetInputs[0]) throw Error('fieldset candidate scope'); + return 'ok'; + })()"#).unwrap(), "ok"); +} + #[test] fn form_named_lookup_own_properties_take_precedence_over_controls_and_prototypes() { let mut vm = new_parsed_test_vm( @@ -208,18 +354,21 @@ fn form_named_lookup_misses_do_not_enumerate_or_traverse_controls() { assert_eq!(result, "ok"); assert_eq!( crate::native_bridge::element::take_form_lookup_work_for_test(), - (0, 0) + Default::default() ); // Positive control: the counter must observe real supported-property work. assert_eq!(vm.eval("lookupForms[0].present.tagName").unwrap(), "INPUT"); - let (traversals, enumerations) = - crate::native_bridge::element::take_form_lookup_work_for_test(); - assert!(traversals > 0); - assert_eq!(enumerations, 0); + let work = crate::native_bridge::element::take_form_lookup_work_for_test(); + assert!(work.inspected_nodes > 0); + assert_eq!(work.traversals, 0); + assert_eq!(work.enumerations, 0); vm.eval("Object.getOwnPropertyNames(lookupForms[0]).length") .unwrap(); - assert!(crate::native_bridge::element::take_form_lookup_work_for_test().1 > 0); + let work = crate::native_bridge::element::take_form_lookup_work_for_test(); + assert!(work.enumerations > 0); + assert!(work.traversals > 0); + assert!(work.inspected_nodes > 0); } #[test]