From 5ba97bb9413b6530e99bdc023d311027a144c940 Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Tue, 4 Aug 2026 18:13:11 +0400 Subject: [PATCH 1/5] test: add walletconnect disconnect-after-refresh regression --- .../src/hooks/useDisconnectAll.test.tsx | 184 ++++++++++++++++++ .../rainbowkit/src/hooks/useDisconnectAll.ts | 17 ++ 2 files changed, 201 insertions(+) create mode 100644 packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx create mode 100644 packages/rainbowkit/src/hooks/useDisconnectAll.ts diff --git a/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx b/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx new file mode 100644 index 0000000000..28652f2cee --- /dev/null +++ b/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx @@ -0,0 +1,184 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { Address } from 'viem'; +import { http } from 'viem'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createConfig, + useAccount, + useConnections, + useDisconnect, + WagmiProvider, +} from 'wagmi'; +import { mainnet } from 'wagmi/chains'; +import { connectorsForWallets } from '../wallets/connectorsForWallets'; +import { + metaMaskWallet, + rainbowWallet, + walletConnectWallet, +} from '../wallets/walletConnectors'; +import { useDisconnectAll } from './useDisconnectAll'; + +const exampleProjectId = '21fef48091f12692cad574a6f7753643'; +const account = '0x1111111111111111111111111111111111111111' as Address; + +function createWalletConnectConfig() { + return createConfig({ + chains: [mainnet], + connectors: connectorsForWallets( + [ + { + groupName: 'Popular', + wallets: [rainbowWallet, metaMaskWallet, walletConnectWallet], + }, + ], + { + projectId: exampleProjectId, + appName: 'rainbowkit.com', + appUrl: 'https://rainbowkit.com', + }, + ), + transports: { + [mainnet.id]: http(), + }, + ssr: true, + }); +} + +function seedRestoredWalletConnectConnections( + config: ReturnType, +) { + const walletConnectConnectors = config.connectors.filter( + (connector) => connector.id === 'walletConnect', + ); + + expect(walletConnectConnectors.length).toBeGreaterThan(1); + + const [currentConnector] = walletConnectConnectors; + if (!currentConnector) { + throw new Error('Expected at least one WalletConnect connector'); + } + + for (const connector of walletConnectConnectors) { + connector.disconnect = vi.fn().mockResolvedValue(undefined); + } + + const connections = new Map( + walletConnectConnectors.map((connector) => [ + connector.uid, + { + accounts: [account] as const, + chainId: mainnet.id, + connector, + }, + ]), + ); + + config.setState({ + chainId: mainnet.id, + connections, + current: currentConnector.uid, + status: 'connected', + }); + + return walletConnectConnectors.length; +} + +function DisconnectHarness({ mode }: { mode: 'current' | 'all' }) { + const { status, isConnected } = useAccount(); + const connections = useConnections(); + const { disconnect } = useDisconnect(); + const disconnectAll = useDisconnectAll(); + + return ( +
+
{status}
+
{String(isConnected)}
+
{connections.length}
+ +
+ ); +} + +describe('useDisconnectAll', () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('shows why a single wagmi disconnect leaves restored walletconnect sessions connected', async () => { + const config = createWalletConnectConfig(); + const restoredCount = seedRestoredWalletConnectConnections(config); + + render( + + + + + , + ); + + expect(screen.getByTestId('connection-count').textContent).toBe( + String(restoredCount), + ); + expect(screen.getByTestId('connected').textContent).toBe('true'); + + fireEvent.click(screen.getByTestId('disconnect')); + + await waitFor(() => { + expect(screen.getByTestId('connection-count').textContent).toBe( + String(restoredCount - 1), + ); + }); + + // This is the #2401 failure mode: one Disconnect click is not enough. + expect(screen.getByTestId('connected').textContent).toBe('true'); + expect(screen.getByTestId('status').textContent).toBe('connected'); + }); + + it('clears every restored walletconnect connection in one click', async () => { + const config = createWalletConnectConfig(); + const restoredCount = seedRestoredWalletConnectConnections(config); + + render( + + + + + , + ); + + expect(screen.getByTestId('connection-count').textContent).toBe( + String(restoredCount), + ); + + fireEvent.click(screen.getByTestId('disconnect')); + + await waitFor(() => { + expect(screen.getByTestId('connection-count').textContent).toBe('0'); + expect(screen.getByTestId('connected').textContent).toBe('false'); + expect(screen.getByTestId('status').textContent).toBe('disconnected'); + }); + }); +}); diff --git a/packages/rainbowkit/src/hooks/useDisconnectAll.ts b/packages/rainbowkit/src/hooks/useDisconnectAll.ts new file mode 100644 index 0000000000..399641e6e3 --- /dev/null +++ b/packages/rainbowkit/src/hooks/useDisconnectAll.ts @@ -0,0 +1,17 @@ +import { useCallback } from 'react'; +import { useConfig, useDisconnect } from 'wagmi'; + +// RainbowKit registers multiple WalletConnect connectors. After refresh, +// reconnect can restore more than one, and wagmi disconnect only clears current. +export function useDisconnectAll() { + const config = useConfig(); + const { disconnectAsync } = useDisconnect(); + + return useCallback(async () => { + const connections = Array.from(config.state.connections.values()); + + for (const { connector } of connections) { + await disconnectAsync({ connector }); + } + }, [config, disconnectAsync]); +} From 1236816c4e0734a276a87ae9c2c401cc8d0c7c7b Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Tue, 4 Aug 2026 18:13:26 +0400 Subject: [PATCH 2/5] fix: clear all connections on wallet disconnect --- .changeset/quiet-rocks-listen.md | 5 +++++ .../src/components/AccountModal/AccountModal.tsx | 9 ++++++--- .../src/components/ChainModal/ChainModal.tsx | 9 ++++++--- .../src/components/ConnectModal/ConnectModal.tsx | 13 +++++++------ 4 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 .changeset/quiet-rocks-listen.md diff --git a/.changeset/quiet-rocks-listen.md b/.changeset/quiet-rocks-listen.md new file mode 100644 index 0000000000..048e709d7f --- /dev/null +++ b/.changeset/quiet-rocks-listen.md @@ -0,0 +1,5 @@ +--- +'@rainbow-me/rainbowkit': patch +--- + +Fix WalletConnect disconnect after page refresh by clearing every restored connection in one click. diff --git a/packages/rainbowkit/src/components/AccountModal/AccountModal.tsx b/packages/rainbowkit/src/components/AccountModal/AccountModal.tsx index fa0e3babf2..9c06c98676 100644 --- a/packages/rainbowkit/src/components/AccountModal/AccountModal.tsx +++ b/packages/rainbowkit/src/components/AccountModal/AccountModal.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { useAccount, useDisconnect } from 'wagmi'; +import { useAccount } from 'wagmi'; +import { useDisconnectAll } from '../../hooks/useDisconnectAll'; import { useProfile } from '../../hooks/useProfile'; import { Dialog } from '../Dialog/Dialog'; import { DialogContent } from '../Dialog/DialogContent'; @@ -16,7 +17,7 @@ export function AccountModal({ onClose, open }: AccountModalProps) { address, includeBalance: open, }); - const { disconnect } = useDisconnect(); + const disconnectAll = useDisconnectAll(); if (!address) { return null; @@ -35,7 +36,9 @@ export function AccountModal({ onClose, open }: AccountModalProps) { ensName={ensName} balance={balance} onClose={onClose} - onDisconnect={disconnect} + onDisconnect={() => { + void disconnectAll(); + }} /> diff --git a/packages/rainbowkit/src/components/ChainModal/ChainModal.tsx b/packages/rainbowkit/src/components/ChainModal/ChainModal.tsx index b143470a08..5ccffc1055 100644 --- a/packages/rainbowkit/src/components/ChainModal/ChainModal.tsx +++ b/packages/rainbowkit/src/components/ChainModal/ChainModal.tsx @@ -1,5 +1,6 @@ import React, { useContext, useState } from 'react'; -import { useAccount, useConfig, useDisconnect, useSwitchChain } from 'wagmi'; +import { useAccount, useConfig, useSwitchChain } from 'wagmi'; +import { useDisconnectAll } from '../../hooks/useDisconnectAll'; import { isMobile } from '../../utils/isMobile'; import { Box } from '../Box/Box'; import { CloseButton } from '../CloseButton/CloseButton'; @@ -44,7 +45,7 @@ export function ChainModal({ onClose, open }: ChainModalProps) { const { i18n } = useContext(I18nContext); - const { disconnect } = useDisconnect(); + const disconnectAll = useDisconnectAll(); const titleId = 'rk_chain_modal_title'; const mobile = isMobile(); const isCurrentChainSupported = chains.some((chain) => chain.id === chainId); @@ -115,7 +116,9 @@ export function ChainModal({ onClose, open }: ChainModalProps) { <> disconnect()} + onClick={() => { + void disconnectAll(); + }} testId="chain-option-disconnect" > { onClose(); - disconnect(); - }, [onClose, disconnect]); + void disconnectAll(); + }, [onClose, disconnectAll]); const onConnectModalCancel = React.useCallback(() => { // We use this for the WalletButton. If the QR code shows up and // the user closes it, we need to know the wallet isn't connecting anymore. // So if it's connecting, we disconnect it. - if (isConnecting) disconnect(); + if (isConnecting) void disconnectAll(); onClose(); - }, [onClose, disconnect, isConnecting]); + }, [onClose, disconnectAll, isConnecting]); if (connectionStatus === 'disconnected') { return ( From 3ce83e754e00cf7c39f4bb32770c0712ebe23756 Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Sun, 9 Aug 2026 09:18:09 +0400 Subject: [PATCH 3/5] fix: cancel pending connect when connections are empty --- .../src/hooks/useDisconnectAll.test.tsx | 29 +++++++++++++++++++ .../rainbowkit/src/hooks/useDisconnectAll.ts | 7 +++++ 2 files changed, 36 insertions(+) diff --git a/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx b/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx index 28652f2cee..860e612899 100644 --- a/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx +++ b/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx @@ -181,4 +181,33 @@ describe('useDisconnectAll', () => { expect(screen.getByTestId('status').textContent).toBe('disconnected'); }); }); + + it('cancels a pending connect when there are no connections yet', async () => { + const config = createWalletConnectConfig(); + + config.setState({ + chainId: mainnet.id, + connections: new Map(), + current: null, + status: 'connecting', + }); + + render( + + + + + , + ); + + expect(screen.getByTestId('connection-count').textContent).toBe('0'); + expect(screen.getByTestId('status').textContent).toBe('connecting'); + + fireEvent.click(screen.getByTestId('disconnect')); + + await waitFor(() => { + expect(screen.getByTestId('status').textContent).toBe('disconnected'); + expect(screen.getByTestId('connected').textContent).toBe('false'); + }); + }); }); diff --git a/packages/rainbowkit/src/hooks/useDisconnectAll.ts b/packages/rainbowkit/src/hooks/useDisconnectAll.ts index 399641e6e3..f02026eae6 100644 --- a/packages/rainbowkit/src/hooks/useDisconnectAll.ts +++ b/packages/rainbowkit/src/hooks/useDisconnectAll.ts @@ -10,6 +10,13 @@ export function useDisconnectAll() { return useCallback(async () => { const connections = Array.from(config.state.connections.values()); + // Pending WalletButton / QR connect can set status to connecting before + // anything lands in connections. Bare disconnect still cancels that. + if (connections.length === 0) { + await disconnectAsync(); + return; + } + for (const { connector } of connections) { await disconnectAsync({ connector }); } From 44be001ae0d14c2570effa99b0db79f23eca84a6 Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Sun, 9 Aug 2026 12:50:24 +0400 Subject: [PATCH 4/5] fix: keep clearing connections after a failed disconnect --- .../src/hooks/useDisconnectAll.test.tsx | 34 +++++++++++++++++++ .../rainbowkit/src/hooks/useDisconnectAll.ts | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx b/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx index 860e612899..7d4720d121 100644 --- a/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx +++ b/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx @@ -182,6 +182,40 @@ describe('useDisconnectAll', () => { }); }); + it('keeps clearing remaining connections if one disconnect fails', async () => { + const config = createWalletConnectConfig(); + seedRestoredWalletConnectConnections(config); + + const walletConnectConnectors = config.connectors.filter( + (connector) => connector.id === 'walletConnect', + ); + const [failingConnector, ...remainingConnectors] = walletConnectConnectors; + if (!failingConnector || remainingConnectors.length === 0) { + throw new Error('Expected multiple WalletConnect connectors'); + } + + failingConnector.disconnect = vi + .fn() + .mockRejectedValue(new Error('teardown failed')); + + render( + + + + + , + ); + + fireEvent.click(screen.getByTestId('disconnect')); + + await waitFor(() => { + expect(failingConnector.disconnect).toHaveBeenCalled(); + for (const connector of remainingConnectors) { + expect(connector.disconnect).toHaveBeenCalled(); + } + }); + }); + it('cancels a pending connect when there are no connections yet', async () => { const config = createWalletConnectConfig(); diff --git a/packages/rainbowkit/src/hooks/useDisconnectAll.ts b/packages/rainbowkit/src/hooks/useDisconnectAll.ts index f02026eae6..2f14c42a6d 100644 --- a/packages/rainbowkit/src/hooks/useDisconnectAll.ts +++ b/packages/rainbowkit/src/hooks/useDisconnectAll.ts @@ -18,7 +18,11 @@ export function useDisconnectAll() { } for (const { connector } of connections) { - await disconnectAsync({ connector }); + try { + await disconnectAsync({ connector }); + } catch { + // One stale provider teardown must not block clearing the rest. + } } }, [config, disconnectAsync]); } From 1b81bb68e872758a3b23868f5aa45d601ffe6701 Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Sun, 9 Aug 2026 13:05:33 +0400 Subject: [PATCH 5/5] fix: drop failed connections from wagmi state --- .../src/hooks/useDisconnectAll.test.tsx | 5 ++- .../rainbowkit/src/hooks/useDisconnectAll.ts | 31 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx b/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx index 7d4720d121..15e39461fb 100644 --- a/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx +++ b/packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx @@ -182,7 +182,7 @@ describe('useDisconnectAll', () => { }); }); - it('keeps clearing remaining connections if one disconnect fails', async () => { + it('clears a connection even when its connector disconnect fails', async () => { const config = createWalletConnectConfig(); seedRestoredWalletConnectConnections(config); @@ -213,6 +213,9 @@ describe('useDisconnectAll', () => { for (const connector of remainingConnectors) { expect(connector.disconnect).toHaveBeenCalled(); } + expect(screen.getByTestId('connection-count').textContent).toBe('0'); + expect(screen.getByTestId('connected').textContent).toBe('false'); + expect(screen.getByTestId('status').textContent).toBe('disconnected'); }); }); diff --git a/packages/rainbowkit/src/hooks/useDisconnectAll.ts b/packages/rainbowkit/src/hooks/useDisconnectAll.ts index 2f14c42a6d..89e50afdb1 100644 --- a/packages/rainbowkit/src/hooks/useDisconnectAll.ts +++ b/packages/rainbowkit/src/hooks/useDisconnectAll.ts @@ -1,6 +1,34 @@ import { useCallback } from 'react'; +import type { Config, Connector } from 'wagmi'; import { useConfig, useDisconnect } from 'wagmi'; +function removeConnection(config: Config, connector: Connector) { + config.setState((state) => { + const connections = new Map(state.connections); + connections.delete(connector.uid); + + if (connections.size === 0) { + return { + ...state, + connections, + current: null, + status: 'disconnected', + }; + } + + const current = + state.current && connections.has(state.current) + ? state.current + : connections.values().next().value!.connector.uid; + + return { + ...state, + connections, + current, + }; + }); +} + // RainbowKit registers multiple WalletConnect connectors. After refresh, // reconnect can restore more than one, and wagmi disconnect only clears current. export function useDisconnectAll() { @@ -21,7 +49,8 @@ export function useDisconnectAll() { try { await disconnectAsync({ connector }); } catch { - // One stale provider teardown must not block clearing the rest. + // wagmi only drops the connection after connector.disconnect succeeds. + removeConnection(config, connector); } } }, [config, disconnectAsync]);