From 4090f4f3154b2fc9525b059f55bb1b2899b53238 Mon Sep 17 00:00:00 2001 From: Yuri Date: Wed, 6 Mar 2024 08:03:25 +0100 Subject: [PATCH] feat: use react query for fetch hooks fixes #127 (#206) Co-authored-by: Leonardo Zizzamia --- .changeset/gold-impalas-retire.md | 59 +++++++++++++++++ package.json | 2 + site/docs/pages/identity/introduction.mdx | 27 ++++++++ site/docs/pages/identity/use-avatar.mdx | 35 ++++++++++ site/docs/pages/identity/use-name.mdx | 35 ++++++++++ site/docs/pages/index.mdx | 4 +- site/sidebar.ts | 13 ++++ src/identity/components/Avatar.test.tsx | 20 +++--- src/identity/components/Avatar.tsx | 13 ++-- src/identity/components/Name.test.tsx | 8 +-- src/identity/components/Name.tsx | 6 +- .../{useAvatar.test.ts => useAvatar.test.tsx} | 38 ++++------- src/identity/hooks/useAvatar.ts | 42 +++++++----- .../{useName.test.ts => useName.test.tsx} | 42 ++++-------- src/identity/hooks/useName.ts | 51 +++++++++------ .../get-new-react-query-test-provider.tsx | 14 ++++ src/utils/hooks/types.ts | 3 - .../hooks/useOnchainActionWithCache.test.ts | 65 ------------------- src/utils/hooks/useOnchainActionWithCache.ts | 52 --------------- yarn.lock | 20 ++++++ 20 files changed, 313 insertions(+), 236 deletions(-) create mode 100644 .changeset/gold-impalas-retire.md create mode 100644 site/docs/pages/identity/use-avatar.mdx create mode 100644 site/docs/pages/identity/use-name.mdx rename src/identity/hooks/{useAvatar.test.ts => useAvatar.test.tsx} (66%) rename src/identity/hooks/{useName.test.ts => useName.test.tsx} (66%) create mode 100644 src/test-utils/hooks/get-new-react-query-test-provider.tsx delete mode 100644 src/utils/hooks/types.ts delete mode 100644 src/utils/hooks/useOnchainActionWithCache.test.ts delete mode 100644 src/utils/hooks/useOnchainActionWithCache.ts diff --git a/.changeset/gold-impalas-retire.md b/.changeset/gold-impalas-retire.md new file mode 100644 index 0000000000..59db2d72dc --- /dev/null +++ b/.changeset/gold-impalas-retire.md @@ -0,0 +1,59 @@ +--- +'@coinbase/onchainkit': minor +--- + +**feat**: Replace internal `useOnchainActionWithCache` with `tanstack/react-query`. This affects `useName` and `useAvatar` hooks. The return type and the input parameters also changed for these 2 hooks. + +BREAKING CHANGES + +The input parameters as well as return types of `useName` and `useAvatar` hooks have changed. The return type of `useName` and `useAvatar` hooks changed. + +### `useName` + +Before + +```tsx +import { useName } from '@coinbase/onchainkit/identity'; + +const { ensName, isLoading } = useName('0x1234'); +``` + +After + +```tsx +import { useName } from '@coinbase/onchainkit/identity'; + +// Return type signature is following @tanstack/react-query useQuery hook signature +const { + data: name, + isLoading, + isError, + error, + status, +} = useName({ address: '0x1234' }, { enabled: true, cacheTime: 1000 * 60 * 60 * 24 }); +``` + +### `useAvatar` + +Before + +```tsx +import { useAvatar } from '@coinbase/onchainkit/identity'; + +const { ensAvatar, isLoading } = useAvatar('vitalik.eth'); +``` + +After + +```tsx +import { useAvatar } from '@coinbase/onchainkit/identity'; + +// Return type signature is following @tanstack/react-query useQuery hook signature +const { + data: avatar, + isLoading, + isError, + error, + status, +} = useAvatar({ ensName: 'vitalik.eth' }, { enabled: true, cacheTime: 1000 * 60 * 60 * 24 }); +``` diff --git a/package.json b/package.json index 79ddae3c90..be1886074b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "release:version": "changeset version && yarn install --immutable" }, "peerDependencies": { + "@tanstack/react-query": "^5", "@xmtp/frames-validator": "^0.5.0", "graphql": "^14", "graphql-request": "^6", @@ -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", diff --git a/site/docs/pages/identity/introduction.mdx b/site/docs/pages/identity/introduction.mdx index ea30e8a5f5..f515c3cb73 100644 --- a/site/docs/pages/identity/introduction.mdx +++ b/site/docs/pages/identity/introduction.mdx @@ -10,6 +10,9 @@ OnchainKit provides TypeScript utilities and React components to help you build - Components: - [``](/identity/avatar): A component to display an ENS avatar. - [``](/identity/name): A component to display an ENS name. +- Hooks: + - [`useName`](/identity/use-name): A hook to get an onchain name for a given address. (ENS only for now) + - [`useAvatar`](/identity/use-avatar): A hook to get avatar image src. (ENS only for now) - Utilities: - [`getEASAttestations`](/identity/get-eas-attestations): A function to fetche EAS attestations. @@ -30,3 +33,27 @@ pnpm add @coinbase/onchainkit react@18 react-dom@18 graphql@14 graphql-request@6 ``` ::: + + +## Components + +If you are using any of the provided components, you will need to install and configure `@tanstack/react-query` and wrap your app in ``. + +```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 + + + + ); +} +``` + +See [Tanstack Query documentation](https://tanstack.com/query/v5/docs/framework/react/quick-start) for more info. diff --git a/site/docs/pages/identity/use-avatar.mdx b/site/docs/pages/identity/use-avatar.mdx new file mode 100644 index 0000000000..846882e08b --- /dev/null +++ b/site/docs/pages/identity/use-avatar.mdx @@ -0,0 +1,35 @@ +# `useAvatar` + +The `useAvatar` hook is used to get avatar image url from an onchain identity provider for a given name. + +Supported providers: + +- ENS + +## Usage + +```tsx +import { useAvatar } from '@coinbase/onchainkit/identity'; + +// Return type signature is following @tanstack/react-query useQuery hook signature +const { + data: avatar, + isLoading, + isError, + error, + status, +} = useAvatar({ ensName: 'vitalik.eth' }, { enabled: true, cacheTime: 1000 * 60 * 60 * 24 }); +``` + +## Props + +```ts +type UseAvatarOptions = { + ensName: string; +}; + +type UseAvatarQueryOptions = { + enabled?: boolean; + cacheTime?: number; +}; +``` diff --git a/site/docs/pages/identity/use-name.mdx b/site/docs/pages/identity/use-name.mdx new file mode 100644 index 0000000000..c9267a6568 --- /dev/null +++ b/site/docs/pages/identity/use-name.mdx @@ -0,0 +1,35 @@ +# `useName` + +The `useName` hook is used to get name from an onchain identity provider for a given address. + +Supported providers: + +- ENS + +## Usage + +```tsx +import { useName } from '@coinbase/onchainkit/identity'; + +// Return type signature is following @tanstack/react-query useQuery hook signature +const { + data: name, + isLoading, + isError, + error, + status, +} = useName({ address: '0x1234' }, { enabled: true, cacheTime: 1000 * 60 * 60 * 24 }); +``` + +## Props + +```ts +type UseNameOptions = { + address: `0x${string}`; +}; + +type UseNameQueryOptions = { + enabled?: boolean; + cacheTime?: number; +}; +``` diff --git a/site/docs/pages/index.mdx b/site/docs/pages/index.mdx index 8e71a4d410..1ba87d6507 100644 --- a/site/docs/pages/index.mdx +++ b/site/docs/pages/index.mdx @@ -15,8 +15,8 @@ import { HomePage } from 'vocs/components';

