sycamore_web/node/
ssr_render.rs

1use super::*;
2
3/// The mode in which SSR is being run.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum SsrMode {
6    /// Synchronous mode.
7    ///
8    /// When a suspense boundary is hit, only the fallback is rendered.
9    Sync,
10    /// Blocking mode.
11    ///
12    /// When a suspense boundary is hit, rendering is paused until the suspense is resolved.
13    Blocking,
14    /// Streaming mode.
15    ///
16    /// When a suspense boundary is hit, the fallback is rendered. Once the suspense is resolved,
17    /// the rendered HTML is streamed to the client.
18    Streaming,
19}
20
21/// Render a [`View`] into a static [`String`]. Useful for rendering to a string on the server side.
22#[must_use]
23pub fn render_to_string(view: impl FnOnce() -> View) -> String {
24    is_not_ssr! {
25        let _ = view;
26        panic!("`render_to_string` only available in SSR mode");
27    }
28    is_ssr! {
29        use std::cell::LazyCell;
30
31        thread_local! {
32            /// Use a static variable here so that we can reuse the same root for multiple calls to
33            /// this function.
34            static SSR_ROOT: LazyCell<RootHandle> = LazyCell::new(|| create_root(|| {}));
35        }
36        SSR_ROOT.with(|root| {
37            root.dispose();
38            root.run_in(|| {
39                render_to_string_in_scope(view)
40            })
41        })
42    }
43}
44
45/// Render a [`View`] into a static [`String`] in the current reactive scope.
46///
47/// Implementation detail of [`render_to_string`].
48#[must_use]
49pub fn render_to_string_in_scope(view: impl FnOnce() -> View) -> String {
50    is_not_ssr! {
51        let _ = view;
52        panic!("`render_to_string` only available in SSR mode");
53    }
54    is_ssr! {
55        let mut buf = String::new();
56
57        let handle = create_child_scope(|| {
58            provide_context(HydrationRegistry::new());
59            provide_context(SsrMode::Sync);
60
61            let prev = IS_HYDRATING.replace(true);
62            let view = view();
63            IS_HYDRATING.set(prev);
64            ssr_node::render_recursive_view(&view, &mut buf);
65        });
66        handle.dispose();
67        buf
68    }
69}
70
71/// Renders a [`View`] into a static [`String`] while awaiting for all suspense boundaries to
72/// resolve. Useful for rendering to a string on the server side.
73///
74/// This sets the SSR mode to "blocking" mode. This means that rendering will wait until suspense
75/// is resolved before returning.
76///
77/// # Example
78/// ```
79/// # use sycamore::prelude::*;
80/// # use sycamore::web::render_to_string_await_suspense;
81/// #[component]
82/// async fn AsyncComponent() -> View {
83///     // Do some async operations.   
84///     # view! {}
85/// }
86///
87/// # tokio_test::block_on(async move {
88/// let ssr = render_to_string_await_suspense(AsyncComponent).await;
89/// # })
90/// ```
91#[must_use]
92#[cfg(feature = "suspense")]
93pub async fn render_to_string_await_suspense(view: impl FnOnce() -> View) -> String {
94    is_not_ssr! {
95        let _ = view;
96        panic!("`render_to_string` only available in SSR mode");
97    }
98    is_ssr! {
99        use std::num::NonZeroU32;
100        use std::cell::LazyCell;
101        use std::fmt::Write;
102        use std::collections::HashMap;
103
104        use futures::StreamExt;
105
106        const BUFFER_SIZE: usize = 5;
107
108        thread_local! {
109            /// Use a static variable here so that we can reuse the same root for multiple calls to
110            /// this function.
111            static SSR_ROOT: LazyCell<RootHandle> = LazyCell::new(|| create_root(|| {}));
112        }
113        IS_HYDRATING.set(true);
114        sycamore_futures::provide_executor_scope(async {
115            let mut buf = String::new();
116
117            let (sender, mut receiver) = futures::channel::mpsc::channel(BUFFER_SIZE);
118            SSR_ROOT.with(|root| {
119                root.dispose();
120                root.run_in(|| {
121                    // We run this in a new scope so that we can dispose everything after we render it.
122                    provide_context(HydrationRegistry::new());
123                    provide_context(SsrMode::Blocking);
124                    let suspense_state = SuspenseState { sender };
125
126                    provide_context(suspense_state);
127
128                    let view = view();
129                    ssr_node::render_recursive_view(&view, &mut buf);
130                });
131            });
132
133            // Split at suspense fragment locations.
134            let split = buf.split("<!--sycamore-suspense-").collect::<Vec<_>>();
135            // Calculate the number of suspense fragments.
136            let n = split.len() - 1;
137
138            // Now we wait until all suspense fragments are resolved.
139            let mut fragment_map = HashMap::new();
140            if n == 0 {
141                receiver.close();
142            }
143            let mut i = 0;
144            while let Some(fragment) = receiver.next().await {
145                fragment_map.insert(fragment.key, fragment.view);
146                i += 1;
147                if i == n {
148                    // We have received all suspense fragments so we shouldn't need the receiver anymore.
149                    receiver.close();
150                }
151            }
152            IS_HYDRATING.set(false);
153
154            // Finally, replace all suspense marker nodes with rendered values.
155            if let [first, rest @ ..] = split.as_slice() {
156                rest.iter().fold(first.to_string(), |mut acc, s| {
157                    // Try to parse the key.
158                    let (num, rest) = s.split_once("-->").expect("end of suspense marker not found");
159                    let key: u32 = num.parse().expect("could not parse suspense key");
160                    let key = NonZeroU32::try_from(key).expect("suspense key cannot be 0");
161                    let fragment = fragment_map.get(&key).expect("fragment not found");
162                    ssr_node::render_recursive_view(fragment, &mut acc);
163
164                    write!(&mut acc, "{rest}").unwrap();
165                    acc
166                })
167            } else {
168                unreachable!("split should always have at least one element")
169            }
170        }).await
171    }
172}
173
174/// Renders a [`View`] to a stream.
175///
176/// This sets the SSR mode to "streaming" mode. This means that the initial HTML with fallbacks is
177/// sent first and then the suspense fragments are streamed as they are resolved.
178///
179/// The streamed suspense fragments are in the form of HTML template elements and a small script
180/// that moves the template elements into the right area of the DOM.
181///
182/// # Executor
183///
184/// This function (unlike [`render_to_string_await_suspense`]) does not automatically create an
185/// executor. You must provide the executor yourself by using `tokio::task::LocalSet`.
186///
187/// # Example
188/// ```
189/// # use sycamore::prelude::*;
190/// # use sycamore::web::{render_to_string_stream, Suspense};
191/// # use futures::StreamExt;
192/// #[component]
193/// async fn AsyncComponent() -> View {
194///     // Do some async operations.   
195///     # view! {}
196/// }
197///
198/// #[component]
199/// fn App() -> View {
200///     view! {
201///         Suspense(fallback=|| "Loading...".into()) {
202///             AsyncComponent {}
203///         }
204///     }
205/// }
206///
207/// # tokio_test::block_on(async move {
208/// // Create a channel for sending the created stream from the local set.
209/// let (tx, rx) = tokio::sync::oneshot::channel();
210/// tokio::task::spawn_blocking(|| {
211///     let handle = tokio::runtime::Handle::current();
212///     handle.block_on(async move {
213///         let local = tokio::task::LocalSet::new();
214///         local.run_until(async move {
215///             let stream = render_to_string_stream(App);
216///             tx.send(stream).ok().unwrap();
217///         }).await;
218///         // Run the remaining tasks in the local set.
219///         local.await;
220///     });
221/// });
222/// let stream = rx.await.unwrap();
223/// tokio::pin!(stream);
224/// while let Some(string) = stream.next().await {
225///     // Send the string to the client.
226///     // Usually, the web framework already supports converting a stream into a response.
227/// }
228/// # })
229/// ```
230#[cfg(feature = "suspense")]
231pub fn render_to_string_stream(
232    view: impl FnOnce() -> View,
233) -> impl futures::Stream<Item = String> + Send {
234    is_not_ssr! {
235        let _ = view;
236        panic!("`render_to_string` only available in SSR mode");
237        #[allow(unreachable_code)] // TODO: never type cannot be coerced into `impl Stream` somehow.
238        futures::stream::empty()
239    }
240    is_ssr! {
241        use std::cell::LazyCell;
242
243        use futures::StreamExt;
244
245        const BUFFER_SIZE: usize = 5;
246
247        thread_local! {
248            /// Use a static variable here so that we can reuse the same root for multiple calls to
249            /// this function.
250            static SSR_ROOT: LazyCell<RootHandle> = LazyCell::new(|| create_root(|| {}));
251        }
252        IS_HYDRATING.set(true);
253        let mut buf = String::new();
254        let (sender, mut receiver) = futures::channel::mpsc::channel(BUFFER_SIZE);
255        SSR_ROOT.with(|root| {
256            root.dispose();
257            root.run_in(|| {
258                // We run this in a new scope so that we can dispose everything after we render it.
259                provide_context(HydrationRegistry::new());
260                provide_context(SsrMode::Streaming);
261                let suspense_state = SuspenseState { sender };
262
263                provide_context(suspense_state);
264
265                let view = view();
266                ssr_node::render_recursive_view(&view, &mut buf);
267            });
268        });
269
270        // Calculate the number of suspense fragments.
271        let mut n = buf.matches("<!--sycamore-suspense-").count();
272
273        // ```js
274        // function __sycamore_suspense(key) {
275        //   let start = document.querySelector(`suspense-start[data-key="${key}"]`)
276        //   let end = document.querySelector(`suspense-end[data-key="${key}"]`)
277        //   let template = document.getElementById(`sycamore-suspense-${key}`)
278        //   start.parentNode.insertBefore(template.content, start)
279        //   while (start.nextSibling != end) {
280        //     start.parentNode.removeChild(start.nextSibling)
281        //   }
282        // }
283        // ```
284        static SUSPENSE_REPLACE_SCRIPT: &str = r#"<script>function __sycamore_suspense(e){let s=document.querySelector(`suspense-start[data-key="${e}"]`),n=document.querySelector(`suspense-end[data-key="${e}"]`),r=document.getElementById(`sycamore-suspense-${e}`);for(s.parentNode.insertBefore(r.content,s);s.nextSibling!=n;)s.parentNode.removeChild(s.nextSibling);}</script>"#;
285        async_stream::stream! {
286            let mut initial = String::new();
287            initial.push_str("<!doctype html>");
288            initial.push_str(&buf);
289            initial.push_str(SUSPENSE_REPLACE_SCRIPT);
290            yield initial;
291
292            if n == 0 {
293                receiver.close();
294            }
295            let mut i = 0;
296            while let Some(fragment) = receiver.next().await {
297                let buf_fragment = render_suspense_fragment(fragment);
298                // Check if we have any nested suspense.
299                let n_add = buf_fragment.matches("<!--sycamore-suspense-").count();
300                n += n_add;
301
302                yield buf_fragment;
303
304                i += 1;
305                if i == n {
306                    // We have received all suspense fragments so we shouldn't need the receiver anymore.
307                    receiver.close();
308                }
309            }
310        }
311    }
312}
313
314#[cfg_ssr]
315#[cfg(feature = "suspense")]
316fn render_suspense_fragment(SuspenseFragment { key, view }: SuspenseFragment) -> String {
317    use std::fmt::Write;
318
319    let mut buf = String::new();
320    write!(&mut buf, "<template id=\"sycamore-suspense-{key}\">",).unwrap();
321    ssr_node::render_recursive_view(&view, &mut buf);
322    write!(
323        &mut buf,
324        "</template><script>__sycamore_suspense({key})</script>"
325    )
326    .unwrap();
327
328    buf
329}
330
331#[cfg(test)]
332#[cfg(feature = "suspense")]
333#[cfg_ssr]
334mod tests {
335    use expect_test::expect;
336    use futures::channel::oneshot;
337
338    use super::*;
339
340    #[component(inline_props)]
341    async fn AsyncComponent(receiver: oneshot::Receiver<()>) -> View {
342        receiver.await.unwrap();
343        view! {
344            "Hello, async!"
345        }
346    }
347
348    #[component(inline_props)]
349    fn App(receiver: oneshot::Receiver<()>) -> View {
350        view! {
351            Suspense(fallback=|| "fallback".into()) {
352                AsyncComponent(receiver=receiver)
353            }
354        }
355    }
356
357    #[test]
358    fn render_to_string_renders_fallback() {
359        let (sender, receiver) = oneshot::channel();
360        let res = render_to_string(move || view! { App(receiver=receiver) });
361        assert_eq!(
362            res,
363            "<!--/--><!--/-->fallback<!--/--><!--/--><!--/--><!--/-->"
364        );
365        assert!(sender.send(()).is_err(), "receiver should be dropped");
366    }
367
368    #[tokio::test]
369    async fn render_to_string_await_suspense_works() {
370        let (sender, receiver) = oneshot::channel();
371        let ssr = render_to_string_await_suspense(move || view! { App(receiver=receiver) });
372        futures::pin_mut!(ssr);
373        assert!(futures::poll!(&mut ssr).is_pending());
374
375        sender.send(()).unwrap();
376        let res = ssr.await;
377
378        let expect = expect![[
379            r#"<suspense-start data-key="1" data-hk="0.0"></suspense-start><!--/--><!--/--><!--/-->Hello, async!<!--/--><!--/--><!--/--><suspense-end data-key="1"></suspense-end>"#
380        ]];
381        expect.assert_eq(&res);
382    }
383}