refactor(dom): add native ownership transfer for detached subtrees

This commit is contained in:
ldm0
2026-10-11 18:06:49 +08:00
parent 8e6803f680
commit 013ba2d15e
16 changed files with 1082 additions and 113 deletions
@@ -182,6 +182,33 @@ pub struct ElementControlState {
}
impl ElementControlState {
pub(super) fn native_node_references(&self) -> Vec<NativeNodeId> {
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<NativeNodeId, NativeNodeId>,
) {
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
}
+11
View File
@@ -96,6 +96,17 @@ pub struct Element {
}
impl Element {
pub(crate) fn native_node_references(&self) -> Vec<NativeNodeId> {
self.rare_data.native_node_references()
}
pub(crate) fn remap_native_node_references(
&mut self,
handles: &std::collections::HashMap<NativeNodeId, NativeNodeId>,
) {
self.rare_data.remap_native_node_references(handles);
}
pub fn svg_user_transform(&self) -> SvgUserTransform {
self.rare_data.svg_user_transform()
}
+31
View File
@@ -50,6 +50,37 @@ impl ElementRareDataPayload {
}
impl ElementRareData {
pub(super) fn native_node_references(&self) -> Vec<NativeNodeId> {
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<NativeNodeId, NativeNodeId>,
) {
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()
+2
View File
@@ -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")
+1 -1
View File
@@ -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();
@@ -100,6 +100,29 @@ impl StylesheetCandidateTransition {
}
impl NativeDom {
pub(super) fn transfer_stylesheet_candidate_scopes_from(
&mut self,
source: &mut Self,
handles: &HashMap<NativeNodeId, NativeNodeId>,
) {
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,
+285
View File
@@ -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<DomHandle, DomHandle>,
}
impl DomSubtreeTransfer {
pub fn root(&self) -> DomHandle {
self.root
}
pub fn handles(&self) -> &HashMap<DomHandle, DomHandle> {
&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<DomHandle>,
}
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<DomSubtreeTransfer, DomSubtreeTransferError> {
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<TransferPlan, DomSubtreeTransferError> {
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<DomHandle, DomHandle>,
) {
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;
+439
View File
@@ -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::<Vec<_>>(),
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()[&current_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()[&current_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());
}
+14 -103
View File
@@ -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<Vec<Arc<Vec<Node>>>>,
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<Self::Item> {
if self.front == self.back {
return None;
}
let index = self.front;
self.front += 1;
self.storage.get(index)
}
fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = self.back - self.front;
(remaining, Some(remaining))
}
}
impl<'a> DoubleEndedIterator for NativeDomNodes<'a> {
fn next_back(&mut self) -> Option<Self::Item> {
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()
}
+40
View File
@@ -130,6 +130,46 @@ pub struct Node {
}
impl Node {
pub(crate) fn native_node_references(&self) -> Vec<NativeNodeId> {
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<NativeNodeId, NativeNodeId>,
) {
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;
+200
View File
@@ -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<Vec<Arc<Vec<Option<Node>>>>>,
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<Node> {
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<Self::Item> {
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<usize>) {
(self.remaining, Some(self.remaining))
}
}
impl DoubleEndedIterator for NativeDomNodes<'_> {
fn next_back(&mut self) -> Option<Self::Item> {
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::<Option<Node>>(),
std::mem::size_of::<Node>()
);
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::<Vec<_>>();
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);
}
}
}
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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
@@ -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)
@@ -56,7 +56,7 @@ pub(super) fn revealed_lazy_image_handles(
document: DomHandle,
output: &FrozenLayoutTree<DomHandle>,
) -> Vec<DomHandle> {
(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))
@@ -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");
@@ -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