From 55ce805b60ce27ca4f475206c00665e7da2923c2 Mon Sep 17 00:00:00 2001 From: Greg Johnston Date: Sat, 4 Mar 2023 09:04:22 -0500 Subject: [PATCH] feat: hot reloading support for `cargo-leptos` (#592) --- Cargo.toml | 2 + examples/tailwind/src/app.rs | 44 +-- integrations/utils/Cargo.toml | 1 + integrations/utils/src/lib.rs | 7 +- leptos/tests/ssr.rs | 52 ++- leptos_dom/src/html.rs | 48 ++- leptos_dom/src/hydration.rs | 18 + leptos_dom/src/lib.rs | 25 +- leptos_dom/src/ssr.rs | 17 +- leptos_dom/src/ssr_in_order.rs | 12 + leptos_hot_reload/Cargo.toml | 27 ++ leptos_hot_reload/src/diff.rs | 550 +++++++++++++++++++++++++++++++ leptos_hot_reload/src/lib.rs | 162 +++++++++ leptos_hot_reload/src/node.rs | 168 ++++++++++ leptos_hot_reload/src/parsing.rs | 20 ++ leptos_hot_reload/src/patch.js | 285 ++++++++++++++++ leptos_macro/Cargo.toml | 1 + leptos_macro/src/lib.rs | 24 +- leptos_macro/src/template.rs | 3 +- leptos_macro/src/view.rs | 64 ++-- meta/src/lib.rs | 7 +- 21 files changed, 1449 insertions(+), 88 deletions(-) create mode 100644 leptos_hot_reload/Cargo.toml create mode 100644 leptos_hot_reload/src/diff.rs create mode 100644 leptos_hot_reload/src/lib.rs create mode 100644 leptos_hot_reload/src/node.rs create mode 100644 leptos_hot_reload/src/parsing.rs create mode 100644 leptos_hot_reload/src/patch.js diff --git a/Cargo.toml b/Cargo.toml index 1151384..1277983 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "leptos", "leptos_dom", "leptos_config", + "leptos_hot_reload", "leptos_macro", "leptos_reactive", "leptos_server", @@ -29,6 +30,7 @@ version = "0.2.0" [workspace.dependencies] leptos = { path = "./leptos", default-features = false, version = "0.2.0" } leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.0" } +leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.2.0" } leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.0" } leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.0" } leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.0" } diff --git a/examples/tailwind/src/app.rs b/examples/tailwind/src/app.rs index eb243cb..52c6f8a 100644 --- a/examples/tailwind/src/app.rs +++ b/examples/tailwind/src/app.rs @@ -6,32 +6,38 @@ use leptos_router::*; pub fn App(cx: Scope) -> impl IntoView { provide_meta_context(cx); - let (count, set_count) = create_signal(cx, 0); - view! { cx, - -

"Welcome to Leptos with Tailwind"

-

"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."

- - - }/> + }/>
} } + +#[component] +fn Home(cx: Scope) -> impl IntoView { + let (count, set_count) = create_signal(cx, 0); + + view! { cx, +
+

"Welcome to Leptos with Tailwind"

+

"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."

