Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add opt-in client-side caching layer to save on network requests #4386

Merged
merged 29 commits into from
Mar 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e266f35
Initial prototype
fzaninotto Feb 4, 2020
97d2212
Fix infinite loop
fzaninotto Feb 4, 2020
1565d3a
Refresh clears the cache
fzaninotto Feb 4, 2020
9348fae
Set cache in dataProvider
fzaninotto Feb 5, 2020
f64f104
Fix read from cache evicts record
fzaninotto Feb 5, 2020
ec705b8
Fix unit tests
fzaninotto Feb 17, 2020
710d3c1
Delay queries in optimistic mode instead of cancelling them
fzaninotto Feb 17, 2020
42a6be3
Store one list of ids per request
fzaninotto Feb 18, 2020
1ed8d68
Dos not display a blank page when changing list params
fzaninotto Feb 18, 2020
014e9b4
Add list validity reducer
fzaninotto Feb 18, 2020
aca8b53
cache getList calls
fzaninotto Feb 18, 2020
f4332f2
Fix total for query in cache
fzaninotto Feb 18, 2020
c79d6ec
Fix bug when coming back to the post edit page
fzaninotto Feb 18, 2020
43b7922
Fix types
fzaninotto Feb 18, 2020
8b67254
Fix wrong total in getMatching selector and cache
fzaninotto Feb 18, 2020
dfb5c7f
Fix create does not refresh the list
fzaninotto Feb 18, 2020
c0b020a
Fix refresh on list
fzaninotto Feb 18, 2020
eabda46
Fix unit tests
fzaninotto Feb 24, 2020
106a33c
Group list cache state into one reducer
fzaninotto Feb 25, 2020
01a060a
Make useGetList use cache, and useListController use useGetList
fzaninotto Feb 25, 2020
ed9c523
Move data providr cache proxy to a utility function
fzaninotto Feb 25, 2020
0fc926d
Fix unit test
fzaninotto Feb 25, 2020
53c5431
add unit tests for useGetList
fzaninotto Feb 25, 2020
555f4b4
Fix compiler error
fzaninotto Feb 27, 2020
48dd9b0
Add more tests to useDataProvider
fzaninotto Feb 27, 2020
fa83f9e
Add documentation
fzaninotto Mar 2, 2020
5eb533e
Add validUntil key in dataProvider spec
fzaninotto Mar 2, 2020
57bc2de
Add tests for update invalidating cache
fzaninotto Mar 2, 2020
f78902b
Update docs/Caching.md
fzaninotto Mar 3, 2020
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
160 changes: 160 additions & 0 deletions docs/Caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
layout: default
title: "Caching"
---

# Caching

Not hitting the server is the best way to improve a web app performance - and its ecological footprint, too (network and datacenter usage account for about 40% of the CO2 emissions in IT). React-admin comes with a built-in cache-first approach called *optimistic rendering*, and it supports caching both at the HTTP level and the application level.

## Optimistic Rendering

By default, react-admin stores all the responses from the dataProvider in the Redux store. This allows displaying the cached result first while fetching for the fresh data. **This behavior is automatic and requires no configuration**.

The Redux store is like a local replica of the API, organized by resource, and shared between all the data provider methods of a given resource. That means that if the `getList('posts')` response contains a record of id 123, a call to `getOne('posts', { id: 123 })` will use that record immediately.

For instance, if the end-user displays a list of posts, then clicks on a post in the list to display the list details, here is what react-admin does:

1. Display the empty List
2. Call `dataProvider.getList('posts')`, and store the result in the Redux store
3. Re-render the List with the data from the Redux store
4. When the user clicks on a post, display immediately the post from the Redux store
5. Call `dataProvider.getOne('posts', { id: 123 })`, and store the result in the Redux store
6. Re-render the detail with the data from the Redux store

In step 4, react-admin displays the post *before* fetching it, because it's already in the Redux store from the previous `getList()` call. In most cases, the post from the `getOne()` response is the same as the one from the `getList()` response, so the re-render of step 6 is invisible to the end-user. If the post was modified on the server side between the `getList()` and the `getOne` calls, the end-user will briefly see the outdated version (at step 4), then the up to date version (at step 6).

