diff --git a/.changeset/wallet-detection-at-hook-level.md b/.changeset/wallet-detection-at-hook-level.md new file mode 100644 index 0000000..9b8dd07 --- /dev/null +++ b/.changeset/wallet-detection-at-hook-level.md @@ -0,0 +1,5 @@ +--- +"@satoshai/kit": patch +--- + +Move wallet extension detection into useWallets hook for reliable availability diff --git a/packages/kit/src/hooks/use-wallets.ts b/packages/kit/src/hooks/use-wallets.ts index 79aa229..cc5a09e 100644 --- a/packages/kit/src/hooks/use-wallets.ts +++ b/packages/kit/src/hooks/use-wallets.ts @@ -3,6 +3,7 @@ import { useMemo } from 'react'; import { useStacksWalletContext } from '../provider/stacks-wallet-provider'; +import { getStacksWallets } from '../utils/get-stacks-wallets'; /** * List all configured wallets with availability status. @@ -11,6 +12,11 @@ import { useStacksWalletContext } from '../provider/stacks-wallet-provider'; * (install link), and whether it's `available` (extension detected or * WalletConnect configured). * + * Performs a fresh check of `window` globals on every render so that + * browser extensions injected after React hydration are detected by the + * time the consumer reads the wallet list (e.g. when a connect-wallet + * drawer opens). + * * @example * ```ts * const { wallets } = useWallets(); @@ -24,6 +30,22 @@ import { useStacksWalletContext } from '../provider/stacks-wallet-provider'; */ export const useWallets = () => { const { wallets } = useStacksWalletContext(); + const { installed } = getStacksWallets(); + const installedKey = installed.join(','); - return useMemo(() => ({ wallets }), [wallets]); + return useMemo( + () => ({ + wallets: wallets.map((w) => ({ + ...w, + // wallet-connect availability is controlled by projectId in + // the provider — don't override with getStacksWallets() which + // unconditionally returns true for it. + available: + w.id === 'wallet-connect' + ? w.available + : w.available || installed.includes(w.id), + })), + }), + [wallets, installedKey] + ); }; diff --git a/packages/kit/src/provider/stacks-wallet-provider.tsx b/packages/kit/src/provider/stacks-wallet-provider.tsx index 34ccb31..60bd9dd 100644 --- a/packages/kit/src/provider/stacks-wallet-provider.tsx +++ b/packages/kit/src/provider/stacks-wallet-provider.tsx @@ -449,18 +449,10 @@ export const StacksWalletProvider = ({ onDisconnect: handleWcDisconnect, }); - // Detect installed wallets. Re-checks once after mount to catch extensions - // that inject globals after React hydration (common with React 19 + Next.js 16). - const [installed, setInstalled] = useState( - () => getStacksWallets().installed - ); - - useEffect(() => { - const fresh = getStacksWallets().installed; - setInstalled((prev) => - fresh.join(',') === prev.join(',') ? prev : fresh - ); - }, []); + // Computed in render body (not memoized) so it picks up wallet extensions + // injected after hydration. The context value useMemo below uses + // walletInfosKey so the reference stays stable when nothing changes. + const { installed } = getStacksWallets(); const configured = wallets ?? [...SUPPORTED_STACKS_WALLETS]; const walletInfos = configured.map((w) => ({ id: w, diff --git a/packages/kit/tests/unit/hooks/use-wallets.test.ts b/packages/kit/tests/unit/hooks/use-wallets.test.ts index a6d2d61..ea6a7c1 100644 --- a/packages/kit/tests/unit/hooks/use-wallets.test.ts +++ b/packages/kit/tests/unit/hooks/use-wallets.test.ts @@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SupportedStacksWallet } from '../../../src/constants/wallets'; import type { WalletContextValue, WalletInfo, @@ -21,14 +22,24 @@ vi.mock('../../../src/provider/stacks-wallet-provider', () => ({ useStacksWalletContext: () => mockContext, })); +let mockInstalled: SupportedStacksWallet[] = []; + +vi.mock('../../../src/utils/get-stacks-wallets', () => ({ + getStacksWallets: () => ({ + supported: ['xverse', 'leather', 'wallet-connect'], + installed: mockInstalled, + }), +})); + const { useWallets } = await import('../../../src/hooks/use-wallets'); beforeEach(() => { mockContext.wallets = []; + mockInstalled = []; }); describe('useWallets', () => { - it('returns wallets from context', () => { + it('returns wallets from context with fresh detection', () => { const wallets: WalletInfo[] = [ { id: 'xverse', @@ -46,20 +57,21 @@ describe('useWallets', () => { }, ]; mockContext.wallets = wallets; + mockInstalled = ['xverse']; const { result } = renderHook(() => useWallets()); - expect(result.current.wallets).toBe(wallets); expect(result.current.wallets).toHaveLength(2); + expect(result.current.wallets[0].available).toBe(true); }); - it('returns empty array when no wallets available', () => { + it('returns empty array when no wallets configured', () => { const { result } = renderHook(() => useWallets()); expect(result.current.wallets).toEqual([]); }); - it('returns same reference on rerender when wallets unchanged', () => { + it('returns same reference on rerender when nothing changes', () => { const wallets: WalletInfo[] = [ { id: 'xverse', @@ -70,6 +82,7 @@ describe('useWallets', () => { }, ]; mockContext.wallets = wallets; + mockInstalled = ['xverse']; const { result, rerender } = renderHook(() => useWallets()); const first = result.current; @@ -78,4 +91,72 @@ describe('useWallets', () => { expect(result.current).toBe(first); }); + + it('marks wallet available when extension injects after provider render', () => { + // Provider rendered before extension injected — available is false + const wallets: WalletInfo[] = [ + { + id: 'xverse', + name: 'Xverse', + icon: 'xverse.png', + webUrl: 'https://xverse.app', + available: false, + }, + { + id: 'leather', + name: 'Leather', + icon: 'leather.png', + webUrl: 'https://leather.io', + available: false, + }, + ]; + mockContext.wallets = wallets; + + // Extension has now injected into window + mockInstalled = ['xverse', 'leather']; + + const { result } = renderHook(() => useWallets()); + + expect(result.current.wallets[0].available).toBe(true); + expect(result.current.wallets[1].available).toBe(true); + }); + + it('preserves provider availability for wallet-connect when configured', () => { + const wallets: WalletInfo[] = [ + { + id: 'wallet-connect', + name: 'WalletConnect', + icon: 'wc.png', + webUrl: '', + available: true, + }, + ]; + mockContext.wallets = wallets; + mockInstalled = ['wallet-connect']; + + const { result } = renderHook(() => useWallets()); + + expect(result.current.wallets[0].available).toBe(true); + }); + + it('does not override wallet-connect availability when provider says unavailable', () => { + // Provider computed available: false (no projectId configured) + const wallets: WalletInfo[] = [ + { + id: 'wallet-connect', + name: 'WalletConnect', + icon: 'wc.png', + webUrl: '', + available: false, + }, + ]; + mockContext.wallets = wallets; + // getStacksWallets unconditionally returns wallet-connect as installed, + // but the hook must not override the provider's projectId-gated decision + mockInstalled = ['wallet-connect']; + + const { result } = renderHook(() => useWallets()); + + expect(result.current.wallets[0].available).toBe(false); + }); }); diff --git a/packages/kit/tests/unit/provider/stacks-wallet-provider.test.ts b/packages/kit/tests/unit/provider/stacks-wallet-provider.test.ts index 30186ef..4b389cb 100644 --- a/packages/kit/tests/unit/provider/stacks-wallet-provider.test.ts +++ b/packages/kit/tests/unit/provider/stacks-wallet-provider.test.ts @@ -7,17 +7,17 @@ import type { SupportedStacksWallet } from '../../../src/constants/wallets'; // ── Mocks ────────────────────────────────────────────────────────── -const NO_EXTENSIONS = { - supported: ['xverse', 'leather', 'asigna', 'fordefi', 'wallet-connect', 'okx'] as SupportedStacksWallet[], +const mockGetStacksWallets = vi.fn(() => ({ + supported: [ + 'xverse', + 'leather', + 'asigna', + 'fordefi', + 'wallet-connect', + 'okx', + ] as SupportedStacksWallet[], installed: [] as SupportedStacksWallet[], -}; - -const WITH_EXTENSIONS = { - ...NO_EXTENSIONS, - installed: ['xverse', 'leather'] as SupportedStacksWallet[], -}; - -const mockGetStacksWallets = vi.fn(() => NO_EXTENSIONS); +})); vi.mock('../../../src/utils/get-stacks-wallets', () => ({ getStacksWallets: (...args: unknown[]) => mockGetStacksWallets(...args), @@ -90,42 +90,42 @@ const wrapper = ({ children }: { children: React.ReactNode }) => // ── Tests ────────────────────────────────────────────────────────── beforeEach(() => { - mockGetStacksWallets.mockReset(); + mockGetStacksWallets.mockClear(); }); -describe('StacksWalletProvider wallet detection', () => { - it('picks up extensions that inject after initial render', () => { - // First call (useState initializer) — no extensions yet - // Second call (useEffect) — extensions have injected - mockGetStacksWallets - .mockReturnValueOnce(NO_EXTENSIONS) - .mockReturnValue(WITH_EXTENSIONS); +describe('StacksWalletProvider', () => { + it('exposes configured wallets with availability from getStacksWallets', () => { + mockGetStacksWallets.mockReturnValue({ + supported: ['xverse', 'leather'] as SupportedStacksWallet[], + installed: ['xverse'] as SupportedStacksWallet[], + }); const { result } = renderHook(() => useStacksWalletContext(), { wrapper, }); - // After mount + effect, wallets should reflect late-detected extensions - const available = result.current.wallets.filter((w) => w.available); - expect(available.map((w) => w.id)).toContain('xverse'); - expect(available.map((w) => w.id)).toContain('leather'); + expect(result.current.wallets).toHaveLength(2); + + const xverse = result.current.wallets.find((w) => w.id === 'xverse'); + const leather = result.current.wallets.find( + (w) => w.id === 'leather' + ); + expect(xverse?.available).toBe(true); + expect(leather?.available).toBe(false); }); - it('does not update state when extensions are already detected at mount', () => { - // Both calls return the same result - mockGetStacksWallets.mockReturnValue(WITH_EXTENSIONS); + it('starts in disconnected state', () => { + mockGetStacksWallets.mockReturnValue({ + supported: [] as SupportedStacksWallet[], + installed: [] as SupportedStacksWallet[], + }); const { result } = renderHook(() => useStacksWalletContext(), { wrapper, }); - const wallets = result.current.wallets; - - // getStacksWallets called at least twice (init + effect), - // but wallets reference should be stable since nothing changed - expect(result.current.wallets).toBe(wallets); - expect(mockGetStacksWallets).toHaveBeenCalledTimes( - mockGetStacksWallets.mock.calls.length - ); + expect(result.current.status).toBe('disconnected'); + expect(result.current.address).toBeUndefined(); + expect(result.current.provider).toBeUndefined(); }); });