Files
tools/vue/toolkit/src/composables/storage/useStorageAsync/index.ts
T
robonen c7644ade69 fix(vue): eslint/tsconfig migration + resolve type errors
@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig;
fix composable + test type errors (writable computed returns, null guards,
overload-compatible signatures, typed test helpers) — all type-level.
2026-06-07 16:29:39 +07:00

268 lines
8.8 KiB
TypeScript

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<T> {
read: (raw: string) => T | Promise<T>;
write: (value: T) => string | Promise<string>;
}
export interface StorageLikeAsync {
getItem: (key: string) => string | null | Promise<string | null>;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
}
export interface UseStorageAsyncOptions<T, Shallow extends boolean = true> 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<T>;
/**
* 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<T, Shallow extends boolean> {
state: Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
isReady: Ref<boolean>;
}
export type UseStorageAsyncReturn<T, Shallow extends boolean>
= & UseStorageAsyncReturnBase<T, Shallow>
& PromiseLike<UseStorageAsyncReturnBase<T, Shallow>>;
/**
* @name useStorageAsync
* @category Storage
* @description Reactive Storage binding with async support — creates a ref synced with an async storage backend
*
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {StorageLikeAsync} storage The async storage backend
* @param {UseStorageAsyncOptions<T>} [options={}] Options
* @returns {UseStorageAsyncReturn<T, Shallow>} 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<T extends string, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T extends number, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T extends boolean, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T = unknown, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T, Shallow extends boolean = true>(
key: MaybeRefOrGetter<string>,
initialValue: MaybeRefOrGetter<T>,
storage: StorageLikeAsync,
options: UseStorageAsyncOptions<T, Shallow> = {},
): UseStorageAsyncReturn<T, Shallow> {
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<T> : Ref<UnwrapRef<T>>;
const isReady = ref(false);
const keyComputed = computed<string>(() => toValue(key));
async function read(event?: StorageEventLike): Promise<T> {
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<T, Shallow> = {
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<UseStorageAsyncReturnBase<T, Shallow>>;
if (initOnMounted) {
readyPromise = new Promise<UseStorageAsyncReturnBase<T, Shallow>>((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);
},
};
}