Skip to content

Commit

Permalink
feat: improve state typing, expose useConnectedMetaMask (#16)
Browse files Browse the repository at this point in the history
* feat: improve state typing, add tests, add useConnectedMetaMask

* feat: update README
  • Loading branch information
VGLoic authored Feb 6, 2022
1 parent 3d9fee5 commit 0b84846
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 53 deletions.
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ Simplistic Context provider and consumer hook in order to manage MetaMask in the

## Installation

The recommend way to use MetaMask React with a React app is to install it as a dependency:
```shell
# If you use npm:
The recommend way to use MetaMask React with a React app is to install it as a dependency.

If you use `npm`:
```console
npm install metamask-react
```

# Or if you use Yarn:
Or if you use `yarn`:
```console
yarn add metamask-react
```

## Example
## Quick Start

The first step is to wrap you `App` or any React subtree with the `MetaMaskProvider`
```javascript
```TypeScript
// index.js
import { MetaMaskProvider } from "metamask-react";

Expand All @@ -33,14 +36,14 @@ ReactDOM.render(
```

In any React child of the provider, one can use the `useMetaMask` hook in order to access the state and methods.
```javascript
```TypeScript
// app.js
import { useMetaMask } from "metamask-react";

...

function App() {
const { status, connect, account } = useMetaMask();
const { status, connect, account, chainId, ethereum } = useMetaMask();

if (status === "initializing") return <div>Synchronisation with MetaMask ongoing...</div>

Expand All @@ -50,7 +53,7 @@ function App() {

if (status === "connecting") return <div>Connecting...</div>

if (status === "connected") return <div>Connected account: {account}</div>
if (status === "connected") return <div>Connected account {account} on chain ID ${chainId}</div>

return null;
}
Expand All @@ -73,3 +76,6 @@ Here is an abstract on the different statuses:
- `connected`: MetaMask is connected to the application
- `connecting`: the connection of your accounts to the application is ongoing
## Type safe hook
Most of the time, the application will use the state when the user is connected, i.e. with status `connected`. Therefore the hook `useConnectedMetaMask` is additionally exposed, it is the same hook as `useMetaMask` but is typed with the connected state, e.g. the `account` or the `chainId` are necessarily not `null`. This hook is only usable when the status is equal to `connected`, it will throw otherwise.
25 changes: 23 additions & 2 deletions src/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ describe("MetaMask provider", () => {
testingUtils.mockChainId("0x1");
});

test("when MetaMask is locked, it should end up in the `notConnected` status", async () => {
jest
.spyOn((ethereum as any)._metamask, "isUnlocked")
.mockReturnValueOnce(false);

const { result, waitForNextUpdate } = renderHook(useMetaMask, {
wrapper: MetaMaskProvider,
});

expect(result.current.status).toEqual("initializing");

await waitForNextUpdate();

expect(result.current.chainId).toEqual("0x1");
expect(result.current.status).toEqual("notConnected");
});

test("when MetaMask is unlocked but no account is connected, it should end up in the `notConnected` status", async () => {
const { result, waitForNextUpdate } = renderHook(useMetaMask, {
wrapper: MetaMaskProvider,
Expand Down Expand Up @@ -115,6 +132,9 @@ describe("MetaMask provider", () => {
const error = {
code: -32002,
};
testingUtils.clearAllMocks();
testingUtils.lowLevel.mockRequest("eth_accounts", []);

testingUtils.lowLevel.mockRequest("eth_requestAccounts", error, {
shouldThrow: true,
});
Expand All @@ -136,7 +156,8 @@ describe("MetaMask provider", () => {
expect(result.current.status).toEqual("connecting");

act(() => {
testingUtils.mockAccounts([address]);
testingUtils.lowLevel.mockRequest("eth_accounts", []);
testingUtils.lowLevel.mockRequest("eth_accounts", [address]);
});

await waitForNextUpdate();
Expand All @@ -146,7 +167,7 @@ describe("MetaMask provider", () => {
});

test("calling `connect` method should end in the `notConnected` status if the request fails", async () => {
const error = new Error("Test Error");
const error = { code: -21 };
testingUtils.lowLevel.mockRequest("eth_requestAccounts", error, {
shouldThrow: true,
});
Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/reducer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { MetaMaskState } from "../metamask-context";
import { reducer } from "../reducer";

describe("state reducer, edge cases", () => {
test('transition from "initializing" to "connecting" is not allowed', () => {
jest.spyOn(console, "warn").mockImplementationOnce(() => {});
const initialState: MetaMaskState = {
account: null,
chainId: null,
status: "initializing",
};
const state = reducer(initialState, { type: "metaMaskConnecting" });
expect(initialState).toEqual(state);
});
test('transition from "unavailable" to "connecting" is not allowed', () => {
jest.spyOn(console, "warn").mockImplementationOnce(() => {});
const initialState: MetaMaskState = {
account: null,
chainId: null,
status: "unavailable",
};
const state = reducer(initialState, { type: "metaMaskConnecting" });
expect(initialState).toEqual(state);
});

test('transition from "initializing" to "notConnected" is not allowed', () => {
jest.spyOn(console, "warn").mockImplementationOnce(() => {});
const initialState: MetaMaskState = {
account: null,
chainId: null,
status: "initializing",
};
const state = reducer(initialState, { type: "metaMaskPermissionRejected" });
expect(initialState).toEqual(state);
});
test('transition from "unavailable" to "notConnected" is not allowed', () => {
jest.spyOn(console, "warn").mockImplementationOnce(() => {});
const initialState: MetaMaskState = {
account: null,
chainId: null,
status: "unavailable",
};
const state = reducer(initialState, { type: "metaMaskPermissionRejected" });
expect(initialState).toEqual(state);
});
test('change of accounts when not in statuts "connected" is not allowed', () => {
jest.spyOn(console, "warn").mockImplementationOnce(() => {});
const initialState: MetaMaskState = {
account: null,
chainId: null,
status: "unavailable",
};
const state = reducer(initialState, {
type: "metaMaskAccountsChanged",
payload: ["0x123"],
});
expect(initialState).toEqual(state);
});
test('change of chain ID when in statuts "initializing" is not allowed', () => {
jest.spyOn(console, "warn").mockImplementationOnce(() => {});
const initialState: MetaMaskState = {
account: null,
chainId: null,
status: "initializing",
};
const state = reducer(initialState, {
type: "metaMaskChainChanged",
payload: "0x1",
});
expect(initialState).toEqual(state);
});
test('change of chain ID when in statuts "unavailable" is not allowed', () => {
jest.spyOn(console, "warn").mockImplementationOnce(() => {});
const initialState: MetaMaskState = {
account: null,
chainId: null,
status: "unavailable",
};
const state = reducer(initialState, {
type: "metaMaskChainChanged",
payload: "0x1",
});
expect(initialState).toEqual(state);
});
});
53 changes: 40 additions & 13 deletions src/metamask-context.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,46 @@
import * as React from "react";
export interface MetaMaskState {
account: null | string;
chainId: null | string;
status:
| "initializing"
| "unavailable"
| "notConnected"
| "connected"
| "connecting";
}

export interface IMetaMaskContext extends MetaMaskState {

type MetaMaskInitializing = {
account: null;
chainId: null;
status: "initializing";
};

type MetaMaskUnavailable = {
account: null;
chainId: null;
status: "unavailable";
};

type MetaMaskNotConnected = {
account: null;
chainId: string;
status: "notConnected";
};

type MetaMaskConnecting = {
account: null;
chainId: string;
status: "connecting";
};

type MetaMaskConnected = {
account: string;
chainId: string;
status: "connected";
};

export type MetaMaskState =
| MetaMaskInitializing
| MetaMaskUnavailable
| MetaMaskNotConnected
| MetaMaskConnecting
| MetaMaskConnected;

export type IMetaMaskContext = MetaMaskState & {
connect: () => Promise<string[] | null>;
ethereum: any;
}
};

export const MetamaskContext = React.createContext<
IMetaMaskContext | undefined
Expand Down
26 changes: 17 additions & 9 deletions src/metamask-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,23 +92,31 @@ function requestAccounts(
const accounts = await ethereum.request({
method: "eth_accounts",
});
if (accounts.length > 0) {
clearInterval(intervalId);
dispatch({ type: "metaMaskConnected", payload: { accounts } });
resolve(accounts);
}
}, 500);
if (accounts.length === 0) return;
clearInterval(intervalId);
const chainId: string = await ethereum.request({
method: "eth_chainId",
});
dispatch({ type: "metaMaskConnected", payload: { accounts, chainId } });
resolve(accounts);
}, 200);
ethereum
.request({
method: "eth_requestAccounts",
})
.then((accounts: string[]) => {
.then(async (accounts: string[]) => {
clearInterval(intervalId);
dispatch({ type: "metaMaskConnected", payload: { accounts } });
const chainId: string = await ethereum.request({
method: "eth_chainId",
});
dispatch({ type: "metaMaskConnected", payload: { accounts, chainId } });
resolve(accounts);
})
.catch((err: unknown) => {
if ((err as ErrorWithCode)?.code === ERROR_CODE_REQUEST_PENDING) return;
if ("code" in (err as { [key: string]: any })) {
if ((err as ErrorWithCode).code === ERROR_CODE_REQUEST_PENDING)
return;
}
dispatch({ type: "metaMaskPermissionRejected" });
clearInterval(intervalId);
reject(err);
Expand Down
Loading

0 comments on commit 0b84846

Please sign in to comment.