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 15 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
11 changes: 11 additions & 0 deletions examples/simple/src/dataProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ const sometimesFailsDataProvider = new Proxy(uploadCapableDataProvider, {
// if (name === 'delete' && resource === 'posts') {
// return Promise.reject(new Error('deletion error'));
// }
// test cache
if (name === 'getList' || name === 'getMany' || name === 'getOne') {
return uploadCapableDataProvider[name](resource, params).then(
response => {
const validUntil = new Date();
validUntil.setTime(validUntil.getTime() + 5 * 60 * 1000); // five minutes
response.validUntil = validUntil;
return response;
}
);
}
return uploadCapableDataProvider[name](resource, params);
},
});
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
54 changes: 37 additions & 17 deletions packages/ra-core/src/controller/useListController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,21 @@ const useListController = (props: ListProps): ListControllerProps => {
});

const [selectedIds, selectionModifiers] = useRecordSelection(resource);
const payload = {
pagination: {
page: query.page,
perPage: query.perPage,
},
sort: { field: query.sort, order: query.order },
filter: { ...query.filter, ...filter },
};
const requestSignature = JSON.stringify(payload);

/**
* We don't use useGetList() here because we want the list of ids to be
* always available for optimistic rendering, and therefore we need a
* custom action (CRUD_GET_LIST), a custom reducer for ids and total
* (admin.resources.[resource].list.ids and admin.resources.[resource].list.total)
* (admin.resources.[resource].list.idsForQueries and admin.resources.[resource].list.total)
* and a custom selector for these reducers.
* Also we don't want that calls to useGetList() in userland change
* the list of ids in the main List view.
Expand All @@ -138,14 +147,7 @@ const useListController = (props: ListProps): ListControllerProps => {
{
type: 'getList',
resource,
payload: {
pagination: {
page: query.page,
perPage: query.perPage,
},
sort: { field: query.sort, order: query.order },
filter: { ...query.filter, ...filter },
},
payload,
},
{
action: CRUD_GET_LIST,
Expand All @@ -158,14 +160,32 @@ const useListController = (props: ListProps): ListControllerProps => {
'warning'
),
},
(state: ReduxState) =>
state.admin.resources[resource]
? state.admin.resources[resource].list.ids
: null,
(state: ReduxState) =>
state.admin.resources[resource]
? state.admin.resources[resource].list.total
: null
// data selector
(state: ReduxState): Identifier[] => {
const resourceState = state.admin.resources[resource];
// if the resource isn't initialized, return null
if (!resourceState) return null;
const idsForQuery =
resourceState.list.idsForQuery[requestSignature];
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
// if the list of ids for the current request (idsForQuery) isn't loaded yet,
// return the list of ids for the previous request (ids)
return idsForQuery === undefined
? resourceState.list.ids
: idsForQuery;
},
// total selector
(state: ReduxState) => {
const resourceState = state.admin.resources[resource];
// if the resource isn't initialized, return null
if (!resourceState) return null;
const totalForQuery =
resourceState.list.totalForQuery[requestSignature];
// if the total for the current request (totalForQuery) isn't loaded yet,
// return the total for the previous request (total)
return totalForQuery === undefined
? resourceState.list.total
: totalForQuery;
}
);
const data = useSelector(
(state: ReduxState) =>
Expand Down
61 changes: 61 additions & 0 deletions packages/ra-core/src/dataProvider/replyWithCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
GetListParams,
GetListResult,
GetOneParams,
GetOneResult,
GetManyParams,
GetManyResult,
} from '../types';

export const canReplyWithCache = (type, payload, resourceState) => {
const now = new Date();
switch (type) {
case 'getList':
return (
resourceState &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could use lodash/get here:

get(resourceState, ['list', 'validity', JSON.stringify(payload as GetListParams)]);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Due to prettier, the code isn't super shorter, and due to lodash, it isn't more readable:

            // with lodash
            return (
                get(resourceState, [
                    'list',
                    'validity',
                    JSON.stringify(payload as GetListParams),
                ]) > now
            );

            // without lodash
            return (
                resourceState &&
                resourceState.list &&
                resourceState.list.validity &&
                resourceState.list.validity[
                    JSON.stringify(payload as GetListParams)
                ] > now
            );

In this case, I prefer a bit more verbosity with a bit less indirection.

resourceState.list &&
resourceState.list.validity &&
resourceState.list.validity[
JSON.stringify(payload as GetListParams)
] > now
);
case 'getOne':
return (
resourceState &&
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
resourceState.validity &&
resourceState.validity[(payload as GetOneParams).id] > now
);
case 'getMany':
return (
resourceState &&
resourceState.validity &&
(payload as GetManyParams).ids.every(
id => resourceState.validity[id] > now
)
);
default:
return false;
}
};

export const getResultFromCache = (type, payload, resourceState) => {
switch (type) {
case 'getList': {
const data = resourceState.data;
const requestSignature = JSON.stringify(payload);
const ids = resourceState.list.idsForQuery[requestSignature];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strange, are we sure resourceState.list.idsForQuery is always set ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes: if the query key was set in validity, it means it was done in idsForQuery, too.

return {
data: ids.map(id => data[id]),
total: resourceState.list.totalForQuery[requestSignature],
} as GetListResult;
}
case 'getOne':
return { data: resourceState.data[payload.id] } as GetOneResult;
case 'getMany':
return {
data: payload.ids.map(id => resourceState.data[id]),
} as GetManyResult;
default:
throw new Error('cannot reply with cache for this method');
}
};
138 changes: 121 additions & 17 deletions packages/ra-core/src/dataProvider/useDataProvider.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useContext, useMemo } from 'react';
import { Dispatch } from 'redux';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector, useStore } from 'react-redux';

import DataProviderContext from './DataProviderContext';
import validateResponseFormat from './validateResponseFormat';
import undoableEventEmitter from './undoableEventEmitter';
import getFetchType from './getFetchType';
import defaultDataProvider from './defaultDataProvider';
import { canReplyWithCache, getResultFromCache } from './replyWithCache';
import {
startOptimisticMode,
stopOptimisticMode,
Expand All @@ -22,6 +23,10 @@ import {
} from '../types';
import useLogoutIfAccessDenied from '../auth/useLogoutIfAccessDenied';

// List of dataProvider calls emitted while in optimistic mode.
// These calls get replayed once the dataProvider exits optimistic mode
const optimisticCalls = [];

/**
* Hook for getting a dataProvider
*
Expand Down Expand Up @@ -116,6 +121,7 @@ const useDataProvider = (): DataProviderProxy => {
const isOptimistic = useSelector(
(state: ReduxState) => state.admin.ui.optimistic
);
const store = useStore<ReduxState>();
const logoutIfAccessDenied = useLogoutIfAccessDenied();

const dataProviderProxy = useMemo(() => {
Expand Down Expand Up @@ -155,35 +161,91 @@ const useDataProvider = (): DataProviderProxy => {
'You must pass an onSuccess callback calling notify() to use the undoable mode'
);
}
if (isOptimistic) {
// in optimistic mode, all fetch actions are canceled,
// so the admin uses the store without synchronization
return Promise.resolve();
}

const params = {
type,
payload,
resource,
action,
rest,
onSuccess,
onFailure,
dataProvider,
dispatch,
logoutIfAccessDenied,
onFailure,
onSuccess,
payload,
resource,
rest,
store,
type,
undoable,
};
return undoable
? performUndoableQuery(params)
: performQuery(params);
if (isOptimistic) {
// in optimistic mode, all fetch calls are stacked, to be
// executed once the dataProvider leaves optimistic mode.
// In the meantime, the admin uses data from the store.
optimisticCalls.push(params);
return Promise.resolve();
}
return doQuery(params);
};
},
});
}, [dataProvider, dispatch, isOptimistic, logoutIfAccessDenied]);
}, [dataProvider, dispatch, isOptimistic, logoutIfAccessDenied, store]);

return dataProviderProxy;
};

const doQuery = ({
type,
payload,
resource,
action,
rest,
onSuccess,
onFailure,
dataProvider,
dispatch,
store,
undoable,
logoutIfAccessDenied,
}) => {
const resourceState = store.getState().admin.resources[resource];
if (canReplyWithCache(type, payload, resourceState)) {
return answerWithCache({
type,
payload,
resource,
action,
rest,
onSuccess,
resourceState,
dispatch,
});
}
return undoable
? performUndoableQuery({
type,
payload,
resource,
action,
rest,
onSuccess,
onFailure,
dataProvider,
dispatch,
logoutIfAccessDenied,
})
: performQuery({
type,
payload,
resource,
action,
rest,
onSuccess,
onFailure,
dataProvider,
dispatch,
logoutIfAccessDenied,
});
};

/**
* In undoable mode, the hook dispatches an optimistic action and executes
* the success side effects right away. Then it waits for a few seconds to
Expand Down Expand Up @@ -267,7 +329,7 @@ const performUndoableQuery = ({
warnBeforeClosingWindow
);
}
dispatch(refreshView());
replayOptimisticCalls();
})
.catch(error => {
if (window) {
Expand Down Expand Up @@ -317,6 +379,16 @@ const warnBeforeClosingWindow = event => {
return 'Your latest modifications are not yet sent to the server. Are you sure?'; // Old IE
};

// Replay calls recorded while in optimistic mode
const replayOptimisticCalls = () => {
Promise.all(
optimisticCalls.map(params =>
Promise.resolve(doQuery.call(null, params))
)
);
optimisticCalls.splice(0, optimisticCalls.length);
};

/**
* In normal mode, the hook calls the dataProvider. When a successful response
* arrives, the hook dispatches a SUCCESS action, executes success side effects
Expand Down Expand Up @@ -400,6 +472,38 @@ const performQuery = ({
}
};

const answerWithCache = ({
type,
payload,
resource,
action,
rest,
onSuccess,
resourceState,
dispatch,
}) => {
dispatch({
type: action,
payload,
meta: { resource, ...rest },
});
const response = getResultFromCache(type, payload, resourceState);
dispatch({
type: `${action}_SUCCESS`,
payload: response,
requestPayload: payload,
meta: {
...rest,
resource,
fetchResponse: getFetchType(type),
fetchStatus: FETCH_END,
fromCache: true,
},
});
onSuccess && onSuccess(response);
return Promise.resolve(response);
};

interface QueryFunctionParams {
/** The fetch type, e.g. `UPDATE_MANY` */
type: string;
Expand Down
Loading