sycamore_web/
suspense.rs

1//! Components for suspense.
2
3use std::future::Future;
4use std::num::NonZeroU32;
5
6use sycamore_futures::{
7    create_detatched_suspense_scope, create_suspense_scope, create_suspense_task,
8};
9use sycamore_macro::{component, Props};
10
11use crate::*;
12
13/// Props for [`Suspense`] and [`Transition`].
14#[derive(Props)]
15pub struct SuspenseProps {
16    /// The fallback [`View`] to display while the child nodes are being awaited.
17    #[prop(default = Box::new(|| view! {}), setter(transform = |f: impl Fn() -> View + 'static| Box::new(f) as Box<dyn Fn() -> View>))]
18    fallback: Box<dyn Fn() -> View>,
19    children: Children,
20    /// The component will automatically update this signal with the `is_loading` state.
21    ///
22    /// This is only updated in non-SSR mode.
23    #[prop(default = Box::new(|_| {}), setter(transform = |f: impl FnMut(bool) + 'static| Box::new(f) as Box<dyn FnMut(bool)>))]
24    set_is_loading: Box<dyn FnMut(bool) + 'static>,
25}
26
27/// `Suspense` lets you wait for `async` tasks to complete before rendering the UI. This is useful
28/// for asynchronous data-fetching or other asynchronous tasks.
29///
30/// `Suspense` is deeply integrated with [async components](https://sycamore-rs.netlify.app/docs/basics/components).
31/// Async components that are nested under the `Suspense` component will not be rendered until they
32/// are resolved. Having multiple async components will have the effect that the final UI will only
33/// be rendered once all individual async components are rendered. This is useful for showing a
34/// loading indicator while the data is being loaded.
35///
36/// # Example
37/// ```
38/// use sycamore::prelude::*;
39/// use sycamore::web::Suspense;
40///
41/// #[component]
42/// async fn AsyncComp() -> View {
43///     view! { "Hello Suspense!" }
44/// }
45///
46/// #[component]
47/// fn App() -> View {
48///     view! {
49///         Suspense(fallback=|| view! { "Loading..." }) {
50///             AsyncComp {}
51///         }
52///     }
53/// }
54/// ```
55#[component]
56pub fn Suspense(props: SuspenseProps) -> View {
57    let SuspenseProps {
58        fallback,
59        children,
60        mut set_is_loading,
61    } = props;
62
63    is_ssr! {
64        use futures::SinkExt;
65
66        let _ = &mut set_is_loading;
67
68        let mode = use_context::<SsrMode>();
69        match mode {
70            // In sync mode, we don't even bother about the children and just return the fallback.
71            //
72            // We make sure to return a closure so that the view can be properly hydrated.
73            SsrMode::Sync => view! {
74                Show(when=true) {
75                    (fallback())
76                }
77                Show(when=false) {}
78            },
79            // In blocking mode, we render a marker node and then replace the marker node with the
80            // children once the suspense is resolved.
81            //
82            // In streaming mode, we render the fallback and then stream the result of the children
83            // once suspense is resolved.
84            SsrMode::Blocking | SsrMode::Streaming => {
85                // We need to create a suspense key so that we know which suspense boundary it is
86                // when we replace the marker with the suspended content.
87                let key = use_suspense_key();
88
89                // Push `children` to the suspense fragments lists.
90                let (mut view, suspense_scope) = create_suspense_scope(move || HydrationRegistry::in_suspense_scope(key, move || children.call()));
91                let state = use_context::<SuspenseState>();
92                // TODO: error if scope is destroyed before suspense resolves.
93                // Probably can fix this by using `FuturesOrdered` instead.
94                sycamore_futures::spawn_local_scoped(async move {
95                    suspense_scope.until_finished().await;
96                    debug_assert!(!suspense_scope.sent.get());
97                    // Make sure parent is sent first.
98                    create_effect(move || {
99                        if !suspense_scope.sent.get() && suspense_scope.parent.as_ref().map_or(true, |parent| parent.get().sent.get()) {
100                            let view = std::mem::take(&mut view);
101                            let fragment = SuspenseFragment::new(key, view! { Show(when=true) { (view) } });
102                            let mut state = state.clone();
103                            sycamore_futures::spawn_local_scoped(async move {
104                                let _ = state.sender.send(fragment).await;
105                            });
106                            suspense_scope.sent.set(true);
107                        }
108                    });
109                });
110
111                // Add some marker nodes so that we know start and finish of fallback.
112                let start = view! { suspense-start(data-key=key.to_string()) };
113                let marker = View::from(move || SsrNode::SuspenseMarker { key: key.into() });
114                let end = view! { NoHydrate { suspense-end(data-key=key.to_string()) } };
115
116                if mode == SsrMode::Blocking {
117                    view! { (start) (marker) (end) }
118                } else if mode == SsrMode::Streaming {
119                    view! {
120                        NoSsr {}
121                        (start)
122                        (marker)
123                        NoHydrate(children=Children::new(fallback))
124                        (end)
125                    }
126                } else {
127                    unreachable!()
128                }
129            }
130        }
131    }
132    is_not_ssr! {
133        let mode = if IS_HYDRATING.get() {
134            use_context::<SsrMode>()
135        } else {
136            SsrMode::Sync
137        };
138        match mode {
139            SsrMode::Sync => {
140                let (view, suspense_scope) = create_suspense_scope(move || children.call());
141                let is_loading = suspense_scope.is_loading();
142
143                create_effect(move || {
144                    set_is_loading(is_loading.get());
145                });
146
147                view! {
148                    Show(when=is_loading) {
149                        (fallback())
150                    }
151                    Show(when=move || !is_loading.get()) {
152                        (view)
153                    }
154                }
155            }
156            SsrMode::Blocking | SsrMode::Streaming => {
157                // Blocking: Since the fallback is never rendered on the server side, we don't need
158                // to hydrate it either.
159                //
160                // Streaming: By the time the WASM is running, page loading should already be completed since
161                // WASM runs inside a deferred script. Therefore we only need to hydrate the view
162                // and not the fallback.
163
164                // First hydrate the `<sycamore-start>` element to get the suspense scope.
165                let start = view! { suspense-start() };
166                let node = start.nodes[0].as_web_sys().unchecked_ref::<web_sys::Element>();
167                let key: NonZeroU32 = node.get_attribute("data-key").unwrap().parse().unwrap();
168
169                let (view, suspense_scope) = HydrationRegistry::in_suspense_scope(key, move || create_suspense_scope(move || children.call()));
170                let is_loading = suspense_scope.is_loading();
171
172                create_effect(move || {
173                    set_is_loading(is_loading.get());
174                });
175
176                view! {
177                    NoSsr {
178                        Show(when=move || !IS_HYDRATING.get() && is_loading.get()) {
179                            (fallback())
180                        }
181                    }
182                    Show(when=move || !IS_HYDRATING.get() && !is_loading.get()) {
183                        (view)
184                    }
185                }
186            }
187        }
188    }
189}
190
191/// Convert an async component to a regular sync component. Also wraps the async component inside a
192/// suspense scope so that content is properly suspended.
193#[component]
194pub fn WrapAsync<F: Future<Output = View>>(f: impl FnOnce() -> F + 'static) -> View {
195    is_not_ssr! {
196        let mode = if IS_HYDRATING.get() {
197            use_context::<SsrMode>()
198        } else {
199            SsrMode::Sync
200        };
201        match mode {
202            SsrMode::Sync => {
203                let view = create_signal(View::default());
204                let ret = view! { ({
205                    view.track();
206                    view.update_silent(std::mem::take)
207                }) };
208                create_suspense_task(async move {
209                    view.set(f().await);
210                });
211                ret
212            }
213            SsrMode::Blocking | SsrMode::Streaming => {
214                // TODO: This does not properly hydrate dynamic text nodes.
215                create_suspense_task(async move { f().await; });
216                view! {}
217            }
218        }
219    }
220    is_ssr! {
221        use std::sync::{Arc, Mutex};
222
223        let node = Arc::new(Mutex::new(View::default()));
224        create_suspense_task({
225            let node = Arc::clone(&node);
226            async move {
227                *node.lock().unwrap() = f().await;
228            }
229        });
230        View::from(move || SsrNode::Dynamic {
231            view: Arc::clone(&node),
232        })
233    }
234}
235
236/// `Transition` is like [`Suspense`] except that it keeps the previous content visible until the
237/// new content is ready.
238#[component]
239pub fn Transition(props: SuspenseProps) -> View {
240    /// Only trigger outer suspense on initial render. In subsequent renders, capture the suspense
241    /// scope.
242    #[component(inline_props)]
243    fn TransitionInner(children: Children, set_is_loading: Box<dyn FnMut(bool)>) -> View {
244        // TODO: Workaround for https://github.com/sycamore-rs/sycamore/issues/718.
245        let mut set_is_loading = set_is_loading;
246
247        // We create a detatched suspense scope here to not create a deadlock with the outer
248        // suspense.
249        let (children, scope) = create_detatched_suspense_scope(move || children.call());
250        // Trigger the outer suspense scope. Note that this is only triggered on the initial render
251        // and future renders will be captured by the inner suspense scope.
252        create_suspense_task(scope.until_finished());
253
254        let is_loading = scope.is_loading();
255        create_effect(move || {
256            set_is_loading(is_loading.get());
257        });
258
259        view! {
260            (children)
261        }
262    }
263
264    view! {
265        Suspense(fallback=props.fallback, children=Children::new(move || {
266            view! { TransitionInner(children=props.children, set_is_loading=props.set_is_loading) }
267        }))
268    }
269}
270
271#[cfg_ssr]
272pub(crate) struct SuspenseFragment {
273    pub key: NonZeroU32,
274    pub view: View,
275}
276
277#[cfg_ssr]
278impl SuspenseFragment {
279    pub fn new(key: NonZeroU32, view: View) -> Self {
280        Self { key, view }
281    }
282}
283
284/// Context for passing suspense fragments in SSR mode.
285#[cfg_ssr]
286#[derive(Clone)]
287pub(crate) struct SuspenseState {
288    pub sender: futures::channel::mpsc::Sender<SuspenseFragment>,
289}
290
291/// Global counter for providing suspense key.
292#[derive(Debug, Clone, Copy)]
293struct SuspenseCounter {
294    next: Signal<NonZeroU32>,
295}
296
297impl SuspenseCounter {
298    fn new() -> Self {
299        Self {
300            next: create_signal(NonZeroU32::new(1).unwrap()),
301        }
302    }
303}
304
305/// Get the next suspense key.
306pub fn use_suspense_key() -> NonZeroU32 {
307    let global_scope = use_global_scope();
308    let counter = global_scope.run_in(|| use_context_or_else(SuspenseCounter::new));
309
310    let next = counter.next.get();
311    counter.next.set(next.checked_add(1).unwrap());
312    next
313}