fix(html): expose document named properties

This commit is contained in:
ldm0
2026-09-27 19:22:01 +08:00
parent c02a8c0e06
commit e2abc16449
4 changed files with 256 additions and 1 deletions
@@ -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);
@@ -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(),
@@ -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<DomHandle> {
// 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<v8::Local<'s, v8::Object>> {
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<DomHandle>);
fn document_named_access_context_for_name(
scope: &mut v8::PinScope<'_, '_>,
name: String,
holder: v8::Local<'_, v8::Object>,
) -> Option<DocumentNamedAccessContext> {
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<DocumentNamedAccessContext> {
let key = v8::Local::<v8::String>::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<v8::Local<'s, v8::Value>> {
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,
@@ -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#"<!doctype html><html><body>
<img id="imageId" name="imageName">
<img id="duplicate" name="duplicate">
<img name="duplicate">
<img id="idOnly">
<img name="42">
<form id="formId" name="formName"></form>
<object id="objectId"></object>
</body></html>"#,
);
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(