Skip to main content

saorsa_core/reactive/
effect.rs

1//! Reactive effects — side effects that re-run when dependencies change.
2//!
3//! An [`Effect`] runs a closure immediately and re-runs it whenever
4//! any of its signal dependencies change. Unlike [`super::computed::Computed`], effects
5//! are eager — they run immediately on notification rather than lazily
6//! on read.
7
8use std::cell::{Cell, RefCell};
9use std::rc::Rc;
10
11use super::context::{self, SubscriberId};
12use super::signal::Subscriber;
13
14/// A reactive effect that re-runs when its dependencies change.
15///
16/// Effects are eager: when a dependency signal changes, the effect
17/// closure is re-run immediately. The effect also runs once on
18/// creation to discover its initial dependencies.
19///
20/// # Examples
21///
22/// ```ignore
23/// let count = Signal::new(0);
24/// let log = Rc::new(RefCell::new(Vec::new()));
25///
26/// let effect = Effect::new({
27///     let count = count.clone();
28///     let log = log.clone();
29///     move || {
30///         log.borrow_mut().push(count.get());
31///     }
32/// });
33///
34/// count.set(1); // effect re-runs, logs [0, 1]
35/// ```
36pub struct Effect(Rc<EffectInner>);
37
38struct EffectInner {
39    /// The effect closure. Uses `RefCell` to allow re-borrowing during notify.
40    effect_fn: RefCell<Box<dyn FnMut()>>,
41    /// Subscriber ID for dependency tracking.
42    sub_id: SubscriberId,
43    /// Whether this effect is still active.
44    active: Cell<bool>,
45}
46
47impl Effect {
48    /// Create a new effect that runs the given closure.
49    ///
50    /// The closure is called immediately to discover dependencies.
51    /// It will be re-called whenever any signal read during its
52    /// execution changes.
53    #[must_use]
54    pub fn new(f: impl FnMut() + 'static) -> Self {
55        let sub_id = context::next_subscriber_id();
56
57        let inner = Rc::new(EffectInner {
58            effect_fn: RefCell::new(Box::new(f)),
59            sub_id,
60            active: Cell::new(true),
61        });
62
63        // Run immediately to discover dependencies.
64        inner.run();
65
66        Effect(inner)
67    }
68
69    /// Check if this effect is still active.
70    pub fn is_active(&self) -> bool {
71        self.0.active.get()
72    }
73
74    /// Permanently deactivate this effect.
75    ///
76    /// Once disposed, the effect will not run again even if its
77    /// dependencies change.
78    pub fn dispose(&self) {
79        self.0.active.set(false);
80    }
81
82    /// Get this effect's subscriber ID.
83    pub fn subscriber_id(&self) -> SubscriberId {
84        self.0.sub_id
85    }
86
87    /// Get a weak reference suitable for subscribing to signals.
88    pub fn as_subscriber(&self) -> std::rc::Weak<dyn Subscriber> {
89        Rc::downgrade(&self.0) as std::rc::Weak<dyn Subscriber>
90    }
91}
92
93impl Clone for Effect {
94    fn clone(&self) -> Self {
95        Effect(Rc::clone(&self.0))
96    }
97}
98
99impl EffectInner {
100    /// Run the effect closure within a tracking context.
101    fn run(&self) {
102        if !self.active.get() {
103            return;
104        }
105
106        context::start_tracking(self.sub_id);
107
108        {
109            let mut f = self.effect_fn.borrow_mut();
110            f();
111        }
112
113        let _deps = context::stop_tracking();
114    }
115}
116
117impl Subscriber for EffectInner {
118    fn notify(&self) {
119        self.run();
120    }
121
122    fn id(&self) -> SubscriberId {
123        self.sub_id
124    }
125}
126
127#[cfg(test)]
128#[allow(clippy::unwrap_used)]
129mod tests {
130    use super::*;
131    use crate::reactive::signal::Signal;
132    use std::cell::Cell;
133
134    #[test]
135    fn effect_runs_immediately() {
136        let ran = Rc::new(Cell::new(false));
137        let _effect = Effect::new({
138            let ran = Rc::clone(&ran);
139            move || {
140                ran.set(true);
141            }
142        });
143        assert!(ran.get());
144    }
145
146    #[test]
147    fn effect_reruns_on_signal_change() {
148        let sig = Signal::new(0);
149        let log: Rc<RefCell<Vec<i32>>> = Rc::new(RefCell::new(Vec::new()));
150
151        let effect = Effect::new({
152            let sig = sig.clone();
153            let log = Rc::clone(&log);
154            move || {
155                log.borrow_mut().push(sig.get());
156            }
157        });
158
159        // Subscribe effect to signal.
160        sig.subscribe(effect.as_subscriber());
161
162        sig.set(1);
163        sig.set(2);
164
165        assert_eq!(*log.borrow(), vec![0, 1, 2]);
166    }
167
168    #[test]
169    fn effect_tracks_multiple_signals() {
170        let a = Signal::new(1);
171        let b = Signal::new(10);
172        let sum_log: Rc<RefCell<Vec<i32>>> = Rc::new(RefCell::new(Vec::new()));
173
174        let effect = Effect::new({
175            let a = a.clone();
176            let b = b.clone();
177            let log = Rc::clone(&sum_log);
178            move || {
179                log.borrow_mut().push(a.get() + b.get());
180            }
181        });
182
183        a.subscribe(effect.as_subscriber());
184        b.subscribe(effect.as_subscriber());
185
186        a.set(2);
187        b.set(20);
188
189        // Initial: 11, after a=2: 12, after b=20: 22
190        assert_eq!(*sum_log.borrow(), vec![11, 12, 22]);
191    }
192
193    #[test]
194    fn disposed_effect_does_not_run() {
195        let sig = Signal::new(0);
196        let count = Rc::new(Cell::new(0u32));
197
198        let effect = Effect::new({
199            let sig = sig.clone();
200            let count = Rc::clone(&count);
201            move || {
202                let _ = sig.get();
203                count.set(count.get() + 1);
204            }
205        });
206
207        sig.subscribe(effect.as_subscriber());
208
209        assert_eq!(count.get(), 1); // Initial run.
210
211        effect.dispose();
212        assert!(!effect.is_active());
213
214        sig.set(1);
215        assert_eq!(count.get(), 1); // Should NOT have run again.
216    }
217
218    #[test]
219    fn effect_with_no_deps_runs_once() {
220        let count = Rc::new(Cell::new(0u32));
221        let _effect = Effect::new({
222            let count = Rc::clone(&count);
223            move || {
224                count.set(count.get() + 1);
225            }
226        });
227        assert_eq!(count.get(), 1);
228    }
229
230    #[test]
231    fn effect_reads_computed() {
232        use crate::reactive::Computed;
233
234        let sig = Signal::new(5);
235        let doubled = Computed::new({
236            let sig = sig.clone();
237            move || sig.get() * 2
238        });
239
240        sig.subscribe(doubled.as_subscriber());
241
242        let log: Rc<RefCell<Vec<i32>>> = Rc::new(RefCell::new(Vec::new()));
243        let effect = Effect::new({
244            let doubled = doubled.clone();
245            let log = Rc::clone(&log);
246            move || {
247                log.borrow_mut().push(doubled.get());
248            }
249        });
250
251        // Subscribe effect to computed's notifications.
252        doubled.subscribe(effect.as_subscriber());
253
254        assert_eq!(*log.borrow(), vec![10]);
255
256        sig.set(7);
257        assert_eq!(*log.borrow(), vec![10, 14]);
258    }
259
260    #[test]
261    fn multiple_effects_on_same_signal() {
262        let sig = Signal::new(0);
263        let count_a = Rc::new(Cell::new(0u32));
264        let count_b = Rc::new(Cell::new(0u32));
265
266        let effect_a = Effect::new({
267            let sig = sig.clone();
268            let count = Rc::clone(&count_a);
269            move || {
270                let _ = sig.get();
271                count.set(count.get() + 1);
272            }
273        });
274
275        let effect_b = Effect::new({
276            let sig = sig.clone();
277            let count = Rc::clone(&count_b);
278            move || {
279                let _ = sig.get();
280                count.set(count.get() + 1);
281            }
282        });
283
284        sig.subscribe(effect_a.as_subscriber());
285        sig.subscribe(effect_b.as_subscriber());
286
287        sig.set(1);
288        assert_eq!(count_a.get(), 2); // Initial + 1 change.
289        assert_eq!(count_b.get(), 2);
290    }
291
292    #[test]
293    fn effect_is_active_initially() {
294        let effect = Effect::new(|| {});
295        assert!(effect.is_active());
296    }
297
298    #[test]
299    fn effect_clone_shares_state() {
300        let effect = Effect::new(|| {});
301        let clone = effect.clone();
302        assert_eq!(effect.subscriber_id(), clone.subscriber_id());
303        effect.dispose();
304        assert!(!clone.is_active());
305    }
306}