Skip to content

Commit 9805a7b

Browse files
committed
feat: added hot state reloading
Addresses most of #121.
1 parent b9b608a commit 9805a7b

File tree

12 files changed

+151
-34
lines changed

12 files changed

+151
-34
lines changed

examples/rx_state/src/idb.rs

-12
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,6 @@ pub fn idb_page(TestPropsRx { username }: TestPropsRx) -> View<G> {
7272
}))
7373
)) { "Thaw from IndexedDB" }
7474
p { (thaw_status.get()) }
75-
76-
// button(on:click = cloned!(frozen_app, render_ctx => move |_| {
77-
// frozen_app.set(render_ctx.freeze());
78-
// })) { "Freeze!" }
79-
// p { (frozen_app.get()) }
80-
81-
// button(on:click = cloned!(frozen_app_3, render_ctx => move |_| {
82-
// render_ctx.thaw(&frozen_app_3.get(), perseus::state::ThawPrefs {
83-
// page: perseus::state::PageThawPrefs::IncludeAll,
84-
// global_prefer_frozen: true
85-
// }).unwrap();
86-
// })) { "Thaw..." }
8775
}
8876
}
8977

packages/perseus-macro/src/template.rs

+8-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ 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;
8+
use crate::template_rx::{get_hsr_thaw_frag, get_live_reload_frag};
99

