Skip to main content

cranpose_core/
launched_effect.rs

1use crate::{hash_key, with_current_composer, Key, RuntimeHandle, TaskHandle};
2#[cfg(not(target_arch = "wasm32"))]
3use std::cell::{Cell, RefCell};
4use std::future::Future;
5use std::hash::Hash;
6use std::pin::Pin;
7#[cfg(not(target_arch = "wasm32"))]
8use std::rc::Rc;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11
12#[derive(Default)]
13struct LaunchedEffectState {
14    key: Option<Key>,
15    cancel: Option<LaunchedEffectCancellation>,
16}
17
18struct LaunchedEffectCancellation {
19    #[cfg(not(target_arch = "wasm32"))]
20    runtime: RuntimeHandle,
21    active: Arc<AtomicBool>,
22    #[cfg(not(target_arch = "wasm32"))]
23    continuations: Rc<RefCell<Vec<u64>>>,
24}
25
26/// Where an effect was written, carried onto the task it spawns.
27///
28/// A leak report that can only say "one task is still queued" names nothing:
29/// every run prints the same number and no run says which effect it was. This
30/// is what turns that count into a place to look, and it stays a pair of plain
31/// fields so carrying it costs nothing until a task is actually spawned.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub struct TaskSite {
34    pub file: &'static str,
35    pub line: u32,
36}
37
38impl TaskSite {
39    /// The site a macro captured with `file!()` and `line!()`.
40    pub const fn new(file: &'static str, line: u32) -> TaskSite {
41        TaskSite { file, line }
42    }
43}
44
45impl Default for TaskSite {
46    fn default() -> TaskSite {
47        TaskSite::new("unknown", 0)
48    }
49}
50
51impl std::fmt::Display for TaskSite {
52    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(formatter, "{}:{}", self.file, self.line)
54    }
55}
56
57impl From<&'static std::panic::Location<'static>> for TaskSite {
58    /// The site a `#[track_caller]` function was called from.
59    fn from(location: &'static std::panic::Location<'static>) -> TaskSite {
60        TaskSite::new(location.file(), location.line())
61    }
62}
63
64#[derive(Default)]
65struct LaunchedEffectAsyncState {
66    key: Option<Key>,
67    cancel: Option<LaunchedEffectCancellation>,
68    task: Option<TaskHandle>,
69    site: TaskSite,
70}
71
72impl LaunchedEffectState {
73    fn should_run(&self, key: Key) -> bool {
74        match self.key {
75            Some(current) => current != key,
76            None => true,
77        }
78    }
79
80    fn set_key(&mut self, key: Key) {
81        self.key = Some(key);
82    }
83
84    fn launch(
85        &mut self,
86        runtime: RuntimeHandle,
87        effect: impl FnOnce(LaunchedEffectScope) + 'static,
88    ) {
89        self.cancel_current();
90        let active = Arc::new(AtomicBool::new(true));
91        #[cfg(not(target_arch = "wasm32"))]
92        let continuations = Rc::new(RefCell::new(Vec::new()));
93        self.cancel = Some(LaunchedEffectCancellation {
94            #[cfg(not(target_arch = "wasm32"))]
95            runtime: runtime.clone(),
96            active: Arc::clone(&active),
97            #[cfg(not(target_arch = "wasm32"))]
98            continuations: Rc::clone(&continuations),
99        });
100        let scope = LaunchedEffectScope {
101            active: Arc::clone(&active),
102            runtime: runtime.clone(),
103            #[cfg(not(target_arch = "wasm32"))]
104            continuations,
105        };
106        runtime.enqueue_ui_task(Box::new(move || effect(scope)));
107    }
108
109    fn cancel_current(&mut self) {
110        if let Some(cancel) = self.cancel.take() {
111            cancel.cancel();
112        }
113    }
114}
115
116impl LaunchedEffectCancellation {
117    fn cancel(&self) {
118        self.active.store(false, Ordering::SeqCst);
119        #[cfg(not(target_arch = "wasm32"))]
120        {
121            let mut pending = self.continuations.borrow_mut();
122            for id in pending.drain(..) {
123                self.runtime.cancel_ui_cont(id);
124            }
125        }
126    }
127}
128
129impl LaunchedEffectAsyncState {
130    fn should_run(&self, key: Key) -> bool {
131        match self.key {
132            Some(current) => current != key,
133            None => true,
134        }
135    }
136
137    fn set_key(&mut self, key: Key) {
138        self.key = Some(key);
139    }
140
141    fn set_site(&mut self, site: TaskSite) {
142        self.site = site;
143    }
144
145    fn launch(
146        &mut self,
147        runtime: RuntimeHandle,
148        mk_future: impl FnOnce(LaunchedEffectScope) -> Pin<Box<dyn Future<Output = ()>>> + 'static,
149    ) {
150        self.cancel_current();
151        let active = Arc::new(AtomicBool::new(true));
152        #[cfg(not(target_arch = "wasm32"))]
153        let continuations = Rc::new(RefCell::new(Vec::new()));
154        self.cancel = Some(LaunchedEffectCancellation {
155            #[cfg(not(target_arch = "wasm32"))]
156            runtime: runtime.clone(),
157            active: Arc::clone(&active),
158            #[cfg(not(target_arch = "wasm32"))]
159            continuations: Rc::clone(&continuations),
160        });
161        let scope = LaunchedEffectScope {
162            active: Arc::clone(&active),
163            runtime: runtime.clone(),
164            #[cfg(not(target_arch = "wasm32"))]
165            continuations,
166        };
167        let future = mk_future(scope.clone());
168        let active_flag = Arc::clone(&scope.active);
169        crate::label_next_ui_task(self.site.to_string());
170        match runtime.spawn_ui(async move {
171            future.await;
172            active_flag.store(false, Ordering::SeqCst);
173        }) {
174            Some(handle) => {
175                self.task = Some(handle);
176            }
177            None => {
178                active.store(false, Ordering::SeqCst);
179                self.cancel = None;
180            }
181        }
182    }
183
184    fn cancel_current(&mut self) {
185        if let Some(handle) = self.task.take() {
186            handle.cancel();
187        }
188        if let Some(cancel) = self.cancel.take() {
189            cancel.cancel();
190        }
191    }
192}
193
194impl Drop for LaunchedEffectState {
195    fn drop(&mut self) {
196        self.cancel_current();
197    }
198}
199
200impl Drop for LaunchedEffectAsyncState {
201    fn drop(&mut self) {
202        self.cancel_current();
203    }
204}
205
206#[derive(Clone)]
207pub struct LaunchedEffectScope {
208    active: Arc<AtomicBool>,
209    runtime: RuntimeHandle,
210    #[cfg(not(target_arch = "wasm32"))]
211    continuations: Rc<RefCell<Vec<u64>>>,
212}
213
214impl LaunchedEffectScope {
215    #[cfg(not(target_arch = "wasm32"))]
216    fn track_continuation(&self, id: u64) {
217        self.continuations.borrow_mut().push(id);
218    }
219
220    #[cfg(not(target_arch = "wasm32"))]
221    fn release_continuation(&self, id: u64) {
222        let mut continuations = self.continuations.borrow_mut();
223        if let Some(index) = continuations.iter().position(|entry| *entry == id) {
224            continuations.remove(index);
225        }
226    }
227
228    pub fn is_active(&self) -> bool {
229        self.active.load(Ordering::SeqCst)
230    }
231
232    pub fn runtime(&self) -> RuntimeHandle {
233        self.runtime.clone()
234    }
235
236    /// Runs a follow-up `LaunchedEffect` task on the UI thread.
237    ///
238    /// The provided closure executes on the runtime thread and may freely
239    /// capture `Rc`/`RefCell` state. This must only be called from the UI
240    /// thread, typically inside another effect callback.
241    pub fn launch(&self, task: impl FnOnce(LaunchedEffectScope) + 'static) {
242        if !self.is_active() {
243            return;
244        }
245        let scope = self.clone();
246        self.runtime.enqueue_ui_task(Box::new(move || {
247            if scope.is_active() {
248                task(scope);
249            }
250        }));
251    }
252
253    /// Posts UI-only work that will execute on the runtime thread.
254    ///
255    /// The closure never crosses threads, so it may capture non-`Send` values.
256    /// Callers must invoke this from the UI thread.
257    pub fn post_ui(&self, task: impl FnOnce() + 'static) {
258        if !self.is_active() {
259            return;
260        }
261        let active = Arc::clone(&self.active);
262        self.runtime.enqueue_ui_task(Box::new(move || {
263            if active.load(Ordering::SeqCst) {
264                task();
265            }
266        }));
267    }
268
269    /// Runs background work and delivers results to the UI.
270    ///
271    /// On native targets, `work` runs on a worker thread and its future is
272    /// driven to completion there. On WASM, `work` runs as a task on the
273    /// browser event loop. The `on_ui` continuation always runs on the runtime
274    /// thread, so it may capture `Rc`/`RefCell` state safely.
275    #[cfg(not(target_arch = "wasm32"))]
276    pub fn launch_background<T, Work, Ui, Fut>(&self, work: Work, on_ui: Ui)
277    where
278        T: Send + 'static,
279        Work: FnOnce(CancelToken) -> Fut + Send + 'static,
280        Fut: Future<Output = T> + Send + 'static,
281        Ui: FnOnce(T) + 'static,
282    {
283        if !self.is_active() {
284            return;
285        }
286        let dispatcher = self.runtime.dispatcher();
287        let active_for_thread = Arc::clone(&self.active);
288        let continuation_scope = self.clone();
289        let continuation_active = Arc::clone(&self.active);
290        let id_cell = Rc::new(Cell::new(0));
291        let id_for_closure = Rc::clone(&id_cell);
292        let continuation = move |value: T| {
293            let id = id_for_closure.get();
294            continuation_scope.release_continuation(id);
295            if continuation_active.load(Ordering::SeqCst) {
296                on_ui(value);
297            }
298        };
299
300        let Some(cont_id) = self.runtime.register_ui_cont(continuation) else {
301            return;
302        };
303        id_cell.set(cont_id);
304        self.track_continuation(cont_id);
305
306        std::thread::spawn(move || {
307            let token = CancelToken::new(Arc::clone(&active_for_thread));
308            let value = pollster::block_on(work(token.clone()));
309            if token.is_cancelled() {
310                return;
311            }
312            dispatcher.post_invoke(cont_id, value);
313        });
314    }
315
316    /// Runs background work and delivers results to the UI.
317    ///
318    /// On native targets, `work` runs on a worker thread and its future is
319    /// driven to completion there. On WASM, `work` runs as a task on the
320    /// browser event loop. The `on_ui` continuation always runs on the runtime
321    /// thread, so it may capture `Rc`/`RefCell` state safely.
322    #[cfg(target_arch = "wasm32")]
323    pub fn launch_background<T, Work, Ui, Fut>(&self, work: Work, on_ui: Ui)
324    where
325        T: 'static,
326        Work: FnOnce(CancelToken) -> Fut + 'static,
327        Fut: Future<Output = T> + 'static,
328        Ui: FnOnce(T) + 'static,
329    {
330        if !self.is_active() {
331            return;
332        }
333        let active_for_task = Arc::clone(&self.active);
334        let scope = self.clone();
335        wasm_bindgen_futures::spawn_local(async move {
336            let token = CancelToken::new(Arc::clone(&active_for_task));
337            let value = work(token.clone()).await;
338            if token.is_cancelled() {
339                return;
340            }
341            scope.post_ui(move || {
342                if token.is_active() {
343                    on_ui(value);
344                }
345            });
346        });
347    }
348}
349
350#[derive(Clone)]
351/// Cooperative cancellation token passed into background `LaunchedEffect` work.
352///
353/// The token flips to "cancelled" when the associated scope leaves composition.
354/// Callers should periodically check [`CancelToken::is_cancelled`] in long-running
355/// operations and exit early; blocking I/O will not be interrupted automatically.
356pub struct CancelToken {
357    active: Arc<AtomicBool>,
358}
359
360impl CancelToken {
361    fn new(active: Arc<AtomicBool>) -> Self {
362        Self { active }
363    }
364
365    /// Returns `true` once the associated scope has been cancelled.
366    pub fn is_cancelled(&self) -> bool {
367        !self.active.load(Ordering::SeqCst)
368    }
369
370    /// Returns whether the scope is still active.
371    pub fn is_active(&self) -> bool {
372        self.active.load(Ordering::SeqCst)
373    }
374}
375
376pub fn __launched_effect_impl<K, F>(group_key: Key, keys: K, effect: F)
377where
378    K: Hash,
379    F: FnOnce(LaunchedEffectScope) + 'static,
380{
381    // Create a group using the caller's location to ensure each LaunchedEffect
382    // gets its own slot table entry, even in conditional branches
383    with_current_composer(|composer| {
384        composer.with_group(group_key, |composer| {
385            let key_hash = hash_key(&keys);
386            let state = composer.remember_effect::<LaunchedEffectState>();
387            if state.with(|state| state.should_run(key_hash)) {
388                state.update(|state| state.set_key(key_hash));
389                let runtime = composer.runtime_handle();
390                let state_for_effect = state.clone();
391                let mut effect_opt = Some(effect);
392                composer.register_side_effect(move || {
393                    if let Some(effect) = effect_opt.take() {
394                        state_for_effect.update(|state| state.launch(runtime.clone(), effect));
395                    }
396                });
397            }
398        });
399    });
400}
401
402#[macro_export]
403macro_rules! LaunchedEffect {
404    ($keys:expr, $effect:expr) => {
405        $crate::__launched_effect_impl(
406            $crate::location_key(file!(), line!(), column!()),
407            $keys,
408            $effect,
409        )
410    };
411}
412
413/// `site` is where the effect was written. It travels onto the task the effect
414/// spawns, so a task the runtime is still holding says which effect started it.
415pub fn __launched_effect_async_impl<K, F>(group_key: Key, site: TaskSite, keys: K, mk_future: F)
416where
417    K: Hash,
418    F: FnOnce(LaunchedEffectScope) -> Pin<Box<dyn Future<Output = ()>>> + 'static,
419{
420    with_current_composer(|composer| {
421        composer.with_group(group_key, |composer| {
422            let key_hash = hash_key(&keys);
423            let state = composer.remember_effect::<LaunchedEffectAsyncState>();
424            if state.with(|state| state.should_run(key_hash)) {
425                state.update(|state| {
426                    state.set_key(key_hash);
427                    state.set_site(site);
428                });
429                let runtime = composer.runtime_handle();
430                let state_for_effect = state.clone();
431                let mut mk_future_opt = Some(mk_future);
432                composer.register_side_effect(move || {
433                    if let Some(mk_future) = mk_future_opt.take() {
434                        state_for_effect.update(|state| {
435                            state.launch(runtime.clone(), mk_future);
436                        });
437                    }
438                });
439            }
440        });
441    });
442}
443
444#[macro_export]
445macro_rules! LaunchedEffectAsync {
446    ($keys:expr, $future:expr) => {
447        $crate::__launched_effect_async_impl(
448            $crate::location_key(file!(), line!(), column!()),
449            $crate::TaskSite::new(file!(), line!()),
450            $keys,
451            $future,
452        )
453    };
454}