feat(monorepo): migrate vue packages and apply oxlint refactors
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user