From 6565fa3de86c548497813bd460b0362d35dc516f Mon Sep 17 00:00:00 2001 From: robonen Date: Sat, 14 Feb 2026 21:38:29 +0700 Subject: [PATCH 1/2] feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories --- web/vue/src/composables/browser/index.ts | 3 + .../{ => browser}/useEventListener/index.ts | 14 +- .../{ => browser}/useFocusGuard/index.test.ts | 0 .../{ => browser}/useFocusGuard/index.ts | 2 +- .../{ => browser}/useSupported/index.test.ts | 0 .../{ => browser}/useSupported/index.ts | 4 +- web/vue/src/composables/component/index.ts | 3 + .../unrefElement/index.test.ts | 0 .../{ => component}/unrefElement/index.ts | 2 +- .../useRenderCount/index.test.ts | 0 .../{ => component}/useRenderCount/index.ts | 6 +- .../useRenderInfo/index.test.ts | 0 .../{ => component}/useRenderInfo/index.ts | 4 +- web/vue/src/composables/index.ts | 27 +- web/vue/src/composables/lifecycle/index.ts | 4 + .../{ => lifecycle}/tryOnBeforeMount/index.ts | 4 +- .../tryOnMounted/index.test.ts | 0 .../{ => lifecycle}/tryOnMounted/index.ts | 4 +- .../tryOnScopeDispose/index.test.ts | 0 .../tryOnScopeDispose/index.ts | 2 +- .../{ => lifecycle}/useMounted/index.test.ts | 0 .../{ => lifecycle}/useMounted/index.ts | 4 +- web/vue/src/composables/math/index.ts | 1 + .../{ => math}/useClamp/index.test.ts | 0 .../composables/{ => math}/useClamp/index.ts | 0 .../reactivity/broadcastedRef/index.ts | 41 +++ web/vue/src/composables/reactivity/index.ts | 4 + .../{ => reactivity}/useCached/index.test.ts | 0 .../{ => reactivity}/useCached/index.ts | 0 .../useLastChanged/index.test.ts | 0 .../{ => reactivity}/useLastChanged/index.ts | 2 +- .../useSyncRefs/index.test.ts | 0 .../{ => reactivity}/useSyncRefs/index.ts | 0 web/vue/src/composables/state/index.ts | 6 + .../useAppSharedState/index.test.ts | 0 .../{ => state}/useAppSharedState/index.ts | 0 .../{ => state}/useAsyncState/index.test.ts | 0 .../{ => state}/useAsyncState/index.ts | 0 .../useContextFactory/index.test.ts | 2 +- .../{ => state}/useContextFactory/index.ts | 2 +- .../{ => state}/useCounter/demo.vue | 0 .../{ => state}/useCounter/index.test.ts | 0 .../{ => state}/useCounter/index.ts | 2 +- .../useInjectionStore/index.test.ts | 0 .../{ => state}/useInjectionStore/index.ts | 0 .../composables/state/useToggle/index.test.ts | 110 ++++++ .../{ => state}/useToggle/index.ts | 29 +- web/vue/src/composables/storage/index.ts | 4 + .../storage/useLocalStorage/index.test.ts | 72 ++++ .../storage/useLocalStorage/index.ts | 40 +++ .../storage/useSessionStorage/index.test.ts | 72 ++++ .../storage/useSessionStorage/index.ts | 40 +++ .../storage/useStorage/index.test.ts | 318 +++++++++++++++++ .../composables/storage/useStorage/index.ts | 223 ++++++++++++ .../storage/useStorageAsync/index.test.ts | 335 ++++++++++++++++++ .../storage/useStorageAsync/index.ts | 186 ++++++++++ web/vue/src/composables/utilities/index.ts | 1 + .../useOffsetPagination/index.test.ts | 0 .../useOffsetPagination/index.ts | 2 +- web/vue/src/types/flush.ts | 10 + web/vue/src/types/index.ts | 1 + web/vue/src/types/window.ts | 11 +- web/vue/tsconfig.json | 9 +- web/vue/vitest.config.ts | 13 + 64 files changed, 1564 insertions(+), 55 deletions(-) create mode 100644 web/vue/src/composables/browser/index.ts rename web/vue/src/composables/{ => browser}/useEventListener/index.ts (96%) rename web/vue/src/composables/{ => browser}/useFocusGuard/index.test.ts (100%) rename web/vue/src/composables/{ => browser}/useFocusGuard/index.ts (97%) rename web/vue/src/composables/{ => browser}/useSupported/index.test.ts (100%) rename web/vue/src/composables/{ => browser}/useSupported/index.ts (89%) create mode 100644 web/vue/src/composables/component/index.ts rename web/vue/src/composables/{ => component}/unrefElement/index.test.ts (100%) rename web/vue/src/composables/{ => component}/unrefElement/index.ts (98%) rename web/vue/src/composables/{ => component}/useRenderCount/index.test.ts (100%) rename web/vue/src/composables/{ => component}/useRenderCount/index.ts (86%) rename web/vue/src/composables/{ => component}/useRenderInfo/index.test.ts (100%) rename web/vue/src/composables/{ => component}/useRenderInfo/index.ts (95%) create mode 100644 web/vue/src/composables/lifecycle/index.ts rename web/vue/src/composables/{ => lifecycle}/tryOnBeforeMount/index.ts (94%) rename web/vue/src/composables/{ => lifecycle}/tryOnMounted/index.test.ts (100%) rename web/vue/src/composables/{ => lifecycle}/tryOnMounted/index.ts (94%) rename web/vue/src/composables/{ => lifecycle}/tryOnScopeDispose/index.test.ts (100%) rename web/vue/src/composables/{ => lifecycle}/tryOnScopeDispose/index.ts (96%) rename web/vue/src/composables/{ => lifecycle}/useMounted/index.test.ts (100%) rename web/vue/src/composables/{ => lifecycle}/useMounted/index.ts (91%) create mode 100644 web/vue/src/composables/math/index.ts rename web/vue/src/composables/{ => math}/useClamp/index.test.ts (100%) rename web/vue/src/composables/{ => math}/useClamp/index.ts (100%) create mode 100644 web/vue/src/composables/reactivity/broadcastedRef/index.ts create mode 100644 web/vue/src/composables/reactivity/index.ts rename web/vue/src/composables/{ => reactivity}/useCached/index.test.ts (100%) rename web/vue/src/composables/{ => reactivity}/useCached/index.ts (100%) rename web/vue/src/composables/{ => reactivity}/useLastChanged/index.test.ts (100%) rename web/vue/src/composables/{ => reactivity}/useLastChanged/index.ts (98%) rename web/vue/src/composables/{ => reactivity}/useSyncRefs/index.test.ts (100%) rename web/vue/src/composables/{ => reactivity}/useSyncRefs/index.ts (100%) create mode 100644 web/vue/src/composables/state/index.ts rename web/vue/src/composables/{ => state}/useAppSharedState/index.test.ts (100%) rename web/vue/src/composables/{ => state}/useAppSharedState/index.ts (100%) rename web/vue/src/composables/{ => state}/useAsyncState/index.test.ts (100%) rename web/vue/src/composables/{ => state}/useAsyncState/index.ts (100%) rename web/vue/src/composables/{ => state}/useContextFactory/index.test.ts (97%) rename web/vue/src/composables/{ => state}/useContextFactory/index.ts (97%) rename web/vue/src/composables/{ => state}/useCounter/demo.vue (100%) rename web/vue/src/composables/{ => state}/useCounter/index.test.ts (100%) rename web/vue/src/composables/{ => state}/useCounter/index.ts (98%) rename web/vue/src/composables/{ => state}/useInjectionStore/index.test.ts (100%) rename web/vue/src/composables/{ => state}/useInjectionStore/index.ts (100%) create mode 100644 web/vue/src/composables/state/useToggle/index.test.ts rename web/vue/src/composables/{ => state}/useToggle/index.ts (51%) create mode 100644 web/vue/src/composables/storage/index.ts create mode 100644 web/vue/src/composables/storage/useLocalStorage/index.test.ts create mode 100644 web/vue/src/composables/storage/useLocalStorage/index.ts create mode 100644 web/vue/src/composables/storage/useSessionStorage/index.test.ts create mode 100644 web/vue/src/composables/storage/useSessionStorage/index.ts create mode 100644 web/vue/src/composables/storage/useStorage/index.test.ts create mode 100644 web/vue/src/composables/storage/useStorage/index.ts create mode 100644 web/vue/src/composables/storage/useStorageAsync/index.test.ts create mode 100644 web/vue/src/composables/storage/useStorageAsync/index.ts create mode 100644 web/vue/src/composables/utilities/index.ts rename web/vue/src/composables/{ => utilities}/useOffsetPagination/index.test.ts (100%) rename web/vue/src/composables/{ => utilities}/useOffsetPagination/index.ts (98%) create mode 100644 web/vue/src/types/flush.ts create mode 100644 web/vue/vitest.config.ts diff --git a/web/vue/src/composables/browser/index.ts b/web/vue/src/composables/browser/index.ts new file mode 100644 index 0000000..fdd3cfb --- /dev/null +++ b/web/vue/src/composables/browser/index.ts @@ -0,0 +1,3 @@ +export * from './useEventListener'; +export * from './useFocusGuard'; +export * from './useSupported'; diff --git a/web/vue/src/composables/useEventListener/index.ts b/web/vue/src/composables/browser/useEventListener/index.ts similarity index 96% rename from web/vue/src/composables/useEventListener/index.ts rename to web/vue/src/composables/browser/useEventListener/index.ts index 62e779f..cf68751 100644 --- a/web/vue/src/composables/useEventListener/index.ts +++ b/web/vue/src/composables/browser/useEventListener/index.ts @@ -1,6 +1,6 @@ import { isArray, isString, noop, type Arrayable, type VoidFunction } from '@robonen/stdlib'; import type { MaybeRefOrGetter } from 'vue'; -import { defaultWindow } from '../..'; +import { defaultWindow } from '@/types'; // TODO: wip @@ -19,7 +19,7 @@ export type ElementEventName = keyof HTMLElementEventMap; /** * @name useEventListener - * @category Elements + * @category Browser * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * * Overload 1: Omitted window target @@ -32,7 +32,7 @@ export function useEventListener( /** * @name useEventListener - * @category Elements + * @category Browser * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * * Overload 2: Explicit window target @@ -46,7 +46,7 @@ export function useEventListener( /** * @name useEventListener - * @category Elements + * @category Browser * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * * Overload 3: Explicit document target @@ -60,7 +60,7 @@ export function useEventListener( /** * @name useEventListener - * @category Elements + * @category Browser * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * * Overload 4: Explicit HTMLElement target @@ -74,7 +74,7 @@ export function useEventListener( /** * @name useEventListener - * @category Elements + * @category Browser * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * * Overload 5: Custom target with inferred event type @@ -88,7 +88,7 @@ export function useEventListener( /** * @name useEventListener - * @category Elements + * @category Browser * @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted * * Overload 6: Custom event target fallback diff --git a/web/vue/src/composables/useFocusGuard/index.test.ts b/web/vue/src/composables/browser/useFocusGuard/index.test.ts similarity index 100% rename from web/vue/src/composables/useFocusGuard/index.test.ts rename to web/vue/src/composables/browser/useFocusGuard/index.test.ts diff --git a/web/vue/src/composables/useFocusGuard/index.ts b/web/vue/src/composables/browser/useFocusGuard/index.ts similarity index 97% rename from web/vue/src/composables/useFocusGuard/index.ts rename to web/vue/src/composables/browser/useFocusGuard/index.ts index a547522..db2e83c 100644 --- a/web/vue/src/composables/useFocusGuard/index.ts +++ b/web/vue/src/composables/browser/useFocusGuard/index.ts @@ -6,7 +6,7 @@ let counter = 0; /** * @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 * * @param {string} [namespace] - A namespace to group the focus guards diff --git a/web/vue/src/composables/useSupported/index.test.ts b/web/vue/src/composables/browser/useSupported/index.test.ts similarity index 100% rename from web/vue/src/composables/useSupported/index.test.ts rename to web/vue/src/composables/browser/useSupported/index.test.ts diff --git a/web/vue/src/composables/useSupported/index.ts b/web/vue/src/composables/browser/useSupported/index.ts similarity index 89% rename from web/vue/src/composables/useSupported/index.ts rename to web/vue/src/composables/browser/useSupported/index.ts index 8a39902..fc669ea 100644 --- a/web/vue/src/composables/useSupported/index.ts +++ b/web/vue/src/composables/browser/useSupported/index.ts @@ -1,9 +1,9 @@ import { computed } from 'vue'; -import { useMounted } from '../useMounted'; +import { useMounted } from '@/composables/lifecycle/useMounted'; /** * @name useSupported - * @category Utilities + * @category Browser * @description SSR-friendly way to check if a feature is supported * * @param {Function} feature The feature to check for support diff --git a/web/vue/src/composables/component/index.ts b/web/vue/src/composables/component/index.ts new file mode 100644 index 0000000..85867cc --- /dev/null +++ b/web/vue/src/composables/component/index.ts @@ -0,0 +1,3 @@ +export * from './unrefElement'; +export * from './useRenderCount'; +export * from './useRenderInfo'; diff --git a/web/vue/src/composables/unrefElement/index.test.ts b/web/vue/src/composables/component/unrefElement/index.test.ts similarity index 100% rename from web/vue/src/composables/unrefElement/index.test.ts rename to web/vue/src/composables/component/unrefElement/index.test.ts diff --git a/web/vue/src/composables/unrefElement/index.ts b/web/vue/src/composables/component/unrefElement/index.ts similarity index 98% rename from web/vue/src/composables/unrefElement/index.ts rename to web/vue/src/composables/component/unrefElement/index.ts index 2cca254..d5e7e22 100644 --- a/web/vue/src/composables/unrefElement/index.ts +++ b/web/vue/src/composables/component/unrefElement/index.ts @@ -11,7 +11,7 @@ export type UnRefElementReturn = T extend /** * @name unrefElement - * @category Components + * @category Component * @description Unwraps a Vue element reference to get the underlying instance or DOM element. * * @param {MaybeComputedElementRef} elRef - The element reference to unwrap. diff --git a/web/vue/src/composables/useRenderCount/index.test.ts b/web/vue/src/composables/component/useRenderCount/index.test.ts similarity index 100% rename from web/vue/src/composables/useRenderCount/index.test.ts rename to web/vue/src/composables/component/useRenderCount/index.test.ts diff --git a/web/vue/src/composables/useRenderCount/index.ts b/web/vue/src/composables/component/useRenderCount/index.ts similarity index 86% rename from web/vue/src/composables/useRenderCount/index.ts rename to web/vue/src/composables/component/useRenderCount/index.ts index d40737b..19e8a29 100644 --- a/web/vue/src/composables/useRenderCount/index.ts +++ b/web/vue/src/composables/component/useRenderCount/index.ts @@ -1,10 +1,10 @@ import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue'; -import { useCounter } from '../useCounter'; -import { getLifeCycleTarger } from '../..'; +import { useCounter } from '@/composables/state/useCounter'; +import { getLifeCycleTarger } from '@/utils'; /** * @name useRenderCount - * @category Components + * @category Component * @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 diff --git a/web/vue/src/composables/useRenderInfo/index.test.ts b/web/vue/src/composables/component/useRenderInfo/index.test.ts similarity index 100% rename from web/vue/src/composables/useRenderInfo/index.test.ts rename to web/vue/src/composables/component/useRenderInfo/index.test.ts diff --git a/web/vue/src/composables/useRenderInfo/index.ts b/web/vue/src/composables/component/useRenderInfo/index.ts similarity index 95% rename from web/vue/src/composables/useRenderInfo/index.ts rename to web/vue/src/composables/component/useRenderInfo/index.ts index bbb4a1f..482dc04 100644 --- a/web/vue/src/composables/useRenderInfo/index.ts +++ b/web/vue/src/composables/component/useRenderInfo/index.ts @@ -1,11 +1,11 @@ import { timestamp } from '@robonen/stdlib'; import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref, type ComponentInternalInstance } from 'vue'; import { useRenderCount } from '../useRenderCount'; -import { getLifeCycleTarger } from '../..'; +import { getLifeCycleTarger } from '@/utils'; /** * @name useRenderInfo - * @category Components + * @category Component * @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 diff --git a/web/vue/src/composables/index.ts b/web/vue/src/composables/index.ts index 68b3cb3..2e69457 100644 --- a/web/vue/src/composables/index.ts +++ b/web/vue/src/composables/index.ts @@ -1,19 +1,8 @@ -export * from './tryOnBeforeMount'; -export * from './tryOnMounted'; -export * from './tryOnScopeDispose'; -export * from './unrefElement'; -export * from './useAppSharedState'; -export * from './useAsyncState'; -export * from './useCached'; -export * from './useClamp'; -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'; +export * from './browser'; +export * from './component'; +export * from './lifecycle'; +export * from './math'; +export * from './reactivity'; +export * from './state'; +export * from './storage'; +export * from './utilities'; diff --git a/web/vue/src/composables/lifecycle/index.ts b/web/vue/src/composables/lifecycle/index.ts new file mode 100644 index 0000000..cecbcba --- /dev/null +++ b/web/vue/src/composables/lifecycle/index.ts @@ -0,0 +1,4 @@ +export * from './tryOnBeforeMount'; +export * from './tryOnMounted'; +export * from './tryOnScopeDispose'; +export * from './useMounted'; diff --git a/web/vue/src/composables/tryOnBeforeMount/index.ts b/web/vue/src/composables/lifecycle/tryOnBeforeMount/index.ts similarity index 94% rename from web/vue/src/composables/tryOnBeforeMount/index.ts rename to web/vue/src/composables/lifecycle/tryOnBeforeMount/index.ts index fc63aa1..337923b 100644 --- a/web/vue/src/composables/tryOnBeforeMount/index.ts +++ b/web/vue/src/composables/lifecycle/tryOnBeforeMount/index.ts @@ -1,5 +1,5 @@ import { onBeforeMount, nextTick, type ComponentInternalInstance } from 'vue'; -import { getLifeCycleTarger } from '../..'; +import { getLifeCycleTarger } from '@/utils'; import type { VoidFunction } from '@robonen/stdlib'; // TODO: test @@ -11,7 +11,7 @@ export interface TryOnBeforeMountOptions { /** * @name tryOnBeforeMount - * @category Components + * @category Lifecycle * @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. diff --git a/web/vue/src/composables/tryOnMounted/index.test.ts b/web/vue/src/composables/lifecycle/tryOnMounted/index.test.ts similarity index 100% rename from web/vue/src/composables/tryOnMounted/index.test.ts rename to web/vue/src/composables/lifecycle/tryOnMounted/index.test.ts diff --git a/web/vue/src/composables/tryOnMounted/index.ts b/web/vue/src/composables/lifecycle/tryOnMounted/index.ts similarity index 94% rename from web/vue/src/composables/tryOnMounted/index.ts rename to web/vue/src/composables/lifecycle/tryOnMounted/index.ts index 67232f1..8bc43ef 100644 --- a/web/vue/src/composables/tryOnMounted/index.ts +++ b/web/vue/src/composables/lifecycle/tryOnMounted/index.ts @@ -1,5 +1,5 @@ import { onMounted, nextTick, type ComponentInternalInstance } from 'vue'; -import { getLifeCycleTarger } from '../..'; +import { getLifeCycleTarger } from '@/utils'; import type { VoidFunction } from '@robonen/stdlib'; // TODO: tests @@ -11,7 +11,7 @@ export interface TryOnMountedOptions { /** * @name tryOnMounted - * @category Components + * @category Lifecycle * @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it * * @param {VoidFunction} fn The function to call diff --git a/web/vue/src/composables/tryOnScopeDispose/index.test.ts b/web/vue/src/composables/lifecycle/tryOnScopeDispose/index.test.ts similarity index 100% rename from web/vue/src/composables/tryOnScopeDispose/index.test.ts rename to web/vue/src/composables/lifecycle/tryOnScopeDispose/index.test.ts diff --git a/web/vue/src/composables/tryOnScopeDispose/index.ts b/web/vue/src/composables/lifecycle/tryOnScopeDispose/index.ts similarity index 96% rename from web/vue/src/composables/tryOnScopeDispose/index.ts rename to web/vue/src/composables/lifecycle/tryOnScopeDispose/index.ts index f5189ef..7439016 100644 --- a/web/vue/src/composables/tryOnScopeDispose/index.ts +++ b/web/vue/src/composables/lifecycle/tryOnScopeDispose/index.ts @@ -3,7 +3,7 @@ import { getCurrentScope, onScopeDispose } from 'vue'; /** * @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. * * @param {VoidFunction} callback - The callback to run when the scope is disposed. diff --git a/web/vue/src/composables/useMounted/index.test.ts b/web/vue/src/composables/lifecycle/useMounted/index.test.ts similarity index 100% rename from web/vue/src/composables/useMounted/index.test.ts rename to web/vue/src/composables/lifecycle/useMounted/index.test.ts diff --git a/web/vue/src/composables/useMounted/index.ts b/web/vue/src/composables/lifecycle/useMounted/index.ts similarity index 91% rename from web/vue/src/composables/useMounted/index.ts rename to web/vue/src/composables/lifecycle/useMounted/index.ts index 1b92e6e..a1e34e2 100644 --- a/web/vue/src/composables/useMounted/index.ts +++ b/web/vue/src/composables/lifecycle/useMounted/index.ts @@ -1,9 +1,9 @@ import { onMounted, readonly, ref, type ComponentInternalInstance } from 'vue'; -import { getLifeCycleTarger } from '../..'; +import { getLifeCycleTarger } from '@/utils'; /** * @name useMounted - * @category Components + * @category Lifecycle * @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 diff --git a/web/vue/src/composables/math/index.ts b/web/vue/src/composables/math/index.ts new file mode 100644 index 0000000..1dcfd64 --- /dev/null +++ b/web/vue/src/composables/math/index.ts @@ -0,0 +1 @@ +export * from './useClamp'; diff --git a/web/vue/src/composables/useClamp/index.test.ts b/web/vue/src/composables/math/useClamp/index.test.ts similarity index 100% rename from web/vue/src/composables/useClamp/index.test.ts rename to web/vue/src/composables/math/useClamp/index.test.ts diff --git a/web/vue/src/composables/useClamp/index.ts b/web/vue/src/composables/math/useClamp/index.ts similarity index 100% rename from web/vue/src/composables/useClamp/index.ts rename to web/vue/src/composables/math/useClamp/index.ts diff --git a/web/vue/src/composables/reactivity/broadcastedRef/index.ts b/web/vue/src/composables/reactivity/broadcastedRef/index.ts new file mode 100644 index 0000000..240fb9c --- /dev/null +++ b/web/vue/src/composables/reactivity/broadcastedRef/index.ts @@ -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} A custom ref that broadcasts value changes across tabs + * + * @example + * const count = broadcastedRef('counter', 0); + * + * @since 0.0.1 + */ +export function broadcastedRef(key: string, initialValue: T) { + const channel = new BroadcastChannel(key); + + onScopeDispose(channel.close); + + return customRef((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(); + }, + }; + }); +} \ No newline at end of file diff --git a/web/vue/src/composables/reactivity/index.ts b/web/vue/src/composables/reactivity/index.ts new file mode 100644 index 0000000..a17f1fc --- /dev/null +++ b/web/vue/src/composables/reactivity/index.ts @@ -0,0 +1,4 @@ +export * from './broadcastedRef'; +export * from './useCached'; +export * from './useLastChanged'; +export * from './useSyncRefs'; diff --git a/web/vue/src/composables/useCached/index.test.ts b/web/vue/src/composables/reactivity/useCached/index.test.ts similarity index 100% rename from web/vue/src/composables/useCached/index.test.ts rename to web/vue/src/composables/reactivity/useCached/index.test.ts diff --git a/web/vue/src/composables/useCached/index.ts b/web/vue/src/composables/reactivity/useCached/index.ts similarity index 100% rename from web/vue/src/composables/useCached/index.ts rename to web/vue/src/composables/reactivity/useCached/index.ts diff --git a/web/vue/src/composables/useLastChanged/index.test.ts b/web/vue/src/composables/reactivity/useLastChanged/index.test.ts similarity index 100% rename from web/vue/src/composables/useLastChanged/index.test.ts rename to web/vue/src/composables/reactivity/useLastChanged/index.test.ts diff --git a/web/vue/src/composables/useLastChanged/index.ts b/web/vue/src/composables/reactivity/useLastChanged/index.ts similarity index 98% rename from web/vue/src/composables/useLastChanged/index.ts rename to web/vue/src/composables/reactivity/useLastChanged/index.ts index dea5e24..4f5752e 100644 --- a/web/vue/src/composables/useLastChanged/index.ts +++ b/web/vue/src/composables/reactivity/useLastChanged/index.ts @@ -10,7 +10,7 @@ export interface UseLastChangedOptions< /** * @name useLastChanged - * @category State + * @category Reactivity * @description Records the last time a value changed * * @param {WatchSource} source The value to track diff --git a/web/vue/src/composables/useSyncRefs/index.test.ts b/web/vue/src/composables/reactivity/useSyncRefs/index.test.ts similarity index 100% rename from web/vue/src/composables/useSyncRefs/index.test.ts rename to web/vue/src/composables/reactivity/useSyncRefs/index.test.ts diff --git a/web/vue/src/composables/useSyncRefs/index.ts b/web/vue/src/composables/reactivity/useSyncRefs/index.ts similarity index 100% rename from web/vue/src/composables/useSyncRefs/index.ts rename to web/vue/src/composables/reactivity/useSyncRefs/index.ts diff --git a/web/vue/src/composables/state/index.ts b/web/vue/src/composables/state/index.ts new file mode 100644 index 0000000..bf2c4fe --- /dev/null +++ b/web/vue/src/composables/state/index.ts @@ -0,0 +1,6 @@ +export * from './useAppSharedState'; +export * from './useAsyncState'; +export * from './useContextFactory'; +export * from './useCounter'; +export * from './useInjectionStore'; +export * from './useToggle'; diff --git a/web/vue/src/composables/useAppSharedState/index.test.ts b/web/vue/src/composables/state/useAppSharedState/index.test.ts similarity index 100% rename from web/vue/src/composables/useAppSharedState/index.test.ts rename to web/vue/src/composables/state/useAppSharedState/index.test.ts diff --git a/web/vue/src/composables/useAppSharedState/index.ts b/web/vue/src/composables/state/useAppSharedState/index.ts similarity index 100% rename from web/vue/src/composables/useAppSharedState/index.ts rename to web/vue/src/composables/state/useAppSharedState/index.ts diff --git a/web/vue/src/composables/useAsyncState/index.test.ts b/web/vue/src/composables/state/useAsyncState/index.test.ts similarity index 100% rename from web/vue/src/composables/useAsyncState/index.test.ts rename to web/vue/src/composables/state/useAsyncState/index.test.ts diff --git a/web/vue/src/composables/useAsyncState/index.ts b/web/vue/src/composables/state/useAsyncState/index.ts similarity index 100% rename from web/vue/src/composables/useAsyncState/index.ts rename to web/vue/src/composables/state/useAsyncState/index.ts diff --git a/web/vue/src/composables/useContextFactory/index.test.ts b/web/vue/src/composables/state/useContextFactory/index.test.ts similarity index 97% rename from web/vue/src/composables/useContextFactory/index.test.ts rename to web/vue/src/composables/state/useContextFactory/index.test.ts index 5272ffe..8a62719 100644 --- a/web/vue/src/composables/useContextFactory/index.test.ts +++ b/web/vue/src/composables/state/useContextFactory/index.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { defineComponent } from 'vue'; import { useContextFactory } from '.'; import { mount } from '@vue/test-utils'; -import { VueToolsError } from '../../utils'; +import { VueToolsError } from '@/utils'; function testFactory( data: Data, diff --git a/web/vue/src/composables/useContextFactory/index.ts b/web/vue/src/composables/state/useContextFactory/index.ts similarity index 97% rename from web/vue/src/composables/useContextFactory/index.ts rename to web/vue/src/composables/state/useContextFactory/index.ts index 3bd6749..5334a9b 100644 --- a/web/vue/src/composables/useContextFactory/index.ts +++ b/web/vue/src/composables/state/useContextFactory/index.ts @@ -1,5 +1,5 @@ import { inject as vueInject, provide as vueProvide, type InjectionKey, type App } from 'vue'; -import { VueToolsError } from '../..'; +import { VueToolsError } from '@/utils'; /** * @name useContextFactory diff --git a/web/vue/src/composables/useCounter/demo.vue b/web/vue/src/composables/state/useCounter/demo.vue similarity index 100% rename from web/vue/src/composables/useCounter/demo.vue rename to web/vue/src/composables/state/useCounter/demo.vue diff --git a/web/vue/src/composables/useCounter/index.test.ts b/web/vue/src/composables/state/useCounter/index.test.ts similarity index 100% rename from web/vue/src/composables/useCounter/index.test.ts rename to web/vue/src/composables/state/useCounter/index.test.ts diff --git a/web/vue/src/composables/useCounter/index.ts b/web/vue/src/composables/state/useCounter/index.ts similarity index 98% rename from web/vue/src/composables/useCounter/index.ts rename to web/vue/src/composables/state/useCounter/index.ts index 41ef8e4..860e81d 100644 --- a/web/vue/src/composables/useCounter/index.ts +++ b/web/vue/src/composables/state/useCounter/index.ts @@ -17,7 +17,7 @@ export interface UseConterReturn { /** * @name useCounter - * @category Utilities + * @category State * @description A composable that provides a counter with increment, decrement, set, get, and reset functions * * @param {MaybeRef} [initialValue=0] The initial value of the counter diff --git a/web/vue/src/composables/useInjectionStore/index.test.ts b/web/vue/src/composables/state/useInjectionStore/index.test.ts similarity index 100% rename from web/vue/src/composables/useInjectionStore/index.test.ts rename to web/vue/src/composables/state/useInjectionStore/index.test.ts diff --git a/web/vue/src/composables/useInjectionStore/index.ts b/web/vue/src/composables/state/useInjectionStore/index.ts similarity index 100% rename from web/vue/src/composables/useInjectionStore/index.ts rename to web/vue/src/composables/state/useInjectionStore/index.ts diff --git a/web/vue/src/composables/state/useToggle/index.test.ts b/web/vue/src/composables/state/useToggle/index.test.ts new file mode 100644 index 0000000..deece4d --- /dev/null +++ b/web/vue/src/composables/state/useToggle/index.test.ts @@ -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); + }); +}); diff --git a/web/vue/src/composables/useToggle/index.ts b/web/vue/src/composables/state/useToggle/index.ts similarity index 51% rename from web/vue/src/composables/useToggle/index.ts rename to web/vue/src/composables/state/useToggle/index.ts index d80e4b8..c5e7794 100644 --- a/web/vue/src/composables/useToggle/index.ts +++ b/web/vue/src/composables/state/useToggle/index.ts @@ -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 { truthyValue?: MaybeRefOrGetter, falsyValue?: MaybeRefOrGetter, } -export function useToggle( - initialValue?: MaybeRef, - options?: UseToggleOptions, -): { value: Ref, toggle: (value?: Truthy | Falsy) => Truthy | Falsy }; +export interface UseToggleReturn { + value: Ref; + 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} [initialValue=false] The initial value + * @param {UseToggleOptions} [options={}] Options for custom truthy/falsy values + * @returns {UseToggleReturn} 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( initialValue: MaybeRef = false as Truthy | Falsy, options: UseToggleOptions = {}, -) { +): UseToggleReturn { const { truthyValue = true as Truthy, falsyValue = false as Falsy, diff --git a/web/vue/src/composables/storage/index.ts b/web/vue/src/composables/storage/index.ts new file mode 100644 index 0000000..a64c221 --- /dev/null +++ b/web/vue/src/composables/storage/index.ts @@ -0,0 +1,4 @@ +export * from './useLocalStorage'; +export * from './useSessionStorage'; +export * from './useStorage'; +export * from './useStorageAsync'; diff --git a/web/vue/src/composables/storage/useLocalStorage/index.test.ts b/web/vue/src/composables/storage/useLocalStorage/index.test.ts new file mode 100644 index 0000000..2a3bb65 --- /dev/null +++ b/web/vue/src/composables/storage/useLocalStorage/index.test.ts @@ -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('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('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('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('ls-no-write', 'default', { + writeDefaults: false, + }); + + expect(state.value).toBe('default'); + expect(localStorage.getItem('ls-no-write')).toBeNull(); + }); +}); diff --git a/web/vue/src/composables/storage/useLocalStorage/index.ts b/web/vue/src/composables/storage/useLocalStorage/index.ts new file mode 100644 index 0000000..c610ee6 --- /dev/null +++ b/web/vue/src/composables/storage/useLocalStorage/index.ts @@ -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} initialValue The initial/default value + * @param {UseStorageOptions} [options={}] Options + * @returns {Ref} 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(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useLocalStorage(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useLocalStorage(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useLocalStorage(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useLocalStorage(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useLocalStorage( + key: string, + initialValue: MaybeRefOrGetter, + options: UseStorageOptions = {}, +): Ref { + const storage = defaultWindow?.localStorage; + + if (!storage) + throw new VueToolsError('useLocalStorage: localStorage is not available'); + + return useStorage(key, initialValue, storage, options); +} diff --git a/web/vue/src/composables/storage/useSessionStorage/index.test.ts b/web/vue/src/composables/storage/useSessionStorage/index.test.ts new file mode 100644 index 0000000..af329cd --- /dev/null +++ b/web/vue/src/composables/storage/useSessionStorage/index.test.ts @@ -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('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('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('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('ss-no-write', 'default', { + writeDefaults: false, + }); + + expect(state.value).toBe('default'); + expect(sessionStorage.getItem('ss-no-write')).toBeNull(); + }); +}); diff --git a/web/vue/src/composables/storage/useSessionStorage/index.ts b/web/vue/src/composables/storage/useSessionStorage/index.ts new file mode 100644 index 0000000..85a1f2d --- /dev/null +++ b/web/vue/src/composables/storage/useSessionStorage/index.ts @@ -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} initialValue The initial/default value + * @param {UseStorageOptions} [options={}] Options + * @returns {Ref} 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(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useSessionStorage(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useSessionStorage(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useSessionStorage(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useSessionStorage(key: string, initialValue: MaybeRefOrGetter, options?: UseStorageOptions): Ref; +export function useSessionStorage( + key: string, + initialValue: MaybeRefOrGetter, + options: UseStorageOptions = {}, +): Ref { + const storage = defaultWindow?.sessionStorage; + + if (!storage) + throw new VueToolsError('useSessionStorage: sessionStorage is not available'); + + return useStorage(key, initialValue, storage, options); +} diff --git a/web/vue/src/composables/storage/useStorage/index.test.ts b/web/vue/src/composables/storage/useStorage/index.test.ts new file mode 100644 index 0000000..78303be --- /dev/null +++ b/web/vue/src/composables/storage/useStorage/index.test.ts @@ -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 } { + const store = new Map(); + + 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('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('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('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('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('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('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('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('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'); + }); +}); diff --git a/web/vue/src/composables/storage/useStorage/index.ts b/web/vue/src/composables/storage/useStorage/index.ts new file mode 100644 index 0000000..5d3d82b --- /dev/null +++ b/web/vue/src/composables/storage/useStorage/index.ts @@ -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 { + read: (raw: string) => T; + write: (value: T) => string; +} + +export const StorageSerializers: { [K: string]: StorageSerializer } & { + boolean: StorageSerializer; + number: StorageSerializer; + string: StorageSerializer; + object: StorageSerializer; + map: StorageSerializer>; + set: StorageSerializer>; + date: StorageSerializer; +} = { + 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) => JSON.stringify([...v.entries()]), + }, + set: { + read: (v: string) => new Set(JSON.parse(v)), + write: (v: Set) => 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 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; + /** + * 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 = Ref; + +export function guessSerializer(value: T): StorageSerializer { + 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(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} initialValue The initial/default value + * @param {StorageLike} storage The storage backend + * @param {UseStorageOptions} [options={}] Options + * @returns {Ref} 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(key: string, initialValue: MaybeRefOrGetter, storage: StorageLike, options?: UseStorageOptions): Ref; +export function useStorage(key: string, initialValue: MaybeRefOrGetter, storage: StorageLike, options?: UseStorageOptions): Ref; +export function useStorage(key: string, initialValue: MaybeRefOrGetter, storage: StorageLike, options?: UseStorageOptions): Ref; +export function useStorage(key: string, initialValue: MaybeRefOrGetter, storage: StorageLike, options?: UseStorageOptions): Ref; +export function useStorage(key: string, initialValue: MaybeRefOrGetter, storage: StorageLike, options?: UseStorageOptions): Ref; +export function useStorage( + key: string, + initialValue: MaybeRefOrGetter, + storage: StorageLike, + options: UseStorageOptions = {}, +): Ref { + 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; + + 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; +} diff --git a/web/vue/src/composables/storage/useStorageAsync/index.test.ts b/web/vue/src/composables/storage/useStorageAsync/index.test.ts new file mode 100644 index 0000000..4c00bcc --- /dev/null +++ b/web/vue/src/composables/storage/useStorageAsync/index.test.ts @@ -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 } { + const store = new Map(); + + 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 } { + const store = new Map(); + + 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('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('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('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('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('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'); + }); +}); diff --git a/web/vue/src/composables/storage/useStorageAsync/index.ts b/web/vue/src/composables/storage/useStorageAsync/index.ts new file mode 100644 index 0000000..870b321 --- /dev/null +++ b/web/vue/src/composables/storage/useStorageAsync/index.ts @@ -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 { + read: (raw: string) => T | Promise; + write: (value: T) => string | Promise; +} + +export interface StorageLikeAsync { + getItem: (key: string) => string | null | Promise; + setItem: (key: string, value: string) => void | Promise; + removeItem: (key: string) => void | Promise; +} + +export interface UseStorageAsyncOptions 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; + /** + * 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 { + state: Shallow extends true ? ShallowRef : Ref>; + isReady: Ref; +} + +export type UseStorageAsyncReturn = + & UseStorageAsyncReturnBase + & PromiseLike>; + +/** + * @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} initialValue The initial/default value + * @param {StorageLikeAsync} storage The async storage backend + * @param {UseStorageAsyncOptions} [options={}] Options + * @returns {UseStorageAsyncReturn} 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(key: string, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; +export function useStorageAsync(key: string, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; +export function useStorageAsync(key: string, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; +export function useStorageAsync(key: string, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; +export function useStorageAsync(key: string, initialValue: MaybeRefOrGetter, storage: StorageLikeAsync, options?: UseStorageAsyncOptions): UseStorageAsyncReturn; +export function useStorageAsync( + key: string, + initialValue: MaybeRefOrGetter, + storage: StorageLikeAsync, + options: UseStorageAsyncOptions = {}, +): UseStorageAsyncReturn { + 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 : Ref>; + const isReady = ref(false); + + async function read(): Promise { + 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 = { + state, + isReady, + }; + + const readyPromise: Promise> = 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); + }, + }; +} diff --git a/web/vue/src/composables/utilities/index.ts b/web/vue/src/composables/utilities/index.ts new file mode 100644 index 0000000..901130d --- /dev/null +++ b/web/vue/src/composables/utilities/index.ts @@ -0,0 +1 @@ +export * from './useOffsetPagination'; diff --git a/web/vue/src/composables/useOffsetPagination/index.test.ts b/web/vue/src/composables/utilities/useOffsetPagination/index.test.ts similarity index 100% rename from web/vue/src/composables/useOffsetPagination/index.test.ts rename to web/vue/src/composables/utilities/useOffsetPagination/index.test.ts diff --git a/web/vue/src/composables/useOffsetPagination/index.ts b/web/vue/src/composables/utilities/useOffsetPagination/index.ts similarity index 98% rename from web/vue/src/composables/useOffsetPagination/index.ts rename to web/vue/src/composables/utilities/useOffsetPagination/index.ts index 65bd7c0..3fe7cde 100644 --- a/web/vue/src/composables/useOffsetPagination/index.ts +++ b/web/vue/src/composables/utilities/useOffsetPagination/index.ts @@ -1,6 +1,6 @@ import type { VoidFunction } from '@robonen/stdlib'; 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 diff --git a/web/vue/src/types/flush.ts b/web/vue/src/types/flush.ts new file mode 100644 index 0000000..7d5cfdd --- /dev/null +++ b/web/vue/src/types/flush.ts @@ -0,0 +1,10 @@ +import type { WatchOptions } from 'vue'; + +export interface ConfigurableFlush { + /** + * Timing for the watcher flush + * + * @default 'pre' + */ + flush?: WatchOptions['flush']; +} diff --git a/web/vue/src/types/index.ts b/web/vue/src/types/index.ts index bb5135c..370b87b 100644 --- a/web/vue/src/types/index.ts +++ b/web/vue/src/types/index.ts @@ -1,2 +1,3 @@ +export * from './flush'; export * from './resumable'; export * from './window'; \ No newline at end of file diff --git a/web/vue/src/types/window.ts b/web/vue/src/types/window.ts index bfd076b..91675bb 100644 --- a/web/vue/src/types/window.ts +++ b/web/vue/src/types/window.ts @@ -1,3 +1,12 @@ import { isClient } from '@robonen/platform/multi'; -export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined \ No newline at end of file +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; +} \ No newline at end of file diff --git a/web/vue/tsconfig.json b/web/vue/tsconfig.json index 2d43941..0ca905e 100644 --- a/web/vue/tsconfig.json +++ b/web/vue/tsconfig.json @@ -1,6 +1,13 @@ { "extends": "@robonen/tsconfig/tsconfig.json", "compilerOptions": { - "lib": ["DOM"] + "lib": ["DOM"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@/composables/*": ["src/composables/*"], + "@/types": ["src/types"], + "@/utils": ["src/utils"] + } } } \ No newline at end of file diff --git a/web/vue/vitest.config.ts b/web/vue/vitest.config.ts new file mode 100644 index 0000000..0bef73c --- /dev/null +++ b/web/vue/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + }, +}); From 5f9e0dc72dcad916cfc84806e35a34534e7c17b2 Mon Sep 17 00:00:00 2001 From: robonen Date: Sat, 14 Feb 2026 21:44:54 +0700 Subject: [PATCH 2/2] feat: add separate vitest configuration files for platform and stdlib environments --- core/platform/vitest.config.ts | 8 ++++++++ core/stdlib/vitest.config.ts | 7 +++++++ vitest.config.ts | 12 +++--------- 3 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 core/platform/vitest.config.ts create mode 100644 core/stdlib/vitest.config.ts diff --git a/core/platform/vitest.config.ts b/core/platform/vitest.config.ts new file mode 100644 index 0000000..69e22cf --- /dev/null +++ b/core/platform/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +}); + diff --git a/core/stdlib/vitest.config.ts b/core/stdlib/vitest.config.ts new file mode 100644 index 0000000..4ac6027 --- /dev/null +++ b/core/stdlib/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 456750e..05448c6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,16 +3,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { projects: [ - { - extends: true, - test: { - typecheck: { - enabled: false, - }, - }, - }, + 'core/stdlib/vitest.config.ts', + 'core/platform/vitest.config.ts', + 'web/vue/vitest.config.ts', ], - environment: 'jsdom', coverage: { provider: 'v8', include: ['core/*', 'web/*'],