feat: hot reloading support for `cargo-leptos` (#592)

This commit is contained in:
Greg Johnston 2023-03-04 09:04:22 -05:00 committed by GitHub
parent 1e0adcd89a
commit 55ce805b60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1449 additions and 88 deletions

View File

@ -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" }

View File

@ -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,
<Stylesheet id="leptos" href="/pkg/tailwind.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Router>
<Routes>
<Route path="" view= move |cx| view! {
cx,
<main class="my-0 mx-auto max-w-3xl text-center">
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
<button
class="bg-sky-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
on:click=move |_| set_count.update(|count| *count += 1)
>
{move || if count() == 0 {
"Click me!".to_string()
} else {
count().to_string()
}}
</button>
</main>
}/>
<Route path="" view= move |cx| view! { cx, <Home/> }/>
</Routes>
</Router>
}
}
#[component]
fn Home(cx: Scope) -> impl IntoView {
let (count, set_count) = create_signal(cx, 0);
view! { cx,
<main class="my-0 mx-auto max-w-3xl text-center">
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
<button
class="bg-amber-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
on:click=move |_| set_count.update(|count| *count += 1)
>
"Something's here | "
{move || if count() == 0 {
"Click me!".to_string()
} else {
count().to_string()
}}
" | Some more text"
</button>
</main>
}
}

View File

@ -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 }

View File

@ -25,6 +25,7 @@ pub fn html_parts(
true => format!(
r#"
<script crossorigin="">(function () {{
{}
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
ws.onmessage = (ev) => {{
let msg = JSON.parse(ev.data);
@ -40,11 +41,15 @@ pub fn html_parts(
}});
if (!found) console.warn(`CSS hot-reload: Could not find a <link href=/\"${{msg.css}}\"> element`);
}};
if(msg.view) {{
patch(msg.view);
}}
}};
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
}})()
</script>
"#
"#,
leptos_hot_reload::HOT_RELOAD_JS
),
false => "".to_string(),
};

View File

@ -16,11 +16,13 @@ fn simple_ssr_test() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<div id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
"<!--leptos-view|leptos-tests-ssr.rs-8|open--><div \
id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
id=\"_0-3\">Value: \
<!--hk=_0-4o|leptos-dyn-child-start-->0<!\
--hk=_0-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-5\">+1</button></div>"
id=\"_0-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-8|close-->"
);
});
}
@ -54,21 +56,25 @@ fn ssr_test_with_components() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<div id=\"_0-1\" \
class=\"counters\"><!--hk=_0-1-0o|leptos-counter-start--><div \
"<!--leptos-view|leptos-tests-ssr.rs-49|open--><div id=\"_0-1\" \
class=\"counters\"><!--hk=_0-1-0o|leptos-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
id=\"_0-1-3\">Value: \
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5\">+1</button></div><!\
--hk=_0-1-0c|leptos-counter-end--><!\
--hk=_0-1-5-0o|leptos-counter-start--><div \
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-38|close--><!--hk=_0-1-0c|leptos-counter-end--><!\
--hk=_0-1-5-0o|leptos-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
id=\"_0-1-5-3\">Value: \
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5-5\">+1</button></div><!\
--hk=_0-1-5-0c|leptos-counter-end--></div>"
--leptos-view|leptos-tests-ssr.rs-38|close--><!\
--hk=_0-1-5-0c|leptos-counter-end--></div><!\
--leptos-view|leptos-tests-ssr.rs-49|close-->"
);
});
}
@ -102,22 +108,26 @@ fn ssr_test_with_snake_case_components() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<div id=\"_0-1\" \
"<!--leptos-view|leptos-tests-ssr.rs-101|open--><div id=\"_0-1\" \
class=\"counters\"><!\
--hk=_0-1-0o|leptos-snake-case-counter-start--><div \
--hk=_0-1-0o|leptos-snake-case-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
id=\"_0-1-3\">Value: \
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5\">+1</button></div><!\
--hk=_0-1-0c|leptos-snake-case-counter-end--><!\
--hk=_0-1-5-0o|leptos-snake-case-counter-start--><div \
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-90|close--><!--hk=_0-1-0c|leptos-snake-case-counter-end--><!\
--hk=_0-1-5-0o|leptos-snake-case-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
id=\"_0-1-5-3\">Value: \
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5-5\">+1</button></div><!\
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div>"
--leptos-view|leptos-tests-ssr.rs-90|close--><!\
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div><!\
--leptos-view|leptos-tests-ssr.rs-101|close-->"
);
});
}
@ -136,7 +146,9 @@ fn test_classes() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<div id=\"_0-1\" class=\"my big red car\"></div>"
"<!--leptos-view|leptos-tests-ssr.rs-142|open--><div id=\"_0-1\" \
class=\"my big red \
car\"></div><!--leptos-view|leptos-tests-ssr.rs-142|close-->"
);
});
}
@ -158,8 +170,10 @@ fn ssr_with_styles() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<div id=\"_0-1\" class=\" myclass\"><button id=\"_0-2\" \
class=\"btn myclass\">-1</button></div>"
"<!--leptos-view|leptos-tests-ssr.rs-164|open--><div id=\"_0-1\" \
class=\" myclass\"><button id=\"_0-2\" class=\"btn \
myclass\">-1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-164|close-->"
);
});
}
@ -178,7 +192,9 @@ fn ssr_option() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<option id=\"_0-1\"></option>"
"<!--leptos-view|leptos-tests-ssr.rs-188|open--><option \
id=\"_0-1\"></option><!--leptos-view|leptos-tests-ssr.\
rs-188|close-->"
);
});
}

