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(¤t) {
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(¤t) {
241 *dirty = true;
242 }
243 if let Some(dependents) = self.deps.get(¤t) {
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}