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 <muhammadhamza1311@gmail.com>
This commit is contained in:
Kaede Hoshikawa 2022-01-12 22:43:09 +09:00 committed by GitHub
parent fff1ffaab8
commit d8c2550fc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1450 additions and 261 deletions

View File

@ -61,6 +61,11 @@ jobs:
continue
fi
# ssr does not need trunk
if [[ "$example" == "simple_ssr" ]]; then
continue
fi
echo "building: $example"
(
cd "$path"

View File

@ -27,6 +27,7 @@ members = [
"examples/password_strength",
"examples/portals",
"examples/router",
"examples/simple_ssr",
"examples/timer",
"examples/todomvc",
"examples/two_apps",

View File

@ -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

View File

@ -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]

View File

@ -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"] }

View File

@ -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.

View File

@ -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::<UuidResponse>().await.unwrap();
uuid_resp.uuid
}
pub struct UuidState {
s: Suspension,
value: Rc<RefCell<Option<Uuid>>>,
}
impl UuidState {
fn new() -> Self {
let (s, handle) = Suspension::new();
let value: Rc<RefCell<Option<Uuid>>> = 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<Uuid> {
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! {
<div>{"Random UUID: "}{uuid}</div>
})
}
#[function_component]
fn App() -> Html {
let fallback = html! {<div>{"Loading..."}</div>};
html! {
<Suspense {fallback}>
<Content />
</Suspense>
}
}
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::<App>::new();
renderer.render().await
})
})
.await
.expect("the thread has failed.");
format!(
r#"<!DOCTYPE HTML>
<html>
<head>
<title>Yew SSR Example</title>
</head>
<body>
{}
</body>
</html>
"#,
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;
}

View File

@ -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"

View File

@ -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"]

View File

@ -41,3 +41,7 @@ args = [
"wasm_bench",
"bench",
]
[tasks.ssr-test]
command = "cargo"
args = ["test", "ssr_tests", "--features", "ssr"]

View File