View File

@ -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<String>
}
// 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<Cow<'static, str>>
pub(crate) prerendered: Option<Cow<'static, str>>,
#[cfg(debug_assertions)]
pub(crate) view_marker: Option<String>
}
}
}
@ -302,7 +308,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
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<El: ElementDescriptor + 'static> HtmlElement<El> {
attrs: smallvec![],
children: smallvec![],
element,
prerendered: None
prerendered: None,
#[cfg(debug_assertions)]
view_marker: None
}
}
}
@ -329,9 +339,18 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
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<String>) -> Self {
self.view_marker = Some(marker.into());
self
}
/// Converts this element into [`HtmlElement<AnyElement>`].
pub fn into_any(self) -> HtmlElement<AnyElement> {
cfg_if! {
@ -340,7 +359,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
cx,
element,
#[cfg(debug_assertions)]
span
span,
#[cfg(debug_assertions)]
view_marker
} = self;
HtmlElement {
@ -351,7 +372,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
is_void: element.is_void(),
},
#[cfg(debug_assertions)]
span
span,
#[cfg(debug_assertions)]
view_marker
}
} else {
let Self {
@ -359,7 +382,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
attrs,
children,
element,
prerendered
prerendered,
#[cfg(debug_assertions)]
view_marker
} = self;
HtmlElement {
@ -370,8 +395,10 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
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<El: ElementDescriptor> IntoView for HtmlElement<El> {
mut attrs,
children,
prerendered,
#[cfg(debug_assertions)]
view_marker,
..
} = self;
@ -760,6 +789,11 @@ impl<El: ElementDescriptor> IntoView for HtmlElement<El> {
element.children.extend(children);
element.prerendered = prerendered;
#[cfg(debug_assertions)]
{
element.view_marker = view_marker;
}
View::Element(element)
}
}

View File

@ -30,6 +30,24 @@ cfg_if! {
map
});
#[cfg(debug_assertions)]
pub(crate) static VIEW_MARKERS: LazyCell<HashMap<String, web_sys::Comment>> = 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<LazyCell<bool>> = RefCell::new(LazyCell::new(|| {
#[cfg(debug_assertions)]
return crate::document().get_element_by_id("_0-0-0").is_some()

View File

@ -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<String>
}
impl fmt::Debug for Element {
@ -167,6 +170,9 @@ cfg_if! {
children: Vec<View>,
prerendered: Option<Cow<'static, str>>,
id: HydrationKey,
#[cfg(debug_assertions)]
/// Optional marker for the view macro source, in debug mode.
pub view_marker: Option<String>
}
impl fmt::Debug for Element {
@ -200,7 +206,12 @@ impl Element {
pub fn into_html_element(self, cx: Scope) -> HtmlElement<AnyElement> {
#[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
}
}
}

View File

@ -19,8 +19,8 @@ type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;
/// let html = leptos::ssr::render_to_string(|cx| view! { cx,
/// <p>"Hello, world!"</p>
/// });
/// // static HTML includes some hydration info
/// assert_eq!(html, "<p id=\"_0-1\">Hello, world!</p>");
/// // trim off the beginning, which has a bunch of hydration info, for comparison
/// assert!(html.contains("Hello, world!</p>"));
/// # }}
/// ```
pub fn render_to_string<F, N>(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}</{tag_name}>")
.into()
}
};
cfg_if! {
if #[cfg(debug_assertions)] {
if let Some(id) = el.view_marker {
format!("<!--leptos-view|{id}|open-->{el_html}<!--leptos-view|{id}|close-->").into()
} else {
el_html
}
} else {
el_html
}
}
}
View::Transparent(_) => Default::default(),

