Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
247 changes: 247 additions & 0 deletions packages/rainbowkit/src/hooks/useDisconnectAll.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
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');
});
});

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

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();

config.setState({
chainId: mainnet.id,
connections: new Map(),
current: null,
status: 'connecting',
});

render(
<WagmiProvider config={config} reconnectOnMount={false}>
<QueryClientProvider client={queryClient}>
<DisconnectHarness mode="all" />
</QueryClientProvider>
</WagmiProvider>,
);

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

// 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) {
try {
await disconnectAsync({ connector });
} catch {
// One stale provider teardown must not block clearing the rest.

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 Clear the connector that fails to disconnect

When one connector in the snapshot rejects from disconnectAsync({ connector }) (for example a restored WalletConnect provider hits a teardown error), wagmi leaves that connection in config.state.connections because the connection is removed only after the connector disconnect succeeds. This catch now swallows the rejection and continues, so after the remaining connectors are cleared the failed connection can become/stay current and the user is still connected after pressing Disconnect; the new regression test only checks that the remaining connectors were called, not that the failing connection was removed.

Useful? React with 👍 / 👎.

}
}
}, [config, disconnectAsync]);
}