feat: restore hot reloading for 0.7 (#2775)

This commit is contained in:
Greg Johnston 2024-08-12 16:11:30 -04:00 committed by GitHub
parent 5657abc07d
commit 7b62ad44d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 252 additions and 66 deletions

View file

@ -72,6 +72,7 @@ pub fn ContactRoutes() -> impl MatchNestedRoutes<Dom> + Clone {
<Route path=path!("/:id") view=Contact/> <Route path=path!("/:id") view=Contact/>
</ParentRoute> </ParentRoute>
} }
.into_inner()
} }
#[component] #[component]

View file

@ -17,6 +17,7 @@ cfg-if = "1.0"
hydration_context = { workspace = true } hydration_context = { workspace = true }
either_of = { workspace = true } either_of = { workspace = true }
leptos_dom = { workspace = true } leptos_dom = { workspace = true }
leptos_hot_reload = { workspace = true }
leptos_macro = { workspace = true } leptos_macro = { workspace = true }
leptos_server = { workspace = true, features = ["tachys"] } leptos_server = { workspace = true, features = ["tachys"] }
leptos_config = { workspace = true } leptos_config = { workspace = true }

View file

@ -168,7 +168,7 @@ mod tests {
fn creates_list() { fn creates_list() {
Owner::new().with(|| { Owner::new().with(|| {
let values = RwSignal::new(vec![1, 2, 3, 4, 5]); let values = RwSignal::new(vec![1, 2, 3, 4, 5]);
let list: HtmlElement<_, _, _, Dom> = view! { let list: View<HtmlElement<_, _, _, Dom>> = view! {
<ol> <ol>
<For each=move || values.get() key=|i| *i let:i> <For each=move || values.get() key=|i| *i let:i>
<li>{i}</li> <li>{i}</li>
@ -187,7 +187,7 @@ mod tests {
fn creates_list_enumerate() { fn creates_list_enumerate() {
Owner::new().with(|| { Owner::new().with(|| {
let values = RwSignal::new(vec![1, 2, 3, 4, 5]); let values = RwSignal::new(vec![1, 2, 3, 4, 5]);
let list: HtmlElement<_, _, _, Dom> = view! { let list: View<HtmlElement<_, _, _, Dom>> = view! {
<ol> <ol>
<ForEnumerate each=move || values.get() key=|i| *i let(index, i)> <ForEnumerate each=move || values.get() key=|i| *i let(index, i)>
<li>{move || index.get()}"-"{i}</li> <li>{move || index.get()}"-"{i}</li>
@ -200,7 +200,7 @@ mod tests {
<!>-<!>4</li><li>4<!>-<!>5</li><!></ol>" <!>-<!>4</li><li>4<!>-<!>5</li><!></ol>"
); );
let list: HtmlElement<_, _, _, Dom> = view! { let list: View<HtmlElement<_, _, _, Dom>> = view! {
<ol> <ol>
<ForEnumerate each=move || values.get() key=|i| *i let(index, i)> <ForEnumerate each=move || values.get() key=|i| *i let(index, i)>
<li>{move || index.get()}"-"{i}</li> <li>{move || index.get()}"-"{i}</li>

View file

@ -24,8 +24,13 @@ pub fn AutoReload(
leptos_config::ReloadWSProtocol::WSS => "'wss://'", leptos_config::ReloadWSProtocol::WSS => "'wss://'",
}; };
let script = include_str!("reload_script.js"); let script = format!(
view! { <script nonce=nonce>{format!("{script}({reload_port:?}, {protocol})")}</script> } "(function (reload_port, protocol) {{ {} {} }})({reload_port:?}, \
{protocol})",
leptos_hot_reload::HOT_RELOAD_JS,
include_str!("reload_script.js")
);
view! { <script nonce=nonce>{script}</script> }
}) })
} }

View file

@ -1,4 +1,3 @@
(function (reload_port, protocol) {
let host = window.location.hostname; let host = window.location.hostname;
let ws = new WebSocket(`${protocol}${host}:${reload_port}/live_reload`); let ws = new WebSocket(`${protocol}${host}:${reload_port}/live_reload`);
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
@ -20,4 +19,3 @@ ws.onmessage = (ev) => {
} }
}; };
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.'); ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
})

View file

@ -1,18 +1,50 @@
use std::borrow::Cow;
use tachys::{ use tachys::{
html::attribute::Attribute, html::attribute::Attribute,
hydration::Cursor, hydration::Cursor,
renderer::dom::Dom, renderer::{dom::Dom, Renderer},
ssr::StreamBuilder, ssr::StreamBuilder,
view::{add_attr::AddAnyAttr, Position, PositionState, Render, RenderHtml}, view::{
add_attr::AddAnyAttr, Position, PositionState, Render, RenderHtml,
ToTemplate,
},
}; };
pub struct View<T>(T) #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct View<T>
where where
T: Sized; T: Sized,
{
inner: T,
#[cfg(debug_assertions)]
view_marker: Option<Cow<'static, str>>,
}
impl<T> View<T> { impl<T> View<T> {
pub fn new(inner: T) -> Self {
Self {
inner,
#[cfg(debug_assertions)]
view_marker: None,
}
}
pub fn into_inner(self) -> T { pub fn into_inner(self) -> T {
self.0 self.inner
}
#[inline(always)]
pub fn with_view_marker(
#[allow(unused_mut)] // used in debug
mut self,
#[allow(unused_variables)] // used in debug
view_marker: impl Into<Cow<'static, str>>,
) -> Self {
#[cfg(debug_assertions)]
{
self.view_marker = Some(view_marker.into());
}
self
} }
} }
@ -28,33 +60,37 @@ where
T: Sized + Render<Dom> + RenderHtml<Dom> + Send, //+ AddAnyAttr<Dom>, T: Sized + Render<Dom> + RenderHtml<Dom> + Send, //+ AddAnyAttr<Dom>,
{ {
fn into_view(self) -> View<Self> { fn into_view(self) -> View<Self> {
View(self) View {
inner: self,
#[cfg(debug_assertions)]
view_marker: None,
}
} }
} }
impl<T: IntoView> Render<Dom> for View<T> { impl<T: Render<Rndr>, Rndr: Renderer> Render<Rndr> for View<T> {
type State = T::State; type State = T::State;
fn build(self) -> Self::State { fn build(self) -> Self::State {
self.0.build() self.inner.build()
} }
fn rebuild(self, state: &mut Self::State) { fn rebuild(self, state: &mut Self::State) {
self.0.rebuild(state) self.inner.rebuild(state)
} }
} }
impl<T: IntoView> RenderHtml<Dom> for View<T> { impl<T: RenderHtml<Rndr>, Rndr: Renderer> RenderHtml<Rndr> for View<T> {
type AsyncOutput = T::AsyncOutput; type AsyncOutput = T::AsyncOutput;
const MIN_LENGTH: usize = <T as RenderHtml<Dom>>::MIN_LENGTH; const MIN_LENGTH: usize = <T as RenderHtml<Rndr>>::MIN_LENGTH;
async fn resolve(self) -> Self::AsyncOutput { async fn resolve(self) -> Self::AsyncOutput {
self.0.resolve().await self.inner.resolve().await
} }
fn dry_resolve(&mut self) { fn dry_resolve(&mut self) {
self.0.dry_resolve(); self.inner.dry_resolve();
} }
fn to_html_with_buf( fn to_html_with_buf(
@ -64,8 +100,20 @@ impl<T: IntoView> RenderHtml<Dom> for View<T> {
escape: bool, escape: bool,
mark_branches: bool, mark_branches: bool,
) { ) {
self.0 #[cfg(debug_assertions)]
let vm = self.view_marker.to_owned();
#[cfg(debug_assertions)]
if let Some(vm) = vm.as_ref() {
buf.push_str(&format!("<!--hot-reload|{vm}|open-->"));
}
self.inner
.to_html_with_buf(buf, position, escape, mark_branches); .to_html_with_buf(buf, position, escape, mark_branches);
#[cfg(debug_assertions)]
if let Some(vm) = vm.as_ref() {
buf.push_str(&format!("<!--hot-reload|{vm}|close-->"));
}
} }
fn to_html_async_with_buf<const OUT_OF_ORDER: bool>( fn to_html_async_with_buf<const OUT_OF_ORDER: bool>(
@ -77,35 +125,70 @@ impl<T: IntoView> RenderHtml<Dom> for View<T> {
) where ) where
Self: Sized, Self: Sized,
{ {
self.0.to_html_async_with_buf::<OUT_OF_ORDER>( #[cfg(debug_assertions)]
let vm = self.view_marker.to_owned();
#[cfg(debug_assertions)]
if let Some(vm) = vm.as_ref() {
buf.push_sync(&format!("<!--hot-reload|{vm}|open-->"));
}
self.inner.to_html_async_with_buf::<OUT_OF_ORDER>(
buf, buf,
position, position,
escape, escape,
mark_branches, mark_branches,
) );
#[cfg(debug_assertions)]
if let Some(vm) = vm.as_ref() {
buf.push_sync(&format!("<!--hot-reload|{vm}|close-->"));
}
} }
fn hydrate<const FROM_SERVER: bool>( fn hydrate<const FROM_SERVER: bool>(
self, self,
cursor: &Cursor<Dom>, cursor: &Cursor<Rndr>,
position: &PositionState, position: &PositionState,
) -> Self::State { ) -> Self::State {
self.0.hydrate::<FROM_SERVER>(cursor, position) self.inner.hydrate::<FROM_SERVER>(cursor, position)
} }
} }
impl<T: IntoView> AddAnyAttr<Dom> for View<T> { impl<T: ToTemplate> ToTemplate for View<T> {
type Output<SomeNewAttr: Attribute<Dom>> = fn to_template(
<T as AddAnyAttr<Dom>>::Output<SomeNewAttr>; buf: &mut String,
class: &mut String,
style: &mut String,
inner_html: &mut String,
position: &mut Position,
) {
T::to_template(buf, class, style, inner_html, position);
}
}
fn add_any_attr<NewAttr: Attribute<Dom>>( impl<T: AddAnyAttr<Rndr>, Rndr> AddAnyAttr<Rndr> for View<T>
where
Rndr: Renderer,
{
type Output<SomeNewAttr: Attribute<Rndr>> = View<T::Output<SomeNewAttr>>;
fn add_any_attr<NewAttr: Attribute<Rndr>>(
self, self,
attr: NewAttr, attr: NewAttr,
) -> Self::Output<NewAttr> ) -> Self::Output<NewAttr>
where where
Self::Output<NewAttr>: RenderHtml<Dom>, Self::Output<NewAttr>: RenderHtml<Rndr>,
{ {
self.0.add_any_attr(attr) let View {
inner,
#[cfg(debug_assertions)]
view_marker,
} = self;
View {
inner: inner.add_any_attr(attr),
#[cfg(debug_assertions)]
view_marker,
}
} }
} }

View file

@ -1,4 +1,6 @@
use crate::{logging, IntoView}; #[cfg(debug_assertions)]
use crate::logging;
use crate::IntoView;
use any_spawner::Executor; use any_spawner::Executor;
use reactive_graph::owner::Owner; use reactive_graph::owner::Owner;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]

View file

@ -5,7 +5,7 @@ fn simple_ssr_test() {
use leptos::prelude::*; use leptos::prelude::*;
let (value, set_value) = signal(0); let (value, set_value) = signal(0);
let rendered: HtmlElement<_, _, _, Dom> = view! { let rendered: View<HtmlElement<_, _, _, Dom>> = view! {
<div> <div>
<button on:click=move |_| set_value.update(|value| *value -= 1)>"-1"</button> <button on:click=move |_| set_value.update(|value| *value -= 1)>"-1"</button>
<span>"Value: " {move || value.get().to_string()} "!"</span> <span>"Value: " {move || value.get().to_string()} "!"</span>
@ -36,7 +36,7 @@ fn ssr_test_with_components() {
} }
} }
let rendered: HtmlElement<_, _, _, Dom> = view! { let rendered: View<HtmlElement<_, _, _, Dom>> = view! {
<div class="counters"> <div class="counters">
<Counter initial_value=1/> <Counter initial_value=1/>
<Counter initial_value=2/> <Counter initial_value=2/>
@ -66,7 +66,7 @@ fn ssr_test_with_snake_case_components() {
</div> </div>
} }
} }
let rendered: HtmlElement<_, _, _, Dom> = view! { let rendered: View<HtmlElement<_, _, _, Dom>> = view! {
<div class="counters"> <div class="counters">
<SnakeCaseCounter initial_value=1/> <SnakeCaseCounter initial_value=1/>
<SnakeCaseCounter initial_value=2/> <SnakeCaseCounter initial_value=2/>
@ -86,7 +86,7 @@ fn test_classes() {
use leptos::prelude::*; use leptos::prelude::*;
let (value, _set_value) = signal(5); let (value, _set_value) = signal(5);
let rendered: HtmlElement<_, _, _, Dom> = view! { let rendered: View<HtmlElement<_, _, _, Dom>> = view! {
<div <div
class="my big" class="my big"
class:a=move || { value.get() > 10 } class:a=move || { value.get() > 10 }
@ -104,7 +104,7 @@ fn ssr_with_styles() {
let (_, set_value) = signal(0); let (_, set_value) = signal(0);
let styles = "myclass"; let styles = "myclass";
let rendered: HtmlElement<_, _, _, Dom> = view! { class=styles, let rendered: View<HtmlElement<_, _, _, Dom>> = view! { class=styles,
<div> <div>
<button class="btn" on:click=move |_| set_value.update(|value| *value -= 1)> <button class="btn" on:click=move |_| set_value.update(|value| *value -= 1)>
"-1" "-1"
@ -124,7 +124,7 @@ fn ssr_option() {
use leptos::prelude::*; use leptos::prelude::*;
let (_, _) = signal(0); let (_, _) = signal(0);
let rendered: HtmlElement<_, _, _, Dom> = view! { <option></option> }; let rendered: View<HtmlElement<_, _, _, Dom>> = view! { <option></option> };
assert_eq!(rendered.to_html(), "<option></option>"); assert_eq!(rendered.to_html(), "<option></option>");
} }

View file

@ -1,12 +1,12 @@
console.log("[HOT RELOADING] Connected to server."); console.log("[HOT RELOADING] Connected to server.\n\nNote: `cargo-leptos watch --hot-reload` only works with the `nightly` feature enabled on Leptos.");
function patch(json) { function patch(json) {
try { try {
const views = JSON.parse(json); const views = JSON.parse(json);
for (const [id, patches] of views) { for (const [id, patches] of views) {
console.log("[HOT RELOAD]", id, patches); console.log("[HOT RELOAD]", id, patches);
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT), const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT),
open = `leptos-view|${id}|open`, open = `hot-reload|${id}|open`,
close = `leptos-view|${id}|close`; close = `hot-reload|${id}|close`;
let start, end; let start, end;
const instances = []; const instances = [];
while (walker.nextNode()) { while (walker.nextNode()) {
@ -259,11 +259,11 @@ function patch(json) {
node: walker.currentNode, node: walker.currentNode,
}); });
} else if (walker.currentNode.nodeType == Node.COMMENT_NODE) { } else if (walker.currentNode.nodeType == Node.COMMENT_NODE) {
if (walker.currentNode.textContent.trim().startsWith("leptos-view")) { if (walker.currentNode.textContent.trim().startsWith("hot-reload")) {
if (walker.currentNode.textContent.trim().endsWith("-children|open")) { if (walker.currentNode.textContent.trim().endsWith("-children|open")) {
const startingName = walker.currentNode.textContent.trim(); const startingName = walker.currentNode.textContent.trim();
const componentName = startingName.replace("-children|open").replace("leptos-view|"); const componentName = startingName.replace("-children|open").replace("hot-reload|");
const endingName = `leptos-view|${componentName}-children|close`; const endingName = `hot-reload|${componentName}-children|close`;
let start = walker.currentNode; let start = walker.currentNode;
let depth = 1; let depth = 1;

View file

@ -302,7 +302,11 @@ pub fn view(tokens: TokenStream) -> TokenStream {
let parser = rstml::Parser::new(config); let parser = rstml::Parser::new(config);
let (nodes, errors) = parser.parse_recoverable(tokens).split_vec(); let (nodes, errors) = parser.parse_recoverable(tokens).split_vec();
let errors = errors.into_iter().map(|e| e.emit_as_expr_tokens()); let errors = errors.into_iter().map(|e| e.emit_as_expr_tokens());
let nodes_output = view::render_view(&nodes, global_class.as_ref(), None); let nodes_output = view::render_view(
&nodes,
global_class.as_ref(),
normalized_call_site(proc_macro::Span::call_site()),
);
quote! { quote! {
{ {
#(#errors;)* #(#errors;)*
@ -312,6 +316,20 @@ pub fn view(tokens: TokenStream) -> TokenStream {
.into() .into()
} }
fn normalized_call_site(site: proc_macro::Span) -> Option<String> {
cfg_if::cfg_if! {
if #[cfg(all(debug_assertions, feature = "nightly"))] {
Some(leptos_hot_reload::span_to_stable_id(
site.source_file().path(),
site.start().line()
))
} else {
_ = site;
None
}
}
}
/// Annotates a function so that it can be used with your template as a Leptos `<Component/>`. /// Annotates a function so that it can be used with your template as a Leptos `<Component/>`.
/// ///
/// The `#[component]` macro allows you to annotate plain Rust functions as components /// The `#[component]` macro allows you to annotate plain Rust functions as components

View file

@ -30,28 +30,60 @@ pub fn render_view(
global_class: Option<&TokenTree>, global_class: Option<&TokenTree>,
view_marker: Option<String>, view_marker: Option<String>,
) -> Option<TokenStream> { ) -> Option<TokenStream> {
match nodes.len() { let (base, should_add_view) = match nodes.len() {
0 => { 0 => {
let span = Span::call_site(); let span = Span::call_site();
(
Some(quote_spanned! { Some(quote_spanned! {
span => () span => ()
}) }),
false,
)
} }
1 => node_to_tokens( 1 => (
node_to_tokens(
&nodes[0], &nodes[0],
TagType::Unknown, TagType::Unknown,
None, None,
global_class, global_class,
view_marker.as_deref(), view_marker.as_deref(),
), ),
_ => fragment_to_tokens( // only add View wrapper and view marker to a regular HTML
// element or component, not to a <{..} /> attribute list
match &nodes[0] {
Node::Element(node) => !is_spread_marker(node),
_ => false,
},
),
_ => (
fragment_to_tokens(
nodes, nodes,
TagType::Unknown, TagType::Unknown,
None, None,
global_class, global_class,
view_marker.as_deref(), view_marker.as_deref(),
), ),
true,
),
};
base.map(|view| {
if !should_add_view {
view
} else if let Some(vm) = view_marker {
quote! {
::leptos::prelude::View::new(
#view
)
.with_view_marker(#vm)
} }
} else {
quote! {
::leptos::prelude::View::new(
#view
)
}
}
})
} }
fn element_children_to_tokens( fn element_children_to_tokens(

View file

@ -345,7 +345,9 @@ where
Unsuspend::new(move || match condition { Unsuspend::new(move || match condition {
Some(true) => Either::Left(view()), Some(true) => Either::Left(view()),
#[allow(clippy::unit_arg)] #[allow(clippy::unit_arg)]
Some(false) => Either::Right(view! { <Redirect path=redirect_path()/> }), Some(false) => {
Either::Right(view! { <Redirect path=redirect_path()/> }.into_inner())
}
None => Either::Right(()), None => Either::Right(()),
}) })
}} }}
@ -390,7 +392,9 @@ where
match condition() { match condition() {
Some(true) => Either::Left(view()), Some(true) => Either::Left(view()),
#[allow(clippy::unit_arg)] #[allow(clippy::unit_arg)]
Some(false) => Either::Right(view! { <Redirect path=redirect_path()/> }), Some(false) => {
Either::Right(view! { <Redirect path=redirect_path()/> }.into_inner())
}
None => Either::Right(()), None => Either::Right(()),
} }
}} }}

View file

@ -92,12 +92,54 @@ impl Renderer for Dom {
} }
fn first_child(node: &Self::Node) -> Option<Self::Node> { fn first_child(node: &Self::Node) -> Option<Self::Node> {
#[cfg(debug_assertions)]
{
let node = node.first_child();
// if it's a comment node that starts with hot-reload, it's a marker that should be
// ignored
if let Some(node) = node.as_ref() {
if node.node_type() == 8
&& node
.text_content()
.unwrap_or_default()
.starts_with("hot-reload")
{
return Self::next_sibling(node);
}
}
node
}
#[cfg(not(debug_assertions))]
{
node.first_child() node.first_child()
} }
}
fn next_sibling(node: &Self::Node) -> Option<Self::Node> { fn next_sibling(node: &Self::Node) -> Option<Self::Node> {
#[cfg(debug_assertions)]
{
let node = node.next_sibling();
// if it's a comment node that starts with hot-reload, it's a marker that should be
// ignored
if let Some(node) = node.as_ref() {
if node.node_type() == 8
&& node
.text_content()
.unwrap_or_default()
.starts_with("hot-reload")
{
return Self::next_sibling(node);
}
}
node
}
#[cfg(not(debug_assertions))]
{
node.next_sibling() node.next_sibling()
} }
}
fn log_node(node: &Self::Node) { fn log_node(node: &Self::Node) {
web_sys::console::log_1(node); web_sys::console::log_1(node);