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

This commit is contained in:
2026-03-07 18:07:22 +07:00
parent abd6605db3
commit 41d5e18f6b
286 changed files with 10295 additions and 5028 deletions
@@ -0,0 +1,7 @@
export * from './useEventListener';
export * from './useFocusGuard';
export * from './useFps';
export * from './useIntervalFn';
export * from './useRafFn';
export * from './useSupported';
export * from './useTabLeader';
@@ -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();
});
});
@@ -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;
}
@@ -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);
});
});
@@ -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);
}
@@ -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>
@@ -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();
});
});
@@ -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,
};
}
@@ -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();
});
});
@@ -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,
};
}
@@ -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();
});
});
@@ -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,
};
}
@@ -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');
});
});
@@ -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());
});
}
@@ -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>
@@ -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();
});
});
@@ -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,
};
}