dioxus_fullstack/
render.rs

1//! A shared pool of renderers for efficient server side rendering.
2use crate::document::ServerDocument;
3use crate::html_storage::serialize::SerializedHydrationData;
4use crate::streaming::{Mount, StreamingRenderer};
5use dioxus_cli_config::base_path;
6use dioxus_interpreter_js::INITIALIZE_STREAMING_JS;
7use dioxus_isrg::{CachedRender, IncrementalRendererError, RenderFreshness};
8use dioxus_lib::document::Document;
9use dioxus_ssr::Renderer;
10use futures_channel::mpsc::Sender;
11use futures_util::{Stream, StreamExt};
12use std::fmt::Write;
13use std::rc::Rc;
14use std::sync::Arc;
15use std::sync::RwLock;
16use std::{collections::HashMap, future::Future};
17use tokio::task::JoinHandle;
18
19use crate::{prelude::*, StreamingMode};
20use dioxus_lib::prelude::*;
21
22/// A suspense boundary that is pending with a placeholder in the client
23struct PendingSuspenseBoundary {
24    mount: Mount,
25    children: Vec<ScopeId>,
26}
27
28/// Spawn a task in the background. If wasm is enabled, this will use the single threaded tokio runtime
29fn spawn_platform<Fut>(f: impl FnOnce() -> Fut + Send + 'static) -> JoinHandle<Fut::Output>
30where
31    Fut: Future + 'static,
32    Fut::Output: Send + 'static,
33{
34    #[cfg(not(target_arch = "wasm32"))]
35    {
36        use tokio_util::task::LocalPoolHandle;
37        static TASK_POOL: std::sync::OnceLock<LocalPoolHandle> = std::sync::OnceLock::new();
38
39        let pool = TASK_POOL.get_or_init(|| {
40            let threads = std::thread::available_parallelism()
41                .unwrap_or(std::num::NonZeroUsize::new(1).unwrap());
42            LocalPoolHandle::new(threads.into())
43        });
44
45        pool.spawn_pinned(f)
46    }
47    #[cfg(target_arch = "wasm32")]
48    {
49        tokio::task::spawn_local(f())
50    }
51}
52
53struct SsrRendererPool {
54    renderers: RwLock<Vec<Renderer>>,
55    incremental_cache: Option<RwLock<dioxus_isrg::IncrementalRenderer>>,
56}
57
58impl SsrRendererPool {
59    fn new(
60        initial_size: usize,
61        incremental: Option<dioxus_isrg::IncrementalRendererConfig>,
62    ) -> Self {
63        let renderers = RwLock::new((0..initial_size).map(|_| pre_renderer()).collect());
64        Self {
65            renderers,
66            incremental_cache: incremental.map(|cache| RwLock::new(cache.build())),
67        }
68    }
69
70    /// Look for a cached route in the incremental cache and send it into the render channel if it exists
71    fn check_cached_route(
72        &self,
73        route: &str,
74        render_into: &mut Sender<Result<String, dioxus_isrg::IncrementalRendererError>>,
75    ) -> Option<RenderFreshness> {
76        if let Some(incremental) = &self.incremental_cache {
77            if let Ok(mut incremental) = incremental.write() {
78                match incremental.get(route) {
79                    Ok(Some(cached_render)) => {
80                        let CachedRender {
81                            freshness,
82                            response,
83                            ..
84                        } = cached_render;
85                        _ = render_into.start_send(String::from_utf8(response.to_vec()).map_err(
86                            |err| dioxus_isrg::IncrementalRendererError::Other(Box::new(err)),
87                        ));
88                        return Some(freshness);
89                    }
90                    Err(e) => {
91                        tracing::error!(
92                            "Failed to get route \"{route}\" from incremental cache: {e}"
93                        );
94                    }
95                    _ => {}
96                }
97            }
98        }
99        None
100    }
101
102    /// Render a virtual dom into a stream. This method will return immediately and continue streaming the result in the background
103    /// The streaming is canceled when the stream the function returns is dropped
104    async fn render_to(
105        self: Arc<Self>,
106        cfg: &ServeConfig,
107        route: String,
108        virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static,
109        server_context: &DioxusServerContext,
110    ) -> Result<
111        (
112            RenderFreshness,
113            impl Stream<Item = Result<String, dioxus_isrg::IncrementalRendererError>>,
114        ),
115        dioxus_isrg::IncrementalRendererError,
116    > {
117        struct ReceiverWithDrop {
118            receiver: futures_channel::mpsc::Receiver<
119                Result<String, dioxus_isrg::IncrementalRendererError>,
120            >,
121            cancel_task: Option<tokio::task::JoinHandle<()>>,
122        }
123
124        impl Stream for ReceiverWithDrop {
125            type Item = Result<String, dioxus_isrg::IncrementalRendererError>;
126
127            fn poll_next(
128                mut self: std::pin::Pin<&mut Self>,
129                cx: &mut std::task::Context<'_>,
130            ) -> std::task::Poll<Option<Self::Item>> {
131                self.receiver.poll_next_unpin(cx)
132            }
133        }
134
135        // When we drop the stream, we need to cancel the task that is feeding values to the stream
136        impl Drop for ReceiverWithDrop {
137            fn drop(&mut self) {
138                if let Some(cancel_task) = self.cancel_task.take() {
139                    cancel_task.abort();
140                }
141            }
142        }
143
144        let (mut into, rx) = futures_channel::mpsc::channel::<
145            Result<String, dioxus_isrg::IncrementalRendererError>,
146        >(1000);
147
148        // before we even spawn anything, we can check synchronously if we have the route cached
149        if let Some(freshness) = self.check_cached_route(&route, &mut into) {
150            return Ok((
151                freshness,
152                ReceiverWithDrop {
153                    receiver: rx,
154                    cancel_task: None,
155                },
156            ));
157        }
158
159        let wrapper = FullstackHTMLTemplate { cfg: cfg.clone() };
160
161        let server_context = server_context.clone();
162        let mut renderer = self
163            .renderers
164            .write()
165            .unwrap()
166            .pop()
167            .unwrap_or_else(pre_renderer);
168
169        let myself = self.clone();
170        let streaming_mode = cfg.streaming_mode;
171
172        let join_handle = spawn_platform(move || async move {
173            let mut virtual_dom = virtual_dom_factory();
174            let document = std::rc::Rc::new(crate::document::server::ServerDocument::default());
175            virtual_dom.provide_root_context(document.clone());
176            // If there is a base path, trim the base path from the route and add the base path formatting to the
177            // history provider
178            let history;
179            if let Some(base_path) = base_path() {
180                let base_path = base_path.trim_matches('/');
181                let base_path = format!("/{base_path}");
182                let route = route.strip_prefix(&base_path).unwrap_or(&route);
183                history =
184                    dioxus_history::MemoryHistory::with_initial_path(route).with_prefix(base_path);
185            } else {
186                history = dioxus_history::MemoryHistory::with_initial_path(&route);
187            }
188            virtual_dom.provide_root_context(Rc::new(history) as Rc<dyn dioxus_history::History>);
189            virtual_dom.provide_root_context(document.clone() as std::rc::Rc<dyn Document>);
190
191            // poll the future, which may call server_context()
192            with_server_context(server_context.clone(), || virtual_dom.rebuild_in_place());
193
194            let mut pre_body = String::new();
195
196            if let Err(err) = wrapper.render_head(&mut pre_body, &virtual_dom) {
197                _ = into.start_send(Err(err));
198                return;
199            }
200
201            let stream = Arc::new(StreamingRenderer::new(pre_body, into));
202            let scope_to_mount_mapping = Arc::new(RwLock::new(HashMap::new()));
203
204            renderer.pre_render = true;
205            {
206                let scope_to_mount_mapping = scope_to_mount_mapping.clone();
207                let stream = stream.clone();
208                renderer.set_render_components(streaming_render_component_callback(
209                    stream,
210                    scope_to_mount_mapping,
211                ));
212            }
213
214            macro_rules! throw_error {
215                ($e:expr) => {
216                    stream.close_with_error($e);
217                    return;
218                };
219            }
220
221            // If streaming is disabled, wait for the virtual dom to finish all suspense work
222            // before rendering anything
223            if streaming_mode == StreamingMode::Disabled {
224                ProvideServerContext::new(virtual_dom.wait_for_suspense(), server_context.clone())
225                    .await
226            }
227
228            // Render the initial frame with loading placeholders
229            let mut initial_frame = renderer.render(&virtual_dom);
230
231            // Along with the initial frame, we render the html after the main element, but before the body tag closes. This should include the script that starts loading the wasm bundle.
232            if let Err(err) = wrapper.render_after_main(&mut initial_frame, &virtual_dom) {
233                throw_error!(err);
234            }
235            stream.render(initial_frame);
236
237            // After the initial render, we need to resolve suspense
238            while virtual_dom.suspended_tasks_remaining() {
239                ProvideServerContext::new(
240                    virtual_dom.wait_for_suspense_work(),
241                    server_context.clone(),
242                )
243                .await;
244                let resolved_suspense_nodes = ProvideServerContext::new(
245                    virtual_dom.render_suspense_immediate(),
246                    server_context.clone(),
247                )
248                .await;
249
250                // Just rerender the resolved nodes
251                for scope in resolved_suspense_nodes {
252                    let pending_suspense_boundary = {
253                        let mut lock = scope_to_mount_mapping.write().unwrap();
254                        lock.remove(&scope)
255                    };
256                    // If the suspense boundary was immediately removed, it may not have a mount. We can just skip resolving it
257                    if let Some(pending_suspense_boundary) = pending_suspense_boundary {
258                        let mut resolved_chunk = String::new();
259                        // After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node
260                        let render_suspense = |into: &mut String| {
261                            renderer.reset_hydration();
262                            renderer.render_scope(into, &virtual_dom, scope)
263                        };
264                        let resolved_data = serialize_server_data(&virtual_dom, scope);
265                        if let Err(err) = stream.replace_placeholder(
266                            pending_suspense_boundary.mount,
267                            render_suspense,
268                            resolved_data,
269                            &mut resolved_chunk,
270                        ) {
271                            throw_error!(dioxus_isrg::IncrementalRendererError::RenderError(err));
272                        }
273
274                        stream.render(resolved_chunk);
275                        // Freeze the suspense boundary to prevent future reruns of any child nodes of the suspense boundary
276                        if let Some(suspense) =
277                            SuspenseContext::downcast_suspense_boundary_from_scope(
278                                &virtual_dom.runtime(),
279                                scope,
280                            )
281                        {
282                            suspense.freeze();
283                            // Go to every child suspense boundary and add an error boundary. Since we cannot rerun any nodes above the child suspense boundary,
284                            // we need to capture the errors and send them to the client as it resolves
285                            virtual_dom.in_runtime(|| {
286                                for &suspense_scope in pending_suspense_boundary.children.iter() {
287                                    start_capturing_errors(suspense_scope);
288                                }
289                            });
290                        }
291                    }
292                }
293            }
294
295            // After suspense is done, we render the html after the body
296            let mut post_streaming = String::new();
297
298            if let Err(err) = wrapper.render_after_body(&mut post_streaming) {
299                throw_error!(err);
300            }
301
302            // If incremental rendering is enabled, add the new render to the cache without the streaming bits
303            if let Some(incremental) = &self.incremental_cache {
304                let mut cached_render = String::new();
305                if let Err(err) = wrapper.render_head(&mut cached_render, &virtual_dom) {
306                    throw_error!(err);
307                }
308                renderer.reset_hydration();
309                if let Err(err) = renderer.render_to(&mut cached_render, &virtual_dom) {
310                    throw_error!(dioxus_isrg::IncrementalRendererError::RenderError(err));
311                }
312                if let Err(err) = wrapper.render_after_main(&mut cached_render, &virtual_dom) {
313                    throw_error!(err);
314                }
315                cached_render.push_str(&post_streaming);
316
317                if let Ok(mut incremental) = incremental.write() {
318                    let _ = incremental.cache(route, cached_render);
319                }
320            }
321
322            stream.render(post_streaming);
323
324            renderer.reset_render_components();
325            myself.renderers.write().unwrap().push(renderer);
326        });
327
328        Ok((
329            RenderFreshness::now(None),
330            ReceiverWithDrop {
331                receiver: rx,
332                cancel_task: Some(join_handle),
333            },
334        ))
335    }
336}
337
338/// Create the streaming render component callback. It will keep track of what scopes are mounted to what pending
339/// suspense boundaries in the DOM.
340///
341/// This mapping is used to replace the DOM mount with the resolved contents once the suspense boundary is finished.
342fn streaming_render_component_callback(
343    stream: Arc<StreamingRenderer<IncrementalRendererError>>,
344    scope_to_mount_mapping: Arc<RwLock<HashMap<ScopeId, PendingSuspenseBoundary>>>,
345) -> impl Fn(&mut Renderer, &mut dyn Write, &VirtualDom, ScopeId) -> std::fmt::Result
346       + Send
347       + Sync
348       + 'static {
349    // We use a stack to keep track of what suspense boundaries we are nested in to add children to the correct boundary
350    // The stack starts with the root scope because the root is a suspense boundary
351    let pending_suspense_boundaries_stack = RwLock::new(vec![]);
352    move |renderer, to, vdom, scope| {
353        let is_suspense_boundary =
354            SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope)
355                .filter(|s| s.has_suspended_tasks())
356                .is_some();
357        if is_suspense_boundary {
358            let mount = stream.render_placeholder(
359                |to| {
360                    {
361                        pending_suspense_boundaries_stack
362                            .write()
363                            .unwrap()
364                            .push(scope);
365                    }
366                    let out = renderer.render_scope(to, vdom, scope);
367                    {
368                        pending_suspense_boundaries_stack.write().unwrap().pop();
369                    }
370                    out
371                },
372                &mut *to,
373            )?;
374            // Add the suspense boundary to the list of pending suspense boundaries
375            // We will replace the mount with the resolved contents later once the suspense boundary is resolved
376            let mut scope_to_mount_mapping_write = scope_to_mount_mapping.write().unwrap();
377            scope_to_mount_mapping_write.insert(
378                scope,
379                PendingSuspenseBoundary {
380                    mount,
381                    children: vec![],
382                },
383            );
384            // Add the scope to the list of children of the parent suspense boundary
385            let pending_suspense_boundaries_stack =
386                pending_suspense_boundaries_stack.read().unwrap();
387            // If there is a parent suspense boundary, add the scope to the list of children
388            // This suspense boundary will start capturing errors when the parent is resolved
389            if let Some(parent) = pending_suspense_boundaries_stack.last() {
390                let parent = scope_to_mount_mapping_write.get_mut(parent).unwrap();
391                parent.children.push(scope);
392            }
393            // Otherwise this is a root suspense boundary, so we need to start capturing errors immediately
394            else {
395                vdom.in_runtime(|| {
396                    start_capturing_errors(scope);
397                });
398            }
399        } else {
400            renderer.render_scope(to, vdom, scope)?
401        }
402        Ok(())
403    }
404}
405
406/// Start capturing errors at a suspense boundary. If the parent suspense boundary is frozen, we need to capture the errors in the suspense boundary
407/// and send them to the client to continue bubbling up
408fn start_capturing_errors(suspense_scope: ScopeId) {
409    // Add an error boundary to the scope
410    suspense_scope.in_runtime(provide_error_boundary);
411}
412
413fn serialize_server_data(virtual_dom: &VirtualDom, scope: ScopeId) -> SerializedHydrationData {
414    // After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node
415    // Extract any data we serialized for hydration (from server futures)
416    let html_data =
417        crate::html_storage::HTMLData::extract_from_suspense_boundary(virtual_dom, scope);
418
419    // serialize the server state into a base64 string
420    html_data.serialized()
421}
422
423/// State used in server side rendering. This utilizes a pool of [`dioxus_ssr::Renderer`]s to cache static templates between renders.
424#[derive(Clone)]
425pub struct SSRState {
426    // We keep a pool of renderers to avoid re-creating them on every request. They are boxed to make them very cheap to move
427    renderers: Arc<SsrRendererPool>,
428}
429
430impl SSRState {
431    /// Create a new [`SSRState`].
432    pub fn new(cfg: &ServeConfig) -> Self {
433        Self {
434            renderers: Arc::new(SsrRendererPool::new(4, cfg.incremental.clone())),
435        }
436    }
437
438    /// Render the application to HTML.
439    pub async fn render<'a>(
440        &'a self,
441        route: String,
442        cfg: &'a ServeConfig,
443        virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static,
444        server_context: &'a DioxusServerContext,
445    ) -> Result<
446        (
447            RenderFreshness,
448            impl Stream<Item = Result<String, dioxus_isrg::IncrementalRendererError>>,
449        ),
450        dioxus_isrg::IncrementalRendererError,
451    > {
452        self.renderers
453            .clone()
454            .render_to(cfg, route, virtual_dom_factory, server_context)
455            .await
456    }
457}
458
459/// The template that wraps the body of the HTML for a fullstack page. This template contains the data needed to hydrate server functions that were run on the server.
460pub struct FullstackHTMLTemplate {
461    cfg: ServeConfig,
462}
463
464impl FullstackHTMLTemplate {
465    /// Create a new [`FullstackHTMLTemplate`].
466    pub fn new(cfg: &ServeConfig) -> Self {
467        Self { cfg: cfg.clone() }
468    }
469}
470
471impl FullstackHTMLTemplate {
472    /// Render any content before the head of the page.
473    pub fn render_head<R: std::fmt::Write>(
474        &self,
475        to: &mut R,
476        virtual_dom: &VirtualDom,
477    ) -> Result<(), dioxus_isrg::IncrementalRendererError> {
478        let ServeConfig { index, .. } = &self.cfg;
479
480        let title = {
481            let document: Option<std::rc::Rc<ServerDocument>> =
482                virtual_dom.in_runtime(|| ScopeId::ROOT.consume_context());
483            // Collect any head content from the document provider and inject that into the head
484            document.and_then(|document| document.title())
485        };
486
487        to.write_str(&index.head_before_title)?;
488        if let Some(title) = title {
489            to.write_str(&title)?;
490        } else {
491            to.write_str(&index.title)?;
492        }
493        to.write_str(&index.head_after_title)?;
494
495        let document: Option<std::rc::Rc<ServerDocument>> =
496            virtual_dom.in_runtime(|| ScopeId::ROOT.consume_context());
497        if let Some(document) = document {
498            // Collect any head content from the document provider and inject that into the head
499            document.render(to)?;
500
501            // Enable a warning when inserting contents into the head during streaming
502            document.start_streaming();
503        }
504
505        self.render_before_body(to)?;
506
507        Ok(())
508    }
509
510    /// Render any content before the body of the page.
511    fn render_before_body<R: std::fmt::Write>(
512        &self,
513        to: &mut R,
514    ) -> Result<(), dioxus_isrg::IncrementalRendererError> {
515        let ServeConfig { index, .. } = &self.cfg;
516
517        to.write_str(&index.close_head)?;
518
519        write!(to, "<script>{INITIALIZE_STREAMING_JS}</script>")?;
520
521        Ok(())
522    }
523
524    /// Render all content after the main element of the page.
525    pub fn render_after_main<R: std::fmt::Write>(
526        &self,
527        to: &mut R,
528        virtual_dom: &VirtualDom,
529    ) -> Result<(), dioxus_isrg::IncrementalRendererError> {
530        let ServeConfig { index, .. } = &self.cfg;
531
532        // Collect the initial server data from the root node. For most apps, no use_server_futures will be resolved initially, so this will be full on `None`s.
533        // Sending down those Nones are still important to tell the client not to run the use_server_futures that are already running on the backend
534        let resolved_data = serialize_server_data(virtual_dom, ScopeId::ROOT);
535        // We always send down the data required to hydrate components on the client
536        let raw_data = resolved_data.data;
537        write!(
538            to,
539            r#"<script>window.initial_dioxus_hydration_data="{raw_data}";"#,
540        )?;
541        #[cfg(debug_assertions)]
542        {
543            // In debug mode, we also send down the type names and locations of the serialized data
544            let debug_types = &resolved_data.debug_types;
545            let debug_locations = &resolved_data.debug_locations;
546            write!(
547                to,
548                r#"window.initial_dioxus_hydration_debug_types={debug_types};"#,
549            )?;
550            write!(
551                to,
552                r#"window.initial_dioxus_hydration_debug_locations={debug_locations};"#,
553            )?;
554        }
555        write!(to, r#"</script>"#,)?;
556        to.write_str(&index.post_main)?;
557
558        Ok(())
559    }
560
561    /// Render all content after the body of the page.
562    pub fn render_after_body<R: std::fmt::Write>(
563        &self,
564        to: &mut R,
565    ) -> Result<(), dioxus_isrg::IncrementalRendererError> {
566        let ServeConfig { index, .. } = &self.cfg;
567
568        to.write_str(&index.after_closing_body_tag)?;
569
570        Ok(())
571    }
572
573    /// Wrap a body in the template
574    pub fn wrap_body<R: std::fmt::Write>(
575        &self,
576        to: &mut R,
577        virtual_dom: &VirtualDom,
578        body: impl std::fmt::Display,
579    ) -> Result<(), dioxus_isrg::IncrementalRendererError> {
580        self.render_head(to, virtual_dom)?;
581        write!(to, "{body}")?;
582        self.render_after_main(to, virtual_dom)?;
583        self.render_after_body(to)?;
584
585        Ok(())
586    }
587}
588
589fn pre_renderer() -> Renderer {
590    let mut renderer = Renderer::default();
591    renderer.pre_render = true;
592    renderer
593}