1010
/// A function that can be wrapped in the Perseus test sub-harness.
1111
pub struct TemplateFn {
@@ -116,13 +116,17 @@ pub fn template_impl(input: TemplateFn, component_name: Ident) -> TokenStream {
116116

117117
// Set up a code fragment for responding to live reload events
118118
let live_reload_frag = get_live_reload_frag();
119+
let hsr_thaw_frag = get_hsr_thaw_frag();
119120

120121
// We create a wrapper function that can be easily provided to `.template()` that does deserialization automatically if needed
121122
// This is dependent on what arguments the template takes
122123
if arg.is_some() {
123124
// There's an argument that will be provided as a `String`, so the wrapper will deserialize it
124125
quote! {
125126
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
127+
#[cfg(target_arch = "wasm32")]
128+
#hsr_thaw_frag
129+
126130
#live_reload_frag
127131

128132
// The user's function, with Sycamore component annotations and the like preserved
@@ -143,6 +147,9 @@ pub fn template_impl(input: TemplateFn, component_name: Ident) -> TokenStream {
143147
// There are no arguments
144148
quote! {
145149
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
150+
#[cfg(target_arch = "wasm32")]
151+
#hsr_thaw_frag
152+
146153
#live_reload_frag
147154

148155
// The user's function, with Sycamore component annotations and the like preserved

packages/perseus-macro/src/template_rx.rs

+46-6
Original file line numberDiff line numberDiff line change
@@ -101,33 +101,63 @@ impl Parse for TemplateFn {
101101
/// Gets the code fragment used to support live reloading and HSR.
102102
// This is also used by the normal `#[template(...)]` macro
103103
pub fn get_live_reload_frag() -> TokenStream {
104+
#[cfg(all(feature = "hsr", debug_assertions))]
105+
let hsr_frag = quote! {
106+
::perseus::state::hsr_freeze(render_ctx).await;
107+
};
108+
#[cfg(not(all(feature = "hsr", debug_assertions)))]
109+
let hsr_frag = quote!();
110+
104111
#[cfg(all(feature = "live-reload", debug_assertions))]
105-
let live_reload_frag = quote! {
112+
let live_reload_frag = quote! {{
106113
use ::sycamore::prelude::cloned; // Pending sycamore-rs/sycamore#339
107114
let render_ctx = ::perseus::get_render_ctx!();
108115
// Listen to the live reload indicator and reload when required
109-
let indic = render_ctx.live_reload_indicator;
116+
let indic = render_ctx.live_reload_indicator.clone();
110117
let mut is_first = true;
111-
::sycamore::prelude::create_effect(cloned!(indic => move || {
118+
::sycamore::prelude::create_effect(cloned!(indic, render_ctx => move || {
112119
let _ = indic.get(); // This is a flip-flop, we don't care about the value
113120
// This will be triggered on initialization as well, which would give us a reload loop
114121
if !is_first {
115122
// Conveniently, Perseus re-exports `wasm_bindgen_futures::spawn_local`!
116-
::perseus::spawn_local(async move {
123+
::perseus::spawn_local(cloned!(render_ctx => async move {
124+
#hsr_frag
125+
117126
::perseus::state::force_reload();
118127
// We shouldn't ever get here unless there was an error, the entire page will be fully reloaded
119-
})
128+
}))
120129
} else {
121130
is_first = false;
122131
}
123132
}));
124-
};
133+
}};
125134
#[cfg(not(all(feature = "live-reload", debug_assertions)))]
126135
let live_reload_frag = quote!();
127136

128137
live_reload_frag
129138
}
130139

140+
/// Gets the code fragment used to support HSR thawing.
141+
pub fn get_hsr_thaw_frag() -> TokenStream {
142+
#[cfg(all(feature = "hsr", debug_assertions))]
143+
let hsr_thaw_frag = quote! {{
144+
use ::sycamore::prelude::cloned; // Pending sycamore-rs/sycamore#339
145+
let mut render_ctx = ::perseus::get_render_ctx!();
146+
::perseus::spawn_local(cloned!(render_ctx => async move {
147+
// We need to make sure we don't run this more than once, because that would lead to a loop
148+
// It also shouldn't run on any pages after the initial load
149+
if render_ctx.is_first.get() {
150+
render_ctx.is_first.set(false);
151+
::perseus::state::hsr_thaw(render_ctx).await;
152+
}
153+
}));
154+
}};
155+
#[cfg(not(all(feature = "hsr", debug_assertions)))]
156+
let hsr_thaw_frag = quote!();
157+
158+
hsr_thaw_frag
159+
}
160+
131161
pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream {
132162
let TemplateFn {
133163
block,
@@ -176,6 +206,7 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
176206

177207
// Set up a code fragment for responding to live reload events
178208
let live_reload_frag = get_live_reload_frag();
209+
let hsr_thaw_frag = get_hsr_thaw_frag();
179210

180211
// We create a wrapper function that can be easily provided to `.template()` that does deserialization automatically if needed
181212
// This is dependent on what arguments the template takes
@@ -210,6 +241,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
210241

211242
#live_reload_frag
212243

244+
#[cfg(target_arch = "wasm32")]
245+
#hsr_thaw_frag
246+
213247
// The user's function
214248
// We know this won't be async because Sycamore doesn't allow that
215249
#(#attrs)*
@@ -258,6 +292,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
258292
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
259293
use ::perseus::state::MakeRx;
260294

295+
#[cfg(target_arch = "wasm32")]
296+
#hsr_thaw_frag
297+
261298
#live_reload_frag
262299

263300
// The user's function, with Sycamore component annotations and the like preserved
@@ -295,6 +332,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
295332
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
296333
use ::perseus::state::MakeRx;
297334

335+
#[cfg(target_arch = "wasm32")]
336+
#hsr_thaw_frag
337+
298338
#live_reload_frag
299339

300340
// The user's function, with Sycamore component annotations and the like preserved

packages/perseus/Cargo.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ js-sys = { version = "0.3", optional = true }
4141
[features]
4242
# Live reloading will only take effect in development, and won't impact production
4343
# BUG This adds 400B to the production bundle (that's without size optimizations though)
44-
default = [ "live-reload" ]
44+
default = [ "live-reload", "hsr" ]
4545
translator-fluent = ["fluent-bundle", "unic-langid", "intl-memoizer"]
4646
# This feature makes tinker-only plugins be registered (this flag is enabled internally in the engine)
4747
tinker-plugins = []
@@ -64,4 +64,5 @@ wasm2js = []
6464
# Enables automatic browser reloading whenever you make a change
6565
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+
# Note that this has no effect within the Perseus code beyond enabling other features and telling the macros to perform HSR
6768
hsr = [ "live-reload", "idb-freezing", "perseus-macro/hsr" ]

packages/perseus/src/router/router_component.rs

+6-5
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::{
1111
templates::{RouterLoadState, RouterState, TemplateNodeType},
1212
DomNode, ErrorPages, Html,
1313
};
14-
use std::cell::RefCell;
14+
use std::cell::{Cell, RefCell};
1515
use std::rc::Rc;
1616
use sycamore::prelude::{
1717
cloned, component, create_effect, view, NodeRef, ReadSignal, Signal, View,
@@ -47,7 +47,7 @@ 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,
50+
is_first: Rc<Cell<bool>>,
5151
#[cfg(all(feature = "live-reload", debug_assertions))]
5252
live_reload_indicator: ReadSignal<bool>,
5353
}
@@ -191,6 +191,8 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
191191
let global_state = GlobalState::default();
192192
// Instantiate an empty frozen app that can persist across templates (with interior mutability for possible thawing)
193193
let frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>> = Rc::new(RefCell::new(None));
194+
// Set up a mutable property for whether or not this is the first load of the first page
195+
let is_first = Rc::new(Cell::new(true));
194196

195197
// 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)
196198
// can signal the templates to perform freezing/thawing
@@ -202,7 +204,7 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
202204
// We do this with an effect because we only want to update in some cases (when the new page is actually loaded)
203205
// 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)
204206
let route_announcement = Signal::new(String::new());
205-
let mut is_first_page = true;
207+
let mut is_first_page = true; // This is different from the first page load (this is the first page as a whole)
206208
create_effect(
207209
cloned!(route_announcement, router_state => move || if let RouterLoadState::Loaded { path, .. } = &*router_state.get_load_state().get() {
208210
if is_first_page {
@@ -260,8 +262,7 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
260262
translations_manager,
261263
error_pages,
262264
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+
is_first,
265266
#[cfg(all(feature = "live-reload", debug_assertions))]
266267
live_reload_indicator: live_reload_indicator.handle(),
267268
};

packages/perseus/src/shell.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::template::{PageProps, Template, TemplateNodeType};
99
use crate::utils::get_path_prefix_client;
1010
use crate::ErrorPages;
1111
use fmterr::fmt_err;
12-
use std::cell::RefCell;
12+
use std::cell::{Cell, RefCell};
1313
use std::collections::HashMap;
1414
use std::rc::Rc;
1515
use sycamore::prelude::*;
@@ -261,7 +261,7 @@ pub struct ShellProps {
261261
/// this will be made obsolete when Sycamore supports this natively.
262262
pub route_verdict: RouteVerdict<TemplateNodeType>,
263263
/// Whether or not this page is the very first to have been rendered since the browser loaded the app.
264-
pub is_first: bool,
264+
pub is_first: Rc<Cell<bool>>,
265265
#[cfg(all(feature = "live-reload", debug_assertions))]
266266
/// 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.
267267
pub live_reload_indicator: ReadSignal<bool>,

packages/perseus/src/state/freeze_idb.rs

+5-1
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,12 @@ impl std::fmt::Debug for IdbFrozenStateStore {
5656
impl IdbFrozenStateStore {
5757
/// Creates a new store for this origin. If it already exists from a previous visit, the existing one will be interfaced with.
5858
pub async fn new() -> Result<Self, IdbError> {
59+
Self::new_with_name("perseus").await
60+
}
61+
/// Creates a new store for this origin. If it already exists from a previous visit, the existing one will be interfaced with. This also allows the provision of a custom name for the DB.
62+
pub(crate) async fn new_with_name(name: &str) -> Result<Self, IdbError> {
5963
// Build the database
60-
let rexie = Rexie::builder("perseus")
64+
let rexie = Rexie::builder(name)
6165
// IndexedDB uses versions to track database schema changes
6266
// If the structure of this DB ever changes, this MUST be changed, and this should be considered a non-API-breaking, but app-breaking change!
6367
.version(1)

packages/perseus/src/state/hsr.rs

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use super::{Freeze, IdbFrozenStateStore};
2+
use crate::templates::RenderCtx;
3+
use wasm_bindgen::JsValue;
4+
5+
/// Freezes the app's state to IndexedDB to be accessed in future.
6+
// TODO Error handling
7+
pub async fn hsr_freeze(render_ctx: RenderCtx) {
8+
let frozen_state = render_ctx.freeze();
9+
// We use a custom name so we don't interfere with any state freezing the user's doing independently
10+
let idb_store = match IdbFrozenStateStore::new_with_name("perseus_hsr").await {
11+
Ok(idb_store) => idb_store,
12+
Err(_) => {
13+
return;
14+
}
15+
};
16+
match idb_store.set(&frozen_state).await {
17+
Ok(_) => log("State frozen."),
18+
Err(_) => {
19+
return;
20+
}
21+
};
22+
}
23+
24+
/// Thaws a previous state frozen in development.
25+
// This will be run at the beginning of every template function, which means it gets executed on the server as well, so we have to Wasm-gate this
26+
#[cfg(target_arch = "wasm32")]
27+
// TODO Error handling
28+
pub async fn hsr_thaw(render_ctx: RenderCtx) {
29+
use super::{PageThawPrefs, ThawPrefs};
30+
31+
let idb_store = match IdbFrozenStateStore::new_with_name("perseus_hsr").await {
32+
Ok(idb_store) => idb_store,
33+
Err(_) => {
34+
return;
35+
}
36+
};
37+
let frozen_state = match idb_store.get().await {
38+
Ok(Some(frozen_state)) => frozen_state,
39+
// If there's no frozen state available, we'll proceed as usual
40+
Ok(None) => return,
41+
Err(_) => {
42+
return;
43+
}
44+
};
45+
46+
// This is designed to override everything to restore the app to its previous state, so we should override everything
47+
// This isn't problematic because the state will be frozen right before the reload and restored right after, so we literally can't miss anything (unless there's auto-typing tech involved!)
48+
let thaw_prefs = ThawPrefs {
49+
page: PageThawPrefs::IncludeAll,
50+
global_prefer_frozen: true,
51+
};
52+
// To be absolutely clear, this will NOT fail if the user has changed their data model, it will be triggered if the state is actually corrupted
53+
// If that's the case, we'll log it and wait for the next freeze to override the invalid stuff
54+
// If the user has updated their data model, the macros will fail with frozen state and switch to active or generated as necessary (meaning we lose the smallest amount of state
55+
// possible!)
56+
match render_ctx.thaw(&frozen_state, thaw_prefs) {
57+
Ok(_) => log("State restored."),
58+
Err(_) => log("Stored state corrupted, waiting for next code change to override."),
59+
};
60+
}
61+
62+
/// Thaws a previous state frozen in development.
63+
#[cfg(not(target_arch = "wasm32"))]
64+
pub async fn hsr_thaw(_render_ctx: RenderCtx) {}
65+
66+
/// An internal function for logging data about HSR.
67+
fn log(msg: &str) {
68+
web_sys::console::log_1(&JsValue::from("[HSR]: ".to_string() + msg));
69+
}

packages/perseus/src/state/live_reload.rs

+2
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ fn log(msg: &str) {
7272
/// 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
7373
/// a total loss of all state unless it's frozen in some way.
7474
///
75+
/// Note that the parameter that forces the browser to bypass its cache is non-standard, and only impacts Firefox. On all other browsers, this has no effect.
76+
///
7577
/// # Panics
7678
/// This will panic if it was impossible to reload (which would be caused by a *very* old browser).
7779
pub fn force_reload() {

packages/perseus/src/state/mod.rs

+5
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@ mod live_reload;
2020
pub(crate) use live_reload::connect_to_reload_server;
2121
#[cfg(all(feature = "live-reload", debug_assertions))]
2222
pub use live_reload::force_reload;
23+
24+
#[cfg(all(feature = "hsr", debug_assertions))]
25+
mod hsr;
26+
#[cfg(all(feature = "hsr", debug_assertions))]
27+
pub use hsr::*; // TODO

packages/perseus/src/template/core.rs

+4-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use crate::Request;
1212
use crate::SsrNode;
1313
use futures::Future;
1414
use http::header::HeaderMap;
15-
use std::cell::RefCell;
15+
use std::cell::{Cell, RefCell};
1616
use std::rc::Rc;
1717
use sycamore::context::{ContextProvider, ContextProviderProps};
1818
use sycamore::prelude::{view, View};
@@ -189,7 +189,7 @@ 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,
192+
is_first: Rc<Cell<bool>>,
193193
#[cfg(all(feature = "live-reload", debug_assertions))]
194194
live_reload_indicator: sycamore::prelude::ReadSignal<bool>,
195195
) -> View<G> {
@@ -233,7 +233,7 @@ impl<G: Html> Template<G> {
233233
frozen_app: Rc::new(RefCell::new(None)),
234234
// On the server-side, every template is the first
235235
// We won't do anything with HSR on the server-side though
236-
is_first: true,
236+
is_first: Rc::new(Cell::new(true)),
237237
#[cfg(all(feature = "live-reload", debug_assertions))]
238238
live_reload_indicator: sycamore::prelude::Signal::new(false).handle()
239239
},
@@ -261,7 +261,7 @@ impl<G: Html> Template<G> {
261261
frozen_app: Rc::new(RefCell::new(None)),
262262
// On the server-side, every template is the first
263263
// We won't do anything with HSR on the server-side though
264-
is_first: true,
264+
is_first: Rc::new(Cell::new(true)),
265265
#[cfg(all(feature = "live-reload", debug_assertions))]
266266
live_reload_indicator: sycamore::prelude::Signal::new(false).handle()
267267
},

packages/perseus/src/template/render_ctx.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::state::{
44
AnyFreeze, Freeze, FrozenApp, GlobalState, MakeRx, MakeUnrx, PageStateStore, ThawPrefs,
55
};
66
use crate::translator::Translator;
7-
use std::cell::RefCell;
7+
use std::cell::{Cell, RefCell};
88
use std::rc::Rc;
99
use sycamore_router::navigate;
1010

@@ -34,7 +34,7 @@ pub struct RenderCtx {
3434
pub frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>>,
3535
/// 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
3636
/// not we should look for stored HSR state.
37-
pub is_first: bool,
37+
pub is_first: Rc<Cell<bool>>,
3838
#[cfg(all(feature = "live-reload", debug_assertions))]
3939
/// 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
4040
/// 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).

0 commit comments

Comments
 (0)