Suspense boundaries/out of order streaming/anyhow like error handling (#2365)

* create static site generation helpers in the router crate

* work on integrating static site generation into fullstack

* move ssg into a separate crate

* integrate ssg with the launch builder

* simplify ssg example

* fix static_routes for child routes

* move CLI hot reloading websocket code into dioxus-hot-reload

* fix some unused imports

* use the same hot reloading websocket code for fullstack

* fix fullstack hot reloading

* move cli hot reloading logic into the hot reload crate

* ssg example working with dx serve

* add more examples

* fix clippy

* switch to a result for Element

* fix formatting

* fix hot reload doctest imports

* fix axum imports

* add show method to error context

* implement retaining nodes during suspense

* fix unterminated if statements

* communicate between tasks and suspense boundaries

* make suspense placeholders easier to use

* implement IntoDynNode and IntoVNode for more wrappers

* fix clippy examples

* fix rsx tests

* add streaming html utilities to the ssr package

* unify hydration and non-hydration ssr cache

* fix router with Result Element

* don't run server doc tests

* Fix hot reload websocket doc examples

* simple apps working with fullstack streaming

* fix preloading wasm

* Report errors encountered while streaming

* remove async from incremental renderer

* document new VirtualDom suspense methods

* make streaming work with incremental rendering

* fix static site generation

* document suspense structs

* create closure type; allow async event handlers in props; allow shorthand event handlers

* test forwarding event handlers with the shorthand syntax

* fix clippy

* fix imports in spawn async doctest

* fix empty rsx

* fix async result event handlers

* fix mounting router in multiple places

* Fix task dead cancel race condition

* simplify diffing before adding suspense

* fix binary size increase

* fix attribute diffing

* more diffing fixes

* create minimal fullstack feature

* smaller fullstack bundles

* allow mounting nodes that are already created and creating nodes without mounting them

* fix hot reload feature

* fix replacing components

* don't reclaim virtual nodes

* client side suspense working!

* fix CLI

* slightly smaller fullstack builds

* fix multiple suspended scopes

* fix merge errors

* yield back to tokio every few polls to fix suspending on many tasks at once

* remove logs

* document suspense boundary and update suspense example

* fix ssg

* make streaming optional

* fix some router and core tests

* fix suspense example

* fix serialization with out of order server futures

* add incremental streaming hackernews demo

* fix hackernews demo

* fix root hackernews redirect

* fix formatting

* add tests for suspense cases

* slightly smaller binaries

* slightly smaller

* improve error handling docs

* fix errors example link

* fix doc tests

* remove log file

* fix ssr cache type inference

* remove index.html

* fix ssg render template

* fix assigning ids on elements with dynamic attributes

* add desktop feature to the workspace examples

* remove router static generation example; ssg lives in the dioxus-static-generation package

* add a test for effects during suspense

* only run effects on mounted nodes

* fix multiple suspense roots

* fix node iterator

* fix closures without arguments

* fix dioxus-core readme doctest

* remove suspense logs

* fix scope stack

* fix clippy

* remove unused suspense boundary from hackernews

* assert that launch never returns for better compiler errors

* fix static generation launch function

* fix web renderer

* pass context providers into server functions

* add an example for FromContext

* clean up DioxusRouterExt

* fix server function context

* fix fullstack desktop example

* forward CLI serve settings to fullstack

* re-export serve config at the root of fullstack

* forward env directly instead of using a guard

* just set the port in the CLI for fullstack playwright tests

* fix fullstack dioxus-cli-config feature

* fix launch server merge conflicts

* fix fullstack launch context

* Merge branch 'main' into suspense-2.0

* fix fullstack html data

* remove drop virtual dom feature

* add a comment about only_write_templates binary size workaround

* remove explicit dependencies from use_server_future

* make ErrorContext and SuspenseContext more similar

* Tweak: small tweaks to tomls to make diff smaller

* only rerun components under suspense after the initial placeholders are sent to the client

* add module docs for suspense

* keep track of when suspense boundaries are resolved

* start implementing JS out of order streaming

* fix core tests

* implement the server side of suspense with js

* fix streaming ssr with nested suspense

* move streaming ssr code into fullstack

* revert minification changes

* serialize server future data as the html streams

* start loading scripts wasm immediately instead of defering the script

* very basic nested suspense example working with minimal html updates

* clean up some suspense/error docs

* fix hydrating nested pending server futures

* sort resolved boundaries by height

* Fix disconnecting clients while streaming

* fix static generation crate

* don't insert extra divs when hydrating streamed chunks

* wait to swap in the elements until they are hydrated

* remove inline streaming script

* hackernews partially working

* fix spa mode

* banish the open shadow dom

* fix removing placeholder

* set up streaming playwright test

* run web playwright tests on 9999 to avoid port conflicts with other local servers

* remove suspense nodes if the suspense boundary is replaced before the suspense resolves on the server

* ignore hydration of removed suspense boundaries

* use path based indexing to fix hydrating suspense after parent suspense with child is removed

* re-export dioxus error

* remove resolved suspense divs if the suspense boundary has been removed

* Fix client side initialized server futures

* ignore comment nodes while traversing nodes in core to avoid lists getting swapped out with suspense

* Pass initial hydration data to the client

* hide pre nodes

* don't panic if reclaiming an element fails

* fix scope stack when polling tasks

* improve deserialization out of length message

* Ok(VNode::placeholder()) -> VNode::empty()

* fix typo in rsx usage

* restore testing changes from suspense example

* clean up some logs and comments

* fix playwright tests

* clean up more changes in core

* clean up core tests

* remove anymap dependency

* clean up changes to hooks

* clean up changes in the router, rsx, and web

* revert changes to axum-hello-world

* fix use_server_future

* fix clippy in dioxus-core

* check that the next or previous node exist before checking if we should ignore them

* fix formatting

* fix suspense playwright test

* remove unused suspense code

* add more suspense playwright tests

* add more docs for error boundaries

* fix suspense core tests

* fix ErrorBoundary example

* remove a bunch of debug logging in js

* fix router failure_external_navigation

* use absolute paths in the interpreter build.rs

* strip '\r' while hashing ts files

* add a wrapper with a default error boundary and suspense boundary

* restore hot reloading

* ignore non-ts files when hashing

* sort ts files before hashing them

* fix rsx tests

* fix fullstack doc tests

* fix core tests

* fix axum auth example

* update suspense hydration diagram

* longer playwright build limit

* tiny fixes - spelling, formatting

* update diagram link

* remove comment and template nodes for suspense placeholders

* remove comment nodes as we hydrate text

* simplify hackernews example

* clean up hydrating text nodes

* switch to a separate environment variable for the base path for smaller binaries

* clean up file system html trait

* fix form data

* move streaming code into fullstack

* implement serialize and deserialize for CapturedError

* remove waits in the nested suspense playwright spec

* force sequential fullstack builds for CI

* longer nested suspense delay for CI

* fix --force-sequential flag

* wait to launch server until client build is done

---------

Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
Evan AlmloffandJonathan Kelley authored and GitHub committed 2024-07-01 20:50:36 -07:00
1 parent ffa36a67c3
commit 022e4ad203
175 files changed
+7872 -4010

No files matched your search

+175 -186
View File
@@ -1,15 +1,14 @@
//! Dioxus utilities for the [Axum](https://docs.rs/axum/latest/axum/index.html) server framework.
//!
//! # Example
//! ```rust
//! ```rust, no_run
//! #![allow(non_snake_case)]
//! use dioxus_lib::prelude::*;
//! use dioxus_fullstack::prelude::*;
//! use dioxus::prelude::*;
//!
//! fn main() {
//! #[cfg(feature = "web")]
//! // Hydrate the application on the client
//! dioxus_web::launch_cfg(app, dioxus_web::Config::new().hydrate(true));
//! launch(app);
//! #[cfg(feature = "server")]
//! {
//! tokio::runtime::Runtime::new()
@@ -22,7 +21,7 @@
//! listener,
//! axum::Router::new()
//! // Server side render the application, serve static assets, and register server functions
//! .serve_dioxus_application(ServerConfig::new(), app)
//! .serve_dioxus_application(ServeConfig::default(), app)
//! .into_make_service(),
//! )
//! .await
@@ -44,7 +43,7 @@
//! "Run a server function"
//! }
//! "Server said: {text}"
//! })
//! }
//! }
//!
//! #[server(GetServerData)]
@@ -61,7 +60,6 @@ use axum::{
response::IntoResponse,
};
use dioxus_lib::prelude::{Element, VirtualDom};
use futures_util::Future;
use http::header::*;
use std::sync::Arc;
@@ -74,21 +72,18 @@ pub trait DioxusRouterExt<S> {
/// Registers server functions with the default handler. This handler function will pass an empty [`DioxusServerContext`] to your server functions.
///
/// # Example
/// ```rust
/// ```rust, no_run
/// # use dioxus_lib::prelude::*;
/// # use dioxus_fullstack::prelude::*;
/// #[tokio::main]
/// async fn main() {
/// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080));
/// axum::Server::bind(&addr)
/// .serve(
/// axum::Router::new()
/// // Register server functions routes with the default handler
/// .register_server_functions()
/// .into_make_service(),
/// )
/// .await
/// .unwrap();
/// let router = axum::Router::new()
/// // Register server functions routes with the default handler
/// .register_server_functions()
/// .into_make_service();
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
/// axum::serve(listener, router).await.unwrap();
/// }
/// ```
fn register_server_functions(self) -> Self
@@ -101,21 +96,19 @@ pub trait DioxusRouterExt<S> {
/// Registers server functions with some additional context to insert into the [`DioxusServerContext`] for that handler.
///
/// # Example
/// ```rust
/// ```rust, no_run
/// # use dioxus_lib::prelude::*;
/// # use dioxus_fullstack::prelude::*;
/// # use std::sync::Arc;
/// #[tokio::main]
/// async fn main() {
/// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080));
/// axum::Server::bind(&addr)
/// .serve(
/// axum::Router::new()
/// // Register server functions routes with the default handler
/// .register_server_functions_with_context(vec![Box::new(|| 1234567890u32)])
/// .into_make_service(),
/// )
/// .await
/// .unwrap();
/// let router = axum::Router::new()
/// // Register server functions routes with the default handler
/// .register_server_functions_with_context(Arc::new(vec![Box::new(|| Box::new(1234567890u32))]))
/// .into_make_service();
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
/// axum::serve(listener, router).await.unwrap();
/// }
/// ```
fn register_server_functions_with_context(self, context_providers: ContextProviders) -> Self;
@@ -123,34 +116,24 @@ pub trait DioxusRouterExt<S> {
/// Serves the static WASM for your Dioxus application (except the generated index.html).
///
/// # Example
/// ```rust
/// ```rust, no_run
/// # #![allow(non_snake_case)]
/// # use dioxus_lib::prelude::*;
/// # use dioxus_fullstack::prelude::*;
/// #[tokio::main]
/// async fn main() {
/// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080));
/// axum::Server::bind(&addr)
/// .serve(
/// axum::Router::new()
/// // Server side render the application, serve static assets, and register server functions
/// .serve_static_assets("dist")
/// // Server render the application
/// // ...
/// .into_make_service(),
/// )
/// .await
/// .unwrap();
/// }
///
/// fn app() -> Element {
/// unimplemented!()
/// let router = axum::Router::new()
/// // Server side render the application, serve static assets, and register server functions
/// .serve_static_assets("dist")
/// // Server render the application
/// // ...
/// .into_make_service();
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
/// axum::serve(listener, router).await.unwrap();
/// }
/// ```
fn serve_static_assets(
self,
assets_path: impl Into<std::path::PathBuf>,
) -> impl Future<Output = Self> + Send + Sync
fn serve_static_assets(self, assets_path: impl Into<std::path::PathBuf>) -> Self
where
Self: Sized;
@@ -158,33 +141,26 @@ pub trait DioxusRouterExt<S> {
/// This will serve static assets, server render the application, register server functions, and integrate with hot reloading.
///
/// # Example
/// ```rust
/// ```rust, no_run
/// # #![allow(non_snake_case)]
/// # use dioxus_lib::prelude::*;
/// # use dioxus_fullstack::prelude::*;
/// #[tokio::main]
/// async fn main() {
/// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080));
/// axum::Server::bind(&addr)
/// .serve(
/// axum::Router::new()
/// // Server side render the application, serve static assets, and register server functions
/// .serve_dioxus_application(ServeConfig::new(), )
/// .into_make_service(),
/// )
/// .await
/// .unwrap();
/// let router = axum::Router::new()
/// // Server side render the application, serve static assets, and register server functions
/// .serve_dioxus_application(ServeConfig::default(), app)
/// .into_make_service();
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
/// axum::serve(listener, router).await.unwrap();
/// }
///
/// fn app() -> Element {
/// unimplemented!()
/// rsx! { "Hello World" }
/// }
/// ```
fn serve_dioxus_application(
self,
cfg: impl Into<ServeConfig>,
app: fn() -> Element,
) -> impl Future<Output = Self> + Send + Sync
fn serve_dioxus_application(self, cfg: impl Into<ServeConfig>, app: fn() -> Element) -> Self
where
Self: Sized;
}
@@ -210,7 +186,7 @@ where
move |server_context| {
for context_provider in context_providers.iter() {
let context = context_provider();
_ = server_context.insert_any(context);
server_context.insert_any(context);
}
},
req,
@@ -229,77 +205,70 @@ where
// TODO: This is a breaking change, but we should probably serve static assets from a different directory than dist where the server executable is located
// This would prevent issues like https://github.com/DioxusLabs/dioxus/issues/2327
fn serve_static_assets(
mut self,
assets_path: impl Into<std::path::PathBuf>,
) -> impl Future<Output = Self> + Send + Sync {
fn serve_static_assets(mut self, assets_path: impl Into<std::path::PathBuf>) -> Self {
use tower_http::services::{ServeDir, ServeFile};
let assets_path = assets_path.into();
async move {
// Serve all files in dist folder except index.html
let dir = std::fs::read_dir(&assets_path).unwrap_or_else(|e| {
panic!(
"Couldn't read assets directory at {:?}: {}",
&assets_path, e
)
});
for entry in dir.flatten() {
let path = entry.path();
if path.ends_with("index.html") {
continue;
}
let route = path
.strip_prefix(&assets_path)
.unwrap()
.iter()
.map(|segment| {
segment.to_str().unwrap_or_else(|| {
panic!("Failed to convert path segment {:?} to string", segment)
})
// Serve all files in dist folder except index.html
let dir = std::fs::read_dir(&assets_path).unwrap_or_else(|e| {
panic!(
"Couldn't read assets directory at {:?}: {}",
&assets_path, e
)
});
for entry in dir.flatten() {
let path = entry.path();
if path.ends_with("index.html") {
continue;
}
let route = path
.strip_prefix(&assets_path)
.unwrap()
.iter()
.map(|segment| {
segment.to_str().unwrap_or_else(|| {
panic!("Failed to convert path segment {:?} to string", segment)
})
.collect::<Vec<_>>()
.join("/");
let route = format!("/{}", route);
if path.is_dir() {
self = self.nest_service(&route, ServeDir::new(path).precompressed_br());
} else {
self = self.nest_service(&route, ServeFile::new(path).precompressed_br());
}
})
.collect::<Vec<_>>()
.join("/");
let route = format!("/{}", route);
if path.is_dir() {
self = self.nest_service(&route, ServeDir::new(path).precompressed_br());
} else {
self = self.nest_service(&route, ServeFile::new(path).precompressed_br());
}
self
}
self
}
fn serve_dioxus_application(
self,
cfg: impl Into<ServeConfig>,
app: fn() -> Element,
) -> impl Future<Output = Self> + Send + Sync {
fn serve_dioxus_application(self, cfg: impl Into<ServeConfig>, app: fn() -> Element) -> Self {
let cfg = cfg.into();
async move {
let ssr_state = SSRState::new(&cfg);
// Add server functions and render index.html
let mut server = self
.serve_static_assets(cfg.assets_path.clone())
.await
.register_server_functions();
let ssr_state = SSRState::new(&cfg);
#[cfg(all(feature = "hot-reload", debug_assertions))]
{
use dioxus_hot_reload::HotReloadRouterExt;
server = server.forward_cli_hot_reloading();
}
// Add server functions and render index.html
#[allow(unused_mut)]
let mut server = self
.serve_static_assets(cfg.assets_path.clone())
.register_server_functions();
server.fallback(get(render_handler).with_state((
cfg,
Arc::new(move || VirtualDom::new(app)),
ssr_state,
)))
#[cfg(all(feature = "hot-reload", debug_assertions))]
{
use dioxus_hot_reload::HotReloadRouterExt;
server = server.forward_cli_hot_reloading();
}
server.fallback(
get(render_handler).with_state(
RenderHandleState::new(app)
.with_config(cfg)
.with_ssr_state(ssr_state),
),
)
}
}
@@ -313,12 +282,54 @@ fn apply_request_parts_to_response<B>(
}
}
type AxumHandler<F> = (
F,
ServeConfig,
SSRState,
Arc<dyn Fn() -> VirtualDom + Send + Sync>,
);
/// State used by [`render_handler`] to render a dioxus component with axum
#[derive(Clone)]
pub struct RenderHandleState {
config: ServeConfig,
build_virtual_dom: Arc<dyn Fn() -> VirtualDom + Send + Sync>,
ssr_state: once_cell::sync::OnceCell<SSRState>,
}
impl RenderHandleState {
/// Create a new [`RenderHandleState`]
pub fn new(root: fn() -> Element) -> Self {
Self {
config: ServeConfig::default(),
build_virtual_dom: Arc::new(move || VirtualDom::new(root)),
ssr_state: Default::default(),
}
}
/// Create a new [`RenderHandleState`] with a custom [`VirtualDom`] factory. This method can be used to pass context into the root component of your application.
pub fn new_with_virtual_dom_factory(
build_virtual_dom: impl Fn() -> VirtualDom + Send + Sync + 'static,
) -> Self {
Self {
config: ServeConfig::default(),
build_virtual_dom: Arc::new(build_virtual_dom),
ssr_state: Default::default(),
}
}
/// Set the [`ServeConfig`] for this [`RenderHandleState`]
pub fn with_config(mut self, config: ServeConfig) -> Self {
self.config = config;
self
}
/// Set the [`SSRState`] for this [`RenderHandleState`]. Sharing a [`SSRState`] between multiple [`RenderHandleState`]s is more efficient than creating a new [`SSRState`] for each [`RenderHandleState`].
pub fn with_ssr_state(mut self, ssr_state: SSRState) -> Self {
self.ssr_state = once_cell::sync::OnceCell::new();
if self.ssr_state.set(ssr_state).is_err() {
panic!("SSRState already set");
}
self
}
fn ssr_state(&self) -> &SSRState {
self.ssr_state.get_or_init(|| SSRState::new(&self.config))
}
}
/// SSR renderer handler for Axum with added context injection.
///
@@ -328,8 +339,7 @@ type AxumHandler<F> = (
/// use std::sync::{Arc, Mutex};
///
/// use axum::routing::get;
/// use dioxus_lib::prelude::*;
/// use dioxus_fullstack::{axum_adapter::render_handler_with_context, prelude::*};
/// use dioxus::prelude::*;
///
/// fn app() -> Element {
/// rsx! {
@@ -339,82 +349,61 @@ type AxumHandler<F> = (
///
/// #[tokio::main]
/// async fn main() {
/// let cfg = ServerConfig::new(app, ())
/// .assets_path("dist")
/// .build();
/// let ssr_state = SSRState::new(&cfg);
///
/// // This could be any state you want to be accessible from your server
/// // functions using `[DioxusServerContext::get]`.
/// let state = Arc::new(Mutex::new("state".to_string()));
///
/// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080));
/// axum::Server::bind(&addr)
/// .serve(
/// axum::Router::new()
/// // Register server functions, etc.
/// // Note you can use `register_server_functions_with_context`
/// // to inject the context into server functions running outside
/// // of an SSR render context.
/// .fallback(get(render_handler_with_context).with_state((
/// move |ctx| ctx.insert(state.clone()).unwrap(),
/// cfg,
/// ssr_state,
/// )))
/// .into_make_service(),
/// let router = axum::Router::new()
/// // Register server functions, etc.
/// // Note you can use `register_server_functions_with_context`
/// // to inject the context into server functions running outside
/// // of an SSR render context.
/// .fallback(get(render_handler)
/// .with_state(RenderHandleState::new(app))
/// )
/// .await
/// .unwrap();
/// .into_make_service();
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
/// axum::serve(listener, router).await.unwrap();
/// }
/// ```
pub async fn render_handler_with_context<F: FnMut(&mut DioxusServerContext)>(
State((mut inject_context, cfg, ssr_state, virtual_dom_factory)): State<AxumHandler<F>>,
pub async fn render_handler(
State(state): State<RenderHandleState>,
request: Request<Body>,
) -> impl IntoResponse {
// Only respond to requests for HTML
if let Some(mime) = request.headers().get("Accept") {
let mime = mime.to_str().map(|mime| mime.to_ascii_lowercase());
match mime {
Ok(accepts) if accepts.contains("text/html") => {}
_ => return Err(StatusCode::NOT_ACCEPTABLE),
}
}
let cfg = &state.config;
let ssr_state = state.ssr_state();
let build_virtual_dom = state.build_virtual_dom.clone();
let (parts, _) = request.into_parts();
let url = parts.uri.path_and_query().unwrap().to_string();
let parts: Arc<tokio::sync::RwLock<http::request::Parts>> =
Arc::new(tokio::sync::RwLock::new(parts));
let mut server_context = DioxusServerContext::new(parts.clone());
inject_context(&mut server_context);
let parts: Arc<parking_lot::RwLock<http::request::Parts>> =
Arc::new(parking_lot::RwLock::new(parts));
let server_context = DioxusServerContext::from_shared_parts(parts.clone());
match ssr_state
.render(url, &cfg, move || virtual_dom_factory(), &server_context)
.render(url, cfg, move || build_virtual_dom(), &server_context)
.await
{
Ok(rendered) => {
let crate::render::RenderResponse { html, freshness } = rendered;
let mut response = axum::response::Html::from(html).into_response();
Ok((freshness, rx)) => {
let mut response = axum::response::Html::from(Body::from_stream(rx)).into_response();
freshness.write(response.headers_mut());
let headers = server_context.response_parts().unwrap().headers.clone();
let headers = server_context.response_parts().headers.clone();
apply_request_parts_to_response(headers, &mut response);
response
Ok(response)
}
Err(e) => {
tracing::error!("Failed to render page: {}", e);
report_err(e).into_response()
Ok(report_err(e).into_response())
}
}
}
type RenderHandlerExtractor = (
ServeConfig,
Arc<dyn Fn() -> VirtualDom + Send + Sync>,
SSRState,
);
/// SSR renderer handler for Axum
pub async fn render_handler(
State((cfg, virtual_dom_factory, ssr_state)): State<RenderHandlerExtractor>,
request: Request<Body>,
) -> impl IntoResponse {
render_handler_with_context(
State((|_: &mut _| (), cfg, ssr_state, virtual_dom_factory)),
request,
)
.await
}
fn report_err<E: std::fmt::Display>(e: E) -> Response<axum::body::Body> {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
@@ -439,7 +428,7 @@ async fn handle_server_fns_inner(
if let Some(mut service) =
server_fn::axum::get_server_fn_service(&path_string)
{
let server_context = DioxusServerContext::new(Arc::new(tokio::sync::RwLock::new(parts)));
let server_context = DioxusServerContext::new(parts);
additional_context(&server_context);
// store Accepts and Referrer in case we need them for redirect (below)
@@ -467,7 +456,7 @@ async fn handle_server_fns_inner(
}
// apply the response parts from the server context to the response
let mut res_options = server_context.response_parts_mut().unwrap();
let mut res_options = server_context.response_parts_mut();
res.headers_mut().extend(res_options.headers.drain());
Ok(res)