From c299b316a6ea5f903ad32593de7f2968a2324a6e Mon Sep 17 00:00:00 2001 From: Shola Ayeni Date: Tue, 3 Nov 2026 10:59:24 +0100 Subject: [PATCH] fix(react): preserve signing client hook order across chains --- .../__tests__/hooks/useSigningClient.test.ts | 96 +++++++++++++++++++ packages/react/src/hooks/useSigningClient.ts | 40 +++++--- 2 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 packages/react/__tests__/hooks/useSigningClient.test.ts diff --git a/packages/react/__tests__/hooks/useSigningClient.test.ts b/packages/react/__tests__/hooks/useSigningClient.test.ts new file mode 100644 index 000000000..7d13e32fc --- /dev/null +++ b/packages/react/__tests__/hooks/useSigningClient.test.ts @@ -0,0 +1,96 @@ +import { WalletState } from '@interchain-kit/core'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useState } from 'react'; + +import { activeRequests, cache } from '../../src/hooks/useAsync'; +import { useSigningClient } from '../../src/hooks/useSigningClient'; +import { useWalletManager } from '../../src/hooks/useWalletManager'; + +jest.mock('../../src/hooks/useWalletManager', () => ({ + useWalletManager: jest.fn(), +})); + +describe('useSigningClient chain type changes', () => { + const signingClient = { sign: jest.fn() }; + const manager = { + getChainByName: jest.fn((chainName: string) => ({ + chainType: chainName === 'cosmoshub' ? 'cosmos' : chainName, + })), + getChainWalletState: jest.fn(), + getRpcEndpoint: jest.fn(), + getSigningClient: jest.fn(), + isReady: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + cache.clear(); + activeRequests.clear(); + manager.getChainWalletState.mockReturnValue({ + walletState: WalletState.Connected, + }); + manager.getRpcEndpoint.mockResolvedValue('https://rpc.example.com'); + manager.getSigningClient.mockResolvedValue(signingClient); + (useWalletManager as jest.Mock).mockReturnValue(manager); + }); + + const useClient = (chainName: string) => { + const client = useSigningClient(chainName, 'test-wallet'); + // A consumer can call other hooks after useSigningClient. + const [marker] = useState('unchanged'); + return { ...client, marker }; + }; + + it.each(['ethereum', 'solana'])( + 'switches from %s to Cosmos and back', + async (chainName) => { + const { result, rerender } = renderHook(({ chain }) => useClient(chain), { + initialProps: { chain: chainName }, + }); + expect(result.current).toEqual({ + signingClient: null, + isLoading: false, + error: null, + marker: 'unchanged', + }); + expect(manager.getRpcEndpoint).not.toHaveBeenCalled(); + expect(manager.getSigningClient).not.toHaveBeenCalled(); + + rerender({ chain: 'cosmoshub' }); + await waitFor(() => + expect(result.current.signingClient).toBe(signingClient) + ); + expect(manager.getSigningClient).toHaveBeenCalledWith( + 'test-wallet', + 'cosmoshub' + ); + + rerender({ chain: chainName }); + expect(result.current).toEqual({ + signingClient: null, + isLoading: false, + error: null, + marker: 'unchanged', + }); + expect(manager.getRpcEndpoint).toHaveBeenCalledTimes(1); + expect(manager.getSigningClient).toHaveBeenCalledTimes(1); + } + ); + + it('reports Cosmos query errors and clears them on a non-Cosmos chain', async () => { + const error = new Error('RPC unavailable'); + manager.getRpcEndpoint.mockRejectedValue(error); + const { result, rerender } = renderHook(({ chain }) => useClient(chain), { + initialProps: { chain: 'cosmoshub' }, + }); + await waitFor(() => expect(result.current.error).toBe(error)); + expect(manager.getSigningClient).not.toHaveBeenCalled(); + rerender({ chain: 'ethereum' }); + expect(result.current).toEqual({ + signingClient: null, + isLoading: false, + error: null, + marker: 'unchanged', + }); + }); +}); diff --git a/packages/react/src/hooks/useSigningClient.ts b/packages/react/src/hooks/useSigningClient.ts index c112f2d4e..4fadf0d8a 100644 --- a/packages/react/src/hooks/useSigningClient.ts +++ b/packages/react/src/hooks/useSigningClient.ts @@ -4,33 +4,49 @@ import { useAsync } from './useAsync'; import { useWalletManager } from './useWalletManager'; export const useSigningClient = (chainName: string, walletName: string) => { - const { getSigningClient, getChainWalletState, getRpcEndpoint, isReady, getChainByName } = useWalletManager(); + const { + getSigningClient, + getChainWalletState, + getRpcEndpoint, + isReady, + getChainByName, + } = useWalletManager(); const chain = getChainByName(chainName); - if (chain.chainType !== 'cosmos') { - return { - signingClient: null, - isLoading: false, - error: null, - }; - } - - const chainWalletState = getChainWalletState(walletName, chainName); + const isCosmosChain = chain.chainType === 'cosmos'; + const chainWalletState = isCosmosChain + ? getChainWalletState(walletName, chainName) + : undefined; const { data, isLoading, error } = useAsync({ queryKey: `signing-client-${chainName}-${walletName}`, queryFn: async () => { await getRpcEndpoint(walletName, chainName); - const currentWalletState = getChainWalletState(walletName, chainName)?.walletState; + const currentWalletState = getChainWalletState( + walletName, + chainName + )?.walletState; if (currentWalletState === WalletState.Connected) { return getSigningClient(walletName, chainName); } }, - enabled: chainWalletState?.walletState === WalletState.Connected && isReady, + enabled: + isCosmosChain && + chainWalletState?.walletState === WalletState.Connected && + isReady, disableCache: true, }); + // Keep hook order stable when the selected chain changes type. + if (!isCosmosChain) { + return { + signingClient: null, + isLoading: false, + error: null, + }; + } + return { signingClient: data, error,