1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 10:54:44 +00:00

Merge pull request #123 from robonen/vue-composable-categories

feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories
This commit is contained in:
2026-02-14 21:45:53 +07:00
committed by GitHub
67 changed files with 1582 additions and 64 deletions

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
},
});

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});

View File

@@ -3,16 +3,10 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
projects: [ projects: [
{ 'core/stdlib/vitest.config.ts',
extends: true, 'core/platform/vitest.config.ts',
test: { 'web/vue/vitest.config.ts',
typecheck: {
enabled: false,
},
},
},
], ],
environment: 'jsdom',
coverage: { coverage: {
provider: 'v8', provider: 'v8',
include: ['core/*', 'web/*'], include: ['core/*', 'web/*'],

View File

@@ -0,0 +1,3 @@
export * from './useEventListener';
export * from './useFocusGuard';
export * from './useSupported';

View File

@@ -1,6 +1,6 @@
import { isArray, isString, noop, type Arrayable, type VoidFunction } from '@robonen/stdlib'; import { isArray, isString, noop, type Arrayable, type VoidFunction } from '@robonen/stdlib';
import type { MaybeRefOrGetter } from 'vue'; import type { MaybeRefOrGetter } from 'vue';
import { defaultWindow } from '../..'; import { defaultWindow } from '@/types';
// TODO: wip // TODO: wip
@@ -19,7 +19,7 @@ export type ElementEventName = keyof HTMLElementEventMap;
/** /**
* @name useEventListener * @name useEventListener
* @category Elements * @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
* *
* Overload 1: Omitted window target * Overload 1: Omitted window target
@@ -32,7 +32,7 @@ export function useEventListener<E extends WindowEventName>(
/** /**
* @name useEventListener * @name useEventListener
* @category Elements * @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
* *
* Overload 2: Explicit window target * Overload 2: Explicit window target
@@ -46,7 +46,7 @@ export function useEventListener<E extends WindowEventName>(
/** /**
* @name useEventListener * @name useEventListener
* @category Elements * @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
* *
* Overload 3: Explicit document target * Overload 3: Explicit document target
@@ -60,7 +60,7 @@ export function useEventListener<E extends DocumentEventName>(
/** /**
* @name useEventListener * @name useEventListener
* @category Elements * @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
* *
* Overload 4: Explicit HTMLElement target * Overload 4: Explicit HTMLElement target
@@ -74,7 +74,7 @@ export function useEventListener<E extends ElementEventName>(
/** /**
* @name useEventListener * @name useEventListener
* @category Elements * @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
* *
* Overload 5: Custom target with inferred event type * Overload 5: Custom target with inferred event type
@@ -88,7 +88,7 @@ export function useEventListener<Names extends string, EventType = Event>(
/** /**
* @name useEventListener * @name useEventListener
* @category Elements * @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
* *
* Overload 6: Custom event target fallback * Overload 6: Custom event target fallback

View File

@@ -6,7 +6,7 @@ let counter = 0;
/** /**
* @name useFocusGuard * @name useFocusGuard
* @category Utilities * @category Browser
* @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior * @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior
* *
* @param {string} [namespace] - A namespace to group the focus guards * @param {string} [namespace] - A namespace to group the focus guards

View File

@@ -1,9 +1,9 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useMounted } from '../useMounted'; import { useMounted } from '@/composables/lifecycle/useMounted';
/** /**
* @name useSupported * @name useSupported
* @category Utilities * @category Browser
* @description SSR-friendly way to check if a feature is supported * @description SSR-friendly way to check if a feature is supported
* *
* @param {Function} feature The feature to check for support * @param {Function} feature The feature to check for support

View File

@@ -0,0 +1,3 @@
export * from './unrefElement';
export * from './useRenderCount';
export * from './useRenderInfo';

View File

@@ -11,7 +11,7 @@ export type UnRefElementReturn<T extends MaybeElement = MaybeElement> = T extend
/** /**
* @name unrefElement * @name unrefElement
* @category Components * @category Component
* @description Unwraps a Vue element reference to get the underlying instance or DOM element. * @description Unwraps a Vue element reference to get the underlying instance or DOM element.
* *
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap. * @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.

View File

@@ -1,10 +1,10 @@
import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue'; import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue';
import { useCounter } from '../useCounter'; import { useCounter } from '@/composables/state/useCounter';
import { getLifeCycleTarger } from '../..'; import { getLifeCycleTarger } from '@/utils';
/** /**
* @name useRenderCount * @name useRenderCount
* @category Components * @category Component
* @description Returns the number of times the component has been rendered into the DOM * @description Returns the number of times the component has been rendered into the DOM
* *
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for * @param {ComponentInternalInstance} [instance] The component instance to track the render count for

View File

@@ -1,11 +1,11 @@
import { timestamp } from '@robonen/stdlib'; import { timestamp } from '@robonen/stdlib';
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref, type ComponentInternalInstance } from 'vue'; import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref, type ComponentInternalInstance } from 'vue';
import { useRenderCount } from '../useRenderCount'; import { useRenderCount } from '../useRenderCount';
import { getLifeCycleTarger } from '../..'; import { getLifeCycleTarger } from '@/utils';
/** /**
* @name useRenderInfo * @name useRenderInfo
* @category Components * @category Component
* @description Returns information about the component's render count and the last time it was rendered * @description Returns information about the component's render count and the last time it was rendered
* *
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for * @param {ComponentInternalInstance} [instance] The component instance to track the render count for

View File

@@ -1,19 +1,8 @@
export * from './tryOnBeforeMount'; export * from './browser';
export * from './tryOnMounted'; export * from './component';
export * from './tryOnScopeDispose'; export * from './lifecycle';
export * from './unrefElement'; export * from './math';
export * from './useAppSharedState'; export * from './reactivity';
export * from './useAsyncState'; export * from './state';
export * from './useCached'; export * from './storage';
export * from './useClamp'; export * from './utilities';
export * from './useContextFactory';
export * from './useCounter';
export * from './useFocusGuard';
export * from './useInjectionStore';
export * from './useLastChanged';
export * from './useMounted';
export * from './useOffsetPagination';
export * from './useRenderCount';
export * from './useRenderInfo';
export * from './useSupported';
export * from './useSyncRefs';

View File

@@ -0,0 +1,4 @@
export * from './tryOnBeforeMount';
export * from './tryOnMounted';
export * from './tryOnScopeDispose';
export * from './useMounted';

View File

@@ -1,5 +1,5 @@
import { onBeforeMount, nextTick, type ComponentInternalInstance } from 'vue'; import { onBeforeMount, nextTick, type ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '../..'; import { getLifeCycleTarger } from '@/utils';
import type { VoidFunction } from '@robonen/stdlib'; import type { VoidFunction } from '@robonen/stdlib';
// TODO: test // TODO: test
@@ -11,7 +11,7 @@ export interface TryOnBeforeMountOptions {
/** /**
* @name tryOnBeforeMount * @name tryOnBeforeMount
* @category Components * @category Lifecycle
* @description Call onBeforeMount if it's inside a component lifecycle hook, otherwise just calls it * @description Call onBeforeMount if it's inside a component lifecycle hook, otherwise just calls it
* *
* @param {VoidFunction} fn - The function to run on before mount. * @param {VoidFunction} fn - The function to run on before mount.

View File

@@ -1,5 +1,5 @@
import { onMounted, nextTick, type ComponentInternalInstance } from 'vue'; import { onMounted, nextTick, type ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '../..'; import { getLifeCycleTarger } from '@/utils';
import type { VoidFunction } from '@robonen/stdlib'; import type { VoidFunction } from '@robonen/stdlib';
// TODO: tests // TODO: tests
@@ -11,7 +11,7 @@ export interface TryOnMountedOptions {
/** /**
* @name tryOnMounted * @name tryOnMounted
* @category Components * @category Lifecycle
* @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it * @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it
* *
* @param {VoidFunction} fn The function to call * @param {VoidFunction} fn The function to call

View File

@@ -3,7 +3,7 @@ import { getCurrentScope, onScopeDispose } from 'vue';
/** /**
* @name tryOnScopeDispose * @name tryOnScopeDispose
* @category Components * @category Lifecycle
* @description A composable that will run a callback when the scope is disposed or do nothing if the scope isn't available. * @description A composable that will run a callback when the scope is disposed or do nothing if the scope isn't available.
* *
* @param {VoidFunction} callback - The callback to run when the scope is disposed. * @param {VoidFunction} callback - The callback to run when the scope is disposed.

View File

@@ -1,9 +1,9 @@
import { onMounted, readonly, ref, type ComponentInternalInstance } from 'vue'; import { onMounted, readonly, ref, type ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '../..'; import { getLifeCycleTarger } from '@/utils';
/** /**
* @name useMounted * @name useMounted
* @category Components * @category Lifecycle
* @description Returns a ref that tracks the mounted state of the component (doesn't track the unmounted state) * @description Returns a ref that tracks the mounted state of the component (doesn't track the unmounted state)
* *
* @param {ComponentInternalInstance} [instance] The component instance to track the mounted state for * @param {ComponentInternalInstance} [instance] The component instance to track the mounted state for

View File

@@ -0,0 +1 @@
export * from './useClamp';

View File

@@ -0,0 +1,41 @@
import { customRef, onScopeDispose } from 'vue';
/**
* @name broadcastedRef
* @category Reactivity
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
*
* @param {string} key The channel key to use for broadcasting
* @param {T} initialValue The initial value of the ref
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
*
* @example
* const count = broadcastedRef('counter', 0);
*
* @since 0.0.1
*/
export function broadcastedRef<T>(key: string, initialValue: T) {
const channel = new BroadcastChannel(key);
onScopeDispose(channel.close);
return customRef<T>((track, trigger) => {
channel.onmessage = (event) => {
track();
return event.data;
};
channel.postMessage(initialValue);
return {
get() {
return initialValue;
},
set(newValue: T) {
initialValue = newValue;
channel.postMessage(newValue);
trigger();
},
};
});
}

View File

@@ -0,0 +1,4 @@
export * from './broadcastedRef';
export * from './useCached';
export * from './useLastChanged';
export * from './useSyncRefs';

View File

@@ -10,7 +10,7 @@ export interface UseLastChangedOptions<
/** /**
* @name useLastChanged * @name useLastChanged
* @category State * @category Reactivity
* @description Records the last time a value changed * @description Records the last time a value changed
* *
* @param {WatchSource} source The value to track * @param {WatchSource} source The value to track

View File

@@ -0,0 +1,6 @@
export * from './useAppSharedState';
export * from './useAsyncState';
export * from './useContextFactory';
export * from './useCounter';
export * from './useInjectionStore';
export * from './useToggle';

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { useContextFactory } from '.'; import { useContextFactory } from '.';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { VueToolsError } from '../../utils'; import { VueToolsError } from '@/utils';
function testFactory<Data>( function testFactory<Data>(
data: Data, data: Data,

View File

@@ -1,5 +1,5 @@
import { inject as vueInject, provide as vueProvide, type InjectionKey, type App } from 'vue'; import { inject as vueInject, provide as vueProvide, type InjectionKey, type App } from 'vue';
import { VueToolsError } from '../..'; import { VueToolsError } from '@/utils';
/** /**
* @name useContextFactory * @name useContextFactory

View File

@@ -17,7 +17,7 @@ export interface UseConterReturn {
/** /**
* @name useCounter * @name useCounter
* @category Utilities * @category State
* @description A composable that provides a counter with increment, decrement, set, get, and reset functions * @description A composable that provides a counter with increment, decrement, set, get, and reset functions
* *
* @param {MaybeRef<number>} [initialValue=0] The initial value of the counter * @param {MaybeRef<number>} [initialValue=0] The initial value of the counter

View File

@@ -0,0 +1,110 @@
import { it, expect, describe } from 'vitest';
import { ref } from 'vue';
import { useToggle } from '.';
describe('useToggle', () => {
it('initialize with false by default', () => {
const { value } = useToggle();
expect(value.value).toBe(false);
});
it('initialize with the provided initial value', () => {
const { value } = useToggle(true);
expect(value.value).toBe(true);
});
it('initialize with the provided initial value from a ref', () => {
const { value } = useToggle(ref(true));
expect(value.value).toBe(true);
});
it('toggle from false to true', () => {
const { value, toggle } = useToggle(false);
toggle();
expect(value.value).toBe(true);
});
it('toggle from true to false', () => {
const { value, toggle } = useToggle(true);
toggle();
expect(value.value).toBe(false);
});
it('toggle multiple times', () => {
const { value, toggle } = useToggle(false);
toggle();
expect(value.value).toBe(true);
toggle();
expect(value.value).toBe(false);
toggle();
expect(value.value).toBe(true);
});
it('toggle returns the new value', () => {
const { toggle } = useToggle(false);
expect(toggle()).toBe(true);
expect(toggle()).toBe(false);
});
it('set a specific value via toggle', () => {
const { value, toggle } = useToggle(false);
toggle(true);
expect(value.value).toBe(true);
toggle(true);
expect(value.value).toBe(true);
});
it('use custom truthy and falsy values', () => {
const { value, toggle } = useToggle('off', {
truthyValue: 'on',
falsyValue: 'off',
});
expect(value.value).toBe('off');
toggle();
expect(value.value).toBe('on');
toggle();
expect(value.value).toBe('off');
});
it('set a specific custom value via toggle', () => {
const { value, toggle } = useToggle('off', {
truthyValue: 'on',
falsyValue: 'off',
});
toggle('on');
expect(value.value).toBe('on');
toggle('on');
expect(value.value).toBe('on');
});
it('use ref-based truthy and falsy values', () => {
const truthy = ref('yes');
const falsy = ref('no');
const { value, toggle } = useToggle('no', {
truthyValue: truthy,
falsyValue: falsy,
});
expect(value.value).toBe('no');
toggle();
expect(value.value).toBe('yes');
toggle();
expect(value.value).toBe('no');
});
it('use getter-based truthy and falsy values', () => {
const { value, toggle } = useToggle(0, {
truthyValue: () => 1,
falsyValue: () => 0,
});
expect(value.value).toBe(0);
toggle();
expect(value.value).toBe(1);
toggle();
expect(value.value).toBe(0);
});
});

View File

@@ -1,19 +1,36 @@
import { isRef, ref, toValue, type MaybeRefOrGetter, type MaybeRef, type Ref } from 'vue'; import { ref, toValue, type MaybeRefOrGetter, type MaybeRef, type Ref } from 'vue';
export interface UseToggleOptions<Truthy, Falsy> { export interface UseToggleOptions<Truthy, Falsy> {
truthyValue?: MaybeRefOrGetter<Truthy>, truthyValue?: MaybeRefOrGetter<Truthy>,
falsyValue?: MaybeRefOrGetter<Falsy>, falsyValue?: MaybeRefOrGetter<Falsy>,
} }
export function useToggle<Truthy = true, Falsy = false>( export interface UseToggleReturn<Truthy, Falsy> {
initialValue?: MaybeRef<Truthy | Falsy>, value: Ref<Truthy | Falsy>;
options?: UseToggleOptions<Truthy, Falsy>, toggle: (value?: Truthy | Falsy) => Truthy | Falsy;
): { value: Ref<Truthy | Falsy>, toggle: (value?: Truthy | Falsy) => Truthy | Falsy }; }
/**
* @name useToggle
* @category State
* @description A composable that provides a boolean toggle with customizable truthy/falsy values
*
* @param {MaybeRef<Truthy | Falsy>} [initialValue=false] The initial value
* @param {UseToggleOptions<Truthy, Falsy>} [options={}] Options for custom truthy/falsy values
* @returns {UseToggleReturn<Truthy, Falsy>} The toggle state and function
*
* @example
* const { value, toggle } = useToggle();
*
* @example
* const { value, toggle } = useToggle(false, { truthyValue: 'on', falsyValue: 'off' });
*
* @since 0.0.1
*/
export function useToggle<Truthy = true, Falsy = false>( export function useToggle<Truthy = true, Falsy = false>(
initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy, initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy,
options: UseToggleOptions<Truthy, Falsy> = {}, options: UseToggleOptions<Truthy, Falsy> = {},
) { ): UseToggleReturn<Truthy, Falsy> {
const { const {
truthyValue = true as Truthy, truthyValue = true as Truthy,
falsyValue = false as Falsy, falsyValue = false as Falsy,

View File

@@ -0,0 +1,4 @@
export * from './useLocalStorage';
export * from './useSessionStorage';
export * from './useStorage';
export * from './useStorageAsync';

View File

@@ -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();
});
});

View File

@@ -0,0 +1,40 @@
import type { MaybeRefOrGetter, Ref } from 'vue';
import { defaultWindow } from '@/types';
import { VueToolsError } from '@/utils/error';
import { useStorage, type UseStorageOptions } from '../useStorage';
/**
* @name useLocalStorage
* @category Storage
* @description Reactive localStorage binding — creates a ref synced with `window.localStorage`
*
* @param {string} key The storage key
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {UseStorageOptions<T>} [options={}] Options
* @returns {Ref<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: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
export function useLocalStorage<T extends number>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
export function useLocalStorage<T extends boolean>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
export function useLocalStorage<T>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
export function useLocalStorage<T = unknown>(key: string, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): Ref<T>;
export function useLocalStorage<T>(
key: string,
initialValue: MaybeRefOrGetter<T>,
options: UseStorageOptions<T> = {},
): Ref<T> {
const storage = defaultWindow?.localStorage;
if (!storage)
throw new VueToolsError('useLocalStorage: localStorage is not available');
return useStorage(key, initialValue, storage, options);
}

View File

@@ -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();
});
});

View File

@@ -0,0 +1,40 @@
import type { MaybeRefOrGetter, Ref } from 'vue';
import { defaultWindow } from '@/types';
import { VueToolsError } from '@/utils/error';
import { useStorage, type UseStorageOptions } from '../useStorage';
/**
* @name useSessionStorage
* @category Storage
* @description Reactive sessionStorage binding — creates a ref synced with `window.sessionStorage`
*
* @param {string} key The storage key
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {UseStorageOptions<T>} [options={}] Options
* @returns {Ref<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: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
export function useSessionStorage<T extends number>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
export function useSessionStorage<T extends boolean>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
export function useSessionStorage<T>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
export function useSessionStorage<T = unknown>(key: string, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): Ref<T>;
export function useSessionStorage<T>(
key: string,
initialValue: MaybeRefOrGetter<T>,
options: UseStorageOptions<T> = {},
): Ref<T> {
const storage = defaultWindow?.sessionStorage;
if (!storage)
throw new VueToolsError('useSessionStorage: sessionStorage is not available');
return useStorage(key, initialValue, storage, options);
}

View File

@@ -0,0 +1,318 @@
import { describe, it, expect, vi } from 'vitest';
import { nextTick } from 'vue';
import { useStorage, StorageSerializers, type StorageLike } 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).toBe(true);
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');
});
});

View File

@@ -0,0 +1,223 @@
import { ref, shallowRef, watch, toValue, type Ref, type MaybeRefOrGetter } from 'vue';
import { isBoolean, isNumber, isString, isObject, isMap, isSet, isDate } from '@robonen/stdlib';
import type { ConfigurableFlush } from '@/types';
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 interface UseStorageOptions<T> extends ConfigurableFlush {
/**
* Use shallowRef instead of ref for the internal state
* @default true
*/
shallow?: boolean;
/**
* Watch for deep changes
* @default true
*/
deep?: 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?: StorageSerializer<T>;
/**
* Merge the default value with the stored value
* @default false
*/
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
/**
* Error handler for read/write failures
*/
onError?: (error: unknown) => void;
}
export type UseStorageReturn<T> = Ref<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 {string} key The storage key
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {StorageLike} storage The storage backend
* @param {UseStorageOptions<T>} [options={}] Options
* @returns {Ref<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: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
export function useStorage<T extends number>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
export function useStorage<T extends boolean>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
export function useStorage<T>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
export function useStorage<T = unknown>(key: string, initialValue: MaybeRefOrGetter<null>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
export function useStorage<T>(
key: string,
initialValue: MaybeRefOrGetter<T>,
storage: StorageLike,
options: UseStorageOptions<T> = {},
): Ref<T> {
const {
shallow = true,
deep = true,
flush = 'pre',
writeDefaults = true,
mergeDefaults = false,
onError = console.error,
} = options;
const defaults = toValue(initialValue);
const serializer = options.serializer ?? guessSerializer(defaults);
const data = (shallow ? shallowRef : ref)(defaults) as Ref<T>;
function read(): T {
const raw = storage.getItem(key);
if (raw == null) {
if (writeDefaults && defaults != null) {
try {
storage.setItem(key, serializer.write(defaults));
} catch (e) {
onError(e);
}
}
return defaults;
}
try {
let value = serializer.read(raw);
if (mergeDefaults) {
value = typeof mergeDefaults === 'function'
? mergeDefaults(value, defaults)
: shallowMerge(value, defaults);
}
return value;
} catch (e) {
onError(e);
return defaults;
}
}
function write(value: T) {
try {
const oldValue = storage.getItem(key);
if (value == null) {
storage.removeItem(key);
} else {
const serialized = serializer.write(value);
if (oldValue !== serialized)
storage.setItem(key, serialized);
}
} catch (e) {
onError(e);
}
}
function update() {
pauseWatch();
try {
data.value = read();
} catch (e) {
onError(e);
} finally {
resumeWatch();
}
}
const { pause: pauseWatch, resume: resumeWatch } = watch(data, (newValue) => {
write(newValue);
}, { flush, deep });
update();
return data;
}

View File

@@ -0,0 +1,335 @@
import { describe, it, expect, vi } from 'vitest';
import { nextTick } from 'vue';
import { useStorageAsync, 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).toBe(false);
});
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).toBe(true);
});
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).toBe(true);
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).toBe(true);
});
// --- 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')).toBe(false);
});
// --- 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')).toBe(false);
});
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');
});
});

