From b7abc1dcaabbb45332f7c693bfca7b08b66e6c18 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 29 Sep 2026 08:52:04 +0800 Subject: [PATCH] fix(forms): preserve image sources in live named lists Carry the selected control or image query into RadioNodeList descriptors and reuse the same matching helpers when resolving live collections. Retained image lists keep their filter when matching controls are added, and retained control lists never switch to image fallback. Cover image membership, descriptor access, name changes, tree order, removal, and adoption while preserving form.elements control filtering. --- moli-renderer-v8/src/native_bridge/element.rs | 1 + .../src/native_bridge/element/forms.rs | 7 +- .../element/forms/form_element.rs | 43 +++++++----- .../src/native_bridge/identity.rs | 22 +++--- .../tests/dom_xhr/forms/named_lookup.rs | 67 +++++++++++++++++++ 5 files changed, 111 insertions(+), 29 deletions(-) diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 5cae9822f7..dee039cb7f 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -90,6 +90,7 @@ pub(crate) use forms::{ form_control_elements, form_data_control_elements, is_valid_submit_button, submit_form_with_submit_event, }; +pub(in crate::native_bridge) use forms::{form_named_control_matches, form_named_image_matches}; #[cfg(test)] pub(crate) use styles::iframe_width_attribute_viewport_width; pub(crate) use styles::{ diff --git a/moli-renderer-v8/src/native_bridge/element/forms.rs b/moli-renderer-v8/src/native_bridge/element/forms.rs index a1a0111b61..56f6bdc083 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms.rs @@ -126,9 +126,10 @@ pub(in crate::native_bridge) use self::form_element::{ form_indexed_deleter, form_indexed_descriptor, form_indexed_enumerator, form_indexed_getter, form_indexed_query, form_indexed_setter, form_length_getter_function, form_method_getter_function, form_method_setter_function, form_name_getter_function, - form_name_setter_function, form_named_definer, form_named_deleter, form_named_descriptor, - form_named_getter, form_named_query, form_no_validate_getter_function, - form_no_validate_setter_function, form_target_getter_function, form_target_setter_function, + form_name_setter_function, form_named_control_matches, form_named_definer, form_named_deleter, + form_named_descriptor, form_named_getter, form_named_image_matches, form_named_query, + form_no_validate_getter_function, form_no_validate_setter_function, + form_target_getter_function, form_target_setter_function, }; pub(crate) use self::input::{cache_input_files_from_selected_files, input_files_for_object}; pub(in crate::native_bridge) use self::input::{ 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 c03e9a37c9..5c28ea517a 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 @@ -569,11 +569,11 @@ pub(in crate::native_bridge) fn form_named_getter<'s>( }; let scope = &mut v8::ContextScope::new(scope, context); let runtime = unsafe { &mut *runtime_ptr }; - let matches = form_named_item_matches(runtime, handle, &key); + let (query_kind, matches) = form_named_item_matches(runtime, handle, &key); if matches.len() > 1 { let descriptor = LiveCollectionDescriptor { collection_kind: CollectionKind::RadioNodeList, - query_kind: LiveCollectionQueryKind::FormControlsByName, + query_kind, root: handle, query: Some(key), include_root: false, @@ -632,11 +632,11 @@ pub(in crate::native_bridge) fn form_named_descriptor<'s>( }; let scope = &mut v8::ContextScope::new(scope, context); let runtime = unsafe { &mut *runtime_ptr }; - let matches = form_named_item_matches(runtime, handle, &key); + let (query_kind, matches) = form_named_item_matches(runtime, handle, &key); let value = if matches.len() > 1 { let descriptor = LiveCollectionDescriptor { collection_kind: CollectionKind::RadioNodeList, - query_kind: LiveCollectionQueryKind::FormControlsByName, + query_kind, root: handle, query: Some(key), include_root: false, @@ -745,7 +745,9 @@ fn form_has_named_item_or_past_name( form_handle: DomHandle, key: &str, ) -> bool { - !form_named_item_matches(runtime, form_handle, key).is_empty() + !form_named_item_matches(runtime, form_handle, key) + .1 + .is_empty() || runtime.form_past_named_item(form_handle, key).is_some() } @@ -942,14 +944,30 @@ fn form_named_item_matches( runtime: &JsContextHost, form_handle: DomHandle, key: &str, -) -> Vec { +) -> (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) { - return Vec::new(); + return (LiveCollectionQueryKind::FormControlsByName, Vec::new()); } - let controls = form_control_elements(runtime, form_handle) + 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) + } +} + +pub(in crate::native_bridge) fn form_named_control_matches( + runtime: &JsContextHost, + form_handle: DomHandle, + key: &str, +) -> Vec { + form_control_elements(runtime, form_handle) .into_iter() .filter(|handle| { runtime @@ -958,15 +976,10 @@ fn form_named_item_matches( .and_then(Node::as_element) .is_some_and(|element| element.matches_named_item_key(key)) }) - .collect::>(); - if controls.is_empty() { - form_named_image_matches(runtime, form_handle, key) - } else { - controls - } + .collect() } -fn form_named_image_matches( +pub(in crate::native_bridge) fn form_named_image_matches( runtime: &JsContextHost, form_handle: DomHandle, key: &str, diff --git a/moli-renderer-v8/src/native_bridge/identity.rs b/moli-renderer-v8/src/native_bridge/identity.rs index 95fe6daa5c..95c37c70c8 100644 --- a/moli-renderer-v8/src/native_bridge/identity.rs +++ b/moli-renderer-v8/src/native_bridge/identity.rs @@ -9,7 +9,10 @@ use std::{ use indexmap::IndexSet; use super::super::document_runtime::DomHandle; -use super::element::{control_label_handles, form_control_elements}; +use super::element::{ + control_label_handles, form_control_elements, form_named_control_matches, + form_named_image_matches, +}; use super::{JsContextHost, RuntimeObservableContextToken}; use dense_reflector_map::DenseReflectorMap; @@ -175,6 +178,7 @@ pub(super) enum LiveCollectionQueryKind { WindowNamedItems, DocumentAllNamedItems, FormControlsByName, + FormImagesByName, Forms, Images, Scripts, @@ -204,6 +208,7 @@ impl LiveCollectionQueryKind { Self::WindowNamedItems => "windowNamedItems", Self::DocumentAllNamedItems => "documentAllNamedItems", Self::FormControlsByName => "formControlsByName", + Self::FormImagesByName => "formImagesByName", Self::Forms => "forms", Self::Images => "images", Self::Scripts => "scripts", @@ -302,16 +307,11 @@ impl LiveCollectionDescriptor { } else if self.query_kind == LiveCollectionQueryKind::FormControls { form_control_elements(host, self.root) } else if self.query_kind == LiveCollectionQueryKind::FormControlsByName { - let query = self.query.as_deref().unwrap_or_default(); - form_control_elements(host, self.root) - .into_iter() - .filter(|handle| { - host.dom_host() - .node(*handle) - .and_then(crate::dom::native::Node::as_element) - .is_some_and(|element| element.matches_named_item_key(query)) - }) - .collect() + form_named_control_matches(host, self.root, self.query.as_deref().unwrap_or_default()) + } else if self.query_kind == LiveCollectionQueryKind::FormImagesByName { + // A retained image list keeps this filter even if a later form[name] + // lookup finds controls. Query kind also distinguishes wrapper caches. + form_named_image_matches(host, self.root, self.query.as_deref().unwrap_or_default()) } else if self.query_kind == LiveCollectionQueryKind::WindowNamedItems { crate::native_bridge::named_access::window_named_item_handles( host.dom_host(), 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 b19e21c6e9..3c4d1d4a2b 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 @@ -101,6 +101,73 @@ fn form_named_lookup_define_property_uses_supported_names_not_visibility() { assert_eq!(result, "ok"); } +#[test] +fn form_named_lookup_live_lists_preserve_their_control_or_image_source() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-image-lists.test/", + "
", + ); + let result = vm.eval(r#" + (() => { + const detachedDocument = document.implementation.createHTMLDocument('forms'); + const forms = [ + document.getElementById('parsed'), + document.createElement('form'), + detachedDocument.body.appendChild(detachedDocument.createElement('form')) + ]; + for (const [mode, form] of forms.entries()) { + const doc = form.ownerDocument; + const first = form.appendChild(doc.createElement('img')); + first.name = 'photos'; + first.id = 'photos'; + const second = form.appendChild(doc.createElement('img')); + second.id = 'photos'; + const nested = form.appendChild(doc.createElement('form')); + nested.appendChild(doc.createElement('img')).name = 'photos'; + const imageInput = form.appendChild(doc.createElement('input')); + imageInput.type = 'image'; + imageInput.name = 'photos'; + const images = form.photos; + const descriptor = Object.getOwnPropertyDescriptor(form, 'photos'); + const descriptorImages = descriptor.value; + if (!(images instanceof RadioNodeList) || images.length !== 2 || images[0] !== first || images[1] !== second) + throw Error(mode + ': image list membership'); + if (descriptor.writable || descriptor.enumerable || !descriptor.configurable || descriptorImages.length !== 2) + throw Error(mode + ': image descriptor'); + if (form.elements.namedItem('photos') !== null) throw Error(mode + ': elements must exclude images'); + const control = form.appendChild(doc.createElement('input')); + control.name = 'photos'; + if (form.photos !== control || images.length !== 2 || images[0] !== first) + throw Error(mode + ': existing image list changed source'); + const otherControl = form.appendChild(doc.createElement('input')); + otherControl.name = 'photos'; + const controls = form.photos; + if (controls.length !== 2 || controls[0] !== control || controls[1] !== otherControl) + throw Error(mode + ': control list membership'); + control.remove(); + otherControl.remove(); + if (controls.length !== 0 || form.photos.length !== 2) throw Error(mode + ': control list fell back to images'); + form.insertBefore(second, first); + if (images[0] !== second || descriptorImages[0] !== second) throw Error(mode + ': image tree order'); + second.id = 'renamed'; + if (images.length !== 1 || images[0] !== first || descriptorImages.length !== 1 || form.photos !== first) + throw Error(mode + ': live image rename'); + first.remove(); + if (images.length !== 0 || descriptorImages.length !== 0) throw Error(mode + ': live image removal'); + second.name = 'photos'; + if (images.length !== 1 || images[0] !== second) throw Error(mode + ': live image insertion'); + if (mode === 2) { + document.body.appendChild(document.adoptNode(form)); + if (images.length !== 1 || images[0] !== second || form.photos !== second) + throw Error('adoption changed image list identity'); + } + } + return 'ok'; + })() + "#).expect("live RadioNodeLists must retain the source selected by the initial named lookup"); + assert_eq!(result, "ok"); +} + #[test] fn form_named_lookup_misses_do_not_enumerate_or_traverse_controls() { let mut vm = new_parsed_test_vm(