mirror of
https://github.com/lexmount/moli.git
synced 2026-10-06 00:00:56 +00:00
perf(forms): resolve named items from indexed candidates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<B>(
|
||||
&self,
|
||||
key: &str,
|
||||
mut visit: impl FnMut(DomHandle) -> std::ops::ControlFlow<B>,
|
||||
) -> std::ops::ControlFlow<B> {
|
||||
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<DomHandle> {
|
||||
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::<Vec<_>>();
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -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<FormLookupWork> = 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;
|
||||
|
||||
@@ -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<DomHandle> {
|
||||
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<DomHandle>,
|
||||
}
|
||||
|
||||
impl FormControlScope {
|
||||
fn new(runtime: &JsContextHost, form: DomHandle) -> Option<Self> {
|
||||
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<DomHandle>,
|
||||
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<DomHandle>) {
|
||||
// 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<DomHandle> {
|
||||
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<DomHandle> {
|
||||
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<DomHandle> {
|
||||
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<LiveCollectionQueryKind> {
|
||||
#[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<DomHandle> {
|
||||
|
||||
@@ -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/",
|
||||
"<!doctype html><body></body>",
|
||||
);
|
||||
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 = '<span></span><input name="unrelated">';
|
||||
}
|
||||
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/",
|
||||
"<!doctype html><body></body>",
|
||||
);
|
||||
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/",
|
||||
"<!doctype html><body></body>",
|
||||
);
|
||||
assert_eq!(vm.eval(r#"(() => {
|
||||
const empty = document.body.appendChild(document.createElement('form'));
|
||||
empty.primeIndexes;
|
||||
const parsed = new DOMParser().parseFromString('<form><input name="shared"><input id="shared" name="shared"><img name="shared"><img name="shared"></form>', '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/",
|
||||
"<!doctype html><body></body>",
|
||||
);
|
||||
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 = '<form id="form"><input name="shared"><input name="shared"></form>';
|
||||
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 = '<input name="shared"><form><input name="shared"></form>';
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user