Skip to content

Commit 891f3bb

Browse files
committed
feat: added state freezing
No re-hydration yet though or saving.
1 parent 93be5de commit 891f3bb

File tree

11 files changed

+149
-64
lines changed

11 files changed

+149
-64
lines changed

examples/basic/.perseus/src/lib.rs

+3-3
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ use perseus::{
1010
shell::{app_shell, get_initial_state, get_render_cfg, InitialState, ShellProps},
1111
},
1212
plugins::PluginAction,
13-
state::PageStateStore,
13+
state::{AnyFreeze, PageStateStore},
1414
templates::{RouterState, TemplateNodeType},
1515
DomNode,
1616
};
17+
use std::cell::RefCell;
1718
use std::rc::Rc;
18-
use std::{any::Any, cell::RefCell};
1919
use sycamore::prelude::{cloned, create_effect, view, NodeRef, ReadSignal};
2020
use sycamore_router::{HistoryIntegration, Router, RouterProps};
2121
use wasm_bindgen::{prelude::wasm_bindgen, JsValue};
@@ -68,7 +68,7 @@ pub fn run() -> Result<(), JsValue> {
6868
// Create a page state store to use
6969
let pss = PageStateStore::default();
7070
// Create a new global state set to `None`, which will be updated and handled entirely by the template macro from here on
71-
let global_state: Rc<RefCell<Box<dyn Any>>> =
71+
let global_state: Rc<RefCell<Box<dyn AnyFreeze>>> =
7272
Rc::new(RefCell::new(Box::new(Option::<()>::None)));
7373

7474
// Create the router we'll use for this app, based on the user's app definition

examples/rx_state/src/index.rs

+10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use perseus::state::Freeze;
12
use perseus::{Html, RenderFnResultWithCause, Template};
23
use sycamore::prelude::*;
34

@@ -16,13 +17,22 @@ pub struct IndexProps {
1617
#[perseus::template2(IndexPage)]
1718
pub fn index_page(IndexPropsRx { username }: IndexPropsRx, global_state: AppStateRx) -> View<G> {
1819
let username_2 = username.clone(); // This is necessary until Sycamore's new reactive primitives are released
20+
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)
21+
let render_ctx = perseus::get_render_ctx!();
22+
1923
view! {
2024
p { (format!("Greetings, {}!", username.get())) }
2125
input(bind:value = username_2, placeholder = "Username")
2226
p { (global_state.test.get()) }
2327

2428
// 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)
2529
a(href = "about") { "About" }
30+
br()
31+
32+
button(on:click = cloned!(frozen_app, render_ctx => move |_| {
33+
frozen_app.set(render_ctx.freeze());
34+
})) { "Freeze!" }
35+
p { (frozen_app.get()) }
2636
}
2737
}
2838

examples/showcase/src/templates/router_state.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ pub fn router_state_page() -> View<G> {
77
let load_state = perseus::get_render_ctx!().router.get_load_state();
88
let load_state_str = create_memo(
99
cloned!(load_state => move || match (*load_state.get()).clone() {
10-
RouterLoadState::Loaded(name) => format!("Loaded {}.", name),
11-
RouterLoadState::Loading(new) => format!("Loading {}.", new),
10+
RouterLoadState::Loaded { template_name, path } => format!("Loaded {} (template: {}).", path, template_name),
11+
RouterLoadState::Loading { template_name, path } => format!("Loading {} (template: {}).", path, template_name),
1212
RouterLoadState::Server => "We're on the server.".to_string()
1313
}),
1414
);

packages/perseus-macro/src/rx_state.rs

+7
Original file line numberDiff line numberDiff line change
@@ -196,5 +196,12 @@ pub fn make_rx_impl(mut orig_struct: ItemStruct, name: Ident) -> TokenStream {
196196
#make_unrx_fields
197197
}
198198
}
199+
impl#generics ::perseus::state::Freeze for #name#generics {
200+
fn freeze(&self) -> ::std::string::String {
201+
let unrx = #make_unrx_fields;
202+
// TODO Is this `.unwrap()` safe?
203+
::serde_json::to_string(&unrx).unwrap()
204+
}
205+
}
199206
}
200207
}

