Skip to main content

rdom_core/
observer.rs

1//! `MutationObserver` — W3C-style subscription to DOM changes.
2//!
3//! Every mutation entry point (`set_attribute`, `add_class`,
4//! `append_child`, ...) emits a `Mutation` record to every registered
5//! observer. Observers can implement anything — a cascade dirty
6//! tracker (rdom-tui), a devtools inspector, an accessibility mirror,
7//! reactive state bindings, undo/redo, collaborative sync.
8//!
9//! ## Zero cost when unused
10//!
11//! `Dom<()>` with no observers registered pays one `is_empty()` check
12//! per mutation — no record is allocated, no closure is invoked. Only
13//! when observers are registered does mutation firing become active.
14//!
15//! ## Re-entrancy
16//!
17//! Observer callbacks may READ the Dom freely but must NOT mutate the
18//! tree. A runtime guard (`is_observing`) panics on re-entrant mutation
19//! with a clear message. This keeps the cascade dirty-tracker's
20//! invariants intact: the set of dirty roots must be computed from a
21//! fixed tree state, not one that shifts under each notification.
22//!
23//! Installing or removing observers *is* allowed from inside a
24//! callback (the web's `MutationObserver.disconnect()` inside the
25//! callback). Notification snapshots the observer ids up front and
26//! takes each observer out of its slot only for its own call, so a
27//! removed observer — including the running one — gets nothing further
28//! and an added one sees only later records.
29//!
30//! ## Nested mutations (fragment unwrap)
31//!
32//! Some public APIs (`append_child` of a `Fragment`) recursively call
33//! themselves internally. Each recursive call fires its own record,
34//! matching browser behavior: inserting a fragment with N children
35//! produces N `ChildListChanged` records, not one. This is correct
36//! but observers should handle reasonable record volume.
37
38use crate::dom::Dom;
39use crate::node_id::NodeId;
40
41/// Which interaction state changed. Fired by `Dom::set_hovered` /
42/// `Dom::set_focused` / `Dom::set_focus_visible` / `Dom::set_active`
43/// so pseudo-class matches (`:hover`, `:focus`, `:focus-within`,
44/// `:focus-visible`, `:active`) can invalidate cleanly. `:hover`,
45/// `:focus-within` and `:active` also match every ancestor of the
46/// element named, so their `prev` / `next` stand for those chains.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48#[non_exhaustive]
49pub enum InteractionKind {
50    Hover,
51    Focus,
52    /// `Dom::focus_visible` flipped while focus stayed put; `prev` and
53    /// `next` both name the focused element (`None` when nothing is
54    /// focused).
55    FocusVisible,
56    /// The element being activated changed (`Dom::set_active`,
57    /// `:active`).
58    Active,
59}
60
61/// One DOM mutation notification.
62#[derive(Debug, Clone)]
63#[non_exhaustive]
64pub enum Mutation {
65    /// `set_attribute` / `remove_attribute` / `toggle_attribute`.
66    /// `old == None && new.is_some()` → attribute added.
67    /// `old.is_some() && new == None` → attribute removed.
68    /// Both `Some` → value changed.
69    AttributeChanged {
70        id: NodeId,
71        name: String,
72        old: Option<String>,
73        new: Option<String>,
74    },
75    /// One class was added or removed. `add_class` / `remove_class` /
76    /// `toggle_class` each fire a single record with one entry in
77    /// either `added` or `removed`; `replace_class` fires once with
78    /// both populated.
79    ClassChanged {
80        id: NodeId,
81        added: Vec<String>,
82        removed: Vec<String>,
83    },
84    /// A parent's child list was mutated. Fires once per top-level
85    /// operation — e.g. `append_child(parent, frag)` where frag has
86    /// three children fires three records, one per unwrapped child.
87    ChildListChanged {
88        parent: NodeId,
89        added: Vec<NodeId>,
90        removed: Vec<NodeId>,
91    },
92    /// Text or Comment node data changed.
93    CharacterDataChanged {
94        id: NodeId,
95        old: String,
96        new: String,
97    },
98    /// Hovered or focused node changed. Both `prev` and `next` may be
99    /// `Some` (re-pointed), either may be `None` (cleared or set-from-
100    /// nothing). The cascade dirty-tracker uses this to invalidate
101    /// both sides' subtrees for `:hover` / `:focus` re-matching.
102    InteractionChanged {
103        prev: Option<NodeId>,
104        next: Option<NodeId>,
105        kind: InteractionKind,
106    },
107    /// Document selection changed via `Dom::set_selection`. Either
108    /// `prev` or `next` may be `None`; cleared selections fire
109    /// `next: None`. Paint observers use this to invalidate the
110    /// `::selection` overlay on the nodes whose range changed.
111    SelectionChanged {
112        prev: Option<crate::Selection>,
113        next: Option<crate::Selection>,
114    },
115    /// **About to detach** — fired in `detach_from_parent` BEFORE
116    /// the structural unlink, so observers can dispatch implicit
117    /// `blur` / `focusout` / `mouseleave` / `mouseout` events
118    /// while the tree is still intact (the focused/hovered node's
119    /// parent_node() still walks the live ancestor chain).
120    ///
121    /// `focused` is `Some(id)` iff `dom.focused()` was inside the
122    /// subtree rooted at `detached_root` at the moment of detach.
123    /// `hovered` likewise for hover. If neither is set, no
124    /// `PreDetach` record is emitted (the structurally-cheap
125    /// short-circuit path in `purge_interaction_state_for_subtree`
126    /// also gates this record's emission).
127    ///
128    /// The subsequent `InteractionChanged { next: None, kind }`
129    /// records still fire from the purge step — observers that
130    /// only care about the post-detach state can listen to those.
131    /// `PreDetach` is for observers that need to dispatch real
132    /// DOM events with bubbling intact.
133    PreDetach {
134        /// Subtree root being detached.
135        detached_root: NodeId,
136        /// Pre-detach focused node, if it was inside the subtree.
137        focused: Option<NodeId>,
138        /// Pre-detach hovered node, if it was inside the subtree.
139        hovered: Option<NodeId>,
140    },
141}
142
143/// Observer callback trait. Receives a mutable `&mut Dom<Ext>` so
144/// observers can READ freely — but any attempt to mutate the tree
145/// inside `observe()` panics via the `is_observing` guard. Installing
146/// or removing observers from inside the callback is allowed (the web's
147/// `MutationObserver.disconnect()` inside the callback): a removed
148/// observer — including the one currently running — receives nothing
149/// further; an added one receives only later records.
150pub trait MutationObserver<Ext>: 'static {
151    fn observe(&mut self, dom: &mut Dom<Ext>, record: &Mutation);
152}
153
154/// Handle returned from `add_mutation_observer`. Pass to
155/// `remove_mutation_observer` to unregister.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
157pub struct ObserverId(pub(crate) u32);
158
159/// One registered observer. The box is `None` while that observer is
160/// being invoked (taken out so the callback can receive `&mut Dom`) and
161/// restored by id afterwards.
162type ObserverSlot<Ext> = (ObserverId, Option<Box<dyn MutationObserver<Ext>>>);
163
164pub(crate) struct ObserverStore<Ext> {
165    next_id: u32,
166    entries: Vec<ObserverSlot<Ext>>,
167}
168
169impl<Ext> Default for ObserverStore<Ext> {
170    fn default() -> Self {
171        Self {
172            next_id: 0,
173            entries: Vec::new(),
174        }
175    }
176}
177
178impl<Ext> std::fmt::Debug for ObserverStore<Ext> {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct("ObserverStore")
181            .field("count", &self.entries.len())
182            .field("next_id", &self.next_id)
183            .finish()
184    }
185}
186
187impl<Ext> ObserverStore<Ext> {
188    pub(crate) fn is_empty(&self) -> bool {
189        self.entries.is_empty()
190    }
191}
192
193// ─── Dom API ────────────────────────────────────────────────────────
194
195impl<Ext: 'static> Dom<Ext> {
196    /// Register a mutation observer. Fires for every subsequent DOM
197    /// mutation on this `Dom`. Returns a handle for removal. Callable
198    /// from inside an `observe()` callback; the new observer first sees
199    /// the *next* record.
200    pub fn add_mutation_observer(
201        &mut self,
202        observer: Box<dyn MutationObserver<Ext>>,
203    ) -> ObserverId {
204        let id = ObserverId(self.observers.next_id);
205        self.observers.next_id += 1;
206        self.observers.entries.push((id, Some(observer)));
207        id
208    }
209
210    /// Remove a previously-registered observer. Returns `true` if the
211    /// observer existed and was removed. Callable from inside an
212    /// `observe()` callback, including by the observer being notified
213    /// (it is dropped once its call returns).
214    pub fn remove_mutation_observer(&mut self, id: ObserverId) -> bool {
215        let before = self.observers.entries.len();
216        self.observers.entries.retain(|(oid, _)| *oid != id);
217        self.observers.entries.len() < before
218    }
219
220    /// How many observers are currently registered.
221    pub fn observer_count(&self) -> usize {
222        self.observers.entries.len()
223    }
224
225    /// Emit a mutation record to every observer registered at the time
226    /// of the call. Panics if called while already inside `observe()`
227    /// (re-entrant mutation). Fast-path noop when no observers AND we're
228    /// not already observing — the `is_observing` check comes first so
229    /// re-entrancy is detected even while an observer is taken out of
230    /// its slot for its own call.
231    pub(crate) fn fire_mutation(&mut self, record: Mutation) {
232        if self.is_observing {
233            panic!(
234                "rdom-core: mutation attempted inside MutationObserver callback: {:?}. \
235                 Observers must not mutate the tree during `observe()`. \
236                 Schedule the mutation for after dispatch returns.",
237                record
238            );
239        }
240        if self.observers.is_empty() {
241            return;
242        }
243
244        // Snapshot the ids to notify: observers added during this
245        // notification are not in the snapshot (they see later records);
246        // observers removed by an earlier callback are skipped when the
247        // re-locate fails. Each observer is taken out of its slot for
248        // the duration of its own call so it can receive `&mut Dom`,
249        // then restored by id — the same shape as `dispatch::fire_at`.
250        let ids: Vec<ObserverId> = self.observers.entries.iter().map(|(id, _)| *id).collect();
251        self.is_observing = true;
252        for id in ids {
253            let Some(pos) = self
254                .observers
255                .entries
256                .iter()
257                .position(|(oid, _)| *oid == id)
258            else {
259                continue; // removed by a previous observer in this round
260            };
261            let Some(mut obs) = self.observers.entries[pos].1.take() else {
262                continue;
263            };
264            // A panicking observer must not poison the Dom: hosts that
265            // catch the panic (rdom-tui does, to restore the terminal)
266            // need the re-entrancy flag cleared and the observer put
267            // back. Restore first, then re-raise the original payload.
268            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
269                obs.observe(self, &record);
270            }));
271            if let Some(pos) = self
272                .observers
273                .entries
274                .iter()
275                .position(|(oid, _)| *oid == id)
276            {
277                self.observers.entries[pos].1 = Some(obs);
278            }
279            // Else: the observer removed itself (or was removed) during
280            // its own callback; dropping `obs` completes the removal.
281            if let Err(payload) = outcome {
282                self.is_observing = false;
283                std::panic::resume_unwind(payload);
284            }
285        }
286        self.is_observing = false;
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::Dom;
294    use std::cell::RefCell;
295    use std::rc::Rc;
296
297    /// Collect all records into a shared Vec for assertions.
298    struct Collector {
299        records: Rc<RefCell<Vec<Mutation>>>,
300    }
301    impl MutationObserver<()> for Collector {
302        fn observe(&mut self, _dom: &mut Dom<()>, record: &Mutation) {
303            self.records.borrow_mut().push(record.clone());
304        }
305    }
306
307    fn install_collector(dom: &mut Dom<()>) -> (ObserverId, Rc<RefCell<Vec<Mutation>>>) {
308        let records = Rc::new(RefCell::new(Vec::new()));
309        let obs = Box::new(Collector {
310            records: records.clone(),
311        });
312        let id = dom.add_mutation_observer(obs);
313        (id, records)
314    }
315
316    #[test]
317    fn add_and_remove_observer() {
318        let mut dom: Dom = Dom::new();
319        let (id, _) = install_collector(&mut dom);
320        assert_eq!(dom.observer_count(), 1);
321        assert!(dom.remove_mutation_observer(id));
322        assert_eq!(dom.observer_count(), 0);
323        // Removing same id twice → false.
324        assert!(!dom.remove_mutation_observer(id));
325    }
326
327    /// A panicking observer must not poison the `Dom`: once the host
328    /// catches the panic (rdom-tui does, to restore the terminal), the
329    /// re-entrancy flag is clear again and every observer — including
330    /// the one that panicked — is still installed.
331    #[test]
332    fn panicking_observer_leaves_dom_usable_and_observers_installed() {
333        struct Bomb;
334        impl MutationObserver<()> for Bomb {
335            fn observe(&mut self, _dom: &mut Dom<()>, _record: &Mutation) {
336                panic!("observer bomb");
337            }
338        }
339        let mut dom: Dom = Dom::new();
340        let root = dom.root();
341        let (_, before) = install_collector(&mut dom);
342        let bomb_id = dom.add_mutation_observer(Box::new(Bomb));
343        let (_, after) = install_collector(&mut dom);
344        assert_eq!(dom.observer_count(), 3);
345
346        let el = dom.create_element("div");
347        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
348            dom.append_child(root, el).unwrap();
349        }));
350        assert!(result.is_err(), "the bomb must actually fire");
351
352        // Dom is not stuck in "observing" state and still has all three.
353        assert_eq!(dom.observer_count(), 3);
354        assert!(dom.remove_mutation_observer(bomb_id));
355        let n_before = before.borrow().len();
356        let n_after = after.borrow().len();
357        dom.set_attribute(el, "id", "x").unwrap();
358        assert_eq!(before.borrow().len(), n_before + 1);
359        assert_eq!(after.borrow().len(), n_after + 1);
360    }
361
362    /// `MutationObserver.disconnect()` inside the callback is legal on
363    /// the web. The in-flight observer removes itself: the call reports
364    /// success, later mutations don't reach it, the others are unaffected.
365    #[test]
366    fn observer_can_remove_itself_during_callback() {
367        struct SelfRemover {
368            me: Rc<std::cell::Cell<Option<ObserverId>>>,
369            seen: Rc<std::cell::Cell<u32>>,
370            removed: Rc<std::cell::Cell<Option<bool>>>,
371        }
372        impl MutationObserver<()> for SelfRemover {
373            fn observe(&mut self, dom: &mut Dom<()>, _record: &Mutation) {
374                self.seen.set(self.seen.get() + 1);
375                let id = self.me.get().expect("id stored before first mutation");
376                self.removed.set(Some(dom.remove_mutation_observer(id)));
377            }
378        }
379        let mut dom: Dom = Dom::new();
380        let (_, other) = install_collector(&mut dom);
381        let me = Rc::new(std::cell::Cell::new(None));
382        let seen = Rc::new(std::cell::Cell::new(0));
383        let removed = Rc::new(std::cell::Cell::new(None));
384        let id = dom.add_mutation_observer(Box::new(SelfRemover {
385            me: me.clone(),
386            seen: seen.clone(),
387            removed: removed.clone(),
388        }));
389        me.set(Some(id));
390        assert_eq!(dom.observer_count(), 2);
391
392        let el = dom.create_element("div");
393        dom.set_attribute(el, "id", "a").unwrap();
394        assert_eq!(seen.get(), 1);
395        assert_eq!(
396            removed.get(),
397            Some(true),
398            "removal inside observe() succeeds"
399        );
400        assert_eq!(dom.observer_count(), 1);
401
402        let n = other.borrow().len();
403        dom.set_attribute(el, "id", "b").unwrap();
404        assert_eq!(seen.get(), 1, "the removed observer gets nothing more");
405        assert_eq!(
406            other.borrow().len(),
407            n + 1,
408            "the other observer still fires"
409        );
410    }
411
412    /// An observer installed from inside a callback receives only records
413    /// for *later* mutations, in registration order after the existing ones.
414    #[test]
415    fn observer_added_during_callback_sees_only_later_records() {
416        struct Installer {
417            installed: Rc<std::cell::Cell<bool>>,
418            records: Rc<RefCell<Vec<Mutation>>>,
419        }
420        impl MutationObserver<()> for Installer {
421            fn observe(&mut self, dom: &mut Dom<()>, _record: &Mutation) {
422                if !self.installed.replace(true) {
423                    dom.add_mutation_observer(Box::new(Collector {
424                        records: self.records.clone(),
425                    }));
426                }
427            }
428        }
429        let mut dom: Dom = Dom::new();
430        let late = Rc::new(RefCell::new(Vec::new()));
431        dom.add_mutation_observer(Box::new(Installer {
432            installed: Rc::new(std::cell::Cell::new(false)),
433            records: late.clone(),
434        }));
435        let el = dom.create_element("div");
436        dom.set_attribute(el, "id", "first").unwrap();
437        assert_eq!(dom.observer_count(), 2);
438        assert!(late.borrow().is_empty(), "not the record that installed it");
439        dom.set_attribute(el, "id", "second").unwrap();
440        assert_eq!(late.borrow().len(), 1);
441    }
442
443    /// Removing an observer that has not fired yet in the current round
444    /// takes effect immediately: it is skipped for this record too.
445    #[test]
446    fn observer_can_remove_a_later_observer_before_it_fires() {
447        struct Remover {
448            victim: Rc<std::cell::Cell<Option<ObserverId>>>,
449        }
450        impl MutationObserver<()> for Remover {
451            fn observe(&mut self, dom: &mut Dom<()>, _record: &Mutation) {
452                if let Some(v) = self.victim.take() {
453                    assert!(dom.remove_mutation_observer(v));
454                }
455            }
456        }
457        let mut dom: Dom = Dom::new();
458        let victim = Rc::new(std::cell::Cell::new(None));
459        dom.add_mutation_observer(Box::new(Remover {
460            victim: victim.clone(),
461        }));
462        let (victim_id, victim_records) = install_collector(&mut dom);
463        victim.set(Some(victim_id));
464
465        let el = dom.create_element("div");
466        dom.set_attribute(el, "id", "x").unwrap();
467        assert!(
468            victim_records.borrow().is_empty(),
469            "removed before its turn"
470        );
471        assert_eq!(dom.observer_count(), 1);
472    }
473
474    #[test]
475    fn no_observers_means_no_fires() {
476        // Without observers, no allocations happen inside fire_mutation.
477        // We can't directly test that, but we can verify mutations still
478        // work (they do — it's a no-op fast path).
479        let mut dom: Dom = Dom::new();
480        let el = dom.create_element("div");
481        let _ = dom.set_attribute(el, "id", "x");
482        // No panic, no observer count increment.
483        assert_eq!(dom.observer_count(), 0);
484    }
485
486    #[test]
487    fn attribute_changed_fires_with_old_new() {
488        let mut dom: Dom = Dom::new();
489        let (_, records) = install_collector(&mut dom);
490        let el = dom.create_element("div");
491        dom.set_attribute(el, "role", "banner").unwrap();
492        dom.set_attribute(el, "role", "navigation").unwrap();
493        dom.remove_attribute(el, "role").unwrap();
494
495        let recs = records.borrow();
496        // 3 records: add (None → banner), change (banner → navigation),
497        // remove (navigation → None).
498        let matches: Vec<_> = recs
499            .iter()
500            .filter_map(|r| match r {
501                Mutation::AttributeChanged { old, new, .. } => Some((old.clone(), new.clone())),
502                _ => None,
503            })
504            .collect();
505        assert_eq!(matches.len(), 3);
506        assert_eq!(matches[0], (None, Some("banner".into())));
507        assert_eq!(
508            matches[1],
509            (Some("banner".into()), Some("navigation".into()))
510        );
511        assert_eq!(matches[2], (Some("navigation".into()), None));
512    }
513
514    #[test]
515    fn class_changed_fires_add_remove_toggle_replace() {
516        let mut dom: Dom = Dom::new();
517        let (_, records) = install_collector(&mut dom);
518        let el = dom.create_element("div");
519        dom.add_class(el, "active").unwrap();
520        dom.remove_class(el, "active").unwrap();
521        dom.toggle_class(el, "on").unwrap(); // add
522        dom.toggle_class(el, "on").unwrap(); // remove
523        dom.add_class(el, "old").unwrap();
524        dom.replace_class(el, "old", "new").unwrap();
525
526        let cls_recs: Vec<_> = records
527            .borrow()
528            .iter()
529            .filter_map(|r| match r {
530                Mutation::ClassChanged { added, removed, .. } => {
531                    Some((added.clone(), removed.clone()))
532                }
533                _ => None,
534            })
535            .collect();
536        // 5 records (not 6, because remove_class of something not present
537        // does not fire).
538        assert_eq!(cls_recs.len(), 6);
539        assert_eq!(cls_recs[0], (vec!["active".to_string()], vec![]));
540        assert_eq!(cls_recs[1], (vec![], vec!["active".to_string()]));
541        assert_eq!(cls_recs[2], (vec!["on".to_string()], vec![]));
542        assert_eq!(cls_recs[3], (vec![], vec!["on".to_string()]));
543        assert_eq!(cls_recs[4], (vec!["old".to_string()], vec![]));
544        assert_eq!(
545            cls_recs[5],
546            (vec!["new".to_string()], vec!["old".to_string()])
547        );
548    }
549
550    #[test]
551    fn child_list_changed_on_append() {
552        let mut dom: Dom = Dom::new();
553        let (_, records) = install_collector(&mut dom);
554        let parent = dom.create_element("div");
555        let child = dom.create_element("span");
556        dom.append_child(parent, child).unwrap();
557
558        let tree_recs: Vec<_> = records
559            .borrow()
560            .iter()
561            .filter_map(|r| match r {
562                Mutation::ChildListChanged {
563                    parent,
564                    added,
565                    removed,
566                } => Some((*parent, added.clone(), removed.clone())),
567                _ => None,
568            })
569            .collect();
570        assert_eq!(tree_recs.len(), 1);
571        assert_eq!(tree_recs[0].0, parent);
572        assert_eq!(tree_recs[0].1, vec![child]);
573        assert_eq!(tree_recs[0].2, Vec::<NodeId>::new());
574    }
575
576    #[test]
577    fn child_list_changed_on_remove() {
578        let mut dom: Dom = Dom::new();
579        let parent = dom.create_element("div");
580        let child = dom.create_element("span");
581        dom.append_child(parent, child).unwrap();
582        // Install AFTER the append so the remove is the only recorded op.
583        let (_, records) = install_collector(&mut dom);
584        dom.remove_child(parent, child).unwrap();
585
586        let rec = records
587            .borrow()
588            .iter()
589            .find(|r| matches!(r, Mutation::ChildListChanged { .. }))
590            .cloned()
591            .unwrap();
592        match rec {
593            Mutation::ChildListChanged { added, removed, .. } => {
594                assert!(added.is_empty());
595                assert_eq!(removed, vec![child]);
596            }
597            _ => unreachable!(),
598        }
599    }
600
601    /// `set_focus_visible` fires one record per change, naming the
602    /// focused element as both `prev` and `next` (the element whose
603    /// `:focus-visible` match flipped); a no-op write fires nothing.
604    #[test]
605    fn interaction_changed_fires_on_set_focus_visible() {
606        let mut dom: Dom = Dom::new();
607        let el = dom.create_element("div");
608        dom.set_focused(Some(el));
609        let (_, records) = install_collector(&mut dom);
610        dom.set_focus_visible(false);
611        dom.set_focus_visible(false);
612        dom.set_focus_visible(true);
613
614        let interactions: Vec<_> = records
615            .borrow()
616            .iter()
617            .filter_map(|r| match r {
618                Mutation::InteractionChanged { prev, next, kind } => Some((*prev, *next, *kind)),
619                _ => None,
620            })
621            .collect();
622        let rec = (Some(el), Some(el), InteractionKind::FocusVisible);
623        assert_eq!(interactions, vec![rec, rec]);
624    }
625
626    #[test]
627    fn interaction_changed_fires_on_set_hovered() {
628        let mut dom: Dom = Dom::new();
629        let (_, records) = install_collector(&mut dom);
630        let el = dom.create_element("div");
631        dom.set_hovered(Some(el));
632        dom.set_hovered(None);
633
634        let interactions: Vec<_> = records
635            .borrow()
636            .iter()
637            .filter_map(|r| match r {
638                Mutation::InteractionChanged { prev, next, kind } => Some((*prev, *next, *kind)),
639                _ => None,
640            })
641            .collect();
642        assert_eq!(interactions.len(), 2);
643        assert_eq!(interactions[0], (None, Some(el), InteractionKind::Hover));
644        assert_eq!(interactions[1], (Some(el), None, InteractionKind::Hover));
645    }
646
647    #[test]
648    fn interaction_changed_fires_on_set_focused() {
649        let mut dom: Dom = Dom::new();
650        let (_, records) = install_collector(&mut dom);
651        let a = dom.create_element("a");
652        let b = dom.create_element("b");
653        dom.set_focused(Some(a));
654        dom.set_focused(Some(b));
655
656        let interactions: Vec<_> = records
657            .borrow()
658            .iter()
659            .filter_map(|r| match r {
660                Mutation::InteractionChanged {
661                    kind: InteractionKind::Focus,
662                    prev,
663                    next,
664                } => Some((*prev, *next)),
665                _ => None,
666            })
667            .collect();
668        assert_eq!(interactions.len(), 2);
669        assert_eq!(interactions[0], (None, Some(a)));
670        assert_eq!(interactions[1], (Some(a), Some(b)));
671    }
672
673    #[test]
674    fn set_hovered_to_same_does_not_fire() {
675        // Self-transition: no change, no record.
676        let mut dom: Dom = Dom::new();
677        let el = dom.create_element("div");
678        dom.set_hovered(Some(el));
679        let (_, records) = install_collector(&mut dom);
680        dom.set_hovered(Some(el));
681        assert!(
682            records
683                .borrow()
684                .iter()
685                .all(|r| !matches!(r, Mutation::InteractionChanged { .. }))
686        );
687    }
688
689    #[test]
690    fn re_entrant_mutation_panics() {
691        // An observer that tries to mutate the tree during its callback
692        // should trigger the is_observing guard and panic. We mutate an
693        // Element (not the Fragment root) so the mutation actually fires.
694        struct EvilObserver {
695            target: NodeId,
696        }
697        impl MutationObserver<()> for EvilObserver {
698            fn observe(&mut self, dom: &mut Dom<()>, _record: &Mutation) {
699                // This should panic: re-entering set_attribute during observe.
700                let _ = dom.set_attribute(self.target, "evil", "1");
701            }
702        }
703        let mut dom: Dom = Dom::new();
704        let el = dom.create_element("div");
705        dom.add_mutation_observer(Box::new(EvilObserver { target: el }));
706        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
707            // Fire a mutation — the observer tries to mutate, panics.
708            let _ = dom.set_attribute(el, "x", "1");
709        }));
710        assert!(result.is_err(), "expected panic on re-entrant mutation");
711    }
712
713    #[test]
714    fn multiple_observers_all_fire() {
715        let mut dom: Dom = Dom::new();
716        let (_, r1) = install_collector(&mut dom);
717        let (_, r2) = install_collector(&mut dom);
718        let el = dom.create_element("div");
719        dom.set_attribute(el, "id", "x").unwrap();
720        assert!(!r1.borrow().is_empty());
721        assert!(!r2.borrow().is_empty());
722    }
723
724    #[test]
725    fn unregistered_observer_does_not_fire() {
726        let mut dom: Dom = Dom::new();
727        let (id, records) = install_collector(&mut dom);
728        dom.remove_mutation_observer(id);
729        let el = dom.create_element("div");
730        dom.set_attribute(el, "id", "x").unwrap();
731        assert!(records.borrow().is_empty());
732    }
733
734    #[test]
735    fn toggle_attribute_fires_twice() {
736        let mut dom: Dom = Dom::new();
737        let (_, records) = install_collector(&mut dom);
738        let el = dom.create_element("input");
739        dom.toggle_attribute(el, "disabled").unwrap(); // add
740        dom.toggle_attribute(el, "disabled").unwrap(); // remove
741
742        let attr_count = records
743            .borrow()
744            .iter()
745            .filter(|r| matches!(r, Mutation::AttributeChanged { .. }))
746            .count();
747        assert_eq!(attr_count, 2);
748    }
749
750    #[test]
751    fn interaction_changed_fires_on_set_active() {
752        let mut dom: Dom = Dom::new();
753        let (_, records) = install_collector(&mut dom);
754        let el = dom.create_element("div");
755        dom.set_active(Some(el));
756        dom.set_active(Some(el));
757        dom.set_active(None);
758
759        let interactions: Vec<_> = records
760            .borrow()
761            .iter()
762            .filter_map(|r| match r {
763                Mutation::InteractionChanged { prev, next, kind } => Some((*prev, *next, *kind)),
764                _ => None,
765            })
766            .collect();
767        assert_eq!(
768            interactions,
769            vec![
770                (None, Some(el), InteractionKind::Active),
771                (Some(el), None, InteractionKind::Active),
772            ]
773        );
774    }
775
776    /// `P7G-HOVER-ANCESTORS-1`: detaching the hovered / focused / active
777    /// node clears that state while the node is still in the tree, so an
778    /// observer can walk the `prev` node's ancestors — the elements whose
779    /// `:hover` / `:focus-within` / `:active` match flips.
780    #[test]
781    fn detach_clears_interaction_state_while_the_ancestor_chain_is_intact() {
782        type Seen = Rc<RefCell<Vec<(InteractionKind, Option<NodeId>)>>>;
783        struct ParentAtRecord {
784            seen: Seen,
785        }
786        impl MutationObserver<()> for ParentAtRecord {
787            fn observe(&mut self, dom: &mut Dom<()>, record: &Mutation) {
788                if let Mutation::InteractionChanged {
789                    prev: Some(p),
790                    next: None,
791                    kind,
792                } = record
793                {
794                    let parent = dom.get_node(*p).and_then(|n| n.parent);
795                    self.seen.borrow_mut().push((*kind, parent));
796                }
797            }
798        }
799        let mut dom: Dom = Dom::new();
800        let root = dom.root();
801        let li = dom.create_element("li");
802        let span = dom.create_element("span");
803        dom.append_child(root, li).unwrap();
804        dom.append_child(li, span).unwrap();
805        dom.set_hovered(Some(span));
806        dom.set_focused(Some(span));
807        dom.set_active(Some(span));
808        let seen: Seen = Rc::new(RefCell::new(Vec::new()));
809        dom.add_mutation_observer(Box::new(ParentAtRecord { seen: seen.clone() }));
810        dom.remove_child(li, span).unwrap();
811        assert_eq!(
812            (dom.hovered(), dom.focused(), dom.active()),
813            (None, None, None)
814        );
815        assert_eq!(
816            *seen.borrow(),
817            vec![
818                (InteractionKind::Focus, Some(li)),
819                (InteractionKind::Hover, Some(li)),
820                (InteractionKind::Active, Some(li)),
821            ]
822        );
823    }
824}