OnchainKit

- React components - and TypeScript utilities + React components + and TypeScript utilities for top-tier onchain apps.
diff --git a/site/sidebar.ts b/site/sidebar.ts index fcae0f1e19..513fef8c45 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -84,6 +84,19 @@ export const sidebar = [ }, ], }, + { + text: 'React Hooks', + items: [ + { + text: 'useName', + link: '/identity/use-name', + }, + { + text: 'useAvatar', + link: '/identity/use-avatar', + }, + ], + }, { text: 'Utilities', items: [ diff --git a/src/identity/components/Avatar.test.tsx b/src/identity/components/Avatar.test.tsx index d289a5fb78..14c6f53e3c 100644 --- a/src/identity/components/Avatar.test.tsx +++ b/src/identity/components/Avatar.test.tsx @@ -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(); @@ -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(); @@ -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(); @@ -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 =
Loading...
; @@ -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 =
Default Avatar
; diff --git a/src/identity/components/Avatar.tsx b/src/identity/components/Avatar.tsx index 1cf626efb1..7025fdc80f 100644 --- a/src/identity/components/Avatar.tsx +++ b/src/identity/components/Avatar.tsx @@ -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: name, isLoading: isLoadingName } = useName({ address }); + const { data: avatar, isLoading: isLoadingAvatar } = useAvatar( + { ensName: name ?? '' }, + { enabled: !!name }, + ); if (isLoadingName || isLoadingAvatar) { return ( @@ -66,7 +69,7 @@ export function Avatar({ ); } - if (!ensName || !ensAvatar) { + if (!name || !avatar) { return ( defaultComponent || ( ); diff --git a/src/identity/components/Name.test.tsx b/src/identity/components/Name.test.tsx index 835a6e975d..aeb3b1fc68 100644 --- a/src/identity/components/Name.test.tsx +++ b/src/identity/components/Name.test.tsx @@ -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(); @@ -38,7 +38,7 @@ 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(); @@ -46,7 +46,7 @@ describe('OnchainAddress', () => { }); 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(); @@ -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(); diff --git a/src/identity/components/Name.tsx b/src/identity/components/Name.tsx index 2140fafb1b..682ce1fb0f 100644 --- a/src/identity/components/Name.tsx +++ b/src/identity/components/Name.tsx @@ -21,11 +21,11 @@ type NameProps = { * @param {React.HTMLAttributes} [props] - Additional HTML attributes for the span element. */ export function Name({ address, className, sliced = true, props }: NameProps) { - const { ensName, isLoading } = useName(address); + const { data: name, isLoading } = useName({ address }); // wrapped in useMemo to prevent unnecessary recalculations. const normalizedAddress = useMemo(() => { - if (!ensName && !isLoading && sliced) { + if (!name && !isLoading && sliced) { return getSlicedAddress(address); } return address; @@ -37,7 +37,7 @@ export function Name({ address, className, sliced = true, props }: NameProps) { return ( - {ensName ?? normalizedAddress} + {name ?? normalizedAddress} ); } diff --git a/src/identity/hooks/useAvatar.test.ts b/src/identity/hooks/useAvatar.test.tsx similarity index 66% rename from src/identity/hooks/useAvatar.test.ts rename to src/identity/hooks/useAvatar.test.tsx index d8e8883de3..a039a63169 100644 --- a/src/identity/hooks/useAvatar.test.ts +++ b/src/identity/hooks/useAvatar.test.tsx @@ -5,14 +5,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import { publicClient } from '../../network/client'; import { useAvatar, ensAvatarAction } from './useAvatar'; -import { useOnchainActionWithCache } from '../../utils/hooks/useOnchainActionWithCache'; +import { getNewReactQueryTestProvider } from '../../test-utils/hooks/get-new-react-query-test-provider'; 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(); @@ -24,20 +22,16 @@ describe('useAvatar', () => { // 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: getNewReactQueryTestProvider(), + }); // 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); }); }); @@ -45,21 +39,15 @@ describe('useAvatar', () => { it('returns the loading state true while still fetching ENS avatar', async () => { const testEnsName = 'test.ens'; - // Mock the getEnsAvatar method of the publicClient - mockUseOnchainActionWithCache.mockImplementation(() => { - return { - data: undefined, - isLoading: true, - }; - }); - // 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: getNewReactQueryTestProvider(), + }); // 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); }); }); @@ -71,8 +59,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 }); @@ -83,10 +70,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'); }); }); }); diff --git a/src/identity/hooks/useAvatar.ts b/src/identity/hooks/useAvatar.ts index e90c64a538..0e706d6bba 100644 --- a/src/identity/hooks/useAvatar.ts +++ b/src/identity/hooks/useAvatar.ts @@ -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 => { - try { - return await publicClient.getEnsAvatar({ - name: normalize(ensName), - }); - } catch (err) { - return null; - } +export const ensAvatarAction = async (ensName: string): Promise => { + return await publicClient.getEnsAvatar({ + name: normalize(ensName), + }); }; -export const useAvatar = (ensName: string) => { +type UseNameOptions = { + ensName: string; +}; + +type UseNameQueryOptions = { + enabled?: boolean; + cacheTime?: number; +}; + +export const useAvatar = ({ ensName }: UseNameOptions, queryOptions?: UseNameQueryOptions) => { + const { enabled = true, cacheTime } = queryOptions ?? {}; const ensActionKey = `ens-avatar-${ensName}` ?? ''; - const { data: ensAvatar, isLoading } = useOnchainActionWithCache( - ensAvatarAction(ensName), - ensActionKey, - ); - return { ensAvatar, isLoading }; + return useQuery({ + queryKey: ['useAvatar', ensActionKey], + queryFn: async () => { + return await ensAvatarAction(ensName); + }, + gcTime: cacheTime, + enabled, + refetchOnWindowFocus: false, + }); }; diff --git a/src/identity/hooks/useName.test.ts b/src/identity/hooks/useName.test.tsx similarity index 66% rename from src/identity/hooks/useName.test.ts rename to src/identity/hooks/useName.test.tsx index 980b7f5d16..46b2d28e26 100644 --- a/src/identity/hooks/useName.test.ts +++ b/src/identity/hooks/useName.test.tsx @@ -5,14 +5,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import { publicClient } from '../../network/client'; import { useName, ensNameAction } from './useName'; -import { useOnchainActionWithCache } from '../../utils/hooks/useOnchainActionWithCache'; +import { getNewReactQueryTestProvider } from '../../test-utils/hooks/get-new-react-query-test-provider'; jest.mock('../../network/client'); -jest.mock('../../utils/hooks/useOnchainActionWithCache'); describe('useName', () => { const mockGetEnsName = publicClient.getEnsName as jest.Mock; - const mockUseOnchainActionWithCache = useOnchainActionWithCache as jest.Mock; beforeEach(() => { jest.clearAllMocks(); @@ -24,20 +22,16 @@ describe('useName', () => { // Mock the getEnsName method of the publicClient mockGetEnsName.mockResolvedValue(testEnsName); - mockUseOnchainActionWithCache.mockImplementation(() => { - return { - data: testEnsName, - isLoading: false, - }; - }); // Use the renderHook function to create a test harness for the useName hook - const { result } = renderHook(() => useName(testAddress)); + const { result } = renderHook(() => useName({ address: testAddress }), { + wrapper: getNewReactQueryTestProvider(), + }); // Wait for the hook to finish fetching the ENS name await waitFor(() => { // Check that the ENS name and loading state are correct - expect(result.current.ensName).toBe(testEnsName); + expect(result.current.data).toBe(testEnsName); expect(result.current.isLoading).toBe(false); }); }); @@ -45,22 +39,15 @@ describe('useName', () => { it('returns the loading state true while still fetching from ens action', async () => { const testAddress = '0x123'; - // Mock the getEnsName method of the publicClient - // mockGetEnsName.mockResolvedValue(testEnsName); - mockUseOnchainActionWithCache.mockImplementation(() => { - return { - data: undefined, - isLoading: true, - }; - }); - // Use the renderHook function to create a test harness for the useName hook - const { result } = renderHook(() => useName(testAddress)); + const { result } = renderHook(() => useName({ address: testAddress }), { + wrapper: getNewReactQueryTestProvider(), + }); // Wait for the hook to finish fetching the ENS name await waitFor(() => { // Check that the ENS name and loading state are correct - expect(result.current.ensName).toBe(undefined); + expect(result.current.data).toBe(undefined); expect(result.current.isLoading).toBe(true); }); }); @@ -72,8 +59,7 @@ describe('useName', () => { mockGetEnsName.mockResolvedValue(expectedEnsName); - const action = ensNameAction(walletAddress); - const name = await action(); + const name = await ensNameAction(walletAddress); expect(name).toBe(expectedEnsName); expect(mockGetEnsName).toHaveBeenCalledWith({ address: walletAddress }); @@ -85,8 +71,7 @@ describe('useName', () => { mockGetEnsName.mockResolvedValue(expectedEnsName); - const action = ensNameAction(walletAddress); - const name = await action(); + const name = await ensNameAction(walletAddress); expect(name).toBe(expectedEnsName); expect(mockGetEnsName).toHaveBeenCalledWith({ address: walletAddress }); @@ -97,10 +82,7 @@ describe('useName', () => { mockGetEnsName.mockRejectedValue(new Error('This is an error')); - const action = ensNameAction(walletAddress); - const name = await action(); - - expect(name).toBe(null); + await expect(ensNameAction(walletAddress)).rejects.toThrow('This is an error'); }); }); }); diff --git a/src/identity/hooks/useName.ts b/src/identity/hooks/useName.ts index 9981f31e9c..f33f043ab1 100644 --- a/src/identity/hooks/useName.ts +++ b/src/identity/hooks/useName.ts @@ -1,6 +1,6 @@ import { publicClient } from '../../network/client'; -import { useOnchainActionWithCache } from '../../utils/hooks/useOnchainActionWithCache'; import type { Address, GetEnsNameReturnType } from 'viem'; +import { useQuery } from '@tanstack/react-query'; /** * An asynchronous function to fetch the Ethereum Name Service (ENS) name for a given Ethereum address. @@ -9,29 +9,42 @@ import type { Address, GetEnsNameReturnType } from 'viem'; * @param address - The Ethereum address for which the ENS name is being fetched. * @returns A promise that resolves to the ENS name (as a string) or null. */ -export const ensNameAction = (address: Address) => async (): Promise => { - try { - return await publicClient.getEnsName({ - address, - }); - } catch (err) { - return null; - } +export const ensNameAction = async (address: Address): Promise => { + return await publicClient.getEnsName({ + address, + }); +}; + +type UseNameOptions = { + address: Address; +}; + +type UseNameQueryOptions = { + enabled?: boolean; + cacheTime?: number; }; /** - * It leverages the `useOnchainActionWithCache` hook for fetching and optionally caching the ENS name, - * handling loading state internally. - * @param address - The Ethereum address for which the ENS name is to be fetched. + * It leverages the `@tanstack/react-query` hook for fetching and optionally caching the ENS name + * @param {UseNameOptions} arguments + * @param {Address} arguments.address - The Ethereum address for which the ENS name is to be fetched. + * @param {UseNameQueryOptions} queryOptions - Additional query options, including `enabled` and `cacheTime` + * @param {boolean} queryOptions.enabled - Whether the query should be enabled. Defaults to true. + * @param {number} queryOptions.cacheTime - Cache time in milliseconds. * @returns An object containing: * - `ensName`: The fetched ENS name for the provided address, or null if not found or in case of an error. - * - `isLoading`: A boolean indicating whether the ENS name is currently being fetched. + * - `{UseQueryResult}`: The rest of useQuery return values. including isLoading, isError, error, isFetching, refetch, etc. */ -export const useName = (address: Address) => { +export const useName = ({ address }: UseNameOptions, queryOptions?: UseNameQueryOptions) => { + const { enabled = true, cacheTime } = queryOptions ?? {}; const ensActionKey = `ens-name-${address}`; - const { data: ensName, isLoading } = useOnchainActionWithCache( - ensNameAction(address), - ensActionKey, - ); - return { ensName, isLoading }; + return useQuery({ + queryKey: ['useName', ensActionKey], + queryFn: async () => { + return await ensNameAction(address); + }, + gcTime: cacheTime, + enabled, + refetchOnWindowFocus: false, + }); }; diff --git a/src/test-utils/hooks/get-new-react-query-test-provider.tsx b/src/test-utils/hooks/get-new-react-query-test-provider.tsx new file mode 100644 index 0000000000..159225f1d2 --- /dev/null +++ b/src/test-utils/hooks/get-new-react-query-test-provider.tsx @@ -0,0 +1,14 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +/** + * This is a unit testing helper function to create a new React Query Test Provider. + * It is important to get a new instance each time to prevent cached results from the previous test + */ +export const getNewReactQueryTestProvider = () => { + const queryClient = new QueryClient(); + function ReactQueryTestProvider({ children }: { children: React.ReactNode }) { + return {children}; + } + + return ReactQueryTestProvider; +}; diff --git a/src/utils/hooks/types.ts b/src/utils/hooks/types.ts deleted file mode 100644 index 06d7de8aae..0000000000 --- a/src/utils/hooks/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type ActionFunction = () => Promise; - -export type ActionKey = string; diff --git a/src/utils/hooks/useOnchainActionWithCache.test.ts b/src/utils/hooks/useOnchainActionWithCache.test.ts deleted file mode 100644 index c58180d24c..0000000000 --- a/src/utils/hooks/useOnchainActionWithCache.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import { renderHook, waitFor } from '@testing-library/react'; -import { useOnchainActionWithCache } from './useOnchainActionWithCache'; -import { InMemoryStorage } from '../store/inMemoryStorageService'; - -jest.mock('../store/inMemoryStorageService', () => ({ - InMemoryStorage: { - getData: jest.fn(), - setData: jest.fn(), - }, -})); - -describe('useOnchainActionWithCache', () => { - const mockAction = jest.fn(); - const actionKey = 'testKey'; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('initializes with loading state and undefined data', () => { - const { result } = renderHook(() => useOnchainActionWithCache(mockAction, actionKey)); - - expect(result.current.isLoading).toBe(true); - expect(result.current.data).toBeUndefined(); - }); - - it('fetches data and updates state', async () => { - const testData = 'testData'; - mockAction.mockResolvedValue(testData); - - const { result } = renderHook(() => useOnchainActionWithCache(mockAction, actionKey)); - - await waitFor(() => { - expect(mockAction).toHaveBeenCalled(); - expect(result.current.data).toBe(testData); - expect(result.current.isLoading).toBe(false); - }); - }); - - it('caches data when an actionKey is provided', async () => { - const testData = 'testData'; - mockAction.mockResolvedValue(testData); - - renderHook(() => useOnchainActionWithCache(mockAction, actionKey)); - - await waitFor(() => { - expect(InMemoryStorage.setData).toHaveBeenCalledWith(actionKey, testData); - }); - }); - - it('does not cache data when actionKey is empty', async () => { - const testData = 'testData'; - mockAction.mockResolvedValue(testData); - - renderHook(() => useOnchainActionWithCache(mockAction, '')); - - await waitFor(() => { - expect(InMemoryStorage.setData).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/utils/hooks/useOnchainActionWithCache.ts b/src/utils/hooks/useOnchainActionWithCache.ts deleted file mode 100644 index 0ceaa9dfdc..0000000000 --- a/src/utils/hooks/useOnchainActionWithCache.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useState } from 'react'; -import { InMemoryStorage } from '../store/inMemoryStorageService'; -import type { ActionFunction, ActionKey } from './types'; -import type { StorageValue } from '../store/types'; - -type ExtractStorageValue = T extends StorageValue ? T : never; - -/** - * A generic hook to fetch and store data using a specified storage service. - * It fetches data based on the given dependencies and stores it using the provided storage service. - * @param action - The action function to fetch data. - * @param actionKey - A key associated with the action for caching purposes. - * @returns The data fetched by the action function and a boolean indicating whether the data is being fetched. - */ -export function useOnchainActionWithCache(action: ActionFunction, actionKey: ActionKey) { - const [data, setData] = useState(undefined); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - let isSubscribed = true; - - const callAction = async () => { - let fetchedData: StorageValue; - // Use cache only if actionKey is not empty - if (actionKey) { - fetchedData = await InMemoryStorage.getData(actionKey); - } - - // If no cached data or actionKey is empty, fetch new data - if (!fetchedData) { - fetchedData = (await action()) as ExtractStorageValue; - // Cache the data only if actionKey is not empty - if (actionKey) { - await InMemoryStorage.setData(actionKey, fetchedData); - } - } - - if (isSubscribed) { - setData(fetchedData); - setIsLoading(false); - } - }; - - void callAction(); - - return () => { - isSubscribed = false; - }; - }, [actionKey, action]); - - return { data, isLoading }; -} diff --git a/yarn.lock b/yarn.lock index 1ab9b75c5a..7362b53a6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2008,6 +2008,7 @@ __metadata: dependencies: "@changesets/changelog-github": "npm:^0.4.8" "@changesets/cli": "npm:^2.26.2" + "@tanstack/react-query": "npm:^5.24.1" "@testing-library/jest-dom": "npm:^6.4.0" "@testing-library/react": "npm:^14.2.0" "@types/jest": "npm:^29.5.12" @@ -2030,6 +2031,7 @@ __metadata: viem: "npm:^2.7.0" yarn: "npm:^1.22.21" peerDependencies: + "@tanstack/react-query": ^5 "@xmtp/frames-validator": ^0.5.0 graphql: ^14 graphql-request: ^6 @@ -2934,6 +2936,24 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.24.1": + version: 5.24.1 + resolution: "@tanstack/query-core@npm:5.24.1" + checksum: b1d0363096dde7a7b021c0565b9d7f2678d5348225d2fec74f8ee97fb2c294a8de1030a6c2ceb24ffd08daecf006ec17550f1db97c74f817d4fab33295e852ef + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.24.1": + version: 5.24.1 + resolution: "@tanstack/react-query@npm:5.24.1" + dependencies: + "@tanstack/query-core": "npm:5.24.1" + peerDependencies: + react: ^18.0.0 + checksum: 2c22e0e4fea282d77e8a54d9118b555d831ab51ec5eec2f5291a32ebd722967b8b3d41b9a8ceea7044decc3d0435c33161ebe82e6dd509277418d371a268a8f7 + languageName: node + linkType: hard + "@testing-library/dom@npm:^9.0.0": version: 9.3.4 resolution: "@testing-library/dom@npm:9.3.4"