diff --git a/packages/perseus-macro/Cargo.toml b/packages/perseus-macro/Cargo.toml index 90a8f49b80..2720b57fd5 100644 --- a/packages/perseus-macro/Cargo.toml +++ b/packages/perseus-macro/Cargo.toml @@ -32,3 +32,9 @@ trybuild = { version = "1.0", features = ["diff"] } sycamore = "^0.7.1" serde = { version = "1", features = [ "derive" ] } perseus = { path = "../perseus", version = "0.3.2" } + +[features] +# Enables live reloading support (which makes the macros listen for live reload events and adjust appropriately). Do NOT enable this here without also enabling it on `perseus`! +live-reload = [] +# Enables support for HSR (which makes the macros respond to live reload events by freezing and thawing as appropriate). Do NOT enable this here without also enabling is on `perseus`! +hsr = [ "live-reload" ] diff --git a/packages/perseus-macro/src/template.rs b/packages/perseus-macro/src/template.rs index 1bba1a0804..ee8c113608 100644 --- a/packages/perseus-macro/src/template.rs +++ b/packages/perseus-macro/src/template.rs @@ -5,6 +5,8 @@ use syn::{ Attribute, Block, FnArg, Generics, Ident, Item, ItemFn, Result, ReturnType, Type, Visibility, }; +use crate::template_rx::get_live_reload_frag; + /// A function that can be wrapped in the Perseus test sub-harness. pub struct TemplateFn { /// The body of the function. @@ -112,12 +114,17 @@ pub fn template_impl(input: TemplateFn, component_name: Ident) -> TokenStream { return_type, } = input; + // Set up a code fragment for responding to live reload events + let live_reload_frag = get_live_reload_frag(); + // We create a wrapper function that can be easily provided to `.template()` that does deserialization automatically if needed // This is dependent on what arguments the template takes if arg.is_some() { // There's an argument that will be provided as a `String`, so the wrapper will deserialize it quote! { #vis fn #name(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View { + #live_reload_frag + // The user's function, with Sycamore component annotations and the like preserved // We know this won't be async because Sycamore doesn't allow that #(#attrs)* @@ -136,6 +143,8 @@ pub fn template_impl(input: TemplateFn, component_name: Ident) -> TokenStream { // There are no arguments quote! { #vis fn #name(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View { + #live_reload_frag + // The user's function, with Sycamore component annotations and the like preserved // We know this won't be async because Sycamore doesn't allow that #(#attrs)* diff --git a/packages/perseus-macro/src/template_rx.rs b/packages/perseus-macro/src/template_rx.rs index 6e453a58c9..d44211bcc4 100644 --- a/packages/perseus-macro/src/template_rx.rs +++ b/packages/perseus-macro/src/template_rx.rs @@ -98,6 +98,36 @@ impl Parse for TemplateFn { } } +/// Gets the code fragment used to support live reloading and HSR. +// This is also used by the normal `#[template(...)]` macro +pub fn get_live_reload_frag() -> TokenStream { + #[cfg(all(feature = "live-reload", debug_assertions))] + let live_reload_frag = quote! { + use ::sycamore::prelude::cloned; // Pending sycamore-rs/sycamore#339 + let render_ctx = ::perseus::get_render_ctx!(); + // Listen to the live reload indicator and reload when required + let indic = render_ctx.live_reload_indicator; + let mut is_first = true; + ::sycamore::prelude::create_effect(cloned!(indic => move || { + let _ = indic.get(); // This is a flip-flop, we don't care about the value + // This will be triggered on initialization as well, which would give us a reload loop + if !is_first { + // Conveniently, Perseus re-exports `wasm_bindgen_futures::spawn_local`! + ::perseus::spawn_local(async move { + ::perseus::state::force_reload(); + // We shouldn't ever get here unless there was an error, the entire page will be fully reloaded + }) + } else { + is_first = false; + } + })); + }; + #[cfg(not(all(feature = "live-reload", debug_assertions)))] + let live_reload_frag = quote!(); + + live_reload_frag +} + pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream { let TemplateFn { block, @@ -144,6 +174,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream None => Ident::new("G", Span::call_site()), }; + // Set up a code fragment for responding to live reload events + let live_reload_frag = get_live_reload_frag(); + // We create a wrapper function that can be easily provided to `.template()` that does deserialization automatically if needed // This is dependent on what arguments the template takes if fn_args.len() == 2 { @@ -175,6 +208,8 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream render_ctx.register_global_state_str::<#global_state_rx>(&props.global_state.unwrap()).unwrap(); } + #live_reload_frag + // The user's function // We know this won't be async because Sycamore doesn't allow that #(#attrs)* @@ -222,6 +257,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream quote! { #vis fn #name(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View { use ::perseus::state::MakeRx; + + #live_reload_frag + // The user's function, with Sycamore component annotations and the like preserved // We know this won't be async because Sycamore doesn't allow that #(#attrs)* @@ -256,6 +294,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream quote! { #vis fn #name(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View { use ::perseus::state::MakeRx; + + #live_reload_frag + // The user's function, with Sycamore component annotations and the like preserved // We know this won't be async because Sycamore doesn't allow that #(#attrs)* diff --git a/packages/perseus/Cargo.toml b/packages/perseus/Cargo.toml index 56758bfe4f..61b7a27bc8 100644 --- a/packages/perseus/Cargo.toml +++ b/packages/perseus/Cargo.toml @@ -62,6 +62,6 @@ idb-freezing = [ "rexie", "web-sys/StorageManager" ] # Note that this is highly experimental, and currently blocked by [rustwasm/wasm-bindgen#2735](https://github.com/rustwasm/wasm-bindgen/issues/2735) wasm2js = [] # Enables automatic browser reloading whenever you make a change -live-reload = [ "js-sys", "web-sys/WebSocket", "web-sys/MessageEvent", "web-sys/ErrorEvent", "web-sys/BinaryType", "web-sys/Location" ] +live-reload = [ "perseus-macro/live-reload", "js-sys", "web-sys/WebSocket", "web-sys/MessageEvent", "web-sys/ErrorEvent", "web-sys/BinaryType", "web-sys/Location" ] # Enables hot state reloading, whereby your entire app's state can be frozen and thawed automatically every time you change code in your app -hsr = [ "live-reload", "idb-freezing" ] +hsr = [ "live-reload", "idb-freezing", "perseus-macro/hsr" ] diff --git a/packages/perseus/src/router/router_component.rs b/packages/perseus/src/router/router_component.rs index f9464c5bf7..ef5524a6b3 100644 --- a/packages/perseus/src/router/router_component.rs +++ b/packages/perseus/src/router/router_component.rs @@ -35,7 +35,7 @@ const ROUTE_ANNOUNCER_STYLES: &str = r#" word-wrap: normal; "#; -/// The properties that `on_route_change` takes. +/// The properties that `on_route_change` takes. See the shell properties for the details for most of these. #[derive(Debug, Clone)] struct OnRouteChangeProps { locales: Rc, @@ -47,6 +47,9 @@ struct OnRouteChangeProps { translations_manager: Rc>, error_pages: Rc>, initial_container: Option, + is_first: bool, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator: ReadSignal, } /// The function that runs when a route change takes place. This can also be run at any time to force the current page to reload. @@ -62,6 +65,9 @@ fn on_route_change( translations_manager, error_pages, initial_container, + is_first, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator, }: OnRouteChangeProps, ) { wasm_bindgen_futures::spawn_local(async move { @@ -94,6 +100,9 @@ fn on_route_change( global_state, frozen_app, route_verdict: verdict, + is_first, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator, }, ) .await @@ -183,6 +192,12 @@ pub fn perseus_router + 'static>( // Instantiate an empty frozen app that can persist across templates (with interior mutability for possible thawing) let frozen_app: Rc>> = Rc::new(RefCell::new(None)); + // If we're using live reload, set up an indicator so that our listening to the WebSocket at the top-level (where we don't have the render context that we need for freezing/thawing) + // can signal the templates to perform freezing/thawing + // It doesn't matter what the initial value is, this is just a flip-flop + #[cfg(all(feature = "live-reload", debug_assertions))] + let live_reload_indicator = Signal::new(true); + // Create a derived state for the route announcement // We do this with an effect because we only want to update in some cases (when the new page is actually loaded) // We also need to know if it's the first page (because we don't want to announce that, screen readers will get that one right) @@ -245,6 +260,10 @@ pub fn perseus_router + 'static>( translations_manager, error_pages, initial_container, + // We can piggyback off a different part of the code for an entirely different purpose! + is_first: is_first_page, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator: live_reload_indicator.handle(), }; // Listen for changes to the reload commander and reload as appropriate @@ -265,8 +284,9 @@ pub fn perseus_router + 'static>( // TODO State thawing in HSR // If live reloading is enabled, connect to the server now + // This doesn't actually perform any reloading or the like, it just signals places that have access to the render context to do so (because we need that for state freezing/thawing) #[cfg(all(feature = "live-reload", debug_assertions))] - crate::state::connect_to_reload_server(); + crate::state::connect_to_reload_server(live_reload_indicator); view! { Router(RouterProps::new(HistoryIntegration::new(), cloned!(on_route_change_props => move |route: ReadSignal| { diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index 18f5e873a7..94e8ddb670 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -260,6 +260,11 @@ pub struct ShellProps { /// The current route verdict. This will be stored in context so that it can be used for possible reloads. Eventually, /// this will be made obsolete when Sycamore supports this natively. pub route_verdict: RouteVerdict, + /// Whether or not this page is the very first to have been rendered since the browser loaded the app. + pub is_first: bool, + #[cfg(all(feature = "live-reload", debug_assertions))] + /// An indicator `Signal` used to allow the root to instruct the app that we're about to reload because of an instruction from the live reloading server. + pub live_reload_indicator: ReadSignal, } /// Fetches the information for the given page and renders it. This should be provided the actual path of the page to render (not just the @@ -280,6 +285,9 @@ pub async fn app_shell( global_state: curr_global_state, frozen_app, route_verdict, + is_first, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator, }: ShellProps, ) { checkpoint("app_shell_entry"); @@ -367,6 +375,9 @@ pub async fn app_shell( page_state_store, curr_global_state, frozen_app, + is_first, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator, ) }, &container_rx_elem, @@ -384,6 +395,9 @@ pub async fn app_shell( page_state_store, curr_global_state, frozen_app, + is_first, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator, ) }, &container_rx_elem, @@ -489,6 +503,12 @@ pub async fn app_shell( page_state_store, curr_global_state, frozen_app, + is_first, + #[cfg(all( + feature = "live-reload", + debug_assertions + ))] + live_reload_indicator, ) }, &container_rx_elem, @@ -506,6 +526,9 @@ pub async fn app_shell( page_state_store, curr_global_state, frozen_app, + is_first, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator, ) }, &container_rx_elem, diff --git a/packages/perseus/src/state/live_reload.rs b/packages/perseus/src/state/live_reload.rs index 3e67ef7655..b7874f8aee 100644 --- a/packages/perseus/src/state/live_reload.rs +++ b/packages/perseus/src/state/live_reload.rs @@ -1,8 +1,10 @@ +use sycamore::prelude::Signal; use wasm_bindgen::{closure::Closure, JsCast, JsValue}; use web_sys::{ErrorEvent, MessageEvent, WebSocket}; -/// Connects to the reload server if it's online. -pub fn connect_to_reload_server() { +/// Connects to the reload server if it's online. This takes a flip-flop `Signal` that it can use to signal other parts of the code to perform actual reloading (we can't do that here because +/// we don't have access to the render context for freezing and thawing). +pub(crate) fn connect_to_reload_server(live_reload_indicator: Signal) { // Get the host and port let host = get_window_var("__PERSEUS_RELOAD_SERVER_HOST"); let port = get_window_var("__PERSEUS_RELOAD_SERVER_PORT"); @@ -23,22 +25,10 @@ pub fn connect_to_reload_server() { // Set up a message handler let onmessage_callback = Closure::wrap(Box::new(move |_| { // With this server, if we receive any message it will be telling us to reload, so we'll do so - wasm_bindgen_futures::spawn_local(async move { - // TODO If we're using HSR, freeze the state to IndexedDB - #[cfg(feature = "hsr")] - todo!(); - // Force reload the page, getting all resources from the sevrer again (to get the new code) - log("Reloading..."); - match web_sys::window() - .unwrap() - .location() - .reload_with_forceget(true) - { - Ok(_) => (), - Err(err) => log(&format!("Reloading failed: {:?}.", err)), - }; - // We shouldn't ever get here unless there was an error, the entire page will be fully reloaded - }); + log("Reloading..."); + // Signal the rest of the code that we need to reload (and potentially freeze state if HSR is enabled) + // Amazingly, the reactive scope isn't interrupted and this actually works! + live_reload_indicator.set(!*live_reload_indicator.get_untracked()); }) as Box); ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref())); // To keep the closure alive, we need to forget about it @@ -78,3 +68,16 @@ fn get_window_var(name: &str) -> Option { fn log(msg: &str) { web_sys::console::log_1(&JsValue::from("[Live Reload Server]: ".to_string() + msg)); } + +/// Force-reloads the page. Any code after this will NOT be called, as the browser will completely reload the page, dumping your code and restarting from the beginning. This will result in +/// a total loss of all state unless it's frozen in some way. +/// +/// # Panics +/// This will panic if it was impossible to reload (which would be caused by a *very* old browser). +pub fn force_reload() { + web_sys::window() + .unwrap() + .location() + .reload_with_forceget(true) + .unwrap(); +} diff --git a/packages/perseus/src/state/mod.rs b/packages/perseus/src/state/mod.rs index b4dcfc0f4b..e493badfd8 100644 --- a/packages/perseus/src/state/mod.rs +++ b/packages/perseus/src/state/mod.rs @@ -17,4 +17,6 @@ pub use freeze_idb::*; // TODO Be specific here #[cfg(all(feature = "live-reload", debug_assertions))] mod live_reload; #[cfg(all(feature = "live-reload", debug_assertions))] -pub use live_reload::connect_to_reload_server; +pub(crate) use live_reload::connect_to_reload_server; +#[cfg(all(feature = "live-reload", debug_assertions))] +pub use live_reload::force_reload; diff --git a/packages/perseus/src/template/core.rs b/packages/perseus/src/template/core.rs index 66e3600da9..354b916a47 100644 --- a/packages/perseus/src/template/core.rs +++ b/packages/perseus/src/template/core.rs @@ -189,6 +189,9 @@ impl Template { global_state: GlobalState, // This should always be empty, it just allows us to persist the value across template loads frozen_app: Rc>>, + is_first: bool, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator: sycamore::prelude::ReadSignal, ) -> View { view! { // We provide the translator through context, which avoids having to define a separate variable for every translation due to Sycamore's `template!` macro taking ownership with `move` closures @@ -199,7 +202,10 @@ impl Template { router: router_state, page_state_store, global_state, - frozen_app + frozen_app, + is_first, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator }, children: || (self.template)(props) }) @@ -224,7 +230,12 @@ impl Template { page_state_store, global_state: GlobalState::default(), // Hydrating state on the server-side is pointless - frozen_app: Rc::new(RefCell::new(None)) + frozen_app: Rc::new(RefCell::new(None)), + // On the server-side, every template is the first + // We won't do anything with HSR on the server-side though + is_first: true, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator: sycamore::prelude::Signal::new(false).handle() }, children: || (self.template)(props) }) @@ -248,6 +259,11 @@ impl Template { global_state: GlobalState::default(), // Hydrating state on the server-side is pointless frozen_app: Rc::new(RefCell::new(None)), + // On the server-side, every template is the first + // We won't do anything with HSR on the server-side though + is_first: true, + #[cfg(all(feature = "live-reload", debug_assertions))] + live_reload_indicator: sycamore::prelude::Signal::new(false).handle() }, children: || (self.head)(props) }) diff --git a/packages/perseus/src/template/render_ctx.rs b/packages/perseus/src/template/render_ctx.rs index ab62d8de28..46f177b0bf 100644 --- a/packages/perseus/src/template/render_ctx.rs +++ b/packages/perseus/src/template/render_ctx.rs @@ -32,6 +32,13 @@ pub struct RenderCtx { pub global_state: GlobalState, /// A previous state the app was once in, still serialized. This will be rehydrated gradually by the template macro. pub frozen_app: Rc>>, + /// Whether or not this page is the very first to have been rendered since the browser loaded the app. This will be reset on full reloads, and is used internally to determine whether or + /// not we should look for stored HSR state. + pub is_first: bool, + #[cfg(all(feature = "live-reload", debug_assertions))] + /// An indicator `Signal` used to allow the root to instruct the app that we're about to reload because of an instruction from the live reloading server. Hooking into this to run code + /// before live reloading takes place is NOT supported, as no guarantee can be made that your code will run before Perseus reloads the page fully (at which point no more code will run). + pub live_reload_indicator: sycamore::prelude::ReadSignal, } impl Freeze for RenderCtx { /// 'Freezes' the relevant parts of the render configuration to a serialized `String` that can later be used to re-initialize the app to the same state at the time of freezing.