View File

@ -180,6 +180,12 @@ impl View {
}
}
View::Element(el) => {
#[cfg(debug_assertions)]
if let Some(id) = &el.view_marker {
chunks.push(StreamChunk::Sync(
format!("<!--leptos-view|{id}|open-->").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!("<!--leptos-view|{id}|close-->").into(),
));
}
}
View::Transparent(_) => {}
View::CoreComponent(node) => {

View File

@ -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"

View File

@ -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<LNode, Vec<usize>>);
impl LNode {
pub fn diff(&self, other: &LNode) -> Vec<Patch> {
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<usize>, 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<Patch> {
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<Item = Patch> + '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<Patch> {
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<Patch>)>);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Patch {
path: Vec<usize>,
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<ReplacementNode>),
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<usize>),
Fragment(Vec<ReplacementNode>),
Element {
name: String,
attrs: Vec<(String, String)>,
children: Vec<ReplacementNode>,
},
}
#[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 }
},]
);
}
}

View File

@ -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<RwLock<HashMap<Utf8PathBuf, Vec<MacroInvocation>>>>,
}
impl ViewMacros {
pub fn new() -> Self {
Self::default()
}
pub fn update_from_paths<T: AsRef<Path>>(&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<Vec<MacroInvocation>> {
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::<proc_macro2::TokenStream>())?;
let template = LNode::parse_view(rsx)?;
views.push(MacroInvocation { id, template })
}
Ok(views)
}
pub fn patch(&self, path: &Utf8PathBuf) -> Result<Option<Patches>> {
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<Path>,
site: proc_macro2::Span,
) -> String {
let file = path
.as_ref()
.to_str()
.unwrap_or_default()
.replace(['/', '\\'], "-");
let start = site.start();
format!("{}-{:?}", file, start.line)
}

View File

@ -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<LNode>),
Text(String),
Element {
name: String,
attrs: Vec<(String, LAttributeValue)>,
children: Vec<LNode>,
},
// 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<Node>) -> Result<LNode> {
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<LNode>) -> 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}>--><pre>&lt;{name}/&gt; will load once Rust code \
has been compiled.</pre><!--</{name}>-->"
),
LNode::DynChild(_) => "<!--<DynChild>--><pre>Dynamic content will \
load once Rust code has been \
compiled.</pre><!--</DynChild>-->"
.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::<String>();
let children =
children.iter().map(LNode::to_html).collect::<String>();
if is_self_closing {
format!("<{name} {attrs}/>")
} else {
format!("<{name} {attrs}>{children}</{name}>")
}
}
}
}
}

View File

@ -0,0 +1,20 @@
use syn_rsx::{NodeElement, NodeValueExpr};
pub fn value_to_string(value: &NodeValueExpr) -> Option<String> {
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())
}

View File