+ +
+ } +} diff --git a/integrations/utils/Cargo.toml b/integrations/utils/Cargo.toml index 7e19f2f..7e1a8d1 100644 --- a/integrations/utils/Cargo.toml +++ b/integrations/utils/Cargo.toml @@ -10,6 +10,7 @@ description = "Utilities to help build server integrations for the Leptos web fr [dependencies] futures = "0.3" leptos = { workspace = true, features = ["ssr"] } +leptos_hot_reload = { workspace = true } leptos_meta = { workspace = true, features = ["ssr"] } leptos_router = { workspace = true, features = ["ssr"] } leptos_config = { workspace = true } diff --git a/integrations/utils/src/lib.rs b/integrations/utils/src/lib.rs index a97b6db..82464e6 100644 --- a/integrations/utils/src/lib.rs +++ b/integrations/utils/src/lib.rs @@ -25,6 +25,7 @@ pub fn html_parts( true => format!( r#" - "# + "#, + leptos_hot_reload::HOT_RELOAD_JS ), false => "".to_string(), }; diff --git a/leptos/tests/ssr.rs b/leptos/tests/ssr.rs index cc70f30..2e51249 100644 --- a/leptos/tests/ssr.rs +++ b/leptos/tests/ssr.rs @@ -16,11 +16,13 @@ fn simple_ssr_test() { assert_eq!( rendered.into_view(cx).render_to_string(cx), - "
Value: \ 0!
" + id=\"_0-5\">+1
" ); }); } @@ -54,21 +56,25 @@ fn ssr_test_with_components() { assert_eq!( rendered.into_view(cx).render_to_string(cx), - "
Value: \ 1!
+1
Value: \ 2!
" + --leptos-view|leptos-tests-ssr.rs-38|close-->
" ); }); } @@ -102,22 +108,26 @@ fn ssr_test_with_snake_case_components() { assert_eq!( rendered.into_view(cx).render_to_string(cx), - "
Value: \ 1!
+1
Value: \ 2!
" + --leptos-view|leptos-tests-ssr.rs-90|close-->
" ); }); } @@ -136,7 +146,9 @@ fn test_classes() { assert_eq!( rendered.into_view(cx).render_to_string(cx), - "
" + "
" ); }); } @@ -158,8 +170,10 @@ fn ssr_with_styles() { assert_eq!( rendered.into_view(cx).render_to_string(cx), - "
" + "
" ); }); } @@ -178,7 +192,9 @@ fn ssr_option() { assert_eq!( rendered.into_view(cx).render_to_string(cx), - "" + "" ); }); } diff --git a/leptos_dom/src/html.rs b/leptos_dom/src/html.rs index 990d9de..b1c285c 100644 --- a/leptos_dom/src/html.rs +++ b/leptos_dom/src/html.rs @@ -90,6 +90,8 @@ where element, #[cfg(debug_assertions)] span: ::tracing::Span::current(), + #[cfg(debug_assertions)] + view_marker: None, } } @@ -259,6 +261,8 @@ cfg_if! { pub(crate) span: ::tracing::Span, pub(crate) cx: Scope, pub(crate) element: El, + #[cfg(debug_assertions)] + pub(crate) view_marker: Option } // Server needs to build a virtualized DOM tree } else { @@ -274,7 +278,9 @@ cfg_if! { #[allow(clippy::type_complexity)] pub(crate) children: SmallVec<[View; 4]>, #[educe(Debug(ignore))] - pub(crate) prerendered: Option> + pub(crate) prerendered: Option>, + #[cfg(debug_assertions)] + pub(crate) view_marker: Option } } } @@ -302,7 +308,9 @@ impl HtmlElement { cx, element, #[cfg(debug_assertions)] - span: ::tracing::Span::current() + span: ::tracing::Span::current(), + #[cfg(debug_assertions)] + view_marker: None } } else { Self { @@ -310,7 +318,9 @@ impl HtmlElement { attrs: smallvec![], children: smallvec![], element, - prerendered: None + prerendered: None, + #[cfg(debug_assertions)] + view_marker: None } } } @@ -329,9 +339,18 @@ impl HtmlElement { children: smallvec![], element, prerendered: Some(html.into()), + #[cfg(debug_assertions)] + view_marker: None, } } + #[cfg(debug_assertions)] + /// Adds an optional marker indicating the view macro source. + pub fn with_view_marker(mut self, marker: impl Into) -> Self { + self.view_marker = Some(marker.into()); + self + } + /// Converts this element into [`HtmlElement`]. pub fn into_any(self) -> HtmlElement { cfg_if! { @@ -340,7 +359,9 @@ impl HtmlElement { cx, element, #[cfg(debug_assertions)] - span + span, + #[cfg(debug_assertions)] + view_marker } = self; HtmlElement { @@ -351,7 +372,9 @@ impl HtmlElement { is_void: element.is_void(), }, #[cfg(debug_assertions)] - span + span, + #[cfg(debug_assertions)] + view_marker } } else { let Self { @@ -359,7 +382,9 @@ impl HtmlElement { attrs, children, element, - prerendered + prerendered, + #[cfg(debug_assertions)] + view_marker } = self; HtmlElement { @@ -370,8 +395,10 @@ impl HtmlElement { element: AnyElement { name: element.name(), is_void: element.is_void(), - id: element.hydration_id().clone(), + id: element.hydration_id().clone() }, + #[cfg(debug_assertions)] + view_marker } } } @@ -742,6 +769,8 @@ impl IntoView for HtmlElement { mut attrs, children, prerendered, + #[cfg(debug_assertions)] + view_marker, .. } = self; @@ -760,6 +789,11 @@ impl IntoView for HtmlElement { element.children.extend(children); element.prerendered = prerendered; + #[cfg(debug_assertions)] + { + element.view_marker = view_marker; + } + View::Element(element) } } diff --git a/leptos_dom/src/hydration.rs b/leptos_dom/src/hydration.rs index 5dd0cfb..133af25 100644 --- a/leptos_dom/src/hydration.rs +++ b/leptos_dom/src/hydration.rs @@ -30,6 +30,24 @@ cfg_if! { map }); + #[cfg(debug_assertions)] + pub(crate) static VIEW_MARKERS: LazyCell> = LazyCell::new(|| { + let document = crate::document(); + let body = document.body().unwrap(); + let walker = document + .create_tree_walker_with_what_to_show(&body, 128) + .unwrap(); + let mut map = HashMap::new(); + while let Ok(Some(node)) = walker.next_node() { + if let Some(content) = node.text_content() { + if let Some(id) = content.strip_prefix("leptos-view|") { + map.insert(id.into(), node.unchecked_into()); + } + } + } + map + }); + static IS_HYDRATING: RefCell> = RefCell::new(LazyCell::new(|| { #[cfg(debug_assertions)] return crate::document().get_element_by_id("_0-0-0").is_some() diff --git a/leptos_dom/src/lib.rs b/leptos_dom/src/lib.rs index a3dbbfb..9a2fd57 100644 --- a/leptos_dom/src/lib.rs +++ b/leptos_dom/src/lib.rs @@ -148,6 +148,9 @@ cfg_if! { pub name: Cow<'static, str>, #[doc(hidden)] pub element: web_sys::HtmlElement, + #[cfg(debug_assertions)] + /// Optional marker for the view macro source of the element. + pub view_marker: Option } impl fmt::Debug for Element { @@ -167,6 +170,9 @@ cfg_if! { children: Vec, prerendered: Option>, id: HydrationKey, + #[cfg(debug_assertions)] + /// Optional marker for the view macro source, in debug mode. + pub view_marker: Option } impl fmt::Debug for Element { @@ -200,7 +206,12 @@ impl Element { pub fn into_html_element(self, cx: Scope) -> HtmlElement { #[cfg(all(target_arch = "wasm32", feature = "web"))] { - let Self { element, .. } = self; + let Self { + element, + #[cfg(debug_assertions)] + view_marker, + .. + } = self; let name = element.node_name().to_ascii_lowercase(); @@ -215,6 +226,8 @@ impl Element { element, #[cfg(debug_assertions)] span: ::tracing::Span::current(), + #[cfg(debug_assertions)] + view_marker, } } @@ -227,6 +240,8 @@ impl Element { children, id, prerendered, + #[cfg(debug_assertions)] + view_marker, } = self; let element = AnyElement { name, is_void, id }; @@ -237,6 +252,8 @@ impl Element { attrs, children: children.into_iter().collect(), prerendered, + #[cfg(debug_assertions)] + view_marker, } } } @@ -258,6 +275,8 @@ impl Element { #[cfg(debug_assertions)] name: el.name(), element: el.as_ref().clone(), + #[cfg(debug_assertions)] + view_marker: None } } else { @@ -267,7 +286,9 @@ impl Element { attrs: Default::default(), children: Default::default(), id: el.hydration_id().clone(), - prerendered: None + prerendered: None, + #[cfg(debug_assertions)] + view_marker: None } } } diff --git a/leptos_dom/src/ssr.rs b/leptos_dom/src/ssr.rs index 870ee3a..3507999 100644 --- a/leptos_dom/src/ssr.rs +++ b/leptos_dom/src/ssr.rs @@ -19,8 +19,8 @@ type PinnedFuture = Pin>>; /// let html = leptos::ssr::render_to_string(|cx| view! { cx, ///

