diff --git a/README.md b/README.md
index 4510c9d..87d11f3 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ const [{data, loading, error}] = useApiQuery({url: `/users/${id}`})
- [`requestInProgress(params: object)`](#requestinprogressparams-object)
- [`writeCachedResponse(params: object, responseBody?: Blob | object | string)`](#writecachedresponseparams-object-responsebody-blob--object--string)
- [`readCachedResponse(params: object)`](#readcachedresponseparams-object)
+ - [`deleteCachedResponsesByUrl(urlPrefix: string)`](#deletecachedresponsesbyurlurlprefix-string)
- [`onCacheUpdate(params: object)`](#oncacheupdateparams-object)
- [`setDefaultHeader(key: string, value: string)`](#setdefaultheaderkey-string-value-string)
- [`onError`](#onerror)
@@ -261,7 +262,7 @@ Note that calling `api.request()` directly always defaults `fetchPolicy` to `'no
#### Using the cache directly
-It can be useful to read and write directly to and from the cache. For this, you can use `Api#readCachedResponse` and `Api#writeCachedResponse`. View the [API examples](#writecachedresponseparams-object-responsebody-blob--object--string) for usage.
+It can be useful to read, write, or remove entries directly from the cache. For this, you can use `Api#readCachedResponse`, `Api#writeCachedResponse`, and `Api#deleteCachedResponsesByUrl`. View the API examples for [write](#writecachedresponseparams-object-responsebody-blob--object--string), [read](#readcachedresponseparams-object), and [delete](#deletecachedresponsesbyurlurlprefix-string) for usage.
#### How request cache keys are determined
@@ -1205,7 +1206,38 @@ Standard request params. See [Api#request()](#requestparams-object-opts-object)
Returns `object | Blob | string`:
-The cached response body, or `undefined` on cache miss.
+The cached response body, or `null` on cache miss.
+
+
+
+#### `deleteCachedResponsesByUrl(urlPrefix: string)`
+
+Removes **all** cached responses whose request `url` matches the given prefix. The next `useApiQuery` or `api.request` call for any matching request will fetch fresh data from the network.
+
+This is useful for cache invalidation after a mutation — evict the cached list responses after creating, updating, or deleting an item so navigating back to the list fetches accurate data. Matching by url (rather than by request params) also covers cache keys that cannot be reconstructed at the call site — most commonly keys that include a serialized request body via `extraKey`, which only the original caller can produce.
+
+Matching stops at path/query boundaries: a prefix of `/users` matches `/users`, `/users/1`, and `/users?page=2`, but **not** `/users-archive`.
+
+Example
+
+```jsx
+await api.request(UserEndpoints.update(1, {name: 'Jane'}))
+
+// Evict every cached response under this url — the plain list,
+// filtered/paginated variants, and POST-based query endpoints
+// whose cache keys embed the serialized request body
+api.deleteCachedResponsesByUrl('/users')
+```
+
+
+
+Details
+
+`urlPrefix`:
+
+The request `url` prefix to match, as passed to `Api#request` (before `baseUrl` is applied).
+
+If no cached entries match, this is a no-op.
diff --git a/src/api/generic-cache.tsx b/src/api/generic-cache.tsx
index f80a769..e07babd 100644
--- a/src/api/generic-cache.tsx
+++ b/src/api/generic-cache.tsx
@@ -1,19 +1,34 @@
+interface CacheEntry {
+ value: TValue
+ timestamp: number
+}
+
export class GenericCache {
- private valuesByCacheKey = new Map()
+ private valuesByCacheKey = new Map>()
has(key: string): boolean {
return this.valuesByCacheKey.has(key)
}
- get(key: string): TValue | null {
- return this.valuesByCacheKey.get(key) || null
+ get(key: string, maxAge?: number): TValue | null {
+ const entry = this.valuesByCacheKey.get(key)
+ if (!entry) return null
+ if (maxAge !== undefined && Date.now() - entry.timestamp > maxAge) {
+ this.valuesByCacheKey.delete(key)
+ return null
+ }
+ return entry.value
}
set(key: string, value: TValue): void {
- this.valuesByCacheKey.set(key, value)
+ this.valuesByCacheKey.set(key, {value, timestamp: Date.now()})
}
del(key: string): void {
this.valuesByCacheKey.delete(key)
}
+
+ keys(): IterableIterator {
+ return this.valuesByCacheKey.keys()
+ }
}
diff --git a/src/api/index.spec.tsx b/src/api/index.spec.tsx
index 001bda2..db74140 100644
--- a/src/api/index.spec.tsx
+++ b/src/api/index.spec.tsx
@@ -535,6 +535,152 @@ test('manual cache read/write and listener', async () => {
expect(onCacheUpdate).not.toBeCalled()
})
+describe('maxAge', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it('serves cached response within maxAge', async () => {
+ const responseBody = {test: 'data'}
+ fetchMock.mockResponse(JSON.stringify(responseBody), {
+ headers: {'content-type': 'application/json'}
+ })
+ const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'}
+
+ await api.request(params, {fetchPolicy: 'fetch-first'})
+ fetchMock.mockClear()
+
+ const result = await api.request(params, {fetchPolicy: 'cache-first', maxAge: 60_000})
+ expect(result).toEqual(responseBody)
+ expect(fetchMock).not.toBeCalled()
+ })
+
+ it('treats expired cache entry as a miss and refetches', async () => {
+ const staleBody = {test: 'stale'}
+ const freshBody = {test: 'fresh'}
+ fetchMock.mockResponseOnce(JSON.stringify(staleBody), {
+ headers: {'content-type': 'application/json'}
+ })
+ const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'}
+
+ await api.request(params, {fetchPolicy: 'fetch-first'})
+
+ jest.advanceTimersByTime(61_000)
+
+ fetchMock.mockResponseOnce(JSON.stringify(freshBody), {
+ headers: {'content-type': 'application/json'}
+ })
+
+ const result = await api.request(params, {fetchPolicy: 'cache-first', maxAge: 60_000})
+ expect(result).toEqual(freshBody)
+ expect(fetchMock).toBeCalledTimes(2)
+ })
+
+ it('ignores maxAge when no cached entry exists', async () => {
+ const responseBody = {test: 'data'}
+ fetchMock.mockResponseOnce(JSON.stringify(responseBody), {
+ headers: {'content-type': 'application/json'}
+ })
+ const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'}
+
+ const result = await api.request(params, {fetchPolicy: 'cache-first', maxAge: 60_000})
+ expect(result).toEqual(responseBody)
+ expect(fetchMock).toBeCalledTimes(1)
+ })
+})
+
test('buildUrl', () => {
expect(api.buildUrl('/endpoint')).toEqual('http://test.com/endpoint')
})
+
+describe('deleteCachedResponsesByUrl', () => {
+ it('removes all cached responses matching a url prefix', async () => {
+ fetchMock.mockResponse(JSON.stringify({num: 1}), {
+ headers: {'content-type': 'application/json'}
+ })
+ const params1: ApiRequestParams<'POST', {}> = {
+ method: 'POST',
+ url: '/data-services/portfolio-groups',
+ extraKey: 'body-a'
+ }
+ const params2: ApiRequestParams<'POST', {}> = {
+ method: 'POST',
+ url: '/data-services/portfolio-groups',
+ extraKey: 'body-b'
+ }
+
+ await api.request(params1, {fetchPolicy: 'fetch-first'})
+ await api.request(params2, {fetchPolicy: 'fetch-first'})
+
+ api.deleteCachedResponsesByUrl('/data-services/portfolio-groups')
+
+ expect(api.readCachedResponse(params1)).toBeNull()
+ expect(api.readCachedResponse(params2)).toBeNull()
+ })
+
+ it('matches sub-paths and query strings of the prefix', async () => {
+ fetchMock.mockResponse(JSON.stringify({num: 1}), {
+ headers: {'content-type': 'application/json'}
+ })
+ const subPath: ApiRequestParams<'GET', {}> = {
+ method: 'GET',
+ url: '/users/1'
+ }
+ const withQuery: ApiRequestParams<'GET', {}> = {
+ method: 'GET',
+ url: '/users?page=2'
+ }
+
+ await api.request(subPath, {fetchPolicy: 'fetch-first'})
+ await api.request(withQuery, {fetchPolicy: 'fetch-first'})
+
+ api.deleteCachedResponsesByUrl('/users')
+
+ expect(api.readCachedResponse(subPath)).toBeNull()
+ expect(api.readCachedResponse(withQuery)).toBeNull()
+ })
+
+ it('does not affect cached entries with non-matching urls', async () => {
+ fetchMock.mockResponse(JSON.stringify({num: 1}), {
+ headers: {'content-type': 'application/json'}
+ })
+ const matching: ApiRequestParams<'GET', {}> = {
+ method: 'GET',
+ url: '/portfolio-groups'
+ }
+ const other: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/users'}
+
+ await api.request(matching, {fetchPolicy: 'fetch-first'})
+ await api.request(other, {fetchPolicy: 'fetch-first'})
+
+ api.deleteCachedResponsesByUrl('/portfolio-groups')
+
+ expect(api.readCachedResponse(matching)).toBeNull()
+ expect(api.readCachedResponse(other)).toEqual({num: 1})
+ })
+
+ it('does not evict urls that extend the prefix without a path boundary', async () => {
+ fetchMock.mockResponse(JSON.stringify({num: 1}), {
+ headers: {'content-type': 'application/json'}
+ })
+ const archive: ApiRequestParams<'GET', {}> = {
+ method: 'GET',
+ url: '/users-archive'
+ }
+
+ await api.request(archive, {fetchPolicy: 'fetch-first'})
+
+ api.deleteCachedResponsesByUrl('/users')
+
+ expect(api.readCachedResponse(archive)).toEqual({num: 1})
+ })
+
+ it('is a no-op when no cached entries match', () => {
+ expect(() =>
+ api.deleteCachedResponsesByUrl('/no-such-endpoint')
+ ).not.toThrow()
+ })
+})
diff --git a/src/api/index.tsx b/src/api/index.tsx
index edf51fb..e0f41e4 100644
--- a/src/api/index.tsx
+++ b/src/api/index.tsx
@@ -4,7 +4,7 @@ import PushStream from 'zen-push'
import {DEFAULT_FETCH_POLICY, READ_CACHE_POLICIES} from './constants'
import {ApiCacheMissError} from './errors'
import {GenericCache} from './generic-cache'
-import {apiRequestId} from './lib'
+import {apiRequestId, parseApiRequestIdUrl} from './lib'
import {ApiRequestManager} from './request-manager'
import {
ApiParseResponseJson,
@@ -64,7 +64,7 @@ export class Api {
params: ApiRequestParams,
options: ApiRequestOptions = {}
): Promise => {
- const {fetchPolicy = DEFAULT_FETCH_POLICY} = options
+ const {fetchPolicy = DEFAULT_FETCH_POLICY, maxAge} = options
if (!READ_CACHE_POLICIES.includes(fetchPolicy)) {
return this.requestManager.getResponseBody(
@@ -73,7 +73,7 @@ export class Api {
) as Promise
}
- const cachedResponse = this.responseBodyCache.get(apiRequestId(params))
+ const cachedResponse = this.responseBodyCache.get(apiRequestId(params), maxAge)
if (cachedResponse) {
if (fetchPolicy === 'cache-and-fetch') {
// kick off in the background
@@ -122,7 +122,7 @@ export class Api {
* The main reason you would use this over `Api#request` with a cached
* fetch policy is that this runs synchronously.
*
- * Note that if there is a cache miss, it will return `undefined`
+ * Note that if there is a cache miss, it will return `null`
*/
readCachedResponse = (
params: ApiRequestParams
@@ -132,6 +132,36 @@ export class Api {
) as TResponseBody | null
}
+ /**
+ * Removes all cached responses whose request `url` matches the given
+ * prefix. Use this when the exact request params cannot be reconstructed
+ * at the call site — for example, cache keys that include a serialized
+ * request body via `extraKey`. The next `useApiQuery` or `Api#request`
+ * call for any matching request will fetch fresh data from the network.
+ *
+ * Matching stops at path/query boundaries: a prefix of `/users` matches
+ * `/users`, `/users/1`, and `/users?page=2`, but not `/users-archive`.
+ */
+ deleteCachedResponsesByUrl = (urlPrefix: string): void => {
+ for (const key of this.responseBodyCache.keys()) {
+ const url = parseApiRequestIdUrl(key)
+
+ if (!url.startsWith(urlPrefix)) {
+ continue
+ }
+
+ const charAfterPrefix = url.charAt(urlPrefix.length)
+
+ if (
+ charAfterPrefix === '' ||
+ charAfterPrefix === '/' ||
+ charAfterPrefix === '?'
+ ) {
+ this.responseBodyCache.del(key)
+ }
+ }
+ }
+
/**
* Clears the entire cache
*/
diff --git a/src/api/lib.spec.tsx b/src/api/lib.spec.tsx
index 1621a8d..5be540e 100644
--- a/src/api/lib.spec.tsx
+++ b/src/api/lib.spec.tsx
@@ -1,6 +1,25 @@
-import {apiRequestId, applyHeaders, cloneHeaders} from './lib'
+import {
+ apiRequestId,
+ applyHeaders,
+ cloneHeaders,
+ parseApiRequestIdUrl
+} from './lib'
import {ApiRequestParams} from './typings'
+describe('parseApiRequestIdUrl', () => {
+ it('extracts the url from an id created by apiRequestId', () => {
+ const requestParams: ApiRequestParams = {
+ method: 'POST',
+ url: '/data-services/portfolio-groups',
+ extraKey: JSON.stringify({page: 1})
+ }
+
+ expect(parseApiRequestIdUrl(apiRequestId(requestParams))).toEqual(
+ '/data-services/portfolio-groups'
+ )
+ })
+})
+
describe('apiRequestId', () => {
it('serializes basic requests', () => {
const requestParams: ApiRequestParams = {
diff --git a/src/api/lib.tsx b/src/api/lib.tsx
index 3b5df93..9096701 100644
--- a/src/api/lib.tsx
+++ b/src/api/lib.tsx
@@ -19,6 +19,16 @@ export function apiRequestId(
])
}
+/**
+ * Extracts the request `url` from an id created by `apiRequestId`.
+ *
+ * Keep this in sync with the array shape serialized in `apiRequestId`.
+ */
+export function parseApiRequestIdUrl(id: string): string {
+ const [, url] = JSON.parse(id) as [string, string]
+ return url
+}
+
function serializeHeaders(paramHeaders?: ApiHeaders): string {
if (!paramHeaders) {
return ''
diff --git a/src/api/typings.tsx b/src/api/typings.tsx
index 33a5649..9516183 100644
--- a/src/api/typings.tsx
+++ b/src/api/typings.tsx
@@ -91,6 +91,19 @@ export interface ApiRequestOptions {
* non-`GET` requests, but you can override here on a per-request basis.
*/
deduplicate?: boolean
+
+ /**
+ * Maximum age of a cached response in milliseconds. If the cached entry
+ * is older than this value it is treated as a cache miss and fresh data
+ * is fetched from the server.
+ *
+ * Define this as a shared constant per endpoint so all callers agree on
+ * the same TTL:
+ * @example
+ * export const USERS_MAX_AGE = 60_000
+ * api.request(params, { fetchPolicy: 'cache-first', maxAge: USERS_MAX_AGE })
+ */
+ maxAge?: number
}
export interface RequestFetcherParams {
diff --git a/src/react/use-api-query/index.tsx b/src/react/use-api-query/index.tsx
index 4336e27..a9957a6 100644
--- a/src/react/use-api-query/index.tsx
+++ b/src/react/use-api-query/index.tsx
@@ -118,7 +118,8 @@ export function useApiQuery(
try {
const data = await api.request(params, {
fetchPolicy,
- deduplicate: opts.deduplicate
+ deduplicate: opts.deduplicate,
+ maxAge: opts.maxAge
})
dispatch(
useApiQueryActions.success({
diff --git a/src/react/use-api-query/typings.tsx b/src/react/use-api-query/typings.tsx
index d626552..f3d218e 100644
--- a/src/react/use-api-query/typings.tsx
+++ b/src/react/use-api-query/typings.tsx
@@ -91,6 +91,13 @@ export interface UseApiQueryOptions<
* instead of `null`
*/
initialData?: TResponseBody
+
+ /**
+ * Maximum age of a cached response in milliseconds. If the cached entry
+ * is older than this value it is treated as a cache miss and fresh data
+ * is fetched from the server.
+ */
+ maxAge?: number
}
export type FalsyValue = '' | 0 | false | undefined | null