Skip to main content

saorsa_core/reactive/
scope.rs

1//! Reactive scope for automatic cleanup of effects.
2//!
3//! A [`ReactiveScope`] owns effects and cleanup callbacks. When the
4//! scope is dropped, all its effects are disposed and all cleanup
5//! callbacks are run. This ties reactive lifetimes to widget lifetimes.
6
7use super::computed::Computed;
8use super::effect::Effect;
9use super::signal::Signal;
10
11/// A scope that owns reactive effects and cleanup callbacks.
12///
13/// When a scope is dropped, all effects it owns are disposed and
14/// all cleanup callbacks are run in reverse registration order.
15/// Child scopes are dropped before their parent's cleanups run.
16///
17/// # Examples
18///
19/// ```ignore
20/// let mut scope = ReactiveScope::new();
21/// let count = Signal::new(0);
22///
23/// scope.create_effect({
24///     let count = count.clone();
25///     move || println!("count = {}", count.get())
26/// });
27///
28/// // Effect runs on creation and when count changes.
29/// count.set(1);
30///
31/// drop(scope); // Effect is disposed, cleanup runs.
32/// count.set(2); // Effect does NOT run.
33/// ```
34pub struct ReactiveScope {
35    /// Owned effects that will be disposed on drop.
36    effects: Vec<Effect>,
37    /// Nested child scopes (dropped before parent cleanups).
38    children: Vec<ReactiveScope>,
39    /// Cleanup callbacks run on drop (in reverse order).
40    cleanups: Vec<Box<dyn FnOnce()>>,
41}
42
43impl ReactiveScope {
44    /// Create a new empty reactive scope.
45    #[must_use]
46    pub fn new() -> Self {
47        Self {
48            effects: Vec::new(),
49            children: Vec::new(),
50            cleanups: Vec::new(),
51        }
52    }
53
54    /// Create a signal. Convenience method — signals are not owned
55    /// by the scope since they are shared handles.
56    pub fn create_signal<T>(&self, value: T) -> Signal<T> {
57        Signal::new(value)
58    }
59
60    /// Create a computed value. Convenience method — computed values
61    /// are shared handles and not owned by the scope.
62    pub fn create_computed<T: Clone + 'static>(&self, f: impl Fn() -> T + 'static) -> Computed<T> {
63        Computed::new(f)
64    }
65
66    /// Create an effect owned by this scope.
67    ///
68    /// The effect will be automatically disposed when the scope is dropped.
69    pub fn create_effect(&mut self, f: impl FnMut() + 'static) -> Effect {
70        let effect = Effect::new(f);
71        self.effects.push(effect.clone());
72        effect
73    }
74
75    /// Register a cleanup callback to run when this scope is dropped.
76    ///
77    /// Callbacks are run in reverse registration order.
78    pub fn on_cleanup(&mut self, f: impl FnOnce() + 'static) {
79        self.cleanups.push(Box::new(f));
80    }
81
82    /// Create a nested child scope.
83    ///
84    /// The child scope will be dropped before the parent's cleanups run.
85    pub fn child(&mut self) -> &mut ReactiveScope {
86        self.children.push(ReactiveScope::new());
87        let last = self.children.len() - 1;
88        &mut self.children[last]
89    }
90
91    /// Get the number of effects owned by this scope.
92    pub fn effect_count(&self) -> usize {
93        self.effects.len()
94    }
95
96    /// Get the number of child scopes.
97    pub fn child_count(&self) -> usize {
98        self.children.len()
99    }
100}
101
102impl Default for ReactiveScope {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl Drop for ReactiveScope {
109    fn drop(&mut self) {
110        // Drop children first (nested scopes).
111        self.children.clear();
112
113        // Dispose all effects.
114        for effect in &self.effects {
115            effect.dispose();
116        }
117
118        // Run cleanups in reverse order.
119        while let Some(cleanup) = self.cleanups.pop() {
120            cleanup();
121        }
122    }
123}
124
125#[cfg(test)]
126#[allow(clippy::unwrap_used)]
127mod tests {
128    use super::*;
129    use std::cell::Cell;
130    use std::rc::Rc;
131
132    #[test]
133    fn scope_disposes_effects_on_drop() {
134        let sig = Signal::new(0);
135        let count = Rc::new(Cell::new(0u32));
136
137        let effect;
138        {
139            let mut scope = ReactiveScope::new();
140            effect = scope.create_effect({
141                let sig = sig.clone();
142                let count = Rc::clone(&count);
143                move || {
144                    let _ = sig.get();
145                    count.set(count.get() + 1);
146                }
147            });
148
149            sig.subscribe(effect.as_subscriber());
150            assert_eq!(count.get(), 1);
151
152            sig.set(1);
153            assert_eq!(count.get(), 2);
154        }
155        // Scope dropped — effect should be disposed.
156        assert!(!effect.is_active());
157
158        sig.set(2);
159        assert_eq!(count.get(), 2); // No further runs.
160    }
161
162    #[test]
163    fn scope_runs_cleanups_on_drop() {
164        let order = Rc::new(RefCell::new(Vec::new()));
165
166        {
167            let mut scope = ReactiveScope::new();
168            scope.on_cleanup({
169                let order = Rc::clone(&order);
170                move || order.borrow_mut().push(1)
171            });
172            scope.on_cleanup({
173                let order = Rc::clone(&order);
174                move || order.borrow_mut().push(2)
175            });
176        }
177
178        // Reverse order.
179        assert_eq!(*order.borrow(), vec![2, 1]);
180    }
181
182    #[test]
183    fn nested_scope_dropped_before_parent_cleanup() {
184        let order = Rc::new(RefCell::new(Vec::new()));
185
186        {
187            let mut scope = ReactiveScope::new();
188            scope.on_cleanup({
189                let order = Rc::clone(&order);
190                move || order.borrow_mut().push("parent")
191            });
192
193            let child = scope.child();
194            child.on_cleanup({
195                let order = Rc::clone(&order);
196                move || order.borrow_mut().push("child")
197            });
198        }
199
200        // Child cleanup runs before parent cleanup.
201        assert_eq!(*order.borrow(), vec!["child", "parent"]);
202    }
203
204    #[test]
205    fn effect_in_dropped_scope_does_not_fire() {
206        let sig = Signal::new(0);
207        let count = Rc::new(Cell::new(0u32));
208
209        {
210            let mut scope = ReactiveScope::new();
211            let effect = scope.create_effect({
212                let sig = sig.clone();
213                let count = Rc::clone(&count);
214                move || {
215                    let _ = sig.get();
216                    count.set(count.get() + 1);
217                }
218            });
219            sig.subscribe(effect.as_subscriber());
220        }
221
222        sig.set(1);
223        // Effect was disposed, so count stays at 1 (just the initial run).
224        assert_eq!(count.get(), 1);
225    }
226
227    #[test]
228    fn create_signal_through_scope() {
229        let scope = ReactiveScope::new();
230        let sig = scope.create_signal(42);
231        assert_eq!(sig.get(), 42);
232    }
233
234    #[test]
235    fn create_computed_through_scope() {
236        let scope = ReactiveScope::new();
237        let sig = Signal::new(3);
238        let doubled = scope.create_computed({
239            let sig = sig.clone();
240            move || sig.get() * 2
241        });
242        assert_eq!(doubled.get(), 6);
243    }
244
245    #[test]
246    fn scope_counts() {
247        let mut scope = ReactiveScope::new();
248        assert_eq!(scope.effect_count(), 0);
249        assert_eq!(scope.child_count(), 0);
250
251        scope.create_effect(|| {});
252        assert_eq!(scope.effect_count(), 1);
253
254        scope.child();
255        assert_eq!(scope.child_count(), 1);
256    }
257
258    #[test]
259    fn scope_can_be_moved() {
260        let mut scope = ReactiveScope::new();
261        scope.create_effect(|| {});
262
263        let scope2 = scope;
264        assert_eq!(scope2.effect_count(), 1);
265    }
266
267    #[test]
268    fn multiple_nested_scopes() {
269        let order = Rc::new(RefCell::new(Vec::new()));
270
271        {
272            let mut scope = ReactiveScope::new();
273            scope.on_cleanup({
274                let order = Rc::clone(&order);
275                move || order.borrow_mut().push("root")
276            });
277
278            let child1 = scope.child();
279            child1.on_cleanup({
280                let order = Rc::clone(&order);
281                move || order.borrow_mut().push("child1")
282            });
283
284            let child2 = scope.child();
285            child2.on_cleanup({
286                let order = Rc::clone(&order);
287                move || order.borrow_mut().push("child2")
288            });
289        }
290
291        // Both children cleanup before root.
292        let v = order.borrow().clone();
293        assert_eq!(v.len(), 3);
294        // Children dropped in order (Vec::clear drops in order).
295        assert_eq!(v[0], "child1");
296        assert_eq!(v[1], "child2");
297        assert_eq!(v[2], "root");
298    }
299
300    use std::cell::RefCell;
301}