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}