@ -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() == "<DynChild>") {
let start = walker.currentNode;
while (walker.currentNode.textContent.trim() !== "</DynChild>") {
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("<", "</");
let start = walker.currentNode;
while (walker.currentNode.textContent.trim() !== endMarker) {
walker.nextSibling();
}
let end = walker.currentNode;
actualChildren.push({
type: "component",
start, end
});
}
} else {
console.warn("[HOT RELOADING] Building children, encountered", walker.currentNode);
}
}
return actualChildren;
}
function childAtPath(element, path) {
if (path.length == 0) {
return element;
} else if (element.children) {
const next = element.children[path[0]],
rest = path.slice(1);
return childAtPath(next, rest);
} else if (path == [0]) {
return element;
} else {
console.warn("[HOT RELOADING] Child at ", path, "not found in ", element);
return element;
}
}
function fromHTML(html) {
const template = document.createElement("template");
template.innerHTML = html;
return template.content.cloneNode(true);
}
}

View File

@ -23,6 +23,7 @@ quote = "1"
syn = { version = "1", features = ["full"] }
syn-rsx = "0.9"
leptos_dom = { workspace = true }
leptos_hot_reload = { workspace = true }
leptos_reactive = { workspace = true }
leptos_server = { workspace = true }
server_fn_macro = { workspace = true }

View File

