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,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;
}