diff --git a/src/components/common/ConnectWalletButton.tsx b/src/components/common/ConnectWalletButton.tsx index d2ca081..729f721 100644 --- a/src/components/common/ConnectWalletButton.tsx +++ b/src/components/common/ConnectWalletButton.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useAccount, useConnect, useDisconnect } from 'wagmi'; -import { Copy, Check } from 'lucide-react'; +import { Copy, Check, Loader2 } from 'lucide-react'; import { Dialog, DialogClose, @@ -21,6 +21,7 @@ import { WALLET_CONNECTION_AD_BLOCKER_MESSAGE, useWalletConnectionStallDetection, } from '@/hooks/useWalletConnectionStallDetection'; +import { useWalletReconnect } from '@/hooks/useWalletReconnect'; import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement'; import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; import showToast from '@/utils/toast.util'; @@ -43,6 +44,20 @@ function ConnectWalletButton() { hasWalletResponse: isConnected || Boolean(error), }); + const { showWaiting, showFailed, cancelAndReset } = useWalletReconnect({ + isPending, + isConnected, + hasError: Boolean(error), + onRetry: useCallback(() => { + if (primaryConnector) connect({ connector: primaryConnector }); + }, [connect, primaryConnector]), + }); + + const handleConnect = () => { + cancelAndReset(); + if (primaryConnector) connect({ connector: primaryConnector }); + }; + const handleCopyAddress = async () => { if (!address) return; try { @@ -173,19 +188,30 @@ function ConnectWalletButton() { ); } + const connectLabel = showWaiting + ? 'Waiting for wallet…' + : isPending + ? 'Connecting...' + : 'Connect Wallet'; + return (
- {error ? ( + {showFailed ? ( +

+ Could not connect — please try again +

+ ) : error ? (

{error.message}

) : null} {showAdBlockerSuggestion ? ( diff --git a/src/hooks/__tests__/useWalletReconnect.test.ts b/src/hooks/__tests__/useWalletReconnect.test.ts new file mode 100644 index 0000000..8657b59 --- /dev/null +++ b/src/hooks/__tests__/useWalletReconnect.test.ts @@ -0,0 +1,354 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MAX_RETRIES, + RETRY_INTERVAL_MS, + WAITING_THRESHOLD_MS, + useWalletReconnect, +} from '@/hooks/useWalletReconnect'; + +function makeOptions(overrides: Partial[0]> = {}) { + return { + isPending: false, + isConnected: false, + hasError: false, + onRetry: vi.fn(), + ...overrides, + }; +} + +describe('useWalletReconnect', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('showWaiting', () => { + it('is false before the waiting threshold elapses', () => { + const { result } = renderHook(() => + useWalletReconnect(makeOptions({ isPending: true })) + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS - 1); + }); + + expect(result.current.showWaiting).toBe(false); + }); + + it('becomes true exactly at the waiting threshold', () => { + const { result } = renderHook(() => + useWalletReconnect(makeOptions({ isPending: true })) + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS); + }); + + expect(result.current.showWaiting).toBe(true); + }); + + it('uses a custom waitingThresholdMs when provided', () => { + const { result } = renderHook(() => + useWalletReconnect(makeOptions({ isPending: true, waitingThresholdMs: 500 })) + ); + + act(() => { + vi.advanceTimersByTime(499); + }); + expect(result.current.showWaiting).toBe(false); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current.showWaiting).toBe(true); + }); + + it('does not show waiting when not pending', () => { + const { result } = renderHook(() => + useWalletReconnect(makeOptions({ isPending: false })) + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS * 2); + }); + + expect(result.current.showWaiting).toBe(false); + }); + + it('resets showWaiting when the connection succeeds before the threshold', () => { + const { result, rerender } = renderHook( + (props: Parameters[0]) => + useWalletReconnect(props), + { initialProps: makeOptions({ isPending: true }) } + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS / 2); + }); + + rerender(makeOptions({ isPending: false, isConnected: true })); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS); + }); + + expect(result.current.showWaiting).toBe(false); + }); + }); + + describe('retry behaviour', () => { + it('calls onRetry after waitingThreshold + retryInterval', () => { + const onRetry = vi.fn(); + renderHook(() => + useWalletReconnect(makeOptions({ isPending: true, onRetry })) + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS + RETRY_INTERVAL_MS); + }); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('calls onRetry again after each retry interval', () => { + const onRetry = vi.fn(); + renderHook(() => + useWalletReconnect(makeOptions({ isPending: true, onRetry })) + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS + RETRY_INTERVAL_MS * 2); + }); + + expect(onRetry).toHaveBeenCalledTimes(2); + }); + + it('uses a custom retryIntervalMs when provided', () => { + const onRetry = vi.fn(); + renderHook(() => + useWalletReconnect( + makeOptions({ isPending: true, onRetry, waitingThresholdMs: 100, retryIntervalMs: 500 }) + ) + ); + + act(() => { + vi.advanceTimersByTime(599); + }); + expect(onRetry).toHaveBeenCalledTimes(0); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('does not retry when connection succeeds before retry fires', () => { + const onRetry = vi.fn(); + const { rerender } = renderHook( + (props: Parameters[0]) => + useWalletReconnect(props), + { initialProps: makeOptions({ isPending: true, onRetry }) } + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS); + }); + + rerender(makeOptions({ isPending: false, isConnected: true, onRetry })); + + act(() => { + vi.advanceTimersByTime(RETRY_INTERVAL_MS); + }); + + expect(onRetry).not.toHaveBeenCalled(); + }); + }); + + describe('failed state after max retries', () => { + it('shows the failed state after MAX_RETRIES retries', () => { + const onRetry = vi.fn(); + const { result } = renderHook(() => + useWalletReconnect(makeOptions({ isPending: true, onRetry })) + ); + + act(() => { + vi.advanceTimersByTime( + WAITING_THRESHOLD_MS + RETRY_INTERVAL_MS * (MAX_RETRIES + 1) + ); + }); + + expect(result.current.showFailed).toBe(true); + expect(result.current.showWaiting).toBe(false); + }); + + it('calls onRetry exactly MAX_RETRIES times before giving up', () => { + const onRetry = vi.fn(); + renderHook(() => + useWalletReconnect(makeOptions({ isPending: true, onRetry })) + ); + + act(() => { + vi.advanceTimersByTime( + WAITING_THRESHOLD_MS + RETRY_INTERVAL_MS * (MAX_RETRIES + 1) + ); + }); + + expect(onRetry).toHaveBeenCalledTimes(MAX_RETRIES); + }); + + it('does not call onRetry again after the failed state is set', () => { + const onRetry = vi.fn(); + renderHook(() => + useWalletReconnect(makeOptions({ isPending: true, onRetry })) + ); + + act(() => { + vi.advanceTimersByTime( + WAITING_THRESHOLD_MS + RETRY_INTERVAL_MS * (MAX_RETRIES + 5) + ); + }); + + expect(onRetry).toHaveBeenCalledTimes(MAX_RETRIES); + }); + + it('uses a custom maxRetries when provided', () => { + const onRetry = vi.fn(); + const { result } = renderHook(() => + useWalletReconnect( + makeOptions({ isPending: true, onRetry, waitingThresholdMs: 100, retryIntervalMs: 200, maxRetries: 1 }) + ) + ); + + act(() => { + vi.advanceTimersByTime(100 + 200 * 2); + }); + + expect(onRetry).toHaveBeenCalledTimes(1); + expect(result.current.showFailed).toBe(true); + }); + }); + + describe('cancelAndReset', () => { + it('clears showWaiting immediately', () => { + const { result } = renderHook(() => + useWalletReconnect(makeOptions({ isPending: true })) + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS); + }); + expect(result.current.showWaiting).toBe(true); + + act(() => { + result.current.cancelAndReset(); + }); + + expect(result.current.showWaiting).toBe(false); + }); + + it('stops the retry timer so onRetry is never called after cancel', () => { + const onRetry = vi.fn(); + const { result } = renderHook(() => + useWalletReconnect(makeOptions({ isPending: true, onRetry })) + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS); + }); + + act(() => { + result.current.cancelAndReset(); + }); + + act(() => { + vi.advanceTimersByTime(RETRY_INTERVAL_MS * 10); + }); + + expect(onRetry).not.toHaveBeenCalled(); + }); + + it('clears showFailed immediately', () => { + const onRetry = vi.fn(); + const { result } = renderHook(() => + useWalletReconnect(makeOptions({ isPending: true, onRetry })) + ); + + act(() => { + vi.advanceTimersByTime( + WAITING_THRESHOLD_MS + RETRY_INTERVAL_MS * (MAX_RETRIES + 1) + ); + }); + expect(result.current.showFailed).toBe(true); + + act(() => { + result.current.cancelAndReset(); + }); + + expect(result.current.showFailed).toBe(false); + }); + + it('stops the waiting timer so showWaiting never fires after cancel', () => { + const { result } = renderHook(() => + useWalletReconnect(makeOptions({ isPending: true })) + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS / 2); + }); + + act(() => { + result.current.cancelAndReset(); + }); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS); + }); + + expect(result.current.showWaiting).toBe(false); + }); + }); + + describe('error handling', () => { + it('clears showWaiting when an error occurs', () => { + const { result, rerender } = renderHook( + (props: Parameters[0]) => + useWalletReconnect(props), + { initialProps: makeOptions({ isPending: true }) } + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS); + }); + expect(result.current.showWaiting).toBe(true); + + rerender(makeOptions({ isPending: false, hasError: true })); + + expect(result.current.showWaiting).toBe(false); + }); + + it('stops the retry timer on error so onRetry is never called', () => { + const onRetry = vi.fn(); + const { rerender } = renderHook( + (props: Parameters[0]) => + useWalletReconnect(props), + { initialProps: makeOptions({ isPending: true, onRetry }) } + ); + + act(() => { + vi.advanceTimersByTime(WAITING_THRESHOLD_MS); + }); + + rerender(makeOptions({ isPending: false, hasError: true, onRetry })); + + act(() => { + vi.advanceTimersByTime(RETRY_INTERVAL_MS * 5); + }); + + expect(onRetry).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/hooks/useWalletReconnect.ts b/src/hooks/useWalletReconnect.ts new file mode 100644 index 0000000..5a40143 --- /dev/null +++ b/src/hooks/useWalletReconnect.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export const WAITING_THRESHOLD_MS = 3_000; +export const RETRY_INTERVAL_MS = 15_000; +export const MAX_RETRIES = 3; + +interface UseWalletReconnectOptions { + isPending: boolean; + isConnected: boolean; + hasError: boolean; + onRetry: () => void; + waitingThresholdMs?: number; + retryIntervalMs?: number; + maxRetries?: number; +} + +export interface UseWalletReconnectResult { + showWaiting: boolean; + showFailed: boolean; + cancelAndReset: () => void; +} + +export function useWalletReconnect({ + isPending, + isConnected, + hasError, + onRetry, + waitingThresholdMs = WAITING_THRESHOLD_MS, + retryIntervalMs = RETRY_INTERVAL_MS, + maxRetries = MAX_RETRIES, +}: UseWalletReconnectOptions): UseWalletReconnectResult { + const [showWaiting, setShowWaiting] = useState(false); + const [showFailed, setShowFailed] = useState(false); + + const inCycleRef = useRef(false); + const retryCountRef = useRef(0); + const waitTimerRef = useRef | null>(null); + const retryTimerRef = useRef | null>(null); + const onRetryRef = useRef(onRetry); + onRetryRef.current = onRetry; + + const clearTimers = useCallback(() => { + if (waitTimerRef.current != null) { + clearTimeout(waitTimerRef.current); + waitTimerRef.current = null; + } + if (retryTimerRef.current != null) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + }, []); + + const cancelAndReset = useCallback(() => { + clearTimers(); + inCycleRef.current = false; + retryCountRef.current = 0; + setShowWaiting(false); + setShowFailed(false); + }, [clearTimers]); + + useEffect(() => { + if (isConnected || hasError) { + cancelAndReset(); + return; + } + + // Already managing the retry cycle; don't restart timers on isPending flicker + if (inCycleRef.current) { + return; + } + + if (!isPending) { + return; + } + + inCycleRef.current = true; + + waitTimerRef.current = setTimeout(() => { + setShowWaiting(true); + + const scheduleRetry = () => { + retryTimerRef.current = setTimeout(() => { + retryCountRef.current += 1; + + if (retryCountRef.current > maxRetries) { + inCycleRef.current = false; + retryCountRef.current = 0; + setShowWaiting(false); + setShowFailed(true); + return; + } + + onRetryRef.current(); + scheduleRetry(); + }, retryIntervalMs); + }; + + scheduleRetry(); + }, waitingThresholdMs); + + return () => { + // Only clear timers if we are not inside an active retry cycle so + // that effect re-runs triggered by isPending flickering during a + // retry do not interrupt the self-managed timer chain. + if (!inCycleRef.current) { + clearTimers(); + } + }; + }, [ + isPending, + isConnected, + hasError, + waitingThresholdMs, + retryIntervalMs, + maxRetries, + cancelAndReset, + clearTimers, + ]); + + return { showWaiting, showFailed, cancelAndReset }; +}