From e2abc1644941e8213fcd3bbd0a547844f467b381 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 14 Jul 2026 22:12:47 +0800 Subject: [PATCH] fix(html): expose document named properties --- .../native_bridge/bindings/node_template.rs | 9 +- .../src/native_bridge/identity.rs | 7 + .../src/native_bridge/named_access.rs | 171 ++++++++++++++++++ .../tests/dom_elements/live_document.rs | 70 +++++++ 4 files changed, 256 insertions(+), 1 deletion(-) diff --git a/moli-renderer-v8/src/native_bridge/bindings/node_template.rs b/moli-renderer-v8/src/native_bridge/bindings/node_template.rs index 34a6c3cb4..aba009c4b 100644 --- a/moli-renderer-v8/src/native_bridge/bindings/node_template.rs +++ b/moli-renderer-v8/src/native_bridge/bindings/node_template.rs @@ -6,7 +6,10 @@ mod element_methods; mod node_accessors; mod node_methods; -use crate::{context_bootstrap::bridge_descriptor::BridgeDescriptor, native_bridge::element}; +use crate::{ + context_bootstrap::bridge_descriptor::BridgeDescriptor, + native_bridge::{element, named_access}, +}; pub(super) fn build_node_wrapper_template<'s, 'i>( scope: &mut v8::PinScope<'s, 'i, ()>, @@ -15,6 +18,10 @@ pub(super) fn build_node_wrapper_template<'s, 'i>( let template = v8::ObjectTemplate::new(scope); let _ = template.set_internal_field_count(1); + if descriptor.prototype_name == "HTMLDocument" { + named_access::install_document_named_property_handler(template); + } + node_accessors::install_node_accessors(scope, template, descriptor); document_accessors::install_document_accessors(scope, template, descriptor); element_accessors::install_element_accessors(scope, template, descriptor); diff --git a/moli-renderer-v8/src/native_bridge/identity.rs b/moli-renderer-v8/src/native_bridge/identity.rs index 95fe6daa5..69c821d90 100644 --- a/moli-renderer-v8/src/native_bridge/identity.rs +++ b/moli-renderer-v8/src/native_bridge/identity.rs @@ -173,6 +173,7 @@ pub(super) enum LiveCollectionQueryKind { ClassName, Name, WindowNamedItems, + DocumentNamedItems, DocumentAllNamedItems, FormControlsByName, Forms, @@ -202,6 +203,7 @@ impl LiveCollectionQueryKind { Self::ClassName => "className", Self::Name => "name", Self::WindowNamedItems => "windowNamedItems", + Self::DocumentNamedItems => "documentNamedItems", Self::DocumentAllNamedItems => "documentAllNamedItems", Self::FormControlsByName => "formControlsByName", Self::Forms => "forms", @@ -318,6 +320,11 @@ impl LiveCollectionDescriptor { self.root, self.query.as_deref().unwrap_or_default(), ) + } else if self.query_kind == LiveCollectionQueryKind::DocumentNamedItems { + crate::native_bridge::named_access::document_named_item_handles( + host.dom_host(), + self.query.as_deref().unwrap_or_default(), + ) } else if self.query_kind == LiveCollectionQueryKind::DocumentAllNamedItems { crate::native_bridge::named_access::document_all_named_item_handles( host.dom_host(), diff --git a/moli-renderer-v8/src/native_bridge/named_access.rs b/moli-renderer-v8/src/native_bridge/named_access.rs index 1d2d4d085..f85bc8fdd 100644 --- a/moli-renderer-v8/src/native_bridge/named_access.rs +++ b/moli-renderer-v8/src/native_bridge/named_access.rs @@ -4,6 +4,7 @@ use moli_dom::native::DomHost; use super::{ JsContextHost, collections, identity::{CollectionKind, LiveCollectionQueryKind}, + node::node_runtime_and_handle_from_object, }; const WINDOW_NAME_ELEMENTS: &[&str] = &["img", "form", "embed", "object"]; @@ -51,6 +52,36 @@ pub(crate) fn document_all_named_item_handles(dom: &DomHost, name: &str) -> Vec< }) } +pub(crate) fn document_named_item_handles(dom: &DomHost, name: &str) -> Vec { + // Document named access deliberately has narrower legacy matching than + // Window or HTMLCollection: form/embed/iframe match by name, object by + // name or id, and img by id only while it also has a non-empty name. + // https://html.spec.whatwg.org/multipage/dom.html#dom-document-nameditem + dom.element_handles_by_id_or_name_matching(name, |_| true) + .into_iter() + .filter(|handle| { + let Some(element) = dom + .node(*handle) + .and_then(moli_dom::native::Node::as_element) + else { + return false; + }; + let name_matches = element.name_attribute() == Some(name); + if dom.is_html_element_named(*handle, "img") { + return name_matches + || (element.id() == Some(name) && element.name_attribute().is_some()); + } + if dom.is_html_element_named(*handle, "object") { + return name_matches || element.id() == Some(name); + } + name_matches + && ["embed", "form", "iframe"] + .into_iter() + .any(|local_name| dom.is_html_element_named(*handle, local_name)) + }) + .collect() +} + pub(crate) fn build_window_named_items_collection<'s>( scope: &mut v8::PinScope<'s, '_>, runtime_ptr: *mut JsContextHost, @@ -68,6 +99,146 @@ pub(crate) fn build_window_named_items_collection<'s>( )) } +fn build_document_named_items_collection<'s>( + scope: &mut v8::PinScope<'s, '_>, + runtime_ptr: *mut JsContextHost, + document_handle: DomHandle, + name: &str, +) -> Option> { + Some(collections::build_live_collection_for_node( + scope, + runtime_ptr, + document_handle, + CollectionKind::HtmlCollection, + LiveCollectionQueryKind::DocumentNamedItems, + Some(name.to_owned()), + false, + )) +} + +type DocumentNamedAccessContext = (*mut JsContextHost, DomHandle, String, Vec); + +fn document_named_access_context_for_name( + scope: &mut v8::PinScope<'_, '_>, + name: String, + holder: v8::Local<'_, v8::Object>, +) -> Option { + if name.is_empty() { + return None; + } + let (runtime_ptr, document_handle) = node_runtime_and_handle_from_object(scope, holder).ok()?; + let runtime = unsafe { &*runtime_ptr }; + if !runtime + .dom_host() + .node(document_handle) + .is_some_and(moli_dom::native::Node::is_document) + { + return None; + } + let handles = document_named_item_handles(runtime.dom_host(), &name); + (!handles.is_empty()).then_some((runtime_ptr, document_handle, name, handles)) +} + +fn document_named_access_context( + scope: &mut v8::PinScope<'_, '_>, + key: v8::Local<'_, v8::Name>, + holder: v8::Local<'_, v8::Object>, +) -> Option { + let key = v8::Local::::try_from(key).ok()?; + document_named_access_context_for_name(scope, key.to_rust_string_lossy(scope), holder) +} + +fn document_named_access_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: DocumentNamedAccessContext, +) -> Option> { + let (runtime_ptr, document_handle, name, handles) = context; + match handles.as_slice() { + [handle] => unsafe { &mut *runtime_ptr } + .native_bridge_mut() + .wrap_handle(scope, runtime_ptr, *handle) + .map(Into::into), + _ => build_document_named_items_collection(scope, runtime_ptr, document_handle, &name) + .map(Into::into), + } +} + +fn document_named_property_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + key: v8::Local<'s, v8::Name>, + args: v8::PropertyCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) -> v8::Intercepted { + let Some(context) = document_named_access_context(scope, key, args.holder()) else { + return v8::Intercepted::kNo; + }; + let Some(value) = document_named_access_value(scope, context) else { + return v8::Intercepted::kNo; + }; + rv.set(value); + v8::Intercepted::kYes +} + +fn document_named_property_query<'s>( + scope: &mut v8::PinScope<'s, '_>, + key: v8::Local<'s, v8::Name>, + args: v8::PropertyCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Integer>, +) -> v8::Intercepted { + if document_named_access_context(scope, key, args.holder()).is_none() { + return v8::Intercepted::kNo; + } + rv.set_int32(v8::PropertyAttribute::NONE.as_u32() as i32); + v8::Intercepted::kYes +} + +fn document_indexed_property_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + index: u32, + args: v8::PropertyCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) -> v8::Intercepted { + let Some(context) = + document_named_access_context_for_name(scope, index.to_string(), args.holder()) + else { + return v8::Intercepted::kNo; + }; + let Some(value) = document_named_access_value(scope, context) else { + return v8::Intercepted::kNo; + }; + rv.set(value); + v8::Intercepted::kYes +} + +fn document_indexed_property_query<'s>( + scope: &mut v8::PinScope<'s, '_>, + index: u32, + args: v8::PropertyCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Integer>, +) -> v8::Intercepted { + if document_named_access_context_for_name(scope, index.to_string(), args.holder()).is_none() { + return v8::Intercepted::kNo; + } + rv.set_int32(v8::PropertyAttribute::NONE.as_u32() as i32); + v8::Intercepted::kYes +} + +pub(in crate::native_bridge) fn install_document_named_property_handler( + template: v8::Local<'_, v8::ObjectTemplate>, +) { + template.set_indexed_property_handler( + v8::IndexedPropertyHandlerConfiguration::new() + .getter(document_indexed_property_getter) + .query(document_indexed_property_query), + ); + template.set_named_property_handler( + v8::NamedPropertyHandlerConfiguration::new() + .getter(document_named_property_getter) + .query(document_named_property_query) + .flags(v8::PropertyHandlerFlags::ONLY_INTERCEPT_STRINGS), + ); +} + fn name_elements(kind: LegacyNamedAccessKind) -> &'static [&'static str] { match kind { LegacyNamedAccessKind::Window => WINDOW_NAME_ELEMENTS, diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs index 659256112..8d19e541c 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs @@ -5785,6 +5785,76 @@ fn live_document_all_obeys_legacy_named_and_indexed_semantics() { ); } +#[test] +fn live_document_named_properties_follow_html_candidate_and_liveness_rules() { + let mut vm = new_parsed_test_vm( + "https://example.com/", + r#" + + + + + +
+ + "#, + ); + + let result = vm + .eval( + r##" + (() => { + const image = document.getElementById("imageId"); + const duplicate = document.duplicate; + const duplicateImages = document.querySelectorAll("#duplicate, [name=duplicate]"); + const initial = [ + document.imageId === image, + document.imageName === image, + "imageId" in document, + document.idOnly === undefined, + !("idOnly" in document), + document[42] === document.querySelector("[name='42']"), + document.formName === document.querySelector("form"), + document.formId === undefined, + document.objectId === document.querySelector("object"), + duplicate instanceof HTMLCollection, + duplicate.length, + duplicate[0] === duplicateImages[0], + duplicate[1] === duplicateImages[1], + ]; + + image.removeAttribute("name"); + const removedName = [ + document.imageId === undefined, + document.imageName === undefined, + ]; + + duplicateImages[1].name = "other"; + const narrowed = [ + duplicate.length, + duplicate[0] === duplicateImages[0], + document.duplicate === duplicateImages[0], + document.other === duplicateImages[1], + ]; + + duplicateImages[0].remove(); + const removed = [ + duplicate.length, + document.duplicate === undefined, + !("duplicate" in document), + ]; + return JSON.stringify({ initial, removedName, narrowed, removed }); + })() + "##, + ) + .expect("document named-property probe should evaluate"); + + assert_eq!( + result, + r#"{"initial":[true,true,true,true,true,true,true,true,true,true,2,true,true],"removedName":[true,true],"narrowed":[1,true,true,true],"removed":[0,true,true]}"# + ); +} + #[test] fn legacy_named_access_filters_name_candidates_by_consumer() { let mut vm = new_parsed_test_vm(