View File

@@ -0,0 +1,186 @@
import { ref, shallowRef, watch, toValue, type Ref, type ShallowRef, type MaybeRefOrGetter, type UnwrapRef } from 'vue';
import type { ConfigurableFlush } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
import { guessSerializer, shallowMerge } 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 {
/**
* Use shallowRef instead of ref for the internal state
* @default true
*/
shallow?: Shallow;
/**
* Watch for deep changes
* @default true
*/
deep?: 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;
}
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 {string} key The storage key
* @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: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T extends number, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T extends boolean, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T = unknown, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<null>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T, Shallow extends boolean = true>(
key: string,
initialValue: MaybeRefOrGetter<T>,
storage: StorageLikeAsync,
options: UseStorageAsyncOptions<T, Shallow> = {},
): UseStorageAsyncReturn<T, Shallow> {
const {
shallow = true,
deep = true,
flush = 'pre',
writeDefaults = true,
mergeDefaults = false,
onReady,
onError = console.error,
} = 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);
async function read(): Promise<T> {
try {
const raw = await storage.getItem(key);
if (raw == null) {
if (writeDefaults && defaults != null) {
try {
await storage.setItem(key, await serializer.write(defaults));
} catch (e) {
onError(e);
}
}
return defaults;
}
let value: T = await serializer.read(raw) as T;
if (mergeDefaults) {
value = typeof mergeDefaults === 'function'
? mergeDefaults(value, defaults)
: shallowMerge(value, defaults);
}
return value;
} catch (e) {
onError(e);
return defaults;
}
}
async function write(value: T) {
try {
if (value == null) {
await storage.removeItem(key);
} else {
const raw = await serializer.write(value);
await storage.setItem(key, raw);
}
} catch (e) {
onError(e);
}
}
let stopWatch: (() => void) | null = null;
tryOnScopeDispose(() => stopWatch?.());
const shell: UseStorageAsyncReturnBase<T, Shallow> = {
state,
isReady,
};
const readyPromise: Promise<UseStorageAsyncReturnBase<T, Shallow>> = 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) => {
write(newValue as T);
}, { flush, deep });
stopWatch = stop;
return shell;
});
return {
...shell,
then(onFulfilled, onRejected) {
return readyPromise.then(onFulfilled, onRejected);
},
};
}

