diff --git a/README.md b/README.md index 468a55d..d5af4b8 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,13 @@ const hooks = createAPIHooks({ // <-- Specify your custom type her export const { useAPIQuery, - useAPIMutation + useSuspenseAPIQuery, + useInfiniteAPIQuery, + useSuspenseInfiniteAPIQuery, + useAPIMutation, useCombinedAPIQueries, - useAPICache + useSuspenseCombinedAPIQueries, + useAPICache, } = hooks; ``` @@ -190,6 +194,29 @@ const hooks = createAPIHooks({ }); ``` +### `useSuspenseAPIQuery` + +Type-safe wrapper around `useSuspenseQuery` from `react-query`. Be sure to use within a `` boundary. + +```typescript +const query = useSuspenseAPIQuery( + // First, specify the route. + 'GET /messages', + // Then, specify the payload. + { filter: 'some-filter' }, +); +``` + +The return value of this hook is identical to the behavior of the `react-query` `useSuspenseQuery` hook's return value. + +```typescript +const query = useQuery('GET /messages', { filter: 'some-filter' }); + +query.data; // Message[] +``` + +Queries are cached using a combination of `route name + payload`. So, in the example above, the query key looks roughly like `['GET /messages', { filter: 'some-filter' }]`. + ### `useAPIQuery` Type-safe wrapper around `useQuery` from `react-query`. @@ -220,20 +247,19 @@ if (query.isError) { query.data; // Message[] ``` -Queries are cached using a combination of `route name + payload`. So, in the example above, the query key looks roughly like `['GET /messages', { filter: 'some-filter' }]`. +### `useSuspenseInfiniteAPIQuery` -### `useInfiniteAPIQuery` - -Type-safe wrapper around [`useInfiniteQuery`](https://tanstack.com/query/latest/docs/react/reference/useInfiniteQuery) from `react-query` which has a similar api as `useQuery` with a few key differences. +Type-safe wrapper around [`useSuspenseInfiniteQuery`](https://tanstack.com/query/latest/docs/react/reference/useSuspenseInfiniteQuery) from `react-query` ```tsx -const query = useInfiniteAPIQuery( +const query = useSuspenseInfiniteAPIQuery( 'GET /list', { // after is the token name in query string for the next page to return. after: undefined, }, { + initialPageParam: {}, // passes the pagination token from request body to query string "after" getNextPageParam: (lastPage) => ({ after: lastPage.next }), getPreviousPageParam: (firstPage) => ({ before: firstPage.previous }), @@ -263,15 +289,46 @@ is required over query string. It may need another queryFn if the pagination tok } ``` -An alternative to using the `getNextPageParam` or `getPreviousPageParam` callback options, the query methods also accept a `pageParam` input. +### `useInfiniteAPIQuery` -```typescript -const lastPage = query?.data?.pages[query.data.pages.length - 1]; -query.fetchNextPage({ - pageParam: { - after: lastPage?.next, +Type-safe wrapper around [`useInfiniteQuery`](https://tanstack.com/query/latest/docs/react/reference/useInfiniteQuery) from `react-query` which has a similar api as `useQuery` with a few key differences. + +```tsx +const query = useInfiniteAPIQuery( + 'GET /list', + { + // after is the token name in query string for the next page to return. + after: undefined, }, -}); + { + initialPageParam: {}, + // passes the pagination token from request body to query string "after" + getNextPageParam: (lastPage) => ({ after: lastPage.next }), + getPreviousPageParam: (firstPage) => ({ before: firstPage.previous }), + }, +); + +... + + + + {query?.data?.pages?.flatMap((page) => + page.items.map((message) => ( +

{message.message}

+ )), + )} + + ); + }); + + const [previousMessage, firstMessage, nextMessage] = Object.values(pages) + .flatMap((page) => page.items) + .map((item) => item.message); + + await TestingLibrary.waitForElementToBeRemoved(() => + TestingLibrary.screen.getByText(/suspense fallback/i), + ); + + // initial load + await TestingLibrary.screen.findByText(firstMessage); + expect(TestingLibrary.screen.queryByText(previousMessage)).not.toBeTruthy(); + expect(TestingLibrary.screen.queryByText(nextMessage)).not.toBeTruthy(); + + // load next page _after_ first + TestingLibrary.fireEvent.click( + TestingLibrary.screen.getByRole('button', { + name: /fetch next/i, + }), + ); + await TestingLibrary.screen.findByText(nextMessage); + + expect(TestingLibrary.screen.queryByText(firstMessage)).toBeTruthy(); + expect(TestingLibrary.screen.queryByText(nextMessage)).toBeTruthy(); + expect(TestingLibrary.screen.queryByText(previousMessage)).not.toBeTruthy(); + + // load previous page _before_ first + TestingLibrary.fireEvent.click( + TestingLibrary.screen.getByRole('button', { + name: /fetch previous/i, + }), + ); + await TestingLibrary.screen.findByText(previousMessage); + + // all data should now be on the page + expect(TestingLibrary.screen.queryByText(firstMessage)).toBeTruthy(); + expect(TestingLibrary.screen.queryByText(nextMessage)).toBeTruthy(); + expect(TestingLibrary.screen.queryByText(previousMessage)).toBeTruthy(); + + expect(listSpy).toHaveBeenCalledTimes(3); + expect(listSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + query: { filter: 'some-filter' }, + }), + ); + expect(listSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + query: { filter: 'some-filter', after: next }, + }), + ); + expect(listSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + query: { filter: 'some-filter', before: previous }, + }), + ); + }); + + test('sending axios parameters works', async () => { + const listSpy = jest.fn().mockResolvedValue({ + status: 200, + data: { + next: undefined, + items: [], + }, + }); + + network.mock('GET /list', listSpy); + + render(() => { + const query = useSuspenseInfiniteAPIQuery( + 'GET /list', + { filter: 'test-filter' }, + { + axios: { headers: { 'test-header': 'test-value' } }, + initialPageParam: {}, + getNextPageParam: () => ({}), + }, + ); + return
{query.data.pages.at(0)?.items.length}
; + }); + + await TestingLibrary.screen.findByText('0'); + + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'test-header': 'test-value', + }), + }), + ); + }); +}); + describe('useAPIMutation', () => { test('works correctly', async () => { const networkPost = jest.fn().mockReturnValue({ @@ -569,6 +842,150 @@ describe('useCombinedAPIQueries', () => { }); }); +describe('useSuspenseCombinedAPIQueries', () => { + beforeEach(() => { + network + .mock('GET /items', { status: 200, data: { message: 'get response' } }) + .mock('POST /items', { status: 200, data: { message: 'post response' } }) + .mock('GET /items/:id', { + status: 200, + data: { message: 'put response' }, + }); + }); + + const setup = () => { + const onRender = jest.fn(); + const screen = render(() => { + const query = useSuspenseCombinedAPIQueries( + ['GET /items', { filter: '' }], + ['POST /items', { message: '' }], + ['GET /items/:id', { filter: '', id: 'test-id' }], + ); + + if (onRender) { + onRender(query); + } + return ; + }); + + return { screen, onRender }; + }; + + test('error state', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + network.mock('POST /items', { status: 500, data: {} }); + setup(); + + await TestingLibrary.waitForElementToBeRemoved(() => + TestingLibrary.screen.getByText(/suspense fallback/i), + ); + + expect(TestingLibrary.screen.getByText(/error fallback/i)).toBeDefined(); + errorSpy.mockRestore(); + }); + + test('success state', async () => { + const { onRender } = setup(); + + await TestingLibrary.waitForElementToBeRemoved(() => + TestingLibrary.screen.getByText(/suspense fallback/i), + ); + + expect(onRender).toHaveBeenCalledWith( + expect.objectContaining({ + isPending: false, + isRefetching: false, + isError: false, + data: [ + { message: 'get response' }, + { message: 'post response' }, + { message: 'put response' }, + ], + }), + ); + }); + + test('refetchAll', async () => { + network + .mockOrdered('GET /items', [ + { status: 200, data: { message: 'get response 1' } }, + { status: 200, data: { message: 'get response 2' } }, + ]) + .mockOrdered('POST /items', [ + { status: 200, data: { message: 'post response 1' } }, + { status: 200, data: { message: 'post response 2' } }, + ]) + .mockOrdered('GET /items/:id', [ + { status: 200, data: { message: 'put response 1' } }, + { status: 200, data: { message: 'put response 2' } }, + ]); + const { screen, onRender } = setup(); + + await TestingLibrary.waitForElementToBeRemoved(() => + TestingLibrary.screen.getByText(/suspense fallback/i), + ); + + expect(onRender).toHaveBeenCalledWith( + expect.objectContaining({ + isPending: false, + isRefetching: false, + isError: false, + data: [ + { message: 'get response 1' }, + { message: 'post response 1' }, + { message: 'put response 1' }, + ], + }), + ); + + TestingLibrary.fireEvent.click(screen.getByText('Refetch All')); + + await TestingLibrary.waitFor(() => { + expect(onRender).toHaveBeenCalledWith( + expect.objectContaining({ + isPending: false, + isRefetching: false, + isError: false, + data: [ + { message: 'get response 2' }, + { message: 'post response 2' }, + { message: 'put response 2' }, + ], + }), + ); + }); + }); + + test('sending axios parameters works', async () => { + const getItems = jest.fn().mockReturnValue({ + status: 200, + data: { message: 'test-message' }, + }); + network.mock('GET /items', getItems); + + render(() => { + useSuspenseCombinedAPIQueries([ + 'GET /items', + { filter: 'test-filter' }, + { axios: { headers: { 'test-header': 'test-value' } } }, + ]); + return
; + }); + + await TestingLibrary.waitForElementToBeRemoved(() => + TestingLibrary.screen.getByText(/suspense fallback/i), + ); + + expect(getItems).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'test-header': 'test-value', + }), + }), + ); + }); +}); + describe('useAPICache', () => { describe('invalidation', () => { beforeEach(() => { diff --git a/src/hooks.ts b/src/hooks.ts index b7321a5..34ec239 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -4,14 +4,18 @@ import { useMutation, QueryKey, useQueryClient, + useSuspenseQuery, useQueries, QueriesOptions, + useSuspenseInfiniteQuery, + useSuspenseQueries, + SuspenseQueriesOptions, } from '@tanstack/react-query'; import { AxiosInstance } from 'axios'; import { createCacheUtils, INFINITE_QUERY_KEY } from './cache'; import { APIQueryHooks, RoughEndpoints } from './types'; import { APIClient, createQueryKey } from './util'; -import { combineQueries } from './combination'; +import { combineQueries, suspenseCombineQueries } from './combination'; export type CreateAPIQueryHooksOptions = { name: string; @@ -50,6 +54,22 @@ export const createAPIHooks = ({ return query; }, + useSuspenseAPIQuery: (route, payload, options) => { + const client = useClient(); + const queryKey: QueryKey = [createQueryKey(name, route, payload)]; + + const query = useSuspenseQuery({ + queryKey, + queryFn: () => + client + .request(route, payload, options?.axios) + .then((res) => res.data), + ...options, + }); + + return query; + }, + useInfiniteAPIQuery: (route, initPayload, options) => { const client = useClient(); const queryKey: QueryKey = [ @@ -58,6 +78,33 @@ export const createAPIHooks = ({ ]; const query = useInfiniteQuery({ + ...options, + queryKey, + queryFn: ({ pageParam }) => { + const payload = { + ...initPayload, + ...(pageParam as any), + // casting here because `pageParam` is typed `any` and once it is + // merged with initPayload it makes `payload` `any` + } as typeof initPayload; + + return client + .request(route, payload, options.axios) + .then((res) => res.data) as any; + }, + }); + + return query; + }, + + useSuspenseInfiniteAPIQuery: (route, initPayload, options) => { + const client = useClient(); + const queryKey: QueryKey = [ + INFINITE_QUERY_KEY, + createQueryKey(name, route, initPayload), + ]; + + const query = useSuspenseInfiniteQuery({ ...options, queryKey, initialPageParam: options.initialPageParam, @@ -77,6 +124,7 @@ export const createAPIHooks = ({ return query; }, + useAPIMutation: (route, options) => { const client = useClient(); @@ -110,6 +158,27 @@ export const createAPIHooks = ({ return combineQueries(queries as any); }, + useSuspenseCombinedAPIQueries: (...routes) => { + const client = useClient(); + + const queries = useSuspenseQueries({ + queries: routes.map(([endpoint, payload, options]) => ({ + ...options, + queryKey: [createQueryKey(name, endpoint, payload)], + queryFn: () => + client + .request(endpoint, payload, options?.axios) + .then((res) => res.data), + })) as [...SuspenseQueriesOptions], + }); + + // The useQueries type inference is not quite as in-depth as ours is. So, + // the types don't fully agree here -- casting to `any` was a painful, but + // simple solution. + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return suspenseCombineQueries(queries as any); + }, + useAPICache: () => { const client = useQueryClient(); return createCacheUtils(client, (route, payload) => diff --git a/src/types.ts b/src/types.ts index b481441..34890d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,10 +7,17 @@ import { UseInfiniteQueryOptions, UseInfiniteQueryResult, InfiniteData, + UseSuspenseQueryResult, + UseSuspenseQueryOptions, + UseSuspenseInfiniteQueryOptions, + UseSuspenseInfiniteQueryResult, DefaultError, } from '@tanstack/react-query'; import { AxiosRequestConfig } from 'axios'; -import { CombinedQueriesResult } from './combination'; +import { + CombinedQueriesResult, + SuspenseCombinedQueriesResult, +} from './combination'; /** * Extracts the path parameters from a route. @@ -58,6 +65,17 @@ type RestrictedUseQueryOptions< axios?: AxiosRequestConfig; }; +type RestrictedUseSuspenseQueryOptions< + Response, + TError = DefaultError, + Data = Response, +> = Omit< + UseSuspenseQueryOptions, + 'queryKey' | 'queryFn' +> & { + axios?: AxiosRequestConfig; +}; + type RestrictedUseInfiniteQueryOptions = Omit< UseInfiniteQueryOptions, DefaultError>, | 'queryKey' @@ -65,6 +83,20 @@ type RestrictedUseInfiniteQueryOptions = Omit< | 'initialPageParam' | 'getNextPageParam' | 'getPreviousPageParam' +> & { + axios?: AxiosRequestConfig; + initialPageParam: Partial; + getNextPageParam: (lastPage: Response) => Partial | undefined; + getPreviousPageParam?: (firstPage: Response) => Partial | undefined; +}; + +type RestrictedUseSuspenseInfiniteQueryOptions = Omit< + UseSuspenseInfiniteQueryOptions, DefaultError>, + | 'queryKey' + | 'queryFn' + | 'initialPageParam' + | 'getNextPageParam' + | 'getPreviousPageParam' > & { axios?: AxiosRequestConfig; initialPageParam: Partial; // use init payload? @@ -157,6 +189,19 @@ export type APIQueryHooks = { >, ) => UseQueryResult; + useSuspenseAPIQuery: < + Route extends keyof Endpoints & string, + Data = Endpoints[Route]['Response'], + >( + route: Route, + payload: RequestPayloadOf, + options?: RestrictedUseSuspenseQueryOptions< + Endpoints[Route]['Response'], + DefaultError, + Data + >, + ) => UseSuspenseQueryResult; + useInfiniteAPIQuery: ( route: Route, payload: RequestPayloadOf, @@ -169,6 +214,18 @@ export type APIQueryHooks = { DefaultError >; + useSuspenseInfiniteAPIQuery: ( + route: Route, + payload: RequestPayloadOf, + options: RestrictedUseSuspenseInfiniteQueryOptions< + Endpoints[Route]['Response'], + RequestPayloadOf + >, + ) => UseSuspenseInfiniteQueryResult< + InfiniteData, + DefaultError + >; + useAPIMutation: ( route: Route, options?: UseMutationOptions< @@ -192,5 +249,13 @@ export type APIQueryHooks = { >; }>; + useSuspenseCombinedAPIQueries( + ...routes: [...CombinedRouteTuples] + ): SuspenseCombinedQueriesResult<{ + [Index in keyof Routes]: QueryObserverResult< + Endpoints[Routes[Index]]['Response'] + >; + }>; + useAPICache(): CacheUtils; }; diff --git a/yarn.lock b/yarn.lock index c1d83e7..5dbc0f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5605,6 +5605,13 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-error-boundary@^4.0.12: + version "4.0.12" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.0.12.tgz#59f8f1dbc53bbbb34fc384c8db7cf4082cb63e2c" + integrity sha512-kJdxdEYlb7CPC1A0SeUY38cHpjuu6UkvzKiAmqmOFL21VRfMhOcWxTCBgLVCO0VEMh9JhFNcVaXlV4/BTpiwOA== + dependencies: + "@babel/runtime" "^7.12.5" + react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"