packages/perseus-macro/src/template2.rs

+5-4
Original file line numberDiff line numberDiff line change
@@ -169,15 +169,16 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
169169
// This means that we can pass an `Option<String>` around safely and then deal with it at the template site
170170
let global_state_refcell = ::perseus::get_render_ctx!().global_state;
171171
let global_state = global_state_refcell.borrow();
172-
if (&global_state).downcast_ref::<::std::option::Option::<()>>().is_some() {
172+
// This will work if the global state hasn't been initialized yet, because it's the default value that Perseus sets
173+
if global_state.as_any().downcast_ref::<::std::option::Option::<()>>().is_some() {
173174
// We can downcast it as the type set by the core render system, so we're the first page to be loaded
174175
// In that case, we'll set the global state properly
175176
drop(global_state);
176-
let mut global_state = global_state_refcell.borrow_mut();
177+
let mut global_state_mut = global_state_refcell.borrow_mut();
177178
// This will be defined if we're the first page
178179
let global_state_props = &props.global_state.unwrap();
179180
let new_global_state = ::serde_json::from_str::<<#global_state_rx as ::perseus::state::MakeUnrx>::Unrx>(global_state_props).unwrap().make_rx();
180-
*global_state = ::std::boxed::Box::new(new_global_state);
181+
*global_state_mut = ::std::boxed::Box::new(new_global_state);
181182
// The component function can now access this in `RenderCtx`
182183
}
183184
// The user's function
@@ -189,7 +190,7 @@ pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream
189190
let global_state = ::perseus::get_render_ctx!().global_state;
190191
let global_state = global_state.borrow();
191192
// 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
192-
let global_state_ref = (&global_state).downcast_ref::<#global_state_rx>().unwrap();
193+
let global_state_ref = global_state.as_any().downcast_ref::<#global_state_rx>().unwrap();
193194
(*global_state_ref).clone()
194195
};
195196
#block

