SSR Hydration (#2552)

* Bring changes to this branch.

* Add feature hydration.

* Hydrate text.

* Hydrate tag.

* Hydrate node.

* Hydrate List.

* Hydrate Suspense.

* Hydrate component.

* Renderer::hydrate.

* Add example and tests.

* Fix comp_id.

* Move some code away from generics.

* Fix everything.

* trybuild?

* Collectable!

* Phantom component.

* Migrate docs as well.

* Update example.

* Fix docs and improve debug message.

* Minor fixing.

* Add hydration to feature soundness check.

* Fix name in debug.

* Remove Shift.

* Remove comment.

* Adjust readme.

* Update website/docs/advanced-topics/server-side-rendering.md

Co-authored-by: Muhammad Hamza <muhammadhamza1311@gmail.com>

* Update packages/yew/src/dom_bundle/bnode.rs

Co-authored-by: Muhammad Hamza <muhammadhamza1311@gmail.com>

* Update packages/yew/src/dom_bundle/bnode.rs

Co-authored-by: Muhammad Hamza <muhammadhamza1311@gmail.com>

* Once via structopt, now direct clap.

* Fix docs and empty fragment.

* Remove struct component warning.

* Move function router into a separate binary.

* Optimise Code Logic.

* Fix condition.

* Fix rendering behaviour.

* Fix comment.

Co-authored-by: Muhammad Hamza <muhammadhamza1311@gmail.com>
This commit is contained in:
Kaede Hoshikawa 2022-04-03 08:00:16 +09:00 committed by GitHub
parent 2db42841a1
commit e46ae55cab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 2264 additions and 247 deletions

View File

@ -32,6 +32,7 @@ jobs:
cargo clippy -- --deny=warnings
cargo clippy --features=ssr -- --deny=warnings
cargo clippy --features=csr -- --deny=warnings
cargo clippy --features=hydration -- --deny=warnings
cargo clippy --all-features --all-targets -- --deny=warnings
working-directory: packages/yew
@ -62,6 +63,7 @@ jobs:
cargo clippy --release -- --deny=warnings
cargo clippy --release --features=ssr -- --deny=warnings
cargo clippy --release --features=csr -- --deny=warnings
cargo clippy --release --features=hydration -- --deny=warnings
cargo clippy --release --all-features --all-targets -- --deny=warnings
working-directory: packages/yew

View File

@ -34,6 +34,7 @@ members = [
"examples/two_apps",
"examples/webgl",
"examples/web_worker_fib",
"examples/ssr_router",
"examples/suspense",
# Tools

View File

@ -13,14 +13,11 @@ yew-router = { path = "../../packages/yew-router" }
serde = { version = "1.0", features = ["derive"] }
lazy_static = "1.4.0"
gloo-timers = "0.2"
wasm-logger = "0.2"
instant = { version = "0.1", features = ["wasm-bindgen"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
instant = { version = "0.1", features = ["wasm-bindgen"] }
wasm-logger = "0.2"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
instant = { version = "0.1" }
[features]
csr = ["yew/csr"]

View File

@ -11,7 +11,7 @@
href="https://cdn.jsdelivr.net/npm/bulma@0.9.0/css/bulma.min.css"
/>
<link data-trunk rel="sass" href="index.scss" />
<link data-trunk rel="rust" data-cargo-features="csr" />
<link data-trunk rel="rust" data-cargo-features="csr" data-bin="function_router" />
</head>
<body></body>

View File

@ -1,4 +1,8 @@
use std::collections::HashMap;
use yew::prelude::*;
use yew::virtual_dom::AttrValue;
use yew_router::history::{AnyHistory, History, MemoryHistory};
use yew_router::prelude::*;
use crate::components::nav::Nav;
@ -47,52 +51,39 @@ pub fn App() -> Html {
}
}
#[cfg(not(target_arch = "wasm32"))]
mod arch_native {
use super::*;
use yew::virtual_dom::AttrValue;
use yew_router::history::{AnyHistory, History, MemoryHistory};
use std::collections::HashMap;
#[derive(Properties, PartialEq, Debug)]
pub struct ServerAppProps {
pub url: AttrValue,
pub queries: HashMap<String, String>,
}
#[function_component]
pub fn ServerApp(props: &ServerAppProps) -> Html {
let history = AnyHistory::from(MemoryHistory::new());
history
.push_with_query(&*props.url, &props.queries)
.unwrap();
html! {
<Router history={history}>
<Nav />
<main>
<Switch<Route> render={Switch::render(switch)} />
</main>
<footer class="footer">
<div class="content has-text-centered">
{ "Powered by " }
<a href="https://yew.rs">{ "Yew" }</a>
{ " using " }
<a href="https://bulma.io">{ "Bulma" }</a>
{ " and images from " }
<a href="https://unsplash.com">{ "Unsplash" }</a>
</div>
</footer>
</Router>
}
}
#[derive(Properties, PartialEq, Debug)]
pub struct ServerAppProps {
pub url: AttrValue,
pub queries: HashMap<String, String>,
}
#[cfg(not(target_arch = "wasm32"))]
pub use arch_native::*;
#[function_component]
pub fn ServerApp(props: &ServerAppProps) -> Html {
let history = AnyHistory::from(MemoryHistory::new());
history
.push_with_query(&*props.url, &props.queries)
.unwrap();
html! {
<Router history={history}>
<Nav />
<main>
<Switch<Route> render={Switch::render(switch)} />
</main>
<footer class="footer">
<div class="content has-text-centered">
{ "Powered by " }
<a href="https://yew.rs">{ "Yew" }</a>
{ " using " }
<a href="https://bulma.io">{ "Bulma" }</a>
{ " and images from " }
<a href="https://unsplash.com">{ "Unsplash" }</a>
</div>
</footer>
</Router>
}
}
fn switch(routes: &Route) -> Html {
match routes.clone() {

View File

@ -0,0 +1,7 @@
pub use function_router::*;
fn main() {
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
#[cfg(feature = "csr")]
yew::Renderer::<App>::new().render();
}

View File

@ -1,14 +0,0 @@
mod app;
mod components;
mod content;
mod generator;
mod pages;
pub use app::*;
fn main() {
#[cfg(target_arch = "wasm32")]
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
#[cfg(feature = "render")]
yew::Renderer::<App>::new().render();
}

View File

@ -6,9 +6,20 @@ edition = "2021"
# 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"] }
yew = { path = "../../packages/yew", features = ["ssr", "hydration"] }
reqwest = { version = "0.11.8", features = ["json"] }
serde = { version = "1.0.132", features = ["derive"] }
uuid = { version = "0.8.2", features = ["serde"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
wasm-logger = "0.2"
log = "0.4"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1.15.0", features = ["full"] }
warp = "0.3"
num_cpus = "1.13"
tokio-util = { version = "0.7", features = ["rt"] }
once_cell = "1.5"
clap = { version = "3.1.7", features = ["derive"] }

View File

@ -2,5 +2,16 @@
This example demonstrates server-side rendering.
Run `cargo run -p simple_ssr` and navigate to http://localhost:8080/ to
view results.
# How to run this example
1. build hydration bundle
`trunk build examples/simple_ssr/index.html`
2. Run the server
`cargo run --bin simple_ssr_server -- --dir examples/simple_ssr/dist`
3. Open Browser
Navigate to http://localhost:8080/ to view results.

View File

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Yew SSR Example</title>
<link data-trunk rel="rust" data-bin="simple_ssr_hydrate" />
</head>
</html>

View File

@ -0,0 +1,7 @@
use simple_ssr::App;
fn main() {
#[cfg(target_arch = "wasm32")]
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
yew::Renderer::<App>::new().hydrate();
}

View File

@ -0,0 +1,53 @@
use clap::Parser;
use once_cell::sync::Lazy;
use simple_ssr::App;
use std::path::PathBuf;
use tokio_util::task::LocalPoolHandle;
use warp::Filter;
// We spawn a local pool that is as big as the number of cpu threads.
static LOCAL_POOL: Lazy<LocalPoolHandle> = Lazy::new(|| LocalPoolHandle::new(num_cpus::get()));
/// A basic example
#[derive(Parser, Debug)]
struct Opt {
/// the "dist" created by trunk directory to be served for hydration.
#[structopt(short, long, parse(from_os_str))]
dir: PathBuf,
}
async fn render(index_html_s: &str) -> String {
let content = LOCAL_POOL
.spawn_pinned(move || async move {
let renderer = yew::ServerRenderer::<App>::new();
renderer.render().await
})
.await
.expect("the task has failed.");
// Good enough for an example, but developers should avoid the replace and extra allocation
// here in an actual app.
index_html_s.replace("<body>", &format!("<body>{}", content))
}
#[tokio::main]
async fn main() {
let opts = Opt::parse();
let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html"))
.await
.expect("failed to read index.html");
let html = warp::path::end().then(move || {
let index_html_s = index_html_s.clone();
async move { warp::reply::html(render(&index_html_s).await) }
});
let routes = html.or(warp::fs::dir(opts.dir));
println!("You can view the website at: http://localhost:8080/");
warp::serve(routes).run(([127, 0, 0, 1], 8080)).await;
}

View File

@ -2,13 +2,15 @@ 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};
#[cfg(not(target_arch = "wasm32"))]
use tokio::task::spawn_local;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[derive(Serialize, Deserialize)]
struct UuidResponse {
uuid: Uuid,
@ -79,7 +81,7 @@ fn Content() -> HtmlResult {
}
#[function_component]
fn App() -> Html {
pub fn App() -> Html {
let fallback = html! {<div>{"Loading..."}</div>};
html! {
@ -88,43 +90,3 @@ fn App() -> Html {
</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

@ -0,0 +1,24 @@
[package]
name = "ssr_router"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
yew = { path = "../../packages/yew", features = ["ssr", "hydration", "trace_hydration"] }
function_router = { path = "../function_router" }
log = "0.4"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
wasm-logger = "0.2"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1.15.0", features = ["full"] }
warp = "0.3"
env_logger = "0.9"
num_cpus = "1.13"
tokio-util = { version = "0.7", features = ["rt"] }
once_cell = "1.5"
clap = { version = "3.1.7", features = ["derive"] }

View File

@ -0,0 +1,19 @@
# SSR Router Example
This example is the same as the function router example, but with
server-side rendering and hydration support. It reuses the same codebase
of the function router example.
# How to run this example
1. Build Hydration Bundle
`trunk build examples/ssr_router/index.html`
2. Run the server
`cargo run --bin ssr_router_server -- --dir examples/ssr_router/dist`
3. Open Browser
Navigate to http://localhost:8080/ to view results.

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link data-trunk rel="rust" data-bin="ssr_router_hydrate" />
<title>Yew • SSR Router</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@0.9.0/css/bulma.min.css"
/>
<link data-trunk rel="sass" href="../function_router/index.scss" />
</head>
</html>

View File

@ -0,0 +1,7 @@
use function_router::App;
fn main() {
#[cfg(target_arch = "wasm32")]
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
yew::Renderer::<App>::new().hydrate();
}

View File

@ -0,0 +1,71 @@
use clap::Parser;
use function_router::{ServerApp, ServerAppProps};
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio_util::task::LocalPoolHandle;
use warp::Filter;
// We spawn a local pool that is as big as the number of cpu threads.
static LOCAL_POOL: Lazy<LocalPoolHandle> = Lazy::new(|| LocalPoolHandle::new(num_cpus::get()));
/// A basic example
#[derive(Parser, Debug)]
struct Opt {
/// the "dist" created by trunk directory to be served for hydration.
#[structopt(short, long, parse(from_os_str))]
dir: PathBuf,
}
async fn render(index_html_s: &str, url: &str, queries: HashMap<String, String>) -> String {
let url = url.to_string();
let content = LOCAL_POOL
.spawn_pinned(move || async move {
let server_app_props = ServerAppProps {
url: url.into(),
queries,
};
let renderer = yew::ServerRenderer::<ServerApp>::with_props(server_app_props);
renderer.render().await
})
.await
.expect("the task has failed.");
// Good enough for an example, but developers should avoid the replace and extra allocation
// here in an actual app.
index_html_s.replace("<body>", &format!("<body>{}", content))
}
#[tokio::main]
async fn main() {
env_logger::init();
let opts = Opt::parse();
let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html"))
.await
.expect("failed to read index.html");
let render = move |s: warp::filters::path::FullPath, queries: HashMap<String, String>| {
let index_html_s = index_html_s.clone();
async move { warp::reply::html(render(&index_html_s, s.as_str(), queries).await) }
};
let html = warp::path::end().and(
warp::path::full()
.and(warp::filters::query::query())
.then(render.clone()),
);
let routes = html.or(warp::fs::dir(opts.dir)).or(warp::path::full()
.and(warp::filters::query::query())
.then(render));
println!("You can view the website at: http://localhost:8080/");
warp::serve(routes).run(([127, 0, 0, 1], 8080)).await;
}

View File

@ -0,0 +1 @@

View File

@ -58,15 +58,12 @@ impl NavigatorContext {
}
}
/// The Router component.
/// The base router.
///
/// This provides location and navigator context to its children and switches.
///
/// If you are building a web application, you may want to consider using [`BrowserRouter`] instead.
///
/// You only need one `<Router />` for each application.
#[function_component(Router)]
pub fn router(props: &RouterProps) -> Html {
/// The implementation is separated to make sure <Router /> has the same virtual dom layout as
/// the <BrowserRouter /> and <HashRouter />.
#[function_component(BaseRouter)]
fn base_router(props: &RouterProps) -> Html {
let RouterProps {
history,
children,
@ -117,6 +114,20 @@ pub fn router(props: &RouterProps) -> Html {
}
}
/// The Router component.
///
/// This provides location and navigator context to its children and switches.
///
/// If you are building a web application, you may want to consider using [`BrowserRouter`] instead.
///
/// You only need one `<Router />` for each application.
#[function_component(Router)]
pub fn router(props: &RouterProps) -> Html {
html! {
<BaseRouter ..{props.clone()} />
}
}
/// Props for [`BrowserRouter`] and [`HashRouter`].
#[derive(Properties, PartialEq, Clone)]
pub struct ConcreteRouterProps {
@ -143,9 +154,9 @@ pub fn browser_router(props: &ConcreteRouterProps) -> Html {
let basename = basename.map(|m| m.to_string()).or_else(base_url);
html! {
<Router history={(*history).clone()} {basename}>
<BaseRouter history={(*history).clone()} {basename}>
{children}
</Router>
</BaseRouter>
}
}
@ -163,8 +174,8 @@ pub fn hash_router(props: &ConcreteRouterProps) -> Html {
let history = use_state(|| AnyHistory::from(HashHistory::new()));
html! {
<Router history={(*history).clone()} {basename}>
<BaseRouter history={(*history).clone()} {basename}>
{children}
</Router>
</BaseRouter>
}
}

View File

@ -41,6 +41,7 @@ pub fn fetch_base_url() -> Option<String> {
}
}
#[cfg(target_arch = "wasm32")]
pub fn compose_path(pathname: &str, query: &str) -> Option<String> {
gloo::utils::window()
.location()
@ -53,6 +54,17 @@ pub fn compose_path(pathname: &str, query: &str) -> Option<String> {
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn compose_path(pathname: &str, query: &str) -> Option<String> {
let query = query.trim();
if !query.is_empty() {
Some(format!("{}?{}", pathname, query))
} else {
Some(pathname.to_owned())
}
}
#[cfg(test)]
mod tests {
use gloo::utils::document;

View File

@ -51,6 +51,7 @@ features = [
"Location",
"MouseEvent",
"Node",
"NodeList",
"PointerEvent",
"ProgressEvent",
"Text",
@ -87,8 +88,10 @@ features = [
[features]
ssr = ["futures", "html-escape"]
csr = []
doc_test = ["csr", "ssr"]
wasm_test = ["csr"]
hydration = ["csr"]
trace_hydration = ["hydration"]
doc_test = ["csr", "hydration", "ssr"]
wasm_test = ["csr", "hydration", "ssr"]
default = []
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]

View File

@ -39,11 +39,13 @@ set -ex
cargo clippy -- --deny=warnings
cargo clippy --features=ssr -- --deny=warnings
cargo clippy --features=csr -- --deny=warnings
cargo clippy --features=hydration -- --deny=warnings
cargo clippy --all-features --all-targets -- --deny=warnings
cargo clippy --release -- --deny=warnings
cargo clippy --release --features=ssr -- --deny=warnings
cargo clippy --release --features=csr -- --deny=warnings
cargo clippy --release --features=hydration -- --deny=warnings
cargo clippy --release --all-features --all-targets -- --deny=warnings
'''

View File

@ -63,3 +63,41 @@ fn clear_element(host: &Element) {
host.remove_child(&child).expect("can't remove a child");
}
}
#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::Fragment;
impl<ICOMP> AppHandle<ICOMP>
where
ICOMP: IntoComponent,
{
pub(crate) fn hydrate_with_props(host: Element, props: Rc<ICOMP::Properties>) -> Self {
let app = Self {
scope: Scope::new(None),
};
let mut fragment = Fragment::collect_children(&host);
let hosting_root = BSubtree::create_root(&host);
app.scope.hydrate_in_place(
hosting_root,
host.clone(),
&mut fragment,
NodeRef::default(),
props,
);
// We remove all remaining nodes, this mimics the clear_element behaviour in
// mount_with_props.
for node in fragment.iter() {
host.remove_child(node).unwrap();
}
app
}
}
}

View File

@ -119,6 +119,48 @@ impl Reconcilable for VComp {
}
}
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{Fragment, Hydratable};
impl Hydratable for VComp {
fn hydrate(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
) -> (NodeRef, Self::Bundle) {
let VComp {
type_id,
mountable,
node_ref,
key,
} = self;
let scoped = mountable.hydrate(
root.clone(),
parent_scope,
parent.clone(),
fragment,
node_ref.clone(),
);
(
node_ref.clone(),
BComp {
type_id,
scope: scoped,
node_ref,
key,
},
)
}
}
}
#[cfg(feature = "wasm_test")]
#[cfg(test)]
mod tests {

View File

@ -453,6 +453,47 @@ impl Reconcilable for VList {
}
}
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{Fragment, Hydratable};
impl Hydratable for VList {
fn hydrate(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
) -> (NodeRef, Self::Bundle) {
let node_ref = NodeRef::default();
let mut children = Vec::with_capacity(self.children.len());
for (index, child) in self.children.into_iter().enumerate() {
let (child_node_ref, child) = child.hydrate(root, parent_scope, parent, fragment);
if index == 0 {
node_ref.reuse(child_node_ref);
}
children.push(child);
}
children.reverse();
(
node_ref,
BList {
rev_children: children,
fully_keyed: self.fully_keyed,
key: self.key,
},
)
}
}
}
#[cfg(test)]
mod layout_tests {
extern crate self as yew;

View File

@ -233,6 +233,55 @@ impl fmt::Debug for BNode {
}
}
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{Fragment, Hydratable};
impl Hydratable for VNode {
fn hydrate(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
) -> (NodeRef, Self::Bundle) {
match self {
VNode::VTag(vtag) => {
let (node_ref, tag) = vtag.hydrate(root, parent_scope, parent, fragment);
(node_ref, tag.into())
}
VNode::VText(vtext) => {
let (node_ref, text) = vtext.hydrate(root, parent_scope, parent, fragment);
(node_ref, text.into())
}
VNode::VComp(vcomp) => {
let (node_ref, comp) = vcomp.hydrate(root, parent_scope, parent, fragment);
(node_ref, comp.into())
}
VNode::VList(vlist) => {
let (node_ref, list) = vlist.hydrate(root, parent_scope, parent, fragment);
(node_ref, list.into())
}
// You cannot hydrate a VRef.
VNode::VRef(_) => {
panic!("VRef is not hydratable. Try moving it to a component mounted after an effect.")
}
// You cannot hydrate a VPortal.
VNode::VPortal(_) => {
panic!("VPortal is not hydratable. Try creating your portal by delaying it with use_effect.")
}
VNode::VSuspense(vsuspense) => {
let (node_ref, suspense) =
vsuspense.hydrate(root, parent_scope, parent, fragment);
(node_ref, suspense.into())
}
}
}
}
}
#[cfg(test)]
mod layout_tests {
use super::*;

View File

@ -7,12 +7,24 @@ use crate::NodeRef;
use gloo::utils::document;
use web_sys::Element;
#[cfg(feature = "hydration")]
use super::Fragment;
#[derive(Debug)]
enum Fallback {
/// Suspense Fallback with fallback being rendered as placeholder.
Bundle(BNode),
/// Suspense Fallback with Hydration Fragment being rendered as placeholder.
#[cfg(feature = "hydration")]
Fragment(Fragment),
}
/// The bundle implementation to [VSuspense]
#[derive(Debug)]
pub(super) struct BSuspense {
children_bundle: BNode,
/// The supsense is suspended if fallback contains [Some] bundle
fallback_bundle: Option<BNode>,
fallback: Option<Fallback>,
detached_parent: Element,
key: Option<Key>,
}
@ -22,27 +34,45 @@ impl BSuspense {
pub fn key(&self) -> Option<&Key> {
self.key.as_ref()
}
/// Get the bundle node that actually shows up in the dom
fn active_node(&self) -> &BNode {
self.fallback_bundle
.as_ref()
.unwrap_or(&self.children_bundle)
}
}
impl ReconcileTarget for BSuspense {
fn detach(self, root: &BSubtree, parent: &Element, parent_to_detach: bool) {
if let Some(fallback) = self.fallback_bundle {
fallback.detach(root, parent, parent_to_detach);
self.children_bundle
.detach(root, &self.detached_parent, false);
} else {
self.children_bundle.detach(root, parent, parent_to_detach);
match self.fallback {
Some(m) => {
match m {
Fallback::Bundle(bundle) => {
bundle.detach(root, parent, parent_to_detach);
}
#[cfg(feature = "hydration")]
Fallback::Fragment(fragment) => {
fragment.detach(root, parent, parent_to_detach);
}
}
self.children_bundle
.detach(root, &self.detached_parent, false);
}
None => {
self.children_bundle.detach(root, parent, parent_to_detach);
}
}
}
fn shift(&self, next_parent: &Element, next_sibling: NodeRef) {
self.active_node().shift(next_parent, next_sibling)
match self.fallback.as_ref() {
Some(Fallback::Bundle(bundle)) => {
bundle.shift(next_parent, next_sibling);
}
#[cfg(feature = "hydration")]
Some(Fallback::Fragment(fragment)) => {
fragment.shift(next_parent, next_sibling);
}
None => {
self.children_bundle.shift(next_parent, next_sibling);
}
}
}
}
@ -77,7 +107,7 @@ impl Reconcilable for VSuspense {
fallback_ref,
BSuspense {
children_bundle,
fallback_bundle: Some(fallback),
fallback: Some(Fallback::Bundle(fallback)),
detached_parent,
key,
},
@ -89,7 +119,7 @@ impl Reconcilable for VSuspense {
child_ref,
BSuspense {
children_bundle,
fallback_bundle: None,
fallback: None,
detached_parent,
key,
},
@ -124,7 +154,7 @@ impl Reconcilable for VSuspense {
) -> NodeRef {
let VSuspense {
children,
fallback,
fallback: vfallback,
suspended,
key: _,
} = self;
@ -134,9 +164,9 @@ impl Reconcilable for VSuspense {
// When it's suspended, we render children into an element that is detached from the dom
// tree while rendering fallback UI into the original place where children resides in.
match (suspended, &mut suspense.fallback_bundle) {
match (suspended, &mut suspense.fallback) {
// Both suspended, reconcile children into detached_parent, fallback into the DOM
(true, Some(fallback_bundle)) => {
(true, Some(fallback)) => {
children.reconcile_node(
root,
parent_scope,
@ -145,7 +175,20 @@ impl Reconcilable for VSuspense {
children_bundle,
);
fallback.reconcile_node(root, parent_scope, parent, next_sibling, fallback_bundle)
match fallback {
Fallback::Bundle(bundle) => {
vfallback.reconcile_node(root, parent_scope, parent, next_sibling, bundle)
}
#[cfg(feature = "hydration")]
Fallback::Fragment(fragment) => {
let node_ref = NodeRef::default();
match fragment.front().cloned() {
Some(m) => node_ref.set(Some(m)),
None => node_ref.link(next_sibling),
}
node_ref
}
}
}
// Not suspended, just reconcile the children into the DOM
(false, None) => {
@ -163,18 +206,26 @@ impl Reconcilable for VSuspense {
children_bundle,
);
// first render of fallback
let (fallback_ref, fallback) =
fallback.attach(root, parent_scope, parent, next_sibling);
suspense.fallback_bundle = Some(fallback);
vfallback.attach(root, parent_scope, parent, next_sibling);
suspense.fallback = Some(Fallback::Bundle(fallback));
fallback_ref
}
// Freshly unsuspended. Detach fallback from the DOM, then shift children into it.
(false, Some(_)) => {
suspense
.fallback_bundle
.take()
.unwrap() // We just matched Some(_)
.detach(root, parent, false);
match suspense.fallback.take() {
Some(Fallback::Bundle(bundle)) => {
bundle.detach(root, parent, false);
}
#[cfg(feature = "hydration")]
Some(Fallback::Fragment(fragment)) => {
fragment.detach(root, parent, false);
}
None => {
unreachable!("None condition has been checked before.")
}
};
children_bundle.shift(parent, next_sibling.clone());
children.reconcile_node(root, parent_scope, parent, next_sibling, children_bundle)
@ -182,3 +233,62 @@ impl Reconcilable for VSuspense {
}
}
}
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{Fragment, Hydratable};
use crate::virtual_dom::Collectable;
impl Hydratable for VSuspense {
fn hydrate(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
) -> (NodeRef, Self::Bundle) {
let detached_parent = document()
.create_element("div")
.expect("failed to create detached element");
let collectable = Collectable::Suspense;
let fallback_fragment = Fragment::collect_between(fragment, &collectable, parent);
let mut nodes = fallback_fragment.deep_clone();
for node in nodes.iter() {
detached_parent.append_child(node).unwrap();
}
let (_, children_bundle) =
self.children
.hydrate(root, parent_scope, &detached_parent, &mut nodes);
// We trim all leading text nodes before checking as it's likely these are whitespaces.
nodes.trim_start_text_nodes(&detached_parent);
assert!(nodes.is_empty(), "expected end of suspense, found node.");
let node_ref = fallback_fragment
.front()
.cloned()
.map(NodeRef::new)
.unwrap_or_default();
(
node_ref,
BSuspense {
children_bundle,
detached_parent,
key: self.key,
// We start hydration with the BSuspense being suspended.
// A subsequent render will resume the BSuspense if not needed to be suspended.
fallback: Some(Fallback::Fragment(fallback_fragment)),
},
)
}
}
}

View File

@ -287,6 +287,98 @@ impl BTag {
}
}
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{node_type_str, Fragment, Hydratable};
use web_sys::Node;
impl Hydratable for VTag {
fn hydrate(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
) -> (NodeRef, Self::Bundle) {
let tag_name = self.tag().to_owned();
let Self {
inner,
listeners,
attributes,
node_ref,
key,
} = self;
// We trim all text nodes as it's likely these are whitespaces.
fragment.trim_start_text_nodes(parent);
let node = fragment
.pop_front()
.unwrap_or_else(|| panic!("expected element of type {}, found EOF.", tag_name));
assert_eq!(
node.node_type(),
Node::ELEMENT_NODE,
"expected element, found node type {}.",
node_type_str(&node),
);
let el = node.dyn_into::<Element>().expect("expected an element.");
assert_eq!(
el.tag_name().to_lowercase(),
tag_name,
"expected element of kind {}, found {}.",
tag_name,
el.tag_name().to_lowercase(),
);
// We simply registers listeners and updates all attributes.
let attributes = attributes.apply(root, &el);
let listeners = listeners.apply(root, &el);
// For input and textarea elements, we update their value anyways.
let inner = match inner {
VTagInner::Input(f) => {
let f = f.apply(root, el.unchecked_ref());
BTagInner::Input(f)
}
VTagInner::Textarea { value } => {
let value = value.apply(root, el.unchecked_ref());
BTagInner::Textarea { value }
}
VTagInner::Other { children, tag } => {
let mut nodes = Fragment::collect_children(&el);
let (_, child_bundle) = children.hydrate(root, parent_scope, &el, &mut nodes);
nodes.trim_start_text_nodes(parent);
assert!(nodes.is_empty(), "expected EOF, found node.");
BTagInner::Other { child_bundle, tag }
}
};
node_ref.set(Some((*el).clone()));
(
node_ref.clone(),
BTag {
inner,
listeners,
attributes,
reference: el,
node_ref,
key,
},
)
}
}
}
#[cfg(feature = "wasm_test")]
#[cfg(test)]
mod tests {

View File

@ -89,6 +89,66 @@ impl std::fmt::Debug for BText {
}
}
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use web_sys::Node;
use crate::dom_bundle::{Fragment, Hydratable};
use wasm_bindgen::JsCast;
impl Hydratable for VText {
fn hydrate(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
) -> (NodeRef, Self::Bundle) {
if let Some(m) = fragment.front().cloned() {
// better safe than sorry.
if m.node_type() == Node::TEXT_NODE {
if let Ok(m) = m.dyn_into::<TextNode>() {
// pop current node.
fragment.pop_front();
// TODO: It may make sense to assert the text content in the text node against
// the VText when #[cfg(debug_assertions)] is true, but this may be complicated.
// We always replace the text value for now.
//
// Please see the next comment for a detailed explanation.
m.set_node_value(Some(self.text.as_ref()));
return (
NodeRef::new(m.clone().into()),
BText {
text: self.text,
text_node: m,
},
);
}
}
}
// If there are multiple text nodes placed back-to-back in SSR, it may be parsed as a single
// text node by browser, hence we need to add extra text nodes here if the next node is not a text node.
// Similarly, the value of the text node may be a combination of multiple VText vnodes.
// So we always need to override their values.
self.attach(
root,
parent_scope,
parent,
fragment
.front()
.cloned()
.map(NodeRef::new)
.unwrap_or_default(),
)
}
}
}
#[cfg(test)]
mod test {
extern crate self as yew;

View File

@ -0,0 +1,166 @@
use std::collections::VecDeque;
use std::ops::{Deref, DerefMut};
use web_sys::{Element, Node};
use super::BSubtree;
use crate::html::NodeRef;
use crate::virtual_dom::Collectable;
/// A Hydration Fragment
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub(crate) struct Fragment(VecDeque<Node>);
impl Deref for Fragment {
type Target = VecDeque<Node>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Fragment {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Fragment {
/// Collects child nodes of an element into a VecDeque.
pub fn collect_children(parent: &Element) -> Self {
let mut fragment = VecDeque::with_capacity(parent.child_nodes().length() as usize);
let mut current_node = parent.first_child();
// This is easier than iterating child nodes at the moment
// as we don't have to downcast iterator values.
while let Some(m) = current_node {
current_node = m.next_sibling();
fragment.push_back(m);
}
Self(fragment)
}
/// Collects nodes for a Component Bundle or a BSuspense.
pub fn collect_between(
collect_from: &mut Fragment,
collect_for: &Collectable,
parent: &Element,
) -> Self {
let is_open_tag = |node: &Node| {
let comment_text = node.text_content().unwrap_or_else(|| "".to_string());
comment_text.starts_with(collect_for.open_start_mark())
&& comment_text.ends_with(collect_for.end_mark())
};
let is_close_tag = |node: &Node| {
let comment_text = node.text_content().unwrap_or_else(|| "".to_string());
comment_text.starts_with(collect_for.close_start_mark())
&& comment_text.ends_with(collect_for.end_mark())
};
// We trim all leading text nodes as it's likely these are whitespaces.
collect_from.trim_start_text_nodes(parent);
let first_node = collect_from
.pop_front()
.unwrap_or_else(|| panic!("expected {} opening tag, found EOF", collect_for.name()));
assert_eq!(
first_node.node_type(),
Node::COMMENT_NODE,
// TODO: improve error message with human readable node type name.
"expected {} start, found node type {}",
collect_for.name(),
first_node.node_type()
);
let mut nodes = VecDeque::new();
if !is_open_tag(&first_node) {
panic!(
"expected {} opening tag, found comment node",
collect_for.name()
);
}
// We remove the opening tag.
parent.remove_child(&first_node).unwrap();
let mut nested_layers = 1;
loop {
let current_node = collect_from.pop_front().unwrap_or_else(|| {
panic!("expected {} closing tag, found EOF", collect_for.name())
});
if current_node.node_type() == Node::COMMENT_NODE {
if is_open_tag(&current_node) {
// We found another opening tag, we need to increase component counter.
nested_layers += 1;
} else if is_close_tag(&current_node) {
// We found a closing tag, minus component counter.
nested_layers -= 1;
if nested_layers == 0 {
// We have found the end of the current tag we are collecting, breaking
// the loop.
// We remove the closing tag.
parent.remove_child(&current_node).unwrap();
break;
}
}
}
nodes.push_back(current_node.clone());
}
Self(nodes)
}
/// Remove child nodes until first non-text node.
pub fn trim_start_text_nodes(&mut self, parent: &Element) {
while let Some(ref m) = self.front().cloned() {
if m.node_type() == Node::TEXT_NODE {
self.pop_front();
parent.remove_child(m).unwrap();
} else {
break;
}
}
}
/// Deeply clones all nodes.
pub fn deep_clone(&self) -> Self {
let nodes = self
.iter()
.map(|m| m.clone_node_with_deep(true).expect("failed to clone node."))
.collect::<VecDeque<_>>();
Self(nodes)
}
// detaches current fragment.
pub fn detach(self, _root: &BSubtree, parent: &Element, parent_to_detach: bool) {
if !parent_to_detach {
for node in self.iter() {
parent
.remove_child(node)
.expect("failed to remove child element");
}
}
}
/// Shift current Fragment into a different position in the dom.
pub fn shift(&self, next_parent: &Element, next_sibling: NodeRef) {
for node in self.iter() {
next_parent
.insert_before(node, next_sibling.get().as_ref())
.unwrap();
}
}
}

View File

@ -5,6 +5,12 @@
//! In order to efficiently implement updates, and diffing, additional information has to be
//! kept around. This information is carried in the bundle.
use web_sys::Element;
use crate::html::AnyScope;
use crate::html::NodeRef;
use crate::virtual_dom::VNode;
mod bcomp;
mod blist;
mod bnode;
@ -14,31 +20,35 @@ mod btag;
mod btext;
mod subtree_root;
#[cfg(feature = "hydration")]
mod fragment;
mod traits;
mod utils;
use web_sys::Element;
use crate::html::AnyScope;
use crate::html::NodeRef;
use crate::virtual_dom::VNode;
use bcomp::BComp;
use blist::BList;
use bnode::BNode;
use bportal::BPortal;
use bsuspense::BSuspense;
use btag::{BTag, Registry};
use btext::BText;
use subtree_root::EventDescriptor;
use traits::{Reconcilable, ReconcileTarget};
use utils::{insert_node, test_log};
#[doc(hidden)] // Publically exported from crate::events
pub use subtree_root::set_event_bubbling;
pub(crate) use subtree_root::BSubtree;
#[cfg(feature = "hydration")]
pub(crate) use fragment::Fragment;
#[cfg(feature = "hydration")]
use traits::Hydratable;
#[cfg(feature = "hydration")]
use utils::node_type_str;
/// A Bundle.
///
/// Each component holds a bundle that represents a realised layout, designated by a [VNode].
@ -50,6 +60,7 @@ pub(crate) struct Bundle(BNode);
impl Bundle {
/// Creates a new bundle.
pub const fn new() -> Self {
Self(BNode::List(BList::new()))
}
@ -76,3 +87,22 @@ impl Bundle {
self.0.detach(root, parent, parent_to_detach);
}
}
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
impl Bundle {
/// Creates a bundle by hydrating a virtual dom layout.
pub fn hydrate(
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
node: VNode,
) -> (NodeRef, Self) {
let (node_ref, bundle) = node.hydrate(root, parent_scope, parent, fragment);
(node_ref, Self(bundle))
}
}
}

View File

@ -34,6 +34,7 @@ pub(super) trait Reconcilable {
/// Returns a reference to the newly inserted element.
fn attach(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
@ -59,6 +60,7 @@ pub(super) trait Reconcilable {
/// Returns a reference to the newly inserted element.
fn reconcile_node(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
@ -78,6 +80,7 @@ pub(super) trait Reconcilable {
/// Replace an existing bundle by attaching self and detaching the existing one
fn replace(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
@ -94,3 +97,30 @@ pub(super) trait Reconcilable {
self_ref
}
}
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::Fragment;
pub(in crate::dom_bundle) trait Hydratable: Reconcilable {
/// hydrates current tree.
///
/// Returns a reference to the first node of the hydrated tree.
///
/// # Important
///
/// DOM tree is hydrated from top to bottom. This is different than [`Reconcilable`].
fn hydrate(
self,
root: &BSubtree,
parent_scope: &AnyScope,
parent: &Element,
fragment: &mut Fragment,
) -> (NodeRef, Self::Bundle);
}
}
#[cfg(feature = "hydration")]
pub(in crate::dom_bundle) use feat_hydration::*;

View File

@ -26,3 +26,41 @@ macro_rules! test_log {
/// Log an operation during tests for debugging purposes
/// Set RUSTFLAGS="--cfg verbose_tests" environment variable to activate.
pub(super) use test_log;
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use std::borrow::Cow;
use wasm_bindgen::JsCast;
use web_sys::Element;
pub(in crate::dom_bundle) fn node_type_str(node: &Node) -> Cow<'static, str> {
match node.node_type() {
Node::ELEMENT_NODE => {
let tag = node
.dyn_ref::<Element>()
.map(|m| m.tag_name().to_lowercase())
.unwrap_or_else(|| "unknown".to_owned());
format!("{} element node", tag).into()
}
Node::ATTRIBUTE_NODE => "attribute node".into(),
Node::TEXT_NODE => "text node".into(),
Node::CDATA_SECTION_NODE => "cdata section node".into(),
Node::ENTITY_REFERENCE_NODE => "entity reference node".into(),
Node::ENTITY_NODE => "entity node".into(),
Node::PROCESSING_INSTRUCTION_NODE => "processing instruction node".into(),
Node::COMMENT_NODE => "comment node".into(),
Node::DOCUMENT_NODE => "document node".into(),
Node::DOCUMENT_TYPE_NODE => "document type node".into(),
Node::DOCUMENT_FRAGMENT_NODE => "document fragment node".into(),
Node::NOTATION_NODE => "notation node".into(),
_ => "unknown node".into(),
}
}
}
#[cfg(feature = "hydration")]
pub(super) use feat_hydration::*;

View File

@ -26,12 +26,13 @@ use std::cell::RefCell;
use std::fmt;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
mod hooks;
pub use hooks::*;
use crate::html::Context;
use crate::html::sealed::SealedBaseComponent;
use crate::html::Context;
/// This attribute creates a function component from a normal Rust function.
///
@ -85,6 +86,19 @@ pub struct HookContext {
}
impl HookContext {
fn new(scope: AnyScope, re_render: ReRender) -> RefCell<Self> {
RefCell::new(HookContext {
effects: Vec::new(),
scope,
re_render,
states: Vec::new(),
counter: 0,
#[cfg(debug_assertions)]
total_hook_counter: None,
})
}
pub(crate) fn next_state<T>(&mut self, initializer: impl FnOnce(ReRender) -> T) -> Rc<T>
where
T: 'static,
@ -103,7 +117,7 @@ impl HookContext {
}
};
state.downcast().unwrap()
state.downcast().unwrap_throw()
}
pub(crate) fn next_effect<T>(&mut self, initializer: impl FnOnce(ReRender) -> T) -> Rc<T>
@ -120,6 +134,59 @@ impl HookContext {
t
}
#[inline(always)]
fn prepare_run(&mut self) {
self.counter = 0;
}
/// asserts hook counter.
///
/// This function asserts that the number of hooks matches for every render.
#[cfg(debug_assertions)]
fn assert_hook_context(&mut self, render_ok: bool) {
// Procedural Macros can catch most conditionally called hooks at compile time, but it cannot
// detect early return (as the return can be Err(_), Suspension).
match (render_ok, self.total_hook_counter) {
// First rendered,
// we store the hook counter.
(true, None) => {
self.total_hook_counter = Some(self.counter);
}
// Component is suspended before it's first rendered.
// We don't have a total count to compare with.
(false, None) => {}
// Subsequent render,
// we compare stored total count and current render count.
(true, Some(total_hook_counter)) => assert_eq!(
total_hook_counter, self.counter,
"Hooks are called conditionally."
),
// Subsequent suspension,
// components can have less hooks called when suspended, but not more.
(false, Some(total_hook_counter)) => assert!(
self.counter <= total_hook_counter,
"Hooks are called conditionally."
),
}
}
fn run_effects(&self) {
for effect in self.effects.iter() {
effect.rendered();
}
}
fn drain_states(&mut self) {
// We clear the effects as these are also references to states.
self.effects.clear();
for state in self.states.drain(..) {
drop(state);
}
}
}
impl fmt::Debug for HookContext {
@ -167,21 +234,14 @@ where
fn create(ctx: &Context<Self>) -> Self {
let scope = AnyScope::from(ctx.link().clone());
let re_render = {
let link = ctx.link().clone();
Rc::new(move || link.send_message(()))
};
Self {
_never: std::marker::PhantomData::default(),
hook_ctx: RefCell::new(HookContext {
effects: Vec::new(),
scope,
re_render: {
let link = ctx.link().clone();
Rc::new(move || link.send_message(()))
},
states: Vec::new(),
counter: 0,
#[cfg(debug_assertions)]
total_hook_counter: None,
}),
hook_ctx: HookContext::new(scope, re_render),
}
}
@ -195,56 +255,27 @@ where
fn view(&self, ctx: &Context<Self>) -> HtmlResult {
let props = ctx.props();
let mut ctx = self.hook_ctx.borrow_mut();
ctx.counter = 0;
let mut hook_ctx = self.hook_ctx.borrow_mut();
hook_ctx.prepare_run();
#[allow(clippy::let_and_return)]
let result = T::run(&mut *ctx, props);
let result = T::run(&mut *hook_ctx, props);
#[cfg(debug_assertions)]
{
// Procedural Macros can catch most conditionally called hooks at compile time, but it cannot
// detect early return (as the return can be Err(_), Suspension).
if result.is_err() {
if let Some(m) = ctx.total_hook_counter {
// Suspended Components can have less hooks called when suspended, but not more.
if m < ctx.counter {
panic!("Hooks are called conditionally.");
}
}
} else {
match ctx.total_hook_counter {
Some(m) => {
if m != ctx.counter {
panic!("Hooks are called conditionally.");
}
}
None => {
ctx.total_hook_counter = Some(ctx.counter);
}
}
}
}
hook_ctx.assert_hook_context(result.is_ok());
result
}
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
let hook_ctx = self.hook_ctx.borrow();
for effect in hook_ctx.effects.iter() {
effect.rendered();
}
hook_ctx.run_effects();
}
fn destroy(&mut self, _ctx: &Context<Self>) {
let mut hook_ctx = self.hook_ctx.borrow_mut();
// We clear the effects as these are also references to states.
hook_ctx.effects.clear();
for state in hook_ctx.states.drain(..) {
drop(state);
}
hook_ctx.drain_states();
}
}

View File

@ -2,6 +2,7 @@
use crate::html::Html;
use crate::virtual_dom::{VChild, VNode};
use crate::Properties;
use std::fmt;
/// A type used for accepting children elements in Component::Properties.
@ -208,3 +209,11 @@ impl<T> IntoIterator for ChildrenRenderer<T> {
self.children.into_iter()
}
}
/// A [Properties] type with Children being the only property.
#[derive(Debug, Properties, PartialEq)]
pub struct ChildrenProps {
/// The Children of a Component.
#[prop_or_default]
pub children: Children,
}

View File

@ -1,7 +1,10 @@
//! Component lifecycle module
use super::scope::{AnyScope, Scope};
use super::BaseComponent;
#[cfg(feature = "hydration")]
use crate::html::RenderMode;
use crate::html::{Html, RenderError};
use crate::scheduler::{self, Runnable, Shared};
use crate::suspense::{BaseSuspense, Suspension};
@ -9,6 +12,8 @@ use crate::{Callback, Context, HtmlResult};
use std::any::Any;
use std::rc::Rc;
#[cfg(feature = "hydration")]
use crate::dom_bundle::Fragment;
#[cfg(feature = "csr")]
use crate::dom_bundle::{BSubtree, Bundle};
#[cfg(feature = "csr")]
@ -25,6 +30,14 @@ pub(crate) enum ComponentRenderState {
next_sibling: NodeRef,
node_ref: NodeRef,
},
#[cfg(feature = "hydration")]
Hydration {
parent: Element,
next_sibling: NodeRef,
node_ref: NodeRef,
root: BSubtree,
fragment: Fragment,
},
#[cfg(feature = "ssr")]
Ssr {
@ -38,7 +51,7 @@ impl std::fmt::Debug for ComponentRenderState {
#[cfg(feature = "csr")]
Self::Render {
ref bundle,
ref root,
root,
ref parent,
ref next_sibling,
ref node_ref,
@ -51,6 +64,22 @@ impl std::fmt::Debug for ComponentRenderState {
.field("node_ref", node_ref)
.finish(),
#[cfg(feature = "hydration")]
Self::Hydration {
ref fragment,
ref parent,
ref next_sibling,
ref node_ref,
ref root,
} => f
.debug_struct("ComponentRenderState::Hydration")
.field("fragment", fragment)
.field("root", root)
.field("parent", parent)
.field("next_sibling", next_sibling)
.field("node_ref", node_ref)
.finish(),
#[cfg(feature = "ssr")]
Self::Ssr { ref sender } => {
let sender_repr = match sender {
@ -82,6 +111,18 @@ impl ComponentRenderState {
*parent = next_parent;
*next_sibling = next_next_sibling;
}
#[cfg(feature = "hydration")]
Self::Hydration {
fragment,
parent,
next_sibling,
..
} => {
fragment.shift(&next_parent, next_next_sibling.clone());
*parent = next_parent;
*next_sibling = next_next_sibling;
}
#[cfg(feature = "ssr")]
Self::Ssr { .. } => {
@ -192,7 +233,22 @@ impl ComponentState {
props: Rc<COMP::Properties>,
) -> Self {
let comp_id = scope.id;
let context = Context { scope, props };
#[cfg(feature = "hydration")]
let mode = {
match initial_render_state {
ComponentRenderState::Render { .. } => RenderMode::Render,
ComponentRenderState::Hydration { .. } => RenderMode::Hydration,
#[cfg(feature = "ssr")]
ComponentRenderState::Ssr { .. } => RenderMode::Ssr,
}
};
let context = Context {
scope,
props,
#[cfg(feature = "hydration")]
mode,
};
let inner = Box::new(CompStateInner {
component: COMP::create(&context),
@ -280,6 +336,20 @@ impl Runnable for UpdateRunner {
state.inner.props_changed(props)
}
#[cfg(feature = "hydration")]
ComponentRenderState::Hydration {
ref mut node_ref,
next_sibling: ref mut current_next_sibling,
..
} => {
// When components are updated, a new node ref could have been passed in
*node_ref = next_node_ref;
// When components are updated, their siblings were likely also updated
*current_next_sibling = next_sibling;
// Only trigger changed if props were changed
state.inner.props_changed(props)
}
#[cfg(feature = "ssr")]
ComponentRenderState::Ssr { .. } => {
#[cfg(debug_assertions)]
@ -331,14 +401,27 @@ impl Runnable for DestroyRunner {
ComponentRenderState::Render {
bundle,
ref parent,
ref root,
ref node_ref,
ref root,
..
} => {
bundle.detach(root, parent, self.parent_to_detach);
node_ref.set(None);
}
// We need to detach the hydrate fragment if the component is not hydrated.
#[cfg(feature = "hydration")]
ComponentRenderState::Hydration {
ref root,
fragment,
ref parent,
ref node_ref,
..
} => {
fragment.detach(root, parent, self.parent_to_detach);
node_ref.set(None);
}
#[cfg(feature = "ssr")]
ComponentRenderState::Ssr { .. } => {}
@ -436,6 +519,7 @@ impl RenderRunner {
..
} => {
let scope = state.inner.any_scope();
let new_node_ref =
bundle.reconcile(root, &scope, parent, next_sibling.clone(), new_root);
node_ref.link(new_node_ref);
@ -453,6 +537,46 @@ impl RenderRunner {
);
}
#[cfg(feature = "hydration")]
ComponentRenderState::Hydration {
ref mut fragment,
ref parent,
ref node_ref,
ref next_sibling,
ref root,
} => {
// We schedule a "first" render to run immediately after hydration,
// to fix NodeRefs (first_node and next_sibling).
scheduler::push_component_first_render(
state.comp_id,
Box::new(RenderRunner {
state: self.state.clone(),
}),
);
let scope = state.inner.any_scope();
// This first node is not guaranteed to be correct here.
// As it may be a comment node that is removed afterwards.
// but we link it anyways.
let (node, bundle) = Bundle::hydrate(root, &scope, parent, fragment, new_root);
// We trim all text nodes before checking as it's likely these are whitespaces.
fragment.trim_start_text_nodes(parent);
assert!(fragment.is_empty(), "expected end of component, found node");
node_ref.link(node);
state.render_state = ComponentRenderState::Render {
root: root.clone(),
bundle,
parent: parent.clone(),
node_ref: node_ref.clone(),
next_sibling: next_sibling.clone(),
};
}
#[cfg(feature = "ssr")]
ComponentRenderState::Ssr { ref mut sender } => {
if let Some(tx) = sender.take() {

View File

@ -0,0 +1,147 @@
//! Primitive Components & Properties Types
use crate::function_component;
use crate::html;
use crate::html::{ChildrenProps, Html, IntoComponent};
/// A Component to represent a component that does not exist in current implementation.
///
/// During Hydration, Yew expected the Virtual DOM hierarchy to match the the layout used in server-side
/// rendering. However, sometimes it is possible / reasonable to omit certain components from one
/// side of the implementation. This component is used to represent a component as if a component "existed"
/// in the place it is defined.
///
/// # Warning
///
/// The Real DOM hierarchy must also match the server-side rendered artifact. This component is
/// only usable when the original component does not introduce any additional elements. (e.g.: Context
/// Providers)
///
/// A generic parameter is provided to help identify the component to be substituted.
/// The type of the generic parameter is not required to be the same component that was in the other
/// implementation. However, this behaviour may change in the future if more debug assertions were
/// to be introduced. It is recommended that the generic parameter represents the component in the
/// other implementation.
///
/// # Example
///
/// ```
/// use yew::prelude::*;
/// # use yew::html::ChildrenProps;
/// #
/// # #[function_component]
/// # fn Comp(props: &ChildrenProps) -> Html {
/// # Html::default()
/// # }
/// #
/// # #[function_component]
/// # fn Provider(props: &ChildrenProps) -> Html {
/// # let children = props.children.clone();
/// #
/// # html! { <>{children}</> }
/// # }
/// # type Provider1 = Provider;
/// # type Provider2 = Provider;
/// # type Provider3 = Provider;
/// # type Provider4 = Provider;
///
/// #[function_component]
/// fn ServerApp() -> Html {
/// // The Server Side Rendering Application has 3 Providers.
/// html! {
/// <Provider1>
/// <Provider2>
/// <Provider3>
/// <Comp />
/// </Provider3>
/// </Provider2>
/// </Provider1>
/// }
/// }
///
/// #[function_component]
/// fn App() -> Html {
/// // The Client Side Rendering Application has 4 Providers.
/// html! {
/// <Provider1>
/// <Provider2>
/// <Provider3>
///
/// // This provider does not exist on the server-side
/// // Hydration will fail due to Virtual DOM layout mismatch.
/// <Provider4>
/// <Comp />
/// </Provider4>
///
/// </Provider3>
/// </Provider2>
/// </Provider1>
/// }
/// }
/// ```
///
/// To mitigate this, we can use a `PhantomComponent`:
///
/// ```
/// use yew::prelude::*;
/// # use yew::html::{PhantomComponent, ChildrenProps};
/// #
/// # #[function_component]
/// # fn Comp(props: &ChildrenProps) -> Html {
/// # Html::default()
/// # }
/// #
/// # #[function_component]
/// # fn Provider(props: &ChildrenProps) -> Html {
/// # let children = props.children.clone();
/// #
/// # html! { <>{children}</> }
/// # }
/// # type Provider1 = Provider;
/// # type Provider2 = Provider;
/// # type Provider3 = Provider;
/// # type Provider4 = Provider;
///
/// #[function_component]
/// fn ServerApp() -> Html {
/// html! {
/// <Provider1>
/// <Provider2>
/// <Provider3>
/// // We add a PhantomComponent for Provider4,
/// // it acts if a Provider4 component presents in this position.
/// <PhantomComponent<Provider4>>
/// <Comp />
/// </PhantomComponent<Provider4>>
/// </Provider3>
/// </Provider2>
/// </Provider1>
/// }
/// }
///
/// #[function_component]
/// fn App() -> Html {
/// html! {
/// <Provider1>
/// <Provider2>
/// <Provider3>
///
/// // Hydration will succeed as the PhantomComponent in the server-side
/// // implementation will represent a Provider4 component in this position.
/// <Provider4>
/// <Comp />
/// </Provider4>
///
/// </Provider3>
/// </Provider2>
/// </Provider1>
/// }
/// }
/// ```
#[function_component]
pub fn PhantomComponent<T>(props: &ChildrenProps) -> Html
where
T: IntoComponent,
{
html! { <>{props.children.clone()}</> }
}

View File

@ -3,12 +3,15 @@
mod children;
#[cfg(any(feature = "csr", feature = "ssr"))]
mod lifecycle;
mod marker;
mod properties;
mod scope;
use super::{Html, HtmlResult, IntoHtmlResult};
pub use children::*;
pub use marker::*;
pub use properties::*;
#[cfg(feature = "csr")]
pub(crate) use scope::Scoped;
pub use scope::{AnyScope, Scope, SendAsMessage};
@ -44,6 +47,15 @@ mod feat_csr_ssr {
}
}
#[cfg(feature = "hydration")]
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum RenderMode {
Hydration,
Render,
#[cfg(feature = "ssr")]
Ssr,
}
#[cfg(debug_assertions)]
#[cfg(any(feature = "csr", feature = "ssr"))]
pub(crate) use feat_csr_ssr::*;
@ -54,6 +66,8 @@ pub(crate) use feat_csr_ssr::*;
pub struct Context<COMP: BaseComponent> {
scope: Scope<COMP>,
props: Rc<COMP::Properties>,
#[cfg(feature = "hydration")]
mode: RenderMode,
}
impl<COMP: BaseComponent> Context<COMP> {
@ -68,6 +82,11 @@ impl<COMP: BaseComponent> Context<COMP> {
pub fn props(&self) -> &COMP::Properties {
&*self.props
}
#[cfg(feature = "hydration")]
pub(crate) fn mode(&self) -> RenderMode {
self.mode
}
}
pub(crate) mod sealed {

View File

@ -43,16 +43,6 @@ impl<COMP: BaseComponent> From<Scope<COMP>> for AnyScope {
}
impl AnyScope {
#[cfg(feature = "csr")]
#[cfg(test)]
pub(crate) fn test() -> Self {
Self {
type_id: TypeId::of::<()>(),
parent: None,
typed_scope: Rc::new(()),
}
}
/// Returns the parent scope
pub fn get_parent(&self) -> Option<&AnyScope> {
self.parent.as_deref()
@ -417,6 +407,17 @@ mod feat_csr {
use std::cell::Ref;
use web_sys::Element;
impl AnyScope {
#[cfg(test)]
pub(crate) fn test() -> Self {
Self {
type_id: TypeId::of::<()>(),
parent: None,
typed_scope: Rc::new(()),
}
}
}
impl<COMP> Scope<COMP>
where
COMP: BaseComponent,
@ -424,6 +425,7 @@ mod feat_csr {
/// Mounts a component with `props` to the specified `element` in the DOM.
pub(crate) fn mount_in_place(
&self,
root: BSubtree,
parent: Element,
next_sibling: NodeRef,
@ -518,6 +520,83 @@ mod feat_csr {
}
}
#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
use crate::dom_bundle::{BSubtree, Fragment};
use crate::html::component::lifecycle::{ComponentRenderState, CreateRunner, RenderRunner};
use crate::html::NodeRef;
use crate::scheduler;
use crate::virtual_dom::Collectable;
use web_sys::Element;
impl<COMP> Scope<COMP>
where
COMP: BaseComponent,
{
/// Hydrates the component.
///
/// Returns a pending NodeRef of the next sibling.
///
/// # Note
///
/// This method is expected to collect all the elements belongs to the current component
/// immediately.
pub(crate) fn hydrate_in_place(
&self,
root: BSubtree,
parent: Element,
fragment: &mut Fragment,
node_ref: NodeRef,
props: Rc<COMP::Properties>,
) {
// This is very helpful to see which component is failing during hydration
// which means this component may not having a stable layout / differs between
// client-side and server-side.
#[cfg(all(debug_assertions, feature = "trace_hydration"))]
gloo::console::trace!(format!(
"queuing hydration of: {}(ID: {:?})",
std::any::type_name::<COMP>(),
self.id
));
#[cfg(debug_assertions)]
let collectable = Collectable::Component(std::any::type_name::<COMP>());
#[cfg(not(debug_assertions))]
let collectable = Collectable::Component;
let fragment = Fragment::collect_between(fragment, &collectable, &parent);
node_ref.set(fragment.front().cloned());
let next_sibling = NodeRef::default();
let state = ComponentRenderState::Hydration {
root,
parent,
node_ref,
next_sibling,
fragment,
};
scheduler::push_component_create(
self.id,
Box::new(CreateRunner {
initial_render_state: state,
props,
scope: self.clone(),
}),
Box::new(RenderRunner {
state: self.state.clone(),
}),
);
// Not guaranteed to already have the scheduler started
scheduler::start();
}
}
}
#[cfg(feature = "csr")]
pub(crate) use feat_csr::*;

View File

@ -24,6 +24,9 @@
//! - `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.)
//! - `hydration`: Enables Hydration support.
//! - `trace_hydration`: Enables trace logging on hydration. (Implies `hydration`. You may want to enable this if you are
//! trying to debug hydration layout mismatch.)
//!
//! ## Example
//!
@ -280,6 +283,7 @@ pub mod utils;
pub mod virtual_dom;
#[cfg(feature = "ssr")]
pub use server_renderer::*;
#[cfg(feature = "csr")]
mod app_handle;
#[cfg(feature = "csr")]
@ -317,6 +321,7 @@ pub mod prelude {
//! # #![allow(unused_imports)]
//! use yew::prelude::*;
//! ```
#[cfg(feature = "csr")]
pub use crate::app_handle::AppHandle;
pub use crate::callback::Callback;

View File

@ -92,3 +92,20 @@ where
AppHandle::<ICOMP>::mount_with_props(self.root, Rc::new(self.props))
}
}
#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
impl<ICOMP> Renderer<ICOMP>
where
ICOMP: IntoComponent + 'static,
{
/// Hydrates the application.
pub fn hydrate(self) -> AppHandle<ICOMP> {
set_default_panic_hook();
AppHandle::<ICOMP>::hydrate_with_props(self.root, Rc::new(self.props))
}
}
}

View File

@ -80,7 +80,7 @@ mod feat_csr_ssr {
with(|s| s.destroy.push(runnable));
}
/// Push a component render and rendered [Runnable]s to be executed
/// Push a component render [Runnable]s to be executed
pub(crate) fn push_component_render(component_id: usize, render: Box<dyn Runnable>) {
with(|s| {
s.render.insert(component_id, render);
@ -115,6 +115,20 @@ mod feat_csr {
}
}
#[cfg(feature = "hydration")]
mod feat_hydration {
use super::*;
pub(crate) fn push_component_first_render(component_id: usize, render: Box<dyn Runnable>) {
with(|s| {
s.render_first.insert(component_id, render);
});
}
}
#[cfg(feature = "hydration")]
pub(crate) use feat_hydration::*;
#[cfg(feature = "csr")]
pub(crate) use feat_csr::*;

View File

@ -13,15 +13,20 @@ pub struct SuspenseProps {
mod feat_csr_ssr {
use super::*;
#[cfg(feature = "hydration")]
use crate::callback::Callback;
#[cfg(feature = "hydration")]
use crate::html::RenderMode;
use crate::html::{Children, Component, Context, Html, Scope};
use crate::suspense::Suspension;
#[cfg(feature = "hydration")]
use crate::suspense::SuspensionHandle;
use crate::virtual_dom::{VNode, VSuspense};
use crate::{function_component, html};
#[derive(Properties, PartialEq, Debug, Clone)]
pub(crate) struct BaseSuspenseProps {
pub children: Children,
pub fallback: Option<Html>,
}
@ -35,6 +40,8 @@ mod feat_csr_ssr {
pub(crate) struct BaseSuspense {
link: Scope<Self>,
suspensions: Vec<Suspension>,
#[cfg(feature = "hydration")]
hydration_handle: Option<SuspensionHandle>,
}
impl Component for BaseSuspense {
@ -42,9 +49,30 @@ mod feat_csr_ssr {
type Message = BaseSuspenseMsg;
fn create(ctx: &Context<Self>) -> Self {
#[cfg(not(feature = "hydration"))]
let suspensions = Vec::new();
// We create a suspension to block suspense until its rendered method is notified.
#[cfg(feature = "hydration")]
let (suspensions, hydration_handle) = {
match ctx.mode() {
RenderMode::Hydration => {
let link = ctx.link().clone();
let (s, handle) = Suspension::new();
s.listen(Callback::from(move |s| {
link.send_message(BaseSuspenseMsg::Resume(s));
}));
(vec![s], Some(handle))
}
_ => (Vec::new(), None),
}
};
Self {
link: ctx.link().clone(),
suspensions: Vec::new(),
suspensions,
#[cfg(feature = "hydration")]
hydration_handle,
}
}
@ -94,6 +122,15 @@ mod feat_csr_ssr {
None => children,
}
}
#[cfg(feature = "hydration")]
fn rendered(&mut self, _ctx: &Context<Self>, first_render: bool) {
if first_render {
if let Some(m) = self.hydration_handle.take() {
m.resume();
}
}
}
}
impl BaseSuspense {

View File

@ -179,7 +179,7 @@ mod tests_attr_value {
}
}
#[cfg(feature = "ssr")] // & feature = "hydration"
#[cfg(any(feature = "ssr", feature = "hydration"))]
mod feat_ssr_hydration {
/// A collectable.
///
@ -251,10 +251,21 @@ mod feat_ssr_hydration {
w.push_str(self.end_mark());
w.push_str("-->");
}
#[cfg(feature = "hydration")]
pub fn name(&self) -> super::Cow<'static, str> {
match self {
#[cfg(debug_assertions)]
Self::Component(m) => format!("Component({})", m).into(),
#[cfg(not(debug_assertions))]
Self::Component => "Component".into(),
Self::Suspense => "Suspense".into(),
}
}
}
}
#[cfg(feature = "ssr")]
#[cfg(any(feature = "ssr", feature = "hydration"))]
pub(crate) use feat_ssr_hydration::*;
/// A collection of attributes for an element

View File

@ -11,6 +11,8 @@ use crate::html::{AnyScope, Scope};
#[cfg(feature = "csr")]
use crate::dom_bundle::BSubtree;
#[cfg(feature = "hydration")]
use crate::dom_bundle::Fragment;
#[cfg(feature = "csr")]
use crate::html::Scoped;
#[cfg(feature = "csr")]
@ -72,6 +74,16 @@ pub(crate) trait Mountable {
parent_scope: &'a AnyScope,
hydratable: bool,
) -> LocalBoxFuture<'a, ()>;
#[cfg(feature = "hydration")]
fn hydrate(
self: Box<Self>,
root: BSubtree,
parent_scope: &AnyScope,
parent: Element,
fragment: &mut Fragment,
node_ref: NodeRef,
) -> Box<dyn Scoped>;
}
pub(crate) struct PropsWrapper<COMP: BaseComponent> {
@ -128,6 +140,21 @@ impl<COMP: BaseComponent> Mountable for PropsWrapper<COMP> {
}
.boxed_local()
}
#[cfg(feature = "hydration")]
fn hydrate(
self: Box<Self>,
root: BSubtree,
parent_scope: &AnyScope,
parent: Element,
fragment: &mut Fragment,
node_ref: NodeRef,
) -> Box<dyn Scoped> {
let scope: Scope<COMP> = Scope::new(Some(parent_scope.clone()));
scope.hydrate_in_place(root, parent, fragment, node_ref, self.props);
Box::new(scope)
}
}
/// A virtual child component.
@ -258,9 +285,10 @@ mod ssr_tests {
}
}
let renderer = ServerRenderer::<Comp>::new();
let s = renderer.render().await;
let s = ServerRenderer::<Comp>::new()
.hydratable(false)
.render()
.await;
assert_eq!(
s,

View File

@ -1,12 +1,12 @@
error[E0277]: the trait bound `Comp: yew::Component` is not satisfied
--> tests/failed_tests/base_component_impl-fail.rs:6:6
|
6 | impl BaseComponent for Comp {
| ^^^^^^^^^^^^^ the trait `yew::Component` is not implemented for `Comp`
|
= note: required because of the requirements on the impl of `html::component::sealed::SealedBaseComponent` for `Comp`
--> tests/failed_tests/base_component_impl-fail.rs:6:6
|
6 | impl BaseComponent for Comp {
| ^^^^^^^^^^^^^ the trait `yew::Component` is not implemented for `Comp`
|
= note: required because of the requirements on the impl of `html::component::sealed::SealedBaseComponent` for `Comp`
note: required by a bound in `BaseComponent`
--> src/html/component/mod.rs
|
| pub trait BaseComponent: sealed::SealedBaseComponent + Sized + 'static {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `BaseComponent`
--> src/html/component/mod.rs
|
| pub trait BaseComponent: sealed::SealedBaseComponent + Sized + 'static {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `BaseComponent`

View File

@ -0,0 +1,542 @@
#![cfg(feature = "hydration")]
use std::rc::Rc;
use std::time::Duration;
mod common;
use common::{obtain_result, obtain_result_by_id};
use gloo::timers::future::sleep;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use wasm_bindgen_test::*;
use web_sys::{HtmlElement, HtmlTextAreaElement};
use yew::prelude::*;
use yew::suspense::{Suspension, SuspensionResult};
use yew::{Renderer, ServerRenderer};
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
async fn hydration_works() {
#[function_component]
fn Comp() -> Html {
let ctr = use_state_eq(|| 0);
let onclick = {
let ctr = ctr.clone();
Callback::from(move |_| {
ctr.set(*ctr + 1);
})
};
html! {
<div>
{"Counter: "}{*ctr}
<button {onclick} class="increase">{"+1"}</button>
</div>
}
}
#[function_component]
fn App() -> Html {
html! {
<div>
<Comp />
</div>
}
}
let s = ServerRenderer::<App>::new().render().await;
gloo::utils::document()
.query_selector("#output")
.unwrap()
.unwrap()
.set_inner_html(&s);
sleep(Duration::ZERO).await;
Renderer::<App>::with_root(gloo_utils::document().get_element_by_id("output").unwrap())
.hydrate();
sleep(Duration::ZERO).await;
let result = obtain_result_by_id("output");
// no placeholders, hydration is successful.
assert_eq!(
result,
r#"<div><div>Counter: 0<button class="increase">+1</button></div></div>"#
);
gloo_utils::document()
.query_selector(".increase")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
sleep(Duration::ZERO).await;
let result = obtain_result_by_id("output");
assert_eq!(
result,
r#"<div><div>Counter: 1<button class="increase">+1</button></div></div>"#
);
}
#[wasm_bindgen_test]
async fn hydration_with_suspense() {
#[derive(PartialEq)]
pub struct SleepState {
s: Suspension,
}
impl SleepState {
fn new() -> Self {
let (s, handle) = Suspension::new();
spawn_local(async move {
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()
}
}
#[hook]
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())
}
}
#[function_component(Content)]
fn content() -> HtmlResult {
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() -> Html {
let fallback = html! {<div>{"wait..."}</div>};
html! {
<div id="result">
<Suspense {fallback}>
<Content />
</Suspense>
</div>
}
}
let s = ServerRenderer::<App>::new().render().await;
gloo::utils::document()
.query_selector("#output")
.unwrap()
.unwrap()
.set_inner_html(&s);
sleep(Duration::ZERO).await;
Renderer::<App>::with_root(gloo_utils::document().get_element_by_id("output").unwrap())
.hydrate();
sleep(Duration::from_millis(10)).await;
let result = obtain_result();
// still hydrating, during hydration, the server rendered result is shown.
assert_eq!(
result.as_str(),
r#"<!--<[yew::functional::FunctionComponent<hydration::hydration_with_suspense::{{closure}}::Content>]>--><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><!--</[yew::functional::FunctionComponent<hydration::hydration_with_suspense::{{closure}}::Content>]>-->"#
);
sleep(Duration::from_millis(50)).await;
let result = obtain_result();
// hydrated.
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>"#
);
gloo_utils::document()
.query_selector(".increase")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
sleep(Duration::from_millis(50)).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<div class="content-area"><div class="actual-result">1</div><button class="increase">increase</button><div class="action-area"><button class="take-a-break">Take a break!</button></div></div>"#
);
gloo_utils::document()
.query_selector(".take-a-break")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
sleep(Duration::from_millis(10)).await;
let result = obtain_result();
assert_eq!(result.as_str(), "<div>wait...</div>");
sleep(Duration::from_millis(50)).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<div class="content-area"><div class="actual-result">1</div><button class="increase">increase</button><div class="action-area"><button class="take-a-break">Take a break!</button></div></div>"#
);
}
#[wasm_bindgen_test]
async fn hydration_with_suspense_not_suspended_at_start() {
#[derive(PartialEq)]
pub struct SleepState {
s: Option<Suspension>,
}
impl SleepState {
fn new() -> Self {
Self { s: None }
}
}
impl Reducible for SleepState {
type Action = ();
fn reduce(self: Rc<Self>, _action: Self::Action) -> Rc<Self> {
let (s, handle) = Suspension::new();
spawn_local(async move {
sleep(Duration::from_millis(50)).await;
handle.resume();
});
Self { s: Some(s) }.into()
}
}
#[hook]
pub fn use_sleep() -> SuspensionResult<Rc<dyn Fn()>> {
let sleep_state = use_reducer(SleepState::new);
let s = match sleep_state.s.clone() {
Some(m) => m,
None => return Ok(Rc::new(move || sleep_state.dispatch(()))),
};
if s.resumed() {
Ok(Rc::new(move || sleep_state.dispatch(())))
} else {
Err(s)
}
}
#[function_component(Content)]
fn content() -> HtmlResult {
let resleep = use_sleep()?;
let value = use_state(|| "I am writing a long story...".to_string());
let on_text_input = {
let value = value.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlTextAreaElement = e.target_unchecked_into();
value.set(input.value());
})
};
let on_take_a_break = Callback::from(move |_| (resleep.clone())());
Ok(html! {
<div class="content-area">
<textarea value={value.to_string()} oninput={on_text_input}></textarea>
<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() -> Html {
let fallback = html! {<div>{"wait..."}</div>};
html! {
<div id="result">
<Suspense {fallback}>
<Content />
</Suspense>
</div>
}
}
let s = ServerRenderer::<App>::new().render().await;
gloo::utils::document()
.query_selector("#output")
.unwrap()
.unwrap()
.set_inner_html(&s);
sleep(Duration::ZERO).await;
Renderer::<App>::with_root(gloo_utils::document().get_element_by_id("output").unwrap())
.hydrate();
sleep(Duration::from_millis(10)).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<div class="content-area"><textarea>I am writing a long story...</textarea><div class="action-area"><button class="take-a-break">Take a break!</button></div></div>"#
);
gloo_utils::document()
.query_selector(".take-a-break")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
sleep(Duration::from_millis(10)).await;
let result = obtain_result();
assert_eq!(result.as_str(), "<div>wait...</div>");
sleep(Duration::from_millis(50)).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<div class="content-area"><textarea>I am writing a long story...</textarea><div class="action-area"><button class="take-a-break">Take a break!</button></div></div>"#
);
}
#[wasm_bindgen_test]
async fn hydration_nested_suspense_works() {
#[derive(PartialEq)]
pub struct SleepState {
s: Suspension,
}
impl SleepState {
fn new() -> Self {
let (s, handle) = Suspension::new();
spawn_local(async move {
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()
}
}
#[hook]
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())
}
}
#[function_component(InnerContent)]
fn inner_content() -> HtmlResult {
let resleep = use_sleep()?;
let on_take_a_break = Callback::from(move |_: MouseEvent| (resleep.clone())());
Ok(html! {
<div class="content-area">
<div class="action-area">
<button class="take-a-break2" onclick={on_take_a_break}>{"Take a break!"}</button>
</div>
</div>
})
}
#[function_component(Content)]
fn content() -> HtmlResult {
let resleep = use_sleep()?;
let fallback = html! {<div>{"wait...(inner)"}</div>};
let on_take_a_break = Callback::from(move |_: MouseEvent| (resleep.clone())());
Ok(html! {
<div class="content-area">
<div class="action-area">
<button class="take-a-break" onclick={on_take_a_break}>{"Take a break!"}</button>
</div>
<Suspense {fallback}>
<InnerContent />
</Suspense>
</div>
})
}
#[function_component(App)]
fn app() -> Html {
let fallback = html! {<div>{"wait...(outer)"}</div>};
html! {
<div id="result">
<Suspense {fallback}>
<Content />
</Suspense>
</div>
}
}
let s = ServerRenderer::<App>::new().render().await;
gloo::utils::document()
.query_selector("#output")
.unwrap()
.unwrap()
.set_inner_html(&s);
sleep(Duration::ZERO).await;
Renderer::<App>::with_root(gloo_utils::document().get_element_by_id("output").unwrap())
.hydrate();
// outer suspense is hydrating...
sleep(Duration::from_millis(10)).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<!--<[yew::functional::FunctionComponent<hydration::hydration_nested_suspense_works::{{closure}}::Content>]>--><div class="content-area"><div class="action-area"><button class="take-a-break">Take a break!</button></div><!--<[yew::functional::FunctionComponent<yew::suspense::component::feat_csr_ssr::Suspense>]>--><!--<[yew::suspense::component::feat_csr_ssr::BaseSuspense]>--><!--<?>--><!--<[yew::functional::FunctionComponent<hydration::hydration_nested_suspense_works::{{closure}}::InnerContent>]>--><div class="content-area"><div class="action-area"><button class="take-a-break2">Take a break!</button></div></div><!--</[yew::functional::FunctionComponent<hydration::hydration_nested_suspense_works::{{closure}}::InnerContent>]>--><!--</?>--><!--</[yew::suspense::component::feat_csr_ssr::BaseSuspense]>--><!--</[yew::functional::FunctionComponent<yew::suspense::component::feat_csr_ssr::Suspense>]>--></div><!--</[yew::functional::FunctionComponent<hydration::hydration_nested_suspense_works::{{closure}}::Content>]>-->"#
);
sleep(Duration::from_millis(50)).await;
// inner suspense is hydrating...
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<div class="content-area"><div class="action-area"><button class="take-a-break">Take a break!</button></div><!--<[yew::functional::FunctionComponent<hydration::hydration_nested_suspense_works::{{closure}}::InnerContent>]>--><div class="content-area"><div class="action-area"><button class="take-a-break2">Take a break!</button></div></div><!--</[yew::functional::FunctionComponent<hydration::hydration_nested_suspense_works::{{closure}}::InnerContent>]>--></div>"#
);
sleep(Duration::from_millis(50)).await;
// hydrated.
let result = obtain_result();
assert_eq!(
result.as_str(),
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>"#
);
gloo_utils::document()
.query_selector(".take-a-break")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
sleep(Duration::from_millis(10)).await;
let result = obtain_result();
assert_eq!(result.as_str(), "<div>wait...(outer)</div>");
sleep(Duration::from_millis(50)).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
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>"#
);
gloo_utils::document()
.query_selector(".take-a-break2")
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
.click();
sleep(Duration::from_millis(10)).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
r#"<div class="content-area"><div class="action-area"><button class="take-a-break">Take a break!</button></div><div>wait...(inner)</div></div>"#
);
sleep(Duration::from_millis(50)).await;
let result = obtain_result();
assert_eq!(
result.as_str(),
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>"#
);
}

View File

@ -126,11 +126,67 @@ 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)
## SSR Hydration
Hydration is the process that connects a Yew application to the
server-side generated HTML file. By default, `ServerRender` prints
hydratable html string which includes additional information to facilitate hydration.
When the `Renderer::hydrate` method is called, instead of start rendering from
scratch, Yew will reconcile the Virtual DOM generated by the application
with the html string generated by the server renderer.
:::caution
Server-side rendering is experiemental and currently has no hydration support.
However, you can still use it to generate static websites.
To successfully hydrate an html representation created by the
`ServerRenderer`, the client must produce a Virtual DOM layout that
exactly matches the one used for SSR including components that do not
contain any elements. If you have any component that is only useful in
one implementation, you may want to use a `PhantomComponent` to fill the
position of the extra component.
:::
## Component Lifecycle during hydration
During Hydration, components schedule 2 consecutive renders after it is
created. Any effects are called after the second render completes.
It is important to make sure that the render function of the your
component is side-effect free. It should not mutate any states or trigger
additional renders. If your component currently mutates states or triggers
additional renders, move them into an `use_effect` hook.
It's possible to use Struct Components with server-side rendering in
hydration, the view function will be called
multiple times before the rendered function will be called.
The DOM is considered as not connected until rendered function is called,
you should prevent any access to rendered nodes
until `rendered()` method is called.
## Example
```rust ,ignore
use yew::prelude::*;
use yew::Renderer;
#[function_component]
fn App() -> Html {
html! {<div>{"Hello, World!"}</div>}
}
fn main() {
let renderer = Renderer::<App>::new();
// hydrates everything under body element, removes trailing
// elements (if any).
renderer.hydrate();
}
```
Example: [simple\_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr)
Example: [ssr\_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router)
:::caution
Server-side rendering is currently experiemental. If you find a bug, please file
an issue on [GitHub](https://github.com/yewstack/yew/issues/new?assignees=&labels=bug&template=bug_report.md&title=).
:::