leptos_use/storage/use_storage.rs
1use crate::{core::MaybeRwSignal, storage::StorageType, utils::FilterOptions};
2use codee::{CodecError, Decoder, Encoder};
3use default_struct_builder::DefaultBuilder;
4use leptos::prelude::*;
5use leptos::reactive::wrappers::read::Signal;
6use std::sync::Arc;
7use thiserror::Error;
8use wasm_bindgen::JsValue;
9
10const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage";
11
12/// Reactive [Storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage).
13///
14/// The function returns a triplet `(read_signal, write_signal, delete_from_storage_fn)`.
15///
16/// ## Demo
17///
18/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_storage)
19///
20/// ## Usage
21///
22/// Pass a [`StorageType`] to determine the kind of key-value browser storage to use.
23/// The specified key is where data is stored. All values are stored as UTF-16 strings which
24/// is then encoded and decoded via the given `*Codec`. This value is synced with other calls using
25/// the same key on the same page and across tabs for local storage.
26/// See [`UseStorageOptions`] to see how behavior can be further customised.
27///
28/// Values are (en)decoded via the given codec. You can use any of the string codecs or a
29/// binary codec wrapped in `Base64`.
30///
31/// > Please check [the codec chapter](https://leptos-use.rs/codecs.html) to see what codecs are
32/// > available and what feature flags they require.
33///
34/// ## Example
35///
36/// ```
37/// # use leptos::prelude::*;
38/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage};
39/// # use serde::{Deserialize, Serialize};
40/// # use codee::string::{FromToStringCodec, JsonSerdeCodec, Base64};
41/// # use codee::binary::ProstCodec;
42/// #
43/// # #[component]
44/// # pub fn Demo() -> impl IntoView {
45/// // Binds a struct:
46/// let (state, set_state, _) = use_local_storage::<MyState, JsonSerdeCodec>("my-state");
47///
48/// // Binds a bool, stored as a string:
49/// let (flag, set_flag, remove_flag) = use_session_storage::<bool, FromToStringCodec>("my-flag");
50///
51/// // Binds a number, stored as a string:
52/// let (count, set_count, _) = use_session_storage::<i32, FromToStringCodec>("my-count");
53/// // Binds a number, stored in JSON:
54/// let (count, set_count, _) = use_session_storage::<i32, JsonSerdeCodec>("my-count-kept-in-js");
55///
56/// // Bind string with SessionStorage stored in ProtoBuf format:
57/// let (id, set_id, _) = use_storage::<String, Base64<ProstCodec>>(
58/// StorageType::Session,
59/// "my-id",
60/// );
61/// # view! { }
62/// # }
63///
64/// // Data stored in JSON must implement Serialize, Deserialize.
65/// // And you have to add the feature "serde" to your project's Cargo.toml
66/// #[derive(Serialize, Deserialize, Clone, PartialEq)]
67/// pub struct MyState {
68/// pub hello: String,
69/// pub greeting: String,
70/// }
71///
72/// // Default can be used to implement initial or deleted values.
73/// // You can also use a signal via UseStorageOptions::default_value`
74/// impl Default for MyState {
75/// fn default() -> Self {
76/// Self {
77/// hello: "hi".to_string(),
78/// greeting: "Hello".to_string()
79/// }
80/// }
81/// }
82/// ```
83///
84/// ## Server-Side Rendering
85///
86/// > Make sure you follow the [instructions in Server-Side Rendering](https://leptos-use.rs/server_side_rendering.html).
87///
88/// On the server the returned signals will just read/manipulate the `initial_value` without persistence.
89///
90/// ### Hydration bugs and `use_cookie`
91///
92/// If you use a value from storage to control conditional rendering you might run into issues with
93/// hydration.
94///
95/// ```
96/// # use leptos::prelude::*;
97/// # use leptos_use::storage::use_session_storage;
98/// # use codee::string::FromToStringCodec;
99/// #
100/// # #[component]
101/// # pub fn Example() -> impl IntoView {
102/// let (flag, set_flag, _) = use_session_storage::<bool, FromToStringCodec>("my-flag");
103///
104/// view! {
105/// <Show when=move || flag.get()>
106/// <div>Some conditional content</div>
107/// </Show>
108/// }
109/// # }
110/// ```
111///
112/// You can see hydration warnings in the browser console and the conditional parts of
113/// the app might never show up when rendered on the server and then hydrated in the browser. The
114/// reason for this is that the server has no access to storage and therefore will always use
115/// `initial_value` as described above. So on the server your app is always rendered as if
116/// the value from storage was `initial_value`. Then in the browser the actual stored value is used
117/// which might be different, hence during hydration the DOM looks different from the one rendered
118/// on the server which produces the hydration warnings.
119///
120/// The recommended way to avoid this is to use `use_cookie` instead because values stored in cookies
121/// are available on the server as well as in the browser.
122///
123/// If you still want to use storage instead of cookies you can use the `delay_during_hydration`
124/// option that will use the `initial_value` during hydration just as on the server and delay loading
125/// the value from storage by an animation frame. This gets rid of the hydration warnings and makes
126/// the app correctly render things. Some flickering might be unavoidable though.
127///
128/// ```
129/// # use leptos::prelude::*;
130/// # use leptos_use::storage::{use_local_storage_with_options, UseStorageOptions};
131/// # use codee::string::FromToStringCodec;
132/// #
133/// # #[component]
134/// # pub fn Example() -> impl IntoView {
135/// let (flag, set_flag, _) = use_local_storage_with_options::<bool, FromToStringCodec>(
136/// "my-flag",
137/// UseStorageOptions::default().delay_during_hydration(true),
138/// );
139///
140/// view! {
141/// <Show when=move || flag.get()>
142/// <div>Some conditional content</div>
143/// </Show>
144/// }
145/// # }
146/// ```
147#[inline(always)]
148pub fn use_storage<T, C>(
149 storage_type: StorageType,
150 key: impl Into<Signal<String>>,
151) -> (Signal<T>, WriteSignal<T>, impl Fn() + Clone + Send + Sync)
152where
153 T: Default + Clone + PartialEq + Send + Sync + 'static,
154 C: Encoder<T, Encoded = String> + Decoder<T, Encoded = str>,
155{
156 use_storage_with_options::<T, C>(storage_type, key, UseStorageOptions::default())
157}
158
159/// Version of [`use_storage`] that accepts [`UseStorageOptions`].
160pub fn use_storage_with_options<T, C>(
161 storage_type: StorageType,
162 key: impl Into<Signal<String>>,
163 options: UseStorageOptions<T, <C as Encoder<T>>::Error, <C as Decoder<T>>::Error>,
164) -> (Signal<T>, WriteSignal<T>, impl Fn() + Clone + Send + Sync)
165where
166 T: Clone + PartialEq + Send + Sync,
167 C: Encoder<T, Encoded = String> + Decoder<T, Encoded = str>,
168{
169 let UseStorageOptions {
170 on_error,
171 listen_to_storage_changes,
172 initial_value,
173 filter,
174 delay_during_hydration,
175 } = options;
176
177 let (data, set_data) = initial_value.into_signal();
178 let default = data.get_untracked();
179
180 #[cfg(feature = "ssr")]
181 {
182 let _ = on_error;
183 let _ = listen_to_storage_changes;
184 let _ = filter;
185 let _ = delay_during_hydration;
186 let _ = storage_type;
187 let _ = key;
188 let _ = INTERNAL_STORAGE_EVENT;
189
190 let remove = move || {
191 set_data.set(default.clone());
192 };
193
194 (data, set_data, remove)
195 }
196
197 #[cfg(not(feature = "ssr"))]
198 {
199 use crate::{
200 UseEventListenerOptions, WatchOptions, sendwrap_fn, use_event_listener_with_options,
201 use_window, watch_with_options,
202 };
203 use send_wrapper::SendWrapper;
204
205 let delaying = StoredValue::new(
206 delay_during_hydration
207 && Owner::current_shared_context()
208 .map(|sc| sc.during_hydration())
209 .unwrap_or_default(),
210 );
211
212 let key = key.into();
213
214 // Get storage API
215 let storage = storage_type
216 .into_storage()
217 .map_err(UseStorageError::StorageNotAvailable)
218 .and_then(|s| s.ok_or(UseStorageError::StorageReturnedNone));
219 let storage = handle_error(&on_error, storage);
220
221 // Schedules a storage event microtask. Uses a queue to avoid re-entering the runtime
222 let dispatch_storage_event = {
223 let on_error = on_error.to_owned();
224
225 move || {
226 let on_error = on_error.to_owned();
227
228 queue_microtask(move || {
229 // TODO : better to use a BroadcastChannel (use_broadcast_channel)?
230 // Note: we cannot construct a full StorageEvent so we _must_ rely on a custom event
231 let custom = web_sys::CustomEventInit::new();
232 custom.set_detail(&JsValue::from_str(&key.get_untracked()));
233 let result = web_sys::CustomEvent::new_with_event_init_dict(
234 INTERNAL_STORAGE_EVENT,
235 &custom,
236 )
237 .and_then(|event| window().dispatch_event(&event))
238 .map_err(UseStorageError::NotifyItemChangedFailed);
239 let _ = handle_error(&on_error, result);
240 })
241 }
242 };
243
244 let read_from_storage = {
245 let storage = storage.to_owned();
246 let on_error = on_error.to_owned();
247
248 move || {
249 storage
250 .to_owned()
251 .and_then(|storage| {
252 // Get directly from storage
253 let result = storage
254 .get_item(&key.get_untracked())
255 .map_err(UseStorageError::GetItemFailed);
256 handle_error(&on_error, result)
257 })
258 .unwrap_or_default() // Drop handled Err(())
259 .as_ref()
260 .map(|encoded| {
261 // Decode item
262 let result = C::decode(encoded)
263 .map_err(|e| UseStorageError::ItemCodecError(CodecError::Decode(e)));
264 handle_error(&on_error, result)
265 })
266 .transpose()
267 .unwrap_or_default() // Drop handled Err(())
268 }
269 };
270
271 // Fetches direct from browser storage and fills set_data if changed (memo)
272 let fetch_from_storage = {
273 let default = default.clone();
274 let read_from_storage = read_from_storage.clone();
275
276 SendWrapper::new(move || {
277 let fetched = read_from_storage();
278
279 match fetched {
280 Some(value) => {
281 // Replace data if changed
282 if value != data.get_untracked() {
283 set_data.set(value)
284 }
285 }
286
287 // Revert to default
288 None => set_data.set(default.clone()),
289 };
290 })
291 };
292
293 // Fires when storage needs to be fetched
294 let notify = ArcTrigger::new();
295
296 // Refetch from storage. Keeps track of how many times we've been notified. Does not increment for calls to set_data
297 let notify_id = Memo::<usize>::new({
298 let notify = notify.clone();
299 let fetch_from_storage = fetch_from_storage.clone();
300
301 move |prev| {
302 notify.track();
303 match prev {
304 None => 1, // Avoid async fetch of initial value
305 Some(prev) => {
306 fetch_from_storage();
307 prev + 1
308 }
309 }
310 }
311 });
312
313 // Notify id as of hook setup. The watch below runs its first tick deferred, so a
314 // notification can race in before then. Comparing against this baseline lets that
315 // first tick recognize an external change instead of assuming there was none.
316 let initial_notify_id = notify_id.get_untracked();
317
318 // Set item on internal (non-event) page changes to the data signal
319 {
320 let storage = storage.to_owned();
321 let on_error = on_error.to_owned();
322 let dispatch_storage_event = dispatch_storage_event.to_owned();
323 let default = default.clone();
324
325 let _ = watch_with_options(
326 move || (notify_id.get(), data.get()),
327 move |(id, value), prev, _| {
328 // Skip setting storage on changes from external events. The ID will change on external events.
329 let change_from_external_event = prev
330 .map(|(prev_id, _)| *prev_id != *id)
331 .unwrap_or_else(|| *id != initial_notify_id);
332
333 if change_from_external_event {
334 return;
335 }
336
337 // Don't write default value if store is empty or still hydrating
338 if value == &default && (read_from_storage().is_none() || delaying.get_value())
339 {
340 return;
341 }
342
343 if let Ok(storage) = &storage {
344 // Encode value
345 let result = C::encode(value)
346 .map_err(|e| UseStorageError::ItemCodecError(CodecError::Encode(e)))
347 .and_then(|enc_value| {
348 // Set storage -- sends a global event
349 storage
350 .set_item(&key.get_untracked(), &enc_value)
351 .map_err(UseStorageError::SetItemFailed)
352 });
353 let result = handle_error(&on_error, result);
354 // Send internal storage event
355 if result.is_ok() {
356 dispatch_storage_event();
357 }
358 }
359 },
360 WatchOptions::default().filter(filter).immediate(true),
361 );
362 }
363
364 if delaying.get_value() {
365 request_animation_frame({
366 let fetch_from_storage = fetch_from_storage.clone();
367 move || {
368 delaying.set_value(false);
369 fetch_from_storage()
370 }
371 });
372 } else {
373 fetch_from_storage();
374 }
375
376 if listen_to_storage_changes {
377 let listener_options = UseEventListenerOptions::default().passive(true);
378 // Listen to global storage events
379 let _ = use_event_listener_with_options(
380 use_window(),
381 leptos::ev::storage,
382 {
383 let notify = notify.clone();
384
385 move |ev| {
386 let ev_key = ev.key();
387 // Key matches or all keys deleted (None)
388 if ev_key == Some(key.get_untracked()) || ev_key.is_none() {
389 notify.notify()
390 }
391 }
392 },
393 listener_options,
394 );
395 // Listen to internal storage events
396 let _ = use_event_listener_with_options(
397 use_window(),
398 leptos::ev::Custom::new(INTERNAL_STORAGE_EVENT),
399 {
400 let notify = notify.clone();
401
402 move |ev: web_sys::CustomEvent| {
403 if Some(key.get_untracked()) == ev.detail().as_string() {
404 notify.notify()
405 }
406 }
407 },
408 listener_options,
409 );
410 };
411
412 Effect::watch(
413 move || key.get(),
414 {
415 let notify = notify.clone();
416 move |_, _, _| notify.notify()
417 },
418 false,
419 );
420
421 // Remove from storage fn
422 let remove = {
423 sendwrap_fn!(move || {
424 // Reset the signal right away just like on the server. The
425 // notification below only refetches on the next reactive tick.
426 set_data.set(default.clone());
427
428 let _ = storage.as_ref().map(|storage| {
429 // Delete directly from storage
430 let result = storage
431 .remove_item(&key.get_untracked())
432 .map_err(UseStorageError::RemoveItemFailed);
433 let _ = handle_error(&on_error, result);
434 notify.notify();
435 dispatch_storage_event();
436 });
437 })
438 };
439
440 (data, set_data, remove)
441 }
442}
443
444/// Session handling errors returned by [`use_storage_with_options`].
445#[derive(Error, Debug)]
446pub enum UseStorageError<E, D> {
447 #[error("storage not available")]
448 StorageNotAvailable(JsValue),
449 #[error("storage not returned from window")]
450 StorageReturnedNone,
451 #[error("failed to get item")]
452 GetItemFailed(JsValue),
453 #[error("failed to set item")]
454 SetItemFailed(JsValue),
455 #[error("failed to delete item")]
456 RemoveItemFailed(JsValue),
457 #[error("failed to notify item changed")]
458 NotifyItemChangedFailed(JsValue),
459 #[error("failed to encode / decode item value")]
460 ItemCodecError(CodecError<E, D>),
461}
462
463/// Options for use with [`fn@crate::storage::use_local_storage_with_options`], [`fn@crate::storage::use_session_storage_with_options`] and [`use_storage_with_options`].
464#[derive(DefaultBuilder)]
465pub struct UseStorageOptions<T, E, D>
466where
467 T: Send + Sync + 'static,
468{
469 // Callback for when an error occurs
470 #[builder(skip)]
471 on_error: Arc<dyn Fn(UseStorageError<E, D>) + Send + Sync>,
472 // Whether to continuously listen to changes from browser storage
473 listen_to_storage_changes: bool,
474 // Initial value to use when the storage key is not set
475 #[builder(skip)]
476 initial_value: MaybeRwSignal<T>,
477 // Debounce or throttle the writing to storage whenever the value changes
478 #[builder(into)]
479 filter: FilterOptions,
480 /// Delays the reading of the value from storage by one animation frame during hydration.
481 /// This ensures that during hydration the value is the initial value just like it is on the server
482 /// which helps prevent hydration errors. Defaults to `false`.
483 delay_during_hydration: bool,
484}
485
486/// Calls the on_error callback with the given error. Removes the error from the Result to avoid double error handling.
487#[cfg(not(feature = "ssr"))]
488fn handle_error<T, E, D>(
489 on_error: &Arc<dyn Fn(UseStorageError<E, D>) + Send + Sync>,
490 result: Result<T, UseStorageError<E, D>>,
491) -> Result<T, ()> {
492 result.map_err(|err| (on_error)(err))
493}
494
495impl<T: Default, E, D> Default for UseStorageOptions<T, E, D>
496where
497 T: Send + Sync + 'static,
498{
499 fn default() -> Self {
500 Self {
501 on_error: Arc::new(|_err| ()),
502 listen_to_storage_changes: true,
503 initial_value: MaybeRwSignal::default(),
504 filter: FilterOptions::default(),
505 delay_during_hydration: false,
506 }
507 }
508}
509
510impl<T: Default, E, D> UseStorageOptions<T, E, D>
511where
512 T: Send + Sync + 'static,
513{
514 /// Optional callback whenever an error occurs.
515 pub fn on_error(
516 self,
517 on_error: impl Fn(UseStorageError<E, D>) + Send + Sync + 'static,
518 ) -> Self {
519 Self {
520 on_error: Arc::new(on_error),
521 ..self
522 }
523 }
524
525 /// 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()`.
526 pub fn initial_value(self, initial: impl Into<MaybeRwSignal<T>>) -> Self {
527 Self {
528 initial_value: initial.into(),
529 ..self
530 }
531 }
532}