diff --git a/CHANGELOG.md b/CHANGELOG.md index de31d32..aa8e844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changes 🔥 + +- `ElementMaybeSignal` is now implemented for `websys::HtmlElement` (thanks to @blorbb). +- `UseStorageOptions` now has `delay_during_hydration` which has to be used when you conditionally show parts of + the DOM controlled by a value from storage. This leads to hydration errors which can be fixed by setting this new + option to `true`. + +### Breaking Changes 🛠 + +- `UseStorageOptions` no longer accepts a `codec` value because this is already provided as a generic parameter to + the respective function calls. + ## [0.10.10] - 2024-05-10 ### Change 🔥 diff --git a/examples/ssr/src/app.rs b/examples/ssr/src/app.rs index 9b4e6f0..80b8695 100644 --- a/examples/ssr/src/app.rs +++ b/examples/ssr/src/app.rs @@ -3,7 +3,7 @@ use leptos::ev::{keypress, KeyboardEvent}; use leptos::*; use leptos_meta::*; use leptos_router::*; -use leptos_use::storage::use_local_storage; +use leptos_use::storage::{use_local_storage, use_local_storage_with_options, UseStorageOptions}; use leptos_use::utils::FromToStringCodec; use leptos_use::{ use_color_mode_with_options, use_cookie_with_options, use_debounce_fn, use_event_listener, @@ -40,7 +40,10 @@ pub fn App() -> impl IntoView { #[component] fn HomePage() -> impl IntoView { // Creates a reactive value to update the button - let (count, set_count, _) = use_local_storage::("count-state"); + let (count, set_count, _) = use_local_storage_with_options::( + "count-state", + UseStorageOptions::default().delay_during_hydration(true), + ); let on_click = move |_| set_count.update(|count| *count += 1); let nf = use_intl_number_format( @@ -96,6 +99,10 @@ fn HomePage() -> impl IntoView {

Dark preferred: {is_dark_preferred}

Test cookie: {move || test_cookie().unwrap_or("".to_string())}

