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:
1 parent
a490a94ae5
commit
176e67e5b7
65 files changed
+1703
-495
No files matched your search
@@ -0,0 +1,4 @@
|
||||
#[cfg(feature = "server")]
|
||||
pub(crate) mod server;
|
||||
#[cfg(feature = "web")]
|
||||
pub(crate) mod web;
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ pub mod launch;
|
||||
|
||||
pub use config::*;
|
||||
|
||||
#[cfg(feature = "document")]
|
||||
mod document;
|
||||
#[cfg(feature = "server")]
|
||||
mod render;
|
||||
#[cfg(feature = "server")]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user