mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/vue",
|
"name": "@robonen/vue",
|
||||||
"version": "0.0.12",
|
"version": "0.0.13",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Collection of powerful tools for Vue",
|
"description": "Collection of powerful tools for Vue",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './useEventListener';
|
export * from './useEventListener';
|
||||||
export * from './useFocusGuard';
|
export * from './useFocusGuard';
|
||||||
export * from './useSupported';
|
export * from './useSupported';
|
||||||
|
export * from './useTabLeader';
|
||||||
|
|||||||
375
web/vue/src/composables/browser/useEventListener/index.test.ts
Normal file
375
web/vue/src/composables/browser/useEventListener/index.test.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { isArray, isString, noop } from '@robonen/stdlib';
|
import { first, isArray, isObject, isString, noop } from '@robonen/stdlib';
|
||||||
import type { Arrayable, VoidFunction } from '@robonen/stdlib';
|
import type { Arrayable, VoidFunction } from '@robonen/stdlib';
|
||||||
|
import { toValue, watch } from 'vue';
|
||||||
import type { MaybeRefOrGetter } from 'vue';
|
import type { MaybeRefOrGetter } from 'vue';
|
||||||
import { defaultWindow } from '@/types';
|
import { defaultWindow } from '@/types';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
// TODO: wip
|
|
||||||
|
|
||||||
interface InferEventTarget<Events> {
|
interface InferEventTarget<Events> {
|
||||||
addEventListener: (event: Events, listener?: any, options?: any) => any;
|
addEventListener: (event: Events, listener?: any, options?: any) => any;
|
||||||
@@ -105,7 +105,7 @@ export function useEventListener(...args: any[]) {
|
|||||||
let listeners: Arrayable<Function>;
|
let listeners: Arrayable<Function>;
|
||||||
let _options: MaybeRefOrGetter<boolean | AddEventListenerOptions> | undefined;
|
let _options: MaybeRefOrGetter<boolean | AddEventListenerOptions> | undefined;
|
||||||
|
|
||||||
if (isString(args[0]) || isArray(args[0])) {
|
if (isString(first(args)) || isArray(first(args))) {
|
||||||
[events, listeners, _options] = args;
|
[events, listeners, _options] = args;
|
||||||
target = defaultWindow;
|
target = defaultWindow;
|
||||||
} else {
|
} else {
|
||||||
@@ -128,11 +128,40 @@ export function useEventListener(...args: any[]) {
|
|||||||
cleanups.length = 0;
|
cleanups.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _register = (el: any, event: string, listener: any, options: any) => {
|
const _register = (el: EventTarget, event: string, listener: Function, options: boolean | AddEventListenerOptions | undefined) => {
|
||||||
el.addEventListener(event, listener, options);
|
el.addEventListener(event, listener as EventListener, options);
|
||||||
return () => el.removeEventListener(event, listener, options);
|
return () => el.removeEventListener(event, listener as EventListener, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cleanup;
|
const stopWatch = watch(
|
||||||
void _register;
|
() => [toValue(target), toValue(_options)] as const,
|
||||||
|
([el, options]) => {
|
||||||
|
_cleanup();
|
||||||
|
|
||||||
|
if (!el)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Clone object options to avoid reactive mutation between add/remove
|
||||||
|
const optionsClone = isObject(options) ? { ...options } : options;
|
||||||
|
|
||||||
|
const eventsArray = events as string[];
|
||||||
|
const listenersArray = listeners as Function[];
|
||||||
|
|
||||||
|
cleanups.push(
|
||||||
|
...eventsArray.flatMap((event) =>
|
||||||
|
listenersArray.map((listener) => _register(el, event, listener, optionsClone)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopWatch();
|
||||||
|
_cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
tryOnScopeDispose(stop);
|
||||||
|
|
||||||
|
return stop;
|
||||||
}
|
}
|
||||||
222
web/vue/src/composables/browser/useTabLeader/index.test.ts
Normal file
222
web/vue/src/composables/browser/useTabLeader/index.test.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { defineComponent, effectScope, nextTick } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { useTabLeader } from '.';
|
||||||
|
|
||||||
|
type LockGrantedCallback = (lock: unknown) => Promise<void>;
|
||||||
|
interface MockLockRequest {
|
||||||
|
key: string;
|
||||||
|
callback: LockGrantedCallback;
|
||||||
|
resolve: () => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRequests: MockLockRequest[] = [];
|
||||||
|
let heldLocks: Set<string>;
|
||||||
|
|
||||||
|
function setupLocksMock() {
|
||||||
|
heldLocks = new Set();
|
||||||
|
|
||||||
|
const mockLocks = {
|
||||||
|
request: vi.fn(async (key: string, options: { signal?: AbortSignal }, callback: LockGrantedCallback) => {
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new DOMException('The operation was aborted.', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heldLocks.has(key)) {
|
||||||
|
// Queue the request — lock is held
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const request: MockLockRequest = { key, callback, resolve, signal: options.signal };
|
||||||
|
|
||||||
|
options.signal?.addEventListener('abort', () => {
|
||||||
|
const index = pendingRequests.indexOf(request);
|
||||||
|
if (index > -1) pendingRequests.splice(index, 1);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingRequests.push(request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
heldLocks.add(key);
|
||||||
|
const result = callback({} as unknown);
|
||||||
|
|
||||||
|
// When the callback promise resolves (lock released), grant to next waiter
|
||||||
|
result.then(() => {
|
||||||
|
heldLocks.delete(key);
|
||||||
|
grantNextLock(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, 'locks', {
|
||||||
|
value: mockLocks,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantNextLock(key: string) {
|
||||||
|
const index = pendingRequests.findIndex((r) => r.key === key);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
const [request] = pendingRequests.splice(index, 1);
|
||||||
|
if (!request) return;
|
||||||
|
|
||||||
|
heldLocks.add(key);
|
||||||
|
|
||||||
|
const result = request.callback({} as unknown);
|
||||||
|
result.then(() => {
|
||||||
|
heldLocks.delete(key);
|
||||||
|
request.resolve();
|
||||||
|
grantNextLock(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountWithComposable = (setup: () => Record<string, any> | void) => {
|
||||||
|
return mount(
|
||||||
|
defineComponent({
|
||||||
|
setup,
|
||||||
|
template: '<div></div>',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(useTabLeader, () => {
|
||||||
|
let component: ReturnType<typeof mountWithComposable>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pendingRequests.length = 0;
|
||||||
|
setupLocksMock();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
component?.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acquire leadership when lock is available', async () => {
|
||||||
|
component = mountWithComposable(() => {
|
||||||
|
const { isLeader, isSupported } = useTabLeader('test-leader');
|
||||||
|
return { isLeader, isSupported };
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(component.vm.isSupported).toBeTruthy();
|
||||||
|
expect(component.vm.isLeader).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not grant leadership when another tab holds the lock', async () => {
|
||||||
|
const scope1 = effectScope();
|
||||||
|
let leader1: ReturnType<typeof useTabLeader>;
|
||||||
|
|
||||||
|
scope1.run(() => {
|
||||||
|
leader1 = useTabLeader('exclusive');
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope2 = effectScope();
|
||||||
|
let leader2: ReturnType<typeof useTabLeader>;
|
||||||
|
|
||||||
|
scope2.run(() => {
|
||||||
|
leader2 = useTabLeader('exclusive');
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(leader1!.isLeader.value).toBeTruthy();
|
||||||
|
expect(leader2!.isLeader.value).toBeFalsy();
|
||||||
|
|
||||||
|
scope1.stop();
|
||||||
|
scope2.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transfer leadership when the leader releases the lock', async () => {
|
||||||
|
const scope1 = effectScope();
|
||||||
|
let leader1: ReturnType<typeof useTabLeader>;
|
||||||
|
|
||||||
|
scope1.run(() => {
|
||||||
|
leader1 = useTabLeader('transfer');
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope2 = effectScope();
|
||||||
|
let leader2: ReturnType<typeof useTabLeader>;
|
||||||
|
|
||||||
|
scope2.run(() => {
|
||||||
|
leader2 = useTabLeader('transfer');
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(leader1!.isLeader.value).toBeTruthy();
|
||||||
|
expect(leader2!.isLeader.value).toBeFalsy();
|
||||||
|
|
||||||
|
// Leader 1 releases (e.g., tab closes)
|
||||||
|
scope1.stop();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(leader1!.isLeader.value).toBeFalsy();
|
||||||
|
expect(leader2!.isLeader.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope2.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manually release and re-acquire leadership', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let leader: ReturnType<typeof useTabLeader>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
leader = useTabLeader('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(leader!.isLeader.value).toBeTruthy();
|
||||||
|
|
||||||
|
leader!.release();
|
||||||
|
await nextTick();
|
||||||
|
expect(leader!.isLeader.value).toBeFalsy();
|
||||||
|
|
||||||
|
leader!.acquire();
|
||||||
|
await nextTick();
|
||||||
|
expect(leader!.isLeader.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not acquire when immediate is false', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let leader: ReturnType<typeof useTabLeader>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
leader = useTabLeader('deferred', { immediate: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(leader!.isLeader.value).toBeFalsy();
|
||||||
|
expect(navigator.locks.request).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
leader!.acquire();
|
||||||
|
await nextTick();
|
||||||
|
expect(leader!.isLeader.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fallback to isLeader always false when locks API is not supported', async () => {
|
||||||
|
Object.defineProperty(navigator, 'locks', {
|
||||||
|
value: undefined,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
component = mountWithComposable(() => {
|
||||||
|
const { isLeader, isSupported } = useTabLeader('unsupported');
|
||||||
|
return { isLeader, isSupported };
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(component.vm.isSupported).toBeFalsy();
|
||||||
|
expect(component.vm.isLeader).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
115
web/vue/src/composables/browser/useTabLeader/index.ts
Normal file
115
web/vue/src/composables/browser/useTabLeader/index.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { ref, readonly } from 'vue';
|
||||||
|
import type { Ref, DeepReadonly, ComputedRef } from 'vue';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface UseTabLeaderOptions {
|
||||||
|
/**
|
||||||
|
* Immediately attempt to acquire leadership on creation
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTabLeaderReturn {
|
||||||
|
/**
|
||||||
|
* Whether the current tab is the leader
|
||||||
|
*/
|
||||||
|
isLeader: DeepReadonly<Ref<boolean>>;
|
||||||
|
/**
|
||||||
|
* Whether the Web Locks API is supported
|
||||||
|
*/
|
||||||
|
isSupported: ComputedRef<boolean>;
|
||||||
|
/**
|
||||||
|
* Manually acquire leadership
|
||||||
|
*/
|
||||||
|
acquire: () => void;
|
||||||
|
/**
|
||||||
|
* Manually release leadership
|
||||||
|
*/
|
||||||
|
release: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useTabLeader
|
||||||
|
* @category Browser
|
||||||
|
* @description Elects a single leader tab using the Web Locks API.
|
||||||
|
* Only one tab at a time holds the lock for a given key.
|
||||||
|
* When the leader tab closes or the scope is disposed, another tab automatically becomes the leader.
|
||||||
|
*
|
||||||
|
* @param {string} key A unique lock name identifying the leader group
|
||||||
|
* @param {UseTabLeaderOptions} [options={}] Options
|
||||||
|
* @returns {UseTabLeaderReturn} Leader state and controls
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { isLeader } = useTabLeader('payment-polling');
|
||||||
|
*
|
||||||
|
* watchEffect(() => {
|
||||||
|
* if (isLeader.value) {
|
||||||
|
* // Only this tab performs polling
|
||||||
|
* startPolling();
|
||||||
|
* } else {
|
||||||
|
* stopPolling();
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @since 0.0.13
|
||||||
|
*/
|
||||||
|
export function useTabLeader(key: string, options: UseTabLeaderOptions = {}): UseTabLeaderReturn {
|
||||||
|
const { immediate = true } = options;
|
||||||
|
|
||||||
|
const isLeader = ref(false);
|
||||||
|
const isSupported = useSupported(() => navigator?.locks);
|
||||||
|
|
||||||
|
let releaseResolve: (() => void) | null = null;
|
||||||
|
let abortController: AbortController | null = null;
|
||||||
|
|
||||||
|
function acquire() {
|
||||||
|
if (!isSupported.value || abortController) return;
|
||||||
|
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
navigator.locks.request(
|
||||||
|
key,
|
||||||
|
{ signal: abortController.signal },
|
||||||
|
() => {
|
||||||
|
isLeader.value = true;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
releaseResolve = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
).catch((error: unknown) => {
|
||||||
|
// AbortError is expected when release() is called before lock is acquired
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function release() {
|
||||||
|
isLeader.value = false;
|
||||||
|
|
||||||
|
if (releaseResolve) {
|
||||||
|
releaseResolve();
|
||||||
|
releaseResolve = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort();
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
tryOnScopeDispose(release);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLeader: readonly(isLeader),
|
||||||
|
isSupported,
|
||||||
|
acquire,
|
||||||
|
release,
|
||||||
|
};
|
||||||
|
}
|
||||||
147
web/vue/src/composables/reactivity/broadcastedRef/index.test.ts
Normal file
147
web/vue/src/composables/reactivity/broadcastedRef/index.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { defineComponent, effectScope, nextTick, watch } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { broadcastedRef } from '.';
|
||||||
|
|
||||||
|
type MessageHandler = ((event: MessageEvent) => void) | null;
|
||||||
|
|
||||||
|
class MockBroadcastChannel {
|
||||||
|
static instances: MockBroadcastChannel[] = [];
|
||||||
|
|
||||||
|
name: string;
|
||||||
|
onmessage: MessageHandler = null;
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(name: string) {
|
||||||
|
this.name = name;
|
||||||
|
MockBroadcastChannel.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage(data: unknown) {
|
||||||
|
if (this.closed) return;
|
||||||
|
|
||||||
|
for (const instance of MockBroadcastChannel.instances) {
|
||||||
|
if (instance !== this && instance.name === this.name && !instance.closed && instance.onmessage) {
|
||||||
|
instance.onmessage(new MessageEvent('message', { data }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
const index = MockBroadcastChannel.instances.indexOf(this);
|
||||||
|
if (index > -1) MockBroadcastChannel.instances.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountWithRef = (setup: () => Record<string, any> | void) => {
|
||||||
|
return mount(
|
||||||
|
defineComponent({
|
||||||
|
setup,
|
||||||
|
template: '<div></div>',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(broadcastedRef, () => {
|
||||||
|
let component: ReturnType<typeof mountWithRef>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
MockBroadcastChannel.instances = [];
|
||||||
|
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
component?.unmount();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create a ref with the initial value', () => {
|
||||||
|
component = mountWithRef(() => {
|
||||||
|
const count = broadcastedRef('test-key', 42);
|
||||||
|
expect(count.value).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcast value changes to other channels with the same key', () => {
|
||||||
|
const ref1 = broadcastedRef('shared', 0);
|
||||||
|
const ref2 = broadcastedRef('shared', 0);
|
||||||
|
|
||||||
|
ref1.value = 100;
|
||||||
|
|
||||||
|
expect(ref2.value).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not broadcast to channels with a different key', () => {
|
||||||
|
const ref1 = broadcastedRef('key-a', 0);
|
||||||
|
const ref2 = broadcastedRef('key-b', 0);
|
||||||
|
|
||||||
|
ref1.value = 100;
|
||||||
|
|
||||||
|
expect(ref2.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('receive values from other channels and trigger reactivity', async () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
component = mountWithRef(() => {
|
||||||
|
const data = broadcastedRef('reactive-test', 'initial');
|
||||||
|
watch(data, callback, { flush: 'sync' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const sender = broadcastedRef('reactive-test', '');
|
||||||
|
sender.value = 'updated';
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
expect(callback).toHaveBeenCalledWith('updated', 'initial', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not broadcast initial value by default', () => {
|
||||||
|
const ref1 = broadcastedRef('no-immediate', 'first');
|
||||||
|
const ref2 = broadcastedRef('no-immediate', 'second');
|
||||||
|
|
||||||
|
expect(ref1.value).toBe('first');
|
||||||
|
expect(ref2.value).toBe('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcast initial value when immediate is true', () => {
|
||||||
|
const ref1 = broadcastedRef('immediate-test', 'existing');
|
||||||
|
broadcastedRef('immediate-test', 'new-value', { immediate: true });
|
||||||
|
|
||||||
|
expect(ref1.value).toBe('new-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close channel on scope dispose', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
broadcastedRef('dispose-test', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(MockBroadcastChannel.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
|
||||||
|
expect(MockBroadcastChannel.instances).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle complex object values via structured clone', () => {
|
||||||
|
const ref1 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
|
||||||
|
const ref2 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
|
||||||
|
|
||||||
|
ref1.value = { status: 'paid', amount: 99.99 };
|
||||||
|
|
||||||
|
expect(ref2.value).toEqual({ status: 'paid', amount: 99.99 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fallback to a regular ref when BroadcastChannel is not available', () => {
|
||||||
|
vi.stubGlobal('BroadcastChannel', undefined);
|
||||||
|
|
||||||
|
const data = broadcastedRef('fallback', 'value');
|
||||||
|
|
||||||
|
expect(data.value).toBe('value');
|
||||||
|
|
||||||
|
data.value = 'updated';
|
||||||
|
expect(data.value).toBe('updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
import { customRef, onScopeDispose } from 'vue';
|
import { customRef, ref } from 'vue';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface BroadcastedRefOptions {
|
||||||
|
/**
|
||||||
|
* Immediately broadcast the initial value to other tabs on creation
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name broadcastedRef
|
* @name broadcastedRef
|
||||||
@@ -7,35 +18,51 @@ import { customRef, onScopeDispose } from 'vue';
|
|||||||
*
|
*
|
||||||
* @param {string} key The channel key to use for broadcasting
|
* @param {string} key The channel key to use for broadcasting
|
||||||
* @param {T} initialValue The initial value of the ref
|
* @param {T} initialValue The initial value of the ref
|
||||||
|
* @param {BroadcastedRefOptions} [options={}] Options
|
||||||
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
|
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const count = broadcastedRef('counter', 0);
|
* const count = broadcastedRef('counter', 0);
|
||||||
*
|
*
|
||||||
* @since 0.0.1
|
* @example
|
||||||
|
* const state = broadcastedRef('payment-status', { status: 'pending' });
|
||||||
|
*
|
||||||
|
* @since 0.0.13
|
||||||
*/
|
*/
|
||||||
export function broadcastedRef<T>(key: string, initialValue: T) {
|
export function broadcastedRef<T>(key: string, initialValue: T, options: BroadcastedRefOptions = {}): Ref<T> {
|
||||||
|
const { immediate = false } = options;
|
||||||
|
|
||||||
|
if (!defaultWindow || typeof BroadcastChannel === 'undefined') {
|
||||||
|
return ref(initialValue) as Ref<T>;
|
||||||
|
}
|
||||||
|
|
||||||
const channel = new BroadcastChannel(key);
|
const channel = new BroadcastChannel(key);
|
||||||
|
let value = initialValue;
|
||||||
|
|
||||||
onScopeDispose(channel.close);
|
const data = customRef<T>((track, trigger) => {
|
||||||
|
channel.onmessage = (event: MessageEvent<T>) => {
|
||||||
return customRef<T>((track, trigger) => {
|
value = event.data;
|
||||||
channel.onmessage = (event) => {
|
trigger();
|
||||||
track();
|
|
||||||
return event.data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
channel.postMessage(initialValue);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get() {
|
get() {
|
||||||
return initialValue;
|
track();
|
||||||
|
return value;
|
||||||
},
|
},
|
||||||
set(newValue: T) {
|
set(newValue: T) {
|
||||||
initialValue = newValue;
|
value = newValue;
|
||||||
channel.postMessage(newValue);
|
channel.postMessage(newValue);
|
||||||
trigger();
|
trigger();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
channel.postMessage(initialValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
tryOnScopeDispose(() => channel.close());
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user