From d8c2550fc7d01f0cc3fa9b8d9d30059f32f7e703 Mon Sep 17 00:00:00 2001 From: Kaede Hoshikawa Date: Wed, 12 Jan 2022 22:43:09 +0900 Subject: [PATCH] Server-side Rendering (without hydration) (#2335) * Basic render to html implementation. * Remove HtmlWriter. * Escape html content. * Add non-suspense tests. * Add Suspense tests. * Gated "ssr" feature. * Add example. * Fix tests. * Fix docs. * Fix heading size. * Remove the unused YewRenderer. * Remove extra comment. * unify naming. * Update docs. * Update docs. * Update docs. * Isolate spawn_local. * Add doc flags. * Add ssr feature to docs. * Move ServerRenderer into their own file. * Fix docs. * Update features and docs. * Fix example. * Adjust comment position. * Fix effects being wrongly called when a component is suspended. * Fix clippy. * Uuid & no double boxing. Co-authored-by: Muhammad Hamza --- .github/workflows/publish-examples.yml | 5 + Cargo.toml | 1 + Makefile.toml | 7 +- examples/futures/Cargo.toml | 2 +- examples/simple_ssr/Cargo.toml | 14 + examples/simple_ssr/README.md | 6 + examples/simple_ssr/src/main.rs | 129 +++++++++ examples/suspense/Cargo.toml | 2 +- packages/yew/Cargo.toml | 21 +- packages/yew/Makefile.toml | 4 + .../yew/src/functional/hooks/use_effect.rs | 58 +++-- packages/yew/src/html/component/lifecycle.rs | 78 ++++-- packages/yew/src/html/component/scope.rs | 168 +++++++----- packages/yew/src/io_coop.rs | 27 ++ packages/yew/src/lib.rs | 18 +- packages/yew/src/server_renderer.rs | 59 +++++ packages/yew/src/suspense/component.rs | 12 +- packages/yew/src/suspense/suspension.rs | 34 ++- packages/yew/src/virtual_dom/vcomp.rs | 90 ++++++- packages/yew/src/virtual_dom/vlist.rs | 79 ++++++ packages/yew/src/virtual_dom/vnode.rs | 39 +++ packages/yew/src/virtual_dom/vsuspense.rs | 125 ++++++++- packages/yew/src/virtual_dom/vtag.rs | 139 +++++++++- packages/yew/src/virtual_dom/vtext.rs | 29 +++ packages/yew/tests/suspense.rs | 176 +++++++++++++ tools/website-test/Cargo.toml | 3 +- tools/website-test/build.rs | 2 +- .../advanced-topics/server-side-rendering.md | 136 ++++++++++ website/docs/concepts/suspense.md | 2 +- website/sidebars.js | 246 +++++++++--------- 30 files changed, 1450 insertions(+), 261 deletions(-) create mode 100644 examples/simple_ssr/Cargo.toml create mode 100644 examples/simple_ssr/README.md create mode 100644 examples/simple_ssr/src/main.rs create mode 100644 packages/yew/src/io_coop.rs create mode 100644 packages/yew/src/server_renderer.rs create mode 100644 website/docs/advanced-topics/server-side-rendering.md diff --git a/.github/workflows/publish-examples.yml b/.github/workflows/publish-examples.yml index af3c9ee72e1..e62f272f535 100644 --- a/.github/workflows/publish-examples.yml +++ b/.github/workflows/publish-examples.yml @@ -61,6 +61,11 @@ jobs: continue fi + # ssr does not need trunk + if [[ "$example" == "simple_ssr" ]]; then + continue + fi + echo "building: $example" ( cd "$path" diff --git a/Cargo.toml b/Cargo.toml index 5180b602f93..bafa940cc5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "examples/password_strength", "examples/portals", "examples/router", + "examples/simple_ssr", "examples/timer", "examples/todomvc", "examples/two_apps", diff --git a/Makefile.toml b/Makefile.toml index 7f4a7ea2670..c0b7cb7fd75 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -38,7 +38,7 @@ category = "Testing" description = "Run all tests" dependencies = ["tests-setup"] env = { CARGO_MAKE_WORKSPACE_SKIP_MEMBERS = ["**/examples/*", "**/packages/changelog"] } -run_task = { name = ["test-flow", "doc-test-flow", "website-test"], fork = true } +run_task = { name = ["test-flow", "doc-test-flow", "ssr-test", "website-test"], fork = true } [tasks.benchmarks] category = "Testing" @@ -117,3 +117,8 @@ category = "Maintainer processes" toolchain = "stable" command = "cargo" args = ["run","-p","changelog", "--release", "${@}"] + +[tasks.ssr-test] +env = { CARGO_MAKE_WORKSPACE_INCLUDE_MEMBERS = ["**/packages/yew"] } +private = true +workspace = true diff --git a/examples/futures/Cargo.toml b/examples/futures/Cargo.toml index 5fabd53278e..8f09bf36ff9 100644 --- a/examples/futures/Cargo.toml +++ b/examples/futures/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT OR Apache-2.0" pulldown-cmark = { version = "0.9", default-features = false } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -yew = { path = "../../packages/yew" } +yew = { path = "../../packages/yew", features = ["tokio"] } gloo-utils = "0.1" [dependencies.web-sys] diff --git a/examples/simple_ssr/Cargo.toml b/examples/simple_ssr/Cargo.toml new file mode 100644 index 00000000000..e1d48e2cc1a --- /dev/null +++ b/examples/simple_ssr/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "simple_ssr" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tokio = { version = "1.15.0", features = ["full"] } +warp = "0.3" +yew = { path = "../../packages/yew", features = ["ssr"] } +reqwest = { version = "0.11.8", features = ["json"] } +serde = { version = "1.0.132", features = ["derive"] } +uuid = { version = "0.8.2", features = ["serde"] } diff --git a/examples/simple_ssr/README.md b/examples/simple_ssr/README.md new file mode 100644 index 00000000000..95cf18b43ea --- /dev/null +++ b/examples/simple_ssr/README.md @@ -0,0 +1,6 @@ +# Server-side Rendering Example + +This example demonstrates server-side rendering. + +Run `cargo run -p simple_ssr` and navigate to http://localhost:8080/ to +view results. diff --git a/examples/simple_ssr/src/main.rs b/examples/simple_ssr/src/main.rs new file mode 100644 index 00000000000..96a29286ef7 --- /dev/null +++ b/examples/simple_ssr/src/main.rs @@ -0,0 +1,129 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use serde::{Deserialize, Serialize}; +use tokio::task::LocalSet; +use tokio::task::{spawn_blocking, spawn_local}; +use uuid::Uuid; +use warp::Filter; +use yew::prelude::*; +use yew::suspense::{Suspension, SuspensionResult}; + +#[derive(Serialize, Deserialize)] +struct UuidResponse { + uuid: Uuid, +} + +async fn fetch_uuid() -> Uuid { + // reqwest works for both non-wasm and wasm targets. + let resp = reqwest::get("https://httpbin.org/uuid").await.unwrap(); + let uuid_resp = resp.json::().await.unwrap(); + + uuid_resp.uuid +} + +pub struct UuidState { + s: Suspension, + value: Rc>>, +} + +impl UuidState { + fn new() -> Self { + let (s, handle) = Suspension::new(); + let value: Rc>> = Rc::default(); + + { + let value = value.clone(); + // we use tokio spawn local here. + spawn_local(async move { + let uuid = fetch_uuid().await; + + { + let mut value = value.borrow_mut(); + *value = Some(uuid); + } + + handle.resume(); + }); + } + + Self { s, value } + } +} + +impl PartialEq for UuidState { + fn eq(&self, rhs: &Self) -> bool { + self.s == rhs.s + } +} + +fn use_random_uuid() -> SuspensionResult { + let s = use_state(UuidState::new); + + let result = match *s.value.borrow() { + Some(ref m) => Ok(*m), + None => Err(s.s.clone()), + }; + + result +} + +#[function_component] +fn Content() -> HtmlResult { + let uuid = use_random_uuid()?; + + Ok(html! { +
{"Random UUID: "}{uuid}
+ }) +} + +#[function_component] +fn App() -> Html { + let fallback = html! {
{"Loading..."}
}; + + html! { + + + + } +} + +async fn render() -> String { + let content = spawn_blocking(move || { + use tokio::runtime::Builder; + let set = LocalSet::new(); + + let rt = Builder::new_current_thread().enable_all().build().unwrap(); + + set.block_on(&rt, async { + let renderer = yew::ServerRenderer::::new(); + + renderer.render().await + }) + }) + .await + .expect("the thread has failed."); + + format!( + r#" + + + Yew SSR Example + + + {} + + +"#, + content + ) +} + +#[tokio::main] +async fn main() { + let routes = warp::any().then(|| async move { warp::reply::html(render().await) }); + + println!("You can view the website at: http://localhost:8080/"); + + warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; +} diff --git a/examples/suspense/Cargo.toml b/examples/suspense/Cargo.toml index cf1ca96c164..abf59d759e6 100644 --- a/examples/suspense/Cargo.toml +++ b/examples/suspense/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -yew = { path = "../../packages/yew" } +yew = { path = "../../packages/yew", features = ["tokio"] } gloo-timers = { version = "0.2.2", features = ["futures"] } wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" diff --git a/packages/yew/Cargo.toml b/packages/yew/Cargo.toml index a20b5779c69..d1555803204 100644 --- a/packages/yew/Cargo.toml +++ b/packages/yew/Cargo.toml @@ -24,12 +24,14 @@ indexmap = { version = "1", features = ["std"] } js-sys = "0.3" slab = "0.4" wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" yew-macro = { version = "^0.19.0", path = "../yew-macro" } thiserror = "1.0" scoped-tls-hkt = "0.1" +futures = { version = "0.3", optional = true } +html-escape = { version = "0.2.9", optional = true } + [dependencies.web-sys] version = "0.3" features = [ @@ -61,10 +63,19 @@ features = [ "Window", ] +[target.'cfg(target_arch = "wasm32")'.dependencies] +# we move it here so no promise-based spawn_local can present for +# non-wasm32 targets. +wasm-bindgen-futures = "0.4" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1.15.0", features = ["rt"], optional = true } + [dev-dependencies] easybench-wasm = "0.2" wasm-bindgen-test = "0.3" gloo = { version = "0.6", features = ["futures"] } +wasm-bindgen-futures = "0.4" rustversion = "1" trybuild = "1" @@ -72,6 +83,12 @@ trybuild = "1" doc_test = [] wasm_test = [] wasm_bench = [] +ssr = ["futures", "html-escape"] +default = [] + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { version = "1.15.0", features = ["full"] } [package.metadata.docs.rs] -features = ["doc_test"] +features = ["doc_test", "ssr"] +rustdoc-args = ["--cfg", "documenting"] diff --git a/packages/yew/Makefile.toml b/packages/yew/Makefile.toml index 6d8c6324d72..1a4a0f505e0 100644 --- a/packages/yew/Makefile.toml +++ b/packages/yew/Makefile.toml @@ -41,3 +41,7 @@ args = [ "wasm_bench", "bench", ] + +[tasks.ssr-test] +command = "cargo" +args = ["test", "ssr_tests", "--features", "ssr"] diff --git a/packages/yew/src/functional/hooks/use_effect.rs b/packages/yew/src/functional/hooks/use_effect.rs index 4ddacc1aad8..da68def20f3 100644 --- a/packages/yew/src/functional/hooks/use_effect.rs +++ b/packages/yew/src/functional/hooks/use_effect.rs @@ -1,7 +1,8 @@ use crate::functional::use_hook; -use std::{borrow::Borrow, rc::Rc}; +use std::rc::Rc; struct UseEffect { + runner: Option Destructor>>, destructor: Option>, } @@ -39,20 +40,27 @@ pub fn use_effect(callback: impl FnOnce() -> Destructor + 'static) where Destructor: FnOnce() + 'static, { - let callback = Box::new(callback); use_hook( move || { - let effect: UseEffect = UseEffect { destructor: None }; + let effect: UseEffect = UseEffect { + runner: None, + destructor: None, + }; effect }, - |_, updater| { + |state, updater| { + state.runner = Some(Box::new(callback) as Box Destructor>); + // Run on every render updater.post_render(move |state: &mut UseEffect| { - if let Some(de) = state.destructor.take() { - de(); + if let Some(callback) = state.runner.take() { + if let Some(de) = state.destructor.take() { + de(); + } + + let new_destructor = callback(); + state.destructor.replace(Box::new(new_destructor)); } - let new_destructor = callback(); - state.destructor.replace(Box::new(new_destructor)); false }); }, @@ -64,9 +72,15 @@ where ) } +type UseEffectDepsRunnerFn = Box Destructor>; + struct UseEffectDeps { + runner_with_deps: Option<( + Rc, + UseEffectDepsRunnerFn, + )>, destructor: Option>, - deps: Rc, + deps: Option>, } /// This hook is similar to [`use_effect`] but it accepts dependencies. @@ -81,29 +95,33 @@ where Dependents: PartialEq + 'static, { let deps = Rc::new(deps); - let deps_c = deps.clone(); use_hook( move || { let destructor: Option> = None; UseEffectDeps { + runner_with_deps: None, destructor, - deps: deps_c, + deps: None, } }, - move |_, updater| { + move |state, updater| { + state.runner_with_deps = Some((deps, Box::new(callback))); + updater.post_render(move |state: &mut UseEffectDeps| { - if state.deps != deps { + if let Some((deps, callback)) = state.runner_with_deps.take() { + if Some(&deps) == state.deps.as_ref() { + return false; + } + if let Some(de) = state.destructor.take() { de(); } - let new_destructor = callback(deps.borrow()); - state.deps = deps; - state.destructor.replace(Box::new(new_destructor)); - } else if state.destructor.is_none() { - state - .destructor - .replace(Box::new(callback(state.deps.borrow()))); + + let new_destructor = callback(&deps); + + state.deps = Some(deps); + state.destructor = Some(Box::new(new_destructor)); } false }); diff --git a/packages/yew/src/html/component/lifecycle.rs b/packages/yew/src/html/component/lifecycle.rs index e1b7a5e4108..709791123d6 100644 --- a/packages/yew/src/html/component/lifecycle.rs +++ b/packages/yew/src/html/component/lifecycle.rs @@ -7,6 +7,8 @@ use crate::suspense::{Suspense, Suspension}; use crate::virtual_dom::{VDiff, VNode}; use crate::Callback; use crate::{Context, NodeRef}; +#[cfg(feature = "ssr")] +use futures::channel::oneshot; use std::rc::Rc; use web_sys::Element; @@ -15,13 +17,19 @@ pub(crate) struct ComponentState { pub(crate) root_node: VNode, context: Context, - parent: Element, + + /// When a component has no parent, it means that it should not be rendered. + parent: Option, + next_sibling: NodeRef, node_ref: NodeRef, has_rendered: bool, suspension: Option, + #[cfg(feature = "ssr")] + html_sender: Option>, + // Used for debug logging #[cfg(debug_assertions)] pub(crate) vcomp_id: u64, @@ -29,12 +37,13 @@ pub(crate) struct ComponentState { impl ComponentState { pub(crate) fn new( - parent: Element, + parent: Option, next_sibling: NodeRef, root_node: VNode, node_ref: NodeRef, scope: Scope, props: Rc, + #[cfg(feature = "ssr")] html_sender: Option>, ) -> Self { #[cfg(debug_assertions)] let vcomp_id = { @@ -55,6 +64,9 @@ impl ComponentState { suspension: None, has_rendered: false, + #[cfg(feature = "ssr")] + html_sender, + #[cfg(debug_assertions)] vcomp_id, } @@ -62,12 +74,14 @@ impl ComponentState { } pub(crate) struct CreateRunner { - pub(crate) parent: Element, + pub(crate) parent: Option, pub(crate) next_sibling: NodeRef, pub(crate) placeholder: VNode, pub(crate) node_ref: NodeRef, pub(crate) props: Rc, pub(crate) scope: Scope, + #[cfg(feature = "ssr")] + pub(crate) html_sender: Option>, } impl Runnable for CreateRunner { @@ -84,6 +98,8 @@ impl Runnable for CreateRunner { self.node_ref, self.scope.clone(), self.props, + #[cfg(feature = "ssr")] + self.html_sender, )); } } @@ -129,11 +145,13 @@ impl Runnable for UpdateRunner { } } UpdateEvent::Shift(parent, next_sibling) => { - state - .root_node - .shift(&state.parent, &parent, next_sibling.clone()); + state.root_node.shift( + state.parent.as_ref().unwrap(), + &parent, + next_sibling.clone(), + ); - state.parent = parent; + state.parent = Some(parent); state.next_sibling = next_sibling; false @@ -173,8 +191,11 @@ impl Runnable for DestroyRunner { crate::virtual_dom::vcomp::log_event(state.vcomp_id, "destroy"); state.component.destroy(&state.context); - state.root_node.detach(&state.parent); - state.node_ref.set(None); + + if let Some(ref m) = state.parent { + state.root_node.detach(m); + state.node_ref.set(None); + } } } } @@ -194,24 +215,33 @@ impl Runnable for RenderRunner { // Currently not suspended, we remove any previous suspension and update // normally. let mut root = m; - std::mem::swap(&mut root, &mut state.root_node); + if state.parent.is_some() { + std::mem::swap(&mut root, &mut state.root_node); + } - if let Some(ref m) = state.suspension { + if let Some(m) = state.suspension.take() { let comp_scope = AnyScope::from(state.context.scope.clone()); let suspense_scope = comp_scope.find_parent_scope::().unwrap(); let suspense = suspense_scope.get_component().unwrap(); - suspense.resume(m.clone()); + suspense.resume(m); } - let ancestor = Some(root); - let new_root = &mut state.root_node; - let scope = state.context.scope.clone().into(); - let next_sibling = state.next_sibling.clone(); + if let Some(ref m) = state.parent { + let ancestor = Some(root); + let new_root = &mut state.root_node; + let scope = state.context.scope.clone().into(); + let next_sibling = state.next_sibling.clone(); - let node = new_root.apply(&scope, &state.parent, next_sibling, ancestor); - state.node_ref.link(node); + let node = new_root.apply(&scope, m, next_sibling, ancestor); + state.node_ref.link(node); + } else { + #[cfg(feature = "ssr")] + if let Some(tx) = state.html_sender.take() { + tx.send(root).unwrap(); + } + } } Err(RenderError::Suspended(m)) => { @@ -236,7 +266,9 @@ impl Runnable for RenderRunner { let comp_scope = AnyScope::from(state.context.scope.clone()); - let suspense_scope = comp_scope.find_parent_scope::().unwrap(); + let suspense_scope = comp_scope + .find_parent_scope::() + .expect("To suspend rendering, a component is required."); let suspense = suspense_scope.get_component().unwrap(); m.listen(Callback::from(move |_| { @@ -277,9 +309,11 @@ impl Runnable for RenderedRunner { #[cfg(debug_assertions)] crate::virtual_dom::vcomp::log_event(state.vcomp_id, "rendered"); - let first_render = !state.has_rendered; - state.component.rendered(&state.context, first_render); - state.has_rendered = true; + if state.suspension.is_none() && state.parent.is_some() { + let first_render = !state.has_rendered; + state.component.rendered(&state.context, first_render); + state.has_rendered = true; + } } } } diff --git a/packages/yew/src/html/component/scope.rs b/packages/yew/src/html/component/scope.rs index 410f9c34530..70d0ce66410 100644 --- a/packages/yew/src/html/component/scope.rs +++ b/packages/yew/src/html/component/scope.rs @@ -15,11 +15,9 @@ use crate::virtual_dom::{insert_node, VNode}; use gloo_utils::document; use std::any::{Any, TypeId}; use std::cell::{Ref, RefCell}; -use std::future::Future; use std::ops::Deref; use std::rc::Rc; use std::{fmt, iter}; -use wasm_bindgen_futures::spawn_local; use web_sys::{Element, Node}; /// Untyped scope used for accessing parent scope @@ -234,12 +232,14 @@ impl Scope { scheduler::push_component_create( CreateRunner { - parent, + parent: Some(parent), next_sibling, placeholder, node_ref, props, scope: self.clone(), + #[cfg(feature = "ssr")] + html_sender: None, }, RenderRunner { state: self.state.clone(), @@ -348,61 +348,6 @@ impl Scope { }; closure.into() } - /// This method creates a [`Callback`] which returns a Future which - /// returns a message to be sent back to the component's event - /// loop. - /// - /// # Panics - /// If the future panics, then the promise will not resolve, and - /// will leak. - pub fn callback_future(&self, function: FN) -> Callback - where - M: Into, - FU: Future + 'static, - FN: Fn(IN) -> FU + 'static, - { - let link = self.clone(); - - let closure = move |input: IN| { - let future: FU = function(input); - link.send_future(future); - }; - - closure.into() - } - - /// This method processes a Future that returns a message and sends it back to the component's - /// loop. - /// - /// # Panics - /// If the future panics, then the promise will not resolve, and will leak. - pub fn send_future(&self, future: F) - where - M: Into, - F: Future + 'static, - { - let link = self.clone(); - let js_future = async move { - let message: COMP::Message = future.await.into(); - link.send_message(message); - }; - spawn_local(js_future); - } - - /// Registers a Future that resolves to multiple messages. - /// # Panics - /// If the future panics, then the promise will not resolve, and will leak. - pub fn send_future_batch(&self, future: F) - where - F: Future> + 'static, - { - let link = self.clone(); - let js_future = async move { - let messages: Vec = future.await; - link.send_message_batch(messages); - }; - spawn_local(js_future); - } /// Accesses a value provided by a parent `ContextProvider` component of the /// same type. @@ -414,6 +359,113 @@ impl Scope { } } +#[cfg(feature = "ssr")] +mod feat_ssr { + use super::*; + use futures::channel::oneshot; + + impl Scope { + pub(crate) async fn render_to_string(&self, w: &mut String, props: Rc) { + let (tx, rx) = oneshot::channel(); + + scheduler::push_component_create( + CreateRunner { + parent: None, + next_sibling: NodeRef::default(), + placeholder: VNode::default(), + node_ref: NodeRef::default(), + props, + scope: self.clone(), + html_sender: Some(tx), + }, + RenderRunner { + state: self.state.clone(), + }, + RenderedRunner { + state: self.state.clone(), + }, + ); + scheduler::start(); + + let html = rx.await.unwrap(); + + let self_any_scope = self.to_any(); + html.render_to_string(w, &self_any_scope).await; + + scheduler::push_component_destroy(DestroyRunner { + state: self.state.clone(), + }); + scheduler::start(); + } + } +} +#[cfg_attr(documenting, doc(cfg(any(target_arch = "wasm32", feature = "tokio"))))] +#[cfg(any(target_arch = "wasm32", feature = "tokio"))] +mod feat_io { + use std::future::Future; + + use super::*; + use crate::io_coop::spawn_local; + + impl Scope { + /// This method creates a [`Callback`] which returns a Future which + /// returns a message to be sent back to the component's event + /// loop. + /// + /// # Panics + /// If the future panics, then the promise will not resolve, and + /// will leak. + pub fn callback_future(&self, function: FN) -> Callback + where + M: Into, + FU: Future + 'static, + FN: Fn(IN) -> FU + 'static, + { + let link = self.clone(); + + let closure = move |input: IN| { + let future: FU = function(input); + link.send_future(future); + }; + + closure.into() + } + + /// This method processes a Future that returns a message and sends it back to the component's + /// loop. + /// + /// # Panics + /// If the future panics, then the promise will not resolve, and will leak. + pub fn send_future(&self, future: F) + where + M: Into, + F: Future + 'static, + { + let link = self.clone(); + let js_future = async move { + let message: COMP::Message = future.await.into(); + link.send_message(message); + }; + spawn_local(js_future); + } + + /// Registers a Future that resolves to multiple messages. + /// # Panics + /// If the future panics, then the promise will not resolve, and will leak. + pub fn send_future_batch(&self, future: F) + where + F: Future> + 'static, + { + let link = self.clone(); + let js_future = async move { + let messages: Vec = future.await; + link.send_message_batch(messages); + }; + spawn_local(js_future); + } + } +} + /// Defines a message type that can be sent to a component. /// Used for the return value of closure given to [Scope::batch_callback](struct.Scope.html#method.batch_callback). pub trait SendAsMessage { diff --git a/packages/yew/src/io_coop.rs b/packages/yew/src/io_coop.rs new file mode 100644 index 00000000000..17df422f3f1 --- /dev/null +++ b/packages/yew/src/io_coop.rs @@ -0,0 +1,27 @@ +//! module that provides io compatibility over browser tasks and other async io tasks (e.g.: tokio) + +#[cfg(target_arch = "wasm32")] +mod io_wasm_bindgen { + pub use wasm_bindgen_futures::spawn_local; +} + +#[cfg(target_arch = "wasm32")] +pub(crate) use io_wasm_bindgen::*; + +#[cfg(all(not(target_arch = "wasm32"), feature = "tokio"))] +mod io_tokio { + use std::future::Future; + + // spawn_local in tokio is more powerful, but we need to adjust the function signature to match + // wasm_bindgen_futures. + #[inline(always)] + pub(crate) fn spawn_local(f: F) + where + F: Future + 'static, + { + tokio::task::spawn_local(f); + } +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "tokio"))] +pub(crate) use io_tokio::*; diff --git a/packages/yew/src/lib.rs b/packages/yew/src/lib.rs index a05c7abaeb7..8c493a26861 100644 --- a/packages/yew/src/lib.rs +++ b/packages/yew/src/lib.rs @@ -1,5 +1,6 @@ #![allow(clippy::needless_doctest_main)] #![doc(html_logo_url = "https://yew.rs/img/logo.png")] +#![cfg_attr(documenting, feature(doc_cfg))] //! # Yew Framework - API Documentation //! @@ -9,9 +10,19 @@ //! - Achieves high performance by minimizing DOM API calls for each page render and by making it easy to offload processing to background web workers. //! - Supports JavaScript interoperability, allowing developers to leverage NPM packages and integrate with existing JavaScript applications. //! -//! ### Supported Targets +//! ### Supported Targets (Client-Side Rendering) //! - `wasm32-unknown-unknown` //! +//! ### Note +//! +//! Server-Side Rendering should work on all targets when feature `ssr` is enabled. +//! +//! ### Supported Features: +//! - `ssr`: Enables Server-side Rendering support and [`ServerRenderer`]. +//! - `tokio`: Enables future-based APIs on non-wasm32 targets with tokio runtime. (You may want to +//! enable this if your application uses future-based APIs and it does not compile / lint on +//! non-wasm32 targets.) +//! //! ## Example //! //! ```rust @@ -257,13 +268,18 @@ pub mod callback; pub mod context; pub mod functional; pub mod html; +mod io_coop; pub mod scheduler; mod sealed; +#[cfg(feature = "ssr")] +mod server_renderer; pub mod suspense; #[cfg(test)] pub mod tests; pub mod utils; pub mod virtual_dom; +#[cfg(feature = "ssr")] +pub use server_renderer::*; /// The module that contains all events available in the framework. pub mod events { diff --git a/packages/yew/src/server_renderer.rs b/packages/yew/src/server_renderer.rs new file mode 100644 index 00000000000..9e5cd5fe1cb --- /dev/null +++ b/packages/yew/src/server_renderer.rs @@ -0,0 +1,59 @@ +use super::*; + +use crate::html::Scope; + +/// A Yew Server-side Renderer. +#[cfg_attr(documenting, doc(cfg(feature = "ssr")))] +#[derive(Debug)] +pub struct ServerRenderer +where + COMP: BaseComponent, +{ + props: COMP::Properties, +} + +impl Default for ServerRenderer +where + COMP: BaseComponent, + COMP::Properties: Default, +{ + fn default() -> Self { + Self::with_props(COMP::Properties::default()) + } +} + +impl ServerRenderer +where + COMP: BaseComponent, + COMP::Properties: Default, +{ + /// Creates a [ServerRenderer] with default properties. + pub fn new() -> Self { + Self::default() + } +} + +impl ServerRenderer +where + COMP: BaseComponent, +{ + /// Creates a [ServerRenderer] with custom properties. + pub fn with_props(props: COMP::Properties) -> Self { + Self { props } + } + + /// Renders Yew Application. + pub async fn render(self) -> String { + let mut s = String::new(); + + self.render_to_string(&mut s).await; + + s + } + + /// Renders Yew Application to a String. + pub async fn render_to_string(self, w: &mut String) { + let scope = Scope::::new(None); + scope.render_to_string(w, self.props.into()).await; + } +} diff --git a/packages/yew/src/suspense/component.rs b/packages/yew/src/suspense/component.rs index 750f2d9f78b..66e35422a9a 100644 --- a/packages/yew/src/suspense/component.rs +++ b/packages/yew/src/suspense/component.rs @@ -1,7 +1,6 @@ use crate::html::{Children, Component, Context, Html, Properties, Scope}; use crate::virtual_dom::{Key, VList, VNode, VSuspense}; -use gloo_utils::document; use web_sys::Element; use super::Suspension; @@ -29,7 +28,7 @@ pub enum SuspenseMsg { pub struct Suspense { link: Scope, suspensions: Vec, - detached_parent: Element, + detached_parent: Option, } impl Component for Suspense { @@ -40,7 +39,14 @@ impl Component for Suspense { Self { link: ctx.link().clone(), suspensions: Vec::new(), - detached_parent: document().create_element("div").unwrap(), + + #[cfg(target_arch = "wasm32")] + detached_parent: web_sys::window() + .and_then(|m| m.document()) + .and_then(|m| m.create_element("div").ok()), + + #[cfg(not(target_arch = "wasm32"))] + detached_parent: None, } } diff --git a/packages/yew/src/suspense/suspension.rs b/packages/yew/src/suspense/suspension.rs index 9430e8d6dd5..501ab5c20f3 100644 --- a/packages/yew/src/suspense/suspension.rs +++ b/packages/yew/src/suspense/suspension.rs @@ -6,7 +6,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::task::{Context, Poll}; use thiserror::Error; -use wasm_bindgen_futures::spawn_local; use crate::Callback; @@ -52,18 +51,6 @@ impl Suspension { (self_.clone(), SuspensionHandle { inner: self_ }) } - /// Creates a Suspension that resumes when the [`Future`] resolves. - pub fn from_future(f: impl Future + 'static) -> Self { - let (self_, handle) = Self::new(); - - spawn_local(async move { - f.await; - handle.resume(); - }); - - self_ - } - /// Returns `true` if the current suspension is already resumed. pub fn resumed(&self) -> bool { self.resumed.load(Ordering::Relaxed) @@ -138,3 +125,24 @@ impl Drop for SuspensionHandle { self.inner.resume_by_ref(); } } + +#[cfg_attr(documenting, doc(cfg(any(target_arch = "wasm32", feature = "tokio"))))] +#[cfg(any(target_arch = "wasm32", feature = "tokio"))] +mod feat_io { + use super::*; + use crate::io_coop::spawn_local; + + impl Suspension { + /// Creates a Suspension that resumes when the [`Future`] resolves. + pub fn from_future(f: impl Future + 'static) -> Self { + let (self_, handle) = Self::new(); + + spawn_local(async move { + f.await; + handle.resume(); + }); + + self_ + } + } +} diff --git a/packages/yew/src/virtual_dom/vcomp.rs b/packages/yew/src/virtual_dom/vcomp.rs index a0055b20697..32b921f3863 100644 --- a/packages/yew/src/virtual_dom/vcomp.rs +++ b/packages/yew/src/virtual_dom/vcomp.rs @@ -2,6 +2,8 @@ use super::{Key, VDiff, VNode}; use crate::html::{AnyScope, BaseComponent, NodeRef, Scope, Scoped}; +#[cfg(feature = "ssr")] +use futures::future::{FutureExt, LocalBoxFuture}; use std::any::TypeId; use std::borrow::Borrow; use std::fmt; @@ -41,7 +43,7 @@ pub(crate) fn get_event_log(vcomp_id: u64) -> Vec { pub struct VComp { type_id: TypeId, scope: Option>, - props: Option>, + mountable: Option>, pub(crate) node_ref: NodeRef, pub(crate) key: Option, @@ -62,7 +64,7 @@ impl Clone for VComp { Self { type_id: self.type_id, scope: None, - props: self.props.as_ref().map(|m| m.copy()), + mountable: self.mountable.as_ref().map(|m| m.copy()), node_ref: self.node_ref.clone(), key: self.key.clone(), @@ -132,7 +134,7 @@ impl VComp { VComp { type_id: TypeId::of::(), node_ref, - props: Some(Box::new(PropsWrapper::::new(props))), + mountable: Some(Box::new(PropsWrapper::::new(props))), scope: None, key, @@ -181,6 +183,13 @@ trait Mountable { next_sibling: NodeRef, ) -> Box; fn reuse(self: Box, node_ref: NodeRef, scope: &dyn Scoped, next_sibling: NodeRef); + + #[cfg(feature = "ssr")] + fn render_to_string<'a>( + &'a self, + w: &'a mut String, + parent_scope: &'a AnyScope, + ) -> LocalBoxFuture<'a, ()>; } struct PropsWrapper { @@ -218,6 +227,19 @@ impl Mountable for PropsWrapper { let scope: Scope = scope.to_any().downcast(); scope.reuse(self.props, node_ref, next_sibling); } + + #[cfg(feature = "ssr")] + fn render_to_string<'a>( + &'a self, + w: &'a mut String, + parent_scope: &'a AnyScope, + ) -> LocalBoxFuture<'a, ()> { + async move { + let scope: Scope = Scope::new(Some(parent_scope.clone())); + scope.render_to_string(w, self.props.clone()).await; + } + .boxed_local() + } } impl VDiff for VComp { @@ -237,7 +259,10 @@ impl VDiff for VComp { next_sibling: NodeRef, ancestor: Option, ) -> NodeRef { - let mountable = self.props.take().expect("VComp has already been mounted"); + let mountable = self + .mountable + .take() + .expect("VComp has already been mounted"); if let Some(mut ancestor) = ancestor { if let VNode::VComp(ref mut vcomp) = &mut ancestor { @@ -283,6 +308,22 @@ impl fmt::Debug for VChild { } } +#[cfg(feature = "ssr")] +mod feat_ssr { + use super::*; + + impl VComp { + pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) { + self.mountable + .as_ref() + .map(|m| m.copy()) + .unwrap() + .render_to_string(w, parent_scope) + .await; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -867,3 +908,44 @@ mod layout_tests { diff_layouts(vec![layout]); } } + +#[cfg(all(test, not(target_arch = "wasm32"), feature = "ssr"))] +mod ssr_tests { + use tokio::test; + + use crate::prelude::*; + use crate::ServerRenderer; + + #[test] + async fn test_props() { + #[derive(PartialEq, Properties, Debug)] + struct ChildProps { + name: String, + } + + #[function_component] + fn Child(props: &ChildProps) -> Html { + html! {
{"Hello, "}{&props.name}{"!"}
} + } + + #[function_component] + fn Comp() -> Html { + html! { +
+ + + +
+ } + } + + let renderer = ServerRenderer::::new(); + + let s = renderer.render().await; + + assert_eq!( + s, + "
Hello, Jane!
Hello, John!
Hello, Josh!
" + ); + } +} diff --git a/packages/yew/src/virtual_dom/vlist.rs b/packages/yew/src/virtual_dom/vlist.rs index 420a12f900f..30ca06c2f92 100644 --- a/packages/yew/src/virtual_dom/vlist.rs +++ b/packages/yew/src/virtual_dom/vlist.rs @@ -284,6 +284,28 @@ impl VList { } } +#[cfg(feature = "ssr")] +mod feat_ssr { + use super::*; + + impl VList { + pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) { + // Concurrently render all children. + for fragment in futures::future::join_all(self.children.iter().map(|m| async move { + let mut w = String::new(); + + m.render_to_string(&mut w, parent_scope).await; + + w + })) + .await + { + w.push_str(&fragment) + } + } + } +} + impl VDiff for VList { fn detach(&mut self, parent: &Element) { for mut child in self.children.drain(..) { @@ -1267,3 +1289,60 @@ mod layout_tests_keys { diff_layouts(layouts); } } + +#[cfg(all(test, not(target_arch = "wasm32"), feature = "ssr"))] +mod ssr_tests { + use tokio::test; + + use crate::prelude::*; + use crate::ServerRenderer; + + #[test] + async fn test_text_back_to_back() { + #[function_component] + fn Comp() -> Html { + let s = "world"; + + html! {
{"Hello "}{s}{"!"}
} + } + + let renderer = ServerRenderer::::new(); + + let s = renderer.render().await; + + assert_eq!(s, "
Hello world!
"); + } + + #[test] + async fn test_fragment() { + #[derive(PartialEq, Properties, Debug)] + struct ChildProps { + name: String, + } + + #[function_component] + fn Child(props: &ChildProps) -> Html { + html! {
{"Hello, "}{&props.name}{"!"}
} + } + + #[function_component] + fn Comp() -> Html { + html! { + <> + + + + + } + } + + let renderer = ServerRenderer::::new(); + + let s = renderer.render().await; + + assert_eq!( + s, + "
Hello, Jane!
Hello, John!
Hello, Josh!
" + ); + } +} diff --git a/packages/yew/src/virtual_dom/vnode.rs b/packages/yew/src/virtual_dom/vnode.rs index e2af46a89cf..86d18d041cc 100644 --- a/packages/yew/src/virtual_dom/vnode.rs +++ b/packages/yew/src/virtual_dom/vnode.rs @@ -296,6 +296,45 @@ impl PartialEq for VNode { } } +#[cfg(feature = "ssr")] +mod feat_ssr { + use futures::future::{FutureExt, LocalBoxFuture}; + + use super::*; + + impl VNode { + // Boxing is needed here, due to: https://rust-lang.github.io/async-book/07_workarounds/04_recursion.html + pub(crate) fn render_to_string<'a>( + &'a self, + w: &'a mut String, + parent_scope: &'a AnyScope, + ) -> LocalBoxFuture<'a, ()> { + async move { + match self { + VNode::VTag(vtag) => vtag.render_to_string(w, parent_scope).await, + VNode::VText(vtext) => vtext.render_to_string(w).await, + VNode::VComp(vcomp) => vcomp.render_to_string(w, parent_scope).await, + VNode::VList(vlist) => vlist.render_to_string(w, parent_scope).await, + // We are pretty safe here as it's not possible to get a web_sys::Node without DOM + // support in the first place. + // + // The only exception would be to use `ServerRenderer` in a browser or wasm32 environment with + // jsdom present. + VNode::VRef(_) => { + panic!("VRef is not possible to be rendered in to a string.") + } + // Portals are not rendered. + VNode::VPortal(_) => {} + VNode::VSuspense(vsuspense) => { + vsuspense.render_to_string(w, parent_scope).await + } + } + } + .boxed_local() + } + } +} + #[cfg(test)] mod layout_tests { use super::*; diff --git a/packages/yew/src/virtual_dom/vsuspense.rs b/packages/yew/src/virtual_dom/vsuspense.rs index 5cf6d177510..e2a73bb1446 100644 --- a/packages/yew/src/virtual_dom/vsuspense.rs +++ b/packages/yew/src/virtual_dom/vsuspense.rs @@ -12,7 +12,7 @@ pub struct VSuspense { fallback: Box, /// The element to attach to when children is not attached to DOM - detached_parent: Element, + detached_parent: Option, /// Whether the current status is suspended. suspended: bool, @@ -25,7 +25,7 @@ impl VSuspense { pub(crate) fn new( children: VNode, fallback: VNode, - detached_parent: Element, + detached_parent: Option, suspended: bool, key: Option, ) -> Self { @@ -51,7 +51,9 @@ impl VDiff for VSuspense { fn detach(&mut self, parent: &Element) { if self.suspended { self.fallback.detach(parent); - self.children.detach(&self.detached_parent); + if let Some(ref m) = self.detached_parent { + self.children.detach(m); + } } else { self.children.detach(parent); } @@ -74,6 +76,8 @@ impl VDiff for VSuspense { next_sibling: NodeRef, ancestor: Option, ) -> NodeRef { + let detached_parent = self.detached_parent.as_ref().expect("no detached parent?"); + let (already_suspended, children_ancestor, fallback_ancestor) = match ancestor { Some(VNode::VSuspense(mut m)) => { // We only preserve the child state if they are the same suspense. @@ -98,7 +102,7 @@ impl VDiff for VSuspense { (true, true) => { self.children.apply( parent_scope, - &self.detached_parent, + detached_parent, NodeRef::default(), children_ancestor, ); @@ -115,13 +119,13 @@ impl VDiff for VSuspense { (true, false) => { children_ancestor.as_ref().unwrap().shift( parent, - &self.detached_parent, + detached_parent, NodeRef::default(), ); self.children.apply( parent_scope, - &self.detached_parent, + detached_parent, NodeRef::default(), children_ancestor, ); @@ -135,7 +139,7 @@ impl VDiff for VSuspense { fallback_ancestor.unwrap().detach(parent); children_ancestor.as_ref().unwrap().shift( - &self.detached_parent, + detached_parent, parent, next_sibling.clone(), ); @@ -145,3 +149,110 @@ impl VDiff for VSuspense { } } } + +#[cfg(feature = "ssr")] +mod feat_ssr { + use super::*; + + impl VSuspense { + pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) { + // always render children on the server side. + self.children.render_to_string(w, parent_scope).await; + } + } +} + +#[cfg(all(test, not(target_arch = "wasm32"), feature = "ssr"))] +mod ssr_tests { + use std::rc::Rc; + use std::time::Duration; + + use tokio::task::{spawn_local, LocalSet}; + use tokio::test; + use tokio::time::sleep; + + use crate::prelude::*; + use crate::suspense::{Suspension, SuspensionResult}; + use crate::ServerRenderer; + + #[test(flavor = "multi_thread", worker_threads = 2)] + async fn test_suspense() { + #[derive(PartialEq)] + pub struct SleepState { + s: Suspension, + } + + impl SleepState { + fn new() -> Self { + let (s, handle) = Suspension::new(); + + // we use tokio spawn local here. + spawn_local(async move { + // we use tokio sleep here. + sleep(Duration::from_millis(50)).await; + + handle.resume(); + }); + + Self { s } + } + } + + impl Reducible for SleepState { + type Action = (); + + fn reduce(self: Rc, _action: Self::Action) -> Rc { + Self::new().into() + } + } + + pub fn use_sleep() -> SuspensionResult> { + let sleep_state = use_reducer(SleepState::new); + + if sleep_state.s.resumed() { + Ok(Rc::new(move || sleep_state.dispatch(()))) + } else { + Err(sleep_state.s.clone()) + } + } + + #[derive(PartialEq, Properties, Debug)] + struct ChildProps { + name: String, + } + + #[function_component] + fn Child(props: &ChildProps) -> HtmlResult { + use_sleep()?; + Ok(html! {
{"Hello, "}{&props.name}{"!"}
}) + } + + #[function_component] + fn Comp() -> Html { + let fallback = html! {"loading..."}; + + html! { + + + + + + } + } + + let local = LocalSet::new(); + + let s = local + .run_until(async move { + let renderer = ServerRenderer::::new(); + + renderer.render().await + }) + .await; + + assert_eq!( + s, + "
Hello, Jane!
Hello, John!
Hello, Josh!
" + ); + } +} diff --git a/packages/yew/src/virtual_dom/vtag.rs b/packages/yew/src/virtual_dom/vtag.rs index c69260ab8f7..01d425ca5ad 100644 --- a/packages/yew/src/virtual_dom/vtag.rs +++ b/packages/yew/src/virtual_dom/vtag.rs @@ -384,8 +384,8 @@ impl VTag { /// Returns `checked` property of an /// [InputElement](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). /// (Not a value of node's attribute). - pub fn checked(&mut self) -> bool { - match &mut self.inner { + pub fn checked(&self) -> bool { + match &self.inner { VTagInner::Input(f) => f.checked, _ => false, } @@ -637,6 +637,63 @@ impl PartialEq for VTag { } } +#[cfg(feature = "ssr")] +mod feat_ssr { + use super::*; + use crate::virtual_dom::VText; + use std::fmt::Write; + + impl VTag { + pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) { + write!(w, "<{}", self.tag()).unwrap(); + + let write_attr = |w: &mut String, name: &str, val: Option<&str>| { + write!(w, " {}", name).unwrap(); + + if let Some(m) = val { + write!(w, "=\"{}\"", html_escape::encode_double_quoted_attribute(m)).unwrap(); + } + }; + + if let VTagInner::Input(_) = self.inner { + if let Some(m) = self.value() { + write_attr(w, "value", Some(m)); + } + + if self.checked() { + write_attr(w, "checked", None); + } + } + + for (k, v) in self.attributes.iter() { + write_attr(w, k, Some(v)); + } + + write!(w, ">").unwrap(); + + match self.inner { + VTagInner::Input(_) => {} + VTagInner::Textarea { .. } => { + if let Some(m) = self.value() { + VText::new(m.to_owned()).render_to_string(w).await; + } + + w.push_str(""); + } + VTagInner::Other { + ref tag, + ref children, + .. + } => { + children.render_to_string(w, parent_scope).await; + + write!(w, "", tag).unwrap(); + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1439,3 +1496,81 @@ mod tests_without_browser { ); } } + +#[cfg(all(test, not(target_arch = "wasm32"), feature = "ssr"))] +mod ssr_tests { + use tokio::test; + + use crate::prelude::*; + use crate::ServerRenderer; + + #[test] + async fn test_simple_tag() { + #[function_component] + fn Comp() -> Html { + html! {
} + } + + let renderer = ServerRenderer::::new(); + + let s = renderer.render().await; + + assert_eq!(s, "
"); + } + + #[test] + async fn test_simple_tag_with_attr() { + #[function_component] + fn Comp() -> Html { + html! {
} + } + + let renderer = ServerRenderer::::new(); + + let s = renderer.render().await; + + assert_eq!(s, r#"
"#); + } + + #[test] + async fn test_simple_tag_with_content() { + #[function_component] + fn Comp() -> Html { + html! {
{"Hello!"}
} + } + + let renderer = ServerRenderer::::new(); + + let s = renderer.render().await; + + assert_eq!(s, r#"
Hello!
"#); + } + + #[test] + async fn test_simple_tag_with_nested_tag_and_input() { + #[function_component] + fn Comp() -> Html { + html! {
{"Hello!"}
} + } + + let renderer = ServerRenderer::::new(); + + let s = renderer.render().await; + + assert_eq!(s, r#"
Hello!
"#); + } + + #[test] + async fn test_textarea() { + #[function_component] + fn Comp() -> Html { + html! { "#); + } +} diff --git a/packages/yew/src/virtual_dom/vtext.rs b/packages/yew/src/virtual_dom/vtext.rs index 9b67aa665bb..4b458fccc34 100644 --- a/packages/yew/src/virtual_dom/vtext.rs +++ b/packages/yew/src/virtual_dom/vtext.rs @@ -28,6 +28,17 @@ impl VText { } } +#[cfg(feature = "ssr")] +mod feat_ssr { + use super::*; + + impl VText { + pub(crate) async fn render_to_string(&self, w: &mut String) { + html_escape::encode_text_to_string(&self.text, w); + } + } +} + impl std::fmt::Debug for VText { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( @@ -180,3 +191,21 @@ mod layout_tests { diff_layouts(vec![layout1, layout2, layout3, layout4]); } } + +#[cfg(all(test, not(target_arch = "wasm32"), feature = "ssr"))] +mod ssr_tests { + use tokio::test; + + use super::*; + + #[test] + async fn test_simple_str() { + let vtext = VText::new("abc"); + + let mut s = String::new(); + + vtext.render_to_string(&mut s).await; + + assert_eq!("abc", s.as_str()); + } +} diff --git a/packages/yew/tests/suspense.rs b/packages/yew/tests/suspense.rs index 881c21c4785..302714d2621 100644 --- a/packages/yew/tests/suspense.rs +++ b/packages/yew/tests/suspense.rs @@ -6,6 +6,7 @@ use yew::prelude::*; wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); +use std::cell::RefCell; use std::rc::Rc; use gloo::timers::future::TimeoutFuture; @@ -399,3 +400,178 @@ async fn suspense_nested_suspense_works() { r#"
"# ); } + +#[wasm_bindgen_test] +async fn effects_not_run_when_suspended() { + #[derive(PartialEq)] + pub struct SleepState { + s: Suspension, + } + + impl SleepState { + fn new() -> Self { + let (s, handle) = Suspension::new(); + + spawn_local(async move { + TimeoutFuture::new(50).await; + + handle.resume(); + }); + + Self { s } + } + } + + impl Reducible for SleepState { + type Action = (); + + fn reduce(self: Rc, _action: Self::Action) -> Rc { + Self::new().into() + } + } + + pub fn use_sleep() -> SuspensionResult> { + let sleep_state = use_reducer(SleepState::new); + + if sleep_state.s.resumed() { + Ok(Rc::new(move || sleep_state.dispatch(()))) + } else { + Err(sleep_state.s.clone()) + } + } + + #[derive(Properties, Clone)] + struct Props { + counter: Rc>, + } + + impl PartialEq for Props { + fn eq(&self, _rhs: &Self) -> bool { + true + } + } + + #[function_component(Content)] + fn content(props: &Props) -> HtmlResult { + { + let counter = props.counter.clone(); + + use_effect(move || { + let mut counter = counter.borrow_mut(); + + *counter += 1; + + || {} + }); + } + + let resleep = use_sleep()?; + + let value = use_state(|| 0); + + let on_increment = { + let value = value.clone(); + + Callback::from(move |_: MouseEvent| { + value.set(*value + 1); + }) + }; + + let on_take_a_break = Callback::from(move |_: MouseEvent| (resleep.clone())()); + + Ok(html! { +
+
{*value}
+ +
+ +
+
+ }) + } + + #[function_component(App)] + fn app(props: &Props) -> Html { + let fallback = html! {
{"wait..."}
}; + + html! { +
+ + + +
+ } + } + + let counter = Rc::new(RefCell::new(0_u64)); + + let props = Props { + counter: counter.clone(), + }; + + yew::start_app_with_props_in_element::( + gloo_utils::document().get_element_by_id("output").unwrap(), + props, + ); + + TimeoutFuture::new(10).await; + let result = obtain_result(); + assert_eq!(result.as_str(), "
wait...
"); + assert_eq!(*counter.borrow(), 0); // effects not called. + + TimeoutFuture::new(50).await; + + let result = obtain_result(); + assert_eq!( + result.as_str(), + r#"
0
"# + ); + assert_eq!(*counter.borrow(), 1); // effects ran 1 time. + + TimeoutFuture::new(10).await; + + gloo_utils::document() + .query_selector(".increase") + .unwrap() + .unwrap() + .dyn_into::() + .unwrap() + .click(); + + gloo_utils::document() + .query_selector(".increase") + .unwrap() + .unwrap() + .dyn_into::() + .unwrap() + .click(); + + let result = obtain_result(); + assert_eq!( + result.as_str(), + r#"
2
"# + ); + assert_eq!(*counter.borrow(), 3); // effects ran 3 times. + + gloo_utils::document() + .query_selector(".take-a-break") + .unwrap() + .unwrap() + .dyn_into::() + .unwrap() + .click(); + + TimeoutFuture::new(10).await; + let result = obtain_result(); + assert_eq!(result.as_str(), "
wait...
"); + assert_eq!(*counter.borrow(), 3); // effects ran 3 times. + + TimeoutFuture::new(50).await; + + let result = obtain_result(); + assert_eq!( + result.as_str(), + r#"
2
"# + ); + assert_eq!(*counter.borrow(), 4); // effects ran 4 times. +} diff --git a/tools/website-test/Cargo.toml b/tools/website-test/Cargo.toml index 1b304b06faf..c2d534ed35b 100644 --- a/tools/website-test/Cargo.toml +++ b/tools/website-test/Cargo.toml @@ -16,8 +16,9 @@ js-sys = "0.3" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" weblog = "0.3.0" -yew = { path = "../../packages/yew/" } +yew = { path = "../../packages/yew/", features = ["ssr"] } yew-router = { path = "../../packages/yew-router/" } +tokio = { version = "1.15.0", features = ["full"] } [dev-dependencies.web-sys] version = "0.3" diff --git a/tools/website-test/build.rs b/tools/website-test/build.rs index d4d86e17826..05fc1298384 100644 --- a/tools/website-test/build.rs +++ b/tools/website-test/build.rs @@ -13,7 +13,7 @@ struct Level { fn main() { let home = env::var("CARGO_MANIFEST_DIR").unwrap(); - let pattern = format!("{}/../../website/docs/**/*.mdx", home); + let pattern = format!("{}/../../website/docs/**/*.md*", home); let base = format!("{}/../../website", home); let base = Path::new(&base).canonicalize().unwrap(); let dir_pattern = format!("{}/../../website/docs/**", home); diff --git a/website/docs/advanced-topics/server-side-rendering.md b/website/docs/advanced-topics/server-side-rendering.md new file mode 100644 index 00000000000..d69dda3cbdc --- /dev/null +++ b/website/docs/advanced-topics/server-side-rendering.md @@ -0,0 +1,136 @@ +--- +title: "Server-side Rendering" +description: "Render Yew on the server-side." +--- + +# Server-side Rendering + +By default, Yew components render at the client side. When a viewer +visits a website, the server sends a skeleton html file without any actual +content and a WebAssembly bundle to the browser. +Everything is rendered at the client side by the WebAssembly +bundle. This is known as client-side rendering. + +This approach works fine for most websites, with some caveats: + +1. Users will not be able to see anything until the entire WebAssembly + bundle is downloaded and initial render has completed. + This can result in poor user experience if the user is using a slow network. +2. Some search engines do not support dynamically rendered web content and + those who do usually rank dynamic websites lower in the search results. + +To solve these problems, we can render our website on the server side. + +## How it Works + +Yew provides a `ServerRenderer` to render pages on the +server-side. + +To render Yew components at the server-side, you can create a renderer +with `ServerRenderer::::new()` and call `renderer.render().await` +to render `` into a `String`. + +```rust +use yew::prelude::*; +use yew::ServerRenderer; + +#[function_component] +fn App() -> Html { + html! {
{"Hello, World!"}
} +} + +#[tokio::main] +async fn main() { + let renderer = ServerRenderer::::new(); + + let rendered = renderer.render().await; + + // Prints:
Hello, World!
+ println!("{}", rendered); +} +``` + +## Component Lifecycle + +The recommended way of working with server-side rendering is +function components. + +All hooks other than `use_effect` (and `use_effect_with_deps`) +will function normally until a component successfully renders into `Html` +for the first time. + +:::caution Web APIs are not available! + +Web APIs such as `web_sys` are not available when your component is +rendering on the server-side. +Your application will panic if you try to use them. +You should isolate logics that need Web APIs in `use_effect` or +`use_effect_with_deps` as effects are not executed during server side +rendering. + +::: + +:::danger Struct Components + +Whilst it's possible to use Struct Components with server-side rendering, +there's no clear boundaries between client-side safe logic like the +`use_effect` hook for function components and lifecycle events are invoked +in a different order than client side. + +In addition, Struct Components will continue to accept messages until all of its +children are rendered and `destroy` method is called. Developers need to +make sure no messages possibly passed to components would link to logic +that makes use of Web APIs. + +When designing an application with server-side rendering support, +prefer function components unless you have a good reason not to. + +::: + +## Data Fetching during Server-side Rendering + +Data fetching is one of the difficult point with server side rendering +and hydration. + +Traditionally, when a component renders, it is instantly available +(outputs a virtual dom to be rendered). This works fine when the +component does not want to fetch any data. But what happens if the component +wants to fetch some data during rendering? + +In the past, there's no mechanism for Yew to detect whether a component is still +fetching data. The data fetching client is responsible to implement +a solution to detect what's being requested during initial render and triggers +a second render after requests are fulfilled. The server repeats this process until +no more pending requests are added during a render before returning a response. + +Not only this wastes CPU resources by repeatedly rendering components, +but the data client also needs to provide a way to make the data fetched on +the server-side available during hydration process to make sure that the +virtual dom returned by initial render is consistent with the +server-side rendered DOM tree which can be hard to implement. + +Yew takes a different approach by trying to solve this issue with ``. + +Suspense is a special component that when used on the client-side, +provides a way to show a fallback UI while the component is fetching +data (suspended) and resumes to normal UI when the data fetching completes. + +When the application is rendered on the server-side, Yew waits until a +component is no longer suspended before serializing it into the string +buffer. + +During the hydration process, elements within a `` component +remains dehydrated until all of its child components are no longer +suspended. + +With this approach, developers can build a client-agnostic, SSR ready +application with data fetching with very little effort. + +Example: [simple\_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr) + +:::caution + +Server-side rendering is experiemental and currently has no hydration support. +However, you can still use it to generate static websites. + +::: diff --git a/website/docs/concepts/suspense.md b/website/docs/concepts/suspense.md index a8ac666a65d..ad7e041be39 100644 --- a/website/docs/concepts/suspense.md +++ b/website/docs/concepts/suspense.md @@ -92,7 +92,7 @@ fn load_user() -> Option { todo!() // implementation omitted. } -fn on_load_user_complete(_fn: F) { +fn on_load_user_complete(_fn: F) { todo!() // implementation omitted. } diff --git a/website/sidebars.js b/website/sidebars.js index 35028fdf089..7af51b7458f 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -3,9 +3,7 @@ - create an ordered group of docs - render a sidebar for each doc of that group - provide next/previous navigation - The sidebars can be generated from the filesystem, or explicitly defined here. - Create as many sidebars as you want. */ @@ -13,135 +11,137 @@ module.exports = { // By default, Docusaurus generates a sidebar from the docs folder structure // conceptsSidebar: [{type: 'autogenerated', dirName: '.'}], - // But you can create a sidebar manually - sidebar: [ + // But you can create a sidebar manually + sidebar: [ + { + type: "category", + label: "Getting Started", + link: { type: "doc", id: "getting-started/introduction" }, + items: [ + "getting-started/build-a-sample-app", + "getting-started/examples", + "getting-started/editor-setup", + ], + }, + { + type: "category", + label: "Concepts", + link: { + type: "generated-index", + title: "Yew concepts", + description: "Learn about the important Yew concepts!", + }, + items: [ + { + type: "category", + label: "Components", + link: { type: "doc", id: "concepts/components/introduction" }, + items: [ + "concepts/components/lifecycle", + "concepts/components/scope", + "concepts/components/callbacks", + "concepts/components/properties", + "concepts/components/children", + "concepts/components/refs", + ], + }, + { + type: "category", + label: "HTML", + link: { type: "doc", id: "concepts/html/introduction" }, + items: [ + "concepts/html/components", + "concepts/html/elements", + "concepts/html/events", + "concepts/html/classes", + "concepts/html/fragments", + "concepts/html/lists", + "concepts/html/literals-and-expressions", + "concepts/html/conditional-rendering", + ], + }, { - type: 'category', - label: 'Getting Started', - link: { type: 'doc', id: 'getting-started/introduction' }, - items: [ - "getting-started/build-a-sample-app", - "getting-started/examples", - "getting-started/editor-setup", - ], + type: "category", + label: "Function Components", + items: [ + "concepts/function-components/introduction", + "concepts/function-components/attribute", + "concepts/function-components/pre-defined-hooks", + "concepts/function-components/custom-hooks", + ], }, { - type: "category", - label: "Concepts", - link: { - type: 'generated-index', - title: 'Yew concepts', - description: 'Learn about the important Yew concepts!', - }, - items: [ - { - type: "category", - label: "Components", - link: { type: 'doc', id: 'concepts/components/introduction' }, - items: [ - "concepts/components/lifecycle", - "concepts/components/scope", - "concepts/components/callbacks", - "concepts/components/properties", - "concepts/components/children", - "concepts/components/refs" - ], - }, - { - type: "category", - label: "HTML", - link: { type: 'doc', id: 'concepts/html/introduction' }, - items: [ - "concepts/html/components", - "concepts/html/elements", - "concepts/html/events", - "concepts/html/classes", - "concepts/html/fragments", - "concepts/html/lists", - "concepts/html/literals-and-expressions", - "concepts/html/conditional-rendering" - ] - }, - { - type: "category", - label: "Function Components", - items: [ - "concepts/function-components/introduction", - "concepts/function-components/attribute", - "concepts/function-components/pre-defined-hooks", - "concepts/function-components/custom-hooks", - ] - }, - { - type: "category", - label: "wasm-bindgen", - link: { - type: 'generated-index', - title: 'wasm-bindgen', - description: 'Learn about wasm-bindgen', - slug: '/concepts/wasm-bindgen' - }, - items: [ - "concepts/wasm-bindgen/introduction", - "concepts/wasm-bindgen/web-sys", - ] - }, - "concepts/agents", - "concepts/contexts", - "concepts/router", - "concepts/suspense", - ] + type: "category", + label: "wasm-bindgen", + link: { + type: "generated-index", + title: "wasm-bindgen", + description: "Learn about wasm-bindgen", + slug: "/concepts/wasm-bindgen", + }, + items: [ + "concepts/wasm-bindgen/introduction", + "concepts/wasm-bindgen/web-sys", + ], }, + "concepts/agents", + "concepts/contexts", + "concepts/router", + "concepts/suspense", + ], + }, + { + type: "category", + label: "Advanced topics", + link: { + type: "generated-index", + title: "Advanced topics", + description: + "Learn about the advanced topics and inner workings of Yew!", + }, + items: [ + "advanced-topics/how-it-works", + "advanced-topics/optimizations", + "advanced-topics/portals", + "advanced-topics/server-side-rendering", + ], + }, + { + type: "category", + label: "More", + link: { + type: "generated-index", + title: "Miscellaneous", + }, + items: [ + "more/debugging", + "more/external-libs", + "more/css", + "more/testing", + "more/roadmap", + ], + }, + { + type: "category", + label: "Migration guides", + items: [ { - type: 'category', - label: 'Advanced topics', - link: { - type: 'generated-index', - title: 'Advanced topics', - description: 'Learn about the advanced topics and inner workings of Yew!', - }, - items: [ - "advanced-topics/how-it-works", - "advanced-topics/optimizations", - "advanced-topics/portals", - ] + type: "category", + label: "yew", + items: ["migration-guides/yew/from-0_18_0-to-0_19_0"], }, { - type: 'category', - label: 'More', - link: { - type: 'generated-index', - title: 'Miscellaneous', - }, - items: [ - "more/debugging", - "more/external-libs", - "more/css", - "more/testing", - "more/roadmap", - ] + type: "category", + label: "yew-agent", + items: ["migration-guides/yew-agent/from-0_0_0-to-0_1_0"], }, { - type: "category", - label: "Migration guides", - items: [ - { - type: "category", - label: "yew", - items: ["migration-guides/yew/from-0_18_0-to-0_19_0"], - }, - { - type: "category", - label: "yew-agent", - items: ["migration-guides/yew-agent/from-0_0_0-to-0_1_0"], - }, - { - type: "category", - label: "yew-router", - items: ["migration-guides/yew-router/from-0_15_0-to-0_16_0"], - }, - ], + type: "category", + label: "yew-router", + items: ["migration-guides/yew-router/from-0_15_0-to-0_16_0"], }, - "tutorial" - ], + ], + }, + "tutorial", + ], };