import { computed, ref, shallowRef, toValue, watch } from 'vue'; import type { MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue'; import { isFunction } from '@robonen/stdlib'; import type { ConfigurableFlush, ConfigurableWindow } from '@/types'; import { defaultWindow } from '@/types'; import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; import { useEventListener } from '@/composables/browser/useEventListener'; import { guessSerializer, shallowMerge } from '../useStorage'; import type { StorageEventLike } from '../useStorage'; export interface StorageSerializerAsync { read: (raw: string) => T | Promise; write: (value: T) => string | Promise; } export interface StorageLikeAsync { getItem: (key: string) => string | null | Promise; setItem: (key: string, value: string) => void | Promise; removeItem: (key: string) => void | Promise; } export interface UseStorageAsyncOptions extends ConfigurableFlush, ConfigurableWindow, ConfigurableEventFilter { /** * Use shallowRef instead of ref for the internal state * @default true */ shallow?: Shallow; /** * Watch for deep changes * @default true */ deep?: boolean; /** * Listen to storage changes from other tabs/windows * @default true */ listenToStorageChanges?: boolean; /** * Write the default value to the storage when it does not exist * @default true */ writeDefaults?: boolean; /** * Custom serializer for reading/writing storage values */ serializer?: StorageSerializerAsync; /** * Merge the default value with the stored value * @default false */ mergeDefaults?: boolean | ((stored: T, defaults: T) => T); /** * Called once when the initial value has been loaded from storage */ onReady?: (value: T) => void; /** * Error handler for read/write failures */ onError?: (error: unknown) => void; /** * Wait for the component to be mounted before reading the storage * * Useful for SSR hydration to prevent mismatch * @default false */ initOnMounted?: boolean; } export interface UseStorageAsyncReturnBase { state: Shallow extends true ? ShallowRef : Ref>; isReady: Ref; } export type UseStorageAsyncReturn = & UseStorageAsyncReturnBase & PromiseLike>; /** * @name useStorageAsync * @category Storage * @description Reactive Storage binding with async support — creates a ref synced with an async storage backend * * @param {MaybeRefOrGetter} key The storage key (can be reactive) * @param {MaybeRefOrGetter} initialValue The initial/default value * @param {StorageLikeAsync} storage The async storage backend * @param {UseStorageAsyncOptions} [options={}] Options * @returns {UseStorageAsyncReturn} An object with state ref and isReady flag, also awaitable * * @example * const { state } = useStorageAsync('access-token', '', asyncStorage); * * @example * const { state, isReady } = await useStorageAsync('settings', { theme: 'dark' }, asyncStorage); * * @example * const { state } = useStorageAsync('key', 'default', asyncStorage, { * onReady: (value) => console.log('Loaded:', value), * }); * * @since 0.0.12 */ export function useStorageAsync(key: MaybeRefOrGetter, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; export function useStorageAsync(key: MaybeRefOrGetter, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; export function useStorageAsync(key: MaybeRefOrGetter, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; export function useStorageAsync(key: MaybeRefOrGetter, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; export function useStorageAsync(key: MaybeRefOrGetter, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; export function useStorageAsync( key: MaybeRefOrGetter, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options: UseStorageAsyncOptions = {}, ): UseStorageAsyncReturn { const { shallow = true, deep = true, flush = 'pre', writeDefaults = true, mergeDefaults = false, listenToStorageChanges = true, window = defaultWindow, eventFilter, initOnMounted = false, onReady, onError = console.error, // eslint-disable-line no-console } = options; const defaults = toValue(initialValue); const serializer = options.serializer ?? guessSerializer(defaults); const state = (shallow ? shallowRef : ref)(defaults) as Shallow extends true ? ShallowRef : Ref>; const isReady = ref(false); const keyComputed = computed(() => toValue(key)); async function read(event?: StorageEventLike): Promise { try { const rawValue = event ? event.newValue : await storage.getItem(keyComputed.value); if (rawValue === undefined || rawValue === null) { if (writeDefaults && defaults !== undefined && defaults !== null) { try { await storage.setItem(keyComputed.value, await serializer.write(defaults)); } catch (e) { onError(e); } } return defaults; } if (!event && mergeDefaults) { const value: T = await serializer.read(rawValue) as T; return isFunction(mergeDefaults) ? mergeDefaults(value, defaults) : shallowMerge(value, defaults); } return await serializer.read(rawValue) as T; } catch (e) { onError(e); return defaults; } } async function write(value: T) { try { if (value === undefined || value === null) { await storage.removeItem(keyComputed.value); } else { const raw = await serializer.write(value); await storage.setItem(keyComputed.value, raw); } } catch (e) { onError(e); } } // Apply event filter if provided const writeWithFilter: (value: T) => void = eventFilter ? (value: T) => (eventFilter as EventFilter)(() => write(value)) : (value: T) => { write(value); }; let stopWatch: (() => void) | null = null; let stopKeyWatch: (() => void) | null = null; tryOnScopeDispose(() => { stopWatch?.(); stopKeyWatch?.(); }); // Event listeners for cross-tab synchronization let firstMounted = false; if (window && listenToStorageChanges) { useEventListener(window, 'storage', (ev: StorageEvent) => { if (initOnMounted && !firstMounted) return; if (ev.key !== keyComputed.value) return; if (ev.storageArea !== storage) return; Promise.resolve().then(() => read(ev)).then((value) => { (state as Ref).value = value; }); }, { passive: true }); } const shell: UseStorageAsyncReturnBase = { state, isReady, }; function performInit() { return read().then((value) => { (state as Ref).value = value; isReady.value = true; onReady?.(value); // Set up watcher AFTER initial state is set — avoids write-back on init const stop = watch(state, (newValue) => { writeWithFilter(newValue as T); }, { flush, deep }); stopWatch = stop; // Watch for key changes stopKeyWatch = watch(keyComputed, () => { read().then((v) => { (state as Ref).value = v; }); }, { flush }); return shell; }); } let readyPromise: Promise>; if (initOnMounted) { readyPromise = new Promise>((resolve) => { tryOnMounted(() => { firstMounted = true; performInit().then(resolve); }); }); } else { readyPromise = performInit(); } return { ...shell, // eslint-disable-next-line unicorn/no-thenable then(onFulfilled, onRejected) { return readyPromise.then(onFulfilled, onRejected); }, }; }