Suspense boundaries/out of order streaming/anyhow like error handling (#2365)
* create static site generation helpers in the router crate * work on integrating static site generation into fullstack * move ssg into a separate crate * integrate ssg with the launch builder * simplify ssg example * fix static_routes for child routes * move CLI hot reloading websocket code into dioxus-hot-reload * fix some unused imports * use the same hot reloading websocket code for fullstack * fix fullstack hot reloading * move cli hot reloading logic into the hot reload crate * ssg example working with dx serve * add more examples * fix clippy * switch to a result for Element * fix formatting * fix hot reload doctest imports * fix axum imports * add show method to error context * implement retaining nodes during suspense * fix unterminated if statements * communicate between tasks and suspense boundaries * make suspense placeholders easier to use * implement IntoDynNode and IntoVNode for more wrappers * fix clippy examples * fix rsx tests * add streaming html utilities to the ssr package * unify hydration and non-hydration ssr cache * fix router with Result Element * don't run server doc tests * Fix hot reload websocket doc examples * simple apps working with fullstack streaming * fix preloading wasm * Report errors encountered while streaming * remove async from incremental renderer * document new VirtualDom suspense methods * make streaming work with incremental rendering * fix static site generation * document suspense structs * create closure type; allow async event handlers in props; allow shorthand event handlers * test forwarding event handlers with the shorthand syntax * fix clippy * fix imports in spawn async doctest * fix empty rsx * fix async result event handlers * fix mounting router in multiple places * Fix task dead cancel race condition * simplify diffing before adding suspense * fix binary size increase * fix attribute diffing * more diffing fixes * create minimal fullstack feature * smaller fullstack bundles * allow mounting nodes that are already created and creating nodes without mounting them * fix hot reload feature * fix replacing components * don't reclaim virtual nodes * client side suspense working! * fix CLI * slightly smaller fullstack builds * fix multiple suspended scopes * fix merge errors * yield back to tokio every few polls to fix suspending on many tasks at once * remove logs * document suspense boundary and update suspense example * fix ssg * make streaming optional * fix some router and core tests * fix suspense example * fix serialization with out of order server futures * add incremental streaming hackernews demo * fix hackernews demo * fix root hackernews redirect * fix formatting * add tests for suspense cases * slightly smaller binaries * slightly smaller * improve error handling docs * fix errors example link * fix doc tests * remove log file * fix ssr cache type inference * remove index.html * fix ssg render template * fix assigning ids on elements with dynamic attributes * add desktop feature to the workspace examples * remove router static generation example; ssg lives in the dioxus-static-generation package * add a test for effects during suspense * only run effects on mounted nodes * fix multiple suspense roots * fix node iterator * fix closures without arguments * fix dioxus-core readme doctest * remove suspense logs * fix scope stack * fix clippy * remove unused suspense boundary from hackernews * assert that launch never returns for better compiler errors * fix static generation launch function * fix web renderer * pass context providers into server functions * add an example for FromContext * clean up DioxusRouterExt * fix server function context * fix fullstack desktop example * forward CLI serve settings to fullstack * re-export serve config at the root of fullstack * forward env directly instead of using a guard * just set the port in the CLI for fullstack playwright tests * fix fullstack dioxus-cli-config feature * fix launch server merge conflicts * fix fullstack launch context * Merge branch 'main' into suspense-2.0 * fix fullstack html data * remove drop virtual dom feature * add a comment about only_write_templates binary size workaround * remove explicit dependencies from use_server_future * make ErrorContext and SuspenseContext more similar * Tweak: small tweaks to tomls to make diff smaller * only rerun components under suspense after the initial placeholders are sent to the client * add module docs for suspense * keep track of when suspense boundaries are resolved * start implementing JS out of order streaming * fix core tests * implement the server side of suspense with js * fix streaming ssr with nested suspense * move streaming ssr code into fullstack * revert minification changes * serialize server future data as the html streams * start loading scripts wasm immediately instead of defering the script * very basic nested suspense example working with minimal html updates * clean up some suspense/error docs * fix hydrating nested pending server futures * sort resolved boundaries by height * Fix disconnecting clients while streaming * fix static generation crate * don't insert extra divs when hydrating streamed chunks * wait to swap in the elements until they are hydrated * remove inline streaming script * hackernews partially working * fix spa mode * banish the open shadow dom * fix removing placeholder * set up streaming playwright test * run web playwright tests on 9999 to avoid port conflicts with other local servers * remove suspense nodes if the suspense boundary is replaced before the suspense resolves on the server * ignore hydration of removed suspense boundaries * use path based indexing to fix hydrating suspense after parent suspense with child is removed * re-export dioxus error * remove resolved suspense divs if the suspense boundary has been removed * Fix client side initialized server futures * ignore comment nodes while traversing nodes in core to avoid lists getting swapped out with suspense * Pass initial hydration data to the client * hide pre nodes * don't panic if reclaiming an element fails * fix scope stack when polling tasks * improve deserialization out of length message * Ok(VNode::placeholder()) -> VNode::empty() * fix typo in rsx usage * restore testing changes from suspense example * clean up some logs and comments * fix playwright tests * clean up more changes in core * clean up core tests * remove anymap dependency * clean up changes to hooks * clean up changes in the router, rsx, and web * revert changes to axum-hello-world * fix use_server_future * fix clippy in dioxus-core * check that the next or previous node exist before checking if we should ignore them * fix formatting * fix suspense playwright test * remove unused suspense code * add more suspense playwright tests * add more docs for error boundaries * fix suspense core tests * fix ErrorBoundary example * remove a bunch of debug logging in js * fix router failure_external_navigation * use absolute paths in the interpreter build.rs * strip '\r' while hashing ts files * add a wrapper with a default error boundary and suspense boundary * restore hot reloading * ignore non-ts files when hashing * sort ts files before hashing them * fix rsx tests * fix fullstack doc tests * fix core tests * fix axum auth example * update suspense hydration diagram * longer playwright build limit * tiny fixes - spelling, formatting * update diagram link * remove comment and template nodes for suspense placeholders * remove comment nodes as we hydrate text * simplify hackernews example * clean up hydrating text nodes * switch to a separate environment variable for the base path for smaller binaries * clean up file system html trait * fix form data * move streaming code into fullstack * implement serialize and deserialize for CapturedError * remove waits in the nested suspense playwright spec * force sequential fullstack builds for CI * longer nested suspense delay for CI * fix --force-sequential flag * wait to launch server until client build is done --------- Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
1 parent
ffa36a67c3
commit
022e4ad203
175 files changed
+7872
-4010
No files matched your search
+187
-107
@@ -1,5 +1,36 @@
|
||||
//! Dioxus SSR uses the design of templates to cache as much as possible about the HTML a block of rsx can render.
|
||||
//!
|
||||
//! The structure of templates can tell us what segments are rendered where and lets us cache segments in the output string.
|
||||
//!
|
||||
//! For example, in this code, we can cache the whole render:
|
||||
//! ```rust, no_run
|
||||
//! use dioxus::prelude::*;
|
||||
//! rsx! {
|
||||
//! div {
|
||||
//! "Hello world"
|
||||
//! }
|
||||
//! };
|
||||
//! ```
|
||||
//! Because everything exists in the template, we can calculate the whole HTML for the template once and then reuse it.
|
||||
//! ```html
|
||||
//! <div>Hello world</div>
|
||||
//! ```
|
||||
//!
|
||||
//! If the template is more complex, we can only cache the parts that are static. In this case, we can cache `<div width="100px">` and `</div>`, but not the child text.
|
||||
//!
|
||||
//! ```rust, no_run
|
||||
//! use dioxus::prelude::*;
|
||||
//! let dynamic = 123;
|
||||
//! rsx! {
|
||||
//! div {
|
||||
//! width: "100px",
|
||||
//! "{dynamic}"
|
||||
//! }
|
||||
//! };
|
||||
//!```
|
||||
|
||||
use dioxus_core::prelude::*;
|
||||
use std::fmt::Write;
|
||||
use std::{fmt::Write, ops::AddAssign};
|
||||
|
||||
use crate::renderer::{str_truthy, BOOL_ATTRS};
|
||||
|
||||
@@ -10,14 +41,53 @@ pub(crate) struct StringCache {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StringChain {
|
||||
pub segments: Vec<Segment>,
|
||||
// If we should add new static text to the last segment
|
||||
// This will be true if the last segment is a static text and the last text isn't part of a hydration only boundary
|
||||
add_text_to_last_segment: bool,
|
||||
segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
impl StringChain {
|
||||
/// Add segments but only when hydration is enabled
|
||||
fn if_hydration_enabled<O>(
|
||||
&mut self,
|
||||
during_prerender: impl FnOnce(&mut StringChain) -> O,
|
||||
) -> O {
|
||||
// Insert a placeholder jump to the end of the hydration only segments
|
||||
let jump_index = self.segments.len();
|
||||
*self += Segment::HydrationOnlySection(0);
|
||||
let out = during_prerender(self);
|
||||
// Go back and fill in where the placeholder jump should skip to
|
||||
let after_hydration_only_section = self.segments.len();
|
||||
// Don't add any text to static text in the hydration only section. This would cause the text to be skipped during non-hydration renders
|
||||
self.add_text_to_last_segment = false;
|
||||
self.segments[jump_index] = Segment::HydrationOnlySection(after_hydration_only_section);
|
||||
out
|
||||
}
|
||||
|
||||
/// Add a new segment
|
||||
pub fn push(&mut self, segment: Segment) {
|
||||
self.add_text_to_last_segment = matches!(segment, Segment::PreRendered(_));
|
||||
self.segments.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<Segment> for StringChain {
|
||||
fn add_assign(&mut self, rhs: Segment) {
|
||||
self.push(rhs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub(crate) enum Segment {
|
||||
/// A marker for where to insert an attribute with a given index
|
||||
Attr(usize),
|
||||
/// A marker for where to insert a node with a given index
|
||||
Node(usize),
|
||||
/// Text that we know is static in the template that is pre-rendered
|
||||
PreRendered(String),
|
||||
/// Anything between this and the segments at the index is only required for hydration. If you don't need to hydrate, you can safely skip to the section at the given index
|
||||
HydrationOnlySection(usize),
|
||||
/// A marker for where to insert a dynamic styles
|
||||
StyleMarker {
|
||||
// If the marker is inside a style tag or not
|
||||
@@ -34,148 +104,158 @@ pub(crate) enum Segment {
|
||||
|
||||
impl std::fmt::Write for StringChain {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
match self.segments.last_mut() {
|
||||
Some(Segment::PreRendered(s2)) => s2.push_str(s),
|
||||
_ => self.segments.push(Segment::PreRendered(s.to_string())),
|
||||
if self.add_text_to_last_segment {
|
||||
match self.segments.last_mut() {
|
||||
Some(Segment::PreRendered(s2)) => s2.push_str(s),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
self.segments.push(Segment::PreRendered(s.to_string()))
|
||||
}
|
||||
|
||||
self.add_text_to_last_segment = true;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl StringCache {
|
||||
pub fn from_template(template: &VNode, prerender: bool) -> Result<Self, std::fmt::Error> {
|
||||
/// Create a new string cache from a template. This intentionally does not include any settings about the render mode (hydration or not) so that we can reuse the cache for both hydration and non-hydration renders.
|
||||
pub fn from_template(template: &VNode) -> Result<Self, std::fmt::Error> {
|
||||
let mut chain = StringChain::default();
|
||||
|
||||
let mut cur_path = vec![];
|
||||
|
||||
for (root_idx, root) in template.template.get().roots.iter().enumerate() {
|
||||
Self::recurse(root, &mut cur_path, root_idx, true, prerender, &mut chain)?;
|
||||
from_template_recursive(root, &mut cur_path, root_idx, true, &mut chain)?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
segments: chain.segments,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn recurse(
|
||||
root: &TemplateNode,
|
||||
cur_path: &mut Vec<usize>,
|
||||
root_idx: usize,
|
||||
is_root: bool,
|
||||
prerender: bool,
|
||||
chain: &mut StringChain,
|
||||
) -> Result<(), std::fmt::Error> {
|
||||
match root {
|
||||
TemplateNode::Element {
|
||||
tag,
|
||||
attrs,
|
||||
children,
|
||||
..
|
||||
} => {
|
||||
cur_path.push(root_idx);
|
||||
write!(chain, "<{tag}")?;
|
||||
// we need to collect the styles and write them at the end
|
||||
let mut styles = Vec::new();
|
||||
// we need to collect the inner html and write it at the end
|
||||
let mut inner_html = None;
|
||||
// we need to keep track of if we have dynamic attrs to know if we need to insert a style and inner_html marker
|
||||
let mut has_dyn_attrs = false;
|
||||
for attr in *attrs {
|
||||
match attr {
|
||||
TemplateAttribute::Static {
|
||||
name,
|
||||
value,
|
||||
namespace,
|
||||
} => {
|
||||
if *name == "dangerous_inner_html" {
|
||||
inner_html = Some(value);
|
||||
} else if let Some("style") = namespace {
|
||||
styles.push((name, value));
|
||||
} else if BOOL_ATTRS.contains(name) {
|
||||
if str_truthy(value) {
|
||||
write!(chain, " {name}=\"{value}\"",)?;
|
||||
}
|
||||
} else {
|
||||
write!(chain, " {name}=\"{value}\"")?;
|
||||
fn from_template_recursive(
|
||||
root: &TemplateNode,
|
||||
cur_path: &mut Vec<usize>,
|
||||
root_idx: usize,
|
||||
is_root: bool,
|
||||
chain: &mut StringChain,
|
||||
) -> Result<(), std::fmt::Error> {
|
||||
match root {
|
||||
TemplateNode::Element {
|
||||
tag,
|
||||
attrs,
|
||||
children,
|
||||
..
|
||||
} => {
|
||||
cur_path.push(root_idx);
|
||||
write!(chain, "<{tag}")?;
|
||||
// we need to collect the styles and write them at the end
|
||||
let mut styles = Vec::new();
|
||||
// we need to collect the inner html and write it at the end
|
||||
let mut inner_html = None;
|
||||
// we need to keep track of if we have dynamic attrs to know if we need to insert a style and inner_html marker
|
||||
let mut has_dyn_attrs = false;
|
||||
for attr in *attrs {
|
||||
match attr {
|
||||
TemplateAttribute::Static {
|
||||
name,
|
||||
value,
|
||||
namespace,
|
||||
} => {
|
||||
if *name == "dangerous_inner_html" {
|
||||
inner_html = Some(value);
|
||||
} else if let Some("style") = namespace {
|
||||
styles.push((name, value));
|
||||
} else if BOOL_ATTRS.contains(name) {
|
||||
if str_truthy(value) {
|
||||
write!(chain, " {name}=\"{value}\"",)?;
|
||||
}
|
||||
}
|
||||
TemplateAttribute::Dynamic { id: index } => {
|
||||
let index = *index;
|
||||
chain.segments.push(Segment::Attr(index));
|
||||
has_dyn_attrs = true
|
||||
} else {
|
||||
write!(chain, " {name}=\"{value}\"")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// write the styles
|
||||
if !styles.is_empty() {
|
||||
write!(chain, " style=\"")?;
|
||||
for (name, value) in styles {
|
||||
write!(chain, "{name}:{value};")?;
|
||||
TemplateAttribute::Dynamic { id: index } => {
|
||||
let index = *index;
|
||||
*chain += Segment::Attr(index);
|
||||
has_dyn_attrs = true
|
||||
}
|
||||
chain.segments.push(Segment::StyleMarker {
|
||||
inside_style_tag: true,
|
||||
});
|
||||
write!(chain, "\"")?;
|
||||
} else if has_dyn_attrs {
|
||||
chain.segments.push(Segment::StyleMarker {
|
||||
inside_style_tag: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// write the id if we are prerendering and this is either a root node or a node with a dynamic attribute
|
||||
if prerender && (has_dyn_attrs || is_root) {
|
||||
// write the styles
|
||||
if !styles.is_empty() {
|
||||
write!(chain, " style=\"")?;
|
||||
for (name, value) in styles {
|
||||
write!(chain, "{name}:{value};")?;
|
||||
}
|
||||
*chain += Segment::StyleMarker {
|
||||
inside_style_tag: true,
|
||||
};
|
||||
write!(chain, "\"")?;
|
||||
} else if has_dyn_attrs {
|
||||
*chain += Segment::StyleMarker {
|
||||
inside_style_tag: false,
|
||||
};
|
||||
}
|
||||
|
||||
// write the id if we are prerendering and this is either a root node or a node with a dynamic attribute
|
||||
if has_dyn_attrs || is_root {
|
||||
chain.if_hydration_enabled(|chain| {
|
||||
write!(chain, " data-node-hydration=\"")?;
|
||||
if has_dyn_attrs {
|
||||
chain.segments.push(Segment::AttributeNodeMarker);
|
||||
*chain += Segment::AttributeNodeMarker;
|
||||
} else if is_root {
|
||||
chain.segments.push(Segment::RootNodeMarker);
|
||||
*chain += Segment::RootNodeMarker;
|
||||
}
|
||||
write!(chain, "\"")?;
|
||||
}
|
||||
|
||||
if children.is_empty() && tag_is_self_closing(tag) {
|
||||
write!(chain, "/>")?;
|
||||
} else {
|
||||
write!(chain, ">")?;
|
||||
// Write the static inner html, or insert a marker if dynamic inner html is possible
|
||||
if let Some(inner_html) = inner_html {
|
||||
chain.write_str(inner_html)?;
|
||||
} else if has_dyn_attrs {
|
||||
chain.segments.push(Segment::InnerHtmlMarker);
|
||||
}
|
||||
|
||||
for child in *children {
|
||||
Self::recurse(child, cur_path, root_idx, false, prerender, chain)?;
|
||||
}
|
||||
write!(chain, "</{tag}>")?;
|
||||
}
|
||||
cur_path.pop();
|
||||
std::fmt::Result::Ok(())
|
||||
})?;
|
||||
}
|
||||
TemplateNode::Text { text } => {
|
||||
// write the id if we are prerendering and this is a root node that may need to be removed in the future
|
||||
if prerender && is_root {
|
||||
|
||||
if children.is_empty() && tag_is_self_closing(tag) {
|
||||
write!(chain, "/>")?;
|
||||
} else {
|
||||
write!(chain, ">")?;
|
||||
// Write the static inner html, or insert a marker if dynamic inner html is possible
|
||||
if let Some(inner_html) = inner_html {
|
||||
chain.write_str(inner_html)?;
|
||||
} else if has_dyn_attrs {
|
||||
*chain += Segment::InnerHtmlMarker;
|
||||
}
|
||||
|
||||
for child in *children {
|
||||
from_template_recursive(child, cur_path, root_idx, false, chain)?;
|
||||
}
|
||||
write!(chain, "</{tag}>")?;
|
||||
}
|
||||
cur_path.pop();
|
||||
}
|
||||
TemplateNode::Text { text } => {
|
||||
// write the id if we are prerendering and this is a root node that may need to be removed in the future
|
||||
if is_root {
|
||||
chain.if_hydration_enabled(|chain| {
|
||||
write!(chain, "<!--node-id")?;
|
||||
chain.segments.push(Segment::RootNodeMarker);
|
||||
*chain += Segment::RootNodeMarker;
|
||||
write!(chain, "-->")?;
|
||||
}
|
||||
write!(
|
||||
chain,
|
||||
"{}",
|
||||
askama_escape::escape(text, askama_escape::Html)
|
||||
)?;
|
||||
if prerender && is_root {
|
||||
write!(chain, "<!--#-->")?;
|
||||
}
|
||||
std::fmt::Result::Ok(())
|
||||
})?;
|
||||
}
|
||||
TemplateNode::Dynamic { id: idx } | TemplateNode::DynamicText { id: idx } => {
|
||||
chain.segments.push(Segment::Node(*idx))
|
||||
write!(
|
||||
chain,
|
||||
"{}",
|
||||
askama_escape::escape(text, askama_escape::Html)
|
||||
)?;
|
||||
if is_root {
|
||||
chain.if_hydration_enabled(|chain| write!(chain, "<!--#-->"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
TemplateNode::Dynamic { id: idx } => *chain += Segment::Node(*idx),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tag_is_self_closing(tag: &str) -> bool {
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
//! Incremental file based incremental rendering
|
||||
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use chrono::offset::Utc;
|
||||
use chrono::DateTime;
|
||||
use dioxus_core::VirtualDom;
|
||||
use rustc_hash::FxHasher;
|
||||
use std::{
|
||||
future::Future,
|
||||
hash::BuildHasherDefault,
|
||||
ops::{Deref, DerefMut},
|
||||
path::PathBuf,
|
||||
pin::Pin,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
|
||||
pub use crate::fs_cache::*;
|
||||
pub use crate::incremental_cfg::*;
|
||||
|
||||
/// An incremental renderer.
|
||||
pub struct IncrementalRenderer {
|
||||
pub(crate) static_dir: PathBuf,
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) memory_cache:
|
||||
Option<lru::LruCache<String, (DateTime<Utc>, Vec<u8>), BuildHasherDefault<FxHasher>>>,
|
||||
pub(crate) invalidate_after: Option<Duration>,
|
||||
pub(crate) ssr_renderer: crate::Renderer,
|
||||
pub(crate) map_path: PathMapFn,
|
||||
}
|
||||
|
||||
impl IncrementalRenderer {
|
||||
/// Get the inner renderer.
|
||||
pub fn renderer(&self) -> &crate::Renderer {
|
||||
&self.ssr_renderer
|
||||
}
|
||||
|
||||
/// Get the inner renderer mutably.
|
||||
pub fn renderer_mut(&mut self) -> &mut crate::Renderer {
|
||||
&mut self.ssr_renderer
|
||||
}
|
||||
|
||||
/// Create a new incremental renderer builder.
|
||||
pub fn builder() -> IncrementalRendererConfig {
|
||||
IncrementalRendererConfig::new()
|
||||
}
|
||||
|
||||
/// Remove a route from the cache.
|
||||
pub fn invalidate(&mut self, route: &str) {
|
||||
if let Some(cache) = &mut self.memory_cache {
|
||||
cache.pop(route);
|
||||
}
|
||||
if let Some(path) = self.find_file(route) {
|
||||
let _ = std::fs::remove_file(path.full_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all routes from the cache.
|
||||
pub fn invalidate_all(&mut self) {
|
||||
if let Some(cache) = &mut self.memory_cache {
|
||||
cache.clear();
|
||||
}
|
||||
// clear the static directory
|
||||
let _ = std::fs::remove_dir_all(&self.static_dir);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn track_timestamps(&self) -> bool {
|
||||
self.invalidate_after.is_some()
|
||||
}
|
||||
|
||||
async fn render_and_cache<'a, R: WrapBody + Send + Sync>(
|
||||
&'a mut self,
|
||||
route: String,
|
||||
mut virtual_dom: VirtualDom,
|
||||
output: &'a mut (impl AsyncWrite + Unpin + Send),
|
||||
rebuild_with: impl FnOnce(&mut VirtualDom) -> Pin<Box<dyn Future<Output = ()> + '_>>,
|
||||
renderer: &'a R,
|
||||
) -> Result<RenderFreshness, IncrementalRendererError> {
|
||||
let mut html_buffer = WriteBuffer { buffer: Vec::new() };
|
||||
{
|
||||
rebuild_with(&mut virtual_dom).await;
|
||||
|
||||
renderer.render_before_body(&mut *html_buffer)?;
|
||||
self.ssr_renderer
|
||||
.render_to(&mut html_buffer, &virtual_dom)?;
|
||||
}
|
||||
renderer.render_after_body(&mut *html_buffer)?;
|
||||
let html_buffer = html_buffer.buffer;
|
||||
|
||||
output.write_all(&html_buffer).await?;
|
||||
|
||||
self.add_to_cache(route, html_buffer)
|
||||
}
|
||||
|
||||
fn add_to_cache(
|
||||
&mut self,
|
||||
route: String,
|
||||
html: Vec<u8>,
|
||||
) -> Result<RenderFreshness, IncrementalRendererError> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::io::Write;
|
||||
let file_path = self.route_as_path(&route);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
let file = std::fs::File::create(file_path)?;
|
||||
let mut file = std::io::BufWriter::new(file);
|
||||
file.write_all(&html)?;
|
||||
}
|
||||
self.add_to_memory_cache(route, html);
|
||||
Ok(RenderFreshness::now(self.invalidate_after))
|
||||
}
|
||||
|
||||
fn add_to_memory_cache(&mut self, route: String, html: Vec<u8>) {
|
||||
if let Some(cache) = self.memory_cache.as_mut() {
|
||||
cache.put(route, (Utc::now(), html));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn promote_memory_cache<K: AsRef<str>>(&mut self, route: K) {
|
||||
if let Some(cache) = self.memory_cache.as_mut() {
|
||||
cache.promote(route.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_cache(
|
||||
&mut self,
|
||||
route: String,
|
||||
output: &mut (impl AsyncWrite + Unpin + std::marker::Send),
|
||||
) -> Result<Option<RenderFreshness>, IncrementalRendererError> {
|
||||
// check the memory cache
|
||||
if let Some((timestamp, cache_hit)) = self
|
||||
.memory_cache
|
||||
.as_mut()
|
||||
.and_then(|cache| cache.get(&route))
|
||||
{
|
||||
let now = Utc::now();
|
||||
let elapsed = timestamp.signed_duration_since(now);
|
||||
let age = elapsed.num_seconds();
|
||||
if let Some(invalidate_after) = self.invalidate_after {
|
||||
if elapsed.to_std().unwrap() < invalidate_after {
|
||||
tracing::trace!("memory cache hit {:?}", route);
|
||||
output.write_all(cache_hit).await?;
|
||||
let max_age = invalidate_after.as_secs();
|
||||
return Ok(Some(RenderFreshness::new(age as u64, max_age)));
|
||||
}
|
||||
} else {
|
||||
tracing::trace!("memory cache hit {:?}", route);
|
||||
output.write_all(cache_hit).await?;
|
||||
return Ok(Some(RenderFreshness::new_age(age as u64)));
|
||||
}
|
||||
}
|
||||
// check the file cache
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(file_path) = self.find_file(&route) {
|
||||
if let Some(freshness) = file_path.freshness(self.invalidate_after) {
|
||||
if let Ok(file) = tokio::fs::File::open(file_path.full_path).await {
|
||||
let mut file = tokio::io::BufReader::new(file);
|
||||
tokio::io::copy_buf(&mut file, output).await?;
|
||||
tracing::trace!("file cache hit {:?}", route);
|
||||
self.promote_memory_cache(&route);
|
||||
return Ok(Some(freshness));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Render a route or get it from cache.
|
||||
pub async fn render<R: WrapBody + Send + Sync>(
|
||||
&mut self,
|
||||
route: String,
|
||||
virtual_dom_factory: impl FnOnce() -> VirtualDom,
|
||||
output: &mut (impl AsyncWrite + Unpin + std::marker::Send),
|
||||
rebuild_with: impl FnOnce(&mut VirtualDom) -> Pin<Box<dyn Future<Output = ()> + '_>>,
|
||||
renderer: &R,
|
||||
) -> Result<RenderFreshness, IncrementalRendererError> {
|
||||
// check if this route is cached
|
||||
if let Some(freshness) = self.search_cache(route.to_string(), output).await? {
|
||||
Ok(freshness)
|
||||
} else {
|
||||
// if not, create it
|
||||
let freshness = self
|
||||
.render_and_cache(route, virtual_dom_factory(), output, rebuild_with, renderer)
|
||||
.await?;
|
||||
tracing::trace!("cache miss");
|
||||
Ok(freshness)
|
||||
}
|
||||
}
|
||||
|
||||
fn find_file(&self, route: &str) -> Option<ValidCachedPath> {
|
||||
let mut file_path = (self.map_path)(route);
|
||||
if let Some(deadline) = self.invalidate_after {
|
||||
// find the first file that matches the route and is a html file
|
||||
file_path.push("index");
|
||||
if let Ok(dir) = std::fs::read_dir(file_path) {
|
||||
let mut file = None;
|
||||
for entry in dir.flatten() {
|
||||
if let Some(cached_path) = ValidCachedPath::try_from_path(entry.path()) {
|
||||
if let Ok(elapsed) = cached_path.timestamp.elapsed() {
|
||||
if elapsed < deadline {
|
||||
file = Some(cached_path);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// if the timestamp is invalid or passed, delete the file
|
||||
if let Err(err) = std::fs::remove_file(entry.path()) {
|
||||
tracing::error!("Failed to remove file: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
file
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
file_path.push("index.html");
|
||||
file_path.exists().then_some({
|
||||
ValidCachedPath {
|
||||
full_path: file_path,
|
||||
timestamp: SystemTime::now(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn route_as_path(&self, route: &str) -> PathBuf {
|
||||
let mut file_path = (self.map_path)(route);
|
||||
if self.track_timestamps() {
|
||||
file_path.push("index");
|
||||
file_path.push(timestamp());
|
||||
} else {
|
||||
file_path.push("index");
|
||||
}
|
||||
file_path.set_extension("html");
|
||||
file_path
|
||||
}
|
||||
}
|
||||
|
||||
struct WriteBuffer {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::fmt::Write for WriteBuffer {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
self.buffer.extend_from_slice(s.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for WriteBuffer {
|
||||
type Target = Vec<u8>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.buffer
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for WriteBuffer {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.buffer
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that can occur while rendering a route or retrieving a cached route.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum IncrementalRendererError {
|
||||
/// An formatting error occurred while rendering a route.
|
||||
#[error("RenderError: {0}")]
|
||||
RenderError(#[from] std::fmt::Error),
|
||||
/// An IO error occurred while rendering a route.
|
||||
#[error("IoError: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
/// An IO error occurred while rendering a route.
|
||||
#[error("Other: {0}")]
|
||||
Other(#[from] Box<dyn std::error::Error + Send + Sync>),
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::incremental::IncrementalRenderer;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use super::fs_cache::PathMapFn;
|
||||
use super::memory_cache::InMemoryCache;
|
||||
|
||||
/// A configuration for the incremental renderer.
|
||||
#[derive(Clone)]
|
||||
pub struct IncrementalRendererConfig {
|
||||
static_dir: PathBuf,
|
||||
memory_cache_limit: usize,
|
||||
invalidate_after: Option<Duration>,
|
||||
map_path: Option<PathMapFn>,
|
||||
clear_cache: bool,
|
||||
pre_render: bool,
|
||||
}
|
||||
|
||||
impl Default for IncrementalRendererConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl IncrementalRendererConfig {
|
||||
/// Create a new incremental renderer configuration.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
static_dir: PathBuf::from("./static"),
|
||||
memory_cache_limit: 10000,
|
||||
invalidate_after: None,
|
||||
map_path: None,
|
||||
clear_cache: true,
|
||||
pre_render: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the cache on startup (default: true)
|
||||
pub fn clear_cache(mut self, clear_cache: bool) -> Self {
|
||||
self.clear_cache = clear_cache;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a mapping from the route to the file path. This will override the default mapping configured with `static_dir`.
|
||||
/// The function should return the path to the folder to store the index.html file in.
|
||||
pub fn map_path<F: Fn(&str) -> PathBuf + Send + Sync + 'static>(mut self, map_path: F) -> Self {
|
||||
self.map_path = Some(Arc::new(map_path));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the static directory.
|
||||
pub fn static_dir<P: AsRef<Path>>(mut self, static_dir: P) -> Self {
|
||||
self.static_dir = static_dir.as_ref().to_path_buf();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the memory cache limit.
|
||||
pub const fn memory_cache_limit(mut self, memory_cache_limit: usize) -> Self {
|
||||
self.memory_cache_limit = memory_cache_limit;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the invalidation time.
|
||||
pub fn invalidate_after(mut self, invalidate_after: Duration) -> Self {
|
||||
self.invalidate_after = Some(invalidate_after);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether to include hydration ids in the pre-rendered html.
|
||||
pub fn pre_render(mut self, pre_render: bool) -> Self {
|
||||
self.pre_render = pre_render;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the incremental renderer.
|
||||
pub fn build(self) -> IncrementalRenderer {
|
||||
let mut renderer = IncrementalRenderer {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
file_system_cache: crate::incremental::fs_cache::FileSystemCache::new(
|
||||
self.static_dir.clone(),
|
||||
self.map_path,
|
||||
self.invalidate_after,
|
||||
),
|
||||
memory_cache: InMemoryCache::new(self.memory_cache_limit, self.invalidate_after),
|
||||
invalidate_after: self.invalidate_after,
|
||||
};
|
||||
|
||||
if self.clear_cache {
|
||||
renderer.invalidate_all();
|
||||
}
|
||||
|
||||
renderer
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#![allow(non_snake_case)]
|
||||
use std::time::Duration;
|
||||
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Information about the freshness of a rendered response
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -9,20 +9,39 @@ pub struct RenderFreshness {
|
||||
age: u64,
|
||||
/// The maximum age of the rendered response
|
||||
max_age: Option<u64>,
|
||||
/// The time the response was rendered
|
||||
timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl RenderFreshness {
|
||||
/// Create new freshness information
|
||||
pub fn new(age: u64, max_age: u64) -> Self {
|
||||
pub(crate) fn new(age: u64, max_age: u64, timestamp: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
age,
|
||||
max_age: Some(max_age),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new freshness information with only the age
|
||||
pub fn new_age(age: u64) -> Self {
|
||||
Self { age, max_age: None }
|
||||
pub(crate) fn new_age(age: u64, timestamp: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
age,
|
||||
max_age: None,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new freshness information from a timestamp
|
||||
pub(crate) fn created_at(timestamp: DateTime<Utc>, max_age: Option<Duration>) -> Self {
|
||||
Self {
|
||||
age: timestamp
|
||||
.signed_duration_since(Utc::now())
|
||||
.num_seconds()
|
||||
.unsigned_abs(),
|
||||
max_age: max_age.map(|d| d.as_secs()),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new freshness information at the current time
|
||||
@@ -30,6 +49,7 @@ impl RenderFreshness {
|
||||
Self {
|
||||
age: 0,
|
||||
max_age: max_age.map(|d| d.as_secs()),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +63,11 @@ impl RenderFreshness {
|
||||
self.max_age
|
||||
}
|
||||
|
||||
/// Get the time the response was rendered
|
||||
pub fn timestamp(&self) -> DateTime<Utc> {
|
||||
self.timestamp
|
||||
}
|
||||
|
||||
/// Write the freshness to the response headers.
|
||||
pub fn write(&self, headers: &mut http::HeaderMap<http::HeaderValue>) {
|
||||
let age = self.age();
|
||||
@@ -55,43 +80,3 @@ impl RenderFreshness {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ValidCachedPath {
|
||||
pub(crate) full_path: PathBuf,
|
||||
pub(crate) timestamp: std::time::SystemTime,
|
||||
}
|
||||
|
||||
impl ValidCachedPath {
|
||||
pub fn try_from_path(value: PathBuf) -> Option<Self> {
|
||||
if value.extension() != Some(std::ffi::OsStr::new("html")) {
|
||||
return None;
|
||||
}
|
||||
let timestamp = decode_timestamp(value.file_stem()?.to_str()?)?;
|
||||
let full_path = value;
|
||||
Some(Self {
|
||||
full_path,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn freshness(&self, max_age: Option<std::time::Duration>) -> Option<RenderFreshness> {
|
||||
let age = self.timestamp.elapsed().ok()?.as_secs();
|
||||
let max_age = max_age.map(|max_age| max_age.as_secs());
|
||||
Some(RenderFreshness::new(age, max_age?))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_timestamp(timestamp: &str) -> Option<std::time::SystemTime> {
|
||||
let timestamp = u64::from_str_radix(timestamp, 16).ok()?;
|
||||
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(timestamp))
|
||||
}
|
||||
|
||||
pub fn timestamp() -> String {
|
||||
let datetime = std::time::SystemTime::now();
|
||||
let timestamp = datetime
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
format!("{:x}", timestamp)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{IncrementalRendererError, RenderFreshness};
|
||||
use std::{path::PathBuf, sync::Arc, time::SystemTime};
|
||||
|
||||
pub(crate) type PathMapFn = Arc<dyn Fn(&str) -> PathBuf + Send + Sync>;
|
||||
|
||||
pub(crate) struct FileSystemCache {
|
||||
static_dir: PathBuf,
|
||||
map_path: PathMapFn,
|
||||
invalidate_after: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl FileSystemCache {
|
||||
pub fn new(
|
||||
static_dir: PathBuf,
|
||||
map_path: Option<PathMapFn>,
|
||||
invalidate_after: Option<std::time::Duration>,
|
||||
) -> Self {
|
||||
Self {
|
||||
static_dir: static_dir.clone(),
|
||||
map_path: map_path.unwrap_or_else(move || {
|
||||
Arc::new(move |route: &str| {
|
||||
let (before_query, _) = route.split_once('?').unwrap_or((route, ""));
|
||||
let mut path = static_dir.clone();
|
||||
for segment in before_query.split('/') {
|
||||
path.push(segment);
|
||||
}
|
||||
path
|
||||
})
|
||||
}),
|
||||
invalidate_after,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn put(
|
||||
&mut self,
|
||||
route: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
data: Vec<u8>,
|
||||
) -> Result<(), IncrementalRendererError> {
|
||||
use std::io::Write;
|
||||
let file_path = self.route_as_path(&route, timestamp);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
let file = std::fs::File::create(file_path)?;
|
||||
let mut file = std::io::BufWriter::new(file);
|
||||
file.write_all(&data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
// clear the static directory
|
||||
let _ = std::fs::remove_dir_all(&self.static_dir);
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self, route: &str) {
|
||||
let file_path = self.find_file(route).unwrap().full_path;
|
||||
if let Err(err) = std::fs::remove_file(file_path) {
|
||||
tracing::error!("Failed to remove file: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
route: &str,
|
||||
) -> Result<Option<(RenderFreshness, Vec<u8>)>, IncrementalRendererError> {
|
||||
if let Some(file_path) = self.find_file(route) {
|
||||
if let Some(freshness) = file_path.freshness(self.invalidate_after) {
|
||||
if let Ok(file) = std::fs::File::open(file_path.full_path) {
|
||||
let mut file = std::io::BufReader::new(file);
|
||||
let mut cache_hit = Vec::new();
|
||||
std::io::copy(&mut file, &mut cache_hit)?;
|
||||
tracing::trace!("file cache hit {:?}", route);
|
||||
return Ok(Some((freshness, cache_hit)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn find_file(&self, route: &str) -> Option<ValidCachedPath> {
|
||||
let mut file_path = (self.map_path)(route);
|
||||
if let Some(deadline) = self.invalidate_after {
|
||||
// find the first file that matches the route and is a html file
|
||||
file_path.push("index");
|
||||
if let Ok(dir) = std::fs::read_dir(file_path) {
|
||||
for entry in dir.flatten() {
|
||||
if let Some(cached_path) = ValidCachedPath::try_from_path(entry.path()) {
|
||||
if let Ok(elapsed) = cached_path.timestamp.elapsed() {
|
||||
if elapsed < deadline {
|
||||
// The timestamp is valid, return the file
|
||||
return Some(cached_path);
|
||||
}
|
||||
}
|
||||
// if the timestamp is invalid or passed, delete the file
|
||||
if let Err(err) = std::fs::remove_file(entry.path()) {
|
||||
tracing::error!("Failed to remove file: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
file_path.push("index.html");
|
||||
file_path.exists().then_some({
|
||||
ValidCachedPath {
|
||||
full_path: file_path,
|
||||
timestamp: SystemTime::now(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn route_as_path(&self, route: &str, timestamp: DateTime<Utc>) -> PathBuf {
|
||||
let mut file_path = (self.map_path)(route);
|
||||
if self.track_timestamps() {
|
||||
file_path.push("index");
|
||||
file_path.push(timestamp_to_string(timestamp));
|
||||
} else {
|
||||
file_path.push("index");
|
||||
}
|
||||
file_path.set_extension("html");
|
||||
file_path
|
||||
}
|
||||
|
||||
fn track_timestamps(&self) -> bool {
|
||||
self.invalidate_after.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ValidCachedPath {
|
||||
pub(crate) full_path: PathBuf,
|
||||
pub(crate) timestamp: std::time::SystemTime,
|
||||
}
|
||||
|
||||
impl ValidCachedPath {
|
||||
pub fn try_from_path(value: PathBuf) -> Option<Self> {
|
||||
if value.extension() != Some(std::ffi::OsStr::new("html")) {
|
||||
return None;
|
||||
}
|
||||
let timestamp = decode_timestamp(value.file_stem()?.to_str()?)?;
|
||||
let full_path = value;
|
||||
Some(Self {
|
||||
full_path,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn freshness(&self, max_age: Option<std::time::Duration>) -> Option<RenderFreshness> {
|
||||
let age = self.timestamp.elapsed().ok()?.as_secs();
|
||||
let max_age = max_age.map(|max_age| max_age.as_secs());
|
||||
Some(RenderFreshness::new(age, max_age?, self.timestamp.into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_timestamp(timestamp: &str) -> Option<std::time::SystemTime> {
|
||||
let timestamp = u64::from_str_radix(timestamp, 16).ok()?;
|
||||
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(timestamp))
|
||||
}
|
||||
|
||||
pub(crate) fn timestamp_to_string(timestamp: DateTime<Utc>) -> String {
|
||||
let timestamp = timestamp
|
||||
.signed_duration_since(DateTime::<Utc>::from(std::time::UNIX_EPOCH))
|
||||
.num_seconds();
|
||||
format!("{:x}", timestamp)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Incremental file based incremental rendering
|
||||
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use chrono::offset::Utc;
|
||||
use chrono::DateTime;
|
||||
use rustc_hash::FxHasher;
|
||||
use std::{hash::BuildHasherDefault, num::NonZeroUsize};
|
||||
|
||||
use super::freshness::RenderFreshness;
|
||||
|
||||
pub(crate) struct InMemoryCache {
|
||||
#[allow(clippy::type_complexity)]
|
||||
lru: Option<lru::LruCache<String, (DateTime<Utc>, Vec<u8>), BuildHasherDefault<FxHasher>>>,
|
||||
invalidate_after: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl InMemoryCache {
|
||||
pub fn new(memory_cache_limit: usize, invalidate_after: Option<std::time::Duration>) -> Self {
|
||||
Self {
|
||||
lru: NonZeroUsize::new(memory_cache_limit)
|
||||
.map(|limit| lru::LruCache::with_hasher(limit, Default::default())),
|
||||
invalidate_after,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
if let Some(cache) = &mut self.lru {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn put(&mut self, route: String, timestamp: DateTime<Utc>, data: Vec<u8>) {
|
||||
if let Some(cache) = &mut self.lru {
|
||||
cache.put(route, (timestamp, data));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self, route: &str) {
|
||||
if let Some(cache) = &mut self.lru {
|
||||
cache.pop(route);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_get_or_insert<'a, F: FnOnce() -> Result<(DateTime<Utc>, Vec<u8>), E>, E>(
|
||||
&'a mut self,
|
||||
route: &str,
|
||||
or_insert: F,
|
||||
) -> Result<Option<(RenderFreshness, &'a [u8])>, E> {
|
||||
if let Some(memory_cache) = self.lru.as_mut() {
|
||||
let (timestamp, _) = memory_cache.try_get_or_insert(route.to_string(), or_insert)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let elapsed = timestamp.signed_duration_since(now);
|
||||
let age = elapsed.num_seconds();
|
||||
// The cache entry is out of date, so we need to remove it.
|
||||
if let Some(invalidate_after) = self.invalidate_after {
|
||||
if elapsed.to_std().unwrap() > invalidate_after {
|
||||
tracing::trace!("memory cache out of date");
|
||||
memory_cache.pop(route);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// We need to reborrow because we may have invalidated the lifetime if the route was removed.
|
||||
// We know it wasn't because we returned... but rust doesn't understand that.
|
||||
let (timestamp, cache_hit) = memory_cache.get(route).unwrap();
|
||||
|
||||
return match self.invalidate_after {
|
||||
Some(invalidate_after) => {
|
||||
tracing::trace!("memory cache hit");
|
||||
let max_age = invalidate_after.as_secs();
|
||||
let freshness = RenderFreshness::new(age as u64, max_age, *timestamp);
|
||||
Ok(Some((freshness, cache_hit)))
|
||||
}
|
||||
None => {
|
||||
tracing::trace!("memory cache hit");
|
||||
let freshness = RenderFreshness::new_age(age as u64, *timestamp);
|
||||
Ok(Some((freshness, cache_hit)))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Incremental file based incremental rendering
|
||||
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
mod config;
|
||||
mod freshness;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod fs_cache;
|
||||
mod memory_cache;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
pub use config::*;
|
||||
pub use freshness::*;
|
||||
|
||||
use self::memory_cache::InMemoryCache;
|
||||
|
||||
/// A render that was cached from a previous render.
|
||||
pub struct CachedRender<'a> {
|
||||
/// The route that was rendered
|
||||
pub route: String,
|
||||
/// The freshness information for the rendered response
|
||||
pub freshness: RenderFreshness,
|
||||
/// The rendered response
|
||||
pub response: &'a [u8],
|
||||
}
|
||||
|
||||
/// An incremental renderer.
|
||||
pub struct IncrementalRenderer {
|
||||
pub(crate) memory_cache: InMemoryCache,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) file_system_cache: fs_cache::FileSystemCache,
|
||||
invalidate_after: Option<Duration>,
|
||||
}
|
||||
|
||||
impl IncrementalRenderer {
|
||||
/// Create a new incremental renderer builder.
|
||||
pub fn builder() -> IncrementalRendererConfig {
|
||||
IncrementalRendererConfig::new()
|
||||
}
|
||||
|
||||
/// Remove a route from the cache.
|
||||
pub fn invalidate(&mut self, route: &str) {
|
||||
self.memory_cache.invalidate(route);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.file_system_cache.invalidate(route);
|
||||
}
|
||||
|
||||
/// Remove all routes from the cache.
|
||||
pub fn invalidate_all(&mut self) {
|
||||
self.memory_cache.clear();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.file_system_cache.clear();
|
||||
}
|
||||
|
||||
/// Cache a rendered response.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use dioxus_ssr::incremental::IncrementalRenderer;
|
||||
/// # let mut renderer = IncrementalRenderer::builder().build();
|
||||
/// let route = "/index".to_string();
|
||||
/// let response = b"<html><body>Hello world</body></html>";
|
||||
/// renderer.cache(route, response).unwrap();
|
||||
/// ```
|
||||
pub fn cache(
|
||||
&mut self,
|
||||
route: String,
|
||||
html: impl Into<Vec<u8>>,
|
||||
) -> Result<RenderFreshness, IncrementalRendererError> {
|
||||
let timestamp = Utc::now();
|
||||
let html = html.into();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.file_system_cache
|
||||
.put(route.clone(), timestamp, html.clone())?;
|
||||
self.memory_cache.put(route, timestamp, html);
|
||||
Ok(RenderFreshness::created_at(
|
||||
timestamp,
|
||||
self.invalidate_after,
|
||||
))
|
||||
}
|
||||
|
||||
/// Try to get a cached response for a route.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use dioxus_ssr::incremental::IncrementalRenderer;
|
||||
/// # let mut renderer = IncrementalRenderer::builder().build();
|
||||
/// # let route = "/index".to_string();
|
||||
/// # let response = b"<html><body>Hello world</body></html>";
|
||||
/// # renderer.cache(route, response).unwrap();
|
||||
/// let route = "/index";
|
||||
/// let response = renderer.get(route).unwrap();
|
||||
/// assert_eq!(response.unwrap().response, b"<html><body>Hello world</body></html>");
|
||||
/// ```
|
||||
///
|
||||
/// If the route is not cached, `None` is returned.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use dioxus_ssr::incremental::IncrementalRenderer;
|
||||
/// # let mut renderer = IncrementalRenderer::builder().build();
|
||||
/// let route = "/index";
|
||||
/// let response = renderer.get(route).unwrap();
|
||||
/// assert!(response.is_none());
|
||||
/// ```
|
||||
pub fn get<'a>(
|
||||
&'a mut self,
|
||||
route: &str,
|
||||
) -> Result<Option<CachedRender<'a>>, IncrementalRendererError> {
|
||||
let Self {
|
||||
memory_cache,
|
||||
file_system_cache,
|
||||
..
|
||||
} = self;
|
||||
|
||||
enum FsGetError {
|
||||
NotPresent,
|
||||
Error(IncrementalRendererError),
|
||||
}
|
||||
|
||||
// The borrow checker prevents us from simply using a match/if and returning early. Instead we need to use the more complex closure API
|
||||
// non lexical lifetimes will make this possible (it works with polonius)
|
||||
let or_insert = || {
|
||||
// check the file cache
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return match file_system_cache.get(route) {
|
||||
Ok(Some((freshness, bytes))) => Ok((freshness.timestamp(), bytes)),
|
||||
Ok(None) => Err(FsGetError::NotPresent),
|
||||
Err(e) => Err(FsGetError::Error(e)),
|
||||
};
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
Err(FsGetError::NotPresent)
|
||||
};
|
||||
|
||||
match memory_cache.try_get_or_insert(route, or_insert) {
|
||||
Ok(Some((freshness, bytes))) => Ok(Some(CachedRender {
|
||||
route: route.to_string(),
|
||||
freshness,
|
||||
response: bytes,
|
||||
})),
|
||||
Err(FsGetError::NotPresent) | Ok(None) => Ok(None),
|
||||
Err(FsGetError::Error(e)) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that can occur while rendering a route or retrieving a cached route.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum IncrementalRendererError {
|
||||
/// An formatting error occurred while rendering a route.
|
||||
#[error("RenderError: {0}")]
|
||||
RenderError(#[from] std::fmt::Error),
|
||||
/// An IO error occurred while rendering a route.
|
||||
#[error("IoError: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
/// An IO error occurred while rendering a route.
|
||||
#[error("Other: {0}")]
|
||||
Other(#[from] Box<dyn std::error::Error + Send + Sync>),
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::incremental::IncrementalRenderer;
|
||||
use crate::incremental::IncrementalRendererError;
|
||||
|
||||
use std::{
|
||||
io::Write,
|
||||
num::NonZeroUsize,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
/// Something that can render a HTML page from a body.
|
||||
pub trait WrapBody {
|
||||
/// Render the HTML before the body
|
||||
fn render_before_body<R: Write>(&self, to: &mut R) -> Result<(), IncrementalRendererError>;
|
||||
/// Render the HTML after the body
|
||||
fn render_after_body<R: Write>(&self, to: &mut R) -> Result<(), IncrementalRendererError>;
|
||||
}
|
||||
|
||||
/// The default page renderer
|
||||
pub struct DefaultRenderer {
|
||||
/// The HTML before the body.
|
||||
pub before_body: String,
|
||||
/// The HTML after the body.
|
||||
pub after_body: String,
|
||||
}
|
||||
|
||||
impl Default for DefaultRenderer {
|
||||
fn default() -> Self {
|
||||
let title = dioxus_cli_config::CURRENT_CONFIG
|
||||
.as_ref()
|
||||
.map(|c| c.dioxus_config.application.name.clone())
|
||||
.unwrap_or("Dioxus Application".into());
|
||||
let before = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{}</title>
|
||||
</head>
|
||||
<body>"#,
|
||||
title
|
||||
);
|
||||
let after = r#"</body>
|
||||
</html>"#;
|
||||
Self {
|
||||
before_body: before.to_string(),
|
||||
after_body: after.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WrapBody for DefaultRenderer {
|
||||
fn render_before_body<R: Write>(&self, to: &mut R) -> Result<(), IncrementalRendererError> {
|
||||
to.write_all(self.before_body.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_after_body<R: Write>(&self, to: &mut R) -> Result<(), IncrementalRendererError> {
|
||||
to.write_all(self.after_body.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type PathMapFn = Arc<dyn Fn(&str) -> PathBuf + Send + Sync>;
|
||||
|
||||
/// A configuration for the incremental renderer.
|
||||
#[derive(Clone)]
|
||||
pub struct IncrementalRendererConfig {
|
||||
static_dir: PathBuf,
|
||||
memory_cache_limit: usize,
|
||||
invalidate_after: Option<Duration>,
|
||||
map_path: Option<PathMapFn>,
|
||||
clear_cache: bool,
|
||||
pre_render: bool,
|
||||
}
|
||||
|
||||
impl Default for IncrementalRendererConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl IncrementalRendererConfig {
|
||||
/// Create a new incremental renderer configuration.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
static_dir: PathBuf::from("./static"),
|
||||
memory_cache_limit: 10000,
|
||||
invalidate_after: None,
|
||||
map_path: None,
|
||||
clear_cache: true,
|
||||
pre_render: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the cache on startup (default: true)
|
||||
pub fn clear_cache(mut self, clear_cache: bool) -> Self {
|
||||
self.clear_cache = clear_cache;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a mapping from the route to the file path. This will override the default mapping configured with `static_dir`.
|
||||
/// The function should return the path to the folder to store the index.html file in.
|
||||
pub fn map_path<F: Fn(&str) -> PathBuf + Send + Sync + 'static>(mut self, map_path: F) -> Self {
|
||||
self.map_path = Some(Arc::new(map_path));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the static directory.
|
||||
pub fn static_dir<P: AsRef<Path>>(mut self, static_dir: P) -> Self {
|
||||
self.static_dir = static_dir.as_ref().to_path_buf();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the memory cache limit.
|
||||
pub const fn memory_cache_limit(mut self, memory_cache_limit: usize) -> Self {
|
||||
self.memory_cache_limit = memory_cache_limit;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the invalidation time.
|
||||
pub fn invalidate_after(mut self, invalidate_after: Duration) -> Self {
|
||||
self.invalidate_after = Some(invalidate_after);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether to include hydration ids in the pre-rendered html.
|
||||
pub fn pre_render(mut self, pre_render: bool) -> Self {
|
||||
self.pre_render = pre_render;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the incremental renderer.
|
||||
pub fn build(self) -> IncrementalRenderer {
|
||||
let static_dir = self.static_dir.clone();
|
||||
let mut ssr_renderer = crate::Renderer::new();
|
||||
if self.pre_render {
|
||||
ssr_renderer.pre_render = true;
|
||||
}
|
||||
let mut renderer = IncrementalRenderer {
|
||||
static_dir: self.static_dir.clone(),
|
||||
memory_cache: NonZeroUsize::new(self.memory_cache_limit)
|
||||
.map(|limit| lru::LruCache::with_hasher(limit, Default::default())),
|
||||
invalidate_after: self.invalidate_after,
|
||||
ssr_renderer,
|
||||
map_path: self.map_path.unwrap_or_else(move || {
|
||||
Arc::new(move |route: &str| {
|
||||
let (before_query, _) = route.split_once('?').unwrap_or((route, ""));
|
||||
let mut path = static_dir.clone();
|
||||
for segment in before_query.split('/') {
|
||||
path.push(segment);
|
||||
}
|
||||
path
|
||||
})
|
||||
}),
|
||||
};
|
||||
|
||||
if self.clear_cache {
|
||||
renderer.invalidate_all();
|
||||
}
|
||||
|
||||
renderer
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,10 @@
|
||||
mod cache;
|
||||
pub mod config;
|
||||
#[cfg(feature = "incremental")]
|
||||
mod fs_cache;
|
||||
#[cfg(feature = "incremental")]
|
||||
pub mod incremental;
|
||||
#[cfg(feature = "incremental")]
|
||||
mod incremental_cfg;
|
||||
|
||||
pub mod renderer;
|
||||
pub mod template;
|
||||
|
||||
use dioxus_core::NoOpMutations;
|
||||
use dioxus_core::{Element, VirtualDom};
|
||||
|
||||
pub use crate::renderer::Renderer;
|
||||
@@ -28,7 +22,7 @@ pub fn render_element(element: Element) -> String {
|
||||
}
|
||||
|
||||
let mut dom = VirtualDom::new_with_props(lazy_app, element);
|
||||
dom.rebuild(&mut NoOpMutations);
|
||||
dom.rebuild_in_place();
|
||||
|
||||
Renderer::new().render(&dom)
|
||||
}
|
||||
|
||||
@@ -2,31 +2,25 @@ use super::cache::Segment;
|
||||
use crate::cache::StringCache;
|
||||
|
||||
use dioxus_core::{prelude::*, AttributeValue, DynamicNode};
|
||||
use std::collections::HashMap;
|
||||
use rustc_hash::FxHashMap;
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
type ComponentRenderCallback = Arc<
|
||||
dyn Fn(&mut Renderer, &mut dyn Write, &VirtualDom, ScopeId) -> std::fmt::Result + Send + Sync,
|
||||
>;
|
||||
|
||||
/// A virtualdom renderer that caches the templates it has seen for faster rendering
|
||||
#[derive(Default)]
|
||||
pub struct Renderer {
|
||||
/// should we do our best to prettify the output?
|
||||
pub pretty: bool,
|
||||
|
||||
/// Control if elements are written onto a new line
|
||||
pub newline: bool,
|
||||
|
||||
/// Should we sanitize text nodes? (escape HTML)
|
||||
pub sanitize: bool,
|
||||
|
||||
/// Choose to write ElementIDs into elements so the page can be re-hydrated later on
|
||||
pub pre_render: bool,
|
||||
|
||||
// Currently not implemented
|
||||
// Don't proceed onto new components. Instead, put the name of the component.
|
||||
pub skip_components: bool,
|
||||
/// A callback used to render components. You can set this callback to control what components are rendered and add wrappers around components that are not present in CSR
|
||||
render_components: Option<ComponentRenderCallback>,
|
||||
|
||||
/// A cache of templates that have been rendered
|
||||
template_cache: HashMap<&'static str, Arc<StringCache>>,
|
||||
template_cache: FxHashMap<usize, Arc<StringCache>>,
|
||||
|
||||
/// The current dynamic node id for hydration
|
||||
dynamic_node_id: usize,
|
||||
@@ -37,42 +31,64 @@ impl Renderer {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set the callback that the renderer uses to render components
|
||||
pub fn set_render_components(
|
||||
&mut self,
|
||||
callback: impl Fn(&mut Renderer, &mut dyn Write, &VirtualDom, ScopeId) -> std::fmt::Result
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
) {
|
||||
self.render_components = Some(Arc::new(callback));
|
||||
}
|
||||
|
||||
/// Reset the callback that the renderer uses to render components
|
||||
pub fn reset_render_components(&mut self) {
|
||||
self.render_components = None;
|
||||
}
|
||||
|
||||
pub fn render(&mut self, dom: &VirtualDom) -> String {
|
||||
let mut buf = String::new();
|
||||
self.render_to(&mut buf, dom).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn render_to(&mut self, buf: &mut impl Write, dom: &VirtualDom) -> std::fmt::Result {
|
||||
pub fn render_to<W: Write + ?Sized>(
|
||||
&mut self,
|
||||
buf: &mut W,
|
||||
dom: &VirtualDom,
|
||||
) -> std::fmt::Result {
|
||||
self.reset_hydration();
|
||||
self.render_scope(buf, dom, ScopeId::ROOT)
|
||||
}
|
||||
|
||||
pub fn render_scope(
|
||||
/// Reset the renderer hydration state
|
||||
pub fn reset_hydration(&mut self) {
|
||||
self.dynamic_node_id = 0;
|
||||
}
|
||||
|
||||
pub fn render_scope<W: Write + ?Sized>(
|
||||
&mut self,
|
||||
buf: &mut impl Write,
|
||||
buf: &mut W,
|
||||
dom: &VirtualDom,
|
||||
scope: ScopeId,
|
||||
) -> std::fmt::Result {
|
||||
let node = dom.get_scope(scope).unwrap().root_node();
|
||||
self.dynamic_node_id = 0;
|
||||
self.render_template(buf, dom, node)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_template(
|
||||
fn render_template<W: Write + ?Sized>(
|
||||
&mut self,
|
||||
buf: &mut impl Write,
|
||||
mut buf: &mut W,
|
||||
dom: &VirtualDom,
|
||||
template: &VNode,
|
||||
) -> std::fmt::Result {
|
||||
let entry = self
|
||||
.template_cache
|
||||
.entry(template.template.get().name)
|
||||
.or_insert_with({
|
||||
let prerender = self.pre_render;
|
||||
move || Arc::new(StringCache::from_template(template, prerender).unwrap())
|
||||
})
|
||||
.entry(template.template.get().id())
|
||||
.or_insert_with(move || Arc::new(StringCache::from_template(template).unwrap()))
|
||||
.clone();
|
||||
|
||||
let mut inner_html = None;
|
||||
@@ -83,8 +99,19 @@ impl Renderer {
|
||||
// We need to keep track of the listeners so we can insert them into the right place
|
||||
let mut accumulated_listeners = Vec::new();
|
||||
|
||||
for segment in entry.segments.iter() {
|
||||
// We keep track of the index we are on manually so that we can jump forward to a new section quickly without iterating every item
|
||||
let mut index = 0;
|
||||
|
||||
while let Some(segment) = entry.segments.get(index) {
|
||||
match segment {
|
||||
Segment::HydrationOnlySection(jump_to) => {
|
||||
// If we are not prerendering, we don't need to write the content of the hydration only section
|
||||
// Instead we can jump to the next section
|
||||
if !self.pre_render {
|
||||
index = *jump_to;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Segment::Attr(idx) => {
|
||||
let attrs = &*template.dynamic_attrs[*idx];
|
||||
for attr in attrs {
|
||||
@@ -112,8 +139,10 @@ impl Renderer {
|
||||
}
|
||||
Segment::Node(idx) => match &template.dynamic_nodes[*idx] {
|
||||
DynamicNode::Component(node) => {
|
||||
if self.skip_components {
|
||||
write!(buf, "<{}><{}/>", node.name, node.name)?;
|
||||
if let Some(render_components) = self.render_components.clone() {
|
||||
let scope_id = node.mounted_scope_id(*idx, template, dom).unwrap();
|
||||
|
||||
render_components(self, &mut buf, dom, scope_id)?;
|
||||
} else {
|
||||
let scope = node.mounted_scope(*idx, template, dom).unwrap();
|
||||
let node = scope.root_node();
|
||||
@@ -147,7 +176,7 @@ impl Renderer {
|
||||
if self.pre_render {
|
||||
write!(
|
||||
buf,
|
||||
"<pre data-node-hydration={}></pre>",
|
||||
"<pre data-node-hydration={} hidden></pre>",
|
||||
self.dynamic_node_id
|
||||
)?;
|
||||
self.dynamic_node_id += 1;
|
||||
@@ -206,6 +235,8 @@ impl Renderer {
|
||||
self.dynamic_node_id += 1
|
||||
}
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -243,25 +274,30 @@ fn to_string_works() {
|
||||
let out = renderer.render(&dom);
|
||||
|
||||
for item in renderer.template_cache.iter() {
|
||||
if item.1.segments.len() > 5 {
|
||||
if item.1.segments.len() > 10 {
|
||||
assert_eq!(
|
||||
item.1.segments,
|
||||
vec![
|
||||
PreRendered("<div class=\"asdasdasd asdasdasd\"".into(),),
|
||||
Attr(0,),
|
||||
PreRendered("<div class=\"asdasdasd asdasdasd\"".to_string()),
|
||||
Attr(0),
|
||||
StyleMarker {
|
||||
inside_style_tag: false,
|
||||
inside_style_tag: false
|
||||
},
|
||||
PreRendered(">".into()),
|
||||
HydrationOnlySection(7), // jump to `>` if we don't need to hydrate
|
||||
PreRendered(" data-node-hydration=\"".to_string()),
|
||||
AttributeNodeMarker,
|
||||
PreRendered("\"".to_string()),
|
||||
PreRendered(">".to_string()),
|
||||
InnerHtmlMarker,
|
||||
PreRendered("Hello world 1 -->".into(),),
|
||||
Node(0,),
|
||||
PreRendered("Hello world 1 -->".to_string()),
|
||||
Node(0),
|
||||
PreRendered(
|
||||
"<-- Hello world 2<div>nest 1</div><div></div><div>nest 2</div>".into(),
|
||||
"<-- Hello world 2<div>nest 1</div><div></div><div>nest 2</div>"
|
||||
.to_string()
|
||||
),
|
||||
Node(1,),
|
||||
Node(2,),
|
||||
PreRendered("</div>".into(),),
|
||||
Node(1),
|
||||
Node(2),
|
||||
PreRendered("</div>".to_string())
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -297,14 +333,14 @@ fn empty_for_loop_works() {
|
||||
assert_eq!(
|
||||
item.1.segments,
|
||||
vec![
|
||||
PreRendered("<div class=\"asdasdasd\"".into(),),
|
||||
Attr(0,),
|
||||
StyleMarker {
|
||||
inside_style_tag: false,
|
||||
},
|
||||
PreRendered(">".into()),
|
||||
InnerHtmlMarker,
|
||||
PreRendered("</div>".into(),),
|
||||
PreRendered("<div class=\"asdasdasd\"".to_string()),
|
||||
HydrationOnlySection(5), // jump to `>` if we don't need to hydrate
|
||||
PreRendered(" data-node-hydration=\"".to_string()),
|
||||
RootNodeMarker,
|
||||
PreRendered("\"".to_string()),
|
||||
PreRendered(">".to_string()),
|
||||
Node(0),
|
||||
PreRendered("</div>".to_string())
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -381,7 +417,10 @@ pub(crate) fn truthy(value: &AttributeValue) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_attribute(buf: &mut impl Write, attr: &Attribute) -> std::fmt::Result {
|
||||
pub(crate) fn write_attribute<W: Write + ?Sized>(
|
||||
buf: &mut W,
|
||||
attr: &Attribute,
|
||||
) -> std::fmt::Result {
|
||||
let name = &attr.name;
|
||||
match &attr.value {
|
||||
AttributeValue::Text(value) => write!(buf, " {name}=\"{value}\""),
|
||||
@@ -392,8 +431,8 @@ pub(crate) fn write_attribute(buf: &mut impl Write, attr: &Attribute) -> std::fm
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_value_unquoted(
|
||||
buf: &mut impl Write,
|
||||
pub(crate) fn write_value_unquoted<W: Write + ?Sized>(
|
||||
buf: &mut W,
|
||||
value: &AttributeValue,
|
||||
) -> std::fmt::Result {
|
||||
match value {
|
||||
|
||||
Reference in new issue
Block a user