+ + 0 }> +
Greater than 0
+
} } diff --git a/src/storage/use_local_storage.rs b/src/storage/use_local_storage.rs index 2e9d375..a392406 100644 --- a/src/storage/use_local_storage.rs +++ b/src/storage/use_local_storage.rs @@ -17,21 +17,21 @@ where T: Clone + Default + PartialEq, C: StringCodec + Default, { - use_storage_with_options( + use_storage_with_options::( StorageType::Local, key, - UseStorageOptions::::default(), + UseStorageOptions::::default(), ) } /// Accepts [`UseStorageOptions`]. See [`use_local_storage`] for details. pub fn use_local_storage_with_options( key: impl AsRef, - options: UseStorageOptions, + options: UseStorageOptions, ) -> (Signal, WriteSignal, impl Fn() + Clone) where T: Clone + PartialEq, C: StringCodec + Default, { - use_storage_with_options(StorageType::Local, key, options) + use_storage_with_options::(StorageType::Local, key, options) } diff --git a/src/storage/use_session_storage.rs b/src/storage/use_session_storage.rs index 55c963b..3edf36e 100644 --- a/src/storage/use_session_storage.rs +++ b/src/storage/use_session_storage.rs @@ -17,21 +17,21 @@ where T: Clone + Default + PartialEq, C: StringCodec + Default, { - use_storage_with_options( + use_storage_with_options::( StorageType::Session, key, - UseStorageOptions::::default(), + UseStorageOptions::::default(), ) } /// Accepts [`UseStorageOptions`]. See [`use_session_storage`] for details. pub fn use_session_storage_with_options( key: impl AsRef, - options: UseStorageOptions, + options: UseStorageOptions, ) -> (Signal, WriteSignal, impl Fn() + Clone) where T: Clone + PartialEq, C: StringCodec + Default, { - use_storage_with_options(StorageType::Session, key, options) + use_storage_with_options::(StorageType::Session, key, options) } diff --git a/src/storage/use_storage.rs b/src/storage/use_storage.rs index 20f14d5..39aa5ea 100644 --- a/src/storage/use_storage.rs +++ b/src/storage/use_storage.rs @@ -2,7 +2,8 @@ use crate::{ core::{MaybeRwSignal, StorageType}, utils::{FilterOptions, StringCodec}, }; -use cfg_if::cfg_if; +use default_struct_builder::DefaultBuilder; +use leptos::leptos_dom::HydrationCtx; use leptos::*; use std::rc::Rc; use thiserror::Error; @@ -39,6 +40,7 @@ const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage"; /// # use serde::{Deserialize, Serialize}; /// # use leptos_use::utils::{FromToStringCodec, JsonCodec, ProstCodec}; /// # +/// # #[component] /// # pub fn Demo() -> impl IntoView { /// // Binds a struct: /// let (state, set_state, _) = use_local_storage::("my-state"); @@ -86,6 +88,64 @@ const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage"; /// ## Server-Side Rendering /// /// On the server the returned signals will just read/manipulate the `initial_value` without persistence. +/// +/// ### Hydration bugs and `use_cookie` +/// +/// If you use a value from storage to control conditional rendering you might run into issues with +/// hydration. +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::storage::use_local_storage; +/// # use leptos_use::utils::FromToStringCodec; +/// # +/// # #[component] +/// # pub fn Example() -> impl IntoView { +/// let (flag, set_flag, _) = use_session_storage::("my-flag"); +/// +/// view! { +/// +///
Some conditional content
+///
+/// } +/// # } +/// ``` +/// +/// You can see hydration warnings in the browser console and the conditional parts of +/// the app might never show up when rendered on the server and then hydrated in the browser. The +/// reason for this is that the server has no access to storage and therefore will always use +/// `initial_value` as described above. So on the server your app is always rendered as if +/// the value from storage was `initial_value`. Then in the browser the actual stored value is used +/// which might be different, hence during hydration the DOM looks different from the one rendered +/// on the server which produces the hydration warnings. +/// +/// The recommended way to avoid this is to use `use_cookie` instead because values stored in cookies +/// are available on the server as well as in the browser. +/// +/// If you still want to use storage instead of cookies you can use the `delay_during_hydration` +/// option that will use the `initial_value` during hydration just as on the server and delay loading +/// the value from storage by an animation frame. This gets rid of the hydration warnings and makes +/// the app correctly render things. Some flickering might be unavoidable though. +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::storage::{use_local_storage_with_options, UseStorageOptions}; +/// # use leptos_use::utils::FromToStringCodec; +/// # +/// # #[component] +/// # pub fn Example() -> impl IntoView { +/// let (flag, set_flag, _) = use_session_storage_with_options::( +/// "my-flag", +/// UseStorageOptions::default().delay_during_hydration(true), +/// ); +/// +/// view! { +/// +///
Some conditional content
+///
+/// } +/// # } +/// ``` #[inline(always)] pub fn use_storage( storage_type: StorageType, @@ -102,24 +162,27 @@ where pub fn use_storage_with_options( storage_type: StorageType, key: impl AsRef, - options: UseStorageOptions, + options: UseStorageOptions, ) -> (Signal, WriteSignal, impl Fn() + Clone) where T: Clone + PartialEq, C: StringCodec + Default, { let UseStorageOptions { - codec, on_error, listen_to_storage_changes, initial_value, filter, + delay_during_hydration, } = options; + let codec = C::default(); + let (data, set_data) = initial_value.into_signal(); let default = data.get_untracked(); - cfg_if! { if #[cfg(feature = "ssr")] { + #[cfg(feature = "ssr")] + { let _ = codec; let _ = on_error; let _ = listen_to_storage_changes; @@ -128,13 +191,15 @@ where let _ = key; let _ = INTERNAL_STORAGE_EVENT; - let remove = move || { set_data.set(default.clone()); }; (data.into(), set_data, remove) - } else { + } + + #[cfg(not(feature = "ssr"))] + { use crate::{use_event_listener, use_window, watch_with_options, WatchOptions}; // Get storage API @@ -176,6 +241,7 @@ where let codec = codec.to_owned(); let key = key.as_ref().to_owned(); let on_error = on_error.to_owned(); + move || { let fetched = storage .to_owned() @@ -212,7 +278,11 @@ where }; // Fetch initial value - fetch_from_storage(); + if delay_during_hydration && HydrationCtx::is_hydrating() { + request_animation_frame(fetch_from_storage.clone()); + } else { + fetch_from_storage(); + } // Fires when storage needs to be fetched let notify = create_trigger(); @@ -306,7 +376,7 @@ where }; (data, set_data, remove) - }} + } } /// Session handling errors returned by [`use_storage_with_options`]. @@ -329,17 +399,26 @@ pub enum UseStorageError { } /// Options for use with [`use_local_storage_with_options`], [`use_session_storage_with_options`] and [`use_storage_with_options`]. -pub struct UseStorageOptions> { - // Translates to and from UTF-16 strings - codec: C, +#[derive(DefaultBuilder)] +pub struct UseStorageOptions +where + T: 'static, +{ // Callback for when an error occurs - on_error: Rc)>, + #[builder(skip)] + on_error: Rc)>, // Whether to continuously listen to changes from browser storage listen_to_storage_changes: bool, // Initial value to use when the storage key is not set + #[builder(skip)] initial_value: MaybeRwSignal, // Debounce or throttle the writing to storage whenever the value changes + #[builder(into)] filter: FilterOptions, + /// Delays the reading of the value from storage by one animation frame during hydration. + /// This ensures that during hydration the value is the initial value just like it is on the server + /// which helps prevent hydration errors. Defaults to `false`. + delay_during_hydration: bool, } /// Calls the on_error callback with the given error. Removes the error from the Result to avoid double error handling. @@ -351,43 +430,27 @@ fn handle_error( result.map_err(|err| (on_error)(err)) } -impl + Default> Default for UseStorageOptions { +impl Default for UseStorageOptions { fn default() -> Self { Self { - codec: C::default(), on_error: Rc::new(|_err| ()), listen_to_storage_changes: true, initial_value: MaybeRwSignal::default(), filter: FilterOptions::default(), + delay_during_hydration: false, } } } -impl> UseStorageOptions { - /// Sets the codec to use for encoding and decoding values to and from UTF-16 strings. - pub fn codec(self, codec: impl Into) -> Self { - Self { - codec: codec.into(), - ..self - } - } - +impl UseStorageOptions { /// Optional callback whenever an error occurs. - pub fn on_error(self, on_error: impl Fn(UseStorageError) + 'static) -> Self { + pub fn on_error(self, on_error: impl Fn(UseStorageError) + 'static) -> Self { Self { on_error: Rc::new(on_error), ..self } } - /// Listen to changes to this storage key from browser and page events. Defaults to true. - pub fn listen_to_storage_changes(self, listen_to_storage_changes: bool) -> Self { - Self { - listen_to_storage_changes, - ..self - } - } - /// Initial value to use when the storage key is not set. Note that this value is read once on creation of the storage hook and not updated again. Accepts a signal and defaults to `T::default()`. pub fn initial_value(self, initial: impl Into>) -> Self { Self { @@ -395,12 +458,4 @@ impl> UseStorageOptions { ..self } } - - /// Debounce or throttle the writing to storage whenever the value changes. - pub fn filter(self, filter: impl Into) -> Self { - Self { - filter: filter.into(), - ..self - } - } }