From f331441cf29880586c0a22894b577df1641bd8d4 Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Mon, 12 Feb 2024 12:09:09 -0700 Subject: [PATCH 1/4] feat: add useSuspenseAPIQuery hook --- README.md | 25 ++++++++++++-- src/hooks.test.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++- src/hooks.ts | 17 ++++++++++ src/types.ts | 26 +++++++++++++++ 4 files changed, 146 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 468a55d..2459180 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,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,8 +243,6 @@ 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' }]`. - ### `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. diff --git a/src/hooks.test.tsx b/src/hooks.test.tsx index 6041430..f81eec6 100644 --- a/src/hooks.test.tsx +++ b/src/hooks.test.tsx @@ -46,6 +46,7 @@ jest.spyOn(client, 'request'); const { useAPIQuery, + useSuspenseAPIQuery, useInfiniteAPIQuery, useAPIMutation, useCombinedAPIQueries, @@ -74,7 +75,11 @@ beforeEach(() => { const render = (Component: React.FC) => TestingLibrary.render(, { wrapper: ({ children }) => ( - {children} + suspense fallback}> + + {children} + + ), }); @@ -149,6 +154,80 @@ describe('useAPIQuery', () => { }); }); +describe('useSuspenseAPIQuery', () => { + test('works correctly', async () => { + network.mock('GET /items', { + status: 200, + data: { message: 'test-message' }, + }); + + const screen = render(() => { + const query = useSuspenseAPIQuery('GET /items', { + filter: 'test-filter', + }); + + return
{query.data?.message || ''}
; + }); + + await TestingLibrary.waitForElementToBeRemoved(() => + screen.getByText(/suspense fallback/i), + ); + + expect((await screen.findByTestId('content')).textContent).toStrictEqual( + 'test-message', + ); + }); + + test('sending axios parameters works', async () => { + const getItems = jest.fn().mockReturnValue({ + status: 200, + data: { message: 'test-message' }, + }); + network.mock('GET /items', getItems); + + const screen = render(() => { + const query = useSuspenseAPIQuery( + 'GET /items', + { filter: 'test-filter' }, + { axios: { headers: { 'test-header': 'test-value' } } }, + ); + return
{query.data?.message || ''}
; + }); + + await screen.findByText(/test-message/i); + + expect(getItems).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'test-header': 'test-value', + }), + }), + ); + }); + + test('using select(...) works and is typed correctly', async () => { + network.mock('GET /items', { + status: 200, + data: { message: 'test-message' }, + }); + + const screen = render(() => { + const query = useSuspenseAPIQuery( + 'GET /items', + { filter: 'test-filter' }, + { select: (data) => data.message }, + ); + + // This line implicitly asserts that `query.data` is typed as string. + query.data.codePointAt(0); + + return
{query.data}
; + }); + + await screen.findByText(/test-message/i); + }); +}); + describe('useInfiniteAPIQuery', () => { test('works correctly', async () => { const next = 'second'; diff --git a/src/hooks.ts b/src/hooks.ts index b7321a5..94554a9 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -4,6 +4,7 @@ import { useMutation, QueryKey, useQueryClient, + useSuspenseQuery, useQueries, QueriesOptions, } from '@tanstack/react-query'; @@ -50,6 +51,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 = [ diff --git a/src/types.ts b/src/types.ts index b481441..8269b72 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,8 @@ import { UseInfiniteQueryOptions, UseInfiniteQueryResult, InfiniteData, + UseSuspenseQueryResult, + UseSuspenseQueryOptions, DefaultError, } from '@tanstack/react-query'; import { AxiosRequestConfig } from 'axios'; @@ -58,6 +60,17 @@ type RestrictedUseQueryOptions< axios?: AxiosRequestConfig; }; +type RestrictedUseSuspenseQueryOptions< + Response, + TError = DefaultError, + Data = Response, +> = Omit< + UseSuspenseQueryOptions, + 'queryKey' | 'queryFn' +> & { + axios?: AxiosRequestConfig; +}; + type RestrictedUseInfiniteQueryOptions = Omit< UseInfiniteQueryOptions, DefaultError>, | 'queryKey' @@ -157,6 +170,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, From dc809989d00a408e2ff766934828af559c011efd Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Mon, 12 Feb 2024 12:09:09 -0700 Subject: [PATCH 2/4] feat: add useSuspenseInfiniteAPIQuery hook --- README.md | 52 ++++++++++--- src/hooks.test.tsx | 190 +++++++++++++++++++++++++++++++++++++++++++++ src/hooks.ts | 30 +++++++ src/types.ts | 28 +++++++ 4 files changed, 290 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2459180..c94b357 100644 --- a/README.md +++ b/README.md @@ -243,18 +243,19 @@ if (query.isError) { query.data; // Message[] ``` -### `useInfiniteAPIQuery` +### `useSuspenseInfiniteAPIQuery` -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 }), @@ -284,15 +285,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({ diff --git a/src/hooks.ts b/src/hooks.ts index 94554a9..98b7e2f 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -7,6 +7,7 @@ import { useSuspenseQuery, useQueries, QueriesOptions, + useSuspenseInfiniteQuery, } from '@tanstack/react-query'; import { AxiosInstance } from 'axios'; import { createCacheUtils, INFINITE_QUERY_KEY } from './cache'; @@ -94,6 +95,35 @@ export const createAPIHooks = ({ 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, + 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; + }, + useAPIMutation: (route, options) => { const client = useClient(); diff --git a/src/types.ts b/src/types.ts index 8269b72..65e1265 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,8 @@ import { InfiniteData, UseSuspenseQueryResult, UseSuspenseQueryOptions, + UseSuspenseInfiniteQueryOptions, + UseSuspenseInfiniteQueryResult, DefaultError, } from '@tanstack/react-query'; import { AxiosRequestConfig } from 'axios'; @@ -85,6 +87,20 @@ type RestrictedUseInfiniteQueryOptions = Omit< getPreviousPageParam?: (firstPage: Response) => Partial | undefined; }; +type RestrictedUseSuspenseInfiniteQueryOptions = Omit< + UseSuspenseInfiniteQueryOptions, DefaultError>, + | 'queryKey' + | 'queryFn' + | 'initialPageParam' + | 'getNextPageParam' + | 'getPreviousPageParam' +> & { + axios?: AxiosRequestConfig; + initialPageParam: Partial; // use init payload? + getNextPageParam: (lastPage: Response) => Partial | undefined; + getPreviousPageParam?: (firstPage: Response) => Partial | undefined; +}; + export type CombinedRouteTuples< Endpoints extends RoughEndpoints, Routes extends (keyof Endpoints)[], @@ -195,6 +211,18 @@ export type APIQueryHooks = { DefaultError >; + useSuspenseInfiniteAPIQuery: ( + route: Route, + payload: RequestPayloadOf, + options: RestrictedUseSuspenseInfiniteQueryOptions< + Endpoints[Route]['Response'], + RequestPayloadOf + >, + ) => UseSuspenseInfiniteQueryResult< + InfiniteData, + DefaultError + >; + useAPIMutation: ( route: Route, options?: UseMutationOptions< From 38776801457e1be04f870d76aa7b7dd4859724e8 Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Mon, 12 Feb 2024 12:09:09 -0700 Subject: [PATCH 3/4] feat: add useSuspenseCombinedAPIQueries hook --- README.md | 73 ++++++++++++++++++++- package.json | 1 + src/combination.ts | 67 +++++++++++++++---- src/hooks.test.tsx | 158 +++++++++++++++++++++++++++++++++++++++++++-- src/hooks.ts | 27 +++++++- src/types.ts | 13 +++- yarn.lock | 7 ++ 7 files changed, 322 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index c94b357..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; ``` @@ -350,10 +354,73 @@ return ( ); ``` -### `useCombinedAPIQueries` +### `useSuspenseCombinedAPIQueries` A helper for combining multiple parallel queries into a single `react-query`-like hook. +Queries performed using this hook are cached independently, just as if they had been performed individually using `useSuspenseAPIQuery`. + +```typescript +const query = useSuspenseCombinedAPIQueries( + ['GET /messages', { filter: 'some-filter' }], + ['GET /messages/:id', { id: 'some-message-id' }], +); + +// Here all queries are complete - pending and error states are handled by suspense and error boundaries + +query.data; // [Message[], Message] + +const [list, message] = query.data; + +list; // Message[] +message; // Message +``` + +#### `isFetching` + +Indicates whether _at least one_ query is in the "fetching" state. + +#### `isRefetching` + +Indicates whether _at least one_ query is in the "refetching" state. + +#### `refetchAll()` + +A helper function for triggering a refetch of every independent query in the combination. + +```typescript +const query = useSuspenseCombinedAPIQueries( + ['GET /messages', { filter: 'some-filter' }], + ['GET /messages/:id', { id: 'some-message-id' }], +); + +// This: +query.refetchAll(); + +// Is equivalent to: +for (const individualQuery of query.queries) { + void individualQuery.refetch(); +} +``` + +#### `queries` + +Provides access to the individual underlying queries. + +```typescript +const query = useSuspenseCombinedAPIQueries( + ['GET /messages', { filter: 'some-filter' }], + ['GET /messages/:id', { id: 'some-message-id' }], +); + +query.queries[0].data; // Messages[] +query.queries[1].data; // Message +``` + +### `useCombinedAPIQueries` + +A helper for combining multiple parallel queries into a single `react-query`-like hook. A non-suspense version of `useSuspenseCombinedAPIQueries`. + Queries performed using this hook are cached independently, just as if they had been performed individually using `useAPIQuery`. ```typescript diff --git a/package.json b/package.json index 1943e30..decaf14 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "prettier": "^2.7.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.12", "semantic-release": "^19.0.5", "ts-jest": "^29.0.3", "typescript": "^4.8.4", diff --git a/src/combination.ts b/src/combination.ts index 3eb12a2..4f569c9 100644 --- a/src/combination.ts +++ b/src/combination.ts @@ -1,6 +1,7 @@ import { DefinedQueryObserverResult, QueryObserverResult, + UseSuspenseQueryResult, } from '@tanstack/react-query'; type CombinedQueriesBaseResult = { @@ -30,10 +31,10 @@ export type CombinedQueriesDefinedResult< }; }; -export type CombinedQueriesLoadingResult< +export type CombinedQueriesPendingResult< Queries extends QueryObserverResult[], > = { - status: 'loading'; + status: 'pending'; isPending: true; isError: false; data: undefined; @@ -53,13 +54,27 @@ export type CombinedQueriesResult = CombinedQueriesBaseResult & ( | CombinedQueriesDefinedResult - | CombinedQueriesLoadingResult + | CombinedQueriesPendingResult | CombinedQueriesErrorResult ); -export const combineQueries = ( +export type SuspenseCombinedQueriesResult< + Queries extends QueryObserverResult[], +> = CombinedQueriesBaseResult & + Pick< + UseSuspenseQueryResult, + 'data' | 'status' | 'isPending' | 'isError' + > & { + queries: { + [Index in keyof Queries]: DefinedQueryObserverResult< + DataOfQuery + >; + }; + }; + +const getBase = ( queries: [...Queries], -): CombinedQueriesResult => { +): CombinedQueriesBaseResult => { const base = { isFetching: queries.some((query) => query.isFetching), isRefetching: queries.some((query) => query.isRefetching), @@ -70,24 +85,32 @@ export const combineQueries = ( }, }; - if (queries.some((query) => query.status === 'error')) { + return base; +}; + +export const combineQueries = ( + queries: [...Queries], +): CombinedQueriesResult => { + const base = getBase(queries); + + if (queries.some((query) => query.status === 'pending')) { return { ...base, - status: 'error', - isPending: false, + status: 'pending', + isPending: true, data: undefined, - isError: true, + isError: false, queries, }; } - if (queries.some((query) => query.status === 'pending')) { + if (queries.some((query) => query.status === 'error')) { return { ...base, - status: 'loading', - isPending: true, + status: 'error', + isPending: false, data: undefined, - isError: false, + isError: true, queries, }; } @@ -101,3 +124,21 @@ export const combineQueries = ( queries: queries as any, }; }; + +export const suspenseCombineQueries = ( + queries: [...Queries], +): SuspenseCombinedQueriesResult => { + const base = getBase(queries); + + // Loading and Error states will be handled by suspense and error + // boundaries so unlike the non-suspense version we only need to + // account for the DefinedQueryObserverResult state + return { + ...base, + status: 'success', + isPending: false, + data: queries.map((query) => query.data) as any, + isError: false, + queries: queries as any, + }; +}; diff --git a/src/hooks.test.tsx b/src/hooks.test.tsx index e66fa7a..2064e3d 100644 --- a/src/hooks.test.tsx +++ b/src/hooks.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import * as TestingLibrary from '@testing-library/react'; import { useQuery as useReactQuery } from '@tanstack/react-query'; +import { ErrorBoundary } from 'react-error-boundary'; import axios, { AxiosInstance } from 'axios'; import { createAPIHooks } from './hooks'; import { createAPIMockingUtility } from './test-utils'; @@ -51,6 +52,7 @@ const { useSuspenseInfiniteAPIQuery, useAPIMutation, useCombinedAPIQueries, + useSuspenseCombinedAPIQueries, useAPICache, } = createAPIHooks({ name: 'test-name', @@ -76,11 +78,13 @@ beforeEach(() => { const render = (Component: React.FC) => TestingLibrary.render(, { wrapper: ({ children }) => ( - suspense fallback}> - - {children} - - + error fallback}> + suspense fallback}> + + {children} + + + ), }); @@ -838,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 98b7e2f..458e608 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -8,12 +8,14 @@ import { 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; @@ -116,7 +118,7 @@ export const createAPIHooks = ({ } as typeof initPayload; return client - .request(route, payload, options?.axios) + .request(route, payload, options.axios) .then((res) => res.data) as any; }, }); @@ -157,6 +159,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 65e1265..417b795 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,7 +14,10 @@ import { 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. @@ -246,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 39ea99f..16748fd 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" From 400c84fdb45b9cb4a0d6261001ae7bf87f258d35 Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Fri, 16 Feb 2024 21:36:16 -0700 Subject: [PATCH 4/4] add comment about any and pull in previous feedback --- src/combination.ts | 3 +++ src/hooks.ts | 1 - src/types.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/combination.ts b/src/combination.ts index 4f569c9..c38f5b9 100644 --- a/src/combination.ts +++ b/src/combination.ts @@ -139,6 +139,9 @@ export const suspenseCombineQueries = ( isPending: false, data: queries.map((query) => query.data) as any, isError: false, + // Data is typed as unknown[] because it can't infer it + // correctly - here we cast it as any, in types we give + // it the correct type we expect. queries: queries as any, }; }; diff --git a/src/hooks.ts b/src/hooks.ts index 458e608..34ec239 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -80,7 +80,6 @@ export const createAPIHooks = ({ const query = useInfiniteQuery({ ...options, queryKey, - initialPageParam: options.initialPageParam, queryFn: ({ pageParam }) => { const payload = { ...initPayload, diff --git a/src/types.ts b/src/types.ts index 417b795..34890d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,7 +85,7 @@ type RestrictedUseInfiniteQueryOptions = Omit< | 'getPreviousPageParam' > & { axios?: AxiosRequestConfig; - initialPageParam: Partial; // use init payload? + initialPageParam: Partial; getNextPageParam: (lastPage: Response) => Partial | undefined; getPreviousPageParam?: (firstPage: Response) => Partial | undefined; };