#![deny(unsafe_code)]
use engine_observables_api::{DomArenaStats, DomNodeKindStats};
use layout_dom_api::{
AttributeView, DomMutation, LayoutDom, LayoutDomMut, LocalName, Namespace, NodeKind, QualName,
};
use serval_static_dom::{StaticDocument, StaticNodeId};
mod forms;
mod serialize;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct NodeId(usize);
impl NodeId {
pub fn raw(self) -> usize {
self.0
}
pub fn from_raw(raw: usize) -> Self {
Self(raw)
}
}
#[cfg(all(debug_assertions, target_pointer_width = "64"))]
mod fence {
pub const INDEX_BITS: u32 = 48;
pub const INDEX_MASK: usize = (1usize << INDEX_BITS) - 1;
pub const TAG_MASK: u64 = (1u64 << (64 - INDEX_BITS)) - 1;
pub fn next_doc_tag() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(1);
COUNTER.fetch_add(1, Ordering::Relaxed) & TAG_MASK
}
}
#[derive(Default)]
struct FnvHasher(u64);
impl std::hash::Hasher for FnvHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
let mut h = if self.0 == 0 {
0xcbf2_9ce4_8422_2325
} else {
self.0
};
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
self.0 = h;
}
}
type NodeStore = std::collections::HashMap<usize, Node, std::hash::BuildHasherDefault<FnvHasher>>;
struct Node {
kind: NodeKind,
name: Option<QualName>,
attrs: Vec<(QualName, String)>,
text: Option<String>,
parent: Option<NodeId>,
children: Vec<NodeId>,
}
impl Node {
fn new(kind: NodeKind) -> Self {
Self {
kind,
name: None,
attrs: Vec::new(),
text: None,
parent: None,
children: Vec::new(),
}
}
}
pub struct ScriptedDom {
nodes: NodeStore,
next_id: usize,
root: NodeId,
mutations: Vec<DomMutation<NodeId>>,
#[cfg(all(debug_assertions, target_pointer_width = "64"))]
doc_tag: u64,
}
impl Default for ScriptedDom {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Default, Clone)]
pub struct Pins {
pinned: std::collections::HashSet<NodeId>,
}
impl Pins {
pub fn new() -> Self {
Self::default()
}
pub fn pin(&mut self, id: NodeId) {
self.pinned.insert(id);
}
pub fn unpin(&mut self, id: NodeId) -> bool {
self.pinned.remove(&id)
}
pub fn retire_dead(&mut self, dead: impl IntoIterator<Item = NodeId>) -> usize {
dead.into_iter().filter(|id| self.pinned.remove(id)).count()
}
pub fn is_pinned(&self, id: NodeId) -> bool {
self.pinned.contains(&id)
}
pub fn iter(&self) -> impl Iterator<Item = NodeId> + '_ {
self.pinned.iter().copied()
}
pub fn len(&self) -> usize {
self.pinned.len()
}
pub fn is_empty(&self) -> bool {
self.pinned.is_empty()
}
pub fn clear(&mut self) {
self.pinned.clear();
}
}
impl ScriptedDom {
pub fn new() -> Self {
let mut dom = Self {
nodes: NodeStore::default(),
next_id: 0,
root: NodeId(0),
mutations: Vec::new(),
#[cfg(all(debug_assertions, target_pointer_width = "64"))]
doc_tag: fence::next_doc_tag(),
};
dom.root = dom.push(Node::new(NodeKind::Document));
dom
}
#[cfg(all(debug_assertions, target_pointer_width = "64"))]
fn pack(&self, value: usize) -> NodeId {
debug_assert!(value <= fence::INDEX_MASK, "scripted-dom node id overflow");
NodeId((((self.doc_tag & fence::TAG_MASK) << fence::INDEX_BITS) as usize) | value)
}
#[cfg(not(all(debug_assertions, target_pointer_width = "64")))]
fn pack(&self, value: usize) -> NodeId {
NodeId(value)
}
#[cfg(all(debug_assertions, target_pointer_width = "64"))]
fn index(&self, id: NodeId) -> usize {
debug_assert!(
(id.0 >> fence::INDEX_BITS) as u64 == (self.doc_tag & fence::TAG_MASK),
"NodeId from a different document (id tag {}, this doc {})",
(id.0 >> fence::INDEX_BITS) as u64,
self.doc_tag & fence::TAG_MASK,
);
id.0 & fence::INDEX_MASK
}
#[cfg(not(all(debug_assertions, target_pointer_width = "64")))]
#[inline]
fn index(&self, id: NodeId) -> usize {
id.0
}
#[cfg(all(debug_assertions, target_pointer_width = "64"))]
fn try_index(&self, id: NodeId) -> Option<usize> {
if (id.0 >> fence::INDEX_BITS) as u64 == (self.doc_tag & fence::TAG_MASK) {
Some(id.0 & fence::INDEX_MASK)
} else {
None
}
}
#[cfg(not(all(debug_assertions, target_pointer_width = "64")))]
#[inline]
fn try_index(&self, id: NodeId) -> Option<usize> {
Some(id.0)
}
pub fn capture_node_id(&self, id: NodeId) -> u64 {
self.index(id) as u64
}
pub fn remint_node_id(&self, raw: u64) -> NodeId {
self.pack(usize::try_from(raw).expect("captured node id must fit in usize"))
}
pub fn remove_child(&mut self, child: NodeId) {
let former_parent = self.node(child).parent;
self.detach(child);
if let Some(former_parent) = former_parent {
self.mutations.push(DomMutation::Removed {
node: child,
former_parent,
});
}
}
pub fn create_document(&mut self) -> NodeId {
self.push(Node::new(NodeKind::Document))
}
pub fn create_comment(&mut self, data: &str) -> NodeId {
let mut node = Node::new(NodeKind::Comment);
node.text = Some(data.to_owned());
self.push(node)
}
pub fn create_fragment(&mut self) -> NodeId {
self.push(Node::new(NodeKind::DocumentFragment))
}
pub fn from_serialized_document(html: &str) -> Self {
let src = StaticDocument::parse(html);
let mut dom = Self::new();
let children: Vec<StaticNodeId> = src.dom_children(src.document()).collect();
for child in children {
let copied = dom.copy_fragment_node(&src, child);
dom.attach_silent(dom.root, copied);
}
dom.mutations.clear();
dom
}
pub fn import_serialized_subtree(&mut self, html: &str) -> Result<NodeId, String> {
let fragment = StaticDocument::parse(html);
let mut candidates = if let Some(body) = Self::fragment_body(&fragment) {
let body_children: Vec<StaticNodeId> = fragment.dom_children(body).collect();
if !body_children.is_empty() {
body_children
} else {
Vec::new()
}
} else {
Vec::new()
};
if candidates.is_empty() {
candidates = fragment
.dom_children(fragment.document())
.filter(|&child| {
!fragment
.element_name(child)
.is_some_and(|q| q.local == LocalName::from("html"))
})
.collect();
}
if candidates.len() != 1 {
return Err(format!(
"serialized subtree must produce exactly one top-level node, got {}",
candidates.len()
));
}
Ok(self.copy_fragment_node(&fragment, candidates[0]))
}
fn node(&self, id: NodeId) -> &Node {
self.nodes
.get(&self.index(id))
.expect("NodeId refers to a live node")
}
fn node_mut(&mut self, id: NodeId) -> &mut Node {
let i = self.index(id);
self.nodes
.get_mut(&i)
.expect("NodeId refers to a live node")
}
fn push(&mut self, node: Node) -> NodeId {
let value = self.next_id;
self.next_id += 1;
self.nodes.insert(value, node);
self.pack(value)
}
fn sibling(&self, id: NodeId, delta: isize) -> Option<NodeId> {
let parent = self.node(id).parent?;
let kids = &self.node(parent).children;
let pos = kids.iter().position(|&c| c == id)?;
let target = pos as isize + delta;
if target < 0 {
return None;
}
kids.get(target as usize).copied()
}
fn detach(&mut self, child: NodeId) {
if let Some(parent) = self.node(child).parent {
let kids = &mut self.node_mut(parent).children;
if let Some(pos) = kids.iter().position(|&c| c == child) {
kids.remove(pos);
}
}
self.node_mut(child).parent = None;
}
fn attach_at(&mut self, parent: NodeId, child: NodeId, reference: Option<NodeId>) {
self.detach(child);
self.node_mut(child).parent = Some(parent);
let idx = reference.and_then(|r| self.node(parent).children.iter().position(|&c| c == r));
let kids = &mut self.node_mut(parent).children;
match idx {
Some(i) => kids.insert(i, child),
None => kids.push(child),
}
}
fn drop_subtree(&mut self, node: NodeId) {
let children = std::mem::take(&mut self.node_mut(node).children);
for child in children {
self.drop_subtree(child);
}
let i = self.index(node);
self.nodes.remove(&i);
}
pub fn collect(&mut self, extra_roots: impl IntoIterator<Item = NodeId>) -> usize {
let mut marked: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut stack: Vec<usize> = Vec::new();
self.seed_mark(self.root, &mut marked, &mut stack);
for r in extra_roots {
self.seed_mark(r, &mut marked, &mut stack);
}
while let Some(v) = stack.pop() {
let neighbours: Vec<NodeId> = match self.nodes.get(&v) {
Some(node) => node
.parent
.into_iter()
.chain(node.children.iter().copied())
.collect(),
None => continue,
};
for nbr in neighbours {
self.seed_mark(nbr, &mut marked, &mut stack);
}
}
let before = self.nodes.len();
self.nodes.retain(|k, _| marked.contains(k));
before - self.nodes.len()
}
pub fn live_node_count(&self) -> usize {
self.nodes.len()
}
pub fn stats(&self) -> DomArenaStats {
let mut node_kinds = DomNodeKindStats::default();
let mut attribute_count = 0usize;
let mut estimated_bytes = std::mem::size_of::<Self>()
+ self.nodes.capacity() * (std::mem::size_of::<usize>() + std::mem::size_of::<Node>())
+ self.mutations.capacity() * std::mem::size_of::<DomMutation<NodeId>>();
for node in self.nodes.values() {
match node.kind {
NodeKind::Document => node_kinds.documents += 1,
NodeKind::DocumentFragment => node_kinds.document_fragments += 1,
NodeKind::Doctype => node_kinds.doctypes += 1,
NodeKind::Element => node_kinds.elements += 1,
NodeKind::Text => node_kinds.text += 1,
NodeKind::Comment => node_kinds.comments += 1,
NodeKind::ProcessingInstruction => node_kinds.processing_instructions += 1,
}
attribute_count += node.attrs.len();
estimated_bytes += node.attrs.capacity() * std::mem::size_of::<(QualName, String)>();
estimated_bytes += node.children.capacity() * std::mem::size_of::<NodeId>();
estimated_bytes += node.name.as_ref().map_or(0, qual_name_bytes);
estimated_bytes += node.text.as_ref().map_or(0, String::capacity);
for (name, value) in &node.attrs {
estimated_bytes += qual_name_bytes(name);
estimated_bytes += value.capacity();
}
}
DomArenaStats {
live_nodes: self.nodes.len(),
node_kinds,
attribute_count,
estimated_bytes,
}
}
fn seed_mark(
&self,
id: NodeId,
marked: &mut std::collections::HashSet<usize>,
stack: &mut Vec<usize>,
) {
if let Some(v) = self.try_index(id) {
if self.nodes.contains_key(&v) && marked.insert(v) {
stack.push(v);
}
}
}
fn attach_silent(&mut self, parent: NodeId, child: NodeId) {
self.node_mut(child).parent = Some(parent);
self.node_mut(parent).children.push(child);
}
fn copy_fragment_node(&mut self, src: &StaticDocument, sid: StaticNodeId) -> NodeId {
let new = match src.kind(sid) {
NodeKind::Element => {
let mut node = Node::new(NodeKind::Element);
node.name = src.element_name(sid).cloned();
for attr in src.attributes(sid) {
node.attrs.push((attr.name.clone(), attr.value.to_owned()));
}
self.push(node)
},
kind @ (NodeKind::Text | NodeKind::Comment) => {
let mut node = Node::new(kind);
node.text = src.text(sid).map(str::to_owned);
self.push(node)
},
other => self.push(Node::new(other)),
};
let children: Vec<StaticNodeId> = src.dom_children(sid).collect();
for child in children {
let copied = self.copy_fragment_node(src, child);
self.attach_silent(new, copied);
}
new
}
fn fragment_body(doc: &StaticDocument) -> Option<StaticNodeId> {
let html = doc.document_element()?;
doc.dom_children(html).find(|&c| {
doc.element_name(c)
.is_some_and(|q| q.local == LocalName::from("body"))
})
}
}
fn qual_name_bytes(name: &QualName) -> usize {
name.ns.as_ref().len()
+ name.local.as_ref().len()
+ name
.prefix
.as_ref()
.map_or(0, |prefix| prefix.as_ref().len())
}
impl LayoutDom for ScriptedDom {
type NodeId = NodeId;
fn document(&self) -> NodeId {
self.root
}
fn is_live(&self, id: NodeId) -> bool {
self.try_index(id)
.is_some_and(|v| self.nodes.contains_key(&v))
}
fn parent(&self, id: NodeId) -> Option<NodeId> {
self.node(id).parent
}
fn prev_sibling(&self, id: NodeId) -> Option<NodeId> {
self.sibling(id, -1)
}
fn next_sibling(&self, id: NodeId) -> Option<NodeId> {
self.sibling(id, 1)
}
fn dom_children(&self, id: NodeId) -> impl Iterator<Item = NodeId> + '_ {
self.node(id).children.iter().copied()
}
fn kind(&self, id: NodeId) -> NodeKind {
self.node(id).kind
}
fn opaque_id(&self, id: NodeId) -> u64 {
let _ = self.index(id);
id.0 as u64
}
fn element_name(&self, id: NodeId) -> Option<&QualName> {
self.node(id).name.as_ref()
}
fn attribute(&self, id: NodeId, ns: &Namespace, local: &LocalName) -> Option<&str> {
self.node(id)
.attrs
.iter()
.find(|(name, _)| &name.ns == ns && &name.local == local)
.map(|(_, value)| value.as_str())
}
fn attributes(&self, id: NodeId) -> impl Iterator<Item = AttributeView<'_>> + '_ {
self.node(id)
.attrs
.iter()
.map(|(name, value)| AttributeView {
name,
value: value.as_str(),
})
}
fn text(&self, id: NodeId) -> Option<&str> {
self.node(id).text.as_deref()
}
}
impl LayoutDomMut for ScriptedDom {
fn create_element(&mut self, name: QualName) -> NodeId {
let mut node = Node::new(NodeKind::Element);
node.name = Some(name);
self.push(node)
}
fn create_text(&mut self, data: &str) -> NodeId {
let mut node = Node::new(NodeKind::Text);
node.text = Some(data.to_owned());
self.push(node)
}
fn append_child(&mut self, parent: NodeId, child: NodeId) {
if let Some(former_parent) = self.node(child).parent {
self.mutations.push(DomMutation::Removed {
node: child,
former_parent,
});
}
self.detach(child);
self.node_mut(child).parent = Some(parent);
self.node_mut(parent).children.push(child);
self.mutations.push(DomMutation::Inserted {
node: child,
parent,
});
}
fn insert_before(&mut self, parent: NodeId, child: NodeId, reference: Option<NodeId>) {
if let Some(former_parent) = self.node(child).parent {
self.mutations.push(DomMutation::Removed {
node: child,
former_parent,
});
}
self.attach_at(parent, child, reference);
self.mutations.push(DomMutation::Inserted {
node: child,
parent,
});
}
fn move_before(&mut self, parent: NodeId, child: NodeId, reference: Option<NodeId>) {
let from_parent = self.node(child).parent;
let reference = if reference == Some(child) {
self.sibling(child, 1)
} else {
reference
};
if from_parent == Some(parent) && self.sibling(child, 1) == reference {
return;
}
self.attach_at(parent, child, reference);
match from_parent {
Some(from_parent) => self.mutations.push(DomMutation::Moved {
node: child,
from_parent,
to_parent: parent,
}),
None => self.mutations.push(DomMutation::Inserted {
node: child,
parent,
}),
}
}
fn remove(&mut self, node: NodeId) {
let former_parent = self.node(node).parent;
self.detach(node);
if let Some(former_parent) = former_parent {
self.mutations.push(DomMutation::Removed {
node,
former_parent,
});
}
self.drop_subtree(node);
}
fn set_attribute(&mut self, node: NodeId, name: QualName, value: &str) {
let attrs = &mut self.node_mut(node).attrs;
let old_value;
if let Some(existing) = attrs
.iter_mut()
.find(|(n, _)| n.ns == name.ns && n.local == name.local)
{
old_value = Some(std::mem::replace(&mut existing.1, value.to_owned()));
} else {
old_value = None;
attrs.push((name.clone(), value.to_owned()));
}
self.mutations.push(DomMutation::AttributeChanged {
node,
name,
old_value,
});
}
fn remove_attribute(&mut self, node: NodeId, name: QualName) {
let removed = {
let attrs = &mut self.node_mut(node).attrs;
attrs
.iter()
.position(|(n, _)| n.ns == name.ns && n.local == name.local)
.map(|pos| attrs.remove(pos).1)
};
if let Some(old) = removed {
self.mutations.push(DomMutation::AttributeChanged {
node,
name,
old_value: Some(old),
});
}
}
fn set_text(&mut self, node: NodeId, data: &str) {
self.node_mut(node).text = Some(data.to_owned());
self.mutations
.push(DomMutation::CharacterDataChanged { node });
}
fn set_inner_html(&mut self, node: NodeId, html: &str) {
let existing = std::mem::take(&mut self.node_mut(node).children);
for child in existing {
self.node_mut(child).parent = None;
self.drop_subtree(child);
}
let fragment = StaticDocument::parse(html);
if let Some(body) = Self::fragment_body(&fragment) {
let body_children: Vec<StaticNodeId> = fragment.dom_children(body).collect();
for child in body_children {
let copied = self.copy_fragment_node(&fragment, child);
self.attach_silent(node, copied);
}
}
self.mutations.push(DomMutation::SubtreeReplaced { node });
}
fn drain_mutations(&mut self, out: &mut Vec<DomMutation<NodeId>>) {
out.append(&mut self.mutations);
}
}
#[cfg(test)]
mod tests {
use super::*;
use layout_dom_api::CapturedMutation;
fn qual(local: &str) -> QualName {
QualName::new(None, Namespace::from(""), LocalName::from(local))
}
#[test]
fn mutate_read_and_record() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let div = dom.create_element(qual("div"));
dom.append_child(root, div);
let text = dom.create_text("hello");
dom.append_child(div, text);
dom.set_attribute(div, qual("id"), "main");
assert_eq!(dom.kind(div), NodeKind::Element);
assert_eq!(dom.element_name(div).unwrap().local, LocalName::from("div"));
assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![div]);
assert_eq!(dom.dom_children(div).collect::<Vec<_>>(), vec![text]);
assert_eq!(dom.parent(text), Some(div));
assert_eq!(dom.text(text), Some("hello"));
assert_eq!(
dom.attribute(div, &Namespace::from(""), &LocalName::from("id")),
Some("main")
);
let mut muts = Vec::new();
dom.drain_mutations(&mut muts);
assert_eq!(muts.len(), 3);
let mut again = Vec::new();
dom.drain_mutations(&mut again);
assert!(again.is_empty());
}
#[test]
fn set_inner_html_builds_subtree() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let div = dom.create_element(qual("div"));
dom.append_child(root, div);
let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
dom.set_inner_html(div, "<p>hi</p><span>x</span>");
let kids: Vec<_> = dom.dom_children(div).collect();
assert_eq!(kids.len(), 2);
assert_eq!(
dom.element_name(kids[0]).unwrap().local,
LocalName::from("p")
);
assert_eq!(
dom.element_name(kids[1]).unwrap().local,
LocalName::from("span")
);
let p_kids: Vec<_> = dom.dom_children(kids[0]).collect();
assert_eq!(p_kids.len(), 1);
assert_eq!(dom.text(p_kids[0]), Some("hi"));
let mut muts = Vec::new();
dom.drain_mutations(&mut muts);
assert!(matches!(
muts.as_slice(),
[DomMutation::SubtreeReplaced { .. }]
));
}
#[test]
fn siblings_and_remove() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let a = dom.create_element(qual("a"));
let b = dom.create_element(qual("b"));
dom.append_child(root, a);
dom.append_child(root, b);
assert_eq!(dom.next_sibling(a), Some(b));
assert_eq!(dom.prev_sibling(b), Some(a));
assert_eq!(dom.prev_sibling(a), None);
let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
dom.remove(a);
assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![b]);
assert_eq!(dom.next_sibling(b), None);
let mut muts = Vec::new();
dom.drain_mutations(&mut muts);
assert!(matches!(muts.as_slice(), [DomMutation::Removed { .. }]));
}
#[test]
fn insert_before_orders_and_appends() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let a = dom.create_element(qual("a"));
let c = dom.create_element(qual("c"));
dom.append_child(root, a);
dom.append_child(root, c);
let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
let b = dom.create_element(qual("b"));
dom.insert_before(root, b, Some(c));
assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![a, b, c]);
assert_eq!(dom.parent(b), Some(root));
let d = dom.create_element(qual("d"));
dom.insert_before(root, d, None);
assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![a, b, c, d]);
let orphan = dom.create_element(qual("orphan"));
let e = dom.create_element(qual("e"));
dom.insert_before(root, e, Some(orphan));
assert_eq!(
dom.dom_children(root).collect::<Vec<_>>(),
vec![a, b, c, d, e]
);
let mut muts = Vec::new();
dom.drain_mutations(&mut muts);
assert_eq!(muts.len(), 3);
assert!(
muts.iter()
.all(|m| matches!(m, DomMutation::Inserted { parent, .. } if *parent == root))
);
}
#[test]
fn in_tree_insert_reports_the_removal_too() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let a = dom.create_element(qual("a"));
let b = dom.create_element(qual("b"));
let child = dom.create_element(qual("child"));
dom.append_child(root, a);
dom.append_child(root, b);
dom.append_child(a, child);
let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
dom.insert_before(b, child, None);
let mut muts = Vec::new();
dom.drain_mutations(&mut muts);
assert_eq!(
muts,
vec![
DomMutation::Removed {
node: child,
former_parent: a,
},
DomMutation::Inserted {
node: child,
parent: b,
},
]
);
}
#[test]
fn move_before_moves_across_parents_with_one_moved_record() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let a = dom.create_element(qual("a"));
let b = dom.create_element(qual("b"));
let child = dom.create_element(qual("child"));
let b_kid = dom.create_element(qual("bkid"));
dom.append_child(root, a);
dom.append_child(root, b);
dom.append_child(a, child);
dom.append_child(b, b_kid);
let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
dom.move_before(b, child, Some(b_kid));
assert_eq!(dom.parent(child), Some(b));
assert_eq!(dom.dom_children(b).collect::<Vec<_>>(), vec![child, b_kid]);
assert!(dom.dom_children(a).next().is_none());
let mut muts = Vec::new();
dom.drain_mutations(&mut muts);
assert_eq!(
muts,
vec![DomMutation::Moved {
node: child,
from_parent: a,
to_parent: b,
}]
);
}
#[test]
fn move_before_same_parent_reorders_and_noops_in_place() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let a = dom.create_element(qual("a"));
let b = dom.create_element(qual("b"));
let c = dom.create_element(qual("c"));
dom.append_child(root, a);
dom.append_child(root, b);
dom.append_child(root, c);
let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
dom.move_before(root, c, Some(a));
assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![c, a, b]);
let mut muts = Vec::new();
dom.drain_mutations(&mut muts);
assert_eq!(
muts,
vec![DomMutation::Moved {
node: c,
from_parent: root,
to_parent: root,
}]
);
dom.move_before(root, c, Some(a));
dom.move_before(root, c, Some(c));
dom.move_before(root, b, None);
let mut noop = Vec::new();
dom.drain_mutations(&mut noop);
assert_eq!(noop, Vec::new());
assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![c, a, b]);
}
#[test]
fn remove_attribute_records_and_noops() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let div = dom.create_element(qual("div"));
dom.append_child(root, div);
dom.set_attribute(div, qual("id"), "main");
let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
dom.remove_attribute(div, qual("id"));
assert_eq!(
dom.attribute(div, &Namespace::from(""), &LocalName::from("id")),
None
);
let mut muts = Vec::new();
dom.drain_mutations(&mut muts);
assert!(matches!(
muts.as_slice(),
[DomMutation::AttributeChanged { old_value: Some(v), .. }] if v.as_str() == "main"
));
dom.remove_attribute(div, qual("id"));
let mut again = Vec::new();
dom.drain_mutations(&mut again);
assert!(again.is_empty());
}
#[test]
fn drained_mutations_round_trip_through_captured_postcard() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let div = dom.create_element(qual("div"));
dom.append_child(root, div);
let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
dom.set_attribute(div, qual("id"), "main");
dom.remove_attribute(div, qual("id"));
let mut muts = Vec::new();
dom.drain_mutations(&mut muts);
let captured: Vec<_> = muts
.iter()
.map(|m| CapturedMutation::capture(m, |id| dom.capture_node_id(*id)))
.collect();
let bytes = postcard::to_stdvec(&captured).expect("serialize captured mutations");
let decoded: Vec<CapturedMutation> =
postcard::from_bytes(&bytes).expect("decode captured mutations");
let replayed: Vec<_> = decoded
.into_iter()
.map(|m| m.replay(|raw| dom.remint_node_id(raw)))
.collect();
assert_eq!(replayed, muts);
}
#[test]
fn stats_count_node_kinds_attributes_and_bytes() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let html = dom.create_element(qual("html"));
let body = dom.create_element(qual("body"));
let text = dom.create_text("hello");
let comment = dom.create_comment("note");
let frag = dom.create_fragment();
dom.append_child(root, html);
dom.append_child(html, body);
dom.append_child(body, text);
dom.append_child(body, comment);
dom.append_child(body, frag);
dom.set_attribute(body, qual("class"), "main");
dom.set_attribute(body, qual("data-x"), "1");
let stats = dom.stats();
assert_eq!(stats.live_nodes, 6);
assert_eq!(stats.node_kinds.documents, 1);
assert_eq!(stats.node_kinds.elements, 2);
assert_eq!(stats.node_kinds.text, 1);
assert_eq!(stats.node_kinds.comments, 1);
assert_eq!(stats.node_kinds.document_fragments, 1);
assert_eq!(stats.attribute_count, 2);
assert!(stats.estimated_bytes >= std::mem::size_of::<ScriptedDom>());
}
#[test]
fn secondary_root_is_same_document() {
let mut dom = ScriptedDom::new();
let primary = dom.document();
let secondary = dom.create_document();
let div = dom.create_element(qual("div"));
dom.append_child(secondary, div);
assert_eq!(dom.parent(div), Some(secondary));
assert_ne!(primary, secondary);
assert_eq!(dom.kind(primary), NodeKind::Document);
assert_eq!(dom.kind(secondary), NodeKind::Document);
}
#[cfg(all(debug_assertions, target_pointer_width = "64"))]
#[test]
#[should_panic(expected = "different document")]
fn cross_document_node_id_panics() {
let mut a = ScriptedDom::new();
let b = ScriptedDom::new();
let id_in_a = a.create_element(qual("div"));
let _ = b.kind(id_in_a);
}
#[cfg(all(debug_assertions, target_pointer_width = "64"))]
#[test]
fn distinct_documents_get_distinct_tags() {
let a = ScriptedDom::new();
let b = ScriptedDom::new();
assert_ne!(a.document().raw(), b.document().raw());
}
#[test]
fn captured_node_id_remints_for_another_document() {
let a = ScriptedDom::new();
let b = ScriptedDom::new();
let captured = a.capture_node_id(a.document());
let reminted = b.remint_node_id(captured);
assert!(b.is_live(reminted));
#[cfg(all(debug_assertions, target_pointer_width = "64"))]
{
assert!(!b.is_live(a.document()));
assert_ne!(a.document(), reminted);
}
}
#[test]
fn dangle_contract_churn_across_frames() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let a = dom.create_element(qual("a"));
let b = dom.create_element(qual("b"));
dom.append_child(root, a);
dom.append_child(root, b);
let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
assert!(dom.is_live(root));
assert!(dom.is_live(a));
assert!(dom.is_live(b));
dom.remove(a);
dom.drain_mutations(&mut drained);
assert!(!dom.is_live(a));
assert!(dom.is_live(b));
assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![b]);
dom.remove_child(b);
dom.drain_mutations(&mut drained);
assert!(dom.is_live(b), "an orphaned node stays live until dropped");
assert!(dom.dom_children(root).collect::<Vec<_>>().is_empty());
dom.append_child(root, b); assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![b]);
assert!(dom.is_live(b));
}
#[test]
fn is_live_is_false_for_dropped_and_foreign_ids() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let n = dom.create_element(qual("n"));
dom.append_child(root, n);
assert!(dom.is_live(n));
dom.remove(n);
assert!(!dom.is_live(n));
let other = ScriptedDom::new();
let foreign = {
let mut o = other;
o.create_element(qual("x"))
};
assert!(!dom.is_live(foreign));
}
const NO_PINS: [NodeId; 0] = [];
#[test]
fn collect_reaps_unpinned_orphans_keeps_pinned_components() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let attached = dom.create_element(qual("attached"));
dom.append_child(root, attached);
let parent = dom.create_element(qual("p"));
let mid = dom.create_element(qual("mid"));
let leaf = dom.create_element(qual("leaf"));
dom.append_child(parent, mid);
dom.append_child(mid, leaf);
let live_before = dom.live_node_count();
let pruned = dom.collect(NO_PINS);
assert_eq!(pruned, 3, "the 3-node orphan subtree is reaped");
assert_eq!(dom.live_node_count(), live_before - 3);
assert!(dom.is_live(root) && dom.is_live(attached));
assert!(!dom.is_live(parent) && !dom.is_live(mid) && !dom.is_live(leaf));
let parent = dom.create_element(qual("p"));
let mid = dom.create_element(qual("mid"));
let leaf = dom.create_element(qual("leaf"));
dom.append_child(parent, mid);
dom.append_child(mid, leaf);
let pruned = dom.collect([leaf]); assert_eq!(pruned, 0, "a pin on the leaf spares the whole component");
assert!(dom.is_live(parent) && dom.is_live(mid) && dom.is_live(leaf));
assert_eq!(dom.collect(NO_PINS), 3);
assert!(!dom.is_live(parent) && !dom.is_live(mid) && !dom.is_live(leaf));
}
#[test]
fn collect_bounds_memory_under_churn() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let baseline = dom.live_node_count();
let mut peak = baseline;
for _ in 0..500 {
let host = dom.create_element(qual("host"));
dom.append_child(root, host);
for _ in 0..8 {
let kid = dom.create_element(qual("kid"));
dom.append_child(host, kid);
}
peak = peak.max(dom.live_node_count());
dom.remove_child(host); dom.collect(NO_PINS); }
assert!(dom.next_id > 4000, "ids are monotonic (no reuse)");
assert_eq!(dom.live_node_count(), baseline, "store plateaus, not grows");
assert!(
peak < baseline + 32,
"peak stays tiny — only one subtree at a time"
);
}
#[test]
fn idle_collect_reaps_orphans_without_mutations() {
let mut dom = ScriptedDom::new();
let root = dom.document();
let o = dom.create_element(qual("o"));
dom.append_child(root, o);
dom.remove_child(o); let mut drained = Vec::new();
dom.drain_mutations(&mut drained);
assert!(dom.is_live(o));
assert_eq!(dom.collect(NO_PINS), 1);
assert!(!dom.is_live(o));
}
#[test]
fn secondary_document_is_pin_kept() {
let mut dom = ScriptedDom::new();
let secondary = dom.create_document();
let body = dom.create_element(qual("body"));
dom.append_child(secondary, body);
assert_eq!(dom.collect([secondary]), 0);
assert!(dom.is_live(secondary) && dom.is_live(body));
assert_eq!(dom.collect(NO_PINS), 2);
assert!(!dom.is_live(secondary) && !dom.is_live(body));
}
#[test]
fn pins_pin_unpin_and_retire() {
let id = NodeId::from_raw;
let mut pins = Pins::new();
assert!(pins.is_empty());
pins.pin(id(0x10));
pins.pin(id(0x20));
pins.pin(id(0x10)); assert_eq!(pins.len(), 2);
assert!(pins.is_pinned(id(0x10)));
assert!(pins.unpin(id(0x20)));
assert!(!pins.unpin(id(0xAA)));
pins.pin(id(0x30));
assert_eq!(pins.retire_dead([id(0x10), id(0x30), id(0xBB)]), 2);
assert!(pins.is_empty());
}
#[test]
fn pins_clear() {
let mut pins = Pins::new();
pins.pin(NodeId::from_raw(1));
pins.pin(NodeId::from_raw(2));
assert_eq!(pins.len(), 2);
pins.clear();
assert!(pins.is_empty());
}
}