Skip to content

Commit

Permalink
feat: added hot state reloading
Browse files Browse the repository at this point in the history
Addresses most of #121.
  • Loading branch information
arctic-hen7 committed Jan 29, 2022
1 parent b9b608a commit 9805a7b
Show file tree
Hide file tree
Showing 12 changed files with 151 additions and 34 deletions.
12 changes: 0 additions & 12 deletions examples/rx_state/src/idb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,6 @@ pub fn idb_page(TestPropsRx { username }: TestPropsRx) -> View<G> {
}))
)) { "Thaw from IndexedDB" }
p { (thaw_status.get()) }

// button(on:click = cloned!(frozen_app, render_ctx => move |_| {
// frozen_app.set(render_ctx.freeze());
// })) { "Freeze!" }
// p { (frozen_app.get()) }

// button(on:click = cloned!(frozen_app_3, render_ctx => move |_| {
// render_ctx.thaw(&frozen_app_3.get(), perseus::state::ThawPrefs {
// page: perseus::state::PageThawPrefs::IncludeAll,
// global_prefer_frozen: true
// }).unwrap();
// })) { "Thaw..." }
}
}

Expand Down
9 changes: 8 additions & 1 deletion packages/perseus-macro/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use syn::{
Attribute, Block, FnArg, Generics, Ident, Item, ItemFn, Result, ReturnType, Type, Visibility,
};

use crate::template_rx::get_live_reload_frag;
use crate::template_rx::{get_hsr_thaw_frag, get_live_reload_frag};

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

// Set up a code fragment for responding to live reload events
let live_reload_frag = get_live_reload_frag();
let hsr_thaw_frag = get_hsr_thaw_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> {
#[cfg(target_arch = "wasm32")]
#hsr_thaw_frag

#live_reload_frag

// The user's function, with Sycamore component annotations and the like preserved
Expand All @@ -143,6 +147,9 @@ 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> {
#[cfg(target_arch = "wasm32")]
#hsr_thaw_frag

#live_reload_frag

// The user's function, with Sycamore component annotations and the like preserved
Expand Down
52 changes: 46 additions & 6 deletions packages/perseus-macro/src/template_rx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,33 +101,63 @@ 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 = "hsr", debug_assertions))]
let hsr_frag = quote! {
::perseus::state::hsr_freeze(render_ctx).await;
};
#[cfg(not(all(feature = "hsr", debug_assertions)))]
let hsr_frag = quote!();

