From e266f357a7026eae7f3a47897cada363d01f528a Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 4 Feb 2020 18:22:34 +0100 Subject: [PATCH 01/29] Initial prototype --- .../src/dataProvider/replyWithCache.ts | 35 ++++++ .../src/dataProvider/useDataProvider.ts | 62 +++++++++- .../src/reducer/admin/resource/index.ts | 6 + .../src/reducer/admin/resource/validity.ts | 109 ++++++++++++++++++ packages/ra-core/src/types.ts | 29 ++++- 5 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 packages/ra-core/src/dataProvider/replyWithCache.ts create mode 100644 packages/ra-core/src/reducer/admin/resource/validity.ts diff --git a/packages/ra-core/src/dataProvider/replyWithCache.ts b/packages/ra-core/src/dataProvider/replyWithCache.ts new file mode 100644 index 00000000000..b1818644241 --- /dev/null +++ b/packages/ra-core/src/dataProvider/replyWithCache.ts @@ -0,0 +1,35 @@ +import { + GetOneParams, + GetOneResult, + GetManyParams, + GetManyResult, +} from '../types'; + +export const canReplyWithCache = (type, resource, payload, resourcesData) => { + const resourceData = resourcesData[resource]; + const now = new Date(); + switch (type) { + case 'getOne': + return resourceData.validity[(payload as GetOneParams).id] > now; + case 'getMany': + return (payload as GetManyParams).ids.every( + id => resourceData.validity[id] > now + ); + default: + return false; + } +}; + +export const getResultFromCache = (type, resource, payload, resourcesData) => { + const resourceData = resourcesData[resource]; + switch (type) { + case 'getOne': + return { data: resourceData.data[payload.id] } as GetOneResult; + case 'getMany': + return { + data: payload.ids.map(id => resourceData.data[id]), + } as GetManyResult; + default: + throw new Error('cannot reply with cache for this method'); + } +}; diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index 046ac47c69f..d6d62e104e4 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -7,6 +7,7 @@ import validateResponseFormat from './validateResponseFormat'; import undoableEventEmitter from './undoableEventEmitter'; import getFetchType from './getFetchType'; import defaultDataProvider from './defaultDataProvider'; +import { canReplyWithCache, getResultFromCache } from './replyWithCache'; import { startOptimisticMode, stopOptimisticMode, @@ -116,6 +117,9 @@ const useDataProvider = (): DataProviderProxy => { const isOptimistic = useSelector( (state: ReduxState) => state.admin.ui.optimistic ); + const resourcesData = useSelector( + (state: ReduxState) => state.admin.resources + ); const logoutIfAccessDenied = useLogoutIfAccessDenied(); const dataProviderProxy = useMemo(() => { @@ -161,6 +165,25 @@ const useDataProvider = (): DataProviderProxy => { return Promise.resolve(); } + if ( + canReplyWithCache( + name, + resource, + payload, + resourcesData + ) + ) { + return answerWithCache({ + type, + payload, + resource, + action, + rest, + onSuccess, + resourcesData, + dispatch, + }); + } const params = { type, payload, @@ -179,7 +202,13 @@ const useDataProvider = (): DataProviderProxy => { }; }, }); - }, [dataProvider, dispatch, isOptimistic, logoutIfAccessDenied]); + }, [ + dataProvider, + dispatch, + isOptimistic, + logoutIfAccessDenied, + resourcesData, + ]); return dataProviderProxy; }; @@ -400,6 +429,37 @@ const performQuery = ({ } }; +const answerWithCache = ({ + type, + payload, + resource, + action, + rest, + onSuccess, + resourcesData, + dispatch, +}) => { + dispatch({ + type: action, + payload, + meta: { resource, ...rest }, + }); + const response = getResultFromCache(type, resource, payload, resourcesData); + dispatch({ + type: `${action}_SUCCESS`, + payload: response, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_END, + }, + }); + onSuccess && onSuccess(response); + return Promise.resolve(response); +}; + interface QueryFunctionParams { /** The fetch type, e.g. `UPDATE_MANY` */ type: string; diff --git a/packages/ra-core/src/reducer/admin/resource/index.ts b/packages/ra-core/src/reducer/admin/resource/index.ts index 1f0dc17346a..012450cca15 100644 --- a/packages/ra-core/src/reducer/admin/resource/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/index.ts @@ -7,6 +7,7 @@ import { import data from './data'; import list from './list'; +import validity from './validity'; const initialState = {}; @@ -21,6 +22,7 @@ export default (previousState = initialState, action: ActionTypes) => { props: action.payload, data: data(undefined, action), list: list(undefined, action), + validity: validity(undefined, action), }; return { ...previousState, @@ -52,6 +54,10 @@ export default (previousState = initialState, action: ActionTypes) => { props: previousState[resource].props, data: data(previousState[resource].data, action), list: list(previousState[resource].list, action), + validity: validity( + previousState[resource].validity, + action + ), } : previousState[resource], }), diff --git a/packages/ra-core/src/reducer/admin/resource/validity.ts b/packages/ra-core/src/reducer/admin/resource/validity.ts new file mode 100644 index 00000000000..853a93f8019 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/resource/validity.ts @@ -0,0 +1,109 @@ +import { Reducer } from 'redux'; +import { FETCH_END } from '../../../actions'; +import { + CREATE, + DELETE, + DELETE_MANY, + GET_LIST, + GET_MANY, + GET_MANY_REFERENCE, + GET_ONE, + UPDATE, + UPDATE_MANY, +} from '../../../core'; +import { Identifier } from '../../../types'; + +interface ValidityRegistry { + // FIXME: use [key: Identifier] once typeScript accepts any type as index (see https://github.com/Microsoft/TypeScript/pull/26797) + [key: string]: Date; + [key: number]: Date; +} + +const initialState = {}; + +const validityReducer: Reducer = ( + previousState = initialState, + { payload, requestPayload, meta } +) => { + if (!meta || !meta.fetchResponse || meta.fetchStatus !== FETCH_END) { + return previousState; + } + if (payload.validUntil) { + // store the validity date + switch (meta.fetchResponse) { + case GET_LIST: + case GET_MANY: + case GET_MANY_REFERENCE: + return addIds( + payload.data.map(record => record.id), + payload.validUntil, + previousState + ); + case UPDATE_MANY: + return addIds(payload.data, payload.validUntil, previousState); + case UPDATE: + case CREATE: + case GET_ONE: + return addIds( + [payload.data.id], + payload.validUntil, + previousState + ); + case DELETE: + case DELETE_MANY: + throw new Error( + 'Responses to dataProvider.delete() or dataProvider.deleteMany() should not contain a validUntil param' + ); + default: + return previousState; + } + } else { + // remove the validity date + switch (meta.fetchResponse) { + case GET_LIST: + case GET_MANY: + case GET_MANY_REFERENCE: + return removeIds( + payload.data.map(record => record.id), + previousState + ); + case UPDATE: + case CREATE: + case GET_ONE: + return removeIds([payload.data.id], previousState); + case UPDATE_MANY: + return removeIds(payload.data, previousState); + case DELETE: + return removeIds([requestPayload.id], previousState); + case DELETE_MANY: + return removeIds(requestPayload.ids, previousState); + default: + return previousState; + } + } +}; + +const addIds = ( + ids: Identifier[] = [], + validUntil: Date, + oldValidityRegistry: ValidityRegistry +): ValidityRegistry => { + const validityRegistry = { ...oldValidityRegistry }; + ids.forEach(id => { + validityRegistry[id] = validUntil; + }); + return validityRegistry; +}; + +const removeIds = ( + ids: Identifier[] = [], + oldValidityRegistry: ValidityRegistry +): ValidityRegistry => { + const validityRegistry = { ...oldValidityRegistry }; + ids.forEach(id => { + delete validityRegistry[id]; + }); + return validityRegistry; +}; + +export default validityReducer; diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 500ce81d75e..703860d83e8 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -33,7 +33,7 @@ export interface Pagination { page: number; perPage: number; } - +export type ValidUntil = Date; /** * i18nProvider types */ @@ -117,6 +117,7 @@ export interface GetListParams { export interface GetListResult { data: Record[]; total: number; + validUntil?: ValidUntil; } export interface GetOneParams { @@ -124,6 +125,7 @@ export interface GetOneParams { } export interface GetOneResult { data: Record; + validUntil?: ValidUntil; } export interface GetManyParams { @@ -131,6 +133,7 @@ export interface GetManyParams { } export interface GetManyResult { data: Record[]; + validUntil?: ValidUntil; } export interface GetManyReferenceParams { @@ -143,6 +146,7 @@ export interface GetManyReferenceParams { export interface GetManyReferenceResult { data: Record[]; total: number; + validUntil?: ValidUntil; } export interface UpdateParams { @@ -152,6 +156,7 @@ export interface UpdateParams { } export interface UpdateResult { data: Record; + validUntil?: ValidUntil; } export interface UpdateManyParams { @@ -160,6 +165,7 @@ export interface UpdateManyParams { } export interface UpdateManyResult { data?: Identifier[]; + validUntil?: ValidUntil; } export interface CreateParams { @@ -167,6 +173,7 @@ export interface CreateParams { } export interface CreateResult { data: Record; + validUntil?: ValidUntil; } export interface DeleteParams { @@ -183,6 +190,17 @@ export interface DeleteManyResult { data?: Identifier[]; } +export type DataProviderResult = + | CreateResult + | DeleteResult + | DeleteManyResult + | GetListResult + | GetManyResult + | GetManyReferenceResult + | GetOneResult + | UpdateResult + | UpdateManyResult; + export type DataProviderProxy = { getList: ( resource: string, @@ -269,7 +287,10 @@ export interface ReduxState { }; resources: { [name: string]: { - data: any; + data: { + [key: string]: Record; + [key: number]: Record; + }; list: { params: any; ids: Identifier[]; @@ -277,6 +298,10 @@ export interface ReduxState { selectedIds: Identifier[]; total: number; }; + validity: { + [key: string]: Date; + [key: number]: Date; + }; }; }; references: { From 97d2212d4367025f10133e415d08ea56d18a4133 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 4 Feb 2020 18:43:01 +0100 Subject: [PATCH 02/29] Fix infinite loop --- .../src/dataProvider/replyWithCache.ts | 14 ++++---- .../src/dataProvider/useDataProvider.ts | 34 ++++++------------- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/packages/ra-core/src/dataProvider/replyWithCache.ts b/packages/ra-core/src/dataProvider/replyWithCache.ts index b1818644241..01d6aed354d 100644 --- a/packages/ra-core/src/dataProvider/replyWithCache.ts +++ b/packages/ra-core/src/dataProvider/replyWithCache.ts @@ -5,29 +5,27 @@ import { GetManyResult, } from '../types'; -export const canReplyWithCache = (type, resource, payload, resourcesData) => { - const resourceData = resourcesData[resource]; +export const canReplyWithCache = (type, payload, resourceState) => { const now = new Date(); switch (type) { case 'getOne': - return resourceData.validity[(payload as GetOneParams).id] > now; + return resourceState.validity[(payload as GetOneParams).id] > now; case 'getMany': return (payload as GetManyParams).ids.every( - id => resourceData.validity[id] > now + id => resourceState.validity[id] > now ); default: return false; } }; -export const getResultFromCache = (type, resource, payload, resourcesData) => { - const resourceData = resourcesData[resource]; +export const getResultFromCache = (type, payload, resourceState) => { switch (type) { case 'getOne': - return { data: resourceData.data[payload.id] } as GetOneResult; + return { data: resourceState.data[payload.id] } as GetOneResult; case 'getMany': return { - data: payload.ids.map(id => resourceData.data[id]), + data: payload.ids.map(id => resourceState.data[id]), } as GetManyResult; default: throw new Error('cannot reply with cache for this method'); diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index d6d62e104e4..b731991a1ee 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -1,6 +1,6 @@ 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'; @@ -117,9 +117,7 @@ const useDataProvider = (): DataProviderProxy => { const isOptimistic = useSelector( (state: ReduxState) => state.admin.ui.optimistic ); - const resourcesData = useSelector( - (state: ReduxState) => state.admin.resources - ); + const store = useStore(); const logoutIfAccessDenied = useLogoutIfAccessDenied(); const dataProviderProxy = useMemo(() => { @@ -165,22 +163,18 @@ const useDataProvider = (): DataProviderProxy => { return Promise.resolve(); } - if ( - canReplyWithCache( - name, - resource, - payload, - resourcesData - ) - ) { + const resourceState = store.getState().admin.resources[ + resource + ]; + if (canReplyWithCache(name, payload, resourceState)) { return answerWithCache({ type, payload, - resource, action, rest, onSuccess, - resourcesData, + resource, + resourceState, dispatch, }); } @@ -202,13 +196,7 @@ const useDataProvider = (): DataProviderProxy => { }; }, }); - }, [ - dataProvider, - dispatch, - isOptimistic, - logoutIfAccessDenied, - resourcesData, - ]); + }, [dataProvider, dispatch, isOptimistic, logoutIfAccessDenied, store]); return dataProviderProxy; }; @@ -436,7 +424,7 @@ const answerWithCache = ({ action, rest, onSuccess, - resourcesData, + resourceState, dispatch, }) => { dispatch({ @@ -444,7 +432,7 @@ const answerWithCache = ({ payload, meta: { resource, ...rest }, }); - const response = getResultFromCache(type, resource, payload, resourcesData); + const response = getResultFromCache(type, payload, resourceState); dispatch({ type: `${action}_SUCCESS`, payload: response, From 1565d3aacaf14268e35f4f1e763dd26b11127616 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 4 Feb 2020 18:54:45 +0100 Subject: [PATCH 03/29] Refresh clears the cache --- .../ra-core/src/reducer/admin/resource/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/ra-core/src/reducer/admin/resource/index.ts b/packages/ra-core/src/reducer/admin/resource/index.ts index 012450cca15..20ee44692ce 100644 --- a/packages/ra-core/src/reducer/admin/resource/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/index.ts @@ -3,6 +3,8 @@ import { RegisterResourceAction, UNREGISTER_RESOURCE, UnregisterResourceAction, + REFRESH_VIEW, + RefreshViewAction, } from '../../../actions'; import data from './data'; @@ -14,6 +16,7 @@ const initialState = {}; type ActionTypes = | RegisterResourceAction | UnregisterResourceAction + | RefreshViewAction | { type: 'OTHER_ACTION'; payload?: any; meta?: { resource?: string } }; export default (previousState = initialState, action: ActionTypes) => { @@ -40,6 +43,19 @@ export default (previousState = initialState, action: ActionTypes) => { }, {}); } + if (action.type === REFRESH_VIEW) { + return Object.keys(previousState).reduce( + (acc, resource) => ({ + ...acc, + [resource]: { + ...previousState[resource], + validity: {}, + }, + }), + {} + ); + } + if (!action.meta || !action.meta.resource) { return previousState; } From 9348fae4ce6aafe86e81703af844f1861a0e6372 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 5 Feb 2020 09:00:20 +0100 Subject: [PATCH 04/29] Set cache in dataProvider --- examples/simple/src/dataProvider.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/examples/simple/src/dataProvider.js b/examples/simple/src/dataProvider.js index b6328174ba1..92481c3aee4 100644 --- a/examples/simple/src/dataProvider.js +++ b/examples/simple/src/dataProvider.js @@ -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); }, }); From f64f1042eda11ae25862cabcbbee45a47687aba2 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 5 Feb 2020 12:01:22 +0100 Subject: [PATCH 05/29] Fix read from cache evicts record --- packages/ra-core/src/dataProvider/useDataProvider.ts | 1 + packages/ra-core/src/reducer/admin/resource/validity.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index b731991a1ee..7664b83efd0 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -442,6 +442,7 @@ const answerWithCache = ({ resource, fetchResponse: getFetchType(type), fetchStatus: FETCH_END, + fromCache: true, }, }); onSuccess && onSuccess(response); diff --git a/packages/ra-core/src/reducer/admin/resource/validity.ts b/packages/ra-core/src/reducer/admin/resource/validity.ts index 853a93f8019..358a4d43c5c 100644 --- a/packages/ra-core/src/reducer/admin/resource/validity.ts +++ b/packages/ra-core/src/reducer/admin/resource/validity.ts @@ -25,7 +25,12 @@ const validityReducer: Reducer = ( previousState = initialState, { payload, requestPayload, meta } ) => { - if (!meta || !meta.fetchResponse || meta.fetchStatus !== FETCH_END) { + if ( + !meta || + !meta.fetchResponse || + meta.fetchStatus !== FETCH_END || + meta.fromCache === true + ) { return previousState; } if (payload.validUntil) { From ec705b800eaed9ddf76d88ba2496fab4f1d285c2 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 17 Feb 2020 08:32:32 +0100 Subject: [PATCH 06/29] Fix unit tests --- .../ra-core/src/dataProvider/replyWithCache.ts | 14 +++++++++++--- .../src/reducer/admin/resource/index.spec.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/ra-core/src/dataProvider/replyWithCache.ts b/packages/ra-core/src/dataProvider/replyWithCache.ts index 01d6aed354d..6291d18074f 100644 --- a/packages/ra-core/src/dataProvider/replyWithCache.ts +++ b/packages/ra-core/src/dataProvider/replyWithCache.ts @@ -9,10 +9,18 @@ export const canReplyWithCache = (type, payload, resourceState) => { const now = new Date(); switch (type) { case 'getOne': - return resourceState.validity[(payload as GetOneParams).id] > now; + return ( + resourceState && + resourceState.validity && + resourceState.validity[(payload as GetOneParams).id] > now + ); case 'getMany': - return (payload as GetManyParams).ids.every( - id => resourceState.validity[id] > now + return ( + resourceState && + resourceState.validity && + (payload as GetManyParams).ids.every( + id => resourceState.validity[id] > now + ) ); default: return false; diff --git a/packages/ra-core/src/reducer/admin/resource/index.spec.ts b/packages/ra-core/src/reducer/admin/resource/index.spec.ts index d0b0fe1f98f..2c8b87e8963 100644 --- a/packages/ra-core/src/reducer/admin/resource/index.spec.ts +++ b/packages/ra-core/src/reducer/admin/resource/index.spec.ts @@ -33,6 +33,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'posts' }, }, comments: { @@ -50,6 +51,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'comments' }, }, }, @@ -77,6 +79,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'posts' }, }, comments: { @@ -94,6 +97,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'comments' }, }, users: { @@ -111,6 +115,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'users', options: 'foo' }, }, }); @@ -135,6 +140,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'posts' }, }, comments: { @@ -152,6 +158,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'comments' }, }, }, @@ -176,6 +183,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'posts' }, }, }); @@ -200,6 +208,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'posts' }, }, comments: { @@ -217,6 +226,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'comments' }, }, }, @@ -249,6 +259,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'posts' }, }, comments: { @@ -266,6 +277,7 @@ describe('Resources Reducer', () => { total: 0, loadedOnce: false, }, + validity: {}, props: { name: 'comments' }, }, }); From 710d3c1bedd0e89da0da8ef1ceb61b702557f804 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 17 Feb 2020 14:25:51 +0100 Subject: [PATCH 07/29] Delay queries in optimistic mode instead of cancelling them The removes the need for a refresh() once the optimistic mode finishes, and allows better usage of the cache even in optimistic mode --- .../src/dataProvider/useDataProvider.ts | 115 +++++++++++++----- 1 file changed, 85 insertions(+), 30 deletions(-) diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index 7664b83efd0..81599e0f2d8 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -23,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 * @@ -157,42 +161,29 @@ 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 resourceState = store.getState().admin.resources[ - resource - ]; - if (canReplyWithCache(name, payload, resourceState)) { - return answerWithCache({ - type, - payload, - action, - rest, - onSuccess, - resource, - resourceState, - dispatch, - }); - } 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); }; }, }); @@ -201,6 +192,60 @@ const useDataProvider = (): DataProviderProxy => { 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 @@ -284,7 +329,7 @@ const performUndoableQuery = ({ warnBeforeClosingWindow ); } - dispatch(refreshView()); + replayOptimisticCalls(); }) .catch(error => { if (window) { @@ -334,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 From 42a6be3a4951b2012201d9612c700a05d75c73af Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 09:19:29 +0100 Subject: [PATCH 08/29] Store one list of ids per request This changes the way the list is displayed when updating params (sort, pargination, filter): the list empties first, to show that the request is loading, then displayes the updated results. --- .../src/controller/useListController.ts | 23 ++++---- .../src/dataProvider/useQueryWithStore.ts | 19 +++++- .../admin/resource/list/idsForQuery.ts | 59 +++++++++++++++++++ .../src/reducer/admin/resource/list/index.ts | 4 +- packages/ra-core/src/types.ts | 3 +- 5 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index bac81a86097..0dd409501ad 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -124,12 +124,20 @@ 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 }, + }; /** * 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. @@ -138,14 +146,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, @@ -160,7 +161,9 @@ const useListController = (props: ListProps): ListControllerProps => { }, (state: ReduxState) => state.admin.resources[resource] - ? state.admin.resources[resource].list.ids + ? state.admin.resources[resource].list.idsForQuery[ + JSON.stringify(payload) + ] : null, (state: ReduxState) => state.admin.resources[resource] diff --git a/packages/ra-core/src/dataProvider/useQueryWithStore.ts b/packages/ra-core/src/dataProvider/useQueryWithStore.ts index 3647f5c9642..cb662396daa 100644 --- a/packages/ra-core/src/dataProvider/useQueryWithStore.ts +++ b/packages/ra-core/src/dataProvider/useQueryWithStore.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import isEqual from 'lodash/isEqual'; @@ -107,6 +107,8 @@ const useQueryWithStore = ( loaded: boolean; } => { const { type, resource, payload } = query; + const requestSignature = JSON.stringify({ query, options }); + const requestSignatureRef = useRef(requestSignature); const data = useSelector(dataSelector); const total = useSelector(totalSelector); const [state, setState] = useSafeSetState({ @@ -116,7 +118,18 @@ const useQueryWithStore = ( loading: true, loaded: data !== undefined && !isEmptyList(data), }); - if (!isEqual(state.data, data) || state.total !== total) { + if (requestSignatureRef.current !== requestSignature) { + // request has changed, reset the loading state + requestSignatureRef.current = requestSignature; + setState({ + data, + total, + error: null, + loading: true, + loaded: data !== undefined && !isEmptyList(data), + }); + } else if (!isEqual(state.data, data) || state.total !== total) { + // the dataProvider response arrived in the Redux store if (isNaN(total)) { console.error( 'Total from response is not a number. Please check your dataProvider or the API.' @@ -157,7 +170,7 @@ const useQueryWithStore = ( }); }); // deep equality, see https://github.com/facebook/react/issues/14476#issuecomment-471199055 - }, [JSON.stringify({ query, options })]); // eslint-disable-line + }, [requestSignature]); // eslint-disable-line return state; }; diff --git a/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts b/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts new file mode 100644 index 00000000000..39d18ff8f1f --- /dev/null +++ b/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts @@ -0,0 +1,59 @@ +import { Reducer } from 'redux'; +import without from 'lodash/without'; +import mapValues from 'lodash/mapValues'; +import { + CRUD_GET_LIST_SUCCESS, + CrudGetListSuccessAction, + CrudGetOneSuccessAction, +} from '../../../../actions'; +import { DELETE, DELETE_MANY } from '../../../../core'; +import { Identifier } from '../../../../types'; + +type IdentifierArray = Identifier[]; + +interface State { + [key: string]: IdentifierArray; +} + +type ActionTypes = + | CrudGetListSuccessAction + | CrudGetOneSuccessAction + | { + type: 'OTHER_ACTION'; + payload: any; + meta: any; + }; + +const initialState = {}; + +const idsForQueryReducer: Reducer = ( + previousState = initialState, + action: ActionTypes +) => { + if (action.meta && action.meta.optimistic) { + if (action.meta.fetch === DELETE) { + return mapValues(previousState, ids => + without(ids, action.payload.id) + ); + } + if (action.meta.fetch === DELETE_MANY) { + return mapValues(previousState, ids => + without(ids, ...action.payload.ids) + ); + } + } + + switch (action.type) { + case CRUD_GET_LIST_SUCCESS: + return { + ...previousState, + [JSON.stringify( + action.requestPayload + )]: action.payload.data.map(({ id }) => id), + }; + default: + return previousState; + } +}; + +export default idsForQueryReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/index.ts b/packages/ra-core/src/reducer/admin/resource/list/index.ts index c969af4bf12..9e08fa52287 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/index.ts @@ -1,5 +1,6 @@ import { combineReducers } from 'redux'; import ids from './ids'; +import idsForQuery from './idsForQuery'; import loadedOnce from './loadedOnce'; import params from './params'; import selectedIds from './selectedIds'; @@ -15,7 +16,8 @@ export default combineReducers({ * * @see https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests */ - ids: ids || defaultReducer, + ids: ids || defaultReducer, // @deprecated, use idsForQuery instead + idsForQuery: idsForQuery || defaultReducer, loadedOnce: loadedOnce || defaultReducer, params: params || defaultReducer, selectedIds: selectedIds || defaultReducer, diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 703860d83e8..169e57d6a18 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -293,7 +293,8 @@ export interface ReduxState { }; list: { params: any; - ids: Identifier[]; + ids: Identifier[]; // @deprecated, use idsForQuery instead + idsForQuery: { [key: string]: Identifier[] }; loadedOnce: boolean; selectedIds: Identifier[]; total: number; From 1ed8d68ae5ed923b52daa42e71d38a50266e2637 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 09:38:34 +0100 Subject: [PATCH 09/29] Dos not display a blank page when changing list params --- .../src/controller/useListController.ts | 19 +++++++++++++------ .../src/reducer/admin/resource/list/ids.ts | 12 ++++++++++++ .../src/reducer/admin/resource/list/index.ts | 2 +- packages/ra-core/src/types.ts | 2 +- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index 0dd409501ad..621cb7ccb42 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -159,12 +159,19 @@ const useListController = (props: ListProps): ListControllerProps => { 'warning' ), }, - (state: ReduxState) => - state.admin.resources[resource] - ? state.admin.resources[resource].list.idsForQuery[ - JSON.stringify(payload) - ] - : null, + (state: ReduxState): Identifier[] => { + // grab the ids from the state + const resourceState = state.admin.resources[resource]; + // if the resource isn't initialized, return null + if (!resourceState) return null; + const idsForQuery = + resourceState.list.idsForQuery[JSON.stringify(payload)]; + // 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; + }, (state: ReduxState) => state.admin.resources[resource] ? state.admin.resources[resource].list.total diff --git a/packages/ra-core/src/reducer/admin/resource/list/ids.ts b/packages/ra-core/src/reducer/admin/resource/list/ids.ts index 2e3d1b9225b..8c3c682186c 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/ids.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/ids.ts @@ -22,6 +22,18 @@ type ActionTypes = meta: any; }; +/** + * List of the ids of the latest loaded page, regardless of params + * + * When loading a the list for the first time, useListController grabs the ids + * from the idsForQuery reducer (not this ids reducer). It's only when the user + * changes page, sort, or filter, that the useListController hook uses the ids + * reducer, so as to show the previous list of results while loading the new + * list (intead of displaying a blank page each time the list params change). + * + * @see useListController + * + */ const idsReducer: Reducer = ( previousState = [], action: ActionTypes diff --git a/packages/ra-core/src/reducer/admin/resource/list/index.ts b/packages/ra-core/src/reducer/admin/resource/list/index.ts index 9e08fa52287..e2e546273c9 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/index.ts @@ -16,7 +16,7 @@ export default combineReducers({ * * @see https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests */ - ids: ids || defaultReducer, // @deprecated, use idsForQuery instead + ids: ids || defaultReducer, idsForQuery: idsForQuery || defaultReducer, loadedOnce: loadedOnce || defaultReducer, params: params || defaultReducer, diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 169e57d6a18..220838d3e81 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -293,7 +293,7 @@ export interface ReduxState { }; list: { params: any; - ids: Identifier[]; // @deprecated, use idsForQuery instead + ids: Identifier[]; idsForQuery: { [key: string]: Identifier[] }; loadedOnce: boolean; selectedIds: Identifier[]; From 014e9b48e545d838bd71748ac9c3db925c4da68b Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 09:55:28 +0100 Subject: [PATCH 10/29] Add list validity reducer --- .../src/reducer/admin/resource/list/index.ts | 2 + .../reducer/admin/resource/list/validity.ts | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 packages/ra-core/src/reducer/admin/resource/list/validity.ts diff --git a/packages/ra-core/src/reducer/admin/resource/list/index.ts b/packages/ra-core/src/reducer/admin/resource/list/index.ts index e2e546273c9..9092fb48616 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/index.ts @@ -5,6 +5,7 @@ import loadedOnce from './loadedOnce'; import params from './params'; import selectedIds from './selectedIds'; import total from './total'; +import validity from './validity'; const defaultReducer = () => null; @@ -22,4 +23,5 @@ export default combineReducers({ params: params || defaultReducer, selectedIds: selectedIds || defaultReducer, total: total || defaultReducer, + validity: validity || defaultReducer, }); diff --git a/packages/ra-core/src/reducer/admin/resource/list/validity.ts b/packages/ra-core/src/reducer/admin/resource/list/validity.ts new file mode 100644 index 00000000000..6e7b0ffa28c --- /dev/null +++ b/packages/ra-core/src/reducer/admin/resource/list/validity.ts @@ -0,0 +1,40 @@ +import { Reducer } from 'redux'; +import { FETCH_END } from '../../../../actions'; +import { GET_LIST } from '../../../../core'; + +interface ValidityRegistry { + [key: string]: Date; +} + +const initialState = {}; + +const validityReducer: Reducer = ( + previousState = initialState, + { payload, requestPayload, meta } +) => { + if ( + !meta || + !meta.fetchResponse || + meta.fetchStatus !== FETCH_END || + meta.fromCache === true || + meta.fetchResponse !== GET_LIST + ) { + return previousState; + } + if (payload.validUntil) { + // store the validity date + return { + ...previousState, + [JSON.stringify(requestPayload)]: new Date(), + }; + } else { + // remove the validity date + const { + [JSON.stringify(requestPayload)]: value, + ...rest + } = previousState; + return rest; + } +}; + +export default validityReducer; From aca8b53bcf7f2e5f29b29605b5b82c4a6864617b Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 10:11:46 +0100 Subject: [PATCH 11/29] cache getList calls --- .../src/dataProvider/replyWithCache.ts | 19 +++++++++++++++++++ .../reducer/admin/resource/list/validity.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/ra-core/src/dataProvider/replyWithCache.ts b/packages/ra-core/src/dataProvider/replyWithCache.ts index 6291d18074f..22a1db1c512 100644 --- a/packages/ra-core/src/dataProvider/replyWithCache.ts +++ b/packages/ra-core/src/dataProvider/replyWithCache.ts @@ -1,4 +1,6 @@ import { + GetListParams, + GetListResult, GetOneParams, GetOneResult, GetManyParams, @@ -8,6 +10,15 @@ import { export const canReplyWithCache = (type, payload, resourceState) => { const now = new Date(); switch (type) { + case 'getList': + return ( + resourceState && + resourceState.list && + resourceState.list.validity && + resourceState.list.validity[ + JSON.stringify(payload as GetListParams) + ] > now + ); case 'getOne': return ( resourceState && @@ -29,6 +40,14 @@ export const canReplyWithCache = (type, payload, resourceState) => { export const getResultFromCache = (type, payload, resourceState) => { switch (type) { + case 'getList': { + const data = resourceState.data; + const ids = resourceState.list.idsForQuery[JSON.stringify(payload)]; + return { + data: ids.map(id => data[id]), + total: resourceState.list.total, // FIXME should be request dependent + } as GetListResult; + } case 'getOne': return { data: resourceState.data[payload.id] } as GetOneResult; case 'getMany': diff --git a/packages/ra-core/src/reducer/admin/resource/list/validity.ts b/packages/ra-core/src/reducer/admin/resource/list/validity.ts index 6e7b0ffa28c..8b67d66ad47 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/validity.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/validity.ts @@ -25,7 +25,7 @@ const validityReducer: Reducer = ( // store the validity date return { ...previousState, - [JSON.stringify(requestPayload)]: new Date(), + [JSON.stringify(requestPayload)]: payload.validUntil, }; } else { // remove the validity date From f4332f20f47ca030670bcd27a2e7c3f7b64ca90a Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 10:27:30 +0100 Subject: [PATCH 12/29] Fix total for query in cache --- .../src/controller/useListController.ts | 22 +++++++++---- .../src/dataProvider/replyWithCache.ts | 5 +-- .../src/reducer/admin/resource/list/index.ts | 2 ++ .../admin/resource/list/totalForQuery.ts | 32 +++++++++++++++++++ packages/ra-core/src/types.ts | 1 + 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index 621cb7ccb42..96aa4fc5f93 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -132,6 +132,7 @@ const useListController = (props: ListProps): ListControllerProps => { 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 @@ -159,23 +160,32 @@ const useListController = (props: ListProps): ListControllerProps => { 'warning' ), }, + // data selector (state: ReduxState): Identifier[] => { - // grab the ids from the state const resourceState = state.admin.resources[resource]; // if the resource isn't initialized, return null if (!resourceState) return null; const idsForQuery = - resourceState.list.idsForQuery[JSON.stringify(payload)]; + resourceState.list.idsForQuery[requestSignature]; // 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; }, - (state: ReduxState) => - state.admin.resources[resource] - ? state.admin.resources[resource].list.total - : null + // 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) => diff --git a/packages/ra-core/src/dataProvider/replyWithCache.ts b/packages/ra-core/src/dataProvider/replyWithCache.ts index 22a1db1c512..e4bcd9ef8c5 100644 --- a/packages/ra-core/src/dataProvider/replyWithCache.ts +++ b/packages/ra-core/src/dataProvider/replyWithCache.ts @@ -42,10 +42,11 @@ export const getResultFromCache = (type, payload, resourceState) => { switch (type) { case 'getList': { const data = resourceState.data; - const ids = resourceState.list.idsForQuery[JSON.stringify(payload)]; + const requestSignature = JSON.stringify(payload); + const ids = resourceState.list.idsForQuery[requestSignature]; return { data: ids.map(id => data[id]), - total: resourceState.list.total, // FIXME should be request dependent + total: resourceState.list.totalForQuery[requestSignature], } as GetListResult; } case 'getOne': diff --git a/packages/ra-core/src/reducer/admin/resource/list/index.ts b/packages/ra-core/src/reducer/admin/resource/list/index.ts index 9092fb48616..663e916ea9b 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/index.ts @@ -5,6 +5,7 @@ import loadedOnce from './loadedOnce'; import params from './params'; import selectedIds from './selectedIds'; import total from './total'; +import totalForQuery from './totalForQuery'; import validity from './validity'; const defaultReducer = () => null; @@ -23,5 +24,6 @@ export default combineReducers({ params: params || defaultReducer, selectedIds: selectedIds || defaultReducer, total: total || defaultReducer, + totalForQuery: totalForQuery || defaultReducer, validity: validity || defaultReducer, }); diff --git a/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts b/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts new file mode 100644 index 00000000000..03c926533e0 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts @@ -0,0 +1,32 @@ +import { Reducer } from 'redux'; +import { + CRUD_GET_LIST_SUCCESS, + CrudGetListSuccessAction, +} from '../../../../actions/dataActions'; + +type ActionTypes = + | CrudGetListSuccessAction + | { + type: 'OTHER_TYPE'; + payload?: { ids: string[] }; + meta?: { optimistic?: boolean; fetch?: string }; + }; + +type State = { [key: string]: number }; + +const initialState = {}; + +const totalReducer: Reducer = ( + previousState = initialState, + action: ActionTypes +) => { + if (action.type === CRUD_GET_LIST_SUCCESS) { + return { + ...previousState, + [JSON.stringify(action.requestPayload)]: action.payload.total, + }; + } + return previousState; +}; + +export default totalReducer; diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 220838d3e81..2b2acab2ee9 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -298,6 +298,7 @@ export interface ReduxState { loadedOnce: boolean; selectedIds: Identifier[]; total: number; + totalForQuery: { [key: string]: number }; }; validity: { [key: string]: Date; From c79d6ec775d729b0f473e12a8d0bad10d8db4d22 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 10:33:39 +0100 Subject: [PATCH 13/29] Fix bug when coming back to the post edit page Now we can share cache between getList and getMatching! --- .../ra-core/src/reducer/admin/resource/list/idsForQuery.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts b/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts index 39d18ff8f1f..22ecc8864ea 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts @@ -4,7 +4,8 @@ import mapValues from 'lodash/mapValues'; import { CRUD_GET_LIST_SUCCESS, CrudGetListSuccessAction, - CrudGetOneSuccessAction, + CRUD_GET_MATCHING_SUCCESS, + CrudGetMatchingAction, } from '../../../../actions'; import { DELETE, DELETE_MANY } from '../../../../core'; import { Identifier } from '../../../../types'; @@ -17,7 +18,7 @@ interface State { type ActionTypes = | CrudGetListSuccessAction - | CrudGetOneSuccessAction + | CrudGetMatchingAction | { type: 'OTHER_ACTION'; payload: any; @@ -45,6 +46,7 @@ const idsForQueryReducer: Reducer = ( switch (action.type) { case CRUD_GET_LIST_SUCCESS: + case CRUD_GET_MATCHING_SUCCESS: return { ...previousState, [JSON.stringify( From 43b792273076ecd6c4980dd57eea6695a85a15f8 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 10:35:55 +0100 Subject: [PATCH 14/29] Fix types --- packages/ra-core/src/actions/dataActions/crudGetMatching.ts | 2 +- .../ra-core/src/reducer/admin/resource/list/idsForQuery.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ra-core/src/actions/dataActions/crudGetMatching.ts b/packages/ra-core/src/actions/dataActions/crudGetMatching.ts index 52abd6a4efb..88f002a17f3 100644 --- a/packages/ra-core/src/actions/dataActions/crudGetMatching.ts +++ b/packages/ra-core/src/actions/dataActions/crudGetMatching.ts @@ -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: { diff --git a/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts b/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts index 22ecc8864ea..f3fc38152d0 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts @@ -5,7 +5,7 @@ import { CRUD_GET_LIST_SUCCESS, CrudGetListSuccessAction, CRUD_GET_MATCHING_SUCCESS, - CrudGetMatchingAction, + CrudGetMatchingSuccessAction, } from '../../../../actions'; import { DELETE, DELETE_MANY } from '../../../../core'; import { Identifier } from '../../../../types'; @@ -18,7 +18,7 @@ interface State { type ActionTypes = | CrudGetListSuccessAction - | CrudGetMatchingAction + | CrudGetMatchingSuccessAction | { type: 'OTHER_ACTION'; payload: any; From 8b67254a98df756018a203c340334f7fbf69c8a3 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 10:53:25 +0100 Subject: [PATCH 15/29] Fix wrong total in getMatching selector and cache --- packages/ra-core/src/dataProvider/useGetMatching.ts | 7 +++++-- .../src/reducer/admin/resource/list/totalForQuery.ts | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/ra-core/src/dataProvider/useGetMatching.ts b/packages/ra-core/src/dataProvider/useGetMatching.ts index e87d5a027e7..0d261cfe3e5 100644 --- a/packages/ra-core/src/dataProvider/useGetMatching.ts +++ b/packages/ra-core/src/dataProvider/useGetMatching.ts @@ -68,6 +68,7 @@ const useGetMatching = ( options?: any ): UseGetMatchingResult => { const relatedTo = referenceSource(referencingResource, source); + const payload = { pagination, sort, filter }; const { data: possibleValues, total, @@ -78,7 +79,7 @@ const useGetMatching = ( { type: 'getList', resource, - payload: { pagination, sort, filter }, + payload, }, { ...options, @@ -93,7 +94,9 @@ const useGetMatching = ( }), (state: ReduxState) => state.admin.resources[resource] - ? state.admin.resources[resource].list.total + ? state.admin.resources[resource].list.totalForQuery[ + JSON.stringify(payload) + ] : null ); diff --git a/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts b/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts index 03c926533e0..62de5f9eaeb 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts @@ -2,10 +2,13 @@ import { Reducer } from 'redux'; import { CRUD_GET_LIST_SUCCESS, CrudGetListSuccessAction, + CRUD_GET_MATCHING_SUCCESS, + CrudGetMatchingSuccessAction, } from '../../../../actions/dataActions'; type ActionTypes = | CrudGetListSuccessAction + | CrudGetMatchingSuccessAction | { type: 'OTHER_TYPE'; payload?: { ids: string[] }; @@ -20,7 +23,10 @@ const totalReducer: Reducer = ( previousState = initialState, action: ActionTypes ) => { - if (action.type === CRUD_GET_LIST_SUCCESS) { + if ( + action.type === CRUD_GET_LIST_SUCCESS || + action.type === CRUD_GET_MATCHING_SUCCESS + ) { return { ...previousState, [JSON.stringify(action.requestPayload)]: action.payload.total, From dfb5c7f6c4c59f823d88d5dd9aa483b66a203122 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 21:41:22 +0100 Subject: [PATCH 16/29] Fix create does not refresh the list --- .../reducer/admin/resource/list/validity.ts | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/ra-core/src/reducer/admin/resource/list/validity.ts b/packages/ra-core/src/reducer/admin/resource/list/validity.ts index 8b67d66ad47..d4c07633e31 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/validity.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/validity.ts @@ -1,6 +1,6 @@ import { Reducer } from 'redux'; import { FETCH_END } from '../../../../actions'; -import { GET_LIST } from '../../../../core'; +import { GET_LIST, CREATE } from '../../../../core'; interface ValidityRegistry { [key: string]: Date; @@ -16,24 +16,33 @@ const validityReducer: Reducer = ( !meta || !meta.fetchResponse || meta.fetchStatus !== FETCH_END || - meta.fromCache === true || - meta.fetchResponse !== GET_LIST + meta.fromCache === true ) { return previousState; } - if (payload.validUntil) { - // store the validity date - return { - ...previousState, - [JSON.stringify(requestPayload)]: payload.validUntil, - }; - } else { - // remove the validity date - const { - [JSON.stringify(requestPayload)]: value, - ...rest - } = previousState; - return rest; + switch (meta.fetchResponse) { + case GET_LIST: { + if (payload.validUntil) { + // store the validity date + return { + ...previousState, + [JSON.stringify(requestPayload)]: payload.validUntil, + }; + } else { + // remove the validity date + const { + [JSON.stringify(requestPayload)]: value, + ...rest + } = previousState; + return rest; + } + } + case CREATE: + // force refresh of all lists because we don't know where the + // new record will appear in the list + return initialState; + default: + return previousState; } }; From c0b020a0566a35a9ebbf38c5b5d746ed403d4a74 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 18 Feb 2020 22:04:38 +0100 Subject: [PATCH 17/29] Fix refresh on list --- .../src/reducer/admin/resource/index.ts | 36 ++++++------------- .../reducer/admin/resource/list/validity.ts | 7 ++-- .../src/reducer/admin/resource/validity.ts | 7 ++-- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/packages/ra-core/src/reducer/admin/resource/index.ts b/packages/ra-core/src/reducer/admin/resource/index.ts index 20ee44692ce..1778f7ec5d8 100644 --- a/packages/ra-core/src/reducer/admin/resource/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/index.ts @@ -43,20 +43,10 @@ export default (previousState = initialState, action: ActionTypes) => { }, {}); } - if (action.type === REFRESH_VIEW) { - return Object.keys(previousState).reduce( - (acc, resource) => ({ - ...acc, - [resource]: { - ...previousState[resource], - validity: {}, - }, - }), - {} - ); - } - - if (!action.meta || !action.meta.resource) { + if ( + action.type !== REFRESH_VIEW && + (!action.meta || !action.meta.resource) + ) { return previousState; } @@ -64,18 +54,12 @@ export default (previousState = initialState, action: ActionTypes) => { const newState = resources.reduce( (acc, resource) => ({ ...acc, - [resource]: - action.meta.resource === resource - ? { - props: previousState[resource].props, - data: data(previousState[resource].data, action), - list: list(previousState[resource].list, action), - validity: validity( - previousState[resource].validity, - action - ), - } - : previousState[resource], + [resource]: { + props: previousState[resource].props, + data: data(previousState[resource].data, action), + list: list(previousState[resource].list, action), + validity: validity(previousState[resource].validity, action), + }, }), {} ); diff --git a/packages/ra-core/src/reducer/admin/resource/list/validity.ts b/packages/ra-core/src/reducer/admin/resource/list/validity.ts index d4c07633e31..c1dc85284c8 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/validity.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/validity.ts @@ -1,5 +1,5 @@ import { Reducer } from 'redux'; -import { FETCH_END } from '../../../../actions'; +import { FETCH_END, REFRESH_VIEW } from '../../../../actions'; import { GET_LIST, CREATE } from '../../../../core'; interface ValidityRegistry { @@ -10,8 +10,11 @@ const initialState = {}; const validityReducer: Reducer = ( previousState = initialState, - { payload, requestPayload, meta } + { type, payload, requestPayload, meta } ) => { + if (type === REFRESH_VIEW) { + return initialState; + } if ( !meta || !meta.fetchResponse || diff --git a/packages/ra-core/src/reducer/admin/resource/validity.ts b/packages/ra-core/src/reducer/admin/resource/validity.ts index 358a4d43c5c..b6559965e5d 100644 --- a/packages/ra-core/src/reducer/admin/resource/validity.ts +++ b/packages/ra-core/src/reducer/admin/resource/validity.ts @@ -1,5 +1,5 @@ import { Reducer } from 'redux'; -import { FETCH_END } from '../../../actions'; +import { FETCH_END, REFRESH_VIEW } from '../../../actions'; import { CREATE, DELETE, @@ -23,8 +23,11 @@ const initialState = {}; const validityReducer: Reducer = ( previousState = initialState, - { payload, requestPayload, meta } + { type, payload, requestPayload, meta } ) => { + if (type === REFRESH_VIEW) { + return initialState; + } if ( !meta || !meta.fetchResponse || From eabda467cb1830335eaca2f68d1b01ac2aea5772 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 24 Feb 2020 06:02:14 +0100 Subject: [PATCH 18/29] Fix unit tests --- .../ReferenceArrayInputController.spec.tsx | 47 ++++++++++- .../src/controller/useListController.spec.tsx | 22 ++++- .../src/reducer/admin/resource/index.spec.ts | 84 +++++++++++++------ .../src/reducer/admin/resource/index.ts | 19 +++-- .../ra-ui-materialui/src/list/List.spec.js | 2 + 5 files changed, 140 insertions(+), 34 deletions(-) diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx index e40c3e5ac73..e7d0fa17c23 100644 --- a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx @@ -70,6 +70,14 @@ describe('', () => { }, list: { total: 42, + totalForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, + }, + idsForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ + 1, + ], + }, }, }, }, @@ -143,6 +151,14 @@ describe('', () => { }, list: { total: 42, + totalForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, + }, + idsForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ + 1, + ], + }, }, }, }, @@ -177,6 +193,14 @@ describe('', () => { }, list: { total: 42, + totalForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, + }, + idsForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ + 1, + ], + }, }, }, }, @@ -211,6 +235,14 @@ describe('', () => { }, list: { total: 42, + totalForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, + }, + idsForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ + 1, + ], + }, }, }, }, @@ -270,6 +302,14 @@ describe('', () => { }, list: { total: 42, + totalForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, + }, + idsForQuery: { + '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ + 1, + ], + }, }, }, }, @@ -567,7 +607,12 @@ describe('', () => { , { admin: { - resources: { tags: { data: {}, list: {} } }, + resources: { + tags: { + data: {}, + list: { idsForQuery: {}, totalForQuery: {} }, + }, + }, references: { possibleValues: {} }, ui: { viewVersion: 1 }, }, diff --git a/packages/ra-core/src/controller/useListController.spec.tsx b/packages/ra-core/src/controller/useListController.spec.tsx index f399520517c..e497e6d6d82 100644 --- a/packages/ra-core/src/controller/useListController.spec.tsx +++ b/packages/ra-core/src/controller/useListController.spec.tsx @@ -70,7 +70,15 @@ describe('useListController', () => { , { admin: { - resources: { posts: { list: { params: {} } } }, + resources: { + posts: { + list: { + params: {}, + idsForQuery: {}, + totalForQuery: {}, + }, + }, + }, }, } ); @@ -110,7 +118,11 @@ describe('useListController', () => { resources: { posts: { list: { - params: { filter: { q: 'hello' } }, + params: { + filter: { q: 'hello' }, + }, + idsForQuery: {}, + totalForQuery: {}, }, }, }, @@ -150,6 +162,8 @@ describe('useListController', () => { posts: { list: { params: {}, + idsForQuery: {}, + totalForQuery: {}, }, }, }, @@ -178,7 +192,7 @@ describe('useListController', () => { expect(updatedCrudGetListCalls[1][0].payload.filter).toEqual({ foo: 2, }); - expect(children).toBeCalledTimes(5); + expect(children).toBeCalledTimes(6); // 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({}); // Check that the permanent filter is not included in the filterValues (passed to Filter form and button) @@ -227,6 +241,8 @@ describe('useListController', () => { posts: { list: { params: { filter: { q: 'hello' } }, + idsForQuery: {}, + totalForQuery: {}, }, }, }, diff --git a/packages/ra-core/src/reducer/admin/resource/index.spec.ts b/packages/ra-core/src/reducer/admin/resource/index.spec.ts index 2c8b87e8963..4a5fb973782 100644 --- a/packages/ra-core/src/reducer/admin/resource/index.spec.ts +++ b/packages/ra-core/src/reducer/admin/resource/index.spec.ts @@ -21,7 +21,6 @@ describe('Resources Reducer', () => { posts: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -29,9 +28,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -39,7 +42,6 @@ describe('Resources Reducer', () => { comments: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -47,9 +49,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'comments' }, @@ -67,7 +73,6 @@ describe('Resources Reducer', () => { posts: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -75,9 +80,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -85,7 +94,6 @@ describe('Resources Reducer', () => { comments: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -93,9 +101,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'comments' }, @@ -103,7 +115,6 @@ describe('Resources Reducer', () => { users: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -111,9 +122,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'users', options: 'foo' }, @@ -128,7 +143,6 @@ describe('Resources Reducer', () => { posts: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -136,9 +150,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -146,7 +164,6 @@ describe('Resources Reducer', () => { comments: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -154,9 +171,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'comments' }, @@ -171,7 +192,6 @@ describe('Resources Reducer', () => { posts: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -179,9 +199,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -196,7 +220,6 @@ describe('Resources Reducer', () => { posts: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -204,9 +227,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -214,7 +241,6 @@ describe('Resources Reducer', () => { comments: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -222,9 +248,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'comments' }, @@ -247,7 +277,6 @@ describe('Resources Reducer', () => { posts: { data: {}, list: { - ids: [], params: { filter: { commentable: true }, order: null, @@ -255,9 +284,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -265,7 +298,6 @@ describe('Resources Reducer', () => { comments: { data: {}, list: { - ids: [], params: { filter: {}, order: null, @@ -273,9 +305,13 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - selectedIds: [], + ids: [], + idsForQuery: {}, total: 0, + totalForQuery: {}, + selectedIds: [], loadedOnce: false, + validity: {}, }, validity: {}, props: { name: 'comments' }, diff --git a/packages/ra-core/src/reducer/admin/resource/index.ts b/packages/ra-core/src/reducer/admin/resource/index.ts index 1778f7ec5d8..3cb7045f15d 100644 --- a/packages/ra-core/src/reducer/admin/resource/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/index.ts @@ -54,12 +54,19 @@ export default (previousState = initialState, action: ActionTypes) => { const newState = resources.reduce( (acc, resource) => ({ ...acc, - [resource]: { - props: previousState[resource].props, - data: data(previousState[resource].data, action), - list: list(previousState[resource].list, action), - validity: validity(previousState[resource].validity, action), - }, + [resource]: + action.type === REFRESH_VIEW || + action.meta.resource === resource + ? { + props: previousState[resource].props, + data: data(previousState[resource].data, action), + list: list(previousState[resource].list, action), + validity: validity( + previousState[resource].validity, + action + ), + } + : previousState[resource], }), {} ); diff --git a/packages/ra-ui-materialui/src/list/List.spec.js b/packages/ra-ui-materialui/src/list/List.spec.js index c19b1dc6833..6b350f2a20f 100644 --- a/packages/ra-ui-materialui/src/list/List.spec.js +++ b/packages/ra-ui-materialui/src/list/List.spec.js @@ -91,6 +91,8 @@ describe('', () => { params: {}, selectedIds: [], total: 0, + idsForQuery: {}, + totalForQuery: {}, }, }, }, From 106a33c43d4827e6ea1d87a31a12c45f088bb6b2 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 25 Feb 2020 09:26:52 +0100 Subject: [PATCH 19/29] Group list cache state into one reducer --- .../ReferenceArrayInputController.spec.tsx | 46 +------------ .../src/controller/useListController.spec.tsx | 12 ++-- .../src/controller/useListController.ts | 30 ++++---- .../src/dataProvider/replyWithCache.ts | 20 +++--- .../src/dataProvider/useGetMatching.ts | 18 +++-- .../src/reducer/admin/resource/index.spec.ts | 48 ++++--------- .../admin/resource/list/cachedRequests.ts | 69 +++++++++++++++++++ .../admin/resource/list/cachedRequests/ids.ts | 30 ++++++++ .../resource/list/cachedRequests/total.ts | 28 ++++++++ .../resource/list/cachedRequests/validity.ts | 27 ++++++++ .../src/reducer/admin/resource/list/ids.ts | 2 +- .../admin/resource/list/idsForQuery.ts | 61 ---------------- .../src/reducer/admin/resource/list/index.ts | 10 +-- .../admin/resource/list/totalForQuery.ts | 38 ---------- packages/ra-core/src/types.ts | 7 +- .../ra-ui-materialui/src/list/List.spec.js | 3 +- 16 files changed, 220 insertions(+), 229 deletions(-) create mode 100644 packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts create mode 100644 packages/ra-core/src/reducer/admin/resource/list/cachedRequests/ids.ts create mode 100644 packages/ra-core/src/reducer/admin/resource/list/cachedRequests/total.ts create mode 100644 packages/ra-core/src/reducer/admin/resource/list/cachedRequests/validity.ts delete mode 100644 packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts delete mode 100644 packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx index e7d0fa17c23..d7b4d6e56d0 100644 --- a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx @@ -68,17 +68,7 @@ describe('', () => { id: 1, }, }, - list: { - total: 42, - totalForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, - }, - idsForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ - 1, - ], - }, - }, + list: {}, }, }, }, @@ -151,14 +141,6 @@ describe('', () => { }, list: { total: 42, - totalForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, - }, - idsForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ - 1, - ], - }, }, }, }, @@ -193,14 +175,6 @@ describe('', () => { }, list: { total: 42, - totalForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, - }, - idsForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ - 1, - ], - }, }, }, }, @@ -235,14 +209,6 @@ describe('', () => { }, list: { total: 42, - totalForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, - }, - idsForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ - 1, - ], - }, }, }, }, @@ -302,14 +268,6 @@ describe('', () => { }, list: { total: 42, - totalForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': 42, - }, - idsForQuery: { - '{"pagination":{"page":1,"perPage":25},"sort":{"field":"id","order":"DESC"},"filter":{"q":""}}': [ - 1, - ], - }, }, }, }, @@ -610,7 +568,7 @@ describe('', () => { resources: { tags: { data: {}, - list: { idsForQuery: {}, totalForQuery: {} }, + list: {}, }, }, references: { possibleValues: {} }, diff --git a/packages/ra-core/src/controller/useListController.spec.tsx b/packages/ra-core/src/controller/useListController.spec.tsx index e497e6d6d82..cdad74385c3 100644 --- a/packages/ra-core/src/controller/useListController.spec.tsx +++ b/packages/ra-core/src/controller/useListController.spec.tsx @@ -74,8 +74,7 @@ describe('useListController', () => { posts: { list: { params: {}, - idsForQuery: {}, - totalForQuery: {}, + cachedRequests: {}, }, }, }, @@ -121,8 +120,7 @@ describe('useListController', () => { params: { filter: { q: 'hello' }, }, - idsForQuery: {}, - totalForQuery: {}, + cachedRequests: {}, }, }, }, @@ -162,8 +160,7 @@ describe('useListController', () => { posts: { list: { params: {}, - idsForQuery: {}, - totalForQuery: {}, + cachedRequests: {}, }, }, }, @@ -241,8 +238,7 @@ describe('useListController', () => { posts: { list: { params: { filter: { q: 'hello' } }, - idsForQuery: {}, - totalForQuery: {}, + cachedRequests: {}, }, }, }, diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index 96aa4fc5f93..fbcc2271ab6 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -138,8 +138,8 @@ const useListController = (props: ListProps): ListControllerProps => { * 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.idsForQueries and admin.resources.[resource].list.total) - * and a custom selector for these reducers. + * (admin.resources.[resource].list.cachedRequests),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. */ @@ -165,26 +165,24 @@ const useListController = (props: ListProps): ListControllerProps => { const resourceState = state.admin.resources[resource]; // if the resource isn't initialized, return null if (!resourceState) return null; - const idsForQuery = - resourceState.list.idsForQuery[requestSignature]; - // 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; + const cachedRequests = + resourceState.list.cachedRequests[requestSignature]; + // if the list of ids for the current request isn't loaded yet, + // return the list of ids for the previous request + return cachedRequests ? cachedRequests.ids : resourceState.list.ids; }, // 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 cachedRequests = + resourceState.list.cachedRequests[requestSignature]; + // if the total for the current request isn't loaded yet, + // return the total for the previous request + return cachedRequests + ? cachedRequests.total + : resourceState.list.total; } ); const data = useSelector( diff --git a/packages/ra-core/src/dataProvider/replyWithCache.ts b/packages/ra-core/src/dataProvider/replyWithCache.ts index e4bcd9ef8c5..37e7d00acf3 100644 --- a/packages/ra-core/src/dataProvider/replyWithCache.ts +++ b/packages/ra-core/src/dataProvider/replyWithCache.ts @@ -1,3 +1,4 @@ +import get from 'lodash/get'; import { GetListParams, GetListResult, @@ -12,12 +13,12 @@ export const canReplyWithCache = (type, payload, resourceState) => { switch (type) { case 'getList': return ( - resourceState && - resourceState.list && - resourceState.list.validity && - resourceState.list.validity[ - JSON.stringify(payload as GetListParams) - ] > now + get(resourceState, [ + 'list', + 'cachedRequests', + JSON.stringify(payload as GetListParams), + 'validity', + ]) > now ); case 'getOne': return ( @@ -43,10 +44,11 @@ export const getResultFromCache = (type, payload, resourceState) => { case 'getList': { const data = resourceState.data; const requestSignature = JSON.stringify(payload); - const ids = resourceState.list.idsForQuery[requestSignature]; + const cachedRequest = + resourceState.list.cachedRequests[requestSignature]; return { - data: ids.map(id => data[id]), - total: resourceState.list.totalForQuery[requestSignature], + data: cachedRequest.ids.map(id => data[id]), + total: cachedRequest.total, } as GetListResult; } case 'getOne': diff --git a/packages/ra-core/src/dataProvider/useGetMatching.ts b/packages/ra-core/src/dataProvider/useGetMatching.ts index 0d261cfe3e5..5028382dc8c 100644 --- a/packages/ra-core/src/dataProvider/useGetMatching.ts +++ b/packages/ra-core/src/dataProvider/useGetMatching.ts @@ -1,4 +1,6 @@ import { useSelector } from 'react-redux'; +import get from 'lodash/get'; + import { CRUD_GET_MATCHING } from '../actions/dataActions/crudGetMatching'; import { Identifier, Pagination, Sort, Record, ReduxState } from '../types'; import useQueryWithStore from './useQueryWithStore'; @@ -93,11 +95,17 @@ const useGetMatching = ( source, }), (state: ReduxState) => - state.admin.resources[resource] - ? state.admin.resources[resource].list.totalForQuery[ - JSON.stringify(payload) - ] - : null + get( + state.admin.resources, + [ + resource, + 'list', + 'cachedRequests', + JSON.stringify(payload), + 'total', + ], + null + ) ); const referenceState = useSelector(state => diff --git a/packages/ra-core/src/reducer/admin/resource/index.spec.ts b/packages/ra-core/src/reducer/admin/resource/index.spec.ts index 4a5fb973782..005e08ad4be 100644 --- a/packages/ra-core/src/reducer/admin/resource/index.spec.ts +++ b/packages/ra-core/src/reducer/admin/resource/index.spec.ts @@ -29,12 +29,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -50,12 +48,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'comments' }, @@ -81,12 +77,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -102,12 +96,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'comments' }, @@ -123,12 +115,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'users', options: 'foo' }, @@ -151,12 +141,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -172,12 +160,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'comments' }, @@ -200,12 +186,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -228,12 +212,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -249,12 +231,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'comments' }, @@ -285,12 +265,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'posts' }, @@ -306,12 +284,10 @@ describe('Resources Reducer', () => { sort: null, }, ids: [], - idsForQuery: {}, + cachedRequests: {}, total: 0, - totalForQuery: {}, selectedIds: [], loadedOnce: false, - validity: {}, }, validity: {}, props: { name: 'comments' }, diff --git a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts new file mode 100644 index 00000000000..fb550b19d8c --- /dev/null +++ b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts @@ -0,0 +1,69 @@ +import { Reducer } from 'redux'; + +import { Identifier } from '../../../../types'; +import { FETCH_END, REFRESH_VIEW } from '../../../../actions'; +import { + GET_LIST, + CREATE, + DELETE, + DELETE_MANY, + UPDATE, + UPDATE_MANY, +} from '../../../../core'; +import ids from './cachedRequests/ids'; +import total from './cachedRequests/total'; +import validity from './cachedRequests/validity'; + +interface CachedRequestState { + ids: Identifier[]; + total: number; + validity: Date; +} + +interface State { + [key: string]: CachedRequestState; +} + +const initialState = {}; +const initialSubstate = { ids: [], total: null, validity: null }; + +const cachedRequestsReducer: Reducer = ( + previousState = initialState, + action +) => { + if (action.type === REFRESH_VIEW) { + // force refresh + return initialState; + } + if (!action.meta || action.meta.fetchStatus !== FETCH_END) { + // not a return from the dataProvider + return previousState; + } + if ( + action.meta.fetchResponse === CREATE || + action.meta.fetchResponse === DELETE || + action.meta.fetchResponse === DELETE_MANY || + action.meta.fetchResponse === UPDATE || + action.meta.fetchResponse === UPDATE_MANY + ) { + // force refresh of all lists because we don't know where the + // new/deleted/updated record(s) will appear in the list + return initialState; + } + if (action.meta.fetchResponse !== GET_LIST || action.meta.fromCache) { + // looks like a GET_MANY, a GET_ONE, or a cached response + return previousState; + } + const requestKey = JSON.stringify(action.requestPayload); + const previousSubState = previousState[requestKey] || initialSubstate; + return { + ...previousState, + [requestKey]: { + ids: ids(previousSubState.ids, action), + total: total(previousSubState.total, action), + validity: validity(previousSubState.validity, action), + }, + }; +}; + +export default cachedRequestsReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/ids.ts b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/ids.ts new file mode 100644 index 00000000000..e5be1a7d0d4 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/ids.ts @@ -0,0 +1,30 @@ +import { Reducer } from 'redux'; +import { + CrudGetListSuccessAction, + CrudGetMatchingSuccessAction, +} from '../../../../../actions'; +import { GET_LIST } from '../../../../../core'; +import { Identifier } from '../../../../../types'; + +type IdentifierArray = Identifier[]; + +type State = IdentifierArray; + +type ActionTypes = + | CrudGetListSuccessAction + | CrudGetMatchingSuccessAction + | { type: 'OTHER_TYPE'; payload: any; meta: any }; + +const initialState = []; + +const idsReducer: Reducer = ( + previousState = initialState, + action: ActionTypes +) => { + if (action.meta && action.meta.fetchResponse === GET_LIST) { + return action.payload.data.map(({ id }) => id); + } + return previousState; +}; + +export default idsReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/total.ts b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/total.ts new file mode 100644 index 00000000000..cfa1edb4af1 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/total.ts @@ -0,0 +1,28 @@ +import { Reducer } from 'redux'; + +import { GET_LIST } from '../../../../../core'; +import { + CrudGetListSuccessAction, + CrudGetMatchingSuccessAction, +} from '../../../../../actions/dataActions'; + +type ActionTypes = + | CrudGetListSuccessAction + | CrudGetMatchingSuccessAction + | { type: 'OTHER_TYPE'; payload: any; meta: any }; + +type State = number; + +const initialState = null; + +const totalReducer: Reducer = ( + previousState = initialState, + action: ActionTypes +) => { + if (action.meta && action.meta.fetchResponse === GET_LIST) { + return action.payload.total; + } + return previousState; +}; + +export default totalReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/validity.ts b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/validity.ts new file mode 100644 index 00000000000..db5833014e4 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/validity.ts @@ -0,0 +1,27 @@ +import { Reducer } from 'redux'; +import { GET_LIST } from '../../../../../core'; + +type State = Date; + +const initialState = null; + +const validityReducer: Reducer = ( + previousState = initialState, + { payload, meta } +) => { + switch (meta.fetchResponse) { + case GET_LIST: { + if (payload.validUntil) { + // store the validity date + return payload.validUntil; + } else { + // remove the validity date + return initialState; + } + } + default: + return previousState; + } +}; + +export default validityReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/ids.ts b/packages/ra-core/src/reducer/admin/resource/list/ids.ts index 8c3c682186c..42343b5f05c 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/ids.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/ids.ts @@ -26,7 +26,7 @@ type ActionTypes = * List of the ids of the latest loaded page, regardless of params * * When loading a the list for the first time, useListController grabs the ids - * from the idsForQuery reducer (not this ids reducer). It's only when the user + * from the cachedRequests reducer (not this ids reducer). It's only when the user * changes page, sort, or filter, that the useListController hook uses the ids * reducer, so as to show the previous list of results while loading the new * list (intead of displaying a blank page each time the list params change). diff --git a/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts b/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts deleted file mode 100644 index f3fc38152d0..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/idsForQuery.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Reducer } from 'redux'; -import without from 'lodash/without'; -import mapValues from 'lodash/mapValues'; -import { - CRUD_GET_LIST_SUCCESS, - CrudGetListSuccessAction, - CRUD_GET_MATCHING_SUCCESS, - CrudGetMatchingSuccessAction, -} from '../../../../actions'; -import { DELETE, DELETE_MANY } from '../../../../core'; -import { Identifier } from '../../../../types'; - -type IdentifierArray = Identifier[]; - -interface State { - [key: string]: IdentifierArray; -} - -type ActionTypes = - | CrudGetListSuccessAction - | CrudGetMatchingSuccessAction - | { - type: 'OTHER_ACTION'; - payload: any; - meta: any; - }; - -const initialState = {}; - -const idsForQueryReducer: Reducer = ( - previousState = initialState, - action: ActionTypes -) => { - if (action.meta && action.meta.optimistic) { - if (action.meta.fetch === DELETE) { - return mapValues(previousState, ids => - without(ids, action.payload.id) - ); - } - if (action.meta.fetch === DELETE_MANY) { - return mapValues(previousState, ids => - without(ids, ...action.payload.ids) - ); - } - } - - switch (action.type) { - case CRUD_GET_LIST_SUCCESS: - case CRUD_GET_MATCHING_SUCCESS: - return { - ...previousState, - [JSON.stringify( - action.requestPayload - )]: action.payload.data.map(({ id }) => id), - }; - default: - return previousState; - } -}; - -export default idsForQueryReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/index.ts b/packages/ra-core/src/reducer/admin/resource/list/index.ts index 663e916ea9b..582bb28db46 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/index.ts @@ -1,12 +1,10 @@ import { combineReducers } from 'redux'; +import cachedRequests from './cachedRequests'; import ids from './ids'; -import idsForQuery from './idsForQuery'; import loadedOnce from './loadedOnce'; import params from './params'; import selectedIds from './selectedIds'; import total from './total'; -import totalForQuery from './totalForQuery'; -import validity from './validity'; const defaultReducer = () => null; @@ -19,11 +17,9 @@ export default combineReducers({ * @see https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests */ ids: ids || defaultReducer, - idsForQuery: idsForQuery || defaultReducer, + total: total || defaultReducer, loadedOnce: loadedOnce || defaultReducer, params: params || defaultReducer, selectedIds: selectedIds || defaultReducer, - total: total || defaultReducer, - totalForQuery: totalForQuery || defaultReducer, - validity: validity || defaultReducer, + cachedRequests: cachedRequests || defaultReducer, }); diff --git a/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts b/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts deleted file mode 100644 index 62de5f9eaeb..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/totalForQuery.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Reducer } from 'redux'; -import { - CRUD_GET_LIST_SUCCESS, - CrudGetListSuccessAction, - CRUD_GET_MATCHING_SUCCESS, - CrudGetMatchingSuccessAction, -} from '../../../../actions/dataActions'; - -type ActionTypes = - | CrudGetListSuccessAction - | CrudGetMatchingSuccessAction - | { - type: 'OTHER_TYPE'; - payload?: { ids: string[] }; - meta?: { optimistic?: boolean; fetch?: string }; - }; - -type State = { [key: string]: number }; - -const initialState = {}; - -const totalReducer: Reducer = ( - previousState = initialState, - action: ActionTypes -) => { - if ( - action.type === CRUD_GET_LIST_SUCCESS || - action.type === CRUD_GET_MATCHING_SUCCESS - ) { - return { - ...previousState, - [JSON.stringify(action.requestPayload)]: action.payload.total, - }; - } - return previousState; -}; - -export default totalReducer; diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 2b2acab2ee9..b00abfc45dc 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -294,11 +294,14 @@ export interface ReduxState { list: { params: any; ids: Identifier[]; - idsForQuery: { [key: string]: Identifier[] }; loadedOnce: boolean; selectedIds: Identifier[]; total: number; - totalForQuery: { [key: string]: number }; + cachedRequests?: { + ids: Identifier[]; + total: number; + validity: Date; + }; }; validity: { [key: string]: Date; diff --git a/packages/ra-ui-materialui/src/list/List.spec.js b/packages/ra-ui-materialui/src/list/List.spec.js index 6b350f2a20f..8a9ba0d1845 100644 --- a/packages/ra-ui-materialui/src/list/List.spec.js +++ b/packages/ra-ui-materialui/src/list/List.spec.js @@ -91,8 +91,7 @@ describe('', () => { params: {}, selectedIds: [], total: 0, - idsForQuery: {}, - totalForQuery: {}, + cachedRequests: {}, }, }, }, From 01a060a032bbbb83c34977bfbba604f465059654 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 25 Feb 2020 22:25:36 +0100 Subject: [PATCH 20/29] Make useGetList use cache, and useListController use useGetList --- examples/simple/src/posts/PostList.js | 6 +- .../src/controller/useListController.ts | 100 +++++++----------- .../ra-core/src/dataProvider/useGetList.ts | 89 ++++++++++------ .../src/reducer/admin/resource/list/total.ts | 6 -- 4 files changed, 100 insertions(+), 101 deletions(-) diff --git a/examples/simple/src/posts/PostList.js b/examples/simple/src/posts/PostList.js index b81f81312e6..fa73cc50071 100644 --- a/examples/simple/src/posts/PostList.js +++ b/examples/simple/src/posts/PostList.js @@ -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 { @@ -80,13 +80,13 @@ const useStyles = makeStyles(theme => ({ publishedAt: { fontStyle: 'italic' }, })); -const PostListBulkActions = props => ( +const PostListBulkActions = memo(props => ( -); +)); const usePostListActionToolbarStyles = makeStyles({ toolbar: { diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index fbcc2271ab6..8fb6c640a26 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -1,8 +1,9 @@ import { isValidElement, ReactElement, useEffect, useMemo } from 'react'; import inflection from 'inflection'; import { Location } from 'history'; -import { useSelector, shallowEqual } from 'react-redux'; import { useLocation } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import get from 'lodash/get'; import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; import useListParams from './useListParams'; @@ -12,8 +13,8 @@ import { useTranslate } from '../i18n'; import { SORT_ASC } from '../reducer/admin/resource/list/queryReducer'; import { CRUD_GET_LIST, ListParams } from '../actions'; import { useNotify } from '../sideEffect'; -import { Sort, RecordMap, Identifier, ReduxState } from '../types'; -import useQueryWithStore from '../dataProvider/useQueryWithStore'; +import { Sort, RecordMap, Identifier, ReduxState, Record } from '../types'; +import useGetList from '../dataProvider/useGetList'; export interface ListProps { // the props you can change @@ -44,10 +45,10 @@ const defaultSort = { const defaultData = {}; -export interface ListControllerProps { +export interface ListControllerProps { basePath: string; currentSort: Sort; - data: RecordMap; + data: RecordMap; defaultTitle: string; displayedFilters: any; filterValues: any; @@ -89,7 +90,9 @@ export interface ListControllerProps { * return ; * } */ -const useListController = (props: ListProps): ListControllerProps => { +const useListController = ( + props: ListProps +): ListControllerProps => { useCheckMinimumRequiredProps('List', ['basePath', 'resource'], props); const { @@ -124,31 +127,19 @@ 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.cachedRequests),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. + * We want the list of ids to be always available for optimistic rendering, + * and therefore we need a custom action (CRUD_GET_LIST) that will be used. */ - const { data: ids, total, loading, loaded } = useQueryWithStore( + const { ids, total, loading, loaded } = useGetList( + resource, { - type: 'getList', - resource, - payload, + page: query.page, + perPage: query.perPage, }, + { field: query.sort, order: query.order }, + { ...query.filter, ...filter }, { action: CRUD_GET_LIST, version, @@ -159,38 +150,25 @@ const useListController = (props: ListProps): ListControllerProps => { : error.message || 'ra.notification.http_error', 'warning' ), - }, - // data selector - (state: ReduxState): Identifier[] => { - const resourceState = state.admin.resources[resource]; - // if the resource isn't initialized, return null - if (!resourceState) return null; - const cachedRequests = - resourceState.list.cachedRequests[requestSignature]; - // if the list of ids for the current request isn't loaded yet, - // return the list of ids for the previous request - return cachedRequests ? cachedRequests.ids : resourceState.list.ids; - }, - // total selector - (state: ReduxState) => { - const resourceState = state.admin.resources[resource]; - // if the resource isn't initialized, return null - if (!resourceState) return null; - const cachedRequests = - resourceState.list.cachedRequests[requestSignature]; - // if the total for the current request isn't loaded yet, - // return the total for the previous request - return cachedRequests - ? cachedRequests.total - : resourceState.list.total; } ); + const data = useSelector( - (state: ReduxState) => - state.admin.resources[resource] - ? state.admin.resources[resource].data - : defaultData, - shallowEqual + (state: ReduxState): RecordMap => + get(state.admin.resources, [resource, 'data'], defaultData) + ); + + // When the user changes the page/sort/filter, this controller runs the + // useGetList hook again. While the result of this new call is loading, + // the ids and total are empty. To avoid rendering an empty list at that + // moment, we override the ids and total with the latest loaded ones. + const defaultIds = useSelector( + (state: ReduxState): Identifier[] => + get(state.admin.resources, [resource, 'list', 'ids'], []) + ); + const defaultTotal = useSelector( + (state: ReduxState): number => + get(state.admin.resources, [resource, 'list', 'total'], 0) ); useEffect(() => { @@ -227,10 +205,10 @@ const useListController = (props: ListProps): ListControllerProps => { displayedFilters: query.displayedFilters, filterValues: query.filterValues, hasCreate, - // ids might be null if the resource has not been initialized yet (custom routes for example) - ids: ids || [], + hideFilter: queryModifiers.hideFilter, + ids: typeof ids === 'undefined' ? defaultIds : ids, + loaded: loaded || defaultIds.length > 0, loading, - loaded, onSelect: selectionModifiers.select, onToggleItem: selectionModifiers.toggle, onUnselectItems: selectionModifiers.clearSelection, @@ -239,13 +217,11 @@ const useListController = (props: ListProps): ListControllerProps => { resource, selectedIds, setFilters: queryModifiers.setFilters, - hideFilter: queryModifiers.hideFilter, - showFilter: queryModifiers.showFilter, setPage: queryModifiers.setPage, setPerPage: queryModifiers.setPerPage, setSort: queryModifiers.setSort, - // total might be null if the resource has not been initialized yet (custom routes for example) - total: total != undefined ? total : 0, // eslint-disable-line eqeqeq + showFilter: queryModifiers.showFilter, + total: typeof total === 'undefined' ? defaultTotal : total, version, }; }; diff --git a/packages/ra-core/src/dataProvider/useGetList.ts b/packages/ra-core/src/dataProvider/useGetList.ts index c9fa57e31b9..6eeeab8af30 100644 --- a/packages/ra-core/src/dataProvider/useGetList.ts +++ b/packages/ra-core/src/dataProvider/useGetList.ts @@ -1,6 +1,18 @@ -import { Pagination, Sort, ReduxState } from '../types'; +import { useSelector, shallowEqual } from 'react-redux'; +import get from 'lodash/get'; + +import { + Pagination, + Sort, + ReduxState, + Identifier, + Record, + RecordMap, +} from '../types'; import useQueryWithStore from './useQueryWithStore'; +const defaultData = {}; + /** * Call the dataProvider.getList() method and return the resolved result * as well as the loading state. @@ -39,44 +51,61 @@ import useQueryWithStore from './useQueryWithStore'; * )}; * }; */ -const useGetList = ( +const useGetList = ( resource: string, pagination: Pagination, sort: Sort, filter: object, options?: any -) => { - if (options && options.action) { - throw new Error( - 'useGetList() does not support custom action names. Use useQueryWithStore() and your own Redux selectors if you need a custom action name for a getList query' - ); - } - const key = JSON.stringify({ - type: 'GET_LIST', - resource: resource, - payload: { pagination, sort, filter }, - }); - const { data, total, error, loading, loaded } = useQueryWithStore( +): { + data?: RecordMap; + ids?: Identifier[]; + total?: number; + error?: any; + loading: boolean; + loaded: boolean; +} => { + const requestSignature = JSON.stringify({ pagination, sort, filter }); + + const { data: ids, total, error, loading, loaded } = useQueryWithStore( { type: 'getList', resource, payload: { pagination, sort, filter } }, options, - (state: ReduxState) => - state.admin.customQueries[key] - ? state.admin.customQueries[key].data - : null, - (state: ReduxState) => - state.admin.customQueries[key] - ? state.admin.customQueries[key].total - : null + // data selector (may return undefined) + (state: ReduxState): Identifier[] => + get(state.admin.resources, [ + resource, + 'list', + 'cachedRequests', + requestSignature, + 'ids', + ]), + // total selector (may return undefined) + (state: ReduxState): number => + get(state.admin.resources, [ + resource, + 'list', + 'cachedRequests', + requestSignature, + 'total', + ]) ); - const ids = data ? data.map(record => record.id) : []; - const dataObject = data - ? data.reduce((acc, next) => { - acc[next.id] = next; - return acc; - }, {}) - : {}; - return { data: dataObject, ids, total, error, loading, loaded }; + const data = useSelector((state: ReduxState): RecordMap => { + if (!ids) return defaultData; + const allResourceData = get( + state.admin.resources, + [resource, 'data'], + defaultData + ); + return ids + .map(id => allResourceData[id]) + .reduce((acc, record) => { + acc[record.id] = record; + return acc; + }, {}); + }, shallowEqual); + + return { data, ids, total, error, loading, loaded }; }; export default useGetList; diff --git a/packages/ra-core/src/reducer/admin/resource/list/total.ts b/packages/ra-core/src/reducer/admin/resource/list/total.ts index fc1b6281467..6967d149f46 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/total.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/total.ts @@ -1,14 +1,11 @@ import { Reducer } from 'redux'; import { - CRUD_GET_ONE_SUCCESS, - CrudGetOneSuccessAction, CRUD_GET_LIST_SUCCESS, CrudGetListSuccessAction, } from '../../../../actions/dataActions'; import { DELETE, DELETE_MANY } from '../../../../core'; type ActionTypes = - | CrudGetOneSuccessAction | CrudGetListSuccessAction | { type: 'OTHER_TYPE'; @@ -22,9 +19,6 @@ const totalReducer: Reducer = ( previousState = 0, action: ActionTypes ) => { - if (action.type === CRUD_GET_ONE_SUCCESS) { - return previousState === 0 ? 1 : previousState; - } if (action.type === CRUD_GET_LIST_SUCCESS) { return action.payload.total; } From ed9c52395e62577ff2cc0c96957f7f99357dc299 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 25 Feb 2020 22:52:56 +0100 Subject: [PATCH 21/29] Move data providr cache proxy to a utility function --- examples/simple/src/dataProvider.js | 14 +------ .../dataProvider/cacheDataProviderProxy.ts | 38 +++++++++++++++++++ packages/ra-core/src/dataProvider/index.ts | 2 + .../ra-ui-materialui/src/list/Datagrid.js | 6 +-- 4 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 packages/ra-core/src/dataProvider/cacheDataProviderProxy.ts diff --git a/examples/simple/src/dataProvider.js b/examples/simple/src/dataProvider.js index 92481c3aee4..2e96b749f26 100644 --- a/examples/simple/src/dataProvider.js +++ b/examples/simple/src/dataProvider.js @@ -1,4 +1,5 @@ import fakeRestProvider from 'ra-data-fakerest'; +import { cacheDataProviderProxy } from 'react-admin'; import data from './data'; import addUploadFeature from './addUploadFeature'; @@ -11,17 +12,6 @@ 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); }, }); @@ -36,4 +26,4 @@ const delayedDataProvider = new Proxy(sometimesFailsDataProvider, { ), }); -export default delayedDataProvider; +export default cacheDataProviderProxy(delayedDataProvider); diff --git a/packages/ra-core/src/dataProvider/cacheDataProviderProxy.ts b/packages/ra-core/src/dataProvider/cacheDataProviderProxy.ts new file mode 100644 index 00000000000..02bcebce79f --- /dev/null +++ b/packages/ra-core/src/dataProvider/cacheDataProviderProxy.ts @@ -0,0 +1,38 @@ +import { DataProvider } from '../types'; + +const fiveMinutes = 5 * 60 * 1000; + +/** + * Wrap a dataProvider in a Proxy that modifies responses to add caching. + * + * This proxy adds a validUntil field to the response of read queries + * (getList, getMany, getOne) so that the useDataProvider enables caching + * for these calls. + * + * @param {DataProvider} dataProvider A data provider object + * @param {number} duration Cache duration in milliseconds. Defaults to 5 minutes. + * + * @example + * + * import { cacheDataProviderProxy } from 'ra-core'; + * + * const cacheEnabledDataProvider = cacheDataProviderProxy(dataProvider); + */ +export default ( + dataProvider: DataProvider, + duration: number = fiveMinutes +): DataProvider => + new Proxy(dataProvider, { + get: (target, name: string) => (resource, params) => { + if (name === 'getList' || name === 'getMany' || name === 'getOne') { + // @ts-ignore + 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); + }, + }); diff --git a/packages/ra-core/src/dataProvider/index.ts b/packages/ra-core/src/dataProvider/index.ts index 98ac18e072f..fccd8794efd 100644 --- a/packages/ra-core/src/dataProvider/index.ts +++ b/packages/ra-core/src/dataProvider/index.ts @@ -4,6 +4,7 @@ import HttpError from './HttpError'; import * as fetchUtils from './fetch'; import Mutation from './Mutation'; import Query from './Query'; +import cacheDataProviderProxy from './cacheDataProviderProxy'; import undoableEventEmitter from './undoableEventEmitter'; import useDataProvider from './useDataProvider'; import useMutation from './useMutation'; @@ -22,6 +23,7 @@ import useDelete from './useDelete'; import useDeleteMany from './useDeleteMany'; export { + cacheDataProviderProxy, convertLegacyDataProvider, DataProviderContext, fetchUtils, diff --git a/packages/ra-ui-materialui/src/list/Datagrid.js b/packages/ra-ui-materialui/src/list/Datagrid.js index ff3ab71d2e9..cf57ffe89c9 100644 --- a/packages/ra-ui-materialui/src/list/Datagrid.js +++ b/packages/ra-ui-materialui/src/list/Datagrid.js @@ -93,8 +93,8 @@ const useStyles = makeStyles( * * */ -function Datagrid({ classes: classesOverride, ...props }) { - const classes = useStyles({ classes: classesOverride }); +const Datagrid = props => { + const classes = useStyles(props); const { basePath, optimized = false, @@ -262,7 +262,7 @@ function Datagrid({ classes: classesOverride, ...props }) { )} ); -} +}; Datagrid.propTypes = { basePath: PropTypes.string, From 0fc926dd640bed81067ab207136352e2c1136e47 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 25 Feb 2020 23:20:12 +0100 Subject: [PATCH 22/29] Fix unit test --- .../ra-core/src/controller/useListController.spec.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ra-core/src/controller/useListController.spec.tsx b/packages/ra-core/src/controller/useListController.spec.tsx index cdad74385c3..41bc81dc368 100644 --- a/packages/ra-core/src/controller/useListController.spec.tsx +++ b/packages/ra-core/src/controller/useListController.spec.tsx @@ -167,6 +167,7 @@ describe('useListController', () => { }, } ); + const crudGetListCalls = dispatch.mock.calls.filter( call => call[0].type === 'RA/CRUD_GET_LIST' ); @@ -174,7 +175,7 @@ describe('useListController', () => { // 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({}); @@ -189,11 +190,11 @@ describe('useListController', () => { expect(updatedCrudGetListCalls[1][0].payload.filter).toEqual({ foo: 2, }); - expect(children).toBeCalledTimes(6); + 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(() => { From 53c5431768b194d33e88d3f371e8d390b6401530 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 25 Feb 2020 23:57:52 +0100 Subject: [PATCH 23/29] add unit tests for useGetList --- .../src/dataProvider/DataProviderContext.ts | 2 +- .../src/dataProvider/useGetList.spec.tsx | 308 ++++++++++++++++++ 2 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 packages/ra-core/src/dataProvider/useGetList.spec.tsx diff --git a/packages/ra-core/src/dataProvider/DataProviderContext.ts b/packages/ra-core/src/dataProvider/DataProviderContext.ts index db440b5eb10..1d3557683c0 100644 --- a/packages/ra-core/src/dataProvider/DataProviderContext.ts +++ b/packages/ra-core/src/dataProvider/DataProviderContext.ts @@ -2,7 +2,7 @@ import { createContext } from 'react'; import { DataProvider } from '../types'; -const DataProviderContext = createContext(null); +const DataProviderContext = createContext>(null); DataProviderContext.displayName = 'DataProviderContext'; diff --git a/packages/ra-core/src/dataProvider/useGetList.spec.tsx b/packages/ra-core/src/dataProvider/useGetList.spec.tsx new file mode 100644 index 00000000000..b7281cf874b --- /dev/null +++ b/packages/ra-core/src/dataProvider/useGetList.spec.tsx @@ -0,0 +1,308 @@ +import React from 'react'; +import { cleanup } from '@testing-library/react'; +import expect from 'expect'; + +import renderWithRedux from '../util/renderWithRedux'; +import useGetList from './useGetList'; +import { DataProviderContext } from '../dataProvider'; + +const UseGetList = ({ + resource = 'posts', + pagination = { page: 1, perPage: 10 }, + sort = { field: 'id', order: 'DESC' }, + filter = {}, + options = {}, + callback = null, + ...rest +}) => { + const hookValue = useGetList(resource, pagination, sort, filter, options); + if (callback) callback(hookValue); + return
hello
; +}; + +describe('useGetList', () => { + afterEach(cleanup); + + it('should call dataProvider.getList() on mount', async () => { + const dataProvider = { + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'foo' }], total: 1 }) + ), + }; + const { dispatch } = renderWithRedux( + + + + ); + await new Promise(resolve => setTimeout(resolve)); + expect(dispatch).toBeCalledTimes(5); + expect(dispatch.mock.calls[0][0].type).toBe('CUSTOM_FETCH'); + expect(dataProvider.getList).toBeCalledTimes(1); + expect(dataProvider.getList.mock.calls[0]).toEqual([ + 'posts', + { + filter: {}, + pagination: { page: 1, perPage: 20 }, + sort: { field: 'id', order: 'DESC' }, + }, + ]); + }); + + it('should not call the dataProvider on update', async () => { + const dataProvider = { + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'foo' }], total: 1 }) + ), + }; + const { dispatch, rerender } = renderWithRedux( + + + + ); + await new Promise(resolve => setTimeout(resolve)); + rerender( + + + + ); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(dispatch).toBeCalledTimes(5); + expect(dataProvider.getList).toBeCalledTimes(1); + }); + + it('should call the dataProvider on update when the resource changes', async () => { + const dataProvider = { + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'foo' }], total: 1 }) + ), + }; + const { dispatch, rerender } = renderWithRedux( + + + + ); + await new Promise(resolve => setTimeout(resolve)); + rerender( + + + + ); + await new Promise(resolve => setTimeout(resolve)); + expect(dispatch).toBeCalledTimes(10); + expect(dataProvider.getList).toBeCalledTimes(2); + }); + + it('should retrieve results from redux state on mount', () => { + const hookValue = jest.fn(); + const dataProvider = { + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1 }, { id: 2 }], total: 2 }) + ), + }; + renderWithRedux( + + + , + { + admin: { + resources: { + posts: { + data: { 1: { id: 1 }, 2: { id: 2 } }, + list: { + cachedRequests: { + '{"pagination":{"page":1,"perPage":10},"sort":{"field":"id","order":"DESC"},"filter":{}}': { + ids: [1, 2], + total: 2, + }, + }, + }, + }, + }, + }, + } + ); + expect(hookValue.mock.calls[0][0]).toEqual({ + data: { 1: { id: 1 }, 2: { id: 2 } }, + ids: [1, 2], + total: 2, + loading: true, + loaded: true, + error: null, + }); + }); + + it('should replace redux data with dataProvider data', async () => { + const hookValue = jest.fn(); + const dataProvider = { + getList: jest.fn(() => + Promise.resolve({ + data: [{ id: 1, title: 'foo' }, { id: 2, title: 'bar' }], + total: 2, + }) + ), + }; + renderWithRedux( + + + , + { + admin: { + resources: { + posts: { + data: { 1: { id: 1 }, 2: { id: 2 } }, + list: { + cachedRequests: { + '{}': { + ids: [1, 2], + total: 2, + }, + }, + }, + }, + }, + }, + } + ); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(hookValue.mock.calls.pop()[0]).toEqual({ + data: { 1: { id: 1, title: 'foo' }, 2: { id: 2, title: 'bar' } }, + ids: [1, 2], + total: 2, + loading: false, + loaded: true, + error: null, + }); + }); + + it('should return loading state false once the dataProvider returns', async () => { + const hookValue = jest.fn(); + const dataProvider = { + getList: jest.fn(() => + Promise.resolve({ + data: [{ id: 1, title: 'foo' }, { id: 2, title: 'bar' }], + total: 2, + }) + ), + }; + renderWithRedux( + + + , + { + admin: { + resources: { + posts: { + data: { 1: { id: 1 }, 2: { id: 2 } }, + list: { + cachedRequests: { + '{}': { + ids: [1, 2], + total: 2, + }, + }, + }, + }, + }, + }, + } + ); + expect(hookValue.mock.calls.pop()[0].loading).toBe(true); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(hookValue.mock.calls.pop()[0].loading).toBe(false); + }); + + it('should set the loading state depending on the availability of the data in the redux store', () => { + const hookValue = jest.fn(); + renderWithRedux(, { + admin: { + resources: { posts: { data: {}, cachedRequests: {} } }, + }, + }); + expect(hookValue.mock.calls[0][0]).toEqual({ + data: {}, + ids: undefined, + total: undefined, + loading: true, + loaded: false, + error: null, + }); + }); + + it('should set the error state when the dataProvider fails', async () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + const hookValue = jest.fn(); + const dataProvider = { + getList: jest.fn(() => Promise.reject(new Error('failed'))), + }; + renderWithRedux( + + + + ); + expect(hookValue.mock.calls.pop()[0].error).toBe(null); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(hookValue.mock.calls.pop()[0].error).toEqual( + new Error('failed') + ); + }); + + it('should execute success side effects on success', async () => { + const onSuccess1 = jest.fn(); + const onSuccess2 = jest.fn(); + const dataProvider = { + getList: jest + .fn() + .mockReturnValueOnce( + Promise.resolve({ + data: [ + { id: 1, title: 'foo' }, + { id: 2, title: 'bar' }, + ], + total: 2, + }) + ) + .mockReturnValueOnce( + Promise.resolve({ + data: [{ id: 3, foo: 1 }, { id: 4, foo: 2 }], + total: 2, + }) + ), + }; + renderWithRedux( + + + + + ); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(onSuccess1).toBeCalledTimes(1); + expect(onSuccess1.mock.calls.pop()[0]).toEqual({ + data: [{ id: 1, title: 'foo' }, { id: 2, title: 'bar' }], + total: 2, + }); + expect(onSuccess2).toBeCalledTimes(1); + expect(onSuccess2.mock.calls.pop()[0]).toEqual({ + data: [{ id: 3, foo: 1 }, { id: 4, foo: 2 }], + total: 2, + }); + }); + + it('should execute failure side effects on failure', async () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + const onFailure = jest.fn(); + const dataProvider = { + getList: jest.fn(() => Promise.reject(new Error('failed'))), + }; + renderWithRedux( + + + + ); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(onFailure).toBeCalledTimes(1); + expect(onFailure.mock.calls.pop()[0]).toEqual(new Error('failed')); + }); +}); From 555f4b45c5ed9b8366c569794f603c1c5ed38051 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 27 Feb 2020 18:01:24 +0100 Subject: [PATCH 24/29] Fix compiler error --- packages/ra-core/src/dataProvider/DataProviderContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-core/src/dataProvider/DataProviderContext.ts b/packages/ra-core/src/dataProvider/DataProviderContext.ts index 1d3557683c0..db440b5eb10 100644 --- a/packages/ra-core/src/dataProvider/DataProviderContext.ts +++ b/packages/ra-core/src/dataProvider/DataProviderContext.ts @@ -2,7 +2,7 @@ import { createContext } from 'react'; import { DataProvider } from '../types'; -const DataProviderContext = createContext>(null); +const DataProviderContext = createContext(null); DataProviderContext.displayName = 'DataProviderContext'; From 48dd9b0b3e506e39b1bcb75612ad51308b79ad15 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 27 Feb 2020 18:31:11 +0100 Subject: [PATCH 25/29] Add more tests to useDataProvider --- .../src/dataProvider/replyWithCache.ts | 1 + .../src/dataProvider/useDataProvider.spec.js | 117 +++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/dataProvider/replyWithCache.ts b/packages/ra-core/src/dataProvider/replyWithCache.ts index 37e7d00acf3..2488a8bc078 100644 --- a/packages/ra-core/src/dataProvider/replyWithCache.ts +++ b/packages/ra-core/src/dataProvider/replyWithCache.ts @@ -26,6 +26,7 @@ export const canReplyWithCache = (type, payload, resourceState) => { resourceState.validity && resourceState.validity[(payload as GetOneParams).id] > now ); + case 'getMany': return ( resourceState && diff --git a/packages/ra-core/src/dataProvider/useDataProvider.spec.js b/packages/ra-core/src/dataProvider/useDataProvider.spec.js index e54490a09d3..b03a5ebb8f8 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.spec.js +++ b/packages/ra-core/src/dataProvider/useDataProvider.spec.js @@ -1,10 +1,11 @@ import React, { useState, useEffect } from 'react'; -import { cleanup, act } from '@testing-library/react'; +import { cleanup, act, fireEvent } from '@testing-library/react'; import expect from 'expect'; import renderWithRedux from '../util/renderWithRedux'; import useDataProvider from './useDataProvider'; import { DataProviderContext } from '../dataProvider'; +import { useRefresh } from '../sideEffect'; const UseGetOne = () => { const [data, setData] = useState(); @@ -12,7 +13,7 @@ const UseGetOne = () => { const dataProvider = useDataProvider(); useEffect(() => { dataProvider - .getOne() + .getOne('posts', { id: 1 }) .then(res => setData(res.data)) .catch(e => setError(e)); }, [dataProvider]); @@ -203,4 +204,116 @@ describe('useDataProvider', () => { expect(onFailure.mock.calls[0][0]).toEqual(new Error('foo')); }); }); + + describe('cache', () => { + it('should not skip the dataProvider call if there is no cache', async () => { + const getOne = jest.fn(() => Promise.resolve({ data: { id: 1 } })); + const dataProvider = { getOne }; + const { rerender } = renderWithRedux( + + + , + { admin: { resources: { posts: { data: {}, list: {} } } } } + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + expect(getOne).toBeCalledTimes(1); + rerender( + + + + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + expect(getOne).toBeCalledTimes(2); + }); + + it('should skip the dataProvider call if there is a valid cache', async () => { + const getOne = jest.fn(() => { + const validUntil = new Date(); + validUntil.setTime(validUntil.getTime() + 1000); + return Promise.resolve({ data: { id: 1 }, validUntil }); + }); + const dataProvider = { getOne }; + const { rerender } = renderWithRedux( + + + , + { admin: { resources: { posts: { data: {}, list: {} } } } } + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + expect(getOne).toBeCalledTimes(1); + rerender( + + + + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + expect(getOne).toBeCalledTimes(1); + }); + + it('should not skip the dataProvider call if there is an invalid cache', async () => { + const getOne = jest.fn(() => { + const validUntil = new Date(); + validUntil.setTime(validUntil.getTime() - 1000); + return Promise.resolve({ data: { id: 1 }, validUntil }); + }); + const dataProvider = { getOne }; + const { rerender } = renderWithRedux( + + + , + { admin: { resources: { posts: { data: {}, list: {} } } } } + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + expect(getOne).toBeCalledTimes(1); + rerender( + + + + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + expect(getOne).toBeCalledTimes(2); + }); + + it('should not use the cache after a refresh', async () => { + const getOne = jest.fn(() => { + const validUntil = new Date(); + validUntil.setTime(validUntil.getTime() + 1000); + return Promise.resolve({ data: { id: 1 }, validUntil }); + }); + const dataProvider = { getOne }; + const Refresh = () => { + const refresh = useRefresh(); + return ; + }; + const { getByText, rerender } = renderWithRedux( + + + + , + { admin: { resources: { posts: { data: {}, list: {} } } } } + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + // click on the refresh button + await act(async () => { + fireEvent.click(getByText('refresh')); + await new Promise(r => setTimeout(r)); + }); + expect(getOne).toBeCalledTimes(1); + rerender( + + + + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + expect(getOne).toBeCalledTimes(2); + }); + }); }); From fa83f9e830f705003b357fd07ce4d74b542e174d Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 2 Mar 2020 10:01:45 +0100 Subject: [PATCH 26/29] Add documentation --- docs/Caching.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/Caching.md diff --git a/docs/Caching.md b/docs/Caching.md new file mode 100644 index 00000000000..d248a2bd344 --- /dev/null +++ b/docs/Caching.md @@ -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 `winfow.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! From 5eb533ed9c27af37109a85776881ae114536e676 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 2 Mar 2020 10:18:45 +0100 Subject: [PATCH 27/29] Add validUntil key in dataProvider spec --- docs/DataProviders.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/DataProviders.md b/docs/DataProviders.md index e24a9de0637..c711889897c 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -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} }` @@ -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: From 57bc2defd354a91c35874d2f3991af67f8d97209 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 2 Mar 2020 10:24:32 +0100 Subject: [PATCH 28/29] Add tests for update invalidating cache --- .../src/dataProvider/useDataProvider.spec.js | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/ra-core/src/dataProvider/useDataProvider.spec.js b/packages/ra-core/src/dataProvider/useDataProvider.spec.js index b03a5ebb8f8..38839c63b6d 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.spec.js +++ b/packages/ra-core/src/dataProvider/useDataProvider.spec.js @@ -4,6 +4,7 @@ import expect from 'expect'; import renderWithRedux from '../util/renderWithRedux'; import useDataProvider from './useDataProvider'; +import useUpdate from './useUpdate'; import { DataProviderContext } from '../dataProvider'; import { useRefresh } from '../sideEffect'; @@ -301,11 +302,11 @@ describe('useDataProvider', () => { // wait for the dataProvider to return await act(async () => await new Promise(r => setTimeout(r))); // click on the refresh button + expect(getOne).toBeCalledTimes(1); await act(async () => { fireEvent.click(getByText('refresh')); await new Promise(r => setTimeout(r)); }); - expect(getOne).toBeCalledTimes(1); rerender( @@ -316,4 +317,43 @@ describe('useDataProvider', () => { expect(getOne).toBeCalledTimes(2); }); }); + + it('should not use the cache after an update', async () => { + const getOne = jest.fn(() => { + const validUntil = new Date(); + validUntil.setTime(validUntil.getTime() + 1000); + return Promise.resolve({ data: { id: 1 }, validUntil }); + }); + const dataProvider = { + getOne, + update: () => Promise.resolve({ data: { id: 1, foo: 'bar' } }), + }; + const Update = () => { + const [update] = useUpdate('posts', 1, { foo: 'bar ' }); + return ; + }; + const { getByText, rerender } = renderWithRedux( + + + + , + { admin: { resources: { posts: { data: {}, list: {} } } } } + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + expect(getOne).toBeCalledTimes(1); + // click on the update button + await act(async () => { + fireEvent.click(getByText('update')); + await new Promise(r => setTimeout(r)); + }); + rerender( + + + + ); + // wait for the dataProvider to return + await act(async () => await new Promise(r => setTimeout(r))); + expect(getOne).toBeCalledTimes(2); + }); }); From f78902baa1b1ec2f0b929a51b061c650b898d931 Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Tue, 3 Mar 2020 18:25:46 +0100 Subject: [PATCH 29/29] Update docs/Caching.md Co-Authored-By: JulienM <39904906+JulienMattiussi@users.noreply.github.com> --- docs/Caching.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Caching.md b/docs/Caching.md index d248a2bd344..8cb7bafbf05 100644 --- a/docs/Caching.md +++ b/docs/Caching.md @@ -32,7 +32,7 @@ Optimistic rendering improves user experience by displaying stale data while get React-admin supports HTTP cache headers by default, provided your API sends them. -Data providers almost always rely on `winfow.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: +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`