feat(monorepo): migrate vue packages and apply oxlint refactors
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user