diff --git a/.changeset/clean-snakes-complain.md b/.changeset/clean-snakes-complain.md new file mode 100644 index 00000000..c1b6e252 --- /dev/null +++ b/.changeset/clean-snakes-complain.md @@ -0,0 +1,5 @@ +--- +'@wallet-standard/react-core': patch +--- + +The `useWallets()` hook will now cause a re-render any time a wallet's `'change'` event fires diff --git a/packages/react/core/package.json b/packages/react/core/package.json index 2ada3d65..5ebbdb83 100644 --- a/packages/react/core/package.json +++ b/packages/react/core/package.json @@ -46,6 +46,7 @@ "@types/react-test-renderer": "^18", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.13", "react-test-renderer": "^18", "shx": "^0.3.4" } diff --git a/packages/react/core/src/__tests__/useWallets-test.ts b/packages/react/core/src/__tests__/useWallets-test.ts index 4b891f2a..1c2067ba 100644 --- a/packages/react/core/src/__tests__/useWallets-test.ts +++ b/packages/react/core/src/__tests__/useWallets-test.ts @@ -1,3 +1,107 @@ +import { getWallets } from '@wallet-standard/app'; +import type { Wallet, WalletVersion } from '@wallet-standard/base'; +import type { StandardEventsFeature, StandardEventsListeners } from '@wallet-standard/features'; +import { act } from 'react-test-renderer'; + +import { renderHook } from '../test-renderer.js'; +import { useWallets } from '../useWallets.js'; + +jest.mock('@wallet-standard/app'); + describe('useWallets', () => { - it.todo('Add a test'); + let mockGet: jest.MockedFn['get']>; + let mockOn: jest.MockedFn['on']>; + let mockRegister: jest.MockedFn['register']>; + beforeEach(() => { + mockGet = jest.fn().mockReturnValue([] as readonly Wallet[]); + mockOn = jest.fn(); + mockRegister = jest.fn(); + jest.mocked(getWallets).mockReturnValue({ + get: mockGet, + on: mockOn, + register: mockRegister, + }); + // Suppresses console output when an `ErrorBoundary` is hit. + // See https://stackoverflow.com/a/72632884/802047 + }); + it('returns a list of registered wallets', () => { + const expectedWallets = [] as readonly Wallet[]; + mockGet.mockReturnValue(expectedWallets); + const { result } = renderHook(() => useWallets()); + expect(result.current).toBe(expectedWallets); + }); + describe.each(['register', 'unregister'])('when the %s event fires', (expectedEvent) => { + let initialWallets: readonly Wallet[]; + let listeners: (((...wallets: Wallet[]) => void) | ((...wallets: Wallet[]) => void))[] = []; + beforeEach(() => { + initialWallets = [] as readonly Wallet[]; + listeners = []; + mockGet.mockReturnValue(initialWallets); + mockOn.mockImplementation((event, listener) => { + if (event === expectedEvent) { + listeners.push(listener); + } + return () => { + /* unsubscribe */ + }; + }); + mockGet.mockReturnValue(initialWallets); + }); + it('updates if the wallet array has changed', () => { + const { result } = renderHook(() => useWallets()); + const expectedWalletsAfterUpdate = ['new' as unknown as Wallet] as readonly Wallet[]; + mockGet.mockReturnValue(expectedWalletsAfterUpdate); + act(() => { + listeners.forEach((l) => { + l(/* doesn't really matter what the listener receives */); + }); + }); + expect(result.current).toBe(expectedWalletsAfterUpdate); + }); + it('does not update if the wallet array has not changed', () => { + const { result } = renderHook(() => useWallets()); + act(() => { + listeners.forEach((l) => { + l(/* doesn't really matter what the listener receives */); + }); + }); + expect(result.current).toBe(initialWallets); + }); + }); + it('recycles the wallets array when the `change` event fires on a wallet', () => { + const listeners: StandardEventsListeners['change'][] = []; + const mockWallets = [ + { + accounts: [], + chains: ['solana:mainnet'] as const, + features: { + 'standard:events': { + on(event, listener) { + if (event === 'change') { + listeners.push(listener); + } + return () => { + /* unsubscribe */ + }; + }, + version: '1.0.0' as const, + }, + } as StandardEventsFeature, + icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=', + name: 'Mock Wallet', + version: '1.0.0' as WalletVersion, + } as const, + ]; + mockGet.mockReturnValue(mockWallets); + const { result } = renderHook(() => useWallets()); + act(() => { + listeners.forEach((l) => { + l({ + /* doesn't really matter what the listener receives */ + }); + }); + }); + expect(result.current).toStrictEqual(mockWallets); + expect(result.current).not.toBe(mockWallets); + }); }); diff --git a/packages/react/core/src/test-renderer.tsx b/packages/react/core/src/test-renderer.tsx new file mode 100644 index 00000000..f20dd970 --- /dev/null +++ b/packages/react/core/src/test-renderer.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; + +type Result = + | { + __type: 'error'; + current: Error; + reset: () => void; + } + | { + __type: 'result'; + current?: T; + }; + +type TestComponentProps = { + executor(): THookReturn; + resultRef: Result; +}; + +function TestComponentHookRenderer unknown>({ + executor, + resultRef, +}: TestComponentProps>) { + resultRef.current = executor(); + return null; +} + +function TestComponent unknown>({ + executor, + resultRef, +}: TestComponentProps>) { + return ( + { + resultRef.__type = 'error'; + resultRef.current = error; + (resultRef as Extract).reset = resetErrorBoundary; + return null; + }} + onReset={() => { + resultRef.__type = 'result'; + delete resultRef.current; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (resultRef as any).reset; + }} + > + + + ); +} + +export function renderHook(executor: () => THookReturn): { + rerenderHook(nextExecutor: () => THookReturn): void; + result: Readonly>; +} { + const result = { __type: 'result' } as Result; + let testRenderer: ReactTestRenderer; + act(() => { + testRenderer = create(); + }); + return { + rerenderHook(nextExecutor) { + act(() => { + testRenderer.update(); + }); + }, + result, + }; +} diff --git a/packages/react/core/src/useWallets.ts b/packages/react/core/src/useWallets.ts index 421eb7c8..e3ab8e82 100644 --- a/packages/react/core/src/useWallets.ts +++ b/packages/react/core/src/useWallets.ts @@ -1,6 +1,9 @@ import { getWallets } from '@wallet-standard/app'; -import type { Wallet } from '@wallet-standard/base'; -import { useCallback, useSyncExternalStore } from 'react'; +import type { Wallet, WalletWithFeatures } from '@wallet-standard/base'; +import { StandardEvents, type StandardEventsFeature } from '@wallet-standard/features'; +import { useCallback, useRef, useSyncExternalStore } from 'react'; + +import { hasEventsFeature } from './WalletProvider.js'; import { useStable } from './useStable.js'; const NO_WALLETS: readonly Wallet[] = []; @@ -9,19 +12,50 @@ function getServerSnapshot(): readonly Wallet[] { return NO_WALLETS; } +function walletHasStandardEventsFeature(wallet: Wallet): wallet is WalletWithFeatures { + return hasEventsFeature(wallet.features); +} + /** TODO: docs */ export function useWallets(): readonly Wallet[] { - const { get: getSnapshot, on } = useStable(getWallets); + const { get, on } = useStable(getWallets); + const prevWallets = useRef(get()); + const outputWallets = useRef(prevWallets.current); + const getSnapshot = useCallback(() => { + const nextWallets = get(); + if (nextWallets !== prevWallets.current) { + // The Wallet Standard itself recyled the wallets array wrapper. Use that array. + outputWallets.current = nextWallets; + } + prevWallets.current = nextWallets; + return outputWallets.current; + }, [get]); const subscribe = useCallback( - (callback: () => void) => { - const disposeRegisterListener = on('register', callback); - const disposeUnregisterListener = on('unregister', callback); + (onStoreChange: () => void) => { + const disposeRegisterListener = on('register', onStoreChange); + const disposeUnregisterListener = on('unregister', onStoreChange); + const disposeWalletChangeListeners = get() + .filter(walletHasStandardEventsFeature) + .map((wallet) => + wallet.features[StandardEvents].on('change', () => { + // Despite a change in a property of a wallet, the array that contains the + // list of wallets will be reused. The wallets array before and after the + // change will be referentially equal. + // + // Here, we force a new wallets array wrapper to be created by cloning the + // array. This gives React the signal to re-render, because it will notice + // that the return value of `getSnapshot()` has changed. + outputWallets.current = [...get()]; + onStoreChange(); + }) + ); return () => { disposeRegisterListener(); disposeUnregisterListener(); + disposeWalletChangeListeners.forEach((d) => d()); }; }, - [on] + [get, on] ); return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f32691e..660da7ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -458,6 +458,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ^4.0.13 + version: 4.0.13(react@18.2.0) react-test-renderer: specifier: ^18 version: 18.3.1(react@18.2.0) @@ -7157,6 +7160,15 @@ packages: react: 18.2.0 scheduler: 0.23.0 + /react-error-boundary@4.0.13(react@18.2.0): + resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.20.0 + react: 18.2.0 + dev: true + /react-error-overlay@6.0.9: resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==}