Skip to content

Commit

Permalink
Move client code into useClient hook, add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
rygine committed Aug 16, 2023
1 parent 796d8f5 commit cd8f87c
Show file tree
Hide file tree
Showing 4 changed files with 323 additions and 15 deletions.
2 changes: 1 addition & 1 deletion examples/react-quickstart/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const App = () => {

// disconnect XMTP client when the wallet changes
useEffect(() => {
disconnect();
void disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signer]);

Expand Down
32 changes: 29 additions & 3 deletions packages/react-sdk/src/contexts/XMTPContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, useMemo } from "react";
import type { ContentCodec } from "@xmtp/xmtp-js";
import { createContext, useMemo, useState } from "react";
import type { Client, ContentCodec, Signer } from "@xmtp/xmtp-js";
import Dexie from "dexie";
import type {
CacheConfiguration,
Expand All @@ -11,6 +11,10 @@ import { combineMessageProcessors } from "@/helpers/combineMessageProcessors";
import { combineCodecs } from "@/helpers/combineCodecs";

export type XMTPContextValue = {
/**
* The XMTP client instance
*/
client?: Client;
/**
* Content codecs used by the XMTP client
*/
Expand All @@ -27,6 +31,12 @@ export type XMTPContextValue = {
* Message processors for caching
*/
processors: CachedMessageProcessors;
setClient: React.Dispatch<React.SetStateAction<Client | undefined>>;
setClientSigner: React.Dispatch<React.SetStateAction<Signer | undefined>>;
/**
* The signer (wallet) to associate with the XMTP client
*/
signer?: Signer | null;
};

const initialDb = new Dexie("__XMTP__");
Expand All @@ -36,9 +46,15 @@ export const XMTPContext = createContext<XMTPContextValue>({
db: initialDb,
namespaces: {},
processors: {},
setClient: () => {},
setClientSigner: () => {},
});

export type XMTPProviderProps = React.PropsWithChildren & {
/**
* Initial XMTP client instance
*/
client?: Client;
/**
* An array of cache configurations to support the caching of messages
*/
Expand All @@ -54,9 +70,15 @@ export type XMTPProviderProps = React.PropsWithChildren & {

export const XMTPProvider: React.FC<XMTPProviderProps> = ({
children,
client: initialClient,
cacheConfig,
dbVersion,
}) => {
const [client, setClient] = useState<Client | undefined>(initialClient);
const [clientSigner, setClientSigner] = useState<Signer | undefined>(
undefined,
);

// combine all processors into a single object
const processors = useMemo(
() => combineMessageProcessors(cacheConfig ?? []),
Expand Down Expand Up @@ -86,12 +108,16 @@ export const XMTPProvider: React.FC<XMTPProviderProps> = ({
// memo-ize the context value to prevent unnecessary re-renders
const value = useMemo(
() => ({
client,
codecs,
db,
namespaces,
processors,
setClient,
setClientSigner,
signer: clientSigner,
}),
[codecs, db, namespaces, processors],
[client, clientSigner, codecs, db, namespaces, processors],
);

return <XMTPContext.Provider value={value}>{children}</XMTPContext.Provider>;
Expand Down
156 changes: 156 additions & 0 deletions packages/react-sdk/src/hooks/useClient.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { it, expect, describe, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { Client } from "@xmtp/xmtp-js";
import { Wallet } from "ethers";
import type { PropsWithChildren } from "react";
import { useClient } from "@/hooks/useClient";
import { XMTPProvider } from "@/contexts/XMTPContext";

const processUnprocessedMessagesMock = vi.hoisted(() => vi.fn());

const TestWrapper: React.FC<PropsWithChildren & { client?: Client }> = ({
children,
client,
}) => <XMTPProvider client={client}>{children}</XMTPProvider>;

vi.mock("@/helpers/caching/messages", async () => {
const actual = await import("@/helpers/caching/messages");
return {
...actual,
processUnprocessedMessages: processUnprocessedMessagesMock,
};
});

describe("useClient", () => {
beforeEach(() => {
processUnprocessedMessagesMock.mockReset();
});

it("should disconnect an active client", async () => {
const disconnectClientMock = vi.fn();
const mockClient = {
close: disconnectClientMock,
};
const { result } = renderHook(() => useClient(), {
wrapper: ({ children }) => (
<TestWrapper client={mockClient as unknown as Client}>
{children}
</TestWrapper>
),
});

expect(result.current.client).toBeDefined();

await act(async () => {
await result.current.disconnect();
});

expect(disconnectClientMock).toHaveBeenCalledTimes(1);
expect(result.current.client).toBeUndefined();
});

it("should not initialize a client if one is already active", async () => {
const mockClient = {
address: "testWalletAddress",
};
const clientCreateSpy = vi.spyOn(Client, "create");
const testWallet = Wallet.createRandom();

const { result } = renderHook(() => useClient(), {
wrapper: ({ children }) => (
<TestWrapper client={mockClient as unknown as Client}>
{children}
</TestWrapper>
),
});

await act(async () => {
await result.current.initialize({ signer: testWallet });
});

expect(clientCreateSpy).not.toHaveBeenCalled();

await waitFor(() => {
expect(processUnprocessedMessagesMock).toBeCalledTimes(1);
});
});

it("should initialize a client if one is not active", async () => {
const testWallet = Wallet.createRandom();
const mockClient = {
address: "testWalletAddress",
} as unknown as Client;
const clientCreateSpy = vi
.spyOn(Client, "create")
.mockResolvedValue(mockClient);

const { result } = renderHook(() => useClient(), {
wrapper: ({ children }) => <TestWrapper>{children}</TestWrapper>,
});

await act(async () => {
await result.current.initialize({ signer: testWallet });
});

expect(clientCreateSpy).toHaveBeenCalledWith(testWallet, {
codecs: [],
privateKeyOverride: undefined,
});
expect(result.current.client).toBe(mockClient);
expect(result.current.signer).toBe(testWallet);

await waitFor(() => {
expect(processUnprocessedMessagesMock).toHaveBeenCalledTimes(1);
});
});

it("should throw an error if client initialization fails", async () => {
const testWallet = Wallet.createRandom();
const testError = new Error("testError");
vi.spyOn(Client, "create").mockRejectedValue(testError);
const onErrorMock = vi.fn();

const { result } = renderHook(() => useClient(onErrorMock));

await act(async () => {
await expect(
result.current.initialize({ signer: testWallet }),
).rejects.toThrow(testError);
});

expect(onErrorMock).toBeCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledWith(testError);
expect(result.current.client).toBeUndefined();
expect(result.current.signer).toBeUndefined();
expect(result.current.error).toEqual(testError);
});

it("should should call the onError callback if processing unprocessed messages fails", async () => {
const testWallet = Wallet.createRandom();
const testError = new Error("testError");
const mockClient = {
address: "testWalletAddress",
} as unknown as Client;
const onErrorMock = vi.fn();
vi.spyOn(Client, "create").mockResolvedValue(mockClient);
processUnprocessedMessagesMock.mockRejectedValue(testError);

const { result } = renderHook(() => useClient(onErrorMock), {
wrapper: ({ children }) => (
<TestWrapper client={mockClient as unknown as Client}>
{children}
</TestWrapper>
),
});

await act(async () => {
await result.current.initialize({ signer: testWallet });
});

await waitFor(() => {
expect(onErrorMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledWith(testError);
expect(result.current.error).toBe(null);
});
});
});
Loading

0 comments on commit cd8f87c

Please sign in to comment.