fix(forms): synchronize selectedcontent clones

This commit is contained in:
ldm0
2026-09-23 08:50:55 +08:00
parent 54bc64c191
commit c8c70abdd0
14 changed files with 508 additions and 3 deletions
@@ -170,6 +170,14 @@ impl DomHost {
self.dom.optgroup_nearest_ancestor_select(handle)
}
pub fn selectedcontent_nearest_ancestor_select(&self, handle: DomHandle) -> Option<DomHandle> {
self.dom.selectedcontent_nearest_ancestor_select(handle)
}
pub fn select_selectedcontent_elements(&self, handle: DomHandle) -> Vec<DomHandle> {
self.dom.select_selectedcontent_elements(handle)
}
pub fn option_is_disabled(&self, handle: DomHandle) -> bool {
self.dom.option_is_disabled(handle)
}
+44
View File
@@ -1603,6 +1603,50 @@ mod tests {
}
}
#[test]
fn selectedcontent_owner_rejects_recursive_clone_boundaries() {
let url = url::Url::parse("https://selectedcontent-owner.test/").unwrap();
let mut host = DomHost::from_dom(NativeDom::new_html(url));
host.reset_html_document_shell();
let body = host.document_body_handle().unwrap();
let select = host.create_element("select");
let button = host.create_element("button");
let selectedcontent = host.create_element("selectedcontent");
assert!(host.append_child(body, select));
assert!(host.append_child(select, button));
assert!(host.append_child(button, selectedcontent));
assert_eq!(
host.selectedcontent_nearest_ancestor_select(selectedcontent),
Some(select)
);
let option = host.create_element("option");
let option_selectedcontent = host.create_element("selectedcontent");
assert!(host.append_child(select, option));
assert!(host.append_child(option, option_selectedcontent));
let nested_selectedcontent = host.create_element("selectedcontent");
assert!(host.append_child(selectedcontent, nested_selectedcontent));
let nested_select = host.create_element("select");
let nested_select_selectedcontent = host.create_element("selectedcontent");
assert!(host.append_child(select, nested_select));
assert!(host.append_child(nested_select, nested_select_selectedcontent));
for rejected in [
option_selectedcontent,
nested_selectedcontent,
nested_select_selectedcontent,
] {
assert_eq!(host.selectedcontent_nearest_ancestor_select(rejected), None);
}
assert_eq!(
host.select_selectedcontent_elements(select),
vec![selectedcontent]
);
}
#[test]
fn selected_option_insertion_deselects_single_select_peers_without_dirtying_them() {
let mut host = DomHost::from_dom(NativeDom::new_html(test_url()));
+49
View File
@@ -95,6 +95,55 @@ impl NativeDom {
)
}
pub fn selectedcontent_nearest_ancestor_select(
&self,
selectedcontent_id: NativeNodeId,
) -> Option<NativeNodeId> {
if !self
.node(selectedcontent_id)
.and_then(Node::as_element)
.is_some_and(|element| element.is_html_element("selectedcontent"))
{
return None;
}
let mut nearest_select = None;
let mut current = self.parent_node(selectedcontent_id);
while let Some(parent) = current {
let Some(element) = self.node(parent).and_then(Node::as_element) else {
current = self.parent_node(parent);
continue;
};
if element.is_html_option() || element.is_html_element("selectedcontent") {
return None;
}
if element.is_html_select() {
if nearest_select.is_some() {
return None;
}
nearest_select = Some(parent);
}
current = self.parent_node(parent);
}
nearest_select
}
pub fn select_selectedcontent_elements(&self, select_id: NativeNodeId) -> Vec<NativeNodeId> {
if !self
.node(select_id)
.and_then(Node::as_element)
.is_some_and(Element::is_html_select)
{
return Vec::new();
}
self.elements_by_tag_name(select_id, "selectedcontent", false)
.into_iter()
.filter(|selectedcontent| {
self.selectedcontent_nearest_ancestor_select(*selectedcontent) == Some(select_id)
})
.collect()
}
fn nearest_ancestor_select(
&self,
element_id: NativeNodeId,
+19
View File
@@ -1405,6 +1405,12 @@ impl ParseHandle {
})
}
fn is_html_option_element(&self) -> bool {
self.element_name.as_ref().is_some_and(|name| {
name.local.as_ref() == "option" && name.ns.as_ref() == "http://www.w3.org/1999/xhtml"
})
}
pub(super) fn node_id(&self) -> NativeNodeId {
self.dom_node_id()
.expect("parser operation requires a real DOM node handle")
@@ -1808,10 +1814,23 @@ impl TreeSink for DocumentSink {
}
}
fn maybe_clone_an_option_into_selectedcontent(&self, option: &Self::Handle) {
if let Some(option_id) = option.dom_node_id() {
self.target
.borrow_mut()
.maybe_clone_an_option_into_selectedcontent(option_id);
}
}
fn pop(&self, node: &Self::Handle) {
// html5ever calls `pop()` when the element has been fully closed by the parser. For
// classic parser-inserted scripts that is the earliest safe moment to hand execution back
// to the runtime without risking partial inline source or a half-built element subtree.
// Its explicit `</option>` path invokes the selectedcontent hook itself, but implicit
// stack pops (including EOF) only arrive here, so route those through the same hook.
if node.is_html_option_element() {
self.maybe_clone_an_option_into_selectedcontent(node);
}
if let Some(node_id) = node.dom_node_id() {
self.target
.borrow_mut()
+28
View File
@@ -628,6 +628,8 @@ pub trait ParserDomMutationConsumer {
fn finish_parsing_link_children(&mut self, node_id: NativeNodeId);
fn maybe_clone_an_option_into_selectedcontent(&mut self, _node_id: NativeNodeId) {}
fn attach_declarative_shadow_for_parser(
&mut self,
host_id: NativeNodeId,
@@ -659,6 +661,7 @@ struct ParserDomMutationSink {
mark_script_already_started_for_parser: unsafe fn(NonNull<()>, NativeNodeId),
finish_parsing_script_children: unsafe fn(NonNull<()>, NativeNodeId),
finish_parsing_link_children: unsafe fn(NonNull<()>, NativeNodeId),
maybe_clone_an_option_into_selectedcontent: unsafe fn(NonNull<()>, NativeNodeId),
attach_declarative_shadow_for_parser:
unsafe fn(NonNull<()>, NativeNodeId, NativeNodeId, Vec<NativeAttribute>) -> bool,
associate_parser_form_owner: unsafe fn(NonNull<()>, NativeNodeId, NativeNodeId) -> bool,
@@ -817,6 +820,15 @@ impl ParserDomMutationSink {
// pointed-to consumer to remain live and exclusive for the pump step.
unsafe { data.cast::<T>().as_mut() }.finish_parsing_link_children(node_id);
}
unsafe fn maybe_clone_an_option_into_selectedcontent_impl<T: ParserDomMutationConsumer>(
data: NonNull<()>,
node_id: NativeNodeId,
) {
// SAFETY: ParserDomMutationSink::from_consumer requires the
// pointed-to consumer to remain live and exclusive for the pump step.
unsafe { data.cast::<T>().as_mut() }
.maybe_clone_an_option_into_selectedcontent(node_id);
}
unsafe fn attach_declarative_shadow_for_parser_impl<T: ParserDomMutationConsumer>(
data: NonNull<()>,
host_id: NativeNodeId,
@@ -859,6 +871,8 @@ impl ParserDomMutationSink {
mark_script_already_started_for_parser: mark_script_already_started_for_parser_impl::<T>,
finish_parsing_script_children: finish_parsing_script_children_impl::<T>,
finish_parsing_link_children: finish_parsing_link_children_impl::<T>,
maybe_clone_an_option_into_selectedcontent:
maybe_clone_an_option_into_selectedcontent_impl::<T>,
attach_declarative_shadow_for_parser: attach_declarative_shadow_for_parser_impl::<T>,
associate_parser_form_owner: associate_parser_form_owner_impl::<T>,
}
@@ -981,6 +995,12 @@ impl ParserDomMutationSink {
unsafe { (self.finish_parsing_link_children)(self.data, node_id) };
}
fn maybe_clone_an_option_into_selectedcontent(self, node_id: NativeNodeId) {
// SAFETY: construction ties the raw pointer and callback to the same
// consumer remains live for the current runtime-DOM sink step.
unsafe { (self.maybe_clone_an_option_into_selectedcontent)(self.data, node_id) };
}
fn attach_declarative_shadow_for_parser(
self,
host_id: NativeNodeId,
@@ -2864,6 +2884,14 @@ impl ParserStreamHtmlTreeSinkTarget {
}
}
pub(super) fn maybe_clone_an_option_into_selectedcontent(&mut self, node_id: NativeNodeId) {
if let Some(owner) = &self.runtime_dom_sinks {
owner
.dom_mutation_sink()
.maybe_clone_an_option_into_selectedcontent(node_id);
}
}
fn attach_declarative_shadow_for_dom_host(
&mut self,
host_id: NativeNodeId,
@@ -322,6 +322,12 @@ impl ParserDomMutationConsumer for DocumentWriteParserMutationOwner<'_, '_, '_>
.finish_parsing_link_children(node_id);
}
fn maybe_clone_an_option_into_selectedcontent(&mut self, node_id: DomHandle) {
let _ = self
.runtime
.sync_selectedcontents_after_parser_option_finished(self.scope, self.host_ptr, node_id);
}
fn attach_declarative_shadow_for_parser(
&mut self,
host_id: DomHandle,
@@ -13,6 +13,7 @@ use moli_selector::stylo_flat_tree_heading_descendants;
use super::*;
mod details;
mod selectedcontent;
mod tree;
#[cfg(test)]
@@ -1769,8 +1770,15 @@ impl DocumentRuntime {
.set_select_explicit_none_state(handle, explicit_none)
}
pub(crate) fn set_select_value(&mut self, handle: DomHandle, value: &str) -> bool {
self.dom_host.set_select_value(handle, value)
pub(crate) fn set_select_value(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
host_ptr: *mut JsContextHost,
handle: DomHandle,
value: &str,
) -> bool {
let changed = self.dom_host.set_select_value(handle, value);
self.sync_selectedcontents_for_select_in_reaction_scope(scope, host_ptr, handle) || changed
}
pub(crate) fn set_script_async(
@@ -0,0 +1,114 @@
use crate::{
custom_elements,
document_runtime::{DocumentRuntime, DomHandle},
dom::native::Node,
native_bridge::JsContextHost,
};
impl DocumentRuntime {
pub(crate) fn sync_selectedcontents_for_select_in_reaction_scope(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
host_ptr: *mut JsContextHost,
select: DomHandle,
) -> bool {
custom_elements::with_custom_element_reaction_scope(scope, host_ptr, |scope| {
self.sync_selectedcontents_for_select_appending_to_current_reaction_queue(
scope, host_ptr, select,
)
})
}
pub(crate) fn sync_selectedcontents_after_parser_option_finished(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
host_ptr: *mut JsContextHost,
option: DomHandle,
) -> bool {
let Some(select) = self.dom_host.option_nearest_ancestor_select(option) else {
return false;
};
if self
.dom_host
.select_selected_option_elements(select)
.first()
.copied()
!= Some(option)
{
return false;
}
self.sync_selectedcontents_for_select_appending_to_current_reaction_queue(
scope, host_ptr, select,
)
}
pub(crate) fn sync_selectedcontents_for_select_appending_to_current_reaction_queue(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
host_ptr: *mut JsContextHost,
select: DomHandle,
) -> bool {
let Some(select_element) = self.dom_host.node(select).and_then(Node::as_element) else {
return false;
};
if !select_element.is_html_select() || select_element.has_attribute("multiple") {
return false;
}
let selected_option = self
.dom_host
.select_selected_option_elements(select)
.first()
.copied();
let targets = self.dom_host.select_selectedcontent_elements(select);
let mut changed = false;
for target in targets {
changed |= self.clone_selected_option_contents_into_selectedcontent(
scope,
host_ptr,
target,
selected_option,
);
}
changed
}
fn clone_selected_option_contents_into_selectedcontent(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
host_ptr: *mut JsContextHost,
selectedcontent: DomHandle,
selected_option: Option<DomHandle>,
) -> bool {
let Some(document) = self.dom_host.owner_document_handle(selectedcontent) else {
return false;
};
let fragment = self.create_document_fragment_for_document(document);
if let Some(option) = selected_option {
let source_children = self.dom_host.child_handles(option).collect::<Vec<_>>();
for source_child in source_children {
let Some(clone) = self.clone_node(scope, host_ptr, source_child, true) else {
return false;
};
if !self
.dom_host
.append_child_without_mutation_effects(fragment, clone)
{
return false;
}
}
}
let existing_children = self
.dom_host
.child_handles(selectedcontent)
.collect::<Vec<_>>();
self.replace_all_children_with_fragment_appending_to_current_reaction_queue(
scope,
host_ptr,
selectedcontent,
fragment,
&existing_children,
)
}
}
@@ -467,6 +467,13 @@ impl ParserDomMutationConsumer for ChildFrameLiveParserOwner<'_, '_, '_> {
.finish_parsing_link_children(node_id);
}
fn maybe_clone_an_option_into_selectedcontent(&mut self, node_id: DomHandle) {
let host_ptr = self.host as *mut JsContextHost;
let _ = self
.host
.sync_selectedcontents_after_parser_option_finished(self.scope, host_ptr, node_id);
}
fn attach_declarative_shadow_for_parser(
&mut self,
host_id: DomHandle,
@@ -697,7 +697,7 @@ pub(in crate::native_bridge) fn select_value_setter_function<'s>(
else {
return;
};
let _ = unsafe { &mut *runtime_ptr }.set_select_value(handle, &next_value);
let _ = unsafe { &mut *runtime_ptr }.set_select_value(scope, runtime_ptr, handle, &next_value);
rv.set_undefined();
}
@@ -75,6 +75,7 @@ use self::parser_turn::{PageTaskTurnResult, ParserDriver};
#[cfg(test)]
use self::parser_turn::{
ParserStepAdvanceOutcome, ScriptHandoffOutcome, bind_parser_owned_script_handle,
finish_parser_session_for_test,
};
pub(super) use self::pending_residence::{PendingPhaseOneResidence, PendingPhaseOneResumeOutcome};
pub(super) use self::state::ConcurrentParseTimeRuntime;
@@ -440,6 +441,23 @@ mod tests {
pub(super) async fn parse_phase_one_html_into_page_vm_for_test_with_env(
html: &'static str,
env: PageVmEnvConfig,
) -> PageVm {
parse_phase_one_html_into_page_vm_for_test_with_env_and_finish(html, env, false).await
}
async fn parse_finished_phase_one_html_into_page_vm_for_test(html: &'static str) -> PageVm {
parse_phase_one_html_into_page_vm_for_test_with_env_and_finish(
html,
default_test_page_vm_env_config(),
true,
)
.await
}
async fn parse_phase_one_html_into_page_vm_for_test_with_env_and_finish(
html: &'static str,
env: PageVmEnvConfig,
finish_after_step: bool,
) -> PageVm {
let PhaseOnePageVmHarness {
mut page_vm,
@@ -472,6 +490,12 @@ mod tests {
.await
.expect("parser step should complete");
assert!(matches!(outcome, ParserStepAdvanceOutcome::Continue));
if finish_after_step {
driver.parser_session.request_finish();
page_vm.vm_mut().with_dom_host_parse_step(|vm| {
finish_parser_session_for_test(driver.parser_session, vm)
});
}
page_vm
}
@@ -12411,6 +12435,113 @@ JSON.stringify({
}));
}
#[test]
fn parser_option_finish_and_select_value_sync_selectedcontent_clones() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread runtime should build");
runtime.block_on(tokio::task::LocalSet::new().run_until(async move {
let mut page_vm = parse_phase_one_html_into_page_vm_for_test(
r#"<!doctype html><html><body>
<select id="select">
<button><selectedcontent id="selectedcontent">default</selectedcontent></button>
<div><option id="one"><span id="source-span">one</span></option></div>
<div><option id="two"><strong>two</strong></option></div>
</select>
<script>
const select = document.getElementById('select');
const selectedcontent = document.getElementById('selectedcontent');
const sourceSpan = document.querySelector('#one > span');
window.selectedcontentState = [
selectedcontent.textContent.trim(),
selectedcontent.firstElementChild !== sourceSpan,
];
select.value = 'two';
window.selectedcontentState.push(
selectedcontent.textContent.trim(),
selectedcontent.firstElementChild.tagName,
);
</script>
</body></html>"#,
)
.await;
let result = page_vm
.evaluate_expression("JSON.stringify(window.selectedcontentState)")
.expect("selectedcontent parser state should evaluate");
assert_eq!(
result.get("value").and_then(serde_json::Value::as_str),
Some(r#"["one",true,"two","STRONG"]"#),
"parser option completion and select.value must synchronously clone the selected option children"
);
}));
}
#[test]
fn parser_eof_option_finish_syncs_selectedcontent_clones() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread runtime should build");
runtime.block_on(tokio::task::LocalSet::new().run_until(async move {
let mut text_page_vm = parse_finished_phase_one_html_into_page_vm_for_test(
r#"<select><button><selectedcontent></button><option>X"#,
)
.await;
let text_result = text_page_vm
.evaluate_expression(
r#"
(() => {
const selectedcontent = document.querySelector('selectedcontent');
const source = document.querySelector('option');
return [
selectedcontent.textContent,
selectedcontent.firstChild !== source.firstChild
].join('|');
})()
"#,
)
.expect("EOF-closed text option selectedcontent state should evaluate");
assert_eq!(
text_result.get("value").and_then(serde_json::Value::as_str),
Some("X|true"),
"EOF-closing an option must clone its text into selectedcontent"
);
let mut nested_page_vm = parse_finished_phase_one_html_into_page_vm_for_test(
r#"<select><button><selectedcontent></button><option>x<i>i<b>ib</i>b"#,
)
.await;
let nested_result = nested_page_vm
.evaluate_expression(
r#"
(() => {
const selectedcontent = document.querySelector('selectedcontent');
const source = document.querySelector('option');
return [
selectedcontent.textContent,
selectedcontent.innerHTML === source.innerHTML,
selectedcontent.firstChild !== source.firstChild,
selectedcontent.querySelector('i') !== source.querySelector('i'),
selectedcontent.querySelectorAll('b').length
].join('|');
})()
"#,
)
.expect("EOF-closed nested option selectedcontent state should evaluate");
assert_eq!(
nested_result
.get("value")
.and_then(serde_json::Value::as_str),
Some("xiibb|true|true|true|2"),
"EOF-closing an option must deep-clone its parsed children into selectedcontent"
);
}));
}
#[test]
fn parser_merged_root_attributes_hide_nonce_content_values() {
let runtime = tokio::runtime::Builder::new_current_thread()
@@ -346,6 +346,12 @@ impl ParserDomMutationConsumer for PhaseOneParserOwner<'_> {
.finish_parsing_link_children(node_id);
}
fn maybe_clone_an_option_into_selectedcontent(&mut self, node_id: NativeNodeId) {
let _ = self
.vm
.sync_selectedcontents_after_parser_option_finished_in_default_context(node_id);
}
fn attach_declarative_shadow_for_parser(
&mut self,
host_id: NativeNodeId,
@@ -387,6 +393,15 @@ impl ParserElementCreationConsumer for PhaseOneParserOwner<'_> {
}
}
#[cfg(test)]
pub(super) fn finish_parser_session_for_test(
parser_session: &mut DocumentParserSession,
vm: &mut ScriptVm,
) {
let mut parser_owner = PhaseOneParserOwner { vm };
let _ = parser_session.finish(&mut parser_owner);
}
pub(super) enum PageTaskTurnResult {
/// No parse-visible task was runnable.
NoTask,
+11
View File
@@ -5816,6 +5816,17 @@ impl ScriptVm {
})
}
pub(crate) fn sync_selectedcontents_after_parser_option_finished_in_default_context(
&mut self,
option: NativeNodeId,
) -> Result<()> {
self.with_default_context_scope(|scope, host_ptr| {
unsafe { &mut *host_ptr }
.sync_selectedcontents_after_parser_option_finished(scope, host_ptr, option);
Ok(())
})
}
pub(crate) fn apply_parser_created_null_registry_associations_in_default_context(
&mut self,
handles: &[NativeNodeId],
@@ -6577,6 +6577,71 @@ async fn element_matches_delegates_loaded_child_document_elements() {
);
}
#[tokio::test]
async fn child_parser_eof_syncs_selectedcontent_for_navigation_and_document_write() {
let mut vm = new_storage_test_vm("https://child-selectedcontent-parser.test/");
vm.eval(
r#"
(() => {
const frame = document.createElement("iframe");
frame.srcdoc = "<select><button><selectedcontent></button><option>X";
(document.body || document.documentElement || document).appendChild(frame);
})()
"#,
)
.expect("child selectedcontent navigation setup should evaluate");
run_child_navigation_commit_and_host_load_for_test(&mut vm, "child selectedcontent navigation")
.await;
assert_eq!(
vm.eval(
r#"
(() => {
const doc = document.querySelector("iframe").contentDocument;
const selectedcontent = doc.querySelector("selectedcontent");
const source = doc.querySelector("option");
return [selectedcontent.textContent, selectedcontent.firstChild !== source.firstChild].join("|");
})()
"#,
)
.expect("child selectedcontent navigation state should evaluate"),
"X|true"
);
vm.eval(
r#"
(() => {
const doc = document.querySelector("iframe").contentDocument;
doc.open();
doc.write("<select><button><selectedcontent></button><option>x<i>i<b>ib</i>b");
doc.close();
})()
"#,
)
.expect("child selectedcontent document.write setup should evaluate");
assert_eq!(
vm.eval(
r#"
(() => {
const doc = document.querySelector("iframe").contentDocument;
const selectedcontent = doc.querySelector("selectedcontent");
const source = doc.querySelector("option");
return [
selectedcontent.textContent,
selectedcontent.innerHTML === source.innerHTML,
selectedcontent.firstChild !== source.firstChild,
selectedcontent.querySelector("i") !== source.querySelector("i"),
selectedcontent.querySelectorAll("b").length
].join("|");
})()
"#,
)
.expect("child selectedcontent document.write state should evaluate"),
"xiibb|true|true|true|2"
);
}
#[tokio::test]
async fn child_navigation_performance_name_updates_after_iframe_src_change() {
let mut vm = new_storage_test_vm("https://child-navigation-performance.test/page.html");