feat(monorepo): migrate vue packages and apply oxlint refactors

This commit is contained in:
2026-03-07 18:07:22 +07:00
parent abd6605db3
commit 41d5e18f6b
286 changed files with 10295 additions and 5028 deletions
@@ -0,0 +1,4 @@
export * from './useLocalStorage';
export * from './useSessionStorage';
export * from './useStorage';
export * from './useStorageAsync';
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { nextTick } from 'vue';
import { useLocalStorage } from '.';
describe(useLocalStorage, () => {
beforeEach(() => {
localStorage.clear();
});
it('stores and reads a string via localStorage', async () => {
const state = useLocalStorage<string>('ls-string', 'hello');
expect(state.value).toBe('hello');
expect(localStorage.getItem('ls-string')).toBe('hello');
state.value = 'world';
await nextTick();
expect(localStorage.getItem('ls-string')).toBe('world');
});
it('stores and reads a number', async () => {
const state = useLocalStorage<number>('ls-number', 42);
expect(state.value).toBe(42);
state.value = 100;
await nextTick();
expect(localStorage.getItem('ls-number')).toBe('100');
});
it('stores and reads an object', async () => {
const state = useLocalStorage('ls-obj', { a: 1 });
expect(state.value).toEqual({ a: 1 });
state.value = { a: 2 };
await nextTick();
expect(JSON.parse(localStorage.getItem('ls-obj')!)).toEqual({ a: 2 });
});
it('reads existing value from localStorage on init', () => {
localStorage.setItem('ls-existing', '"stored"');
const state = useLocalStorage('ls-existing', 'default', {
serializer: { read: v => JSON.parse(v), write: v => JSON.stringify(v) },
});
expect(state.value).toBe('stored');
});
it('removes from localStorage when set to null', async () => {
const state = useLocalStorage<string | null>('ls-null', 'value');
expect(localStorage.getItem('ls-null')).toBe('value');
state.value = null;
await nextTick();
expect(localStorage.getItem('ls-null')).toBeNull();
});
it('passes options through to useStorage', () => {
const state = useLocalStorage<string>('ls-no-write', 'default', {
writeDefaults: false,
});
expect(state.value).toBe('default');
expect(localStorage.getItem('ls-no-write')).toBeNull();
});
});
@@ -0,0 +1,45 @@
import { ref, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import type { RemovableRef } from '@/types';
import { defaultWindow } from '@/types';
import { useStorage } from '../useStorage';
import type { UseStorageOptions } from '../useStorage';
/**
* @name useLocalStorage
* @category Storage
* @description Reactive localStorage binding — creates a ref synced with `window.localStorage`
*
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {UseStorageOptions<T>} [options={}] Options
* @returns {RemovableRef<T>} A reactive ref synced with localStorage
*
* @example
* const count = useLocalStorage('my-count', 0);
*
* @example
* const state = useLocalStorage('my-state', { hello: 'world' });
*
* @since 0.0.12
*/
export function useLocalStorage<T extends string>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T extends number>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T extends boolean>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T = unknown>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T>(
key: MaybeRefOrGetter<string>,
initialValue: MaybeRefOrGetter<T>,
options: UseStorageOptions<T> = {},
): RemovableRef<T> {
const window = options.window ?? defaultWindow;
const storage = window?.localStorage;
if (!storage) {
// SSR / non-browser environment: return an in-memory ref
return (options.shallow !== false ? shallowRef : ref)(toValue(initialValue)) as RemovableRef<T>;
}
return useStorage(key, initialValue, storage, { ...options, window });
}
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { nextTick } from 'vue';
import { useSessionStorage } from '.';
describe(useSessionStorage, () => {
beforeEach(() => {
sessionStorage.clear();
});
it('stores and reads a string via sessionStorage', async () => {
const state = useSessionStorage<string>('ss-string', 'hello');
expect(state.value).toBe('hello');
expect(sessionStorage.getItem('ss-string')).toBe('hello');
state.value = 'world';
await nextTick();
expect(sessionStorage.getItem('ss-string')).toBe('world');
});
it('stores and reads a number', async () => {
const state = useSessionStorage<number>('ss-number', 42);
expect(state.value).toBe(42);
state.value = 100;
await nextTick();
expect(sessionStorage.getItem('ss-number')).toBe('100');
});
it('stores and reads an object', async () => {
const state = useSessionStorage('ss-obj', { a: 1 });
expect(state.value).toEqual({ a: 1 });
state.value = { a: 2 };
await nextTick();
expect(JSON.parse(sessionStorage.getItem('ss-obj')!)).toEqual({ a: 2 });
});
it('reads existing value from sessionStorage on init', () => {
sessionStorage.setItem('ss-existing', '"stored"');
const state = useSessionStorage('ss-existing', 'default', {
serializer: { read: v => JSON.parse(v), write: v => JSON.stringify(v) },
});
expect(state.value).toBe('stored');
});
it('removes from sessionStorage when set to null', async () => {
const state = useSessionStorage<string | null>('ss-null', 'value');
expect(sessionStorage.getItem('ss-null')).toBe('value');
state.value = null;
await nextTick();
expect(sessionStorage.getItem('ss-null')).toBeNull();
});
it('passes options through to useStorage', () => {
const state = useSessionStorage<string>('ss-no-write', 'default', {
writeDefaults: false,
});
expect(state.value).toBe('default');
expect(sessionStorage.getItem('ss-no-write')).toBeNull();
});
});
@@ -0,0 +1,45 @@
import { ref, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import type { RemovableRef } from '@/types';
import { defaultWindow } from '@/types';
import { useStorage } from '../useStorage';
import type { UseStorageOptions } from '../useStorage';
/**
* @name useSessionStorage
* @category Storage
* @description Reactive sessionStorage binding — creates a ref synced with `window.sessionStorage`
*
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {UseStorageOptions<T>} [options={}] Options
* @returns {RemovableRef<T>} A reactive ref synced with sessionStorage
*
* @example
* const count = useSessionStorage('my-count', 0);
*
* @example
* const state = useSessionStorage('my-state', { hello: 'world' });
*
* @since 0.0.12
*/
export function useSessionStorage<T extends string>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T extends number>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T extends boolean>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T = unknown>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T>(
key: MaybeRefOrGetter<string>,
initialValue: MaybeRefOrGetter<T>,
options: UseStorageOptions<T> = {},
): RemovableRef<T> {
const window = options.window ?? defaultWindow;
const storage = window?.sessionStorage;
if (!storage) {
// SSR / non-browser environment: return an in-memory ref
return (options.shallow !== false ? shallowRef : ref)(toValue(initialValue)) as RemovableRef<T>;
}
return useStorage(key, initialValue, storage, { ...options, window });
}
@@ -0,0 +1,490 @@
import { describe, it, expect, vi } from 'vitest';
import { nextTick, ref } from 'vue';
import { useStorage, StorageSerializers, customStorageEventName } from '.';
import type { StorageLike, StorageEventLike } from '.';
function createMockStorage(): StorageLike & { store: Map<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
};
}
describe(useStorage, () => {
// --- Basic types ---
it('stores and reads a string', async () => {
const storage = createMockStorage();
const state = useStorage<string>('test-string', 'hello', storage);
expect(state.value).toBe('hello');
expect(storage.getItem('test-string')).toBe('hello');
state.value = 'world';
await nextTick();
expect(storage.getItem('test-string')).toBe('world');
});
it('stores and reads a number', async () => {
const storage = createMockStorage();
const state = useStorage<number>('test-number', 42, storage);
expect(state.value).toBe(42);
state.value = 100;
await nextTick();
expect(storage.getItem('test-number')).toBe('100');
});
it('stores and reads a boolean', async () => {
const storage = createMockStorage();
const state = useStorage<boolean>('test-bool', true, storage);
expect(state.value).toBeTruthy();
state.value = false;
await nextTick();
expect(storage.getItem('test-bool')).toBe('false');
});
it('stores and reads an object', async () => {
const storage = createMockStorage();
const state = useStorage('test-obj', { a: 1, b: 'two' }, storage);
expect(state.value).toEqual({ a: 1, b: 'two' });
state.value = { a: 2, b: 'three' };
await nextTick();
expect(JSON.parse(storage.getItem('test-obj')!)).toEqual({ a: 2, b: 'three' });
});
// --- Reads existing value from storage ---
it('reads existing value from storage on init', () => {
const storage = createMockStorage();
storage.store.set('existing', '"stored-value"');
const state = useStorage('existing', 'default', storage, {
serializer: StorageSerializers.object,
});
expect(state.value).toBe('stored-value');
});
// --- Removes item when set to null ---
it('removes from storage when value is set to null', async () => {
const storage = createMockStorage();
const state = useStorage<string | null>('test-null', 'value', storage);
await nextTick();
expect(storage.getItem('test-null')).toBe('value');
state.value = null;
await nextTick();
expect(storage.getItem('test-null')).toBeNull();
});
// --- Custom serializer ---
it('uses custom serializer', async () => {
const storage = createMockStorage();
const serializer = {
read: (v: string) => v.split(',').map(Number),
write: (v: number[]) => v.join(','),
};
const state = useStorage('custom-ser', [1, 2, 3], storage, { serializer });
expect(state.value).toEqual([1, 2, 3]);
state.value = [4, 5, 6];
await nextTick();
expect(storage.getItem('custom-ser')).toBe('4,5,6');
});
// --- Merge defaults ---
it('merges defaults with stored value', () => {
const storage = createMockStorage();
storage.store.set('merge-test', JSON.stringify({ hello: 'stored' }));
const state = useStorage(
'merge-test',
{ hello: 'default', greeting: 'hi' },
storage,
{ mergeDefaults: true },
);
expect(state.value.hello).toBe('stored');
expect(state.value.greeting).toBe('hi');
});
it('uses custom merge function', () => {
const storage = createMockStorage();
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
const state = useStorage(
'merge-fn',
{ a: 0, b: 2 },
storage,
{
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
},
);
expect(state.value).toEqual({ a: 1, b: 2 });
});
// --- Map and Set ---
it('stores and reads a Map', async () => {
const storage = createMockStorage();
const initial = new Map([['key1', 'val1']]);
const state = useStorage('test-map', initial, storage);
expect(state.value).toEqual(new Map([['key1', 'val1']]));
state.value = new Map([['key2', 'val2']]);
await nextTick();
const raw = storage.getItem('test-map');
expect(JSON.parse(raw!)).toEqual([['key2', 'val2']]);
});
it('stores and reads a Set', async () => {
const storage = createMockStorage();
const initial = new Set([1, 2, 3]);
const state = useStorage('test-set', initial, storage);
expect(state.value).toEqual(new Set([1, 2, 3]));
state.value = new Set([4, 5]);
await nextTick();
const raw = storage.getItem('test-set');
expect(JSON.parse(raw!)).toEqual([4, 5]);
});
// --- Date ---
it('stores and reads a Date', async () => {
const storage = createMockStorage();
const date = new Date('2026-02-14T00:00:00.000Z');
const state = useStorage('test-date', date, storage);
expect(state.value).toEqual(date);
const newDate = new Date('2026-12-25T00:00:00.000Z');
state.value = newDate;
await nextTick();
expect(storage.getItem('test-date')).toBe(newDate.toISOString());
});
// --- Error handling ---
it('calls onError when read fails', () => {
const storage = createMockStorage();
storage.store.set('bad-json', '{invalid');
const onError = vi.fn();
const state = useStorage('bad-json', { fallback: true }, storage, { onError });
expect(onError).toHaveBeenCalledOnce();
expect(state.value).toEqual({ fallback: true });
});
it('calls onError when write fails', async () => {
const onError = vi.fn();
const storage: StorageLike = {
getItem: () => null,
setItem: () => { throw new Error('quota exceeded'); },
removeItem: () => {},
};
const state = useStorage<string>('fail-write', 'init', storage, { onError });
// One error from initial persist of defaults
expect(onError).toHaveBeenCalledOnce();
state.value = 'new';
await nextTick();
// Another error from the write triggered by value change
expect(onError).toHaveBeenCalledTimes(2);
});
// --- Persists defaults on init ---
it('persists default value to storage on init when key does not exist', () => {
const storage = createMockStorage();
useStorage('new-key', 'default-val', storage);
expect(storage.getItem('new-key')).toBe('default-val');
});
it('does not overwrite existing storage value with defaults', () => {
const storage = createMockStorage();
storage.store.set('existing-key', 'existing-val');
useStorage('existing-key', 'default-val', storage);
expect(storage.getItem('existing-key')).toBe('existing-val');
});
// --- writeDefaults: false ---
it('does not persist defaults when writeDefaults is false', () => {
const storage = createMockStorage();
useStorage('no-write', 'default-val', storage, { writeDefaults: false });
expect(storage.getItem('no-write')).toBeNull();
});
// --- No infinite loop on init ---
it('calls setItem exactly once on init for writeDefaults', () => {
const setItem = vi.fn();
const storage: StorageLike = {
getItem: () => null,
setItem,
removeItem: vi.fn(),
};
useStorage<string>('init-key', 'value', storage);
expect(setItem).toHaveBeenCalledOnce();
expect(setItem).toHaveBeenCalledWith('init-key', 'value');
});
// --- No-op write when value unchanged ---
it('does not call setItem when value is unchanged', async () => {
const storage = createMockStorage();
const state = useStorage<string>('noop-key', 'same', storage);
const setItem = vi.spyOn(storage, 'setItem');
setItem.mockClear();
// Re-assign the same value
state.value = 'same';
await nextTick();
expect(setItem).not.toHaveBeenCalled();
});
// --- shallow: false with deep mutation ---
it('writes to storage on deep object mutation with shallow: false', async () => {
const storage = createMockStorage();
const state = useStorage('deep-obj', { nested: { count: 0 } }, storage, { shallow: false });
expect(state.value.nested.count).toBe(0);
state.value.nested.count = 42;
await nextTick();
expect(JSON.parse(storage.getItem('deep-obj')!)).toEqual({ nested: { count: 42 } });
});
// --- Multiple rapid assignments ---
it('only writes last value when multiple assignments happen before flush', async () => {
const storage = createMockStorage();
const state = useStorage<string>('rapid-key', 'initial', storage);
const setItem = vi.spyOn(storage, 'setItem');
setItem.mockClear();
state.value = 'first';
state.value = 'second';
state.value = 'third';
await nextTick();
// Watcher fires once with the last value (pre flush batches)
expect(storage.getItem('rapid-key')).toBe('third');
});
// --- Cross-tab synchronization via custom storage event ---
// Note: Mock storage is not `instanceof Storage`, so useStorage listens
// for the custom event 'vuetools-storage' instead of the native 'storage' event.
it('updates ref when receiving a custom storage event with matching key', async () => {
const storage = createMockStorage();
const state = useStorage<string>('sync-key', 'initial', storage, {
listenToStorageChanges: true,
window: globalThis,
});
expect(state.value).toBe('initial');
const detail: StorageEventLike = {
key: 'sync-key',
oldValue: 'initial',
newValue: 'from-other-tab',
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('from-other-tab');
});
it('ignores storage events with a different key', async () => {
const storage = createMockStorage();
const state = useStorage<string>('my-key', 'initial', storage, {
listenToStorageChanges: true,
window: globalThis,
});
const detail: StorageEventLike = {
key: 'other-key',
oldValue: null,
newValue: 'other-value',
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('initial');
});
it('resets to default when storage event has null key (clear)', async () => {
const storage = createMockStorage();
storage.store.set('clear-key', 'stored');
const state = useStorage<string>('clear-key', 'default', storage, {
listenToStorageChanges: true,
window: globalThis,
});
expect(state.value).toBe('stored');
// null key means storage.clear() was called
const detail: StorageEventLike = {
key: null,
oldValue: null,
newValue: null,
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('default');
});
it('does not update ref on storage events when listenToStorageChanges is false', async () => {
const storage = createMockStorage();
const state = useStorage<string>('no-listen', 'initial', storage, {
listenToStorageChanges: false,
window: globalThis,
});
const detail: StorageEventLike = {
key: 'no-listen',
oldValue: null,
newValue: 'changed',
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('initial');
});
// --- Custom storage event for non-native backends ---
it('responds to custom storage events for non-native backends', async () => {
const storage = createMockStorage();
const state = useStorage<string>('custom-backend', 'initial', storage, {
listenToStorageChanges: true,
window: globalThis,
});
const detail: StorageEventLike = {
key: 'custom-backend',
oldValue: 'initial',
newValue: 'updated-via-custom',
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('updated-via-custom');
});
// --- Reactive key ---
it('re-reads from storage when reactive key changes', async () => {
const storage = createMockStorage();
storage.store.set('key-a', 'value-a');
storage.store.set('key-b', 'value-b');
const keyRef = ref('key-a');
const state = useStorage<string>(keyRef, 'default', storage);
expect(state.value).toBe('value-a');
keyRef.value = 'key-b';
await nextTick();
expect(state.value).toBe('value-b');
});
// --- initOnMounted ---
it('defers reading from storage until mounted when initOnMounted is true', async () => {
const storage = createMockStorage();
storage.store.set('mounted-key', 'stored-value');
// Outside component context, tryOnMounted calls fn synchronously
const state = useStorage<string>('mounted-key', 'default', storage, {
initOnMounted: true,
});
// tryOnMounted calls fn synchronously when outside component context
expect(state.value).toBe('stored-value');
});
// --- eventFilter ---
it('applies event filter to writes', async () => {
vi.useFakeTimers();
const storage = createMockStorage();
const state = useStorage<string>('filtered-key', 'initial', storage, {
eventFilter: invoke => setTimeout(invoke, 100),
});
const setItem = vi.spyOn(storage, 'setItem');
setItem.mockClear();
state.value = 'filtered';
await nextTick();
// Not written yet — debounced
expect(setItem).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(setItem).toHaveBeenCalledWith('filtered-key', 'filtered');
vi.useRealTimers();
});
});
@@ -0,0 +1,361 @@
import { nextTick, ref, shallowRef, toValue, watch } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { isBoolean, isNumber, isString, isObject, isMap, isSet, isDate } from '@robonen/stdlib';
import type { ConfigurableFlush, ConfigurableWindow, RemovableRef } from '@/types';
import { defaultWindow } from '@/types';
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
import { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
export interface StorageSerializer<T> {
read: (raw: string) => T;
write: (value: T) => string;
}
export const StorageSerializers: { [K: string]: StorageSerializer<any> } & {
boolean: StorageSerializer<boolean>;
number: StorageSerializer<number>;
string: StorageSerializer<string>;
object: StorageSerializer<any>;
map: StorageSerializer<Map<any, any>>;
set: StorageSerializer<Set<any>>;
date: StorageSerializer<Date>;
} = {
boolean: {
read: (v: string) => v === 'true',
write: (v: boolean) => String(v),
},
number: {
read: (v: string) => Number.parseFloat(v),
write: (v: number) => String(v),
},
string: {
read: (v: string) => v,
write: (v: string) => v,
},
object: {
read: (v: string) => JSON.parse(v),
write: (v: any) => JSON.stringify(v),
},
map: {
read: (v: string) => new Map(JSON.parse(v)),
write: (v: Map<any, any>) => JSON.stringify([...v.entries()]),
},
set: {
read: (v: string) => new Set(JSON.parse(v)),
write: (v: Set<any>) => JSON.stringify([...v]),
},
date: {
read: (v: string) => new Date(v),
write: (v: Date) => v.toISOString(),
},
};
export interface StorageLike {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
}
export const customStorageEventName = 'vuetools-storage';
export interface StorageEventLike {
storageArea: StorageLike | null;
key: StorageEvent['key'];
oldValue: StorageEvent['oldValue'];
newValue: StorageEvent['newValue'];
}
export interface UseStorageOptions<T> extends ConfigurableFlush, ConfigurableWindow, ConfigurableEventFilter {
/**
* Use shallowRef instead of ref for the internal state
* @default true
*/
shallow?: boolean;
/**
* 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;
/**
* Merge the default value with the stored value
* @default false
*/
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
/**
* Custom serializer for reading/writing storage values
*/
serializer?: StorageSerializer<T>;
/**
* 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 type UseStorageReturn<T> = RemovableRef<T>;
export function guessSerializer<T>(value: T): StorageSerializer<T> {
if (isBoolean(value)) return StorageSerializers.boolean as any;
if (isNumber(value)) return StorageSerializers.number as any;
if (isString(value)) return StorageSerializers.string as any;
if (isMap(value)) return StorageSerializers.map as any;
if (isSet(value)) return StorageSerializers.set as any;
if (isDate(value)) return StorageSerializers.date as any;
if (isObject(value)) return StorageSerializers.object as any;
return StorageSerializers.object as any;
}
export function shallowMerge<T>(stored: T, defaults: T): T {
if (isObject(stored) && isObject(defaults))
return { ...defaults, ...stored };
return stored;
}
/**
* @name useStorage
* @category Storage
* @description Reactive Storage binding — creates a ref synced with a storage backend
*
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {StorageLike} storage The storage backend
* @param {UseStorageOptions<T>} [options={}] Options
* @returns {RemovableRef<T>} A reactive ref synced with storage
*
* @example
* const count = useStorage('my-count', 0, storage);
*
* @example
* const state = useStorage('my-state', { hello: 'world' }, storage);
*
* @example
* const id = useStorage('my-id', 'default', storage, {
* serializer: { read: (v) => v, write: (v) => v },
* });
*
* @since 0.0.12
*/
export function useStorage<T extends string>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T extends number>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T extends boolean>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T = unknown>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T>(
key: MaybeRefOrGetter<string>,
initialValue: MaybeRefOrGetter<T>,
storage: StorageLike,
options: UseStorageOptions<T> = {},
): RemovableRef<T> {
const {
shallow = true,
deep = true,
flush = 'pre',
writeDefaults = true,
mergeDefaults = false,
listenToStorageChanges = true,
window = defaultWindow,
eventFilter,
initOnMounted = false,
onError = console.error, // eslint-disable-line no-console
} = options;
const defaults = toValue(initialValue);
const serializer = options.serializer ?? guessSerializer(defaults);
const data = (shallow ? shallowRef : ref)(defaults) as RemovableRef<T>;
const resolvedKey = () => toValue(key);
function read(event?: StorageEventLike): T {
const rawValue = event
? event.newValue
: storage.getItem(resolvedKey());
if (rawValue === undefined || rawValue === null) {
if (writeDefaults && defaults !== undefined && defaults !== null) {
try {
storage.setItem(resolvedKey(), serializer.write(defaults));
}
catch (e) {
onError(e);
}
}
return defaults;
}
try {
if (!event && mergeDefaults) {
const value = serializer.read(rawValue);
return typeof mergeDefaults === 'function'
? mergeDefaults(value, defaults)
: shallowMerge(value, defaults);
}
return serializer.read(rawValue);
}
catch (e) {
onError(e);
return defaults;
}
}
function dispatchWriteEvent(oldValue: string | null, newValue: string | null) {
if (!window)
return;
const payload = {
key: resolvedKey(),
oldValue,
newValue,
storageArea: storage as Storage,
};
// Use native StorageEvent for built-in Storage, CustomEvent for custom backends
window.dispatchEvent(
storage instanceof Storage
? new StorageEvent('storage', payload)
: new CustomEvent<StorageEventLike>(customStorageEventName, { detail: payload }),
);
}
function write(value: T) {
try {
const oldValue = storage.getItem(resolvedKey());
if (value === undefined || value === null) {
dispatchWriteEvent(oldValue, null);
storage.removeItem(resolvedKey());
}
else {
const serialized = serializer.write(value);
if (oldValue !== serialized) {
storage.setItem(resolvedKey(), serialized);
dispatchWriteEvent(oldValue, serialized);
}
}
}
catch (e) {
onError(e);
}
}
// Write-lock prevents the data watcher from writing back to storage
// when data.value is being updated programmatically (from storage reads,
// key changes, or cross-tab events). The lock is released via nextTick
// so it persists through the pre-flush watcher cycle.
let writeLock = false;
function lockWritesUntilFlush() {
writeLock = true;
nextTick(() => {
writeLock = false;
});
}
function update(event: StorageEventLike) {
if (event.storageArea !== storage)
return;
if (event.key === null) {
lockWritesUntilFlush();
data.value = defaults;
return;
}
if (event.key !== resolvedKey())
return;
const currentSerialized = serializer.write(data.value);
if (event.newValue !== currentSerialized) {
lockWritesUntilFlush();
data.value = read(event);
}
}
function updateFromCustomEvent(event: CustomEvent<StorageEventLike>) {
update(event.detail);
}
// Apply event filter if provided
const writeWithFilter: (value: T) => void = eventFilter
? (value: T) => (eventFilter as EventFilter)(() => write(value))
: write;
// Initialize data from storage BEFORE creating watchers to avoid
// Vue 3.5 pause/resume replay race conditions
if (!initOnMounted) {
data.value = read();
}
// Data write watcher — skips writes when writeLock is active
watch(data, (newValue) => {
if (writeLock)
return;
writeWithFilter(newValue);
}, { flush, deep });
// Watch for reactive key changes
if (typeof key !== 'string') {
watch(resolvedKey, () => {
lockWritesUntilFlush();
data.value = read();
});
}
// Event listeners for cross-tab synchronization
let firstMounted = false;
const onStorageEvent = (ev: StorageEvent): void => {
if (initOnMounted && !firstMounted)
return;
update(ev);
};
const onStorageCustomEvent = (ev: CustomEvent<StorageEventLike>): void => {
if (initOnMounted && !firstMounted)
return;
updateFromCustomEvent(ev);
};
if (window && listenToStorageChanges) {
if (storage instanceof Storage)
useEventListener(window, 'storage', onStorageEvent, { passive: true });
else
useEventListener(window as any, customStorageEventName as any, onStorageCustomEvent as any);
}
if (initOnMounted) {
tryOnMounted(() => {
firstMounted = true;
lockWritesUntilFlush();
data.value = read();
});
}
return data;
}
@@ -0,0 +1,388 @@
import { describe, it, expect, vi } from 'vitest';
import { nextTick, ref } from 'vue';
import { useStorageAsync } from '.';
import type { StorageLikeAsync } from '.';
function createMockAsyncStorage(): StorageLikeAsync & { store: Map<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: async (key: string) => store.get(key) ?? null,
setItem: async (key: string, value: string) => { store.set(key, value); },
removeItem: async (key: string) => { store.delete(key); },
};
}
function createDelayedAsyncStorage(delay: number): StorageLikeAsync & { store: Map<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: (key: string) => new Promise(resolve => setTimeout(() => resolve(store.get(key) ?? null), delay)),
setItem: (key: string, value: string) => new Promise(resolve => setTimeout(() => {
store.set(key, value);
resolve();
}, delay)),
removeItem: (key: string) => new Promise(resolve => setTimeout(() => {
store.delete(key);
resolve();
}, delay)),
};
}
describe(useStorageAsync, () => {
// --- Basic read/write ---
it('returns default value before storage is ready', () => {
const storage = createMockAsyncStorage();
const { state, isReady } = useStorageAsync('key', 'default', storage);
expect(state.value).toBe('default');
expect(isReady.value).toBeFalsy();
});
it('reads existing value from async storage', async () => {
const storage = createMockAsyncStorage();
storage.store.set('key', 'stored');
const { state, isReady } = await useStorageAsync('key', 'default', storage);
expect(state.value).toBe('stored');
expect(isReady.value).toBeTruthy();
});
it('writes value to async storage on change', async () => {
const storage = createMockAsyncStorage();
const { state } = await useStorageAsync<string>('key', 'initial', storage);
state.value = 'updated';
await nextTick();
// Allow async write to complete
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('key')).toBe('updated');
});
// --- Types ---
it('reads and writes a number', async () => {
const storage = createMockAsyncStorage();
storage.store.set('num', '42');
const { state } = await useStorageAsync<number>('num', 0, storage);
expect(state.value).toBe(42);
state.value = 100;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('num')).toBe('100');
});
it('reads and writes a boolean', async () => {
const storage = createMockAsyncStorage();
storage.store.set('flag', 'true');
const { state } = await useStorageAsync('flag', false, storage);
expect(state.value).toBeTruthy();
state.value = false;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('flag')).toBe('false');
});
it('reads and writes an object', async () => {
const storage = createMockAsyncStorage();
storage.store.set('obj', JSON.stringify({ a: 1 }));
const { state } = await useStorageAsync('obj', { a: 0, b: 2 }, storage);
expect(state.value).toEqual({ a: 1 });
});
// --- Awaitable ---
it('is awaitable and resolves after initial read', async () => {
const storage = createDelayedAsyncStorage(50);
storage.store.set('delayed', 'loaded');
const { state, isReady } = await useStorageAsync('delayed', 'default', storage);
expect(state.value).toBe('loaded');
expect(isReady.value).toBeTruthy();
});
// --- onReady callback ---
it('calls onReady callback after initial load', async () => {
const storage = createMockAsyncStorage();
storage.store.set('ready', 'ready-value');
const onReady = vi.fn();
await useStorageAsync('ready', 'default', storage, { onReady });
expect(onReady).toHaveBeenCalledOnce();
expect(onReady).toHaveBeenCalledWith('ready-value');
});
it('calls onReady with default when key not in storage', async () => {
const storage = createMockAsyncStorage();
const onReady = vi.fn();
await useStorageAsync('missing', 'fallback', storage, { onReady });
expect(onReady).toHaveBeenCalledWith('fallback');
});
// --- Merge defaults ---
it('merges defaults with stored value', async () => {
const storage = createMockAsyncStorage();
storage.store.set('merge', JSON.stringify({ hello: 'stored' }));
const { state } = await useStorageAsync(
'merge',
{ hello: 'default', greeting: 'hi' },
storage,
{ mergeDefaults: true },
);
expect(state.value.hello).toBe('stored');
expect(state.value.greeting).toBe('hi');
});
it('uses custom merge function', async () => {
const storage = createMockAsyncStorage();
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
const { state } = await useStorageAsync(
'merge-fn',
{ a: 0, b: 2 },
storage,
{
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
},
);
expect(state.value).toEqual({ a: 1, b: 2 });
});
// --- Custom serializer ---
it('uses custom async serializer', async () => {
const storage = createMockAsyncStorage();
storage.store.set('custom', '1,2,3');
const serializer = {
read: async (v: string) => v.split(',').map(Number),
write: async (v: number[]) => v.join(','),
};
const { state } = await useStorageAsync('custom', [0], storage, { serializer });
expect(state.value).toEqual([1, 2, 3]);
state.value = [4, 5, 6];
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('custom')).toBe('4,5,6');
});
// --- Null / remove ---
it('removes from storage when value is set to null', async () => {
const storage = createMockAsyncStorage();
storage.store.set('nullable', 'exists');
const { state } = await useStorageAsync<string | null>('nullable', 'default', storage);
state.value = null;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.has('nullable')).toBeFalsy();
});
// --- Error handling ---
it('calls onError when read fails', async () => {
const onError = vi.fn();
const storage: StorageLikeAsync = {
getItem: async () => { throw new Error('read failure'); },
setItem: async () => {},
removeItem: async () => {},
};
const { state } = await useStorageAsync('fail-read', 'fallback', storage, { onError });
expect(onError).toHaveBeenCalledOnce();
expect(state.value).toBe('fallback');
});
it('calls onError when write fails', async () => {
const onError = vi.fn();
const storage: StorageLikeAsync = {
getItem: async () => null,
setItem: async () => { throw new Error('write failure'); },
removeItem: async () => {},
};
const { state } = await useStorageAsync<string>('fail-write', 'initial', storage, { onError });
// One error from writeDefaults persisting initial value
expect(onError).toHaveBeenCalledOnce();
state.value = 'new';
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
// Another error from the write triggered by value change
expect(onError).toHaveBeenCalledTimes(2);
});
// --- No unnecessary write-back on initial read ---
it('does not write back to storage after initial read', async () => {
const setItem = vi.fn(async () => {});
const storage: StorageLikeAsync = {
getItem: async () => 'existing',
setItem,
removeItem: async () => {},
};
await useStorageAsync('key', 'default', storage);
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(setItem).not.toHaveBeenCalled();
});
it('does not write back to storage when key is missing and writeDefaults is false', async () => {
const setItem = vi.fn(async () => {});
const storage: StorageLikeAsync = {
getItem: async () => null,
setItem,
removeItem: async () => {},
};
await useStorageAsync('key', 'default', storage, { writeDefaults: false });
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(setItem).not.toHaveBeenCalled();
});
// --- shallow: false with deep mutation ---
it('writes to storage on deep object mutation with shallow: false', async () => {
const storage = createMockAsyncStorage();
const { state } = await useStorageAsync(
'deep-obj',
{ nested: { count: 0 } },
storage,
{ shallow: false },
);
state.value.nested.count = 42;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(JSON.parse(storage.store.get('deep-obj')!)).toEqual({ nested: { count: 42 } });
});
// --- Multiple rapid assignments ---
it('handles multiple rapid assignments', async () => {
const storage = createMockAsyncStorage();
const { state } = await useStorageAsync<string>('rapid', 'initial', storage);
state.value = 'first';
state.value = 'second';
state.value = 'third';
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('rapid')).toBe('third');
});
// --- writeDefaults ---
it('persists defaults to storage when key does not exist', async () => {
const storage = createMockAsyncStorage();
await useStorageAsync('new-key', 'default-val', storage);
expect(storage.store.get('new-key')).toBe('default-val');
});
it('does not persist defaults when writeDefaults is false', async () => {
const storage = createMockAsyncStorage();
await useStorageAsync('new-key', 'default-val', storage, { writeDefaults: false });
expect(storage.store.has('new-key')).toBeFalsy();
});
it('does not overwrite existing value with defaults', async () => {
const storage = createMockAsyncStorage();
storage.store.set('existing', 'stored');
await useStorageAsync('existing', 'default', storage);
expect(storage.store.get('existing')).toBe('stored');
});
// --- Reactive key ---
it('re-reads from async storage when reactive key changes', async () => {
const storage = createMockAsyncStorage();
storage.store.set('key-a', 'value-a');
storage.store.set('key-b', 'value-b');
const keyRef = ref('key-a');
const { state } = await useStorageAsync<string>(keyRef, 'default', storage);
expect(state.value).toBe('value-a');
keyRef.value = 'key-b';
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(state.value).toBe('value-b');
});
// --- eventFilter ---
it('applies event filter to writes', async () => {
const storage = createMockAsyncStorage();
let captured: (() => void) | undefined;
const { state } = await useStorageAsync<string>('filtered', 'initial', storage, {
eventFilter: (invoke) => { captured = invoke; },
});
const setItem = vi.spyOn(storage, 'setItem');
(setItem as any).mockClear();
state.value = 'filtered-value';
await nextTick();
// Not written yet — held by filter
expect(setItem).not.toHaveBeenCalled();
expect(captured).toBeDefined();
// Release the filter
captured!();
await new Promise(resolve => setTimeout(resolve, 0));
expect(setItem).toHaveBeenCalled();
});
});
@@ -0,0 +1,266 @@
import { computed, ref, shallowRef, watch, toValue } from 'vue';
import type { Ref, ShallowRef, MaybeRefOrGetter, UnwrapRef } from 'vue';
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 typeof mergeDefaults === 'function'
? 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);
},
};
}