Skip to content

Commit

Permalink
feat: added state freezing
Browse files Browse the repository at this point in the history
No re-hydration yet though or saving.
  • Loading branch information
arctic-hen7 committed Jan 21, 2022
1 parent 93be5de commit 891f3bb
Show file tree
Hide file tree
Showing 11 changed files with 149 additions and 64 deletions.
6 changes: 3 additions & 3 deletions examples/basic/.perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<RefCell<Box<dyn Any>>> =
let global_state: Rc<RefCell<Box<dyn AnyFreeze>>> =
Rc::new(RefCell::new(Box::new(Option::<()>::None)));

// Create the router we'll use for this app, based on the user's app definition
Expand Down
10 changes: 10 additions & 0 deletions examples/rx_state/src/index.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use perseus::state::Freeze;
use perseus::{Html, RenderFnResultWithCause, Template};
use sycamore::prelude::*;

Expand All @@ -16,13 +17,22 @@ pub struct IndexProps {
#[perseus::template2(IndexPage)]
pub fn index_page(IndexPropsRx { username }: IndexPropsRx, global_state: AppStateRx) -> View<G> {
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")
p { (global_state.test.get()) }

// 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()) }
}
}

Expand Down
4 changes: 2 additions & 2 deletions examples/showcase/src/templates/router_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ pub fn router_state_page() -> View<G> {
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()
}),
);
Expand Down
7 changes: 7 additions & 0 deletions packages/perseus-macro/src/rx_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
}
9 changes: 5 additions & 4 deletions packages/perseus-macro/src/template2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,16 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
// This means that we can pass an `Option<String>` 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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 28 additions & 40 deletions packages/perseus/src/page_state_store.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<RefCell<HashMap<PageStateKey, Box<dyn Any>>>>,
map: Rc<RefCell<HashMap<String, Box<dyn AnyFreeze>>>>,
}
impl PageStateStore {
/// Gets an element out of the state by its type and URL.
pub fn get<T: Any + Clone>(&self, url: &str) -> Option<T> {
let type_id = TypeId::of::<T>();
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<T: AnyFreeze + Clone>(&self, url: &str) -> Option<T> {
let map = self.map.borrow();
map.get(&key).map(|val| {
if let Some(val) = val.downcast_ref::<T>() {
(*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::<T>().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<T: Any + Clone>(&mut self, url: &str, val: T) {
let type_id = TypeId::of::<T>();
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<T: AnyFreeze + Clone>(&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<T: Any + Clone>(&self, url: &str) -> bool {
let type_id = TypeId::of::<T>();
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()
}
}

Expand Down
16 changes: 13 additions & 3 deletions packages/perseus/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
28 changes: 28 additions & 0 deletions packages/perseus/src/rx_state.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<T: Any + Freeze> AnyFreeze for T {
fn as_any(&self) -> &dyn Any {
self
}
}
25 changes: 17 additions & 8 deletions packages/perseus/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<RefCell<Box<dyn Any>>>,
pub global_state: Rc<RefCell<Box<dyn AnyFreeze>>>,
}

/// 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
Expand All @@ -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();
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading

0 comments on commit 891f3bb

Please sign in to comment.