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 };
+}