Skip to main content

day_reactive/
lib.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! day-reactive: the reactive core (DESIGN.md §3.3, §4).
5//!
6//! Build-once / bind-forever: signals, memos, effects, `bind`, and `watch` over a thread-local
7//! generational arena. All handles are `Copy` and `!Send`; the only cross-thread door is
8//! [`Setter`]. Writes batch; the drain runs to fixpoint in (priority, scope-depth, creation-seq)
9//! order; layout/turn-end callbacks run once after the fixpoint (§3.3's turn state machine).
10//!
11//! `Signal` is `!Send`:
12//! ```compile_fail
13//! fn assert_send<T: Send>(_: T) {}
14//! let s = day_reactive::Signal::new(1);
15//! assert_send(s); // must not compile
16//! ```
17
18// ---------------------------------------------------------------------------------------------
19// `tls_group!`: one pthread key for a module's whole set of thread-locals.
20//
21// On Android, Rust's `thread_local!` has no native-TLS path (`rustc --print cfg
22// --target aarch64-linux-android` emits no `target_thread_local`), so every `thread_local!`
23// static takes one of bionic's 128 `pthread_key_create` slots, allocated on first touch and
24// never released. day plus its dependencies declares far more than that, so a long-running app
25// exhausts the table as more of its code runs first, and the next library to ask for a key
26// fails. Android's WebView is the usual casualty: its browser startup takes several keys, gets
27// none, and traps with no message at all.
28//
29// This macro takes `thread_local!`'s syntax and gives back the same names with the same `.with`,
30// but backs all of them with one key: the slots become fields of a single per-module struct and
31// each name becomes a zero-sized handle that borrows its field. Call sites do not change.
32// Cell/RefCell conveniences (`set`/`get`/`replace`) are absent: go through `.with`, which is
33// what the field itself offers.
34#[macro_export]
35macro_rules! tls_group {
36    ($( $(#[$attr:meta])* $vis:vis static $name:ident : $ty:ty = $init:expr ; )+) => {
37        #[allow(non_snake_case)]
38        struct TlsGroupSlots { $( $name: $ty, )+ }
39        ::std::thread_local! {
40            static TLS_GROUP: TlsGroupSlots = TlsGroupSlots { $( $name: $init, )+ };
41        }
42        $(
43            $(#[$attr])*
44            #[allow(non_camel_case_types, dead_code)]
45            #[derive(Clone, Copy)]
46            $vis struct $name;
47            impl $name {
48                #[inline]
49                #[allow(dead_code)]
50                $vis fn with<R>(self, f: impl ::core::ops::FnOnce(&$ty) -> R) -> R {
51                    TLS_GROUP.with(|g| f(&g.$name))
52                }
53                #[inline]
54                #[allow(dead_code)]
55                $vis fn try_with<R>(
56                    self,
57                    f: impl ::core::ops::FnOnce(&$ty) -> R,
58                ) -> ::core::result::Result<R, ::std::thread::AccessError> {
59                    TLS_GROUP.try_with(|g| f(&g.$name))
60                }
61            }
62        )+
63    };
64}
65
66// ---------------------------------------------------------------------------------------------
67// `tls_root!` / `tls_slots!`: one pthread key for a whole crate.
68//
69// `tls_group!` above already collapses a module's statics to one key. These two go the last step:
70// each module declares its slots as a plain struct (`tls_slots!`) and the crate root gathers them
71// into a single thread-local (`tls_root!`). A crate then costs one key no matter how many modules
72// keep state, and the root is also the natural place to hang teardown.
73//
74// Call sites are unchanged: the names still answer `.with`. The cost is one line per module
75// naming its field in the root, and `tls_root!` listing the modules. `crate::tls_root` resolves
76// in the expanding crate, so each crate keeps its own root.
77#[macro_export]
78macro_rules! tls_root {
79    ($( $(#[$attr:meta])* $field:ident : $ty:ty ),+ $(,)?) => {
80        #[allow(non_snake_case)]
81        pub(crate) struct TlsRoot { $( $(#[$attr])* pub(crate) $field: $ty, )+ }
82        ::std::thread_local! {
83            static TLS_ROOT: TlsRoot =
84                TlsRoot { $( $(#[$attr])* $field: ::core::default::Default::default(), )+ };
85        }
86        /// Borrow the crate's single thread-local root. Reentrant: the root itself holds no
87        /// lock, so a slot's closure may reach another slot.
88        #[inline]
89        pub(crate) fn tls_root<R>(f: impl ::core::ops::FnOnce(&TlsRoot) -> R) -> R {
90            TLS_ROOT.with(f)
91        }
92        /// Best-effort access for destructor paths after the crate's TLS has gone away.
93        #[inline]
94        #[allow(dead_code)]
95        pub(crate) fn try_tls_root<R>(
96            f: impl ::core::ops::FnOnce(&TlsRoot) -> R,
97        ) -> ::core::result::Result<R, ::std::thread::AccessError> {
98            TLS_ROOT.try_with(f)
99        }
100    };
101}
102
103/// One module's slots, stored as `$field` of the crate's [`tls_root!`].
104// `crate::tls_root` is correct here, and `$crate` would be wrong: the root belongs to the crate
105// expanding this macro, not to day-reactive. That resolution is what gives each crate a root of
106// its own, so the lint is describing the intent rather than a mistake.
107#[allow(clippy::crate_in_macro_def)]
108#[macro_export]
109macro_rules! tls_slots {
110    ($field:ident; $( $(#[$attr:meta])* $vis:vis static $name:ident : $ty:ty = $init:expr ; )+) => {
111        #[allow(non_snake_case)]
112        pub(crate) struct TlsGroupSlots { $( $name: $ty, )+ }
113        impl ::core::default::Default for TlsGroupSlots {
114            fn default() -> Self { Self { $( $name: $init, )+ } }
115        }
116        $(
117            $(#[$attr])*
118            #[allow(non_camel_case_types, dead_code)]
119            #[derive(Clone, Copy)]
120            $vis struct $name;
121            impl $name {
122                #[inline]
123                #[allow(dead_code)]
124                $vis fn with<R>(self, f: impl ::core::ops::FnOnce(&$ty) -> R) -> R {
125                    crate::tls_root(|r| f(&r.$field.$name))
126                }
127                #[inline]
128                #[allow(dead_code)]
129                $vis fn try_with<R>(
130                    self,
131                    f: impl ::core::ops::FnOnce(&$ty) -> R,
132                ) -> ::core::result::Result<R, ::std::thread::AccessError> {
133                    crate::try_tls_root(|r| f(&r.$field.$name))
134                }
135            }
136        )+
137    };
138}
139
140use std::any::{Any, TypeId};
141use std::cell::{Cell, RefCell};
142use std::collections::{HashMap, HashSet};
143use std::future::Future;
144use std::marker::PhantomData;
145use std::panic::Location;
146use std::pin::Pin;
147use std::rc::Rc;
148use std::sync::Arc;
149
150use slotmap::{Key, SlotMap, new_key_type};
151
152new_key_type! {
153    pub struct NodeKey;
154    pub struct ScopeKey;
155}
156
157// ---------------------------------------------------------------------------
158// Runtime
159// ---------------------------------------------------------------------------
160
161#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
162enum NodeState {
163    Clean,
164    Check,
165    Dirty,
166}
167
168#[derive(Clone, Copy, PartialEq, Eq)]
169enum NodeKind {
170    Signal,
171    Memo,
172    /// Effects, binds, watches: anything with a re-runnable reaction closure.
173    Reaction,
174}
175
176/// Type-erased memo comparator (a monomorphized `PartialEq::eq`).
177type MemoEq = fn(&dyn Any, &dyn Any) -> bool;
178
179/// A node's stored value, shared so a reader can hold it while the runtime borrow is released.
180///
181/// `with`/`try_with` must run the caller's closure without holding the thread-local runtime
182/// borrow (the closure routinely reads other signals, which re-enters `with_rt`). This used to be
183/// done by taking the value out of the node and putting it back afterwards, which had two costs: a
184/// re-entrant read of the same signal found the hole and reported "disposed", and a panic inside
185/// the closure lost the value permanently. Sharing the cell instead lets a reader clone it out
186/// cheaply, so concurrent reads work and the value is never in limbo.
187type NodeValue = Rc<RefCell<Box<dyn Any>>>;
188
189struct Node {
190    kind: NodeKind,
191    state: NodeState,
192    value: Option<NodeValue>,
193    /// Memo recompute (returns boxed new value), compared with `eq`.
194    memo_compute: Option<Rc<dyn Fn() -> Box<dyn Any>>>,
195    memo_eq: Option<MemoEq>,
196    /// Reaction closure (effect/bind/watch body).
197    reaction: Option<Rc<dyn Fn()>>,
198    sources: Vec<NodeKey>,
199    /// Callbacks an external dependency registry (day-model's trigger claims) hangs on this
200    /// computation's current run, drained when the run's sources clear (re-track or disposal),
201    /// mirroring the per-run lifetime of `sources` itself. Run outside the runtime borrow.
202    run_cleanups: Vec<Box<dyn FnOnce()>>,
203    observers: Vec<NodeKey>,
204    #[allow(dead_code)] // ownership is tracked scope→nodes; kept for diagnostics
205    scope: ScopeKey,
206    /// Ordering: (priority, scope depth, creation seq). Priority 0 = structural binding.
207    priority: u8,
208    depth: u32,
209    seq: u64,
210    last_changed: u64,
211    last_run: u64,
212    queued: bool,
213    created_at: &'static Location<'static>,
214}
215
216struct ScopeData {
217    parent: ScopeKey,
218    children: Vec<ScopeKey>,
219    nodes: Vec<NodeKey>,
220    cleanups: Vec<Box<dyn FnOnce()>>,
221    context: HashMap<TypeId, Box<dyn Any>>,
222    depth: u32,
223}
224
225struct Runtime {
226    nodes: SlotMap<NodeKey, Node>,
227    scopes: SlotMap<ScopeKey, ScopeData>,
228    root_scope: ScopeKey,
229    current_scope: ScopeKey,
230    /// Observer stack; `None` = untracked frame.
231    observers: Vec<Option<NodeKey>>,
232    pending: Vec<NodeKey>,
233    batch_depth: u32,
234    draining: bool,
235    tick: u64,
236    next_seq: u64,
237    scheduler: Option<Rc<dyn Fn()>>,
238    schedule_posted: bool,
239    /// The async-spawn door ([`install_spawner`]): runs a boxed future on the app's main-loop
240    /// executor, returning an abort closure.
241    spawner: Option<Spawner>,
242    turn_end: Vec<Rc<dyn Fn()>>,
243    /// One-shot callbacks for the end of the current turn ([`at_turn_end`]).
244    turn_end_once: Vec<Box<dyn FnOnce()>>,
245    warned_writes: HashSet<*const Location<'static>>,
246}
247
248impl Runtime {
249    fn new() -> Self {
250        let mut scopes = SlotMap::with_key();
251        let root_scope = scopes.insert(ScopeData {
252            parent: ScopeKey::null(),
253            children: Vec::new(),
254            nodes: Vec::new(),
255            cleanups: Vec::new(),
256            context: HashMap::new(),
257            depth: 0,
258        });
259        Runtime {
260            nodes: SlotMap::with_key(),
261            scopes,
262            root_scope,
263            current_scope: root_scope,
264            observers: Vec::new(),
265            pending: Vec::new(),
266            batch_depth: 0,
267            draining: false,
268            tick: 1,
269            next_seq: 0,
270            scheduler: None,
271            schedule_posted: false,
272            turn_end_once: Vec::new(),
273            spawner: None,
274            turn_end: Vec::new(),
275            warned_writes: HashSet::new(),
276        }
277    }
278}
279
280crate::tls_group! {
281    static RT: RefCell<Runtime> = RefCell::new(Runtime::new());
282
283}
284
285/// Per-drain re-run cap (§4.2): panic in debug, warn-and-defer in release.
286const RERUN_CAP: u32 = 100;
287
288/// Per-turn cap on drain→turn-end rounds ([`flush_sync`]): same panic/warn split as
289/// [`RERUN_CAP`]. Two rounds settle the normal case (a turn-end hook scheduling reactive
290/// work, such as day-persistence's deferred requeries); reaching the cap means hooks are feeding
291/// each other every round.
292const TURN_ROUND_CAP: u32 = 8;
293
294fn with_rt<R>(f: impl FnOnce(&mut Runtime) -> R) -> R {
295    RT.with(|rt| f(&mut rt.borrow_mut()))
296}
297
298// ---------------------------------------------------------------------------
299// Graph internals
300// ---------------------------------------------------------------------------
301
302#[track_caller]
303fn create_node(rt: &mut Runtime, kind: NodeKind, scope: ScopeKey) -> NodeKey {
304    let depth = rt.scopes.get(scope).map(|s| s.depth).unwrap_or(0);
305    let seq = rt.next_seq;
306    rt.next_seq += 1;
307    let key = rt.nodes.insert(Node {
308        kind,
309        state: NodeState::Clean,
310        value: None,
311        memo_compute: None,
312        memo_eq: None,
313        reaction: None,
314        sources: Vec::new(),
315        run_cleanups: Vec::new(),
316        observers: Vec::new(),
317        scope,
318        priority: 1,
319        depth,
320        seq,
321        last_changed: 0,
322        last_run: 0,
323        queued: false,
324        created_at: Location::caller(),
325    });
326    if let Some(s) = rt.scopes.get_mut(scope) {
327        s.nodes.push(key);
328    }
329    key
330}
331
332/// Register a tracked read: current observer gains `source` as a dependency.
333fn track_read(rt: &mut Runtime, source: NodeKey) {
334    if let Some(Some(obs)) = rt.observers.last().copied() {
335        if obs == source {
336            return;
337        }
338        let already = rt.nodes[obs].sources.contains(&source);
339        if !already {
340            rt.nodes[obs].sources.push(source);
341            rt.nodes[source].observers.push(obs);
342        }
343    }
344}
345
346/// Unsubscribe `key` from its sources and hand back its run cleanups. The caller must run the
347/// returned closures after releasing the runtime borrow: they call into consumers (day-model
348/// claim release disposes trigger scopes) that re-enter the runtime.
349#[must_use]
350fn clear_sources(rt: &mut Runtime, key: NodeKey) -> Vec<Box<dyn FnOnce()>> {
351    let sources = std::mem::take(&mut rt.nodes[key].sources);
352    for s in sources {
353        if let Some(n) = rt.nodes.get_mut(s)
354            && let Some(pos) = n.observers.iter().position(|&o| o == key)
355        {
356            n.observers.swap_remove(pos);
357        }
358    }
359    std::mem::take(&mut rt.nodes[key].run_cleanups)
360}
361
362/// Mark downstream after a source changed. Direct observers get `Dirty`; transitive
363/// (through memos) get `Check`. Reactions are enqueued.
364fn mark_observers(rt: &mut Runtime, source: NodeKey, level: NodeState) {
365    // Small explicit stack to avoid recursion borrow issues.
366    let mut stack: Vec<(NodeKey, NodeState)> = rt.nodes[source]
367        .observers
368        .iter()
369        .map(|&o| (o, level))
370        .collect();
371    while let Some((key, level)) = stack.pop() {
372        let Some(node) = rt.nodes.get_mut(key) else {
373            continue;
374        };
375        if node.state >= level {
376            continue; // already at least this dirty
377        }
378        node.state = level;
379        match node.kind {
380            NodeKind::Reaction => {
381                if !node.queued {
382                    node.queued = true;
383                    rt.pending.push(key);
384                }
385            }
386            NodeKind::Memo => {
387                for &o in rt.nodes[key].observers.iter() {
388                    stack.push((o, NodeState::Check));
389                }
390            }
391            NodeKind::Signal => {}
392        }
393    }
394}
395
396/// Pull-refresh a memo: recompute if (transitively) dirty; bump `last_changed` only on real change.
397fn refresh_memo(key: NodeKey) {
398    let (state, kind) = match with_rt(|rt| rt.nodes.get(key).map(|n| (n.state, n.kind))) {
399        Some(v) => v,
400        None => return,
401    };
402    if kind != NodeKind::Memo || state == NodeState::Clean {
403        return;
404    }
405    if state == NodeState::Check {
406        // Refresh sources; only recompute if one actually changed since our last run.
407        let (sources, last_run) =
408            with_rt(|rt| (rt.nodes[key].sources.clone(), rt.nodes[key].last_run));
409        let mut any_changed = false;
410        for s in sources {
411            refresh_memo(s);
412            if with_rt(|rt| {
413                rt.nodes
414                    .get(s)
415                    .map(|n| n.last_changed > last_run)
416                    .unwrap_or(false)
417            }) {
418                any_changed = true;
419            }
420        }
421        if !any_changed {
422            with_rt(|rt| {
423                if let Some(n) = rt.nodes.get_mut(key) {
424                    n.state = NodeState::Clean;
425                }
426            });
427            return;
428        }
429    }
430    // Recompute.
431    let compute = with_rt(|rt| rt.nodes[key].memo_compute.clone());
432    let Some(compute) = compute else { return };
433    let cleanups = with_rt(|rt| clear_sources(rt, key));
434    for f in cleanups {
435        f();
436    }
437    with_rt(|rt| rt.observers.push(Some(key)));
438    let new_value = {
439        // Popped on unwind too; see `run_reaction`.
440        let _g = RtGuard(|rt: &mut Runtime| {
441            rt.observers.pop();
442        });
443        compute()
444    };
445    with_rt(|rt| {
446        let tick = rt.tick;
447        let node = &mut rt.nodes[key];
448        let changed = match (node.value.as_ref(), node.memo_eq) {
449            (Some(old), Some(eq)) => {
450                // A reader may be holding this cell; compare against it without disturbing them.
451                let old = old.borrow();
452                !eq(old.as_ref(), new_value.as_ref())
453            }
454            _ => true,
455        };
456        node.last_run = tick;
457        node.state = NodeState::Clean;
458        if changed {
459            // Install a new cell rather than writing through the old one: a read in flight keeps
460            // reading the value it borrowed, for the duration of its closure, instead of panicking.
461            node.value = Some(Rc::new(RefCell::new(new_value)));
462            node.last_changed = tick;
463            rt.tick += 1;
464        }
465        // Downstream was already marked Check when we were invalidated; observers that pull us
466        // will see last_changed. Nothing further to do here.
467    });
468}
469
470/// Run one reaction if it is actually stale.
471fn run_reaction(key: NodeKey) {
472    let info = with_rt(|rt| {
473        rt.nodes.get_mut(key).map(|n| {
474            n.queued = false;
475            (n.state, n.sources.clone(), n.last_run)
476        })
477    });
478    let Some((state, sources, last_run)) = info else {
479        return;
480    };
481    if state == NodeState::Clean {
482        return;
483    }
484    if state == NodeState::Check {
485        let mut any_changed = false;
486        for s in &sources {
487            refresh_memo(*s);
488            if with_rt(|rt| {
489                rt.nodes
490                    .get(*s)
491                    .map(|n| n.last_changed > last_run)
492                    .unwrap_or(false)
493            }) {
494                any_changed = true;
495                break;
496            }
497        }
498        if !any_changed {
499            with_rt(|rt| {
500                if let Some(n) = rt.nodes.get_mut(key) {
501                    n.state = NodeState::Clean;
502                }
503            });
504            return;
505        }
506    }
507    let reaction = with_rt(|rt| rt.nodes.get(key).and_then(|n| n.reaction.clone()));
508    let Some(reaction) = reaction else { return };
509    let cleanups = with_rt(|rt| {
510        let cleanups = clear_sources(rt, key);
511        if let Some(n) = rt.nodes.get_mut(key) {
512            n.state = NodeState::Clean;
513        }
514        cleanups
515    });
516    // Released claims must drop before the fresh run tracks, and outside the borrow.
517    for f in cleanups {
518        f();
519    }
520    with_rt(|rt| rt.observers.push(Some(key)));
521    {
522        // Popped on unwind too (same rule as `untrack`): a stranded frame would attribute
523        // every later read to this dead run, even through a user's own `catch_unwind`.
524        let _g = RtGuard(|rt: &mut Runtime| {
525            rt.observers.pop();
526        });
527        reaction();
528    }
529    with_rt(|rt| {
530        let tick = rt.tick;
531        rt.tick += 1;
532        if let Some(n) = rt.nodes.get_mut(key) {
533            n.last_run = tick;
534        }
535    });
536}
537
538/// Drain the pending queue to fixpoint, then run turn-end callbacks (§3.3 steps 2–3),
539/// repeating both until the turn is quiescent. A turn-end callback may schedule new
540/// reactive work (day-persistence's autosave flush re-derives live queries and diffs the
541/// answers into signals); that work belongs to this turn: §3.3 step 2's "writes made
542/// during the drain extend the current drain", extended across the turn-end boundary.
543/// Left pending instead, every cross-row readout (a query-fed list, a derived bound)
544/// would render one turn stale. Round two drains the deltas and re-runs the hooks, and
545/// autosave then finds nothing dirty, so a settled app exits after two rounds;
546/// The turn-round cap stops turn-end hooks that feed each other forever.
547pub fn flush_sync() {
548    let already = with_rt(|rt| {
549        if rt.draining {
550            return true;
551        }
552        rt.draining = true;
553        false
554    });
555    if already {
556        return; // re-entrant flush folds into the current drain
557    }
558    let mut round = 0u32;
559    'turn: loop {
560        round += 1;
561        // Fresh per round: the re-run cap is per drain (§4.2), and each round is one drain.
562        let mut run_counts: HashMap<NodeKey, u32> = HashMap::new();
563        loop {
564            let mut batch = with_rt(|rt| std::mem::take(&mut rt.pending));
565            if batch.is_empty() {
566                break;
567            }
568            // (priority, scope-depth, creation-seq): owners before descendants.
569            with_rt(|rt| {
570                batch.sort_by_key(|&k| {
571                    rt.nodes
572                        .get(k)
573                        .map(|n| (n.priority, n.depth, n.seq))
574                        .unwrap_or((u8::MAX, u32::MAX, u64::MAX))
575                })
576            });
577            for key in batch {
578                let count = run_counts.entry(key).or_insert(0);
579                *count += 1;
580                if *count > RERUN_CAP {
581                    if *count == RERUN_CAP + 1 {
582                        let loc = with_rt(|rt| rt.nodes.get(key).map(|n| n.created_at));
583                        if let Some(loc) = loc {
584                            if cfg!(debug_assertions) {
585                                panic!(
586                                    "day-reactive: effect created at {loc} re-ran more than {RERUN_CAP} times in one drain (reactive cycle?)"
587                                );
588                            } else {
589                                log::warn!(
590                                    "day-reactive: effect created at {loc} exceeded the re-run cap; skipping it for the rest of this drain (the next source write re-arms it)"
591                                );
592                            }
593                        }
594                    }
595                    // Break the cycle but leave the node runnable. This key was drained out of
596                    // `pending` above with `queued` still true; without the reset here,
597                    // `mark_observers` would never enqueue it again and the binding would be
598                    // silently dead for the rest of the process. Clean + unqueued means the next
599                    // source write re-marks and re-enqueues it with a fresh per-drain budget.
600                    with_rt(|rt| {
601                        if let Some(n) = rt.nodes.get_mut(key) {
602                            n.queued = false;
603                            n.state = NodeState::Clean;
604                        }
605                    });
606                    continue;
607                }
608                run_reaction(key);
609            }
610        }
611        let (once, turn_end) = with_rt(|rt| {
612            rt.draining = false;
613            rt.schedule_posted = false;
614            (std::mem::take(&mut rt.turn_end_once), rt.turn_end.clone())
615        });
616        // One-shot work first: it settles what this turn changed (a toolbar composed once from
617        // everything the turn contributed), which the standing hooks (layout) then see.
618        for cb in once {
619            cb();
620        }
621        for cb in turn_end {
622            cb();
623        }
624        // Quiescent, or a nested flush inside a callback already settled everything.
625        let more = with_rt(|rt| {
626            if rt.pending.is_empty() {
627                return false;
628            }
629            rt.draining = true;
630            true
631        });
632        if !more {
633            return;
634        }
635        if round >= TURN_ROUND_CAP {
636            // The callbacks' first write posted a scheduled drain (draining was false
637            // then), so the deferred work still runs next turn, the same recovery shape as
638            // the re-run cap.
639            with_rt(|rt| rt.draining = false);
640            if cfg!(debug_assertions) {
641                panic!(
642                    "day-reactive: turn-end callbacks scheduled new reactive work for {TURN_ROUND_CAP} straight rounds in one turn (hooks feeding each other?)"
643                );
644            } else {
645                log::warn!(
646                    "day-reactive: turn-end callbacks still scheduling work after {TURN_ROUND_CAP} rounds; deferring the rest to the next turn"
647                );
648            }
649            return;
650        }
651        continue 'turn;
652    }
653}
654
655/// Reset the runtime to a clean idle state after a panic unwound through a drain or batch,
656/// e.g. a reactive-cycle assertion (the rerun cap) that tripped inside a native event callback
657/// which the backend *contained* (rather than letting it abort the process across the C ABI; a
658/// GTK/Qt signal trampoline can't unwind). The in-flight `pending` work and the observer stack
659/// are dropped (the next interaction re-derives them); persistent registrations (effects, memos,
660/// turn-end hooks) are kept.
661pub fn recover_from_panic() {
662    with_rt(|rt| {
663        rt.draining = false;
664        rt.schedule_posted = false;
665        rt.batch_depth = 0;
666        rt.turn_end_once.clear();
667        rt.pending.clear();
668        rt.observers.clear();
669        // The scope stack is unwound by `Scope::enter`'s guard, but a panic raised between
670        // scopes (or from a callsite that set `current_scope` directly) can still leave it on a
671        // disposed scope, where every later `Signal::new` would be born dead. Re-root it.
672        rt.current_scope = rt.root_scope;
673    });
674}
675
676/// After a write: schedule work. Inside a batch or drain, the fixpoint picks it up; outside,
677/// post a coalesced drain through the installed scheduler (§3.3 step 3).
678fn schedule_after_write(rt: &mut Runtime) -> Option<Rc<dyn Fn()>> {
679    if rt.batch_depth > 0 || rt.draining {
680        return None;
681    }
682    if rt.schedule_posted {
683        return None;
684    }
685    rt.schedule_posted = true;
686    rt.scheduler.clone()
687}
688
689fn signal_write_boxed(key: NodeKey, apply: impl FnOnce(&mut Box<dyn Any>) -> bool) {
690    let poster = with_rt(|rt| {
691        let node = rt.nodes.get_mut(key)?;
692        let cell = node.value.as_ref()?.clone();
693        let mut value = match cell.try_borrow_mut() {
694            Ok(v) => v,
695            // A write to a signal from inside its own `with`/`try_with` closure. Before the value
696            // was shared this silently did nothing (the read's restore clobbered the write), so
697            // say it plainly instead of losing the update.
698            Err(_) => panic!(
699                "day-reactive: wrote to a Signal while a with()/try_with() read of it is still \
700                 in flight — the write would be lost. Finish the read closure first (copy the \
701                 value out with get()) and write after it returns."
702            ),
703        };
704        let changed = apply(&mut value);
705        drop(value);
706        let node = rt.nodes.get_mut(key)?;
707        if !changed {
708            return None;
709        }
710        node.last_changed = rt.tick;
711        rt.tick += 1;
712        mark_observers(rt, key, NodeState::Dirty);
713        schedule_after_write(rt)
714    });
715    if let Some(post) = poster {
716        post();
717    }
718}
719
720// ---------------------------------------------------------------------------
721// Public: batching / scheduling / turn end
722// ---------------------------------------------------------------------------
723
724/// Run `f` in a batch: writes coalesce; the synchronous fixpoint drain runs at batch close.
725/// Restores a piece of runtime state when it goes out of scope, on the normal path and on an
726/// unwind. day-core contains panics at its trampoline boundaries (`pump_events`, posted tasks,
727/// lifecycle) so a panicking app callback degrades the UI instead of aborting the process; that
728/// guarantee is only worth anything if the reactive runtime is still coherent afterwards.
729/// Restoring after `f()` returns is not enough, because that line is skipped on unwind.
730struct RtGuard<F: FnMut(&mut Runtime)>(F);
731
732impl<F: FnMut(&mut Runtime)> Drop for RtGuard<F> {
733    fn drop(&mut self) {
734        with_rt(|rt| (self.0)(rt));
735    }
736}
737
738pub fn batch<R>(f: impl FnOnce() -> R) -> R {
739    with_rt(|rt| rt.batch_depth += 1);
740    let r = {
741        // Runs on unwind too, so a contained panic can't strand `batch_depth` above zero, which
742        // would stop every later write from scheduling a drain (a silently frozen UI). Saturating:
743        // if `recover_from_panic` already zeroed the depth mid-unwind, a plain `-= 1` here would
744        // underflow: a panic inside this Drop, which is a process abort.
745        let _g = RtGuard(|rt: &mut Runtime| rt.batch_depth = rt.batch_depth.saturating_sub(1));
746        f()
747    };
748    let should_drain = with_rt(|rt| {
749        rt.batch_depth == 0
750            && !rt.draining
751            && (!rt.pending.is_empty() || !rt.turn_end_once.is_empty())
752    });
753    if should_drain {
754        flush_sync();
755    }
756    r
757}
758
759/// Force a synchronous fixpoint drain **now**, even inside an open [`batch`]. Event dispatch wraps
760/// handlers in a batch (day-core), so a handler that needs its writes to drain immediately
761/// (namely `with_animation`, whose ambient animation must be live *while* the resulting patches
762/// apply) cannot rely on the batch's close (that runs later, after the scope ends). This
763/// temporarily drops the batch depth so [`flush_sync`] runs, then restores it. No-op if a drain
764/// is already in progress (the writes fold into it); harmless if nothing is pending.
765pub fn flush_now() {
766    let saved = with_rt(|rt| std::mem::replace(&mut rt.batch_depth, 0));
767    // Restored on unwind too: the debug rerun-cap panic can cross here under an event-dispatch
768    // batch, and skipping the restore would hand the enclosing batch guard a zero depth.
769    let _g = RtGuard(move |rt: &mut Runtime| rt.batch_depth = saved);
770    flush_sync();
771}
772
773/// Run `f` without tracking reads.
774pub fn untrack<R>(f: impl FnOnce() -> R) -> R {
775    with_rt(|rt| rt.observers.push(None));
776    // Popped on unwind too: a stranded `None` observer would silently disable dependency
777    // tracking for every read that followed, so nothing would ever update again.
778    let _g = RtGuard(|rt: &mut Runtime| {
779        rt.observers.pop();
780    });
781    f()
782}
783
784/// The identity of one reactive computation (a reaction or a memo) across its lifetime.
785/// Opaque; `Copy + Eq + Hash`, so an external dependency registry can key per-computation
786/// bookkeeping on it. Slotmap-generational underneath: a recycled slot is a new `RunId`.
787#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
788pub struct RunId(NodeKey);
789
790/// The computation currently tracking reads: `None` at top level and under [`untrack`].
791pub fn active_run() -> Option<RunId> {
792    with_rt(|rt| rt.observers.last().copied().flatten()).map(RunId)
793}
794
795/// Hang `f` on the active computation's current run: it fires when that computation next
796/// re-tracks (a re-run clears its sources first) or is disposed. This is the hook that lets an
797/// external dependency registry (day-model's trigger claims) mirror this graph's per-run
798/// source bookkeeping. Returns `false`, registering nothing, when no computation is tracking.
799pub fn on_run_retrack(f: impl FnOnce() + 'static) -> bool {
800    with_rt(|rt| {
801        let Some(Some(key)) = rt.observers.last().copied() else {
802            return false;
803        };
804        match rt.nodes.get_mut(key) {
805            Some(n) => {
806                n.run_cleanups.push(Box::new(f));
807                true
808            }
809            None => false,
810        }
811    })
812}
813
814/// Install "post a drain on the main loop". Backends call this once at startup.
815pub fn install_scheduler(post: impl Fn() + 'static) {
816    with_rt(|rt| rt.scheduler = Some(Rc::new(post)));
817}
818
819/// A boxed, `!Send` future for the [`install_spawner`] executor door.
820pub type LocalBoxFuture = Pin<Box<dyn Future<Output = ()> + 'static>>;
821
822/// The installed spawner: runs a future on the main-loop executor, returns its abort closure.
823type Spawner = Rc<dyn Fn(LocalBoxFuture) -> Box<dyn FnOnce()>>;
824
825/// Install the async-spawn door: `spawn` runs a future on the app's main-loop executor and
826/// returns an abort closure (remove + drop the future). The abort must be a no-op once the
827/// task has completed: [`Resource`] stores it after an eager first poll, so a synchronously
828/// ready future has already finished by then. day-core's `launch_with` wires this to
829/// `day::task` / `TaskHandle::abort`; call it once at startup (docs/async.md).
830pub fn install_spawner(spawn: impl Fn(LocalBoxFuture) -> Box<dyn FnOnce()> + 'static) {
831    with_rt(|rt| rt.spawner = Some(Rc::new(spawn)));
832}
833
834/// Spawn through the installed door ([`install_spawner`]); the [`on_main`] panic model.
835fn spawn_local(fut: LocalBoxFuture) -> Box<dyn FnOnce()> {
836    let spawner = with_rt(|rt| rt.spawner.clone());
837    match spawner {
838        Some(s) => s(fut),
839        None => panic!("day-reactive: no spawner installed (backend not started)"),
840    }
841}
842
843/// Register a callback run once after every fixpoint drain (day-core's layout turn).
844pub fn on_turn_end(cb: impl Fn() + 'static) {
845    with_rt(|rt| rt.turn_end.push(Rc::new(cb)));
846}
847
848/// Run `cb` once, when the current turn settles: after the drain inside a drain or a batch
849/// (even a batch that leaves no reactive work behind), or right away when no turn is open.
850///
851/// This is how a consumer coalesces a burst of changes into one pass. Swapping a page
852/// withdraws the old page's toolbar items and contributes the new page's within one turn, and
853/// only the settled result is worth handing to the toolkit.
854pub fn at_turn_end(cb: impl FnOnce() + 'static) {
855    let now = with_rt(|rt| {
856        if rt.draining || rt.batch_depth > 0 {
857            rt.turn_end_once.push(Box::new(cb));
858            None
859        } else {
860            Some(cb)
861        }
862    });
863    if let Some(cb) = now {
864        cb();
865    }
866}
867
868// ---------------------------------------------------------------------------
869// Cross-thread: the main poster + Setter
870// ---------------------------------------------------------------------------
871
872type MainPoster = Box<dyn Fn(Box<dyn FnOnce() + Send>) + Send + Sync>;
873static MAIN_POSTER: std::sync::OnceLock<MainPoster> = std::sync::OnceLock::new();
874
875/// Install the cross-thread → main-thread door. Backends call this once at startup.
876pub fn install_main_poster(post: impl Fn(Box<dyn FnOnce() + Send>) + Send + Sync + 'static) {
877    let _ = MAIN_POSTER.set(Box::new(post));
878}
879
880/// Whether a backend has installed the main poster yet, i.e. whether [`on_main`] would work
881/// rather than panic. Platform glue that can be called before launch (a notification tap that
882/// cold-starts the process) probes this and buffers instead of posting. The poster is set once
883/// and never cleared, so a `false` can only become `true`: a caller that buffers on a false
884/// negative is still correct, provided something drains the buffer at launch.
885pub fn has_main_poster() -> bool {
886    MAIN_POSTER.get().is_some()
887}
888
889/// Schedule `f` on the UI thread (usable from any thread once a backend installed the poster).
890pub fn on_main(f: impl FnOnce() + Send + 'static) {
891    match MAIN_POSTER.get() {
892        Some(post) => post(Box::new(f)),
893        None => panic!("day-reactive: no main poster installed (backend not started)"),
894    }
895}
896
897type DelayedPoster = Box<dyn Fn(u32, Box<dyn FnOnce() + Send>) + Send + Sync>;
898static DELAYED_POSTER: std::sync::OnceLock<DelayedPoster> = std::sync::OnceLock::new();
899
900/// Install the timer door (`Platform::post_delayed`). Backends call this once at startup.
901pub fn install_delayed_poster(
902    post: impl Fn(u32, Box<dyn FnOnce() + Send>) + Send + Sync + 'static,
903) {
904    let _ = DELAYED_POSTER.set(Box::new(post));
905}
906
907/// Schedule `f` on the UI thread after (at least) `ms` milliseconds, the rail behind
908/// `day::sleep` (docs/async.md).
909pub fn on_main_delayed(ms: u32, f: impl FnOnce() + Send + 'static) {
910    match DELAYED_POSTER.get() {
911        Some(post) => post(ms, Box::new(f)),
912        None => panic!("day-reactive: no delayed poster installed (backend not started)"),
913    }
914}
915
916// ---------------------------------------------------------------------------
917// Scope
918// ---------------------------------------------------------------------------
919
920/// Ownership scope for signals/effects (§4.3). `Copy` handle; not `Send`.
921#[derive(Clone, Copy, PartialEq, Eq)]
922pub struct Scope {
923    key: ScopeKey,
924    _not_send: PhantomData<*const ()>,
925}
926
927impl Scope {
928    pub fn current() -> Scope {
929        Scope {
930            key: with_rt(|rt| rt.current_scope),
931            _not_send: PhantomData,
932        }
933    }
934
935    pub fn root() -> Scope {
936        Scope {
937            key: with_rt(|rt| rt.root_scope),
938            _not_send: PhantomData,
939        }
940    }
941
942    /// Create a child of the current scope.
943    pub fn child() -> Scope {
944        Scope::current().create_child()
945    }
946
947    pub fn create_child(self) -> Scope {
948        let key = with_rt(|rt| {
949            let depth = rt.scopes.get(self.key).map(|s| s.depth + 1).unwrap_or(1);
950            let child = rt.scopes.insert(ScopeData {
951                parent: self.key,
952                children: Vec::new(),
953                nodes: Vec::new(),
954                cleanups: Vec::new(),
955                context: HashMap::new(),
956                depth,
957            });
958            if let Some(p) = rt.scopes.get_mut(self.key) {
959                p.children.push(child);
960            }
961            child
962        });
963        Scope {
964            key,
965            _not_send: PhantomData,
966        }
967    }
968
969    /// A scope owned by nobody; dispose it manually.
970    pub fn detached() -> Scope {
971        let key = with_rt(|rt| {
972            rt.scopes.insert(ScopeData {
973                parent: ScopeKey::null(),
974                children: Vec::new(),
975                nodes: Vec::new(),
976                cleanups: Vec::new(),
977                context: HashMap::new(),
978                depth: 0,
979            })
980        });
981        Scope {
982            key,
983            _not_send: PhantomData,
984        }
985    }
986
987    /// Run `f` with `self` as the current scope.
988    pub fn enter<R>(self, f: impl FnOnce() -> R) -> R {
989        let prev = with_rt(|rt| std::mem::replace(&mut rt.current_scope, self.key));
990        // Restored on unwind too. Otherwise a contained panic leaves the runtime parented to
991        // this (usually transient, soon-disposed) scope, so every later `Signal::new` is created
992        // dead and its first read panics with a misleading "read of disposed Signal".
993        let _g = RtGuard(move |rt: &mut Runtime| rt.current_scope = prev);
994        f()
995    }
996
997    pub fn on_cleanup(self, f: impl FnOnce() + 'static) {
998        with_rt(|rt| {
999            if let Some(s) = rt.scopes.get_mut(self.key) {
1000                s.cleanups.push(Box::new(f));
1001            }
1002        });
1003    }
1004
1005    pub fn is_alive(self) -> bool {
1006        with_rt(|rt| rt.scopes.contains_key(self.key))
1007    }
1008
1009    /// Dispose this scope: children first, then own nodes (unsubscribed + dropped) and cleanups.
1010    pub fn dispose(self) {
1011        let children = match with_rt(|rt| rt.scopes.get(self.key).map(|s| s.children.clone())) {
1012            Some(c) => c,
1013            None => return,
1014        };
1015        for c in children {
1016            (Scope {
1017                key: c,
1018                _not_send: PhantomData,
1019            })
1020            .dispose();
1021        }
1022        let (nodes, cleanups, parent) = match with_rt(|rt| {
1023            rt.scopes
1024                .remove(self.key)
1025                .map(|s| (s.nodes, s.cleanups, s.parent))
1026        }) {
1027            Some(v) => v,
1028            None => return,
1029        };
1030        let mut run_cleanups: Vec<Box<dyn FnOnce()>> = Vec::new();
1031        with_rt(|rt| {
1032            if let Some(p) = rt.scopes.get_mut(parent)
1033                && let Some(pos) = p.children.iter().position(|&c| c == self.key)
1034            {
1035                p.children.swap_remove(pos);
1036            }
1037            for key in nodes {
1038                run_cleanups.extend(clear_sources(rt, key));
1039                // Detach us from downstream observers too.
1040                if let Some(node) = rt.nodes.get_mut(key) {
1041                    let observers = std::mem::take(&mut node.observers);
1042                    for o in observers {
1043                        if let Some(on) = rt.nodes.get_mut(o)
1044                            && let Some(pos) = on.sources.iter().position(|&s| s == key)
1045                        {
1046                            on.sources.swap_remove(pos);
1047                        }
1048                    }
1049                }
1050                rt.nodes.remove(key);
1051                // Pending entries for removed nodes are skipped at pop (generational key check).
1052            }
1053        });
1054        // A dying computation's per-run claims drop like a re-track would drop them: outside
1055        // the borrow, before the scope's own cleanups (which may release scope-level claims).
1056        for f in run_cleanups {
1057            f();
1058        }
1059        for c in cleanups {
1060            c();
1061        }
1062    }
1063
1064    /// Provide a context value visible to this scope and its descendants.
1065    pub fn provide<T: 'static>(self, value: T) {
1066        with_rt(|rt| {
1067            if let Some(s) = rt.scopes.get_mut(self.key) {
1068                s.context.insert(TypeId::of::<T>(), Box::new(value));
1069            }
1070        });
1071    }
1072
1073    /// Look up a context value here or in any ancestor (requires `T: Clone`).
1074    pub fn use_context<T: Clone + 'static>(self) -> Option<T> {
1075        with_rt(|rt| {
1076            let mut cur = self.key;
1077            while let Some(s) = rt.scopes.get(cur) {
1078                if let Some(v) = s.context.get(&TypeId::of::<T>()) {
1079                    return v.downcast_ref::<T>().cloned();
1080                }
1081                cur = s.parent;
1082            }
1083            None
1084        })
1085    }
1086}
1087
1088// ---------------------------------------------------------------------------
1089// Signal
1090// ---------------------------------------------------------------------------
1091
1092/// A `Copy`, `!Send` reactive value handle (§4.2).
1093pub struct Signal<T: 'static> {
1094    key: NodeKey,
1095    created_at: &'static Location<'static>,
1096    _m: PhantomData<*const T>,
1097}
1098
1099impl<T: 'static> Clone for Signal<T> {
1100    fn clone(&self) -> Self {
1101        *self
1102    }
1103}
1104impl<T: 'static> Copy for Signal<T> {}
1105
1106impl<T: 'static> std::fmt::Debug for Signal<T> {
1107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1108        write!(f, "Signal({:?})", self.key)
1109    }
1110}
1111
1112impl<T: 'static> Signal<T> {
1113    #[track_caller]
1114    pub fn new(value: T) -> Self {
1115        Self::new_in(Scope::current(), value)
1116    }
1117
1118    /// A process-global signal: allocated in the root scope regardless of where the call
1119    /// runs, so it never dies with a transient caller. **Every lazily-initialized global
1120    /// registry must use this, not [`Signal::new`]**, because a lazy global first touched inside a
1121    /// presented cover / pushed page / `when` arm otherwise inherits that scope and is
1122    /// disposed with it, and every later read panics (the day-localization locale signal was the
1123    /// observed case).
1124    #[track_caller]
1125    pub fn global(value: T) -> Self {
1126        Self::new_in(Scope::root(), value)
1127    }
1128
1129    #[track_caller]
1130    pub fn new_in(scope: Scope, value: T) -> Self {
1131        let created_at = Location::caller();
1132        let key = with_rt(|rt| {
1133            let k = create_node(rt, NodeKind::Signal, scope.key);
1134            rt.nodes[k].value = Some(Rc::new(RefCell::new(Box::new(value) as Box<dyn Any>)));
1135            rt.nodes[k].created_at = created_at;
1136            k
1137        });
1138        Signal {
1139            key,
1140            created_at,
1141            _m: PhantomData,
1142        }
1143    }
1144
1145    /// Tracked read by reference.
1146    #[track_caller]
1147    pub fn with<R>(self, f: impl FnOnce(&T) -> R) -> R {
1148        match self.try_with(f) {
1149            Some(r) => r,
1150            None => panic!(
1151                "day-reactive: read of disposed Signal created at {} (use try_with in closures that can outlive their scope)",
1152                self.created_at
1153            ),
1154        }
1155    }
1156
1157    pub fn try_with<R>(self, f: impl FnOnce(&T) -> R) -> Option<R> {
1158        // Clone the shared cell out so `f` runs with the runtime borrow released (it routinely
1159        // reads other signals). The value stays in the node throughout, so a re-entrant read of
1160        // This signal inside `f` succeeds instead of reporting a disposed node, and a panic in
1161        // `f` cannot strand it.
1162        let cell = with_rt(|rt| {
1163            track_read(rt, self.key);
1164            rt.nodes.get(self.key).and_then(|n| n.value.clone())
1165        })?;
1166        let value = cell.borrow();
1167        value.downcast_ref::<T>().map(f)
1168    }
1169
1170    pub fn with_untracked<R>(self, f: impl FnOnce(&T) -> R) -> R {
1171        untrack(|| self.with(f))
1172    }
1173
1174    #[track_caller]
1175    pub fn get(self) -> T
1176    where
1177        T: Clone,
1178    {
1179        self.with(|v| v.clone())
1180    }
1181
1182    pub fn try_get(self) -> Option<T>
1183    where
1184        T: Clone,
1185    {
1186        self.try_with(|v| v.clone())
1187    }
1188
1189    pub fn get_untracked(self) -> T
1190    where
1191        T: Clone,
1192    {
1193        untrack(|| self.get())
1194    }
1195
1196    /// Mark-only tracked read (subscribe without reading).
1197    pub fn track(self) {
1198        with_rt(|rt| track_read(rt, self.key));
1199    }
1200
1201    #[track_caller]
1202    pub fn set(self, value: T) {
1203        self.write_check(move |slot| {
1204            *slot = value;
1205            true
1206        });
1207    }
1208
1209    #[track_caller]
1210    pub fn set_if_changed(self, value: T)
1211    where
1212        T: PartialEq,
1213    {
1214        self.write_check(move |slot| {
1215            if *slot == value {
1216                false
1217            } else {
1218                *slot = value;
1219                true
1220            }
1221        });
1222    }
1223
1224    #[track_caller]
1225    pub fn update(self, f: impl FnOnce(&mut T)) {
1226        self.write_check(move |slot| {
1227            f(slot);
1228            true
1229        });
1230    }
1231
1232    #[track_caller]
1233    fn write_check(self, apply: impl FnOnce(&mut T) -> bool) {
1234        let alive = with_rt(|rt| rt.nodes.contains_key(self.key));
1235        if !alive {
1236            // Writes to disposed handles are defined no-ops (§4.3), warned once per callsite.
1237            let loc = Location::caller();
1238            let first = with_rt(|rt| rt.warned_writes.insert(loc as *const _));
1239            if first && cfg!(debug_assertions) {
1240                log::warn!(
1241                    "day-reactive: write at {loc} to a disposed signal (created at {}) ignored",
1242                    self.created_at
1243                );
1244            }
1245            return;
1246        }
1247        signal_write_boxed(self.key, |boxed| match boxed.downcast_mut::<T>() {
1248            Some(slot) => apply(slot),
1249            None => false,
1250        });
1251    }
1252
1253    /// A `Send` write-only handle (§3.3).
1254    pub fn setter(self) -> Setter<T>
1255    where
1256        T: Send,
1257    {
1258        Setter {
1259            key: self.key,
1260            _m: PhantomData,
1261        }
1262    }
1263}
1264
1265/// `Send` write-only handle to a signal; delivery hops to the UI thread via the main poster.
1266/// Writes after disposal are silent no-ops (§4.3).
1267pub struct Setter<T: Send + 'static> {
1268    key: NodeKey,
1269    _m: PhantomData<fn(T)>,
1270}
1271
1272impl<T: Send + 'static> Clone for Setter<T> {
1273    fn clone(&self) -> Self {
1274        *self
1275    }
1276}
1277impl<T: Send + 'static> Copy for Setter<T> {}
1278
1279impl<T: Send + 'static> Setter<T> {
1280    pub fn set(self, value: T) {
1281        let key = self.key;
1282        on_main(move || {
1283            let poster = with_rt(|rt| {
1284                let cell = rt.nodes.get(key)?.value.clone()?;
1285                {
1286                    let mut boxed = cell.try_borrow_mut().ok()?;
1287                    let slot = boxed.downcast_mut::<T>()?;
1288                    *slot = value;
1289                }
1290                let node = rt.nodes.get_mut(key)?;
1291                node.last_changed = rt.tick;
1292                rt.tick += 1;
1293                mark_observers(rt, key, NodeState::Dirty);
1294                schedule_after_write(rt)
1295            });
1296            if let Some(post) = poster {
1297                post();
1298            }
1299        });
1300    }
1301}
1302
1303// ---------------------------------------------------------------------------
1304// Memo
1305// ---------------------------------------------------------------------------
1306
1307/// Cached, equality-diffed derived value (§4.2).
1308pub struct Memo<T: 'static> {
1309    key: NodeKey,
1310    created_at: &'static Location<'static>,
1311    _m: PhantomData<*const T>,
1312}
1313
1314impl<T: 'static> Clone for Memo<T> {
1315    fn clone(&self) -> Self {
1316        *self
1317    }
1318}
1319impl<T: 'static> Copy for Memo<T> {}
1320
1321impl<T: 'static> Memo<T> {
1322    #[track_caller]
1323    pub fn new(f: impl Fn() -> T + 'static) -> Self
1324    where
1325        T: PartialEq,
1326    {
1327        Self::new_with_eq(f, |a, b| a == b)
1328    }
1329
1330    #[track_caller]
1331    pub fn new_with_eq(f: impl Fn() -> T + 'static, eq: fn(&T, &T) -> bool) -> Self {
1332        let created_at = Location::caller();
1333        // The value is stored as EqBox<T> (value + comparator together), so diffing needs no
1334        // specialization and the arena-level eq is a plain fn pointer (eqbox_eq::<T>).
1335        let key = with_rt(|rt| {
1336            let scope = rt.current_scope;
1337            create_node(rt, NodeKind::Memo, scope)
1338        });
1339        let compute: Rc<dyn Fn() -> Box<dyn Any>> =
1340            Rc::new(move || Box::new(EqBox { value: f(), eq }) as Box<dyn Any>);
1341        with_rt(|rt| {
1342            let node = &mut rt.nodes[key];
1343            node.memo_compute = Some(compute);
1344            node.memo_eq = Some(eqbox_eq::<T>);
1345            node.state = NodeState::Dirty; // lazy: computed on first read
1346            node.created_at = created_at;
1347        });
1348        Memo {
1349            key,
1350            created_at,
1351            _m: PhantomData,
1352        }
1353    }
1354
1355    #[track_caller]
1356    pub fn with<R>(self, f: impl FnOnce(&T) -> R) -> R {
1357        refresh_memo(self.key);
1358        let value = with_rt(|rt| {
1359            track_read(rt, self.key);
1360            rt.nodes.get(self.key).and_then(|n| n.value.clone())
1361        });
1362        match value {
1363            // Shared, like `Signal::try_with`: the memo stays readable while `f` runs.
1364            Some(cell) => {
1365                let boxed = cell.borrow();
1366                boxed
1367                    .downcast_ref::<EqBox<T>>()
1368                    .map(|e| f(&e.value))
1369                    .unwrap_or_else(|| panic!("day-reactive: memo type mismatch"))
1370            }
1371            None => panic!(
1372                "day-reactive: read of disposed Memo created at {}",
1373                self.created_at
1374            ),
1375        }
1376    }
1377
1378    #[track_caller]
1379    pub fn get(self) -> T
1380    where
1381        T: Clone,
1382    {
1383        self.with(|v| v.clone())
1384    }
1385}
1386
1387/// Value + comparator stored together so memo diffing needs no specialization.
1388struct EqBox<T> {
1389    value: T,
1390    eq: fn(&T, &T) -> bool,
1391}
1392
1393fn eqbox_eq<T: 'static>(a: &dyn Any, b: &dyn Any) -> bool {
1394    match (a.downcast_ref::<EqBox<T>>(), b.downcast_ref::<EqBox<T>>()) {
1395        (Some(a), Some(b)) => (a.eq)(&a.value, &b.value),
1396        _ => false,
1397    }
1398}
1399
1400// ---------------------------------------------------------------------------
1401// Effect / bind / watch / Trigger
1402// ---------------------------------------------------------------------------
1403
1404/// A reaction that re-runs when its tracked reads change. Runs once at creation.
1405pub struct Effect;
1406
1407impl Effect {
1408    // Constructor-style registration: the reaction is owned by the current Scope (disposed
1409    // with it), so there is no handle value to return.
1410    #[allow(clippy::new_ret_no_self)]
1411    #[track_caller]
1412    pub fn new(f: impl Fn() + 'static) {
1413        create_reaction(Rc::new(f), 1);
1414    }
1415}
1416
1417#[track_caller]
1418fn create_reaction(f: Rc<dyn Fn()>, priority: u8) -> NodeKey {
1419    let created_at = Location::caller();
1420    let key = with_rt(|rt| {
1421        let scope = rt.current_scope;
1422        let k = create_node(rt, NodeKind::Reaction, scope);
1423        rt.nodes[k].reaction = Some(f);
1424        rt.nodes[k].priority = priority;
1425        rt.nodes[k].created_at = created_at;
1426        k
1427    });
1428    // Initial run, tracked.
1429    with_rt(|rt| rt.observers.push(Some(key)));
1430    let reaction = with_rt(|rt| rt.nodes[key].reaction.clone());
1431    {
1432        // Popped on unwind too; see `run_reaction`.
1433        let _g = RtGuard(|rt: &mut Runtime| {
1434            rt.observers.pop();
1435        });
1436        if let Some(r) = reaction {
1437            r();
1438        }
1439    }
1440    with_rt(|rt| {
1441        let tick = rt.tick;
1442        rt.tick += 1;
1443        if let Some(n) = rt.nodes.get_mut(key) {
1444            n.last_run = tick;
1445        }
1446    });
1447    key
1448}
1449
1450/// The binding primitive (§4.2): compute (tracked) + apply (untracked), equality-gated.
1451/// Structural priority: bindings drain before plain effects. `apply` receives the new value
1452/// by reference so `V` needs only `PartialEq` (no `Clone`).
1453#[track_caller]
1454pub fn bind<V: PartialEq + 'static>(
1455    compute: impl Fn() -> V + 'static,
1456    apply: impl Fn(&V) + 'static,
1457) {
1458    let last: RefCell<Option<V>> = RefCell::new(None);
1459    create_reaction(
1460        Rc::new(move || {
1461            let v = compute();
1462            if last.borrow().as_ref() != Some(&v) {
1463                untrack(|| apply(&v));
1464                *last.borrow_mut() = Some(v);
1465            }
1466        }),
1467        0,
1468    );
1469}
1470
1471/// `bind` pre-seeded with the value already applied at build time: the initial run does not
1472/// re-apply (pieces pass initial values through realize props; §5.2's no-duplicate-op rule).
1473#[track_caller]
1474pub fn bind_seeded<V: PartialEq + 'static>(
1475    seed: V,
1476    compute: impl Fn() -> V + 'static,
1477    apply: impl Fn(&V) + 'static,
1478) {
1479    let last: RefCell<Option<V>> = RefCell::new(Some(seed));
1480    create_reaction(
1481        Rc::new(move || {
1482            let v = compute();
1483            if last.borrow().as_ref() != Some(&v) {
1484                untrack(|| apply(&v));
1485                *last.borrow_mut() = Some(v);
1486            }
1487        }),
1488        0,
1489    );
1490}
1491
1492/// `bind` for payloads without `PartialEq`, applying on every recompute.
1493#[track_caller]
1494pub fn bind_always<V: 'static>(compute: impl Fn() -> V + 'static, apply: impl Fn(V) + 'static) {
1495    create_reaction(
1496        Rc::new(move || {
1497            let v = compute();
1498            untrack(|| apply(v));
1499        }),
1500        0,
1501    );
1502}
1503
1504/// Derive-state without effect-write loops (§4.2): `source` is tracked; `cb` runs untracked
1505/// with (new, old). Does not fire for the initial value: a "load whatever is selected"
1506/// reaction has to run once by hand first, or use [`bind`], which applies the initial value
1507/// and then every change.
1508#[track_caller]
1509pub fn watch<S: Clone + 'static>(
1510    source: impl Fn() -> S + 'static,
1511    cb: impl Fn(&S, Option<&S>) + 'static,
1512) {
1513    let prev: RefCell<Option<S>> = RefCell::new(None);
1514    create_reaction(
1515        Rc::new(move || {
1516            let new = source();
1517            let old = prev.borrow_mut().replace(new.clone());
1518            if old.is_some() {
1519                untrack(|| cb(&new, old.as_ref()));
1520            }
1521        }),
1522        1,
1523    );
1524}
1525
1526/// Data-less invalidation source.
1527#[derive(Clone, Copy)]
1528pub struct Trigger {
1529    signal: Signal<u64>,
1530}
1531
1532impl Trigger {
1533    #[track_caller]
1534    pub fn new() -> Self {
1535        Trigger {
1536            signal: Signal::new(0),
1537        }
1538    }
1539    pub fn track(self) {
1540        self.signal.track();
1541    }
1542    pub fn notify(self) {
1543        self.signal.update(|v| *v = v.wrapping_add(1));
1544    }
1545}
1546
1547impl Default for Trigger {
1548    fn default() -> Self {
1549        Self::new()
1550    }
1551}
1552
1553// ---------------------------------------------------------------------------
1554// Two-way binding surface (§5.3)
1555// ---------------------------------------------------------------------------
1556
1557/// A readable-writable value a control can bind to two-way: a `Signal`, or any projection that
1558/// knows how to read and write its own slot (day-model's `Field` is the other implementor).
1559///
1560/// The trait lives here rather than in day-pieces so a store crate can implement it without
1561/// depending on the piece vocabulary; day-pieces re-exports it, so piece code sees no move.
1562pub trait Binding<T: 'static>: Clone + 'static {
1563    /// Tracked read: the current value, subscribing the running computation to changes.
1564    fn read(&self) -> T;
1565    /// Untracked read: the current value, subscribing nothing (the reactive world's "peek").
1566    fn peek(&self) -> T;
1567    /// Write the value, waking whatever tracks it.
1568    fn write(&self, v: T);
1569    /// A live write mid-gesture (`Event::ValueChanged`): readers must follow, but nothing
1570    /// durable should key off it. Defaults to [`Binding::write`]: a plain signal has no
1571    /// notion of "durable", and a degraded backend that never commits stays correct.
1572    fn write_preview(&self, v: T) {
1573        self.write(v);
1574    }
1575    /// The settled value that ends a gesture (`Event::ValueCommitted`). Defaults to
1576    /// [`Binding::write`]; a day-model field seals its preview session here: one change
1577    /// record, one undo unit, one UPDATE for the whole drag.
1578    fn write_commit(&self, v: T) {
1579        self.write(v);
1580    }
1581    /// Seal the last preview when its control is disposed. Persistent bindings default to
1582    /// committing; scope-owned signals have already received the preview and may be gone.
1583    fn write_teardown(&self, v: T) {
1584        self.write_commit(v);
1585    }
1586}
1587
1588impl<T: Clone + 'static> Binding<T> for Signal<T> {
1589    fn read(&self) -> T {
1590        self.get()
1591    }
1592    fn peek(&self) -> T {
1593        self.get_untracked()
1594    }
1595    fn write(&self, v: T) {
1596        self.set(v);
1597    }
1598    fn write_teardown(&self, _v: T) {}
1599}
1600
1601/// The object-safe half of [`Binding`], behind [`AnyBinding`]. Its methods carry an `erased_`
1602/// prefix so a call on a `Signal` inside this crate never sees two candidates named `read`.
1603trait ErasedBinding<T: 'static> {
1604    fn erased_read(&self) -> T;
1605    fn erased_peek(&self) -> T;
1606    fn erased_write(&self, v: T);
1607    fn erased_write_preview(&self, v: T);
1608    fn erased_write_commit(&self, v: T);
1609    fn erased_write_teardown(&self, v: T);
1610}
1611
1612impl<T: 'static, B: Binding<T>> ErasedBinding<T> for B {
1613    fn erased_read(&self) -> T {
1614        Binding::read(self)
1615    }
1616    fn erased_peek(&self) -> T {
1617        Binding::peek(self)
1618    }
1619    fn erased_write(&self, v: T) {
1620        Binding::write(self, v)
1621    }
1622    fn erased_write_preview(&self, v: T) {
1623        Binding::write_preview(self, v)
1624    }
1625    fn erased_write_commit(&self, v: T) {
1626        Binding::write_commit(self, v)
1627    }
1628    fn erased_write_teardown(&self, v: T) {
1629        Binding::write_teardown(self, v)
1630    }
1631}
1632
1633/// A type-erased [`Binding`]: what a piece stores when its builder takes `impl Binding<T>`
1634/// (docs/api-style.md "Three input spellings"), so the piece itself needs no type parameter
1635/// per bound value.
1636///
1637/// `Copy`, like [`Signal`], so it captures into closures the same way: the erased binding lives
1638/// in a slot owned by the reactive scope that created it, which is the scope building the
1639/// piece. Reads and writes forward to the binding it wraps, preview and commit included.
1640pub struct AnyBinding<T: 'static> {
1641    slot: Signal<Rc<dyn ErasedBinding<T>>>,
1642}
1643
1644impl<T: 'static> Clone for AnyBinding<T> {
1645    fn clone(&self) -> Self {
1646        *self
1647    }
1648}
1649impl<T: 'static> Copy for AnyBinding<T> {}
1650
1651impl<T: 'static> AnyBinding<T> {
1652    /// Erase `binding`. Call it where the piece is built: the slot belongs to the current scope.
1653    pub fn new(binding: impl Binding<T>) -> Self {
1654        let erased: Rc<dyn ErasedBinding<T>> = Rc::new(binding);
1655        AnyBinding {
1656            slot: Signal::new(erased),
1657        }
1658    }
1659    fn inner(self) -> Rc<dyn ErasedBinding<T>> {
1660        self.slot.get_untracked()
1661    }
1662    /// Tracked read.
1663    pub fn get(self) -> T {
1664        self.inner().erased_read()
1665    }
1666    /// Tracked read, or `None` once the scope that created the binding is disposed.
1667    pub fn try_get(self) -> Option<T> {
1668        self.slot.try_get().map(|b| b.erased_read())
1669    }
1670    /// Untracked read.
1671    pub fn get_untracked(self) -> T {
1672        self.inner().erased_peek()
1673    }
1674    /// Tracked read through a projection.
1675    pub fn with<R>(self, f: impl FnOnce(&T) -> R) -> R {
1676        let v = self.inner().erased_read();
1677        f(&v)
1678    }
1679    /// Write, waking whatever tracks the binding.
1680    pub fn set(self, v: T) {
1681        self.inner().erased_write(v)
1682    }
1683    /// Write only when the value differs from the current one.
1684    pub fn set_if_changed(self, v: T)
1685    where
1686        T: PartialEq,
1687    {
1688        let b = self.inner();
1689        if b.erased_peek() != v {
1690            b.erased_write(v);
1691        }
1692    }
1693    /// Read the current value, let `f` change it, write it back.
1694    pub fn update(self, f: impl FnOnce(&mut T)) {
1695        let b = self.inner();
1696        let mut v = b.erased_peek();
1697        f(&mut v);
1698        b.erased_write(v);
1699    }
1700}
1701
1702impl<T: 'static> Binding<T> for AnyBinding<T> {
1703    fn read(&self) -> T {
1704        self.inner().erased_read()
1705    }
1706    fn peek(&self) -> T {
1707        self.inner().erased_peek()
1708    }
1709    fn write(&self, v: T) {
1710        self.inner().erased_write(v)
1711    }
1712    fn write_preview(&self, v: T) {
1713        self.inner().erased_write_preview(v)
1714    }
1715    fn write_commit(&self, v: T) {
1716        self.inner().erased_write_commit(v)
1717    }
1718    fn write_teardown(&self, v: T) {
1719        self.inner().erased_write_teardown(v)
1720    }
1721}
1722
1723// ---------------------------------------------------------------------------
1724// Async data loading: Load + Resource (§4.5, docs/async.md)
1725// ---------------------------------------------------------------------------
1726
1727/// The lifecycle of an async-loaded value. `Failed` carries the fetcher's error as a shared
1728/// trait object so `Load` stays `Clone` for signal reads.
1729#[derive(Clone)]
1730pub enum Load<T> {
1731    Loading,
1732    Ready(T),
1733    Failed(Arc<dyn std::error::Error + Send + Sync>),
1734}
1735
1736impl<T> Load<T> {
1737    /// The loaded value, if ready.
1738    pub fn ready(&self) -> Option<&T> {
1739        match self {
1740            Load::Ready(v) => Some(v),
1741            _ => None,
1742        }
1743    }
1744    pub fn is_loading(&self) -> bool {
1745        matches!(self, Load::Loading)
1746    }
1747    pub fn is_ready(&self) -> bool {
1748        matches!(self, Load::Ready(_))
1749    }
1750    /// The failure, if the last fetch failed.
1751    pub fn error(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
1752        match self {
1753            Load::Failed(e) => Some(e.as_ref()),
1754            _ => None,
1755        }
1756    }
1757}
1758
1759impl<T: std::fmt::Debug> std::fmt::Debug for Load<T> {
1760    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1761        match self {
1762            Load::Loading => write!(f, "Loading"),
1763            Load::Ready(v) => f.debug_tuple("Ready").field(v).finish(),
1764            Load::Failed(e) => f.debug_tuple("Failed").field(&e.to_string()).finish(),
1765        }
1766    }
1767}
1768
1769/// Latest-wins bookkeeping shared by a Resource's reaction, its spawned futures, and disposal.
1770struct FetchState {
1771    generation: Cell<u64>,
1772    abort: RefCell<Option<Box<dyn FnOnce()>>>,
1773}
1774
1775impl FetchState {
1776    /// Invalidate the in-flight fetch: bump the generation first (so a completion that cannot
1777    /// be aborted still fails its check), then take-and-call the abort outside the borrow
1778    /// (dropping a future can re-enter reactive state).
1779    fn supersede(&self) -> u64 {
1780        let next = self.generation.get() + 1;
1781        self.generation.set(next);
1782        let old = self.abort.borrow_mut().take();
1783        if let Some(abort) = old {
1784            abort();
1785        }
1786        next
1787    }
1788}
1789
1790/// Declarative async data loading (§4.5): a tracked `source` whose value feeds an async
1791/// `fetcher`; the result lands in a `Signal<Load<T>>`. The source re-runs when its tracked
1792/// reads change; an unchanged source value refetches nothing, [`Resource::refetch`] always
1793/// does. Latest wins: a source change supersedes the in-flight fetch (aborting its task;
1794/// dropping any `FetchFuture` inside cancels the platform request) and a stale completion
1795/// writes nothing. Scope disposal aborts the in-flight fetch the same way.
1796///
1797/// ```ignore
1798/// let stations = Resource::new(
1799///     move || region.get(),                              // tracked: refetch on change
1800///     |region| async move { fetch_stations(region).await },
1801/// );
1802/// when(move || stations.ready(), move || station_list(stations));
1803/// ```
1804pub struct Resource<T: 'static> {
1805    state: Signal<Load<T>>,
1806    refetch_count: Signal<u64>,
1807    _not_send: PhantomData<*const T>,
1808}
1809
1810impl<T> Clone for Resource<T> {
1811    fn clone(&self) -> Self {
1812        *self
1813    }
1814}
1815impl<T> Copy for Resource<T> {}
1816
1817impl<T: 'static> Resource<T> {
1818    /// Start loading: `source` runs tracked (its signal reads become dependencies) and its
1819    /// value moves into `fetcher`'s future, which runs on the main-loop executor via the
1820    /// installed spawner, so no `Send` bound anywhere, and the future may read/write signals
1821    /// after its awaits. Infallible fetchers use `E = std::convert::Infallible`.
1822    #[track_caller]
1823    pub fn new<S, Fut, E>(
1824        source: impl Fn() -> S + 'static,
1825        fetcher: impl Fn(S) -> Fut + 'static,
1826    ) -> Resource<T>
1827    where
1828        S: Clone + PartialEq + 'static,
1829        Fut: Future<Output = Result<T, E>> + 'static,
1830        E: std::error::Error + Send + Sync + 'static,
1831    {
1832        let state: Signal<Load<T>> = Signal::new(Load::Loading);
1833        let refetch_count: Signal<u64> = Signal::new(0);
1834        let fs = Rc::new(FetchState {
1835            generation: Cell::new(0),
1836            abort: RefCell::new(None),
1837        });
1838        let reaction_fs = fs.clone();
1839        let prev: RefCell<Option<(S, u64)>> = RefCell::new(None);
1840        create_reaction(
1841            Rc::new(move || {
1842                // Tracked reads first: the refetch counter and the source. Everything else runs
1843                // untracked: the fetcher may read signals without making them dependencies.
1844                let count = refetch_count.get();
1845                let s = source();
1846                let fs = reaction_fs.clone();
1847                untrack(|| {
1848                    // Equality gate on (source value, refetch count): a rerun caused by an
1849                    // unrelated tracked read refetches nothing; refetch() always changes the
1850                    // pair, so it always fetches.
1851                    let same = prev
1852                        .borrow()
1853                        .as_ref()
1854                        .is_some_and(|(ps, pc)| ps == &s && *pc == count);
1855                    if same {
1856                        return;
1857                    }
1858                    *prev.borrow_mut() = Some((s.clone(), count));
1859                    let my_generation = fs.supersede();
1860                    state.set(Load::Loading);
1861                    let fut = fetcher(s);
1862                    let done = fs.clone();
1863                    let abort = spawn_local(Box::pin(async move {
1864                        let result = fut.await;
1865                        if done.generation.get() != my_generation {
1866                            return; // superseded while in flight; latest wins
1867                        }
1868                        done.abort.borrow_mut().take(); // completed: the stored abort is dead
1869                        match result {
1870                            Ok(v) => state.set(Load::Ready(v)),
1871                            Err(e) => state.set(Load::Failed(Arc::new(e))),
1872                        }
1873                    }));
1874                    // Eager-poll ordering: the spawner polls once before returning, so a
1875                    // synchronously-ready fetcher has already completed here; storing its
1876                    // abort is harmless only because the spawner contract makes aborting a
1877                    // finished task a no-op (this line is why that contract exists).
1878                    *fs.abort.borrow_mut() = Some(abort);
1879                });
1880            }),
1881            1,
1882        );
1883        // Disposal: supersede + abort the in-flight task. (A completion that slipped through
1884        // would write to disposed signals, the defined no-op, but aborting frees the platform
1885        // request immediately.)
1886        let cleanup_fs = fs;
1887        Scope::current().on_cleanup(move || {
1888            cleanup_fs.supersede();
1889        });
1890        Resource {
1891            state,
1892            refetch_count,
1893            _not_send: PhantomData,
1894        }
1895    }
1896
1897    /// The underlying state signal (tracked reads, `when(move || …)` guards, `each` sources).
1898    pub fn signal(self) -> Signal<Load<T>> {
1899        self.state
1900    }
1901
1902    /// The current load state (tracked).
1903    pub fn get(self) -> Load<T>
1904    where
1905        T: Clone,
1906    {
1907        self.state.get()
1908    }
1909
1910    /// Read the current load state by reference (tracked).
1911    pub fn with<R>(self, f: impl FnOnce(&Load<T>) -> R) -> R {
1912        self.state.with(f)
1913    }
1914
1915    /// Whether a fetch is in flight (tracked).
1916    pub fn loading(self) -> bool {
1917        self.with(Load::is_loading)
1918    }
1919
1920    /// Whether a value is ready (tracked): `when(move || r.ready(), …)`.
1921    pub fn ready(self) -> bool {
1922        self.with(Load::is_ready)
1923    }
1924
1925    /// Force a refetch with the current source value (even if unchanged).
1926    pub fn refetch(self) {
1927        self.refetch_count.update(|c| *c = c.wrapping_add(1));
1928    }
1929}
1930
1931// ---------------------------------------------------------------------------
1932// Tests
1933// ---------------------------------------------------------------------------
1934
1935#[cfg(test)]
1936mod tests {
1937    use super::*;
1938    use std::cell::Cell as StdCell;
1939    use std::rc::Rc;
1940
1941    fn counter() -> (Rc<StdCell<u32>>, impl Fn()) {
1942        let c = Rc::new(StdCell::new(0));
1943        let c2 = c.clone();
1944        (c, move || c2.set(c2.get() + 1))
1945    }
1946
1947    #[test]
1948    fn signal_get_set() {
1949        let s = Signal::new(1);
1950        assert_eq!(s.get(), 1);
1951        s.set(5);
1952        assert_eq!(s.get(), 5);
1953        s.update(|v| *v += 1);
1954        assert_eq!(s.get(), 6);
1955    }
1956
1957    #[test]
1958    fn signal_teardown_does_not_write_after_scope_disposal() {
1959        let scope = Scope::child();
1960        let warned = with_rt(|rt| rt.warned_writes.len());
1961        scope.enter(|| {
1962            let signal = Signal::new(String::new());
1963            signal.write_preview("Fixture final edit".into());
1964            assert_eq!(signal.peek(), "Fixture final edit");
1965            Scope::current().on_cleanup(move || {
1966                signal.write_teardown("Fixture final edit".into());
1967            });
1968        });
1969        scope.dispose();
1970        assert_eq!(with_rt(|rt| rt.warned_writes.len()), warned);
1971    }
1972
1973    #[test]
1974    fn persistent_binding_teardown_seals_preview() {
1975        #[derive(Clone)]
1976        struct FixtureBinding(Rc<RefCell<Vec<String>>>);
1977        impl Binding<String> for FixtureBinding {
1978            fn read(&self) -> String {
1979                String::new()
1980            }
1981            fn peek(&self) -> String {
1982                self.read()
1983            }
1984            fn write(&self, value: String) {
1985                self.0.borrow_mut().push(value);
1986            }
1987            fn write_preview(&self, _value: String) {}
1988        }
1989        let committed = Rc::new(RefCell::new(Vec::new()));
1990        let binding = FixtureBinding(committed.clone());
1991        let scope = Scope::child();
1992        scope.enter(|| {
1993            binding.write_preview("Fixture edit".into());
1994            Scope::current().on_cleanup(move || binding.write_teardown("Fixture edit".into()));
1995        });
1996        assert!(committed.borrow().is_empty());
1997        scope.dispose();
1998        assert_eq!(*committed.borrow(), ["Fixture edit"]);
1999    }
2000
2001    #[test]
2002    fn effect_reruns_on_change() {
2003        let s = Signal::new(1);
2004        let (count, bump) = counter();
2005        Effect::new(move || {
2006            s.track();
2007            bump();
2008        });
2009        assert_eq!(count.get(), 1); // initial run
2010        batch(|| s.set(2));
2011        assert_eq!(count.get(), 2);
2012        batch(|| s.set(3));
2013        assert_eq!(count.get(), 3);
2014    }
2015
2016    #[test]
2017    fn batch_coalesces() {
2018        let s = Signal::new(0);
2019        let (count, bump) = counter();
2020        Effect::new(move || {
2021            s.track();
2022            bump();
2023        });
2024        batch(|| {
2025            s.set(1);
2026            s.set(2);
2027            s.set(3);
2028        });
2029        assert_eq!(count.get(), 2); // initial + one drain
2030        assert_eq!(s.get(), 3);
2031    }
2032
2033    #[test]
2034    fn set_if_changed_no_op() {
2035        let s = Signal::new(7);
2036        let (count, bump) = counter();
2037        Effect::new(move || {
2038            s.track();
2039            bump();
2040        });
2041        batch(|| s.set_if_changed(7));
2042        assert_eq!(count.get(), 1); // no re-run
2043    }
2044
2045    #[test]
2046    fn memo_caches_and_diffs() {
2047        let s = Signal::new(1);
2048        let computes = Rc::new(StdCell::new(0));
2049        let c2 = computes.clone();
2050        let doubled = Memo::new(move || {
2051            c2.set(c2.get() + 1);
2052            s.get() * 2
2053        });
2054        assert_eq!(doubled.get(), 2);
2055        assert_eq!(doubled.get(), 2);
2056        assert_eq!(computes.get(), 1); // cached
2057        let (effect_runs, bump) = counter();
2058        Effect::new(move || {
2059            let _ = doubled.get();
2060            bump();
2061        });
2062        assert_eq!(effect_runs.get(), 1);
2063        batch(|| s.set(1)); // same value → memo recomputes? signal changed, memo recomputes, same output
2064        // memo output unchanged (1*2 == 2)? s was already 1 → set(1) marks dirty (set is not diffed)
2065        assert_eq!(effect_runs.get(), 1); // memo diffing gates the effect
2066        batch(|| s.set(5));
2067        assert_eq!(doubled.get(), 10);
2068        assert_eq!(effect_runs.get(), 2);
2069    }
2070
2071    #[test]
2072    fn bind_applies_on_change_only() {
2073        let s = Signal::new(1);
2074        let applied: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
2075        let a2 = applied.clone();
2076        bind(
2077            move || s.get().to_string(),
2078            move |v| a2.borrow_mut().push(v.clone()),
2079        );
2080        assert_eq!(*applied.borrow(), vec!["1"]);
2081        batch(|| s.set(2));
2082        assert_eq!(*applied.borrow(), vec!["1", "2"]);
2083        batch(|| s.update(|v| *v = 2)); // update always marks; bind's eq gate stops the apply
2084        assert_eq!(*applied.borrow(), vec!["1", "2"]);
2085    }
2086
2087    #[test]
2088    fn watch_skips_initial_and_passes_old() {
2089        let s = Signal::new(10);
2090        type Log = Rc<RefCell<Vec<(i32, Option<i32>)>>>;
2091        let log: Log = Rc::new(RefCell::new(Vec::new()));
2092        let l2 = log.clone();
2093        watch(
2094            move || s.get(),
2095            move |new, old| l2.borrow_mut().push((*new, old.copied())),
2096        );
2097        assert!(log.borrow().is_empty());
2098        batch(|| s.set(11));
2099        assert_eq!(*log.borrow(), vec![(11, Some(10))]);
2100    }
2101
2102    #[test]
2103    fn scope_dispose_stops_effects_and_write_is_noop() {
2104        let s = Signal::new(0);
2105        let (count, bump) = counter();
2106        let scope = Scope::child();
2107        let inner = scope.enter(|| {
2108            let inner = Signal::new(1);
2109            Effect::new(move || {
2110                s.track();
2111                bump();
2112            });
2113            inner
2114        });
2115        assert_eq!(count.get(), 1);
2116        batch(|| s.set(1));
2117        assert_eq!(count.get(), 2);
2118        scope.dispose();
2119        batch(|| s.set(2));
2120        assert_eq!(count.get(), 2); // effect gone
2121        inner.set(9); // silent no-op
2122        assert_eq!(inner.try_get(), None);
2123    }
2124
2125    #[test]
2126    fn dispose_during_drain_skips_pending() {
2127        let s = Signal::new(0);
2128        let scope = Scope::child();
2129        let (count, bump) = counter();
2130        // Owner effect (created first in outer scope) disposes the child scope when s becomes 1.
2131        let scope_cell = Rc::new(StdCell::new(Some(scope)));
2132        let sc = scope_cell.clone();
2133        Effect::new(move || {
2134            if s.get() == 1
2135                && let Some(scope) = sc.take()
2136            {
2137                scope.dispose();
2138            }
2139        });
2140        scope.enter(|| {
2141            Effect::new(move || {
2142                s.track();
2143                bump();
2144            });
2145        });
2146        assert_eq!(count.get(), 1);
2147        batch(|| s.set(1));
2148        // Owner ran first (created earlier, same depth? owner depth 0 < child depth 1) and
2149        // disposed the child → child effect must not run again.
2150        assert_eq!(count.get(), 1);
2151    }
2152
2153    #[test]
2154    fn fixpoint_extends_drain() {
2155        let a = Signal::new(0);
2156        let b = Signal::new(0);
2157        let (count, bump) = counter();
2158        Effect::new(move || {
2159            if a.get() == 1 {
2160                b.set(1); // write during drain extends the drain
2161            }
2162        });
2163        Effect::new(move || {
2164            b.track();
2165            bump();
2166        });
2167        assert_eq!(count.get(), 1);
2168        batch(|| a.set(1));
2169        assert_eq!(count.get(), 2);
2170        assert_eq!(b.get(), 1);
2171    }
2172
2173    // Debug-only: release warns-and-defers instead of panicking (the release
2174    // side is pinned by `unwind_safety_tests::rerun_cap_defers_instead_of_killing_the_binding`).
2175    #[cfg(debug_assertions)]
2176    #[test]
2177    #[should_panic(expected = "re-ran more than")]
2178    fn rerun_cap_panics_in_debug() {
2179        let s = Signal::new(0);
2180        Effect::new(move || {
2181            let v = s.get();
2182            s.set(v + 1); // classic cycle
2183        });
2184        batch(|| s.set(1));
2185    }
2186
2187    #[test]
2188    fn context_provides_down() {
2189        #[derive(Clone, PartialEq, Debug)]
2190        struct Theme(u32);
2191        let scope = Scope::child();
2192        scope.provide(Theme(7));
2193        let child = scope.create_child();
2194        assert_eq!(child.use_context::<Theme>(), Some(Theme(7)));
2195        scope.dispose();
2196    }
2197
2198    #[test]
2199    fn trigger_notifies() {
2200        let t = Trigger::new();
2201        let (count, bump) = counter();
2202        Effect::new(move || {
2203            t.track();
2204            bump();
2205        });
2206        batch(|| t.notify());
2207        assert_eq!(count.get(), 2);
2208    }
2209
2210    #[test]
2211    fn turn_end_runs_after_fixpoint() {
2212        let s = Signal::new(0);
2213        let order: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
2214        let o1 = order.clone();
2215        Effect::new(move || {
2216            s.track();
2217            o1.borrow_mut().push("effect");
2218        });
2219        let o2 = order.clone();
2220        on_turn_end(move || o2.borrow_mut().push("turn-end"));
2221        order.borrow_mut().clear();
2222        batch(|| s.set(1));
2223        assert_eq!(*order.borrow(), vec!["effect", "turn-end"]);
2224    }
2225
2226    #[test]
2227    fn at_turn_end_waits_for_the_turn_and_runs_once() {
2228        let s = Signal::new(0);
2229        let order: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
2230        // Outside a turn: now.
2231        let o = order.clone();
2232        at_turn_end(move || o.borrow_mut().push("now".into()));
2233        assert_eq!(*order.borrow(), vec!["now".to_string()]);
2234        order.borrow_mut().clear();
2235        // Inside a batch: after the drain, and before the standing hooks.
2236        let o1 = order.clone();
2237        Effect::new(move || {
2238            let v = s.get();
2239            o1.borrow_mut().push(format!("effect {v}"));
2240        });
2241        let o2 = order.clone();
2242        on_turn_end(move || o2.borrow_mut().push("standing".into()));
2243        order.borrow_mut().clear();
2244        let o3 = order.clone();
2245        batch(|| {
2246            at_turn_end(move || o3.borrow_mut().push("once".into()));
2247            s.set(1);
2248        });
2249        assert_eq!(*order.borrow(), vec!["effect 1", "once", "standing"]);
2250        // A batch with no reactive work still ends its turn for a deferred callback.
2251        order.borrow_mut().clear();
2252        let o4 = order.clone();
2253        batch(|| at_turn_end(move || o4.borrow_mut().push("quiet".into())));
2254        assert_eq!(*order.borrow(), vec!["quiet", "standing"]);
2255    }
2256
2257    #[test]
2258    fn bindings_run_before_effects() {
2259        let s = Signal::new(0);
2260        let order: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
2261        let o1 = order.clone();
2262        // Plain effect created first (lower seq); priority must still put the bind first.
2263        Effect::new(move || {
2264            s.track();
2265            o1.borrow_mut().push("effect");
2266        });
2267        let o2 = order.clone();
2268        bind(move || s.get(), move |_| o2.borrow_mut().push("bind"));
2269        order.borrow_mut().clear();
2270        batch(|| s.set(1));
2271        assert_eq!(*order.borrow(), vec!["bind", "effect"]);
2272    }
2273}
2274
2275#[cfg(test)]
2276mod resource_tests {
2277    use super::*;
2278    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
2279
2280    // ---- test executor: mirrors day::task's shape (eager first poll; abort = remove+drop;
2281    // pump() re-polls whatever is still pending). `ABORTED` records aborts of still-pending
2282    // tasks, the probe the latest-wins/disposal tests read. All thread-local: each #[test]
2283    // thread gets a fresh Runtime and a fresh executor.
2284
2285    thread_local! {
2286        static TEST_TASKS: RefCell<Vec<(u64, Option<LocalBoxFuture>)>> =
2287            const { RefCell::new(Vec::new()) };
2288        static NEXT_ID: Cell<u64> = const { Cell::new(1) };
2289        static ABORTED: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
2290        static INSTALLED: Cell<bool> = const { Cell::new(false) };
2291    }
2292
2293    fn noop_waker() -> Waker {
2294        fn raw() -> RawWaker {
2295            RawWaker::new(std::ptr::null(), &VT)
2296        }
2297        static VT: RawWakerVTable = RawWakerVTable::new(|_| raw(), |_| {}, |_| {}, |_| {});
2298        // SAFETY: every vtable entry ignores the data pointer.
2299        unsafe { Waker::from_raw(raw()) }
2300    }
2301
2302    fn poll_one(fut: &mut LocalBoxFuture) -> bool {
2303        let waker = noop_waker();
2304        let mut cx = Context::from_waker(&waker);
2305        fut.as_mut().poll(&mut cx).is_ready()
2306    }
2307
2308    fn install_test_spawner() {
2309        if INSTALLED.with(|c| c.replace(true)) {
2310            return;
2311        }
2312        install_spawner(|mut fut| {
2313            let id = NEXT_ID.with(|c| {
2314                let v = c.get();
2315                c.set(v + 1);
2316                v
2317            });
2318            if !poll_one(&mut fut) {
2319                TEST_TASKS.with(|t| t.borrow_mut().push((id, Some(fut))));
2320            }
2321            Box::new(move || {
2322                let removed = TEST_TASKS.with(|t| {
2323                    let mut t = t.borrow_mut();
2324                    let before = t.len();
2325                    t.retain(|(i, _)| *i != id);
2326                    before != t.len()
2327                });
2328                if removed {
2329                    ABORTED.with(|a| a.borrow_mut().push(id));
2330                }
2331            })
2332        });
2333    }
2334
2335    fn aborted_count() -> usize {
2336        ABORTED.with(|a| a.borrow().len())
2337    }
2338
2339    /// Re-poll every pending task once (completions drop out).
2340    fn pump() {
2341        let ids: Vec<u64> = TEST_TASKS.with(|t| t.borrow().iter().map(|(i, _)| *i).collect());
2342        for id in ids {
2343            let fut = TEST_TASKS.with(|t| {
2344                t.borrow_mut()
2345                    .iter_mut()
2346                    .find(|(i, _)| *i == id)
2347                    .and_then(|(_, s)| s.take())
2348            });
2349            let Some(mut fut) = fut else { continue };
2350            if poll_one(&mut fut) {
2351                TEST_TASKS.with(|t| t.borrow_mut().retain(|(i, _)| *i != id));
2352            } else {
2353                TEST_TASKS.with(|t| {
2354                    if let Some((_, s)) = t.borrow_mut().iter_mut().find(|(i, _)| *i == id) {
2355                        *s = Some(fut);
2356                    }
2357                });
2358            }
2359        }
2360        // Completions wrote signals; with no scheduler installed a test must drain explicitly
2361        // (the production main loop drains via the installed scheduler).
2362        flush_sync();
2363    }
2364
2365    type Slot<T> = Rc<RefCell<Option<Result<T, TestErr>>>>;
2366
2367    /// A future the test resolves by hand (fill the slot, then `pump()`); no waker wiring, since
2368    /// pump re-polls everything.
2369    fn manual_future<T: 'static>(slot: Slot<T>) -> impl Future<Output = Result<T, TestErr>> {
2370        std::future::poll_fn(move |_| match slot.borrow_mut().take() {
2371            Some(r) => Poll::Ready(r),
2372            None => Poll::Pending,
2373        })
2374    }
2375
2376    #[derive(Debug)]
2377    struct TestErr(&'static str);
2378    impl std::fmt::Display for TestErr {
2379        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2380            write!(f, "{}", self.0)
2381        }
2382    }
2383    impl std::error::Error for TestErr {}
2384
2385    #[test]
2386    fn resource_sync_fetcher_is_ready_immediately() {
2387        install_test_spawner();
2388        let r = Resource::new(|| (), |_| async { Ok::<_, std::convert::Infallible>(42) });
2389        assert!(r.ready());
2390        assert_eq!(r.with(|l| l.ready().copied()), Some(42));
2391    }
2392
2393    #[test]
2394    fn resource_loading_then_ready() {
2395        install_test_spawner();
2396        let slot: Slot<i32> = Rc::new(RefCell::new(None));
2397        let s2 = slot.clone();
2398        let r = Resource::new(move || (), move |_| manual_future(s2.clone()));
2399        assert!(r.loading());
2400        *slot.borrow_mut() = Some(Ok(7));
2401        pump();
2402        assert_eq!(r.with(|l| l.ready().copied()), Some(7));
2403    }
2404
2405    #[test]
2406    fn resource_failed_preserves_error() {
2407        install_test_spawner();
2408        let r = Resource::new(|| (), |_| async { Err::<i32, _>(TestErr("boom")) });
2409        assert!(!r.ready());
2410        assert_eq!(
2411            r.with(|l| l.error().map(|e| e.to_string())),
2412            Some("boom".into())
2413        );
2414    }
2415
2416    #[test]
2417    fn resource_refetches_on_source_change() {
2418        install_test_spawner();
2419        let src = Signal::new(1i32);
2420        let fetches = Rc::new(Cell::new(0));
2421        let f = fetches.clone();
2422        let r = Resource::new(
2423            move || src.get(),
2424            move |v| {
2425                f.set(f.get() + 1);
2426                async move { Ok::<_, TestErr>(v * 10) }
2427            },
2428        );
2429        assert_eq!(fetches.get(), 1);
2430        assert_eq!(r.with(|l| l.ready().copied()), Some(10));
2431        batch(|| src.set(2));
2432        assert_eq!(fetches.get(), 2);
2433        assert_eq!(r.with(|l| l.ready().copied()), Some(20));
2434    }
2435
2436    #[test]
2437    fn resource_source_equality_gates() {
2438        install_test_spawner();
2439        let a = Signal::new(1i32);
2440        let b = Signal::new(10i32);
2441        let fetches = Rc::new(Cell::new(0));
2442        let f = fetches.clone();
2443        let r = Resource::new(
2444            move || {
2445                let _ = b.get(); // tracked but not part of the source value
2446                a.get()
2447            },
2448            move |v| {
2449                f.set(f.get() + 1);
2450                async move { Ok::<_, TestErr>(v) }
2451            },
2452        );
2453        assert_eq!(fetches.get(), 1);
2454        batch(|| b.set(20)); // reruns the reaction; the source value is unchanged → no refetch
2455        assert_eq!(fetches.get(), 1);
2456        assert_eq!(r.with(|l| l.ready().copied()), Some(1));
2457        batch(|| a.set(2)); // the value changed → refetch
2458        assert_eq!(fetches.get(), 2);
2459    }
2460
2461    #[test]
2462    fn resource_refetch_forces() {
2463        install_test_spawner();
2464        let fetches = Rc::new(Cell::new(0));
2465        let f = fetches.clone();
2466        let r = Resource::new(
2467            || (),
2468            move |_| {
2469                f.set(f.get() + 1);
2470                async { Ok::<_, TestErr>(0) }
2471            },
2472        );
2473        assert_eq!(fetches.get(), 1);
2474        batch(|| r.refetch()); // same source value; the refetch counter forces it past the gate
2475        assert_eq!(fetches.get(), 2);
2476    }
2477
2478    #[test]
2479    fn resource_latest_wins() {
2480        install_test_spawner();
2481        let src = Signal::new(1i32);
2482        let slots: Rc<RefCell<Vec<Slot<i32>>>> = Rc::new(RefCell::new(Vec::new()));
2483        let sl = slots.clone();
2484        let r = Resource::new(
2485            move || src.get(),
2486            move |_| {
2487                let slot: Slot<i32> = Rc::new(RefCell::new(None));
2488                sl.borrow_mut().push(slot.clone());
2489                manual_future(slot)
2490            },
2491        );
2492        assert!(r.loading());
2493        let before = aborted_count();
2494        batch(|| src.set(2)); // supersede generation 1 while it is in flight
2495        assert_eq!(aborted_count(), before + 1, "the stale task was aborted");
2496        // Resolving generation 1 anyway must change nothing (its future is gone).
2497        *slots.borrow()[0].borrow_mut() = Some(Ok(111));
2498        pump();
2499        assert!(r.loading(), "a stale result writes nothing");
2500        *slots.borrow()[1].borrow_mut() = Some(Ok(222));
2501        pump();
2502        assert_eq!(r.with(|l| l.ready().copied()), Some(222));
2503    }
2504
2505    #[test]
2506    fn resource_scope_disposal_aborts() {
2507        install_test_spawner();
2508        let scope = Scope::child();
2509        let slot: Slot<i32> = Rc::new(RefCell::new(None));
2510        let s2 = slot.clone();
2511        let r = scope.enter(|| Resource::new(move || (), move |_| manual_future(s2.clone())));
2512        assert!(r.loading());
2513        let before = aborted_count();
2514        scope.dispose();
2515        assert_eq!(aborted_count(), before + 1, "disposal aborted the fetch");
2516        // Resolving afterwards is inert: the future is gone; nothing pends; no panic.
2517        *slot.borrow_mut() = Some(Ok(1));
2518        pump();
2519    }
2520
2521    #[test]
2522    fn resource_ready_is_tracked() {
2523        install_test_spawner();
2524        let slot: Slot<i32> = Rc::new(RefCell::new(None));
2525        let s2 = slot.clone();
2526        let r = Resource::new(move || (), move |_| manual_future(s2.clone()));
2527        let runs = Rc::new(Cell::new(0));
2528        let rn = runs.clone();
2529        Effect::new(move || {
2530            let _ = r.ready();
2531            rn.set(rn.get() + 1);
2532        });
2533        assert_eq!(runs.get(), 1);
2534        *slot.borrow_mut() = Some(Ok(1));
2535        pump();
2536        assert!(runs.get() >= 2, "ready() re-ran the effect on completion");
2537    }
2538
2539    #[test]
2540    #[should_panic(expected = "no spawner installed")]
2541    fn spawner_missing_panics() {
2542        // No test spawner installed: this test thread's Runtime has none.
2543        let _r = Resource::new(|| (), |_| async { Ok::<_, std::convert::Infallible>(1) });
2544    }
2545}
2546
2547#[cfg(test)]
2548mod unwind_safety_tests {
2549    use super::*;
2550
2551    /// Run `f`, swallowing a panic, with the panic hook silenced so the test output stays clean.
2552    fn contained(f: impl FnOnce() + std::panic::UnwindSafe) {
2553        let prev = std::panic::take_hook();
2554        std::panic::set_hook(Box::new(|_| {}));
2555        let r = std::panic::catch_unwind(f);
2556        std::panic::set_hook(prev);
2557        assert!(r.is_err(), "expected the closure to panic");
2558    }
2559
2560    /// A panic inside `Scope::enter` must not leave the runtime parented to the transient scope.
2561    /// Before the guard, every later `Signal::new` landed in a disposed scope and its first read
2562    /// panicked with a misleading "read of disposed Signal".
2563    #[test]
2564    fn panic_in_scope_enter_restores_current_scope() {
2565        let before = with_rt(|rt| rt.current_scope);
2566        let scope = Scope::detached();
2567        contained(|| scope.enter(|| panic!("boom in enter")));
2568        assert_eq!(with_rt(|rt| rt.current_scope), before, "scope not restored");
2569        // The real symptom: a signal created afterwards must still be alive and readable.
2570        let s = Signal::new(7u32);
2571        assert_eq!(s.get(), 7);
2572    }
2573
2574    /// A panic inside `untrack` must pop its `None` observer, or dependency tracking stays off
2575    /// for the rest of the process and nothing ever updates again.
2576    /// A panic inside an effect's own closure (contained by a backend trampoline) must pop
2577    /// the effect's observer frame during unwind; a stranded frame would attribute every
2578    /// later read to the dead run, even through a user's own `catch_unwind`, without any
2579    /// call to `recover_from_panic`.
2580    #[test]
2581    fn panic_in_effect_body_pops_its_observer_frame() {
2582        let depth = with_rt(|rt| rt.observers.len());
2583        let boom = Signal::new(false);
2584        Effect::new(move || {
2585            if boom.get() {
2586                panic!("effect boom");
2587            }
2588        });
2589        assert_eq!(with_rt(|rt| rt.observers.len()), depth);
2590        contained(|| batch(|| boom.set(true)));
2591        assert_eq!(
2592            with_rt(|rt| rt.observers.len()),
2593            depth,
2594            "observer frame stranded by the panicking effect run"
2595        );
2596        recover_from_panic(); // clear draining/pending for the tests that follow
2597    }
2598
2599    #[test]
2600    fn panic_in_untrack_restores_tracking() {
2601        let depth = with_rt(|rt| rt.observers.len());
2602        contained(|| untrack(|| panic!("boom in untrack")));
2603        assert_eq!(with_rt(|rt| rt.observers.len()), depth, "observer stranded");
2604
2605        // Tracking still works: an effect re-runs when its source changes.
2606        let src = Signal::new(0i32);
2607        let runs = Rc::new(RefCell::new(0));
2608        let r2 = runs.clone();
2609        Effect::new(move || {
2610            src.get();
2611            *r2.borrow_mut() += 1;
2612        });
2613        flush_sync();
2614        let before = *runs.borrow();
2615        src.set(1);
2616        flush_sync();
2617        assert!(
2618            *runs.borrow() > before,
2619            "effect did not re-run — tracking is off"
2620        );
2621    }
2622
2623    /// A panic inside `batch` must not strand `batch_depth`, which would stop every later write
2624    /// from scheduling a drain.
2625    #[test]
2626    fn panic_in_batch_restores_depth() {
2627        let depth = with_rt(|rt| rt.batch_depth);
2628        contained(|| batch(|| panic!("boom in batch")));
2629        assert_eq!(with_rt(|rt| rt.batch_depth), depth, "batch depth stranded");
2630    }
2631
2632    /// A panic inside a `with` closure must not consume the signal's value. Before the value was
2633    /// shared it was taken out for the closure's duration, so an unwind lost it permanently and
2634    /// the signal was dead for the rest of the process.
2635    #[test]
2636    fn panic_in_with_leaves_the_value_intact() {
2637        let s = Signal::new(String::from("intact"));
2638        contained(|| s.with(|_| panic!("boom in with")));
2639        assert_eq!(s.with(|v| v.clone()), "intact");
2640    }
2641
2642    /// Reading a signal from inside its own `with` closure now works. It used to find the hole
2643    /// left by the take and panic with "read of disposed Signal".
2644    #[test]
2645    fn reentrant_read_inside_with_succeeds() {
2646        let s = Signal::new(5u32);
2647        let doubled = s.with(|outer| *outer + s.with(|inner| *inner));
2648        assert_eq!(doubled, 10);
2649        assert_eq!(
2650            s.with(|v| *v),
2651            5,
2652            "value still in place after the nested read"
2653        );
2654    }
2655
2656    /// Two different signals read inside one another's closures: the ordinary case, and the
2657    /// reason the closure must run with the runtime borrow released.
2658    #[test]
2659    fn nested_reads_of_distinct_signals_succeed() {
2660        let a = Signal::new(2u32);
2661        let b = Signal::new(3u32);
2662        assert_eq!(a.with(|x| *x * b.with(|y| *y)), 6);
2663    }
2664
2665    /// Writing a signal while a read of it is in flight is reported instead of silently lost.
2666    #[test]
2667    fn write_during_read_panics_with_a_clear_message() {
2668        let s = Signal::new(1u32);
2669        let prev = std::panic::take_hook();
2670        std::panic::set_hook(Box::new(|_| {}));
2671        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2672            s.with(|_| s.set(2));
2673        }));
2674        std::panic::set_hook(prev);
2675        let payload = r.unwrap_err();
2676        // A `panic!` with no format arguments carries a `&'static str`, not a `String`.
2677        let msg = payload
2678            .downcast_ref::<&str>()
2679            .map(|s| s.to_string())
2680            .or_else(|| payload.downcast_ref::<String>().cloned())
2681            .expect("panic payload should be a string");
2682        assert!(
2683            msg.contains("in flight") && msg.contains("Signal"),
2684            "message should name the cause, got: {msg}"
2685        );
2686    }
2687
2688    /// Release builds must survive a reactive cycle: the capped effect is skipped for the
2689    /// rest of the drain but re-arms on the next source write; it must not be permanently
2690    /// disabled. Release-only because the same cycle panics under
2691    /// `debug_assertions`; run via `cargo test --release -p day-reactive`.
2692    #[cfg(not(debug_assertions))]
2693    #[test]
2694    fn rerun_cap_defers_instead_of_killing_the_binding() {
2695        let s = Signal::new(0u32);
2696        let runs = std::rc::Rc::new(std::cell::Cell::new(0u32));
2697        let r2 = runs.clone();
2698        Effect::new(move || {
2699            let v = s.get();
2700            r2.set(r2.get() + 1);
2701            if v < 1_000_000 {
2702                s.set(v + 1); // self-sustaining cycle: trips the cap every drain
2703            }
2704        });
2705        flush_sync();
2706        let after_cycle = runs.get();
2707        assert!(
2708            after_cycle <= RERUN_CAP + 1,
2709            "cap did not bound the drain: {after_cycle} runs"
2710        );
2711
2712        // A fresh external write must re-arm the effect with a new per-drain budget.
2713        batch(|| s.set(2_000_000)); // past the cycle threshold: the effect runs once, quietly
2714        assert_eq!(
2715            runs.get(),
2716            after_cycle + 1,
2717            "binding stayed dead after hitting the re-run cap"
2718        );
2719    }
2720}