@ -1,7 +1,8 @@
use crate::functional::use_hook;
use std::{borrow::Borrow, rc::Rc};
use std::rc::Rc;
struct UseEffect<Destructor> {
runner: Option<Box<dyn FnOnce() -> Destructor>>,
destructor: Option<Box<Destructor>>,
}
@ -39,20 +40,27 @@ pub fn use_effect<Destructor>(callback: impl FnOnce() -> Destructor + 'static)
where
Destructor: FnOnce() + 'static,
{
let callback = Box::new(callback);
use_hook(
move || {
let effect: UseEffect<Destructor> = UseEffect { destructor: None };
let effect: UseEffect<Destructor> = UseEffect {
runner: None,
destructor: None,
};
effect
},
|_, updater| {
|state, updater| {
state.runner = Some(Box::new(callback) as Box<dyn FnOnce() -> Destructor>);
// Run on every render
updater.post_render(move |state: &mut UseEffect<Destructor>| {
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<Dependents, Destructor> = Box<dyn FnOnce(&Dependents) -> Destructor>;
struct UseEffectDeps<Destructor, Dependents> {
runner_with_deps: Option<(
Rc<Dependents>,
UseEffectDepsRunnerFn<Dependents, Destructor>,
)>,
destructor: Option<Box<Destructor>>,
deps: Rc<Dependents>,
deps: Option<Rc<Dependents>>,
}
/// 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<Box<Destructor>> = 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<Destructor, Dependents>| {
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
});

View File

@ -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<COMP: BaseComponent> {
pub(crate) root_node: VNode,
context: Context<COMP>,
parent: Element,
/// When a component has no parent, it means that it should not be rendered.
parent: Option<Element>,
next_sibling: NodeRef,
node_ref: NodeRef,
has_rendered: bool,
suspension: Option<Suspension>,
#[cfg(feature = "ssr")]
html_sender: Option<oneshot::Sender<VNode>>,
// Used for debug logging
#[cfg(debug_assertions)]
pub(crate) vcomp_id: u64,
@ -29,12 +37,13 @@ pub(crate) struct ComponentState<COMP: BaseComponent> {
impl<COMP: BaseComponent> ComponentState<COMP> {
pub(crate) fn new(
parent: Element,
parent: Option<Element>,
next_sibling: NodeRef,
root_node: VNode,
node_ref: NodeRef,
scope: Scope<COMP>,
props: Rc<COMP::Properties>,
#[cfg(feature = "ssr")] html_sender: Option<oneshot::Sender<VNode>>,
) -> Self {
#[cfg(debug_assertions)]
let vcomp_id = {
@ -55,6 +64,9 @@ impl<COMP: BaseComponent> ComponentState<COMP> {
suspension: None,
has_rendered: false,
#[cfg(feature = "ssr")]
html_sender,
#[cfg(debug_assertions)]
vcomp_id,
}
@ -62,12 +74,14 @@ impl<COMP: BaseComponent> ComponentState<COMP> {
}
pub(crate) struct CreateRunner<COMP: BaseComponent> {
pub(crate) parent: Element,
pub(crate) parent: Option<Element>,
pub(crate) next_sibling: NodeRef,
pub(crate) placeholder: VNode,
pub(crate) node_ref: NodeRef,
pub(crate) props: Rc<COMP::Properties>,
pub(crate) scope: Scope<COMP>,
#[cfg(feature = "ssr")]
pub(crate) html_sender: Option<oneshot::Sender<VNode>>,
}
impl<COMP: BaseComponent> Runnable for CreateRunner<COMP> {
@ -84,6 +98,8 @@ impl<COMP: BaseComponent> Runnable for CreateRunner<COMP> {
self.node_ref,
self.scope.clone(),
self.props,
#[cfg(feature = "ssr")]
self.html_sender,
));
}
}
@ -129,11 +145,13 @@ impl<COMP: BaseComponent> Runnable for UpdateRunner<COMP> {
}
}
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<COMP: BaseComponent> Runnable for DestroyRunner<COMP> {
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<COMP: BaseComponent> Runnable for RenderRunner<COMP> {
// 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::<Suspense>().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<COMP: BaseComponent> Runnable for RenderRunner<COMP> {
let comp_scope = AnyScope::from(state.context.scope.clone());
let suspense_scope = comp_scope.find_parent_scope::<Suspense>().unwrap();
let suspense_scope = comp_scope
.find_parent_scope::<Suspense>()
.expect("To suspend rendering, a <Suspense /> component is required.");
let suspense = suspense_scope.get_component().unwrap();
m.listen(Callback::from(move |_| {
@ -277,9 +309,11 @@ impl<COMP: BaseComponent> Runnable for RenderedRunner<COMP> {
#[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;
}
}
}
}

View File

@ -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<COMP: BaseComponent> Scope<COMP> {
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<COMP: BaseComponent> Scope<COMP> {
};
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<FN, FU, IN, M>(&self, function: FN) -> Callback<IN>
where
M: Into<COMP::Message>,
FU: Future<Output = M> + '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<F, M>(&self, future: F)
where
M: Into<COMP::Message>,
F: Future<Output = M> + '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<F>(&self, future: F)
where
F: Future<Output = Vec<COMP::Message>> + 'static,
{
let link = self.clone();
let js_future = async move {
let messages: Vec<COMP::Message> = 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<COMP: BaseComponent> Scope<COMP> {
}
}
#[cfg(feature = "ssr")]
mod feat_ssr {
use super::*;
use futures::channel::oneshot;
impl<COMP: BaseComponent> Scope<COMP> {
pub(crate) async fn render_to_string(&self, w: &mut String, props: Rc<COMP::Properties>) {
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<COMP: BaseComponent> Scope<COMP> {
/// 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<FN, FU, IN, M>(&self, function: FN) -> Callback<IN>
where
M: Into<COMP::Message>,
FU: Future<Output = M> + '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<F, M>(&self, future: F)
where
M: Into<COMP::Message>,
F: Future<Output = M> + '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<F>(&self, future: F)
where
F: Future<Output = Vec<COMP::Message>> + 'static,
{
let link = self.clone();
let js_future = async move {
let messages: Vec<COMP::Message> = 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<COMP: BaseComponent> {

View File

@ -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: F)
where
F: Future<Output = ()> + 'static,
{
tokio::task::spawn_local(f);
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "tokio"))]
pub(crate) use io_tokio::*;

View File

@ -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 {

View File

@ -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<COMP>
where
COMP: BaseComponent,
{
props: COMP::Properties,
}
impl<COMP> Default for ServerRenderer<COMP>
where
COMP: BaseComponent,
COMP::Properties: Default,
{
fn default() -> Self {
Self::with_props(COMP::Properties::default())
}
}
impl<COMP> ServerRenderer<COMP>
where
COMP: BaseComponent,
COMP::Properties: Default,
{
/// Creates a [ServerRenderer] with default properties.
pub fn new() -> Self {
Self::default()
}
}
impl<COMP> ServerRenderer<COMP>
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::<COMP>::new(None);
scope.render_to_string(w, self.props.into()).await;
}
}

View File

@ -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<Self>,
suspensions: Vec<Suspension>,
detached_parent: Element,
detached_parent: Option<Element>,
}
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,
}
}

View File

@ -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<Output = ()> + '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<Output = ()> + 'static) -> Self {
let (self_, handle) = Self::new();
spawn_local(async move {
f.await;
handle.resume();
});
self_
}
}
}

View File

@ -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<String> {
pub struct VComp {
type_id: TypeId,
scope: Option<Box<dyn Scoped>>,
props: Option<Box<dyn Mountable>>,
mountable: Option<Box<dyn Mountable>>,
pub(crate) node_ref: NodeRef,
pub(crate) key: Option<Key>,
@ -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::<COMP>(),
node_ref,
props: Some(Box::new(PropsWrapper::<COMP>::new(props))),
mountable: Some(Box::new(PropsWrapper::<COMP>::new(props))),
scope: None,
key,
@ -181,6 +183,13 @@ trait Mountable {
next_sibling: NodeRef,
) -> Box<dyn Scoped>;
fn reuse(self: Box<Self>, 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<COMP: BaseComponent> {
@ -218,6 +227,19 @@ impl<COMP: BaseComponent> Mountable for PropsWrapper<COMP> {
let scope: Scope<COMP> = 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<COMP> = 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<VNode>,
) -> 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<COMP: BaseComponent> fmt::Debug for VChild<COMP> {
}
}
#[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! { <div>{"Hello, "}{&props.name}{"!"}</div> }
}
#[function_component]
fn Comp() -> Html {
html! {
<div>
<Child name="Jane" />
<Child name="John" />
<Child name="Josh" />
</div>
}
}
let renderer = ServerRenderer::<Comp>::new();
let s = renderer.render().await;
assert_eq!(
s,
"<div><div>Hello, Jane!</div><div>Hello, John!</div><div>Hello, Josh!</div></div>"
);
}
}

View File

@ -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! { <div>{"Hello "}{s}{"!"}</div> }
}
let renderer = ServerRenderer::<Comp>::new();
let s = renderer.render().await;
assert_eq!(s, "<div>Hello world!</div>");
}
#[test]
async fn test_fragment() {
#[derive(PartialEq, Properties, Debug)]
struct ChildProps {
name: String,
}
#[function_component]
fn Child(props: &ChildProps) -> Html {
html! { <div>{"Hello, "}{&props.name}{"!"}</div> }
}
#[function_component]
fn Comp() -> Html {
html! {
<>
<Child name="Jane" />
<Child name="John" />
<Child name="Josh" />
</>
}
}
let renderer = ServerRenderer::<Comp>::new();
let s = renderer.render().await;
assert_eq!(
s,
"<div>Hello, Jane!</div><div>Hello, John!</div><div>Hello, Josh!</div>"
);
}
}

View File

@ -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::*;

View File

@ -12,7 +12,7 @@ pub struct VSuspense {
fallback: Box<VNode>,
/// The element to attach to when children is not attached to DOM
detached_parent: Element,
detached_parent: Option<Element>,
/// 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<Element>,
suspended: bool,
key: Option<Key>,
) -> 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<VNode>,
) -> 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<Self>, _action: Self::Action) -> Rc<Self> {
Self::new().into()
}
}
pub fn use_sleep() -> SuspensionResult<Rc<dyn Fn()>> {
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! { <div>{"Hello, "}{&props.name}{"!"}</div> })
}
#[function_component]
fn Comp() -> Html {
let fallback = html! {"loading..."};
html! {
<Suspense {fallback}>
<Child name="Jane" />
<Child name="John" />
<Child name="Josh" />
</Suspense>
}
}
let local = LocalSet::new();
let s = local
.run_until(async move {
let renderer = ServerRenderer::<Comp>::new();
renderer.render().await
})
.await;
assert_eq!(
s,
"<div>Hello, Jane!</div><div>Hello, John!</div><div>Hello, Josh!</div>"
);
}
}

