Skip to content

Commit 9d2a729

Browse files
committed
feat: added idb wrapper for state freezing
1 parent 2d67a40 commit 9d2a729

File tree

6 files changed

+241
-2
lines changed

6 files changed

+241
-2
lines changed

examples/rx_state/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ edition = "2018"
66
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
77

88
[dependencies]
9-
perseus = { path = "../../packages/perseus", features = [ "hydrate" ] }
9+
perseus = { path = "../../packages/perseus", features = [ "hydrate", "idb-freezing" ] }
1010
sycamore = "0.7"
1111
serde = { version = "1", features = ["derive"] }
1212
serde_json = "1"

examples/rx_state/src/idb.rs

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use perseus::state::IdbFrozenStateStore;
2+
use perseus::{Html, RenderFnResultWithCause, Template};
3+
use sycamore::prelude::*;
4+
5+
#[perseus::make_rx(TestPropsRx)]
6+
pub struct TestProps {
7+
pub username: String,
8+
}
9+
10+
// This special macro (normally we'd use `template(IndexProps)`) converts the state we generate elsewhere to a reactive version
11+
#[perseus::template_rx(IdbPage)]
12+
pub fn idb_page(TestPropsRx { username }: TestPropsRx) -> View<G> {
13+
let username_2 = username.clone(); // This is necessary until Sycamore's new reactive primitives are released
14+
// TODO Futures etc.
15+
let _idb_store = IdbFrozenStateStore::new();
16+
17+
view! {
18+
p { (format!("Greetings, {}!", username.get())) }
19+
input(bind:value = username_2, placeholder = "Username")
20+
21+
// 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)
22+
a(href = "about") { "About" }
23+
a(href = "") { "Index" }
24+
25+
// button(on:click = cloned!(frozen_app, render_ctx => move |_| {
26+
// frozen_app.set(render_ctx.freeze());
27+
// })) { "Freeze!" }
28+
// p { (frozen_app.get()) }
29+
30+
// button(on:click = cloned!(frozen_app_3, render_ctx => move |_| {
31+
// render_ctx.thaw(&frozen_app_3.get(), perseus::state::ThawPrefs {
32+
// page: perseus::state::PageThawPrefs::IncludeAll,
33+
// global_prefer_frozen: true
34+
// }).unwrap();
35+
// })) { "Thaw..." }
36+
}
37+
}
38+
39+
pub fn get_template<G: Html>() -> Template<G> {
40+
Template::new("idb")
41+
.build_state_fn(get_build_state)
42+
.template(idb_page)
43+
}
44+
45+
#[perseus::autoserde(build_state)]
46+
pub async fn get_build_state(_path: String, _locale: String) -> RenderFnResultWithCause<TestProps> {
47+
Ok(TestProps {
48+
username: "".to_string(),
49+
})
50+
}

examples/rx_state/src/lib.rs

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod about;
22
mod global_state;
3+
mod idb;
34
mod index;
45
mod test;
56

