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

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

This commit is contained in:
2026-03-07 18:07:22 +07:00
parent abd6605db3
commit 41d5e18f6b
286 changed files with 10295 additions and 5028 deletions

28
vue/toolkit/README.md Normal file
View File

@@ -0,0 +1,28 @@
# @robonen/vue
Collection of composables and utilities for Vue 3.
## Install
```bash
pnpm install @robonen/vue
```
## Composables
| Category | Composables |
| -------------- | ------------------------------------------------------------------ |
| **browser** | `useEventListener`, `useFocusGuard`, `useSupported` |
| **component** | `unrefElement`, `useRenderCount`, `useRenderInfo` |
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
| **math** | `useClamp` |
| **reactivity** | `broadcastedRef`, `useCached`, `useLastChanged`, `useSyncRefs` |
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useInjectionStore`, `useToggle` |
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
| **utilities** | `useOffsetPagination` |
## Usage
```ts
import { useToggle, useEventListener } from '@robonen/vue';
```

7
vue/toolkit/jsr.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/vue",
"license": "Apache-2.0",
"version": "0.0.7",
"exports": "./src/index.ts"
}

View File

@@ -0,0 +1,4 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, vue, vitest, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, vue, vitest, imports, stylistic));

55
vue/toolkit/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "@robonen/vue",
"version": "0.0.13",
"license": "Apache-2.0",
"description": "Collection of powerful tools for Vue",
"keywords": [
"vue",
"tools",
"ui",
"utilities",
"composables"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "vue/toolkit"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"@vue/test-utils": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
},
"dependencies": {
"@robonen/platform": "workspace:*",
"@robonen/stdlib": "workspace:*",
"vue": "catalog:"
}
}

View File

@@ -0,0 +1,7 @@
export * from './useEventListener';
export * from './useFocusGuard';
export * from './useFps';
export * from './useIntervalFn';
export * from './useRafFn';
export * from './useSupported';
export * from './useTabLeader';

View File

@@ -0,0 +1,402 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { defineComponent, effectScope, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useEventListener } from '.';
const mountWithEventListener = (
setup: () => Record<string, any> | void,
) => {
return mount(
defineComponent({
setup,
template: '<div></div>',
}),
);
};
describe(useEventListener, () => {
let component: ReturnType<typeof mountWithEventListener>;
afterEach(() => {
component?.unmount();
});
it('register and trigger a listener on an explicit target', async () => {
const listener = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
target.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledOnce();
});
it('remove listener when stop is called', async () => {
const listener = vi.fn();
const target = document.createElement('div');
let stop: () => void;
component = mountWithEventListener(() => {
stop = useEventListener(target, 'click', listener);
});
await nextTick();
stop!();
target.dispatchEvent(new Event('click'));
expect(listener).not.toHaveBeenCalled();
});
it('remove listener when component is unmounted', async () => {
const listener = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
component.unmount();
target.dispatchEvent(new Event('click'));
expect(listener).not.toHaveBeenCalled();
});
it('register multiple events at once', async () => {
const listener = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, ['click', 'focus'], listener);
});
await nextTick();
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('focus'));
expect(listener).toHaveBeenCalledTimes(2);
});
it('register multiple listeners at once', async () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, 'click', [listener1, listener2]);
});
await nextTick();
target.dispatchEvent(new Event('click'));
expect(listener1).toHaveBeenCalledOnce();
expect(listener2).toHaveBeenCalledOnce();
});
it('register multiple events and multiple listeners', async () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, ['click', 'focus'], [listener1, listener2]);
});
await nextTick();
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('focus'));
expect(listener1).toHaveBeenCalledTimes(2);
expect(listener2).toHaveBeenCalledTimes(2);
});
it('react to a reactive target change', async () => {
const listener = vi.fn();
const el1 = document.createElement('div');
const el2 = document.createElement('div');
const target = ref<HTMLElement>(el1);
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
el1.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
target.value = el2;
await nextTick();
// Old target should no longer trigger listener
el1.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
// New target should trigger listener
el2.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(2);
});
it('cleanup when reactive target becomes null', async () => {
const listener = vi.fn();
const el = document.createElement('div');
const target = ref<HTMLElement | null>(el);
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
el.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
target.value = null;
await nextTick();
el.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
});
it('return noop when target is undefined', () => {
const listener = vi.fn();
const stop = useEventListener(undefined as any, 'click', listener);
expect(stop).toBeTypeOf('function');
stop(); // should not throw
expect(listener).not.toHaveBeenCalled();
});
it('pass options to addEventListener', async () => {
const target = document.createElement('div');
const addSpy = vi.spyOn(target, 'addEventListener');
component = mountWithEventListener(() => {
useEventListener(target, 'click', () => {}, { capture: true });
});
await nextTick();
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), { capture: true });
});
it('use window as default target when event string is passed directly', async () => {
const listener = vi.fn();
const addSpy = vi.spyOn(globalThis, 'addEventListener');
const removeSpy = vi.spyOn(globalThis, 'removeEventListener');
component = mountWithEventListener(() => {
useEventListener('click', listener);
});
await nextTick();
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
component.unmount();
expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
addSpy.mockRestore();
removeSpy.mockRestore();
});
it('use window as default target when event array is passed directly', async () => {
const listener = vi.fn();
const addSpy = vi.spyOn(globalThis, 'addEventListener');
component = mountWithEventListener(() => {
useEventListener(['click', 'keydown'], listener);
});
await nextTick();
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), undefined);
addSpy.mockRestore();
});
it('work with document target', async () => {
const listener = vi.fn();
component = mountWithEventListener(() => {
useEventListener(document, 'click', listener);
});
await nextTick();
document.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledOnce();
});
it('auto cleanup when effectScope is disposed', async () => {
const listener = vi.fn();
const target = document.createElement('div');
const scope = effectScope();
scope.run(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
target.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
scope.stop();
target.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledTimes(1);
});
it('re-register when reactive options change', async () => {
const target = document.createElement('div');
const listener = vi.fn();
const options = ref<boolean | AddEventListenerOptions>(false);
const addSpy = vi.spyOn(target, 'addEventListener');
const removeSpy = vi.spyOn(target, 'removeEventListener');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener, options);
});
await nextTick();
expect(addSpy).toHaveBeenCalledTimes(1);
expect(addSpy).toHaveBeenLastCalledWith('click', listener, false);
options.value = true;
await nextTick();
expect(removeSpy).toHaveBeenCalledTimes(1);
expect(addSpy).toHaveBeenCalledTimes(2);
expect(addSpy).toHaveBeenLastCalledWith('click', listener, true);
});
it('pass correct arguments to removeEventListener on stop', async () => {
const listener = vi.fn();
const options = { capture: true };
const target = document.createElement('div');
const removeSpy = vi.spyOn(target, 'removeEventListener');
let stop: () => void;
component = mountWithEventListener(() => {
stop = useEventListener(target, 'click', listener, options);
});
await nextTick();
stop!();
expect(removeSpy).toHaveBeenCalledWith('click', listener, { capture: true });
});
it('remove all listeners for all events on stop', async () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
const events = ['click', 'scroll', 'blur'];
const options = { capture: true };
const target = document.createElement('div');
const removeSpy = vi.spyOn(target, 'removeEventListener');
let stop: () => void;
component = mountWithEventListener(() => {
stop = useEventListener(target, events, [listener1, listener2], options);
});
await nextTick();
stop!();
expect(removeSpy).toHaveBeenCalledTimes(events.length * 2);
for (const event of events) {
expect(removeSpy).toHaveBeenCalledWith(event, listener1, { capture: true });
expect(removeSpy).toHaveBeenCalledWith(event, listener2, { capture: true });
}
});
it('clone object options to prevent reactive mutation issues', async () => {
const target = document.createElement('div');
const listener = vi.fn();
const options = ref<AddEventListenerOptions>({ capture: true });
const addSpy = vi.spyOn(target, 'addEventListener');
const removeSpy = vi.spyOn(target, 'removeEventListener');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener, options);
});
await nextTick();
expect(addSpy).toHaveBeenCalledWith('click', listener, { capture: true });
// Change options reactively — old removal should use the snapshotted options
options.value = { capture: false };
await nextTick();
expect(removeSpy).toHaveBeenCalledWith('click', listener, { capture: true });
expect(addSpy).toHaveBeenLastCalledWith('click', listener, { capture: false });
});
it('not listen when reactive target starts as null', async () => {
const listener = vi.fn();
const target = ref<HTMLElement | null>(null);
const el = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
await nextTick();
el.dispatchEvent(new Event('click'));
expect(listener).not.toHaveBeenCalled();
// Set target later
target.value = el;
await nextTick();
el.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledOnce();
});
it('register listener synchronously for static target', () => {
const listener = vi.fn();
const target = document.createElement('div');
component = mountWithEventListener(() => {
useEventListener(target, 'click', listener);
});
// No nextTick needed — listener is registered synchronously for static targets
target.dispatchEvent(new Event('click'));
expect(listener).toHaveBeenCalledOnce();
});
it('register listener synchronously for default window target', () => {
const listener = vi.fn();
const addSpy = vi.spyOn(globalThis, 'addEventListener');
component = mountWithEventListener(() => {
useEventListener('click', listener);
});
// No nextTick needed — registered synchronously
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
addSpy.mockRestore();
});
});

View File

@@ -0,0 +1,180 @@
import type { Arrayable, VoidFunction } from '@robonen/stdlib';
import { first, isArray, isFunction, isObject, isString, noop } from '@robonen/stdlib';
import { isRef, toValue, watch } from 'vue';
import { defaultWindow } from '@/types';
import type { MaybeRefOrGetter } from 'vue';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
interface InferEventTarget<Events> {
addEventListener: (event: Events, listener?: any, options?: any) => any;
removeEventListener: (event: Events, listener?: any, options?: any) => any;
}
export type GeneralEventListener<E = Event> = (evt: E) => void;
export type WindowEventName = keyof WindowEventMap;
export type DocumentEventName = keyof DocumentEventMap;
export type ElementEventName = keyof HTMLElementEventMap;
type ListenerOptions = boolean | AddEventListenerOptions;
/**
* @name useEventListener
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 1: Omitted window target
*/
export function useEventListener<E extends WindowEventName>(
event: Arrayable<E>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
/**
* @name useEventListener
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 2: Explicit window target
*/
export function useEventListener<E extends WindowEventName>(
target: Window,
event: Arrayable<E>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
/**
* @name useEventListener
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 3: Explicit document target
*/
export function useEventListener<E extends DocumentEventName>(
target: Document,
event: Arrayable<E>,
listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
/**
* @name useEventListener
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 4: Explicit HTMLElement target
*/
export function useEventListener<E extends ElementEventName>(
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
event: Arrayable<E>,
listener: Arrayable<(this: HTMLElement, ev: HTMLElementEventMap[E]) => any>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
/**
* @name useEventListener
* @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
*/
export function useEventListener<Names extends string, EventType = Event>(
target: MaybeRefOrGetter<InferEventTarget<Names> | null | undefined>,
event: Arrayable<Names>,
listener: Arrayable<GeneralEventListener<EventType>>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
/**
* @name useEventListener
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 6: Custom event target fallback
*/
export function useEventListener<EventType = Event>(
target: MaybeRefOrGetter<EventTarget | null | undefined>,
event: Arrayable<string>,
listener: Arrayable<GeneralEventListener<EventType>>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
export function useEventListener(...args: any[]) {
let target: MaybeRefOrGetter<EventTarget> | undefined = defaultWindow;
let _events: Arrayable<string>;
let _listeners: Arrayable<EventListener>;
let _options: MaybeRefOrGetter<ListenerOptions> | undefined;
if (isString(first(args)) || isArray(first(args))) {
[_events, _listeners, _options] = args;
}
else {
[target, _events, _listeners, _options] = args;
}
if (!target)
return noop;
const events = isArray(_events) ? _events : [_events];
const listeners = isArray(_listeners) ? _listeners : [_listeners];
const cleanups: VoidFunction[] = [];
const _cleanup = () => {
cleanups.forEach(fn => fn());
cleanups.length = 0;
};
const _register = (el: EventTarget, event: string, listener: EventListener, options: ListenerOptions | undefined) => {
el.addEventListener(event, listener, options);
return () => el.removeEventListener(event, listener, options);
};
const _registerAll = (el: EventTarget, options: ListenerOptions | undefined) => {
// Clone object options to avoid reactive mutation between add/remove
const optionsClone = isObject(options) ? { ...options } : options;
for (const event of events) {
for (const listener of listeners) {
cleanups.push(_register(el, event, listener, optionsClone));
}
}
};
const isTargetReactive = isRef(target) || isFunction(target);
const isOptionsReactive = isRef(_options) || isFunction(_options);
// Reactive path: use watch for ref/getter targets (e.g., template refs)
if (isTargetReactive || isOptionsReactive) {
const stopWatch = watch(
() => [toValue(target), toValue(_options)] as const,
([el, options]) => {
_cleanup();
if (!el)
return;
_registerAll(el, options);
},
{ immediate: true, flush: 'post' },
);
const stop = () => {
stopWatch();
_cleanup();
};
tryOnScopeDispose(stop);
return stop;
}
// Fast path: static target — register synchronously, no watch overhead
_registerAll(target as EventTarget, _options as ListenerOptions);
tryOnScopeDispose(_cleanup);
return _cleanup;
}

View File

@@ -0,0 +1,69 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, nextTick } from 'vue';
import { useFocusGuard } from '.';
const setupFocusGuard = (namespace?: string) => {
return mount(
defineComponent({
setup() {
useFocusGuard(namespace);
},
template: '<div></div>',
}),
);
};
const getFocusGuards = (namespace: string) =>
document.querySelectorAll(`[data-${namespace}]`);
describe(useFocusGuard, () => {
let component: ReturnType<typeof setupFocusGuard>;
const namespace = 'test-guard';
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
component.unmount();
});
it('create focus guards when mounted', async () => {
component = setupFocusGuard(namespace);
const guards = getFocusGuards(namespace);
expect(guards).toHaveLength(2);
guards.forEach((guard) => {
expect(guard.getAttribute('tabindex')).toBe('0');
expect(guard.getAttribute('style')).toContain('opacity: 0');
});
});
it('remove focus guards when unmounted', () => {
component = setupFocusGuard(namespace);
component.unmount();
expect(getFocusGuards(namespace)).toHaveLength(0);
});
it('correctly manage multiple instances with the same namespace', () => {
const wrapper1 = setupFocusGuard(namespace);
const wrapper2 = setupFocusGuard(namespace);
// Guards should not be duplicated
expect(getFocusGuards(namespace)).toHaveLength(2);
wrapper1.unmount();
// Second instance still keeps the guards
expect(getFocusGuards(namespace)).toHaveLength(2);
wrapper2.unmount();
// No guards left after all instances are unmounted
expect(getFocusGuards(namespace)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,40 @@
import { focusGuard } from '@robonen/platform/browsers';
import { onMounted, onUnmounted } from 'vue';
// Global counter to drop the focus guards when the last instance is unmounted
let counter = 0;
/**
* @name useFocusGuard
* @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
* @returns {void}
*
* @example
* useFocusGuard();
*
* @example
* useFocusGuard('my-namespace');
*
* @since 0.0.2
*/
export function useFocusGuard(namespace?: string) {
const manager = focusGuard(namespace);
const createGuard = () => {
manager.createGuard();
counter++;
};
const removeGuard = () => {
if (counter <= 1)
manager.removeGuard();
counter = Math.max(0, counter - 1);
};
onMounted(createGuard);
onUnmounted(removeGuard);
}

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">import { useFps } from './index';
const { fps, min, max, isActive, reset, toggle } = useFps({ every: 10 });
</script>
<template>
<div class="space-y-4">
<div class="flex items-end gap-8">
<div>
<div class="text-4xl font-mono font-bold tabular-nums text-(--color-text)">{{ fps }}</div>
<div class="text-xs text-(--color-text-mute) mt-1">FPS</div>
</div>
<div>
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ min === Infinity ? '—' : min }}</div>
<div class="text-xs text-(--color-text-mute) mt-1">Min</div>
</div>
<div>
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ max || '—' }}</div>
<div class="text-xs text-(--color-text-mute) mt-1">Max</div>
</div>
</div>
<div class="h-2 rounded-full border border-(--color-border) overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="fps >= 50 ? 'bg-emerald-500' : fps >= 30 ? 'bg-amber-500' : 'bg-red-500'"
:style="{ width: `${Math.min(fps / 60 * 100, 100)}%` }"
/>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
@click="reset"
>
Reset
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,244 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { effectScope } from 'vue';
import { useFps } from '.';
let rafCallbacks: Array<(time: number) => void> = [];
let rafIdCounter = 0;
beforeEach(() => {
rafCallbacks = [];
rafIdCounter = 0;
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
const id = ++rafIdCounter;
rafCallbacks.push(cb);
return id;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
function triggerFrame(time: number) {
const cbs = [...rafCallbacks];
rafCallbacks = [];
cbs.forEach(cb => cb(time));
}
function triggerFrames(startTime: number, interval: number, count: number) {
for (let i = 0; i < count; i++) {
triggerFrame(startTime + i * interval);
}
}
describe(useFps, () => {
it('starts at 0 fps', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps();
});
expect(result!.fps.value).toBe(0);
scope.stop();
});
it('reports fps after "every" frames', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 5 });
});
// ~60fps = 16.67ms per frame
// First frame has delta=0, skipped by useFps. Need 5 real-delta frames.
triggerFrame(100); // delta=0, skipped
triggerFrame(116.67); // delta=16.67
triggerFrame(133.33); // delta=16.66
triggerFrame(150); // delta=16.67
triggerFrame(166.67); // delta=16.67
triggerFrame(183.33); // delta=16.66 → 5 deltas collected, update
expect(result!.fps.value).toBe(60);
scope.stop();
});
it('does not update fps before collecting enough frames', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 10 });
});
triggerFrame(100);
triggerFrame(116.67);
triggerFrame(133.33);
expect(result!.fps.value).toBe(0);
scope.stop();
});
it('tracks min and max fps', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 3 });
});
// First batch: ~60fps (16.67ms intervals)
triggerFrame(100); // delta=0, skipped
triggerFrame(116.67); // delta=16.67
triggerFrame(133.33); // delta=16.66
triggerFrame(150); // delta=16.67 → 3 deltas, update
const firstFps = result!.fps.value;
expect(firstFps).toBe(60);
// Second batch: ~30fps (33.33ms intervals)
triggerFrame(183.33); // delta=33.33
triggerFrame(216.67); // delta=33.34
triggerFrame(250); // delta=33.33 → 3 deltas, update
const secondFps = result!.fps.value;
expect(secondFps).toBe(30);
expect(result!.max.value).toBe(60);
expect(result!.min.value).toBe(30);
scope.stop();
});
it('resets min, max, and fps', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 3 });
});
triggerFrame(100);
triggerFrame(116.67);
triggerFrame(133.33);
triggerFrame(150);
expect(result!.fps.value).toBe(60);
result!.reset();
expect(result!.fps.value).toBe(0);
expect(result!.min.value).toBe(Infinity);
expect(result!.max.value).toBe(0);
scope.stop();
});
it('cleans up on scope dispose', () => {
const scope = effectScope();
scope.run(() => {
useFps();
});
// Should not throw on stop
scope.stop();
// No more raf callbacks should be registered after stop
triggerFrame(100);
expect(rafCallbacks).toHaveLength(0);
});
it('does nothing when window is undefined (SSR)', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ window: undefined as any });
});
expect(result!.fps.value).toBe(0);
scope.stop();
});
it('is active by default', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps();
});
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
it('does not start when immediate is false', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ immediate: false });
});
expect(result!.isActive.value).toBeFalsy();
scope.stop();
});
it('pauses and resumes fps tracking', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 3 });
});
// Collect one batch
triggerFrame(100);
triggerFrame(116.67);
triggerFrame(133.33);
triggerFrame(150);
expect(result!.fps.value).toBe(60);
result!.pause();
expect(result!.isActive.value).toBeFalsy();
// Frames while paused should not update
triggerFrame(200);
triggerFrame(300);
triggerFrame(400);
triggerFrame(500);
expect(result!.fps.value).toBe(60);
result!.resume();
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
it('toggles fps tracking', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps();
});
expect(result!.isActive.value).toBeTruthy();
result!.toggle();
expect(result!.isActive.value).toBeFalsy();
result!.toggle();
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
});

View File

@@ -0,0 +1,109 @@
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
import type { UseRafFnCallbackArgs } from '@/composables/browser/useRafFn';
import type { Ref } from 'vue';
import { ref } from 'vue';
import { useRafFn } from '@/composables/browser/useRafFn';
export interface UseFpsOptions extends ResumableOptions, ConfigurableWindow {
/**
* Number of frames to average over for a smoother reading.
*
* @default 10
*/
every?: number;
}
export interface UseFpsReturn extends ResumableActions {
/**
* Current frames per second (averaged over the last `every` frames)
*/
fps: Readonly<Ref<number>>;
/**
* Minimum FPS recorded since the composable was created or last reset
*/
min: Readonly<Ref<number>>;
/**
* Maximum FPS recorded since the composable was created or last reset
*/
max: Readonly<Ref<number>>;
/**
* Whether the FPS counter is currently active
*/
isActive: Readonly<Ref<boolean>>;
/**
* Reset min/max tracking
*/
reset: () => void;
}
/**
* Reactive FPS counter based on `requestAnimationFrame`.
* Reports a smoothed FPS value averaged over a configurable number of frames,
* and tracks min/max values.
*
* @param options - Configuration options
*
* @example
* ```ts
* const { fps, min, max, reset } = useFps();
* ```
*/
export function useFps(options: UseFpsOptions = {}): UseFpsReturn {
const { every = 10, ...rafOptions } = options;
const fps = ref(0);
const min = ref(Infinity);
const max = ref(0);
let deltaSum = 0;
let frameCount = 0;
function update({ delta }: UseRafFnCallbackArgs) {
if (!delta)
return;
deltaSum += delta;
frameCount++;
if (frameCount < every)
return;
const currentFps = Math.round(1000 / (deltaSum / frameCount));
fps.value = currentFps;
if (currentFps < min.value)
min.value = currentFps;
if (currentFps > max.value)
max.value = currentFps;
deltaSum = 0;
frameCount = 0;
}
function reset() {
min.value = Infinity;
max.value = 0;
fps.value = 0;
deltaSum = 0;
frameCount = 0;
}
const { isActive, pause, resume, toggle } = useRafFn(update, rafOptions);
return {
fps,
min,
max,
isActive,
reset,
pause,
resume,
toggle,
};
}

View File

@@ -0,0 +1,259 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { defineComponent, effectScope, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useIntervalFn } from '.';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const ComponentStub = defineComponent({
props: {
callback: {
type: Function,
required: true,
},
interval: {
type: Number,
default: 1000,
},
options: {
type: Object,
default: () => ({}),
},
},
setup(props) {
const result = useIntervalFn(props.callback as () => void, props.interval, props.options);
return { ...result };
},
template: '<div>{{ isActive }}</div>',
});
describe(useIntervalFn, () => {
it('starts immediately by default', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
expect(wrapper.text()).toBe('true');
});
it('does not start when immediate is false', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: {
callback,
options: { immediate: false },
},
});
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(5000);
expect(callback).not.toHaveBeenCalled();
});
it('calls callback on each interval', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: { callback, interval: 500 },
});
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(1500);
expect(callback).toHaveBeenCalledTimes(5);
});
it('calls callback immediately when immediateCallback is true', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: {
callback,
interval: 1000,
options: { immediateCallback: true },
},
});
expect(callback).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(2);
});
it('pauses and resumes', async () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback, interval: 100 },
});
vi.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(3);
wrapper.vm.pause();
await nextTick();
expect(wrapper.text()).toBe('false');
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(3);
wrapper.vm.resume();
await nextTick();
expect(wrapper.text()).toBe('true');
vi.advanceTimersByTime(200);
expect(callback).toHaveBeenCalledTimes(5);
});
it('toggles the interval', async () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
expect(wrapper.text()).toBe('true');
wrapper.vm.toggle();
await nextTick();
expect(wrapper.text()).toBe('false');
wrapper.vm.toggle();
await nextTick();
expect(wrapper.text()).toBe('true');
});
it('supports reactive interval', async () => {
const callback = vi.fn();
const interval = ref(1000);
const scope = effectScope();
scope.run(() => {
useIntervalFn(callback, interval);
});
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
// Change interval to 200ms — watcher triggers async
interval.value = 200;
await nextTick();
vi.advanceTimersByTime(200);
expect(callback).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(200);
expect(callback).toHaveBeenCalledTimes(3);
scope.stop();
});
it('does not fire with interval <= 0', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
const { isActive } = useIntervalFn(callback, 0);
expect(isActive.value).toBeFalsy();
});
vi.advanceTimersByTime(5000);
expect(callback).not.toHaveBeenCalled();
scope.stop();
});
it('cleans up on scope dispose', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
useIntervalFn(callback, 100);
});
vi.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(3);
scope.stop();
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(3);
});
it('cleans up on component unmount', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback, interval: 100 },
});
vi.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(3);
wrapper.unmount();
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(3);
});
it('resume is idempotent when already active', () => {
const callback = vi.fn();
const scope = effectScope();
let result: ReturnType<typeof useIntervalFn>;
scope.run(() => {
result = useIntervalFn(callback, 100);
});
expect(result!.isActive.value).toBeTruthy();
result!.resume();
expect(result!.isActive.value).toBeTruthy();
// Should still tick normally — no double interval
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledTimes(1);
scope.stop();
});
it('pause is idempotent when already paused', () => {
const callback = vi.fn();
const scope = effectScope();
let result: ReturnType<typeof useIntervalFn>;
scope.run(() => {
result = useIntervalFn(callback, 100, { immediate: false });
});
expect(result!.isActive.value).toBeFalsy();
result!.pause();
expect(result!.isActive.value).toBeFalsy();
scope.stop();
});
it('uses default interval of 1000ms', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
useIntervalFn(callback);
});
vi.advanceTimersByTime(999);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(callback).toHaveBeenCalledTimes(1);
scope.stop();
});
});

View File

@@ -0,0 +1,112 @@
import { readonly, ref, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import type { ResumableActions, ResumableOptions } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseIntervalFnOptions extends ResumableOptions {
/**
* Whether to invoke the callback immediately on start.
*
* @default false
*/
immediateCallback?: boolean;
}
export interface UseIntervalFnReturn extends ResumableActions {
/**
* Whether the interval is currently active
*/
isActive: Readonly<Ref<boolean>>;
}
/**
* Call a function on every interval. Supports reactive interval duration,
* pause/resume, and automatic cleanup on scope dispose.
*
* @param callback - Function to call on every interval tick
* @param interval - Interval duration in milliseconds (can be reactive)
* @param options - Configuration options
*
* @example
* ```ts
* const { pause, resume, isActive } = useIntervalFn(() => {
* console.log('tick');
* }, 1000);
* ```
*
* @example
* ```ts
* // Reactive interval
* const delay = ref(1000);
* useIntervalFn(() => console.log('tick'), delay);
* delay.value = 500; // interval restarts with new duration
* ```
*/
export function useIntervalFn(
callback: () => void,
interval: MaybeRefOrGetter<number> = 1000,
options: UseIntervalFnOptions = {},
): UseIntervalFnReturn {
const {
immediate = true,
immediateCallback = false,
} = options;
const isActive = ref(false);
let timerId: ReturnType<typeof setInterval> | null = null;
function clean() {
if (timerId !== null) {
clearInterval(timerId);
timerId = null;
}
}
function resume() {
const ms = toValue(interval);
if (ms <= 0)
return;
isActive.value = true;
if (immediateCallback)
callback();
clean();
timerId = setInterval(callback, ms);
}
function pause() {
isActive.value = false;
clean();
}
function toggle() {
if (isActive.value)
pause();
else
resume();
}
// Re-start when interval changes reactively
watch(() => toValue(interval), () => {
if (isActive.value) {
clean();
resume();
}
});
if (immediate)
resume();
tryOnScopeDispose(pause);
return {
isActive: readonly(isActive),
pause,
resume,
toggle,
};
}

View File

@@ -0,0 +1,252 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { defineComponent, effectScope, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { useRafFn } from '.';
let rafCallbacks: Array<(time: number) => void> = [];
let rafIdCounter = 0;
let currentTime = 0;
beforeEach(() => {
rafCallbacks = [];
rafIdCounter = 0;
currentTime = 0;
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
const id = ++rafIdCounter;
rafCallbacks.push(cb);
return id;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
function triggerFrame(time: number) {
currentTime = time;
const cbs = [...rafCallbacks];
rafCallbacks = [];
cbs.forEach(cb => cb(currentTime));
}
const ComponentStub = defineComponent({
props: {
callback: {
type: Function,
required: true,
},
options: {
type: Object,
default: () => ({}),
},
},
setup(props) {
const result = useRafFn(props.callback as any, props.options);
return { ...result };
},
template: '<div>{{ isActive }}</div>',
});
describe(useRafFn, () => {
it('starts immediately by default', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
expect(wrapper.text()).toBe('true');
});
it('does not start when immediate is false', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: {
callback,
options: { immediate: false },
},
});
expect(wrapper.text()).toBe('false');
expect(callback).not.toHaveBeenCalled();
});
it('calls the callback on animation frame with delta and timestamp', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
expect(callback).toHaveBeenCalledWith({ delta: 0, timestamp: 100 });
});
it('provides correct delta between frames', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
triggerFrame(116.67);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback.mock.calls[1]![0]!.delta).toBeCloseTo(16.67, 1);
});
it('pauses and resumes the loop', async () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
expect(callback).toHaveBeenCalledTimes(1);
wrapper.vm.pause();
await nextTick();
expect(wrapper.text()).toBe('false');
triggerFrame(200);
expect(callback).toHaveBeenCalledTimes(1);
wrapper.vm.resume();
await nextTick();
expect(wrapper.text()).toBe('true');
triggerFrame(300);
expect(callback).toHaveBeenCalledTimes(2);
});
it('resets delta after resume', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
wrapper.vm.pause();
wrapper.vm.resume();
triggerFrame(500);
// After resume, first frame delta resets to 0
const lastCall = callback.mock.calls[callback.mock.calls.length - 1]![0]!;
expect(lastCall.delta).toBe(0);
expect(lastCall.timestamp).toBe(500);
});
it('toggles the loop', async () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
expect(wrapper.text()).toBe('true');
wrapper.vm.toggle();
await nextTick();
expect(wrapper.text()).toBe('false');
wrapper.vm.toggle();
await nextTick();
expect(wrapper.text()).toBe('true');
});
it('limits frame rate with fpsLimit', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: {
callback,
options: { fpsLimit: 30 },
},
});
// First frame always fires (delta is 0)
triggerFrame(100);
expect(callback).toHaveBeenCalledTimes(1);
// 30fps = ~33.33ms per frame — too soon, skipped
triggerFrame(110);
expect(callback).toHaveBeenCalledTimes(1);
// Enough time passed (~40ms > 33.33ms)
triggerFrame(140);
expect(callback).toHaveBeenCalledTimes(2);
});
it('cleans up on scope dispose', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
useRafFn(callback);
});
triggerFrame(100);
expect(callback).toHaveBeenCalledTimes(1);
scope.stop();
triggerFrame(200);
expect(callback).toHaveBeenCalledTimes(1);
});
it('cleans up on component unmount', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
expect(callback).toHaveBeenCalledTimes(1);
wrapper.unmount();
triggerFrame(200);
expect(callback).toHaveBeenCalledTimes(1);
});
it('does nothing when window is undefined (SSR)', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
const { isActive } = useRafFn(callback, { window: undefined as any });
expect(isActive.value).toBeFalsy();
});
expect(callback).not.toHaveBeenCalled();
scope.stop();
});
it('resume is idempotent when already active', () => {
const scope = effectScope();
let result: ReturnType<typeof useRafFn>;
scope.run(() => {
result = useRafFn(vi.fn());
});
expect(result!.isActive.value).toBeTruthy();
result!.resume();
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
it('pause is idempotent when already paused', () => {
const scope = effectScope();
let result: ReturnType<typeof useRafFn>;
scope.run(() => {
result = useRafFn(vi.fn(), { immediate: false });
});
expect(result!.isActive.value).toBeFalsy();
result!.pause();
expect(result!.isActive.value).toBeFalsy();
scope.stop();
});
});

View File

@@ -0,0 +1,120 @@
import { readonly, ref } from 'vue';
import type { Ref } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseRafFnCallbackArgs {
/**
* Time elapsed since the last frame in milliseconds
*/
delta: number;
/**
* `DOMHighResTimeStamp` passed by `requestAnimationFrame`
*/
timestamp: DOMHighResTimeStamp;
}
export interface UseRafFnOptions extends ResumableOptions, ConfigurableWindow {
/**
* Maximum frames per second. Set to `0` or `undefined` to disable the limit.
*
* @default undefined
*/
fpsLimit?: number;
}
export interface UseRafFnReturn extends ResumableActions {
/**
* Whether the RAF loop is currently active
*/
isActive: Readonly<Ref<boolean>>;
}
/**
* Call a function on every `requestAnimationFrame` with delta time tracking.
* Automatically cleans up when the component scope is disposed.
*
* @param callback - Function to call on every animation frame
* @param options - Configuration options
*
* @example
* ```ts
* const { pause, resume, isActive } = useRafFn(({ delta, timestamp }) => {
* console.log(`${delta}ms since last frame`);
* });
* ```
*/
export function useRafFn(
callback: (args: UseRafFnCallbackArgs) => void,
options: UseRafFnOptions = {},
): UseRafFnReturn {
const {
immediate = true,
fpsLimit,
} = options;
const window = 'window' in options ? options.window : defaultWindow;
const isActive = ref(false);
const intervalLimit = fpsLimit ? 1000 / fpsLimit : null;
let previousFrameTimestamp = 0;
let rafId: number | null = null;
function loop(timestamp: DOMHighResTimeStamp) {
if (!isActive.value || !window)
return;
if (!previousFrameTimestamp)
previousFrameTimestamp = timestamp;
const delta = timestamp - previousFrameTimestamp;
if (intervalLimit && delta && delta < intervalLimit) {
rafId = window.requestAnimationFrame(loop);
return;
}
previousFrameTimestamp = timestamp;
callback({ delta, timestamp });
rafId = window.requestAnimationFrame(loop);
}
function resume() {
if (!isActive.value && window) {
isActive.value = true;
previousFrameTimestamp = 0;
rafId = window.requestAnimationFrame(loop);
}
}
function pause() {
isActive.value = false;
if (rafId !== null && window) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
}
function toggle() {
if (isActive.value)
pause();
else
resume();
}
if (immediate)
resume();
tryOnScopeDispose(pause);
return {
isActive: readonly(isActive),
pause,
resume,
toggle,
};
}

View File

@@ -0,0 +1,37 @@
import { defineComponent } from 'vue';
import { describe, it, expect } from 'vitest';
import { useSupported } from '.';
import { mount } from '@vue/test-utils';
const ComponentStub = defineComponent({
props: {
location: {
type: String,
default: 'location',
},
},
setup(props) {
const isSupported = useSupported(() => props.location in globalThis);
return { isSupported };
},
template: `<div>{{ isSupported }}</div>`,
});
describe(useSupported, () => {
it('return whether the feature is supported', async () => {
const component = mount(ComponentStub);
expect(component.text()).toBe('true');
});
it('return whether the feature is not supported', async () => {
const component = mount(ComponentStub, {
props: {
location: 'unsupported',
},
});
expect(component.text()).toBe('false');
});
});

View File

@@ -0,0 +1,30 @@
import { computed } from 'vue';
import { useMounted } from '@/composables/lifecycle/useMounted';
/**
* @name useSupported
* @category Browser
* @description SSR-friendly way to check if a feature is supported
*
* @param {Function} feature The feature to check for support
* @returns {ComputedRef<boolean>} Whether the feature is supported
*
* @example
* const isSupported = useSupported(() => 'IntersectionObserver' in window);
*
* @example
* const isSupported = useSupported(() => 'ResizeObserver' in window);
*
* @since 0.0.1
*/
export function useSupported(feature: () => unknown) {
const isMounted = useMounted();
return computed(() => {
// add reactive dependency on isMounted
// eslint-disable-next-line no-unused-expressions
isMounted.value;
return Boolean(feature());
});
}

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">import { useTabLeader } from './index';
const { isLeader, isSupported, acquire, release } = useTabLeader('docs-demo-leader');
</script>
<template>
<div class="space-y-4">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-(--color-text-soft)">Web Locks API:</span>
<span
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
:class="isSupported ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700' : 'border-red-500/30 bg-red-500/10 text-red-700'"
>
{{ isSupported ? 'Supported' : 'Not supported' }}
</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-(--color-text-soft)">Leader status:</span>
<span
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
:class="isLeader ? 'border-brand-500/30 bg-brand-500/10 text-brand-600' : 'border-(--color-border) bg-(--color-bg-mute) text-(--color-text-soft)'"
>
<span
class="w-2 h-2 rounded-full"
:class="isLeader ? 'bg-brand-500 animate-pulse' : 'bg-(--color-text-mute)'"
/>
{{ isLeader ? 'Leader' : 'Follower' }}
</span>
</div>
<p class="text-xs text-(--color-text-mute)">
Open this page in multiple tabs only one will be the leader.
Close the leader tab and another will take over automatically.
</p>
<div class="flex items-center gap-2 pt-2">
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!isSupported || isLeader"
@click="acquire"
>
Acquire
</button>
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!isSupported || !isLeader"
@click="release"
>
Release
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { defineComponent, effectScope, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { useTabLeader } from '.';
type LockGrantedCallback = (lock: unknown) => Promise<void>;
interface MockLockRequest {
key: string;
callback: LockGrantedCallback;
resolve: () => void;
signal?: AbortSignal;
}
const pendingRequests: MockLockRequest[] = [];
let heldLocks: Set<string>;
function setupLocksMock() {
heldLocks = new Set();
const mockLocks = {
request: vi.fn(async (key: string, options: { signal?: AbortSignal }, callback: LockGrantedCallback) => {
if (options.signal?.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
if (heldLocks.has(key)) {
// Queue the request — lock is held
return new Promise<void>((resolve) => {
const request: MockLockRequest = { key, callback, resolve, signal: options.signal };
options.signal?.addEventListener('abort', () => {
const index = pendingRequests.indexOf(request);
if (index > -1) pendingRequests.splice(index, 1);
resolve();
});
pendingRequests.push(request);
});
}
heldLocks.add(key);
const result = callback({} as unknown);
// When the callback promise resolves (lock released), grant to next waiter
result.then(() => {
heldLocks.delete(key);
grantNextLock(key);
});
return result;
}),
};
Object.defineProperty(navigator, 'locks', {
value: mockLocks,
writable: true,
configurable: true,
});
}
function grantNextLock(key: string) {
const index = pendingRequests.findIndex(r => r.key === key);
if (index === -1) return;
const [request] = pendingRequests.splice(index, 1);
if (!request) return;
heldLocks.add(key);
const result = request.callback({} as unknown);
result.then(() => {
heldLocks.delete(key);
request.resolve();
grantNextLock(key);
});
}
const mountWithComposable = (setup: () => Record<string, any> | void) => {
return mount(
defineComponent({
setup,
template: '<div></div>',
}),
);
};
describe(useTabLeader, () => {
let component: ReturnType<typeof mountWithComposable>;
beforeEach(() => {
pendingRequests.length = 0;
setupLocksMock();
});
afterEach(() => {
component?.unmount();
});
it('acquire leadership when lock is available', async () => {
component = mountWithComposable(() => {
const { isLeader, isSupported } = useTabLeader('test-leader');
return { isLeader, isSupported };
});
await nextTick();
expect(component.vm.isSupported).toBeTruthy();
expect(component.vm.isLeader).toBeTruthy();
});
it('not grant leadership when another tab holds the lock', async () => {
const scope1 = effectScope();
let leader1: ReturnType<typeof useTabLeader>;
scope1.run(() => {
leader1 = useTabLeader('exclusive');
});
const scope2 = effectScope();
let leader2: ReturnType<typeof useTabLeader>;
scope2.run(() => {
leader2 = useTabLeader('exclusive');
});
await nextTick();
expect(leader1!.isLeader.value).toBeTruthy();
expect(leader2!.isLeader.value).toBeFalsy();
scope1.stop();
scope2.stop();
});
it('transfer leadership when the leader releases the lock', async () => {
const scope1 = effectScope();
let leader1: ReturnType<typeof useTabLeader>;
scope1.run(() => {
leader1 = useTabLeader('transfer');
});
const scope2 = effectScope();
let leader2: ReturnType<typeof useTabLeader>;
scope2.run(() => {
leader2 = useTabLeader('transfer');
});
await nextTick();
expect(leader1!.isLeader.value).toBeTruthy();
expect(leader2!.isLeader.value).toBeFalsy();
// Leader 1 releases (e.g., tab closes)
scope1.stop();
await nextTick();
expect(leader1!.isLeader.value).toBeFalsy();
expect(leader2!.isLeader.value).toBeTruthy();
scope2.stop();
});
it('manually release and re-acquire leadership', async () => {
const scope = effectScope();
let leader: ReturnType<typeof useTabLeader>;
scope.run(() => {
leader = useTabLeader('manual');
});
await nextTick();
expect(leader!.isLeader.value).toBeTruthy();
leader!.release();
await nextTick();
expect(leader!.isLeader.value).toBeFalsy();
leader!.acquire();
await nextTick();
expect(leader!.isLeader.value).toBeTruthy();
scope.stop();
});
it('not acquire when immediate is false', async () => {
const scope = effectScope();
let leader: ReturnType<typeof useTabLeader>;
scope.run(() => {
leader = useTabLeader('deferred', { immediate: false });
});
await nextTick();
expect(leader!.isLeader.value).toBeFalsy();
expect(navigator.locks.request).not.toHaveBeenCalled();
leader!.acquire();
await nextTick();
expect(leader!.isLeader.value).toBeTruthy();
scope.stop();
});
it('fallback to isLeader always false when locks API is not supported', async () => {
Object.defineProperty(navigator, 'locks', {
value: undefined,
writable: true,
configurable: true,
});
component = mountWithComposable(() => {
const { isLeader, isSupported } = useTabLeader('unsupported');
return { isLeader, isSupported };
});
await nextTick();
expect(component.vm.isSupported).toBeFalsy();
expect(component.vm.isLeader).toBeFalsy();
});
});

View File

@@ -0,0 +1,115 @@
import { ref, readonly } from 'vue';
import type { Ref, DeepReadonly, ComputedRef } from 'vue';
import { useSupported } from '@/composables/browser/useSupported';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseTabLeaderOptions {
/**
* Immediately attempt to acquire leadership on creation
* @default true
*/
immediate?: boolean;
}
export interface UseTabLeaderReturn {
/**
* Whether the current tab is the leader
*/
isLeader: DeepReadonly<Ref<boolean>>;
/**
* Whether the Web Locks API is supported
*/
isSupported: ComputedRef<boolean>;
/**
* Manually acquire leadership
*/
acquire: () => void;
/**
* Manually release leadership
*/
release: () => void;
}
/**
* @name useTabLeader
* @category Browser
* @description Elects a single leader tab using the Web Locks API.
* Only one tab at a time holds the lock for a given key.
* When the leader tab closes or the scope is disposed, another tab automatically becomes the leader.
*
* @param {string} key A unique lock name identifying the leader group
* @param {UseTabLeaderOptions} [options={}] Options
* @returns {UseTabLeaderReturn} Leader state and controls
*
* @example
* const { isLeader } = useTabLeader('payment-polling');
*
* watchEffect(() => {
* if (isLeader.value) {
* // Only this tab performs polling
* startPolling();
* } else {
* stopPolling();
* }
* });
*
* @since 0.0.13
*/
export function useTabLeader(key: string, options: UseTabLeaderOptions = {}): UseTabLeaderReturn {
const { immediate = true } = options;
const isLeader = ref(false);
const isSupported = useSupported(() => navigator?.locks);
let releaseResolve: (() => void) | null = null;
let abortController: AbortController | null = null;
function acquire() {
if (!isSupported.value || abortController) return;
abortController = new AbortController();
navigator.locks.request(
key,
{ signal: abortController.signal },
() => {
isLeader.value = true;
return new Promise<void>((resolve) => {
releaseResolve = resolve;
});
},
).catch((error: unknown) => {
// AbortError is expected when release() is called before lock is acquired
if (error instanceof DOMException && error.name === 'AbortError') return;
throw error;
});
}
function release() {
isLeader.value = false;
if (releaseResolve) {
releaseResolve();
releaseResolve = null;
}
if (abortController) {
abortController.abort();
abortController = null;
}
}
if (immediate) {
acquire();
}
tryOnScopeDispose(release);
return {
isLeader: readonly(isLeader),
isSupported,
acquire,
release,
};
}

View File

@@ -0,0 +1,3 @@
export * from './unrefElement';
export * from './useForwardExpose';
export * from './useTemplateRefsList';

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { computed, defineComponent, nextTick, ref, shallowRef } from 'vue';
import { mount } from '@vue/test-utils';
import { unrefElement } from '.';
describe(unrefElement, () => {
it('returns a plain element when passed a raw element', () => {
const htmlEl = document.createElement('div');
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
expect(unrefElement(htmlEl)).toBe(htmlEl);
expect(unrefElement(svgEl)).toBe(svgEl);
});
it('returns element when passed a ref or shallowRef to an element', () => {
const el = document.createElement('div');
const elRef = ref<HTMLElement | null>(el);
const shallowElRef = shallowRef<HTMLElement | null>(el);
expect(unrefElement(elRef)).toBe(el);
expect(unrefElement(shallowElRef)).toBe(el);
});
it('returns element when passed a computed ref or getter function', () => {
const el = document.createElement('div');
const computedElRef = computed(() => el);
const elGetter = () => el;
expect(unrefElement(computedElRef)).toBe(el);
expect(unrefElement(elGetter)).toBe(el);
});
it('returns component $el when passed a component instance', async () => {
const Child = defineComponent({
template: `<span class="child-el">child</span>`,
});
const Parent = defineComponent({
components: { Child },
template: `<Child ref="childRef" />`,
});
const wrapper = mount(Parent);
await nextTick();
const childInstance = (wrapper.vm as any).$refs.childRef;
const result = unrefElement(childInstance);
expect(result).toBe(childInstance.$el);
expect((result as HTMLElement).classList.contains('child-el')).toBeTruthy();
});
it('handles null and undefined values', () => {
expect(unrefElement(undefined)).toBe(undefined);
expect(unrefElement(null)).toBe(null);
expect(unrefElement(ref<null>(null))).toBe(null);
expect(unrefElement(ref<undefined>(undefined))).toBe(undefined);
expect(unrefElement(() => null)).toBe(null);
expect(unrefElement(() => undefined)).toBe(undefined);
});
});

View File

@@ -0,0 +1,33 @@
import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter } from 'vue';
import { toValue } from 'vue';
export type VueInstance = ComponentPublicInstance;
export type MaybeElement = HTMLElement | SVGElement | VueInstance | undefined | null;
export type MaybeElementRef<El extends MaybeElement = MaybeElement> = MaybeRef<El>;
export type MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;
export type UnRefElementReturn<T extends MaybeElement = MaybeElement> = T extends VueInstance ? Exclude<MaybeElement, VueInstance> : T | undefined;
/**
* @name unrefElement
* @category Component
* @description Unwraps a Vue element reference to get the underlying instance or DOM element.
*
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.
* @returns {UnRefElementReturn<El>} - The unwrapped element or undefined.
*
* @example
* const element = useTemplateRef<HTMLElement>('element');
* const result = unrefElement(element); // result is the element instance
*
* @example
* const component = useTemplateRef<Component>('component');
* const result = unrefElement(component); // result is the component instance
*
* @since 0.0.11
*/
export function unrefElement<El extends MaybeElement>(elRef: MaybeComputedElementRef<El>): UnRefElementReturn<El> {
const plain = toValue(elRef);
return (plain as VueInstance)?.$el ?? plain;
}

View File

@@ -0,0 +1,276 @@
import { describe, expect, it } from 'vitest';
import type { UseForwardExposeReturn } from '.';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useForwardExpose } from '.';
describe(useForwardExpose, () => {
it('returns forwardRef, currentRef, and currentElement', () => {
let result!: UseForwardExposeReturn<any>;
const Component = defineComponent({
setup() {
result = useForwardExpose();
return {};
},
template: `<div>test</div>`,
});
mount(Component);
expect(result.forwardRef).toBeTypeOf('function');
expect(result.currentRef).toBeDefined();
expect(result.currentElement).toBeDefined();
});
it('exposes parent props on instance.exposed', () => {
const Component = defineComponent({
props: {
label: { type: String, default: 'hello' },
},
setup() {
useForwardExpose();
return {};
},
template: `<div>{{ label }}</div>`,
});
const wrapper = mount(Component, { props: { label: 'world' } });
expect(wrapper.vm.$.exposed).toBeDefined();
expect(wrapper.vm.$.exposed!.label).toBe('world');
});
it('exposes $el on instance.exposed', () => {
const Component = defineComponent({
setup() {
useForwardExpose();
return {};
},
template: `<div class="root">content</div>`,
});
const wrapper = mount(Component);
expect(wrapper.vm.$.exposed).toBeDefined();
expect(wrapper.vm.$.exposed!.$el).toBeInstanceOf(HTMLDivElement);
expect(wrapper.vm.$.exposed!.$el.classList.contains('root')).toBeTruthy();
});
it('forwardRef with a DOM element updates $el', async () => {
let result!: UseForwardExposeReturn<any>;
const Component = defineComponent({
setup() {
result = useForwardExpose();
return {};
},
template: `<div><span class="inner">inner</span></div>`,
});
const wrapper = mount(Component);
const innerSpan = wrapper.find('.inner').element;
result.forwardRef(innerSpan as Element);
await nextTick();
expect(result.currentRef.value).toBe(innerSpan);
expect(wrapper.vm.$.exposed!.$el).toBe(innerSpan);
});
it('forwardRef with a child component instance copies child exposed', async () => {
const Child = defineComponent({
setup(_, { expose }) {
const childValue = ref('from-child');
expose({ childValue });
return { childValue };
},
template: `<span class="child">child</span>`,
});
let result!: UseForwardExposeReturn<any>;
const Parent = defineComponent({
components: { Child },
setup() {
result = useForwardExpose();
return { forwardRef: result.forwardRef };
},
template: `<Child :ref="forwardRef" />`,
});
const wrapper = mount(Parent);
await nextTick();
// The parent's exposed should contain the child's exposed ref
expect(wrapper.vm.$.exposed).toBeDefined();
expect(wrapper.vm.$.exposed!.childValue).toEqual(ref('from-child'));
});
it('forwardRef with null clears currentRef without error', async () => {
let result!: UseForwardExposeReturn<any>;
const Component = defineComponent({
setup() {
result = useForwardExpose();
return {};
},
template: `<div>test</div>`,
});
mount(Component);
expect(() => result.forwardRef(null)).not.toThrow();
expect(result.currentRef.value).toBeNull();
});
it('merges prior expose bindings', () => {
const Component = defineComponent({
setup(_, { expose }) {
const custom = ref(42);
expose({ custom });
useForwardExpose();
return {};
},
template: `<div>test</div>`,
});
const wrapper = mount(Component);
expect(wrapper.vm.$.exposed).toBeDefined();
expect(wrapper.vm.$.exposed!.custom).toEqual(ref(42));
expect(wrapper.vm.$.exposed!.$el).toBeDefined();
});
it('currentElement resolves to HTMLElement for element ref', async () => {
let result!: UseForwardExposeReturn<any>;
const Component = defineComponent({
setup() {
result = useForwardExpose();
return {};
},
template: `<div class="resolved">content</div>`,
});
const wrapper = mount(Component);
const el = wrapper.find('.resolved').element;
result.forwardRef(el as Element);
await nextTick();
expect(result.currentElement.value).toBe(el);
});
it('switching child components updates exposed correctly', async () => {
const ChildA = defineComponent({
setup(_, { expose }) {
const a = ref('value-a');
expose({ a });
return { a };
},
template: `<span class="a">A</span>`,
});
const ChildB = defineComponent({
setup(_, { expose }) {
const b = ref('value-b');
expose({ b });
return { b };
},
template: `<span class="b">B</span>`,
});
let result!: UseForwardExposeReturn<any>;
const Parent = defineComponent({
components: { ChildA, ChildB },
setup() {
const showA = ref(true);
result = useForwardExpose();
return { showA, forwardRef: result.forwardRef };
},
template: `
<ChildA v-if="showA" :ref="forwardRef" />
<ChildB v-else :ref="forwardRef" />
`,
});
const wrapper = mount(Parent);
await nextTick();
// Initially ChildA is rendered
expect(wrapper.vm.$.exposed!.a).toEqual(ref('value-a'));
// Switch to ChildB
wrapper.vm.showA = false;
await nextTick();
// ChildB's exposed should be available
expect(wrapper.vm.$.exposed!.b).toEqual(ref('value-b'));
});
it('$el remains correct after switching from component to element', async () => {
const Child = defineComponent({
setup(_, { expose }) {
expose({ test: ref(1) });
return {};
},
template: `<span class="child-el">child</span>`,
});
let result!: UseForwardExposeReturn<any>;
const Parent = defineComponent({
components: { Child },
setup() {
result = useForwardExpose();
return { forwardRef: result.forwardRef };
},
template: `<Child :ref="forwardRef" />`,
});
const wrapper = mount(Parent);
await nextTick();
expect(wrapper.vm.$.exposed!.test).toEqual(ref(1));
// Now forward to a plain DOM element — $el must update on instance.exposed
const div = document.createElement('div');
result.forwardRef(div);
await nextTick();
expect(wrapper.vm.$.exposed!.$el).toBe(div);
});
it('parent props remain accessible after child forwarding', async () => {
const Child = defineComponent({
setup(_, { expose }) {
expose({ childProp: ref('child') });
return {};
},
template: `<span>child</span>`,
});
let result!: UseForwardExposeReturn<any>;
const Parent = defineComponent({
components: { Child },
props: {
parentLabel: { type: String, default: 'parent' },
},
setup() {
result = useForwardExpose();
return { forwardRef: result.forwardRef };
},
template: `<Child :ref="forwardRef" />`,
});
const wrapper = mount(Parent, { props: { parentLabel: 'test' } });
await nextTick();
// Both parent props and child exposed should be accessible
expect(wrapper.vm.$.exposed!.parentLabel).toBe('test');
expect(wrapper.vm.$.exposed!.childProp).toEqual(ref('child'));
});
});

View File

@@ -0,0 +1,128 @@
import type { ComponentPublicInstance, Ref } from 'vue';
import { computed, getCurrentInstance, shallowRef } from 'vue';
import type { MaybeElement } from '../unrefElement';
import { unrefElement } from '../unrefElement';
/** Set of non-element node names that should be skipped when resolving `$el` */
const NON_ELEMENT_NODES = new Set(['#text', '#comment']);
export interface UseForwardExposeReturn<T extends ComponentPublicInstance> {
/** Callback to set as `:ref` — forwards child's exposed API and `$el` through the parent */
forwardRef: (ref: T | MaybeElement) => void;
/** Reactive reference to the forwarded element or component instance */
currentRef: Ref<T | MaybeElement>;
/** Computed property resolving to the underlying `HTMLElement`, skipping text/comment nodes */
currentElement: Readonly<Ref<HTMLElement | undefined>>;
}
/**
* @name useForwardExpose
* @category Component
* @description Forwards a child component's exposed API and DOM element (`$el`) through
* the parent component. Useful for wrapper / headless components that need to transparently
* proxy the inner component's ref to the consumer.
*
* Merges the parent's own props and any prior `expose()` bindings onto `instance.exposed`,
* then updates them when `forwardRef` is called with a child element or component instance.
*
* @returns {UseForwardExposeReturn<T>} An object with `forwardRef`, `currentRef`, and `currentElement`
*
* @example
* const { forwardRef, currentElement } = useForwardExpose();
* // Template: <ChildComponent :ref="forwardRef" />
*
* @example
* const { forwardRef, currentRef } = useForwardExpose<InstanceType<typeof MyInput>>();
* // Template: <MyInput :ref="forwardRef" />
* // currentRef.value exposes MyInput's public API
*
* @since 0.0.14
*/
export function useForwardExpose<T extends ComponentPublicInstance>(): UseForwardExposeReturn<T> {
const instance = getCurrentInstance()!;
const currentRef = shallowRef<T | MaybeElement>();
const currentElement = computed<HTMLElement | undefined>(() => {
// @ts-expect-error — $el exists on component instances but not on HTMLElement/SVGElement
const el = currentRef.value?.$el;
return NON_ELEMENT_NODES.has(el?.nodeName)
? (el.nextElementSibling as HTMLElement | undefined) ?? undefined
: (unrefElement(currentRef) as HTMLElement | undefined);
});
// localExpose should only be assigned once else will create infinite loop
const localExpose = instance.exposed;
const ret: Record<string, any> = {};
// Collect all property descriptors in a single pass
const descriptors: PropertyDescriptorMap = {};
for (const key in instance.props) {
descriptors[key] = {
enumerable: true,
configurable: true,
get: () => instance.props[key],
};
}
if (localExpose && Object.keys(localExpose).length > 0) {
for (const key in localExpose) {
descriptors[key] = {
enumerable: true,
configurable: true,
get: () => localExpose[key],
};
}
}
descriptors['$el'] = {
enumerable: true,
configurable: true,
get: () => instance.vnode.el,
};
Object.defineProperties(ret, descriptors);
instance.exposed = ret;
function forwardRef(ref: T | MaybeElement) {
currentRef.value = ref;
if (!ref) return;
const $elDescriptor: PropertyDescriptor = {
enumerable: true,
configurable: true,
get: () => (ref instanceof Element ? ref : ref.$el),
};
// Keep ret in sync — it's the source of descriptors for future rebuilds
Object.defineProperty(ret, '$el', $elDescriptor);
// Also update current instance.exposed if it has diverged from ret
if (instance.exposed && instance.exposed !== ret) {
Object.defineProperty(instance.exposed, '$el', $elDescriptor);
}
if (!(ref instanceof Element) && !Object.prototype.hasOwnProperty.call(ref, '$el')) {
const childExposed = ref.$.exposed;
if (childExposed) {
// Copy descriptors from ret (includes props, prior expose, $el)
const allDescriptors = Object.getOwnPropertyDescriptors(ret);
allDescriptors['$el'] = $elDescriptor;
for (const key in childExposed) {
allDescriptors[key] = {
enumerable: true,
configurable: true,
get: () => childExposed[key],
};
}
instance.exposed = Object.defineProperties({}, allDescriptors);
}
}
}
return { forwardRef, currentRef, currentElement };
}

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useTemplateRefsList } from '.';
describe(useTemplateRefsList, () => {
it('collects elements rendered with v-for', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2, 3]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
expect(wrapper.vm.refs[0]).toBeInstanceOf(HTMLDivElement);
expect(wrapper.vm.refs[1]).toBeInstanceOf(HTMLDivElement);
expect(wrapper.vm.refs[2]).toBeInstanceOf(HTMLDivElement);
});
it('updates refs when items are added', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
wrapper.vm.items.push(3);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
});
it('updates refs when items are removed', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2, 3]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
wrapper.vm.items.splice(0, 1);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
});
it('returns empty array when no elements are rendered', async () => {
const Component = defineComponent({
setup() {
const items = ref<number[]>([]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div><span v-for="item in items" :key="item" :ref="set">{{ item }}</span></div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(0);
});
it('unwraps component instances to their root elements', async () => {
const Child = defineComponent({
template: `<span class="child">child</span>`,
});
const Parent = defineComponent({
components: { Child },
setup() {
const items = ref([1, 2]);
const { refs, set } = useTemplateRefsList<HTMLSpanElement>();
return { items, refs, set };
},
template: `<div><Child v-for="item in items" :key="item" :ref="set" /></div>`,
});
const wrapper = mount(Parent);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
expect(wrapper.vm.refs[0]).toBeInstanceOf(HTMLSpanElement);
expect(wrapper.vm.refs[0]!.classList.contains('child')).toBeTruthy();
});
it('preserves element order matching v-for order', async () => {
const Component = defineComponent({
setup() {
const items = ref(['a', 'b', 'c']);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set" :data-item="item">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs[0]!.dataset.item).toBe('a');
expect(wrapper.vm.refs[1]!.dataset.item).toBe('b');
expect(wrapper.vm.refs[2]!.dataset.item).toBe('c');
});
it('handles complete list replacement', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2, 3]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set" :data-item="item">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
wrapper.vm.items = [4, 5];
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
expect(wrapper.vm.refs[0]!.dataset.item).toBe('4');
expect(wrapper.vm.refs[1]!.dataset.item).toBe('5');
});
});

View File

@@ -0,0 +1,72 @@
import { onBeforeUpdate, onMounted, onUpdated, readonly, shallowRef } from 'vue';
import type { DeepReadonly, ShallowRef } from 'vue';
import type { MaybeElement } from '../unrefElement';
import { unrefElement } from '../unrefElement';
export interface UseTemplateRefsListReturn<El extends Element> {
/** Reactive readonly array of collected template refs */
refs: DeepReadonly<ShallowRef<El[]>>;
/** Ref setter function — bind via `:ref="set"` in templates */
set: (el: MaybeElement) => void;
}
/**
* @name useTemplateRefsList
* @category Component
* @description Collects a dynamic list of template refs for use with `v-for`.
* Automatically clears the list before each component update and repopulates it
* with fresh element references. Handles both plain DOM elements and Vue component
* instances (unwraps `$el`).
*
* Uses a non-reactive buffer internally to collect refs during the render cycle,
* then flushes to a `shallowRef` in `onMounted`/`onUpdated` to avoid triggering
* recursive update loops.
*
* @returns {UseTemplateRefsListReturn<El>} An object with a reactive `refs` array and a `set` function
*
* @example
* const { refs, set } = useTemplateRefsList<HTMLDivElement>();
* // Template: <div v-for="item in items" :key="item.id" :ref="set" />
* // refs.value contains all rendered div elements
*
* @since 0.0.14
*/
export function useTemplateRefsList<El extends Element = Element>(): UseTemplateRefsListReturn<El> {
const refs = shallowRef<El[]>([]);
let buffer: El[] = [];
const set = (el: MaybeElement) => {
const plain = unrefElement(el);
if (plain)
buffer.push(plain as unknown as El);
};
const flush = () => {
buffer.sort(documentPositionComparator);
refs.value = buffer;
};
onBeforeUpdate(() => {
buffer = [];
});
onMounted(flush);
onUpdated(flush);
return {
refs: readonly(refs) as DeepReadonly<ShallowRef<El[]>>,
set,
};
}
function documentPositionComparator(a: Element, b: Element): number {
if (a === b) return 0;
const position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
}

View File

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

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useRenderCount } from '.';
const ComponentStub = defineComponent({
setup() {
const count = useRenderCount();
const visibleCount = ref(0);
const hiddenCount = ref(0);
return { count, visibleCount, hiddenCount };
},
template: `<div>{{ visibleCount }}</div>`,
});
describe(useRenderCount, () => {
it('return the number of times the component has been rendered', async () => {
const component = mount(ComponentStub);
// Initial render
expect(component.vm.count).toBe(1);
component.vm.hiddenCount = 1;
await nextTick();
// Will not trigger a render
expect(component.vm.count).toBe(1);
expect(component.text()).toBe('0');
component.vm.visibleCount++;
await nextTick();
// Will trigger a render
expect(component.vm.count).toBe(2);
expect(component.text()).toBe('1');
component.vm.visibleCount++;
component.vm.visibleCount++;
await nextTick();
// Will trigger a single render for both updates
expect(component.vm.count).toBe(3);
expect(component.text()).toBe('3');
});
it('can be used with a specific component instance', async () => {
const component = mount(ComponentStub);
const instance = component.vm.$;
const count = useRenderCount(instance);
// Initial render (should be zero because the component has already rendered on mount)
expect(count.value).toBe(0);
component.vm.hiddenCount = 1;
await nextTick();
// Will not trigger a render
expect(count.value).toBe(0);
component.vm.visibleCount++;
await nextTick();
// Will trigger a render
expect(count.value).toBe(1);
component.vm.visibleCount++;
component.vm.visibleCount++;
await nextTick();
// Will trigger a single render for both updates
expect(count.value).toBe(2);
});
});

View File

@@ -0,0 +1,30 @@
import { onMounted, onUpdated, readonly } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { useCounter } from '@/composables/state/useCounter';
import { getLifeCycleTarger } from '@/utils';
/**
* @name useRenderCount
* @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
* @returns {Readonly<Ref<number>>} The number of times the component has been rendered
*
* @example
* const count = useRenderCount();
*
* @example
* const count = useRenderCount(getCurrentInstance());
*
* @since 0.0.1
*/
export function useRenderCount(instance?: ComponentInternalInstance) {
const { count, increment } = useCounter(0);
const target = getLifeCycleTarger(instance);
onMounted(increment, target);
onUpdated(increment, target);
return readonly(count);
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { useRenderInfo } from '.';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
const NamedComponentStub = defineComponent({
name: 'ComponentStub',
setup() {
const info = useRenderInfo();
const visibleCount = ref(0);
const hiddenCount = ref(0);
return { info, visibleCount, hiddenCount };
},
template: `<div>{{ visibleCount }}</div>`,
});
const UnnamedComponentStub = defineComponent({
setup() {
const info = useRenderInfo();
const visibleCount = ref(0);
const hiddenCount = ref(0);
return { info, visibleCount, hiddenCount };
},
template: `<div>{{ visibleCount }}</div>`,
});
describe(useRenderInfo, () => {
it('return uid if component name is not available', async () => {
const wrapper = mount(UnnamedComponentStub);
expect(wrapper.vm.info.component).toBe(wrapper.vm.$.uid);
});
it('return render info for the given instance', async () => {
const wrapper = mount(NamedComponentStub);
// Initial render
expect(wrapper.vm.info.component).toBe('ComponentStub');
expect(wrapper.vm.info.count.value).toBe(1);
expect(wrapper.vm.info.duration.value).toBeGreaterThan(0);
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
const lastRendered = wrapper.vm.info.lastRendered;
const duration = wrapper.vm.info.duration.value;
// Will not trigger a render
wrapper.vm.hiddenCount++;
await nextTick();
expect(wrapper.vm.info.component).toBe('ComponentStub');
expect(wrapper.vm.info.count.value).toBe(1);
expect(wrapper.vm.info.duration.value).toBe(duration);
expect(wrapper.vm.info.lastRendered).toBe(lastRendered);
// Will trigger a render
wrapper.vm.visibleCount++;
await nextTick();
expect(wrapper.vm.info.component).toBe('ComponentStub');
expect(wrapper.vm.info.count.value).toBe(2);
expect(wrapper.vm.info.duration.value).not.toBe(duration);
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
});
it('can be used with a specific component instance', async () => {
const wrapper = mount(NamedComponentStub);
const instance = wrapper.vm.$;
const info = useRenderInfo(instance);
// Initial render (should be zero because the component has already rendered on mount)
expect(info.component).toBe('ComponentStub');
expect(info.count.value).toBe(0);
expect(info.duration.value).toBe(0);
expect(info.lastRendered).toBeGreaterThan(0);
const lastRendered = info.lastRendered;
const duration = info.duration.value;
// Will not trigger a render
wrapper.vm.hiddenCount++;
await nextTick();
expect(info.component).toBe('ComponentStub');
expect(info.count.value).toBe(0);
expect(info.duration.value).toBe(duration);
expect(info.lastRendered).toBe(lastRendered);
// Will trigger a render
wrapper.vm.visibleCount++;
await nextTick();
expect(info.component).toBe('ComponentStub');
expect(info.count.value).toBe(1);
expect(info.duration.value).not.toBe(duration);
expect(info.lastRendered).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,48 @@
import { timestamp } from '@robonen/stdlib';
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { useRenderCount } from '../useRenderCount';
import { getLifeCycleTarger } from '@/utils';
/**
* @name useRenderInfo
* @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
*
*
* @example
* const { component, count, duration, lastRendered } = useRenderInfo();
*
* @example
* const { component, count, duration, lastRendered } = useRenderInfo(getCurrentInstance());
*
* @since 0.0.1
*/
export function useRenderInfo(instance?: ComponentInternalInstance) {
const target = getLifeCycleTarger(instance);
const duration = ref(0);
let renderStartTime = 0;
const startMark = () => {
renderStartTime = performance.now();
};
const endMark = () => {
duration.value = Math.max(performance.now() - renderStartTime, 0);
renderStartTime = 0;
};
onBeforeMount(startMark, target);
onMounted(endMark, target);
onBeforeUpdate(startMark, target);
onUpdated(endMark, target);
return {
component: target?.type.name ?? target?.uid,
count: useRenderCount(instance),
duration: readonly(duration),
lastRendered: timestamp(),
};
}

View File

@@ -0,0 +1,9 @@
export * from './browser';
export * from './component';
export * from './debug';
export * from './lifecycle';
export * from './math';
export * from './reactivity';
export * from './state';
export * from './storage';
export * from './utilities';

View File

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

View File

@@ -0,0 +1,46 @@
import { onBeforeMount, nextTick } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '@/utils';
import type { VoidFunction } from '@robonen/stdlib';
// TODO: test
export interface TryOnBeforeMountOptions {
sync?: boolean;
target?: ComponentInternalInstance;
}
/**
* @name tryOnBeforeMount
* @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.
* @param {TryOnBeforeMountOptions} options - The options for the function.
* @param {boolean} [options.sync=true] - If true, the function will run synchronously, otherwise it will run asynchronously.
* @param {ComponentInternalInstance} [options.target] - The target component instance to run the function on.
* @returns {void}
*
* @example
* tryOnBeforeMount(() => console.log('Before mount'));
*
* @example
* tryOnBeforeMount(() => console.log('Before mount async'), { sync: false });
*
* @since 0.0.1
*/
export function tryOnBeforeMount(fn: VoidFunction, options: TryOnBeforeMountOptions = {}) {
const {
sync = true,
target,
} = options;
const instance = getLifeCycleTarger(target);
if (instance)
onBeforeMount(fn, instance);
else if (sync)
fn();
else
nextTick(fn);
}

View File

@@ -0,0 +1,60 @@
import { describe, it, vi, expect } from 'vitest';
import { defineComponent, nextTick } from 'vue';
import type { PropType } from 'vue';
import { tryOnMounted } from '.';
import { mount } from '@vue/test-utils';
import type { VoidFunction } from '@robonen/stdlib';
const ComponentStub = defineComponent({
props: {
callback: {
type: Function as PropType<VoidFunction>,
},
},
setup(props) {
if (props.callback) {
tryOnMounted(props.callback);
}
},
template: `<div></div>`,
});
describe(tryOnMounted, () => {
it('run the callback when mounted', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: { callback },
});
expect(callback).toHaveBeenCalled();
});
it('run the callback outside of a component lifecycle', () => {
const callback = vi.fn();
tryOnMounted(callback);
expect(callback).toHaveBeenCalled();
});
it('run the callback asynchronously', async () => {
const callback = vi.fn();
tryOnMounted(callback, { sync: false });
expect(callback).not.toHaveBeenCalled();
await nextTick();
expect(callback).toHaveBeenCalled();
});
it.skip('run the callback with a specific target', () => {
const callback = vi.fn();
const component = mount(ComponentStub);
tryOnMounted(callback, { target: component.vm.$ });
expect(callback).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,46 @@
import { onMounted, nextTick } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '@/utils';
import type { VoidFunction } from '@robonen/stdlib';
// TODO: tests
export interface TryOnMountedOptions {
sync?: boolean;
target?: ComponentInternalInstance;
}
/**
* @name tryOnMounted
* @category Lifecycle
* @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it
*
* @param {VoidFunction} fn The function to call
* @param {TryOnMountedOptions} options The options to use
* @param {boolean} [options.sync=true] If the function should be called synchronously
* @param {ComponentInternalInstance} [options.target] The target instance to use
* @returns {void}
*
* @example
* tryOnMounted(() => console.log('Mounted!'));
*
* @example
* tryOnMounted(() => console.log('Mounted!'), { sync: false });
*
* @since 0.0.1
*/
export function tryOnMounted(fn: VoidFunction, options: TryOnMountedOptions = {}) {
const {
sync = true,
target,
} = options;
const instance = getLifeCycleTarger(target);
if (instance)
onMounted(fn, instance);
else if (sync)
fn();
else
nextTick(fn);
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope } from 'vue';
import type { PropType } from 'vue';
import { tryOnScopeDispose } from '.';
import { mount } from '@vue/test-utils';
import type { VoidFunction } from '@robonen/stdlib';
const ComponentStub = defineComponent({
props: {
callback: {
type: Function as PropType<VoidFunction>,
required: true,
},
},
setup(props) {
tryOnScopeDispose(props.callback);
},
template: '<div></div>',
});
describe(tryOnScopeDispose, () => {
it('returns false when the scope is not active', () => {
const callback = vi.fn();
const detectedScope = tryOnScopeDispose(callback);
expect(detectedScope).toBeFalsy();
expect(callback).not.toHaveBeenCalled();
});
it('run the callback when the scope is disposed', () => {
const callback = vi.fn();
const scope = effectScope();
let detectedScope: boolean | undefined;
scope.run(() => {
detectedScope = tryOnScopeDispose(callback);
});
expect(detectedScope).toBeTruthy();
expect(callback).not.toHaveBeenCalled();
scope.stop();
expect(callback).toHaveBeenCalled();
});
it('run callback when the component is unmounted', () => {
const callback = vi.fn();
const component = mount(ComponentStub, {
props: { callback },
});
expect(callback).not.toHaveBeenCalled();
component.unmount();
expect(callback).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,24 @@
import type { VoidFunction } from '@robonen/stdlib';
import { getCurrentScope, onScopeDispose } from 'vue';
/**
* @name tryOnScopeDispose
* @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.
* @returns {boolean} - Returns true if the callback was run, otherwise false.
*
* @example
* tryOnScopeDispose(() => console.log('Scope disposed'));
*
* @since 0.0.1
*/
export function tryOnScopeDispose(callback: VoidFunction) {
if (getCurrentScope()) {
onScopeDispose(callback);
return true;
}
return false;
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useMounted } from '.';
const ComponentStub = defineComponent({
setup() {
const isMounted = useMounted();
return { isMounted };
},
template: `<div>{{ isMounted }}</div>`,
});
describe(useMounted, () => {
it('return the mounted state of the component', async () => {
const component = mount(ComponentStub);
// Initial render
expect(component.text()).toBe('false');
await nextTick();
// Will trigger a render
expect(component.text()).toBe('true');
});
});

View File

@@ -0,0 +1,30 @@
import { onMounted, readonly, ref } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '@/utils';
/**
* @name useMounted
* @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
* @returns {Readonly<Ref<boolean>>} The mounted state of the component
*
* @example
* const isMounted = useMounted();
*
* @example
* const isMounted = useMounted(getCurrentInstance());
*
* @since 0.0.1
*/
export function useMounted(instance?: ComponentInternalInstance) {
const isMounted = ref(false);
const targetInstance = getLifeCycleTarger(instance);
onMounted(() => {
isMounted.value = true;
}, targetInstance);
return readonly(isMounted);
}

View File

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

View File

@@ -0,0 +1,60 @@
import { ref, readonly, computed } from 'vue';
import { describe, it, expect } from 'vitest';
import { useClamp } from '.';
describe(useClamp, () => {
it('non-reactive values should be clamped', () => {
const clampedValue = useClamp(10, 0, 5);
expect(clampedValue.value).toBe(5);
});
it('clamp the value within the given range', () => {
const value = ref(10);
const clampedValue = useClamp(value, 0, 5);
expect(clampedValue.value).toBe(5);
});
it('clamp the value within the given range using functions', () => {
const value = ref(10);
const clampedValue = useClamp(value, () => 0, () => 5);
expect(clampedValue.value).toBe(5);
});
it('clamp readonly values', () => {
const computedValue = computed(() => 10);
const readonlyValue = readonly(ref(10));
const clampedValue1 = useClamp(computedValue, 0, 5);
const clampedValue2 = useClamp(readonlyValue, 0, 5);
expect(clampedValue1.value).toBe(5);
expect(clampedValue2.value).toBe(5);
});
it('update the clamped value when the original value changes', () => {
const value = ref(10);
const clampedValue = useClamp(value, 0, 5);
value.value = 3;
expect(clampedValue.value).toBe(3);
});
it('update the clamped value when the min or max changes', () => {
const value = ref(10);
const min = ref(0);
const max = ref(5);
const clampedValue = useClamp(value, min, max);
expect(clampedValue.value).toBe(5);
max.value = 15;
expect(clampedValue.value).toBe(10);
min.value = 11;
expect(clampedValue.value).toBe(11);
});
});

View File

@@ -0,0 +1,40 @@
import { clamp, isFunction } from '@robonen/stdlib';
import { computed, isReadonly, ref, toValue } from 'vue';
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, WritableComputedRef } from 'vue';
/**
* @name useClamp
* @category Math
* @description Clamps a value between a minimum and maximum value
*
* @param {MaybeRefOrGetter<number>} value The value to clamp
* @param {MaybeRefOrGetter<number>} min The minimum value
* @param {MaybeRefOrGetter<number>} max The maximum value
* @returns {ComputedRef<number>} The clamped value
*
* @example
* const value = ref(10);
* const clampedValue = useClamp(value, 0, 5);
*
* @example
* const value = ref(10);
* const clampedValue = useClamp(value, () => 0, () => 5);
*
* @since 0.0.1
*/
export function useClamp(value: MaybeRef<number>, min: MaybeRefOrGetter<number>, max: MaybeRefOrGetter<number>): WritableComputedRef<number>;
export function useClamp(value: MaybeRefOrGetter<number>, min: MaybeRefOrGetter<number>, max: MaybeRefOrGetter<number>): ComputedRef<number> {
if (isFunction(value) || isReadonly(value))
return computed(() => clamp(toValue(value), toValue(min), toValue(max)));
const _value = ref(value);
return computed<number>({
get() {
return clamp(_value.value, toValue(min), toValue(max));
},
set(newValue) {
_value.value = clamp(newValue, toValue(min), toValue(max));
},
});
}

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { defineComponent, effectScope, nextTick, watch } from 'vue';
import { mount } from '@vue/test-utils';
import { broadcastedRef } from '.';
type MessageHandler = ((event: MessageEvent) => void) | null;
class MockBroadcastChannel {
static instances: MockBroadcastChannel[] = [];
name: string;
onmessage: MessageHandler = null;
closed = false;
constructor(name: string) {
this.name = name;
MockBroadcastChannel.instances.push(this);
}
postMessage(data: unknown) {
if (this.closed) return;
for (const instance of MockBroadcastChannel.instances) {
if (instance !== this && instance.name === this.name && !instance.closed && instance.onmessage) {
instance.onmessage(new MessageEvent('message', { data }));
}
}
}
close() {
this.closed = true;
const index = MockBroadcastChannel.instances.indexOf(this);
if (index > -1) MockBroadcastChannel.instances.splice(index, 1);
}
}
const mountWithRef = (setup: () => Record<string, any> | void) => {
return mount(
defineComponent({
setup,
template: '<div></div>',
}),
);
};
describe(broadcastedRef, () => {
let component: ReturnType<typeof mountWithRef>;
beforeEach(() => {
MockBroadcastChannel.instances = [];
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
});
afterEach(() => {
component?.unmount();
vi.unstubAllGlobals();
});
it('create a ref with the initial value', () => {
component = mountWithRef(() => {
const count = broadcastedRef('test-key', 42);
expect(count.value).toBe(42);
});
});
it('broadcast value changes to other channels with the same key', () => {
const ref1 = broadcastedRef('shared', 0);
const ref2 = broadcastedRef('shared', 0);
ref1.value = 100;
expect(ref2.value).toBe(100);
});
it('not broadcast to channels with a different key', () => {
const ref1 = broadcastedRef('key-a', 0);
const ref2 = broadcastedRef('key-b', 0);
ref1.value = 100;
expect(ref2.value).toBe(0);
});
it('receive values from other channels and trigger reactivity', async () => {
const callback = vi.fn();
component = mountWithRef(() => {
const data = broadcastedRef('reactive-test', 'initial');
watch(data, callback, { flush: 'sync' });
});
const sender = broadcastedRef('reactive-test', '');
sender.value = 'updated';
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith('updated', 'initial', expect.anything());
});
it('not broadcast initial value by default', () => {
const ref1 = broadcastedRef('no-immediate', 'first');
const ref2 = broadcastedRef('no-immediate', 'second');
expect(ref1.value).toBe('first');
expect(ref2.value).toBe('second');
});
it('broadcast initial value when immediate is true', () => {
const ref1 = broadcastedRef('immediate-test', 'existing');
broadcastedRef('immediate-test', 'new-value', { immediate: true });
expect(ref1.value).toBe('new-value');
});
it('close channel on scope dispose', () => {
const scope = effectScope();
scope.run(() => {
broadcastedRef('dispose-test', 0);
});
expect(MockBroadcastChannel.instances).toHaveLength(1);
scope.stop();
expect(MockBroadcastChannel.instances).toHaveLength(0);
});
it('handle complex object values via structured clone', () => {
const ref1 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
const ref2 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
ref1.value = { status: 'paid', amount: 99.99 };
expect(ref2.value).toEqual({ status: 'paid', amount: 99.99 });
});
it('fallback to a regular ref when BroadcastChannel is not available', () => {
vi.stubGlobal('BroadcastChannel', undefined);
const data = broadcastedRef('fallback', 'value');
expect(data.value).toBe('value');
data.value = 'updated';
expect(data.value).toBe('updated');
});
});

View File

@@ -0,0 +1,68 @@
import { customRef, ref } from 'vue';
import type { Ref } from 'vue';
import { defaultWindow } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface BroadcastedRefOptions {
/**
* Immediately broadcast the initial value to other tabs on creation
* @default false
*/
immediate?: boolean;
}
/**
* @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
* @param {BroadcastedRefOptions} [options={}] Options
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
*
* @example
* const count = broadcastedRef('counter', 0);
*
* @example
* const state = broadcastedRef('payment-status', { status: 'pending' });
*
* @since 0.0.13
*/
export function broadcastedRef<T>(key: string, initialValue: T, options: BroadcastedRefOptions = {}): Ref<T> {
const { immediate = false } = options;
if (!defaultWindow || typeof BroadcastChannel === 'undefined') {
return ref(initialValue) as Ref<T>;
}
const channel = new BroadcastChannel(key);
let value = initialValue;
const data = customRef<T>((track, trigger) => {
channel.onmessage = (event: MessageEvent<T>) => {
value = event.data;
trigger();
};
return {
get() {
track();
return value;
},
set(newValue: T) {
value = newValue;
channel.postMessage(newValue);
trigger();
},
};
});
if (immediate) {
channel.postMessage(initialValue);
}
tryOnScopeDispose(() => channel.close());
return data;
}

View File

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

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { ref, nextTick, reactive } from 'vue';
import { useCached } from '.';
const arrayEquals = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
describe(useCached, () => {
it('default comparator', async () => {
const externalValue = ref(0);
const cachedValue = useCached(externalValue);
expect(cachedValue.value).toBe(0);
externalValue.value = 1;
await nextTick();
expect(cachedValue.value).toBe(1);
});
it('custom array comparator', async () => {
const externalValue = ref([1]);
const initialValue = externalValue.value;
const cachedValue = useCached(externalValue, arrayEquals);
expect(cachedValue.value).toEqual(initialValue);
externalValue.value = initialValue;
await nextTick();
expect(cachedValue.value).toEqual(initialValue);
externalValue.value = [1];
await nextTick();
expect(cachedValue.value).toEqual(initialValue);
externalValue.value = [2];
await nextTick();
expect(cachedValue.value).not.toEqual(initialValue);
expect(cachedValue.value).toEqual([2]);
});
it('getter source', async () => {
const externalValue = reactive({ value: 0 });
const cachedValue = useCached(() => externalValue.value);
expect(cachedValue.value).toBe(0);
externalValue.value = 1;
await nextTick();
expect(cachedValue.value).toBe(1);
});
});

View File

@@ -0,0 +1,39 @@
import { ref, watch, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue';
export type Comparator<Value> = (a: Value, b: Value) => boolean;
/**
* @name useCached
* @category Reactivity
* @description Caches the value of an external ref and updates it only when the value changes
*
* @param {Ref<T>} externalValue Ref to cache
* @param {Comparator<T>} comparator Comparator function to compare the values
* @param {WatchOptions} watchOptions Watch options
* @returns {Ref<T>} Cached ref
*
* @example
* const externalValue = ref(0);
* const cachedValue = useCached(externalValue);
*
* @example
* const externalValue = ref(0);
* const cachedValue = useCached(externalValue, (a, b) => a === b, { immediate: true });
*
* @since 0.0.1
*/
export function useCached<Value = unknown>(
externalValue: MaybeRefOrGetter<Value>,
comparator: Comparator<Value> = (a, b) => a === b,
watchOptions?: WatchOptions,
): Ref<Value> {
const cached = ref(toValue(externalValue)) as Ref<Value>;
watch(() => toValue(externalValue), (value) => {
if (!comparator(value, cached.value))
cached.value = value;
}, watchOptions);
return cached;
}

View File

@@ -0,0 +1,50 @@
import { ref, nextTick } from 'vue';
import { describe, it, expect } from 'vitest';
import { useLastChanged } from '.';
import { timestamp } from '@robonen/stdlib';
describe(useLastChanged, () => {
it('initialize with null if no initialValue is provided', () => {
const source = ref(0);
const lastChanged = useLastChanged(source);
expect(lastChanged.value).toBeNull();
});
it('initialize with the provided initialValue', () => {
const source = ref(0);
const initialValue = 123456789;
const lastChanged = useLastChanged(source, { initialValue });
expect(lastChanged.value).toBe(initialValue);
});
it('update the timestamp when the source changes', async () => {
const source = ref(0);
const lastChanged = useLastChanged(source);
const initialTimestamp = lastChanged.value;
source.value = 1;
await nextTick();
expect(lastChanged.value).not.toBe(initialTimestamp);
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
});
it('update the timestamp immediately if immediate option is true', async () => {
const source = ref(0);
const lastChanged = useLastChanged(source, { immediate: true });
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
});
it('not update the timestamp if the source does not change', async () => {
const source = ref(0);
const lastChanged = useLastChanged(source);
const initialTimestamp = lastChanged.value;
await nextTick();
expect(lastChanged.value).toBe(initialTimestamp);
});
});

View File

@@ -0,0 +1,41 @@
import { timestamp } from '@robonen/stdlib';
import { ref, watch } from 'vue';
import type { WatchSource, WatchOptions, Ref } from 'vue';
export interface UseLastChangedOptions<
Immediate extends boolean,
InitialValue extends number | null | undefined = undefined,
> extends WatchOptions<Immediate> {
initialValue?: InitialValue;
}
/**
* @name useLastChanged
* @category Reactivity
* @description Records the last time a value changed
*
* @param {WatchSource} source The value to track
* @param {UseLastChangedOptions} [options={}] The options for the last changed tracker
* @returns {Ref<number | null>} The timestamp of the last change
*
* @example
* const value = ref(0);
* const lastChanged = useLastChanged(value);
*
* @example
* const value = ref(0);
* const lastChanged = useLastChanged(value, { immediate: true });
*
* @since 0.0.1
*/
export function useLastChanged(source: WatchSource, options?: UseLastChangedOptions<false>): Ref<number | null>;
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<true> | UseLastChangedOptions<boolean, number>): Ref<number>;
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<boolean, any> = {}): Ref<number | null> | Ref<number> {
const lastChanged = ref<number | null>(options.initialValue ?? null);
watch(source, () => {
lastChanged.value = timestamp();
}, options);
return lastChanged;
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useSyncRefs } from '.';
describe(useSyncRefs, () => {
it('sync the value of a source ref with multiple target refs', () => {
const source = ref(0);
const target1 = ref(0);
const target2 = ref(0);
useSyncRefs(source, [target1, target2]);
source.value = 10;
expect(target1.value).toBe(10);
expect(target2.value).toBe(10);
});
it('sync the value of a source ref with a single target ref', () => {
const source = ref(0);
const target = ref(0);
useSyncRefs(source, target);
source.value = 20;
expect(target.value).toBe(20);
});
it('stop watching when the stop handle is called', () => {
const source = ref(0);
const target = ref(0);
const stop = useSyncRefs(source, target);
source.value = 30;
stop();
source.value = 40;
expect(target.value).toBe(30);
});
});

View File

@@ -0,0 +1,47 @@
import { watch } from 'vue';
import type { Ref, WatchOptions, WatchSource } from 'vue';
import { isArray } from '@robonen/stdlib';
/**
* @name useSyncRefs
* @category Reactivity
* @description Syncs the value of a source ref with multiple target refs
*
* @param {WatchSource<T>} source Source ref to sync
* @param {Ref<T> | Ref<T>[]} targets Target refs to sync
* @param {WatchOptions} watchOptions Watch options
* @returns {WatchStopHandle} Watch stop handle
*
* @example
* const source = ref(0);
* const target1 = ref(0);
* const target2 = ref(0);
* useSyncRefs(source, [target1, target2]);
*
* @example
* const source = ref(0);
* const target1 = ref(0);
* useSyncRefs(source, target1, { immediate: true });
*
* @since 0.0.1
*/
export function useSyncRefs<T = unknown>(
source: WatchSource<T>,
targets: Ref<T> | Ref<T>[],
watchOptions: WatchOptions = {},
) {
const {
flush = 'sync',
deep = false,
immediate = true,
} = watchOptions;
if (!isArray(targets))
targets = [targets];
return watch(
source,
value => targets.forEach((target) => { target.value = value; }),
{ flush, deep, immediate },
);
}

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

@@ -0,0 +1,40 @@
import { describe, it, vi, expect } from 'vitest';
import { ref, reactive } from 'vue';
import { useAppSharedState } from '.';
describe(useAppSharedState, () => {
it('initialize state only once', () => {
const stateFactory = (initValue?: number) => {
const count = ref(initValue ?? 0);
return { count };
};
const useSharedState = useAppSharedState(stateFactory);
const state1 = useSharedState(1);
const state2 = useSharedState(2);
expect(state1.count.value).toBe(1);
expect(state2.count.value).toBe(1);
expect(state1).toBe(state2);
});
it('return the same state object across different calls', () => {
const stateFactory = () => {
const state = reactive({ count: 0 });
const increment = () => state.count++;
return { state, increment };
};
const useSharedState = useAppSharedState(stateFactory);
const sharedState1 = useSharedState();
const sharedState2 = useSharedState();
expect(sharedState1.state.count).toBe(0);
sharedState1.increment();
expect(sharedState1.state.count).toBe(1);
expect(sharedState2.state.count).toBe(1);
expect(sharedState1).toBe(sharedState2);
});
});

View File

@@ -0,0 +1,42 @@
import type { AnyFunction } from '@robonen/stdlib';
import { effectScope } from 'vue';
// TODO: maybe we should control subscriptions and dispose them when the child scope is disposed
/**
* @name useAppSharedState
* @category State
* @description Provides a shared state object for use across Vue instances
*
* @param {Function} stateFactory A factory function that returns the shared state object
* @returns {Function} A function that returns the shared state object
*
* @example
* const useSharedState = useAppSharedState((initValue?: number) => {
* const count = ref(initValue ?? 0);
* return { count };
* });
*
* @example
* const useSharedState = useAppSharedState(() => {
* const state = reactive({ count: 0 });
* const increment = () => state.count++;
* return { state, increment };
* });
*
* @since 0.0.1
*/
export function useAppSharedState<Fn extends AnyFunction>(stateFactory: Fn) {
let initialized = false;
let state: ReturnType<Fn>;
const scope = effectScope(true);
return (...args: Parameters<Fn>) => {
if (!initialized) {
state = scope.run(() => stateFactory(...args));
initialized = true;
}
return state;
};
}

View File

@@ -0,0 +1,209 @@
import { isShallow, nextTick, ref } from 'vue';
import { it, expect, describe, vi, beforeEach, afterEach } from 'vitest';
import { useAsyncState } from '.';
describe(useAsyncState, () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('works with a promise', async () => {
const { state, isReady, isLoading, error } = useAsyncState(
Promise.resolve('data'),
'initial',
);
expect(state.value).toBe('initial');
expect(isReady.value).toBeFalsy();
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBeTruthy();
expect(isLoading.value).toBeFalsy();
expect(error.value).toBe(null);
});
it('works with a function returning a promise', async () => {
const { state, isReady, isLoading, error } = useAsyncState(
() => Promise.resolve('data'),
'initial',
);
expect(state.value).toBe('initial');
expect(isReady.value).toBeFalsy();
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBeTruthy();
expect(isLoading.value).toBeFalsy();
expect(error.value).toBe(null);
});
it('handles errors', async () => {
const { state, isReady, isLoading, error } = useAsyncState(
Promise.reject(new Error('test-error')),
'initial',
);
expect(state.value).toBe('initial');
expect(isReady.value).toBeFalsy();
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('initial');
expect(isReady.value).toBeFalsy();
expect(isLoading.value).toBeFalsy();
expect(error.value).toEqual(new Error('test-error'));
});
it('calls onSuccess callback', async () => {
const onSuccess = vi.fn();
useAsyncState(
Promise.resolve('data'),
'initial',
{ onSuccess },
);
await nextTick();
expect(onSuccess).toHaveBeenCalledWith('data');
});
it('calls onError callback', async () => {
const onError = vi.fn();
const error = new Error('test-error');
useAsyncState(
Promise.reject(error),
'initial',
{ onError },
);
await nextTick();
expect(onError).toHaveBeenCalledWith(error);
});
it('throws error if throwError is true', async () => {
const error = new Error('test-error');
const { executeImmediately } = useAsyncState(
Promise.reject(error),
'initial',
{ immediate: false, throwError: true },
);
await expect(() => executeImmediately()).rejects.toThrow(error);
});
it('resets state on execute if resetOnExecute is true', async () => {
const { state, executeImmediately } = useAsyncState(
(data: string) => Promise.resolve(data),
'initial',
{ immediate: false, resetOnExecute: true },
);
await executeImmediately('new data');
expect(state.value).toBe('new data');
executeImmediately('another data');
expect(state.value).toBe('initial');
});
it('delays execution with default delay', async () => {
const { isLoading, execute } = useAsyncState(
() => Promise.resolve('data'),
'initial',
{ delay: 100, immediate: false },
);
const promise = execute();
expect(isLoading.value).toBeTruthy();
await vi.advanceTimersByTimeAsync(50);
expect(isLoading.value).toBeTruthy();
await vi.advanceTimersByTimeAsync(50);
await promise;
expect(isLoading.value).toBeFalsy();
});
it('is awaitable', async () => {
const { state } = await useAsyncState(
Promise.resolve('data'),
'initial',
);
expect(state.value).toBe('data');
});
it('works with executeImmediately', async () => {
const { state, isReady, isLoading, error, executeImmediately } = useAsyncState(
() => Promise.resolve('data'),
'initial',
{ immediate: false },
);
executeImmediately();
expect(state.value).toBe('initial');
expect(isLoading.value).toBeTruthy();
expect(isReady.value).toBeFalsy();
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBeTruthy();
expect(isLoading.value).toBeFalsy();
expect(error.value).toBe(null);
});
it('passes params to the function', async () => {
const promiseFn = vi.fn((...args: any[]) => Promise.resolve(args.join(' ')));
const { executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
await executeImmediately('hello', 'world');
expect(promiseFn).toHaveBeenCalledWith('hello', 'world');
});
it('uses shallowRef by default', async () => {
const { state } = await useAsyncState(
Promise.resolve({ a: 1 }),
{ a: 0 },
);
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBeTruthy();
});
it('uses ref when shallow is false', async () => {
const { state } = await useAsyncState(
Promise.resolve({ a: ref(1) }),
{ a: ref(0) },
{ shallow: false },
);
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBeFalsy();
});
});

View File

@@ -0,0 +1,130 @@
import { ref, shallowRef, watch } from 'vue';
import type { Ref, ShallowRef, UnwrapRef } from 'vue';
import { isFunction, sleep } from '@robonen/stdlib';
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
delay?: number;
shallow?: Shallow;
immediate?: boolean;
resetOnExecute?: boolean;
throwError?: boolean;
onError?: (error: unknown) => void;
onSuccess?: (data: Data) => void;
}
export interface UseAsyncStateReturnBase<Data, Params extends any[], Shallow extends boolean> {
state: Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>;
isLoading: Ref<boolean>;
isReady: Ref<boolean>;
error: Ref<unknown | null>;
execute: (delay?: number, ...params: Params) => Promise<Data>;
executeImmediately: (...params: Params) => Promise<Data>;
}
export type UseAsyncStateReturn<Data, Params extends any[], Shallow extends boolean>
= & UseAsyncStateReturnBase<Data, Params, Shallow>
& PromiseLike<UseAsyncStateReturnBase<Data, Params, Shallow>>;
/**
* @name useAsyncState
* @category State
* @description A composable that provides a state for async operations without setup blocking
*/
export function useAsyncState<Data, Params extends any[] = [], Shallow extends boolean = true>(
maybePromise: Promise<Data> | ((...args: Params) => Promise<Data>),
initialState: Data,
options?: UseAsyncStateOptions<Shallow, Data>,
): UseAsyncStateReturn<Data, Params, Shallow> {
const {
delay = 0,
shallow = true,
immediate = true,
resetOnExecute = false,
throwError = false,
onError,
onSuccess,
} = options ?? {};
const state = shallow ? shallowRef(initialState) : ref(initialState);
const error = ref<unknown | null>(null);
const isLoading = ref(false);
const isReady = ref(false);
const execute = async (actualDelay = delay, ...params: any[]) => {
if (resetOnExecute)
state.value = initialState;
isLoading.value = true;
isReady.value = false;
error.value = null;
if (actualDelay > 0)
await sleep(actualDelay);
const promise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
try {
const data = await promise;
state.value = data;
isReady.value = true;
onSuccess?.(data);
}
catch (e: unknown) {
error.value = e;
onError?.(e);
if (throwError)
throw e;
}
finally {
isLoading.value = false;
}
return state.value as Data;
};
const executeImmediately = (...params: Params) => {
return execute(0, ...params);
};
if (immediate)
execute();
const shell = {
state: state as Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>,
isLoading,
isReady,
error,
execute,
executeImmediately,
};
function waitResolve() {
return new Promise<UseAsyncStateReturnBase<Data, Params, Shallow>>((resolve, reject) => {
watch(
isLoading,
(loading) => {
if (loading === false) {
if (error.value)
reject(error.value);
else
resolve(shell);
}
},
{
immediate: true,
once: true,
flush: 'sync',
},
);
});
}
return {
...shell,
// eslint-disable-next-line unicorn/no-thenable
then(onFulfilled, onRejected) {
return waitResolve().then(onFulfilled, onRejected);
},
};
}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { defineComponent } from 'vue';
import { useContextFactory } from '.';
import { mount } from '@vue/test-utils';
import { VueToolsError } from '@/utils';
function testFactory<Data>(
data: Data,
context: ReturnType<typeof useContextFactory<Data>>,
fallback?: Data,
) {
const { inject, provide } = context;
const Child = defineComponent({
setup() {
const value = inject(fallback);
return { value };
},
template: `{{ value }}`,
});
const Parent = defineComponent({
components: { Child },
setup() {
provide(data);
},
template: `<Child />`,
});
return {
Parent,
Child,
};
}
// TODO: maybe replace template with passing mock functions to setup
describe(useContextFactory, () => {
it('provide and inject context correctly', () => {
const { Parent } = testFactory('test', useContextFactory('TestContext'));
const component = mount(Parent);
expect(component.text()).toBe('test');
});
it('throw an error when context is not provided', () => {
const { Child } = testFactory('test', useContextFactory('TestContext'));
expect(() => mount(Child)).toThrow(VueToolsError);
});
it('inject a fallback value when context is not provided', () => {
const { Child } = testFactory('test', useContextFactory('TestContext'), 'fallback');
const component = mount(Child);
expect(component.text()).toBe('fallback');
});
it('correctly handle null values', () => {
const { Parent } = testFactory(null, useContextFactory('TestContext'));
const component = mount(Parent);
expect(component.text()).toBe('');
});
it('provide context globally with app', () => {
const context = useContextFactory('TestContext');
const { Child } = testFactory(null, context);
const childComponent = mount(Child, {
global: {
plugins: [app => context.appProvide(app)('test')],
},
});
expect(childComponent.text()).toBe('test');
});
});

View File

@@ -0,0 +1,63 @@
import { inject as vueInject, provide as vueProvide } from 'vue';
import type { InjectionKey, App } from 'vue';
import { VueToolsError } from '@/utils';
/**
* @name useContextFactory
* @category State
* @description A composable that provides a factory for creating context with unique key
*
* @param {string} name The name of the context
* @returns {Object} An object with `inject`, `provide`, `appProvide` and `key` properties
* @throws {VueToolsError} when the context is not provided
*
* @example
* const { inject, provide } = useContextFactory('MyContext');
*
* provide('Hello World');
* const value = inject();
*
* @example
* const { inject: injectContext, appProvide } = useContextFactory('MyContext');
*
* // In a plugin
* {
* install(app) {
* appProvide(app)('Hello World');
* }
* }
*
* // In a component
* const value = injectContext();
*
* @since 0.0.1
*/
export function useContextFactory<ContextValue>(name: string) {
const injectionKey: InjectionKey<ContextValue> = Symbol(name);
const inject = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
const context = vueInject(injectionKey, fallback);
if (context !== undefined)
return context;
throw new VueToolsError(`useContextFactory: '${name}' context is not provided`);
};
const provide = (context: ContextValue) => {
vueProvide(injectionKey, context);
return context;
};
const appProvide = (app: App) => (context: ContextValue) => {
app.provide(injectionKey, context);
return context;
};
return {
inject,
provide,
appProvide,
key: injectionKey,
};
}

View File

@@ -0,0 +1,6 @@
<script setup lang="ts"></script>
<template>
<div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
import { it, expect, describe } from 'vitest';
import { ref } from 'vue';
import { useCounter } from '.';
describe(useCounter, () => {
it('initialize count with the provided initial value', () => {
const { count } = useCounter(5);
expect(count.value).toBe(5);
});
it('initialize count with the provided initial value from a ref', () => {
const { count } = useCounter(ref(5));
expect(count.value).toBe(5);
});
it('initialize count with the provided initial value from a getter', () => {
const { count } = useCounter(() => 5);
expect(count.value).toBe(5);
});
it('increment count by 1 by default', () => {
const { count, increment } = useCounter(0);
increment();
expect(count.value).toBe(1);
});
it('increment count by the specified delta', () => {
const { count, increment } = useCounter(0);
increment(5);
expect(count.value).toBe(5);
});
it('decrement count by 1 by default', () => {
const { count, decrement } = useCounter(5);
decrement();
expect(count.value).toBe(4);
});
it('decrement count by the specified delta', () => {
const { count, decrement } = useCounter(10);
decrement(5);
expect(count.value).toBe(5);
});
it('set count to the specified value', () => {
const { count, set } = useCounter(0);
set(10);
expect(count.value).toBe(10);
});
it('get the current count value', () => {
const { get } = useCounter(5);
expect(get()).toBe(5);
});
it('reset count to the initial value', () => {
const { count, reset } = useCounter(10);
count.value = 5;
reset();
expect(count.value).toBe(10);
});
it('reset count to the specified value', () => {
const { count, reset } = useCounter(10);
count.value = 5;
reset(20);
expect(count.value).toBe(20);
});
it('clamp count to the minimum value', () => {
const { count, decrement } = useCounter(Number.MIN_SAFE_INTEGER);
decrement();
expect(count.value).toBe(Number.MIN_SAFE_INTEGER);
});
it('clamp count to the maximum value', () => {
const { count, increment } = useCounter(Number.MAX_SAFE_INTEGER);
increment();
expect(count.value).toBe(Number.MAX_SAFE_INTEGER);
});
});

View File

@@ -0,0 +1,77 @@
import { ref, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { clamp } from '@robonen/stdlib';
export interface UseCounterOptions {
min?: number;
max?: number;
}
export interface UseConterReturn {
count: Ref<number>;
increment: (delta?: number) => void;
decrement: (delta?: number) => void;
set: (value: number) => void;
get: () => number;
reset: (value?: number) => void;
}
/**
* @name useCounter
* @category State
* @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 {UseCounterOptions} [options={}] The options for the counter
* @param {number} [options.min=Number.MIN_SAFE_INTEGER] The minimum value of the counter
* @param {number} [options.max=Number.MAX_SAFE_INTEGER] The maximum value of the counter
* @returns {UseConterReturn} The counter object
*
* @example
* const { count, increment } = useCounter(0);
*
* @example
* const { count, increment, decrement, set, get, reset } = useCounter(0, { min: 0, max: 10 });
*
* @since 0.0.1
*/
export function useCounter(
initialValue: MaybeRefOrGetter<number> = 0,
options: UseCounterOptions = {},
): UseConterReturn {
let _initialValue = toValue(initialValue);
const count = ref(_initialValue);
const {
min = Number.MIN_SAFE_INTEGER,
max = Number.MAX_SAFE_INTEGER,
} = options;
const increment = (delta = 1) => {
count.value = clamp(count.value + delta, min, max);
};
const decrement = (delta = 1) => {
count.value = clamp(count.value - delta, min, max);
};
const set = (value: number) => {
count.value = clamp(value, min, max);
};
const get = () => count.value;
const reset = (value = _initialValue) => {
_initialValue = value;
return set(value);
};
return {
count,
increment,
decrement,
set,
get,
reset,
};
};

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { defineComponent, ref } from 'vue';
import { useInjectionStore } from '.';
import { mount } from '@vue/test-utils';
function testFactory<Args, Return>(
store: ReturnType<typeof useInjectionStore<Args[], Return>>,
) {
const { useProvidingState, useInjectedState } = store;
const Child = defineComponent({
setup() {
const state = useInjectedState();
return { state };
},
template: `{{ state }}`,
});
const Parent = defineComponent({
components: { Child },
setup() {
const state = useProvidingState();
return { state };
},
template: `<Child />`,
});
return {
Parent,
Child,
};
}
describe('useInjectionState', () => {
it('provides and injects state correctly', () => {
const { Parent } = testFactory(
useInjectionStore(() => ref('base')),
);
const wrapper = mount(Parent);
expect(wrapper.text()).toBe('base');
});
it('injects default value when state is not provided', () => {
const { Child } = testFactory(
useInjectionStore(() => ref('without provider'), {
defaultValue: ref('default'),
injectionKey: 'testKey',
}),
);
const wrapper = mount(Child);
expect(wrapper.text()).toBe('default');
});
it('provides state at app level', () => {
const injectionStore = useInjectionStore(() => ref('app level'));
const { Child } = testFactory(injectionStore);
const wrapper = mount(Child, {
global: {
plugins: [
(app) => {
const state = injectionStore.useAppProvidingState(app)();
expect(state.value).toBe('app level');
},
],
},
});
expect(wrapper.text()).toBe('app level');
});
it('works with custom injection key', () => {
const { Parent } = testFactory(
useInjectionStore(() => ref('custom key'), {
injectionKey: Symbol('customKey'),
}),
);
const wrapper = mount(Parent);
expect(wrapper.text()).toBe('custom key');
});
it('handles state factory with arguments', () => {
const injectionStore = useInjectionStore((arg: string) => arg);
const { Child } = testFactory(injectionStore);
const wrapper = mount(Child, {
global: {
plugins: [
app => injectionStore.useAppProvidingState(app)('with args'),
],
},
});
expect(wrapper.text()).toBe('with args');
});
});

View File

@@ -0,0 +1,73 @@
import { useContextFactory } from '../useContextFactory';
import type { App } from 'vue';
export interface useInjectionStoreOptions<Return> {
injectionName?: string;
defaultValue?: Return;
}
/**
* @name useInjectionStore
* @category State
* @description Create a global state that can be injected into components
*
* @param {Function} stateFactory A factory function that creates the state
* @param {useInjectionStoreOptions} options An object with the following properties
* @param {string | InjectionKey} options.injectionKey The key to use for the injection
* @param {any} options.defaultValue The default value to use when the state is not provided
* @returns {Object} An object with `useProvidingState`, `useAppProvidingState`, and `useInjectedState` functions
*
* @example
* const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World'));
*
* // In a parent component
* const state = useProvidingState();
*
* // In a child component
* const state = useInjectedState();
*
* @example
* const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World'), {
* injectionKey: 'MyState',
* defaultValue: 'Default Value'
* });
*
* // In a plugin
* {
* install(app) {
* const state = useAppProvidingState(app)();
* state.value = 'Hello World';
* }
* }
*
* // In a component
* const state = useInjectedState();
*
* @since 0.0.5
*/
export function useInjectionStore<Args extends any[], Return>(
stateFactory: (...args: Args) => Return,
options?: useInjectionStoreOptions<Return>,
) {
const ctx = useContextFactory<Return>(options?.injectionName ?? stateFactory.name ?? 'InjectionStore');
const useProvidingState = (...args: Args) => {
const state = stateFactory(...args);
ctx.provide(state);
return state;
};
const useAppProvidingState = (app: App) => (...args: Args) => {
const state = stateFactory(...args);
ctx.appProvide(app)(state);
return state;
};
const useInjectedState = () => ctx.inject(options?.defaultValue);
return {
useProvidingState,
useAppProvidingState,
useInjectedState,
};
}

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).toBeFalsy();
});
it('initialize with the provided initial value', () => {
const { value } = useToggle(true);
expect(value.value).toBeTruthy();
});
it('initialize with the provided initial value from a ref', () => {
const { value } = useToggle(ref(true));
expect(value.value).toBeTruthy();
});
it('toggle from false to true', () => {
const { value, toggle } = useToggle(false);
toggle();
expect(value.value).toBeTruthy();
});
it('toggle from true to false', () => {
const { value, toggle } = useToggle(true);
toggle();
expect(value.value).toBeFalsy();
});
it('toggle multiple times', () => {
const { value, toggle } = useToggle(false);
toggle();
expect(value.value).toBeTruthy();
toggle();
expect(value.value).toBeFalsy();
toggle();
expect(value.value).toBeTruthy();
});
it('toggle returns the new value', () => {
const { toggle } = useToggle(false);
expect(toggle()).toBeTruthy();
expect(toggle()).toBeFalsy();
});
it('set a specific value via toggle', () => {
const { value, toggle } = useToggle(false);
toggle(true);
expect(value.value).toBeTruthy();
toggle(true);
expect(value.value).toBeTruthy();
});
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

@@ -0,0 +1,57 @@
import { ref, toValue } from 'vue';
import type { MaybeRefOrGetter, MaybeRef, Ref } from 'vue';
export interface UseToggleOptions<Truthy, Falsy> {
truthyValue?: MaybeRefOrGetter<Truthy>;
falsyValue?: MaybeRefOrGetter<Falsy>;
}
export interface UseToggleReturn<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>(
initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy,
options: UseToggleOptions<Truthy, Falsy> = {},
): UseToggleReturn<Truthy, Falsy> {
const {
truthyValue = true as Truthy,
falsyValue = false as Falsy,
} = options;
const value = ref(initialValue) as Ref<Truthy | Falsy>;
const toggle = (newValue?: Truthy | Falsy) => {
if (newValue !== undefined) {
value.value = newValue;
return value.value;
}
const truthy = toValue(truthyValue);
const falsy = toValue(falsyValue);
value.value = value.value === truthy ? falsy : truthy;
return value.value;
};
return { value, toggle };
}

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,45 @@
import { ref, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import type { RemovableRef } from '@/types';
import { defaultWindow } from '@/types';
import { useStorage } from '../useStorage';
import type { UseStorageOptions } from '../useStorage';
/**
* @name useLocalStorage
* @category Storage
* @description Reactive localStorage binding — creates a ref synced with `window.localStorage`
*
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {UseStorageOptions<T>} [options={}] Options
* @returns {RemovableRef<T>} A reactive ref synced with localStorage
*
* @example
* const count = useLocalStorage('my-count', 0);
*
* @example
* const state = useLocalStorage('my-state', { hello: 'world' });
*
* @since 0.0.12
*/
export function useLocalStorage<T extends string>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T extends number>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T extends boolean>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T = unknown>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useLocalStorage<T>(
key: MaybeRefOrGetter<string>,
initialValue: MaybeRefOrGetter<T>,
options: UseStorageOptions<T> = {},
): RemovableRef<T> {
const window = options.window ?? defaultWindow;
const storage = window?.localStorage;
if (!storage) {
// SSR / non-browser environment: return an in-memory ref
return (options.shallow !== false ? shallowRef : ref)(toValue(initialValue)) as RemovableRef<T>;
}
return useStorage(key, initialValue, storage, { ...options, window });
}

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,45 @@
import { ref, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import type { RemovableRef } from '@/types';
import { defaultWindow } from '@/types';
import { useStorage } from '../useStorage';
import type { UseStorageOptions } from '../useStorage';
/**
* @name useSessionStorage
* @category Storage
* @description Reactive sessionStorage binding — creates a ref synced with `window.sessionStorage`
*
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {UseStorageOptions<T>} [options={}] Options
* @returns {RemovableRef<T>} A reactive ref synced with sessionStorage
*
* @example
* const count = useSessionStorage('my-count', 0);
*
* @example
* const state = useSessionStorage('my-state', { hello: 'world' });
*
* @since 0.0.12
*/
export function useSessionStorage<T extends string>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T extends number>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T extends boolean>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T = unknown>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useSessionStorage<T>(
key: MaybeRefOrGetter<string>,
initialValue: MaybeRefOrGetter<T>,
options: UseStorageOptions<T> = {},
): RemovableRef<T> {
const window = options.window ?? defaultWindow;
const storage = window?.sessionStorage;
if (!storage) {
// SSR / non-browser environment: return an in-memory ref
return (options.shallow !== false ? shallowRef : ref)(toValue(initialValue)) as RemovableRef<T>;
}
return useStorage(key, initialValue, storage, { ...options, window });
}

View File

@@ -0,0 +1,490 @@
import { describe, it, expect, vi } from 'vitest';
import { nextTick, ref } from 'vue';
import { useStorage, StorageSerializers, customStorageEventName } from '.';
import type { StorageLike, StorageEventLike } from '.';
function createMockStorage(): StorageLike & { store: Map<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
};
}
describe(useStorage, () => {
// --- Basic types ---
it('stores and reads a string', async () => {
const storage = createMockStorage();
const state = useStorage<string>('test-string', 'hello', storage);
expect(state.value).toBe('hello');
expect(storage.getItem('test-string')).toBe('hello');
state.value = 'world';
await nextTick();
expect(storage.getItem('test-string')).toBe('world');
});
it('stores and reads a number', async () => {
const storage = createMockStorage();
const state = useStorage<number>('test-number', 42, storage);
expect(state.value).toBe(42);
state.value = 100;
await nextTick();
expect(storage.getItem('test-number')).toBe('100');
});
it('stores and reads a boolean', async () => {
const storage = createMockStorage();
const state = useStorage<boolean>('test-bool', true, storage);
expect(state.value).toBeTruthy();
state.value = false;
await nextTick();
expect(storage.getItem('test-bool')).toBe('false');
});
it('stores and reads an object', async () => {
const storage = createMockStorage();
const state = useStorage('test-obj', { a: 1, b: 'two' }, storage);
expect(state.value).toEqual({ a: 1, b: 'two' });
state.value = { a: 2, b: 'three' };
await nextTick();
expect(JSON.parse(storage.getItem('test-obj')!)).toEqual({ a: 2, b: 'three' });
});
// --- Reads existing value from storage ---
it('reads existing value from storage on init', () => {
const storage = createMockStorage();
storage.store.set('existing', '"stored-value"');
const state = useStorage('existing', 'default', storage, {
serializer: StorageSerializers.object,
});
expect(state.value).toBe('stored-value');
});
// --- Removes item when set to null ---
it('removes from storage when value is set to null', async () => {
const storage = createMockStorage();
const state = useStorage<string | null>('test-null', 'value', storage);
await nextTick();
expect(storage.getItem('test-null')).toBe('value');
state.value = null;
await nextTick();
expect(storage.getItem('test-null')).toBeNull();
});
// --- Custom serializer ---
it('uses custom serializer', async () => {
const storage = createMockStorage();
const serializer = {
read: (v: string) => v.split(',').map(Number),
write: (v: number[]) => v.join(','),
};
const state = useStorage('custom-ser', [1, 2, 3], storage, { serializer });
expect(state.value).toEqual([1, 2, 3]);
state.value = [4, 5, 6];
await nextTick();
expect(storage.getItem('custom-ser')).toBe('4,5,6');
});
// --- Merge defaults ---
it('merges defaults with stored value', () => {
const storage = createMockStorage();
storage.store.set('merge-test', JSON.stringify({ hello: 'stored' }));
const state = useStorage(
'merge-test',
{ hello: 'default', greeting: 'hi' },
storage,
{ mergeDefaults: true },
);
expect(state.value.hello).toBe('stored');
expect(state.value.greeting).toBe('hi');
});
it('uses custom merge function', () => {
const storage = createMockStorage();
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
const state = useStorage(
'merge-fn',
{ a: 0, b: 2 },
storage,
{
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
},
);
expect(state.value).toEqual({ a: 1, b: 2 });
});
// --- Map and Set ---
it('stores and reads a Map', async () => {
const storage = createMockStorage();
const initial = new Map([['key1', 'val1']]);
const state = useStorage('test-map', initial, storage);
expect(state.value).toEqual(new Map([['key1', 'val1']]));
state.value = new Map([['key2', 'val2']]);
await nextTick();
const raw = storage.getItem('test-map');
expect(JSON.parse(raw!)).toEqual([['key2', 'val2']]);
});
it('stores and reads a Set', async () => {
const storage = createMockStorage();
const initial = new Set([1, 2, 3]);
const state = useStorage('test-set', initial, storage);
expect(state.value).toEqual(new Set([1, 2, 3]));
state.value = new Set([4, 5]);
await nextTick();
const raw = storage.getItem('test-set');
expect(JSON.parse(raw!)).toEqual([4, 5]);
});
// --- Date ---
it('stores and reads a Date', async () => {
const storage = createMockStorage();
const date = new Date('2026-02-14T00:00:00.000Z');
const state = useStorage('test-date', date, storage);
expect(state.value).toEqual(date);
const newDate = new Date('2026-12-25T00:00:00.000Z');
state.value = newDate;
await nextTick();
expect(storage.getItem('test-date')).toBe(newDate.toISOString());
});
// --- Error handling ---
it('calls onError when read fails', () => {
const storage = createMockStorage();
storage.store.set('bad-json', '{invalid');
const onError = vi.fn();
const state = useStorage('bad-json', { fallback: true }, storage, { onError });
expect(onError).toHaveBeenCalledOnce();
expect(state.value).toEqual({ fallback: true });
});
it('calls onError when write fails', async () => {
const onError = vi.fn();
const storage: StorageLike = {
getItem: () => null,
setItem: () => { throw new Error('quota exceeded'); },
removeItem: () => {},
};
const state = useStorage<string>('fail-write', 'init', storage, { onError });
// One error from initial persist of defaults
expect(onError).toHaveBeenCalledOnce();
state.value = 'new';
await nextTick();
// Another error from the write triggered by value change
expect(onError).toHaveBeenCalledTimes(2);
});
// --- Persists defaults on init ---
it('persists default value to storage on init when key does not exist', () => {
const storage = createMockStorage();
useStorage('new-key', 'default-val', storage);
expect(storage.getItem('new-key')).toBe('default-val');
});
it('does not overwrite existing storage value with defaults', () => {
const storage = createMockStorage();
storage.store.set('existing-key', 'existing-val');
useStorage('existing-key', 'default-val', storage);
expect(storage.getItem('existing-key')).toBe('existing-val');
});
// --- writeDefaults: false ---
it('does not persist defaults when writeDefaults is false', () => {
const storage = createMockStorage();
useStorage('no-write', 'default-val', storage, { writeDefaults: false });
expect(storage.getItem('no-write')).toBeNull();
});
// --- No infinite loop on init ---
it('calls setItem exactly once on init for writeDefaults', () => {
const setItem = vi.fn();
const storage: StorageLike = {
getItem: () => null,
setItem,
removeItem: vi.fn(),
};
useStorage<string>('init-key', 'value', storage);
expect(setItem).toHaveBeenCalledOnce();
expect(setItem).toHaveBeenCalledWith('init-key', 'value');
});
// --- No-op write when value unchanged ---
it('does not call setItem when value is unchanged', async () => {
const storage = createMockStorage();
const state = useStorage<string>('noop-key', 'same', storage);
const setItem = vi.spyOn(storage, 'setItem');
setItem.mockClear();
// Re-assign the same value
state.value = 'same';
await nextTick();
expect(setItem).not.toHaveBeenCalled();
});
// --- shallow: false with deep mutation ---
it('writes to storage on deep object mutation with shallow: false', async () => {
const storage = createMockStorage();
const state = useStorage('deep-obj', { nested: { count: 0 } }, storage, { shallow: false });
expect(state.value.nested.count).toBe(0);
state.value.nested.count = 42;
await nextTick();
expect(JSON.parse(storage.getItem('deep-obj')!)).toEqual({ nested: { count: 42 } });
});
// --- Multiple rapid assignments ---
it('only writes last value when multiple assignments happen before flush', async () => {
const storage = createMockStorage();
const state = useStorage<string>('rapid-key', 'initial', storage);
const setItem = vi.spyOn(storage, 'setItem');
setItem.mockClear();
state.value = 'first';
state.value = 'second';
state.value = 'third';
await nextTick();
// Watcher fires once with the last value (pre flush batches)
expect(storage.getItem('rapid-key')).toBe('third');
});
// --- Cross-tab synchronization via custom storage event ---
// Note: Mock storage is not `instanceof Storage`, so useStorage listens
// for the custom event 'vuetools-storage' instead of the native 'storage' event.
it('updates ref when receiving a custom storage event with matching key', async () => {
const storage = createMockStorage();
const state = useStorage<string>('sync-key', 'initial', storage, {
listenToStorageChanges: true,
window: globalThis,
});
expect(state.value).toBe('initial');
const detail: StorageEventLike = {
key: 'sync-key',
oldValue: 'initial',
newValue: 'from-other-tab',
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('from-other-tab');
});
it('ignores storage events with a different key', async () => {
const storage = createMockStorage();
const state = useStorage<string>('my-key', 'initial', storage, {
listenToStorageChanges: true,
window: globalThis,
});
const detail: StorageEventLike = {
key: 'other-key',
oldValue: null,
newValue: 'other-value',
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('initial');
});
it('resets to default when storage event has null key (clear)', async () => {
const storage = createMockStorage();
storage.store.set('clear-key', 'stored');
const state = useStorage<string>('clear-key', 'default', storage, {
listenToStorageChanges: true,
window: globalThis,
});
expect(state.value).toBe('stored');
// null key means storage.clear() was called
const detail: StorageEventLike = {
key: null,
oldValue: null,
newValue: null,
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('default');
});
it('does not update ref on storage events when listenToStorageChanges is false', async () => {
const storage = createMockStorage();
const state = useStorage<string>('no-listen', 'initial', storage, {
listenToStorageChanges: false,
window: globalThis,
});
const detail: StorageEventLike = {
key: 'no-listen',
oldValue: null,
newValue: 'changed',
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('initial');
});
// --- Custom storage event for non-native backends ---
it('responds to custom storage events for non-native backends', async () => {
const storage = createMockStorage();
const state = useStorage<string>('custom-backend', 'initial', storage, {
listenToStorageChanges: true,
window: globalThis,
});
const detail: StorageEventLike = {
key: 'custom-backend',
oldValue: 'initial',
newValue: 'updated-via-custom',
storageArea: storage,
};
globalThis.dispatchEvent(new CustomEvent(customStorageEventName, { detail }));
await nextTick();
expect(state.value).toBe('updated-via-custom');
});
// --- Reactive key ---
it('re-reads from storage when reactive key changes', async () => {
const storage = createMockStorage();
storage.store.set('key-a', 'value-a');
storage.store.set('key-b', 'value-b');
const keyRef = ref('key-a');
const state = useStorage<string>(keyRef, 'default', storage);
expect(state.value).toBe('value-a');
keyRef.value = 'key-b';
await nextTick();
expect(state.value).toBe('value-b');
});
// --- initOnMounted ---
it('defers reading from storage until mounted when initOnMounted is true', async () => {
const storage = createMockStorage();
storage.store.set('mounted-key', 'stored-value');
// Outside component context, tryOnMounted calls fn synchronously
const state = useStorage<string>('mounted-key', 'default', storage, {
initOnMounted: true,
});
// tryOnMounted calls fn synchronously when outside component context
expect(state.value).toBe('stored-value');
});
// --- eventFilter ---
it('applies event filter to writes', async () => {
vi.useFakeTimers();
const storage = createMockStorage();
const state = useStorage<string>('filtered-key', 'initial', storage, {
eventFilter: invoke => setTimeout(invoke, 100),
});
const setItem = vi.spyOn(storage, 'setItem');
setItem.mockClear();
state.value = 'filtered';
await nextTick();
// Not written yet — debounced
expect(setItem).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(setItem).toHaveBeenCalledWith('filtered-key', 'filtered');
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,361 @@
import { nextTick, ref, shallowRef, toValue, watch } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { isBoolean, isNumber, isString, isObject, isMap, isSet, isDate } from '@robonen/stdlib';
import type { ConfigurableFlush, ConfigurableWindow, RemovableRef } from '@/types';
import { defaultWindow } from '@/types';
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
import { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
export interface StorageSerializer<T> {
read: (raw: string) => T;
write: (value: T) => string;
}
export const StorageSerializers: { [K: string]: StorageSerializer<any> } & {
boolean: StorageSerializer<boolean>;
number: StorageSerializer<number>;
string: StorageSerializer<string>;
object: StorageSerializer<any>;
map: StorageSerializer<Map<any, any>>;
set: StorageSerializer<Set<any>>;
date: StorageSerializer<Date>;
} = {
boolean: {
read: (v: string) => v === 'true',
write: (v: boolean) => String(v),
},
number: {
read: (v: string) => Number.parseFloat(v),
write: (v: number) => String(v),
},
string: {
read: (v: string) => v,
write: (v: string) => v,
},
object: {
read: (v: string) => JSON.parse(v),
write: (v: any) => JSON.stringify(v),
},
map: {
read: (v: string) => new Map(JSON.parse(v)),
write: (v: Map<any, any>) => JSON.stringify([...v.entries()]),
},
set: {
read: (v: string) => new Set(JSON.parse(v)),
write: (v: Set<any>) => JSON.stringify([...v]),
},
date: {
read: (v: string) => new Date(v),
write: (v: Date) => v.toISOString(),
},
};
export interface StorageLike {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
}
export const customStorageEventName = 'vuetools-storage';
export interface StorageEventLike {
storageArea: StorageLike | null;
key: StorageEvent['key'];
oldValue: StorageEvent['oldValue'];
newValue: StorageEvent['newValue'];
}
export interface UseStorageOptions<T> extends ConfigurableFlush, ConfigurableWindow, ConfigurableEventFilter {
/**
* Use shallowRef instead of ref for the internal state
* @default true
*/
shallow?: boolean;
/**
* Watch for deep changes
* @default true
*/
deep?: boolean;
/**
* Listen to storage changes from other tabs/windows
* @default true
*/
listenToStorageChanges?: boolean;
/**
* Write the default value to the storage when it does not exist
* @default true
*/
writeDefaults?: boolean;
/**
* Merge the default value with the stored value
* @default false
*/
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
/**
* Custom serializer for reading/writing storage values
*/
serializer?: StorageSerializer<T>;
/**
* Error handler for read/write failures
*/
onError?: (error: unknown) => void;
/**
* Wait for the component to be mounted before reading the storage
*
* Useful for SSR hydration to prevent mismatch
* @default false
*/
initOnMounted?: boolean;
}
export type UseStorageReturn<T> = RemovableRef<T>;
export function guessSerializer<T>(value: T): StorageSerializer<T> {
if (isBoolean(value)) return StorageSerializers.boolean as any;
if (isNumber(value)) return StorageSerializers.number as any;
if (isString(value)) return StorageSerializers.string as any;
if (isMap(value)) return StorageSerializers.map as any;
if (isSet(value)) return StorageSerializers.set as any;
if (isDate(value)) return StorageSerializers.date as any;
if (isObject(value)) return StorageSerializers.object as any;
return StorageSerializers.object as any;
}
export function shallowMerge<T>(stored: T, defaults: T): T {
if (isObject(stored) && isObject(defaults))
return { ...defaults, ...stored };
return stored;
}
/**
* @name useStorage
* @category Storage
* @description Reactive Storage binding — creates a ref synced with a storage backend
*
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {StorageLike} storage The storage backend
* @param {UseStorageOptions<T>} [options={}] Options
* @returns {RemovableRef<T>} A reactive ref synced with storage
*
* @example
* const count = useStorage('my-count', 0, storage);
*
* @example
* const state = useStorage('my-state', { hello: 'world' }, storage);
*
* @example
* const id = useStorage('my-id', 'default', storage, {
* serializer: { read: (v) => v, write: (v) => v },
* });
*
* @since 0.0.12
*/
export function useStorage<T extends string>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T extends number>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T extends boolean>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T = unknown>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, storage: StorageLike, options?: UseStorageOptions<T>): RemovableRef<T>;
export function useStorage<T>(
key: MaybeRefOrGetter<string>,
initialValue: MaybeRefOrGetter<T>,
storage: StorageLike,
options: UseStorageOptions<T> = {},
): RemovableRef<T> {
const {
shallow = true,
deep = true,
flush = 'pre',
writeDefaults = true,
mergeDefaults = false,
listenToStorageChanges = true,
window = defaultWindow,
eventFilter,
initOnMounted = false,
onError = console.error, // eslint-disable-line no-console
} = options;
const defaults = toValue(initialValue);
const serializer = options.serializer ?? guessSerializer(defaults);
const data = (shallow ? shallowRef : ref)(defaults) as RemovableRef<T>;
const resolvedKey = () => toValue(key);
function read(event?: StorageEventLike): T {
const rawValue = event
? event.newValue
: storage.getItem(resolvedKey());
if (rawValue === undefined || rawValue === null) {
if (writeDefaults && defaults !== undefined && defaults !== null) {
try {
storage.setItem(resolvedKey(), serializer.write(defaults));
}
catch (e) {
onError(e);
}
}
return defaults;
}
try {
if (!event && mergeDefaults) {
const value = serializer.read(rawValue);
return typeof mergeDefaults === 'function'
? mergeDefaults(value, defaults)
: shallowMerge(value, defaults);
}
return serializer.read(rawValue);
}
catch (e) {
onError(e);
return defaults;
}
}
function dispatchWriteEvent(oldValue: string | null, newValue: string | null) {
if (!window)
return;
const payload = {
key: resolvedKey(),
oldValue,
newValue,
storageArea: storage as Storage,
};
// Use native StorageEvent for built-in Storage, CustomEvent for custom backends
window.dispatchEvent(
storage instanceof Storage
? new StorageEvent('storage', payload)
: new CustomEvent<StorageEventLike>(customStorageEventName, { detail: payload }),
);
}
function write(value: T) {
try {
const oldValue = storage.getItem(resolvedKey());
if (value === undefined || value === null) {
dispatchWriteEvent(oldValue, null);
storage.removeItem(resolvedKey());
}
else {
const serialized = serializer.write(value);
if (oldValue !== serialized) {
storage.setItem(resolvedKey(), serialized);
dispatchWriteEvent(oldValue, serialized);
}
}
}
catch (e) {
onError(e);
}
}
// Write-lock prevents the data watcher from writing back to storage
// when data.value is being updated programmatically (from storage reads,
// key changes, or cross-tab events). The lock is released via nextTick
// so it persists through the pre-flush watcher cycle.
let writeLock = false;
function lockWritesUntilFlush() {
writeLock = true;
nextTick(() => {
writeLock = false;
});
}
function update(event: StorageEventLike) {
if (event.storageArea !== storage)
return;
if (event.key === null) {
lockWritesUntilFlush();
data.value = defaults;
return;
}
if (event.key !== resolvedKey())
return;
const currentSerialized = serializer.write(data.value);
if (event.newValue !== currentSerialized) {
lockWritesUntilFlush();
data.value = read(event);
}
}
function updateFromCustomEvent(event: CustomEvent<StorageEventLike>) {
update(event.detail);
}
// Apply event filter if provided
const writeWithFilter: (value: T) => void = eventFilter
? (value: T) => (eventFilter as EventFilter)(() => write(value))
: write;
// Initialize data from storage BEFORE creating watchers to avoid
// Vue 3.5 pause/resume replay race conditions
if (!initOnMounted) {
data.value = read();
}
// Data write watcher — skips writes when writeLock is active
watch(data, (newValue) => {
if (writeLock)
return;
writeWithFilter(newValue);
}, { flush, deep });
// Watch for reactive key changes
if (typeof key !== 'string') {
watch(resolvedKey, () => {
lockWritesUntilFlush();
data.value = read();
});
}
// Event listeners for cross-tab synchronization
let firstMounted = false;
const onStorageEvent = (ev: StorageEvent): void => {
if (initOnMounted && !firstMounted)
return;
update(ev);
};
const onStorageCustomEvent = (ev: CustomEvent<StorageEventLike>): void => {
if (initOnMounted && !firstMounted)
return;
updateFromCustomEvent(ev);
};
if (window && listenToStorageChanges) {
if (storage instanceof Storage)
useEventListener(window, 'storage', onStorageEvent, { passive: true });
else
useEventListener(window as any, customStorageEventName as any, onStorageCustomEvent as any);
}
if (initOnMounted) {
tryOnMounted(() => {
firstMounted = true;
lockWritesUntilFlush();
data.value = read();
});
}
return data;
}

View File

@@ -0,0 +1,388 @@
import { describe, it, expect, vi } from 'vitest';
import { nextTick, ref } from 'vue';
import { useStorageAsync } from '.';
import type { StorageLikeAsync } from '.';
function createMockAsyncStorage(): StorageLikeAsync & { store: Map<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: async (key: string) => store.get(key) ?? null,
setItem: async (key: string, value: string) => { store.set(key, value); },
removeItem: async (key: string) => { store.delete(key); },
};
}
function createDelayedAsyncStorage(delay: number): StorageLikeAsync & { store: Map<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: (key: string) => new Promise(resolve => setTimeout(() => resolve(store.get(key) ?? null), delay)),
setItem: (key: string, value: string) => new Promise(resolve => setTimeout(() => {
store.set(key, value);
resolve();
}, delay)),
removeItem: (key: string) => new Promise(resolve => setTimeout(() => {
store.delete(key);
resolve();
}, delay)),
};
}
describe(useStorageAsync, () => {
// --- Basic read/write ---
it('returns default value before storage is ready', () => {
const storage = createMockAsyncStorage();
const { state, isReady } = useStorageAsync('key', 'default', storage);
expect(state.value).toBe('default');
expect(isReady.value).toBeFalsy();
});
it('reads existing value from async storage', async () => {
const storage = createMockAsyncStorage();
storage.store.set('key', 'stored');
const { state, isReady } = await useStorageAsync('key', 'default', storage);
expect(state.value).toBe('stored');
expect(isReady.value).toBeTruthy();
});
it('writes value to async storage on change', async () => {
const storage = createMockAsyncStorage();
const { state } = await useStorageAsync<string>('key', 'initial', storage);
state.value = 'updated';
await nextTick();
// Allow async write to complete
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('key')).toBe('updated');
});
// --- Types ---
it('reads and writes a number', async () => {
const storage = createMockAsyncStorage();
storage.store.set('num', '42');
const { state } = await useStorageAsync<number>('num', 0, storage);
expect(state.value).toBe(42);
state.value = 100;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('num')).toBe('100');
});
it('reads and writes a boolean', async () => {
const storage = createMockAsyncStorage();
storage.store.set('flag', 'true');
const { state } = await useStorageAsync('flag', false, storage);
expect(state.value).toBeTruthy();
state.value = false;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('flag')).toBe('false');
});
it('reads and writes an object', async () => {
const storage = createMockAsyncStorage();
storage.store.set('obj', JSON.stringify({ a: 1 }));
const { state } = await useStorageAsync('obj', { a: 0, b: 2 }, storage);
expect(state.value).toEqual({ a: 1 });
});
// --- Awaitable ---
it('is awaitable and resolves after initial read', async () => {
const storage = createDelayedAsyncStorage(50);
storage.store.set('delayed', 'loaded');
const { state, isReady } = await useStorageAsync('delayed', 'default', storage);
expect(state.value).toBe('loaded');
expect(isReady.value).toBeTruthy();
});
// --- onReady callback ---
it('calls onReady callback after initial load', async () => {
const storage = createMockAsyncStorage();
storage.store.set('ready', 'ready-value');
const onReady = vi.fn();
await useStorageAsync('ready', 'default', storage, { onReady });
expect(onReady).toHaveBeenCalledOnce();
expect(onReady).toHaveBeenCalledWith('ready-value');
});
it('calls onReady with default when key not in storage', async () => {
const storage = createMockAsyncStorage();
const onReady = vi.fn();
await useStorageAsync('missing', 'fallback', storage, { onReady });
expect(onReady).toHaveBeenCalledWith('fallback');
});
// --- Merge defaults ---
it('merges defaults with stored value', async () => {
const storage = createMockAsyncStorage();
storage.store.set('merge', JSON.stringify({ hello: 'stored' }));
const { state } = await useStorageAsync(
'merge',
{ hello: 'default', greeting: 'hi' },
storage,
{ mergeDefaults: true },
);
expect(state.value.hello).toBe('stored');
expect(state.value.greeting).toBe('hi');
});
it('uses custom merge function', async () => {
const storage = createMockAsyncStorage();
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
const { state } = await useStorageAsync(
'merge-fn',
{ a: 0, b: 2 },
storage,
{
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
},
);
expect(state.value).toEqual({ a: 1, b: 2 });
});
// --- Custom serializer ---
it('uses custom async serializer', async () => {
const storage = createMockAsyncStorage();
storage.store.set('custom', '1,2,3');
const serializer = {
read: async (v: string) => v.split(',').map(Number),
write: async (v: number[]) => v.join(','),
};
const { state } = await useStorageAsync('custom', [0], storage, { serializer });
expect(state.value).toEqual([1, 2, 3]);
state.value = [4, 5, 6];
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('custom')).toBe('4,5,6');
});
// --- Null / remove ---
it('removes from storage when value is set to null', async () => {
const storage = createMockAsyncStorage();
storage.store.set('nullable', 'exists');
const { state } = await useStorageAsync<string | null>('nullable', 'default', storage);
state.value = null;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.has('nullable')).toBeFalsy();
});
// --- Error handling ---
it('calls onError when read fails', async () => {
const onError = vi.fn();
const storage: StorageLikeAsync = {
getItem: async () => { throw new Error('read failure'); },
setItem: async () => {},
removeItem: async () => {},
};
const { state } = await useStorageAsync('fail-read', 'fallback', storage, { onError });
expect(onError).toHaveBeenCalledOnce();
expect(state.value).toBe('fallback');
});
it('calls onError when write fails', async () => {
const onError = vi.fn();
const storage: StorageLikeAsync = {
getItem: async () => null,
setItem: async () => { throw new Error('write failure'); },
removeItem: async () => {},
};
const { state } = await useStorageAsync<string>('fail-write', 'initial', storage, { onError });
// One error from writeDefaults persisting initial value
expect(onError).toHaveBeenCalledOnce();
state.value = 'new';
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
// Another error from the write triggered by value change
expect(onError).toHaveBeenCalledTimes(2);
});
// --- No unnecessary write-back on initial read ---
it('does not write back to storage after initial read', async () => {
const setItem = vi.fn(async () => {});
const storage: StorageLikeAsync = {
getItem: async () => 'existing',
setItem,
removeItem: async () => {},
};
await useStorageAsync('key', 'default', storage);
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(setItem).not.toHaveBeenCalled();
});
it('does not write back to storage when key is missing and writeDefaults is false', async () => {
const setItem = vi.fn(async () => {});
const storage: StorageLikeAsync = {
getItem: async () => null,
setItem,
removeItem: async () => {},
};
await useStorageAsync('key', 'default', storage, { writeDefaults: false });
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(setItem).not.toHaveBeenCalled();
});
// --- shallow: false with deep mutation ---
it('writes to storage on deep object mutation with shallow: false', async () => {
const storage = createMockAsyncStorage();
const { state } = await useStorageAsync(
'deep-obj',
{ nested: { count: 0 } },
storage,
{ shallow: false },
);
state.value.nested.count = 42;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(JSON.parse(storage.store.get('deep-obj')!)).toEqual({ nested: { count: 42 } });
});
// --- Multiple rapid assignments ---
it('handles multiple rapid assignments', async () => {
const storage = createMockAsyncStorage();
const { state } = await useStorageAsync<string>('rapid', 'initial', storage);
state.value = 'first';
state.value = 'second';
state.value = 'third';
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(storage.store.get('rapid')).toBe('third');
});
// --- writeDefaults ---
it('persists defaults to storage when key does not exist', async () => {
const storage = createMockAsyncStorage();
await useStorageAsync('new-key', 'default-val', storage);
expect(storage.store.get('new-key')).toBe('default-val');
});
it('does not persist defaults when writeDefaults is false', async () => {
const storage = createMockAsyncStorage();
await useStorageAsync('new-key', 'default-val', storage, { writeDefaults: false });
expect(storage.store.has('new-key')).toBeFalsy();
});
it('does not overwrite existing value with defaults', async () => {
const storage = createMockAsyncStorage();
storage.store.set('existing', 'stored');
await useStorageAsync('existing', 'default', storage);
expect(storage.store.get('existing')).toBe('stored');
});
// --- Reactive key ---
it('re-reads from async storage when reactive key changes', async () => {
const storage = createMockAsyncStorage();
storage.store.set('key-a', 'value-a');
storage.store.set('key-b', 'value-b');
const keyRef = ref('key-a');
const { state } = await useStorageAsync<string>(keyRef, 'default', storage);
expect(state.value).toBe('value-a');
keyRef.value = 'key-b';
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(state.value).toBe('value-b');
});
// --- eventFilter ---
it('applies event filter to writes', async () => {
const storage = createMockAsyncStorage();
let captured: (() => void) | undefined;
const { state } = await useStorageAsync<string>('filtered', 'initial', storage, {
eventFilter: (invoke) => { captured = invoke; },
});
const setItem = vi.spyOn(storage, 'setItem');
(setItem as any).mockClear();
state.value = 'filtered-value';
await nextTick();
// Not written yet — held by filter
expect(setItem).not.toHaveBeenCalled();
expect(captured).toBeDefined();
// Release the filter
captured!();
await new Promise(resolve => setTimeout(resolve, 0));
expect(setItem).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,266 @@
import { computed, ref, shallowRef, watch, toValue } from 'vue';
import type { Ref, ShallowRef, MaybeRefOrGetter, UnwrapRef } from 'vue';
import type { ConfigurableFlush, ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
import { useEventListener } from '@/composables/browser/useEventListener';
import { guessSerializer, shallowMerge } from '../useStorage';
import type { StorageEventLike } from '../useStorage';
export interface StorageSerializerAsync<T> {
read: (raw: string) => T | Promise<T>;
write: (value: T) => string | Promise<string>;
}
export interface StorageLikeAsync {
getItem: (key: string) => string | null | Promise<string | null>;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
}
export interface UseStorageAsyncOptions<T, Shallow extends boolean = true> extends ConfigurableFlush, ConfigurableWindow, ConfigurableEventFilter {
/**
* Use shallowRef instead of ref for the internal state
* @default true
*/
shallow?: Shallow;
/**
* Watch for deep changes
* @default true
*/
deep?: boolean;
/**
* Listen to storage changes from other tabs/windows
* @default true
*/
listenToStorageChanges?: boolean;
/**
* Write the default value to the storage when it does not exist
* @default true
*/
writeDefaults?: boolean;
/**
* Custom serializer for reading/writing storage values
*/
serializer?: StorageSerializerAsync<T>;
/**
* Merge the default value with the stored value
* @default false
*/
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
/**
* Called once when the initial value has been loaded from storage
*/
onReady?: (value: T) => void;
/**
* Error handler for read/write failures
*/
onError?: (error: unknown) => void;
/**
* Wait for the component to be mounted before reading the storage
*
* Useful for SSR hydration to prevent mismatch
* @default false
*/
initOnMounted?: boolean;
}
export interface UseStorageAsyncReturnBase<T, Shallow extends boolean> {
state: Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
isReady: Ref<boolean>;
}
export type UseStorageAsyncReturn<T, Shallow extends boolean>
= & UseStorageAsyncReturnBase<T, Shallow>
& PromiseLike<UseStorageAsyncReturnBase<T, Shallow>>;
/**
* @name useStorageAsync
* @category Storage
* @description Reactive Storage binding with async support — creates a ref synced with an async storage backend
*
* @param {MaybeRefOrGetter<string>} key The storage key (can be reactive)
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
* @param {StorageLikeAsync} storage The async storage backend
* @param {UseStorageAsyncOptions<T>} [options={}] Options
* @returns {UseStorageAsyncReturn<T, Shallow>} An object with state ref and isReady flag, also awaitable
*
* @example
* const { state } = useStorageAsync('access-token', '', asyncStorage);
*
* @example
* const { state, isReady } = await useStorageAsync('settings', { theme: 'dark' }, asyncStorage);
*
* @example
* const { state } = useStorageAsync('key', 'default', asyncStorage, {
* onReady: (value) => console.log('Loaded:', value),
* });
*
* @since 0.0.12
*/
export function useStorageAsync<T extends string, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T extends number, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T extends boolean, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T = unknown, Shallow extends boolean = true>(key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
export function useStorageAsync<T, Shallow extends boolean = true>(
key: MaybeRefOrGetter<string>,
initialValue: MaybeRefOrGetter<T>,
storage: StorageLikeAsync,
options: UseStorageAsyncOptions<T, Shallow> = {},
): UseStorageAsyncReturn<T, Shallow> {
const {
shallow = true,
deep = true,
flush = 'pre',
writeDefaults = true,
mergeDefaults = false,
listenToStorageChanges = true,
window = defaultWindow,
eventFilter,
initOnMounted = false,
onReady,
onError = console.error, // eslint-disable-line no-console
} = options;
const defaults = toValue(initialValue);
const serializer = options.serializer ?? guessSerializer(defaults);
const state = (shallow ? shallowRef : ref)(defaults) as Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
const isReady = ref(false);
const keyComputed = computed<string>(() => toValue(key));
async function read(event?: StorageEventLike): Promise<T> {
try {
const rawValue = event
? event.newValue
: await storage.getItem(keyComputed.value);
if (rawValue === undefined || rawValue === null) {
if (writeDefaults && defaults !== undefined && defaults !== null) {
try {
await storage.setItem(keyComputed.value, await serializer.write(defaults));
}
catch (e) {
onError(e);
}
}
return defaults;
}
if (!event && mergeDefaults) {
const value: T = await serializer.read(rawValue) as T;
return typeof mergeDefaults === 'function'
? mergeDefaults(value, defaults)
: shallowMerge(value, defaults);
}
return await serializer.read(rawValue) as T;
}
catch (e) {
onError(e);
return defaults;
}
}
async function write(value: T) {
try {
if (value === undefined || value === null) {
await storage.removeItem(keyComputed.value);
}
else {
const raw = await serializer.write(value);
await storage.setItem(keyComputed.value, raw);
}
}
catch (e) {
onError(e);
}
}
// Apply event filter if provided
const writeWithFilter: (value: T) => void = eventFilter
? (value: T) => (eventFilter as EventFilter)(() => write(value))
: (value: T) => { write(value); };
let stopWatch: (() => void) | null = null;
let stopKeyWatch: (() => void) | null = null;
tryOnScopeDispose(() => {
stopWatch?.();
stopKeyWatch?.();
});
// Event listeners for cross-tab synchronization
let firstMounted = false;
if (window && listenToStorageChanges) {
useEventListener(window, 'storage', (ev: StorageEvent) => {
if (initOnMounted && !firstMounted)
return;
if (ev.key !== keyComputed.value)
return;
if (ev.storageArea !== storage)
return;
Promise.resolve().then(() => read(ev)).then((value) => {
(state as Ref).value = value;
});
}, { passive: true });
}
const shell: UseStorageAsyncReturnBase<T, Shallow> = {
state,
isReady,
};
function performInit() {
return read().then((value) => {
(state as Ref).value = value;
isReady.value = true;
onReady?.(value);
// Set up watcher AFTER initial state is set — avoids write-back on init
const stop = watch(state, (newValue) => {
writeWithFilter(newValue as T);
}, { flush, deep });
stopWatch = stop;
// Watch for key changes
stopKeyWatch = watch(keyComputed, () => {
read().then((v) => {
(state as Ref).value = v;
});
}, { flush });
return shell;
});
}
let readyPromise: Promise<UseStorageAsyncReturnBase<T, Shallow>>;
if (initOnMounted) {
readyPromise = new Promise<UseStorageAsyncReturnBase<T, Shallow>>((resolve) => {
tryOnMounted(() => {
firstMounted = true;
performInit().then(resolve);
});
});
}
else {
readyPromise = performInit();
}
return {
...shell,
// eslint-disable-next-line unicorn/no-thenable
then(onFulfilled, onRejected) {
return readyPromise.then(onFulfilled, onRejected);
},
};
}

View File

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

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, vi } from 'vitest';
import { nextTick, ref } from 'vue';
import { useOffsetPagination } from '.';
describe(useOffsetPagination, () => {
it('initialize with default values without options', () => {
const { currentPage, currentPageSize, totalPages, isFirstPage } = useOffsetPagination({});
expect(currentPage.value).toBe(1);
expect(currentPageSize.value).toBe(10);
expect(totalPages.value).toBe(Infinity);
expect(isFirstPage.value).toBeTruthy();
});
it('calculate total pages correctly', () => {
const { totalPages } = useOffsetPagination({ total: 100, pageSize: 10 });
expect(totalPages.value).toBe(10);
});
it('update current page correctly', () => {
const { currentPage, next, previous, select } = useOffsetPagination({ total: 100, pageSize: 10 });
next();
expect(currentPage.value).toBe(2);
previous();
expect(currentPage.value).toBe(1);
select(5);
expect(currentPage.value).toBe(5);
});
it('handle out of bounds increments correctly', () => {
const { currentPage, next, previous } = useOffsetPagination({ total: 10, pageSize: 5 });
next();
next();
next();
expect(currentPage.value).toBe(2);
previous();
previous();
previous();
expect(currentPage.value).toBe(1);
});
it('handle page boundaries correctly', () => {
const { currentPage, isFirstPage, isLastPage } = useOffsetPagination({ total: 20, pageSize: 10 });
expect(currentPage.value).toBe(1);
expect(isFirstPage.value).toBeTruthy();
expect(isLastPage.value).toBeFalsy();
currentPage.value = 2;
expect(currentPage.value).toBe(2);
expect(isFirstPage.value).toBeFalsy();
expect(isLastPage.value).toBeTruthy();
});
it('call onPageChange callback', async () => {
const onPageChange = vi.fn();
const { currentPage, next } = useOffsetPagination({ total: 100, pageSize: 10, onPageChange });
next();
await nextTick();
expect(onPageChange).toHaveBeenCalledTimes(1);
expect(onPageChange.mock.calls[0]![0]).toHaveProperty('currentPage', currentPage.value);
});
it('call onPageSizeChange callback', async () => {
const onPageSizeChange = vi.fn();
const pageSize = ref(10);
const { currentPageSize } = useOffsetPagination({ total: 100, pageSize, onPageSizeChange });
pageSize.value = 20;
await nextTick();
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
expect(onPageSizeChange.mock.calls[0]![0]).toHaveProperty('currentPageSize', currentPageSize.value);
});
it('call onPageCountChange callback', async () => {
const onTotalPagesChange = vi.fn();
const total = ref(100);
const { totalPages } = useOffsetPagination({ total, pageSize: 10, onTotalPagesChange });
total.value = 200;
await nextTick();
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
expect(onTotalPagesChange.mock.calls[0]![0]).toHaveProperty('totalPages', totalPages.value);
});
it('handle complex reactive options', async () => {
const total = ref(100);
const pageSize = ref(10);
const page = ref(1);
const onPageChange = vi.fn();
const onPageSizeChange = vi.fn();
const onTotalPagesChange = vi.fn();
const { currentPage, currentPageSize, totalPages } = useOffsetPagination({
total,
pageSize,
page,
onPageChange,
onPageSizeChange,
onTotalPagesChange,
});
// Initial values
expect(currentPage.value).toBe(1);
expect(currentPageSize.value).toBe(10);
expect(totalPages.value).toBe(10);
expect(onPageChange).toHaveBeenCalledTimes(0);
expect(onPageSizeChange).toHaveBeenCalledTimes(0);
expect(onTotalPagesChange).toHaveBeenCalledTimes(0);
total.value = 300;
pageSize.value = 15;
page.value = 2;
await nextTick();
// Valid values after changes
expect(currentPage.value).toBe(2);
expect(currentPageSize.value).toBe(15);
expect(totalPages.value).toBe(20);
expect(onPageChange).toHaveBeenCalledTimes(1);
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
page.value = 21;
await nextTick();
// Invalid values after changes
expect(currentPage.value).toBe(20);
expect(onPageChange).toHaveBeenCalledTimes(2);
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,129 @@
import type { VoidFunction } from '@robonen/stdlib';
import { computed, reactive, toValue, watch } from 'vue';
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, UnwrapNestedRefs, WritableComputedRef } from 'vue';
import { useClamp } from '@/composables/math/useClamp';
// TODO: sync returned refs with passed refs
export interface UseOffsetPaginationOptions {
total?: MaybeRefOrGetter<number>;
pageSize?: MaybeRef<number>;
page?: MaybeRef<number>;
onPageChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
onPageSizeChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
onTotalPagesChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
}
export interface UseOffsetPaginationReturn {
currentPage: WritableComputedRef<number>;
currentPageSize: WritableComputedRef<number>;
totalPages: ComputedRef<number>;
isFirstPage: ComputedRef<boolean>;
isLastPage: ComputedRef<boolean>;
next: VoidFunction;
previous: VoidFunction;
select: (page: number) => void;
}
export type UseOffsetPaginationInfinityReturn = Omit<UseOffsetPaginationReturn, 'isLastPage'>;
/**
* @name useOffsetPagination
* @category Utilities
* @description A composable function that provides pagination functionality for offset based pagination
*
* @param {UseOffsetPaginationOptions} options The options for the pagination
* @param {MaybeRefOrGetter<number>} options.total The total number of items
* @param {MaybeRef<number>} options.pageSize The number of items per page
* @param {MaybeRef<number>} options.page The current page
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageChange A callback that is called when the page changes
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageSizeChange A callback that is called when the page size changes
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onTotalPagesChange A callback that is called when the total number of pages changes
* @returns {UseOffsetPaginationReturn} The pagination object
*
* @example
* const {
* currentPage,
* currentPageSize,
* totalPages,
* isFirstPage,
* isLastPage,
* next,
* previous,
* select,
* } = useOffsetPagination({ total: 100, pageSize: 10, page: 1 });
*
* @example
* const {
* currentPage,
* } = useOffsetPagination({
* total: 100,
* pageSize: 10,
* page: 1,
* onPageChange: ({ currentPage }) => console.log(currentPage),
* onPageSizeChange: ({ currentPageSize }) => console.log(currentPageSize),
* onTotalPagesChange: ({ totalPages }) => console.log(totalPages),
* });
*
* @since 0.0.1
*/
export function useOffsetPagination(options: Omit<UseOffsetPaginationOptions, 'total'>): UseOffsetPaginationInfinityReturn;
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn;
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn {
const {
total = Number.POSITIVE_INFINITY,
pageSize = 10,
page = 1,
} = options;
const currentPageSize = useClamp(pageSize, 1, Number.POSITIVE_INFINITY);
const totalPages = computed(() => Math.max(
1,
Math.ceil(toValue(total) / toValue(currentPageSize)),
));
const currentPage = useClamp(page, 1, totalPages);
const isFirstPage = computed(() => currentPage.value === 1);
const isLastPage = computed(() => currentPage.value === totalPages.value);
const next = () => currentPage.value++;
const previous = () => currentPage.value--;
const select = (page: number) => {
currentPage.value = page;
};
const returnValue = {
currentPage,
currentPageSize,
totalPages,
isFirstPage,
isLastPage,
next,
previous,
select,
};
// NOTE: Don't forget to await nextTick() after calling next() or previous() to ensure the callback is called
if (options.onPageChange) {
watch(currentPage, () => {
options.onPageChange!(reactive(returnValue));
});
}
if (options.onPageSizeChange) {
watch(currentPageSize, () => {
options.onPageSizeChange!(reactive(returnValue));
});
}
if (options.onTotalPagesChange) {
watch(totalPages, () => {
options.onTotalPagesChange!(reactive(returnValue));
});
}
return returnValue;
}

3
vue/toolkit/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './composables';
export * from './utils';
export * from './types';

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

@@ -0,0 +1,4 @@
export * from './flush';
export * from './ref';
export * from './resumable';
export * from './window';

View File

@@ -0,0 +1,7 @@
import type { Ref } from 'vue';
/**
* A ref that can be set to `null` to remove the associated storage entry.
* Setting the value to `null` or `undefined` will call `removeItem` on the storage backend.
*/
export type RemovableRef<T> = Ref<T>;

View File

@@ -0,0 +1,30 @@
/**
* Often times, we want to pause and resume a process. This is a common pattern in
* reactive programming. This interface defines the options and actions for a resumable
* process.
*/
/**
* The options for a resumable process.
*
* @typedef {Object} ResumableOptions
* @property {boolean} [immediate] Whether to immediately resume the process
*
*/
export interface ResumableOptions {
immediate?: boolean;
}
/**
* The actions for a resumable process.
*
* @typedef {Object} ResumableActions
* @property {Function} resume Resumes the process
* @property {Function} pause Pauses the process
* @property {Function} toggle Toggles the process
*/
export interface ResumableActions {
resume: () => void;
pause: () => void;
toggle: () => void;
}

View File

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

View File

@@ -0,0 +1,22 @@
import { getCurrentInstance } from 'vue';
import type { ComponentInternalInstance } from 'vue';
/**
* @name getLifeCycleTarger
* @category Utils
* @description Function to get the target instance of the lifecycle hook
*
* @param {ComponentInternalInstance} target The target instance of the lifecycle hook
* @returns {ComponentInternalInstance | null} Instance of the lifecycle hook or null
*
* @example
* const target = getLifeCycleTarger();
*
* @example
* const target = getLifeCycleTarger(instance);
*
* @since 0.0.1
*/
export function getLifeCycleTarger(target?: ComponentInternalInstance) {
return target || getCurrentInstance();
}

View File

@@ -0,0 +1,13 @@
/**
* @name VueToolsError
* @category Error
* @description VueToolsError is a custom error class that represents an error in Vue Tools
*
* @since 0.0.1
*/
export class VueToolsError extends Error {
constructor(message: string) {
super(message);
this.name = 'VueToolsError';
}
}

View File

@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ref } from 'vue';
import { bypassFilter, debounceFilter, throttleFilter, pausableFilter } from './filters';
describe(bypassFilter, () => {
it('invokes callback immediately', () => {
const fn = vi.fn();
bypassFilter(fn);
expect(fn).toHaveBeenCalledOnce();
});
});
describe(debounceFilter, () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('delays invocation by the specified ms', () => {
const filter = debounceFilter(100);
const fn = vi.fn();
filter(fn);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(fn).toHaveBeenCalledOnce();
});
it('resets timer on repeated calls', () => {
const filter = debounceFilter(100);
const fn = vi.fn();
filter(fn);
vi.advanceTimersByTime(80);
filter(fn);
vi.advanceTimersByTime(80);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(20);
expect(fn).toHaveBeenCalledOnce();
});
it('supports reactive ms via ref', () => {
const delay = ref(100);
const filter = debounceFilter(delay);
const fn = vi.fn();
filter(fn);
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
delay.value = 200;
filter(fn);
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce(); // still 1
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(2);
});
});
describe(throttleFilter, () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('invokes immediately on first call (leading)', () => {
const filter = throttleFilter(100);
const fn = vi.fn();
filter(fn);
expect(fn).toHaveBeenCalledOnce();
});
it('throttles subsequent calls', () => {
const filter = throttleFilter(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
filter(fn1);
expect(fn1).toHaveBeenCalledOnce();
// Within throttle window
filter(fn2);
expect(fn2).not.toHaveBeenCalled();
// After throttle window, trailing fires
vi.advanceTimersByTime(100);
expect(fn2).toHaveBeenCalledOnce();
});
it('does not invoke trailing when trailing=false', () => {
const filter = throttleFilter(100, false, true);
const fn1 = vi.fn();
const fn2 = vi.fn();
filter(fn1);
expect(fn1).toHaveBeenCalledOnce();
filter(fn2);
vi.advanceTimersByTime(200);
expect(fn2).not.toHaveBeenCalled();
});
it('does not invoke leading when leading=false', () => {
const filter = throttleFilter(100, true, false);
const fn = vi.fn();
filter(fn);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
});
});
describe(pausableFilter, () => {
it('invokes immediately when active', () => {
const { filter, isActive } = pausableFilter();
const fn = vi.fn();
expect(isActive.value).toBeTruthy();
filter(fn);
expect(fn).toHaveBeenCalledOnce();
});
it('queues invocations when paused', () => {
const { filter, pause } = pausableFilter();
const fn = vi.fn();
pause();
filter(fn);
expect(fn).not.toHaveBeenCalled();
});
it('replays queued invocations on resume', () => {
const { filter, pause, resume } = pausableFilter();
const fn1 = vi.fn();
const fn2 = vi.fn();
pause();
filter(fn1);
filter(fn2);
expect(fn1).not.toHaveBeenCalled();
expect(fn2).not.toHaveBeenCalled();
resume();
expect(fn1).toHaveBeenCalledOnce();
expect(fn2).toHaveBeenCalledOnce();
});
it('isActive reflects the paused state', () => {
const { isActive, pause, resume } = pausableFilter();
expect(isActive.value).toBeTruthy();
pause();
expect(isActive.value).toBeFalsy();
resume();
expect(isActive.value).toBeTruthy();
});
});

View File

@@ -0,0 +1,138 @@
import { ref, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
export type EventFilter = (invoke: () => void) => void;
export interface ConfigurableEventFilter {
/**
* Event filter for controlling how frequently writes propagate
*
* @example debounceFilter(500) — debounce writes by 500ms
* @example throttleFilter(1000) — throttle writes to once per 1000ms
*/
eventFilter?: EventFilter;
}
/**
* A no-op filter that invokes the callback immediately
*/
export const bypassFilter: EventFilter = (invoke) => {
invoke();
};
/**
* Create a debounce event filter
*
* @param ms Delay in milliseconds (can be reactive)
* @returns EventFilter
*/
export function debounceFilter(ms: MaybeRefOrGetter<number>): EventFilter {
let timer: ReturnType<typeof setTimeout> | undefined;
const filter: EventFilter = (invoke) => {
if (timer !== undefined)
clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
invoke();
}, toValue(ms));
};
return filter;
}
/**
* Create a throttle event filter
*
* @param ms Interval in milliseconds (can be reactive)
* @param trailing Whether to invoke on trailing edge (default: true)
* @param leading Whether to invoke on leading edge (default: true)
* @returns EventFilter
*/
export function throttleFilter(
ms: MaybeRefOrGetter<number>,
trailing = true,
leading = true,
): EventFilter {
let lastExec = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let lastInvoke: (() => void) | undefined;
let isLeading = true;
const clear = () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
const filter: EventFilter = (invoke) => {
const duration = toValue(ms);
const elapsed = Date.now() - lastExec;
lastInvoke = invoke;
if (elapsed >= duration && (leading || !isLeading)) {
lastExec = Date.now();
isLeading = false;
invoke();
clear();
return;
}
isLeading = false;
if (trailing) {
clear();
timer = setTimeout(() => {
lastExec = Date.now();
isLeading = true;
timer = undefined;
lastInvoke?.();
}, Math.max(0, duration - elapsed));
}
};
return filter;
}
export interface PausableEventFilterReturn {
filter: EventFilter;
isActive: Ref<boolean>;
pause: () => void;
resume: () => void;
}
/**
* Create a pausable event filter
*
* When paused, invocations are queued and replayed on resume.
*
* @returns PausableEventFilterReturn
*/
export function pausableFilter(): PausableEventFilterReturn {
const isActive = ref(true);
let pendingInvocations: Array<() => void> = [];
const filter: EventFilter = (invoke) => {
if (isActive.value) {
invoke();
}
else {
pendingInvocations.push(invoke);
}
};
return {
filter,
isActive: isActive as Ref<boolean>,
pause: () => { isActive.value = false; },
resume: () => {
isActive.value = true;
const pending = pendingInvocations;
pendingInvocations = [];
for (const fn of pending) fn();
},
};
}

View File

@@ -0,0 +1,3 @@
export * from './components';
export * from './error';
export * from './filters';

13
vue/toolkit/tsconfig.json Normal file
View File

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

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
external: ['vue'],
noExternal: [/^@robonen\//],
});

View File

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