packages/perseus/src/lib.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ pub mod templates {
102102
pub mod state {
103103
pub use crate::global_state::GlobalStateCreator;
104104
pub use crate::page_state_store::PageStateStore;
105-
pub use crate::rx_state::{MakeRx, MakeUnrx};
105+
pub use crate::rx_state::*;
106106
}
107107
/// A series of exports that should be unnecessary for nearly all uses of Perseus. These are used principally in developing alternative
108108
/// engines.

packages/perseus/src/page_state_store.rs

+28-40
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
1-
use std::any::{Any, TypeId};
21
use std::cell::RefCell;
32
use std::collections::HashMap;
43
use std::rc::Rc;
54

6-
/// A key type for the `PageStateStore` that denotes both a page's state type and its URL.
7-
#[derive(Hash, PartialEq, Eq)]
8-
pub struct PageStateKey {
9-
state_type: TypeId,
10-
url: String,
11-
}
5+
use crate::{rx_state::Freeze, state::AnyFreeze};
126

137
/// 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,
148
/// 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 {
1913
// TODO Make this work with multiple pages for a single template
2014
#[derive(Default, Clone)]
2115
pub struct PageStateStore {
22-
/// 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
16+
/// 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
2317
/// to be `Signal`s or `struct`s composed of `Signal`s.
2418
// 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!
25-
map: Rc<RefCell<HashMap<PageStateKey, Box<dyn Any>>>>,
19+
map: Rc<RefCell<HashMap<String, Box<dyn AnyFreeze>>>>,
2620
}
2721
impl PageStateStore {
28-
/// Gets an element out of the state by its type and URL.
29-
pub fn get<T: Any + Clone>(&self, url: &str) -> Option<T> {
30-
let type_id = TypeId::of::<T>();
31-
let key = PageStateKey {
32-
state_type: type_id,
33-
url: url.to_string(),
34-
};
22+
/// 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.
23+
pub fn get<T: AnyFreeze + Clone>(&self, url: &str) -> Option<T> {
3524
let map = self.map.borrow();
36-
map.get(&key).map(|val| {
37-
if let Some(val) = val.downcast_ref::<T>() {
38-
(*val).clone()
39-
} else {
40-
// We extracted it by its type ID, it certainly should be able to downcast to that same type ID!
41-
unreachable!()
42-
}
43-
})
25+
map.get(url)
26+
.map(|val| val.as_any().downcast_ref::<T>().map(|val| (*val).clone()))
27+
.flatten()
4428
}
45-
/// 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).
46-
pub fn add<T: Any + Clone>(&mut self, url: &str, val: T) {
47-
let type_id = TypeId::of::<T>();
48-
let key = PageStateKey {
49-
state_type: type_id,
50-
url: url.to_string(),
51-
};
29+
/// 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).
30+
pub fn add<T: AnyFreeze + Clone>(&mut self, url: &str, val: T) {
5231
let mut map = self.map.borrow_mut();
53-
map.insert(key, Box::new(val));
32+
map.insert(url.to_string(), Box::new(val));
33+
}
34+
/// Checks if the state contains an entry for the given URL.
35+
pub fn contains(&self, url: &str) -> bool {
36+
self.map.borrow().contains_key(url)
5437
}
55-
/// Checks if the state contains the element of the given type for the given page.
56-
pub fn contains<T: Any + Clone>(&self, url: &str) -> bool {
57-
let type_id = TypeId::of::<T>();
58-
let key = PageStateKey {
59-
state_type: type_id,
60-
url: url.to_string(),
61-
};
62-
self.map.borrow().contains_key(&key)
38+
}
39+
// Good for convenience, and there's no reason we can't do this
40+
impl Freeze for PageStateStore {
41+
// TODO Avoid literally cloning all the page states here if possible
42+
fn freeze(&self) -> String {
43+
let map = self.map.borrow();
44+
let mut str_map = HashMap::new();
45+
for (k, v) in map.iter() {
46+
let v_str = v.freeze();
47+
str_map.insert(k, v_str);
48+
}
49+
50+
serde_json::to_string(&str_map).unwrap()
6351
}
6452
}
6553

packages/perseus/src/router.rs

+13-3
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,20 @@ impl RouterState {
321321
/// 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).
322322
#[derive(Clone)]
323323
pub enum RouterLoadState {
324-
/// The page has been loaded. The name of the template is attached.
325-
Loaded(String),
324+
/// The page has been loaded.
325+
Loaded {
326+
/// The name of the template being loaded (mostly for convenience).
327+
template_name: String,
328+
/// The full path to the new page being loaded (including the locale, if we're using i18n).
329+
path: String,
330+
},
326331
/// A new page is being loaded, and will soon replace whatever is currently loaded. The name of the new template is attached.
327-
Loading(String),
332+
Loading {
333+
/// The name of the template being loaded (mostly for convenience).
334+
template_name: String,
335+
/// The full path to the new page being loaded (including the locale, if we're using i18n).
336+
path: String,
337+
},
328338
/// 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.
329339
Server,
330340
}

packages/perseus/src/rx_state.rs

+28
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::any::Any;
2+
13
/// 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.
24
pub trait MakeRx {
35
/// 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 {
1618
/// and fewer arguments that the user needs to provide to macros.
1719
fn make_unrx(self) -> Self::Unrx;
1820
}
21+
22+
/// 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
23+
/// to (they always do in Perseus macro-generated code).
24+
pub trait Freeze {
25+
/// 'Freezes' the reactive `struct` by making it unreactive and converting it to a `String`.
26+
fn freeze(&self) -> String;
27+
}
28+
29+
// 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
30+
// 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).
31+
impl Freeze for Option<()> {
32+
fn freeze(&self) -> String {
33+
serde_json::to_string(&Option::<()>::None).unwrap()
34+
}
35+
}
36+
37+
/// A convenience super-trait for `Freeze`able things that can be downcast to concrete types.
38+
pub trait AnyFreeze: Freeze + Any {
39+
/// Gives `&dyn Any` to enable downcasting.
40+
fn as_any(&self) -> &dyn Any;
41+
}
42+
impl<T: Any + Freeze> AnyFreeze for T {
43+
fn as_any(&self) -> &dyn Any {
44+
self
45+
}
46+
}

packages/perseus/src/shell.rs

+17-8
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ use crate::error_pages::ErrorPageData;
33
use crate::errors::*;
44
use crate::page_data::PageData;
55
use crate::path_prefix::get_path_prefix_client;
6-
use crate::state::PageStateStore;
6+
use crate::state::{AnyFreeze, PageStateStore};
77
use crate::template::Template;
88
use crate::templates::{PageProps, RouterLoadState, RouterState, TemplateNodeType};
99
use crate::ErrorPages;
1010
use fmterr::fmt_err;
11-
use std::any::Any;
1211
use std::cell::RefCell;
1312
use std::collections::HashMap;
1413
use std::rc::Rc;
@@ -255,7 +254,7 @@ pub struct ShellProps {
255254
/// The container for reactive content.
256255
pub container_rx_elem: Element,
257256
/// The global state store. Brekaing it out here prevents it being overriden every time a new template loads.
258-
pub global_state: Rc<RefCell<Box<dyn Any>>>,
257+
pub global_state: Rc<RefCell<Box<dyn AnyFreeze>>>,
259258
}
260259

261260
/// 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(
282281
locale => format!("{}/{}", locale, &path),
283282
};
284283
// Update the router state
285-
router_state.set_load_state(RouterLoadState::Loading(template.get_path()));
284+
router_state.set_load_state(RouterLoadState::Loading {
285+
template_name: template.get_path(),
286+
path: path_with_locale.clone(),
287+
});
286288
// Get the global state if possible (we'll want this in all cases except errors)
287289
// 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`)
288290
let global_state = get_global_state();
@@ -345,7 +347,7 @@ pub async fn app_shell(
345347
let router_state_2 = router_state.clone();
346348
// BUG (Sycamore): this will double-render if the component is just text (no nodes)
347349
let page_props = PageProps {
348-
path: path_with_locale,
350+
path: path_with_locale.clone(),
349351
state,
350352
global_state,
351353
};
@@ -384,7 +386,10 @@ pub async fn app_shell(
384386
);
385387
checkpoint("page_interactive");
386388
// Update the router state
387-
router_state.set_load_state(RouterLoadState::Loaded(path));
389+
router_state.set_load_state(RouterLoadState::Loaded {
390+
template_name: path,
391+
path: path_with_locale,
392+
});
388393
}
389394
// If we have no initial state, we should proceed as usual, fetching the content and state from the server
390395
InitialState::NotPresent => {
@@ -461,10 +466,11 @@ pub async fn app_shell(
461466
let router_state_2 = router_state.clone();
462467
// BUG (Sycamore): this will double-render if the component is just text (no nodes)
463468
let page_props = PageProps {
464-
path: path_with_locale,
469+
path: path_with_locale.clone(),
465470
state: page_data.state,
466471
global_state,
467472
};
473+
let template_name = template.get_path();
468474
#[cfg(not(feature = "hydrate"))]
469475
{
470476
// If we aren't hydrating, we'll have to delete everything and re-render
@@ -500,7 +506,10 @@ pub async fn app_shell(
500506
);
501507
checkpoint("page_interactive");
502508
// Update the router state
503-
router_state.set_load_state(RouterLoadState::Loaded(path));
509+
router_state.set_load_state(RouterLoadState::Loaded {
510+
template_name,
511+
path: path_with_locale,
512+
});
504513
}
505514
// If the page failed to serialize, an exception has occurred
506515
Err(err) => panic!("page data couldn't be serialized: '{}'", err),

0 commit comments

Comments
 (0)