From 9d5c4908ce1405395661f9e94f879c1f2da90efc Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:00:54 +0800 Subject: [PATCH] fix(forms): preserve live and detached named property semantics --- moli-dom/src/native/host/document.rs | 26 ++ moli-dom/src/native/host/query_index.rs | 23 ++ .../detached_objects/builders/elements.rs | 13 +- moli-renderer-v8/src/native_bridge/element.rs | 29 +- .../src/native_bridge/element/forms.rs | 2 +- .../element/forms/form_element.rs | 175 +++++----- .../native_bridge/element/template_install.rs | 1 + .../template_install/accessors_forms.rs | 1 + .../accessors_forms/controls.rs | 9 +- .../src/script_vm/tests/dom_xhr/forms.rs | 1 + .../tests/dom_xhr/forms/named_lookup.rs | 301 ++++++++++++++++++ 11 files changed, 485 insertions(+), 96 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs diff --git a/moli-dom/src/native/host/document.rs b/moli-dom/src/native/host/document.rs index 61bafdaba36..230de2e627c 100644 --- a/moli-dom/src/native/host/document.rs +++ b/moli-dom/src/native/host/document.rs @@ -810,6 +810,32 @@ impl DomHost { }) } + /// Whether any current element can match this legacy named-property key. + /// + /// This is deliberately independent of connectivity and document/tree scope: + /// callers may use a miss to avoid a traversal, but must still validate the + /// owner and scope on a hit. Reuse the incrementally maintained named indexes + /// 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 { + if key.is_empty() { + return false; + } + 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)) + }) + }) + }; + self.id_index.borrow().as_ref().is_some_and(matches) + || self.name_index.borrow().as_ref().is_some_and(matches) + } + pub fn element_handles_by_id_or_name_matching( &self, key: &str, diff --git a/moli-dom/src/native/host/query_index.rs b/moli-dom/src/native/host/query_index.rs index ee18e392058..a3ba184c42d 100644 --- a/moli-dom/src/native/host/query_index.rs +++ b/moli-dom/src/native/host/query_index.rs @@ -518,6 +518,29 @@ mod tests { )) } + #[test] + fn global_named_item_candidate_check_tracks_live_attributes_in_detached_trees() { + let mut host = test_host(); + assert!(!host.has_element_with_named_item_key("late")); + assert!(!host.has_element_with_named_item_key("")); + let input = host.create_element("input"); + assert!(host.set_attribute(input, "name", "late")); + assert!(host.has_element_with_named_item_key("late")); + assert!(host.set_attribute(input, "name", "renamed")); + assert!(!host.has_element_with_named_item_key("late")); + assert!(host.has_element_with_named_item_key("renamed")); + assert!(host.set_attribute(input, "id", "renamed")); + assert!(host.remove_attribute(input, "name")); + assert!(host.has_element_with_named_item_key("renamed")); + let root = host.create_element("div"); + assert!(host.append_child(root, input)); + assert!(host.has_element_with_named_item_key("renamed")); + assert!(host.remove_attribute(input, "id")); + assert!(!host.has_element_with_named_item_key("renamed")); + assert!(host.set_attribute(input, "name", "")); + assert!(!host.has_element_with_named_item_key("")); + } + #[test] fn cached_tag_name_collection_reuses_query_until_mutation() { let mut host = test_host(); diff --git a/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/elements.rs b/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/elements.rs index b88da42ac5e..c6d3981c925 100644 --- a/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/elements.rs +++ b/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/elements.rs @@ -487,9 +487,16 @@ pub(in crate::native_bridge::document) fn build_detached_element_object<'s>( let mut object = if let Some(proto) = element_interface_prototype(scope, owner_document, prototype_name, html_interface_like) { - DetachedElementObjectDeclaration::new(proto, to_string_tag) - .bind(scope) - .ok()? + let declaration = DetachedElementObjectDeclaration::new(proto, to_string_tag); + if html_interface_like && local_name == "form" { + let template = v8::ObjectTemplate::new(scope); + crate::native_bridge::element::install_form_property_handlers(template); + let object = template.new_instance(scope)?; + declaration.bind_into(scope, object).ok()?; + object + } else { + declaration.bind(scope).ok()? + } } else { new_detached_object_with_prototype(scope, "__detachedElementPrototype", to_string_tag)? }; diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 1fd0d41a1d3..b806e94ab4e 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -34,6 +34,32 @@ mod event_handlers; mod events; 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)) }; +} + +#[cfg(test)] +pub(crate) fn take_form_lookup_work_for_test() -> (u64, u64) { + FORM_LOOKUP_WORK.with(|work| work.replace((0, 0))) +} + +#[cfg(test)] +fn record_form_lookup_traversal_for_test() { + FORM_LOOKUP_WORK.with(|work| { + let (traversals, enumerations) = work.get(); + work.set((traversals + 1, enumerations)); + }); +} + +#[cfg(test)] +fn record_form_lookup_enumeration_for_test() { + FORM_LOOKUP_WORK.with(|work| { + let (traversals, enumerations) = work.get(); + work.set((traversals, enumerations + 1)); + }); +} mod geometry; mod global_attributes; mod html_elements; @@ -645,7 +671,8 @@ use stylesheets::{ style_blocking_setter_function, style_disabled_getter_function, style_disabled_setter_function, }; pub(super) use template_install::{ - install_specialized_instance_properties, install_specialized_template, + install_form_property_handlers, install_specialized_instance_properties, + install_specialized_template, }; pub(super) use tree_mutation::{ node_insert_adjacent_element_callback, node_insert_adjacent_html_callback, diff --git a/moli-renderer-v8/src/native_bridge/element/forms.rs b/moli-renderer-v8/src/native_bridge/element/forms.rs index 28a20b093c5..d016f1d7763 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms.rs @@ -18,7 +18,7 @@ use super::super::{ current_or_live_delegate_node_arg_handle, insert_before_in_reaction_scope, insert_before_to_current_reaction_queue, node_or_foreign_arg_handle, node_or_foreign_arg_handle_allow_detached, node_runtime_and_handle_from_args, - node_runtime_and_handle_from_args_or_detached, node_runtime_and_handle_from_object, + node_runtime_and_handle_from_args_or_detached, node_runtime_and_handle_from_object_or_detached, remove_child_in_reaction_scope, remove_child_to_current_reaction_queue, set_text_content_in_reaction_scope, set_wrapped_node_or_null, 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 2f4cbb4403e..4bd57e78275 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 @@ -358,6 +358,8 @@ fn collect_form_control_elements_from( form_handle: Option, include_image_inputs: bool, ) -> Vec { + #[cfg(test)] + crate::native_bridge::element::record_form_lookup_traversal_for_test(); let mut out = Vec::new(); let mut stack = Vec::new(); if include_root { @@ -542,13 +544,14 @@ pub(in crate::native_bridge) fn form_target_setter_function<'s>( rv.set_undefined(); } -pub(in crate::native_bridge) fn form_named_getter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -585,33 +588,32 @@ pub(in crate::native_bridge) fn form_named_getter( let Some(past_handle) = runtime.form_past_named_item(handle, &key) else { return v8::Intercepted::kNo; }; - let Some(node) = runtime - .native_bridge_mut() - .wrap_handle(scope, runtime_ptr, past_handle) + let Some(node) = + wrapped_handle_value_for_receiver(scope, runtime_ptr, args.holder(), past_handle) else { return v8::Intercepted::kNo; }; - rv.set(node.into()); + rv.set(node); return v8::Intercepted::kYes; }; runtime.remember_form_past_named_item(handle, key, match_handle); - let Some(node) = runtime - .native_bridge_mut() - .wrap_handle(scope, runtime_ptr, match_handle) + let Some(node) = + wrapped_handle_value_for_receiver(scope, runtime_ptr, args.holder(), match_handle) else { return v8::Intercepted::kNo; }; - rv.set(node.into()); + rv.set(node); v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_named_descriptor( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_descriptor<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -644,24 +646,22 @@ pub(in crate::native_bridge) fn form_named_descriptor( list.into() } else if let Some(match_handle) = matches.first().copied() { runtime.remember_form_past_named_item(handle, key, match_handle); - let Some(node) = runtime - .native_bridge_mut() - .wrap_handle(scope, runtime_ptr, match_handle) + let Some(node) = + wrapped_handle_value_for_receiver(scope, runtime_ptr, args.holder(), match_handle) else { return v8::Intercepted::kNo; }; - node.into() + node } else { let Some(past_handle) = runtime.form_past_named_item(handle, &key) else { return v8::Intercepted::kNo; }; - let Some(node) = runtime - .native_bridge_mut() - .wrap_handle(scope, runtime_ptr, past_handle) + let Some(node) = + wrapped_handle_value_for_receiver(scope, runtime_ptr, args.holder(), past_handle) else { return v8::Intercepted::kNo; }; - node.into() + node }; let Ok(descriptor) = DataPropertyDescriptorDeclaration::new(value, false, false).bind(scope) else { @@ -671,13 +671,14 @@ pub(in crate::native_bridge) fn form_named_descriptor( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_named_deleter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_deleter<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -697,14 +698,15 @@ pub(in crate::native_bridge) fn form_named_deleter( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_named_definer( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_definer<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, _desc: &v8::PropertyDescriptor, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -732,30 +734,15 @@ fn object_has_expando_named_property( if form_native_property_can_be_overridden(key) { return false; } - let Some(names) = object.get_own_property_names( - scope, - v8::GetPropertyNamesArgs { - mode: v8::KeyCollectionMode::OwnOnly, - property_filter: v8::PropertyFilter::ALL_PROPERTIES | v8::PropertyFilter::SKIP_SYMBOLS, - index_filter: v8::IndexFilter::IncludeIndices, - key_conversion: v8::KeyConversionMode::KeepNumbers, - }, - ) else { + let Some(key) = v8_string(scope, key) else { return false; }; - for index in 0..names.length() { - let Some(name) = names - .get_index(scope, index) - .and_then(|value| value.to_string(scope)) - .map(|value| value.to_rust_string_lossy(scope)) - else { - continue; - }; - if name == key { - return true; - } - } - false + // Enumerating own keys invokes the indexed interceptor, which resolves every + // form control. A real-property check neither enumerates virtual properties + // nor evaluates an own accessor getter. + object + .has_real_named_property(scope, key.into()) + .unwrap_or(false) } fn form_native_property_can_be_overridden(key: &str) -> bool { @@ -801,13 +788,14 @@ fn form_has_named_item_or_past_name( || runtime.form_past_named_item(form_handle, key).is_some() } -pub(in crate::native_bridge) fn form_indexed_getter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -827,13 +815,14 @@ pub(in crate::native_bridge) fn form_indexed_getter( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_query( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_query<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Integer>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -852,14 +841,15 @@ pub(in crate::native_bridge) fn form_indexed_query( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_setter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_setter<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, _value: v8::Local<'_, v8::Value>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -870,13 +860,14 @@ pub(in crate::native_bridge) fn form_indexed_setter( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_descriptor( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_descriptor<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -900,13 +891,14 @@ pub(in crate::native_bridge) fn form_indexed_descriptor( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_deleter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_deleter<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -917,12 +909,15 @@ pub(in crate::native_bridge) fn form_indexed_deleter( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_enumerator( - scope: &mut v8::PinScope<'_, '_>, - args: v8::PropertyCallbackArguments<'_>, +pub(in crate::native_bridge) fn form_indexed_enumerator<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Array>, ) { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + #[cfg(test)] + crate::native_bridge::element::record_form_lookup_enumeration_for_test(); + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { rv.set(v8::Array::new(scope, 0)); return; @@ -933,14 +928,15 @@ pub(in crate::native_bridge) fn form_indexed_enumerator( rv.set(v8::Array::new_with_elements(scope, &keys)); } -pub(in crate::native_bridge) fn form_indexed_definer( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_definer<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, _desc: &v8::PropertyDescriptor, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -951,13 +947,14 @@ pub(in crate::native_bridge) fn form_indexed_definer( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_named_query( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_query<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Integer>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -984,6 +981,12 @@ fn form_named_item_matches( form_handle: DomHandle, key: &str, ) -> 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) { + return Vec::new(); + } let controls = form_control_elements(runtime, form_handle) .into_iter() .filter(|handle| { diff --git a/moli-renderer-v8/src/native_bridge/element/template_install.rs b/moli-renderer-v8/src/native_bridge/element/template_install.rs index 412589a2bcc..743f497f49b 100644 --- a/moli-renderer-v8/src/native_bridge/element/template_install.rs +++ b/moli-renderer-v8/src/native_bridge/element/template_install.rs @@ -7,6 +7,7 @@ mod accessors_structural; mod instance; mod methods; +pub(in crate::native_bridge) use accessors_forms::install_form_property_handlers; use accessors_forms::install_form_template_accessors; use accessors_media::install_media_template_accessors; use accessors_resource::install_resource_template_accessors; diff --git a/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms.rs b/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms.rs index 9d48775d66c..dffba5af3f0 100644 --- a/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms.rs @@ -5,6 +5,7 @@ mod input; mod select; use self::controls::install_form_control_template_accessors; +pub(in crate::native_bridge) use self::controls::install_form_property_handlers; use self::input::install_input_template_accessors; use self::select::install_select_option_template_accessors; diff --git a/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms/controls.rs b/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms/controls.rs index 8e45434083a..cce5cf64872 100644 --- a/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms/controls.rs +++ b/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms/controls.rs @@ -6,13 +6,13 @@ use crate::native_bridge::element::forms::{ }; pub(super) fn install_form_control_template_accessors<'s, 'i>( - scope: &mut v8::PinScope<'s, 'i, ()>, + _scope: &mut v8::PinScope<'s, 'i, ()>, template: v8::Local<'s, v8::ObjectTemplate>, installer: SpecializedTemplateInstaller, ) -> bool { match installer { SpecializedTemplateInstaller::HtmlFormElement => { - install_form_accessors(scope, template); + install_form_property_handlers(template); true } SpecializedTemplateInstaller::HtmlButtonElement @@ -29,9 +29,8 @@ pub(super) fn install_form_control_template_accessors<'s, 'i>( } } -fn install_form_accessors<'s, 'i>( - _scope: &mut v8::PinScope<'s, 'i, ()>, - template: v8::Local<'s, v8::ObjectTemplate>, +pub(in crate::native_bridge) fn install_form_property_handlers( + template: v8::Local<'_, v8::ObjectTemplate>, ) { template.set_indexed_property_handler( v8::IndexedPropertyHandlerConfiguration::new() diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs index 3ec889f4cc9..d7a60b05151 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs @@ -2,6 +2,7 @@ use super::*; mod dialog; mod event_activation; +mod named_lookup; #[test] fn object_set_custom_validity_updates_validity_state() { 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 new file mode 100644 index 00000000000..27c60ab28a4 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs @@ -0,0 +1,301 @@ +use super::*; + +#[test] +fn form_named_lookup_misses_do_not_enumerate_or_traverse_controls() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-work.test/", + "", + ); + vm.exec( + r#" + globalThis.lookupForms = []; + for (let i = 0; i < 32; ++i) { + const form = document.body.appendChild(document.createElement('form')); + form.innerHTML = '
text
'; + lookupForms.push(form); + } + lookupForms[0].absentLookupKey; + "#, + None, + ) + .expect("prepare named lookup workload"); + crate::native_bridge::element::take_form_lookup_work_for_test(); + let result = vm + .eval( + r#" + (() => { + for (const form of lookupForms) { + for (let i = 0; i < 8; ++i) { + if (form.absentLookupKey !== undefined) throw Error('getter'); + if ('absentLookupKey' in form) throw Error('query'); + if (Object.getOwnPropertyDescriptor(form, 'absentLookupKey') !== undefined) + throw Error('descriptor'); + } + } + return 'ok'; + })() + "#, + ) + .expect("misses should preserve ordinary property semantics"); + assert_eq!(result, "ok"); + assert_eq!( + crate::native_bridge::element::take_form_lookup_work_for_test(), + (0, 0) + ); + + // 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); + vm.eval("Object.getOwnPropertyNames(lookupForms[0]).length") + .unwrap(); + assert!(crate::native_bridge::element::take_form_lookup_work_for_test().1 > 0); +} + +#[test] +fn form_named_lookup_uncontested_own_properties_do_not_invoke_accessors_during_queries() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-expando.test/", + "", + ); + let result = vm.eval(r#" + (() => { + const f = document.body.appendChild(document.createElement('form')); + let reads = 0; + let writes = 0; + Object.defineProperty(f, 'ownData', {value: 17, writable: true, configurable: true}); + Object.defineProperty(f, 'ownAccessor', { + get() { ++reads; return 23; }, set(v) { writes += v; }, configurable: true + }); + f.innerHTML = ''; + const proto = Object.create(Object.getPrototypeOf(f)); + Object.defineProperty(proto, 'protoKey', {value: 'prototype'}); + Object.setPrototypeOf(f, proto); + if (!('ownAccessor' in f) || reads !== 0) throw Error('query called getter'); + const descriptor = Object.getOwnPropertyDescriptor(f, 'ownAccessor'); + if (typeof descriptor.get !== 'function' || reads !== 0) throw Error('descriptor called getter'); + if (f.ownAccessor !== 23 || reads !== 1) throw Error('accessor value'); + f.ownAccessor = 3; + if (writes !== 3 || reads !== 1) throw Error('accessor setter'); + if (f.ownData !== 17 || f.protoKey !== 'prototype') throw Error('own/prototype values'); + if (Object.getOwnPropertyDescriptor(f, 'protoKey') !== undefined) throw Error('prototype is not own'); + if (!delete f.ownData || f.ownData !== undefined) throw Error('ordinary data deletion'); + if (!delete f.ownAccessor || f.ownAccessor !== undefined || reads !== 1) + throw Error('ordinary accessor deletion'); + return 'ok'; + })() + "#).expect("own properties must not be confused with intercepted or inherited properties"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_keeps_native_overrides_descriptors_and_index_boundaries() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-boundaries.test/", + r#"
"#, + ); + let result = vm.eval(r#" + (() => { + const f = document.getElementById('f'); + const inputs = document.querySelectorAll('input'); + for (let i = 0; i < inputs.length; ++i) { + const name = inputs[i].name; + if (f[name] !== inputs[i] || !(name in f)) throw Error('native or named override: ' + name); + if (Object.getOwnPropertyDescriptor(f, name).value !== inputs[i]) throw Error('descriptor'); + } + if (f[0] !== inputs[0] || f['01'] !== inputs[5]) throw Error('index routing'); + const symbol = Symbol('plain'); + f[symbol] = 9; + if (f[symbol] !== 9 || !delete f[symbol]) throw Error('symbol'); + return 'ok'; + })() + "#).expect("named overrides and property operations should keep their existing contract"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_index_misses_become_hits_and_preserve_past_names() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-mutations.test/", + r#"
"#, + ); + let result = vm + .eval( + r#" + (() => { + const f = document.getElementById('owner'); + const other = document.getElementById('other'); + if (f.lateKey !== undefined || 'lateKey' in f) throw Error('initial miss'); + const input = document.createElement('input'); + input.name = 'lateKey'; + f.appendChild(input); + if (f.lateKey !== input) throw Error('insert after miss'); + input.name = 'renamedKey'; + if (f.renamedKey !== input || f.lateKey !== input) throw Error('rename/past name'); + input.removeAttribute('name'); + if (f.lateKey !== input) throw Error('global index miss discarded past name'); + other.appendChild(input); + if (f.lateKey !== undefined) throw Error('past owner invalidation'); + input.id = 'lateId'; + input.setAttribute('form', 'owner'); + if (f.lateId !== input || other.lateId !== undefined) throw Error('external form owner'); + input.removeAttribute('id'); + input.setAttribute('form', 'missing'); + if (f.lateId !== undefined) throw Error('removed id and owner'); + const decoy = document.body.appendChild(document.createElement('div')); + decoy.id = 'decoyKey'; + if (f.decoyKey !== undefined) throw Error('global hit must not bypass control predicate'); + const img = f.appendChild(document.createElement('img')); + img.name = 'imageKey'; + if (f.imageKey !== img) throw Error('image fallback'); + const real = f.appendChild(document.createElement('input')); + real.name = 'imageKey'; + if (f.imageKey !== real) throw Error('control wins over image'); + return 'ok'; + })() + "#, + ) + .expect("named lookup must remain live without caching misses"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_candidates_include_detached_shadow_and_child_documents() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-scopes.test/", + "", + ); + let result = vm.eval(r#" + (() => { + function check(doc, parent, key) { + const form = doc.createElement('form'); + if (form[key] !== undefined) throw Error('initial scope miss'); + const input = doc.createElement('input'); + input.name = key; + form.appendChild(input); + if (parent) parent.appendChild(form); + if (form[key] !== input || !(key in form) || Object.getOwnPropertyDescriptor(form, key).value !== input) + throw Error('scope candidate lost: ' + key); + return form; + } + check(document, null, 'detachedKey'); + const host = document.body.appendChild(document.createElement('div')); + const shadow = host.attachShadow({mode: 'closed'}); + check(document, shadow, 'shadowKey'); + const child = document.body.appendChild(document.createElement('iframe')).contentWindow; + const childForm = check(child.document, child.document.body, 'childKey'); + if (Object.getPrototypeOf(childForm.childKey) !== child.HTMLInputElement.prototype) throw Error('child realm'); + return 'ok'; + })() + "#).expect("global candidate miss check must include every document and tree scope"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_detached_document_and_adopted_wrappers() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-detached-document.test/", + "", + ); + let result = vm.eval(r#" + (() => { + const results=[]; + for (const adopted of [false,true]) { + const doc=document.implementation.createHTMLDocument('detached'); + const form=doc.body.appendChild(doc.createElement('form')); + if (form.otherDocumentKey !== undefined) throw Error('initial miss'); + const input=form.appendChild(doc.createElement('input')); + input.name='otherDocumentKey'; + if (adopted) document.body.appendChild(document.adoptNode(form)); + const d=Object.getOwnPropertyDescriptor(form,'otherDocumentKey'); + results.push(form.otherDocumentKey===input, 'otherDocumentKey' in form, !!d&&d.value===input); + } + if (!results.every(Boolean)) throw Error('detached-document or adopted named property unavailable'); + return 'ok'; + })() + "#).expect("detached-document wrappers should expose named controls before and after adoption"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_candidates_do_not_cache_custom_element_eligibility() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-custom.test/", + r#"
"#, + ); + let result = vm.eval(r#" + (() => { + const f = document.getElementById('owner'); + if (f.faceKey !== undefined) throw Error('initial miss'); + const face = document.createElement('named-lookup-face'); + face.setAttribute('name', 'faceKey'); + f.appendChild(face); + if (f.faceKey !== undefined) throw Error('unregistered candidate'); + customElements.define('named-lookup-face', class extends HTMLElement { + static formAssociated = true; + constructor() { super(); this.attachInternals(); } + }); + if (f.faceKey !== face || !('faceKey' in f) || Object.getOwnPropertyDescriptor(f, 'faceKey').value !== face) + throw Error('upgrade must change eligibility without id/name mutation'); + const notFace = document.createElement('named-lookup-ordinary'); + notFace.setAttribute('name', 'ordinaryKey'); + f.appendChild(notFace); + customElements.define('named-lookup-ordinary', class extends HTMLElement {}); + if (f.ordinaryKey !== undefined) throw Error('non form-associated custom element'); + return 'ok'; + })() + "#).expect("named candidates are not a cached form eligibility decision"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_preserves_identity_and_live_properties_across_document_scopes() { + let mut vm = new_parsed_test_vm( + "https://form-document-properties.test/", + "", + ); + let result = vm.eval(r#" + (() => { + for (const mode of ['active', 'detached', 'adopted']) { + const doc = mode === 'active' ? document : document.implementation.createHTMLDocument('forms'); + const f = doc.body.appendChild(doc.createElement('form')); + const input = f.appendChild(doc.createElement('input')); + input.name = 'originalKey'; + if (f.originalKey !== input) throw Error(mode + ': initial identity'); + if (mode === 'adopted') { + if (document.adoptNode(f) !== f) throw Error('adoption replaced wrapper'); + document.body.appendChild(f); + } + if (f[0] !== input || !(0 in f) || Object.getOwnPropertyDescriptor(f, '0').value !== input) + throw Error(mode + ': indexed identity'); + const d = Object.getOwnPropertyDescriptor(f, 'originalKey'); + if (d.value !== input || d.writable || d.enumerable || !d.configurable) + throw Error(mode + ': named descriptor'); + input.name = 'newKey'; + if (f.newKey !== input || f.originalKey !== input) throw Error(mode + ': past name'); + const second = f.appendChild(doc.createElement('input')); + second.name = 'newKey'; + const list = f.newKey; + if (!(list instanceof RadioNodeList) || list.length !== 2 || list[0] !== input || list[1] !== second) + throw Error(mode + ': multiple matches'); + second.remove(); + if (list.length !== 1 || f.newKey !== input) throw Error(mode + ': live list'); + input.name = 'submit'; + if (f.submit !== input || Object.getOwnPropertyDescriptor(f, 'submit').value !== input) + throw Error(mode + ': native member override'); + const symbol = Symbol('custom'); + f[symbol] = 42; + if (f[symbol] !== 42 || !delete f[symbol]) throw Error(mode + ': ordinary symbol'); + input.remove(); + if (f.originalKey !== undefined || f.newKey !== undefined || 'originalKey' in f || f[0] !== undefined) + throw Error(mode + ': removed control'); + if (typeof f.submit !== 'function') throw Error(mode + ': restored native member'); + f.remove(); + } + return 'ok'; + })() + "#).expect("form property behavior must not depend on Document attachment or adoption"); + assert_eq!(result, "ok"); +}