#[cfg(all(feature = "live-reload", debug_assertions))]
let live_reload_frag = quote! {
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 indic = render_ctx.live_reload_indicator.clone();
let mut is_first = true;
::sycamore::prelude::create_effect(cloned!(indic => move || {
::sycamore::prelude::create_effect(cloned!(indic, render_ctx => 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::spawn_local(cloned!(render_ctx => async move {
#hsr_frag

::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
}

/// Gets the code fragment used to support HSR thawing.
pub fn get_hsr_thaw_frag() -> TokenStream {
#[cfg(all(feature = "hsr", debug_assertions))]
let hsr_thaw_frag = quote! {{
use ::sycamore::prelude::cloned; // Pending sycamore-rs/sycamore#339
let mut render_ctx = ::perseus::get_render_ctx!();
::perseus::spawn_local(cloned!(render_ctx => async move {
// We need to make sure we don't run this more than once, because that would lead to a loop
// It also shouldn't run on any pages after the initial load
if render_ctx.is_first.get() {
render_ctx.is_first.set(false);
::perseus::state::hsr_thaw(render_ctx).await;
}
}));
}};
#[cfg(not(all(feature = "hsr", debug_assertions)))]
let hsr_thaw_frag = quote!();

hsr_thaw_frag
}

pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream {
let TemplateFn {
block,
Expand Down Expand Up @@ -176,6 +206,7 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream

// Set up a code fragment for responding to live reload events
let live_reload_frag = get_live_reload_frag();
let hsr_thaw_frag = get_hsr_thaw_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
Expand Down Expand Up @@ -210,6 +241,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream

#live_reload_frag

#[cfg(target_arch = "wasm32")]
#hsr_thaw_frag

// The user's function
// We know this won't be async because Sycamore doesn't allow that
#(#attrs)*
Expand Down Expand Up @@ -258,6 +292,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
use ::perseus::state::MakeRx;

#[cfg(target_arch = "wasm32")]
#hsr_thaw_frag

#live_reload_frag

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

#[cfg(target_arch = "wasm32")]
#hsr_thaw_frag

#live_reload_frag

// The user's function, with Sycamore component annotations and the like preserved
Expand Down
3 changes: 2 additions & 1 deletion packages/perseus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ js-sys = { version = "0.3", optional = true }
[features]
# Live reloading will only take effect in development, and won't impact production
# BUG This adds 400B to the production bundle (that's without size optimizations though)
default = [ "live-reload" ]
default = [ "live-reload", "hsr" ]
translator-fluent = ["fluent-bundle", "unic-langid", "intl-memoizer"]
# This feature makes tinker-only plugins be registered (this flag is enabled internally in the engine)
tinker-plugins = []
Expand All @@ -64,4 +64,5 @@ wasm2js = []
# Enables automatic browser reloading whenever you make a change
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
# Note that this has no effect within the Perseus code beyond enabling other features and telling the macros to perform HSR
hsr = [ "live-reload", "idb-freezing", "perseus-macro/hsr" ]
11 changes: 6 additions & 5 deletions packages/perseus/src/router/router_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::{
templates::{RouterLoadState, RouterState, TemplateNodeType},
DomNode, ErrorPages, Html,
};
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use sycamore::prelude::{
cloned, component, create_effect, view, NodeRef, ReadSignal, Signal, View,
Expand Down Expand Up @@ -47,7 +47,7 @@ struct OnRouteChangeProps<G: Html> {
translations_manager: Rc<RefCell<ClientTranslationsManager>>,
error_pages: Rc<ErrorPages<DomNode>>,
initial_container: Option<Element>,
is_first: bool,
is_first: Rc<Cell<bool>>,
#[cfg(all(feature = "live-reload", debug_assertions))]
live_reload_indicator: ReadSignal<bool>,
}
Expand Down Expand Up @@ -191,6 +191,8 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
let global_state = GlobalState::default();
// 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));
// Set up a mutable property for whether or not this is the first load of the first page
let is_first = Rc::new(Cell::new(true));

// 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
Expand All @@ -202,7 +204,7 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
// 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)
let route_announcement = Signal::new(String::new());
let mut is_first_page = true;
let mut is_first_page = true; // This is different from the first page load (this is the first page as a whole)
create_effect(
cloned!(route_announcement, router_state => move || if let RouterLoadState::Loaded { path, .. } = &*router_state.get_load_state().get() {
if is_first_page {
Expand Down Expand Up @@ -260,8 +262,7 @@ 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,
is_first,
#[cfg(all(feature = "live-reload", debug_assertions))]
live_reload_indicator: live_reload_indicator.handle(),
};
Expand Down
4 changes: 2 additions & 2 deletions packages/perseus/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::template::{PageProps, Template, TemplateNodeType};
use crate::utils::get_path_prefix_client;
use crate::ErrorPages;
use fmterr::fmt_err;
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use sycamore::prelude::*;
Expand Down Expand Up @@ -261,7 +261,7 @@ pub struct ShellProps {
/// 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,
pub is_first: Rc<Cell<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>,
Expand Down
6 changes: 5 additions & 1 deletion packages/perseus/src/state/freeze_idb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ impl std::fmt::Debug for IdbFrozenStateStore {
impl IdbFrozenStateStore {
/// Creates a new store for this origin. If it already exists from a previous visit, the existing one will be interfaced with.
pub async fn new() -> Result<Self, IdbError> {
Self::new_with_name("perseus").await
}
/// 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.
pub(crate) async fn new_with_name(name: &str) -> Result<Self, IdbError> {
// Build the database
let rexie = Rexie::builder("perseus")
let rexie = Rexie::builder(name)
// IndexedDB uses versions to track database schema changes
// 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!
.version(1)
Expand Down
69 changes: 69 additions & 0 deletions packages/perseus/src/state/hsr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use super::{Freeze, IdbFrozenStateStore};
use crate::templates::RenderCtx;
use wasm_bindgen::JsValue;

/// Freezes the app's state to IndexedDB to be accessed in future.
// TODO Error handling
pub async fn hsr_freeze(render_ctx: RenderCtx) {
let frozen_state = render_ctx.freeze();
// We use a custom name so we don't interfere with any state freezing the user's doing independently
let idb_store = match IdbFrozenStateStore::new_with_name("perseus_hsr").await {
Ok(idb_store) => idb_store,
Err(_) => {
return;
}
};
match idb_store.set(&frozen_state).await {
Ok(_) => log("State frozen."),
Err(_) => {
return;
}
};
}

/// Thaws a previous state frozen in development.
// 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
#[cfg(target_arch = "wasm32")]
// TODO Error handling
pub async fn hsr_thaw(render_ctx: RenderCtx) {
use super::{PageThawPrefs, ThawPrefs};

let idb_store = match IdbFrozenStateStore::new_with_name("perseus_hsr").await {
Ok(idb_store) => idb_store,
Err(_) => {
return;
}
};
let frozen_state = match idb_store.get().await {
Ok(Some(frozen_state)) => frozen_state,
// If there's no frozen state available, we'll proceed as usual
Ok(None) => return,
Err(_) => {
return;
}
};

// This is designed to override everything to restore the app to its previous state, so we should override everything
// 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!)
let thaw_prefs = ThawPrefs {
page: PageThawPrefs::IncludeAll,
global_prefer_frozen: true,
};
// 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
// If that's the case, we'll log it and wait for the next freeze to override the invalid stuff
// 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
// possible!)
match render_ctx.thaw(&frozen_state, thaw_prefs) {
Ok(_) => log("State restored."),
Err(_) => log("Stored state corrupted, waiting for next code change to override."),
};
}

/// Thaws a previous state frozen in development.
#[cfg(not(target_arch = "wasm32"))]
pub async fn hsr_thaw(_render_ctx: RenderCtx) {}

/// An internal function for logging data about HSR.
fn log(msg: &str) {
web_sys::console::log_1(&JsValue::from("[HSR]: ".to_string() + msg));
}
2 changes: 2 additions & 0 deletions packages/perseus/src/state/live_reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ fn log(msg: &str) {
/// 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.
///
/// 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.
///
/// # Panics
/// This will panic if it was impossible to reload (which would be caused by a *very* old browser).
pub fn force_reload() {
Expand Down
5 changes: 5 additions & 0 deletions packages/perseus/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ mod live_reload;
pub(crate) use live_reload::connect_to_reload_server;
#[cfg(all(feature = "live-reload", debug_assertions))]
pub use live_reload::force_reload;

#[cfg(all(feature = "hsr", debug_assertions))]
mod hsr;
#[cfg(all(feature = "hsr", debug_assertions))]
pub use hsr::*; // TODO
8 changes: 4 additions & 4 deletions packages/perseus/src/template/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::Request;
use crate::SsrNode;
use futures::Future;
use http::header::HeaderMap;
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use sycamore::context::{ContextProvider, ContextProviderProps};
use sycamore::prelude::{view, View};
Expand Down Expand Up @@ -189,7 +189,7 @@ 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,
is_first: Rc<Cell<bool>>,
#[cfg(all(feature = "live-reload", debug_assertions))]
live_reload_indicator: sycamore::prelude::ReadSignal<bool>,
) -> View<G> {
Expand Down Expand Up @@ -233,7 +233,7 @@ impl<G: Html> Template<G> {
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,
is_first: Rc::new(Cell::new(true)),
#[cfg(all(feature = "live-reload", debug_assertions))]
live_reload_indicator: sycamore::prelude::Signal::new(false).handle()
},
Expand Down Expand Up @@ -261,7 +261,7 @@ impl<G: Html> Template<G> {
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,
is_first: Rc::new(Cell::new(true)),
#[cfg(all(feature = "live-reload", debug_assertions))]
live_reload_indicator: sycamore::prelude::Signal::new(false).handle()
},
Expand Down
4 changes: 2 additions & 2 deletions packages/perseus/src/template/render_ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::state::{
AnyFreeze, Freeze, FrozenApp, GlobalState, MakeRx, MakeUnrx, PageStateStore, ThawPrefs,
};
use crate::translator::Translator;
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use sycamore_router::navigate;

Expand Down Expand Up @@ -34,7 +34,7 @@ pub struct RenderCtx {
pub frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>>,
/// 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,
pub is_first: Rc<Cell<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).
Expand Down

0 comments on commit 9805a7b

Please sign in to comment.