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/>
</ParentRoute>
}
.into_inner()
}
#[component]

View file

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

View file

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

View file

@ -24,8 +24,13 @@ pub fn AutoReload(
leptos_config::ReloadWSProtocol::WSS => "'wss://'",
};
let script = include_str!("reload_script.js");
view! { <script nonce=nonce>{format!("{script}({reload_port:?}, {protocol})")}</script> }
let script = format!(
"(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 ws = new WebSocket(`${protocol}${host}:${reload_port}/live_reload`);
ws.onmessage = (ev) => {
@ -20,4 +19,3 @@ ws.onmessage = (ev) => {
}
};
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
})

View file

@ -1,18 +1,50 @@
use std::borrow::Cow;
use tachys::{
html::attribute::Attribute,
hydration::Cursor,
renderer::dom::Dom,
renderer::{dom::Dom, Renderer},
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
T: Sized;
T: Sized,
{
inner: T,
#[cfg(debug_assertions)]
view_marker: Option<Cow<'static, str>>,
}
impl<T> View<T> {
pub fn new(inner: T) -> Self {
Self {
inner,
#[cfg(debug_assertions)]
view_marker: None,
}
}
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>,
{
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;
fn build(self) -> Self::State {
self.0.build()
self.inner.build()
}
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;
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 {
self.0.resolve().await
self.inner.resolve().await
}
fn dry_resolve(&mut self) {
self.0.dry_resolve();
self.inner.dry_resolve();
}
fn to_html_with_buf(
@ -64,8 +100,20 @@ impl<T: IntoView> RenderHtml<Dom> for View<T> {
escape: 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);
#[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>(
@ -77,35 +125,70 @@ impl<T: IntoView> RenderHtml<Dom> for View<T> {
) where
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,
position,
escape,
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>(
self,
cursor: &Cursor<Dom>,
cursor: &Cursor<Rndr>,
position: &PositionState,
) -> Self::State {
self.0.hydrate::<FROM_SERVER>(cursor, position)
self.inner.hydrate::<FROM_SERVER>(cursor, position)
}
}
impl<T: IntoView> AddAnyAttr<Dom> for View<T> {
type Output<SomeNewAttr: Attribute<Dom>> =
<T as AddAnyAttr<Dom>>::Output<SomeNewAttr>;
impl<T: ToTemplate> ToTemplate for View<T> {
fn to_template(
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,
attr: NewAttr,
) -> Self::Output<NewAttr>
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 reactive_graph::owner::Owner;
#[cfg(debug_assertions)]

View file

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

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) {
try {
const views = JSON.parse(json);
for (const [id, patches] of views) {
console.log("[HOT RELOAD]", id, patches);
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT),
open = `leptos-view|${id}|open`,
close = `leptos-view|${id}|close`;
open = `hot-reload|${id}|open`,
close = `hot-reload|${id}|close`;
let start, end;
const instances = [];
while (walker.nextNode()) {
@ -259,11 +259,11 @@ function patch(json) {
node: walker.currentNode,
});
} 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")) {
const startingName = walker.currentNode.textContent.trim();
const componentName = startingName.replace("-children|open").replace("leptos-view|");
const endingName = `leptos-view|${componentName}-children|close`;
const componentName = startingName.replace("-children|open").replace("hot-reload|");
const endingName = `hot-reload|${componentName}-children|close`;
let start = walker.currentNode;
let depth = 1;

View file

@ -302,7 +302,11 @@ pub fn view(tokens: TokenStream) -> TokenStream {
let parser = rstml::Parser::new(config);
let (nodes, errors) = parser.parse_recoverable(tokens).split_vec();
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! {
{
#(#errors;)*
@ -312,6 +316,20 @@ pub fn view(tokens: TokenStream) -> TokenStream {
.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/>`.
///
/// 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>,
view_marker: Option<String>,
) -> Option<TokenStream> {
match nodes.len() {
let (base, should_add_view) = match nodes.len() {
0 => {
let span = Span::call_site();
Some(quote_spanned! {
span => ()
})
(
Some(quote_spanned! {
span => ()
}),
false,
)
}
1 => node_to_tokens(
&nodes[0],
TagType::Unknown,
None,
global_class,
view_marker.as_deref(),
1 => (
node_to_tokens(
&nodes[0],
TagType::Unknown,
None,
global_class,
view_marker.as_deref(),
),
// 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,
TagType::Unknown,
None,
global_class,
view_marker.as_deref(),
_ => (
fragment_to_tokens(
nodes,
TagType::Unknown,
None,
global_class,
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(

View file

@ -345,7 +345,9 @@ where
Unsuspend::new(move || match condition {
Some(true) => Either::Left(view()),
#[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(()),
})
}}
@ -390,7 +392,9 @@ where
match condition() {
Some(true) => Either::Left(view()),
#[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(()),
}
}}

View file

@ -92,11 +92,53 @@ impl Renderer for Dom {
}
fn first_child(node: &Self::Node) -> Option<Self::Node> {
node.first_child()
#[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()
}
}
fn next_sibling(node: &Self::Node) -> Option<Self::Node> {
node.next_sibling()
#[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()
}
}
fn log_node(node: &Self::Node) {