From cd39e945e8cc717389011a2d65fe346cef65faa6 Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Mon, 12 Jun 2023 13:31:15 -0600 Subject: [PATCH 1/3] feat: support updating cache of a infinite query entry --- README.md | 12 ++ src/cache.ts | 43 ++++--- src/hooks.test.tsx | 272 ++++++++++++++++++++++++++------------------- src/hooks.ts | 2 + src/types.ts | 7 ++ 5 files changed, 202 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index 0e1c599..9fce9aa 100644 --- a/README.md +++ b/README.md @@ -455,6 +455,18 @@ cache.updateCache( ); ``` +When dealing with a cache entry that is paginated prefer using `updatePaginatedCache` which behaves the same as `updateCache` + +```typescript +const cache = useAPICache(); + +cache.updatePaginatedCache( + '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..c708df3 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -49,6 +49,29 @@ export const createCacheUtils = ( payload: RequestPayloadOf, ) => InternalQueryKey, ): CacheUtils => { + const updateCache: CacheUtils['updateCache'] = ( + route, + payload, + updater, + ) => { + client.setQueryData( + [makeQueryKey(route, payload)], + typeof updater !== 'function' + ? updater + : (current) => { + if (current === undefined) { + return; + } + return produce( + current, + // @ts-expect-error TypeScript incorrectly thinks that `updater` + // still might not be a function. It is wrong. + updater, + ); + }, + ); + }; + return { invalidateQueries: (spec) => { void client.invalidateQueries(createQueryFilterFromSpec(spec)); @@ -56,23 +79,7 @@ export const createCacheUtils = ( resetQueries: (spec) => { void client.resetQueries(createQueryFilterFromSpec(spec)); }, - updateCache: (route, payload, updater) => { - client.setQueryData( - [makeQueryKey(route, payload)], - typeof updater !== 'function' - ? updater - : (current) => { - if (current === undefined) { - return; - } - return produce( - current, - // @ts-expect-error TypeScript incorrectly thinks that `updater` - // still might not be a function. It is wrong. - updater, - ); - }, - ); - }, + updateCache: updateCache, + updatePaginatedCache: updateCache, }; }; diff --git a/src/hooks.test.tsx b/src/hooks.test.tsx index 2f3eeb1..5d92586 100644 --- a/src/hooks.test.tsx +++ b/src/hooks.test.tsx @@ -770,152 +770,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', 'updatePaginatedCache'] 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 () => { - network.mock('GET /items', { - status: 200, - data: { message: 'Frodo Baggins' }, - }); + 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); + }} + /> + )); - 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'); - 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..6e913e1 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -42,6 +42,8 @@ export const createAPIHooks = ({ 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..fef6913 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; + + updatePaginatedCache: ( + route: Route, + payload: RequestPayloadOf, + updater: CacheUpdate>, + ) => void; }; export type APIQueryHooks = { From 6bbb98a651ca5b9f7aa3e2e869041edde073236f Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Mon, 12 Jun 2023 16:28:00 -0600 Subject: [PATCH 2/3] rename infinite cache update method and support infinite query key --- README.md | 4 +- src/cache.ts | 50 +++++++++++---------- src/hooks.test.tsx | 107 ++++++++++++++++++++++++++++++++++++++++++--- src/hooks.ts | 7 ++- src/types.ts | 2 +- 5 files changed, 135 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9fce9aa..43c34c3 100644 --- a/README.md +++ b/README.md @@ -455,12 +455,12 @@ cache.updateCache( ); ``` -When dealing with a cache entry that is paginated prefer using `updatePaginatedCache` which behaves the same as `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.updatePaginatedCache( +cache.updateInfiniteCache( 'GET /list', { filter: 'some-filter' }, (current) => {...}, diff --git a/src/cache.ts b/src/cache.ts index c708df3..71c9cf4 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 = 'infinite' as const; + export const createCacheUtils = ( client: QueryClient, makeQueryKey: ( @@ -49,28 +52,27 @@ export const createCacheUtils = ( payload: RequestPayloadOf, ) => InternalQueryKey, ): CacheUtils => { - const updateCache: CacheUtils['updateCache'] = ( - route, - payload, - updater, - ) => { - client.setQueryData( - [makeQueryKey(route, payload)], - typeof updater !== 'function' - ? updater - : (current) => { - if (current === undefined) { - return; - } - return produce( - current, - // @ts-expect-error TypeScript incorrectly thinks that `updater` - // still might not be a function. It is wrong. - updater, - ); - }, - ); - }; + const updateCache: ( + keyPrefix?: typeof infinite, + ) => CacheUtils['updateCache'] = + (keyPrefix) => (route, payload, updater) => { + client.setQueryData( + [keyPrefix, makeQueryKey(route, payload)].filter(Boolean), + typeof updater !== 'function' + ? updater + : (current) => { + if (current === undefined) { + return; + } + return produce( + current, + // @ts-expect-error TypeScript incorrectly thinks that `updater` + // still might not be a function. It is wrong. + updater, + ); + }, + ); + }; return { invalidateQueries: (spec) => { @@ -79,7 +81,7 @@ export const createCacheUtils = ( resetQueries: (spec) => { void client.resetQueries(createQueryFilterFromSpec(spec)); }, - updateCache: updateCache, - updatePaginatedCache: updateCache, + updateCache: updateCache(), + updateInfiniteCache: updateCache(infinite), }; }; diff --git a/src/hooks.test.tsx b/src/hooks.test.tsx index 5d92586..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,7 +865,7 @@ describe('useAPICache', () => { }); }); - (['updateCache', 'updatePaginatedCache'] as const).forEach((method) => { + (['updateCache', 'updateInfiniteCache'] as const).forEach((method) => { describe(`${method}`, () => { const config = method === 'updateCache' diff --git a/src/hooks.ts b/src/hooks.ts index 6e913e1..d63f81b 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 } from './cache'; import { combineQueries } from './combination'; import { APIQueryHooks, RoughEndpoints } from './types'; import { APIClient, createQueryKey } from './util'; @@ -35,7 +35,10 @@ export const createAPIHooks = ({ ); }, useInfiniteAPIQuery: (route, initPayload, options) => { - const queryKey: QueryKey = [createQueryKey(name, route, initPayload)]; + const queryKey: QueryKey = [ + infinite, + createQueryKey(name, route, initPayload), + ]; const query = useInfiniteQuery( queryKey, ({ pageParam }) => { diff --git a/src/types.ts b/src/types.ts index fef6913..92dc8ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -124,7 +124,7 @@ export type CacheUtils = { updater: CacheUpdate, ) => void; - updatePaginatedCache: ( + updateInfiniteCache: ( route: Route, payload: RequestPayloadOf, updater: CacheUpdate>, From 8488968bb9da9a087aa3d0fff20ddeafd17b13ab Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Mon, 12 Jun 2023 16:43:57 -0600 Subject: [PATCH 3/3] rename exported constanat --- src/cache.ts | 6 +++--- src/hooks.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cache.ts b/src/cache.ts index 71c9cf4..8a8c33b 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -43,7 +43,7 @@ const createQueryFilterFromSpec = ( }), }); -export const infinite = 'infinite' as const; +export const INFINITE_QUERY_KEY = 'infinite' as const; export const createCacheUtils = ( client: QueryClient, @@ -53,7 +53,7 @@ export const createCacheUtils = ( ) => InternalQueryKey, ): CacheUtils => { const updateCache: ( - keyPrefix?: typeof infinite, + keyPrefix?: typeof INFINITE_QUERY_KEY, ) => CacheUtils['updateCache'] = (keyPrefix) => (route, payload, updater) => { client.setQueryData( @@ -82,6 +82,6 @@ export const createCacheUtils = ( void client.resetQueries(createQueryFilterFromSpec(spec)); }, updateCache: updateCache(), - updateInfiniteCache: updateCache(infinite), + updateInfiniteCache: updateCache(INFINITE_QUERY_KEY), }; }; diff --git a/src/hooks.ts b/src/hooks.ts index d63f81b..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, infinite } from './cache'; +import { createCacheUtils, INFINITE_QUERY_KEY } from './cache'; import { combineQueries } from './combination'; import { APIQueryHooks, RoughEndpoints } from './types'; import { APIClient, createQueryKey } from './util'; @@ -36,7 +36,7 @@ export const createAPIHooks = ({ }, useInfiniteAPIQuery: (route, initPayload, options) => { const queryKey: QueryKey = [ - infinite, + INFINITE_QUERY_KEY, createQueryKey(name, route, initPayload), ]; const query = useInfiniteQuery(