Skip to main content

incular_runtime/
reactive.rs

1//! Additional reactive primitives built on top of the runtime's retained
2//! element dependency queue.
3//!
4//! `Signal` remains the inexpensive direct `Signal -> Element` path.  The
5//! primitives in this module add graph nodes only when an application opts in
6//! to derived values or external work:
7//!
8//! * [`Memo`] caches an expensive derived value and only propagates changes in
9//!   its result.
10//! * [`Effect`] runs an owner-mounted side effect after a reactive dependency
11//!   changes.
12//! * [`Action`] owns an explicitly dispatched asynchronous operation and its
13//!   UI-observable state.
14
15use std::{
16    cell::{Cell, RefCell},
17    collections::HashMap,
18    future::Future,
19    pin::Pin,
20    rc::{Rc, Weak},
21};
22
23use incular_widgets::internal::ElementId;
24
25use super::{
26    BuildScope, Dependency, ReactiveNode, ReactiveNodeId, ReactiveQueue, ReactiveRootId, Signal,
27    TaskFailure, TaskHandle, TrackedDependency,
28};
29
30#[derive(Clone)]
31struct ReactiveBinding {
32    owner_element: Option<ElementId>,
33    root: ReactiveRootId,
34    queue: Weak<RefCell<ReactiveQueue>>,
35    spawner: Option<super::tasks::RuntimeSpawner>,
36    owner_scope: Option<super::tasks::TaskScope>,
37}
38
39struct TrackingScope {
40    binding: ReactiveBinding,
41    node: Rc<dyn ReactiveNode>,
42}
43
44thread_local! {
45    static TRACKING_STACK: RefCell<Vec<TrackingScope>> = const { RefCell::new(Vec::new()) };
46}
47
48fn binding_from_build_scope(scope: &BuildScope) -> ReactiveBinding {
49    ReactiveBinding {
50        owner_element: scope.element,
51        root: scope.root,
52        queue: scope.queue.clone(),
53        spawner: Some(scope.spawner.clone()),
54        owner_scope: Some(scope.owner_scope.clone()),
55    }
56}
57
58fn current_binding() -> Option<ReactiveBinding> {
59    TRACKING_STACK
60        .with(|stack| stack.borrow().last().map(|scope| scope.binding.clone()))
61        .or_else(|| {
62            super::BUILD_SCOPE.with(|scope| scope.borrow().as_ref().map(binding_from_build_scope))
63        })
64}
65
66fn current_element() -> Option<ElementId> {
67    super::BUILD_SCOPE.with(|scope| scope.borrow().as_ref().and_then(|scope| scope.element))
68}
69
70fn current_node() -> Option<ReactiveNodeId> {
71    TRACKING_STACK.with(|stack| stack.borrow().last().map(|scope| scope.node.id()))
72}
73
74/// Records a source read against either the currently computing graph node or
75/// the current retained element. This is the common hook used by `Signal` and
76/// all derived reactive values.
77pub(super) fn track_dependency(dependency: Rc<dyn Dependency>) {
78    let tracking = TRACKING_STACK.with(|stack| {
79        stack.borrow().last().map(|scope| {
80            (
81                scope.binding.root,
82                scope.binding.queue.clone(),
83                scope.node.clone(),
84            )
85        })
86    });
87    if let Some((root, queue, node)) = tracking {
88        let id = node.id();
89        let tracked = subscribe_node(dependency, root, id, Rc::downgrade(&node), queue);
90        node.record_dependency(tracked);
91        return;
92    }
93
94    super::BUILD_SCOPE.with(|scope| {
95        let current = scope.borrow();
96        let Some(scope) = current.as_ref() else {
97            dependency.source().track();
98            return;
99        };
100        if let Some(element) = scope.element {
101            let tracked = subscribe_element(dependency, scope.root, element, scope.queue.clone());
102            if let Some(queue) = scope.queue.upgrade() {
103                queue.borrow_mut().record(element, tracked);
104            }
105        } else if let Some(initial_dependencies) = &scope.initial_dependencies {
106            initial_dependencies.borrow_mut().record_signal(dependency);
107        }
108    });
109}
110
111struct TrackingGuard;
112
113impl Drop for TrackingGuard {
114    fn drop(&mut self) {
115        TRACKING_STACK.with(|stack| {
116            stack.borrow_mut().pop();
117        });
118    }
119}
120
121fn with_tracking<R>(
122    binding: ReactiveBinding,
123    node: Rc<dyn ReactiveNode>,
124    compute: impl FnOnce() -> R,
125) -> R {
126    TRACKING_STACK.with(|stack| {
127        stack.borrow_mut().push(TrackingScope { binding, node });
128    });
129    let guard = TrackingGuard;
130    let _sources = incular_core::reactivity::TrackingGuard::enter(track_source);
131    let result = compute();
132    drop(guard);
133    result
134}
135
136fn detach_sources(sources: &RefCell<Vec<(ReactiveRootId, TrackedDependency)>>) {
137    sources.borrow_mut().clear();
138}
139fn detach_sources_for(
140    root: ReactiveRootId,
141    sources: &RefCell<HashMap<ReactiveRootId, Vec<TrackedDependency>>>,
142) {
143    sources.borrow_mut().remove(&root);
144}
145
146struct SourceDependency(incular_core::reactivity::DependencySource);
147impl Dependency for SourceDependency {
148    fn source(&self) -> &incular_core::reactivity::DependencySource {
149        &self.0
150    }
151}
152pub(super) fn track_source(source: incular_core::reactivity::DependencySource) {
153    track_dependency(Rc::new(SourceDependency(source)));
154}
155
156pub(super) fn subscribe_element(
157    dependency: Rc<dyn Dependency>,
158    root: ReactiveRootId,
159    element: ElementId,
160    queue: Weak<RefCell<ReactiveQueue>>,
161) -> TrackedDependency {
162    if let Some(queue) = queue.upgrade() {
163        dependency.prepare_subscription(&queue);
164    }
165    #[cfg(feature = "devtools")]
166    let weak = Rc::downgrade(&dependency);
167    let source = dependency.source().clone();
168    #[cfg(feature = "devtools")]
169    let callback_source = weak.clone();
170    let is_signal = dependency.is_signal();
171    let skips_current = dependency.skips_current_consumer();
172    let subscription = source.subscribe((root, element), move || {
173        if skips_current
174            && current_element() == Some(element)
175            && current_binding().is_some_and(|binding| binding.root == root)
176        {
177            return;
178        }
179        if let Some(queue) = queue.upgrade() {
180            let mut queue = queue.borrow_mut();
181            let newly_queued = queue.enqueue(element);
182            if is_signal && newly_queued {
183                super::scheduler_counters::DEPENDENTS_ENQUEUED
184                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
185            }
186            #[cfg(feature = "devtools")]
187            {
188                if super::environment::DEV_TASK_COMPLETION.with(Cell::get) {
189                    queue.note_cause(
190                        element,
191                        incular_widgets::internal::InvalidationCause::TaskCompletion,
192                    );
193                }
194                if let Some(cause) = callback_source
195                    .upgrade()
196                    .and_then(|dependency| dependency.cause())
197                {
198                    queue.note_cause(element, cause);
199                }
200            }
201        }
202    });
203    TrackedDependency {
204        source_id: dependency.source().id(),
205        source: dependency.source().clone(),
206        dependency: Rc::downgrade(&dependency),
207        _subscription: subscription,
208    }
209}
210
211fn subscribe_node(
212    dependency: Rc<dyn Dependency>,
213    root: ReactiveRootId,
214    id: ReactiveNodeId,
215    node: Weak<dyn ReactiveNode>,
216    queue: Weak<RefCell<ReactiveQueue>>,
217) -> TrackedDependency {
218    if let Some(queue) = queue.upgrade() {
219        dependency.prepare_subscription(&queue);
220        queue.borrow_mut().register_node(id, node.clone());
221    }
222    let skips_current = dependency.skips_current_consumer();
223    let subscription = dependency.source().subscribe((root, id), move || {
224        if skips_current && current_node() == Some(id) {
225            return;
226        }
227        if let (Some(queue), Some(live)) = (queue.upgrade(), node.upgrade()) {
228            live.mark_dirty();
229            queue.borrow_mut().enqueue_node(id, node.clone());
230        }
231    });
232    TrackedDependency {
233        source_id: dependency.source().id(),
234        source: dependency.source().clone(),
235        dependency: Rc::downgrade(&dependency),
236        _subscription: subscription,
237    }
238}
239
240struct BoolGuard<'a>(&'a Cell<bool>);
241
242impl Drop for BoolGuard<'_> {
243    fn drop(&mut self) {
244        self.0.set(false);
245    }
246}
247
248struct MemoInner<T> {
249    id: ReactiveNodeId,
250    value: RefCell<Option<T>>,
251    compute: RefCell<Box<dyn FnMut() -> T>>,
252    dirty: Cell<bool>,
253    evaluating: Cell<bool>,
254    runtime: RefCell<HashMap<ReactiveRootId, ReactiveBinding>>,
255    sources: RefCell<HashMap<ReactiveRootId, Vec<TrackedDependency>>>,
256    self_ref: RefCell<Weak<MemoInner<T>>>,
257    source: incular_core::reactivity::DependencySource,
258}
259
260impl<T> MemoInner<T>
261where
262    T: PartialEq + 'static,
263{
264    fn new(compute: impl FnMut() -> T + 'static) -> Rc<Self> {
265        let inner = Rc::new(Self {
266            id: super::NEXT_REACTIVE_NODE
267                .fetch_update(
268                    std::sync::atomic::Ordering::Relaxed,
269                    std::sync::atomic::Ordering::Relaxed,
270                    |id| id.checked_add(1),
271                )
272                .expect("reactive node identity space exhausted"),
273            value: RefCell::new(None),
274            compute: RefCell::new(Box::new(compute)),
275            dirty: Cell::new(true),
276            evaluating: Cell::new(false),
277            runtime: RefCell::new(HashMap::new()),
278            sources: RefCell::new(HashMap::new()),
279            self_ref: RefCell::new(Weak::new()),
280            source: incular_core::reactivity::DependencySource::default(),
281        });
282        *inner.self_ref.borrow_mut() = Rc::downgrade(&inner);
283        inner
284    }
285
286    fn ensure_fresh(self: &Rc<Self>) {
287        let current = current_binding();
288        let bound = self.runtime.borrow();
289        let needs_tracking = current
290            .as_ref()
291            .is_some_and(|binding| !bound.contains_key(&binding.root));
292        drop(bound);
293        let unbound = self.runtime.borrow().is_empty() && current.is_none();
294        if (unbound || self.value.borrow().is_none() || self.dirty.get() || needs_tracking)
295            && self.recompute(current)
296        {
297            self.notify_dependents();
298        }
299    }
300
301    fn recompute(self: &Rc<Self>, current: Option<ReactiveBinding>) -> bool {
302        assert!(
303            !self.evaluating.replace(true),
304            "reactive memo dependency cycle"
305        );
306        let _evaluating = BoolGuard(&self.evaluating);
307        let _read_only = incular_core::reactivity::ReadOnlyGuard::enter();
308        let binding = current.or_else(|| self.runtime.borrow().values().next().cloned());
309        if let Some(binding) = &binding {
310            detach_sources_for(binding.root, &self.sources);
311        }
312        let value = if let Some(binding) = binding.clone() {
313            let node: Rc<dyn ReactiveNode> = self.clone();
314            with_tracking(binding, node, || (self.compute.borrow_mut())())
315        } else {
316            (self.compute.borrow_mut())()
317        };
318        let changed = {
319            let mut previous = self.value.borrow_mut();
320            let changed = match previous.as_ref() {
321                Some(previous) => previous != &value,
322                None => true,
323            };
324            *previous = Some(value);
325            changed
326        };
327        self.dirty.set(false);
328        if let Some(binding) = binding {
329            self.runtime
330                .borrow_mut()
331                .insert(binding.root, binding.clone());
332            // A shared memo has one value, so every root must observe the same
333            // newly selected branch. Refresh other roots without recomputing.
334            let dependencies = self
335                .sources
336                .borrow()
337                .get(&binding.root)
338                .map(|sources| {
339                    sources
340                        .iter()
341                        .map(|tracked| {
342                            tracked.dependency.upgrade().unwrap_or_else(|| {
343                                Rc::new(SourceDependency(tracked.source.clone()))
344                            })
345                        })
346                        .collect::<Vec<_>>()
347                })
348                .unwrap_or_default();
349            let others = self
350                .runtime
351                .borrow()
352                .values()
353                .filter(|other| other.root != binding.root)
354                .cloned()
355                .collect::<Vec<_>>();
356            for other in others {
357                let node: Rc<dyn ReactiveNode> = self.clone();
358                let subscriptions = dependencies
359                    .iter()
360                    .map(|dependency| {
361                        subscribe_node(
362                            dependency.clone(),
363                            other.root,
364                            self.id,
365                            Rc::downgrade(&node),
366                            other.queue.clone(),
367                        )
368                    })
369                    .collect();
370                self.sources.borrow_mut().insert(other.root, subscriptions);
371            }
372        }
373        changed
374    }
375
376    fn notify_dependents(&self) {
377        self.source.notify();
378    }
379
380    fn register_node(&self, queue: &Rc<RefCell<ReactiveQueue>>) {
381        let Some(this) = self.self_ref.borrow().upgrade() else {
382            return;
383        };
384        let node: Rc<dyn ReactiveNode> = this;
385        queue
386            .borrow_mut()
387            .register_node(self.id, Rc::downgrade(&node));
388    }
389
390    fn dispose_root(&self, root: ReactiveRootId) {
391        detach_sources_for(root, &self.sources);
392        self.runtime.borrow_mut().remove(&root);
393        if self.runtime.borrow().is_empty() {
394            self.dirty.set(true);
395        }
396    }
397
398    fn record_source(&self, dependency: TrackedDependency) {
399        let root = current_binding().map(|binding| binding.root);
400        let Some(root) = root.or_else(|| self.runtime.borrow().keys().next().copied()) else {
401            return;
402        };
403        let mut sources = self.sources.borrow_mut();
404        let entries = sources.entry(root).or_default();
405        if !entries
406            .iter()
407            .any(|source| source.source_id == dependency.source_id)
408        {
409            entries.push(dependency);
410        }
411    }
412}
413
414impl<T> ReactiveNode for MemoInner<T>
415where
416    T: PartialEq + 'static,
417{
418    fn id(&self) -> ReactiveNodeId {
419        self.id
420    }
421
422    fn mark_dirty(&self) {
423        self.dirty.set(true);
424    }
425
426    fn run(&self, root: ReactiveRootId) {
427        if !self.dirty.get() {
428            return;
429        }
430        let Some(this) = self.self_ref.borrow().upgrade() else {
431            return;
432        };
433        let binding = self.runtime.borrow().get(&root).cloned();
434        if this.recompute(binding) {
435            this.notify_dependents();
436        }
437    }
438
439    fn record_dependency(&self, dependency: TrackedDependency) {
440        self.record_source(dependency);
441    }
442
443    fn dispose(&self, root: ReactiveRootId) {
444        self.dispose_root(root);
445    }
446}
447
448impl<T: PartialEq + 'static> Dependency for MemoInner<T> {
449    fn skips_current_consumer(&self) -> bool {
450        true
451    }
452    fn source(&self) -> &incular_core::reactivity::DependencySource {
453        &self.source
454    }
455    fn prepare_subscription(&self, queue: &Rc<RefCell<ReactiveQueue>>) {
456        self.register_node(queue);
457    }
458}
459
460/// A lazily evaluated, cached value derived from one or more [`super::Signal`]s
461/// or other reactive values.
462#[derive(Clone)]
463pub struct Memo<T> {
464    inner: Rc<MemoInner<T>>,
465}
466
467impl<T> Memo<T>
468where
469    T: PartialEq + 'static,
470{
471    /// Creates a memo. The function is not evaluated until the memo is read.
472    /// Reads outside a runtime evaluate directly until a runtime owns its
473    /// subscriptions; this avoids caching a value with no invalidation owner.
474    #[must_use]
475    pub fn new(compute: impl Fn() -> T + 'static) -> Self {
476        Self {
477            inner: MemoInner::new(compute),
478        }
479    }
480}
481
482impl<T> Memo<T>
483where
484    T: Clone + PartialEq + 'static,
485{
486    /// Reads the current value and subscribes the current builder or reactive
487    /// computation to this memo.
488    pub fn get(&self) -> T {
489        track_dependency(self.inner.clone());
490        self.inner.ensure_fresh();
491        self.inner
492            .value
493            .borrow()
494            .as_ref()
495            .expect("memo value initialized after evaluation")
496            .clone()
497    }
498
499    /// Reads the value without subscribing the current builder. The memo's
500    /// own source dependencies remain tracked when it needs evaluation.
501    pub fn get_untracked(&self) -> T {
502        self.inner.ensure_fresh();
503        self.inner
504            .value
505            .borrow()
506            .as_ref()
507            .expect("memo value initialized after evaluation")
508            .clone()
509    }
510
511    /// Reads by reference after ensuring that the memo is current.
512    pub fn with<R>(&self, read: impl FnOnce(&T) -> R) -> R {
513        track_dependency(self.inner.clone());
514        self.inner.ensure_fresh();
515        let value = self.inner.value.borrow();
516        read(
517            value
518                .as_ref()
519                .expect("memo value initialized after evaluation"),
520        )
521    }
522
523    /// Reference-based untracked read.
524    pub fn with_untracked<R>(&self, read: impl FnOnce(&T) -> R) -> R {
525        self.inner.ensure_fresh();
526        let value = self.inner.value.borrow();
527        read(
528            value
529                .as_ref()
530                .expect("memo value initialized after evaluation"),
531        )
532    }
533
534    #[must_use]
535    pub fn dependent_count(&self) -> usize {
536        self.inner.source.subscriber_count()
537    }
538}
539
540struct EffectInner {
541    id: ReactiveNodeId,
542    callback: RefCell<Box<dyn FnMut()>>,
543    dirty: Cell<bool>,
544    running: Cell<bool>,
545    mounted: Cell<bool>,
546    runtime: RefCell<Option<ReactiveBinding>>,
547    sources: RefCell<Vec<(ReactiveRootId, TrackedDependency)>>,
548    self_ref: RefCell<Weak<EffectInner>>,
549}
550
551impl EffectInner {
552    fn new(callback: impl FnMut() + 'static) -> Rc<Self> {
553        let inner = Rc::new(Self {
554            id: super::NEXT_REACTIVE_NODE
555                .fetch_update(
556                    std::sync::atomic::Ordering::Relaxed,
557                    std::sync::atomic::Ordering::Relaxed,
558                    |id| id.checked_add(1),
559                )
560                .expect("reactive node identity space exhausted"),
561            callback: RefCell::new(Box::new(callback)),
562            dirty: Cell::new(false),
563            running: Cell::new(false),
564            mounted: Cell::new(false),
565            runtime: RefCell::new(None),
566            sources: RefCell::new(Vec::new()),
567            self_ref: RefCell::new(Weak::new()),
568        });
569        *inner.self_ref.borrow_mut() = Rc::downgrade(&inner);
570        inner
571    }
572
573    fn mount(&self, binding: ReactiveBinding) -> bool {
574        let same_root = self
575            .runtime
576            .borrow()
577            .as_ref()
578            .is_some_and(|current| current.root == binding.root);
579        if self.mounted.get() && same_root {
580            return true;
581        }
582        if self.mounted.get() {
583            detach_sources(&self.sources);
584        }
585        self.mounted.set(true);
586        self.dirty.set(true);
587        *self.runtime.borrow_mut() = Some(binding.clone());
588        let Some(queue) = binding.queue.upgrade() else {
589            self.mounted.set(false);
590            self.dirty.set(false);
591            *self.runtime.borrow_mut() = None;
592            return false;
593        };
594        let node: Rc<dyn ReactiveNode> = self
595            .self_ref
596            .borrow()
597            .upgrade()
598            .expect("mounted effect still alive");
599        queue
600            .borrow_mut()
601            .enqueue_node(self.id, Rc::downgrade(&node));
602        true
603    }
604
605    fn run(&self) {
606        if !self.mounted.get() || !self.dirty.get() || self.running.replace(true) {
607            return;
608        }
609        let _running = BoolGuard(&self.running);
610        self.dirty.set(false);
611        let Some(binding) = self.runtime.borrow().clone() else {
612            return;
613        };
614        detach_sources(&self.sources);
615        let node: Rc<dyn ReactiveNode> = self
616            .self_ref
617            .borrow()
618            .upgrade()
619            .expect("running effect still alive");
620        with_tracking(binding, node, || (self.callback.borrow_mut())());
621    }
622
623    fn record_source(&self, dependency: TrackedDependency) {
624        let Some(root) = self.runtime.borrow().as_ref().map(|binding| binding.root) else {
625            return;
626        };
627        let mut sources = self.sources.borrow_mut();
628        if !sources.iter().any(|(source_root, source)| {
629            *source_root == root && source.source_id == dependency.source_id
630        }) {
631            sources.push((root, dependency));
632        }
633    }
634}
635
636impl ReactiveNode for EffectInner {
637    fn owns_element(&self, root: ReactiveRootId, element: ElementId) -> bool {
638        self.runtime
639            .borrow()
640            .as_ref()
641            .is_some_and(|binding| binding.root == root && binding.owner_element == Some(element))
642    }
643    fn id(&self) -> ReactiveNodeId {
644        self.id
645    }
646
647    fn mark_dirty(&self) {
648        self.dirty.set(true);
649    }
650
651    fn run(&self, _root: ReactiveRootId) {
652        Self::run(self);
653    }
654
655    fn record_dependency(&self, dependency: TrackedDependency) {
656        self.record_source(dependency);
657    }
658
659    fn dispose(&self, root: ReactiveRootId) {
660        let owns_root = self
661            .runtime
662            .borrow()
663            .as_ref()
664            .is_some_and(|binding| binding.root == root);
665        if owns_root {
666            detach_sources(&self.sources);
667            self.mounted.set(false);
668            self.dirty.set(false);
669            *self.runtime.borrow_mut() = None;
670        }
671    }
672}
673
674/// An owner-mounted side effect. Effects never run as part of a widget build;
675/// [`Effect::mount`] queues the initial run for the runtime's reactive phase.
676#[derive(Clone)]
677pub struct Effect {
678    inner: Rc<EffectInner>,
679}
680
681impl Effect {
682    #[must_use]
683    pub fn new(callback: impl FnMut() + 'static) -> Self {
684        Self {
685            inner: EffectInner::new(callback),
686        }
687    }
688
689    /// Mounts this effect into the currently executing Incular builder. It is
690    /// idempotent, so a stable handle can safely call this from a rebuilding
691    /// root without creating duplicate effects.
692    pub fn mount(&self) -> bool {
693        let Some(binding) = current_binding() else {
694            return false;
695        };
696        self.inner.mount(binding)
697    }
698
699    #[must_use]
700    pub fn is_mounted(&self) -> bool {
701        self.inner.mounted.get()
702    }
703
704    pub fn dispose(&self) {
705        let root = self
706            .inner
707            .runtime
708            .borrow()
709            .as_ref()
710            .map(|binding| binding.root);
711        if let Some(root) = root {
712            self.inner.dispose(root);
713        }
714    }
715}
716
717/// Error returned when an action is dispatched before it has been observed by
718/// or mounted into an Incular application.
719#[derive(Clone, Copy, Debug, PartialEq, Eq)]
720pub enum ActionDispatchError {
721    NotMounted,
722}
723
724/// Failure from an [`Action`] operation or from the Incular task runtime.
725#[derive(Clone, Debug, PartialEq, Eq)]
726pub enum ActionError<E> {
727    Operation(E),
728    Runtime(TaskFailure),
729}
730
731/// UI-observable lifecycle state for an [`Action`].
732#[derive(Clone, Debug, PartialEq, Eq)]
733pub enum ActionState<T, E> {
734    Idle,
735    Loading,
736    Ready(T),
737    Error(ActionError<E>),
738}
739
740impl<T, E> ActionState<T, E> {
741    #[must_use]
742    pub const fn is_idle(&self) -> bool {
743        matches!(self, Self::Idle)
744    }
745
746    #[must_use]
747    pub const fn is_loading(&self) -> bool {
748        matches!(self, Self::Loading)
749    }
750
751    #[must_use]
752    pub const fn is_ready(&self) -> bool {
753        matches!(self, Self::Ready(_))
754    }
755
756    #[must_use]
757    pub const fn is_error(&self) -> bool {
758        matches!(self, Self::Error(_))
759    }
760}
761
762type ActionFuture<O, E> = Pin<Box<dyn Future<Output = Result<O, E>> + Send + 'static>>;
763
764#[derive(Clone)]
765struct ActionBinding {
766    root: ReactiveRootId,
767    spawner: super::tasks::RuntimeSpawner,
768    owner_scope: super::tasks::TaskScope,
769}
770
771struct ActionInner<I, O, E> {
772    operation: Rc<dyn Fn(I) -> ActionFuture<O, E>>,
773    state: Signal<ActionState<O, E>>,
774    binding: RefCell<Option<ActionBinding>>,
775    active: RefCell<Option<TaskHandle>>,
776    generation: Cell<u64>,
777}
778
779impl<I, O, E> Drop for ActionInner<I, O, E> {
780    fn drop(&mut self) {
781        if let Some(active) = self.active.get_mut().take() {
782            active.cancel();
783        }
784    }
785}
786
787/// An explicitly dispatched asynchronous operation with reactive lifecycle
788/// state. The operation is never started merely because the action is read.
789#[derive(Clone)]
790pub struct Action<I, O, E> {
791    inner: Rc<ActionInner<I, O, E>>,
792}
793
794impl<I, O, E> Action<I, O, E>
795where
796    I: 'static,
797    O: Clone + Send + 'static,
798    E: Clone + Send + 'static,
799{
800    /// Creates an action. `operation` is invoked only by [`Self::dispatch`].
801    #[must_use]
802    pub fn new<F, Fut>(operation: F) -> Self
803    where
804        F: Fn(I) -> Fut + 'static,
805        Fut: Future<Output = Result<O, E>> + Send + 'static,
806    {
807        let operation: Rc<dyn Fn(I) -> ActionFuture<O, E>> =
808            Rc::new(move |input| Box::pin(operation(input)));
809        Self {
810            inner: Rc::new(ActionInner {
811                operation,
812                state: Signal::new(ActionState::Idle),
813                binding: RefCell::new(None),
814                active: RefCell::new(None),
815                generation: Cell::new(0),
816            }),
817        }
818    }
819
820    fn bind_current(&self) {
821        let Some(binding) = current_binding() else {
822            return;
823        };
824        let (Some(spawner), Some(owner_scope)) = (binding.spawner, binding.owner_scope) else {
825            return;
826        };
827        let mut current = self.inner.binding.borrow_mut();
828        let same_root = current.as_ref().is_some_and(|current| {
829            current.root == binding.root && !current.owner_scope.is_cancelled()
830        });
831        if !same_root {
832            *current = Some(ActionBinding {
833                root: binding.root,
834                spawner,
835                owner_scope,
836            });
837        }
838    }
839
840    /// Mounts the action to the current builder's owner without requiring a
841    /// `BuildContext` argument. Reading [`Self::state`] also mounts it.
842    pub fn mount(&self) -> bool {
843        self.bind_current();
844        self.inner
845            .binding
846            .borrow()
847            .as_ref()
848            .is_some_and(|binding| !binding.owner_scope.is_cancelled())
849    }
850
851    /// Returns the current state and subscribes the current builder to it.
852    pub fn state(&self) -> ActionState<O, E> {
853        self.bind_current();
854        self.inner.state.get()
855    }
856
857    /// Returns the underlying state signal for advanced composition.
858    pub fn state_signal(&self) -> Signal<ActionState<O, E>> {
859        self.bind_current();
860        self.inner.state.clone()
861    }
862
863    /// Dispatches the operation. A second dispatch cancels the previous
864    /// operation logically; a late completion cannot overwrite the latest
865    /// result.
866    pub fn dispatch(&self, input: I) -> Result<(), ActionDispatchError> {
867        let binding = {
868            let mut current = self.inner.binding.borrow_mut();
869            let Some(binding) = current.clone() else {
870                return Err(ActionDispatchError::NotMounted);
871            };
872            if binding.owner_scope.is_cancelled() {
873                *current = None;
874                return Err(ActionDispatchError::NotMounted);
875            }
876            binding
877        };
878        if let Some(active) = self.inner.active.borrow_mut().take() {
879            active.cancel();
880        }
881        let generation = self
882            .inner
883            .generation
884            .get()
885            .checked_add(1)
886            .expect("action generation exhausted");
887        self.inner.generation.set(generation);
888        self.inner
889            .state
890            .update(|state| *state = ActionState::Loading);
891
892        let operation = self.inner.operation.clone();
893        let weak = Rc::downgrade(&self.inner);
894        let handle = binding.spawner.spawn_into_in(
895            &binding.owner_scope,
896            operation(input),
897            move |result, _runtime| {
898                let Some(inner) = weak.upgrade() else {
899                    return;
900                };
901                if inner.generation.get() != generation {
902                    return;
903                }
904                inner.active.borrow_mut().take();
905                inner.state.update(|state| {
906                    *state = match result {
907                        Ok(Ok(value)) => ActionState::Ready(value),
908                        Ok(Err(error)) => ActionState::Error(ActionError::Operation(error)),
909                        Err(error) => ActionState::Error(ActionError::Runtime(error)),
910                    };
911                });
912            },
913        );
914        *self.inner.active.borrow_mut() = Some(handle);
915        Ok(())
916    }
917
918    /// Cancels the active operation and exposes a runtime cancellation error.
919    pub fn cancel(&self) {
920        let generation = self
921            .inner
922            .generation
923            .get()
924            .checked_add(1)
925            .expect("action generation exhausted");
926        self.inner.generation.set(generation);
927        if let Some(active) = self.inner.active.borrow_mut().take() {
928            active.cancel();
929            self.inner.state.update(|state| {
930                *state = ActionState::Error(ActionError::Runtime(TaskFailure::Cancelled));
931            });
932        }
933    }
934}