From b859a056c743e33b1286789950d21bde9890caa3 Mon Sep 17 00:00:00 2001 From: biokes Date: Tue, 25 Aug 2026 02:06:23 +0100 Subject: [PATCH 01/11] feat: cross-tab wallet session sync & auto-reconnect - Add types/wallet-sync.ts with WalletSyncState, WalletSyncMessage, WalletSyncMessageType enum, and config/listener type definitions - Add utils/walletStorage.ts: type-safe localStorage read/write/clear with quota-exceeded handling, version counter, staleness check, and conflict resolution helper (isStateNewer) - Add utils/walletSyncManager.ts: WalletSyncManager class wrapping BroadcastChannel with localStorage+storage-event fallback, debounced broadcasts, immediate disconnect broadcast, unique tab ID generation, and singleton helpers (getWalletSyncManager / destroyWalletSyncManager) - Add hooks/useWalletSync.ts: React hook over WalletSyncManager that manages subscription lifecycle, exposes broadcastState, broadcastDisconnect, loadPersistedState, and getTabId - Refactor hooks/useWallet.ts: declare sync before useWalletSync to fix hoisting bug; route broadcastState through a stable ref to break the circular dependency; add mount-time persisted-state restore; broadcast on account/network/allowed changes; broadcast disconnect immediately - Export useWalletSync from hooks/index.ts - Update WalletButton: add syncedAcrossTabs pulse-ring badge, Synced label in dropdown, previousAddress account-switch notice banner, and onDismissSwitchNotice callback - Update ConnectButton: add isConnecting and disabled props; show 'Connecting in another tab...' when another tab is mid-connection - Update Navbar: track address changes with useRef to detect cross-tab account switches and pass syncedAcrossTabs + previousAddress to WalletButton; forward isBusy to ConnectButton as isConnecting - Update WalletData: forward isBusy to ConnectButton as isConnecting - Add utils/walletStorage.test.ts: tests for all storage helpers - Add utils/walletSyncManager.test.ts: tests for broadcast, debounce, disconnect, listener cleanup, singleton, and localStorage fallback - Add hooks/useWalletSync.test.ts: tests for subscription lifecycle, callbacks, broadcastState/Disconnect, loadPersistedState, cleanup - Extend hooks/useWallet.test.ts: mock useWalletSync and add 6 cross-tab integration tests (persist restore, newer/stale state, disconnect propagation, disconnect broadcast, account-change broadcast) - Update README.md architecture notes with cross-tab sync documentation --- README.md | 28 ++ components/atoms/connect-button/index.tsx | 45 ++- components/atoms/wallet-button/index.tsx | 121 ++++++- components/molecules/wallet-data/index.tsx | 10 +- components/organisms/navbar/index.tsx | 38 ++- hooks/index.ts | 1 + hooks/useWallet.test.ts | 172 ++++++++++ hooks/useWallet.ts | 147 +++++++- hooks/useWalletSync.test.ts | 304 +++++++++++++++++ hooks/useWalletSync.ts | 184 ++++++++++ types/wallet-sync.ts | 77 +++++ utils/walletStorage.test.ts | 205 +++++++++++ utils/walletStorage.ts | 181 ++++++++++ utils/walletSyncManager.test.ts | 356 ++++++++++++++++++++ utils/walletSyncManager.ts | 374 +++++++++++++++++++++ 15 files changed, 2196 insertions(+), 47 deletions(-) create mode 100644 hooks/useWalletSync.test.ts create mode 100644 hooks/useWalletSync.ts create mode 100644 types/wallet-sync.ts create mode 100644 utils/walletStorage.test.ts create mode 100644 utils/walletStorage.ts create mode 100644 utils/walletSyncManager.test.ts create mode 100644 utils/walletSyncManager.ts diff --git a/README.md b/README.md index 5685d6b..5faa041 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,34 @@ Full page sections wired to on-chain data. - The profile page fetches reputation, bio, and past gigs from `GET /api/profile`, which proxies to `PROFILE_API_BASE_URL` when configured and falls back to typed mock data for local/dev environments. - No new runtime dependencies were added for this feature. +### Cross-Tab Wallet Session Sync & Auto-Reconnect + +Wallet state (account, network, allowed status) is synchronized across all open browser tabs without requiring user interaction. + +**How it works:** + +1. When `useWallet` detects a state change during its 2-second polling cycle (account switch, network change), it broadcasts the new state to all other tabs via `BroadcastChannel`. +2. Each tab receiving the message compares the incoming version number against its own. Higher version wins; ties are broken by timestamp. Stale or equal state is silently discarded. +3. When a tab receives a newer state, it applies it immediately and fires a silent Freighter validation call to confirm the account is still accessible. +4. Disconnect is always broadcast immediately (not debounced) so all tabs clear their state at once. +5. On mount, each tab reads the last persisted state from `localStorage` and restores it before the first poll completes, eliminating the blank-wallet flash on page load. + +**Key files:** + +| File | Role | +|------|------| +| `types/wallet-sync.ts` | Shared TypeScript interfaces (`WalletSyncState`, `WalletSyncMessage`, etc.) | +| `utils/walletStorage.ts` | localStorage read/write/clear with quota-exceeded handling and version counter | +| `utils/walletSyncManager.ts` | `WalletSyncManager` class — owns the `BroadcastChannel`, debounce logic, listener registry, and localStorage fallback | +| `hooks/useWalletSync.ts` | React wrapper around `WalletSyncManager`; manages subscription lifecycle | +| `hooks/useWallet.ts` | Integrates sync into the existing polling hook via a stable `broadcastStateRef` | + +**Fallback strategy:** + +`BroadcastChannel` is used where available (all modern browsers). In environments where it is unavailable or throws, the manager falls back to `localStorage` + `storage` events, which fire across tabs when a key changes. + +**No new runtime dependencies.** Everything uses native Web APIs (`BroadcastChannel`, `localStorage`, `crypto.randomUUID`). + ### Contract Bindings Codegen TrustFlow uses a codegen pipeline to generate fully typed TypeScript client bindings from Soroban contract specs. This ensures all frontend contract calls are compile-time checked and stay in sync with the contract interface. diff --git a/components/atoms/connect-button/index.tsx b/components/atoms/connect-button/index.tsx index 6453618..d3acff2 100644 --- a/components/atoms/connect-button/index.tsx +++ b/components/atoms/connect-button/index.tsx @@ -7,20 +7,45 @@ export interface ConnectButtonProps { isHigher?: boolean /** Called after a successful setAllowed + wallet connection is detected */ onConnect?: () => void + /** + * When true, renders the button in a disabled loading state without + * starting the Freighter flow. Use this when another tab is already + * in the middle of a connection attempt (pass `isBusy` from useWallet). + */ + isConnecting?: boolean + /** + * Disables the button entirely. Takes precedence over isConnecting. + * Useful when the parent knows a connection already exists but the + * component tree hasn't unmounted yet. + */ + disabled?: boolean } /** * Renders a "Connect Wallet" button that triggers the Freighter permission flow. * * - Shows a loading spinner while the connection is in progress + * - Accepts `isConnecting` to reflect a connection attempt started by another + * tab, preventing duplicate Freighter permission popups * - Displays inline error text if the connection fails * - Fires `onConnect` so parents can refresh state after a successful connect */ -export function ConnectButton({ label, isHigher, onConnect }: ConnectButtonProps) { +export function ConnectButton({ + label, + isHigher, + onConnect, + isConnecting = false, + disabled = false, +}: ConnectButtonProps) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + // Either this instance is loading, or a cross-tab connection is in progress + const isAnyLoading = loading || isConnecting + const isDisabled = disabled || isAnyLoading + async function handleClick() { + if (isDisabled) return setLoading(true) setError(null) try { @@ -35,19 +60,27 @@ export function ConnectButton({ label, isHigher, onConnect }: ConnectButtonProps } } + // Label shown inside the button + const buttonLabel = loading + ? 'Connecting…' + : isConnecting + ? 'Connecting in another tab…' + : label + return (
{error && (

diff --git a/components/atoms/wallet-button/index.tsx b/components/atoms/wallet-button/index.tsx index e7515ae..55b3cc0 100644 --- a/components/atoms/wallet-button/index.tsx +++ b/components/atoms/wallet-button/index.tsx @@ -11,13 +11,27 @@ interface WalletButtonProps { switchAccountLabel?: string /** Label for the disconnect menu item */ disconnectLabel?: string + /** + * When true, renders a small sync badge on the connection indicator + * to signal that this account's state is shared with other tabs. + */ + syncedAcrossTabs?: boolean + /** + * When set, displays a brief account-switch notice inside the dropdown. + * Should be a short address string; the parent clears it after showing. + */ + previousAddress?: string | null + /** Callback so the parent can clear the previousAddress notice */ + onDismissSwitchNotice?: () => void } /** * Shows the connected wallet address and a dropdown with: * - Current network indicator + * - Optional account-switch notice when active account changed in another tab + * - Optional sync badge when state is shared across browser tabs * - Switch account (re-opens Freighter permission popup) - * - Disconnect (clears local connection state) + * - Disconnect (clears local connection state and broadcasts to other tabs) * * Clicking outside closes the dropdown. */ @@ -27,27 +41,41 @@ export function WalletButton({ onDisconnect, switchAccountLabel = 'Switch account', disconnectLabel = 'Disconnect', + syncedAcrossTabs = false, + previousAddress = null, + onDismissSwitchNotice, }: WalletButtonProps) { const [open, setOpen] = useState(false) const ref = useRef(null) + // Auto-open the dropdown briefly when an account switch notice arrives + // so the user sees the change without having to click. + useEffect(() => { + if (previousAddress) { + setOpen(true) + } + }, [previousAddress]) + useEffect(() => { function handleClickOutside(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false) + // Dismiss the switch notice when the user clicks away + if (previousAddress) { + onDismissSwitchNotice?.() + } } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) + }, [previousAddress, onDismissSwitchNotice]) const displayName = `${address.slice(0, 4)}...${address.slice(-4)}` - - // Derive a short, human-readable network label const networkLabel = deriveNetworkLabel(network) function handleSwap() { setOpen(false) + onDismissSwitchNotice?.() // Re-invoking setAllowed opens the Freighter permission popup so the user // can approve a different profile without disconnecting first. void setAllowed() @@ -55,24 +83,39 @@ export function WalletButton({ function handleDisconnect() { setOpen(false) + onDismissSwitchNotice?.() onDisconnect() } + function handleToggle() { + setOpen((v) => !v) + // Dismiss notice when user manually opens/closes the dropdown + if (open && previousAddress) { + onDismissSwitchNotice?.() + } + } + return (