"Hello, world!"

/// }); -/// // static HTML includes some hydration info -/// assert_eq!(html, "

Hello, world!

"); +/// // trim off the beginning, which has a bunch of hydration info, for comparison +/// assert!(html.contains("Hello, world!

")); /// # }} /// ``` pub fn render_to_string(f: F) -> String @@ -380,7 +380,7 @@ impl View { } } View::Element(el) => { - if let Some(prerendered) = el.prerendered { + let el_html = if let Some(prerendered) = el.prerendered { prerendered } else { let tag_name = el.name; @@ -425,6 +425,17 @@ impl View { format!("<{tag_name}{attrs}>{children}") .into() } + }; + cfg_if! { + if #[cfg(debug_assertions)] { + if let Some(id) = el.view_marker { + format!("{el_html}").into() + } else { + el_html + } + } else { + el_html + } } } View::Transparent(_) => Default::default(), diff --git a/leptos_dom/src/ssr_in_order.rs b/leptos_dom/src/ssr_in_order.rs index b0a58a7..8ab65db 100644 --- a/leptos_dom/src/ssr_in_order.rs +++ b/leptos_dom/src/ssr_in_order.rs @@ -180,6 +180,12 @@ impl View { } } View::Element(el) => { + #[cfg(debug_assertions)] + if let Some(id) = &el.view_marker { + chunks.push(StreamChunk::Sync( + format!("").into(), + )); + } if let Some(prerendered) = el.prerendered { chunks.push(StreamChunk::Sync(prerendered)) } else { @@ -234,6 +240,12 @@ impl View { )); } } + #[cfg(debug_assertions)] + if let Some(id) = &el.view_marker { + chunks.push(StreamChunk::Sync( + format!("").into(), + )); + } } View::Transparent(_) => {} View::CoreComponent(node) => { diff --git a/leptos_hot_reload/Cargo.toml b/leptos_hot_reload/Cargo.toml new file mode 100644 index 0000000..6ff80d0 --- /dev/null +++ b/leptos_hot_reload/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "leptos_hot_reload" +version = { workspace = true } +edition = "2021" +authors = ["Greg Johnston"] +license = "MIT" +repository = "https://github.com/leptos-rs/leptos" +description = "Utility types used for dev mode and hot-reloading for the Leptos web framework." +readme = "../README.md" + +[dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +syn = { version = "1", features = [ + "full", + "parsing", + "extra-traits", + "visit", + "printing", +] } +quote = "1" +syn-rsx = "0.9" +proc-macro2 = { version = "1", features = ["span-locations", "nightly"] } +parking_lot = "0.12" +walkdir = "2" +camino = "1.1.3" +indexmap = "1.9.2" diff --git a/leptos_hot_reload/src/diff.rs b/leptos_hot_reload/src/diff.rs new file mode 100644 index 0000000..8a67218 --- /dev/null +++ b/leptos_hot_reload/src/diff.rs @@ -0,0 +1,550 @@ +use crate::node::{LAttributeValue, LNode}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +// TODO: insertion and removal code are still somewhat broken +// namely, it will tend to remove and move or mutate nodes, +// which causes a bit of a problem for DynChild etc. + +#[derive(Debug, Default)] +struct OldChildren(IndexMap>); + +impl LNode { + pub fn diff(&self, other: &LNode) -> Vec { + let mut old_children = OldChildren::default(); + self.add_old_children(vec![], &mut old_children); + self.diff_at(other, &[], &old_children) + } + + fn to_replacement_node( + &self, + old_children: &OldChildren, + ) -> ReplacementNode { + match old_children.0.get(self) { + // if the child already exists in the DOM, we can pluck it out + // and move it around + Some(path) => ReplacementNode::Path(path.to_owned()), + // otherwise, we should generate some HTML + // but we need to do this recursively in case we're replacing an element + // with children who need to be plucked out + None => match self { + LNode::Fragment(fragment) => ReplacementNode::Fragment( + fragment + .iter() + .map(|node| node.to_replacement_node(old_children)) + .collect(), + ), + LNode::Element { + name, + attrs, + children, + } => ReplacementNode::Element { + name: name.to_owned(), + attrs: attrs + .iter() + .filter_map(|(name, value)| match value { + LAttributeValue::Boolean => { + Some((name.to_owned(), name.to_owned())) + } + LAttributeValue::Static(value) => { + Some((name.to_owned(), value.to_owned())) + } + _ => None, + }) + .collect(), + children: children + .iter() + .map(|node| node.to_replacement_node(old_children)) + .collect(), + }, + LNode::Text(_) + | LNode::Component(_, _) + | LNode::DynChild(_) => ReplacementNode::Html(self.to_html()), + }, + } + } + + fn add_old_children(&self, path: Vec, positions: &mut OldChildren) { + match self { + LNode::Fragment(frag) => { + for (idx, child) in frag.iter().enumerate() { + let mut new_path = path.clone(); + new_path.push(idx); + child.add_old_children(new_path, positions); + } + } + LNode::Element { children, .. } => { + for (idx, child) in children.iter().enumerate() { + let mut new_path = path.clone(); + new_path.push(idx); + child.add_old_children(new_path, positions); + } + } + // only need to insert dynamic content, as these might change + LNode::Component(_, _) | LNode::DynChild(_) => { + positions.0.insert(self.clone(), path); + } + // can just create text nodes, whatever + LNode::Text(_) => {} + } + } + + fn diff_at( + &self, + other: &LNode, + path: &[usize], + orig_children: &OldChildren, + ) -> Vec { + if std::mem::discriminant(self) != std::mem::discriminant(other) { + return vec![Patch { + path: path.to_owned(), + action: PatchAction::ReplaceWith( + other.to_replacement_node(orig_children), + ), + }]; + } + match (self, other) { + // fragment: diff children + (LNode::Fragment(old), LNode::Fragment(new)) => { + LNode::diff_children(path, old, new, orig_children) + } + // text node: replace text + (LNode::Text(_), LNode::Text(new)) => vec![Patch { + path: path.to_owned(), + action: PatchAction::SetText(new.to_owned()), + }], + // elements + ( + LNode::Element { + name: old_name, + attrs: old_attrs, + children: old_children, + }, + LNode::Element { + name: new_name, + attrs: new_attrs, + children: new_children, + }, + ) => { + let tag_patch = (old_name != new_name).then(|| Patch { + path: path.to_owned(), + action: PatchAction::ChangeTagName(new_name.to_owned()), + }); + + let attrs_patch = LNode::diff_attrs(path, old_attrs, new_attrs); + + let children_patch = LNode::diff_children( + path, + old_children, + new_children, + orig_children, + ); + + attrs_patch + .into_iter() + // tag patch comes second so we remove old attrs before copying them over + .chain(tag_patch) + .chain(children_patch) + .collect() + } + // components + dynamic context: no patches + _ => vec![], + } + } + + fn diff_attrs<'a>( + path: &'a [usize], + old: &'a [(String, LAttributeValue)], + new: &'a [(String, LAttributeValue)], + ) -> impl Iterator + 'a { + let additions = new + .iter() + .filter_map(|(name, new_value)| { + let old_attr = old.iter().find(|(o_name, _)| o_name == name); + let replace = match old_attr { + None => true, + Some((_, old_value)) if old_value != new_value => true, + _ => false, + }; + if replace { + match &new_value { + LAttributeValue::Boolean => { + Some((name.to_owned(), "".to_string())) + } + LAttributeValue::Static(s) => { + Some((name.to_owned(), s.to_owned())) + } + _ => None, + } + } else { + None + } + }) + .map(|(name, value)| Patch { + path: path.to_owned(), + action: PatchAction::SetAttribute(name, value), + }); + + let removals = old.iter().filter_map(|(name, _)| { + if !new.iter().any(|(new_name, _)| new_name == name) { + Some(Patch { + path: path.to_owned(), + action: PatchAction::RemoveAttribute(name.to_owned()), + }) + } else { + None + } + }); + + additions.chain(removals) + } + + fn diff_children( + path: &[usize], + old: &[LNode], + new: &[LNode], + old_children: &OldChildren, + ) -> Vec { + if old.is_empty() && new.is_empty() { + vec![] + } else if old.is_empty() { + vec![Patch { + path: path.to_owned(), + action: PatchAction::AppendChildren( + new.iter() + .map(LNode::to_html) + .map(ReplacementNode::Html) + .collect(), + ), + }] + } else if new.is_empty() { + vec![Patch { + path: path.to_owned(), + action: PatchAction::ClearChildren, + }] + } else { + let mut a = 0; + let mut b = std::cmp::max(old.len(), new.len()) - 1; // min is 0, have checked both have items + let mut patches = vec![]; + // common prefix + while a < b { + let old = old.get(a); + let new = new.get(a); + + match (old, new) { + (None, None) => {} + (None, Some(new)) => patches.push(Patch { + path: path.to_owned(), + action: PatchAction::InsertChild { + before: a, + child: new.to_replacement_node(old_children), + }, + }), + (Some(_), None) => patches.push(Patch { + path: path.to_owned(), + action: PatchAction::RemoveChild { at: a }, + }), + (Some(old), Some(new)) => { + if old != new { + break; + } + } + } + + a += 1; + } + + // common suffix + while b >= a { + let old = old.get(b); + let new = new.get(b); + + match (old, new) { + (None, None) => {} + (None, Some(new)) => patches.push(Patch { + path: path.to_owned(), + action: PatchAction::InsertChildAfter { + after: b - 1, + child: new.to_replacement_node(old_children), + }, + }), + (Some(_), None) => patches.push(Patch { + path: path.to_owned(), + action: PatchAction::RemoveChild { at: b }, + }), + (Some(old), Some(new)) => { + if old != new { + break; + } + } + } + + if b == 0 { + break; + } else { + b -= 1; + } + } + + // diffing in middle + if b >= a { + let old_slice_end = + if b >= old.len() { old.len() - 1 } else { b }; + let new_slice_end = + if b >= new.len() { new.len() - 1 } else { b }; + let old = &old[a..=old_slice_end]; + let new = &new[a..=new_slice_end]; + + for (new_idx, new_node) in new.iter().enumerate() { + match old.get(new_idx) { + Some(old_node) => { + let mut new_path = path.to_vec(); + new_path.push(new_idx + a); + let diffs = old_node.diff_at( + new_node, + &new_path, + old_children, + ); + patches.extend(&mut diffs.into_iter()); + } + None => patches.push(Patch { + path: path.to_owned(), + action: PatchAction::InsertChild { + before: new_idx, + child: new_node + .to_replacement_node(old_children), + }, + }), + } + } + } + + patches + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Patches(pub Vec<(String, Vec)>); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Patch { + path: Vec, + action: PatchAction, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PatchAction { + ReplaceWith(ReplacementNode), + ChangeTagName(String), + RemoveAttribute(String), + SetAttribute(String, String), + SetText(String), + ClearChildren, + AppendChildren(Vec), + RemoveChild { + at: usize, + }, + InsertChild { + before: usize, + child: ReplacementNode, + }, + InsertChildAfter { + after: usize, + child: ReplacementNode, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReplacementNode { + Html(String), + Path(Vec), + Fragment(Vec), + Element { + name: String, + attrs: Vec<(String, String)>, + children: Vec, + }, +} + +#[cfg(test)] +mod tests { + use crate::{ + diff::{Patch, PatchAction, ReplacementNode}, + node::LAttributeValue, + LNode, + }; + + #[test] + fn patches_text() { + let a = LNode::Text("foo".into()); + let b = LNode::Text("bar".into()); + let delta = a.diff(&b); + assert_eq!( + delta, + vec![Patch { + path: vec![], + action: PatchAction::SetText("bar".into()) + }] + ); + } + + #[test] + fn patches_attrs() { + let a = LNode::Element { + name: "button".into(), + attrs: vec![ + ("class".into(), LAttributeValue::Static("a".into())), + ("type".into(), LAttributeValue::Static("button".into())), + ], + children: vec![], + }; + let b = LNode::Element { + name: "button".into(), + attrs: vec![ + ("class".into(), LAttributeValue::Static("a b".into())), + ("id".into(), LAttributeValue::Static("button".into())), + ], + children: vec![], + }; + let delta = a.diff(&b); + assert_eq!( + delta, + vec![ + Patch { + path: vec![], + action: PatchAction::SetAttribute( + "class".into(), + "a b".into() + ) + }, + Patch { + path: vec![], + action: PatchAction::SetAttribute( + "id".into(), + "button".into() + ) + }, + Patch { + path: vec![], + action: PatchAction::RemoveAttribute("type".into()) + }, + ] + ); + } + + #[test] + fn patches_child_text() { + let a = LNode::Element { + name: "button".into(), + attrs: vec![], + children: vec![ + LNode::Text("foo".into()), + LNode::Text("bar".into()), + ], + }; + let b = LNode::Element { + name: "button".into(), + attrs: vec![], + children: vec![ + LNode::Text("foo".into()), + LNode::Text("baz".into()), + ], + }; + let delta = a.diff(&b); + assert_eq!( + delta, + vec![Patch { + path: vec![1], + action: PatchAction::SetText("baz".into()) + },] + ); + } + + #[test] + fn inserts_child() { + let a = LNode::Element { + name: "div".into(), + attrs: vec![], + children: vec![LNode::Element { + name: "button".into(), + attrs: vec![], + children: vec![LNode::Text("bar".into())], + }], + }; + let b = LNode::Element { + name: "div".into(), + attrs: vec![], + children: vec![ + LNode::Element { + name: "button".into(), + attrs: vec![], + children: vec![LNode::Text("foo".into())], + }, + LNode::Element { + name: "button".into(), + attrs: vec![], + children: vec![LNode::Text("bar".into())], + }, + ], + }; + let delta = a.diff(&b); + assert_eq!( + delta, + vec![ + Patch { + path: vec![], + action: PatchAction::InsertChildAfter { + after: 0, + child: ReplacementNode::Element { + name: "button".into(), + attrs: vec![], + children: vec![ReplacementNode::Html("bar".into())] + } + } + }, + Patch { + path: vec![0, 0], + action: PatchAction::SetText("foo".into()) + } + ] + ); + } + + #[test] + fn removes_child() { + let a = LNode::Element { + name: "div".into(), + attrs: vec![], + children: vec![ + LNode::Element { + name: "button".into(), + attrs: vec![], + children: vec![LNode::Text("foo".into())], + }, + LNode::Element { + name: "button".into(), + attrs: vec![], + children: vec![LNode::Text("bar".into())], + }, + ], + }; + let b = LNode::Element { + name: "div".into(), + attrs: vec![], + children: vec![LNode::Element { + name: "button".into(), + attrs: vec![], + children: vec![LNode::Text("foo".into())], + }], + }; + let delta = a.diff(&b); + assert_eq!( + delta, + vec![Patch { + path: vec![], + action: PatchAction::RemoveChild { at: 1 } + },] + ); + } +} diff --git a/leptos_hot_reload/src/lib.rs b/leptos_hot_reload/src/lib.rs new file mode 100644 index 0000000..e0329ba --- /dev/null +++ b/leptos_hot_reload/src/lib.rs @@ -0,0 +1,162 @@ +extern crate proc_macro; + +use anyhow::Result; +use camino::Utf8PathBuf; +use diff::Patches; +use node::LNode; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fs::File, + io::Read, + path::{Path, PathBuf}, + sync::Arc, +}; +use syn::{ + spanned::Spanned, + visit::{self, Visit}, + Macro, +}; +use walkdir::WalkDir; + +pub mod diff; +pub mod node; +pub mod parsing; + +pub const HOT_RELOAD_JS: &str = include_str!("patch.js"); + +#[derive(Debug, Clone, Default)] +pub struct ViewMacros { + // keyed by original location identifier + views: Arc>>>, +} + +impl ViewMacros { + pub fn new() -> Self { + Self::default() + } + + pub fn update_from_paths>(&self, paths: &[T]) -> Result<()> { + let mut views = HashMap::new(); + + for path in paths { + for entry in WalkDir::new(path).into_iter().flatten() { + if entry.file_type().is_file() { + let path: PathBuf = entry.path().into(); + let path = Utf8PathBuf::try_from(path)?; + if path.extension() == Some("rs") || path.ends_with(".rs") { + let macros = Self::parse_file(&path)?; + let entry = views.entry(path.clone()).or_default(); + *entry = macros; + } + } + } + } + + *self.views.write() = views; + + Ok(()) + } + + pub fn parse_file(path: &Utf8PathBuf) -> Result> { + let mut file = File::open(path)?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + let ast = syn::parse_file(&content)?; + + let mut visitor = ViewMacroVisitor::default(); + visitor.visit_file(&ast); + let mut views = Vec::new(); + for view in visitor.views { + let span = view.span(); + let id = span_to_stable_id(path, span); + let mut tokens = view.tokens.clone().into_iter(); + tokens.next(); // cx + tokens.next(); // , + // TODO handle class = ... + let rsx = + syn_rsx::parse2(tokens.collect::())?; + let template = LNode::parse_view(rsx)?; + views.push(MacroInvocation { id, template }) + } + Ok(views) + } + + pub fn patch(&self, path: &Utf8PathBuf) -> Result> { + let new_views = Self::parse_file(path)?; + let mut lock = self.views.write(); + let diffs = match lock.get(path) { + None => return Ok(None), + Some(current_views) => { + if current_views.len() == new_views.len() { + let mut diffs = Vec::new(); + for (current_view, new_view) in + current_views.iter().zip(&new_views) + { + if current_view.id == new_view.id + && current_view.template != new_view.template + { + diffs.push(( + current_view.id.clone(), + current_view.template.diff(&new_view.template), + )); + } + } + diffs + } else { + return Ok(None); + } + } + }; + + // update the status to the new views + lock.insert(path.clone(), new_views); + + Ok(Some(Patches(diffs))) + } +} + +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct MacroInvocation { + id: String, + template: LNode, +} + +impl std::fmt::Debug for MacroInvocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MacroInvocation") + .field("id", &self.id) + .finish() + } +} + +#[derive(Default, Debug)] +pub struct ViewMacroVisitor<'a> { + views: Vec<&'a Macro>, +} + +impl<'ast> Visit<'ast> for ViewMacroVisitor<'ast> { + fn visit_macro(&mut self, node: &'ast Macro) { + let ident = node.path.get_ident().map(|n| n.to_string()); + if ident == Some("view".to_string()) { + self.views.push(node); + } + + // Delegate to the default impl to visit any nested functions. + visit::visit_macro(self, node); + } +} + +pub fn span_to_stable_id( + path: impl AsRef, + site: proc_macro2::Span, +) -> String { + let file = path + .as_ref() + .to_str() + .unwrap_or_default() + .replace(['/', '\\'], "-"); + let start = site.start(); + format!("{}-{:?}", file, start.line) +} diff --git a/leptos_hot_reload/src/node.rs b/leptos_hot_reload/src/node.rs new file mode 100644 index 0000000..f26ce81 --- /dev/null +++ b/leptos_hot_reload/src/node.rs @@ -0,0 +1,168 @@ +use crate::parsing::{is_component_node, value_to_string}; +use anyhow::Result; +use quote::quote; +use serde::{Deserialize, Serialize}; +use syn_rsx::Node; + +// A lightweight virtual DOM structure we can use to hold +// the state of a Leptos view macro template. This is because +// `syn` types are `!Send` so we can't store them as we might like. +// This is only used to diff view macros for hot reloading so it's very minimal +// and ignores many of the data types. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LNode { + Fragment(Vec), + Text(String), + Element { + name: String, + attrs: Vec<(String, LAttributeValue)>, + children: Vec, + }, + // don't need anything; skipped during patching because it should + // contain its own view macros + Component(String, Vec<(String, String)>), + DynChild(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LAttributeValue { + Boolean, + Static(String), + // safely ignored + Dynamic, + Noop, +} + +impl LNode { + pub fn parse_view(nodes: Vec) -> Result { + let mut out = Vec::new(); + for node in nodes { + LNode::parse_node(node, &mut out)?; + } + if out.len() == 1 { + Ok(out.pop().unwrap()) + } else { + Ok(LNode::Fragment(out)) + } + } + + pub fn parse_node(node: Node, views: &mut Vec) -> Result<()> { + match node { + Node::Fragment(frag) => { + for child in frag.children { + LNode::parse_node(child, views)?; + } + } + Node::Text(text) => { + if let Some(value) = value_to_string(&text.value) { + views.push(LNode::Text(value)); + } else { + let value = text.value.as_ref(); + let code = quote! { #value }; + let code = code.to_string(); + views.push(LNode::DynChild(code)); + } + } + Node::Block(block) => { + let value = block.value.as_ref(); + let code = quote! { #value }; + let code = code.to_string(); + views.push(LNode::DynChild(code)); + } + Node::Element(el) => { + if is_component_node(&el) { + views.push(LNode::Component( + el.name.to_string(), + el.attributes + .into_iter() + .filter_map(|attr| match attr { + Node::Attribute(attr) => Some(( + attr.key.to_string(), + format!("{:#?}", attr.value), + )), + _ => None, + }) + .collect(), + )); + } else { + let name = el.name.to_string(); + let mut attrs = Vec::new(); + + for attr in el.attributes { + if let Node::Attribute(attr) = attr { + let name = attr.key.to_string(); + if let Some(value) = + attr.value.as_ref().and_then(value_to_string) + { + attrs.push(( + name, + LAttributeValue::Static(value), + )); + } else { + attrs.push((name, LAttributeValue::Dynamic)); + } + } + } + + let mut children = Vec::new(); + for child in el.children { + LNode::parse_node(child, &mut children)?; + } + + views.push(LNode::Element { + name, + attrs, + children, + }); + } + } + _ => {} + } + Ok(()) + } + + pub fn to_html(&self) -> String { + match self { + LNode::Fragment(frag) => frag.iter().map(LNode::to_html).collect(), + LNode::Text(text) => text.to_owned(), + LNode::Component(name, _) => format!( + "
<{name}/> will load once Rust code \
+                 has been compiled.
" + ), + LNode::DynChild(_) => "
Dynamic content will \
+                                   load once Rust code has been \
+                                   compiled.
" + .to_string(), + LNode::Element { + name, + attrs, + children, + } => { + // this is naughty, but the browsers are tough and can handle it + // I wouldn't do this for real code, but this is just for dev mode + let is_self_closing = children.is_empty(); + + let attrs = attrs + .iter() + .filter_map(|(name, value)| match value { + LAttributeValue::Boolean => Some(format!("{name} ")), + LAttributeValue::Static(value) => { + Some(format!("{name}=\"{value}\" ")) + } + LAttributeValue::Dynamic => None, + LAttributeValue::Noop => None, + }) + .collect::(); + + let children = + children.iter().map(LNode::to_html).collect::(); + + if is_self_closing { + format!("<{name} {attrs}/>") + } else { + format!("<{name} {attrs}>{children}") + } + } + } + } +} diff --git a/leptos_hot_reload/src/parsing.rs b/leptos_hot_reload/src/parsing.rs new file mode 100644 index 0000000..07963ce --- /dev/null +++ b/leptos_hot_reload/src/parsing.rs @@ -0,0 +1,20 @@ +use syn_rsx::{NodeElement, NodeValueExpr}; + +pub fn value_to_string(value: &NodeValueExpr) -> Option { + match &value.as_ref() { + syn::Expr::Lit(lit) => match &lit.lit { + syn::Lit::Str(s) => Some(s.value()), + syn::Lit::Char(c) => Some(c.value().to_string()), + syn::Lit::Int(i) => Some(i.base10_digits().to_string()), + syn::Lit::Float(f) => Some(f.base10_digits().to_string()), + _ => None, + }, + _ => None, + } +} + +pub fn is_component_node(node: &NodeElement) -> bool { + node.name + .to_string() + .starts_with(|c: char| c.is_ascii_uppercase()) +} diff --git a/leptos_hot_reload/src/patch.js b/leptos_hot_reload/src/patch.js new file mode 100644 index 0000000..908843f --- /dev/null +++ b/leptos_hot_reload/src/patch.js @@ -0,0 +1,285 @@ +console.log("[HOT RELOADING] Connected to server."); +function patch(json) { + try { + const views = JSON.parse(json); + for ([id, patches] of views) { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT), + open = `leptos-view|${id}|open`, + close = `leptos-view|${id}|close`; + let start, end; + while (walker.nextNode()) { + if (walker.currentNode.textContent == open) { + start = walker.currentNode; + } else if (walker.currentNode.textContent == close) { + end = walker.currentNode; + break; + } + } + // build tree of current actual children + const range = new Range(); + range.setStartAfter(start); + range.setEndBefore(end); + const actualChildren = buildActualChildren(start.parentElement, range); + const actions = []; + + // build up the set of actions + for (const patch of patches) { + const child = childAtPath( + actualChildren.length > 1 ? { children: actualChildren } : actualChildren[0], + patch.path + ); + const action = patch.action; + if (action == "ClearChildren") { + actions.push(() => { + console.log("[HOT RELOAD] > ClearChildren", child.node); + child.node.textContent = "" + }); + } else if (action.ReplaceWith) { + actions.push(() => { + console.log("[HOT RELOAD] > ReplaceWith", child, action.ReplaceWith); + const replacement = fromReplacementNode(action.ReplaceWith, actualChildren); + if (child.node) { + child.node.replaceWith(replacement) + } else { + const range = new Range(); + range.setStartAfter(child.start); + range.setEndAfter(child.end); + range.deleteContents(); + child.start.replaceWith(replacement); + } + }); + } else if (action.ChangeTagName) { + const oldNode = child.node; + actions.push(() => { + console.log("[HOT RELOAD] > ChangeTagName", child.node, action.ChangeTagName); + const newElement = document.createElement(action.ChangeTagName); + for (const attr of oldNode.attributes) { + newElement.setAttribute(attr.name, attr.value); + } + for (const childNode of child.node.childNodes) { + newElement.appendChild(childNode); + } + + child.node.replaceWith(newElement) + }); + } else if (action.RemoveAttribute) { + actions.push(() => { + console.log("[HOT RELOAD] > RemoveAttribute", child.node, action.RemoveAttribute); + child.node.removeAttribute(action.RemoveAttribute); + }); + } else if (action.SetAttribute) { + const [name, value] = action.SetAttribute; + actions.push(() => { + console.log("[HOT RELOAD] > SetAttribute", child.node, action.SetAttribute); + child.node.setAttribute(name, value); + }); + } else if (action.SetText) { + const node = child.node; + actions.push(() => { + console.log("[HOT RELOAD] > SetText", child.node, action.SetText); + node.textContent = action.SetText + }); + } else if (action.AppendChildren) { + actions.push(() => { + console.log("[HOT RELOAD] > AppendChildren", child.node, action.AppendChildren); + const newChildren = fromReplacementNode(action.AppendChildren, actualChildren); + child.node.append(newChildren); + }); + } else if (action.RemoveChild) { + actions.push(() => { + console.log("[HOT RELOAD] > RemoveChild", child.node, child.children, action.RemoveChild); + const toRemove = child.children[action.RemoveChild.at]; + let toRemoveNode = toRemove.node; + if (!toRemoveNode) { + const range = new Range(); + range.setStartBefore(toRemove.start); + range.setEndAfter(toRemove.end); + toRemoveNode = range.deleteContents(); + } else { + toRemoveNode.parentNode.removeChild(toRemoveNode); + } + }) + } else if (action.InsertChild) { + const newChild = fromReplacementNode(action.InsertChild.child, actualChildren), + before = child.children[action.InsertChild.before]; + actions.push(() => { + console.log("[HOT RELOAD] > InsertChild", child, child.node, action.InsertChild, " before ", before); + if (!before) { + child.node.appendChild(newChild); + } else { + child.node.insertBefore(newChild, (before.node || before.start)); + } + }) + } else if (action.InsertChildAfter) { + const newChild = fromReplacementNode(action.InsertChildAfter.child, actualChildren), + after = child.children[action.InsertChildAfter.after]; + actions.push(() => { + console.log("[HOT RELOAD] > InsertChildAfter", child, child.node, action.InsertChildAfter, " after ", after); + console.log("newChild is ", newChild); + if (!after || !(after.node || after.start).nextSibling) { + child.node.appendChild(newChild); + } else { + child.node.insertBefore(newChild, (after.node || after.start).nextSibling); + } + }) + } else { + console.warn("[HOT RELOADING] Unmatched action", action); + } + } + + // actually run the actions + // the reason we delay them is so that children aren't moved before other children are found, etc. + for (const action of actions) { + action(); + } + } + } catch (e) { + console.warn("[HOT RELOADING] Error: ", e); + } + + function fromReplacementNode(node, actualChildren) { + console.log("fromReplacementNode", node, actualChildren); + if (node.Html) { + return fromHTML(node.Html); + } + else if (node.Fragment) { + const frag = document.createDocumentFragment(); + for (const child of node.Fragment) { + frag.appendChild(fromReplacementNode(child, actualChildren)); + } + return frag; + } + else if (node.Element) { + const element = document.createElement(node.Element.name); + for (const [name, value] of node.Element.attrs) { + element.setAttribute(name, value); + } + for (const child of node.Element.children) { + element.appendChild(fromReplacementNode(child, actualChildren)); + } + return element; + } + else { + const child = childAtPath( + actualChildren.length > 1 ? { children: actualChildren } : actualChildren[0], + node.Path + ); + console.log("fromReplacementNode", child, "\n", node, actualChildren); + if (child) { + let childNode = child.node; + if (!childNode) { + const range = new Range(); + range.setStartBefore(child.start); + range.setEndAfter(child.end); + // okay this is somewhat silly + // if we do cloneContents() here to return it, + // we strip away the event listeners + // if we're moving just one object, this is less than ideal + // so I'm actually going to *extract* them, then clone and reinsert + /* const toReinsert = range.cloneContents(); + if (child.end.nextSibling) { + child.end.parentNode.insertBefore(toReinsert, child.end.nextSibling); + } else { + child.end.parentNode.appendChild(toReinsert); + } */ + childNode = range.cloneContents(); + } + return childNode; + } else { + console.warn("[HOT RELOADING] Could not find replacement node at ", node.Path); + return undefined; + } + } + } + + function buildActualChildren(element, range) { + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT, + { + acceptNode(node) { + return node.parentNode == element && (!range || range.isPointInRange(node, 0)) + } + } + ); + const actualChildren = [], + elementCount = {}; + while (walker.nextNode()) { + if (walker.currentNode.nodeType == Node.ELEMENT_NODE) { + if (elementCount[walker.currentNode.nodeName]) { + elementCount[walker.currentNode.nodeName] += 1; + } else { + elementCount[walker.currentNode.nodeName] = 0; + } + elementCount[walker.currentNode.nodeName]; + + actualChildren.push({ + type: "element", + name: walker.currentNode.nodeName, + number: elementCount[walker.currentNode.nodeName], + node: walker.currentNode, + children: buildActualChildren(walker.currentNode) + }); + } else if (walker.currentNode.nodeType == Node.TEXT_NODE) { + actualChildren.push({ + type: "text", + node: walker.currentNode + }); + } else if (walker.currentNode.nodeType == Node.COMMENT_NODE) { + if (walker.currentNode.textContent.trim().startsWith("leptos-view")) { + } else if (walker.currentNode.textContent.trim() == "<() />") { + actualChildren.push({ + type: "unit", + node: walker.currentNode + }); + } else if (walker.currentNode.textContent.trim() == "") { + let start = walker.currentNode; + while (walker.currentNode.textContent.trim() !== "") { + walker.nextNode(); + } + let end = walker.currentNode; + actualChildren.push({ + type: "dyn-child", + start, end + }); + } else if (walker.currentNode.textContent.trim().startsWith("<")) { + let componentName = walker.currentNode.textContent.trim(); + let endMarker = componentName.replace("<", " TokenStream { let tokens: proc_macro2::TokenStream = tokens.into(); let mut tokens = tokens.into_iter(); let (cx, comma) = (tokens.next(), tokens.next()); + match (cx, comma) { (Some(TokenTree::Ident(cx)), Some(TokenTree::Punct(punct))) if punct.as_char() == ',' => @@ -328,6 +329,7 @@ pub fn view(tokens: TokenStream) -> TokenStream { &nodes, Mode::default(), global_class.as_ref(), + normalized_call_site(proc_macro::Span::call_site()), ), Err(error) => error.to_compile_error(), } @@ -342,6 +344,20 @@ pub fn view(tokens: TokenStream) -> TokenStream { } } +fn normalized_call_site(site: proc_macro::Span) -> Option { + cfg_if::cfg_if! { + if #[cfg(all(debug_assertions, not(feature = "stable")))] { + Some(leptos_hot_reload::span_to_stable_id( + site.source_file().path(), + site.into() + )) + } else { + _ = site; + None + } + } +} + /// An optimized, cached template for client-side rendering. Follows the same /// syntax as the [view!] macro. In hydration or server-side rendering mode, /// behaves exactly as the `view` macro. In client-side rendering mode, uses a `