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 ( 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]); +}