Optimistic rendering improves user experience by displaying stale data while getting fresh data from the API, but it does not reduce the ecological footprint of an app, as the web app still makes API requests on every page.

**Tip**: This design choice explains why react-admin requires that all data provider methods return records of the same shape for a given resource. Otherwise, if the posts returned by `getList()` contain fewer fields than the posts returned by `getOne()`, in the previous scenario, the user will see an incomplete post at step 4.

## HTTP Cache

React-admin supports HTTP cache headers by default, provided your API sends them.

Data providers almost always rely on `window.fetch()` to call the HTTP API. React-admin's `fetchJSON()`, and third-party libraries like `axios` use `window.fetch()`, too. Fortunately, the `window.fetch()` HTTP client behaves just like your browser and follows the [RFC 7234](https://tools.ietf.org/html/rfc7234) about HTTP cache headers. So if your API includes one of the following cache headers, all data providers support them:

- `Cache-Control`
- `Expires`
- `ETag`
- `Last-Modified`

In other terms, enabling the HTTP cache is entirely a server-side action - **nothing is necessary on the react-admin side**.

For instance, let's imagine that your data provider translates a `getOne('posts', { id: 123 })` call into a `GET https://api.acme.com/posts/123`, and that the server returns the following response:

```
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Cache-Control: max-age=120
Age: 0
{
"id": 123,
"title": "Hello, world"
}
```

The browser HTTP client knows that the response is valid for the next 2 minutes. If a component makes a new call to `getOne('posts', { id: 123 })` within 2 minutes, `window.fetch()` will return the response from the first call without even calling the API.

Refer to your backend framework or CDN documentation to enable cache headers - and don't forget to whitelist these headers in the `Access-Control-Allow-Headers` CORS header if the API lives in another domain than the web app itself.

HTTP cache can help improve the performance and reduce the ecological footprint of a web app. The main drawback is that responses are cached based on their request signature. The cached responses for `GET https://api.acme.com/posts` and `GET https://api.acme.com/posts/123` live in separate buckets on the client-side, and cannot be shared. As a consequence, the browser still makes a lot of useless requests to the API. HTTP cache also has another drawback: browser caches ignore the REST semantics. That means that a call to `DELETE https://api.acme.com/posts/123` can't invalidate the cache of the `GET https://api.acme.com/posts` request, and therefore the cache is sometimes wrong.

These shortcomings explain why most APIs adopt use short expiration or use "validation caching" (based on `Etag` or `Last-Modified` headers) instead of "expiration caching" (based on the `Cache-Control` or `Expires` headers). But with validation caching, the client must send *every request* to the server (sometimes the server returns an empty response, letting the client know that it can use its cache). Validation caching reduces network traffic a lot less than expiration caching and has less impact on performance.

Finally, if your API uses GraphQL, it probably doesn't offer HTTP caching.

## Application Cache

React-admin comes with its caching system, called *application cache*, to overcome the limitations if the HTTP cache. **This cache is opt-in** - you have to enable it by including validity information in the `dataProvider` response. But before explaining how to configure it, let's see how it works.