@@ -9,7 +10,8 @@ define_app! {
910
templates: [
1011
index::get_template::<G>(),
1112
test::get_template::<G>(),
12-
about::get_template::<G>()
13+
about::get_template::<G>(),
14+
idb::get_template::<G>()
1315
],
1416
error_pages: perseus::ErrorPages::new(|url, status, err, _| {
1517
sycamore::view! {

packages/perseus/Cargo.toml

+6
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ fluent-bundle = { version = "0.15", optional = true }
3535
unic-langid = { version = "0.9", optional = true }
3636
intl-memoizer = { version = "0.5", optional = true }
3737
tokio = { version = "1", features = [ "fs", "io-util" ] }
38+
rexie = { version = "0.2", optional = true }
3839

3940
[features]
4041
default = []
@@ -52,3 +53,8 @@ hydrate = []
5253
# This feature enables the preloading of the Wasm bundle for locale redirections, which in theory improves UX
5354
# For now, this is experimental until it can be tested in the wild (local testing of this is extremely difficult for UX, we need real world metrics)
5455
preload-wasm-on-redirect = []
56+
# This exposes an API for saving frozen state to IndexedDB simply, with options for making your storage persistent so the browser won't delete it
57+
idb-freezing = [ "rexie", "web-sys/StorageManager" ]
58+
# Switches to expecting the server to provide a JS bundle that's been created from Wasm
59+
# Note that this is highly experimental, and currently blocked by [rustwasm/wasm-bindgen#2735](https://github.com/rustwasm/wasm-bindgen/issues/2735)
60+
wasm2js = []
+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
use rexie::{Direction, Error as RexieError, ObjectStore, Rexie, TransactionMode};
2+
use std::rc::Rc;
3+
use thiserror::Error;
4+
use wasm_bindgen::JsValue;
5+
use wasm_bindgen_futures::JsFuture;
6+
7+
#[allow(missing_docs)]
8+
#[derive(Debug, Error)]
9+
pub enum IdbError {
10+
#[error("couldn't build database")]
11+
BuildError {
12+
#[source]
13+
source: RexieError,
14+
},
15+
// The source of this would be a `JsValue`, which we drop for performance
16+
#[error("persistence check failed")]
17+
PersistenceCheckFailed {
18+
/// Whether or not this persistence check could be retried and might result in a success (this is just an estimation).
19+
retry: bool,
20+
},
21+
#[error("an error occurred while constructing an IndexedDB transaction")]
22+
TransactionError {
23+
#[source]
24+
source: RexieError,
25+
},
26+
#[error("an error occured while trying to set a new value")]
27+
SetError {
28+
#[source]
29+
source: RexieError,
30+
},
31+
#[error("an error occurred while clearing the store of previous values")]
32+
ClearError {
33+
#[source]
34+
source: RexieError,
35+
},
36+
#[error("an error occurred while trying to get the latest value")]
37+
GetError {
38+
#[source]
39+
source: RexieError,
40+
},
41+
}
42+
43+
/// A frozen state store that uses IndexedDB as a backend. This will only store a single frozen state at a time, removing all previously stored states every time a new one is set.
44+
///
45+
/// TODO Browser compatibility information.
46+
#[derive(Clone)]
47+
pub struct IdbFrozenStateStore {
48+
/// A handle to the database.
49+
db: Rc<Rexie>,
50+
}
51+
impl std::fmt::Debug for IdbFrozenStateStore {
52+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53+
f.debug_struct("IdbFrozenStateStore").finish()
54+
}
55+
}
56+
impl IdbFrozenStateStore {
57+
/// Creates a new store for this origin. If it already exists from a previous visit, the existing one will be interfaced with.
58+
pub async fn new() -> Result<Self, IdbError> {
59+
// Build the database
60+
let rexie = Rexie::builder("perseus")
61+
// IndexedDB uses versions to track database schema changes
62+
// 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!
63+
.version(1)
64+
.add_object_store(
65+
// We'll store many versions of frozen state so that the user can revert to previous states
66+
ObjectStore::new("frozen_state")
67+
.key_path("id")
68+
.auto_increment(true), // IndexedDB doesn't need us to register value types, only things that should be indexed (gotta love JS type safety haven't you!)
69+
)
70+
.build()
71+
.await
72+
.map_err(|err| IdbError::BuildError { source: err })?;
73+
74+
Ok(Self { db: Rc::new(rexie) })
75+
}
76+
/// Gets the stored frozen state. Be warned that the result of this could be arbitrarily old, or it may have been tampered with by the user (in which case Perseus will either return an
77+
/// error that you can handle or it'll fall back to the active state). If no state has been stored yet, this will return `Ok(None)`.
78+
pub async fn get(&self) -> Result<Option<String>, IdbError> {
79+
let transaction = self
80+
.db
81+
.transaction(&["frozen_state"], TransactionMode::ReadOnly)
82+
.map_err(|err| IdbError::TransactionError { source: err })?;
83+
let store = transaction
84+
.store("frozen_state")
85+
.map_err(|err| IdbError::TransactionError { source: err })?;
86+
87+
// Get the last element from the store by working backwards and getting everything, with a limit of 1
88+
let frozen_states = store
89+
.get_all(None, Some(1), None, Some(Direction::Prev))
90+
.await
91+
.map_err(|err| IdbError::GetError { source: err })?;
92+
let frozen_state = match frozen_states.get(0) {
93+
Some((_key, value)) => value,
94+
None => return Ok(None),
95+
};
96+
// TODO Do this without cloning the whole thing into the Wasm table and then moving it into Rust
97+
let frozen_state = frozen_state.as_string().unwrap();
98+
transaction
99+
.commit()
100+
.await
101+
.map_err(|err| IdbError::TransactionError { source: err })?;
102+
103+
Ok(Some(frozen_state))
104+
}
105+
/// Sets the content to a new frozen state.
106+
pub async fn set(&self, frozen_state: &str) -> Result<(), IdbError> {
107+
let transaction = self
108+
.db
109+
.transaction(&["frozen_state"], TransactionMode::ReadWrite)
110+
.map_err(|err| IdbError::TransactionError { source: err })?;
111+
let store = transaction
112+
.store("frozen_state")
113+
.map_err(|err| IdbError::TransactionError { source: err })?;
114+
115+
// We only store a single frozen state, and they can be quite large, so we'll remove any that are already in here
116+
store
117+
.clear()
118+
.await
119+
.map_err(|err| IdbError::ClearError { source: err })?;
120+
// We can add the frozen state directly because it's already a serialized string
121+
// This returns the ID, but we don't need to care about that
122+
store
123+
.add(&JsValue::from(frozen_state), None)
124+
.await
125+
.map_err(|err| IdbError::SetError { source: err })?;
126+
transaction
127+
.commit()
128+
.await
129+
.map_err(|err| IdbError::SetError { source: err })?;
130+
131+
Ok(())
132+
}
133+
/// Checks if the storage is persistently stored. If it is, the browser isn't allowed to clear it, the user would have to manually. This doesn't provide a guarantee that all users who've
134+
/// been to your site before will have previous state stored, you should assume that they could well have cleared it manually (or with very stringent privacy settings).
135+
///
136+
/// If this returns an error, a recommendation about whether or not to retry will be attached. You generally shouldn't retry this more than once if there was an error.
137+
///
138+
/// For more information about persistent storage on the web, see [here](https://web.dev/persistent-storage).
139+
pub async fn is_persistent() -> Result<bool, IdbError> {
140+
let storage_manager = web_sys::window().unwrap().navigator().storage();
141+
// If we can't access this, we're probably in a very old browser, so retrying isn't worth it in all likelihood
142+
let persisted = storage_manager
143+
.persisted()
144+
.map_err(|_| IdbError::PersistenceCheckFailed { retry: false })?;
145+
let persisted = JsFuture::from(persisted)
146+
.await
147+
.map_err(|_| IdbError::PersistenceCheckFailed { retry: true })?;
148+
let persisted_bool = persisted
149+
.as_bool()
150+
.ok_or(IdbError::PersistenceCheckFailed { retry: true })?;
151+
152+
Ok(persisted_bool)
153+
}
154+
/// Requests persistent storage from the browser. In Firefox, the user will be prompted, though in Chrome the browser will automatically accept or deny based on your site's level of
155+
/// engagement, whether ot not it's been installed or bookmarked, and whether or not it's been granted the permission to show notifications. In other words, do NOT assume that this will
156+
/// be accepted, even if you ask the user very nicely. That said, especially in Firefox, you should display a custom notification before this with `alert()` or similar that explains why
157+
/// your site needs persistent storage for frozen state.
158+
///
159+
/// If this returns `false`, the request was rejected, but you can retry in future (for user experience though, it's recommended to only do so very sparingly). If this returns an error,
160+
/// a recommendation about whether or not to retry will be attached. You generally shouldn't retry this more than once if there was an error.
161+
///
162+
/// For more information about persistent storage on the web, see [here](https://web.dev/persistent-storage).
163+
pub async fn request_persistence() -> Result<bool, IdbError> {
164+
let storage_manager = web_sys::window().unwrap().navigator().storage();
165+
// If we can't access this, we're probably in a very old browser, so retrying isn't worth it in all likelihood
166+
let res = storage_manager
167+
.persist()
168+
.map_err(|_| IdbError::PersistenceCheckFailed { retry: false })?;
169+
let res = JsFuture::from(res)
170+
.await
171+
.map_err(|_| IdbError::PersistenceCheckFailed { retry: true })?;
172+
let res_bool = res.as_bool().unwrap();
173+
174+
Ok(res_bool)
175+
}
176+
}

packages/perseus/src/state/mod.rs

+5
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,8 @@ pub use freeze::{FrozenApp, PageThawPrefs, ThawPrefs};
77
pub use global_state::{GlobalState, GlobalStateCreator};
88
pub use page_state_store::PageStateStore;
99
pub use rx_state::{AnyFreeze, Freeze, MakeRx, MakeUnrx};
10+
11+
#[cfg(feature = "idb-freezing")]
12+
mod freeze_idb;
13+
#[cfg(feature = "idb-freezing")]
14+
pub use freeze_idb::*; // TODO Be specific here

0 commit comments

Comments
 (0)