Introduce additional information in SSR artifact to facilitate Hydration (#2540)

* Bring changes to this branch.

* Child components always render after parents.

* Add hydratable to render_to_string.

* Revert portal example.

* Fix vcomp.

* Prefer debug_assert.

* ServerRenderer now a Builder Pattern.

* Collectable.
This commit is contained in:
Kaede Hoshikawa 2022-03-26 08:57:08 +09:00 committed by GitHub
parent ee6a67e3ea
commit 2e098f4f6c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 246 additions and 51 deletions

View File

@ -204,8 +204,15 @@ mod feat_ssr {
ComponentRenderState, CreateRunner, DestroyRunner, RenderRunner, ComponentRenderState, CreateRunner, DestroyRunner, RenderRunner,
}; };
use crate::virtual_dom::Collectable;
impl<COMP: BaseComponent> Scope<COMP> { impl<COMP: BaseComponent> Scope<COMP> {
pub(crate) async fn render_to_string(self, w: &mut String, props: Rc<COMP::Properties>) { pub(crate) async fn render_to_string(
self,
w: &mut String,
props: Rc<COMP::Properties>,
hydratable: bool,
) {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
let state = ComponentRenderState::Ssr { sender: Some(tx) }; let state = ComponentRenderState::Ssr { sender: Some(tx) };
@ -222,10 +229,24 @@ mod feat_ssr {
); );
scheduler::start(); scheduler::start();
#[cfg(debug_assertions)]
let collectable = Collectable::Component(std::any::type_name::<COMP>());
#[cfg(not(debug_assertions))]
let collectable = Collectable::Component;
if hydratable {
collectable.write_open_tag(w);
}
let html = rx.await.unwrap(); let html = rx.await.unwrap();
let self_any_scope = AnyScope::from(self.clone()); let self_any_scope = AnyScope::from(self.clone());
html.render_to_string(w, &self_any_scope).await; html.render_to_string(w, &self_any_scope, hydratable).await;
if hydratable {
collectable.write_close_tag(w);
}
scheduler::push_component_destroy(Box::new(DestroyRunner { scheduler::push_component_destroy(Box::new(DestroyRunner {
state: self.state.clone(), state: self.state.clone(),

View File

@ -10,6 +10,7 @@ where
ICOMP: IntoComponent, ICOMP: IntoComponent,
{ {
props: ICOMP::Properties, props: ICOMP::Properties,
hydratable: bool,
} }
impl<ICOMP> Default for ServerRenderer<ICOMP> impl<ICOMP> Default for ServerRenderer<ICOMP>
@ -39,7 +40,22 @@ where
{ {
/// Creates a [ServerRenderer] with custom properties. /// Creates a [ServerRenderer] with custom properties.
pub fn with_props(props: ICOMP::Properties) -> Self { pub fn with_props(props: ICOMP::Properties) -> Self {
Self { props } Self {
props,
hydratable: true,
}
}
/// Sets whether an the rendered result is hydratable.
///
/// Defaults to `true`.
///
/// When this is sets to `true`, the rendered artifact will include additional information
/// to assist with the hydration process.
pub fn hydratable(mut self, val: bool) -> Self {
self.hydratable = val;
self
} }
/// Renders Yew Application. /// Renders Yew Application.
@ -54,6 +70,8 @@ where
/// Renders Yew Application to a String. /// Renders Yew Application to a String.
pub async fn render_to_string(self, w: &mut String) { pub async fn render_to_string(self, w: &mut String) {
let scope = Scope::<<ICOMP as IntoComponent>::Component>::new(None); let scope = Scope::<<ICOMP as IntoComponent>::Component>::new(None);
scope.render_to_string(w, self.props.into()).await; scope
.render_to_string(w, self.props.into(), self.hydratable)
.await;
} }
} }

View File

@ -179,6 +179,84 @@ mod tests_attr_value {
} }
} }
#[cfg(feature = "ssr")] // & feature = "hydration"
mod feat_ssr_hydration {
/// A collectable.
///
/// This indicates a kind that can be collected from fragment to be processed at a later time
pub(crate) enum Collectable {
#[cfg(debug_assertions)]
Component(&'static str),
#[cfg(not(debug_assertions))]
Component,
Suspense,
}
impl Collectable {
pub fn open_start_mark(&self) -> &'static str {
match self {
#[cfg(debug_assertions)]
Self::Component(_) => "<[",
#[cfg(not(debug_assertions))]
Self::Component => "<[",
Self::Suspense => "<?",
}
}
pub fn close_start_mark(&self) -> &'static str {
match self {
#[cfg(debug_assertions)]
Self::Component(_) => "</[",
#[cfg(not(debug_assertions))]
Self::Component => "</[",
Self::Suspense => "</?",
}
}
pub fn end_mark(&self) -> &'static str {
match self {
#[cfg(debug_assertions)]
Self::Component(_) => "]>",
#[cfg(not(debug_assertions))]
Self::Component => "]>",
Self::Suspense => ">",
}
}
#[cfg(feature = "ssr")]
pub fn write_open_tag(&self, w: &mut String) {
w.push_str("<!--");
w.push_str(self.open_start_mark());
#[cfg(debug_assertions)]
match self {
Self::Component(type_name) => w.push_str(type_name),
Self::Suspense => {}
}
w.push_str(self.end_mark());
w.push_str("-->");
}
#[cfg(feature = "ssr")]
pub fn write_close_tag(&self, w: &mut String) {
w.push_str("<!--");
w.push_str(self.close_start_mark());
#[cfg(debug_assertions)]
match self {
Self::Component(type_name) => w.push_str(type_name),
Self::Suspense => {}
}
w.push_str(self.end_mark());
w.push_str("-->");
}
}
}
#[cfg(feature = "ssr")]
pub(crate) use feat_ssr_hydration::*;
/// A collection of attributes for an element /// A collection of attributes for an element
#[derive(PartialEq, Eq, Clone, Debug)] #[derive(PartialEq, Eq, Clone, Debug)]
pub enum Attributes { pub enum Attributes {

View File

@ -70,6 +70,7 @@ pub(crate) trait Mountable {
&'a self, &'a self,
w: &'a mut String, w: &'a mut String,
parent_scope: &'a AnyScope, parent_scope: &'a AnyScope,
hydratable: bool,
) -> LocalBoxFuture<'a, ()>; ) -> LocalBoxFuture<'a, ()>;
} }
@ -117,10 +118,13 @@ impl<COMP: BaseComponent> Mountable for PropsWrapper<COMP> {
&'a self, &'a self,
w: &'a mut String, w: &'a mut String,
parent_scope: &'a AnyScope, parent_scope: &'a AnyScope,
hydratable: bool,
) -> LocalBoxFuture<'a, ()> { ) -> LocalBoxFuture<'a, ()> {
async move { async move {
let scope: Scope<COMP> = Scope::new(Some(parent_scope.clone())); let scope: Scope<COMP> = Scope::new(Some(parent_scope.clone()));
scope.render_to_string(w, self.props.clone()).await; scope
.render_to_string(w, self.props.clone(), hydratable)
.await;
} }
.boxed_local() .boxed_local()
} }
@ -210,10 +214,15 @@ mod feat_ssr {
use crate::html::AnyScope; use crate::html::AnyScope;
impl VComp { impl VComp {
pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) { pub(crate) async fn render_to_string(
&self,
w: &mut String,
parent_scope: &AnyScope,
hydratable: bool,
) {
self.mountable self.mountable
.as_ref() .as_ref()
.render_to_string(w, parent_scope) .render_to_string(w, parent_scope, hydratable)
.await; .await;
} }
} }

View File

@ -90,12 +90,17 @@ mod feat_ssr {
use crate::html::AnyScope; use crate::html::AnyScope;
impl VList { impl VList {
pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) { pub(crate) async fn render_to_string(
&self,
w: &mut String,
parent_scope: &AnyScope,
hydratable: bool,
) {
// Concurrently render all children. // Concurrently render all children.
for fragment in futures::future::join_all(self.children.iter().map(|m| async move { for fragment in futures::future::join_all(self.children.iter().map(|m| async move {
let mut w = String::new(); let mut w = String::new();
m.render_to_string(&mut w, parent_scope).await; m.render_to_string(&mut w, parent_scope, hydratable).await;
w w
})) }))
@ -123,9 +128,10 @@ mod ssr_tests {
html! { <div>{"Hello "}{s}{"!"}</div> } html! { <div>{"Hello "}{s}{"!"}</div> }
} }
let renderer = ServerRenderer::<Comp>::new(); let s = ServerRenderer::<Comp>::new()
.hydratable(false)
let s = renderer.render().await; .render()
.await;
assert_eq!(s, "<div>Hello world!</div>"); assert_eq!(s, "<div>Hello world!</div>");
} }
@ -153,9 +159,10 @@ mod ssr_tests {
} }
} }
let renderer = ServerRenderer::<Comp>::new(); let s = ServerRenderer::<Comp>::new()
.hydratable(false)
let s = renderer.render().await; .render()
.await;
assert_eq!( assert_eq!(
s, s,

View File

@ -157,13 +157,20 @@ mod feat_ssr {
&'a self, &'a self,
w: &'a mut String, w: &'a mut String,
parent_scope: &'a AnyScope, parent_scope: &'a AnyScope,
hydratable: bool,
) -> LocalBoxFuture<'a, ()> { ) -> LocalBoxFuture<'a, ()> {
async move { async move {
match self { match self {
VNode::VTag(vtag) => vtag.render_to_string(w, parent_scope).await, VNode::VTag(vtag) => vtag.render_to_string(w, parent_scope, hydratable).await,
VNode::VText(vtext) => vtext.render_to_string(w).await, VNode::VText(vtext) => {
VNode::VComp(vcomp) => vcomp.render_to_string(w, parent_scope).await, vtext.render_to_string(w, parent_scope, hydratable).await
VNode::VList(vlist) => vlist.render_to_string(w, parent_scope).await, }
VNode::VComp(vcomp) => {
vcomp.render_to_string(w, parent_scope, hydratable).await
}
VNode::VList(vlist) => {
vlist.render_to_string(w, parent_scope, hydratable).await
}
// We are pretty safe here as it's not possible to get a web_sys::Node without DOM // We are pretty safe here as it's not possible to get a web_sys::Node without DOM
// support in the first place. // support in the first place.
// //
@ -175,7 +182,9 @@ mod feat_ssr {
// Portals are not rendered. // Portals are not rendered.
VNode::VPortal(_) => {} VNode::VPortal(_) => {}
VNode::VSuspense(vsuspense) => { VNode::VSuspense(vsuspense) => {
vsuspense.render_to_string(w, parent_scope).await vsuspense
.render_to_string(w, parent_scope, hydratable)
.await
} }
} }
} }

View File

@ -28,11 +28,29 @@ impl VSuspense {
mod feat_ssr { mod feat_ssr {
use super::*; use super::*;
use crate::html::AnyScope; use crate::html::AnyScope;
use crate::virtual_dom::Collectable;
impl VSuspense { impl VSuspense {
pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) { pub(crate) async fn render_to_string(
&self,
w: &mut String,
parent_scope: &AnyScope,
hydratable: bool,
) {
let collectable = Collectable::Suspense;
if hydratable {
collectable.write_open_tag(w);
}
// always render children on the server side. // always render children on the server side.
self.children.render_to_string(w, parent_scope).await; self.children
.render_to_string(w, parent_scope, hydratable)
.await;
if hydratable {
collectable.write_close_tag(w);
}
} }
} }
} }
@ -120,9 +138,10 @@ mod ssr_tests {
let s = local let s = local
.run_until(async move { .run_until(async move {
let renderer = ServerRenderer::<Comp>::new(); ServerRenderer::<Comp>::new()
.hydratable(false)
renderer.render().await .render()
.await
}) })
.await; .await;

View File

@ -418,8 +418,19 @@ mod feat_ssr {
use crate::{html::AnyScope, virtual_dom::VText}; use crate::{html::AnyScope, virtual_dom::VText};
use std::fmt::Write; use std::fmt::Write;
// Elements that cannot have any child elements.
static VOID_ELEMENTS: &[&str; 14] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
"source", "track", "wbr",
];
impl VTag { impl VTag {
pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) { pub(crate) async fn render_to_string(
&self,
w: &mut String,
parent_scope: &AnyScope,
hydratable: bool,
) {
write!(w, "<{}", self.tag()).unwrap(); write!(w, "<{}", self.tag()).unwrap();
let write_attr = |w: &mut String, name: &str, val: Option<&str>| { let write_attr = |w: &mut String, name: &str, val: Option<&str>| {
@ -450,7 +461,9 @@ mod feat_ssr {
VTagInner::Input(_) => {} VTagInner::Input(_) => {}
VTagInner::Textarea { .. } => { VTagInner::Textarea { .. } => {
if let Some(m) = self.value() { if let Some(m) = self.value() {
VText::new(m.to_owned()).render_to_string(w).await; VText::new(m.to_owned())
.render_to_string(w, parent_scope, hydratable)
.await;
} }
w.push_str("</textarea>"); w.push_str("</textarea>");
@ -460,9 +473,14 @@ mod feat_ssr {
ref children, ref children,
.. ..
} => { } => {
children.render_to_string(w, parent_scope).await; if !VOID_ELEMENTS.contains(&tag.as_ref()) {
children.render_to_string(w, parent_scope, hydratable).await;
write!(w, "</{}>", tag).unwrap(); write!(w, "</{}>", tag).unwrap();
} else {
// We don't write children of void elements nor closing tags.
debug_assert!(children.is_empty(), "{} cannot have any children!", tag);
}
} }
} }
} }
@ -483,9 +501,10 @@ mod ssr_tests {
html! { <div></div> } html! { <div></div> }
} }
let renderer = ServerRenderer::<Comp>::new(); let s = ServerRenderer::<Comp>::new()
.hydratable(false)
let s = renderer.render().await; .render()
.await;
assert_eq!(s, "<div></div>"); assert_eq!(s, "<div></div>");
} }
@ -497,9 +516,10 @@ mod ssr_tests {
html! { <div class="abc"></div> } html! { <div class="abc"></div> }
} }
let renderer = ServerRenderer::<Comp>::new(); let s = ServerRenderer::<Comp>::new()
.hydratable(false)
let s = renderer.render().await; .render()
.await;
assert_eq!(s, r#"<div class="abc"></div>"#); assert_eq!(s, r#"<div class="abc"></div>"#);
} }
@ -511,9 +531,10 @@ mod ssr_tests {
html! { <div>{"Hello!"}</div> } html! { <div>{"Hello!"}</div> }
} }
let renderer = ServerRenderer::<Comp>::new(); let s = ServerRenderer::<Comp>::new()
.hydratable(false)
let s = renderer.render().await; .render()
.await;
assert_eq!(s, r#"<div>Hello!</div>"#); assert_eq!(s, r#"<div>Hello!</div>"#);
} }
@ -525,9 +546,10 @@ mod ssr_tests {
html! { <div>{"Hello!"}<input value="abc" type="text" /></div> } html! { <div>{"Hello!"}<input value="abc" type="text" /></div> }
} }
let renderer = ServerRenderer::<Comp>::new(); let s = ServerRenderer::<Comp>::new()
.hydratable(false)
let s = renderer.render().await; .render()
.await;
assert_eq!(s, r#"<div>Hello!<input value="abc" type="text"></div>"#); assert_eq!(s, r#"<div>Hello!<input value="abc" type="text"></div>"#);
} }
@ -539,9 +561,10 @@ mod ssr_tests {
html! { <textarea value="teststring" /> } html! { <textarea value="teststring" /> }
} }
let renderer = ServerRenderer::<Comp>::new(); let s = ServerRenderer::<Comp>::new()
.hydratable(false)
let s = renderer.render().await; .render()
.await;
assert_eq!(s, r#"<textarea>teststring</textarea>"#); assert_eq!(s, r#"<textarea>teststring</textarea>"#);
} }

View File

@ -34,9 +34,15 @@ impl PartialEq for VText {
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
mod feat_ssr { mod feat_ssr {
use super::*; use super::*;
use crate::html::AnyScope;
impl VText { impl VText {
pub(crate) async fn render_to_string(&self, w: &mut String) { pub(crate) async fn render_to_string(
&self,
w: &mut String,
_parent_scope: &AnyScope,
_hydratable: bool,
) {
html_escape::encode_text_to_string(&self.text, w); html_escape::encode_text_to_string(&self.text, w);
} }
} }
@ -46,16 +52,21 @@ mod feat_ssr {
mod ssr_tests { mod ssr_tests {
use tokio::test; use tokio::test;
use super::*; use crate::prelude::*;
use crate::ServerRenderer;
#[test] #[test]
async fn test_simple_str() { async fn test_simple_str() {
let vtext = VText::new("abc"); #[function_component]
fn Comp() -> Html {
html! { "abc" }
}
let mut s = String::new(); let s = ServerRenderer::<Comp>::new()
.hydratable(false)
.render()
.await;
vtext.render_to_string(&mut s).await; assert_eq!(s, r#"abc"#);
assert_eq!("abc", s.as_str());
} }
} }