From 99cd596ed69b276b238c45879d0113af90326e92 Mon Sep 17 00:00:00 2001 From: Armin Mehinovic Date: Fri, 16 Aug 2024 16:31:00 +0200 Subject: [PATCH] Adjust start and end params to cover whole page(s). Cache responses in chunks based on the pagination model and props. Combine cache entries when needed --- .../src/hooks/features/dataSource/cache.ts | 104 ++++++++++++++++-- .../features/dataSource/useGridDataSource.ts | 54 +++++++-- 2 files changed, 138 insertions(+), 20 deletions(-) diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts index c4ad35d6982a4..05fa98374d2f7 100644 --- a/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts @@ -1,17 +1,23 @@ import { GridGetRowsParams, GridGetRowsResponse } from '../../../models'; -type GridDataSourceCacheDefaultConfig = { +export type GridDataSourceCacheDefaultConfig = { /** * Time To Live for each cache entry in milliseconds. * After this time the cache entry will become stale and the next query will result in cache miss. * @default 300000 (5 minutes) */ ttl?: number; + /** + * The number of rows to store in each cache entry. If not set, the whole array will be stored in a single cache entry. + * Setting this value to smallest page size will result in better cache hit rate. + * Has no effect if cursor pagination is used. + * @default undefined + */ + chunkSize?: number; }; function getKey(params: GridGetRowsParams) { return JSON.stringify([ - params.paginationModel, params.filterModel, params.sortModel, params.groupKeys, @@ -21,32 +27,106 @@ function getKey(params: GridGetRowsParams) { } export class GridDataSourceCacheDefault { - private cache: Record; + private cache: Record< + string, + { + value: GridGetRowsResponse; + expiry: number; + chunk: { startIndex: string | number; endIndex: number }; + } + >; private ttl: number; - constructor({ ttl = 300000 }: GridDataSourceCacheDefaultConfig) { + private chunkSize: number; + + private getChunkRanges = (params: GridGetRowsParams) => { + if (this.chunkSize < 1 || typeof params.start !== 'number') { + return [{ startIndex: params.start, endIndex: params.end }]; + } + + // split the range into chunks + const chunkRanges = []; + for (let i = params.start; i < params.end; i += this.chunkSize) { + const endIndex = Math.min(i + this.chunkSize - 1, params.end); + chunkRanges.push({ startIndex: i, endIndex }); + } + + return chunkRanges; + }; + + constructor({ chunkSize, ttl = 300000 }: GridDataSourceCacheDefaultConfig) { this.cache = {}; this.ttl = ttl; + this.chunkSize = chunkSize || 0; } set(key: GridGetRowsParams, value: GridGetRowsResponse) { - const keyString = getKey(key); + const chunks = this.getChunkRanges(key); const expiry = Date.now() + this.ttl; - this.cache[keyString] = { value, expiry }; + + chunks.forEach((chunk) => { + const isLastChunk = chunk.endIndex === key.end; + const keyString = getKey({ ...key, start: chunk.startIndex, end: chunk.endIndex }); + const chunkValue: GridGetRowsResponse = { + ...value, + pageInfo: { + ...value.pageInfo, + // If the original response had page info, update that information for all but last chunk and keep the original value for the last chunk + hasNextPage: + (value.pageInfo?.hasNextPage !== undefined && !isLastChunk) || + value.pageInfo?.hasNextPage, + nextCursor: + value.pageInfo?.nextCursor !== undefined && !isLastChunk + ? value.rows[chunk.endIndex + 1].id + : value.pageInfo?.nextCursor, + }, + rows: + typeof chunk.startIndex !== 'number' || typeof key.start !== 'number' + ? value.rows + : value.rows.slice(chunk.startIndex - key.start, chunk.endIndex - key.start + 1), + }; + + this.cache[keyString] = { value: chunkValue, expiry, chunk }; + }); } get(key: GridGetRowsParams): GridGetRowsResponse | undefined { - const keyString = getKey(key); - const entry = this.cache[keyString]; - if (!entry) { + const chunks = this.getChunkRanges(key); + + const startChunk = chunks.findIndex((chunk) => chunk.startIndex === key.start); + const endChunk = chunks.findIndex((chunk) => chunk.endIndex === key.end); + + // If desired range cannot fit completely in chunks, then it is a cache miss + if (startChunk === -1 || endChunk === -1) { return undefined; } - if (Date.now() > entry.expiry) { - delete this.cache[keyString]; + + const cachedResponses = []; + + for (let i = startChunk; i <= endChunk; i += 1) { + const keyString = getKey({ ...key, start: chunks[i].startIndex, end: chunks[i].endIndex }); + const entry = this.cache[keyString]; + const isCacheValid = entry?.value && Date.now() < entry.expiry; + cachedResponses.push(isCacheValid ? entry?.value : null); + } + + // If any of the chunks is missing, then it is a cache miss + if (cachedResponses.some((response) => response === null)) { return undefined; } - return entry.value; + + // Merge the chunks into a single response + return (cachedResponses as GridGetRowsResponse[]).reduce( + (acc: GridGetRowsResponse, response) => { + return { + rows: [...acc.rows, ...response.rows], + rowCount: response.rowCount, + pageInfo: response.pageInfo, + }; + }, + { rows: [], rowCount: 0, pageInfo: {} }, + ); } clear() { diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts index a6446d94a7c71..fad157c9666a4 100644 --- a/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts @@ -7,6 +7,7 @@ import { GridDataSourceGroupNode, useGridSelector, GridRowId, + gridPaginationModelSelector, } from '@mui/x-data-grid'; import { GridGetRowsParams, @@ -19,7 +20,7 @@ import { gridGetRowsParamsSelector, gridDataSourceErrorsSelector } from './gridD import { GridDataSourceApi, GridDataSourceApiBase, GridDataSourcePrivateApi } from './interfaces'; import { NestedDataManager, RequestStatus, runIf } from './utils'; import { GridDataSourceCache } from '../../../models'; -import { GridDataSourceCacheDefault } from './cache'; +import { GridDataSourceCacheDefault, GridDataSourceCacheDefaultConfig } from './cache'; const INITIAL_STATE = { loading: {}, @@ -32,11 +33,14 @@ const noopCache: GridDataSourceCache = { set: () => {}, }; -function getCache(cacheProp?: GridDataSourceCache | null) { +function getCache( + cacheProp?: GridDataSourceCache | null, + options: GridDataSourceCacheDefaultConfig = {}, +) { if (cacheProp === null) { return noopCache; } - return cacheProp ?? new GridDataSourceCacheDefault({}); + return cacheProp ?? new GridDataSourceCacheDefault(options); } export const dataSourceStateInitializer: GridStateInitializer = (state) => { @@ -56,6 +60,7 @@ export const useGridDataSource = ( | 'sortingMode' | 'filterMode' | 'paginationMode' + | 'pageSizeOptions' | 'treeData' | 'lazyLoading' >, @@ -63,6 +68,7 @@ export const useGridDataSource = ( const nestedDataManager = useLazyRef( () => new NestedDataManager(apiRef), ).current; + const paginationModel = useGridSelector(apiRef, gridPaginationModelSelector); const groupsToAutoFetch = useGridSelector(apiRef, gridRowGroupsToFetchSelector); const scheduledGroups = React.useRef(0); @@ -71,8 +77,38 @@ export const useGridDataSource = ( const onError = props.unstable_onDataSourceError; + const cacheChunkSize = React.useMemo(() => { + const sortedPageSizeOptions = props.pageSizeOptions + .map((option) => (typeof option === 'number' ? option : option.value)) + .sort((a, b) => a - b); + + return Math.min(paginationModel.pageSize, sortedPageSizeOptions[0]); + }, [paginationModel.pageSize, props.pageSizeOptions]); + const [cache, setCache] = React.useState(() => - getCache(props.unstable_dataSourceCache), + getCache(props.unstable_dataSourceCache, { + chunkSize: cacheChunkSize, + }), + ); + + // Adjust the render context range to fit the pagination model's page size + // First row index should be decreased to the start of the page, end row index should be increased to the end of the page or the last row + const adjustRowParams = React.useCallback( + (params: Pick) => { + if (typeof params.start !== 'number') { + return params; + } + + const rowCount = apiRef.current.state.pagination.rowCount; + return { + start: params.start - (params.start % paginationModel.pageSize), + end: Math.min( + params.end + paginationModel.pageSize - (params.end % paginationModel.pageSize) - 1, + rowCount - 1, + ), + }; + }, + [apiRef, paginationModel], ); const fetchRows = React.useCallback( @@ -150,10 +186,10 @@ export const useGridDataSource = ( const fetchRowBatch = React.useCallback( (fetchParams: GridGetRowsParams) => { - rowFetchSlice.current = { start: Number(fetchParams.start), end: fetchParams.end }; + rowFetchSlice.current = adjustRowParams(fetchParams); return fetchRows(); }, - [fetchRows], + [adjustRowParams, fetchRows], ); const fetchRowChildren = React.useCallback( @@ -319,9 +355,11 @@ export const useGridDataSource = ( isFirstRender.current = false; return; } - const newCache = getCache(props.unstable_dataSourceCache); + const newCache = getCache(props.unstable_dataSourceCache, { + chunkSize: cacheChunkSize, + }); setCache((prevCache) => (prevCache !== newCache ? newCache : prevCache)); - }, [props.unstable_dataSourceCache]); + }, [props.unstable_dataSourceCache, cacheChunkSize]); React.useEffect(() => { if (props.unstable_dataSource) {