From 29a7325bb72b62eee42acb4dade29b14743c5fb6 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Wed, 4 Dec 2024 15:25:08 -0600 Subject: [PATCH] Expose streaming options to choose between full prerendering and out of order streaming (#3288) * expose streaming options to choose between full prerendering and out of order streaming * add examples to streaming_mode --- .../fullstack-hackernews/src/main.rs | 6 +- packages/fullstack/src/render.rs | 143 ++++++++++-------- packages/fullstack/src/serve_config.rs | 49 ++++++ .../playwright-tests/fullstack/src/main.rs | 3 + .../nested-suspense/src/main.rs | 6 +- .../suspense-carousel/src/main.rs | 6 +- 6 files changed, 149 insertions(+), 64 deletions(-) diff --git a/example-projects/fullstack-hackernews/src/main.rs b/example-projects/fullstack-hackernews/src/main.rs index 3bb1c0927..a645b0c60 100644 --- a/example-projects/fullstack-hackernews/src/main.rs +++ b/example-projects/fullstack-hackernews/src/main.rs @@ -17,7 +17,11 @@ fn main() { #[cfg(feature = "server")] tracing_subscriber::fmt::init(); - dioxus::launch(|| rsx! { Router:: {} }); + LaunchBuilder::new() + .with_cfg(server_only! { + dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + }) + .launch(|| rsx! { Router:: {} }); } #[derive(Clone, Routable)] diff --git a/packages/fullstack/src/render.rs b/packages/fullstack/src/render.rs index 5d6b594ee..0172ac837 100644 --- a/packages/fullstack/src/render.rs +++ b/packages/fullstack/src/render.rs @@ -3,18 +3,19 @@ use crate::document::ServerDocument; use crate::streaming::{Mount, StreamingRenderer}; use dioxus_cli_config::base_path; use dioxus_interpreter_js::INITIALIZE_STREAMING_JS; -use dioxus_isrg::{CachedRender, RenderFreshness}; +use dioxus_isrg::{CachedRender, IncrementalRendererError, RenderFreshness}; use dioxus_lib::document::Document; use dioxus_ssr::Renderer; use futures_channel::mpsc::Sender; use futures_util::{Stream, StreamExt}; +use std::fmt::Write; use std::rc::Rc; use std::sync::Arc; use std::sync::RwLock; use std::{collections::HashMap, future::Future}; use tokio::task::JoinHandle; -use crate::prelude::*; +use crate::{prelude::*, StreamingMode}; use dioxus_lib::prelude::*; /// A suspense boundary that is pending with a placeholder in the client @@ -165,6 +166,7 @@ impl SsrRendererPool { .unwrap_or_else(pre_renderer); let myself = self.clone(); + let streaming_mode = cfg.streaming_mode; let join_handle = spawn_platform(move || async move { let mut virtual_dom = virtual_dom_factory(); @@ -202,65 +204,10 @@ impl SsrRendererPool { { let scope_to_mount_mapping = scope_to_mount_mapping.clone(); let stream = stream.clone(); - // We use a stack to keep track of what suspense boundaries we are nested in to add children to the correct boundary - // The stack starts with the root scope because the root is a suspense boundary - let pending_suspense_boundaries_stack = RwLock::new(vec![]); - renderer.set_render_components(move |renderer, to, vdom, scope| { - let is_suspense_boundary = - SuspenseContext::downcast_suspense_boundary_from_scope( - &vdom.runtime(), - scope, - ) - .filter(|s| s.has_suspended_tasks()) - .is_some(); - if is_suspense_boundary { - let mount = stream.render_placeholder( - |to| { - { - pending_suspense_boundaries_stack - .write() - .unwrap() - .push(scope); - } - let out = renderer.render_scope(to, vdom, scope); - { - pending_suspense_boundaries_stack.write().unwrap().pop(); - } - out - }, - &mut *to, - )?; - // Add the suspense boundary to the list of pending suspense boundaries - // We will replace the mount with the resolved contents later once the suspense boundary is resolved - let mut scope_to_mount_mapping_write = - scope_to_mount_mapping.write().unwrap(); - scope_to_mount_mapping_write.insert( - scope, - PendingSuspenseBoundary { - mount, - children: vec![], - }, - ); - // Add the scope to the list of children of the parent suspense boundary - let pending_suspense_boundaries_stack = - pending_suspense_boundaries_stack.read().unwrap(); - // If there is a parent suspense boundary, add the scope to the list of children - // This suspense boundary will start capturing errors when the parent is resolved - if let Some(parent) = pending_suspense_boundaries_stack.last() { - let parent = scope_to_mount_mapping_write.get_mut(parent).unwrap(); - parent.children.push(scope); - } - // Otherwise this is a root suspense boundary, so we need to start capturing errors immediately - else { - vdom.in_runtime(|| { - start_capturing_errors(scope); - }); - } - } else { - renderer.render_scope(to, vdom, scope)? - } - Ok(()) - }); + renderer.set_render_components(streaming_render_component_callback( + stream, + scope_to_mount_mapping, + )); } macro_rules! throw_error { @@ -270,6 +217,12 @@ impl SsrRendererPool { }; } + // If streaming is disabled, wait for the virtual dom to finish all suspense work + // before rendering anything + if streaming_mode == StreamingMode::Disabled { + virtual_dom.wait_for_suspense().await; + } + // Render the initial frame with loading placeholders let mut initial_frame = renderer.render(&virtual_dom); @@ -380,6 +333,74 @@ impl SsrRendererPool { } } +/// Create the streaming render component callback. It will keep track of what scopes are mounted to what pending +/// suspense boundaries in the DOM. +/// +/// This mapping is used to replace the DOM mount with the resolved contents once the suspense boundary is finished. +fn streaming_render_component_callback( + stream: Arc>, + scope_to_mount_mapping: Arc>>, +) -> impl Fn(&mut Renderer, &mut dyn Write, &VirtualDom, ScopeId) -> std::fmt::Result + + Send + + Sync + + 'static { + // We use a stack to keep track of what suspense boundaries we are nested in to add children to the correct boundary + // The stack starts with the root scope because the root is a suspense boundary + let pending_suspense_boundaries_stack = RwLock::new(vec![]); + move |renderer, to, vdom, scope| { + let is_suspense_boundary = + SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope) + .filter(|s| s.has_suspended_tasks()) + .is_some(); + if is_suspense_boundary { + let mount = stream.render_placeholder( + |to| { + { + pending_suspense_boundaries_stack + .write() + .unwrap() + .push(scope); + } + let out = renderer.render_scope(to, vdom, scope); + { + pending_suspense_boundaries_stack.write().unwrap().pop(); + } + out + }, + &mut *to, + )?; + // Add the suspense boundary to the list of pending suspense boundaries + // We will replace the mount with the resolved contents later once the suspense boundary is resolved + let mut scope_to_mount_mapping_write = scope_to_mount_mapping.write().unwrap(); + scope_to_mount_mapping_write.insert( + scope, + PendingSuspenseBoundary { + mount, + children: vec![], + }, + ); + // Add the scope to the list of children of the parent suspense boundary + let pending_suspense_boundaries_stack = + pending_suspense_boundaries_stack.read().unwrap(); + // If there is a parent suspense boundary, add the scope to the list of children + // This suspense boundary will start capturing errors when the parent is resolved + if let Some(parent) = pending_suspense_boundaries_stack.last() { + let parent = scope_to_mount_mapping_write.get_mut(parent).unwrap(); + parent.children.push(scope); + } + // Otherwise this is a root suspense boundary, so we need to start capturing errors immediately + else { + vdom.in_runtime(|| { + start_capturing_errors(scope); + }); + } + } else { + renderer.render_scope(to, vdom, scope)? + } + Ok(()) + } +} + /// Start capturing errors at a suspense boundary. If the parent suspense boundary is frozen, we need to capture the errors in the suspense boundary /// and send them to the client to continue bubbling up fn start_capturing_errors(suspense_scope: ScopeId) { diff --git a/packages/fullstack/src/serve_config.rs b/packages/fullstack/src/serve_config.rs index dc3b6a770..ad392779f 100644 --- a/packages/fullstack/src/serve_config.rs +++ b/packages/fullstack/src/serve_config.rs @@ -17,6 +17,7 @@ pub struct ServeConfigBuilder { pub(crate) index_path: Option, pub(crate) incremental: Option, pub(crate) context_providers: ContextProviders, + pub(crate) streaming_mode: StreamingMode, } impl LaunchConfig for ServeConfigBuilder {} @@ -30,6 +31,7 @@ impl ServeConfigBuilder { index_path: None, incremental: None, context_providers: Default::default(), + streaming_mode: StreamingMode::default(), } } @@ -141,6 +143,40 @@ impl ServeConfigBuilder { self } + /// Set the streaming mode for the server. By default, streaming is disabled. + /// + /// ```rust, no_run + /// # use dioxus::prelude::*; + /// # fn app() -> Element { todo!() } + /// dioxus::LaunchBuilder::new() + /// .with_context(server_only! { + /// dioxus::fullstack::ServeConfig::builder().streaming_mode(dioxus::fullstack::StreamingMode::OutOfOrder) + /// }) + /// .launch(app); + /// ``` + pub fn streaming_mode(mut self, mode: StreamingMode) -> Self { + self.streaming_mode = mode; + self + } + + /// Enable out of order streaming. This will cause server futures to be resolved out of order and streamed to the client as they resolve. + /// + /// It is equivalent to calling `streaming_mode(StreamingMode::OutOfOrder)` + /// + /// /// ```rust, no_run + /// # use dioxus::prelude::*; + /// # fn app() -> Element { todo!() } + /// dioxus::LaunchBuilder::new() + /// .with_context(server_only! { + /// dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + /// }) + /// .launch(app); + /// ``` + pub fn enable_out_of_order_streaming(mut self) -> Self { + self.streaming_mode = StreamingMode::OutOfOrder; + self + } + /// Build the ServeConfig. This may fail if the index.html file is not found. pub fn build(self) -> Result { // The CLI always bundles static assets into the exe/public directory @@ -164,6 +200,7 @@ impl ServeConfigBuilder { index, incremental: self.incremental, context_providers: self.context_providers, + streaming_mode: self.streaming_mode, }) } } @@ -262,6 +299,17 @@ pub(crate) struct IndexHtml { pub(crate) after_closing_body_tag: String, } +/// The streaming mode to use while rendering the page +#[derive(Clone, Copy, Default, PartialEq)] +pub enum StreamingMode { + /// Streaming is disabled; all server futures should be resolved before hydrating the page on the client + #[default] + Disabled, + /// Out of order streaming is enabled; server futures are resolved out of order and streamed to the client + /// as they resolve + OutOfOrder, +} + /// Used to configure how to serve a Dioxus application. It contains information about how to serve static assets, and what content to render with [`dioxus-ssr`]. /// See [`ServeConfigBuilder`] to create a ServeConfig #[derive(Clone)] @@ -269,6 +317,7 @@ pub struct ServeConfig { pub(crate) index: IndexHtml, pub(crate) incremental: Option, pub(crate) context_providers: ContextProviders, + pub(crate) streaming_mode: StreamingMode, } impl LaunchConfig for ServeConfig {} diff --git a/packages/playwright-tests/fullstack/src/main.rs b/packages/playwright-tests/fullstack/src/main.rs index 1440c84e8..061f81993 100644 --- a/packages/playwright-tests/fullstack/src/main.rs +++ b/packages/playwright-tests/fullstack/src/main.rs @@ -9,6 +9,9 @@ use dioxus::{prelude::*, CapturedError}; fn main() { dioxus::LaunchBuilder::new() + .with_cfg(server_only! { + dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + }) .with_context(1234u32) .launch(app); } diff --git a/packages/playwright-tests/nested-suspense/src/main.rs b/packages/playwright-tests/nested-suspense/src/main.rs index d85994848..8ffed72ae 100644 --- a/packages/playwright-tests/nested-suspense/src/main.rs +++ b/packages/playwright-tests/nested-suspense/src/main.rs @@ -12,7 +12,11 @@ use dioxus::prelude::*; use serde::{Deserialize, Serialize}; fn main() { - dioxus::launch(app); + LaunchBuilder::new() + .with_cfg(server_only! { + dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + }) + .launch(app); } fn app() -> Element { diff --git a/packages/playwright-tests/suspense-carousel/src/main.rs b/packages/playwright-tests/suspense-carousel/src/main.rs index fd72d73a3..4a80b5292 100644 --- a/packages/playwright-tests/suspense-carousel/src/main.rs +++ b/packages/playwright-tests/suspense-carousel/src/main.rs @@ -107,5 +107,9 @@ fn NestedSuspendedComponent(id: i32) -> Element { } fn main() { - dioxus::launch(app); + LaunchBuilder::new() + .with_cfg(server_only! { + dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + }) + .launch(app); }