View File

@ -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("</textarea>");
}
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! { <div></div> }
}
let renderer = ServerRenderer::<Comp>::new();
let s = renderer.render().await;
assert_eq!(s, "<div></div>");
}
#[test]
async fn test_simple_tag_with_attr() {
#[function_component]
fn Comp() -> Html {
html! { <div class="abc"></div> }
}
let renderer = ServerRenderer::<Comp>::new();
let s = renderer.render().await;
assert_eq!(s, r#"<div class="abc"></div>"#);
}
#[test]
async fn test_simple_tag_with_content() {
#[function_component]
fn Comp() -> Html {
html! { <div>{"Hello!"}</div> }
}
let renderer = ServerRenderer::<Comp>::new();
let s = renderer.render().await;
assert_eq!(s, r#"<div>Hello!</div>"#);
}
#[test]
async fn test_simple_tag_with_nested_tag_and_input() {
#[function_component]
fn Comp() -> Html {
html! { <div>{"Hello!"}<input value="abc" type="text" /></div> }
}
let renderer = ServerRenderer::<Comp>::new();
let s = renderer.render().await;
assert_eq!(s, r#"<div>Hello!<input value="abc" type="text"></div>"#);
}
#[test]
async fn test_textarea() {
#[function_component]
fn Comp() -> Html {
html! { <textarea value="teststring" /> }
}
let renderer = ServerRenderer::<Comp>::new();
let s = renderer.render().await;
assert_eq!(s, r#"<textarea>teststring</textarea>"#);
}
}

View File

@ -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());
}
}

