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.
This commit is contained in:
ldm0
2026-09-29 08:52:04 +08:00
parent 8751291e45
commit b7abc1dcaa
5 changed files with 111 additions and 29 deletions
@@ -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::{
@@ -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::{
@@ -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<DomHandle> {
) -> (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) {
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<DomHandle> {
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::<Vec<_>>();
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,
+11 -11
View File
@@ -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(),
@@ -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/",
"<!doctype html><html><body><form id='parsed'></form></body></html>",
);
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(