Skip to main content

rdom_core/
abort.rs

1//! `AbortController` / `AbortSignal` — listener lifetime management.
2//!
3//! Modern DOM pattern (introduced in the WHATWG Fetch spec, later
4//! adopted by DOM, AbortSignal spec §3). The usage pattern:
5//!
6//! ```ignore
7//! let ctrl = AbortController::new();
8//! let sig = ctrl.signal();
9//!
10//! dom.add_event_listener(btn, "click",
11//!     ListenerOptions::default().with_signal(sig.clone()),
12//!     |ctx| { /* ... */ })?;
13//! dom.add_event_listener(btn, "mouseover",
14//!     ListenerOptions::default().with_signal(sig.clone()),
15//!     |ctx| { /* ... */ })?;
16//!
17//! // Later — one call removes BOTH listeners on the next dispatch visit.
18//! ctrl.abort();
19//! ```
20//!
21//! Far more ergonomic than tracking individual [`ListenerId`]s.
22//! Critical for scope-bound handlers (component un-mount, dialog
23//! close, event-loop iteration boundaries).
24//!
25//! ## Semantics
26//!
27//! - `AbortController` is the *write* handle: construction + `abort()`.
28//!   Hold it in the scope that decides when listeners should die.
29//! - `AbortSignal` is the *read-only* handle. Clone it cheaply
30//!   (`Rc<..>` under the hood). Pass one copy per listener to
31//!   `ListenerOptions::with_signal`.
32//! - When `abort()` is called: the shared state flips. Listeners
33//!   whose signal is aborted are dropped lazily — the next time the
34//!   dispatcher visits them, they're skipped *and* removed from
35//!   storage. No reverse-index or eager scan needed.
36//! - Adding a listener with an already-aborted signal is a no-op:
37//!   `add_event_listener` returns a valid `ListenerId` but the
38//!   listener never fires and is dropped on first dispatch visit.
39//!   Matches browser behavior.
40//! - Safe under re-entrancy: a handler calling `controller.abort()`
41//!   flips the flag; the *current* handler keeps running to
42//!   completion (synchronous dispatch). *Subsequent* listeners on
43//!   the current node and later nodes see the aborted flag and skip.
44//!
45//! ## Single-threaded
46//!
47//! rdom-core is single-threaded; we use `Rc<Cell<bool>>` internally.
48//! If the runtime ever gains a threaded backend (unlikely for a TUI),
49//! swap for `Arc<AtomicBool>` — the public API stays the same.
50//!
51//! [`ListenerId`]: crate::ListenerId
52
53use std::cell::Cell;
54use std::rc::Rc;
55
56/// Shared aborted-flag backing a controller + its signals.
57#[derive(Debug, Default)]
58struct AbortState {
59    aborted: Cell<bool>,
60}
61
62/// The *write* end of an abort pair — fires the signal via
63/// [`abort`](Self::abort).
64///
65/// Cloneable: multiple controllers can share the same state through
66/// clones, so any clone's `abort()` fires the shared signal. Most
67/// apps hold one and pass signals out.
68#[derive(Debug, Clone)]
69pub struct AbortController {
70    state: Rc<AbortState>,
71}
72
73impl AbortController {
74    /// Create a fresh controller whose signal is not yet aborted.
75    pub fn new() -> Self {
76        Self {
77            state: Rc::new(AbortState::default()),
78        }
79    }
80
81    /// Produce an [`AbortSignal`] bound to this controller. Call
82    /// multiple times to attach the same abort semantics to
83    /// independent listeners.
84    pub fn signal(&self) -> AbortSignal {
85        AbortSignal {
86            state: Rc::clone(&self.state),
87        }
88    }
89
90    /// Fire the abort. Every listener whose signal was spawned from
91    /// this controller will be skipped + removed on the next
92    /// dispatch visit. Idempotent — calling twice has no additional
93    /// effect.
94    pub fn abort(&self) {
95        self.state.aborted.set(true);
96    }
97
98    /// Has the signal been aborted?
99    pub fn is_aborted(&self) -> bool {
100        self.state.aborted.get()
101    }
102}
103
104impl Default for AbortController {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110/// The *read* end of an abort pair — carried by listeners so
111/// dispatch can check whether they should still fire.
112///
113/// Cheap to clone (`Rc` clone); attach one to each listener you
114/// want the controller to govern.
115#[derive(Debug, Clone)]
116pub struct AbortSignal {
117    state: Rc<AbortState>,
118}
119
120impl AbortSignal {
121    /// Has the backing controller fired?
122    pub fn is_aborted(&self) -> bool {
123        self.state.aborted.get()
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn new_controller_not_aborted() {
133        let c = AbortController::new();
134        assert!(!c.is_aborted());
135        assert!(!c.signal().is_aborted());
136    }
137
138    #[test]
139    fn abort_flips_controller_and_signals() {
140        let c = AbortController::new();
141        let s1 = c.signal();
142        let s2 = c.signal();
143        c.abort();
144        assert!(c.is_aborted());
145        assert!(s1.is_aborted());
146        assert!(s2.is_aborted());
147    }
148
149    #[test]
150    fn signal_clone_shares_state() {
151        let c = AbortController::new();
152        let s = c.signal();
153        let s_clone = s.clone();
154        c.abort();
155        assert!(s.is_aborted());
156        assert!(s_clone.is_aborted());
157    }
158
159    #[test]
160    fn controller_clone_shares_state() {
161        let c1 = AbortController::new();
162        let c2 = c1.clone();
163        let sig = c1.signal();
164        c2.abort();
165        // Aborting either clone fires the shared signal.
166        assert!(c1.is_aborted());
167        assert!(c2.is_aborted());
168        assert!(sig.is_aborted());
169    }
170
171    #[test]
172    fn default_constructs_unaborted() {
173        let c: AbortController = Default::default();
174        assert!(!c.is_aborted());
175    }
176
177    #[test]
178    fn abort_is_idempotent() {
179        let c = AbortController::new();
180        c.abort();
181        c.abort();
182        c.abort();
183        assert!(c.is_aborted());
184    }
185}