Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use react query for fetch hooks fixes #127 #206

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"release:version": "changeset version && yarn install --immutable"
},
"peerDependencies": {
"@tanstack/react-query": "*",
Yuripetusko marked this conversation as resolved.
Show resolved Hide resolved
"@xmtp/frames-validator": "^0.5.0",
"graphql": "^14",
"graphql-request": "^6",
Expand All @@ -27,6 +28,7 @@
"devDependencies": {
"@changesets/changelog-github": "^0.4.8",
"@changesets/cli": "^2.26.2",
"@tanstack/react-query": "^5.24.1",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0",
"@types/jest": "^29.5.12",
Expand Down
29 changes: 26 additions & 3 deletions site/docs/pages/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,48 @@ npm install @coinbase/onchainkit

# Depending on the components or utilities you use,
# you may end up utilizing any of those libraries.
npm install [email protected] react@18 react-dom@18
npm install [email protected] react@18 react-dom@18 @tanstack/[email protected]
Yuripetusko marked this conversation as resolved.
Show resolved Hide resolved
```

```bash [yarn]
yarn add @coinbase/onchainkit

# Depending on the components or utilities you use,
# you may end up utilizing any of those libraries.
yarn add [email protected] react@18 react-dom@18
yarn add [email protected] react@18 react-dom@18 @tanstack/[email protected]
```

```bash [pnpm]
pnpm add @coinbase/onchainkit

# Depending on the components or utilities you use,
# you may end up utilizing any of those libraries.
pnpm install [email protected] react@18 react-dom@18
pnpm install [email protected] react@18 react-dom@18 @tanstack/[email protected]
```

## React hooks
Yuripetusko marked this conversation as resolved.
Show resolved Hide resolved

If you are using any of the provided react hooks, you will need to install and configure `@tanstack/react-query` and wrap your app in `<QueryClientProvider>`.

```tsx
import { Avatar } from '@coinbase/onchainkit/identity';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

// Create a client
const queryClient = new QueryClient();

function App() {
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<Avatar address="0x1234567890abcdef1234567890abcdef12345678" />
</QueryClientProvider>
);
}
```

See [Tanstack Query documentation](https://tanstack.com/query/v5/docs/framework/react/quick-start) for more info.

:::

OnchainKit is divided into various theme utilities and components that are available for your use:
Expand Down
20 changes: 10 additions & 10 deletions src/identity/components/Avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ describe('Avatar Component', () => {
});

it('should display loading indicator when loading', async () => {
(useAvatar as jest.Mock).mockReturnValue({ ensAvatar: null, isLoading: true });
(useName as jest.Mock).mockReturnValue({ ensName: null, isLoading: true });
(useAvatar as jest.Mock).mockReturnValue({ data: null, isLoading: true });
(useName as jest.Mock).mockReturnValue({ data: null, isLoading: true });

render(<Avatar address="0x123" />);

Expand All @@ -35,8 +35,8 @@ describe('Avatar Component', () => {
});

it('should display default avatar when no ENS name or avatar is available', async () => {
(useAvatar as jest.Mock).mockReturnValue({ ensAvatar: null, isLoading: false });
(useName as jest.Mock).mockReturnValue({ ensName: null, isLoading: false });
(useAvatar as jest.Mock).mockReturnValue({ data: null, isLoading: false });
(useName as jest.Mock).mockReturnValue({ data: null, isLoading: false });

render(<Avatar address="0x123" />);

Expand All @@ -47,8 +47,8 @@ describe('Avatar Component', () => {
});

it('should display ENS avatar when available', async () => {
(useAvatar as jest.Mock).mockReturnValue({ ensAvatar: 'avatar_url', isLoading: false });
(useName as jest.Mock).mockReturnValue({ ensName: 'ens_name', isLoading: false });
(useAvatar as jest.Mock).mockReturnValue({ data: 'avatar_url', isLoading: false });
(useName as jest.Mock).mockReturnValue({ data: 'ens_name', isLoading: false });

render(<Avatar address="0x123" className="custom-class" />);

Expand All @@ -61,8 +61,8 @@ describe('Avatar Component', () => {
});

it('renders custom loading component when provided', () => {
(useAvatar as jest.Mock).mockReturnValue({ ensAvatar: null, isLoading: true });
(useName as jest.Mock).mockReturnValue({ ensName: null, isLoading: true });
(useAvatar as jest.Mock).mockReturnValue({ data: null, isLoading: true });
(useName as jest.Mock).mockReturnValue({ data: null, isLoading: true });

const CustomLoadingComponent = <div data-testid="custom-loading">Loading...</div>;

Expand All @@ -74,8 +74,8 @@ describe('Avatar Component', () => {
});

it('renders custom default component when no ENS name or avatar is available', () => {
(useAvatar as jest.Mock).mockReturnValue({ ensAvatar: null, isLoading: false });
(useName as jest.Mock).mockReturnValue({ ensName: null, isLoading: false });
(useAvatar as jest.Mock).mockReturnValue({ data: null, isLoading: false });
(useName as jest.Mock).mockReturnValue({ data: null, isLoading: false });

const CustomDefaultComponent = <div data-testid="custom-default">Default Avatar</div>;

Expand Down
7 changes: 5 additions & 2 deletions src/identity/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ export function Avatar({
defaultComponent,
props,
}: AvatarProps) {
const { ensName, isLoading: isLoadingName } = useName(address);
const { ensAvatar, isLoading: isLoadingAvatar } = useAvatar(ensName as string);
const { data: ensName, isLoading: isLoadingName } = useName({ address });
Yuripetusko marked this conversation as resolved.
Show resolved Hide resolved
const { data: ensAvatar, isLoading: isLoadingAvatar } = useAvatar(
{ ensName: ensName ?? '' },
{ enabled: !!ensName },
);

if (isLoadingName || isLoadingAvatar) {
return (
Expand Down
8 changes: 4 additions & 4 deletions src/identity/components/Name.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('OnchainAddress', () => {
});

it('displays ENS name when available', () => {
(useName as jest.Mock).mockReturnValue({ ensName: testName, isLoading: false });
(useName as jest.Mock).mockReturnValue({ data: testName, isLoading: false });

render(<Name address={testAddress} />);

Expand All @@ -38,15 +38,15 @@ describe('OnchainAddress', () => {
});

it('displays sliced address when ENS name is not available and sliced is true as default', () => {
(useName as jest.Mock).mockReturnValue({ ensName: null, isLoading: false });
(useName as jest.Mock).mockReturnValue({ data: null, isLoading: false });

render(<Name address={testAddress} />);

expect(screen.getByText(mockSliceAddress(testAddress))).toBeInTheDocument();
});

it('displays empty when ens still fetching', () => {
(useName as jest.Mock).mockReturnValue({ ensName: null, isLoading: true });
(useName as jest.Mock).mockReturnValue({ data: null, isLoading: true });

render(<Name address={testAddress} />);

Expand All @@ -55,7 +55,7 @@ describe('OnchainAddress', () => {
});

it('displays full address when ENS name is not available and sliced is false', () => {
(useName as jest.Mock).mockReturnValue({ ensName: null, isLoading: false });
(useName as jest.Mock).mockReturnValue({ data: null, isLoading: false });

render(<Name address={testAddress} sliced={false} />);

Expand Down
2 changes: 1 addition & 1 deletion src/identity/components/Name.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type NameProps = {
* @param {React.HTMLAttributes<HTMLSpanElement>} [props] - Additional HTML attributes for the span element.
*/
export function Name({ address, className, sliced = true, props }: NameProps) {
const { ensName, isLoading } = useName(address);
const { data: ensName, isLoading } = useName({ address });

// wrapped in useMemo to prevent unnecessary recalculations.
const normalizedAddress = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,63 +3,61 @@
*/

import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { publicClient } from '../../network/client';
import { useAvatar, ensAvatarAction } from './useAvatar';
import { useOnchainActionWithCache } from '../../utils/hooks/useOnchainActionWithCache';

jest.mock('../../network/client');
jest.mock('../../utils/hooks/useOnchainActionWithCache');

describe('useAvatar', () => {
const mockGetEnsAvatar = publicClient.getEnsAvatar as jest.Mock;
const mockUseOnchainActionWithCache = useOnchainActionWithCache as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
});

it('returns the correct ENS avatar and loading state', async () => {
const queryClient = new QueryClient();
function ReactQueryTestProvider({ children }: { children: React.ReactNode }) {
Yuripetusko marked this conversation as resolved.
Show resolved Hide resolved
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

const testEnsName = 'test.ens';
const testEnsAvatar = 'avatarUrl';

// Mock the getEnsAvatar method of the publicClient
mockGetEnsAvatar.mockResolvedValue(testEnsAvatar);
mockUseOnchainActionWithCache.mockImplementation(() => {
return {
data: testEnsAvatar,
isLoading: false,
};
});

// Use the renderHook function to create a test harness for the useAvatar hook
const { result } = renderHook(() => useAvatar(testEnsName));
const { result } = renderHook(() => useAvatar({ ensName: testEnsName }), {
wrapper: ReactQueryTestProvider,
});

// Wait for the hook to finish fetching the ENS avatar
await waitFor(() => {
// Check that the ENS avatar and loading state are correct
expect(result.current.ensAvatar).toBe(testEnsAvatar);
expect(result.current.data).toBe(testEnsAvatar);
expect(result.current.isLoading).toBe(false);
});
});

it('returns the loading state true while still fetching ENS avatar', async () => {
const testEnsName = 'test.ens';
const queryClient = new QueryClient();
function ReactQueryTestProvider({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

// Mock the getEnsAvatar method of the publicClient
mockUseOnchainActionWithCache.mockImplementation(() => {
return {
data: undefined,
isLoading: true,
};
});
const testEnsName = 'test.ens';

// Use the renderHook function to create a test harness for the useAvatar hook
const { result } = renderHook(() => useAvatar(testEnsName));
const { result } = renderHook(() => useAvatar({ ensName: testEnsName }), {
wrapper: ReactQueryTestProvider,
});

// Wait for the hook to finish fetching the ENS avatar
await waitFor(() => {
// Check that the loading state is correct
expect(result.current.ensAvatar).toBe(undefined);
expect(result.current.data).toBe(undefined);
expect(result.current.isLoading).toBe(true);
});
});
Expand All @@ -71,8 +69,7 @@ describe('useAvatar', () => {

mockGetEnsAvatar.mockResolvedValue(expectedAvatarUrl);

const action = ensAvatarAction(ensName);
const avatarUrl = await action();
const avatarUrl = await ensAvatarAction(ensName);

expect(avatarUrl).toBe(expectedAvatarUrl);
expect(mockGetEnsAvatar).toHaveBeenCalledWith({ name: ensName });
Expand All @@ -83,10 +80,7 @@ describe('useAvatar', () => {

mockGetEnsAvatar.mockRejectedValue(new Error('This is an error'));

const action = ensAvatarAction(ensName);
const avatarUrl = await action();

expect(avatarUrl).toBe(null);
await expect(ensAvatarAction(ensName)).rejects.toThrow('This is an error');
});
});
});
42 changes: 26 additions & 16 deletions src/identity/hooks/useAvatar.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import { publicClient } from '../../network/client';
import { useOnchainActionWithCache } from '../../utils/hooks/useOnchainActionWithCache';
import { GetEnsAvatarReturnType, normalize } from 'viem/ens';
import { type GetEnsAvatarReturnType, normalize } from 'viem/ens';
import { useQuery } from '@tanstack/react-query';

export const ensAvatarAction = (ensName: string) => async (): Promise<GetEnsAvatarReturnType> => {
try {
return await publicClient.getEnsAvatar({
name: normalize(ensName),
});
} catch (err) {
return null;
}
export const ensAvatarAction = async (ensName: string): Promise<GetEnsAvatarReturnType> => {
return await publicClient.getEnsAvatar({
name: normalize(ensName),
});
};

export const useAvatar = (ensName: string) => {
type Arguments = {
Yuripetusko marked this conversation as resolved.
Show resolved Hide resolved
ensName: string;
};

type QueryOptions = {
enabled?: boolean;
cacheTime?: number;
};

export const useAvatar = ({ ensName }: Arguments, queryOptions?: QueryOptions) => {
const { enabled = true, cacheTime } = queryOptions ?? {};
const ensActionKey = `ens-avatar-${ensName}` ?? '';
const { data: ensAvatar, isLoading } = useOnchainActionWithCache(
ensAvatarAction(ensName),
ensActionKey,
);
return { ensAvatar, isLoading };
return useQuery<GetEnsAvatarReturnType>({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the type set, that's super neat.

queryKey: ['useAvatar', ensActionKey],
queryFn: async () => {
return await ensAvatarAction(ensName);
},
gcTime: cacheTime,
enabled,
refetchOnWindowFocus: false,
});
};
Loading
Loading