Skip to main content

layout_dom_api/
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//! Profile-neutral DOM trait.
6//!
7//! `LayoutDom` is the ID-first surface that `serval-layout` (and other
8//! read-only DOM walkers — reader-mode, serialization, querySelector helpers)
9//! consume. It does not commit to a backing store: `serval-static-dom`'s
10//! `StaticDocument` and a future scripted-DOM provider both implement it.
11//!
12//! Design rationale and prior art: see
13//! `docs/2026-05-16_layout_dom_api_design.md`.
14
15#![deny(unsafe_code)]
16
17use std::fmt::Debug;
18use std::hash::Hash;
19use std::ops::ControlFlow;
20
21#[cfg(feature = "capture")]
22use markup5ever::Prefix;
23pub use markup5ever::interface::QuirksMode;
24pub use markup5ever::{LocalName, Namespace, QualName};
25#[cfg(feature = "capture")]
26use serde::{Deserialize, Serialize};
27
28/// Profile-neutral DOM. Implementors expose opaque `NodeId`s and a small set
29/// of lookup primitives; traversal happens through the default `walk` impl
30/// over a [`NodeVisitor`], or through caller-driven cursors built on the
31/// lookup primitives.
32pub trait LayoutDom {
33    /// Opaque per-backend node identity. Must be `Copy` for cheap pass-through.
34    type NodeId: Copy + Eq + Hash + Debug + 'static;
35
36    // ---- identity / structure -------------------------------------------
37
38    /// The document root.
39    ///
40    /// Two shapes are supported. A `Document` wrapper node whose element
41    /// children are the roots: parsed HTML has exactly one (`<html>`), but a
42    /// host-built synthetic DOM (an app chrome layer, a widget pool) may hang
43    /// SEVERAL elements here with no wrapper — layout styles and paints every
44    /// one of them (serval-layout wraps them in a synthetic block root; see
45    /// its `multi_root_document_paints_every_root_element` test). Or an
46    /// element node (a re-rooted subtree view): that element is itself the
47    /// root. Hosts do not need to invent an `<html>`/container element just
48    /// to satisfy layout. Note the CSS root-background propagation
49    /// (`<html>`/`<body>` background painting the whole canvas) applies only
50    /// to a sole-root document.
51    fn document(&self) -> Self::NodeId;
52
53    /// Whether `id` still resolves to a live node — the **dangle contract**.
54    ///
55    /// Contract: an id for an **attached** node is always live. An id for a node
56    /// that was dropped (by [`LayoutDomMut::remove`], or — once a backend
57    /// collects detached nodes — orphaned, unpinned, and collected) is **dead**.
58    /// `is_live` is the only read that is safe to call on a possibly-dead id; it
59    /// never panics. The other accessors assume a live id and may panic on a
60    /// dead one (the same "not found" outcome a removed slot gives). A caller
61    /// that holds an id across frames (a handler registry, a layout side-table,
62    /// a query result, an undrained mutation log) must treat it as possibly dead
63    /// and guard reads with `is_live`.
64    ///
65    /// Default: `true`. Immutable backends (a parsed [`LayoutDom`] with no
66    /// removal) never produce dead ids; a mutable backend overrides this.
67    fn is_live(&self, _id: Self::NodeId) -> bool {
68        true
69    }
70
71    /// The document's quirks mode, as selected by the parser (presence/absence
72    /// of a `<!DOCTYPE>`). Drives quirk-gated cascade behaviour (Stylo's
73    /// `QuirksMode`-conditional UA rules, e.g. the table font-size quirk).
74    /// Defaults to standards mode; a backend that parses a real document
75    /// overrides it.
76    fn quirks_mode(&self) -> QuirksMode {
77        QuirksMode::NoQuirks
78    }
79
80    /// Parent node, if any.
81    fn parent(&self, id: Self::NodeId) -> Option<Self::NodeId>;
82
83    /// Previous sibling in DOM order. Hot on selector-matching paths
84    /// (`prev_sibling_element` in `selectors::Element`); deriving it from
85    /// `dom_children(parent)` would be O(siblings) per call.
86    fn prev_sibling(&self, id: Self::NodeId) -> Option<Self::NodeId>;
87
88    /// Next sibling in DOM order. See [`Self::prev_sibling`].
89    fn next_sibling(&self, id: Self::NodeId) -> Option<Self::NodeId>;
90
91    /// DOM-tree children (parse-order, ignores shadow trees).
92    fn dom_children(&self, id: Self::NodeId) -> impl Iterator<Item = Self::NodeId> + '_;
93
94    /// Flat-tree children (slot-assigned for shadow hosts, otherwise DOM
95    /// order). Backends without shadow DOM should leave this defaulted.
96    fn flat_children(&self, id: Self::NodeId) -> impl Iterator<Item = Self::NodeId> + '_ {
97        self.dom_children(id)
98    }
99
100    // ---- kind and hot primitives ----------------------------------------
101
102    /// What kind of node `id` is. Plain enum; details via the typed
103    /// accessors below.
104    fn kind(&self, id: Self::NodeId) -> NodeKind;
105
106    /// Stable per-node identity as a `u64`. Used by foreign trait adapters
107    /// (Stylo's `OpaqueNode`, `selectors::OpaqueElement`) that need a
108    /// pointer-shaped value for identity comparisons in the cascade.
109    ///
110    /// Must satisfy: distinct nodes within the same backing store return
111    /// distinct `opaque_id` values, and the same node returns the same value
112    /// across calls. Implementations may use the inner storage index (dense
113    /// DOMs) or a hash (sparse DOMs).
114    ///
115    /// The default implementation hashes `id` with `DefaultHasher` — works
116    /// for any `NodeId: Hash` but isn't guaranteed to be collision-free
117    /// across all node sets. Backends should override when they can return
118    /// the natural underlying index cheaply.
119    fn opaque_id(&self, id: Self::NodeId) -> u64 {
120        use std::collections::hash_map::DefaultHasher;
121        use std::hash::Hasher;
122        let mut hasher = DefaultHasher::new();
123        id.hash(&mut hasher);
124        hasher.finish()
125    }
126
127    /// Element name when `id` is an element, else `None`. Hot on
128    /// selector/style match paths.
129    fn element_name(&self, id: Self::NodeId) -> Option<&QualName>;
130
131    /// Attribute value lookup by namespace + local name. Hot on selector/style
132    /// match paths. Backends with column-stored attrs can implement this as
133    /// a keyed lookup without materializing a full slice.
134    fn attribute(&self, id: Self::NodeId, ns: &Namespace, local: &LocalName) -> Option<&str>;
135
136    /// Iterate this element's attributes (cold path: serialization,
137    /// introspection). Yields `AttributeView`s borrowed from the backing
138    /// store.
139    fn attributes(&self, id: Self::NodeId) -> impl Iterator<Item = AttributeView<'_>> + '_;
140
141    /// Text content for text or comment nodes, else `None`.
142    fn text(&self, id: Self::NodeId) -> Option<&str>;
143
144    // ---- traversal -------------------------------------------------------
145
146    /// Walk the whole document from `document()`, descending via
147    /// `dom_children`. Backends override when they want backend-driven
148    /// traversal (parallel layout pass, prefetching, flat-tree descent).
149    fn walk<V>(&self, visitor: &mut V) -> ControlFlow<V::Stop>
150    where
151        V: NodeVisitor<Self> + ?Sized,
152    {
153        walk_subtree(self, self.document(), visitor)
154    }
155
156    // ---- class / tag queries --------------------------------------------
157    //
158    // Pre-order subtree searches a host and serval-internal callers both reach for
159    // (find the element painting a class, collect a class's placeholders, hit-test a
160    // tag). Provided as defaults over `dom_children` / `attributes` / `element_name`
161    // so neither side re-rolls the walk.
162
163    /// Whether element `id` carries CSS class `class` (whitespace-split `class` attr).
164    fn has_class(&self, id: Self::NodeId, class: &str) -> bool {
165        self.attributes(id).any(|a| {
166            a.name.local.as_ref() == "class" && a.value.split_whitespace().any(|c| c == class)
167        })
168    }
169
170    /// The first element carrying CSS class `class` in pre-order under `id` (inclusive).
171    fn first_with_class(&self, id: Self::NodeId, class: &str) -> Option<Self::NodeId> {
172        if self.has_class(id, class) {
173            return Some(id);
174        }
175        self.dom_children(id)
176            .find_map(|c| self.first_with_class(c, class))
177    }
178
179    /// Every element carrying CSS class `class` in pre-order under `id` (inclusive).
180    fn all_with_class(&self, id: Self::NodeId, class: &str) -> Vec<Self::NodeId> {
181        let mut out = Vec::new();
182        if self.has_class(id, class) {
183            out.push(id);
184        }
185        for child in self.dom_children(id) {
186            out.extend(self.all_with_class(child, class));
187        }
188        out
189    }
190
191    /// The first element with local tag name `local` in pre-order under `id` (inclusive).
192    fn first_tag(&self, id: Self::NodeId, local: &str) -> Option<Self::NodeId> {
193        if self
194            .element_name(id)
195            .is_some_and(|q| q.local.as_ref() == local)
196        {
197            return Some(id);
198        }
199        self.dom_children(id).find_map(|c| self.first_tag(c, local))
200    }
201}
202
203/// Mutation extension for scripted DOMs (plan Part 3 / the layout_dom_api design's
204/// open question #1). Read-only consumers (reader-mode, serialization, static
205/// layout) implement only [`LayoutDom`]; `serval-scripted-dom` implements both.
206///
207/// Mutators record *structural* change as [`DomMutation`] records — they carry no
208/// notion of dirty bits, style, or layout. serval-layout's scheduler drains the
209/// stream ([`Self::drain_mutations`]) and translates it into StylePlane/LayoutPlane
210/// invalidation; the DOM provider itself stays render-state-free.
211pub trait LayoutDomMut: LayoutDom {
212    /// Create a detached element node (no parent until appended).
213    fn create_element(&mut self, name: QualName) -> Self::NodeId;
214
215    /// Create a detached text node.
216    fn create_text(&mut self, data: &str) -> Self::NodeId;
217
218    /// Append `child` as the last child of `parent`, detaching it from any
219    /// previous parent first.
220    fn append_child(&mut self, parent: Self::NodeId, child: Self::NodeId);
221
222    /// Insert `child` immediately before `reference` among `parent`'s children,
223    /// detaching `child` from any previous parent first. Appends if `reference`
224    /// is `None`, or if `reference` is not a child of `parent` (defensive: the
225    /// DOM `insertBefore` throws in that case, but the layout-side contract
226    /// stays total). The ordered-insertion primitive a reactive differ needs;
227    /// `append_child` is the `reference == None` tail case.
228    fn insert_before(
229        &mut self,
230        parent: Self::NodeId,
231        child: Self::NodeId,
232        reference: Option<Self::NodeId>,
233    );
234
235    /// Atomically move an in-tree `child` under `parent` before `reference`
236    /// (append when `None`), preserving subtree state — the `Node.moveBefore()`
237    /// contract (WHATWG DOM; docs/2026-07-05_movebefore_dom_standard_plan.md).
238    /// Records one [`DomMutation::Moved`] instead of the `Removed` + `Inserted`
239    /// pair [`insert_before`](Self::insert_before) produces for an in-tree node,
240    /// so consumers may keep the subtree's retained state. A move resolving to
241    /// the current position records nothing; a disconnected `child` degrades to
242    /// a plain insert (the DOM-level `moveBefore` throws there; this layout-side
243    /// contract stays total, like `insert_before`'s bad-reference fallback).
244    fn move_before(
245        &mut self,
246        parent: Self::NodeId,
247        child: Self::NodeId,
248        reference: Option<Self::NodeId>,
249    );
250
251    /// Detach `node` from its parent and drop its subtree.
252    fn remove(&mut self, node: Self::NodeId);
253
254    /// Set (or replace) an attribute on an element.
255    fn set_attribute(&mut self, node: Self::NodeId, name: QualName, value: &str);
256
257    /// Remove the attribute named `name` from `node` (no-op if absent). Records
258    /// an [`DomMutation::AttributeChanged`] carrying the removed value as
259    /// `old_value` (the live DOM then reads as absent), so serval-layout builds
260    /// the Stylo snapshot the same way it does for a value change.
261    fn remove_attribute(&mut self, node: Self::NodeId, name: QualName);
262
263    /// Replace a text/comment node's character data.
264    fn set_text(&mut self, node: Self::NodeId, data: &str);
265
266    /// Replace `node`'s children with the subtree parsed from an HTML fragment
267    /// (the `innerHTML` setter). Records a single [`DomMutation::SubtreeReplaced`].
268    fn set_inner_html(&mut self, node: Self::NodeId, html: &str);
269
270    /// Drain the structural mutations recorded since the last call into `out`.
271    /// The provider records WHAT changed; serval-layout decides what to invalidate.
272    fn drain_mutations(&mut self, out: &mut Vec<DomMutation<Self::NodeId>>);
273}
274
275/// A recorded structural DOM mutation — render-state-free (no dirty bits, no style).
276/// `Id` is the implementor's [`LayoutDom::NodeId`].
277#[derive(Clone, Debug, PartialEq, Eq)]
278pub enum DomMutation<Id> {
279    /// `node` was inserted under `parent`.
280    Inserted { node: Id, parent: Id },
281    /// `node` was removed from `former_parent`.
282    Removed { node: Id, former_parent: Id },
283    /// The attribute named `name` was set or changed on `node`.
284    ///
285    /// `old_value` is the attribute's value *before* this change (`None`
286    /// if the attribute was newly added). It is plain pre-mutation DOM
287    /// data — not render state — and lets serval-layout reconstruct a
288    /// Stylo `ElementSnapshot` at restyle time (the old value is gone from
289    /// the live DOM by then). See
290    /// `docs/2026-05-25_fine_grained_restyle_plan.md`.
291    AttributeChanged {
292        node: Id,
293        name: QualName,
294        old_value: Option<String>,
295    },
296    /// A text/comment node's character data changed.
297    CharacterDataChanged { node: Id },
298    /// `node`'s entire child subtree was replaced (e.g. via `innerHTML`).
299    SubtreeReplaced { node: Id },
300    /// `node` moved atomically from `from_parent` to `to_parent` (possibly the
301    /// same parent, reordered) with state preserved — the `Node.moveBefore()`
302    /// contract (WHATWG DOM). Unlike a `Removed` + `Inserted` pair this promises
303    /// the subtree never left the tree: consumers may keep per-node retained
304    /// state (boxes, shaped text, focus, scroll) and treat the move as a
305    /// splice/graft candidate rather than a teardown. A conservative consumer
306    /// handles it exactly as removed-from + inserted-under.
307    /// (docs/2026-07-05_movebefore_dom_standard_plan.md, S1.)
308    Moved {
309        node: Id,
310        from_parent: Id,
311        to_parent: Id,
312    },
313}
314
315/// Serializable mirror of [`QualName`] for capture/replay logs.
316#[cfg(feature = "capture")]
317#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
318pub struct CapturedQualName {
319    pub prefix: Option<String>,
320    pub ns: String,
321    pub local: String,
322}
323
324#[cfg(feature = "capture")]
325impl From<&QualName> for CapturedQualName {
326    fn from(value: &QualName) -> Self {
327        Self {
328            prefix: value.prefix.as_ref().map(ToString::to_string),
329            ns: value.ns.to_string(),
330            local: value.local.to_string(),
331        }
332    }
333}
334
335#[cfg(feature = "capture")]
336impl CapturedQualName {
337    pub fn into_qual_name(self) -> QualName {
338        QualName::new(
339            self.prefix.map(Prefix::from),
340            Namespace::from(self.ns),
341            LocalName::from(self.local),
342        )
343    }
344}
345
346/// Serializable mirror of [`DomMutation`] for capture/replay logs.
347#[cfg(feature = "capture")]
348#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
349pub enum CapturedMutation {
350    Inserted {
351        node: u64,
352        parent: u64,
353    },
354    Removed {
355        node: u64,
356        former_parent: u64,
357    },
358    AttributeChanged {
359        node: u64,
360        name: CapturedQualName,
361        old_value: Option<String>,
362    },
363    CharacterDataChanged {
364        node: u64,
365    },
366    SubtreeReplaced {
367        node: u64,
368    },
369    Moved {
370        node: u64,
371        from_parent: u64,
372        to_parent: u64,
373    },
374}
375
376#[cfg(feature = "capture")]
377impl CapturedMutation {
378    pub fn capture<Id>(mutation: &DomMutation<Id>, to_raw: impl Fn(&Id) -> u64) -> Self {
379        match mutation {
380            DomMutation::Inserted { node, parent } => Self::Inserted {
381                node: to_raw(node),
382                parent: to_raw(parent),
383            },
384            DomMutation::Removed {
385                node,
386                former_parent,
387            } => Self::Removed {
388                node: to_raw(node),
389                former_parent: to_raw(former_parent),
390            },
391            DomMutation::AttributeChanged {
392                node,
393                name,
394                old_value,
395            } => Self::AttributeChanged {
396                node: to_raw(node),
397                name: CapturedQualName::from(name),
398                old_value: old_value.clone(),
399            },
400            DomMutation::CharacterDataChanged { node } => {
401                Self::CharacterDataChanged { node: to_raw(node) }
402            },
403            DomMutation::SubtreeReplaced { node } => Self::SubtreeReplaced { node: to_raw(node) },
404            DomMutation::Moved {
405                node,
406                from_parent,
407                to_parent,
408            } => Self::Moved {
409                node: to_raw(node),
410                from_parent: to_raw(from_parent),
411                to_parent: to_raw(to_parent),
412            },
413        }
414    }
415
416    pub fn replay<Id>(self, from_raw: impl Fn(u64) -> Id) -> DomMutation<Id> {
417        match self {
418            Self::Inserted { node, parent } => DomMutation::Inserted {
419                node: from_raw(node),
420                parent: from_raw(parent),
421            },
422            Self::Removed {
423                node,
424                former_parent,
425            } => DomMutation::Removed {
426                node: from_raw(node),
427                former_parent: from_raw(former_parent),
428            },
429            Self::AttributeChanged {
430                node,
431                name,
432                old_value,
433            } => DomMutation::AttributeChanged {
434                node: from_raw(node),
435                name: name.into_qual_name(),
436                old_value,
437            },
438            Self::CharacterDataChanged { node } => DomMutation::CharacterDataChanged {
439                node: from_raw(node),
440            },
441            Self::SubtreeReplaced { node } => DomMutation::SubtreeReplaced {
442                node: from_raw(node),
443            },
444            Self::Moved {
445                node,
446                from_parent,
447                to_parent,
448            } => DomMutation::Moved {
449                node: from_raw(node),
450                from_parent: from_raw(from_parent),
451                to_parent: from_raw(to_parent),
452            },
453        }
454    }
455}
456
457/// Plain node kind. Use the typed accessors on [`LayoutDom`]
458/// (`element_name`, `attribute`, `text`, etc.) to read kind-specific data.
459#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
460pub enum NodeKind {
461    Document,
462    Doctype,
463    Element,
464    Text,
465    Comment,
466    ProcessingInstruction,
467    /// A `DocumentFragment` (nodeType 11): a parentless container, used as the
468    /// scripted-DOM holder for `createDocumentFragment` and fragment parsing.
469    DocumentFragment,
470}
471
472/// Borrowed view of one attribute on an element.
473#[derive(Clone, Copy, Debug)]
474pub struct AttributeView<'a> {
475    pub name: &'a QualName,
476    pub value: &'a str,
477}
478
479/// Visitor over a [`LayoutDom`]. Methods return [`ControlFlow`] so the visitor
480/// can bail early with a typed `Stop` value. Use `type Stop = ()` for plain
481/// "stop or not"; use `core::convert::Infallible` to assert the walk never
482/// terminates early; use a typed error type to carry per-node-failure data
483/// out of the walk.
484pub trait NodeVisitor<D: LayoutDom + ?Sized> {
485    /// Early-termination payload carried out of the walk.
486    type Stop;
487
488    /// Called when descending into a node. Default: descend.
489    fn enter(&mut self, _dom: &D, _id: D::NodeId) -> ControlFlow<Self::Stop, Descent> {
490        ControlFlow::Continue(Descent::Descend)
491    }
492
493    /// Called after a node's subtree has been visited. Default: continue.
494    fn exit(&mut self, _dom: &D, _id: D::NodeId) -> ControlFlow<Self::Stop> {
495        ControlFlow::Continue(())
496    }
497}
498
499/// Per-node descent decision returned from [`NodeVisitor::enter`].
500#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
501pub enum Descent {
502    /// Descend into this node's children.
503    Descend,
504    /// Skip this node's subtree but continue walking siblings/parent.
505    Skip,
506}
507
508/// Walk `root`'s subtree with `visitor`, descending via
509/// [`LayoutDom::dom_children`]. Returns `ControlFlow::Break(stop)` if any
510/// visitor method bailed; otherwise `ControlFlow::Continue(())`.
511pub fn walk_subtree<D, V>(dom: &D, root: D::NodeId, visitor: &mut V) -> ControlFlow<V::Stop>
512where
513    D: LayoutDom + ?Sized,
514    V: NodeVisitor<D> + ?Sized,
515{
516    match visitor.enter(dom, root)? {
517        Descent::Skip => ControlFlow::Continue(()),
518        Descent::Descend => {
519            for child in dom.dom_children(root) {
520                walk_subtree(dom, child, visitor)?;
521            }
522            visitor.exit(dom, root)
523        },
524    }
525}