diff --git a/src/App.tsx b/src/App.tsx index 6b6e7c2..20fc307 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { } from '@/lib/idleLock'; import { parseStellarQrPayload } from '@/utils/qr'; import { useOfflineQueueStore } from '@/stores/offlineQueueStore'; +import { initNameWatchlistSync, cleanupNameWatchlistSync } from '@/store/nameWatchlistStore'; function SessionLock({ onUnlock }: { onUnlock: () => void }) { const [passphrase, setPassphrase] = useState(''); @@ -157,6 +158,11 @@ export function App() { return () => idleLock.stop(); }, [relockSession, sessionLocked]); + useEffect(() => { + initNameWatchlistSync(); + return () => cleanupNameWatchlistSync(); + }, []); + useEffect(() => { if (location.pathname !== '/send') return; const sharedText = new URLSearchParams(location.search).get('text'); diff --git a/src/store/multiTabSync.test.tsx b/src/store/multiTabSync.test.tsx index d4d3899..b51b9e4 100644 --- a/src/store/multiTabSync.test.tsx +++ b/src/store/multiTabSync.test.tsx @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { describe, expect, it, beforeEach } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { ContactsProvider, useContacts } from './contactsStore'; import { @@ -8,12 +8,21 @@ import { DEFAULT_SCAN_STRATEGY, } from '../context/ScanStrategyContext'; import { SplitTemplatesProvider, useSplitTemplates } from './splitTemplatesStore'; -import { useNameWatchlistStore } from './nameWatchlistStore'; +import { + useNameWatchlistStore, + initNameWatchlistSync, + cleanupNameWatchlistSync, +} from './nameWatchlistStore'; import { useStealthLabels } from '../hooks/useStealthLabels'; describe('Multi-tab synchronization', () => { beforeEach(() => { localStorage.clear(); + initNameWatchlistSync(); + }); + + afterEach(() => { + cleanupNameWatchlistSync(); }); it('synchronizes contacts between tabs', () => { diff --git a/src/store/nameWatchlistStore.test.ts b/src/store/nameWatchlistStore.test.ts index 6795b8a..c0d09d3 100644 --- a/src/store/nameWatchlistStore.test.ts +++ b/src/store/nameWatchlistStore.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; class MemoryStorage implements Storage { private values = new Map(); @@ -28,16 +28,56 @@ class MemoryStorage implements Storage { } } +class MockWindow { + localStorage: Storage; + private listeners: Map void)[]> = new Map(); + + constructor(storage: Storage) { + this.localStorage = storage; + } + + addEventListener(event: string, listener: (e: StorageEvent) => void) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)!.push(listener); + } + + removeEventListener(event: string, listener: (e: StorageEvent) => void) { + const eventListeners = this.listeners.get(event); + if (eventListeners) { + const index = eventListeners.indexOf(listener); + if (index > -1) { + eventListeners.splice(index, 1); + } + } + } + + dispatchEvent(event: StorageEvent) { + const eventListeners = this.listeners.get(event.type); + if (eventListeners) { + eventListeners.forEach((listener) => listener(event)); + } + } +} + describe('name auction watchlist', () => { beforeEach(() => { vi.resetModules(); const storage = new MemoryStorage(); + const mockWindow = new MockWindow(storage); vi.stubGlobal('localStorage', storage); - vi.stubGlobal('window', { localStorage: storage }); + vi.stubGlobal('window', mockWindow); + }); + + afterEach(async () => { + const { cleanupNameWatchlistSync } = await import('./nameWatchlistStore'); + cleanupNameWatchlistSync(); }); it('restores watched auctions and sealed bid recovery data', async () => { - const { useNameWatchlistStore } = await import('./nameWatchlistStore'); + const { useNameWatchlistStore, initNameWatchlistSync } = await import('./nameWatchlistStore'); + initNameWatchlistSync(); const store = useNameWatchlistStore.getState(); store.watchAuction({ name: 'NOVA', endsAt: 1_800_000_000 }); store.saveBid({ @@ -66,7 +106,8 @@ describe('name auction watchlist', () => { it('recovers from corrupt and unknown-version persisted data', async () => { localStorage.setItem('wraith-name-auction-watchlist', '{not-json'); - const { useNameWatchlistStore } = await import('./nameWatchlistStore'); + const { useNameWatchlistStore, initNameWatchlistSync } = await import('./nameWatchlistStore'); + initNameWatchlistSync(); await useNameWatchlistStore.persist.rehydrate(); expect(useNameWatchlistStore.getState().watchedAuctions).toEqual([]); expect(useNameWatchlistStore.getState().bids).toEqual({}); diff --git a/src/store/nameWatchlistStore.tsx b/src/store/nameWatchlistStore.tsx index ffd70f7..a450779 100644 --- a/src/store/nameWatchlistStore.tsx +++ b/src/store/nameWatchlistStore.tsx @@ -138,8 +138,13 @@ export const useNameWatchlistStore = create()( ), ); -if (typeof window !== 'undefined') { - window.addEventListener('storage', (e) => { +let storageListener: ((e: StorageEvent) => void) | null = null; + +export function initNameWatchlistSync() { + if (typeof window === 'undefined' || storageListener !== null) return; + if (typeof window.addEventListener !== 'function') return; + + storageListener = (e: StorageEvent) => { if (e.key === 'wraith-name-auction-watchlist') { if (e.newValue) { useNameWatchlistStore.persist.rehydrate(); @@ -149,5 +154,17 @@ if (typeof window !== 'undefined') { } else if (e.key === null) { useNameWatchlistStore.setState({ watchedAuctions: [], bids: {} }); } - }); + }; + + window.addEventListener('storage', storageListener); +} + +export function cleanupNameWatchlistSync() { + if (storageListener === null) return; + if (typeof window.removeEventListener !== 'function') { + storageListener = null; + return; + } + window.removeEventListener('storage', storageListener); + storageListener = null; }