Skip to content

Commit

Permalink
fix sdk storage with hydration
Browse files Browse the repository at this point in the history
  • Loading branch information
ealmloff committed May 30, 2024
1 parent ffffa63 commit 4045351
Show file tree
Hide file tree
Showing 8 changed files with 139 additions and 69 deletions.
9 changes: 6 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
[workspace]
resolver = "2"
members = ["sdk", "examples/*"]
# We exclude the storage example from the workspace so that the dioxus features for other renderers are not enabled
exclude = ["examples/storage"]


[workspace.dependencies]
dioxus-sdk = { path = "./sdk" }
dioxus = { version = "0.5" }
dioxus-web = { version = "0.5" }
dioxus-desktop = { version = "0.5" }
# dioxus = { version = "0.5" }
# dioxus-desktop = { version = "0.5" }
dioxus = { git = "https://github.com/DioxusLabs/dioxus", branch = "v0.5" }
dioxus-desktop = { git = "https://github.com/DioxusLabs/dioxus", branch = "v0.5" }
1 change: 1 addition & 0 deletions examples/storage/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/.dioxus
13 changes: 11 additions & 2 deletions examples/storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@ version = "0.1.0"
edition = "2021"

[dependencies]
dioxus-sdk = { workspace = true, features = ["storage"] }
dioxus = { workspace = true, features = ["router"] }
dioxus-sdk = { path = "../../sdk", features = ["storage"] }
dioxus = { git = "https://github.com/DioxusLabs/dioxus", branch = "v0.5", features = ["router"] }

[features]
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
fullstack = ["dioxus/fullstack"]
server = ["dioxus/axum"]

# # Fullstack support requires a patch from 0.5.2
# [patch.crates-io]
# dioxus = { git = "https://github.com/dioxuslabs/dioxus", branch = "v0.5" }
# dioxus-core = { git = "https://github.com/dioxuslabs/dioxus", branch = "v0.5" }
# dioxus-hooks = { git = "https://github.com/dioxuslabs/dioxus", branch = "v0.5" }
# dioxus-signals = { git = "https://github.com/dioxuslabs/dioxus", branch = "v0.5" }
6 changes: 6 additions & 0 deletions examples/storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ Web:
```sh
dioxus serve --features web
```

Fullstack:

```sh
dioxus serve --platform fullstack --features fullstack
```
15 changes: 7 additions & 8 deletions examples/storage/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use dioxus::prelude::*;
use dioxus_router::prelude::*;
use dioxus_sdk::storage::*;

fn main() {
Expand All @@ -18,9 +17,9 @@ fn app() -> Element {
enum Route {
#[layout(Footer)]
#[route("/")]
Page1 {},
#[route("/page2")]
Page2 {},
Home {},
#[route("/storage")]
Storage {},
}

#[component]
Expand Down Expand Up @@ -61,21 +60,21 @@ fn Footer() -> Element {

nav {
ul {
li { Link { to: Route::Page1 {}, "Page1" } }
li { Link { to: Route::Page2 {}, "Page2" } }
li { Link { to: Route::Home {}, "Home" } }
li { Link { to: Route::Storage {}, "Storage" } }
}
}
}
}
}

