sycamore_futures/
suspense.rs

1//! Suspense with first class `async`/`await` support.
2//!
3//! The [`Suspense`] component is used to "suspend" execution and wait until async tasks are
4//! finished before rendering.
5
6use futures::channel::oneshot;
7use futures::Future;
8use sycamore_reactive::*;
9
10use crate::*;
11
12/// A context value that keeps track of all the signals representing the number of tasks remaining
13/// in a suspense scope.
14///
15/// This is useful for figuring out when all suspense tasks are completed on the page.
16#[derive(Copy, Clone, Debug, Default)]
17struct AllTasksRemaining {
18    all_tasks_remaining: Signal<Vec<Signal<u32>>>,
19}
20
21/// Represents a new suspense scope. This is created by a call to [`create_suspense_scope`].
22#[derive(Copy, Clone, Debug)]
23pub struct SuspenseScope {
24    tasks_remaining: Signal<u32>,
25    /// The parent suspense scope of the current scope, if it exists.
26    pub parent: Option<Signal<SuspenseScope>>,
27    /// Signal that is set to `true` when the view is rendered and streamed into the buffer.
28    /// This is unused on the client side.
29    pub sent: Signal<bool>,
30}
31
32impl SuspenseScope {
33    /// Create a new suspense scope, optionally with a parent scope.
34    ///
35    /// The parent scope should always be located in a reactive scope that is an ancestor of
36    /// this scope.
37    pub fn new(parent: Option<SuspenseScope>) -> Self {
38        let tasks_remaining = create_signal(0);
39        let global = use_global_scope().run_in(|| use_context_or_else(AllTasksRemaining::default));
40        global
41            .all_tasks_remaining
42            .update(|vec| vec.push(tasks_remaining));
43        Self {
44            tasks_remaining,
45            parent: parent.map(create_signal),
46            sent: create_signal(false),
47        }
48    }
49
50    /// Implementation for [`Self::is_loading`]. This is used to recursively check whether we are
51    /// loading or not.
52    fn _is_loading(self) -> bool {
53        self.tasks_remaining.get() > 0
54            || self
55                .parent
56                .as_ref()
57                .map_or(false, |parent| parent.get()._is_loading())
58    }
59
60    /// Returns a signal representing whether we are currently loading this suspense or not.
61    ///
62    /// Implementation for the [`use_is_loading`] hook.
63    pub fn is_loading(self) -> ReadSignal<bool> {
64        create_selector(move || self._is_loading())
65    }
66
67    /// Returns a future that resolves once the scope is no longer loading.
68    pub async fn until_finished(self) {
69        let (tx, rx) = oneshot::channel();
70        let mut tx = Some(tx);
71        create_effect(move || {
72            if !self._is_loading() {
73                if let Some(tx) = tx.take() {
74                    tx.send(()).unwrap();
75                }
76            }
77        });
78
79        rx.await.unwrap()
80    }
81}
82
83/// A guard that keeps a suspense scope suspended until it is dropped.
84#[derive(Debug)]
85pub struct SuspenseTaskGuard {
86    scope: Option<SuspenseScope>,
87}
88
89impl SuspenseTaskGuard {
90    /// Creates a new suspense task guard. This will suspend the current suspense scope until this
91    /// guard is dropped.
92    pub fn new() -> Self {
93        let scope = try_use_context::<SuspenseScope>();
94        if let Some(mut scope) = scope {
95            scope.tasks_remaining += 1;
96        }
97        Self { scope }
98    }
99
100    /// Create a new suspense task guard from a suspense scope.
101    pub fn from_scope(mut scope: SuspenseScope) -> Self {
102        scope.tasks_remaining += 1;
103        Self { scope: Some(scope) }
104    }
105}
106
107impl Default for SuspenseTaskGuard {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113impl Drop for SuspenseTaskGuard {
114    fn drop(&mut self) {
115        if let Some(mut scope) = self.scope {
116            scope.tasks_remaining -= 1;
117        }
118    }
119}
120
121/// Creates a new task that is to be tracked by the suspense system.
122///
123/// This is used to signal to a `Suspense` component higher up in the component hierarchy that
124/// there is some async task that should be awaited before showing the UI.
125///
126/// If this is called from outside a suspense scope, the task will be executed normally.
127pub fn create_suspense_task(f: impl Future<Output = ()> + 'static) {
128    let guard = SuspenseTaskGuard::new();
129    spawn_local_scoped(async move {
130        f.await;
131        drop(guard);
132    });
133}
134
135/// Create a new suspense scope that is detatched from the rest of the suspense hierarchy.
136///
137/// This is useful if you want the result of this suspense to be independent of the praent suspense
138/// scope.
139///
140/// It is rarely recommended to use this fucntion as it can lead to unexpected behavior when using
141/// server side rendering, and in particular, streaming. Instead, use [`create_suspense_scope`].
142///
143/// The reason for this is because we generally expect outer suspenses to be resolved first before
144/// an inner suspense is resolved, since otherwise we would have no place to show the inner suspense
145/// as the outer fallback is still being displayed.
146pub fn create_detatched_suspense_scope<T>(f: impl FnOnce() -> T) -> (T, SuspenseScope) {
147    let scope = SuspenseScope::new(None);
148    provide_context_in_new_scope(scope, move || {
149        let ret = f();
150        (ret, scope)
151    })
152}
153
154/// Calls the given function and registers all suspense tasks.
155///
156/// Returns a tuple containing the return value of the function and the created suspense scope.
157///
158/// If this is called inside another call to [`await_suspense`], this suspense will wait until the
159/// parent suspense is resolved.
160pub fn create_suspense_scope<T>(f: impl FnOnce() -> T) -> (T, SuspenseScope) {
161    let parent = try_use_context::<SuspenseScope>();
162    let scope = SuspenseScope::new(parent);
163    provide_context_in_new_scope(scope, move || {
164        let ret = f();
165        (ret, scope)
166    })
167}
168
169/// Waits until all suspense task in current scope are completed.
170///
171/// Does not create a new suspense scope.
172///
173/// If not called inside a suspense scope, the future will resolve immediately.
174pub async fn await_suspense_current() {
175    if let Some(scope) = try_use_context::<SuspenseScope>() {
176        scope.until_finished().await;
177    }
178}
179
180/// Returns a signal representing whether we are currently loading this suspense or not.
181///
182/// This will be true if there are any tasks remaining in this scope or in any parent
183/// scope.
184///
185/// This function is also reactive and so the loading state can be tracked. If it is called outside
186/// of a suspense scope, the signal will always be `false`.
187pub fn use_is_loading() -> ReadSignal<bool> {
188    try_use_context::<SuspenseScope>().map_or(*create_signal(false), |scope| scope.is_loading())
189}
190
191/// Returns whether any suspense scope is current loading.
192///
193/// This is unlike [`use_is_loading`] in that it can be called outside of a suspense scope and does
194/// not apply to any suspense scope in particular.
195pub fn use_is_loading_global() -> bool {
196    if let Some(global) = try_use_context::<AllTasksRemaining>() {
197        global
198            .all_tasks_remaining
199            .with(|vec| vec.iter().any(|signal| signal.get() > 0))
200    } else {
201        false
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use std::cell::Cell;
208    use std::rc::Rc;
209
210    use super::*;
211
212    #[test]
213    fn suspense_scope() {
214        let _ = create_root(|| {
215            let _ = create_suspense_scope(|| {
216                let outer_scope = try_use_context::<SuspenseScope>();
217                assert!(outer_scope.is_some());
218                assert!(outer_scope.unwrap().parent.is_none());
219
220                let _ = create_suspense_scope(|| {
221                    let inner_scope = try_use_context::<SuspenseScope>();
222                    assert!(inner_scope.is_some());
223                    assert!(inner_scope.unwrap().parent.is_some());
224                });
225            });
226        });
227    }
228
229    #[tokio::test]
230    async fn suspense_await_suspense() {
231        let (tx, rx) = oneshot::channel();
232        let is_completed = Rc::new(Cell::new(false));
233
234        let local = tokio::task::LocalSet::new();
235        local
236            .run_until(async {
237                let _ = create_root({
238                    let is_completed = is_completed.clone();
239                    || {
240                        spawn_local_scoped(async move {
241                            let (_, scope) = create_suspense_scope(|| {
242                                create_suspense_task(async move {
243                                    rx.await.unwrap();
244                                });
245                            });
246
247                            scope.until_finished().await;
248                            is_completed.set(true);
249                        });
250                    }
251                });
252            })
253            .await;
254
255        assert!(!is_completed.get());
256
257        tx.send(()).unwrap();
258        local.await;
259        assert!(is_completed.get());
260    }
261
262    #[tokio::test]
263    async fn use_is_loading_global_works() {
264        let (tx, rx) = oneshot::channel();
265
266        let local = tokio::task::LocalSet::new();
267        let mut root = create_root(|| {});
268        local
269            .run_until(async {
270                root = create_root(|| {
271                    let _ = create_suspense_scope(|| {
272                        create_suspense_task(async move {
273                            rx.await.unwrap();
274                        });
275                    });
276                });
277            })
278            .await;
279
280        root.run_in(|| {
281            assert!(use_is_loading_global());
282        });
283
284        tx.send(()).unwrap();
285        local.await;
286
287        root.run_in(|| {
288            assert!(!use_is_loading_global());
289        });
290    }
291}