From c8c70abdd0e6245c09d886c0a76216d751dcf7f6 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 16 Jul 2026 02:54:30 +0800 Subject: [PATCH] fix(forms): synchronize selectedcontent clones --- moli-dom/src/native/host/collections/form.rs | 8 ++ moli-dom/src/native/mod.rs | 44 ++++++ moli-dom/src/native/queries.rs | 49 +++++++ moli-parser/src/html.rs | 19 +++ moli-parser/src/live_target.rs | 28 ++++ .../src/document_runtime/document_write.rs | 6 + .../src/document_runtime/mutation_commands.rs | 12 +- .../mutation_commands/selectedcontent.rs | 114 +++++++++++++++ .../child_documents/live_parser.rs | 7 + .../element/forms/select/select_element.rs | 2 +- moli-renderer-v8/src/runtime/phase_one/mod.rs | 131 ++++++++++++++++++ .../src/runtime/phase_one/parser_turn.rs | 15 ++ moli-renderer-v8/src/script_vm.rs | 11 ++ .../tests/dom_elements/dom_surface.rs | 65 +++++++++ 14 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 moli-renderer-v8/src/document_runtime/mutation_commands/selectedcontent.rs diff --git a/moli-dom/src/native/host/collections/form.rs b/moli-dom/src/native/host/collections/form.rs index 48038ecdd..bab5c5b39 100644 --- a/moli-dom/src/native/host/collections/form.rs +++ b/moli-dom/src/native/host/collections/form.rs @@ -170,6 +170,14 @@ impl DomHost { self.dom.optgroup_nearest_ancestor_select(handle) } + pub fn selectedcontent_nearest_ancestor_select(&self, handle: DomHandle) -> Option { + self.dom.selectedcontent_nearest_ancestor_select(handle) + } + + pub fn select_selectedcontent_elements(&self, handle: DomHandle) -> Vec { + self.dom.select_selectedcontent_elements(handle) + } + pub fn option_is_disabled(&self, handle: DomHandle) -> bool { self.dom.option_is_disabled(handle) } diff --git a/moli-dom/src/native/mod.rs b/moli-dom/src/native/mod.rs index 70ff08e43..06a4ded54 100644 --- a/moli-dom/src/native/mod.rs +++ b/moli-dom/src/native/mod.rs @@ -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())); diff --git a/moli-dom/src/native/queries.rs b/moli-dom/src/native/queries.rs index 70eb8eec6..2dba8afb4 100644 --- a/moli-dom/src/native/queries.rs +++ b/moli-dom/src/native/queries.rs @@ -95,6 +95,55 @@ impl NativeDom { ) } + pub fn selectedcontent_nearest_ancestor_select( + &self, + selectedcontent_id: NativeNodeId, + ) -> Option { + 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 { + 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, diff --git a/moli-parser/src/html.rs b/moli-parser/src/html.rs index db693e2f2..b22d401b5 100644 --- a/moli-parser/src/html.rs +++ b/moli-parser/src/html.rs @@ -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 `` 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() diff --git a/moli-parser/src/live_target.rs b/moli-parser/src/live_target.rs index 99f1620d9..23865d525 100644 --- a/moli-parser/src/live_target.rs +++ b/moli-parser/src/live_target.rs @@ -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) -> 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::().as_mut() }.finish_parsing_link_children(node_id); } + unsafe fn maybe_clone_an_option_into_selectedcontent_impl( + 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::().as_mut() } + .maybe_clone_an_option_into_selectedcontent(node_id); + } unsafe fn attach_declarative_shadow_for_parser_impl( data: NonNull<()>, host_id: NativeNodeId, @@ -859,6 +871,8 @@ impl ParserDomMutationSink { mark_script_already_started_for_parser: mark_script_already_started_for_parser_impl::, finish_parsing_script_children: finish_parsing_script_children_impl::, finish_parsing_link_children: finish_parsing_link_children_impl::, + maybe_clone_an_option_into_selectedcontent: + maybe_clone_an_option_into_selectedcontent_impl::, attach_declarative_shadow_for_parser: attach_declarative_shadow_for_parser_impl::, associate_parser_form_owner: associate_parser_form_owner_impl::, } @@ -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, diff --git a/moli-renderer-v8/src/document_runtime/document_write.rs b/moli-renderer-v8/src/document_runtime/document_write.rs index 151e3f09d..c8d0d89d6 100644 --- a/moli-renderer-v8/src/document_runtime/document_write.rs +++ b/moli-renderer-v8/src/document_runtime/document_write.rs @@ -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, diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands.rs b/moli-renderer-v8/src/document_runtime/mutation_commands.rs index 9d56b14b4..3ac26bb8d 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -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( diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands/selectedcontent.rs b/moli-renderer-v8/src/document_runtime/mutation_commands/selectedcontent.rs new file mode 100644 index 000000000..b07237ddd --- /dev/null +++ b/moli-renderer-v8/src/document_runtime/mutation_commands/selectedcontent.rs @@ -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, + ) -> 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::>(); + 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::>(); + self.replace_all_children_with_fragment_appending_to_current_reaction_queue( + scope, + host_ptr, + selectedcontent, + fragment, + &existing_children, + ) + } +} diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs index 9244a3da0..e14647296 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs @@ -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, diff --git a/moli-renderer-v8/src/native_bridge/element/forms/select/select_element.rs b/moli-renderer-v8/src/native_bridge/element/forms/select/select_element.rs index 442761026..282af9fd4 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/select/select_element.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/select/select_element.rs @@ -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(); } diff --git a/moli-renderer-v8/src/runtime/phase_one/mod.rs b/moli-renderer-v8/src/runtime/phase_one/mod.rs index c6010fd26..6b0f483d1 100644 --- a/moli-renderer-v8/src/runtime/phase_one/mod.rs +++ b/moli-renderer-v8/src/runtime/phase_one/mod.rs @@ -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#" + + +"#, + ) + .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#"