From c48de9a3d1c6431610819d458d22cd8a89589e84 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 15 Feb 2026 05:29:08 +0700 Subject: [PATCH] feat(web/vue): update version to 0.0.13 and add useTabLeader composable with tests --- web/vue/package.json | 2 +- web/vue/src/composables/browser/index.ts | 1 + .../browser/useEventListener/index.test.ts | 375 ++++++++++++++++++ .../browser/useEventListener/index.ts | 48 ++- .../browser/useTabLeader/index.test.ts | 222 +++++++++++ .../composables/browser/useTabLeader/index.ts | 115 ++++++ .../reactivity/broadcastedRef/index.test.ts | 147 +++++++ .../reactivity/broadcastedRef/index.ts | 53 ++- 8 files changed, 940 insertions(+), 23 deletions(-) create mode 100644 web/vue/src/composables/browser/useEventListener/index.test.ts create mode 100644 web/vue/src/composables/browser/useTabLeader/index.test.ts create mode 100644 web/vue/src/composables/browser/useTabLeader/index.ts create mode 100644 web/vue/src/composables/reactivity/broadcastedRef/index.test.ts diff --git a/web/vue/package.json b/web/vue/package.json index fd8326f..6521ec2 100644 --- a/web/vue/package.json +++ b/web/vue/package.json @@ -1,6 +1,6 @@ { "name": "@robonen/vue", - "version": "0.0.12", + "version": "0.0.13", "license": "Apache-2.0", "description": "Collection of powerful tools for Vue", "keywords": [ diff --git a/web/vue/src/composables/browser/index.ts b/web/vue/src/composables/browser/index.ts index fdd3cfb..232c767 100644 --- a/web/vue/src/composables/browser/index.ts +++ b/web/vue/src/composables/browser/index.ts @@ -1,3 +1,4 @@ export * from './useEventListener'; export * from './useFocusGuard'; export * from './useSupported'; +export * from './useTabLeader'; diff --git a/web/vue/src/composables/browser/useEventListener/index.test.ts b/web/vue/src/composables/browser/useEventListener/index.test.ts new file mode 100644 index 0000000..9415dbd --- /dev/null +++ b/web/vue/src/composables/browser/useEventListener/index.test.ts @@ -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 | void, +) => { + return mount( + defineComponent({ + setup, + template: '
', + }), + ); +}; + +describe(useEventListener, () => { + let component: ReturnType; + + 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(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(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(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, '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(window, '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(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({ 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(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(); + }); +}); diff --git a/web/vue/src/composables/browser/useEventListener/index.ts b/web/vue/src/composables/browser/useEventListener/index.ts index 86c0065..9ab4c2f 100644 --- a/web/vue/src/composables/browser/useEventListener/index.ts +++ b/web/vue/src/composables/browser/useEventListener/index.ts @@ -1,9 +1,10 @@ -import { isArray, isString, noop } from '@robonen/stdlib'; +import { isArray, isObject, isString, noop } from '@robonen/stdlib'; import type { Arrayable, VoidFunction } from '@robonen/stdlib'; +import { first } from '@robonen/stdlib'; +import { toValue, watch } from 'vue'; import type { MaybeRefOrGetter } from 'vue'; import { defaultWindow } from '@/types'; - -// TODO: wip +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; interface InferEventTarget { addEventListener: (event: Events, listener?: any, options?: any) => any; @@ -105,7 +106,7 @@ export function useEventListener(...args: any[]) { let listeners: Arrayable; let _options: MaybeRefOrGetter | undefined; - if (isString(args[0]) || isArray(args[0])) { + if (isString(first(args)) || isArray(first(args))) { [events, listeners, _options] = args; target = defaultWindow; } else { @@ -128,11 +129,40 @@ export function useEventListener(...args: any[]) { cleanups.length = 0; } - const _register = (el: any, event: string, listener: any, options: any) => { - el.addEventListener(event, listener, options); - return () => el.removeEventListener(event, listener, options); + const _register = (el: EventTarget, event: string, listener: Function, options: boolean | AddEventListenerOptions | undefined) => { + el.addEventListener(event, listener as EventListener, options); + return () => el.removeEventListener(event, listener as EventListener, options); } - void _cleanup; - void _register; + const stopWatch = watch( + () => [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; } \ No newline at end of file diff --git a/web/vue/src/composables/browser/useTabLeader/index.test.ts b/web/vue/src/composables/browser/useTabLeader/index.test.ts new file mode 100644 index 0000000..20948e0 --- /dev/null +++ b/web/vue/src/composables/browser/useTabLeader/index.test.ts @@ -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; +interface MockLockRequest { + key: string; + callback: LockGrantedCallback; + resolve: () => void; + signal?: AbortSignal; +} + +const pendingRequests: MockLockRequest[] = []; +let heldLocks: Set; + +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((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 | void) => { + return mount( + defineComponent({ + setup, + template: '
', + }), + ); +}; + +describe(useTabLeader, () => { + let component: ReturnType; + + 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).toBe(true); + expect(component.vm.isLeader).toBe(true); + }); + + it('not grant leadership when another tab holds the lock', async () => { + const scope1 = effectScope(); + let leader1: ReturnType; + + scope1.run(() => { + leader1 = useTabLeader('exclusive'); + }); + + const scope2 = effectScope(); + let leader2: ReturnType; + + scope2.run(() => { + leader2 = useTabLeader('exclusive'); + }); + + await nextTick(); + + expect(leader1!.isLeader.value).toBe(true); + expect(leader2!.isLeader.value).toBe(false); + + scope1.stop(); + scope2.stop(); + }); + + it('transfer leadership when the leader releases the lock', async () => { + const scope1 = effectScope(); + let leader1: ReturnType; + + scope1.run(() => { + leader1 = useTabLeader('transfer'); + }); + + const scope2 = effectScope(); + let leader2: ReturnType; + + scope2.run(() => { + leader2 = useTabLeader('transfer'); + }); + + await nextTick(); + expect(leader1!.isLeader.value).toBe(true); + expect(leader2!.isLeader.value).toBe(false); + + // Leader 1 releases (e.g., tab closes) + scope1.stop(); + await nextTick(); + + expect(leader1!.isLeader.value).toBe(false); + expect(leader2!.isLeader.value).toBe(true); + + scope2.stop(); + }); + + it('manually release and re-acquire leadership', async () => { + const scope = effectScope(); + let leader: ReturnType; + + scope.run(() => { + leader = useTabLeader('manual'); + }); + + await nextTick(); + expect(leader!.isLeader.value).toBe(true); + + leader!.release(); + await nextTick(); + expect(leader!.isLeader.value).toBe(false); + + leader!.acquire(); + await nextTick(); + expect(leader!.isLeader.value).toBe(true); + + scope.stop(); + }); + + it('not acquire when immediate is false', async () => { + const scope = effectScope(); + let leader: ReturnType; + + scope.run(() => { + leader = useTabLeader('deferred', { immediate: false }); + }); + + await nextTick(); + expect(leader!.isLeader.value).toBe(false); + expect(navigator.locks.request).not.toHaveBeenCalled(); + + leader!.acquire(); + await nextTick(); + expect(leader!.isLeader.value).toBe(true); + + 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).toBe(false); + expect(component.vm.isLeader).toBe(false); + }); +}); diff --git a/web/vue/src/composables/browser/useTabLeader/index.ts b/web/vue/src/composables/browser/useTabLeader/index.ts new file mode 100644 index 0000000..42f0e6e --- /dev/null +++ b/web/vue/src/composables/browser/useTabLeader/index.ts @@ -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>; + /** + * Whether the Web Locks API is supported + */ + isSupported: ComputedRef; + /** + * 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((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, + }; +} diff --git a/web/vue/src/composables/reactivity/broadcastedRef/index.test.ts b/web/vue/src/composables/reactivity/broadcastedRef/index.test.ts new file mode 100644 index 0000000..1aaedaa --- /dev/null +++ b/web/vue/src/composables/reactivity/broadcastedRef/index.test.ts @@ -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 | void) => { + return mount( + defineComponent({ + setup, + template: '
', + }), + ); +}; + +describe(broadcastedRef, () => { + let component: ReturnType; + + 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'); + }); +}); diff --git a/web/vue/src/composables/reactivity/broadcastedRef/index.ts b/web/vue/src/composables/reactivity/broadcastedRef/index.ts index 240fb9c..6102b07 100644 --- a/web/vue/src/composables/reactivity/broadcastedRef/index.ts +++ b/web/vue/src/composables/reactivity/broadcastedRef/index.ts @@ -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 @@ -7,35 +18,51 @@ import { customRef, onScopeDispose } from 'vue'; * * @param {string} key The channel key to use for broadcasting * @param {T} initialValue The initial value of the ref + * @param {BroadcastedRefOptions} [options={}] Options * @returns {Ref} A custom ref that broadcasts value changes across tabs * * @example * const count = broadcastedRef('counter', 0); * - * @since 0.0.1 + * @example + * const state = broadcastedRef('payment-status', { status: 'pending' }); + * + * @since 0.0.13 */ -export function broadcastedRef(key: string, initialValue: T) { +export function broadcastedRef(key: string, initialValue: T, options: BroadcastedRefOptions = {}): Ref { + const { immediate = false } = options; + + if (!defaultWindow || typeof BroadcastChannel === 'undefined') { + return ref(initialValue) as Ref; + } + const channel = new BroadcastChannel(key); + let value = initialValue; - onScopeDispose(channel.close); - - return customRef((track, trigger) => { - channel.onmessage = (event) => { - track(); - return event.data; + const data = customRef((track, trigger) => { + channel.onmessage = (event: MessageEvent) => { + value = event.data; + trigger(); }; - channel.postMessage(initialValue); - return { get() { - return initialValue; + track(); + return value; }, set(newValue: T) { - initialValue = newValue; + value = newValue; channel.postMessage(newValue); trigger(); }, }; }); + + if (immediate) { + channel.postMessage(initialValue); + } + + tryOnScopeDispose(() => channel.close()); + + return data; } \ No newline at end of file