Skip to content

Commit

Permalink
refactor: made live reloading have access to render context
Browse files Browse the repository at this point in the history
This makes it much easier to build #121.
  • Loading branch information
arctic-hen7 committed Jan 29, 2022
1 parent 2e33424 commit b9b608a
Show file tree
Hide file tree
Showing 10 changed files with 152 additions and 25 deletions.
6 changes: 6 additions & 0 deletions packages/perseus-macro/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
9 changes: 9 additions & 0 deletions packages/perseus-macro/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
#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)*
Expand All @@ -136,6 +143,8 @@ pub fn template_impl(input: TemplateFn, component_name: Ident) -> TokenStream {
// There are no arguments
quote! {
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
#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)*
Expand Down
41 changes: 41 additions & 0 deletions packages/perseus-macro/src/template_rx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)*
Expand Down Expand Up @@ -222,6 +257,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
quote! {
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
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)*
Expand Down Expand Up @@ -256,6 +294,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
quote! {
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
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)*
Expand Down
4 changes: 2 additions & 2 deletions packages/perseus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
24 changes: 22 additions & 2 deletions packages/perseus/src/router/router_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<G: Html> {
locales: Rc<Locales>,
Expand All @@ -47,6 +47,9 @@ struct OnRouteChangeProps<G: Html> {
translations_manager: Rc<RefCell<ClientTranslationsManager>>,
error_pages: Rc<ErrorPages<DomNode>>,
initial_container: Option<Element>,
is_first: bool,
#[cfg(all(feature = "live-reload", debug_assertions))]
live_reload_indicator: ReadSignal<bool>,
}

/// 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.
Expand All @@ -62,6 +65,9 @@ fn on_route_change<G: Html>(
translations_manager,
error_pages,
initial_container,
is_first,
#[cfg(all(feature = "live-reload", debug_assertions))]
live_reload_indicator,
}: OnRouteChangeProps<G>,
) {
wasm_bindgen_futures::spawn_local(async move {
Expand Down Expand Up @@ -94,6 +100,9 @@ fn on_route_change<G: Html>(
global_state,
frozen_app,
route_verdict: verdict,
is_first,
#[cfg(all(feature = "live-reload", debug_assertions))]
live_reload_indicator,
},
)
.await
Expand Down Expand Up @@ -183,6 +192,12 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
// Instantiate an empty frozen app that can persist across templates (with interior mutability for possible thawing)
let frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>> = 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)
Expand Down Expand Up @@ -245,6 +260,10 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + '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
Expand All @@ -265,8 +284,9 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + '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<AppRoute>| {
Expand Down
23 changes: 23 additions & 0 deletions packages/perseus/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TemplateNodeType>,
/// 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<bool>,
}

/// 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
Expand All @@ -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");
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
39 changes: 21 additions & 18 deletions packages/perseus/src/state/live_reload.rs
Original file line number Diff line number Diff line change
@@ -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<bool>) {
// Get the host and port
let host = get_window_var("__PERSEUS_RELOAD_SERVER_HOST");
let port = get_window_var("__PERSEUS_RELOAD_SERVER_PORT");
Expand All @@ -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<dyn FnMut(MessageEvent)>);
ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
// To keep the closure alive, we need to forget about it
Expand Down Expand Up @@ -78,3 +68,16 @@ fn get_window_var(name: &str) -> Option<String> {
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();
}
4 changes: 3 additions & 1 deletion packages/perseus/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
20 changes: 18 additions & 2 deletions packages/perseus/src/template/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ impl<G: Html> Template<G> {
global_state: GlobalState,
// This should always be empty, it just allows us to persist the value across template loads
frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>>,
is_first: bool,
#[cfg(all(feature = "live-reload", debug_assertions))]
live_reload_indicator: sycamore::prelude::ReadSignal<bool>,
) -> View<G> {
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
Expand All @@ -199,7 +202,10 @@ impl<G: Html> Template<G> {
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)
})
Expand All @@ -224,7 +230,12 @@ impl<G: Html> Template<G> {
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)
})
Expand All @@ -248,6 +259,11 @@ impl<G: Html> Template<G> {
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)
})
Expand Down
Loading

0 comments on commit b9b608a

Please sign in to comment.