From 10634fb7046438ca518ef6f40133220b06887422 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Sat, 22 Jan 2022 12:07:17 +1100 Subject: [PATCH] feat: added global state rehydration --- examples/basic/.perseus/src/lib.rs | 18 ++++++++++--- packages/perseus-macro/src/template2.rs | 34 ++++++++++++++++++++---- packages/perseus/src/lib.rs | 1 + packages/perseus/src/page_state_store.rs | 12 ++++----- packages/perseus/src/shell.rs | 16 +++++------ packages/perseus/src/template.rs | 21 ++++++++++----- 6 files changed, 74 insertions(+), 28 deletions(-) diff --git a/examples/basic/.perseus/src/lib.rs b/examples/basic/.perseus/src/lib.rs index e92c2e6643..d37ed3efec 100644 --- a/examples/basic/.perseus/src/lib.rs +++ b/examples/basic/.perseus/src/lib.rs @@ -10,7 +10,7 @@ use perseus::{ shell::{app_shell, get_initial_state, get_render_cfg, InitialState, ShellProps}, }, plugins::PluginAction, - state::{AnyFreeze, PageStateStore}, + state::{AnyFreeze, FrozenApp, PageStateStore}, templates::{RouterState, TemplateNodeType}, DomNode, }; @@ -71,6 +71,17 @@ pub fn run() -> Result<(), JsValue> { let global_state: Rc>> = Rc::new(RefCell::new(Box::new(Option::<()>::None))); + // TODO Try to fetch a previous frozen app + let frozen_app: Option> = Some(Rc::new(FrozenApp { + global_state: r#"{"test":"Hello from the frozen app!"}"#.to_string(), + route: "".to_string(), + page_state_store: { + let mut map = std::collections::HashMap::new(); + map.insert("".to_string(), r#"{"username":"Sam"}"#.to_string()); + map + }, + })); + // Create the router we'll use for this app, based on the user's app definition create_app_route! { name => AppRoute, @@ -93,7 +104,7 @@ pub fn run() -> Result<(), JsValue> { // Sycamore's reactivity is broken by a future, so we need to explicitly add the route to the reactive dependencies here // We do need the future though (otherwise `container_rx` doesn't link to anything until it's too late) let _ = route.get(); - wasm_bindgen_futures::spawn_local(cloned!((locales, route, container_rx, router_state, pss, global_state, translations_manager, error_pages, initial_container) => async move { + wasm_bindgen_futures::spawn_local(cloned!((locales, route, container_rx, router_state, pss, global_state, frozen_app, translations_manager, error_pages, initial_container) => async move { let container_rx_elem = container_rx.get::().unchecked_into::(); checkpoint("router_entry"); match &route.get().as_ref().0 { @@ -117,7 +128,8 @@ pub fn run() -> Result<(), JsValue> { initial_container: initial_container.unwrap().clone(), container_rx_elem: container_rx_elem.clone(), page_state_store: pss.clone(), - global_state: global_state.clone() + global_state: global_state.clone(), + frozen_app } ).await, // If the user is using i18n, then they'll want to detect the locale on any paths missing a locale diff --git a/packages/perseus-macro/src/template2.rs b/packages/perseus-macro/src/template2.rs index 4d966a8986..f89911c221 100644 --- a/packages/perseus-macro/src/template2.rs +++ b/packages/perseus-macro/src/template2.rs @@ -167,7 +167,9 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream // Deserialize the global state, make it reactive, and register it with the `RenderCtx` // If it's already there, we'll leave it // This means that we can pass an `Option` around safely and then deal with it at the template site - let global_state_refcell = ::perseus::get_render_ctx!().global_state; + let render_ctx = ::perseus::get_render_ctx!(); + let frozen_app = render_ctx.frozen_app; + let global_state_refcell = render_ctx.global_state; let global_state = global_state_refcell.borrow(); // This will work if the global state hasn't been initialized yet, because it's the default value that Perseus sets if global_state.as_any().downcast_ref::<::std::option::Option::<()>>().is_some() { @@ -175,10 +177,32 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream // In that case, we'll set the global state properly drop(global_state); let mut global_state_mut = global_state_refcell.borrow_mut(); - // This will be defined if we're the first page - let global_state_props = &props.global_state.unwrap(); - let new_global_state = ::serde_json::from_str::<<#global_state_rx as ::perseus::state::MakeUnrx>::Unrx>(global_state_props).unwrap().make_rx(); - *global_state_mut = ::std::boxed::Box::new(new_global_state); + // If there's a frozen app, we'll try to use that + let new_global_state = match frozen_app { + // If it hadn't been initialized yet when we froze, it would've been set to `None` here, and we'll use the one from the server + ::std::option::Option::Some(frozen_app) if frozen_app.global_state != "None" => { + let global_state_str = frozen_app.global_state.clone(); + let global_state = ::serde_json::from_str::<<#global_state_rx as ::perseus::state::MakeUnrx>::Unrx>(&global_state_str); + // We don't control the source of the frozen app, so we have to assume that it could well be invalid, in whcih case we'll turn to the server + match global_state { + ::std::result::Result::Ok(global_state) => global_state, + ::std::result::Result::Err(_) => { + // This will be defined if we're the first page + let global_state_str = props.global_state.unwrap(); + // That's from the server, so it's unrecoverable if it doesn't deserialize + ::serde_json::from_str::<<#global_state_rx as ::perseus::state::MakeUnrx>::Unrx>(&global_state_str).unwrap() + } + } + }, + _ => { + // This will be defined if we're the first page + let global_state_str = props.global_state.unwrap(); + // That's from the server, so it's unrecoverable if it doesn't deserialize + ::serde_json::from_str::<<#global_state_rx as ::perseus::state::MakeUnrx>::Unrx>(&global_state_str).unwrap() + } + }; + let new_global_state_rx = new_global_state.make_rx(); + *global_state_mut = ::std::boxed::Box::new(new_global_state_rx); // The component function can now access this in `RenderCtx` } // The user's function diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index b7c92cee23..079ac73066 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -103,6 +103,7 @@ pub mod state { pub use crate::global_state::GlobalStateCreator; pub use crate::page_state_store::PageStateStore; pub use crate::rx_state::*; + pub use crate::template::FrozenApp; } /// A series of exports that should be unnecessary for nearly all uses of Perseus. These are used principally in developing alternative /// engines. diff --git a/packages/perseus/src/page_state_store.rs b/packages/perseus/src/page_state_store.rs index 67f94bb24f..a14263e7cc 100644 --- a/packages/perseus/src/page_state_store.rs +++ b/packages/perseus/src/page_state_store.rs @@ -2,7 +2,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; -use crate::{rx_state::Freeze, state::AnyFreeze}; +use crate::state::AnyFreeze; /// A container for page state in Perseus. This is designed as a context store, in which one of each type can be stored. Therefore, it acts very similarly to Sycamore's context system, /// though it's specifically designed for each page to store one reactive properties object. In theory, you could interact with this entirely independently of Perseus' state interface, @@ -36,18 +36,18 @@ impl PageStateStore { self.map.borrow().contains_key(url) } } -// Good for convenience, and there's no reason we can't do this -impl Freeze for PageStateStore { +impl PageStateStore { + /// Freezes the component entries into a new `HashMap` of `String`s to avoid extra layers of deserialization. // TODO Avoid literally cloning all the page states here if possible - fn freeze(&self) -> String { + pub fn freeze_to_hash_map(&self) -> HashMap { let map = self.map.borrow(); let mut str_map = HashMap::new(); for (k, v) in map.iter() { let v_str = v.freeze(); - str_map.insert(k, v_str); + str_map.insert(k.to_string(), v_str); } - serde_json::to_string(&str_map).unwrap() + str_map } } diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index 3c12fb1350..b7498750cc 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -5,7 +5,7 @@ use crate::page_data::PageData; use crate::path_prefix::get_path_prefix_client; use crate::state::{AnyFreeze, PageStateStore}; use crate::template::Template; -use crate::templates::{PageProps, RouterLoadState, RouterState, TemplateNodeType}; +use crate::templates::{FrozenApp, PageProps, RouterLoadState, RouterState, TemplateNodeType}; use crate::ErrorPages; use fmterr::fmt_err; use std::cell::RefCell; @@ -255,6 +255,8 @@ pub struct ShellProps { pub container_rx_elem: Element, /// The global state store. Brekaing it out here prevents it being overriden every time a new template loads. pub global_state: Rc>>, + /// A previous frozen state to be gradully rehydrated. + pub frozen_app: Option>, } /// 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 @@ -273,6 +275,7 @@ pub async fn app_shell( initial_container, container_rx_elem, global_state: curr_global_state, + frozen_app, }: ShellProps, ) { checkpoint("app_shell_entry"); @@ -304,13 +307,6 @@ pub async fn app_shell( &JsValue::undefined(), ) .unwrap(); - // // Also do this for the global state - // Reflect::set( - // &JsValue::from(web_sys::window().unwrap()), - // &JsValue::from("__PERSEUS_GLOBAL_STATE"), - // &JsValue::undefined(), - // ) - // .unwrap(); // We need to move the server-rendered content from its current container to the reactive container (otherwise Sycamore can't work with it properly) let initial_html = initial_container.inner_html(); container_rx_elem.set_inner_html(&initial_html); @@ -364,6 +360,7 @@ pub async fn app_shell( router_state_2, page_state_store, curr_global_state, + frozen_app, ) }, &container_rx_elem, @@ -380,6 +377,7 @@ pub async fn app_shell( router_state_2, page_state_store, curr_global_state, + frozen_app, ) }, &container_rx_elem, @@ -484,6 +482,7 @@ pub async fn app_shell( router_state_2.clone(), page_state_store, curr_global_state, + frozen_app, ) }, &container_rx_elem, @@ -500,6 +499,7 @@ pub async fn app_shell( router_state_2, page_state_store, curr_global_state, + frozen_app, ) }, &container_rx_elem, diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index 2a183f6e10..f6ca9ed1e8 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -41,8 +41,8 @@ pub struct FrozenApp { pub global_state: String, /// The frozen route. pub route: String, - /// The frozen page state store. - pub page_state_store: String, + /// The frozen page state store. We store this as a `HashMap` as this level so that we can avoid another deserialization. + pub page_state_store: HashMap, } /// This encapsulates all elements of context currently provided to Perseus templates. While this can be used manually, there are macros @@ -67,6 +67,8 @@ pub struct RenderCtx { /// Because we store `dyn Any` in here, we initialize it as `Option::None`, and then the template macro (which does the heavy lifting for global state) will find that it can't downcast /// to the user's global state type, which will prompt it to deserialize whatever global state it was given and then write that here. pub global_state: Rc>>, + /// A previous state the app was once in, still serialized. This will be rehydrated graudally by the template macro. + pub frozen_app: Option>, } 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. @@ -80,7 +82,7 @@ impl Freeze for RenderCtx { RouterLoadState::Server => "SERVER", } .to_string(), - page_state_store: self.page_state_store.freeze(), + page_state_store: self.page_state_store.freeze_to_hash_map(), }; serde_json::to_string(&frozen_app).unwrap() } @@ -288,6 +290,7 @@ impl Template { // Render executors /// Executes the user-given function that renders the template on the client-side ONLY. This takes in an extsing global state. + #[allow(clippy::too_many_arguments)] pub fn render_for_template_client( &self, props: PageProps, @@ -296,6 +299,7 @@ impl Template { router_state: RouterState, page_state_store: PageStateStore, global_state: Rc>>, + frozen_app: Option>, ) -> 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 @@ -305,7 +309,8 @@ impl Template { translator: translator.clone(), router: router_state, page_state_store, - global_state + global_state, + frozen_app }, children: || (self.template)(props) }) @@ -328,7 +333,9 @@ impl Template { translator: translator.clone(), router: router_state, page_state_store, - global_state: Rc::new(RefCell::new(Box::new(Option::<()>::None))) + global_state: Rc::new(RefCell::new(Box::new(Option::<()>::None))), + // Hydrating state on the server-side is pointless + frozen_app: None }, children: || (self.template)(props) }) @@ -349,7 +356,9 @@ impl Template { // The head string is rendered to a string, and so never has information about router or page state router: RouterState::default(), page_state_store: PageStateStore::default(), - global_state: Rc::new(RefCell::new(Box::new(Option::<()>::None))) + global_state: Rc::new(RefCell::new(Box::new(Option::<()>::None))), + // Hydrating state on the server-side is pointless + frozen_app: None, }, children: || (self.head)(props) })