View File

@ -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#"<div class="content-area"><div class="action-area"><button class="take-a-break">Take a break!</button></div><div class="content-area"><div class="action-area"><button class="take-a-break2">Take a break!</button></div></div></div>"#
);
}
#[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<Self>, _action: Self::Action) -> Rc<Self> {
Self::new().into()
}
}
pub fn use_sleep() -> SuspensionResult<Rc<dyn Fn()>> {
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<RefCell<u64>>,
}
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! {
<div class="content-area">
<div class="actual-result">{*value}</div>
<button class="increase" onclick={on_increment}>{"increase"}</button>
<div class="action-area">
<button class="take-a-break" onclick={on_take_a_break}>{"Take a break!"}</button>
</div>
</div>
})
}
#[function_component(App)]
fn app(props: &Props) -> Html {
let fallback = html! {<div>{"wait..."}</div>};
html! {
<div id="result">
<Suspense {fallback}>
<Content counter={props.counter.clone()} />
</Suspense>
</div>
}
}
let counter = Rc::new(RefCell::new(0_u64));
let props = Props {
counter: counter.clone(),
};
yew::start_app_with_props_in_element::<App>(
gloo_utils::document().get_element_by_id("output").unwrap(),
props,
);
TimeoutFuture::new(10).await;
let result = obtain_result();
assert_eq!(result.as_str(), "<div>wait...</div>");
assert_eq!(*counter.borrow(), 0); // effects not called.
TimeoutFuture::new(50).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<div class="content-area"><div class="actual-result">0</div><button class="increase">increase</button><div class="action-area"><button class="take-a-break">Take a break!</button></div></div>"#
);
assert_eq!(*counter.borrow(), 1); // effects ran 1 time.
TimeoutFuture::new(10).await;
gloo_utils::document()
.query_selector(".increase")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
gloo_utils::document()
.query_selector(".increase")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<div class="content-area"><div class="actual-result">2</div><button class="increase">increase</button><div class="action-area"><button class="take-a-break">Take a break!</button></div></div>"#
);
assert_eq!(*counter.borrow(), 3); // effects ran 3 times.
gloo_utils::document()
.query_selector(".take-a-break")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
TimeoutFuture::new(10).await;
let result = obtain_result();
assert_eq!(result.as_str(), "<div>wait...</div>");
assert_eq!(*counter.borrow(), 3); // effects ran 3 times.
TimeoutFuture::new(50).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<div class="content-area"><div class="actual-result">2</div><button class="increase">increase</button><div class="action-area"><button class="take-a-break">Take a break!</button></div></div>"#
);
assert_eq!(*counter.borrow(), 4); // effects ran 4 times.
}

View File

@ -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"

View File

@ -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);

View File

@ -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::<App>::new()` and call `renderer.render().await`
to render `<App />` into a `String`.
```rust
use yew::prelude::*;
use yew::ServerRenderer;
#[function_component]
fn App() -> Html {
html! {<div>{"Hello, World!"}</div>}
}
#[tokio::main]
async fn main() {
let renderer = ServerRenderer::<App>::new();
let rendered = renderer.render().await;
// Prints: <div>Hello, World!</div>
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 />`.
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 `<Suspense />` 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.
:::

View File

@ -92,7 +92,7 @@ fn load_user() -> Option<User> {
todo!() // implementation omitted.
}
fn on_load_user_complete<F: Fn()>(_fn: F) {
fn on_load_user_complete<F: FnOnce()>(_fn: F) {
todo!() // implementation omitted.
}

View File

@ -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: '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: "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: "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: "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: '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: "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: 'More',
link: {
type: 'generated-index',
title: 'Miscellaneous',
},
items: [
"more/debugging",
"more/external-libs",
"more/css",
"more/testing",
"more/roadmap",
]
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: "yew",
items: ["migration-guides/yew/from-0_18_0-to-0_19_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-agent",
items: ["migration-guides/yew-agent/from-0_0_0-to-0_1_0"],
},
"tutorial"
],
{
type: "category",
label: "yew-router",
items: ["migration-guides/yew-router/from-0_15_0-to-0_16_0"],
},
],
},
"tutorial",
],
};