Skip to main content

pocopine_core/
task.rs

1//! Scoped async task helpers.
2//!
3//! `spawn_scoped` and `spawn_latest` tie task lifetime to the current
4//! component scope. Cancellation in v1 is cooperative: cancelling a task
5//! flips a flag and drops it from the scope registry, but a future that
6//! never checks that flag may still run to completion.
7
8use std::borrow::Cow;
9use std::cell::{Cell, RefCell};
10use std::collections::HashMap;
11use std::future::Future;
12use std::rc::Rc;
13
14use wasm_bindgen_futures::spawn_local;
15
16use crate::reactive::ScopeId;
17use crate::scope::current_scope_id;
18
19#[derive(Default)]
20struct ScopeTasks {
21    tasks: Vec<Rc<TaskState>>,
22    latest: HashMap<String, Rc<TaskState>>,
23}
24
25struct TaskState {
26    cancelled: Cell<bool>,
27}
28
29#[derive(Clone)]
30pub struct TaskHandle {
31    inner: Rc<TaskState>,
32}
33
34thread_local! {
35    static TASKS: RefCell<HashMap<ScopeId, ScopeTasks>> = RefCell::new(HashMap::new());
36}
37
38impl TaskHandle {
39    fn new() -> Self {
40        Self {
41            inner: Rc::new(TaskState {
42                cancelled: Cell::new(false),
43            }),
44        }
45    }
46
47    fn cancelled() -> Self {
48        let handle = Self::new();
49        handle.cancel();
50        handle
51    }
52
53    pub fn cancel(&self) {
54        self.inner.cancelled.set(true);
55    }
56
57    pub fn is_cancelled(&self) -> bool {
58        self.inner.cancelled.get()
59    }
60}
61
62pub fn spawn(fut: impl Future<Output = ()> + 'static) {
63    spawn_local(fut);
64}
65
66pub fn spawn_scoped(fut: impl Future<Output = ()> + 'static) -> TaskHandle {
67    let scope_id = current_scope_id()
68        .expect("pocopine::spawn_scoped called outside a handler / lifecycle context");
69    spawn_for_scope(scope_id, fut)
70}
71
72/// Spawn a task tied to an explicit scope. If that scope has already
73/// unmounted, the future is dropped and the returned handle starts in
74/// the cancelled state.
75pub fn spawn_for_scope(scope_id: ScopeId, fut: impl Future<Output = ()> + 'static) -> TaskHandle {
76    if crate::scope::Scope::find(scope_id).is_none() {
77        return TaskHandle::cancelled();
78    }
79
80    let handle = TaskHandle::new();
81    TASKS.with(|tasks| {
82        tasks
83            .borrow_mut()
84            .entry(scope_id)
85            .or_default()
86            .tasks
87            .push(handle.inner.clone());
88    });
89    let inner = handle.inner.clone();
90    spawn_local(async move {
91        fut.await;
92        let _ = inner;
93    });
94    handle
95}
96
97pub fn spawn_latest(
98    task_name: impl Into<Cow<'static, str>>,
99    fut: impl Future<Output = ()> + 'static,
100) -> TaskHandle {
101    let scope_id = current_scope_id()
102        .expect("pocopine::spawn_latest called outside a handler / lifecycle context");
103    let task_name = task_name.into().into_owned();
104    spawn_latest_for_scope(scope_id, task_name, fut)
105}
106
107/// Spawn a latest-wins task tied to an explicit scope. Reusing
108/// `task_name` cancels the previous live task in that slot. If the
109/// scope has already unmounted, the future is dropped and the returned
110/// handle starts in the cancelled state.
111pub fn spawn_latest_for_scope(
112    scope_id: ScopeId,
113    task_name: impl Into<Cow<'static, str>>,
114    fut: impl Future<Output = ()> + 'static,
115) -> TaskHandle {
116    if crate::scope::Scope::find(scope_id).is_none() {
117        return TaskHandle::cancelled();
118    }
119
120    let task_name = task_name.into().into_owned();
121    let handle = TaskHandle::new();
122    TASKS.with(|tasks| {
123        let mut tasks = tasks.borrow_mut();
124        let scope_tasks = tasks.entry(scope_id).or_default();
125        if let Some(prev) = scope_tasks
126            .latest
127            .insert(task_name.clone(), handle.inner.clone())
128        {
129            prev.cancelled.set(true);
130        }
131        scope_tasks.tasks.push(handle.inner.clone());
132    });
133    let inner = handle.inner.clone();
134    spawn_local(async move {
135        fut.await;
136        TASKS.with(|tasks| {
137            let mut tasks = tasks.borrow_mut();
138            let Some(scope_tasks) = tasks.get_mut(&scope_id) else {
139                return;
140            };
141            if scope_tasks
142                .latest
143                .get(&task_name)
144                .is_some_and(|current| Rc::ptr_eq(current, &inner))
145            {
146                scope_tasks.latest.remove(&task_name);
147            }
148        });
149    });
150    handle
151}
152
153pub fn clear_scope(scope_id: ScopeId) {
154    TASKS.with(|tasks| {
155        if let Some(scope_tasks) = tasks.borrow_mut().remove(&scope_id) {
156            for task in scope_tasks.tasks {
157                task.cancelled.set(true);
158            }
159        }
160    });
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn spawn_for_scope_returns_cancelled_handle_when_scope_is_gone() {
169        let handle = spawn_for_scope(ScopeId(u64::MAX), async move {});
170
171        assert!(handle.is_cancelled());
172    }
173
174    #[test]
175    fn spawn_latest_for_scope_returns_cancelled_handle_when_scope_is_gone() {
176        let handle = spawn_latest_for_scope(ScopeId(u64::MAX), "search", async move {});
177
178        assert!(handle.is_cancelled());
179    }
180}