c7644ade69
@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.
268 lines
8.8 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|