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
15 changes: 15 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useNotifications } from './hooks/useNotifications';
import { useContractHealth } from './hooks/useContractHealth';
import { useAdmin } from './hooks/useAdmin';
import { usePageTitle } from './hooks/usePageTitle';
import { useSessionSync } from './hooks/useSessionSync';
import {
ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_FEED,
ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE,
Expand Down Expand Up @@ -50,6 +51,7 @@ function App() {

usePageTitle();

// Restore initial session on mount
useEffect(() => {
if (userSession.isUserSignedIn()) {
const data = userSession.loadUserData();
Expand All @@ -63,6 +65,19 @@ function App() {
analytics.trackSession();
}, []);

// Synchronize session state across tabs and provider account changes
useSessionSync((sessionState) => {
const { isSignedIn, userData } = sessionState;
if (isSignedIn && isValidUserData(userData)) {
setUserData(userData);
// Reset auth loading state in case this was triggered by async operation
setAuthLoading(false);
} else if (!isSignedIn) {
setUserData(null);
setAuthLoading(false);
}
});

useEffect(() => {
analytics.trackPageView(location.pathname);
}, [location.pathname]);
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/hooks/useSessionSync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* useSessionSync -- Synchronize wallet session state across browser tabs.
*
* Listens for storage events and wallet provider changes to detect
* session mutations in other tabs (logout, account switch, etc).
* Automatically updates session state in the current tab.
*
* @module hooks/useSessionSync
*/
import { useEffect } from 'react';
import { userSession } from '../utils/stacks';

/**
* Hook to synchronize session state across tabs.
*
* Listens for:
* - Storage events (triggered by `localStorage` changes in other tabs)
* - Provider account changes via wallet extension events
*
* When a session change is detected, invokes the provided callback
* so the consuming component can update its local state.
*
* @param {Function} onSessionChange - Callback invoked when session state changes.
* Called with { isSignedIn: boolean, userData: object|null }
* @returns {void}
*/
export function useSessionSync(onSessionChange) {
useEffect(() => {
if (!onSessionChange) return;

/**
* Handle storage events from other tabs.
* Triggered when localStorage changes in another tab.
*/
const handleStorageChange = (event) => {
// Watch for changes to the blockstack-session key
if (event.key === 'blockstack-session') {
const isSignedIn = userSession.isUserSignedIn();
const userData = isSignedIn ? userSession.loadUserData() : null;
onSessionChange({ isSignedIn, userData });
}
};

/**
* Handle wallet provider account changes.
* Some wallet extensions emit events when the user switches accounts.
*/
const handleAccountChange = () => {
const isSignedIn = userSession.isUserSignedIn();
const userData = isSignedIn ? userSession.loadUserData() : null;
onSessionChange({ isSignedIn, userData });
};

/**
* Listen for provider account change events.
* Leather and Xverse use different event names.
*/
const addProviderListeners = () => {
if (window.StacksProvider) {
window.StacksProvider.on?.('accountsChanged', handleAccountChange);
}
if (window.LeatherProvider) {
window.LeatherProvider.on?.('accountsChanged', handleAccountChange);
}
};

/**
* Clean up provider listeners.
*/
const removeProviderListeners = () => {
if (window.StacksProvider) {
window.StacksProvider.removeListener?.('accountsChanged', handleAccountChange);
}
if (window.LeatherProvider) {
window.LeatherProvider.removeListener?.('accountsChanged', handleAccountChange);
}
};

// Register storage event listener
window.addEventListener('storage', handleStorageChange);

// Register provider listeners
addProviderListeners();

// Cleanup
return () => {
window.removeEventListener('storage', handleStorageChange);
removeProviderListeners();
};
}, [onSessionChange]);
}
259 changes: 259 additions & 0 deletions frontend/src/hooks/useSessionSync.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useSessionSync } from './useSessionSync';
import * as stacksUtils from '../utils/stacks';

vi.mock('../utils/stacks', () => ({
userSession: {
isUserSignedIn: vi.fn(),
loadUserData: vi.fn(),
},
}));

describe('useSessionSync', () => {
let mockCallback;
let addEventListenerSpy;
let removeEventListenerSpy;

beforeEach(() => {
mockCallback = vi.fn();
addEventListenerSpy = vi.spyOn(window, 'addEventListener');
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');

vi.clearAllMocks();
stacksUtils.userSession.isUserSignedIn.mockReturnValue(false);
stacksUtils.userSession.loadUserData.mockReturnValue(null);
});

afterEach(() => {
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});

it('registers storage event listener on mount', () => {
renderHook(() => useSessionSync(mockCallback));

expect(addEventListenerSpy).toHaveBeenCalledWith('storage', expect.any(Function));
});

it('unregisters storage event listener on unmount', () => {
const { unmount } = renderHook(() => useSessionSync(mockCallback));

unmount();

expect(removeEventListenerSpy).toHaveBeenCalledWith('storage', expect.any(Function));
});

it('does not call callback when callback is not provided', () => {
renderHook(() => useSessionSync(null));

// Should not crash, and no callback to call
expect(mockCallback).not.toHaveBeenCalled();
});

it('detects session logout from another tab', () => {
const { rerender } = renderHook(() => useSessionSync(mockCallback));

Check failure on line 55 in frontend/src/hooks/useSessionSync.test.js

View workflow job for this annotation

GitHub Actions / Frontend Lint

'rerender' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u

// Simulate other tab logging out
stacksUtils.userSession.isUserSignedIn.mockReturnValue(false);

const storageEvent = new StorageEvent('storage', {
key: 'blockstack-session',
newValue: null,
});
window.dispatchEvent(storageEvent);

expect(mockCallback).toHaveBeenCalledWith({
isSignedIn: false,
userData: null,
});
});

it('detects session login from another tab', () => {
renderHook(() => useSessionSync(mockCallback));

const mockUserData = {
profile: {
stxAddress: { mainnet: 'SP2R...' },
},
};

// Simulate other tab logging in
stacksUtils.userSession.isUserSignedIn.mockReturnValue(true);
stacksUtils.userSession.loadUserData.mockReturnValue(mockUserData);

const storageEvent = new StorageEvent('storage', {
key: 'blockstack-session',
newValue: JSON.stringify({ userData: mockUserData }),
});
window.dispatchEvent(storageEvent);

expect(mockCallback).toHaveBeenCalledWith({
isSignedIn: true,
userData: mockUserData,
});
});

it('ignores storage events for other keys', () => {
renderHook(() => useSessionSync(mockCallback));

const storageEvent = new StorageEvent('storage', {
key: 'some-other-key',
newValue: 'some value',
});
window.dispatchEvent(storageEvent);

expect(mockCallback).not.toHaveBeenCalled();
});

it('ignores storage events where key is null (full clear)', () => {
renderHook(() => useSessionSync(mockCallback));

// Full localStorage clear emits event with key=null
const storageEvent = new StorageEvent('storage', {
key: null,
});
window.dispatchEvent(storageEvent);

expect(mockCallback).not.toHaveBeenCalled();
});

it('handles wallet provider account change events', () => {
const mockProvider = {
on: vi.fn(),
removeListener: vi.fn(),
};
window.StacksProvider = mockProvider;

const mockUserData = {
profile: {
stxAddress: { mainnet: 'SP3XY...' },
},
};

renderHook(() => useSessionSync(mockCallback));

// Get the registered callback
const accountChangeCallback = mockProvider.on.mock.calls[0]?.[1];
expect(accountChangeCallback).toBeDefined();

// Simulate account change event
stacksUtils.userSession.isUserSignedIn.mockReturnValue(true);
stacksUtils.userSession.loadUserData.mockReturnValue(mockUserData);
accountChangeCallback();

expect(mockCallback).toHaveBeenCalledWith({
isSignedIn: true,
userData: mockUserData,
});

// Cleanup
delete window.StacksProvider;
});

it('handles Leather provider account changes', () => {
const mockProvider = {
on: vi.fn(),
removeListener: vi.fn(),
};
window.LeatherProvider = mockProvider;

const mockUserData = {
profile: {
stxAddress: { mainnet: 'SP4AB...' },
},
};

renderHook(() => useSessionSync(mockCallback));

const accountChangeCallback = mockProvider.on.mock.calls[0]?.[1];
expect(accountChangeCallback).toBeDefined();

stacksUtils.userSession.isUserSignedIn.mockReturnValue(true);
stacksUtils.userSession.loadUserData.mockReturnValue(mockUserData);
accountChangeCallback();

expect(mockCallback).toHaveBeenCalledWith({
isSignedIn: true,
userData: mockUserData,
});

// Cleanup
delete window.LeatherProvider;
});

it('removes provider listeners on unmount', () => {
const mockProvider = {
on: vi.fn(),
removeListener: vi.fn(),
};
window.StacksProvider = mockProvider;

const { unmount } = renderHook(() => useSessionSync(mockCallback));

const accountChangeCallback = mockProvider.on.mock.calls[0]?.[1];

unmount();

expect(mockProvider.removeListener).toHaveBeenCalledWith(
'accountsChanged',
accountChangeCallback
);

// Cleanup
delete window.StacksProvider;
});

it('handles rapid successive storage events', () => {
renderHook(() => useSessionSync(mockCallback));

const mockUserData1 = {
profile: { stxAddress: { mainnet: 'SP1...' } },
};
const mockUserData2 = {
profile: { stxAddress: { mainnet: 'SP2...' } },
};

// Simulate rapid account switches
stacksUtils.userSession.isUserSignedIn.mockReturnValue(true);
stacksUtils.userSession.loadUserData.mockReturnValue(mockUserData1);

window.dispatchEvent(
new StorageEvent('storage', { key: 'blockstack-session' })
);

stacksUtils.userSession.loadUserData.mockReturnValue(mockUserData2);
window.dispatchEvent(
new StorageEvent('storage', { key: 'blockstack-session' })
);

expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenNthCalledWith(1, {
isSignedIn: true,
userData: mockUserData1,
});
expect(mockCallback).toHaveBeenNthCalledWith(2, {
isSignedIn: true,
userData: mockUserData2,
});
});

it('works with callback updates', () => {
const { rerender } = renderHook(
({ cb }) => useSessionSync(cb),
{ initialProps: { cb: mockCallback } }
);

const newCallback = vi.fn();
rerender({ cb: newCallback });

stacksUtils.userSession.isUserSignedIn.mockReturnValue(true);
window.dispatchEvent(
new StorageEvent('storage', { key: 'blockstack-session' })
);

// New callback should be called
expect(newCallback).toHaveBeenCalled();
expect(mockCallback).not.toHaveBeenCalled();
});
});
Loading