Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

</details>

#### `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`.

<details><summary>Example</summary>

```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>

<details><summary>Details</summary>

`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.

</details>

Expand Down
23 changes: 19 additions & 4 deletions src/api/generic-cache.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
interface CacheEntry<TValue> {
value: TValue
timestamp: number
}

export class GenericCache<TValue = any> {
private valuesByCacheKey = new Map<string, TValue>()
private valuesByCacheKey = new Map<string, CacheEntry<TValue>>()

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<string> {
return this.valuesByCacheKey.keys()
}
}
146 changes: 146 additions & 0 deletions src/api/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
38 changes: 34 additions & 4 deletions src/api/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,7 +64,7 @@ export class Api {
params: ApiRequestParams<ApiRequestMethod, TResponseBody>,
options: ApiRequestOptions = {}
): Promise<TResponseBody> => {
const {fetchPolicy = DEFAULT_FETCH_POLICY} = options
const {fetchPolicy = DEFAULT_FETCH_POLICY, maxAge} = options

if (!READ_CACHE_POLICIES.includes(fetchPolicy)) {
return this.requestManager.getResponseBody<TResponseBody>(
Expand All @@ -73,7 +73,7 @@ export class Api {
) as Promise<TResponseBody>
}

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
Expand Down Expand Up @@ -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 = <TResponseBody extends ResponseBody>(
params: ApiRequestParams<ApiRequestMethod, TResponseBody>
Expand All @@ -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
*/
Expand Down
21 changes: 20 additions & 1 deletion src/api/lib.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
10 changes: 10 additions & 0 deletions src/api/lib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
Expand Down
13 changes: 13 additions & 0 deletions src/api/typings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/react/use-api-query/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ export function useApiQuery<TResponseBody extends ResponseBody>(
try {
const data = await api.request(params, {
fetchPolicy,
deduplicate: opts.deduplicate
deduplicate: opts.deduplicate,
maxAge: opts.maxAge
})
dispatch(
useApiQueryActions.success({
Expand Down
Loading