diff --git a/moli-dom/src/native/element/control_state.rs b/moli-dom/src/native/element/control_state.rs index 35d656c8b9..03d99cd2e6 100644 --- a/moli-dom/src/native/element/control_state.rs +++ b/moli-dom/src/native/element/control_state.rs @@ -182,6 +182,33 @@ pub struct ElementControlState { } impl ElementControlState { + pub(super) fn native_node_references(&self) -> Vec { + let mut references = Vec::new(); + references.extend(self.dialog_previously_focused_element); + if let Some(explicit) = &self.explicit_element_references { + for elements in explicit.references_by_attribute.values() { + references.extend(elements); + } + } + references + } + + pub(super) fn remap_native_node_references( + &mut self, + handles: &HashMap, + ) { + if let Some(focused) = self.dialog_previously_focused_element.as_mut() { + *focused = handles[focused]; + } + if let Some(explicit) = self.explicit_element_references.as_mut() { + for elements in explicit.references_by_attribute.values_mut() { + for element in elements { + *element = handles[element]; + } + } + } + } + pub fn web_mcp_form_active(&self) -> bool { self.web_mcp_form_active } diff --git a/moli-dom/src/native/element/mod.rs b/moli-dom/src/native/element/mod.rs index 13fdbfb932..73284e2bcf 100644 --- a/moli-dom/src/native/element/mod.rs +++ b/moli-dom/src/native/element/mod.rs @@ -96,6 +96,17 @@ pub struct Element { } impl Element { + pub(crate) fn native_node_references(&self) -> Vec { + self.rare_data.native_node_references() + } + + pub(crate) fn remap_native_node_references( + &mut self, + handles: &std::collections::HashMap, + ) { + self.rare_data.remap_native_node_references(handles); + } + pub fn svg_user_transform(&self) -> SvgUserTransform { self.rare_data.svg_user_transform() } diff --git a/moli-dom/src/native/element/rare_data.rs b/moli-dom/src/native/element/rare_data.rs index b64a2d1d9f..a0aec4fefc 100644 --- a/moli-dom/src/native/element/rare_data.rs +++ b/moli-dom/src/native/element/rare_data.rs @@ -50,6 +50,37 @@ impl ElementRareDataPayload { } impl ElementRareData { + pub(super) fn native_node_references(&self) -> Vec { + let Some(payload) = self.payload.as_deref() else { + return Vec::new(); + }; + let mut references = Vec::new(); + references.extend(payload.parser_associated_form_owner); + references.extend(payload.template_contents); + if let Some(control) = &payload.control_state { + references.extend(control.native_node_references()); + } + references + } + + pub(super) fn remap_native_node_references( + &mut self, + handles: &std::collections::HashMap, + ) { + let Some(payload) = self.payload.as_deref_mut() else { + return; + }; + if let Some(owner) = payload.parser_associated_form_owner.as_mut() { + *owner = handles[owner]; + } + if let Some(contents) = payload.template_contents.as_mut() { + *contents = handles[contents]; + } + if let Some(control) = payload.control_state.as_mut() { + control.remap_native_node_references(handles); + } + } + pub(super) fn svg_user_transform(&self) -> SvgUserTransform { self.payload .as_deref() diff --git a/moli-dom/src/native/host.rs b/moli-dom/src/native/host.rs index ea970b0434..1c8b961b5c 100644 --- a/moli-dom/src/native/host.rs +++ b/moli-dom/src/native/host.rs @@ -22,6 +22,7 @@ mod query_index; mod retarget; mod stylesheet_candidates; mod svg_user_transform; +mod transfer; mod types; pub use html_serialization::{ShadowRootInclusion, ShadowRootRegistryAttributePolicy}; @@ -45,6 +46,7 @@ use self::types::{ }; pub use self::types::{ConnectedShadowRootSnapshot, DomHandle, DomHost}; pub use self::types::{HostElementSnapshot, ShadowRootBindingSnapshot, ShadowRootInit}; +pub use transfer::{DomSubtreeTransfer, DomSubtreeTransferError}; fn is_html_frame_owner_candidate(local_name: &str, namespace: &str) -> bool { (namespace.is_empty() || namespace == "http://www.w3.org/1999/xhtml") diff --git a/moli-dom/src/native/host/document.rs b/moli-dom/src/native/host/document.rs index 93e5726333..4a93829e4a 100644 --- a/moli-dom/src/native/host/document.rs +++ b/moli-dom/src/native/host/document.rs @@ -245,7 +245,7 @@ impl DomHost { } } - fn record_shadow_root_binding_mutation(&self) { + pub(super) fn record_shadow_root_binding_mutation(&self) { self.shadow_root_binding_version .set(self.shadow_root_binding_version.get().saturating_add(1)); self.connected_shadow_roots_cache.borrow_mut().take(); diff --git a/moli-dom/src/native/host/stylesheet_candidates.rs b/moli-dom/src/native/host/stylesheet_candidates.rs index ec394895c5..07c3706346 100644 --- a/moli-dom/src/native/host/stylesheet_candidates.rs +++ b/moli-dom/src/native/host/stylesheet_candidates.rs @@ -100,6 +100,29 @@ impl StylesheetCandidateTransition { } impl NativeDom { + pub(super) fn transfer_stylesheet_candidate_scopes_from( + &mut self, + source: &mut Self, + handles: &HashMap, + ) { + let source_scopes = Arc::make_mut( + &mut source + .stylesheet_candidate_registries + .candidates_by_tree_scope, + ); + let target_scopes = Arc::make_mut( + &mut self + .stylesheet_candidate_registries + .candidates_by_tree_scope, + ); + for (old, new) in handles { + if let Some(candidates) = source_scopes.remove(old) { + let moved = candidates.iter().map(|handle| handles[handle]).collect(); + target_scopes.insert(*new, Arc::new(moved)); + } + } + } + pub fn stylesheet_candidate_handles_for_tree_scope( &self, tree_scope: NativeNodeId, diff --git a/moli-dom/src/native/host/transfer.rs b/moli-dom/src/native/host/transfer.rs new file mode 100644 index 0000000000..42d1f022e3 --- /dev/null +++ b/moli-dom/src/native/host/transfer.rs @@ -0,0 +1,285 @@ +use super::*; + +/// Native identities to rebind after moving an ownership graph between hosts. +/// V8 reflectors, callbacks, observers, resources and registry state belong to +/// the renderer and must migrate separately before exposing the moved graph. +#[derive(Debug, PartialEq, Eq)] +pub struct DomSubtreeTransfer { + root: DomHandle, + handles: HashMap, +} + +impl DomSubtreeTransfer { + pub fn root(&self) -> DomHandle { + self.root + } + + pub fn handles(&self) -> &HashMap { + &self.handles + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DomSubtreeTransferError { + InvalidTargetDocument(DomHandle), + InvalidSource(DomHandle), + AttachedSource(DomHandle), + InvalidTree(DomHandle), + OpenReference { + node: DomHandle, + referenced: DomHandle, + }, + HandleCapacity, +} + +#[derive(Clone, Copy)] +enum TransferDocument { + Target, + TemplateContents, +} + +struct TransferPlan { + nodes: Vec<(DomHandle, TransferDocument)>, + membership: HashSet, +} + +impl DomHost { + /// Move a detached, closed native ownership graph without DOM clone steps. + /// + /// Includes all shadow roots (also closed/non-clonable), template contents, + /// manual slot assignments and native element state. Old source slots are + /// retired, never reused; snapshots keep their immutable source view. + /// + /// This is the native stage of adoption, not a Web API entrypoint. The + /// caller must first perform removal and its lifecycle/observer work, and + /// then rebind renderer identities using the returned map. References that + /// cross the graph boundary currently require a wider ownership service; + /// they are rejected before either host changes instead of being dropped + /// or accidentally rebound to a colliding host-local handle. + pub fn transfer_detached_subtree_from( + &mut self, + document: DomHandle, + source: &mut Self, + root: DomHandle, + ) -> Result { + if !self.node(document).is_some_and(Node::is_document) { + return Err(DomSubtreeTransferError::InvalidTargetDocument(document)); + } + let plan = source.preflight_subtree_transfer(root)?; + let needs_template_document = plan + .nodes + .iter() + .any(|(_, owner)| matches!(owner, TransferDocument::TemplateContents)); + let capacity = self + .dom + .len() + .checked_add(plan.nodes.len()) + .and_then(|count| { + count.checked_add(usize::from( + needs_template_document + && !self.dom.inert_template_documents.contains_key(&document), + )) + }); + if capacity.is_none_or(|count| count > u32::MAX as usize) { + return Err(DomSubtreeTransferError::HandleCapacity); + } + // Every fallible check precedes destination allocation or source removal. + let template_document = if needs_template_document { + self.dom + .appropriate_template_contents_owner_document(document) + } else { + document + }; + let first_slot = self.dom.len(); + let handles: HashMap<_, _> = plan + .nodes + .iter() + .enumerate() + .map(|(offset, (old, _))| (*old, DomHandle::new(first_slot + offset))) + .collect(); + for (old, owner) in &plan.nodes { + let mut node = source + .dom + .nodes + .take(old.index()) + .expect("preflight retained every source node"); + let document = match owner { + TransferDocument::Target => document, + TransferDocument::TemplateContents => template_document, + }; + node.remap_for_transfer(handles[old], document, &handles); + self.dom.nodes.push(node); + } + self.transfer_shadow_bindings_from(source, &handles); + self.dom + .transfer_stylesheet_candidate_scopes_from(&mut source.dom, &handles); + for old in &plan.membership { + if let Some(assigned) = source.manual_slot_assignments.borrow_mut().remove(old) { + let assigned = assigned.into_iter().map(|node| handles[&node]).collect(); + self.manual_slot_assignments + .borrow_mut() + .insert(handles[old], assigned); + } + } + source + .child_browsing_context_host_candidates + .borrow_mut() + .retain(|handle| !plan.membership.contains(handle)); + for (old, _) in &plan.nodes { + let new = handles[old]; + if self + .node(new) + .and_then(Node::as_element) + .is_some_and(|element| { + is_html_frame_owner_candidate(element.local_name(), element.namespace()) + }) + { + self.child_browsing_context_host_candidates + .borrow_mut() + .push(new); + } + } + if source + .active_element + .get() + .is_some_and(|node| plan.membership.contains(&node)) + { + source.active_element.set(None); + } + if source + .focus_transition_common_ancestor + .get() + .is_some_and(|node| plan.membership.contains(&node)) + { + source.focus_transition_common_ancestor.set(None); + } + source + .hovered_elements + .borrow_mut() + .retain(|node| !plan.membership.contains(node)); + source.invalidate_indexes_after_transfer(); + self.invalidate_indexes_after_transfer(); + Ok(DomSubtreeTransfer { + root: handles[&root], + handles, + }) + } + + fn preflight_subtree_transfer( + &self, + root: DomHandle, + ) -> Result { + let node = self + .node(root) + .ok_or(DomSubtreeTransferError::InvalidSource(root))?; + if node.is_document() || self.is_shadow_root(root) { + return Err(DomSubtreeTransferError::InvalidSource(root)); + } + if node.parent_node().is_some() || node.is_connected() { + return Err(DomSubtreeTransferError::AttachedSource(root)); + } + let mut plan = TransferPlan { + nodes: Vec::new(), + membership: HashSet::new(), + }; + let mut stack = vec![(root, TransferDocument::Target)]; + while let Some((handle, owner)) = stack.pop() { + let node = self + .node(handle) + .ok_or(DomSubtreeTransferError::InvalidTree(handle))?; + if !plan.membership.insert(handle) || node.is_document() || node.is_connected() { + return Err(DomSubtreeTransferError::InvalidTree(handle)); + } + plan.nodes.push((handle, owner)); + let mut child = node.first_child(); + let mut siblings = HashSet::new(); + while let Some(handle) = child { + if !siblings.insert(handle) || self.parent_node(handle) != Some(node.id()) { + return Err(DomSubtreeTransferError::InvalidTree(handle)); + } + stack.push((handle, owner)); + child = self + .node(handle) + .ok_or(DomSubtreeTransferError::InvalidTree(handle))? + .next_sibling(); + } + if let Some(shadow) = self.shadow_root_handle(handle) { + stack.push((shadow, owner)); + } + if let Some(contents) = node.as_element().and_then(Element::template_contents) { + stack.push((contents, TransferDocument::TemplateContents)); + } + } + // Both outgoing and incoming host-local references must be closed. + for node in self.dom.nodes() { + for referenced in node.native_node_references() { + if plan.membership.contains(&node.id()) != plan.membership.contains(&referenced) { + return Err(DomSubtreeTransferError::OpenReference { + node: node.id(), + referenced, + }); + } + } + } + for (slot, assigned) in self.manual_slot_assignments.borrow().iter() { + for &referenced in assigned { + if plan.membership.contains(slot) != plan.membership.contains(&referenced) { + return Err(DomSubtreeTransferError::OpenReference { + node: *slot, + referenced, + }); + } + } + } + for &(node, _) in &plan.nodes { + for referenced in self + .dom + .stylesheet_candidate_handles_for_tree_scope(node) + .iter() + { + if !plan.membership.contains(referenced) { + return Err(DomSubtreeTransferError::OpenReference { + node, + referenced: *referenced, + }); + } + } + } + Ok(plan) + } + + fn transfer_shadow_bindings_from( + &mut self, + source: &mut Self, + handles: &HashMap, + ) { + let mut changed = false; + for (old, new) in handles { + if let Some(mut state) = source.shadow_roots_by_host.borrow_mut().remove(old) { + let old_root = state.handle; + state.handle = handles[&old_root]; + source.shadow_hosts_by_root.borrow_mut().remove(&old_root); + self.shadow_hosts_by_root + .borrow_mut() + .insert(state.handle, *new); + self.shadow_roots_by_host.borrow_mut().insert(*new, state); + changed = true; + } + } + if changed { + source.record_shadow_root_binding_mutation(); + self.record_shadow_root_binding_mutation(); + } + } + + fn invalidate_indexes_after_transfer(&self) { + self.id_index.borrow_mut().take(); + self.name_index.borrow_mut().take(); + *self.element_query_index.borrow_mut() = ElementQueryIndex::default(); + self.shadow_slot_name_indexes.borrow_mut().clear(); + self.record_mutation(MutationScope::QueryState); + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-dom/src/native/host/transfer/tests.rs b/moli-dom/src/native/host/transfer/tests.rs new file mode 100644 index 0000000000..b6790967c6 --- /dev/null +++ b/moli-dom/src/native/host/transfer/tests.rs @@ -0,0 +1,439 @@ +use super::*; +use crate::native::{CustomElementState, DomStringValue, SelectedFile}; + +fn host(name: &str) -> DomHost { + DomHost::from_dom(NativeDom::new_html( + Url::parse(&format!("https://{name}.test/")).unwrap(), + )) +} + +fn element(host: &mut DomHost, handle: DomHandle) -> &mut Element { + host.node_mut(handle) + .unwrap() + .data_mut() + .as_element_mut() + .unwrap() +} + +#[test] +fn transfer_moves_complete_native_state_and_retires_source_handles() { + let mut source = host("source"); + let mut target = host("target"); + for _ in 0..31 { + target.create_element("aside"); + } + let document = target.document_handle(); + let root = source.create_element("section"); + let input = source.create_element("input"); + element(&mut source, input) + .set_input_value_from_user_edit(DomStringValue::from_utf16(&[0xd800, 65, 0xdfff])); + element(&mut source, input).set_selection_range_with_direction(1, 2, "backward"); + element(&mut source, input).set_checked_with_dirty(true, true); + let files = source.create_element("input"); + source.set_attribute(files, "type", "file"); + element(&mut source, files).set_selected_files(vec![SelectedFile { + bytes: vec![1, 3, 9], + mime_type: "text/plain".into(), + name: "native.txt".into(), + last_modified: 7.0, + }]); + let script = source.create_element("script"); + element(&mut source, script).set_script_already_started(true); + element(&mut source, script).set_script_force_async(false); + element(&mut source, script).set_script_text_internal_slot("retained source"); + let media = source.create_element("video"); + element(&mut source, media).set_media_current_time(41.0); + element(&mut source, media).set_media_volume(0.25); + element(&mut source, media).set_media_paused(false); + let svg = source + .create_element_ns(Some("http://www.w3.org/2000/svg"), "p:svg") + .unwrap(); + source.set_svg_root_scale(svg, 8.0); + source.set_svg_root_translation(svg, [31.0, 7.0, 0.0, 1.0]); + let custom = source.create_element("x-moved"); + element(&mut source, custom).set_custom_element_state(CustomElementState::Custom); + element(&mut source, custom).set_custom_element_is_name(Some("x-moved".into())); + element(&mut source, custom).set_cryptographic_nonce(Some("native nonce".into())); + element(&mut source, custom).set_scroll_top(17.0); + element(&mut source, custom).set_custom_validation_message("retained validation"); + element(&mut source, custom).set_attribute_ns_utf16_units( + "value".into(), + "urn:one".into(), + Some("p".into()), + "\u{fffd}".into(), + vec![0xd800], + ); + element(&mut source, custom).set_attribute_ns_utf16_units( + "value".into(), + "urn:two".into(), + Some("p".into()), + "\u{fffd}".into(), + vec![0xdfff], + ); + let text = source.create_text_node(DomStringValue::from_utf16(&[0xdfff, 13, 0xd800])); + let children = [input, files, script, media, svg, custom, text]; + for child in children { + assert!(source.append_child(root, child)); + } + source + .node_mut(script) + .unwrap() + .flags_mut() + .set_parser_created(true); + let payloads: HashMap<_, _> = [root] + .into_iter() + .chain(children) + .map(|id| (id, format!("{:?}", source.node(id).unwrap().data()))) + .collect(); + let source_snapshot = source.clone(); + let allocated = source.dom.len(); + let source_version = source.query_version(); + let target_version = target.query_version(); + let moved = target + .transfer_detached_subtree_from(document, &mut source, root) + .unwrap(); + assert_eq!(moved.handles().len(), children.len() + 1); + assert_eq!(source.dom.len(), allocated); + assert_eq!(source.dom.nodes().len(), 1); + assert!(source.query_version() > source_version); + assert!(target.query_version() > target_version); + for (&old, &new) in moved.handles() { + assert!(source.node(old).is_none()); + let node = target.node(new).unwrap(); + assert_eq!(node.id(), new); + assert_eq!(node.owner_document(), Some(document)); + assert!(!node.is_connected()); + assert_eq!(format!("{:?}", node.data()), payloads[&old]); + assert!(source_snapshot.node(old).is_some()); + } + assert_eq!( + target.child_handles(moved.root()).collect::>(), + children.map(|id| moved.handles()[&id]) + ); + assert!( + target + .node(moved.handles()[&script]) + .unwrap() + .flags() + .parser_created() + ); + let fresh = source.create_element("p"); + assert_eq!(fresh.index(), allocated); + assert!(moved.handles().keys().all(|old| *old != fresh)); +} + +#[test] +fn transfer_preserves_all_shadow_roots_manual_slots_and_stylesheet_membership() { + let mut source = host("source"); + let mut target = host("target"); + let root = source.create_element("div"); + let light = source.create_element("span"); + source.append_child(root, light); + let mut init = ShadowRootInit::new("closed"); + init.set_slot_assignment("manual"); + init.set_serializable(true); + init.set_null_custom_element_registry(true); + let shadow = source + .attach_declarative_shadow_root_with_init(root, init.clone()) + .unwrap(); + source.set_shadow_root_available_to_element_internals(shadow, true); + let slot = source.create_element("slot"); + let style = source.create_element("style"); + source.append_child(shadow, slot); + source.append_child(shadow, style); + source.assign_nodes_to_slot(slot, vec![light]); + let nested_host = source.create_element("div"); + source.append_child(shadow, nested_host); + let nested = source.attach_shadow_root(nested_host, "open").unwrap(); + let content = source.create_text_node("nested"); + source.append_child(nested, content); + assert_eq!( + source + .stylesheet_candidate_handles_for_tree_scope(shadow) + .as_ref(), + &[style] + ); + let snapshot = source.clone(); + let moved = target + .transfer_detached_subtree_from(target.document_handle(), &mut source, root) + .unwrap(); + let new_root = moved.root(); + let new_shadow = moved.handles()[&shadow]; + assert_eq!(source.shadow_root_handle(root), None); + assert_eq!(source.shadow_root_host(shadow), None); + assert_eq!( + source + .stylesheet_candidate_handles_for_tree_scope(shadow) + .len(), + 0 + ); + assert_eq!(target.shadow_root_handle(new_root), Some(new_shadow)); + assert_eq!(target.shadow_root_host(new_shadow), Some(new_root)); + assert_eq!( + target.shadow_root_mode(new_shadow).as_deref(), + Some("closed") + ); + assert_eq!(target.shadow_root_clonable(new_shadow), Some(false)); + assert_eq!(target.shadow_root_serializable(new_shadow), Some(true)); + assert_eq!(target.shadow_root_is_declarative(new_shadow), Some(true)); + assert_eq!( + target.shadow_root_available_to_element_internals(new_shadow), + Some(true) + ); + assert_eq!( + target.shadow_root_uses_null_custom_element_registry(new_shadow), + Some(true) + ); + assert_eq!( + target.assigned_nodes_for_slot_with_options(moved.handles()[&slot], false), + vec![moved.handles()[&light]] + ); + assert_eq!( + target + .stylesheet_candidate_handles_for_tree_scope(new_shadow) + .as_ref(), + &[moved.handles()[&style]] + ); + assert_eq!( + target.shadow_root_host(moved.handles()[&nested]), + Some(moved.handles()[&nested_host]) + ); + assert_eq!(snapshot.shadow_root_handle(root), Some(shadow)); + assert_eq!( + snapshot.assigned_nodes_for_slot_with_options(slot, false), + vec![light] + ); +} + +#[test] +fn transfer_retargets_nested_template_contents_to_target_inert_document() { + for xml in [false, true] { + let mut source = host("source"); + let mut target = if xml { + DomHost::from_dom(NativeDom::new_xml( + Url::parse("https://target.test/").unwrap(), + )) + } else { + host("target") + }; + let root = source.create_element("template"); + let contents = source + .node(root) + .unwrap() + .as_element() + .unwrap() + .template_contents() + .unwrap(); + let nested = source.create_element("template"); + let nested_contents = source + .node(nested) + .unwrap() + .as_element() + .unwrap() + .template_contents() + .unwrap(); + source.append_child(contents, nested); + let text = source.create_text_node(DomStringValue::from_utf16(&[0xd800, 0xdfff])); + source.append_child(nested_contents, text); + let moved = target + .transfer_detached_subtree_from(target.document_handle(), &mut source, root) + .unwrap(); + let target_document = target.document_handle(); + let target_inert = target + .dom + .appropriate_template_contents_owner_document(target_document); + assert_ne!(target_document, target_inert); + assert_eq!( + target.node(moved.root()).unwrap().owner_document(), + Some(target_document) + ); + for old in [contents, nested, nested_contents, text] { + assert_eq!( + target.node(moved.handles()[&old]).unwrap().owner_document(), + Some(target_inert) + ); + } + assert_eq!( + target + .node(moved.handles()[&contents]) + .unwrap() + .data() + .as_document_fragment() + .unwrap() + .host(), + Some(moved.root()) + ); + assert_eq!( + target + .node(moved.handles()[&nested]) + .unwrap() + .as_element() + .unwrap() + .template_contents(), + Some(moved.handles()[&nested_contents]) + ); + assert_eq!( + target + .node(moved.handles()[&text]) + .unwrap() + .character_data_value() + .unwrap() + .utf16_units() + .as_ref(), + &[0xd800, 0xdfff] + ); + } +} + +#[test] +fn transfer_remaps_element_references_without_confusing_host_local_ids() { + let mut source = host("source"); + let mut target = host("target"); + for _ in 0..40 { + target.create_element("p"); + } + let root = source.create_element("form"); + let input = source.create_element("input"); + let dialog = source.create_element("dialog"); + source.append_child(root, input); + source.append_child(root, dialog); + element(&mut source, input).set_parser_associated_form_owner(Some(root)); + element(&mut source, dialog).set_dialog_previously_focused_element(Some(input)); + element(&mut source, root) + .set_explicit_element_references("aria-controls", vec![input, dialog, input]); + let moved = target + .transfer_detached_subtree_from(target.document_handle(), &mut source, root) + .unwrap(); + let new_input = moved.handles()[&input]; + let new_dialog = moved.handles()[&dialog]; + assert_eq!( + target + .node(new_input) + .unwrap() + .as_element() + .unwrap() + .parser_associated_form_owner(), + Some(moved.root()) + ); + assert_eq!( + target + .node(new_dialog) + .unwrap() + .as_element() + .unwrap() + .dialog_previously_focused_element(), + Some(new_input) + ); + assert_eq!( + target + .node(moved.root()) + .unwrap() + .as_element() + .unwrap() + .explicit_element_references("aria-controls"), + Some([new_input, new_dialog, new_input].as_slice()) + ); +} + +#[test] +fn transfer_rejects_open_references_before_mutating_either_host() { + for incoming in [false, true] { + let mut source = host("source"); + let mut target = host("target"); + let root = source.create_element("template"); + let other = source.create_element("p"); + let (node, referenced) = if incoming { + (other, root) + } else { + (root, other) + }; + element(&mut source, node) + .set_explicit_element_references("aria-controls", vec![referenced]); + let before_source = format!("{source:?}"); + let before_target = format!("{target:?}"); + assert_eq!( + target.transfer_detached_subtree_from(target.document_handle(), &mut source, root), + Err(DomSubtreeTransferError::OpenReference { node, referenced }) + ); + assert_eq!(format!("{source:?}"), before_source); + assert_eq!(format!("{target:?}"), before_target); + } +} + +#[test] +fn transfer_rejects_connected_or_unsupported_sources_atomically() { + let mut source = host("source"); + let mut target = host("target"); + let root = source.create_element("div"); + source.append_child(source.document_handle(), root); + let before_source = format!("{source:?}"); + let before_target = format!("{target:?}"); + assert_eq!( + target.transfer_detached_subtree_from(target.document_handle(), &mut source, root), + Err(DomSubtreeTransferError::AttachedSource(root)) + ); + assert_eq!(format!("{source:?}"), before_source); + assert_eq!(format!("{target:?}"), before_target); + source.remove_child(source.document_handle(), root); + let source_document = source.document_handle(); + assert_eq!( + target.transfer_detached_subtree_from( + target.document_handle(), + &mut source, + source_document + ), + Err(DomSubtreeTransferError::InvalidSource(source_document)) + ); + let shadow = source.attach_shadow_root(root, "closed").unwrap(); + assert_eq!( + target.transfer_detached_subtree_from(target.document_handle(), &mut source, shadow), + Err(DomSubtreeTransferError::InvalidSource(shadow)) + ); + let bad_document = target.create_element("p"); + assert_eq!( + target.transfer_detached_subtree_from(bad_document, &mut source, root), + Err(DomSubtreeTransferError::InvalidTargetDocument(bad_document)) + ); +} + +#[test] +fn transfer_keeps_retained_indexes_complete_across_repeated_moves() { + let mut first = host("first"); + let mut second = host("second"); + let root = first.create_element("div"); + let child = first.create_element("p"); + first.set_attribute(child, "id", "moved"); + first.append_child(root, child); + first.append_child(first.document_handle(), root); + assert_eq!(first.element_handle_by_id("moved"), Some(child)); + first.remove_child(first.document_handle(), root); + let frozen = first.clone(); + let mut current_root = root; + let mut current_child = child; + for _ in 0..3 { + let moved = second + .transfer_detached_subtree_from(second.document_handle(), &mut first, current_root) + .unwrap(); + second.append_child(second.document_handle(), moved.root()); + assert_eq!( + second.element_handle_by_id("moved"), + Some(moved.handles()[¤t_child]) + ); + assert_eq!(first.element_handle_by_id("moved"), None); + second.remove_child(second.document_handle(), moved.root()); + let returned = first + .transfer_detached_subtree_from(first.document_handle(), &mut second, moved.root()) + .unwrap(); + first.append_child(first.document_handle(), returned.root()); + let returned_child = returned.handles()[&moved.handles()[¤t_child]]; + assert_eq!(first.element_handle_by_id("moved"), Some(returned_child)); + first.remove_child(first.document_handle(), returned.root()); + // Next iteration deliberately uses new identities, never vacant slots. + assert!(first.node(current_root).is_none()); + assert!(second.node(moved.root()).is_none()); + current_root = returned.root(); + current_child = returned_child; + } + assert!(frozen.node(root).is_some()); + assert!(frozen.node(child).is_some()); +} diff --git a/moli-dom/src/native/mod.rs b/moli-dom/src/native/mod.rs index 00c1d37869..227b86227b 100644 --- a/moli-dom/src/native/mod.rs +++ b/moli-dom/src/native/mod.rs @@ -7,13 +7,16 @@ mod host; mod html_serialization; mod markdown; mod node; +mod node_storage; mod parser_construction; mod queries; mod scripts; mod string_value; mod svg_user_transform; -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; +#[cfg(test)] +use std::sync::Arc; pub use document::{ Document, DocumentBaseUrlPolicyCheck, DocumentFragment, DocumentMetaReferrerPolicySource, @@ -30,8 +33,9 @@ pub use host::{ DomMutationRecordKind, DomScriptMutationEffects, DomSlotAssignmentChange, DomSlotMutationEffects, DomStyleInvalidationInputs, DomStylesheetOwnerChange, DomStylesheetOwnerChangeKind, DomStylesheetOwnerTransitions, DomStylesheetOwnerTreeScopes, - DomTextNormalizationStep, DomTextareaValueChange, DomTreeMutationEffects, ScriptPrepareTrigger, - ScriptPrepareTriggerKind, StylesheetCandidateTreeScopeSnapshots, + DomSubtreeTransfer, DomSubtreeTransferError, DomTextNormalizationStep, DomTextareaValueChange, + DomTreeMutationEffects, ScriptPrepareTrigger, ScriptPrepareTriggerKind, + StylesheetCandidateTreeScopeSnapshots, }; pub use host::{ HostElementSnapshot, ShadowRootBindingSnapshot, ShadowRootInclusion, ShadowRootInit, @@ -42,111 +46,16 @@ pub use node::{ CDataSection, Comment, LiveDomNodeMetadata, NativeNodeId, Node, NodeData, NodeFlags, NodeType, ProcessingInstruction, Text, }; +pub use node_storage::NativeDomNodes; +use node_storage::NativeNodeStorage; pub use parser_construction::ParserConstruction; pub use string_value::DomStringValue; -// Node IDs remain dense indexes, while immutable page snapshots share complete -// chunks. A mutation detaches only its 256-node chunk, bounding copy-on-write -// work without introducing a pointer per node or changing tree identity. +// Node IDs are monotonically allocated slot indexes. Immutable snapshots share +// chunks; a mutation detaches only its 256-node chunk. Transferred slots are +// vacant and are never reused for a different node. const NATIVE_DOM_NODE_CHUNK_CAPACITY: usize = 256; -#[derive(Debug, Clone)] -struct NativeNodeStorage { - chunks: Arc>>>, - len: usize, -} - -impl NativeNodeStorage { - fn from_node(node: Node) -> Self { - Self { - chunks: Arc::new(vec![Arc::new(vec![node])]), - len: 1, - } - } - - fn len(&self) -> usize { - self.len - } - - fn is_empty(&self) -> bool { - self.len == 0 - } - - fn get(&self, index: usize) -> Option<&Node> { - let chunk = self.chunks.get(index / NATIVE_DOM_NODE_CHUNK_CAPACITY)?; - chunk.get(index % NATIVE_DOM_NODE_CHUNK_CAPACITY) - } - - fn get_mut(&mut self, index: usize) -> Option<&mut Node> { - let chunks = Arc::make_mut(&mut self.chunks); - let chunk = chunks.get_mut(index / NATIVE_DOM_NODE_CHUNK_CAPACITY)?; - Arc::make_mut(chunk).get_mut(index % NATIVE_DOM_NODE_CHUNK_CAPACITY) - } - - fn push(&mut self, node: Node) { - let chunks = Arc::make_mut(&mut self.chunks); - match chunks.last_mut() { - Some(chunk) if chunk.len() < NATIVE_DOM_NODE_CHUNK_CAPACITY => { - Arc::make_mut(chunk).push(node); - } - _ => chunks.push(Arc::new(vec![node])), - } - self.len += 1; - } - - fn iter(&self) -> NativeDomNodes<'_> { - NativeDomNodes { - storage: self, - front: 0, - back: self.len, - } - } -} - -#[derive(Clone)] -pub struct NativeDomNodes<'a> { - storage: &'a NativeNodeStorage, - front: usize, - back: usize, -} - -impl<'a> NativeDomNodes<'a> { - pub fn iter(&self) -> Self { - self.clone() - } -} - -impl<'a> Iterator for NativeDomNodes<'a> { - type Item = &'a Node; - - fn next(&mut self) -> Option { - if self.front == self.back { - return None; - } - let index = self.front; - self.front += 1; - self.storage.get(index) - } - - fn size_hint(&self) -> (usize, Option) { - let remaining = self.back - self.front; - (remaining, Some(remaining)) - } -} - -impl<'a> DoubleEndedIterator for NativeDomNodes<'a> { - fn next_back(&mut self) -> Option { - if self.front == self.back { - return None; - } - self.back -= 1; - self.storage.get(self.back) - } -} - -impl ExactSizeIterator for NativeDomNodes<'_> {} -impl std::iter::FusedIterator for NativeDomNodes<'_> {} - #[derive(Debug, Clone)] pub struct NativeDom { nodes: NativeNodeStorage, @@ -213,6 +122,8 @@ impl NativeDom { } } + /// Number of allocated handle slots, including transferred vacancies. + /// Use `nodes().len()` for the number of nodes currently owned by this DOM. pub fn len(&self) -> usize { self.nodes.len() } diff --git a/moli-dom/src/native/node/mod.rs b/moli-dom/src/native/node/mod.rs index 045a7bc88d..97818ba45e 100644 --- a/moli-dom/src/native/node/mod.rs +++ b/moli-dom/src/native/node/mod.rs @@ -130,6 +130,46 @@ pub struct Node { } impl Node { + pub(crate) fn native_node_references(&self) -> Vec { + let mut references = match &self.data { + NodeData::Element(element) => element.native_node_references(), + NodeData::DocumentFragment(fragment) => fragment.host().into_iter().collect(), + _ => Vec::new(), + }; + references.extend(self.parent_node); + references.extend(self.first_child); + references.extend(self.last_child); + references.extend(self.prev_sibling); + references.extend(self.next_sibling); + references + } + + /// Rebase a preflighted, closed ownership graph without applying clone steps. + pub(crate) fn remap_for_transfer( + &mut self, + id: NativeNodeId, + owner_document: NativeNodeId, + handles: &std::collections::HashMap, + ) { + self.id = id; + self.owner_document = Some(owner_document); + self.parent_node = self.parent_node.map(|handle| handles[&handle]); + self.first_child = self.first_child.map(|handle| handles[&handle]); + self.last_child = self.last_child.map(|handle| handles[&handle]); + self.prev_sibling = self.prev_sibling.map(|handle| handles[&handle]); + self.next_sibling = self.next_sibling.map(|handle| handles[&handle]); + self.flags.set_tree_state(false, false); + match &mut self.data { + NodeData::Element(element) => element.remap_native_node_references(handles), + NodeData::DocumentFragment(fragment) => { + if let Some(host) = fragment.host() { + fragment.set_host(handles[&host]); + } + } + _ => {} + } + } + pub const DOCUMENT_POSITION_DISCONNECTED: u16 = 0x01; pub const DOCUMENT_POSITION_PRECEDING: u16 = 0x02; pub const DOCUMENT_POSITION_FOLLOWING: u16 = 0x04; diff --git a/moli-dom/src/native/node_storage.rs b/moli-dom/src/native/node_storage.rs new file mode 100644 index 0000000000..e21b0b63aa --- /dev/null +++ b/moli-dom/src/native/node_storage.rs @@ -0,0 +1,200 @@ +use std::sync::Arc; + +use super::{NATIVE_DOM_NODE_CHUNK_CAPACITY, Node}; + +#[derive(Debug, Clone)] +pub(super) struct NativeNodeStorage { + pub(super) chunks: Arc>>>>, + allocated: usize, + owned: usize, +} + +impl NativeNodeStorage { + pub(super) fn from_node(node: Node) -> Self { + Self { + chunks: Arc::new(vec![Arc::new(vec![Some(node)])]), + allocated: 1, + owned: 1, + } + } + + pub(super) fn len(&self) -> usize { + self.allocated + } + + pub(super) fn is_empty(&self) -> bool { + self.owned == 0 + } + + pub(super) fn get(&self, index: usize) -> Option<&Node> { + let chunk = self.chunks.get(index / NATIVE_DOM_NODE_CHUNK_CAPACITY)?; + chunk.get(index % NATIVE_DOM_NODE_CHUNK_CAPACITY)?.as_ref() + } + + pub(super) fn get_mut(&mut self, index: usize) -> Option<&mut Node> { + let chunks = Arc::make_mut(&mut self.chunks); + let chunk = chunks.get_mut(index / NATIVE_DOM_NODE_CHUNK_CAPACITY)?; + Arc::make_mut(chunk) + .get_mut(index % NATIVE_DOM_NODE_CHUNK_CAPACITY)? + .as_mut() + } + + /// Move the complete native payload out without reusing its old handle. + /// Snapshots keep their own payload through the existing chunk COW boundary. + pub(super) fn take(&mut self, index: usize) -> Option { + self.get(index)?; + let chunks = Arc::make_mut(&mut self.chunks); + let chunk = Arc::make_mut(&mut chunks[index / NATIVE_DOM_NODE_CHUNK_CAPACITY]); + let node = chunk[index % NATIVE_DOM_NODE_CHUNK_CAPACITY].take()?; + self.owned -= 1; + Some(node) + } + + pub(super) fn push(&mut self, node: Node) { + let chunks = Arc::make_mut(&mut self.chunks); + match chunks.last_mut() { + Some(chunk) if chunk.len() < NATIVE_DOM_NODE_CHUNK_CAPACITY => { + Arc::make_mut(chunk).push(Some(node)); + } + _ => chunks.push(Arc::new(vec![Some(node)])), + } + self.allocated += 1; + self.owned += 1; + } + + pub(super) fn iter(&self) -> NativeDomNodes<'_> { + NativeDomNodes { + storage: self, + front: 0, + back: self.allocated, + remaining: self.owned, + } + } +} + +#[derive(Clone)] +pub struct NativeDomNodes<'a> { + storage: &'a NativeNodeStorage, + front: usize, + back: usize, + remaining: usize, +} + +impl NativeDomNodes<'_> { + pub fn iter(&self) -> Self { + self.clone() + } +} + +impl<'a> Iterator for NativeDomNodes<'a> { + type Item = &'a Node; + + fn next(&mut self) -> Option { + while self.front < self.back { + let index = self.front; + self.front += 1; + if let Some(node) = self.storage.get(index) { + self.remaining -= 1; + return Some(node); + } + } + debug_assert_eq!(self.remaining, 0); + None + } + + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} + +impl DoubleEndedIterator for NativeDomNodes<'_> { + fn next_back(&mut self) -> Option { + while self.front < self.back { + self.back -= 1; + if let Some(node) = self.storage.get(self.back) { + self.remaining -= 1; + return Some(node); + } + } + debug_assert_eq!(self.remaining, 0); + None + } +} + +impl ExactSizeIterator for NativeDomNodes<'_> {} +impl std::iter::FusedIterator for NativeDomNodes<'_> {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::native::{NativeNodeId, NodeData, NodeFlags, Text}; + + fn node(index: usize) -> Node { + Node::new( + NativeNodeId::new(index), + None, + None, + NodeFlags::new(false), + NodeData::Text(Text::new("native")), + ) + } + + #[test] + fn vacancies_preserve_exact_size_double_ended_iteration_and_handle_allocation() { + assert_eq!( + std::mem::size_of::>(), + std::mem::size_of::() + ); + let mut storage = NativeNodeStorage::from_node(node(0)); + for index in 1..520 { + storage.push(node(index)); + } + for index in [0, 1, 3, 256, 257, 518, 519] { + assert!(storage.take(index).is_some()); + } + assert!(storage.take(256).is_none()); + assert!(storage.get_mut(256).is_none()); + assert_eq!(storage.len(), 520); + let mut iter = storage.iter(); + assert_eq!(iter.len(), 513); + assert_eq!(iter.next().unwrap().id().index(), 2); + assert_eq!(iter.next_back().unwrap().id().index(), 517); + assert_eq!(iter.size_hint(), (511, Some(511))); + let copied = iter.iter(); + assert_eq!(copied.len(), 511); + let rest = iter + .by_ref() + .map(|node| node.id().index()) + .collect::>(); + assert_eq!(rest.len(), 511); + assert!(!rest.contains(&256)); + assert!(iter.next().is_none()); + assert!(iter.next_back().is_none()); + storage.push(node(520)); + assert_eq!(storage.get(520).unwrap().id().index(), 520); + assert_eq!(storage.iter().len(), 514); + } + + #[test] + fn transfer_retires_only_the_source_chunk_and_preserves_immutable_snapshots() { + let mut storage = NativeNodeStorage::from_node(node(0)); + for index in 1..600 { + storage.push(node(index)); + } + let snapshot = storage.clone(); + let payload = storage.take(300).unwrap(); + assert_eq!(payload.id().index(), 300); + assert_eq!(snapshot.get(300).unwrap().id().index(), 300); + assert!(storage.get(300).is_none()); + assert_eq!(snapshot.iter().len(), 600); + assert_eq!(storage.iter().len(), 599); + for (index, (live, frozen)) in storage + .chunks + .iter() + .zip(snapshot.chunks.iter()) + .enumerate() + { + assert_eq!(Arc::ptr_eq(live, frozen), index != 1); + } + } +} diff --git a/moli-renderer-v8/src/document_runtime/lifecycle.rs b/moli-renderer-v8/src/document_runtime/lifecycle.rs index db941377bc..5c90a0b7bd 100644 --- a/moli-renderer-v8/src/document_runtime/lifecycle.rs +++ b/moli-renderer-v8/src/document_runtime/lifecycle.rs @@ -564,7 +564,7 @@ impl DocumentRuntime { scope: &mut v8::PinScope<'_, '_>, host_ptr: *mut JsContextHost, ) { - let handles = (0..self.dom_host.dom().nodes().len()) + let handles = (0..self.dom_host.dom().len()) .map(crate::document_runtime::DomHandle::new) .collect::>(); for handle in handles { @@ -596,7 +596,7 @@ impl DocumentRuntime { scope: &mut v8::PinScope<'_, '_>, host_ptr: *mut JsContextHost, ) { - let handles = (0..self.dom_host.dom().nodes().len()) + let handles = (0..self.dom_host.dom().len()) .map(DomHandle::new) .collect::>(); for handle in handles { @@ -1197,7 +1197,7 @@ mod tests { ); let mut runtime = DocumentRuntime::new(&document); - let image_handles = (0..runtime.dom_host().dom().nodes().len()) + let image_handles = (0..runtime.dom_host().dom().len()) .map(crate::document_runtime::DomHandle::new) .filter(|handle| { runtime diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/lifecycle.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/lifecycle.rs index fc0172a719..426c1d442b 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/lifecycle.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/lifecycle.rs @@ -125,7 +125,7 @@ impl JsContextHost { else { return; }; - let image_handles = (0..self.dom_host().dom().nodes().len()) + let image_handles = (0..self.dom_host().dom().len()) .map(DomHandle::new) .filter(|handle| { self.dom_host().owner_document_handle(*handle) == Some(snapshot.document_handle) @@ -163,7 +163,7 @@ impl JsContextHost { else { return; }; - let handles = (0..self.dom_host().dom().nodes().len()) + let handles = (0..self.dom_host().dom().len()) .map(DomHandle::new) .filter(|handle| { self.dom_host().is_connected(*handle) diff --git a/moli-renderer-v8/src/native_bridge/element/images/lazy.rs b/moli-renderer-v8/src/native_bridge/element/images/lazy.rs index c17ff29da1..4ab50c21de 100644 --- a/moli-renderer-v8/src/native_bridge/element/images/lazy.rs +++ b/moli-renderer-v8/src/native_bridge/element/images/lazy.rs @@ -56,7 +56,7 @@ pub(super) fn revealed_lazy_image_handles( document: DomHandle, output: &FrozenLayoutTree, ) -> Vec { - (0..runtime.dom_host().dom().nodes().len()) + (0..runtime.dom_host().dom().len()) .map(DomHandle::new) .filter(|handle| is_unadmitted_lazy_image(runtime, document, *handle)) .filter(|handle| image_is_near_live_viewport(runtime, document, output, *handle)) diff --git a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/inserted_resource_lifecycle.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/inserted_resource_lifecycle.rs index f6d90ca79b..79cb2e0e6c 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/inserted_resource_lifecycle.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/inserted_resource_lifecycle.rs @@ -548,12 +548,12 @@ track.remove(); .get_element_by_id("js-replace-inserted-track") .or_else(|| { let dom_host = page_vm.vm().document_runtime.dom_host(); - dom_host.dom().nodes().iter().enumerate().find_map(|(index, node)| { + dom_host.dom().nodes().iter().find_map(|node| { node.as_element() .is_some_and(|element| { element.attribute("id") == Some("js-replace-inserted-track") }) - .then_some(DomHandle::new(index)) + .then_some(node.id()) }) }) .expect("detached replacement track handle should exist"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/child_document_lifecycle.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/child_document_lifecycle.rs index 3e300bcfdd..6b967c3313 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/child_document_lifecycle.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/child_document_lifecycle.rs @@ -1020,7 +1020,7 @@ async fn child_image_network_failure_releases_lifecycle_before_later_host_load() .expect("child image pre-terminal trace"), "dcl" ); - let child_image = (0..vm.document_runtime.dom_host().dom().nodes().len()) + let child_image = (0..vm.document_runtime.dom_host().dom().len()) .map(crate::document_runtime::DomHandle::new) .find(|handle| { vm.document_runtime