Skip to main content

serval_scripted_dom/
lib.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5//! Mutable scripted-DOM provider.
6//!
7//! `ScriptedDom` is the mutable sibling of `serval-static-dom`'s `StaticDocument`:
8//! a `NodeId`-keyed arena that implements [`LayoutDom`] (read) and [`LayoutDomMut`]
9//! (mutate), recording each structural change as a [`DomMutation`] for
10//! serval-layout's scheduler to translate into invalidation. The arena owns the
11//! node data; JS reflectors bridge back to it by `NodeId` (via
12//! `script-engine-api`'s `make_reflector`/`reflector_data`), so the engine never
13//! owns DOM data.
14//!
15//! Scope (2026-05-23): structural mutation + the mutation stream. The reflector
16//! bridge wiring and the DomMutation → serval-layout invalidation loop are the next
17//! pass (they need the `script-runtime-api` host layer and serval-layout's
18//! scheduler). `set_inner_html` is deferred — it needs html5ever fragment parsing.
19
20#![deny(unsafe_code)]
21
22use engine_observables_api::{DomArenaStats, DomNodeKindStats};
23use layout_dom_api::{
24    AttributeView, DomMutation, LayoutDom, LayoutDomMut, LocalName, Namespace, NodeKind, QualName,
25};
26use serval_static_dom::{StaticDocument, StaticNodeId};
27
28mod forms;
29mod serialize;
30
31/// Opaque node identity: a stable index into the arena (slots are never reused, so
32/// ids stay valid for the document's lifetime).
33// `usize`-backed (pointer-sized): serval-layout's Stylo style-sharing cache asserts
34// `size_of::<NodeId>() == size_of::<usize>()` (it packs the id into a pointer-shaped
35// `OpaqueElement`). A `u32` would fail that assertion.
36#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
37pub struct NodeId(usize);
38
39impl NodeId {
40    /// The raw arena index. The reflector bridge packs this into a
41    /// `script_engine_api::ReflectorData` (`u64`) so JS can carry it opaquely.
42    pub fn raw(self) -> usize {
43        self.0
44    }
45
46    /// Rebuild a `NodeId` from a raw index recovered from a reflector.
47    pub fn from_raw(raw: usize) -> Self {
48        Self(raw)
49    }
50}
51
52// --- G0: the document fence -------------------------------------------------
53//
54// Live `ScriptedDom`s are multiplying (chrome, workbench, roster, panes,
55// cards, windows). A `NodeId` minted by one document and used against another
56// is a silent wrong-node bug. To catch it, each document carries a
57// process-unique `doc_tag`; on 64-bit *debug* builds the tag is packed into a
58// `NodeId`'s high bits and every accessor `debug_assert`s ownership.
59//
60// On release and on wasm32 the packing and the asserts compile out entirely,
61// so ids are the bare arena index exactly as before and behavior is
62// byte-identical. wasm32 has no room to pack (a `usize` is 32 bits there, all
63// of it spoken for by the index), and native debug runs already exercise the
64// bug class. The packed value rides opaquely through Stylo's `OpaqueElement`
65// (never dereferenced) and through the reflector bridge's `raw()`/`from_raw()`
66// u64 round-trip, so the tag survives both paths and the assert fires on
67// reconstructed ids too.
68
69#[cfg(all(debug_assertions, target_pointer_width = "64"))]
70mod fence {
71    /// Low bits of a `NodeId` carrying the arena index. 2^48 nodes per
72    /// document is ample; the remaining 16 high bits carry the `doc_tag`.
73    pub const INDEX_BITS: u32 = 48;
74    pub const INDEX_MASK: usize = (1usize << INDEX_BITS) - 1;
75    /// 16-bit tag space. On wraparound (65k+ documents in one process) tags
76    /// may alias and the assert weakens to a heuristic; it never miscompares a
77    /// same-tag id, so correctness is unaffected.
78    pub const TAG_MASK: u64 = (1u64 << (64 - INDEX_BITS)) - 1;
79
80    /// Mint a process-unique tag. Starts at 1 so the first document is nonzero.
81    pub fn next_doc_tag() -> u64 {
82        use std::sync::atomic::{AtomicU64, Ordering};
83        static COUNTER: AtomicU64 = AtomicU64::new(1);
84        COUNTER.fetch_add(1, Ordering::Relaxed) & TAG_MASK
85    }
86}
87
88/// A tiny deterministic FNV-1a hasher for the node store (G3). std `HashMap`'s
89/// `RandomState` is seed-dependent (and its seed is an entropy question on
90/// wasm32); fixing the hasher makes the store's iteration order identical on
91/// every run and target, so pelt-live's byte-determinism stays airtight even
92/// though the store is now a map rather than a dense slab.
93#[derive(Default)]
94struct FnvHasher(u64);
95
96impl std::hash::Hasher for FnvHasher {
97    fn finish(&self) -> u64 {
98        self.0
99    }
100    fn write(&mut self, bytes: &[u8]) {
101        let mut h = if self.0 == 0 {
102            0xcbf2_9ce4_8422_2325
103        } else {
104            self.0
105        };
106        for &b in bytes {
107            h ^= b as u64;
108            h = h.wrapping_mul(0x0000_0100_0000_01b3);
109        }
110        self.0 = h;
111    }
112}
113
114/// The node store: a *prunable* map from a monotonic node value to its [`Node`].
115/// Keyed by the untagged id value (the low bits the fence's `index` returns), so
116/// the G0 doc-tag never reaches the key. Pruning dead entries is what bounds
117/// memory to live nodes (G3); the deterministic hasher keeps it order-stable.
118type NodeStore = std::collections::HashMap<usize, Node, std::hash::BuildHasherDefault<FnvHasher>>;
119
120struct Node {
121    kind: NodeKind,
122    name: Option<QualName>,
123    attrs: Vec<(QualName, String)>,
124    text: Option<String>,
125    parent: Option<NodeId>,
126    children: Vec<NodeId>,
127}
128
129impl Node {
130    fn new(kind: NodeKind) -> Self {
131        Self {
132            kind,
133            name: None,
134            attrs: Vec::new(),
135            text: None,
136            parent: None,
137            children: Vec::new(),
138        }
139    }
140}
141
142/// A mutable DOM arena. Nodes live in a prunable [`NodeStore`] keyed by a
143/// monotonic id; a pruned id is "gone" (ids are never reused). Memory is bounded
144/// to *live* nodes by [`collect`](ScriptedDom::collect) (G3), not to every node
145/// ever created.
146pub struct ScriptedDom {
147    nodes: NodeStore,
148    /// Monotonic id counter. The next node's untagged value; never decremented,
149    /// so ids are never reused even as the store is pruned.
150    next_id: usize,
151    /// The primary document root (returned by `document()`) — the sole permanent
152    /// mark root for `collect`. Everything else (secondary documents, fragments,
153    /// detached subtrees) survives only via reachability from here or the host's
154    /// pins, so a dropped secondary document collects like any other orphan.
155    root: NodeId,
156    mutations: Vec<DomMutation<NodeId>>,
157    /// Process-unique document tag (G0 fence). Only present where the fence is
158    /// active; elsewhere ids are untagged and this field would be dead weight.
159    #[cfg(all(debug_assertions, target_pointer_width = "64"))]
160    doc_tag: u64,
161}
162
163impl Default for ScriptedDom {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169/// A set of node ids to treat as **extra mark roots** for
170/// [`ScriptedDom::collect`] — nodes the document tree no longer reaches but that
171/// must survive anyway. The host pins a node while script can still reach it (it
172/// holds a reflector), so a pinned orphan and its whole connected component are
173/// spared; unpinning it makes it collectable. The DOM only sees a pin set; the
174/// host's word for these is "reflector pins" (it `pin`s on minting a reflector
175/// and `retire`s the ones the engine reports dead). Engine-agnostic — it traffics
176/// only in [`NodeId`], naming no engine type.
177#[derive(Debug, Default, Clone)]
178pub struct Pins {
179    pinned: std::collections::HashSet<NodeId>,
180}
181
182impl Pins {
183    /// An empty pin set.
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Pin `id` (script can still reach it). Idempotent.
189    pub fn pin(&mut self, id: NodeId) {
190        self.pinned.insert(id);
191    }
192
193    /// Drop the pin for `id`. Returns `true` if it had been pinned (an id never
194    /// pinned here is a harmless no-op).
195    pub fn unpin(&mut self, id: NodeId) -> bool {
196        self.pinned.remove(&id)
197    }
198
199    /// Retire a batch of now-dead ids (the host maps the engine's
200    /// `drain_dead_reflectors` output through `NodeId::from_raw` first),
201    /// unpinning each. Returns how many were actually pinned.
202    pub fn retire_dead(&mut self, dead: impl IntoIterator<Item = NodeId>) -> usize {
203        dead.into_iter().filter(|id| self.pinned.remove(id)).count()
204    }
205
206    /// Whether `id` is currently pinned.
207    pub fn is_pinned(&self, id: NodeId) -> bool {
208        self.pinned.contains(&id)
209    }
210
211    /// The pinned ids — pass to [`ScriptedDom::collect`] as the extra roots.
212    pub fn iter(&self) -> impl Iterator<Item = NodeId> + '_ {
213        self.pinned.iter().copied()
214    }
215
216    /// Number of pinned ids.
217    pub fn len(&self) -> usize {
218        self.pinned.len()
219    }
220
221    /// Whether nothing is pinned.
222    pub fn is_empty(&self) -> bool {
223        self.pinned.is_empty()
224    }
225
226    /// Drop every pin (document teardown).
227    pub fn clear(&mut self) {
228        self.pinned.clear();
229    }
230}
231
232impl ScriptedDom {
233    /// A fresh document with an empty `Document` root.
234    pub fn new() -> Self {
235        let mut dom = Self {
236            nodes: NodeStore::default(),
237            next_id: 0,
238            // Placeholder; overwritten by the `push` below so the root id
239            // carries this document's tag like every other node.
240            root: NodeId(0),
241            mutations: Vec::new(),
242            #[cfg(all(debug_assertions, target_pointer_width = "64"))]
243            doc_tag: fence::next_doc_tag(),
244        };
245        dom.root = dom.push(Node::new(NodeKind::Document));
246        dom
247    }
248
249    /// Pack a monotonic id value into a `NodeId`, tagging it with this document
250    /// on a fenced build. Off the fence this is the bare value (today's behavior).
251    #[cfg(all(debug_assertions, target_pointer_width = "64"))]
252    fn pack(&self, value: usize) -> NodeId {
253        debug_assert!(value <= fence::INDEX_MASK, "scripted-dom node id overflow");
254        NodeId((((self.doc_tag & fence::TAG_MASK) << fence::INDEX_BITS) as usize) | value)
255    }
256    #[cfg(not(all(debug_assertions, target_pointer_width = "64")))]
257    fn pack(&self, value: usize) -> NodeId {
258        NodeId(value)
259    }
260
261    /// Resolve a `NodeId` to its store key (the untagged id value), asserting it
262    /// belongs to this document first. Every accessor that reads the store goes
263    /// through here.
264    #[cfg(all(debug_assertions, target_pointer_width = "64"))]
265    fn index(&self, id: NodeId) -> usize {
266        debug_assert!(
267            (id.0 >> fence::INDEX_BITS) as u64 == (self.doc_tag & fence::TAG_MASK),
268            "NodeId from a different document (id tag {}, this doc {})",
269            (id.0 >> fence::INDEX_BITS) as u64,
270            self.doc_tag & fence::TAG_MASK,
271        );
272        id.0 & fence::INDEX_MASK
273    }
274    #[cfg(not(all(debug_assertions, target_pointer_width = "64")))]
275    #[inline]
276    fn index(&self, id: NodeId) -> usize {
277        id.0
278    }
279
280    /// Like [`index`](Self::index) but **non-asserting**: returns `None` for an
281    /// id minted by a different document (on a fenced build) instead of
282    /// panicking, so [`is_live`](Self::is_live) can answer for any id.
283    #[cfg(all(debug_assertions, target_pointer_width = "64"))]
284    fn try_index(&self, id: NodeId) -> Option<usize> {
285        if (id.0 >> fence::INDEX_BITS) as u64 == (self.doc_tag & fence::TAG_MASK) {
286            Some(id.0 & fence::INDEX_MASK)
287        } else {
288            None
289        }
290    }
291    #[cfg(not(all(debug_assertions, target_pointer_width = "64")))]
292    #[inline]
293    fn try_index(&self, id: NodeId) -> Option<usize> {
294        Some(id.0)
295    }
296
297    /// Normalize `id` for capture/replay: strip any debug doc-tag fence and return
298    /// the bare arena index the recorder writes.
299    pub fn capture_node_id(&self, id: NodeId) -> u64 {
300        self.index(id) as u64
301    }
302
303    /// Re-mint a captured bare arena index against this document, restoring this
304    /// document's tag on a fenced build.
305    pub fn remint_node_id(&self, raw: u64) -> NodeId {
306        self.pack(usize::try_from(raw).expect("captured node id must fit in usize"))
307    }
308
309    /// DOM `removeChild`: orphan `child` from its parent but keep it (and its
310    /// subtree) alive and re-insertable, recording a [`DomMutation::Removed`].
311    /// Unlike [`LayoutDomMut::remove`](layout_dom_api::LayoutDomMut::remove), which
312    /// also drops the subtree — script may hold a reference to a removed node and
313    /// re-insert it, so the scripted DOM orphans rather than frees.
314    pub fn remove_child(&mut self, child: NodeId) {
315        let former_parent = self.node(child).parent;
316        self.detach(child);
317        if let Some(former_parent) = former_parent {
318            self.mutations.push(DomMutation::Removed {
319                node: child,
320                former_parent,
321            });
322        }
323    }
324
325    /// Create a detached `Document` node (a second document, for
326    /// `DOMImplementation.createDocument` / `createHTMLDocument`). Lives in the same
327    /// store as the primary document, so `NodeId`s stay globally unique. It is
328    /// **pin-kept**, not a permanent root (G3): while script holds a reflector to
329    /// it (or anything in it) the host pins it and `collect` spares the whole
330    /// component; once script drops it, it collects like any other orphan.
331    pub fn create_document(&mut self) -> NodeId {
332        self.push(Node::new(NodeKind::Document))
333    }
334
335    /// Create a detached `Comment` node carrying `data`.
336    pub fn create_comment(&mut self, data: &str) -> NodeId {
337        let mut node = Node::new(NodeKind::Comment);
338        node.text = Some(data.to_owned());
339        self.push(node)
340    }
341
342    /// Create a detached `DocumentFragment` node (a parentless container).
343    pub fn create_fragment(&mut self) -> NodeId {
344        self.push(Node::new(NodeKind::DocumentFragment))
345    }
346
347    /// Rebuild a fresh document from serialized document children (the same
348    /// shape `inner_html(document())` emits for capture).
349    pub fn from_serialized_document(html: &str) -> Self {
350        let src = StaticDocument::parse(html);
351        let mut dom = Self::new();
352        let children: Vec<StaticNodeId> = src.dom_children(src.document()).collect();
353        for child in children {
354            let copied = dom.copy_fragment_node(&src, child);
355            dom.attach_silent(dom.root, copied);
356        }
357        dom.mutations.clear();
358        dom
359    }
360
361    /// Import exactly one serialized subtree as a detached node.
362    pub fn import_serialized_subtree(&mut self, html: &str) -> Result<NodeId, String> {
363        let fragment = StaticDocument::parse(html);
364        let mut candidates = if let Some(body) = Self::fragment_body(&fragment) {
365            let body_children: Vec<StaticNodeId> = fragment.dom_children(body).collect();
366            if !body_children.is_empty() {
367                body_children
368            } else {
369                Vec::new()
370            }
371        } else {
372            Vec::new()
373        };
374        if candidates.is_empty() {
375            candidates = fragment
376                .dom_children(fragment.document())
377                .filter(|&child| {
378                    !fragment
379                        .element_name(child)
380                        .is_some_and(|q| q.local == LocalName::from("html"))
381                })
382                .collect();
383        }
384        if candidates.len() != 1 {
385            return Err(format!(
386                "serialized subtree must produce exactly one top-level node, got {}",
387                candidates.len()
388            ));
389        }
390        Ok(self.copy_fragment_node(&fragment, candidates[0]))
391    }
392
393    fn node(&self, id: NodeId) -> &Node {
394        self.nodes
395            .get(&self.index(id))
396            .expect("NodeId refers to a live node")
397    }
398
399    fn node_mut(&mut self, id: NodeId) -> &mut Node {
400        let i = self.index(id);
401        self.nodes
402            .get_mut(&i)
403            .expect("NodeId refers to a live node")
404    }
405
406    fn push(&mut self, node: Node) -> NodeId {
407        let value = self.next_id;
408        self.next_id += 1;
409        self.nodes.insert(value, node);
410        self.pack(value)
411    }
412
413    fn sibling(&self, id: NodeId, delta: isize) -> Option<NodeId> {
414        let parent = self.node(id).parent?;
415        let kids = &self.node(parent).children;
416        let pos = kids.iter().position(|&c| c == id)?;
417        let target = pos as isize + delta;
418        if target < 0 {
419            return None;
420        }
421        kids.get(target as usize).copied()
422    }
423
424    /// Unlink `child` from its current parent (no mutation recorded).
425    fn detach(&mut self, child: NodeId) {
426        if let Some(parent) = self.node(child).parent {
427            let kids = &mut self.node_mut(parent).children;
428            if let Some(pos) = kids.iter().position(|&c| c == child) {
429                kids.remove(pos);
430            }
431        }
432        self.node_mut(child).parent = None;
433    }
434
435    /// The shared tree surgery behind `insert_before` / `move_before`: detach
436    /// `child`, re-link it under `parent` before `reference` (no mutation
437    /// recorded — the callers record what the operation *means*). The insertion
438    /// index resolves *after* detaching (so a move within the same parent
439    /// reflects the post-detach positions); a missing or non-child reference
440    /// falls back to append.
441    fn attach_at(&mut self, parent: NodeId, child: NodeId, reference: Option<NodeId>) {
442        self.detach(child);
443        self.node_mut(child).parent = Some(parent);
444        let idx = reference.and_then(|r| self.node(parent).children.iter().position(|&c| c == r));
445        let kids = &mut self.node_mut(parent).children;
446        match idx {
447            Some(i) => kids.insert(i, child),
448            None => kids.push(child),
449        }
450    }
451
452    /// Free a node and its whole subtree (entries removed from the store).
453    fn drop_subtree(&mut self, node: NodeId) {
454        let children = std::mem::take(&mut self.node_mut(node).children);
455        for child in children {
456            self.drop_subtree(child);
457        }
458        let i = self.index(node);
459        self.nodes.remove(&i);
460    }
461
462    /// Mark-sweep collection (G3 dangle contract). Prune every node not reachable
463    /// — by **undirected** walk over parent + child edges — from a document root
464    /// or one of `extra_roots`. `extra_roots` are the host's live-reflector pins
465    /// ([`ReflectorPins`](../serval_scripted/struct.ReflectorPins.html)); passing
466    /// them keeps a pinned orphan's whole connected component alive (JS can walk
467    /// `parentNode` up and children down and re-insert any of it). The DOM stays
468    /// pin-agnostic — it takes extra roots, not reflector knowledge. Returns the
469    /// number of nodes pruned.
470    ///
471    /// Idempotent and cheap to call often: the host drives it at the
472    /// `drain_mutations` boundary, on an idle tick, and right after an unpin.
473    pub fn collect(&mut self, extra_roots: impl IntoIterator<Item = NodeId>) -> usize {
474        // Mark: undirected reachability from the primary document root + extra
475        // roots (secondary documents and fragments survive only via the pins).
476        let mut marked: std::collections::HashSet<usize> = std::collections::HashSet::new();
477        let mut stack: Vec<usize> = Vec::new();
478        self.seed_mark(self.root, &mut marked, &mut stack);
479        for r in extra_roots {
480            self.seed_mark(r, &mut marked, &mut stack);
481        }
482        while let Some(v) = stack.pop() {
483            // This node's neighbours (parent + children) as owned ids, dropping
484            // the store borrow before recursing.
485            let neighbours: Vec<NodeId> = match self.nodes.get(&v) {
486                Some(node) => node
487                    .parent
488                    .into_iter()
489                    .chain(node.children.iter().copied())
490                    .collect(),
491                None => continue,
492            };
493            for nbr in neighbours {
494                self.seed_mark(nbr, &mut marked, &mut stack);
495            }
496        }
497
498        // Sweep: prune every unmarked entry.
499        let before = self.nodes.len();
500        self.nodes.retain(|k, _| marked.contains(k));
501        before - self.nodes.len()
502    }
503
504    /// The number of live nodes currently in the store. Bounded by `collect`
505    /// (G3), not by total nodes ever created — a diagnostic for the host and the
506    /// regression signal for the bounded-memory churn test.
507    pub fn live_node_count(&self) -> usize {
508        self.nodes.len()
509    }
510
511    /// Cheap live counts plus a rough byte estimate for the DOM arena.
512    pub fn stats(&self) -> DomArenaStats {
513        let mut node_kinds = DomNodeKindStats::default();
514        let mut attribute_count = 0usize;
515        let mut estimated_bytes = std::mem::size_of::<Self>()
516            + self.nodes.capacity() * (std::mem::size_of::<usize>() + std::mem::size_of::<Node>())
517            + self.mutations.capacity() * std::mem::size_of::<DomMutation<NodeId>>();
518
519        for node in self.nodes.values() {
520            match node.kind {
521                NodeKind::Document => node_kinds.documents += 1,
522                NodeKind::DocumentFragment => node_kinds.document_fragments += 1,
523                NodeKind::Doctype => node_kinds.doctypes += 1,
524                NodeKind::Element => node_kinds.elements += 1,
525                NodeKind::Text => node_kinds.text += 1,
526                NodeKind::Comment => node_kinds.comments += 1,
527                NodeKind::ProcessingInstruction => node_kinds.processing_instructions += 1,
528            }
529
530            attribute_count += node.attrs.len();
531            estimated_bytes += node.attrs.capacity() * std::mem::size_of::<(QualName, String)>();
532            estimated_bytes += node.children.capacity() * std::mem::size_of::<NodeId>();
533            estimated_bytes += node.name.as_ref().map_or(0, qual_name_bytes);
534            estimated_bytes += node.text.as_ref().map_or(0, String::capacity);
535            for (name, value) in &node.attrs {
536                estimated_bytes += qual_name_bytes(name);
537                estimated_bytes += value.capacity();
538            }
539        }
540
541        DomArenaStats {
542            live_nodes: self.nodes.len(),
543            node_kinds,
544            attribute_count,
545            estimated_bytes,
546        }
547    }
548
549    /// Mark `id`'s store key live (if it resolves to a live node here) and queue
550    /// it for the `collect` walk. Skips foreign/dead ids and already-marked ones.
551    fn seed_mark(
552        &self,
553        id: NodeId,
554        marked: &mut std::collections::HashSet<usize>,
555        stack: &mut Vec<usize>,
556    ) {
557        if let Some(v) = self.try_index(id) {
558            if self.nodes.contains_key(&v) && marked.insert(v) {
559                stack.push(v);
560            }
561        }
562    }
563
564    /// Link `child` under `parent` without recording a mutation. Used while
565    /// building a parsed subtree, which is covered by one `SubtreeReplaced`.
566    fn attach_silent(&mut self, parent: NodeId, child: NodeId) {
567        self.node_mut(child).parent = Some(parent);
568        self.node_mut(parent).children.push(child);
569    }
570
571    /// Deep-copy a node from a parsed [`StaticDocument`] into this arena (silent),
572    /// returning the new id.
573    fn copy_fragment_node(&mut self, src: &StaticDocument, sid: StaticNodeId) -> NodeId {
574        let new = match src.kind(sid) {
575            NodeKind::Element => {
576                let mut node = Node::new(NodeKind::Element);
577                node.name = src.element_name(sid).cloned();
578                for attr in src.attributes(sid) {
579                    node.attrs.push((attr.name.clone(), attr.value.to_owned()));
580                }
581                self.push(node)
582            },
583            kind @ (NodeKind::Text | NodeKind::Comment) => {
584                let mut node = Node::new(kind);
585                node.text = src.text(sid).map(str::to_owned);
586                self.push(node)
587            },
588            other => self.push(Node::new(other)),
589        };
590        let children: Vec<StaticNodeId> = src.dom_children(sid).collect();
591        for child in children {
592            let copied = self.copy_fragment_node(src, child);
593            self.attach_silent(new, copied);
594        }
595        new
596    }
597
598    /// The `<body>` element of a `parse_document`-parsed fragment, if present.
599    fn fragment_body(doc: &StaticDocument) -> Option<StaticNodeId> {
600        let html = doc.document_element()?;
601        doc.dom_children(html).find(|&c| {
602            doc.element_name(c)
603                .is_some_and(|q| q.local == LocalName::from("body"))
604        })
605    }
606}
607
608fn qual_name_bytes(name: &QualName) -> usize {
609    name.ns.as_ref().len()
610        + name.local.as_ref().len()
611        + name
612            .prefix
613            .as_ref()
614            .map_or(0, |prefix| prefix.as_ref().len())
615}
616
617impl LayoutDom for ScriptedDom {
618    type NodeId = NodeId;
619
620    fn document(&self) -> NodeId {
621        self.root
622    }
623
624    /// The dangle-contract liveness check (see [`LayoutDom::is_live`]). Live iff
625    /// the id belongs to this document and still has an entry in the store
626    /// (attached or orphaned-but-kept); a dropped or collected node has no entry.
627    /// Never panics, unlike the read accessors.
628    fn is_live(&self, id: NodeId) -> bool {
629        self.try_index(id)
630            .is_some_and(|v| self.nodes.contains_key(&v))
631    }
632
633    fn parent(&self, id: NodeId) -> Option<NodeId> {
634        self.node(id).parent
635    }
636
637    fn prev_sibling(&self, id: NodeId) -> Option<NodeId> {
638        self.sibling(id, -1)
639    }
640
641    fn next_sibling(&self, id: NodeId) -> Option<NodeId> {
642        self.sibling(id, 1)
643    }
644
645    fn dom_children(&self, id: NodeId) -> impl Iterator<Item = NodeId> + '_ {
646        self.node(id).children.iter().copied()
647    }
648
649    fn kind(&self, id: NodeId) -> NodeKind {
650        self.node(id).kind
651    }
652
653    fn opaque_id(&self, id: NodeId) -> u64 {
654        // Assert ownership (the fence), then return the full packed id so the
655        // tag rides opaquely through Stylo's `OpaqueElement`. Off the fence
656        // this is just `id.0`, identical to before.
657        let _ = self.index(id);
658        id.0 as u64
659    }
660
661    fn element_name(&self, id: NodeId) -> Option<&QualName> {
662        self.node(id).name.as_ref()
663    }
664
665    fn attribute(&self, id: NodeId, ns: &Namespace, local: &LocalName) -> Option<&str> {
666        self.node(id)
667            .attrs
668            .iter()
669            .find(|(name, _)| &name.ns == ns && &name.local == local)
670            .map(|(_, value)| value.as_str())
671    }
672
673    fn attributes(&self, id: NodeId) -> impl Iterator<Item = AttributeView<'_>> + '_ {
674        self.node(id)
675            .attrs
676            .iter()
677            .map(|(name, value)| AttributeView {
678                name,
679                value: value.as_str(),
680            })
681    }
682
683    fn text(&self, id: NodeId) -> Option<&str> {
684        self.node(id).text.as_deref()
685    }
686}
687
688impl LayoutDomMut for ScriptedDom {
689    fn create_element(&mut self, name: QualName) -> NodeId {
690        let mut node = Node::new(NodeKind::Element);
691        node.name = Some(name);
692        self.push(node)
693    }
694
695    fn create_text(&mut self, data: &str) -> NodeId {
696        let mut node = Node::new(NodeKind::Text);
697        node.text = Some(data.to_owned());
698        self.push(node)
699    }
700
701    fn append_child(&mut self, parent: NodeId, child: NodeId) {
702        // Spec observability: appending an in-tree node is a remove + insert,
703        // and the former parent's consumers must hear the removal. The detach
704        // used to be silent here. (moveBefore plan S1.)
705        if let Some(former_parent) = self.node(child).parent {
706            self.mutations.push(DomMutation::Removed {
707                node: child,
708                former_parent,
709            });
710        }
711        self.detach(child);
712        self.node_mut(child).parent = Some(parent);
713        self.node_mut(parent).children.push(child);
714        self.mutations.push(DomMutation::Inserted {
715            node: child,
716            parent,
717        });
718    }
719
720    fn insert_before(&mut self, parent: NodeId, child: NodeId, reference: Option<NodeId>) {
721        // As in `append_child`: an in-tree insert is a remove + insert, both
722        // observable. State preservation is `move_before`'s contract, not this
723        // one's. (moveBefore plan S1.)
724        if let Some(former_parent) = self.node(child).parent {
725            self.mutations.push(DomMutation::Removed {
726                node: child,
727                former_parent,
728            });
729        }
730        self.attach_at(parent, child, reference);
731        self.mutations.push(DomMutation::Inserted {
732            node: child,
733            parent,
734        });
735    }
736
737    fn move_before(&mut self, parent: NodeId, child: NodeId, reference: Option<NodeId>) {
738        let from_parent = self.node(child).parent;
739        // Spec pre-move step: a reference of the moving node itself means
740        // "before my own next sibling", i.e. stay in place.
741        let reference = if reference == Some(child) {
742            self.sibling(child, 1)
743        } else {
744            reference
745        };
746        // A move resolving to the current position is a no-op: nothing moves,
747        // nothing is recorded (and per spec, no state resets either).
748        if from_parent == Some(parent) && self.sibling(child, 1) == reference {
749            return;
750        }
751        self.attach_at(parent, child, reference);
752        match from_parent {
753            Some(from_parent) => self.mutations.push(DomMutation::Moved {
754                node: child,
755                from_parent,
756                to_parent: parent,
757            }),
758            // A disconnected node has no subtree state to preserve; record the
759            // plain insert this actually is. (The DOM-level `moveBefore` throws
760            // there; this layout-side contract stays total.)
761            None => self.mutations.push(DomMutation::Inserted {
762                node: child,
763                parent,
764            }),
765        }
766    }
767
768    fn remove(&mut self, node: NodeId) {
769        let former_parent = self.node(node).parent;
770        self.detach(node);
771        if let Some(former_parent) = former_parent {
772            self.mutations.push(DomMutation::Removed {
773                node,
774                former_parent,
775            });
776        }
777        self.drop_subtree(node);
778    }
779
780    fn set_attribute(&mut self, node: NodeId, name: QualName, value: &str) {
781        let attrs = &mut self.node_mut(node).attrs;
782        // Capture the prior value before overwriting — serval-layout needs
783        // it to build the Stylo snapshot at restyle time (the old value is
784        // gone from the live DOM once we mutate). `None` = newly added.
785        let old_value;
786        if let Some(existing) = attrs
787            .iter_mut()
788            .find(|(n, _)| n.ns == name.ns && n.local == name.local)
789        {
790            old_value = Some(std::mem::replace(&mut existing.1, value.to_owned()));
791        } else {
792            old_value = None;
793            attrs.push((name.clone(), value.to_owned()));
794        }
795        self.mutations.push(DomMutation::AttributeChanged {
796            node,
797            name,
798            old_value,
799        });
800    }
801
802    fn remove_attribute(&mut self, node: NodeId, name: QualName) {
803        // Drop the matching attribute and capture its prior value; the
804        // borrow ends before we record the mutation. No-op (and no record)
805        // when the attribute is absent.
806        let removed = {
807            let attrs = &mut self.node_mut(node).attrs;
808            attrs
809                .iter()
810                .position(|(n, _)| n.ns == name.ns && n.local == name.local)
811                .map(|pos| attrs.remove(pos).1)
812        };
813        if let Some(old) = removed {
814            self.mutations.push(DomMutation::AttributeChanged {
815                node,
816                name,
817                old_value: Some(old),
818            });
819        }
820    }
821
822    fn set_text(&mut self, node: NodeId, data: &str) {
823        self.node_mut(node).text = Some(data.to_owned());
824        self.mutations
825            .push(DomMutation::CharacterDataChanged { node });
826    }
827
828    fn set_inner_html(&mut self, node: NodeId, html: &str) {
829        // Drop the current children silently — the single SubtreeReplaced covers it.
830        let existing = std::mem::take(&mut self.node_mut(node).children);
831        for child in existing {
832            self.node_mut(child).parent = None;
833            self.drop_subtree(child);
834        }
835        // Parse via the static parser (a LayoutDom) and copy the <body> children in.
836        // (Simplification: uses `parse_document` + the body subtree rather than true
837        // context-aware fragment parsing; fine for the common element-fragment case.)
838        let fragment = StaticDocument::parse(html);
839        if let Some(body) = Self::fragment_body(&fragment) {
840            let body_children: Vec<StaticNodeId> = fragment.dom_children(body).collect();
841            for child in body_children {
842                let copied = self.copy_fragment_node(&fragment, child);
843                self.attach_silent(node, copied);
844            }
845        }
846        self.mutations.push(DomMutation::SubtreeReplaced { node });
847    }
848
849    fn drain_mutations(&mut self, out: &mut Vec<DomMutation<NodeId>>) {
850        out.append(&mut self.mutations);
851    }
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use layout_dom_api::CapturedMutation;
858
859    fn qual(local: &str) -> QualName {
860        QualName::new(None, Namespace::from(""), LocalName::from(local))
861    }
862
863    #[test]
864    fn mutate_read_and_record() {
865        let mut dom = ScriptedDom::new();
866        let root = dom.document();
867
868        let div = dom.create_element(qual("div"));
869        dom.append_child(root, div);
870        let text = dom.create_text("hello");
871        dom.append_child(div, text);
872        dom.set_attribute(div, qual("id"), "main");
873
874        // Read surface reflects the mutations.
875        assert_eq!(dom.kind(div), NodeKind::Element);
876        assert_eq!(dom.element_name(div).unwrap().local, LocalName::from("div"));
877        assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![div]);
878        assert_eq!(dom.dom_children(div).collect::<Vec<_>>(), vec![text]);
879        assert_eq!(dom.parent(text), Some(div));
880        assert_eq!(dom.text(text), Some("hello"));
881        assert_eq!(
882            dom.attribute(div, &Namespace::from(""), &LocalName::from("id")),
883            Some("main")
884        );
885
886        // Mutation stream: 2 inserts + 1 attribute change, then drained empty.
887        let mut muts = Vec::new();
888        dom.drain_mutations(&mut muts);
889        assert_eq!(muts.len(), 3);
890        let mut again = Vec::new();
891        dom.drain_mutations(&mut again);
892        assert!(again.is_empty());
893    }
894
895    #[test]
896    fn set_inner_html_builds_subtree() {
897        let mut dom = ScriptedDom::new();
898        let root = dom.document();
899        let div = dom.create_element(qual("div"));
900        dom.append_child(root, div);
901        let mut drained = Vec::new();
902        dom.drain_mutations(&mut drained); // clear the append
903
904        dom.set_inner_html(div, "<p>hi</p><span>x</span>");
905
906        let kids: Vec<_> = dom.dom_children(div).collect();
907        assert_eq!(kids.len(), 2);
908        assert_eq!(
909            dom.element_name(kids[0]).unwrap().local,
910            LocalName::from("p")
911        );
912        assert_eq!(
913            dom.element_name(kids[1]).unwrap().local,
914            LocalName::from("span")
915        );
916        // <p>hi</p> — the <p> has a single text child "hi".
917        let p_kids: Vec<_> = dom.dom_children(kids[0]).collect();
918        assert_eq!(p_kids.len(), 1);
919        assert_eq!(dom.text(p_kids[0]), Some("hi"));
920
921        let mut muts = Vec::new();
922        dom.drain_mutations(&mut muts);
923        assert!(matches!(
924            muts.as_slice(),
925            [DomMutation::SubtreeReplaced { .. }]
926        ));
927    }
928
929    #[test]
930    fn siblings_and_remove() {
931        let mut dom = ScriptedDom::new();
932        let root = dom.document();
933        let a = dom.create_element(qual("a"));
934        let b = dom.create_element(qual("b"));
935        dom.append_child(root, a);
936        dom.append_child(root, b);
937
938        assert_eq!(dom.next_sibling(a), Some(b));
939        assert_eq!(dom.prev_sibling(b), Some(a));
940        assert_eq!(dom.prev_sibling(a), None);
941
942        let mut drained = Vec::new();
943        dom.drain_mutations(&mut drained); // clear the two inserts
944
945        dom.remove(a);
946        assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![b]);
947        assert_eq!(dom.next_sibling(b), None);
948
949        let mut muts = Vec::new();
950        dom.drain_mutations(&mut muts);
951        assert!(matches!(muts.as_slice(), [DomMutation::Removed { .. }]));
952    }
953
954    #[test]
955    fn insert_before_orders_and_appends() {
956        let mut dom = ScriptedDom::new();
957        let root = dom.document();
958        let a = dom.create_element(qual("a"));
959        let c = dom.create_element(qual("c"));
960        dom.append_child(root, a);
961        dom.append_child(root, c);
962        let mut drained = Vec::new();
963        dom.drain_mutations(&mut drained); // clear the two appends
964
965        // Insert b before c → [a, b, c].
966        let b = dom.create_element(qual("b"));
967        dom.insert_before(root, b, Some(c));
968        assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![a, b, c]);
969        assert_eq!(dom.parent(b), Some(root));
970
971        // reference = None appends → [a, b, c, d].
972        let d = dom.create_element(qual("d"));
973        dom.insert_before(root, d, None);
974        assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![a, b, c, d]);
975
976        // A reference that isn't a child of root falls back to append → [a, b, c, d, e].
977        let orphan = dom.create_element(qual("orphan"));
978        let e = dom.create_element(qual("e"));
979        dom.insert_before(root, e, Some(orphan));
980        assert_eq!(
981            dom.dom_children(root).collect::<Vec<_>>(),
982            vec![a, b, c, d, e]
983        );
984
985        // Each insert recorded exactly one Inserted under root (all three
986        // children were detached fresh nodes, so no Removed accompanies them).
987        let mut muts = Vec::new();
988        dom.drain_mutations(&mut muts);
989        assert_eq!(muts.len(), 3);
990        assert!(
991            muts.iter()
992                .all(|m| matches!(m, DomMutation::Inserted { parent, .. } if *parent == root))
993        );
994    }
995
996    #[test]
997    fn in_tree_insert_reports_the_removal_too() {
998        // Re-inserting an in-tree node is a remove + insert, both observable —
999        // the former parent's consumers must hear the child left. (moveBefore
1000        // plan S1: this detach used to be silent.)
1001        let mut dom = ScriptedDom::new();
1002        let root = dom.document();
1003        let a = dom.create_element(qual("a"));
1004        let b = dom.create_element(qual("b"));
1005        let child = dom.create_element(qual("child"));
1006        dom.append_child(root, a);
1007        dom.append_child(root, b);
1008        dom.append_child(a, child);
1009        let mut drained = Vec::new();
1010        dom.drain_mutations(&mut drained);
1011
1012        dom.insert_before(b, child, None);
1013        let mut muts = Vec::new();
1014        dom.drain_mutations(&mut muts);
1015        assert_eq!(
1016            muts,
1017            vec![
1018                DomMutation::Removed {
1019                    node: child,
1020                    former_parent: a,
1021                },
1022                DomMutation::Inserted {
1023                    node: child,
1024                    parent: b,
1025                },
1026            ]
1027        );
1028    }
1029
1030    #[test]
1031    fn move_before_moves_across_parents_with_one_moved_record() {
1032        let mut dom = ScriptedDom::new();
1033        let root = dom.document();
1034        let a = dom.create_element(qual("a"));
1035        let b = dom.create_element(qual("b"));
1036        let child = dom.create_element(qual("child"));
1037        let b_kid = dom.create_element(qual("bkid"));
1038        dom.append_child(root, a);
1039        dom.append_child(root, b);
1040        dom.append_child(a, child);
1041        dom.append_child(b, b_kid);
1042        let mut drained = Vec::new();
1043        dom.drain_mutations(&mut drained);
1044
1045        dom.move_before(b, child, Some(b_kid));
1046        assert_eq!(dom.parent(child), Some(b));
1047        assert_eq!(dom.dom_children(b).collect::<Vec<_>>(), vec![child, b_kid]);
1048        assert!(dom.dom_children(a).next().is_none());
1049
1050        // One Moved record: not a Removed + Inserted pair, so consumers may
1051        // keep the subtree's retained state.
1052        let mut muts = Vec::new();
1053        dom.drain_mutations(&mut muts);
1054        assert_eq!(
1055            muts,
1056            vec![DomMutation::Moved {
1057                node: child,
1058                from_parent: a,
1059                to_parent: b,
1060            }]
1061        );
1062    }
1063
1064    #[test]
1065    fn move_before_same_parent_reorders_and_noops_in_place() {
1066        let mut dom = ScriptedDom::new();
1067        let root = dom.document();
1068        let a = dom.create_element(qual("a"));
1069        let b = dom.create_element(qual("b"));
1070        let c = dom.create_element(qual("c"));
1071        dom.append_child(root, a);
1072        dom.append_child(root, b);
1073        dom.append_child(root, c);
1074        let mut drained = Vec::new();
1075        dom.drain_mutations(&mut drained);
1076
1077        // Reorder: move c before a → [c, a, b], one Moved with equal parents.
1078        dom.move_before(root, c, Some(a));
1079        assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![c, a, b]);
1080        let mut muts = Vec::new();
1081        dom.drain_mutations(&mut muts);
1082        assert_eq!(
1083            muts,
1084            vec![DomMutation::Moved {
1085                node: c,
1086                from_parent: root,
1087                to_parent: root,
1088            }]
1089        );
1090
1091        // A move to the current position records nothing: before its own next
1092        // sibling, before itself (the spec pre-move step), and a last child
1093        // "moved" to append.
1094        dom.move_before(root, c, Some(a));
1095        dom.move_before(root, c, Some(c));
1096        dom.move_before(root, b, None);
1097        let mut noop = Vec::new();
1098        dom.drain_mutations(&mut noop);
1099        assert_eq!(noop, Vec::new());
1100        assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![c, a, b]);
1101    }
1102
1103    #[test]
1104    fn remove_attribute_records_and_noops() {
1105        let mut dom = ScriptedDom::new();
1106        let root = dom.document();
1107        let div = dom.create_element(qual("div"));
1108        dom.append_child(root, div);
1109        dom.set_attribute(div, qual("id"), "main");
1110        let mut drained = Vec::new();
1111        dom.drain_mutations(&mut drained); // clear the append + set
1112
1113        // Removing a present attribute drops it and records the old value.
1114        dom.remove_attribute(div, qual("id"));
1115        assert_eq!(
1116            dom.attribute(div, &Namespace::from(""), &LocalName::from("id")),
1117            None
1118        );
1119        let mut muts = Vec::new();
1120        dom.drain_mutations(&mut muts);
1121        assert!(matches!(
1122            muts.as_slice(),
1123            [DomMutation::AttributeChanged { old_value: Some(v), .. }] if v.as_str() == "main"
1124        ));
1125
1126        // Removing an absent attribute is a no-op and records nothing.
1127        dom.remove_attribute(div, qual("id"));
1128        let mut again = Vec::new();
1129        dom.drain_mutations(&mut again);
1130        assert!(again.is_empty());
1131    }
1132
1133    #[test]
1134    fn drained_mutations_round_trip_through_captured_postcard() {
1135        let mut dom = ScriptedDom::new();
1136        let root = dom.document();
1137        let div = dom.create_element(qual("div"));
1138        dom.append_child(root, div);
1139        let mut drained = Vec::new();
1140        dom.drain_mutations(&mut drained); // clear the insert
1141
1142        dom.set_attribute(div, qual("id"), "main");
1143        dom.remove_attribute(div, qual("id"));
1144
1145        let mut muts = Vec::new();
1146        dom.drain_mutations(&mut muts);
1147        let captured: Vec<_> = muts
1148            .iter()
1149            .map(|m| CapturedMutation::capture(m, |id| dom.capture_node_id(*id)))
1150            .collect();
1151        let bytes = postcard::to_stdvec(&captured).expect("serialize captured mutations");
1152        let decoded: Vec<CapturedMutation> =
1153            postcard::from_bytes(&bytes).expect("decode captured mutations");
1154        let replayed: Vec<_> = decoded
1155            .into_iter()
1156            .map(|m| m.replay(|raw| dom.remint_node_id(raw)))
1157            .collect();
1158
1159        assert_eq!(replayed, muts);
1160    }
1161
1162    #[test]
1163    fn stats_count_node_kinds_attributes_and_bytes() {
1164        let mut dom = ScriptedDom::new();
1165        let root = dom.document();
1166        let html = dom.create_element(qual("html"));
1167        let body = dom.create_element(qual("body"));
1168        let text = dom.create_text("hello");
1169        let comment = dom.create_comment("note");
1170        let frag = dom.create_fragment();
1171        dom.append_child(root, html);
1172        dom.append_child(html, body);
1173        dom.append_child(body, text);
1174        dom.append_child(body, comment);
1175        dom.append_child(body, frag);
1176        dom.set_attribute(body, qual("class"), "main");
1177        dom.set_attribute(body, qual("data-x"), "1");
1178
1179        let stats = dom.stats();
1180        assert_eq!(stats.live_nodes, 6);
1181        assert_eq!(stats.node_kinds.documents, 1);
1182        assert_eq!(stats.node_kinds.elements, 2);
1183        assert_eq!(stats.node_kinds.text, 1);
1184        assert_eq!(stats.node_kinds.comments, 1);
1185        assert_eq!(stats.node_kinds.document_fragments, 1);
1186        assert_eq!(stats.attribute_count, 2);
1187        assert!(stats.estimated_bytes >= std::mem::size_of::<ScriptedDom>());
1188    }
1189
1190    // --- G0: the document fence ---------------------------------------------
1191
1192    #[test]
1193    fn secondary_root_is_same_document() {
1194        // `create_document` mints a second root in the *same* arena (same tag),
1195        // so cross-using ids between the two roots must not trip the fence.
1196        let mut dom = ScriptedDom::new();
1197        let primary = dom.document();
1198        let secondary = dom.create_document();
1199        let div = dom.create_element(qual("div"));
1200        dom.append_child(secondary, div);
1201        assert_eq!(dom.parent(div), Some(secondary));
1202        assert_ne!(primary, secondary);
1203        // Both roots resolve without panicking.
1204        assert_eq!(dom.kind(primary), NodeKind::Document);
1205        assert_eq!(dom.kind(secondary), NodeKind::Document);
1206    }
1207
1208    /// On a fenced build (64-bit debug) a `NodeId` minted by one document used
1209    /// against another panics. On release/wasm the fence compiles out, so this
1210    /// test only exists where the assert is live.
1211    #[cfg(all(debug_assertions, target_pointer_width = "64"))]
1212    #[test]
1213    #[should_panic(expected = "different document")]
1214    fn cross_document_node_id_panics() {
1215        let mut a = ScriptedDom::new();
1216        let b = ScriptedDom::new();
1217        let id_in_a = a.create_element(qual("div"));
1218        // `id_in_a` carries a's tag; resolving it against b trips the fence.
1219        let _ = b.kind(id_in_a);
1220    }
1221
1222    #[cfg(all(debug_assertions, target_pointer_width = "64"))]
1223    #[test]
1224    fn distinct_documents_get_distinct_tags() {
1225        let a = ScriptedDom::new();
1226        let b = ScriptedDom::new();
1227        // Roots share the arena index (0) but differ in the tagged high bits.
1228        assert_ne!(a.document().raw(), b.document().raw());
1229    }
1230
1231    #[test]
1232    fn captured_node_id_remints_for_another_document() {
1233        let a = ScriptedDom::new();
1234        let b = ScriptedDom::new();
1235        let captured = a.capture_node_id(a.document());
1236        let reminted = b.remint_node_id(captured);
1237        assert!(b.is_live(reminted));
1238        #[cfg(all(debug_assertions, target_pointer_width = "64"))]
1239        {
1240            assert!(!b.is_live(a.document()));
1241            assert_ne!(a.document(), reminted);
1242        }
1243    }
1244
1245    // --- G2: the dangle contract (is_live) ----------------------------------
1246
1247    /// The contract under create/remove/re-query across frames (the slab
1248    /// implementation G3 must preserve, allocator aside). Attached → live;
1249    /// dropped → dead; orphaned-but-kept → still live; cross-document → not live.
1250    #[test]
1251    fn dangle_contract_churn_across_frames() {
1252        let mut dom = ScriptedDom::new();
1253        let root = dom.document();
1254
1255        // Frame 1: build a small tree, drain the mutations (the frame boundary).
1256        let a = dom.create_element(qual("a"));
1257        let b = dom.create_element(qual("b"));
1258        dom.append_child(root, a);
1259        dom.append_child(root, b);
1260        let mut drained = Vec::new();
1261        dom.drain_mutations(&mut drained);
1262
1263        // Attached ids are live.
1264        assert!(dom.is_live(root));
1265        assert!(dom.is_live(a));
1266        assert!(dom.is_live(b));
1267
1268        // Frame 2: `remove` drops `a`'s subtree. Its id is now dead, and a
1269        // re-query of the tree no longer contains it.
1270        dom.remove(a);
1271        dom.drain_mutations(&mut drained);
1272        assert!(!dom.is_live(a));
1273        assert!(dom.is_live(b));
1274        assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![b]);
1275
1276        // Frame 3: `remove_child` orphans `b` but keeps it — still live and
1277        // re-insertable (the orphan semantics the gc-arena refit must honor).
1278        dom.remove_child(b);
1279        dom.drain_mutations(&mut drained);
1280        assert!(dom.is_live(b), "an orphaned node stays live until dropped");
1281        assert!(dom.dom_children(root).collect::<Vec<_>>().is_empty());
1282        dom.append_child(root, b); // re-insert the orphan
1283        assert_eq!(dom.dom_children(root).collect::<Vec<_>>(), vec![b]);
1284        assert!(dom.is_live(b));
1285    }
1286
1287    #[test]
1288    fn is_live_is_false_for_dropped_and_foreign_ids() {
1289        let mut dom = ScriptedDom::new();
1290        let root = dom.document();
1291        let n = dom.create_element(qual("n"));
1292        dom.append_child(root, n);
1293        assert!(dom.is_live(n));
1294        dom.remove(n);
1295        assert!(!dom.is_live(n));
1296
1297        // An id from another document is not live here (no panic — `is_live` is
1298        // the non-asserting check, unlike the read accessors).
1299        let other = ScriptedDom::new();
1300        let foreign = {
1301            let mut o = other;
1302            o.create_element(qual("x"))
1303        };
1304        assert!(!dom.is_live(foreign));
1305    }
1306
1307    // --- G3: mark-sweep collection ------------------------------------------
1308
1309    const NO_PINS: [NodeId; 0] = [];
1310
1311    /// `collect` keeps the attached tree and reaps an unpinned orphan, but spares
1312    /// a pinned orphan *and its whole connected component* (undirected mark).
1313    #[test]
1314    fn collect_reaps_unpinned_orphans_keeps_pinned_components() {
1315        let mut dom = ScriptedDom::new();
1316        let root = dom.document();
1317        let attached = dom.create_element(qual("attached"));
1318        dom.append_child(root, attached);
1319
1320        // An orphan subtree: parent -> mid -> leaf, all detached from the document.
1321        let parent = dom.create_element(qual("p"));
1322        let mid = dom.create_element(qual("mid"));
1323        let leaf = dom.create_element(qual("leaf"));
1324        dom.append_child(parent, mid);
1325        dom.append_child(mid, leaf);
1326
1327        // With no pins, the orphan subtree is unreachable → collected; the
1328        // attached tree survives.
1329        let live_before = dom.live_node_count();
1330        let pruned = dom.collect(NO_PINS);
1331        assert_eq!(pruned, 3, "the 3-node orphan subtree is reaped");
1332        assert_eq!(dom.live_node_count(), live_before - 3);
1333        assert!(dom.is_live(root) && dom.is_live(attached));
1334        assert!(!dom.is_live(parent) && !dom.is_live(mid) && !dom.is_live(leaf));
1335
1336        // Rebuild the orphan and pin only the *deep* leaf: the undirected mark
1337        // must keep the leaf's ancestors too (JS can walk parentNode up).
1338        let parent = dom.create_element(qual("p"));
1339        let mid = dom.create_element(qual("mid"));
1340        let leaf = dom.create_element(qual("leaf"));
1341        dom.append_child(parent, mid);
1342        dom.append_child(mid, leaf);
1343        let pruned = dom.collect([leaf]); // pin the leaf
1344        assert_eq!(pruned, 0, "a pin on the leaf spares the whole component");
1345        assert!(dom.is_live(parent) && dom.is_live(mid) && dom.is_live(leaf));
1346
1347        // Drop the pin → the component is now collectable.
1348        assert_eq!(dom.collect(NO_PINS), 3);
1349        assert!(!dom.is_live(parent) && !dom.is_live(mid) && !dom.is_live(leaf));
1350    }
1351
1352    /// The bounded-memory property: sustained create/remove-child/collect cycles
1353    /// plateau the store, where the never-reuse slab would grow without bound.
1354    #[test]
1355    fn collect_bounds_memory_under_churn() {
1356        let mut dom = ScriptedDom::new();
1357        let root = dom.document();
1358        let baseline = dom.live_node_count();
1359
1360        let mut peak = baseline;
1361        for _ in 0..500 {
1362            // Attach a small subtree, then orphan it and collect (no pins) — the
1363            // SPA churn shape: create nodes, detach them, JS drops the reflectors.
1364            let host = dom.create_element(qual("host"));
1365            dom.append_child(root, host);
1366            for _ in 0..8 {
1367                let kid = dom.create_element(qual("kid"));
1368                dom.append_child(host, kid);
1369            }
1370            peak = peak.max(dom.live_node_count());
1371            dom.remove_child(host); // orphan the whole subtree
1372            dom.collect(NO_PINS); // reap it
1373        }
1374
1375        // Monotonic ids kept climbing, but the store is back to baseline — bounded.
1376        assert!(dom.next_id > 4000, "ids are monotonic (no reuse)");
1377        assert_eq!(dom.live_node_count(), baseline, "store plateaus, not grows");
1378        assert!(
1379            peak < baseline + 32,
1380            "peak stays tiny — only one subtree at a time"
1381        );
1382    }
1383
1384    /// A quiet document (no further mutations) still reaps orphans on an idle
1385    /// `collect` — the backgrounded-SPA case.
1386    #[test]
1387    fn idle_collect_reaps_orphans_without_mutations() {
1388        let mut dom = ScriptedDom::new();
1389        let root = dom.document();
1390        let o = dom.create_element(qual("o"));
1391        dom.append_child(root, o);
1392        dom.remove_child(o); // orphan; no pin
1393        let mut drained = Vec::new();
1394        dom.drain_mutations(&mut drained);
1395
1396        // No mutations happen now; an idle collect still reaps the orphan.
1397        assert!(dom.is_live(o));
1398        assert_eq!(dom.collect(NO_PINS), 1);
1399        assert!(!dom.is_live(o));
1400    }
1401
1402    /// A `create_document` secondary is pin-kept (G3 carve-out #3): while pinned
1403    /// its whole subtree survives `collect`; once the pin drops, it collects like
1404    /// any other orphan (a dropped `createHTMLDocument` no longer leaks).
1405    #[test]
1406    fn secondary_document_is_pin_kept() {
1407        let mut dom = ScriptedDom::new();
1408        let secondary = dom.create_document();
1409        let body = dom.create_element(qual("body"));
1410        dom.append_child(secondary, body);
1411
1412        // Pinned (script holds it) → the whole secondary component survives.
1413        assert_eq!(dom.collect([secondary]), 0);
1414        assert!(dom.is_live(secondary) && dom.is_live(body));
1415
1416        // Dropped (no pin) → it collects.
1417        assert_eq!(dom.collect(NO_PINS), 2);
1418        assert!(!dom.is_live(secondary) && !dom.is_live(body));
1419    }
1420
1421    #[test]
1422    fn pins_pin_unpin_and_retire() {
1423        let id = NodeId::from_raw;
1424        let mut pins = Pins::new();
1425        assert!(pins.is_empty());
1426
1427        pins.pin(id(0x10));
1428        pins.pin(id(0x20));
1429        pins.pin(id(0x10)); // idempotent
1430        assert_eq!(pins.len(), 2);
1431        assert!(pins.is_pinned(id(0x10)));
1432
1433        assert!(pins.unpin(id(0x20)));
1434        assert!(!pins.unpin(id(0xAA))); // never pinned → no-op
1435
1436        pins.pin(id(0x30));
1437        assert_eq!(pins.retire_dead([id(0x10), id(0x30), id(0xBB)]), 2);
1438        assert!(pins.is_empty());
1439    }
1440
1441    #[test]
1442    fn pins_clear() {
1443        let mut pins = Pins::new();
1444        pins.pin(NodeId::from_raw(1));
1445        pins.pin(NodeId::from_raw(2));
1446        assert_eq!(pins.len(), 2);
1447        pins.clear();
1448        assert!(pins.is_empty());
1449    }
1450}