Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions dashboard/src/__tests__/wallet-integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
11 changes: 11 additions & 0 deletions dashboard/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;

Expand Down
29 changes: 29 additions & 0 deletions dashboard/src/hooks/useWalletAccountSync.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
24 changes: 22 additions & 2 deletions dashboard/src/pages/EventExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]
Expand Down
Loading