Skip to main content

cranpose_core/
launched_effect.rs

1#[cfg(not(target_arch = "wasm32"))]
2use std::cell::{Cell, RefCell};
3#[cfg(not(target_arch = "wasm32"))]
4use std::rc::Rc;
5use std::{
6    future::Future,
7    sync::{
8        Arc,
9        atomic::{AtomicBool, Ordering},
10    },
11};
12
13use crate::{
14    CoroutineScope, Key, Owned, RuntimeHandle, TaskSite, effect_key::EffectKey,
15    task_site::TaskLabel, with_current_composer,
16};
17
18#[derive(Default)]
19struct LaunchedEffectState {
20    key: Option<EffectKey>,
21    scope: Option<LaunchedEffectScope>,
22}
23
24impl LaunchedEffectState {
25    fn should_run<K: PartialEq + 'static>(&self, keys: &K) -> bool {
26        self.key.as_ref().is_none_or(|current| !current.holds(keys))
27    }
28}
29
30impl Drop for LaunchedEffectState {
31    fn drop(&mut self) {
32        if let Some(scope) = self.scope.take() {
33            scope.cancel();
34        }
35    }
36}
37
38/// The scope a [`LaunchedEffect`] receives: the effect's coroutine scope.
39///
40/// The effect is cancelled when its keys change or its call site leaves the
41/// composition. The cancellation drops the effect's future where it waits.
42/// No line after that `.await` runs. The cancellation also cancels every task
43/// the scope launched.
44#[derive(Clone)]
45pub struct LaunchedEffectScope {
46    active: Arc<AtomicBool>,
47    runtime: RuntimeHandle,
48    coroutines: CoroutineScope,
49    #[cfg(not(target_arch = "wasm32"))]
50    continuations: Rc<RefCell<Vec<u64>>>,
51}
52
53impl LaunchedEffectScope {
54    fn new(runtime: RuntimeHandle) -> Self {
55        Self {
56            active: Arc::new(AtomicBool::new(true)),
57            coroutines: CoroutineScope::new(Some(runtime.clone())),
58            runtime,
59            #[cfg(not(target_arch = "wasm32"))]
60            continuations: Rc::default(),
61        }
62    }
63
64    fn cancel(&self) {
65        self.active.store(false, Ordering::SeqCst);
66        self.coroutines.close();
67        #[cfg(not(target_arch = "wasm32"))]
68        {
69            let pending = std::mem::take(&mut *self.continuations.borrow_mut());
70            for id in pending {
71                self.runtime.cancel_ui_cont(id);
72            }
73        }
74    }
75
76    #[cfg(not(target_arch = "wasm32"))]
77    fn release_continuation(&self, id: u64) {
78        self.continuations.borrow_mut().retain(|entry| *entry != id);
79    }
80
81    /// Returns `true` until the effect is cancelled. The effect is cancelled
82    /// when its keys change or its call site leaves the composition.
83    pub fn is_active(&self) -> bool {
84        self.active.load(Ordering::SeqCst)
85    }
86
87    /// Returns a handle to the runtime that runs the effect.
88    pub fn runtime(&self) -> RuntimeHandle {
89        self.runtime.clone()
90    }
91
92    /// Waits for the next frame, then calls `on_frame` with the frame time in
93    /// nanoseconds and returns what it returns. Compose's `withFrameNanos`.
94    /// Await it in a loop to run once per frame:
95    ///
96    /// ```ignore
97    /// LaunchedEffect((), async move |scope| loop {
98    ///     scope.with_frame_nanos(|nanos| time.set(nanos)).await;
99    /// });
100    /// ```
101    #[cfg(feature = "internal")]
102    pub async fn with_frame_nanos<R>(&self, on_frame: impl FnOnce(u64) -> R) -> R {
103        on_frame(self.runtime.frame_clock().next_frame().await)
104    }
105
106    /// Waits for the next frame, then calls `on_frame` with the frame time in
107    /// milliseconds and returns what it returns. Compose's `withFrameMillis`.
108    #[cfg(feature = "internal")]
109    pub async fn with_frame_millis<R>(&self, on_frame: impl FnOnce(u64) -> R) -> R {
110        self.with_frame_nanos(|nanos| on_frame(nanos / 1_000_000))
111            .await
112    }
113
114    /// Launches `future` on the UI thread as a child of the effect, as
115    /// Compose's `launch` inside a `LaunchedEffect` does: the effect's
116    /// cancellation cancels it too.
117    pub fn launch(&self, future: impl Future<Output = ()> + 'static) {
118        self.coroutines.launch(future);
119    }
120
121    /// Runs background work and delivers results to the UI.
122    ///
123    /// On native targets, `work` runs on a worker thread. Its future runs to
124    /// completion there. The `on_ui` continuation runs on the runtime thread.
125    /// It may capture `Rc` and `RefCell` state. It does not run once the
126    /// effect is cancelled.
127    #[cfg(not(target_arch = "wasm32"))]
128    pub fn launch_background<T, Work, Ui, Fut>(&self, work: Work, on_ui: Ui)
129    where
130        T: Send + 'static,
131        Work: FnOnce(CancelToken) -> Fut + Send + 'static,
132        Fut: Future<Output = T> + Send + 'static,
133        Ui: FnOnce(T) + 'static,
134    {
135        if !self.is_active() {
136            return;
137        }
138        let dispatcher = self.runtime.dispatcher();
139        let active_for_thread = Arc::clone(&self.active);
140        let continuation_scope = self.clone();
141        let id_cell = Rc::new(Cell::new(0));
142        let id_for_closure = Rc::clone(&id_cell);
143        let continuation = move |value: T| {
144            continuation_scope.release_continuation(id_for_closure.get());
145            if continuation_scope.is_active() {
146                on_ui(value);
147            }
148        };
149
150        let Some(cont_id) = self.runtime.register_ui_cont(continuation) else {
151            return;
152        };
153        id_cell.set(cont_id);
154        self.continuations.borrow_mut().push(cont_id);
155
156        std::thread::spawn(move || {
157            let token = CancelToken::new(Arc::clone(&active_for_thread));
158            let value = pollster::block_on(work(token.clone()));
159            if token.is_cancelled() {
160                return;
161            }
162            dispatcher.post_invoke(cont_id, value);
163        });
164    }
165
166    /// Runs background work and delivers results to the UI.
167    ///
168    /// On the web, `work` runs as a child task of the effect on the runtime
169    /// thread. The `on_ui` continuation runs on the same thread. It does not
170    /// run once the effect is cancelled.
171    #[cfg(target_arch = "wasm32")]
172    pub fn launch_background<T, Work, Ui, Fut>(&self, work: Work, on_ui: Ui)
173    where
174        T: 'static,
175        Work: FnOnce(CancelToken) -> Fut + 'static,
176        Fut: Future<Output = T> + 'static,
177        Ui: FnOnce(T) + 'static,
178    {
179        if !self.is_active() {
180            return;
181        }
182        let token = CancelToken::new(Arc::clone(&self.active));
183        self.launch(async move {
184            let value = work(token.clone()).await;
185            if token.is_active() {
186                on_ui(value);
187            }
188        });
189    }
190}
191
192#[derive(Clone)]
193/// Cooperative cancellation token passed into background `LaunchedEffect` work.
194///
195/// The token flips to "cancelled" when the associated scope leaves composition.
196/// Callers should periodically check [`CancelToken::is_cancelled`] in long-running
197/// operations and exit early; blocking I/O will not be interrupted automatically.
198pub struct CancelToken {
199    active: Arc<AtomicBool>,
200}
201
202impl CancelToken {
203    fn new(active: Arc<AtomicBool>) -> Self {
204        Self { active }
205    }
206
207    /// Returns `true` once the associated scope has been cancelled.
208    pub fn is_cancelled(&self) -> bool {
209        !self.active.load(Ordering::SeqCst)
210    }
211
212    /// Returns whether the scope is still active.
213    pub fn is_active(&self) -> bool {
214        self.active.load(Ordering::SeqCst)
215    }
216}
217
218// Cancels the effect that `state` runs, if any. Opens the scope of the next.
219// The old scope is cancelled outside the slot's borrow. Its cancellation
220// drops its futures and what they hold.
221fn restart(state: Owned<LaunchedEffectState>, runtime: RuntimeHandle) -> LaunchedEffectScope {
222    let scope = LaunchedEffectScope::new(runtime);
223    let previous = state.update(|state| state.scope.replace(scope.clone()));
224    if let Some(previous) = previous {
225        previous.cancel();
226    }
227    scope
228}
229
230/// Implements [`LaunchedEffect`] for a call site that has already computed its group key. Call
231/// `LaunchedEffect` instead.
232///
233/// `site` is where the effect was written. It travels onto the task the effect
234/// spawns. A task that the runtime still holds then names the effect that
235/// started it.
236#[doc(hidden)]
237pub fn __launched_effect_impl<K, F, Fut>(group_key: Key, site: TaskSite, keys: K, block: F)
238where
239    K: PartialEq + 'static,
240    F: FnOnce(LaunchedEffectScope) -> Fut + 'static,
241    Fut: Future<Output = ()> + 'static,
242{
243    with_current_composer(|composer| {
244        let state = composer.remember_effect::<LaunchedEffectState>(group_key);
245        if state.with(|state| state.should_run(&keys)) {
246            let key = EffectKey::new(keys);
247            state.update(|state| state.key = Some(key));
248            let runtime = composer.runtime_handle();
249            composer.register_side_effect(move || {
250                let scope = restart(state, runtime);
251                let future = Box::pin(block(scope.clone()));
252                scope
253                    .coroutines
254                    .launch_labelled(future, TaskLabel::Site(site));
255            });
256        }
257    });
258}
259
260/// Runs `block` as a coroutine when this call site first enters composition,
261/// as Jetpack Compose's `LaunchedEffect` does. It runs `block` again whenever
262/// `keys` no longer equals the value of the last run.
263///
264/// ```ignore
265/// LaunchedEffect(line, async move |_| {
266///     for typed in 1..=length {
267///         delay(LETTER).await;
268///         letters.set(typed);
269///     }
270/// });
271/// ```
272///
273/// A change of `keys` cancels the running coroutine. The call site leaving the
274/// composition cancels it too. The coroutine stops where it awaits. No line
275/// after that `.await` runs. `keys` may be a tuple to depend on more than one
276/// value, as Compose's `LaunchedEffect(vararg keys)` does.
277#[expect(non_snake_case, reason = "names follow Compose's casing")]
278#[track_caller]
279pub fn LaunchedEffect<K, F, Fut>(keys: K, block: F)
280where
281    K: PartialEq + 'static,
282    F: FnOnce(LaunchedEffectScope) -> Fut + 'static,
283    Fut: Future<Output = ()> + 'static,
284{
285    __launched_effect_impl(
286        crate::caller_location_key(),
287        std::panic::Location::caller().into(),
288        keys,
289        block,
290    );
291}