Skip to content

Commit 183f464

Browse files
authored
A useWallets() hook that only vends handles (#53)
This is the first React hook in the new Wallet Standard React package. ```ts function MyComponent() { const bitcoinWallets = useWallets().filter(({chains}) => chains.some(chain => chain.startsWith('bitcoin:')), ); return ( <ul> {bitcoinWallets.map( ({name}) => <li key={name}>{name}</li> )} </ul> ); } ``` Characteristcs: * Causes a rerender when a wallet is registered, unregistered, or changed * If a wallet (or wallet account within) hasn't changed since last render it will be referentially equal to the last observed value
2 parents d476318 + ef4b590 commit 183f464

File tree

10 files changed

+114
-76
lines changed

10 files changed

+114
-76
lines changed

.changeset/fresh-monkeys-compare.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@wallet-standard/react-core': major
3+
---
4+
5+
A `useWallets()` hook you can use to obtain an array of `UiWallet` objects that represent the currently registered Wallet Standard wallets. You can render these wallets in the UI of your application using the `name` and `icon` properties within, you can enumerate the `UiWalletAccount` objects authorized for the current domain through the `accounts` property, and you can use the `UiWallet` itself with compatible hooks, to materialize wallet features and more.

packages/example/react/src/App.tsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
11
import { GlowWalletAdapter } from '@solana/wallet-adapter-glow';
22
import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom';
33
import { registerWalletAdapter, SOLANA_MAINNET_CHAIN } from '@solana/wallet-standard';
4-
import { useWallets, WalletProvider } from '@wallet-standard/react';
5-
import type { FC, ReactNode } from 'react';
4+
import { useWallets } from '@wallet-standard/react';
5+
import type { FC } from 'react';
66
import React, { useEffect } from 'react';
77

88
export const App: FC = () => {
9-
return (
10-
<Context>
11-
<Content />
12-
</Context>
13-
);
14-
};
15-
16-
const Context: FC<{ children: NonNullable<ReactNode> }> = ({ children }) => {
179
useEffect(() => {
1810
const adapters = [new PhantomWalletAdapter(), new GlowWalletAdapter()];
1911
const destructors = adapters.map((adapter) => registerWalletAdapter(adapter, SOLANA_MAINNET_CHAIN));
2012
return () => destructors.forEach((destroy) => destroy());
2113
}, []);
22-
23-
return <WalletProvider>{children}</WalletProvider>;
14+
return <Content />;
2415
};
2516

2617
const Content: FC = () => {

packages/react/core/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# `@wallet-standard/react-core`
2+
3+
This package provides React hooks for using Wallet Standard wallets, accounts, and features.
4+
5+
## Hooks
6+
7+
### `useWallets()`
8+
9+
Vends an array of `UiWallet` objects; one for every registered Wallet Standard `Wallet`.

packages/react/core/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
"@wallet-standard/app": "workspace:^",
3939
"@wallet-standard/base": "workspace:^",
4040
"@wallet-standard/experimental-features": "workspace:^",
41-
"@wallet-standard/features": "workspace:^"
41+
"@wallet-standard/features": "workspace:^",
42+
"@wallet-standard/ui": "workspace:^",
43+
"@wallet-standard/ui-registry": "workspace:^"
4244
},
4345
"devDependencies": {
4446
"@types/react": "^18.3",

packages/react/core/src/__tests__/useWallets-test.ts renamed to packages/react/core/src/__tests__/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT-test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import type { StandardEventsFeature, StandardEventsListeners } from '@wallet-sta
44
import { act } from 'react-test-renderer';
55

66
import { renderHook } from '../test-renderer.js';
7-
import { useWallets } from '../useWallets.js';
7+
import { useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT } from '../useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT.js';
88

99
jest.mock('@wallet-standard/app');
1010

11-
describe('useWallets', () => {
11+
describe('useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT', () => {
1212
let mockGet: jest.MockedFn<ReturnType<typeof getWallets>['get']>;
1313
let mockOn: jest.MockedFn<ReturnType<typeof getWallets>['on']>;
1414
let mockRegister: jest.MockedFn<ReturnType<typeof getWallets>['register']>;
@@ -27,7 +27,7 @@ describe('useWallets', () => {
2727
it('returns a list of registered wallets', () => {
2828
const expectedWallets = [] as readonly Wallet[];
2929
mockGet.mockReturnValue(expectedWallets);
30-
const { result } = renderHook(() => useWallets());
30+
const { result } = renderHook(() => useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT());
3131
expect(result.current).toBe(expectedWallets);
3232
});
3333
describe.each(['register', 'unregister'])('when the %s event fires', (expectedEvent) => {
@@ -48,7 +48,7 @@ describe('useWallets', () => {
4848
mockGet.mockReturnValue(initialWallets);
4949
});
5050
it('updates if the wallet array has changed', () => {
51-
const { result } = renderHook(() => useWallets());
51+
const { result } = renderHook(() => useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT());
5252
const expectedWalletsAfterUpdate = ['new' as unknown as Wallet] as readonly Wallet[];
5353
mockGet.mockReturnValue(expectedWalletsAfterUpdate);
5454
act(() => {
@@ -59,7 +59,7 @@ describe('useWallets', () => {
5959
expect(result.current).toBe(expectedWalletsAfterUpdate);
6060
});
6161
it('does not update if the wallet array has not changed', () => {
62-
const { result } = renderHook(() => useWallets());
62+
const { result } = renderHook(() => useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT());
6363
act(() => {
6464
listeners.forEach((l) => {
6565
l(/* doesn't really matter what the listener receives */);
@@ -93,7 +93,7 @@ describe('useWallets', () => {
9393
} as const,
9494
];
9595
mockGet.mockReturnValue(mockWallets);
96-
const { result } = renderHook(() => useWallets());
96+
const { result } = renderHook(() => useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT());
9797
act(() => {
9898
listeners.forEach((l) => {
9999
l({

packages/react/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export * from '@wallet-standard/ui';
2+
13
export * from './features/index.js';
24

35
export * from './useWallet.js';

packages/react/core/src/useWallets.ts

Lines changed: 13 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,17 @@
1-
import { getWallets } from '@wallet-standard/app';
2-
import type { Wallet, WalletWithFeatures } from '@wallet-standard/base';
3-
import { StandardEvents, type StandardEventsFeature } from '@wallet-standard/features';
4-
import { useCallback, useRef, useSyncExternalStore } from 'react';
1+
import type { UiWallet } from '@wallet-standard/ui';
2+
import { getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } from '@wallet-standard/ui-registry';
3+
import { useMemo } from 'react';
54

6-
import { hasEventsFeature } from './WalletProvider.js';
7-
import { useStable } from './useStable.js';
5+
import { useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT } from './useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT.js';
86

9-
const NO_WALLETS: readonly Wallet[] = [];
10-
11-
function getServerSnapshot(): readonly Wallet[] {
12-
return NO_WALLETS;
13-
}
14-
15-
function walletHasStandardEventsFeature(wallet: Wallet): wallet is WalletWithFeatures<StandardEventsFeature> {
16-
return hasEventsFeature(wallet.features);
17-
}
18-
19-
/** TODO: docs */
20-
export function useWallets(): readonly Wallet[] {
21-
const { get, on } = useStable(getWallets);
22-
const prevWallets = useRef(get());
23-
const outputWallets = useRef(prevWallets.current);
24-
const getSnapshot = useCallback(() => {
25-
const nextWallets = get();
26-
if (nextWallets !== prevWallets.current) {
27-
// The Wallet Standard itself recyled the wallets array wrapper. Use that array.
28-
outputWallets.current = nextWallets;
29-
}
30-
prevWallets.current = nextWallets;
31-
return outputWallets.current;
32-
}, [get]);
33-
const subscribe = useCallback(
34-
(onStoreChange: () => void) => {
35-
const disposeRegisterListener = on('register', onStoreChange);
36-
const disposeUnregisterListener = on('unregister', onStoreChange);
37-
const disposeWalletChangeListeners = get()
38-
.filter(walletHasStandardEventsFeature)
39-
.map((wallet) =>
40-
wallet.features[StandardEvents].on('change', () => {
41-
// Despite a change in a property of a wallet, the array that contains the
42-
// list of wallets will be reused. The wallets array before and after the
43-
// change will be referentially equal.
44-
//
45-
// Here, we force a new wallets array wrapper to be created by cloning the
46-
// array. This gives React the signal to re-render, because it will notice
47-
// that the return value of `getSnapshot()` has changed.
48-
outputWallets.current = [...get()];
49-
onStoreChange();
50-
})
51-
);
52-
return () => {
53-
disposeRegisterListener();
54-
disposeUnregisterListener();
55-
disposeWalletChangeListeners.forEach((d) => d());
56-
};
57-
},
58-
[get, on]
7+
/**
8+
* Vends an array of `UiWallet` objects; one for every registered Wallet Standard `Wallet`.
9+
*/
10+
export function useWallets(): readonly UiWallet[] {
11+
const wallets = useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT();
12+
const uiWallets = useMemo(
13+
() => wallets.map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED),
14+
[wallets]
5915
);
60-
return useSyncExternalStore<readonly Wallet[]>(subscribe, getSnapshot, getServerSnapshot);
16+
return uiWallets;
6117
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { getWallets } from '@wallet-standard/app';
2+
import type { Wallet, WalletWithFeatures } from '@wallet-standard/base';
3+
import { StandardEvents, type StandardEventsFeature } from '@wallet-standard/features';
4+
import { useCallback, useRef, useSyncExternalStore } from 'react';
5+
6+
import { hasEventsFeature } from './WalletProvider.js';
7+
import { useStable } from './useStable.js';
8+
9+
const NO_WALLETS: readonly Wallet[] = [];
10+
11+
function getServerSnapshot(): readonly Wallet[] {
12+
return NO_WALLETS;
13+
}
14+
15+
function walletHasStandardEventsFeature(wallet: Wallet): wallet is WalletWithFeatures<StandardEventsFeature> {
16+
return hasEventsFeature(wallet.features);
17+
}
18+
19+
/** TODO: docs */
20+
export function useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT(): readonly Wallet[] {
21+
const { get, on } = useStable(getWallets);
22+
const prevWallets = useRef(get());
23+
const outputWallets = useRef(prevWallets.current);
24+
const getSnapshot = useCallback(() => {
25+
const nextWallets = get();
26+
if (nextWallets !== prevWallets.current) {
27+
// The Wallet Standard itself recyled the wallets array wrapper. Use that array.
28+
outputWallets.current = nextWallets;
29+
}
30+
prevWallets.current = nextWallets;
31+
return outputWallets.current;
32+
}, [get]);
33+
const subscribe = useCallback(
34+
(onStoreChange: () => void) => {
35+
const disposeRegisterListener = on('register', onStoreChange);
36+
const disposeUnregisterListener = on('unregister', onStoreChange);
37+
const disposeWalletChangeListeners = get()
38+
.filter(walletHasStandardEventsFeature)
39+
.map((wallet) =>
40+
wallet.features[StandardEvents].on('change', () => {
41+
// Despite a change in a property of a wallet, the array that contains the
42+
// list of wallets will be reused. The wallets array before and after the
43+
// change will be referentially equal.
44+
//
45+
// Here, we force a new wallets array wrapper to be created by cloning the
46+
// array. This gives React the signal to re-render, because it will notice
47+
// that the return value of `getSnapshot()` has changed.
48+
outputWallets.current = [...get()];
49+
onStoreChange();
50+
})
51+
);
52+
return () => {
53+
disposeRegisterListener();
54+
disposeUnregisterListener();
55+
disposeWalletChangeListeners.forEach((d) => d());
56+
};
57+
},
58+
[get, on]
59+
);
60+
return useSyncExternalStore<readonly Wallet[]>(subscribe, getSnapshot, getServerSnapshot);
61+
}

packages/react/core/tsconfig.all.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
{
1414
"path": "../../experimental/features/tsconfig.all.json"
1515
},
16+
{
17+
"path": "../../ui/_/tsconfig.all.json"
18+
},
19+
{
20+
"path": "../../ui/registry/tsconfig.all.json"
21+
},
1622
{
1723
"path": "./tsconfig.cjs.json"
1824
},

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)