Skip to content

Commit b9b608a

Browse files
committed
refactor: made live reloading have access to render context
This makes it much easier to build #121.
1 parent 2e33424 commit b9b608a

File tree

10 files changed

+152
-25
lines changed

10 files changed

+152
-25
lines changed

packages/perseus-macro/Cargo.toml

+6
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,9 @@ trybuild = { version = "1.0", features = ["diff"] }
3232
sycamore = "^0.7.1"
3333
serde = { version = "1", features = [ "derive" ] }
3434
perseus = { path = "../perseus", version = "0.3.2" }
35+
36+
[features]
37+
# 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`!
38+
live-reload = []
39+
# 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`!
40+
hsr = [ "live-reload" ]

packages/perseus-macro/src/template.rs

+9
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use syn::{
55
Attribute, Block, FnArg, Generics, Ident, Item, ItemFn, Result, ReturnType, Type, Visibility,
66
};
77

8+
use crate::template_rx::get_live_reload_frag;
9+
810
/// A function that can be wrapped in the Perseus test sub-harness.
911
pub struct TemplateFn {
1012
/// The body of the function.
@@ -112,12 +114,17 @@ pub fn template_impl(input: TemplateFn, component_name: Ident) -> TokenStream {
112114
return_type,
113115
} = input;
114116

117+
// Set up a code fragment for responding to live reload events
118+
let live_reload_frag = get_live_reload_frag();
119+
115120
// We create a wrapper function that can be easily provided to `.template()` that does deserialization automatically if needed
116121
// This is dependent on what arguments the template takes
117122
if arg.is_some() {
118123
// There's an argument that will be provided as a `String`, so the wrapper will deserialize it
119124
quote! {
120125
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
126+
#live_reload_frag
127+
121128
// The user's function, with Sycamore component annotations and the like preserved
122129
// We know this won't be async because Sycamore doesn't allow that
123130
#(#attrs)*
@@ -136,6 +143,8 @@ pub fn template_impl(input: TemplateFn, component_name: Ident) -> TokenStream {
136143
// There are no arguments
137144
quote! {
138145
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
146+
#live_reload_frag
147+
139148
// The user's function, with Sycamore component annotations and the like preserved
140149
// We know this won't be async because Sycamore doesn't allow that
141150
#(#attrs)*

packages/perseus-macro/src/template_rx.rs

+41
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,36 @@ impl Parse for TemplateFn {
9898
}
9999
}
100100

101+
/// Gets the code fragment used to support live reloading and HSR.
102+
// This is also used by the normal `#[template(...)]` macro
103+
pub fn get_live_reload_frag() -> TokenStream {
104+
#[cfg(all(feature = "live-reload", debug_assertions))]
105+
let live_reload_frag = quote! {
106+
use ::sycamore::prelude::cloned; // Pending sycamore-rs/sycamore#339
107+
let render_ctx = ::perseus::get_render_ctx!();
108+
// Listen to the live reload indicator and reload when required
109+
let indic = render_ctx.live_reload_indicator;
110+
let mut is_first = true;
111+
::sycamore::prelude::create_effect(cloned!(indic => move || {
112+
let _ = indic.get(); // This is a flip-flop, we don't care about the value
113+
// This will be triggered on initialization as well, which would give us a reload loop
114+
if !is_first {
115+
// Conveniently, Perseus re-exports `wasm_bindgen_futures::spawn_local`!
116+
::perseus::spawn_local(async move {
117+
::perseus::state::force_reload();
118+
// We shouldn't ever get here unless there was an error, the entire page will be fully reloaded
119+
})
120+
} else {
121+
is_first = false;
122+
}
123+
}));
124+
};
125+
#[cfg(not(all(feature = "live-reload", debug_assertions)))]
126+
let live_reload_frag = quote!();
127+
128+
live_reload_frag
129+
}
130+
101131
pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream {
102132
let TemplateFn {
103133
block,
@@ -144,6 +174,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
144174
None => Ident::new("G", Span::call_site()),
145175
};
146176

177+
// Set up a code fragment for responding to live reload events
178+
let live_reload_frag = get_live_reload_frag();
179+
147180
// We create a wrapper function that can be easily provided to `.template()` that does deserialization automatically if needed
148181
// This is dependent on what arguments the template takes
149182
if fn_args.len() == 2 {
@@ -175,6 +208,8 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
175208
render_ctx.register_global_state_str::<#global_state_rx>(&props.global_state.unwrap()).unwrap();
176209
}
177210

211+
#live_reload_frag
212+
178213
// The user's function
179214
// We know this won't be async because Sycamore doesn't allow that
180215
#(#attrs)*
@@ -222,6 +257,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
222257
quote! {
223258
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
224259
use ::perseus::state::MakeRx;
260+
261+
#live_reload_frag
262+
225263
// The user's function, with Sycamore component annotations and the like preserved
226264
// We know this won't be async because Sycamore doesn't allow that
227265
#(#attrs)*
@@ -256,6 +294,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
256294
quote! {
257295
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
258296
use ::perseus::state::MakeRx;
297+
298+
#live_reload_frag
299+
259300
// The user's function, with Sycamore component annotations and the like preserved
260301
// We know this won't be async because Sycamore doesn't allow that
261302
#(#attrs)*

packages/perseus/Cargo.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,6 @@ idb-freezing = [ "rexie", "web-sys/StorageManager" ]
6262
# Note that this is highly experimental, and currently blocked by [rustwasm/wasm-bindgen#2735](https://github.com/rustwasm/wasm-bindgen/issues/2735)
6363
wasm2js = []
6464
# Enables automatic browser reloading whenever you make a change
65-
live-reload = [ "js-sys", "web-sys/WebSocket", "web-sys/MessageEvent", "web-sys/ErrorEvent", "web-sys/BinaryType", "web-sys/Location" ]
65+
live-reload = [ "perseus-macro/live-reload", "js-sys", "web-sys/WebSocket", "web-sys/MessageEvent", "web-sys/ErrorEvent", "web-sys/BinaryType", "web-sys/Location" ]
6666
# Enables hot state reloading, whereby your entire app's state can be frozen and thawed automatically every time you change code in your app
67-
hsr = [ "live-reload", "idb-freezing" ]
67+
hsr = [ "live-reload", "idb-freezing", "perseus-macro/hsr" ]

packages/perseus/src/router/router_component.rs

+22-2
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const ROUTE_ANNOUNCER_STYLES: &str = r#"
3535
word-wrap: normal;
3636
"#;
3737

38-
/// The properties that `on_route_change` takes.
38+
/// The properties that `on_route_change` takes. See the shell properties for the details for most of these.
3939
#[derive(Debug, Clone)]
4040
struct OnRouteChangeProps<G: Html> {
4141
locales: Rc<Locales>,
@@ -47,6 +47,9 @@ struct OnRouteChangeProps<G: Html> {
4747
translations_manager: Rc<RefCell<ClientTranslationsManager>>,
4848
error_pages: Rc<ErrorPages<DomNode>>,
4949
initial_container: Option<Element>,
50+
is_first: bool,
51+
#[cfg(all(feature = "live-reload", debug_assertions))]
52+
live_reload_indicator: ReadSignal<bool>,
5053
}
5154

5255
/// 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<G: Html>(
6265
translations_manager,
6366
error_pages,
6467
initial_container,
68+
is_first,
69+
#[cfg(all(feature = "live-reload", debug_assertions))]
70+
live_reload_indicator,
6571
}: OnRouteChangeProps<G>,
6672
) {
6773
wasm_bindgen_futures::spawn_local(async move {
@@ -94,6 +100,9 @@ fn on_route_change<G: Html>(
94100
global_state,
95101
frozen_app,
96102
route_verdict: verdict,
103+
is_first,
104+
#[cfg(all(feature = "live-reload", debug_assertions))]
105+
live_reload_indicator,
97106
},
98107
)
99108
.await
@@ -183,6 +192,12 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
183192
// Instantiate an empty frozen app that can persist across templates (with interior mutability for possible thawing)
184193
let frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>> = Rc::new(RefCell::new(None));
185194

195+
// 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)
196+
// can signal the templates to perform freezing/thawing
197+
// It doesn't matter what the initial value is, this is just a flip-flop
198+
#[cfg(all(feature = "live-reload", debug_assertions))]
199+
let live_reload_indicator = Signal::new(true);
200+
186201
// Create a derived state for the route announcement
187202
// We do this with an effect because we only want to update in some cases (when the new page is actually loaded)
188203
// 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<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
245260
translations_manager,
246261
error_pages,
247262
initial_container,
263+
// We can piggyback off a different part of the code for an entirely different purpose!
264+
is_first: is_first_page,
265+
#[cfg(all(feature = "live-reload", debug_assertions))]
266+
live_reload_indicator: live_reload_indicator.handle(),
248267
};
249268

250269
// Listen for changes to the reload commander and reload as appropriate
@@ -265,8 +284,9 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
265284

266285
// TODO State thawing in HSR
267286
// If live reloading is enabled, connect to the server now
287+
// 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)
268288
#[cfg(all(feature = "live-reload", debug_assertions))]
269-
crate::state::connect_to_reload_server();
289+
crate::state::connect_to_reload_server(live_reload_indicator);
270290

271291
view! {
272292
Router(RouterProps::new(HistoryIntegration::new(), cloned!(on_route_change_props => move |route: ReadSignal<AppRoute>| {

packages/perseus/src/shell.rs

+23
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,11 @@ pub struct ShellProps {
260260
/// The current route verdict. This will be stored in context so that it can be used for possible reloads. Eventually,
261261
/// this will be made obsolete when Sycamore supports this natively.
262262
pub route_verdict: RouteVerdict<TemplateNodeType>,
263+
/// Whether or not this page is the very first to have been rendered since the browser loaded the app.
264+
pub is_first: bool,
265+
#[cfg(all(feature = "live-reload", debug_assertions))]
266+
/// 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.
267+
pub live_reload_indicator: ReadSignal<bool>,
263268
}
264269

265270
/// 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(
280285
global_state: curr_global_state,
281286
frozen_app,
282287
route_verdict,
288+
is_first,
289+
#[cfg(all(feature = "live-reload", debug_assertions))]
290+
live_reload_indicator,
283291
}: ShellProps,
284292
) {
285293
checkpoint("app_shell_entry");
@@ -367,6 +375,9 @@ pub async fn app_shell(
367375
page_state_store,
368376
curr_global_state,
369377
frozen_app,
378+
is_first,
379+
#[cfg(all(feature = "live-reload", debug_assertions))]
380+
live_reload_indicator,
370381
)
371382
},
372383
&container_rx_elem,
@@ -384,6 +395,9 @@ pub async fn app_shell(
384395
page_state_store,
385396
curr_global_state,
386397
frozen_app,
398+
is_first,
399+
#[cfg(all(feature = "live-reload", debug_assertions))]
400+
live_reload_indicator,
387401
)
388402
},
389403
&container_rx_elem,
@@ -489,6 +503,12 @@ pub async fn app_shell(
489503
page_state_store,
490504
curr_global_state,
491505
frozen_app,
506+
is_first,
507+
#[cfg(all(
508+
feature = "live-reload",
509+
debug_assertions
510+
))]
511+
live_reload_indicator,
492512
)
493513
},
494514
&container_rx_elem,
@@ -506,6 +526,9 @@ pub async fn app_shell(
506526
page_state_store,
507527
curr_global_state,
508528
frozen_app,
529+
is_first,
530+
#[cfg(all(feature = "live-reload", debug_assertions))]
531+
live_reload_indicator,
509532
)
510533
},
511534
&container_rx_elem,

packages/perseus/src/state/live_reload.rs

+21-18
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
use sycamore::prelude::Signal;
12
use wasm_bindgen::{closure::Closure, JsCast, JsValue};
23
use web_sys::{ErrorEvent, MessageEvent, WebSocket};
34

4-
/// Connects to the reload server if it's online.
5-
pub fn connect_to_reload_server() {
5+
/// 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
6+
/// we don't have access to the render context for freezing and thawing).
7+
pub(crate) fn connect_to_reload_server(live_reload_indicator: Signal<bool>) {
68
// Get the host and port
79
let host = get_window_var("__PERSEUS_RELOAD_SERVER_HOST");
810
let port = get_window_var("__PERSEUS_RELOAD_SERVER_PORT");
@@ -23,22 +25,10 @@ pub fn connect_to_reload_server() {
2325
// Set up a message handler
2426
let onmessage_callback = Closure::wrap(Box::new(move |_| {
2527
// With this server, if we receive any message it will be telling us to reload, so we'll do so
26-
wasm_bindgen_futures::spawn_local(async move {
27-
// TODO If we're using HSR, freeze the state to IndexedDB
28-
#[cfg(feature = "hsr")]
29-
todo!();
30-
// Force reload the page, getting all resources from the sevrer again (to get the new code)
31-
log("Reloading...");
32-
match web_sys::window()
33-
.unwrap()
34-
.location()
35-
.reload_with_forceget(true)
36-
{
37-
Ok(_) => (),
38-
Err(err) => log(&format!("Reloading failed: {:?}.", err)),
39-
};
40-
// We shouldn't ever get here unless there was an error, the entire page will be fully reloaded
41-
});
28+
log("Reloading...");
29+
// Signal the rest of the code that we need to reload (and potentially freeze state if HSR is enabled)
30+
// Amazingly, the reactive scope isn't interrupted and this actually works!
31+
live_reload_indicator.set(!*live_reload_indicator.get_untracked());
4232
}) as Box<dyn FnMut(MessageEvent)>);
4333
ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
4434
// To keep the closure alive, we need to forget about it
@@ -78,3 +68,16 @@ fn get_window_var(name: &str) -> Option<String> {
7868
fn log(msg: &str) {
7969
web_sys::console::log_1(&JsValue::from("[Live Reload Server]: ".to_string() + msg));
8070
}
71+
72+
/// 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
73+
/// a total loss of all state unless it's frozen in some way.
74+
///
75+
/// # Panics
76+
/// This will panic if it was impossible to reload (which would be caused by a *very* old browser).
77+
pub fn force_reload() {
78+
web_sys::window()
79+
.unwrap()
80+
.location()
81+
.reload_with_forceget(true)
82+
.unwrap();
83+
}

packages/perseus/src/state/mod.rs

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ pub use freeze_idb::*; // TODO Be specific here
1717
#[cfg(all(feature = "live-reload", debug_assertions))]
1818
mod live_reload;
1919
#[cfg(all(feature = "live-reload", debug_assertions))]
20-
pub use live_reload::connect_to_reload_server;
20+
pub(crate) use live_reload::connect_to_reload_server;
21+
#[cfg(all(feature = "live-reload", debug_assertions))]
22+
pub use live_reload::force_reload;

packages/perseus/src/template/core.rs

+18-2
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ impl<G: Html> Template<G> {
189189
global_state: GlobalState,
190190
// This should always be empty, it just allows us to persist the value across template loads
191191
frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>>,
192+
is_first: bool,
193+
#[cfg(all(feature = "live-reload", debug_assertions))]
194+
live_reload_indicator: sycamore::prelude::ReadSignal<bool>,
192195
) -> View<G> {
193196
view! {
194197
// 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<G: Html> Template<G> {
199202
router: router_state,
200203
page_state_store,
201204
global_state,
202-
frozen_app
205+
frozen_app,
206+
is_first,
207+
#[cfg(all(feature = "live-reload", debug_assertions))]
208+
live_reload_indicator
203209
},
204210
children: || (self.template)(props)
205211
})
@@ -224,7 +230,12 @@ impl<G: Html> Template<G> {
224230
page_state_store,
225231
global_state: GlobalState::default(),
226232
// Hydrating state on the server-side is pointless
227-
frozen_app: Rc::new(RefCell::new(None))
233+
frozen_app: Rc::new(RefCell::new(None)),
234+
// On the server-side, every template is the first
235+
// We won't do anything with HSR on the server-side though
236+
is_first: true,
237+
#[cfg(all(feature = "live-reload", debug_assertions))]
238+
live_reload_indicator: sycamore::prelude::Signal::new(false).handle()
228239
},
229240
children: || (self.template)(props)
230241
})
@@ -248,6 +259,11 @@ impl<G: Html> Template<G> {
248259
global_state: GlobalState::default(),
249260
// Hydrating state on the server-side is pointless
250261
frozen_app: Rc::new(RefCell::new(None)),
262+
// On the server-side, every template is the first
263+
// We won't do anything with HSR on the server-side though
264+
is_first: true,
265+
#[cfg(all(feature = "live-reload", debug_assertions))]
266+
live_reload_indicator: sycamore::prelude::Signal::new(false).handle()
251267
},
252268
children: || (self.head)(props)
253269
})

0 commit comments

Comments
 (0)