View File

@@ -0,0 +1 @@
export * from './useOffsetPagination';

View File

@@ -1,6 +1,6 @@
import type { VoidFunction } from '@robonen/stdlib'; import type { VoidFunction } from '@robonen/stdlib';
import { computed, reactive, toValue, watch, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type UnwrapNestedRefs, type WritableComputedRef } from 'vue'; import { computed, reactive, toValue, watch, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type UnwrapNestedRefs, type WritableComputedRef } from 'vue';
import { useClamp } from '../useClamp'; import { useClamp } from '@/composables/math/useClamp';
// TODO: sync returned refs with passed refs // TODO: sync returned refs with passed refs

View File

@@ -0,0 +1,10 @@
import type { WatchOptions } from 'vue';
export interface ConfigurableFlush {
/**
* Timing for the watcher flush
*
* @default 'pre'
*/
flush?: WatchOptions['flush'];
}

View File

@@ -1,2 +1,3 @@
export * from './flush';
export * from './resumable'; export * from './resumable';
export * from './window'; export * from './window';

View File

@@ -1,3 +1,12 @@
import { isClient } from '@robonen/platform/multi'; import { isClient } from '@robonen/platform/multi';
export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined
export interface ConfigurableWindow {
/**
* Specify a custom `window` instance, e.g. working with iframes or testing environments
*
* @default defaultWindow
*/
window?: Window;
}

View File

@@ -1,6 +1,13 @@
{ {
"extends": "@robonen/tsconfig/tsconfig.json", "extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"lib": ["DOM"] "lib": ["DOM"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/composables/*": ["src/composables/*"],
"@/types": ["src/types"],
"@/utils": ["src/utils"]
}
} }
} }

13
web/vue/vitest.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
},
});