switch to a Document trait and introduce Script/Head/Style/Meta components (#2635)

* switch to a Document trait and introduce Script/Head/Style/Meta components

* Fix desktop title

* Insert special elements into the head during the inital SSR render

* Make all head component attributes optional

* hydrate head elements

* improve the server streaming head warning

* Document fullstack head hydration approach

* deduplicate head elements by href

* move Link into head::Link

* document head components

* add meta and title examples

* Fix a few doc examples

* fix formatting

* Add title to playwright tests

* serde is optional on web, but it is enabled by hydrate

* remove leftover console log
This commit is contained in:
Evan Almloff authored and GitHub committed 2024-07-17 20:54:03 -05:00
1 parent a490a94ae5
commit 176e67e5b7
65 files changed
+1703 -495

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
#[cfg(feature = "server")]
pub(crate) mod server;
#[cfg(feature = "web")]
pub(crate) mod web;
+140
View File
@@ -0,0 +1,140 @@
//! On the server, we collect any elements that should be rendered into the head in the first frame of SSR.
//! After the first frame, we have already sent down the head, so we can't modify it in place. The web client
//! will hydrate the head with the correct contents once it loads.
use std::cell::RefCell;
use dioxus_lib::{html::document::*, prelude::*};
use dioxus_ssr::Renderer;
use generational_box::GenerationalBox;
#[derive(Default)]
struct ServerDocumentInner {
streaming: bool,
title: Option<String>,
meta: Vec<Element>,
link: Vec<Element>,
script: Vec<Element>,
}
/// A Document provider that collects all contents injected into the head for SSR rendering.
#[derive(Default)]
pub(crate) struct ServerDocument(RefCell<ServerDocumentInner>);
impl ServerDocument {
pub(crate) fn render(
&self,
to: &mut impl std::fmt::Write,
renderer: &mut Renderer,
) -> std::fmt::Result {
fn lazy_app(props: Element) -> Element {
props
}
let myself = self.0.borrow();
let element = rsx! {
if let Some(title) = myself.title.as_ref() {
title { title: "{title}" }
}
{myself.meta.iter().map(|m| rsx! { {m} })}
{myself.link.iter().map(|l| rsx! { {l} })}
{myself.script.iter().map(|s| rsx! { {s} })}
};
let mut dom = VirtualDom::new_with_props(lazy_app, element);
dom.rebuild_in_place();
// We don't hydrate the head, so we can set the pre_render flag to false to save a few bytes
let was_pre_rendering = renderer.pre_render;
renderer.pre_render = false;
renderer.render_to(to, &dom)?;
renderer.pre_render = was_pre_rendering;
Ok(())
}
pub(crate) fn start_streaming(&self) {
self.0.borrow_mut().streaming = true;
}
pub(crate) fn warn_if_streaming(&self) {
if self.0.borrow().streaming {
tracing::warn!("Attempted to insert content into the head after the initial streaming frame. Inserting content into the head only works during the initial render of SSR outside before resolving any suspense boundaries.");
}
}
/// Write the head element into the serialized context for hydration
/// We write true if the head element was written to the DOM during server side rendering
pub(crate) fn serialize_for_hydration(&self) {
let serialize = crate::html_storage::serialize_context();
serialize.push(&!self.0.borrow().streaming);
}
}
impl Document for ServerDocument {
fn new_evaluator(&self, js: String) -> GenerationalBox<Box<dyn Evaluator>> {
NoOpDocument.new_evaluator(js)
}
fn set_title(&self, title: String) {
self.warn_if_streaming();
self.serialize_for_hydration();
self.0.borrow_mut().title = Some(title);
}
fn create_meta(&self, props: MetaProps) {
self.warn_if_streaming();
self.serialize_for_hydration();
self.0.borrow_mut().meta.push(rsx! {
meta {
name: props.name,
charset: props.charset,
http_equiv: props.http_equiv,
content: props.content,
property: props.property,
}
});
}
fn create_script(&self, props: ScriptProps) {
self.warn_if_streaming();
self.serialize_for_hydration();
let children = props.script_contents();
self.0.borrow_mut().script.push(rsx! {
script {
src: props.src,
defer: props.defer,
crossorigin: props.crossorigin,
fetchpriority: props.fetchpriority,
integrity: props.integrity,
nomodule: props.nomodule,
nonce: props.nonce,
referrerpolicy: props.referrerpolicy,
r#type: props.r#type,
{children}
}
});
}
fn create_link(&self, props: head::LinkProps) {
self.warn_if_streaming();
self.serialize_for_hydration();
self.0.borrow_mut().link.push(rsx! {
link {
rel: props.rel,
media: props.media,
title: props.title,
disabled: props.disabled,
r#as: props.r#as,
sizes: props.sizes,
href: props.href,
crossorigin: props.crossorigin,
referrerpolicy: props.referrerpolicy,
fetchpriority: props.fetchpriority,
hreflang: props.hreflang,
integrity: props.integrity,
r#type: props.r#type,
blocking: props.blocking,
}
})
}
}
+58
View File
@@ -0,0 +1,58 @@
#![allow(unused)]
//! On the client, we use the [`WebDocument`] implementation to render the head for any elements that were not rendered on the server.
use dioxus_lib::events::Document;
use dioxus_web::WebDocument;
fn head_element_written_on_server() -> bool {
dioxus_web::take_server_data()
.ok()
.flatten()
.unwrap_or_default()
}
pub(crate) struct FullstackWebDocument;
impl Document for FullstackWebDocument {
fn new_evaluator(
&self,
js: String,
) -> generational_box::GenerationalBox<Box<dyn dioxus_lib::prelude::document::Evaluator>> {
WebDocument.new_evaluator(js)
}
fn set_title(&self, title: String) {
if head_element_written_on_server() {
return;
}
WebDocument.set_title(title);
}
fn create_meta(&self, props: dioxus_lib::prelude::MetaProps) {
if head_element_written_on_server() {
return;
}
WebDocument.create_meta(props);
}
fn create_script(&self, props: dioxus_lib::prelude::ScriptProps) {
if head_element_written_on_server() {
return;
}
WebDocument.create_script(props);
}
fn create_style(&self, props: dioxus_lib::prelude::StyleProps) {
if head_element_written_on_server() {
return;
}
WebDocument.create_style(props);
}
fn create_link(&self, props: dioxus_lib::prelude::head::LinkProps) {
if head_element_written_on_server() {
return;
}
WebDocument.create_link(props);
}
}
+15 -13
View File
@@ -22,27 +22,29 @@ use serde::{de::DeserializeOwned, Serialize};
/// ```
pub fn use_server_cached<O: 'static + Clone + Serialize + DeserializeOwned>(
server_fn: impl Fn() -> O,
) -> O {
use_hook(|| server_cached(server_fn))
}
pub(crate) fn server_cached<O: 'static + Clone + Serialize + DeserializeOwned>(
value: impl FnOnce() -> O,
) -> O {
#[cfg(feature = "server")]
{
let serialize = crate::html_storage::use_serialize_context();
use_hook(|| {
let data = server_fn();
serialize.push(&data);
data
})
let serialize = crate::html_storage::serialize_context();
let data = value();
serialize.push(&data);
data
}
#[cfg(all(not(feature = "server"), feature = "web"))]
{
use_hook(|| {
dioxus_web::take_server_data()
.ok()
.flatten()
.unwrap_or_else(server_fn)
})
dioxus_web::take_server_data()
.ok()
.flatten()
.unwrap_or_else(value)
}
#[cfg(not(any(feature = "server", feature = "web")))]
{
use_hook(server_fn)
value()
}
}
+5 -1
View File
@@ -32,7 +32,11 @@ impl SerializeContext {
}
pub(crate) fn use_serialize_context() -> SerializeContext {
use_hook(|| has_context().unwrap_or_else(|| provide_context(SerializeContext::default())))
use_hook(serialize_context)
}
pub(crate) fn serialize_context() -> SerializeContext {
has_context().unwrap_or_else(|| provide_context(SerializeContext::default()))
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
+13 -2
View File
@@ -50,12 +50,23 @@ pub fn launch(
#[allow(unused)]
pub fn launch(
root: fn() -> Element,
contexts: Vec<Box<dyn Fn() -> Box<dyn Any + Send + Sync> + Send + Sync>>,
#[allow(unused_mut)] mut contexts: Vec<
Box<dyn Fn() -> Box<dyn Any + Send + Sync> + Send + Sync>,
>,
platform_config: Config,
) {
let contexts = Arc::new(contexts);
let factory = virtual_dom_factory(root, contexts);
let mut factory = virtual_dom_factory(root, contexts);
let cfg = platform_config.web_cfg.hydrate(true);
#[cfg(feature = "document")]
let factory = move || {
let mut vdom = factory();
vdom.provide_root_context(std::rc::Rc::new(crate::document::web::FullstackWebDocument)
as std::rc::Rc<dyn dioxus_lib::prelude::document::Document>);
vdom
};
dioxus_web::launch::launch_virtual_dom(factory(), cfg)
}
+2
View File
@@ -18,6 +18,8 @@ pub mod launch;
pub use config::*;
#[cfg(feature = "document")]
mod document;
#[cfg(feature = "server")]
mod render;
#[cfg(feature = "server")]
+39 -5
View File
@@ -160,8 +160,34 @@ impl SsrRendererPool {
let join_handle = spawn_platform(move || async move {
let mut virtual_dom = virtual_dom_factory();
#[cfg(feature = "document")]
let document = std::rc::Rc::new(crate::document::server::ServerDocument::default());
#[cfg(feature = "document")]
virtual_dom.provide_root_context(document.clone() as std::rc::Rc<dyn Document>);
// poll the future, which may call server_context()
tracing::info!("Rebuilding vdom");
with_server_context(server_context.clone(), || virtual_dom.rebuild_in_place());
let mut pre_body = String::new();
if let Err(err) = wrapper.render_head(&mut pre_body) {
_ = into.start_send(Err(err));
return;
}
#[cfg(feature = "document")]
{
// Collect any head content from the document provider and inject that into the head
if let Err(err) = document.render(&mut pre_body, &mut renderer) {
_ = into.start_send(Err(err.into()));
return;
}
// Enable a warning when inserting contents into the head during streaming
document.start_streaming();
}
if let Err(err) = wrapper.render_before_body(&mut pre_body) {
_ = into.start_send(Err(err));
return;
@@ -206,10 +232,6 @@ impl SsrRendererPool {
};
}
// poll the future, which may call server_context()
tracing::info!("Rebuilding vdom");
with_server_context(server_context.clone(), || virtual_dom.rebuild_in_place());
// Render the initial frame with loading placeholders
let mut initial_frame = renderer.render(&virtual_dom);
@@ -364,6 +386,18 @@ impl FullstackHTMLTemplate {
}
impl FullstackHTMLTemplate {
/// Render any content before the head of the page.
pub fn render_head<R: std::fmt::Write>(
&self,
to: &mut R,
) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> {
let ServeConfig { index, .. } = &self.cfg;
to.write_str(&index.head)?;
Ok(())
}
/// Render any content before the body of the page.
pub fn render_before_body<R: std::fmt::Write>(
&self,
@@ -371,7 +405,7 @@ impl FullstackHTMLTemplate {
) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> {
let ServeConfig { index, .. } = &self.cfg;
to.write_str(&index.pre_main)?;
to.write_str(&index.close_head)?;
Ok(())
}
+9 -2
View File
@@ -107,13 +107,19 @@ fn load_index_html(contents: String, root_id: &'static str) -> IndexHtml {
post_main.1.to_string(),
);
let (head, close_head) = pre_main.split_once("</head>").unwrap_or_else(|| {
panic!("Failed to find closing </head> tag after id=\"{root_id}\" in index.html.")
});
let (head, close_head) = (head.to_string(), "</head>".to_string() + close_head);
let (post_main, after_closing_body_tag) =
post_main.split_once("</body>").unwrap_or_else(|| {
panic!("Failed to find closing </body> tag after id=\"{root_id}\" in index.html.")
});
IndexHtml {
pre_main,
head,
close_head,
post_main: post_main.to_string(),
after_closing_body_tag: "</body>".to_string() + after_closing_body_tag,
}
@@ -121,7 +127,8 @@ fn load_index_html(contents: String, root_id: &'static str) -> IndexHtml {
#[derive(Clone)]
pub(crate) struct IndexHtml {
pub(crate) pre_main: String,
pub(crate) head: String,
pub(crate) close_head: String,
pub(crate) post_main: String,
pub(crate) after_closing_body_tag: String,
}