Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/fix-provider-switch-network-reactivity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@reown/appkit': patch
'@reown/appkit-controllers': patch
---

Fix `useAppKitProvider` not re-rendering after `switchNetwork` in React and Vue. The provider reference is intentionally shared across chains within a namespace, so consumers wrapping it in chain-bound objects (e.g. ethers `BrowserProvider`) had no signal to reconstruct after a network switch.
19 changes: 15 additions & 4 deletions packages/appkit/src/library/vue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { onUnmounted, reactive, ref } from 'vue'

import type { ChainNamespace } from '@reown/appkit-common'
import { type ConnectorType, type Event } from '@reown/appkit-controllers'
import { ProviderController } from '@reown/appkit-controllers'
import { ChainController, ProviderController } from '@reown/appkit-controllers'
import type {
AppKitAccountButton,
AppKitButton,
Expand Down Expand Up @@ -66,13 +66,24 @@ export function useAppKitProvider<T>(chainNamespace: ChainNamespace): UseAppKitR
const walletProvider = ref(ProviderController.state.providers[chainNamespace] as T | undefined)
const walletProviderType = ref(ProviderController.state.providerIds[chainNamespace])

const unsubscribe = ProviderController.subscribe(newState => {
walletProvider.value = newState.providers[chainNamespace]
const unsubscribeProvider = ProviderController.subscribe(newState => {
walletProvider.value = newState.providers[chainNamespace] as T | undefined
walletProviderType.value = newState.providerIds[chainNamespace]
})

/*
* Re-fire on network switch even when the provider reference is unchanged,
* so consumers wrapping the provider in chain-bound objects can reconstruct
* after switchNetwork. See #5453.
*/
const unsubscribeNetwork = ChainController.subscribeKey('activeCaipNetwork', () => {
walletProvider.value = ProviderController.state.providers[chainNamespace] as T | undefined
walletProviderType.value = ProviderController.state.providerIds[chainNamespace]
})

onUnmounted(() => {
unsubscribe?.()
unsubscribeProvider?.()
unsubscribeNetwork?.()
})

return reactive({
Expand Down
63 changes: 61 additions & 2 deletions packages/appkit/tests/library/react.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { createElement } from 'react'
import { act, createElement } from 'react'

import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { type CaipNetwork, ConstantsUtil } from '@reown/appkit-common'
import { ChainController, ProviderController } from '@reown/appkit-controllers'

import { useAppKitProvider } from '../../exports/react'
import { AppKitProvider, type AppKitProviderProps } from '../../src/library/react/providers'
import { solana } from '../mocks/Networks'

let mod: typeof import('../../exports/react')

Expand Down Expand Up @@ -35,3 +40,57 @@
expect(mod.createAppKit).toHaveBeenCalledTimes(1)
})
})

describe('useAppKitProvider', () => {
const originalActiveCaipNetwork = ChainController.state.activeCaipNetwork
const originalActiveChain = ChainController.state.activeChain
const originalProviders = { ...ProviderController.state.providers }

function ProviderProbe({ renderCount }: { renderCount: { current: number } }) {
const { walletProvider, walletProviderType } = useAppKitProvider<{ id: string }>('solana')
renderCount.current += 1

return createElement('div', null, [
createElement('span', { key: 'p', 'data-testid': 'provider-id' }, walletProvider?.id ?? ''),
createElement('span', { key: 't', 'data-testid': 'provider-type' }, walletProviderType ?? '')
])
}

beforeEach(() => {
ProviderController.setProvider(ConstantsUtil.CHAIN.SOLANA, { id: 'phantom-mainnet' })
ProviderController.setProviderId(ConstantsUtil.CHAIN.SOLANA, 'ANNOUNCED')
ChainController.state.activeCaipNetwork = solana as CaipNetwork
ChainController.state.activeChain = ConstantsUtil.CHAIN.SOLANA
})

afterEach(() => {
ProviderController.state.providers = { ...originalProviders }
ChainController.state.activeCaipNetwork = originalActiveCaipNetwork
ChainController.state.activeChain = originalActiveChain
})

it('re-renders when activeCaipNetwork changes (Solana switchNetwork)', async () => {
const renderCount = { current: 0 }

let result: ReturnType<typeof render>
await act(async () => {
result = render(createElement(ProviderProbe, { renderCount }) as React.ReactElement)
})

expect((await result!.findByTestId('provider-id')).textContent).toBe('phantom-mainnet')
const initialRenderCount = renderCount.current

const solanaDevnet: CaipNetwork = {
...(solana as CaipNetwork),
id: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1',
caipNetworkId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1',
name: 'Solana Devnet'
}

await act(async () => {
ChainController.setActiveCaipNetwork(solanaDevnet)
})

expect(renderCount.current).toBeGreaterThan(initialRenderCount)

Check failure on line 94 in packages/appkit/tests/library/react.test.ts

View workflow job for this annotation

GitHub Actions / test

tests/library/react.test.ts > useAppKitProvider > re-renders when activeCaipNetwork changes (Solana switchNetwork)

AssertionError: expected 1 to be greater than 1 ❯ tests/library/react.test.ts:94:33
})
})
62 changes: 51 additions & 11 deletions packages/appkit/tests/library/vue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { ChainController, ProviderController } from '@reown/appkit-controllers'

import { type ConnectorType, createAppKit, useAppKitProvider } from '../../exports/vue-core.js'
import { useAppKitNetwork } from '../../exports/vue.js'
import { mainnet } from '../mocks/Networks.js'
import { mainnet, solana } from '../mocks/Networks.js'

const TestComponent = defineComponent({
setup() {
const state = useAppKitProvider('eip155')
const state = useAppKitProvider('solana')

return { state }
},
Expand All @@ -26,11 +26,13 @@ const TestComponent = defineComponent({
describe('useAppKitProvider', () => {
const mockSubscribe = vi.fn()
const mockUnsubscribe = vi.fn()
const mockSubscribeNetworkKey = vi.fn()
const mockUnsubscribeNetworkKey = vi.fn()

beforeAll(() => {
createAppKit({
projectId: 'test',
networks: [mainnet]
networks: [mainnet, solana]
})
})

Expand All @@ -40,10 +42,10 @@ describe('useAppKitProvider', () => {
vi.spyOn(ProviderController, 'state', 'get').mockReturnValue({
...ProviderController.state,
providers: {
eip155: { test: 'provider' }
solana: { test: 'solana-provider' }
} as unknown as Record<ChainNamespace, ConnectorType>,
providerIds: {
eip155: 'test-provider'
solana: 'ANNOUNCED'
} as unknown as Record<ChainNamespace, ConnectorType>
})

Expand All @@ -52,13 +54,23 @@ describe('useAppKitProvider', () => {

return mockUnsubscribe
})

vi.spyOn(ChainController, 'subscribeKey').mockImplementation((key, callback) => {
if (key === 'activeCaipNetwork') {
mockSubscribeNetworkKey(callback)

return mockUnsubscribeNetworkKey
}

return vi.fn()
})
})

it('should initialize with correct initial state', () => {
const wrapper = mount(TestComponent)

expect(wrapper.get('[data-testid="provider"]').text()).toContain('test')
expect(wrapper.get('[data-testid="type"]').text()).toBe('"test-provider"')
expect(wrapper.get('[data-testid="provider"]').text()).toContain('solana-provider')
expect(wrapper.get('[data-testid="type"]').text()).toBe('"ANNOUNCED"')
})

it('should subscribe to provider updates', () => {
Expand All @@ -75,17 +87,44 @@ describe('useAppKitProvider', () => {

firstCallback({
providers: {
eip155: { test: 'new-provider' }
solana: { test: 'new-solana-provider' }
},
providerIds: {
eip155: 'new-provider-type'
solana: 'WALLET_CONNECT'
}
})

await wrapper.vm.$nextTick()

expect(wrapper.get('[data-testid="provider"]').text()).toContain('new-provider')
expect(wrapper.get('[data-testid="type"]').text()).toBe('"new-provider-type"')
expect(wrapper.get('[data-testid="provider"]').text()).toContain('new-solana-provider')
expect(wrapper.get('[data-testid="type"]').text()).toBe('"WALLET_CONNECT"')
})

it('should re-fire when Solana switchNetwork updates activeCaipNetwork', async () => {
const wrapper = mount(TestComponent)

expect(ChainController.subscribeKey).toHaveBeenCalledWith(
'activeCaipNetwork',
expect.any(Function)
)

vi.spyOn(ProviderController, 'state', 'get').mockReturnValue({
...ProviderController.state,
providers: {
solana: { test: 'devnet-provider' }
} as unknown as Record<ChainNamespace, ConnectorType>,
providerIds: {
solana: 'ANNOUNCED'
} as unknown as Record<ChainNamespace, ConnectorType>
})

const networkCallback = mockSubscribeNetworkKey.mock.calls[0]?.[0]
networkCallback?.()

await wrapper.vm.$nextTick()

expect(wrapper.get('[data-testid="provider"]').text()).toContain('devnet-provider')
expect(wrapper.get('[data-testid="type"]').text()).toBe('"ANNOUNCED"')
})

it('should unsubscribe on component unmount', () => {
Expand All @@ -94,6 +133,7 @@ describe('useAppKitProvider', () => {
wrapper.unmount()

expect(mockUnsubscribe).toHaveBeenCalled()
expect(mockUnsubscribeNetworkKey).toHaveBeenCalled()
})
})

Expand Down
6 changes: 6 additions & 0 deletions packages/controllers/exports/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@
// -- Hooks ------------------------------------------------------------
export function useAppKitProvider<T>(chainNamespace: ChainNamespace) {
const { providers, providerIds } = useSnapshot(ProviderController.state)
/*
* Re-render on network switch even when the provider reference is unchanged,
* so consumers wrapping the provider in chain-bound objects (e.g. ethers
* BrowserProvider) can reconstruct after switchNetwork. See #5453.
*/
useSnapshot(ChainController.state)

const walletProvider = providers[chainNamespace] as T
const walletProviderType = providerIds[chainNamespace]
Expand Down Expand Up @@ -440,14 +446,14 @@

const [isFetchingWallets, setIsFetchingWallets] = useState(false)
const [currentWcPayUrl, setCurrentWcPayUrl] = useState<string | undefined>(undefined)
const { wcUri, wcFetchingUri, wcError } = useSnapshot(ConnectionController.state)

Check warning on line 449 in packages/controllers/exports/react.ts

View workflow job for this annotation

GitHub Actions / code_style (lint)

Variable name `wcError` must have one of the following prefixes: is, has, can, should, will, did

Check warning on line 449 in packages/controllers/exports/react.ts

View workflow job for this annotation

GitHub Actions / code_style (lint)

Variable name `wcFetchingUri` must have one of the following prefixes: is, has, can, should, will, did
const {
wallets: wcAllWallets,
search: wcSearchWallets,
page,
count
} = useSnapshot(ApiController.state)
const { initialized, connectingWallet } = useSnapshot(PublicStateController.state)

Check warning on line 456 in packages/controllers/exports/react.ts

View workflow job for this annotation

GitHub Actions / code_style (lint)

Variable name `initialized` must have one of the following prefixes: is, has, can, should, will, did
const { clientId: wcClientId } = useSnapshot(BlockchainApiController.state)

// Alert if headless is not enabled
Expand Down
37 changes: 36 additions & 1 deletion packages/controllers/tests/hooks/react.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ConnectionController,
ConnectorController,
type ConnectorControllerState,
ProviderController,
PublicStateController,
StorageUtil
} from '../../exports/index.js'
Expand All @@ -18,10 +19,11 @@ import {
useAppKitConnection,
useAppKitConnections,
useAppKitNetworkCore,
useAppKitProvider,
useAppKitWallets,
useDisconnect
} from '../../exports/react.js'
import { extendedMainnet } from '../../exports/testing.js'
import { extendedMainnet, solanaCaipNetwork } from '../../exports/testing.js'
import { AssetUtil } from '../../exports/utils.js'
import { ConnectUtil } from '../../src/utils/ConnectUtil.js'
import type { WalletItem } from '../../src/utils/ConnectUtil.js'
Expand Down Expand Up @@ -328,6 +330,39 @@ describe('useAppKitAccount', () => {
})
})

describe('useAppKitProvider', () => {
beforeEach(() => {
vi.resetAllMocks()
})

it('should return the Solana provider and provider type for the namespace', () => {
const solanaProvider = { id: 'solana-wallet-standard-provider' }

useSnapshot
.mockReturnValueOnce({
providers: { solana: solanaProvider },
providerIds: { solana: 'ANNOUNCED' }
})
.mockReturnValueOnce({ activeCaipNetwork: solanaCaipNetwork })

const { walletProvider, walletProviderType } = useAppKitProvider('solana')

expect(walletProvider).toBe(solanaProvider)
expect(walletProviderType).toBe('ANNOUNCED')
})

it('should subscribe to ChainController state so Solana switchNetwork re-renders the hook', () => {
useSnapshot
.mockReturnValueOnce({ providers: {}, providerIds: {} })
.mockReturnValueOnce({ activeCaipNetwork: solanaCaipNetwork })

useAppKitProvider('solana')

expect(useSnapshot).toHaveBeenCalledWith(ProviderController.state)
expect(useSnapshot).toHaveBeenCalledWith(ChainController.state)
})
})

describe('useDisconnect', () => {
it('should disconnect as expected', async () => {
const disconnectSpy = vi.spyOn(ConnectionController, 'disconnect')
Expand Down
Loading