Skip to content

Commit

Permalink
A series of hooks you can use to materialize features from wallets an…
Browse files Browse the repository at this point in the history
…d accounts (#58)

This PR implements React hooks for all of the `standard:` features except for `decrypt` and `encrypt`.

The idea here is that you pass the hook an account (and chain, where relevant) and it either returns you a method implementation of that feature that you can call as many times as you need, or it throws in case the account can't be found, the account doesn't support that feature, or the account doesn't support that chain.
  • Loading branch information
steveluscher authored Jun 7, 2024
2 parents 7dcc681 + dd53afd commit 029343b
Show file tree
Hide file tree
Showing 28 changed files with 441 additions and 547 deletions.
5 changes: 5 additions & 0 deletions .changeset/wicked-bananas-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@wallet-standard/react-core': major
---

Replaced the feature context providers with two hooks: `useConnect()` and `useDisconnect()`
42 changes: 42 additions & 0 deletions packages/react/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,45 @@ This package provides React hooks for using Wallet Standard wallets, accounts, a
### `useWallets()`

Vends an array of `UiWallet` objects; one for every registered Wallet Standard `Wallet`.

### `useConnect(uiWallet)`

Given a `UiWallet`, this hook returns a function you can call to ask the wallet to authorize the current domain to use new accounts. Calling the returned method will return a promise for the array of account authorized by the wallet.

```tsx
function ConnectButton({ onAccountsConnected, wallet }) {
const [isConnecting, connect] = useConnect(wallet);
return (
<button
disabled={isConnecting}
onClick={() => {
connect.then(onAccountsConnected);
}}
>
Connect Wallet
</button>
);
}
```

Be aware that calls to `connect` operate on a single promise that is global to the entire app. If you call `connect` multiple times or from multiple places in your app, the exact same promise will be vended to all callers. This reflects the fact that a wallet can only field one authorization request at a time.

### `useDisconnect(uiWallet)`

Given a `UiWallet`, this hook returns a function you can call to ask the wallet to deauthorize accounts authorized for the current domain, or at least to remove them from the wallet's `accounts` array for the time being, depending on the wallet's implementation of `standard:disconnect`. Call the returned method will return a promise that resolves once the disconnection is complete.

```tsx
function DisconnectButton({ onDisconnected, wallet }) {
const [isDisconnecting, disconnect] = useDisconnect(wallet);
return (
<button
disabled={isDisconnecting}
onClick={() => {
disconnect.then(onDisconnected);
}}
>
Disconnect
</button>
);
}
```
21 changes: 2 additions & 19 deletions packages/react/core/src/WalletStandardProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import type { FC, ReactNode } from 'react';
import React from 'react';
import {
ConnectProvider,
DisconnectProvider,
SignAndSendTransactionProvider,
SignMessageProvider,
SignTransactionProvider,
} from './features/index.js';
import { WalletAccountProvider } from './WalletAccountProvider.js';
import { WalletProvider } from './WalletProvider.js';

Expand All @@ -17,20 +10,10 @@ export interface WalletStandardProviderProps {
}

/** TODO: docs */
export const WalletStandardProvider: FC<WalletStandardProviderProps> = ({ children, onError }) => {
export const WalletStandardProvider: FC<WalletStandardProviderProps> = ({ children }) => {
return (
<WalletProvider>
<WalletAccountProvider>
<ConnectProvider onError={onError}>
<DisconnectProvider onError={onError}>
<SignAndSendTransactionProvider onError={onError}>
<SignTransactionProvider onError={onError}>
<SignMessageProvider onError={onError}>{children}</SignMessageProvider>
</SignTransactionProvider>
</SignAndSendTransactionProvider>
</DisconnectProvider>
</ConnectProvider>
</WalletAccountProvider>
<WalletAccountProvider>{children}</WalletAccountProvider>
</WalletProvider>
);
};
130 changes: 130 additions & 0 deletions packages/react/core/src/__tests__/useConnect-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { Wallet, WalletVersion } from '@wallet-standard/base';
import { StandardConnect } from '@wallet-standard/features';
import type { UiWallet } from '@wallet-standard/ui';
import { getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } from '@wallet-standard/ui-registry';

import { useConnect } from '../features/useConnect.js';
import { renderHook } from '../test-renderer.js';

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

describe('useConnect', () => {
let mockConnect: jest.Mock;
let mockUiWallet: UiWallet;
beforeEach(() => {
mockConnect = jest.fn().mockResolvedValue({ accounts: [] });
const mockWallet: Wallet = {
accounts: [],
chains: [],
features: {
[StandardConnect]: {
connect: mockConnect,
},
},
icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=',
name: 'Mock Wallet',
version: '1.0.0' as WalletVersion,
};
jest.mocked(getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED).mockReturnValue(mockWallet);
// Suppresses console output when an `ErrorBoundary` is hit.
// See https://stackoverflow.com/a/72632884/802047
jest.spyOn(console, 'error').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
});
describe('the `isConnecting` property', () => {
it('is `false` before calling `connect()`', () => {
const { result } = renderHook(() => useConnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
}
const [isConnecting, _] = result.current;
expect(isConnecting).toBe(false);
});
it('is `false` after the connection resolves', async () => {
expect.assertions(1);
const { result } = renderHook(() => useConnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
}
const [_A, connect] = result.current;
await connect();
const [isConnecting, _B] = result.current;
expect(isConnecting).toBe(false);
});
it('is `true` after calling `connect()`', () => {
const { result } = renderHook(() => useConnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
}
const [_, connect] = result.current;
connect();
const [isConnecting] = result.current;
expect(isConnecting).toBe(true);
});
it('is `true` on hooks that did not trigger the connect', () => {
const { result: resultA } = renderHook(() => useConnect(mockUiWallet));
const { result: resultB } = renderHook(() => useConnect(mockUiWallet));
if (resultA.__type === 'error' || !resultA.current) {
throw resultA.current;
}
if (resultB.__type === 'error' || !resultB.current) {
throw resultB.current;
}
const [_, connectA] = resultA.current;
connectA();
const [isConnectingB] = resultB.current;
expect(isConnectingB).toBe(true);
});
});
describe('the `connect` property', () => {
it("calls the wallet's connect implementation when called ", () => {
const { result } = renderHook(() => useConnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
} else {
const [_, connect] = result.current;
connect();
expect(mockConnect).toHaveBeenCalled();
}
});
it("calls the wallet's connect implementation once despite multiple calls", () => {
const { result } = renderHook(() => useConnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
} else {
const [_, connect] = result.current;
connect();
connect();
expect(mockConnect).toHaveBeenCalledTimes(1);
}
});
it("calls the wallet's connect implementation once despite calls from different hooks", () => {
const { result: resultA } = renderHook(() => useConnect(mockUiWallet));
const { result: resultB } = renderHook(() => useConnect(mockUiWallet));
if (resultA.__type === 'error' || !resultA.current) {
throw resultA.current;
} else if (resultB.__type === 'error' || !resultB.current) {
throw resultB.current;
} else {
const [_A, connectA] = resultA.current;
const [_B, connectB] = resultB.current;
connectA();
connectB();
expect(mockConnect).toHaveBeenCalledTimes(1);
}
});
it("calls the wallet's connect implementation anew after the first connection resolves", async () => {
expect.assertions(1);
const { result } = renderHook(() => useConnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
} else {
const [_, connect] = result.current;
connect();
await jest.runAllTimersAsync();
connect();
expect(mockConnect).toHaveBeenCalledTimes(2);
}
});
});
});
130 changes: 130 additions & 0 deletions packages/react/core/src/__tests__/useDisconnect-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { Wallet, WalletVersion } from '@wallet-standard/base';
import { StandardDisconnect } from '@wallet-standard/features';
import type { UiWallet } from '@wallet-standard/ui';
import { getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } from '@wallet-standard/ui-registry';

import { useDisconnect } from '../features/useDisconnect.js';
import { renderHook } from '../test-renderer.js';

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

describe('useDisconnect', () => {
let mockDisconnect: jest.Mock;
let mockUiWallet: UiWallet;
beforeEach(() => {
mockDisconnect = jest.fn().mockResolvedValue({ accounts: [] });
const mockWallet: Wallet = {
accounts: [],
chains: [],
features: {
[StandardDisconnect]: {
disconnect: mockDisconnect,
},
},
icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=',
name: 'Mock Wallet',
version: '1.0.0' as WalletVersion,
};
jest.mocked(getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED).mockReturnValue(mockWallet);
// Suppresses console output when an `ErrorBoundary` is hit.
// See https://stackoverflow.com/a/72632884/802047
jest.spyOn(console, 'error').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
});
describe('the `isDisconnecting` property', () => {
it('is `false` before calling `disconnect()`', () => {
const { result } = renderHook(() => useDisconnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
}
const [isDisconnecting, _] = result.current;
expect(isDisconnecting).toBe(false);
});
it('is `false` after the disconnection resolves', async () => {
expect.assertions(1);
const { result } = renderHook(() => useDisconnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
}
const [_A, disconnectA] = result.current;
await disconnectA();
const [isDisconnecting, _B] = result.current;
expect(isDisconnecting).toBe(false);
});
it('is `true` after calling `disconnect()`', () => {
const { result } = renderHook(() => useDisconnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
}
const [_, disconnect] = result.current;
disconnect();
const [isDisconnecting] = result.current;
expect(isDisconnecting).toBe(true);
});
it('is `true` on hooks that did not trigger the disconnect', () => {
const { result: resultA } = renderHook(() => useDisconnect(mockUiWallet));
const { result: resultB } = renderHook(() => useDisconnect(mockUiWallet));
if (resultA.__type === 'error' || !resultA.current) {
throw resultA.current;
}
if (resultB.__type === 'error' || !resultB.current) {
throw resultB.current;
}
const [_, disconnectA] = resultA.current;
disconnectA();
const [isDisconnectingB] = resultB.current;
expect(isDisconnectingB).toBe(true);
});
});
describe('the `disconnect` property', () => {
it("calls the wallet's disconnect implementation when called ", () => {
const { result } = renderHook(() => useDisconnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
} else {
const [_, disconnect] = result.current;
disconnect();
expect(mockDisconnect).toHaveBeenCalled();
}
});
it("calls the wallet's disconnect implementation once despite multiple calls", () => {
const { result } = renderHook(() => useDisconnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
} else {
const [_, disconnect] = result.current;
disconnect();
disconnect();
expect(mockDisconnect).toHaveBeenCalledTimes(1);
}
});
it("calls the wallet's disconnect implementation once despite calls from different hooks", () => {
const { result: resultA } = renderHook(() => useDisconnect(mockUiWallet));
const { result: resultB } = renderHook(() => useDisconnect(mockUiWallet));
if (resultA.__type === 'error' || !resultA.current) {
throw resultA.current;
} else if (resultB.__type === 'error' || !resultB.current) {
throw resultB.current;
} else {
const [_A, disconnectA] = resultA.current;
const [_B, disconnectB] = resultB.current;
disconnectA();
disconnectB();
expect(mockDisconnect).toHaveBeenCalledTimes(1);
}
});
it("calls the wallet's disconnect implementation anew after the first disconnection resolves", async () => {
expect.assertions(1);
const { result } = renderHook(() => useDisconnect(mockUiWallet));
if (result.__type === 'error' || !result.current) {
throw result.current;
} else {
const [_, disconnect] = result.current;
disconnect();
await jest.runAllTimersAsync();
disconnect();
expect(mockDisconnect).toHaveBeenCalledTimes(2);
}
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ describe('useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT', () => {
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[];
Expand Down
Loading

0 comments on commit 029343b

Please sign in to comment.