diff --git a/examples/basic/.perseus/src/lib.rs b/examples/basic/.perseus/src/lib.rs index d46c4f83d5..e92c2e6643 100644 --- a/examples/basic/.perseus/src/lib.rs +++ b/examples/basic/.perseus/src/lib.rs @@ -10,12 +10,12 @@ use perseus::{ shell::{app_shell, get_initial_state, get_render_cfg, InitialState, ShellProps}, }, plugins::PluginAction, - state::PageStateStore, + state::{AnyFreeze, PageStateStore}, templates::{RouterState, TemplateNodeType}, DomNode, }; +use std::cell::RefCell; use std::rc::Rc; -use std::{any::Any, cell::RefCell}; use sycamore::prelude::{cloned, create_effect, view, NodeRef, ReadSignal}; use sycamore_router::{HistoryIntegration, Router, RouterProps}; use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; @@ -68,7 +68,7 @@ pub fn run() -> Result<(), JsValue> { // Create a page state store to use let pss = PageStateStore::default(); // Create a new global state set to `None`, which will be updated and handled entirely by the template macro from here on - let global_state: Rc>> = + let global_state: Rc>> = Rc::new(RefCell::new(Box::new(Option::<()>::None))); // Create the router we'll use for this app, based on the user's app definition diff --git a/examples/rx_state/src/index.rs b/examples/rx_state/src/index.rs index 46416e2fe4..5a4695fa1d 100644 --- a/examples/rx_state/src/index.rs +++ b/examples/rx_state/src/index.rs @@ -1,3 +1,4 @@ +use perseus::state::Freeze; use perseus::{Html, RenderFnResultWithCause, Template}; use sycamore::prelude::*; @@ -16,6 +17,9 @@ pub struct IndexProps { #[perseus::template2(IndexPage)] pub fn index_page(IndexPropsRx { username }: IndexPropsRx, global_state: AppStateRx) -> View { let username_2 = username.clone(); // This is necessary until Sycamore's new reactive primitives are released + let frozen_app = Signal::new(String::new()); // This is not part of our data model, so it's not part of the state properties (everything else should be though) + let render_ctx = perseus::get_render_ctx!(); + view! { p { (format!("Greetings, {}!", username.get())) } input(bind:value = username_2, placeholder = "Username") @@ -23,6 +27,12 @@ pub fn index_page(IndexPropsRx { username }: IndexPropsRx, global_state: AppStat // When the user visits this and then comes back, they'll still be able to see their username (the previous state will be retrieved from the global state automatically) a(href = "about") { "About" } + br() + + button(on:click = cloned!(frozen_app, render_ctx => move |_| { + frozen_app.set(render_ctx.freeze()); + })) { "Freeze!" } + p { (frozen_app.get()) } } } diff --git a/examples/showcase/src/templates/router_state.rs b/examples/showcase/src/templates/router_state.rs index 772d47453f..23bc66a7d6 100644 --- a/examples/showcase/src/templates/router_state.rs +++ b/examples/showcase/src/templates/router_state.rs @@ -7,8 +7,8 @@ pub fn router_state_page() -> View { let load_state = perseus::get_render_ctx!().router.get_load_state(); let load_state_str = create_memo( cloned!(load_state => move || match (*load_state.get()).clone() { - RouterLoadState::Loaded(name) => format!("Loaded {}.", name), - RouterLoadState::Loading(new) => format!("Loading {}.", new), + RouterLoadState::Loaded { template_name, path } => format!("Loaded {} (template: {}).", path, template_name), + RouterLoadState::Loading { template_name, path } => format!("Loading {} (template: {}).", path, template_name), RouterLoadState::Server => "We're on the server.".to_string() }), ); diff --git a/packages/perseus-macro/src/rx_state.rs b/packages/perseus-macro/src/rx_state.rs index 132cda0db0..91cf8435d0 100644 --- a/packages/perseus-macro/src/rx_state.rs +++ b/packages/perseus-macro/src/rx_state.rs @@ -196,5 +196,12 @@ pub fn make_rx_impl(mut orig_struct: ItemStruct, name: Ident) -> TokenStream { #make_unrx_fields } } + impl#generics ::perseus::state::Freeze for #name#generics { + fn freeze(&self) -> ::std::string::String { + let unrx = #make_unrx_fields; + // TODO Is this `.unwrap()` safe? + ::serde_json::to_string(&unrx).unwrap() + } + } } } diff --git a/packages/perseus-macro/src/template2.rs b/packages/perseus-macro/src/template2.rs index beecd4552e..4d966a8986 100644 --- a/packages/perseus-macro/src/template2.rs +++ b/packages/perseus-macro/src/template2.rs @@ -169,15 +169,16 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream // 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 global_state = global_state_refcell.borrow(); - if (&global_state).downcast_ref::<::std::option::Option::<()>>().is_some() { + // 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() { // We can downcast it as the type set by the core render system, so we're the first page to be loaded // In that case, we'll set the global state properly drop(global_state); - let mut global_state = global_state_refcell.borrow_mut(); + 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 = ::std::boxed::Box::new(new_global_state); + *global_state_mut = ::std::boxed::Box::new(new_global_state); // The component function can now access this in `RenderCtx` } // The user's function @@ -189,7 +190,7 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream let global_state = ::perseus::get_render_ctx!().global_state; let global_state = global_state.borrow(); // We can guarantee that it will downcast correctly now, because we'll only invoke the component from this function, which sets up the global state correctly - let global_state_ref = (&global_state).downcast_ref::<#global_state_rx>().unwrap(); + let global_state_ref = global_state.as_any().downcast_ref::<#global_state_rx>().unwrap(); (*global_state_ref).clone() }; #block diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index 7e4a397a82..b7c92cee23 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -102,7 +102,7 @@ pub mod templates { pub mod state { pub use crate::global_state::GlobalStateCreator; pub use crate::page_state_store::PageStateStore; - pub use crate::rx_state::{MakeRx, MakeUnrx}; + pub use crate::rx_state::*; } /// 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 eb2f28687b..67f94bb24f 100644 --- a/packages/perseus/src/page_state_store.rs +++ b/packages/perseus/src/page_state_store.rs @@ -1,14 +1,8 @@ -use std::any::{Any, TypeId}; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; -/// A key type for the `PageStateStore` that denotes both a page's state type and its URL. -#[derive(Hash, PartialEq, Eq)] -pub struct PageStateKey { - state_type: TypeId, - url: String, -} +use crate::{rx_state::Freeze, 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, @@ -19,47 +13,41 @@ pub struct PageStateKey { // TODO Make this work with multiple pages for a single template #[derive(Default, Clone)] pub struct PageStateStore { - /// A map of type IDs to anything, allowing one storage of each type (each type is intended to a properties `struct` for a template). Entries must be `Clone`able becasue we assume them + /// A map of type IDs to anything, allowing one storage of each type (each type is intended to a properties `struct` for a template). Entries must be `Clone`able because we assume them /// to be `Signal`s or `struct`s composed of `Signal`s. // Technically, this should be `Any + Clone`, but that's not possible without something like `dyn_clone`, and we don't need it because we can restrict on the methods instead! - map: Rc>>>, + map: Rc>>>, } impl PageStateStore { - /// Gets an element out of the state by its type and URL. - pub fn get(&self, url: &str) -> Option { - let type_id = TypeId::of::(); - let key = PageStateKey { - state_type: type_id, - url: url.to_string(), - }; + /// Gets an element out of the state by its type and URL. If the element stored for the given URL doesn't match the provided type, `None` will be returned. + pub fn get(&self, url: &str) -> Option { let map = self.map.borrow(); - map.get(&key).map(|val| { - if let Some(val) = val.downcast_ref::() { - (*val).clone() - } else { - // We extracted it by its type ID, it certainly should be able to downcast to that same type ID! - unreachable!() - } - }) + map.get(url) + .map(|val| val.as_any().downcast_ref::().map(|val| (*val).clone())) + .flatten() } - /// Adds a new element to the state by its type and URL. Any existing element with the same type and URL will be silently overriden (use `.contains()` to check first if needed). - pub fn add(&mut self, url: &str, val: T) { - let type_id = TypeId::of::(); - let key = PageStateKey { - state_type: type_id, - url: url.to_string(), - }; + /// Adds a new element to the state by its URL. Any existing element with the same URL will be silently overriden (use `.contains()` to check first if needed). + pub fn add(&mut self, url: &str, val: T) { let mut map = self.map.borrow_mut(); - map.insert(key, Box::new(val)); + map.insert(url.to_string(), Box::new(val)); + } + /// Checks if the state contains an entry for the given URL. + pub fn contains(&self, url: &str) -> bool { + self.map.borrow().contains_key(url) } - /// Checks if the state contains the element of the given type for the given page. - pub fn contains(&self, url: &str) -> bool { - let type_id = TypeId::of::(); - let key = PageStateKey { - state_type: type_id, - url: url.to_string(), - }; - self.map.borrow().contains_key(&key) +} +// Good for convenience, and there's no reason we can't do this +impl Freeze for PageStateStore { + // TODO Avoid literally cloning all the page states here if possible + fn freeze(&self) -> String { + 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); + } + + serde_json::to_string(&str_map).unwrap() } } diff --git a/packages/perseus/src/router.rs b/packages/perseus/src/router.rs index 8daf6b10fd..5344c083ac 100644 --- a/packages/perseus/src/router.rs +++ b/packages/perseus/src/router.rs @@ -321,10 +321,20 @@ impl RouterState { /// The current load state of the router. You can use this to be warned of when a new page is about to be loaded (and display a loading bar or the like, perhaps). #[derive(Clone)] pub enum RouterLoadState { - /// The page has been loaded. The name of the template is attached. - Loaded(String), + /// The page has been loaded. + Loaded { + /// The name of the template being loaded (mostly for convenience). + template_name: String, + /// The full path to the new page being loaded (including the locale, if we're using i18n). + path: String, + }, /// A new page is being loaded, and will soon replace whatever is currently loaded. The name of the new template is attached. - Loading(String), + Loading { + /// The name of the template being loaded (mostly for convenience). + template_name: String, + /// The full path to the new page being loaded (including the locale, if we're using i18n). + path: String, + }, /// We're on the server, and there is no router. Whatever you render based on this state will appear when the user first loads the page, before it's made interactive. Server, } diff --git a/packages/perseus/src/rx_state.rs b/packages/perseus/src/rx_state.rs index 8307baac06..f66e0a846e 100644 --- a/packages/perseus/src/rx_state.rs +++ b/packages/perseus/src/rx_state.rs @@ -1,3 +1,5 @@ +use std::any::Any; + /// A trait for `struct`s that can be made reactive. Typically, this will be derived with the `#[make_rx]` macro, though it can be implemented manually if you have more niche requirements. pub trait MakeRx { /// The type of the reactive version that we'll convert to. By having this as an associated type, we can associate the reactive type with the unreactive, meaning greater inference @@ -16,3 +18,29 @@ pub trait MakeUnrx { /// and fewer arguments that the user needs to provide to macros. fn make_unrx(self) -> Self::Unrx; } + +/// A trait for reactive `struct`s that can be made unreactive and serialized to a `String`. `struct`s that implement this should implement `MakeUnrx` for simplicity, but they technically don't have +/// to (they always do in Perseus macro-generated code). +pub trait Freeze { + /// 'Freezes' the reactive `struct` by making it unreactive and converting it to a `String`. + fn freeze(&self) -> String; +} + +// Perseus initializes the global state as an `Option::<()>::None`, so it has to implement `Freeze`. It may seem silly, because we wouldn't want to freeze the global state if it hadn't been +// initialized, but that means it's unmodified from the server, so there would be no point in freezing it (just as there'd be no point in freezing the router state). +impl Freeze for Option<()> { + fn freeze(&self) -> String { + serde_json::to_string(&Option::<()>::None).unwrap() + } +} + +/// A convenience super-trait for `Freeze`able things that can be downcast to concrete types. +pub trait AnyFreeze: Freeze + Any { + /// Gives `&dyn Any` to enable downcasting. + fn as_any(&self) -> &dyn Any; +} +impl AnyFreeze for T { + fn as_any(&self) -> &dyn Any { + self + } +} diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index 6e040f3af9..3c12fb1350 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -3,12 +3,11 @@ use crate::error_pages::ErrorPageData; use crate::errors::*; use crate::page_data::PageData; use crate::path_prefix::get_path_prefix_client; -use crate::state::PageStateStore; +use crate::state::{AnyFreeze, PageStateStore}; use crate::template::Template; use crate::templates::{PageProps, RouterLoadState, RouterState, TemplateNodeType}; use crate::ErrorPages; use fmterr::fmt_err; -use std::any::Any; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; @@ -255,7 +254,7 @@ pub struct ShellProps { /// The container for reactive content. 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>>, + pub global_state: Rc>>, } /// 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 @@ -282,7 +281,10 @@ pub async fn app_shell( locale => format!("{}/{}", locale, &path), }; // Update the router state - router_state.set_load_state(RouterLoadState::Loading(template.get_path())); + router_state.set_load_state(RouterLoadState::Loading { + template_name: template.get_path(), + path: path_with_locale.clone(), + }); // Get the global state if possible (we'll want this in all cases except errors) // If this is a subsequent load, the template macro will have already set up the global state, and it will ignore whatever we naively give it (so we'll give it `None`) let global_state = get_global_state(); @@ -345,7 +347,7 @@ pub async fn app_shell( let router_state_2 = router_state.clone(); // BUG (Sycamore): this will double-render if the component is just text (no nodes) let page_props = PageProps { - path: path_with_locale, + path: path_with_locale.clone(), state, global_state, }; @@ -384,7 +386,10 @@ pub async fn app_shell( ); checkpoint("page_interactive"); // Update the router state - router_state.set_load_state(RouterLoadState::Loaded(path)); + router_state.set_load_state(RouterLoadState::Loaded { + template_name: path, + path: path_with_locale, + }); } // If we have no initial state, we should proceed as usual, fetching the content and state from the server InitialState::NotPresent => { @@ -461,10 +466,11 @@ pub async fn app_shell( let router_state_2 = router_state.clone(); // BUG (Sycamore): this will double-render if the component is just text (no nodes) let page_props = PageProps { - path: path_with_locale, + path: path_with_locale.clone(), state: page_data.state, global_state, }; + let template_name = template.get_path(); #[cfg(not(feature = "hydrate"))] { // If we aren't hydrating, we'll have to delete everything and re-render @@ -500,7 +506,10 @@ pub async fn app_shell( ); checkpoint("page_interactive"); // Update the router state - router_state.set_load_state(RouterLoadState::Loaded(path)); + router_state.set_load_state(RouterLoadState::Loaded { + template_name, + path: path_with_locale, + }); } // If the page failed to serialize, an exception has occurred Err(err) => panic!("page data couldn't be serialized: '{}'", err), diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index 1f002daad6..2a183f6e10 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -2,7 +2,10 @@ use crate::default_headers::default_headers; use crate::errors::*; +use crate::router::RouterLoadState; use crate::router::RouterState; +use crate::rx_state::Freeze; +use crate::state::AnyFreeze; use crate::state::PageStateStore; use crate::translator::Translator; use crate::Html; @@ -10,7 +13,8 @@ use crate::Request; use crate::SsrNode; use futures::Future; use http::header::HeaderMap; -use std::any::Any; +use serde::Deserialize; +use serde::Serialize; use std::cell::RefCell; use std::collections::HashMap; use std::pin::Pin; @@ -30,6 +34,17 @@ pub struct PageProps { pub global_state: Option, } +/// A representation of a frozen app. +#[derive(Serialize, Deserialize)] +pub struct FrozenApp { + /// The frozen global state. If it was never initialized, this will be `None`. + pub global_state: String, + /// The frozen route. + pub route: String, + /// The frozen page state store. + pub page_state_store: String, +} + /// This encapsulates all elements of context currently provided to Perseus templates. While this can be used manually, there are macros /// to make this easier for each thing in here. #[derive(Clone)] @@ -51,7 +66,24 @@ 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>>, + pub global_state: Rc>>, +} +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. + fn freeze(&self) -> String { + let frozen_app = FrozenApp { + global_state: self.global_state.borrow().freeze(), + route: match &*self.router.get_load_state().get_untracked() { + RouterLoadState::Loaded { path, .. } => path, + RouterLoadState::Loading { path, .. } => path, + // If we encounter this during re-hydration, we won't try to set the URL in the browser + RouterLoadState::Server => "SERVER", + } + .to_string(), + page_state_store: self.page_state_store.freeze(), + }; + serde_json::to_string(&frozen_app).unwrap() + } } /// Represents all the different states that can be generated for a single template, allowing amalgamation logic to be run with the knowledge @@ -263,7 +295,7 @@ impl Template { is_server: bool, router_state: RouterState, page_state_store: PageStateStore, - global_state: Rc>>, + global_state: Rc>>, ) -> 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