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}