Skip to content

Commit

Permalink
A useWallets() hook that only vends handles (#53)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
steveluscher authored Jun 7, 2024
2 parents d476318 + ef4b590 commit 183f464
Show file tree
Hide file tree
Showing 10 changed files with 114 additions and 76 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-monkeys-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@wallet-standard/react-core': major
---

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.
15 changes: 3 additions & 12 deletions packages/example/react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
import { GlowWalletAdapter } from '@solana/wallet-adapter-glow';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom';
import { registerWalletAdapter, SOLANA_MAINNET_CHAIN } from '@solana/wallet-standard';
import { useWallets, WalletProvider } from '@wallet-standard/react';
import type { FC, ReactNode } from 'react';
import { useWallets } from '@wallet-standard/react';
import type { FC } from 'react';
import React, { useEffect } from 'react';

export const App: FC = () => {
return (
<Context>
<Content />
</Context>
);
};

const Context: FC<{ children: NonNullable<ReactNode> }> = ({ children }) => {
useEffect(() => {
const adapters = [new PhantomWalletAdapter(), new GlowWalletAdapter()];
const destructors = adapters.map((adapter) => registerWalletAdapter(adapter, SOLANA_MAINNET_CHAIN));
return () => destructors.forEach((destroy) => destroy());
}, []);

return <WalletProvider>{children}</WalletProvider>;
return <Content />;
};

const Content: FC = () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/react/core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# `@wallet-standard/react-core`

This package provides React hooks for using Wallet Standard wallets, accounts, and features.

## Hooks

### `useWallets()`

Vends an array of `UiWallet` objects; one for every registered Wallet Standard `Wallet`.
4 changes: 3 additions & 1 deletion packages/react/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"@wallet-standard/app": "workspace:^",
"@wallet-standard/base": "workspace:^",
"@wallet-standard/experimental-features": "workspace:^",
"@wallet-standard/features": "workspace:^"
"@wallet-standard/features": "workspace:^",
"@wallet-standard/ui": "workspace:^",
"@wallet-standard/ui-registry": "workspace:^"
},
"devDependencies": {
"@types/react": "^18.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import type { StandardEventsFeature, StandardEventsListeners } from '@wallet-sta
import { act } from 'react-test-renderer';

import { renderHook } from '../test-renderer.js';
import { useWallets } from '../useWallets.js';
import { useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT } from '../useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT.js';

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

describe('useWallets', () => {
describe('useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT', () => {
let mockGet: jest.MockedFn<ReturnType<typeof getWallets>['get']>;
let mockOn: jest.MockedFn<ReturnType<typeof getWallets>['on']>;
let mockRegister: jest.MockedFn<ReturnType<typeof getWallets>['register']>;
Expand All @@ -27,7 +27,7 @@ describe('useWallets', () => {
it('returns a list of registered wallets', () => {
const expectedWallets = [] as readonly Wallet[];
mockGet.mockReturnValue(expectedWallets);
const { result } = renderHook(() => useWallets());
const { result } = renderHook(() => useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT());
expect(result.current).toBe(expectedWallets);
});
describe.each(['register', 'unregister'])('when the %s event fires', (expectedEvent) => {
Expand All @@ -48,7 +48,7 @@ describe('useWallets', () => {
mockGet.mockReturnValue(initialWallets);
});
it('updates if the wallet array has changed', () => {
const { result } = renderHook(() => useWallets());
const { result } = renderHook(() => useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT());
const expectedWalletsAfterUpdate = ['new' as unknown as Wallet] as readonly Wallet[];
mockGet.mockReturnValue(expectedWalletsAfterUpdate);
act(() => {
Expand All @@ -59,7 +59,7 @@ describe('useWallets', () => {
expect(result.current).toBe(expectedWalletsAfterUpdate);
});
it('does not update if the wallet array has not changed', () => {
const { result } = renderHook(() => useWallets());
const { result } = renderHook(() => useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT());
act(() => {
listeners.forEach((l) => {
l(/* doesn't really matter what the listener receives */);
Expand Down Expand Up @@ -93,7 +93,7 @@ describe('useWallets', () => {
} as const,
];
mockGet.mockReturnValue(mockWallets);
const { result } = renderHook(() => useWallets());
const { result } = renderHook(() => useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT());
act(() => {
listeners.forEach((l) => {
l({
Expand Down
2 changes: 2 additions & 0 deletions packages/react/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from '@wallet-standard/ui';

export * from './features/index.js';

export * from './useWallet.js';
Expand Down
70 changes: 13 additions & 57 deletions packages/react/core/src/useWallets.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,17 @@
import { getWallets } from '@wallet-standard/app';
import type { Wallet, WalletWithFeatures } from '@wallet-standard/base';
import { StandardEvents, type StandardEventsFeature } from '@wallet-standard/features';
import { useCallback, useRef, useSyncExternalStore } from 'react';
import type { UiWallet } from '@wallet-standard/ui';
import { getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } from '@wallet-standard/ui-registry';
import { useMemo } from 'react';

import { hasEventsFeature } from './WalletProvider.js';
import { useStable } from './useStable.js';
import { useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT } from './useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT.js';

const NO_WALLETS: readonly Wallet[] = [];

function getServerSnapshot(): readonly Wallet[] {
return NO_WALLETS;
}

function walletHasStandardEventsFeature(wallet: Wallet): wallet is WalletWithFeatures<StandardEventsFeature> {
return hasEventsFeature(wallet.features);
}

/** TODO: docs */
export function useWallets(): readonly Wallet[] {
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(
(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());
};
},
[get, on]
/**
* Vends an array of `UiWallet` objects; one for every registered Wallet Standard `Wallet`.
*/
export function useWallets(): readonly UiWallet[] {
const wallets = useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT();
const uiWallets = useMemo(
() => wallets.map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED),
[wallets]
);
return useSyncExternalStore<readonly Wallet[]>(subscribe, getSnapshot, getServerSnapshot);
return uiWallets;
}
61 changes: 61 additions & 0 deletions packages/react/core/src/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { getWallets } from '@wallet-standard/app';
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[] = [];

function getServerSnapshot(): readonly Wallet[] {
return NO_WALLETS;
}

function walletHasStandardEventsFeature(wallet: Wallet): wallet is WalletWithFeatures<StandardEventsFeature> {
return hasEventsFeature(wallet.features);
}

/** TODO: docs */
export function useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT(): readonly Wallet[] {
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(
(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());
};
},
[get, on]
);
return useSyncExternalStore<readonly Wallet[]>(subscribe, getSnapshot, getServerSnapshot);
}
6 changes: 6 additions & 0 deletions packages/react/core/tsconfig.all.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
{
"path": "../../experimental/features/tsconfig.all.json"
},
{
"path": "../../ui/_/tsconfig.all.json"
},
{
"path": "../../ui/registry/tsconfig.all.json"
},
{
"path": "./tsconfig.cjs.json"
},
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 183f464

Please sign in to comment.