#[component]
fn Page1() -> Element {
fn Home() -> Element {
rsx!("Home")
}

#[component]
fn Page2() -> Element {
fn Storage() -> Element {
let mut count_session = use_singleton_persistent(|| 0);
let mut count_local = use_synced_storage::<LocalStorage, i32>("synced".to_string(), || 0);

Expand Down
2 changes: 1 addition & 1 deletion sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ unic-langid = { version = "0.9.1", features = ["serde"], optional = true }
rustc-hash = { version = "1.1.0", optional = true }
postcard = { version = "1.0.2", features = ["use-std"], optional = true }
once_cell = { version = "1.17.0", optional = true }
dioxus-signals = { version = "0.5.0-alpha.2", features = [
dioxus-signals = { git = "https://github.com/DioxusLabs/dioxus", branch = "v0.5", features = [
"serialize",
], optional = true }

Expand Down
147 changes: 98 additions & 49 deletions sdk/src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ use futures_util::stream::StreamExt;
pub use persistence::{
new_persistent, new_singleton_persistent, use_persistent, use_singleton_persistent,
};
use std::cell::RefCell;
use std::rc::Rc;

use dioxus::prelude::*;
use postcard::to_allocvec;
Expand Down Expand Up @@ -68,7 +70,32 @@ where
T: Serialize + DeserializeOwned + Clone + Send + Sync + PartialEq + 'static,
S::Key: Clone,
{
use_hook(|| new_storage::<S, T>(key, init))
let mut init = Some(init);
let storage = use_hook(|| new_storage::<S, T>(key, || init.take().unwrap()()));
use_hydrate_storage_hook::<S, T>(storage, init);
storage
}

#[allow(unused)]
enum StorageMode {
Client,
HydrateClient,
Server,
}

impl StorageMode {
// Get the active mode
const fn current() -> Self {
server_only! {
return StorageMode::Server;
}

fullstack! {
return StorageMode::HydrateClient;
}

StorageMode::Client
}
}

/// Creates a Signal that can be used to store data that will persist across application reloads.
Expand All @@ -93,30 +120,18 @@ where
T: Serialize + DeserializeOwned + Clone + Send + Sync + PartialEq + 'static,
S::Key: Clone,
{
let mut init = Some(init);
let mode = StorageMode::current();

if cfg!(feature = "ssr") {
match mode {
// SSR does not support storage on the backend. We will just use a normal Signal to represent the initial state.
// The client will hydrate this with a correct StorageEntry and maintain state.
Signal::new(init.take().unwrap()())
} else if cfg!(feature = "hydrate") {
let key_clone = key.clone();
let mut storage_entry = new_storage_entry::<S, T>(key, init.take().unwrap());
if generation() == 0 {
// The first generation is rendered on the server side and so must be hydrated.
needs_update();
}
if generation() == 1 {
// The first time the vdom is hydrated, we set the correct value from storage and set up the subscription to storage events.
storage_entry.set(get_from_storage::<S, T>(key_clone, init.take().unwrap()));
StorageMode::Server => Signal::new(init()),
_ => {
// Otherwise the client is rendered normally, so we can just use the storage entry.
let storage_entry = new_storage_entry::<S, T>(key, init);
storage_entry.save_to_storage_on_change();
storage_entry.data
}
storage_entry.data
} else {
// The client is rendered normally, so we can just use the storage entry.
let storage_entry = new_storage_entry::<S, T>(key, init.take().unwrap());
storage_entry.save_to_storage_on_change();
storage_entry.data
}
}

Expand All @@ -130,7 +145,10 @@ where
T: Serialize + DeserializeOwned + Clone + Send + Sync + PartialEq + 'static,
S::Key: Clone,
{
use_hook(|| new_synced_storage::<S, T>(key, init))
let mut init = Some(init);
let storage = use_hook(|| new_synced_storage::<S, T>(key, || init.take().unwrap()()));
use_hydrate_storage_hook::<S, T>(storage, init);
storage
}

/// Create a signal that can be used to store data that will persist across application reloads and be synced across all app sessions for a given installation or browser.
Expand All @@ -143,34 +161,20 @@ where
T: Serialize + DeserializeOwned + Clone + Send + Sync + PartialEq + 'static,
S::Key: Clone,
{
let mut init = Some(init);
let signal = {
if cfg!(feature = "ssr") {
let mode = StorageMode::current();

match mode {
// SSR does not support synced storage on the backend. We will just use a normal Signal to represent the initial state.
// The client will hydrate this with a correct SyncedStorageEntry and maintain state.
Signal::new(init.take().unwrap()())
} else if cfg!(feature = "hydrate") {
let key_clone = key.clone();
let mut storage_entry = new_synced_storage_entry::<S, T>(key, init.take().unwrap());
if generation() == 0 {
// The first generation is rendered on the server side and so must be hydrated.
needs_update();
}
if generation() == 1 {
// The first time the vdom is hydrated, we set the correct value from storage and set up the subscription to storage events.
storage_entry
.entry
.set(get_from_storage::<S, T>(key_clone, init.take().unwrap()));
StorageMode::Server => Signal::new(init()),
_ => {
// The client is rendered normally, so we can just use the synced storage entry.
let storage_entry = new_synced_storage_entry::<S, T>(key, init);
storage_entry.save_to_storage_on_change();
storage_entry.subscribe_to_storage();
*storage_entry.data()
}
*storage_entry.data()
} else {
// The client is rendered normally, so we can just use the synced storage entry.
let storage_entry = new_synced_storage_entry::<S, T>(key, init.take().unwrap());
storage_entry.save_to_storage_on_change();
storage_entry.subscribe_to_storage();
*storage_entry.data()
}
};
signal
Expand All @@ -183,7 +187,10 @@ where
T: Serialize + DeserializeOwned + Clone + Send + Sync + PartialEq + 'static,
S::Key: Clone,
{
use_hook(|| new_storage_entry::<S, T>(key, init))
let mut init = Some(init);
let signal = use_hook(|| new_storage_entry::<S, T>(key, || init.take().unwrap()()));
use_hydrate_storage_hook::<S, T>(*signal.data(), init);
signal
}

/// A hook that creates a StorageEntry with the latest value from storage or the init value if it doesn't exist, and provides a channel to subscribe to updates to the underlying storage.
Expand All @@ -196,7 +203,10 @@ where
T: Serialize + DeserializeOwned + Clone + Send + Sync + PartialEq + 'static,
S::Key: Clone,
{
use_hook(|| new_synced_storage_entry::<S, T>(key, init))
let mut init = Some(init);
let signal = use_hook(|| new_synced_storage_entry::<S, T>(key, || init.take().unwrap()()));
use_hydrate_storage_hook::<S, T>(*signal.data(), init);
signal
}

/// Returns a StorageEntry with the latest value from storage or the init value if it doesn't exist.
Expand Down Expand Up @@ -264,15 +274,16 @@ pub trait StorageEntryTrait<S: StorageBacking, T: PartialEq + Clone + 'static>:
T: Serialize + DeserializeOwned + Clone + PartialEq + 'static,
{
let entry_clone = self.clone();
let old = Signal::new(self.data().cloned());
let old = RefCell::new(None);
let data = *self.data();
spawn(async move {
loop {
let (rc, mut reactive_context) = ReactiveContext::new();
rc.run_in(|| {
if *old.read() != *data.read() {
if old.borrow().as_ref() != Some(&*data.read()) {
tracing::trace!("Saving to storage");
entry_clone.save();
old.replace(Some(data()));
}
});
if reactive_context.next().await.is_none() {
Expand Down Expand Up @@ -574,8 +585,46 @@ pub(crate) fn try_serde_from_string<T: DeserializeOwned>(value: &str) -> Option<
match yazi::decompress(&bytes, yazi::Format::Zlib) {
Ok((decompressed, _)) => match postcard::from_bytes(&decompressed) {
Ok(v) => Some(v),
Err(err) => None,
Err(_) => None,
},
Err(err) => None,
Err(_) => None,
}
}

// Take a signal and a storage key and hydrate the value if we are hydrating the client.
pub(crate) fn use_hydrate_storage_hook<S, T>(
mut signal: Signal<T>,
init: Option<impl FnOnce() -> T>,
) -> Signal<T>
where
S: StorageBacking,
T: Serialize + DeserializeOwned + Clone + Send + Sync + PartialEq + 'static,
S::Key: Clone,
{
let mode = StorageMode::current();
// We read the value from storage and store it here if we are hydrating the client.
let original_storage_value: Rc<RefCell<Option<T>>> = use_hook(|| Rc::new(RefCell::new(None)));

// If we are not hydrating the client
if let StorageMode::HydrateClient = mode {
if generation() == 0 {
// We always use the default value for the first render.
if let Some(default_value) = init {
// Read the value from storage before we reset it for hydration
original_storage_value
.borrow_mut()
.replace(signal.peek().clone());
signal.set(default_value());
}
// And we trigger a new render for after hydration
needs_update();
}
if generation() == 1 {
// After we hydrate, set the original value from storage
if let Some(original_storage_value) = original_storage_value.borrow_mut().take() {
signal.set(original_storage_value);
}
}
}
signal
}
15 changes: 9 additions & 6 deletions sdk/src/storage/persistence.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::storage::new_storage_entry;
use crate::storage::SessionStorage;
use crate::storage::{new_storage_entry, use_hydrate_storage_hook};
use dioxus::prelude::*;
use dioxus_signals::Signal;
use serde::de::DeserializeOwned;
Expand All @@ -10,20 +10,21 @@ use super::StorageEntryTrait;
/// A persistent storage hook that can be used to store data across application reloads.
///
/// Depending on the platform this uses either local storage or a file storage
#[allow(clippy::needless_return)]
pub fn use_persistent<
T: Serialize + DeserializeOwned + Default + Clone + Send + Sync + PartialEq + 'static,
>(
key: impl ToString,
init: impl FnOnce() -> T,
) -> Signal<T> {
use_hook(|| new_persistent(key, init))
let mut init = Some(init);
let storage = use_hook(|| new_persistent(key.to_string(), || init.take().unwrap()()));
use_hydrate_storage_hook::<SessionStorage, T>(storage, init);
storage
}

/// Creates a persistent storage signal that can be used to store data across application reloads.
///
/// Depending on the platform this uses either local storage or a file storage
#[allow(clippy::needless_return)]
pub fn new_persistent<
T: Serialize + DeserializeOwned + Default + Clone + Send + Sync + PartialEq + 'static,
>(
Expand All @@ -39,14 +40,16 @@ pub fn new_persistent<
/// The state will be the same for every call to this hook from the same line of code.
///
/// Depending on the platform this uses either local storage or a file storage
#[allow(clippy::needless_return)]
#[track_caller]
pub fn use_singleton_persistent<
T: Serialize + DeserializeOwned + Default + Clone + Send + Sync + PartialEq + 'static,
>(
init: impl FnOnce() -> T,
) -> Signal<T> {
use_hook(|| new_singleton_persistent(init))
let mut init = Some(init);
let signal = use_hook(|| new_singleton_persistent(|| init.take().unwrap()()));
use_hydrate_storage_hook::<SessionStorage, T>(signal, init);
signal
}

/// Create a persistent storage signal that can be used to store data across application reloads.
Expand Down

0 comments on commit 4045351

Please sign in to comment.