React-admin already stores responses from the `dataProvider` in the Redux store, for the [optimistic rendering](#optimistic-rendering). The application cache checks if this data is valid, and *skips the call to the `dataProvider` altogether* if it's the case.

For instance, if the end-user displays a list of posts, then clicks on a post in the list to display the list details, here is what react-admin does:

1. Display the empty List
2. Call `dataProvider.getList('posts')`, and store the result in the Redux store
3. Re-render the List with the data from the Redux store
4. When the user clicks on a post, display immediately the post from the Redux store (optimistic rendering)
5. Check that the post of id 123 is still valid, and as it's the case, end here

The application cache uses the semantics of the `dataProvider` verb. That means that requests for a list (`getList`) also populate the cache for individual records (`getOne`, `getMany`). That also means that write requests (`create`, `udpate`, `updateMany`, `delete`, `deleteMany`) invalidate the list cache - because after an update, for instance, the ordering of items can be changed.

So the application cache uses expiration caching together with a deeper knowledge of the data model, to allow longer expirations without the risk of displaying stale data. It especially fits admins for API backends with a small number of users (because with a large number of users, there is a high chance that a record kept in the client-side cache for a few minutes may be updated on the backend by another user). It also works with GraphQL APIs.

To enable it, the `dataProvider` response must include a `validUntil` key, containing the date until which the record(s) is (are) valid.

```diff
// response to getOne('posts', { id: 123 })
{
"data": { "id": 123, "title": "Hello, world" }
+ "validUntil": new Date('2020-03-02T13:24:05')
}

// response to getMany('posts', { ids: [123, 124] }
{
"data": [
{ "id": 123, "title": "Hello, world" },
{ "id": 124, "title": "Post title 2" },
],
+ "validUntil": new Date('2020-03-02T13:24:05')
}

// response to getList('posts')
{
"data": [
{ "id": 123, "title": "Hello, world" },
{ "id": 124, "title": "Post title 2" },
...

],
"total": 45,
+ "validUntil": new Date('2020-03-02T13:24:05')
}
```

To empty the cache, the `dataProvider` can simply omit the `validUntil` key in the response.

**Tip**: As of writing, the `validUntil` key is only taken into account for `getOne`, `getMany`, and `getList`.

It's your responsibility to determine the validity date based on the API response, or based on a fixed time policy.

For instance, to have a `dataProvider` declare responses for `getOne`, `getMany`, and `getList` valid for 5 minutes, you can wrap it in the following proxy:

```jsx
// in src/dataProvider.js
import simpleRestProvider from 'ra-data-simple-rest';

const dataProvider = simpleRestProvider('http://path.to.my.api/');

const cacheDataProviderProxy = (dataProvider, duration = 5 * 60 * 1000) =>
new Proxy(dataProvider, {
get: (target, name: string) => (resource, params) => {
if (name === 'getOne' || name === 'getMany' || name === 'getList') {
return dataProvider[name](resource, params).then(response => {
const validUntil = new Date();
validUntil.setTime(validUntil.getTime() + duration);
response.validUntil = validUntil;
return response;
});
}
return dataProvider[name](resource, params);
},
});

export default cacheDataProviderProxy(dataProvider);
```

**Tip**: As caching responses for a fixed period of time is a common pattern, react-admin exports this `cacheDataProviderProxy` wrapper, so you can write the following instead:

```jsx
// in src/dataProvider.js
import simpleRestProvider from 'ra-data-simple-rest';
import { cacheDataProviderProxy } from 'react-admin';

const dataProvider = simpleRestProvider('http://path.to.my.api/');

export default cacheDataProviderProxy(dataProvider);
```

Application cache provides a very significative boost for the end-user and saves a large portion of the network traffic. Even a short expiration date (30 seconds or one minute) can speed up a complex admin with a low risk of displaying stale data. Adding an application cache is, therefore, a warmly recommended practice!
9 changes: 5 additions & 4 deletions docs/DataProviders.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,9 @@ Data Providers methods must return a Promise for an object with a `data` propert

Method | Response format
------------------ | ----------------
`getList` | `{ data: {Record[]}, total: {int} }`
`getOne` | `{ data: {Record} }`
`getMany` | `{ data: {Record[]} }`
`getList` | `{ data: {Record[]}, total: {int}, validUntil?: {Date} }`
`getOne` | `{ data: {Record}, validUntil?: {Date} }`
`getMany` | `{ data: {Record[]}, validUntil?: {Date} }`
`getManyReference` | `{ data: {Record[]}, total: {int} }`
`create` | `{ data: {Record} }`
`update` | `{ data: {Record} }`
Expand Down Expand Up @@ -439,9 +439,10 @@ dataProvider.deleteMany('posts', { ids: [123, 234] })
// {
// data: [123, 234]
// }

```

**Tip**: The `validUntil` field in the response is optional. It enables the Application cache, a client-side optimization to speed up rendering and reduce network traffic. Check [the Caching documentation](./Caching.md#application-cache) for more details.

### Example Implementation

Let's say that you want to map the react-admin requests to a REST backend exposing the following API:
Expand Down
3 changes: 2 additions & 1 deletion examples/simple/src/dataProvider.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fakeRestProvider from 'ra-data-fakerest';
import { cacheDataProviderProxy } from 'react-admin';

import data from './data';
import addUploadFeature from './addUploadFeature';
Expand All @@ -25,4 +26,4 @@ const delayedDataProvider = new Proxy(sometimesFailsDataProvider, {
),
});

export default delayedDataProvider;
export default cacheDataProviderProxy(delayedDataProvider);
6 changes: 3 additions & 3 deletions examples/simple/src/posts/PostList.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { Children, Fragment, cloneElement, memo } from 'react';
import BookIcon from '@material-ui/icons/Book';
import Chip from '@material-ui/core/Chip';
import { useMediaQuery, makeStyles } from '@material-ui/core';
import React, { Children, Fragment, cloneElement } from 'react';
import lodashGet from 'lodash/get';
import jsonExport from 'jsonexport/dist';
import {
Expand Down Expand Up @@ -80,13 +80,13 @@ const useStyles = makeStyles(theme => ({
publishedAt: { fontStyle: 'italic' },
}));

const PostListBulkActions = props => (
const PostListBulkActions = memo(props => (
<Fragment>
<ResetViewsButton {...props} />
<BulkDeleteButton {...props} />
<BulkExportButton {...props} />
</Fragment>
);
));

const usePostListActionToolbarStyles = makeStyles({
toolbar: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface RequestPayload {
}

export const CRUD_GET_MATCHING = 'RA/CRUD_GET_MATCHING';
interface CrudGetMatchingAction {
export interface CrudGetMatchingAction {
readonly type: typeof CRUD_GET_MATCHING;
readonly payload: RequestPayload;
readonly meta: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ describe('<ReferenceArrayInputController />', () => {
id: 1,
},
},
list: {
total: 42,
},
list: {},
},
},
},
Expand Down Expand Up @@ -567,7 +565,12 @@ describe('<ReferenceArrayInputController />', () => {
</ReferenceArrayInputController>,
{
admin: {
resources: { tags: { data: {}, list: {} } },
resources: {
tags: {
data: {},
list: {},
},
},
references: { possibleValues: {} },
ui: { viewVersion: 1 },
},
Expand Down
23 changes: 18 additions & 5 deletions packages/ra-core/src/controller/useListController.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ describe('useListController', () => {
<ListController {...props} />,
{
admin: {
resources: { posts: { list: { params: {} } } },
resources: {
posts: {
list: {
params: {},
cachedRequests: {},
},
},
},
},
}
);
Expand Down Expand Up @@ -110,7 +117,10 @@ describe('useListController', () => {
resources: {
posts: {
list: {
params: { filter: { q: 'hello' } },
params: {
filter: { q: 'hello' },
},
cachedRequests: {},
},
},
},
Expand Down Expand Up @@ -150,20 +160,22 @@ describe('useListController', () => {
posts: {
list: {
params: {},
cachedRequests: {},
},
},
},
},
}
);

const crudGetListCalls = dispatch.mock.calls.filter(
call => call[0].type === 'RA/CRUD_GET_LIST'
);
expect(crudGetListCalls).toHaveLength(1);
// Check that the permanent filter was used in the query
expect(crudGetListCalls[0][0].payload.filter).toEqual({ foo: 1 });
// Check that the permanent filter is not included in the displayedFilters (passed to Filter form and button)
expect(children).toBeCalledTimes(3);
expect(children).toBeCalledTimes(2);
expect(children.mock.calls[0][0].displayedFilters).toEqual({});
// Check that the permanent filter is not included in the filterValues (passed to Filter form and button)
expect(children.mock.calls[0][0].filterValues).toEqual({});
Expand All @@ -180,9 +192,9 @@ describe('useListController', () => {
});
expect(children).toBeCalledTimes(5);
// Check that the permanent filter is not included in the displayedFilters (passed to Filter form and button)
expect(children.mock.calls[3][0].displayedFilters).toEqual({});
expect(children.mock.calls[2][0].displayedFilters).toEqual({});
// Check that the permanent filter is not included in the filterValues (passed to Filter form and button)
expect(children.mock.calls[3][0].filterValues).toEqual({});
expect(children.mock.calls[2][0].filterValues).toEqual({});
});

afterEach(() => {
Expand Down Expand Up @@ -227,6 +239,7 @@ describe('useListController', () => {
posts: {
list: {
params: { filter: { q: 'hello' } },
cachedRequests: {},
},
},
},
Expand Down
Loading