Skip to main content

oxiui_core/
reactive.rs

1//! Reactive primitives: [`Signal<T>`] and [`Computed<T>`] with automatic
2//! dependency tracking, topological dirty propagation, and cycle detection.
3//!
4//! All types are `Send + Sync` — the runtime stores node state behind an
5//! `Arc<RwLock<RuntimeInner>>` and runs computed thunks **without** holding the
6//! lock (critical deadlock prevention).
7//!
8//! # Design
9//!
10//! - **[`ReactiveRuntime`]** — the shared graph owner.  Clone freely; it is
11//!   `Arc`-backed.
12//! - **[`Signal<T>`]** — a settable cell.  `set()` marks transitive dependents
13//!   dirty via BFS over the dep graph.
14//! - **[`Computed<T>`]** — lazily evaluated, cached derived value.  On `get()`
15//!   the thunk is run **outside** any lock; dependency edges are registered by
16//!   reads that occur during the thunk.
17//! - **Thread-local stack** — `COMPUTATION_STACK` tracks which computed node
18//!   is currently evaluating.  Any `get()` call on a signal or computed while
19//!   the stack is non-empty registers a dep edge (source → caller).
20//! - **Cycle detection** — two layers: (1) runtime: stack contains self →
21//!   `Err(Cycle)`; (2) graph: DFS before inserting an edge →
22//!   `Err(DependencyCycle)`.
23//!
24//! # Example
25//! ```no_run
26//! use oxiui_core::reactive::ReactiveRuntime;
27//!
28//! let rt = ReactiveRuntime::new();
29//! let count = rt.signal(0i32);
30//! let doubled = rt.computed({
31//!     let c = count.clone();
32//!     move || c.get() * 2
33//! }).expect("no cycle");
34//!
35//! count.set(5);
36//! assert_eq!(doubled.get(), Ok(10));
37//! ```
38
39use std::{
40    any::Any,
41    cell::RefCell,
42    collections::{HashMap, HashSet, VecDeque},
43    marker::PhantomData,
44    sync::{Arc, RwLock},
45};
46
47// ─── Type aliases ────────────────────────────────────────────────────────────
48
49/// A boxed type-erased value that is `Send + Sync`.
50type AnyValue = Box<dyn Any + Send + Sync>;
51
52/// A cloneable computed thunk: no arguments, returns a type-erased value.
53///
54/// We use `Arc` so the thunk can be cloned out of the `RwLock` guard before
55/// being called (holding the lock across user code would deadlock).
56type ArcThunk = Arc<dyn Fn() -> AnyValue + Send + Sync>;
57
58// ─── Thread-local computation stack ─────────────────────────────────────────
59
60thread_local! {
61    /// The stack of [`NodeId`]s currently being evaluated.
62    ///
63    /// The top of the stack is the computed node whose thunk is executing now.
64    /// Any `get()` call on a node while the stack is non-empty registers the
65    /// reading node (stack top) as a dependent of the node being read.
66    static COMPUTATION_STACK: RefCell<Vec<NodeId>> = const { RefCell::new(Vec::new()) };
67}
68
69/// Push `id` onto the thread-local computation stack.
70fn stack_push(id: NodeId) {
71    COMPUTATION_STACK.with(|s| s.borrow_mut().push(id));
72}
73
74/// Pop the top entry from the thread-local computation stack.
75fn stack_pop() {
76    COMPUTATION_STACK.with(|s| {
77        s.borrow_mut().pop();
78    });
79}
80
81/// Return the node currently being evaluated (the top of the stack), if any.
82fn stack_top() -> Option<NodeId> {
83    COMPUTATION_STACK.with(|s| s.borrow().last().copied())
84}
85
86/// Return `true` if `id` is anywhere on the thread-local computation stack.
87fn stack_contains(id: NodeId) -> bool {
88    COMPUTATION_STACK.with(|s| s.borrow().contains(&id))
89}
90
91// ─── Error ───────────────────────────────────────────────────────────────────
92
93/// Errors produced by the reactive runtime.
94#[derive(Debug, Clone, PartialEq)]
95pub enum ReactiveError {
96    /// A computation would read its own result — runtime self-reference cycle
97    /// detected via the thread-local evaluation stack.
98    Cycle,
99    /// Inserting a dependency edge would introduce a cycle in the dep graph —
100    /// detected via DFS before the edge is committed.
101    DependencyCycle,
102    /// The stored value's concrete type does not match the requested `T`.
103    TypeMismatch,
104}
105
106impl std::fmt::Display for ReactiveError {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match self {
109            ReactiveError::Cycle => {
110                write!(f, "reactive cycle: a computed node reads its own value")
111            }
112            ReactiveError::DependencyCycle => {
113                write!(f, "reactive dependency cycle detected on edge insertion")
114            }
115            ReactiveError::TypeMismatch => write!(
116                f,
117                "reactive type mismatch: stored type does not match requested type"
118            ),
119        }
120    }
121}
122
123impl std::error::Error for ReactiveError {}
124
125// ─── NodeId ──────────────────────────────────────────────────────────────────
126
127/// A stable, opaque identifier for a reactive node (signal or computed).
128#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
129pub struct NodeId(u64);
130
131// ─── NodeKind ────────────────────────────────────────────────────────────────
132
133/// Internal storage for a reactive graph node.
134enum NodeKind {
135    /// A settable cell holding a type-erased value.
136    Signal {
137        /// The current value.
138        value: AnyValue,
139    },
140    /// A lazily evaluated, cached derived value.
141    Computed {
142        /// The function that computes the value.
143        ///
144        /// Stored behind `Arc` so it can be cloned out of the write lock before
145        /// being called — holding the lock across user code would deadlock.
146        thunk: ArcThunk,
147        /// The most recently computed value, or `None` if never evaluated.
148        cached: Option<AnyValue>,
149        /// Whether the cached value is stale and must be recomputed.
150        dirty: bool,
151    },
152}
153
154// ─── RuntimeInner ────────────────────────────────────────────────────────────
155
156/// The mutable interior of [`ReactiveRuntime`].
157struct RuntimeInner {
158    /// All nodes indexed by [`NodeId`].
159    nodes: HashMap<NodeId, NodeKind>,
160    /// `deps[N]` = nodes that *read* `N` (dependents of `N`).
161    ///
162    /// When `N` changes, every node in `deps[N]` must be marked dirty.
163    deps: HashMap<NodeId, Vec<NodeId>>,
164    /// Monotonically increasing counter used to generate [`NodeId`]s.
165    next_id: u64,
166}
167
168impl RuntimeInner {
169    fn new() -> Self {
170        Self {
171            nodes: HashMap::new(),
172            deps: HashMap::new(),
173            next_id: 0,
174        }
175    }
176
177    /// Allocate and return a fresh [`NodeId`].
178    fn alloc_id(&mut self) -> NodeId {
179        let id = NodeId(self.next_id);
180        self.next_id += 1;
181        id
182    }
183
184    /// Return `true` if there is a path from `start` to `target` through the
185    /// existing `deps` graph (BFS).
186    ///
187    /// Used to detect whether adding `deps[source].push(caller)` would create
188    /// a cycle: we check if there is a path from `caller` → `source` (since
189    /// the new edge goes `source → caller`, a cycle exists iff `caller` is
190    /// already reachable from `source` via dep edges).
191    fn reachable(&self, start: NodeId, target: NodeId) -> bool {
192        let mut visited = HashSet::new();
193        let mut queue = VecDeque::new();
194        queue.push_back(start);
195        while let Some(current) = queue.pop_front() {
196            if current == target {
197                return true;
198            }
199            if !visited.insert(current) {
200                continue;
201            }
202            if let Some(dependents) = self.deps.get(&current) {
203                for &dep in dependents {
204                    queue.push_back(dep);
205                }
206            }
207        }
208        false
209    }
210
211    /// Register that `caller` depends on `source` (i.e. `caller` reads `source`).
212    ///
213    /// Adds `caller` to `deps[source]`, avoiding duplicates.  Returns
214    /// `Err(DependencyCycle)` if the new edge would form a cycle.
215    fn try_add_dependency(&mut self, source: NodeId, caller: NodeId) -> Result<(), ReactiveError> {
216        // A cycle exists if `source` is already reachable from `caller`.
217        if self.reachable(caller, source) {
218            return Err(ReactiveError::DependencyCycle);
219        }
220        let dependents = self.deps.entry(source).or_default();
221        if !dependents.contains(&caller) {
222            dependents.push(caller);
223        }
224        Ok(())
225    }
226
227    /// Mark all transitive dependents of `id` as dirty (BFS).
228    fn mark_dirty_transitive(&mut self, id: NodeId) {
229        let mut visited = HashSet::new();
230        let mut queue = VecDeque::new();
231        if let Some(dependents) = self.deps.get(&id) {
232            for &dep in dependents {
233                queue.push_back(dep);
234            }
235        }
236        while let Some(current) = queue.pop_front() {
237            if !visited.insert(current) {
238                continue;
239            }
240            if let Some(NodeKind::Computed { dirty, .. }) = self.nodes.get_mut(&current) {
241                *dirty = true;
242            }
243            if let Some(dependents) = self.deps.get(&current) {
244                for &dep in dependents {
245                    queue.push_back(dep);
246                }
247            }
248        }
249    }
250}
251
252// ─── ReactiveRuntime ─────────────────────────────────────────────────────────
253
254/// A shared reactive graph that owns [`Signal`]s and [`Computed`]s.
255///
256/// The runtime is `Clone` (and `Send + Sync`) because it is backed by
257/// `Arc<RwLock<_>>`.  All handles (`Signal`, `Computed`) clone the same `Arc`.
258#[derive(Clone)]
259pub struct ReactiveRuntime {
260    inner: Arc<RwLock<RuntimeInner>>,
261}
262
263impl Default for ReactiveRuntime {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269impl ReactiveRuntime {
270    /// Create a new, empty reactive runtime.
271    pub fn new() -> Self {
272        Self {
273            inner: Arc::new(RwLock::new(RuntimeInner::new())),
274        }
275    }
276
277    /// Create a new [`Signal`] holding `initial`.
278    ///
279    /// The returned handle is lightweight; clone it freely.
280    pub fn signal<T: Send + Sync + Clone + 'static>(&self, initial: T) -> Signal<T> {
281        let mut inner = self
282            .inner
283            .write()
284            .expect("ReactiveRuntime::signal: RwLock poisoned");
285        let id = inner.alloc_id();
286        inner.nodes.insert(
287            id,
288            NodeKind::Signal {
289                value: Box::new(initial),
290            },
291        );
292        drop(inner);
293        Signal {
294            runtime: Arc::clone(&self.inner),
295            id,
296            _phantom: PhantomData,
297        }
298    }
299
300    /// Create a new [`Computed`] whose value is derived by calling `f`.
301    ///
302    /// The thunk `f` is called lazily on the first `get()` and whenever the
303    /// cached value is stale.  Dependency edges are registered automatically
304    /// when `f` calls `.get()` on signals or other computeds.
305    ///
306    /// Returns `Err(ReactiveError::DependencyCycle)` if a cycle is detected on
307    /// the first evaluation (not possible via the safe public API).
308    pub fn computed<T: Send + Sync + Clone + 'static>(
309        &self,
310        f: impl Fn() -> T + Send + Sync + 'static,
311    ) -> Result<Computed<T>, ReactiveError> {
312        let thunk: ArcThunk = Arc::new(move || Box::new(f()) as AnyValue);
313        let mut inner = self
314            .inner
315            .write()
316            .expect("ReactiveRuntime::computed: RwLock poisoned");
317        let id = inner.alloc_id();
318        inner.nodes.insert(
319            id,
320            NodeKind::Computed {
321                thunk,
322                cached: None,
323                dirty: true,
324            },
325        );
326        drop(inner);
327        Ok(Computed {
328            runtime: Arc::clone(&self.inner),
329            id,
330            _phantom: PhantomData,
331        })
332    }
333}
334
335// ─── Signal<T> ───────────────────────────────────────────────────────────────
336
337/// A settable reactive value of type `T`.
338///
339/// `Signal<T>` is a lightweight handle; clone it to share access to the same
340/// reactive cell.  All clones observe the same value and propagation.
341pub struct Signal<T: Send + Sync + Clone + 'static> {
342    runtime: Arc<RwLock<RuntimeInner>>,
343    /// The node's identifier within the shared runtime.
344    id: NodeId,
345    _phantom: PhantomData<T>,
346}
347
348impl<T: Send + Sync + Clone + 'static> Clone for Signal<T> {
349    fn clone(&self) -> Self {
350        Self {
351            runtime: Arc::clone(&self.runtime),
352            id: self.id,
353            _phantom: PhantomData,
354        }
355    }
356}
357
358impl<T: Send + Sync + Clone + 'static> Signal<T> {
359    /// Read the current value.
360    ///
361    /// If called inside a [`Computed`] thunk, registers the reading computed as
362    /// a dependent so future `set()` calls propagate correctly.
363    ///
364    /// # Panics
365    /// Panics only if the `RwLock` is poisoned or the stored type violates `T`
366    /// (neither is possible via the public API).
367    pub fn get(&self) -> T {
368        // (1) Acquire read lock, clone the value, release.
369        let value = {
370            let inner = self.runtime.read().expect("Signal::get: RwLock poisoned");
371            match inner.nodes.get(&self.id) {
372                Some(NodeKind::Signal { value }) => value
373                    .downcast_ref::<T>()
374                    .expect("Signal<T> type invariant: stored type must match T")
375                    .clone(),
376                _ => panic!("Signal<T> node not found or wrong kind"),
377            }
378        }; // read lock released here
379
380        // (2) Register dependency edge if inside a computed evaluation.
381        if let Some(caller) = stack_top() {
382            let mut inner = self
383                .runtime
384                .write()
385                .expect("Signal::get dep-reg: RwLock poisoned");
386            // Ignore errors: DependencyCycle is surfaced by Computed::get().
387            let _ = inner.try_add_dependency(self.id, caller);
388        }
389
390        value
391    }
392
393    /// Update the value and mark all transitive dependents dirty.
394    pub fn set(&self, value: T) {
395        let mut inner = self.runtime.write().expect("Signal::set: RwLock poisoned");
396        match inner.nodes.get_mut(&self.id) {
397            Some(NodeKind::Signal { value: stored }) => {
398                *stored = Box::new(value);
399            }
400            _ => panic!("Signal<T> node not found or wrong kind"),
401        }
402        inner.mark_dirty_transitive(self.id);
403        // write lock released here
404    }
405}
406
407// ─── Computed<T> ─────────────────────────────────────────────────────────────
408
409/// A lazily evaluated reactive value derived from signals or other computeds.
410///
411/// `Computed<T>` is a lightweight handle; clone it to share access to the same
412/// derived node.
413pub struct Computed<T: Send + Sync + Clone + 'static> {
414    runtime: Arc<RwLock<RuntimeInner>>,
415    /// The node's identifier within the shared runtime.
416    id: NodeId,
417    _phantom: PhantomData<T>,
418}
419
420impl<T: Send + Sync + Clone + 'static> Clone for Computed<T> {
421    fn clone(&self) -> Self {
422        Self {
423            runtime: Arc::clone(&self.runtime),
424            id: self.id,
425            _phantom: PhantomData,
426        }
427    }
428}
429
430impl<T: Send + Sync + Clone + 'static> Computed<T> {
431    /// Read the current value, recomputing if stale.
432    ///
433    /// If called inside another computed thunk, registers the outer computed as
434    /// a dependent of this one.
435    ///
436    /// # Errors
437    /// - [`ReactiveError::Cycle`] — this node is already on the evaluation
438    ///   stack (self-referential cycle).
439    /// - [`ReactiveError::DependencyCycle`] — registering a dep edge would
440    ///   introduce a graph cycle.
441    /// - [`ReactiveError::TypeMismatch`] — the cached value cannot be downcast
442    ///   to `T` (unreachable via the public API).
443    pub fn get(&self) -> Result<T, ReactiveError> {
444        // ── Layer 1 cycle check: self already on the evaluation stack? ────────
445        if stack_contains(self.id) {
446            return Err(ReactiveError::Cycle);
447        }
448
449        // ── Register dependency edge (even for a clean/cached read) ───────────
450        // This ensures that a computed reading another computed still gets the
451        // edge, so later dirty propagation reaches all transitive dependents.
452        if let Some(caller) = stack_top() {
453            let mut inner = self
454                .runtime
455                .write()
456                .expect("Computed::get dep-reg: RwLock poisoned");
457            inner.try_add_dependency(self.id, caller)?;
458        } // write lock released
459
460        // ── Check dirty flag ──────────────────────────────────────────────────
461        let is_dirty = {
462            let inner = self
463                .runtime
464                .read()
465                .expect("Computed::get dirty-check: RwLock poisoned");
466            match inner.nodes.get(&self.id) {
467                Some(NodeKind::Computed { dirty, .. }) => *dirty,
468                _ => return Err(ReactiveError::TypeMismatch),
469            }
470        }; // read lock released
471
472        if !is_dirty {
473            // Return the cached value without recomputing.
474            let inner = self
475                .runtime
476                .read()
477                .expect("Computed::get cached-read: RwLock poisoned");
478            return match inner.nodes.get(&self.id) {
479                Some(NodeKind::Computed {
480                    cached: Some(v), ..
481                }) => v
482                    .downcast_ref::<T>()
483                    .cloned()
484                    .ok_or(ReactiveError::TypeMismatch),
485                _ => Err(ReactiveError::TypeMismatch),
486            };
487        } // read lock released
488
489        // ── Recompute path ────────────────────────────────────────────────────
490        // Step 1: Clone the Arc-thunk out of the RwLock guard.
491        //         We MUST release the lock before calling the thunk, because
492        //         the thunk calls signal.get() which acquires the write lock
493        //         for dep registration — holding both would deadlock.
494        let thunk: ArcThunk = {
495            let inner = self
496                .runtime
497                .read()
498                .expect("Computed::get thunk-clone: RwLock poisoned");
499            match inner.nodes.get(&self.id) {
500                Some(NodeKind::Computed { thunk, .. }) => Arc::clone(thunk),
501                _ => return Err(ReactiveError::TypeMismatch),
502            }
503        }; // read lock released — thunk is now owned by this stack frame
504
505        // Step 2: Push self onto the computation stack and run the thunk.
506        //         No lock is held at this point.
507        stack_push(self.id);
508        let new_value: AnyValue = thunk();
509        stack_pop();
510
511        // Step 3: Re-acquire write lock, store the new value, clear dirty flag.
512        {
513            let mut inner = self
514                .runtime
515                .write()
516                .expect("Computed::get store: RwLock poisoned");
517            match inner.nodes.get_mut(&self.id) {
518                Some(NodeKind::Computed { cached, dirty, .. }) => {
519                    *cached = Some(new_value);
520                    *dirty = false;
521                }
522                _ => return Err(ReactiveError::TypeMismatch),
523            }
524        } // write lock released
525
526        // Step 4: Read the freshly stored value back and return it.
527        let inner = self
528            .runtime
529            .read()
530            .expect("Computed::get final-read: RwLock poisoned");
531        match inner.nodes.get(&self.id) {
532            Some(NodeKind::Computed {
533                cached: Some(v), ..
534            }) => v
535                .downcast_ref::<T>()
536                .cloned()
537                .ok_or(ReactiveError::TypeMismatch),
538            _ => Err(ReactiveError::TypeMismatch),
539        }
540    }
541}
542
543// ─── Tests ───────────────────────────────────────────────────────────────────
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    /// Basic signal read and write.
550    #[test]
551    fn test_signal_get_set() {
552        let rt = ReactiveRuntime::new();
553        let s = rt.signal(42i32);
554        assert_eq!(s.get(), 42);
555        s.set(99);
556        assert_eq!(s.get(), 99);
557    }
558
559    /// A computed node derives its value from a signal and updates when the
560    /// signal changes.
561    #[test]
562    fn test_computed_derives_from_signal() {
563        let rt = ReactiveRuntime::new();
564        let s = rt.signal(10i32);
565        let sc = s.clone();
566        let c = rt.computed(move || sc.get() * 2).expect("no cycle");
567        assert_eq!(c.get(), Ok(20));
568        s.set(5);
569        assert_eq!(c.get(), Ok(10));
570    }
571
572    /// Dirty propagation through a chain a → b → c.
573    #[test]
574    fn test_chain_propagation() {
575        let rt = ReactiveRuntime::new();
576        let a = rt.signal(1i32);
577        let ac = a.clone();
578        let b = rt.computed(move || ac.get() * 2).expect("b ok");
579        let bc = b.clone();
580        let c = rt.computed(move || bc.get().expect("b") + 1).expect("c ok");
581
582        // Initial: a=1, b=2, c=3
583        assert_eq!(c.get(), Ok(3));
584
585        // After set: a=10, b=20, c=21
586        a.set(10);
587        assert_eq!(c.get(), Ok(21));
588    }
589
590    /// DFS-based cycle detection via `try_add_dependency` — the diamond pattern
591    /// must NOT produce a false-positive cycle error.
592    #[test]
593    fn test_cycle_detection_no_false_positive_diamond() {
594        // Diamond: a → b, a → c, (b, c) → d
595        let rt = ReactiveRuntime::new();
596        let a = rt.signal(2i32);
597        let ac1 = a.clone();
598        let ac2 = a.clone();
599        let b = rt.computed(move || ac1.get() * 3).expect("b ok");
600        let c = rt.computed(move || ac2.get() + 10).expect("c ok");
601        let bc = b.clone();
602        let cc = c.clone();
603        let d = rt
604            .computed(move || bc.get().expect("b") + cc.get().expect("c"))
605            .expect("diamond: no cycle");
606
607        // a=2: b=6, c=12, d=18
608        assert_eq!(d.get(), Ok(18));
609        a.set(5);
610        // a=5: b=15, c=15, d=30
611        assert_eq!(d.get(), Ok(30));
612    }
613
614    /// The DFS guard in `try_add_dependency` correctly rejects an edge that
615    /// would form a cycle in the dep graph.
616    #[test]
617    fn test_cycle_detection_dep_graph_dfs() {
618        let rt = ReactiveRuntime::new();
619        let a = rt.signal(1i32);
620        let ac = a.clone();
621        let b = rt.computed(move || ac.get() + 1).expect("b ok");
622
623        // Trigger b.get() so the dep edge a→b is registered in the graph.
624        let _ = b.get();
625
626        // Attempting to add the reverse edge b→a (meaning a "reads" b) would
627        // create a cycle: a→b already exists.
628        let result = {
629            let mut inner = rt.inner.write().unwrap();
630            // try_add_dependency(source=b, caller=a): deps[b].push(a)
631            // But there is already a path caller=a → source=b, so this cycles.
632            inner.try_add_dependency(b.id, a.id)
633        };
634        assert_eq!(result, Err(ReactiveError::DependencyCycle));
635    }
636
637    /// Diamond recomputes to correct values after multiple set() calls.
638    #[test]
639    fn test_diamond_recomputes_correctly() {
640        let rt = ReactiveRuntime::new();
641        let a = rt.signal(1i32);
642        let ac1 = a.clone();
643        let ac2 = a.clone();
644        let b = rt.computed(move || ac1.get() * 2).expect("b");
645        let c = rt.computed(move || ac2.get() + 5).expect("c");
646        let bc = b.clone();
647        let cc = c.clone();
648        let d = rt
649            .computed(move || bc.get().expect("b") + cc.get().expect("c"))
650            .expect("d");
651
652        // a=1: b=2, c=6, d=8
653        assert_eq!(d.get(), Ok(8));
654        a.set(3);
655        // a=3: b=6, c=8, d=14
656        assert_eq!(d.get(), Ok(14));
657        a.set(0);
658        // a=0: b=0, c=5, d=5
659        assert_eq!(d.get(), Ok(5));
660    }
661
662    /// Compile-time verification that all public reactive types implement
663    /// `Send + Sync`.
664    #[test]
665    fn test_send_sync_bounds() {
666        fn _assert_send<T: Send>() {}
667        fn _assert_sync<T: Sync>() {}
668        fn _check(rt: ReactiveRuntime) {
669            let _: &dyn Send = &rt;
670            let _: &dyn Sync = &rt;
671        }
672        _assert_send::<ReactiveRuntime>();
673        _assert_sync::<ReactiveRuntime>();
674        _assert_send::<Signal<i32>>();
675        _assert_sync::<Signal<i32>>();
676        _assert_send::<Computed<i32>>();
677        _assert_sync::<Computed<i32>>();
678    }
679
680    /// Nested computed (B reads A which reads signal x).  Must complete without
681    /// deadlock within a tight deadline.
682    #[test]
683    fn test_no_deadlock_nested_computed() {
684        use std::time::{Duration, Instant};
685
686        let rt = ReactiveRuntime::new();
687        let x = rt.signal(7i32);
688        let xc = x.clone();
689        let comp_a = rt.computed(move || xc.get() * 3).expect("a ok");
690        let ac = comp_a.clone();
691        let comp_b = rt.computed(move || ac.get().expect("a") + 1).expect("b ok");
692
693        let start = Instant::now();
694        let result = comp_b.get();
695        let elapsed = start.elapsed();
696
697        // 7 * 3 + 1 = 22
698        assert_eq!(result, Ok(22));
699        assert!(
700            elapsed < Duration::from_secs(1),
701            "get() should not deadlock (elapsed: {elapsed:?})",
702        );
703    }
704}