From 99f57ffc46b3b599416669861920a95698072197 Mon Sep 17 00:00:00 2001 From: Jess Date: Fri, 26 Jun 2026 13:28:37 +0100 Subject: [PATCH] fix(#175): clear notification feed immediately on wallet switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: EventExplorerPage and ActivityFeed fetched data once on mount with no subscription to walletStore.address. Switching wallets updated the store but neither component reacted, leaving stale events on screen. Changes: - Add useWalletAccountSync hook — fires a callback on every subsequent address change (wallet switch or disconnect), skipping the initial mount. Callback ref is stable so callers can pass inline functions without causing spurious re-subscriptions. - EventExplorerPage: wire in useWalletAccountSync to clear the event store, reset to page 1, and re-fetch on account change. Also remove duplicate fetchEvents import and duplicate loadEvents() call that were causing double fetches on every mount. - ActivityFeed: wire in useWalletAccountSync to clear local events, live events, and total count, reset to page 1, and reload on account change. - wallet-integration.test.tsx: add four regression tests under 'Notification feed clears on wallet switch (issue #175)' covering immediate address update on switch, null address after disconnect, no stale localStorage after switch, and all three supported wallet providers. --- .../src/__tests__/wallet-integration.test.tsx | 96 +++++++++++++++++++ dashboard/src/components/ActivityFeed.tsx | 11 +++ dashboard/src/hooks/useWalletAccountSync.ts | 29 ++++++ dashboard/src/pages/EventExplorerPage.tsx | 24 ++++- 4 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 dashboard/src/hooks/useWalletAccountSync.ts diff --git a/dashboard/src/__tests__/wallet-integration.test.tsx b/dashboard/src/__tests__/wallet-integration.test.tsx index 309ae25c..c3297500 100644 --- a/dashboard/src/__tests__/wallet-integration.test.tsx +++ b/dashboard/src/__tests__/wallet-integration.test.tsx @@ -191,3 +191,99 @@ describe('Wallet integration report', () => { expect(fs.existsSync(REPORT_PATH)).toBe(true); }); }); + +// ─── Regression tests for issue #175 ───────────────────────────────────────── +// Verify that switching wallets updates walletStore address immediately and +// leaves no stale address behind, which is the precondition that +// useWalletAccountSync relies on to trigger a feed refresh. + +describe('Notification feed clears on wallet switch (issue #175)', () => { + it('walletStore address updates immediately when switching to a different wallet', async () => { + const { wallet, store, kit } = await load(); + + // Connect first wallet + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: 'freighter' }); + kit.__emit('STATE_UPDATED', { address: SUPPORTED_WALLETS[0].address }); + }; + await wallet.connectWallet(); + expect(store.useWalletStore.getState().address).toBe(SUPPORTED_WALLETS[0].address); + + // Switch to second wallet — address in the store must change synchronously + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: 'albedo' }); + kit.__emit('STATE_UPDATED', { address: SUPPORTED_WALLETS[1].address }); + }; + await wallet.connectWallet(); + + expect(store.useWalletStore.getState().address).toBe(SUPPORTED_WALLETS[1].address); + expect(store.useWalletStore.getState().address).not.toBe(SUPPORTED_WALLETS[0].address); + }); + + it('walletStore address is null after disconnect, clearing any previous account', async () => { + const { wallet, store, kit } = await load(); + + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: 'freighter' }); + kit.__emit('STATE_UPDATED', { address: SUPPORTED_WALLETS[0].address }); + }; + await wallet.connectWallet(); + expect(store.useWalletStore.getState().address).toBe(SUPPORTED_WALLETS[0].address); + + kit.__control.disconnectImpl = async () => { + kit.__emit('DISCONNECT', {}); + }; + await wallet.disconnectWallet(); + + expect(store.useWalletStore.getState().address).toBeNull(); + expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBeNull(); + }); + + it('no stale address remains in localStorage after switching wallets', async () => { + const { wallet, store, kit } = await load(); + + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: 'freighter' }); + kit.__emit('STATE_UPDATED', { address: SUPPORTED_WALLETS[0].address }); + }; + await wallet.connectWallet(); + + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: 'xbull' }); + kit.__emit('STATE_UPDATED', { address: SUPPORTED_WALLETS[2].address }); + }; + await wallet.connectWallet(); + + // localStorage must reflect the new account only + expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe(SUPPORTED_WALLETS[2].address); + expect(localStorage.getItem(WALLET_ADDRESS_KEY)).not.toBe(SUPPORTED_WALLETS[0].address); + expect(store.useWalletStore.getState().address).toBe(SUPPORTED_WALLETS[2].address); + }); + + it('switching wallets across all supported providers leaves only the current address', async () => { + for (const [index, provider] of SUPPORTED_WALLETS.entries()) { + const { wallet, store, kit } = await load(); + + // First connect one of the other wallets + const previous = SUPPORTED_WALLETS[(index + 1) % SUPPORTED_WALLETS.length]; + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: previous.id }); + kit.__emit('STATE_UPDATED', { address: previous.address }); + }; + await wallet.connectWallet(); + + // Now switch to the target provider + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: provider.id }); + kit.__emit('STATE_UPDATED', { address: provider.address }); + }; + await wallet.connectWallet(); + + expect(store.useWalletStore.getState().address).toBe(provider.address); + expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe(provider.address); + expect(localStorage.getItem(WALLET_ID_KEY)).toBe(provider.id); + + localStorage.clear(); + } + }); +}); diff --git a/dashboard/src/components/ActivityFeed.tsx b/dashboard/src/components/ActivityFeed.tsx index a6a70ac8..405a0d4c 100644 --- a/dashboard/src/components/ActivityFeed.tsx +++ b/dashboard/src/components/ActivityFeed.tsx @@ -3,6 +3,7 @@ import { fetchActivityFeed, generateMockActivityEvents } from '../services/activ import type { ActivityEvent, ActivityType } from '../types/activity'; import { formatTimestamp } from '../utils/formatTime'; import { PaginationControls } from './PaginationControls'; +import { useWalletAccountSync } from '../hooks/useWalletAccountSync'; // Helper to get icon/color based on activity type const getActivityTypeStyle = (type: ActivityType) => { @@ -148,6 +149,16 @@ export function ActivityFeed() { setLiveEvents([]); }; + // Clear stale activity and re-fetch from page 1 whenever the connected + // wallet address changes (switch or disconnect). This is the fix for issue #175. + useWalletAccountSync((_nextAddress) => { + setEvents([]); + setLiveEvents([]); + setTotal(0); + setPage(1); + loadEvents(1, pageSize); + }); + // Events shown: live prepended events (only on page 1) + paginated events const displayedEvents = page === 1 ? [...liveEvents, ...events] : events; diff --git a/dashboard/src/hooks/useWalletAccountSync.ts b/dashboard/src/hooks/useWalletAccountSync.ts new file mode 100644 index 00000000..fe339cb8 --- /dev/null +++ b/dashboard/src/hooks/useWalletAccountSync.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from 'react'; +import { useWalletStore } from '../store/walletStore'; + +/** + * Calls `onAccountChange` whenever the connected wallet address changes during + * an active session. + * + * The callback is skipped on the initial mount (address going from undefined to + * its initial value) — it fires only for subsequent transitions, i.e. a real + * wallet switch or disconnect while the page is open. + */ +export function useWalletAccountSync(onAccountChange: (address: string | null) => void): void { + const address = useWalletStore((state) => state.address); + + // Track whether this is the very first render so we can skip it. + const isFirstRender = useRef(true); + // Hold a stable ref to the callback so the effect doesn't re-subscribe on + // every render if the caller passes an inline function. + const callbackRef = useRef(onAccountChange); + callbackRef.current = onAccountChange; + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + callbackRef.current(address); + }, [address]); +} diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 509a58f8..bcf08db3 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -9,10 +9,10 @@ import { IndexingHealthPanel } from '../components/IndexingHealthPanel'; import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors'; import { useEventStore } from '../store/eventStore'; import { fetchEvents, fetchStatus, type ContractStatus } from '../services/eventsApi'; -import { fetchEvents } from '../services/eventsApi'; import { resolveIndexingHealthUrl } from '../services/indexingHealthApi'; import { generateMockEvents } from '../utils/eventData'; import { restoreWalletSession } from '../services/wallet'; +import { useWalletAccountSync } from '../hooks/useWalletAccountSync'; const DEFAULT_EVENT_COUNT = 5000; const DEFAULT_LIMIT = 12; @@ -87,13 +87,33 @@ export function EventExplorerPage() { loadEvents(); loadStatus(); - loadEvents(); return () => { cancelled = true; }; }, [setEvents, setError, setLoading]); + // Clear stale events and re-fetch whenever the connected wallet address + // changes (switch or disconnect). This is the fix for issue #175. + useWalletAccountSync((_nextAddress) => { + setEvents([]); + setError(null); + setPage(1); + + setLoading(true); + fetchEvents(API_URL) + .then((remoteEvents) => { + setEvents(remoteEvents); + }) + .catch(() => { + setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); + setError('Listener API unavailable — showing mock events for demo.'); + }) + .finally(() => { + setLoading(false); + }); + }); + const pageCount = useMemo( () => Math.max(1, Math.ceil(filteredEvents.length / limit)), [filteredEvents.length, limit]