Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-rocks-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rainbow-me/rainbowkit': patch
---

Fix WalletConnect disconnect after page refresh by clearing every restored connection in one click.
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,7 +17,7 @@ export function AccountModal({ onClose, open }: AccountModalProps) {
address,
includeBalance: open,
});
const { disconnect } = useDisconnect();
const disconnectAll = useDisconnectAll();

if (!address) {
return null;
Expand All @@ -35,7 +36,9 @@ export function AccountModal({ onClose, open }: AccountModalProps) {
ensName={ensName}
balance={balance}
onClose={onClose}
onDisconnect={disconnect}
onDisconnect={() => {
void disconnectAll();
}}
/>
</DialogContent>
</Dialog>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -115,7 +116,9 @@ export function ChainModal({ onClose, open }: ChainModalProps) {
<>
<Box background="generalBorderDim" height="1" marginX="8" />
<MenuButton
onClick={() => disconnect()}
onClick={() => {
void disconnectAll();
}}
testId="chain-option-disconnect"
>
<Box
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { useAccount, useDisconnect } from 'wagmi';
import { useAccount } from 'wagmi';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { useDisconnectAll } from '../../hooks/useDisconnectAll';
import ConnectOptions from '../ConnectOptions/ConnectOptions';
import { Dialog } from '../Dialog/Dialog';
import { DialogContent } from '../Dialog/DialogContent';
Expand All @@ -15,23 +16,23 @@ export function ConnectModal({ onClose, open }: ConnectModalProps) {
const titleId = 'rk_connect_title';
const connectionStatus = useConnectionStatus();

const { disconnect } = useDisconnect();
const disconnectAll = useDisconnectAll();
const { isConnecting } = useAccount();

// when a user cancels or dismisses the SignIn modal for SIWE, disconnect and call onClose
const onAuthCancel = React.useCallback(() => {
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep cancelling pending wallet connections

When the WalletButton QR flow is still pending and the user closes the connect modal, wagmi has isConnecting set but has not added anything to config.state.connections yet; useDisconnectAll therefore snapshots an empty list and this call becomes a no-op. The previous no-argument disconnect() still forced wagmi's status back to disconnected, which is exactly what the comment above this line says the close handler needs, so this can leave the custom WalletButton stuck in its loading/disabled connecting state until the pending connect promise settles. Preserve the old no-connection fallback when cancelling an in-flight connect.

Useful? React with 👍 / 👎.


onClose();
}, [onClose, disconnect, isConnecting]);
}, [onClose, disconnectAll, isConnecting]);

if (connectionStatus === 'disconnected') {
return (
Expand Down
184 changes: 184 additions & 0 deletions packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createWalletConnectConfig>,
) {
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 (
<div>
<div data-testid="status">{status}</div>
<div data-testid="connected">{String(isConnected)}</div>
<div data-testid="connection-count">{connections.length}</div>
<button
data-testid="disconnect"
onClick={() => {
if (mode === 'current') {
disconnect();
return;
}

void disconnectAll();
}}
type="button"
>
Disconnect
</button>
</div>
);
}

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(
<WagmiProvider config={config} reconnectOnMount={false}>
<QueryClientProvider client={queryClient}>
<DisconnectHarness mode="current" />
</QueryClientProvider>
</WagmiProvider>,
);

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(
<WagmiProvider config={config} reconnectOnMount={false}>
<QueryClientProvider client={queryClient}>
<DisconnectHarness mode="all" />
</QueryClientProvider>
</WagmiProvider>,
);

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');
});
});
});
17 changes: 17 additions & 0 deletions packages/rainbowkit/src/hooks/useDisconnectAll.ts
Original file line number Diff line number Diff line change
@@ -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]);
}