@ -9,7 +9,7 @@ use proc_macro2::TokenTree;
use quote::ToTokens;
use server_fn_macro::{server_macro_impl, ServerContext};
use syn::parse_macro_input;
use syn_rsx::{parse, NodeAttribute, NodeElement};
use syn_rsx::{parse, NodeAttribute};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum Mode {
@ -284,6 +284,7 @@ pub fn view(tokens: TokenStream) -> 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<String> {
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 `<template>`
@ -704,12 +720,6 @@ pub fn params_derive(
}
}
pub(crate) fn is_component_node(node: &NodeElement) -> bool {
node.name
.to_string()
.starts_with(|c: char| c.is_ascii_uppercase())
}
pub(crate) fn attribute_value(attr: &NodeAttribute) -> &syn::Expr {
match &attr.value {
Some(value) => value.as_ref(),

View File

@ -1,4 +1,5 @@
use crate::{attribute_value, is_component_node};
use crate::attribute_value;
use leptos_hot_reload::parsing::is_component_node;
use proc_macro2::{Ident, Span, TokenStream};
use quote::{quote, quote_spanned};
use syn::spanned::Spanned;

View File

@ -1,4 +1,5 @@
use crate::{attribute_value, is_component_node, Mode};
use crate::{attribute_value, Mode};
use leptos_hot_reload::parsing::{is_component_node, value_to_string};
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use quote::{format_ident, quote, quote_spanned};
use syn::{spanned::Spanned, Expr, ExprLit, ExprPath, Lit};
@ -146,6 +147,7 @@ pub(crate) fn render_view(
nodes: &[Node],
mode: Mode,
global_class: Option<&TokenTree>,
call_site: Option<String>,
) -> TokenStream {
if mode == Mode::Ssr {
match nodes.len() {
@ -155,7 +157,9 @@ pub(crate) fn render_view(
span => leptos::leptos_dom::Unit
}
}
1 => root_node_to_tokens_ssr(cx, &nodes[0], global_class),
1 => {
root_node_to_tokens_ssr(cx, &nodes[0], global_class, call_site)
}
_ => fragment_to_tokens_ssr(
cx,
Span::call_site(),
@ -171,7 +175,13 @@ pub(crate) fn render_view(
span => leptos::leptos_dom::Unit
}
}
1 => node_to_tokens(cx, &nodes[0], TagType::Unknown, global_class),
1 => node_to_tokens(
cx,
&nodes[0],
TagType::Unknown,
global_class,
call_site,
),
_ => fragment_to_tokens(
cx,
Span::call_site(),
@ -188,6 +198,7 @@ fn root_node_to_tokens_ssr(
cx: &Ident,
node: &Node,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
match node {
Node::Fragment(fragment) => fragment_to_tokens_ssr(
@ -211,7 +222,7 @@ fn root_node_to_tokens_ssr(
}
}
Node::Element(node) => {
root_element_to_tokens_ssr(cx, node, global_class)
root_element_to_tokens_ssr(cx, node, global_class, view_marker)
}
}
}
@ -223,7 +234,7 @@ fn fragment_to_tokens_ssr(
global_class: Option<&TokenTree>,
) -> TokenStream {
let nodes = nodes.iter().map(|node| {
let node = root_node_to_tokens_ssr(cx, node, global_class);
let node = root_node_to_tokens_ssr(cx, node, global_class, None);
quote! {
#node.into_view(#cx)
}
@ -241,6 +252,7 @@ fn root_element_to_tokens_ssr(
cx: &Ident,
node: &NodeElement,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
if is_component_node(node) {
component_to_tokens(cx, node, global_class)
@ -265,10 +277,10 @@ fn root_element_to_tokens_ssr(
}
} else {
quote! {
format!(
#template,
#(#holes)*
)
format!(
#template,
#(#holes)*
)
}
};
@ -298,10 +310,15 @@ fn root_element_to_tokens_ssr(
leptos::leptos_dom::#typed_element_name::default()
}
};
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
quote! {
{
#(#exprs_for_compiler)*
::leptos::HtmlElement::from_html(cx, #full_name, #template)
::leptos::HtmlElement::from_html(cx, #full_name, #template)#view_marker
}
}
}
@ -429,19 +446,6 @@ fn element_to_tokens_ssr(
}
}
fn value_to_string(value: &syn_rsx::NodeValueExpr) -> Option<String> {
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,
}
}
// returns `inner_html`
fn attribute_to_tokens_ssr<'a>(
cx: &Ident,
@ -634,7 +638,7 @@ fn fragment_to_tokens(
global_class: Option<&TokenTree>,
) -> TokenStream {
let nodes = nodes.iter().map(|node| {
let node = node_to_tokens(cx, node, parent_type, global_class);
let node = node_to_tokens(cx, node, parent_type, global_class, None);
quote! {
#node.into_view(#cx)
@ -664,6 +668,7 @@ fn node_to_tokens(
node: &Node,
parent_type: TagType,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
match node {
Node::Fragment(fragment) => fragment_to_tokens(
@ -687,7 +692,7 @@ fn node_to_tokens(
}
Node::Attribute(node) => attribute_to_tokens(cx, node),
Node::Element(node) => {
element_to_tokens(cx, node, parent_type, global_class)
element_to_tokens(cx, node, parent_type, global_class, view_marker)
}
}
}
@ -697,6 +702,7 @@ fn element_to_tokens(
node: &NodeElement,
mut parent_type: TagType,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
if is_component_node(node) {
component_to_tokens(cx, node, global_class)
@ -778,7 +784,7 @@ fn element_to_tokens(
}
}
Node::Element(node) => {
element_to_tokens(cx, node, parent_type, global_class)
element_to_tokens(cx, node, parent_type, global_class, None)
}
Node::Comment(_) | Node::Doctype(_) | Node::Attribute(_) => {
quote! {}
@ -788,11 +794,17 @@ fn element_to_tokens(
.child((#cx, #child))
}
});
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
quote! {
#name
#(#attrs)*
#global_class_expr
#(#children)*
#view_marker
}
}
}

View File

@ -250,12 +250,11 @@ impl MetaContext {
/// };
///
/// // `app` contains only the body content w/ hydration stuff, not the meta tags
/// assert_eq!(
/// app.into_view(cx).render_to_string(cx),
/// "<main id=\"_0-1\"><!--hk=_0-2c|leptos-unit--><!--hk=_0-4c|leptos-unit--><p id=\"_0-5\">Some text</p></main>"
/// assert!(
/// !app.into_view(cx).render_to_string(cx).contains("my title")
/// );
/// // `MetaContext::dehydrate()` gives you HTML that should be in the `<head>`
/// assert_eq!(use_head(cx).dehydrate(), "<title>my title</title><link id=\"leptos-link-1\" href=\"/style.css\" rel=\"stylesheet\" leptos-hk=\"_0-3\"/>")
/// assert!(use_head(cx).dehydrate().contains("<title>my title</title>"))
/// });
/// # }
/// ```