diff --git a/README.md b/README.md index 0e1c599..43c34c3 100644 --- a/README.md +++ b/README.md @@ -455,6 +455,18 @@ cache.updateCache( ); ``` +When dealing with a cache entry that was initiated via `useInfiniteAPIQuery` (paginated) prefer using `updateInfiniteCache` which otherwise behaves the same as `updateCache` + +```typescript +const cache = useAPICache(); + +cache.updateInfiniteCache( + 'GET /list', + { filter: 'some-filter' }, + (current) => {...}, +); +``` + **Note**: if performing a programmatic update, _no update will occur_ if there is not a cached value. ## Test Utility API Reference diff --git a/src/cache.ts b/src/cache.ts index 78f6898..8a8c33b 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -20,6 +20,7 @@ const createQueryFilterFromSpec = ( } const payloadsToInvalidate = endpoints[entry.route]; + if (!payloadsToInvalidate) { return false; } @@ -42,6 +43,8 @@ const createQueryFilterFromSpec = ( }), }); +export const INFINITE_QUERY_KEY = 'infinite' as const; + export const createCacheUtils = ( client: QueryClient, makeQueryKey: ( @@ -49,16 +52,12 @@ export const createCacheUtils = ( payload: RequestPayloadOf, ) => InternalQueryKey, ): CacheUtils => { - return { - invalidateQueries: (spec) => { - void client.invalidateQueries(createQueryFilterFromSpec(spec)); - }, - resetQueries: (spec) => { - void client.resetQueries(createQueryFilterFromSpec(spec)); - }, - updateCache: (route, payload, updater) => { + const updateCache: ( + keyPrefix?: typeof INFINITE_QUERY_KEY, + ) => CacheUtils['updateCache'] = + (keyPrefix) => (route, payload, updater) => { client.setQueryData( - [makeQueryKey(route, payload)], + [keyPrefix, makeQueryKey(route, payload)].filter(Boolean), typeof updater !== 'function' ? updater : (current) => { @@ -73,6 +72,16 @@ export const createCacheUtils = ( ); }, ); + }; + + return { + invalidateQueries: (spec) => { + void client.invalidateQueries(createQueryFilterFromSpec(spec)); + }, + resetQueries: (spec) => { + void client.resetQueries(createQueryFilterFromSpec(spec)); }, + updateCache: updateCache(), + updateInfiniteCache: updateCache(INFINITE_QUERY_KEY), }; }; diff --git a/src/hooks.test.tsx b/src/hooks.test.tsx index 2f3eeb1..d9baee3 100644 --- a/src/hooks.test.tsx +++ b/src/hooks.test.tsx @@ -500,12 +500,24 @@ describe('useCombinedAPIQueries', () => { describe('useAPICache', () => { describe('invalidation', () => { beforeEach(() => { + const messages = [{ message: '1' }, { message: '2' }, { message: '3' }]; // Mock a bunch of different requests to help us confirm render count. - network.mockOrdered('GET /items/:id', [ - { status: 200, data: { message: '1' } }, - { status: 200, data: { message: '2' } }, - { status: 200, data: { message: '3' } }, - ]); + network + .mockOrdered( + 'GET /items/:id', + messages.map((data) => ({ status: 200, data })), + ) + .mockOrdered( + 'GET /list', + messages.map((item) => ({ + status: 200, + data: { + previous: undefined, + next: undefined, + items: [item], + }, + })), + ); }); (['resetQueries', 'invalidateQueries'] as const).forEach((method) => { @@ -537,6 +549,89 @@ describe('useAPICache', () => { ); }; + it('invalidates infinite queries', async () => { + const screen = render(() => ( + { + const { data } = useInfiniteAPIQuery( + 'GET /list', + { + filter: 'some-filter', + }, + { + cacheTime: Infinity, + }, + ); + + return `Response: ${ + data?.pages?.at(-1)?.items?.at(-1)?.message || 'undefined' + }`; + }} + onPress={(invalidate) => { + invalidate({ + 'GET /list': (variables) => + variables.filter === 'some-filter', + }); + }} + /> + )); + + await TestingLibrary.waitFor(() => { + expect(screen.getByTestId('text').textContent).toStrictEqual( + 'Response: 1', + ); + }); + + expect(client.request).toHaveBeenCalledTimes(1); + + TestingLibrary.fireEvent.click( + screen.getByTestId('invalidate-button'), + ); + + await TestingLibrary.waitFor(() => { + expect(screen.getByTestId('text').textContent).toStrictEqual( + 'Response: 2', + ); + expect(client.request).toHaveBeenCalledTimes(2); + }); + + screen.rerender( + { + const { data } = useInfiniteAPIQuery( + 'GET /list', + { + filter: 'some-filter', + }, + { + cacheTime: Infinity, + }, + ); + + return `Response: ${ + data?.pages?.at(-1)?.items?.at(-1)?.message || 'undefined' + }`; + }} + onPress={(invalidate) => { + invalidate({ + 'GET /list': 'all', + }); + }} + />, + ); + + TestingLibrary.fireEvent.click( + screen.getByTestId('invalidate-button'), + ); + + await TestingLibrary.waitFor(() => { + expect(screen.getByTestId('text').textContent).toStrictEqual( + 'Response: 3', + ); + expect(client.request).toHaveBeenCalledTimes(3); + }); + }); + it('invalidates matching queries based on static match', async () => { const variables: RequestPayloadOf = { id: 'some-id', @@ -770,152 +865,192 @@ describe('useAPICache', () => { }); }); - describe('updateCache', () => { - const TestComponent: React.FC<{ - getRenderData: () => string; - onPress: (cache: CacheUtils) => void; - }> = ({ getRenderData, onPress }) => { - const cache = useAPICache(); - const data = getRenderData(); - return ( - <> -
{data}
- - - ); - }; - - it('updates queries using static data', async () => { - network.mock('GET /items', { - status: 200, - data: { message: 'Frodo Baggins' }, + (['updateCache', 'updateInfiniteCache'] as const).forEach((method) => { + describe(`${method}`, () => { + const config = + method === 'updateCache' + ? { + route: 'GET /items', + getRenderData: () => { + const { data } = useAPIQuery('GET /items', { filter: '' }); + return `Response: ${data?.message}`; + }, + } + : { + route: 'GET /list', + getRenderData: () => { + const { data } = useInfiniteAPIQuery('GET /list', { + filter: '', + }); + return `Response: ${data?.pages?.at(0)?.items?.at(0)?.message}`; + }, + }; + + const TestComponent: React.FC<{ + getRenderData: () => string; + onPress: (cache: CacheUtils) => void; + }> = ({ getRenderData, onPress }) => { + const cache = useAPICache(); + const data = getRenderData(); + return ( + <> +
{data}
+ + + ); + }; + + beforeEach(() => { + network + .mock('GET /items', { + status: 200, + data: { message: 'Frodo Baggins' }, + }) + .mock('GET /list', { + status: 200, + data: { + items: [{ message: 'Frodo Baggins' }], + }, + }); }); - const update = { message: 'Samwise Gamgee' }; + it('updates queries using static data', async () => { + const update = + method === 'updateCache' + ? { message: 'Samwise Gamgee' } + : { + pages: [{ items: [{ message: 'Samwise Gamgee' }] }], + pageParams: [], + }; + + const screen = render(() => ( + { + const updateMethod = cache[method]; + // @ts-expect-error + updateMethod(config.route, { filter: '' }, update); + }} + /> + )); - const screen = render(() => ( - { - const { data } = useAPIQuery('GET /items', { filter: '' }); - return `Response: ${data?.message}`; - }} - onPress={(cache) => { - cache.updateCache('GET /items', { filter: '' }, update); - }} - /> - )); + await screen.findByText('Response: Frodo Baggins'); - await screen.findByText('Response: Frodo Baggins'); + expect(client.request).toHaveBeenCalledTimes(1); - expect(client.request).toHaveBeenCalledTimes(1); + TestingLibrary.fireEvent.click(screen.getByText('Update Cache')); - TestingLibrary.fireEvent.click(screen.getByText('Update Cache')); + // The update does not happen immediately. + await TestingLibrary.waitFor(() => { + expect(screen.getByTestId('render-data').textContent).toStrictEqual( + 'Response: Samwise Gamgee', + ); + }); - // The update does not happen immediately. - await TestingLibrary.waitFor(() => { - expect(screen.getByTestId('render-data').textContent).toStrictEqual( - 'Response: Samwise Gamgee', - ); + // Confirm that another network call is not triggered. + expect(client.request).toHaveBeenCalledTimes(1); }); - // Confirm that another network call is not triggered. - expect(client.request).toHaveBeenCalledTimes(1); - }); + it('updates queries using a function when there is existing data', async () => { + const updater = + method === 'updateCache' + ? () => ({ message: 'Samwise Gamgee' }) + : () => ({ + pages: [{ items: [{ message: 'Samwise Gamgee' }] }], + pageParams: [], + }); - it('updates queries using a function when there is existing data', async () => { - network.mock('GET /items', { - status: 200, - data: { message: 'Frodo Baggins' }, - }); + const screen = render(() => ( + { + const updateMethod = cache[method]; + // @ts-expect-error + updateMethod(config.route, { filter: '' }, updater); + }} + /> + )); - const screen = render(() => ( - { - const { data } = useAPIQuery('GET /items', { filter: '' }); - return `Response: ${data?.message}`; - }} - onPress={(cache) => { - cache.updateCache('GET /items', { filter: '' }, () => ({ - message: 'Samwise Gamgee', - })); - }} - /> - )); + await screen.findByText('Response: Frodo Baggins'); - await screen.findByText('Response: Frodo Baggins'); + expect(client.request).toHaveBeenCalledTimes(1); - expect(client.request).toHaveBeenCalledTimes(1); + TestingLibrary.fireEvent.click(screen.getByText('Update Cache')); - TestingLibrary.fireEvent.click(screen.getByText('Update Cache')); + // The update does not happen immediately. + await TestingLibrary.waitFor(() => { + expect(screen.getByTestId('render-data').textContent).toStrictEqual( + 'Response: Samwise Gamgee', + ); + }); - // The update does not happen immediately. - await TestingLibrary.waitFor(() => { - expect(screen.getByTestId('render-data').textContent).toStrictEqual( - 'Response: Samwise Gamgee', - ); + expect(client.request).toHaveBeenCalledTimes(1); }); - expect(client.request).toHaveBeenCalledTimes(1); - }); + it('supports mutating the current value when using a function', async () => { + const updater = + method === 'updateCache' + ? (current: any) => { + current.message = 'Samwise Gamgee'; + } + : (current: any) => { + current.pages.at(0).items.at(0).message = 'Samwise Gamgee'; + }; + + const screen = render(() => ( + { + const updateMethod = cache[method]; + // @ts-expect-error + updateMethod(config.route, { filter: '' }, updater); + }} + /> + )); - it('supports mutating the current value when using a function', async () => { - network.mock('GET /items', { - status: 200, - data: { message: 'Frodo Baggins' }, - }); + await screen.findByText('Response: Frodo Baggins'); - const screen = render(() => ( - { - const { data } = useAPIQuery('GET /items', { filter: '' }); - return `Response: ${data?.message}`; - }} - onPress={(cache) => { - cache.updateCache('GET /items', { filter: '' }, (current) => { - current.message = 'Samwise Gamgee'; - }); - }} - /> - )); - - await screen.findByText('Response: Frodo Baggins'); + expect(client.request).toHaveBeenCalledTimes(1); - expect(client.request).toHaveBeenCalledTimes(1); + TestingLibrary.fireEvent.click(screen.getByText('Update Cache')); - TestingLibrary.fireEvent.click(screen.getByText('Update Cache')); + // The update does not happen immediately. + await TestingLibrary.waitFor(() => { + expect(screen.getByTestId('render-data').textContent).toStrictEqual( + 'Response: Samwise Gamgee', + ); + }); - // The update does not happen immediately. - await TestingLibrary.waitFor(() => { - expect(screen.getByTestId('render-data').textContent).toStrictEqual( - 'Response: Samwise Gamgee', - ); + expect(client.request).toHaveBeenCalledTimes(1); }); - expect(client.request).toHaveBeenCalledTimes(1); - }); + it('does nothing when a function update is passed, but there is no data', async () => { + network.reset(); - it('does nothing when a function update is passed, but there is no data', async () => { - const updateFn = jest.fn(); - const screen = render(() => ( - { - return 'Response: nothing'; - }} - onPress={(cache) => { - cache.updateCache('GET /items', { filter: '' }, updateFn); - }} - /> - )); + const updateFn = jest.fn(); + const screen = render(() => ( + { + return 'Response: nothing'; + }} + onPress={(cache) => { + const updateMethod = cache[method]; + // @ts-expect-error + updateMethod(config.route, { filter: '' }, updateFn); + }} + /> + )); - await screen.findByText('Response: nothing'); + await screen.findByText('Response: nothing'); - expect(client.request).toHaveBeenCalledTimes(0); + expect(client.request).toHaveBeenCalledTimes(0); - TestingLibrary.fireEvent.click(screen.getByText('Update Cache')); + TestingLibrary.fireEvent.click(screen.getByText('Update Cache')); - expect(client.request).toHaveBeenCalledTimes(0); + expect(client.request).toHaveBeenCalledTimes(0); - expect(updateFn).not.toHaveBeenCalled(); + expect(updateFn).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/src/hooks.ts b/src/hooks.ts index e964e53..9133de9 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -7,7 +7,7 @@ import { useQueryClient, } from '@tanstack/react-query'; import { AxiosInstance } from 'axios'; -import { createCacheUtils } from './cache'; +import { createCacheUtils, INFINITE_QUERY_KEY } from './cache'; import { combineQueries } from './combination'; import { APIQueryHooks, RoughEndpoints } from './types'; import { APIClient, createQueryKey } from './util'; @@ -35,13 +35,18 @@ export const createAPIHooks = ({ ); }, useInfiniteAPIQuery: (route, initPayload, options) => { - const queryKey: QueryKey = [createQueryKey(name, route, initPayload)]; + const queryKey: QueryKey = [ + INFINITE_QUERY_KEY, + createQueryKey(name, route, initPayload), + ]; const query = useInfiniteQuery( queryKey, ({ pageParam }) => { const payload = { ...initPayload, ...pageParam, + // casting here because `pageParam` is typed `any` and once it is + // merged with initPayload it makes `payload` `any` } as typeof initPayload; return client diff --git a/src/types.ts b/src/types.ts index 77956f7..92dc8ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,7 @@ import { UseInfiniteQueryResult, FetchNextPageOptions, FetchPreviousPageOptions, + InfiniteData, InfiniteQueryObserverResult, } from '@tanstack/react-query'; import { AxiosRequestConfig } from 'axios'; @@ -122,6 +123,12 @@ export type CacheUtils = { payload: RequestPayloadOf, updater: CacheUpdate, ) => void; + + updateInfiniteCache: ( + route: Route, + payload: RequestPayloadOf, + updater: CacheUpdate>, + ) => void; }; export type APIQueryHooks = {