Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/wallet-detection-at-hook-level.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@satoshai/kit": patch
---

Move wallet extension detection into useWallets hook for reliable availability
24 changes: 23 additions & 1 deletion packages/kit/src/hooks/use-wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand All @@ -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]
);
};
16 changes: 4 additions & 12 deletions packages/kit/src/provider/stacks-wallet-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
89 changes: 85 additions & 4 deletions packages/kit/tests/unit/hooks/use-wallets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -70,6 +82,7 @@ describe('useWallets', () => {
},
];
mockContext.wallets = wallets;
mockInstalled = ['xverse'];

const { result, rerender } = renderHook(() => useWallets());
const first = result.current;
Expand All @@ -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);
});
});
66 changes: 33 additions & 33 deletions packages/kit/tests/unit/provider/stacks-wallet-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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();
});
});
Loading