diff --git a/docs/data/data-grid/events/events.json b/docs/data/data-grid/events/events.json index a2831d2974c2..fedd745dd5c4 100644 --- a/docs/data/data-grid/events/events.json +++ b/docs/data/data-grid/events/events.json @@ -227,7 +227,7 @@ { "projects": ["x-data-grid-pro", "x-data-grid-premium"], "name": "fetchRows", - "description": "Fired when a new batch of rows is requested to be loaded. Called with a GridFetchRowsParams object.", + "description": "Fired when a new batch of rows is requested to be loaded. Called with a GridFetchRowsParams object. Used to trigger onFetchRows.", "params": "GridFetchRowsParams", "event": "MuiEvent<{}>", "componentProp": "onFetchRows" diff --git a/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.js b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.js index 43c742a5b90a..e598eb38982e 100644 --- a/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.js +++ b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.js @@ -38,6 +38,7 @@ export default function ServerSideDataGridNoCache() { ...initialState, pagination: { paginationModel: { pageSize: 10, page: 0 }, + rowCount: 0, }, }), [initialState], diff --git a/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx index b62606d8985f..19a578b73a8c 100644 --- a/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx +++ b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx @@ -38,6 +38,7 @@ export default function ServerSideDataGridNoCache() { ...initialState, pagination: { paginationModel: { pageSize: 10, page: 0 }, + rowCount: 0, }, }), [initialState], diff --git a/docs/data/data-grid/server-side-data/ServerSideLazyLoadingErrorHandling.js b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingErrorHandling.js new file mode 100644 index 000000000000..da05c2b3c9ea --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingErrorHandling.js @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { + DataGridPro, + useGridApiRef, + GridToolbar, + GRID_ROOT_GROUP_ID, +} from '@mui/x-data-grid-pro'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import Snackbar from '@mui/material/Snackbar'; + +function ErrorSnackbar(props) { + const { onRetry, ...rest } = props; + return ( + + + Retry + + } + > + Failed to fetch row data + + + ); +} + +function ServerSideLazyLoadingErrorHandling() { + const apiRef = useGridApiRef(); + const [retryParams, setRetryParams] = React.useState(null); + const [shouldRequestsFail, setShouldRequestsFail] = React.useState(false); + + const { fetchRows, ...props } = useMockServer( + { rowLength: 100 }, + { useCursorPagination: false, minDelay: 300, maxDelay: 800 }, + shouldRequestsFail, + ); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + start: `${params.start}`, + end: `${params.end}`, + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + return ( +
+ setShouldRequestsFail(event.target.checked)} + /> + } + label="Make the requests fail" + /> +
+ {retryParams && ( + { + apiRef.current.unstable_dataSource.fetchRows( + GRID_ROOT_GROUP_ID, + retryParams, + ); + setRetryParams(null); + }} + /> + )} + setRetryParams(params)} + unstable_dataSourceCache={null} + lazyLoading + paginationModel={{ page: 0, pageSize: 10 }} + slots={{ toolbar: GridToolbar }} + /> +
+
+ ); +} + +export default ServerSideLazyLoadingErrorHandling; diff --git a/docs/data/data-grid/server-side-data/ServerSideLazyLoadingErrorHandling.tsx b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingErrorHandling.tsx new file mode 100644 index 000000000000..ed8244044376 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingErrorHandling.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { + DataGridPro, + useGridApiRef, + GridToolbar, + GridDataSource, + GridGetRowsParams, + GRID_ROOT_GROUP_ID, +} from '@mui/x-data-grid-pro'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import Snackbar, { SnackbarProps } from '@mui/material/Snackbar'; + +function ErrorSnackbar(props: SnackbarProps & { onRetry: () => void }) { + const { onRetry, ...rest } = props; + return ( + + + Retry + + } + > + Failed to fetch row data + + + ); +} + +function ServerSideLazyLoadingErrorHandling() { + const apiRef = useGridApiRef(); + const [retryParams, setRetryParams] = React.useState( + null, + ); + const [shouldRequestsFail, setShouldRequestsFail] = React.useState(false); + + const { fetchRows, ...props } = useMockServer( + { rowLength: 100 }, + { useCursorPagination: false, minDelay: 300, maxDelay: 800 }, + shouldRequestsFail, + ); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + start: `${params.start}`, + end: `${params.end}`, + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + return ( +
+ setShouldRequestsFail(event.target.checked)} + /> + } + label="Make the requests fail" + /> +
+ {retryParams && ( + { + apiRef.current.unstable_dataSource.fetchRows( + GRID_ROOT_GROUP_ID, + retryParams, + ); + setRetryParams(null); + }} + /> + )} + setRetryParams(params)} + unstable_dataSourceCache={null} + lazyLoading + paginationModel={{ page: 0, pageSize: 10 }} + slots={{ toolbar: GridToolbar }} + /> +
+
+ ); +} + +export default ServerSideLazyLoadingErrorHandling; diff --git a/docs/data/data-grid/server-side-data/ServerSideLazyLoadingInfinite.js b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingInfinite.js new file mode 100644 index 000000000000..c2a75162e751 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingInfinite.js @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +function ServerSideLazyLoadingInfinite() { + const { fetchRows, ...props } = useMockServer( + { rowLength: 100 }, + { useCursorPagination: false, minDelay: 200, maxDelay: 500 }, + ); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + start: `${params.start}`, + end: `${params.end}`, + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + + return { + rows: getRowsResponse.rows, + }; + }, + }), + [fetchRows], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideLazyLoadingInfinite; diff --git a/docs/data/data-grid/server-side-data/ServerSideLazyLoadingInfinite.tsx b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingInfinite.tsx new file mode 100644 index 000000000000..05b5486df6a8 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingInfinite.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { + DataGridPro, + GridDataSource, + GridGetRowsParams, +} from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +function ServerSideLazyLoadingInfinite() { + const { fetchRows, ...props } = useMockServer( + { rowLength: 100 }, + { useCursorPagination: false, minDelay: 200, maxDelay: 500 }, + ); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params: GridGetRowsParams) => { + const urlParams = new URLSearchParams({ + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + start: `${params.start}`, + end: `${params.end}`, + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + + return { + rows: getRowsResponse.rows, + }; + }, + }), + [fetchRows], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideLazyLoadingInfinite; diff --git a/docs/data/data-grid/server-side-data/ServerSideLazyLoadingModeUpdate.js b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingModeUpdate.js new file mode 100644 index 000000000000..dc1204f74f02 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingModeUpdate.js @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import FormControl from '@mui/material/FormControl'; +import FormLabel from '@mui/material/FormLabel'; +import RadioGroup from '@mui/material/RadioGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Radio from '@mui/material/Radio'; + +function GridCustomToolbar({ count, setCount }) { + return ( + + Row count + setCount(Number(event.target.value))} + > + } label="Unknown" /> + } label="40" /> + } label="100" /> + + + ); +} + +function ServerSideLazyLoadingModeUpdate() { + const { fetchRows, ...props } = useMockServer( + { rowLength: 100 }, + { useCursorPagination: false, minDelay: 200, maxDelay: 500 }, + ); + + const [rowCount, setRowCount] = React.useState(-1); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + start: `${params.start}`, + end: `${params.end}`, + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + + return { + rows: getRowsResponse.rows, + }; + }, + }), + [fetchRows], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideLazyLoadingModeUpdate; diff --git a/docs/data/data-grid/server-side-data/ServerSideLazyLoadingModeUpdate.tsx b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingModeUpdate.tsx new file mode 100644 index 000000000000..f23341266633 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingModeUpdate.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import { + DataGridPro, + GridDataSource, + GridGetRowsParams, + GridSlots, +} from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import FormControl from '@mui/material/FormControl'; +import FormLabel from '@mui/material/FormLabel'; +import RadioGroup from '@mui/material/RadioGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Radio from '@mui/material/Radio'; + +interface CustomToolbarProps { + count: number; + setCount: (count: number) => void; +} + +function GridCustomToolbar({ count, setCount }: CustomToolbarProps) { + return ( + + Row count + setCount(Number(event.target.value))} + > + } label="Unknown" /> + } label="40" /> + } label="100" /> + + + ); +} + +function ServerSideLazyLoadingModeUpdate() { + const { fetchRows, ...props } = useMockServer( + { rowLength: 100 }, + { useCursorPagination: false, minDelay: 200, maxDelay: 500 }, + ); + + const [rowCount, setRowCount] = React.useState(-1); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params: GridGetRowsParams) => { + const urlParams = new URLSearchParams({ + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + start: `${params.start}`, + end: `${params.end}`, + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + + return { + rows: getRowsResponse.rows, + }; + }, + }), + [fetchRows], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideLazyLoadingModeUpdate; diff --git a/docs/data/data-grid/server-side-data/ServerSideLazyLoadingViewport.js b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingViewport.js new file mode 100644 index 000000000000..88fe9c31794a --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingViewport.js @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +function ServerSideLazyLoadingViewport() { + const { fetchRows, ...props } = useMockServer( + { rowLength: 100000 }, + { useCursorPagination: false, minDelay: 200, maxDelay: 500 }, + ); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + start: `${params.start}`, + end: `${params.end}`, + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideLazyLoadingViewport; diff --git a/docs/data/data-grid/server-side-data/ServerSideLazyLoadingViewport.tsx b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingViewport.tsx new file mode 100644 index 000000000000..1d6c08815ec8 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideLazyLoadingViewport.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { + DataGridPro, + GridDataSource, + GridGetRowsParams, +} from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +function ServerSideLazyLoadingViewport() { + const { fetchRows, ...props } = useMockServer( + { rowLength: 100000 }, + { useCursorPagination: false, minDelay: 200, maxDelay: 500 }, + ); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params: GridGetRowsParams) => { + const urlParams = new URLSearchParams({ + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + start: `${params.start}`, + end: `${params.end}`, + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideLazyLoadingViewport; diff --git a/docs/data/data-grid/server-side-data/infinite-loading.md b/docs/data/data-grid/server-side-data/infinite-loading.md deleted file mode 100644 index d2bcc0e58d03..000000000000 --- a/docs/data/data-grid/server-side-data/infinite-loading.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: React Server-side infinite loading ---- - -# Data Grid - Server-side infinite loading [](/x/introduction/licensing/#pro-plan 'Pro plan')🚧 - -

Row infinite loading with server-side data source.

- -:::warning -This feature isn't implemented yet. It's coming. - -👍 Upvote [issue #10858](https://github.com/mui/mui-x/issues/10858) if you want to see it land faster. - -Don't hesitate to leave a comment on the same issue to influence what gets built. Especially if you already have a use case for this component, or if you are facing a pain point with the [current solution](https://mui.com/x/react-data-grid/row-updates/#infinite-loading). -::: diff --git a/docs/data/data-grid/server-side-data/lazy-loading.md b/docs/data/data-grid/server-side-data/lazy-loading.md index 6c66183ab0e6..f4a8e23c3c79 100644 --- a/docs/data/data-grid/server-side-data/lazy-loading.md +++ b/docs/data/data-grid/server-side-data/lazy-loading.md @@ -2,14 +2,108 @@ title: React Server-side lazy loading --- -# Data Grid - Server-side lazy loading [](/x/introduction/licensing/#pro-plan 'Pro plan')🚧 +# Data Grid - Server-side lazy loading [](/x/introduction/licensing/#pro-plan 'Pro plan')

Row lazy-loading with server-side data source.

+Lazy Loading changes the way pagination works by removing page controls and loading data dynamically (in a single list) as the user scrolls through the grid. + +It is enabled by adding `lazyLoading` prop in combination with `unstable_dataSource` prop. + +Initially, the first page data is fetched and displayed in the grid. What triggers the loading of next page data depends on the value of the total row count. + +If the total row count is known, the Data Grid gets filled with skeleton rows and fetches more data if one of the skeleton rows falls into the rendering context. +This loading strategy is often referred to as [**viewport loading**](#viewport-loading). + +If the total row count is unknown, the Data Grid fetches more data when the user scrolls to the bottom. This loading strategy is often referred to as [**infinite loading**](#infinite-loading). + +:::info +Row count can be provided either by returning the `rowCount` in the response of the `getRows` method in `unstable_dataSource`, via the `rowCount` prop or by calling [`setRowCount`](/x/api/data-grid/grid-api/#grid-api-prop-setRowCount) API. +::: + +:::warning +Order of precedence for the row count: + +- `rowCount` prop +- `rowCount` returned by the `getRows` method +- row count set using the `setRowCount` API + +This means that, if the row count is set using the API, that value gets overridden once a new value is returned by the `getRows` method, even if it is `undefined`. +::: + +## Viewport loading + +The viewport loading mode is enabled when the row count is known (`rowCount >= 0`). Grid fetches the first page immediately and adds skeleton rows to match the total row count. Other pages are fetched once the user starts scrolling and moves a skeleton row inside the rendering context (index range defined by [Virtualization](/x/react-data-grid/virtualization/)). + +If the user scrolls too fast, the grid loads multiple pages with one request (by adjusting `start` and `end` param) in order to reduce the server load. + +In addition to this, the grid throttles new requests made to the data source after each rendering context change. This can be controlled with `lazyLoadingRequestThrottleMs` prop. + +The demo below shows the viewport loading mode. + +{{"demo": "ServerSideLazyLoadingViewport.js", "bg": "inline"}} + +:::info +The data source demos use a utility function `useMockServer` to simulate the server-side data fetching. +In a real-world scenario, you should replace this with your own server-side data-fetching logic. + +Open info section of the browser console to see the requests being made and the data being fetched in response. +::: + +## Infinite loading + +The infinite loading mode is enabled when the row count is unknown (`-1` or `undefined`). New page is loaded when the scroll reaches the bottom of the viewport area. + +The area which triggers the new request can be changed using `scrollEndThreshold`. + +The demo below shows the infinite loading mode. Page size is set to `15` and the mock server is configured to return a total of `100` rows. Once the response does not contain any new rows, the grid stops requesting new data. + +{{"demo": "ServerSideLazyLoadingInfinite.js", "bg": "inline"}} + +## Updating the loading mode + +The grid changes the loading mode dynamically if the total row count gets updated in any of the three ways described above. + +Based on the previous and the new value for the total row count, the following scenarios are possible: + +- **Unknown `rowCount` to known `rowCount`**: When the row count is set to a valid value from an unknown value, the Data Grid switches to the viewport loading mode. It checks the number of already fetched rows and adds skeleton rows to match the provided row count. + +- **Known `rowCount` to unknown `rowCount`**: If the row count is updated and set to `-1`, the Data Grid resets, fetches the first page, and sets itself in the infinite loading mode. + +- **Known `rowCount` greater than the actual row count**: This can happen either by reducing the value of the row count after more rows were already fetched or if the row count was unknown and the grid in the inifite loading mode already fetched more rows. In this case, the grid resets, fetches the first page and continues in one of the modes depending on the new value of the `rowCount`. + +:::warning +`rowCount` is expected to be static. Changing its value can cause the grid to reset and the cache to be cleared which leads to poor performance and user experience. +::: + +The demo below serves more as a showcase of the behavior described above and is not representing something you would implement in a real-world scenario. + +{{"demo": "ServerSideLazyLoadingModeUpdate.js", "bg": "inline"}} + +## Nested rows 🚧 + :::warning This feature isn't implemented yet. It's coming. -👍 Upvote [issue #10857](https://github.com/mui/mui-x/issues/10857) if you want to see it land faster. +👍 Upvote [issue #14527](https://github.com/mui/mui-x/issues/14527) if you want to see it land faster. -Don't hesitate to leave a comment on the same issue to influence what gets built. Especially if you already have a use case for this component, or if you are facing a pain point with the [current solution](https://mui.com/x/react-data-grid/row-updates/#lazy-loading). +Don't hesitate to leave a comment on the same issue to influence what gets built. Especially if you already have a use case for this feature, or if you are facing a pain point with your current solution. ::: + +When completed, it would be possible to use `lazyLoading` flag in combination with [Tree data](/x/react-data-grid/server-side-data/tree-data/) and [Row grouping](/x/react-data-grid/server-side-data/row-grouping/). + +## Error handling + +To handle errors, use `unstable_onDataSourceError` prop as described in the [Error handling](/x/react-data-grid/server-side-data/#error-handling) section of the data source overview page. + +Second parameter of type `GridGetRowsParams` can be passed to `getRows` method of the [`unstable_dataSource`](/x/api/data-grid/grid-api/#grid-api-prop-unstable_dataSource) to retry the request. If successful, the grid uses `rows` and `rowCount` data to determine if the rows should be appended at the end of the grid or if the skeleton rows should be replaced. + +The following demo gives an example how to use `GridGetRowsParams` to retry a failed request. + +{{"demo": "ServerSideLazyLoadingErrorHandling.js", "bg": "inline"}} + +## API + +- [DataGrid](/x/api/data-grid/data-grid/) +- [DataGridPro](/x/api/data-grid/data-grid-pro/) +- [DataGridPremium](/x/api/data-grid/data-grid-premium/) diff --git a/docs/data/pages.ts b/docs/data/pages.ts index 62ebde3d4dee..5913f28be6cd 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -140,23 +140,17 @@ const pages: MuiPage[] = [ pathname: '/x/react-data-grid/server-side-data-group', title: 'Server-side data', plan: 'pro', + newFeature: true, children: [ { pathname: '/x/react-data-grid/server-side-data', title: 'Overview' }, { pathname: '/x/react-data-grid/server-side-data/tree-data', plan: 'pro' }, { pathname: '/x/react-data-grid/server-side-data/lazy-loading', plan: 'pro', - planned: true, - }, - { - pathname: '/x/react-data-grid/server-side-data/infinite-loading', - plan: 'pro', - planned: true, }, { pathname: '/x/react-data-grid/server-side-data/row-grouping', plan: 'premium', - planned: true, }, { pathname: '/x/react-data-grid/server-side-data/aggregation', diff --git a/docs/pages/x/api/data-grid/data-grid-premium.json b/docs/pages/x/api/data-grid/data-grid-premium.json index ded5c1bd3c90..f85ba5298511 100644 --- a/docs/pages/x/api/data-grid/data-grid-premium.json +++ b/docs/pages/x/api/data-grid/data-grid-premium.json @@ -218,6 +218,8 @@ }, "keepColumnPositionIfDraggedOutside": { "type": { "name": "bool" }, "default": "false" }, "keepNonExistentRowsSelected": { "type": { "name": "bool" }, "default": "false" }, + "lazyLoading": { "type": { "name": "bool" }, "default": "false" }, + "lazyLoadingRequestThrottleMs": { "type": { "name": "number" }, "default": "500" }, "loading": { "type": { "name": "bool" }, "default": "false" }, "localeText": { "type": { "name": "object" } }, "logger": { diff --git a/docs/pages/x/api/data-grid/data-grid-pro.json b/docs/pages/x/api/data-grid/data-grid-pro.json index 42df3c139311..4516b0d2ad89 100644 --- a/docs/pages/x/api/data-grid/data-grid-pro.json +++ b/docs/pages/x/api/data-grid/data-grid-pro.json @@ -192,6 +192,8 @@ }, "keepColumnPositionIfDraggedOutside": { "type": { "name": "bool" }, "default": "false" }, "keepNonExistentRowsSelected": { "type": { "name": "bool" }, "default": "false" }, + "lazyLoading": { "type": { "name": "bool" }, "default": "false" }, + "lazyLoadingRequestThrottleMs": { "type": { "name": "number" }, "default": "500" }, "loading": { "type": { "name": "bool" }, "default": "false" }, "localeText": { "type": { "name": "object" } }, "logger": { diff --git a/docs/pages/x/react-data-grid/server-side-data/infinite-loading.js b/docs/pages/x/react-data-grid/server-side-data/infinite-loading.js deleted file mode 100644 index 17c80ea529aa..000000000000 --- a/docs/pages/x/react-data-grid/server-side-data/infinite-loading.js +++ /dev/null @@ -1,7 +0,0 @@ -import * as React from 'react'; -import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; -import * as pageProps from 'docsx/data/data-grid/server-side-data/infinite-loading.md?muiMarkdown'; - -export default function Page() { - return ; -} diff --git a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json index 3b9442aa58e3..bc609f615939 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json @@ -238,6 +238,12 @@ "keepNonExistentRowsSelected": { "description": "If true, the selection model will retain selected rows that do not exist. Useful when using server side pagination and row selections need to be retained when changing pages." }, + "lazyLoading": { + "description": "Used together with unstable_dataSource to enable lazy loading. If enabled, the grid stops adding paginationModel to the data requests (getRows) and starts sending start and end values depending on the loading mode and the scroll position." + }, + "lazyLoadingRequestThrottleMs": { + "description": "If positive, the Data Grid will throttle data source requests on rendered rows interval change." + }, "loading": { "description": "If true, a loading overlay is displayed." }, "localeText": { "description": "Set the locale text of the Data Grid. You can find all the translation keys supported in the source in the GitHub repository." @@ -631,7 +637,7 @@ "description": "Override the height/width of the Data Grid inner scrollbar." }, "scrollEndThreshold": { - "description": "Set the area in px at the bottom of the grid viewport where onRowsScrollEnd is called." + "description": "Set the area in px at the bottom of the grid viewport where onRowsScrollEnd is called. If combined with lazyLoading, it defines the area where the next data request is triggered." }, "showCellVerticalBorder": { "description": "If true, vertical borders will be displayed between cells." diff --git a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json index c1d26982ac46..eb9e1b98673c 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json @@ -219,6 +219,12 @@ "keepNonExistentRowsSelected": { "description": "If true, the selection model will retain selected rows that do not exist. Useful when using server side pagination and row selections need to be retained when changing pages." }, + "lazyLoading": { + "description": "Used together with unstable_dataSource to enable lazy loading. If enabled, the grid stops adding paginationModel to the data requests (getRows) and starts sending start and end values depending on the loading mode and the scroll position." + }, + "lazyLoadingRequestThrottleMs": { + "description": "If positive, the Data Grid will throttle data source requests on rendered rows interval change." + }, "loading": { "description": "If true, a loading overlay is displayed." }, "localeText": { "description": "Set the locale text of the Data Grid. You can find all the translation keys supported in the source in the GitHub repository." @@ -573,7 +579,7 @@ "description": "Override the height/width of the Data Grid inner scrollbar." }, "scrollEndThreshold": { - "description": "Set the area in px at the bottom of the grid viewport where onRowsScrollEnd is called." + "description": "Set the area in px at the bottom of the grid viewport where onRowsScrollEnd is called. If combined with lazyLoading, it defines the area where the next data request is triggered." }, "showCellVerticalBorder": { "description": "If true, vertical borders will be displayed between cells." diff --git a/packages/x-data-grid-generator/src/hooks/serverUtils.ts b/packages/x-data-grid-generator/src/hooks/serverUtils.ts index 80651b133af5..c0643d608e92 100644 --- a/packages/x-data-grid-generator/src/hooks/serverUtils.ts +++ b/packages/x-data-grid-generator/src/hooks/serverUtils.ts @@ -40,8 +40,8 @@ export interface QueryOptions { pageSize?: number; filterModel?: GridFilterModel; sortModel?: GridSortModel; - firstRowToRender?: number; - lastRowToRender?: number; + start?: number; + end?: number; } export interface ServerSideQueryOptions { @@ -50,8 +50,8 @@ export interface ServerSideQueryOptions { groupKeys?: string[]; filterModel?: GridFilterModel; sortModel?: GridSortModel; - firstRowToRender?: number; - lastRowToRender?: number; + start?: number; + end?: number; groupFields?: string[]; } @@ -277,7 +277,7 @@ export const loadServerRows = ( } const delay = randomInt(minDelay, maxDelay); - const { cursor, page = 0, pageSize } = queryOptions; + const { cursor, page = 0, pageSize, start, end } = queryOptions; let nextCursor; let firstRowIndex; @@ -289,22 +289,25 @@ export const loadServerRows = ( filteredRows = [...filteredRows].sort(rowComparator); const totalRowCount = filteredRows.length; - if (!pageSize) { + if (start !== undefined && end !== undefined) { + firstRowIndex = start; + lastRowIndex = end; + } else if (!pageSize) { firstRowIndex = 0; - lastRowIndex = filteredRows.length; + lastRowIndex = filteredRows.length - 1; } else if (useCursorPagination) { firstRowIndex = cursor ? filteredRows.findIndex(({ id }) => id === cursor) : 0; firstRowIndex = Math.max(firstRowIndex, 0); // if cursor not found return 0 - lastRowIndex = firstRowIndex + pageSize; + lastRowIndex = firstRowIndex + pageSize - 1; - nextCursor = lastRowIndex >= filteredRows.length ? undefined : filteredRows[lastRowIndex].id; + nextCursor = filteredRows[lastRowIndex + 1]?.id; } else { firstRowIndex = page * pageSize; - lastRowIndex = (page + 1) * pageSize; + lastRowIndex = (page + 1) * pageSize - 1; } const hasNextPage = lastRowIndex < filteredRows.length - 1; const response: FakeServerResponse = { - returnedRows: filteredRows.slice(firstRowIndex, lastRowIndex), + returnedRows: filteredRows.slice(firstRowIndex, lastRowIndex + 1), hasNextPage, nextCursor, totalRowCount, diff --git a/packages/x-data-grid-generator/src/hooks/useMockServer.ts b/packages/x-data-grid-generator/src/hooks/useMockServer.ts index e9c168f0ca08..07cf79dce50e 100644 --- a/packages/x-data-grid-generator/src/hooks/useMockServer.ts +++ b/packages/x-data-grid-generator/src/hooks/useMockServer.ts @@ -104,7 +104,7 @@ const getColumnsFromOptions = (options: ColumnsOptions): GridColDefGenerator[] | return columns; }; -function decodeParams(url: string): GridGetRowsParams { +function decodeParams(url: string) { const params = new URL(url).searchParams; const decodedParams = {} as any; const array = Array.from(params.entries()); @@ -117,7 +117,7 @@ function decodeParams(url: string): GridGetRowsParams { } } - return decodedParams as GridGetRowsParams; + return decodedParams; } const getInitialState = (columns: GridColDefGenerator[], groupingField?: string) => { diff --git a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx index 0f05ca94e442..f74a92f9b230 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx @@ -528,6 +528,18 @@ DataGridPremiumRaw.propTypes = { * @default false */ keepNonExistentRowsSelected: PropTypes.bool, + /** + * Used together with `unstable_dataSource` to enable lazy loading. + * If enabled, the grid stops adding `paginationModel` to the data requests (`getRows`) + * and starts sending `start` and `end` values depending on the loading mode and the scroll position. + * @default false + */ + lazyLoading: PropTypes.bool, + /** + * If positive, the Data Grid will throttle data source requests on rendered rows interval change. + * @default 500 + */ + lazyLoadingRequestThrottleMs: PropTypes.number, /** * If `true`, a loading overlay is displayed. * @default false @@ -1027,6 +1039,7 @@ DataGridPremiumRaw.propTypes = { scrollbarSize: PropTypes.number, /** * Set the area in `px` at the bottom of the grid viewport where onRowsScrollEnd is called. + * If combined with `lazyLoading`, it defines the area where the next data request is triggered. * @default 80 */ scrollEndThreshold: PropTypes.number, diff --git a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx index 46c76914dda7..5e3f20c6ea49 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx @@ -61,6 +61,7 @@ import { columnGroupsStateInitializer, useGridLazyLoader, useGridLazyLoaderPreProcessors, + useGridDataSourceLazyLoader, headerFilteringStateInitializer, useGridHeaderFiltering, virtualizationStateInitializer, @@ -180,6 +181,7 @@ export const useDataGridPremiumComponent = ( useGridScroll(apiRef, props); useGridInfiniteLoader(apiRef, props); useGridLazyLoader(apiRef, props); + useGridDataSourceLazyLoader(apiRef, props); useGridColumnMenu(apiRef); useGridCsvExport(apiRef, props); useGridPrintExport(apiRef, props); diff --git a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx index cf10a788ce68..faebe1534b6d 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx +++ b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx @@ -483,6 +483,18 @@ DataGridProRaw.propTypes = { * @default false */ keepNonExistentRowsSelected: PropTypes.bool, + /** + * Used together with `unstable_dataSource` to enable lazy loading. + * If enabled, the grid stops adding `paginationModel` to the data requests (`getRows`) + * and starts sending `start` and `end` values depending on the loading mode and the scroll position. + * @default false + */ + lazyLoading: PropTypes.bool, + /** + * If positive, the Data Grid will throttle data source requests on rendered rows interval change. + * @default 500 + */ + lazyLoadingRequestThrottleMs: PropTypes.number, /** * If `true`, a loading overlay is displayed. * @default false @@ -933,6 +945,7 @@ DataGridProRaw.propTypes = { scrollbarSize: PropTypes.number, /** * Set the area in `px` at the bottom of the grid viewport where onRowsScrollEnd is called. + * If combined with `lazyLoading`, it defines the area where the next data request is triggered. * @default 80 */ scrollEndThreshold: PropTypes.number, diff --git a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx index 0240d83802fc..f997b0c8d7f3 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx +++ b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx @@ -86,6 +86,7 @@ import { useGridDataSource, dataSourceStateInitializer, } from '../hooks/features/dataSource/useGridDataSource'; +import { useGridDataSourceLazyLoader } from '../hooks/features/serverSideLazyLoader/useGridDataSourceLazyLoader'; export const useDataGridProComponent = ( inputApiRef: React.MutableRefObject | undefined, @@ -163,6 +164,7 @@ export const useDataGridProComponent = ( useGridScroll(apiRef, props); useGridInfiniteLoader(apiRef, props); useGridLazyLoader(apiRef, props); + useGridDataSourceLazyLoader(apiRef, props); useGridColumnMenu(apiRef); useGridCsvExport(apiRef, props); useGridPrintExport(apiRef, props); diff --git a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts index 295702230804..0d100ebdcec7 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts +++ b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts @@ -56,6 +56,8 @@ export const DATA_GRID_PRO_PROPS_DEFAULT_VALUES: DataGridProPropsWithDefaultValu scrollEndThreshold: 80, treeData: false, unstable_listView: false, + lazyLoading: false, + lazyLoadingRequestThrottleMs: 500, }; const defaultSlots = DATA_GRID_PRO_DEFAULT_SLOTS_COMPONENTS; 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 5645235abf01..ada0a066a10d 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,51 +1,133 @@ 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, params.groupFields, + params.start, + params.end, ]); } 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: { startIndex: number; endIndex: number }[] = []; + 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: (GridGetRowsResponse | null)[] = []; + + 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/interfaces.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/interfaces.ts index 90bfc4ed39de..08eaf7c33f42 100644 --- a/packages/x-data-grid-pro/src/hooks/features/dataSource/interfaces.ts +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/interfaces.ts @@ -1,5 +1,5 @@ import { GridRowId } from '@mui/x-data-grid'; -import { GridDataSourceCache } from '../../../models'; +import { GridDataSourceCache, GridGetRowsParams } from '../../../models'; export interface GridDataSourceState { loading: Record; @@ -23,11 +23,13 @@ export interface GridDataSourceApiBase { */ setChildrenFetchError: (parentId: GridRowId, error: Error | null) => void; /** - * Fetches the rows from the server for a given `parentId`. - * If no `parentId` is provided, it fetches the root rows. - * @param {string} parentId The id of the group to be fetched. + * Fetches the rows from the server. + * If no `parentId` option is provided, it fetches the root rows. + * Any missing parameter from `params` will be filled from the state (sorting, filtering, etc.). + * @param {GridRowId} parentId The id of the parent node. + * @param {Partial} params Request parameters override. */ - fetchRows: (parentId?: GridRowId) => void; + fetchRows: (parentId?: GridRowId, params?: Partial) => void; /** * The data source cache object. */ 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 31f3d209a695..dd9ce54b2acd 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 @@ -2,20 +2,28 @@ import * as React from 'react'; import useLazyRef from '@mui/utils/useLazyRef'; import { useGridApiEventHandler, - gridRowsLoadingSelector, useGridApiMethod, GridDataSourceGroupNode, useGridSelector, - GridRowId, + gridPaginationModelSelector, + GRID_ROOT_GROUP_ID, + useFirstRender, + GridEventListener, } from '@mui/x-data-grid'; -import { gridRowGroupsToFetchSelector, GridStateInitializer } from '@mui/x-data-grid/internals'; +import { + GridGetRowsParams, + gridRowGroupsToFetchSelector, + GridStateInitializer, + GridStrategyProcessor, + useGridRegisterStrategyProcessor, +} from '@mui/x-data-grid/internals'; import { GridPrivateApiPro } from '../../../models/gridApiPro'; import { DataGridProProcessedProps } from '../../../models/dataGridProProps'; import { gridGetRowsParamsSelector, gridDataSourceErrorsSelector } from './gridDataSourceSelector'; import { GridDataSourceApi, GridDataSourceApiBase, GridDataSourcePrivateApi } from './interfaces'; -import { runIfServerMode, NestedDataManager, RequestStatus } from './utils'; +import { DataSourceRowsUpdateStrategy, NestedDataManager, RequestStatus, runIf } from './utils'; import { GridDataSourceCache } from '../../../models'; -import { GridDataSourceCacheDefault } from './cache'; +import { GridDataSourceCacheDefault, GridDataSourceCacheDefaultConfig } from './cache'; const INITIAL_STATE = { loading: {}, @@ -28,11 +36,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) => { @@ -52,28 +63,52 @@ export const useGridDataSource = ( | 'sortingMode' | 'filterMode' | 'paginationMode' + | 'pageSizeOptions' | 'treeData' + | 'lazyLoading' >, ) => { + const setStrategyAvailability = React.useCallback(() => { + apiRef.current.setStrategyAvailability( + 'dataSource', + DataSourceRowsUpdateStrategy.Default, + props.unstable_dataSource && !props.lazyLoading ? () => true : () => false, + ); + }, [apiRef, props.lazyLoading, props.unstable_dataSource]); + + const [defaultRowsUpdateStrategyActive, setDefaultRowsUpdateStrategyActive] = + React.useState(false); const nestedDataManager = useLazyRef( () => new NestedDataManager(apiRef), ).current; + const paginationModel = useGridSelector(apiRef, gridPaginationModelSelector); const groupsToAutoFetch = useGridSelector(apiRef, gridRowGroupsToFetchSelector); const scheduledGroups = React.useRef(0); + 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, + }), ); - const fetchRows = React.useCallback( - async (parentId?: GridRowId) => { + const fetchRows = React.useCallback( + async (parentId, params) => { const getRows = props.unstable_dataSource?.getRows; if (!getRows) { return; } - if (parentId) { + if (parentId && parentId !== GRID_ROOT_GROUP_ID) { nestedDataManager.queue([parentId]); return; } @@ -88,39 +123,50 @@ export const useGridDataSource = ( const fetchParams = { ...gridGetRowsParamsSelector(apiRef), ...apiRef.current.unstable_applyPipeProcessors('getRowsParams', {}), + ...params, }; const cachedData = apiRef.current.unstable_dataSource.cache.get(fetchParams); if (cachedData !== undefined) { - const rows = cachedData.rows; - apiRef.current.setRows(rows); - if (cachedData.rowCount) { - apiRef.current.setRowCount(cachedData.rowCount); - } + apiRef.current.applyStrategyProcessor('dataSourceRowsUpdate', { + response: cachedData, + fetchParams, + }); return; } - const isLoading = gridRowsLoadingSelector(apiRef); - if (!isLoading) { + // Manage loading state only for the default strategy + if (defaultRowsUpdateStrategyActive || apiRef.current.getRowsCount() === 0) { apiRef.current.setLoading(true); } try { const getRowsResponse = await getRows(fetchParams); apiRef.current.unstable_dataSource.cache.set(fetchParams, getRowsResponse); - if (getRowsResponse.rowCount) { - apiRef.current.setRowCount(getRowsResponse.rowCount); - } - apiRef.current.setRows(getRowsResponse.rows); - apiRef.current.setLoading(false); + apiRef.current.applyStrategyProcessor('dataSourceRowsUpdate', { + response: getRowsResponse, + fetchParams, + }); } catch (error) { - apiRef.current.setRows([]); - apiRef.current.setLoading(false); + apiRef.current.applyStrategyProcessor('dataSourceRowsUpdate', { + error: error as Error, + fetchParams, + }); onError?.(error as Error, fetchParams); + } finally { + if (defaultRowsUpdateStrategyActive) { + apiRef.current.setLoading(false); + } } }, - [nestedDataManager, apiRef, props.unstable_dataSource?.getRows, onError], + [ + nestedDataManager, + apiRef, + defaultRowsUpdateStrategyActive, + props.unstable_dataSource?.getRows, + onError, + ], ); const fetchRowChildren = React.useCallback( @@ -154,9 +200,7 @@ export const useGridDataSource = ( const rows = cachedData.rows; nestedDataManager.setRequestSettled(id); apiRef.current.updateServerRows(rows, rowNode.path); - if (cachedData.rowCount) { - apiRef.current.setRowCount(cachedData.rowCount); - } + apiRef.current.setRowCount(cachedData.rowCount === undefined ? -1 : cachedData.rowCount); apiRef.current.setRowChildrenExpansion(id, true); apiRef.current.unstable_dataSource.setChildrenLoading(id, false); return; @@ -180,9 +224,9 @@ export const useGridDataSource = ( } nestedDataManager.setRequestSettled(id); apiRef.current.unstable_dataSource.cache.set(fetchParams, getRowsResponse); - if (getRowsResponse.rowCount) { - apiRef.current.setRowCount(getRowsResponse.rowCount); - } + apiRef.current.setRowCount( + getRowsResponse.rowCount === undefined ? -1 : getRowsResponse.rowCount, + ); apiRef.current.updateServerRows(getRowsResponse.rows, rowNode.path); apiRef.current.setRowChildrenExpansion(id, true); } catch (error) { @@ -242,6 +286,31 @@ export const useGridDataSource = ( [apiRef], ); + const handleStrategyActivityChange = React.useCallback< + GridEventListener<'strategyAvailabilityChange'> + >(() => { + setDefaultRowsUpdateStrategyActive( + apiRef.current.getActiveStrategy('dataSource') === DataSourceRowsUpdateStrategy.Default, + ); + }, [apiRef]); + + const handleDataUpdate = React.useCallback>( + (params) => { + if ('error' in params) { + apiRef.current.setRows([]); + return; + } + + const { response } = params; + if (response.rowCount !== undefined) { + apiRef.current.setRowCount(response.rowCount); + } + apiRef.current.setRows(response.rows); + apiRef.current.publishEvent('rowsFetched'); + }, + [apiRef], + ); + const resetDataSourceState = React.useCallback(() => { apiRef.current.setState((state) => { return { @@ -268,12 +337,31 @@ export const useGridDataSource = ( useGridApiMethod(apiRef, dataSourceApi, 'public'); useGridApiMethod(apiRef, dataSourcePrivateApi, 'private'); - useGridApiEventHandler(apiRef, 'sortModelChange', runIfServerMode(props.sortingMode, fetchRows)); - useGridApiEventHandler(apiRef, 'filterModelChange', runIfServerMode(props.filterMode, fetchRows)); + useGridRegisterStrategyProcessor( + apiRef, + DataSourceRowsUpdateStrategy.Default, + 'dataSourceRowsUpdate', + handleDataUpdate, + ); + + useGridApiEventHandler(apiRef, 'strategyAvailabilityChange', handleStrategyActivityChange); + useGridApiEventHandler( + apiRef, + 'sortModelChange', + runIf(defaultRowsUpdateStrategyActive, () => fetchRows()), + ); + useGridApiEventHandler( + apiRef, + 'filterModelChange', + runIf(defaultRowsUpdateStrategyActive, () => fetchRows()), + ); useGridApiEventHandler( apiRef, 'paginationModelChange', - runIfServerMode(props.paginationMode, fetchRows), + runIf(defaultRowsUpdateStrategyActive, () => fetchRows()), + ); + useGridApiEventHandler(apiRef, 'getRows', (params: GridGetRowsParams) => + fetchRows(GRID_ROOT_GROUP_ID, params), ); const isFirstRender = React.useRef(true); @@ -282,9 +370,19 @@ 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 (!isFirstRender.current) { + setStrategyAvailability(); + } else { + isFirstRender.current = false; + } + }, [setStrategyAvailability]); React.useEffect(() => { if (props.unstable_dataSource) { @@ -304,4 +402,8 @@ export const useGridDataSource = ( scheduledGroups.current = groupsToAutoFetch.length; } }, [apiRef, nestedDataManager, groupsToAutoFetch]); + + useFirstRender(() => { + setStrategyAvailability(); + }); }; diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/utils.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/utils.ts index dafc6d9783f2..7d48411bb5ac 100644 --- a/packages/x-data-grid-pro/src/hooks/features/dataSource/utils.ts +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/utils.ts @@ -3,9 +3,9 @@ import { GridPrivateApiPro } from '../../../models/gridApiPro'; const MAX_CONCURRENT_REQUESTS = Infinity; -export const runIfServerMode = (modeProp: 'server' | 'client', fn: Function) => () => { - if (modeProp === 'server') { - fn(); +export const runIf = (condition: boolean, fn: Function) => (params: unknown) => { + if (condition) { + fn(params); } }; @@ -16,6 +16,11 @@ export enum RequestStatus { UNKNOWN, } +export enum DataSourceRowsUpdateStrategy { + Default = 'set-new-rows', + LazyLoading = 'replace-row-range', +} + /** * Fetches row children from the server with option to limit the number of concurrent requests * Determines the status of a request based on the enum `RequestStatus` diff --git a/packages/x-data-grid-pro/src/hooks/features/index.ts b/packages/x-data-grid-pro/src/hooks/features/index.ts index dd9209be6a53..456da75b157c 100644 --- a/packages/x-data-grid-pro/src/hooks/features/index.ts +++ b/packages/x-data-grid-pro/src/hooks/features/index.ts @@ -6,4 +6,4 @@ export * from './treeData'; export * from './detailPanel'; export * from './rowPinning'; export * from './dataSource/interfaces'; -export * from './dataSource/cache'; +export { GridDataSourceCacheDefault } from './dataSource/cache'; diff --git a/packages/x-data-grid-pro/src/hooks/features/lazyLoader/useGridLazyLoader.ts b/packages/x-data-grid-pro/src/hooks/features/lazyLoader/useGridLazyLoader.ts index 0ced8d346ae7..2041a6f6b485 100644 --- a/packages/x-data-grid-pro/src/hooks/features/lazyLoader/useGridLazyLoader.ts +++ b/packages/x-data-grid-pro/src/hooks/features/lazyLoader/useGridLazyLoader.ts @@ -7,56 +7,12 @@ import { gridRenderContextSelector, useGridApiOptionHandler, GridEventListener, - GridRowEntry, } from '@mui/x-data-grid'; import { getVisibleRows } from '@mui/x-data-grid/internals'; import { GridPrivateApiPro } from '../../../models/gridApiPro'; import { DataGridProProcessedProps } from '../../../models/dataGridProProps'; import { GridFetchRowsParams } from '../../../models/gridFetchRowsParams'; - -function findSkeletonRowsSection({ - apiRef, - visibleRows, - range, -}: { - apiRef: React.MutableRefObject; - visibleRows: GridRowEntry[]; - range: { firstRowIndex: number; lastRowIndex: number }; -}) { - let { firstRowIndex, lastRowIndex } = range; - const visibleRowsSection = visibleRows.slice(range.firstRowIndex, range.lastRowIndex); - let startIndex = 0; - let endIndex = visibleRowsSection.length - 1; - let isSkeletonSectionFound = false; - - while (!isSkeletonSectionFound && firstRowIndex < lastRowIndex) { - const isStartingWithASkeletonRow = - apiRef.current.getRowNode(visibleRowsSection[startIndex].id)?.type === 'skeletonRow'; - const isEndingWithASkeletonRow = - apiRef.current.getRowNode(visibleRowsSection[endIndex].id)?.type === 'skeletonRow'; - - if (isStartingWithASkeletonRow && isEndingWithASkeletonRow) { - isSkeletonSectionFound = true; - } - - if (!isStartingWithASkeletonRow) { - startIndex += 1; - firstRowIndex += 1; - } - - if (!isEndingWithASkeletonRow) { - endIndex -= 1; - lastRowIndex -= 1; - } - } - - return isSkeletonSectionFound - ? { - firstRowIndex, - lastRowIndex, - } - : undefined; -} +import { findSkeletonRowsSection } from './utils'; /** * @requires useGridRows (state) diff --git a/packages/x-data-grid-pro/src/hooks/features/lazyLoader/utils.ts b/packages/x-data-grid-pro/src/hooks/features/lazyLoader/utils.ts new file mode 100644 index 000000000000..584d3696d17a --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/features/lazyLoader/utils.ts @@ -0,0 +1,46 @@ +import { GridRowEntry } from '@mui/x-data-grid'; +import { GridPrivateApiPro } from '../../../models/gridApiPro'; + +export const findSkeletonRowsSection = ({ + apiRef, + visibleRows, + range, +}: { + apiRef: React.MutableRefObject; + visibleRows: GridRowEntry[]; + range: { firstRowIndex: number; lastRowIndex: number }; +}) => { + let { firstRowIndex, lastRowIndex } = range; + const visibleRowsSection = visibleRows.slice(range.firstRowIndex, range.lastRowIndex); + let startIndex = 0; + let endIndex = visibleRowsSection.length - 1; + let isSkeletonSectionFound = false; + + while (!isSkeletonSectionFound && firstRowIndex < lastRowIndex) { + const isStartingWithASkeletonRow = + apiRef.current.getRowNode(visibleRowsSection[startIndex].id)?.type === 'skeletonRow'; + const isEndingWithASkeletonRow = + apiRef.current.getRowNode(visibleRowsSection[endIndex].id)?.type === 'skeletonRow'; + + if (isStartingWithASkeletonRow && isEndingWithASkeletonRow) { + isSkeletonSectionFound = true; + } + + if (!isStartingWithASkeletonRow) { + startIndex += 1; + firstRowIndex += 1; + } + + if (!isEndingWithASkeletonRow) { + endIndex -= 1; + lastRowIndex -= 1; + } + } + + return isSkeletonSectionFound + ? { + firstRowIndex, + lastRowIndex, + } + : undefined; +}; diff --git a/packages/x-data-grid-pro/src/hooks/features/serverSideLazyLoader/useGridDataSourceLazyLoader.ts b/packages/x-data-grid-pro/src/hooks/features/serverSideLazyLoader/useGridDataSourceLazyLoader.ts new file mode 100644 index 000000000000..2fc27b4a0cb5 --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/features/serverSideLazyLoader/useGridDataSourceLazyLoader.ts @@ -0,0 +1,475 @@ +import * as React from 'react'; +import { throttle } from '@mui/x-internals/throttle'; +import { + useGridApiEventHandler, + useGridSelector, + gridSortModelSelector, + gridFilterModelSelector, + GridEventListener, + GRID_ROOT_GROUP_ID, + GridGroupNode, + GridSkeletonRowNode, + gridPaginationModelSelector, + gridDimensionsSelector, + gridFilteredSortedRowIdsSelector, + useFirstRender, +} from '@mui/x-data-grid'; +import { + getVisibleRows, + GridGetRowsParams, + gridRenderContextSelector, + GridStrategyProcessor, + useGridRegisterStrategyProcessor, +} from '@mui/x-data-grid/internals'; +import { GridPrivateApiPro } from '../../../models/gridApiPro'; +import { DataGridProProcessedProps } from '../../../models/dataGridProProps'; +import { findSkeletonRowsSection } from '../lazyLoader/utils'; +import { GRID_SKELETON_ROW_ROOT_ID } from '../lazyLoader/useGridLazyLoaderPreProcessors'; +import { DataSourceRowsUpdateStrategy, runIf } from '../dataSource/utils'; + +enum LoadingTrigger { + VIEWPORT, + SCROLL_END, +} + +const INTERVAL_CACHE_INITIAL_STATE = { + firstRowToRender: 0, + lastRowToRender: 0, +}; + +const getSkeletonRowId = (index: number) => `${GRID_SKELETON_ROW_ROOT_ID}-${index}`; + +/** + * @requires useGridRows (state) + * @requires useGridPagination (state) + * @requires useGridDimensions (method) - can be after + * @requires useGridScroll (method + */ +export const useGridDataSourceLazyLoader = ( + privateApiRef: React.MutableRefObject, + props: Pick< + DataGridProProcessedProps, + | 'pagination' + | 'paginationMode' + | 'unstable_dataSource' + | 'lazyLoading' + | 'lazyLoadingRequestThrottleMs' + | 'scrollEndThreshold' + >, +): void => { + const setStrategyAvailability = React.useCallback(() => { + privateApiRef.current.setStrategyAvailability( + 'dataSource', + DataSourceRowsUpdateStrategy.LazyLoading, + props.unstable_dataSource && props.lazyLoading ? () => true : () => false, + ); + }, [privateApiRef, props.lazyLoading, props.unstable_dataSource]); + + const [lazyLoadingRowsUpdateStrategyActive, setLazyLoadingRowsUpdateStrategyActive] = + React.useState(false); + const sortModel = useGridSelector(privateApiRef, gridSortModelSelector); + const filterModel = useGridSelector(privateApiRef, gridFilterModelSelector); + const paginationModel = useGridSelector(privateApiRef, gridPaginationModelSelector); + const filteredSortedRowIds = useGridSelector(privateApiRef, gridFilteredSortedRowIdsSelector); + const dimensions = useGridSelector(privateApiRef, gridDimensionsSelector); + const renderContext = useGridSelector(privateApiRef, gridRenderContextSelector); + const renderedRowsIntervalCache = React.useRef(INTERVAL_CACHE_INITIAL_STATE); + const previousLastRowIndex = React.useRef(0); + const loadingTrigger = React.useRef(null); + const rowsStale = React.useRef(false); + + // 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 + const adjustRowParams = React.useCallback( + (params: GridGetRowsParams) => { + if (typeof params.start !== 'number') { + return params; + } + + return { + ...params, + start: params.start - (params.start % paginationModel.pageSize), + end: params.end + paginationModel.pageSize - (params.end % paginationModel.pageSize) - 1, + }; + }, + [paginationModel], + ); + + const resetGrid = React.useCallback(() => { + privateApiRef.current.setLoading(true); + privateApiRef.current.unstable_dataSource.cache.clear(); + rowsStale.current = true; + previousLastRowIndex.current = 0; + const getRowsParams: GridGetRowsParams = { + start: 0, + end: paginationModel.pageSize - 1, + sortModel, + filterModel, + }; + + privateApiRef.current.publishEvent('getRows', getRowsParams); + }, [privateApiRef, sortModel, filterModel, paginationModel.pageSize]); + + const ensureValidRowCount = React.useCallback( + (previousLoadingTrigger: LoadingTrigger, newLoadingTrigger: LoadingTrigger) => { + // switching from lazy loading to infinite loading should always reset the grid + // since there is no guarantee that the new data will be placed correctly + // there might be some skeleton rows in between the data or the data has changed (row count became unknown) + if ( + previousLoadingTrigger === LoadingTrigger.VIEWPORT && + newLoadingTrigger === LoadingTrigger.SCROLL_END + ) { + resetGrid(); + return; + } + + // switching from infinite loading to lazy loading should reset the grid only if the known row count + // is smaller than the amount of rows rendered + const tree = privateApiRef.current.state.rows.tree; + const rootGroup = tree[GRID_ROOT_GROUP_ID] as GridGroupNode; + const rootGroupChildren = [...rootGroup.children]; + + const pageRowCount = privateApiRef.current.state.pagination.rowCount; + const rootChildrenCount = rootGroupChildren.length; + + if (rootChildrenCount > pageRowCount) { + resetGrid(); + } + }, + [privateApiRef, resetGrid], + ); + + const addSkeletonRows = React.useCallback( + (fillEmptyGrid = false) => { + const tree = privateApiRef.current.state.rows.tree; + const rootGroup = tree[GRID_ROOT_GROUP_ID] as GridGroupNode; + const rootGroupChildren = [...rootGroup.children]; + + const pageRowCount = privateApiRef.current.state.pagination.rowCount; + const rootChildrenCount = rootGroupChildren.length; + + /** + * Do nothing if + * - rowCount is unknown + * - children count is 0 and empty grid should not be filled + * - children count is equal to rowCount + */ + if ( + pageRowCount === -1 || + pageRowCount === undefined || + (!fillEmptyGrid && rootChildrenCount === 0) || + rootChildrenCount === pageRowCount + ) { + return; + } + + // fill the grid with skeleton rows + for (let i = 0; i < pageRowCount - rootChildrenCount; i += 1) { + const skeletonId = getSkeletonRowId(i); + + rootGroupChildren.push(skeletonId); + + const skeletonRowNode: GridSkeletonRowNode = { + type: 'skeletonRow', + id: skeletonId, + parent: GRID_ROOT_GROUP_ID, + depth: 0, + }; + + tree[skeletonId] = skeletonRowNode; + } + + tree[GRID_ROOT_GROUP_ID] = { ...rootGroup, children: rootGroupChildren }; + + privateApiRef.current.setState( + (state) => ({ + ...state, + rows: { + ...state.rows, + tree, + }, + }), + 'addSkeletonRows', + ); + }, + [privateApiRef], + ); + + const updateLoadingTrigger = React.useCallback( + (rowCount: number) => { + const newLoadingTrigger = + rowCount === -1 ? LoadingTrigger.SCROLL_END : LoadingTrigger.VIEWPORT; + + if (loadingTrigger.current !== newLoadingTrigger) { + loadingTrigger.current = newLoadingTrigger; + } + + if (loadingTrigger.current !== null) { + ensureValidRowCount(loadingTrigger.current, newLoadingTrigger); + } + }, + [ensureValidRowCount], + ); + + const handleDataUpdate = React.useCallback>( + (params) => { + if ('error' in params) { + return; + } + + const { response, fetchParams } = params; + privateApiRef.current.setRowCount(response.rowCount === undefined ? -1 : response.rowCount); + + if (rowsStale.current) { + rowsStale.current = false; + privateApiRef.current.scroll({ top: 0 }); + privateApiRef.current.setRows(response.rows); + } else { + const startingIndex = + typeof fetchParams.start === 'string' + ? Math.max(filteredSortedRowIds.indexOf(fetchParams.start), 0) + : fetchParams.start; + + privateApiRef.current.unstable_replaceRows(startingIndex, response.rows); + } + + if (loadingTrigger.current === null) { + updateLoadingTrigger(privateApiRef.current.state.pagination.rowCount); + } + + addSkeletonRows(); + privateApiRef.current.setLoading(false); + privateApiRef.current.requestPipeProcessorsApplication('hydrateRows'); + privateApiRef.current.publishEvent('rowsFetched'); + }, + [privateApiRef, filteredSortedRowIds, updateLoadingTrigger, addSkeletonRows], + ); + + const handleRowCountChange = React.useCallback(() => { + if (loadingTrigger.current === null) { + return; + } + + updateLoadingTrigger(privateApiRef.current.state.pagination.rowCount); + addSkeletonRows(); + privateApiRef.current.requestPipeProcessorsApplication('hydrateRows'); + }, [privateApiRef, updateLoadingTrigger, addSkeletonRows]); + + const handleScrolling: GridEventListener<'scrollPositionChange'> = React.useCallback( + (newScrollPosition) => { + if ( + loadingTrigger.current !== LoadingTrigger.SCROLL_END || + previousLastRowIndex.current >= renderContext.lastRowIndex + ) { + return; + } + + const position = newScrollPosition.top + dimensions.viewportInnerSize.height; + const target = dimensions.contentSize.height - props.scrollEndThreshold; + + if (position >= target) { + previousLastRowIndex.current = renderContext.lastRowIndex; + + const getRowsParams: GridGetRowsParams = { + start: renderContext.lastRowIndex, + end: renderContext.lastRowIndex + paginationModel.pageSize - 1, + sortModel, + filterModel, + }; + + privateApiRef.current.setLoading(true); + privateApiRef.current.publishEvent('getRows', adjustRowParams(getRowsParams)); + } + }, + [ + privateApiRef, + props.scrollEndThreshold, + sortModel, + filterModel, + dimensions, + paginationModel.pageSize, + renderContext.lastRowIndex, + adjustRowParams, + ], + ); + + const handleRenderedRowsIntervalChange = React.useCallback< + GridEventListener<'renderedRowsIntervalChange'> + >( + (params) => { + if (loadingTrigger.current !== LoadingTrigger.VIEWPORT) { + return; + } + + const getRowsParams: GridGetRowsParams = { + start: params.firstRowIndex, + end: params.lastRowIndex, + sortModel, + filterModel, + }; + + if ( + renderedRowsIntervalCache.current.firstRowToRender === params.firstRowIndex && + renderedRowsIntervalCache.current.lastRowToRender === params.lastRowIndex + ) { + return; + } + + renderedRowsIntervalCache.current = { + firstRowToRender: params.firstRowIndex, + lastRowToRender: params.lastRowIndex, + }; + + const currentVisibleRows = getVisibleRows(privateApiRef, { + pagination: props.pagination, + paginationMode: props.paginationMode, + }); + + const skeletonRowsSection = findSkeletonRowsSection({ + apiRef: privateApiRef, + visibleRows: currentVisibleRows.rows, + range: { + firstRowIndex: params.firstRowIndex, + lastRowIndex: params.lastRowIndex, + }, + }); + + if (!skeletonRowsSection) { + return; + } + + getRowsParams.start = skeletonRowsSection.firstRowIndex; + getRowsParams.end = skeletonRowsSection.lastRowIndex; + + privateApiRef.current.publishEvent('getRows', adjustRowParams(getRowsParams)); + }, + [ + privateApiRef, + props.pagination, + props.paginationMode, + sortModel, + filterModel, + adjustRowParams, + ], + ); + + const throttledHandleRenderedRowsIntervalChange = React.useMemo( + () => throttle(handleRenderedRowsIntervalChange, props.lazyLoadingRequestThrottleMs), + [props.lazyLoadingRequestThrottleMs, handleRenderedRowsIntervalChange], + ); + + const handleGridSortModelChange = React.useCallback>( + (newSortModel) => { + previousLastRowIndex.current = 0; + const rangeParams = + loadingTrigger.current === LoadingTrigger.VIEWPORT + ? { + start: renderContext.firstRowIndex, + end: renderContext.lastRowIndex, + } + : { + start: 0, + end: paginationModel.pageSize - 1, + }; + + const getRowsParams: GridGetRowsParams = { + ...rangeParams, + sortModel: newSortModel, + filterModel, + }; + + privateApiRef.current.setLoading(true); + if (loadingTrigger.current === LoadingTrigger.VIEWPORT) { + // replace all rows with skeletons to maintain the same scroll position + privateApiRef.current.setRows([]); + addSkeletonRows(true); + } else { + rowsStale.current = true; + } + + privateApiRef.current.publishEvent('getRows', adjustRowParams(getRowsParams)); + }, + [ + privateApiRef, + filterModel, + paginationModel.pageSize, + renderContext, + addSkeletonRows, + adjustRowParams, + ], + ); + + const handleGridFilterModelChange = React.useCallback>( + (newFilterModel) => { + rowsStale.current = true; + previousLastRowIndex.current = 0; + const getRowsParams: GridGetRowsParams = { + start: 0, + end: paginationModel.pageSize - 1, + sortModel, + filterModel: newFilterModel, + }; + + privateApiRef.current.setLoading(true); + privateApiRef.current.publishEvent('getRows', getRowsParams); + }, + [privateApiRef, sortModel, paginationModel.pageSize], + ); + + const handleStrategyActivityChange = React.useCallback< + GridEventListener<'strategyAvailabilityChange'> + >(() => { + setLazyLoadingRowsUpdateStrategyActive( + privateApiRef.current.getActiveStrategy('dataSource') === + DataSourceRowsUpdateStrategy.LazyLoading, + ); + }, [privateApiRef]); + + useGridRegisterStrategyProcessor( + privateApiRef, + DataSourceRowsUpdateStrategy.LazyLoading, + 'dataSourceRowsUpdate', + handleDataUpdate, + ); + + useGridApiEventHandler(privateApiRef, 'strategyAvailabilityChange', handleStrategyActivityChange); + + useGridApiEventHandler( + privateApiRef, + 'rowCountChange', + runIf(lazyLoadingRowsUpdateStrategyActive, handleRowCountChange), + ); + useGridApiEventHandler( + privateApiRef, + 'scrollPositionChange', + runIf(lazyLoadingRowsUpdateStrategyActive, handleScrolling), + ); + useGridApiEventHandler( + privateApiRef, + 'renderedRowsIntervalChange', + runIf(lazyLoadingRowsUpdateStrategyActive, throttledHandleRenderedRowsIntervalChange), + ); + useGridApiEventHandler( + privateApiRef, + 'sortModelChange', + runIf(lazyLoadingRowsUpdateStrategyActive, handleGridSortModelChange), + ); + useGridApiEventHandler( + privateApiRef, + 'filterModelChange', + runIf(lazyLoadingRowsUpdateStrategyActive, handleGridFilterModelChange), + ); + + useFirstRender(() => { + setStrategyAvailability(); + }); + + const isFirstRender = React.useRef(true); + React.useEffect(() => { + if (!isFirstRender.current) { + setStrategyAvailability(); + } else { + isFirstRender.current = false; + } + }, [setStrategyAvailability]); +}; diff --git a/packages/x-data-grid-pro/src/internals/index.ts b/packages/x-data-grid-pro/src/internals/index.ts index ed619bb0cc9e..f821f3417011 100644 --- a/packages/x-data-grid-pro/src/internals/index.ts +++ b/packages/x-data-grid-pro/src/internals/index.ts @@ -43,6 +43,7 @@ export { } from '../hooks/features/rowPinning/useGridRowPinningPreProcessors'; export { useGridLazyLoader } from '../hooks/features/lazyLoader/useGridLazyLoader'; export { useGridLazyLoaderPreProcessors } from '../hooks/features/lazyLoader/useGridLazyLoaderPreProcessors'; +export { useGridDataSourceLazyLoader } from '../hooks/features/serverSideLazyLoader/useGridDataSourceLazyLoader'; export { useGridDataSource, dataSourceStateInitializer, diff --git a/packages/x-data-grid-pro/src/internals/propValidation.ts b/packages/x-data-grid-pro/src/internals/propValidation.ts index 13f138529d47..06e607b2b3de 100644 --- a/packages/x-data-grid-pro/src/internals/propValidation.ts +++ b/packages/x-data-grid-pro/src/internals/propValidation.ts @@ -31,4 +31,10 @@ export const propValidatorsDataGridPro: PropValidator isNumber(props.rowCount) && 'MUI X: Usage of the `rowCount` prop with client side pagination (`paginationMode="client"`) has no effect. `rowCount` is only meant to be used with `paginationMode="server"`.') || undefined, + (props) => + (props.signature !== GridSignature.DataGrid && + (props.rowsLoadingMode === 'server' || props.onRowsScrollEnd) && + props.lazyLoading && + 'MUI X: Usage of the client side lazy loading (`rowsLoadingMode="server"` or `onRowsScrollEnd=...`) cannot be used together with server side lazy loading `lazyLoading="true"`.') || + undefined, ]; diff --git a/packages/x-data-grid-pro/src/models/dataGridProProps.ts b/packages/x-data-grid-pro/src/models/dataGridProProps.ts index d2e2c490600d..867670292021 100644 --- a/packages/x-data-grid-pro/src/models/dataGridProProps.ts +++ b/packages/x-data-grid-pro/src/models/dataGridProProps.ts @@ -77,6 +77,7 @@ export interface DataGridProPropsWithDefaultValue - Data source lazy loader', () => { + const { render } = createRenderer(); + const defaultTransformGetRowsResponse = (response: GridGetRowsResponse) => response; + + let transformGetRowsResponse: (response: GridGetRowsResponse) => GridGetRowsResponse; + let apiRef: React.MutableRefObject; + let fetchRowsSpy: SinonSpy; + let mockServer: ReturnType; + + function TestDataSourceLazyLoader(props: Partial) { + apiRef = useGridApiRef(); + mockServer = useMockServer( + { rowLength: 100, maxColumns: 1 }, + { useCursorPagination: false, minDelay: 0, maxDelay: 0, verbose: false }, + ); + fetchRowsSpy = spy(mockServer, 'fetchRows'); + const { fetchRows } = mockServer; + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params: GridGetRowsParams) => { + const urlParams = new URLSearchParams({ + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + start: `${params.start}`, + end: `${params.end}`, + }); + + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + + const response = transformGetRowsResponse(getRowsResponse); + return { + rows: response.rows, + rowCount: response.rowCount, + }; + }, + }), + [fetchRows], + ); + + const baselineProps = { + unstable_dataSource: dataSource, + columns: mockServer.columns, + lazyLoading: true, + paginationModel: { page: 0, pageSize: 10 }, + disableVirtualization: true, + }; + + return ( +
+ +
+ ); + } + + beforeEach(function beforeTest() { + if (isJSDOM) { + this.skip(); // Needs layout + } + + transformGetRowsResponse = defaultTransformGetRowsResponse; + }); + + it('should load the first page initially', async () => { + render(); + await waitFor(() => { + expect(fetchRowsSpy.callCount).to.equal(1); + }); + }); + + describe('Viewport loading', () => { + it('should render skeleton rows if rowCount is bigger than the number of rows', async () => { + render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + // The 11th row should be a skeleton + expect(getRow(10).dataset.id).to.equal('auto-generated-skeleton-row-root-0'); + }); + + it('should make a new data source request once the skeleton rows are in the render context', async () => { + render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + // reset the spy call count + fetchRowsSpy.resetHistory(); + + apiRef.current.scrollToIndexes({ rowIndex: 10 }); + + await waitFor(() => { + expect(fetchRowsSpy.callCount).to.equal(1); + }); + }); + + it('should keep the scroll position when sorting is applied', async () => { + render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + const initialSearchParams = new URL(fetchRowsSpy.lastCall.args[0]).searchParams; + expect(initialSearchParams.get('end')).to.equal('9'); + + apiRef.current.scrollToIndexes({ rowIndex: 10 }); + + await waitFor(() => { + expect(fetchRowsSpy.callCount).to.equal(2); + }); + + const beforeSortSearchParams = new URL(fetchRowsSpy.lastCall.args[0]).searchParams; + expect(beforeSortSearchParams.get('end')).to.not.equal('9'); + + apiRef.current.sortColumn(mockServer.columns[0].field, 'asc'); + + await waitFor(() => { + expect(fetchRowsSpy.callCount).to.equal(3); + }); + + const afterSortSearchParams = new URL(fetchRowsSpy.lastCall.args[0]).searchParams; + expect(afterSortSearchParams.get('end')).to.equal(beforeSortSearchParams.get('end')); + }); + + it('should reset the scroll position when filter is applied', async () => { + render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + apiRef.current.scrollToIndexes({ rowIndex: 10 }); + + await waitFor(() => { + expect(fetchRowsSpy.callCount).to.equal(2); + }); + + const beforeFilteringSearchParams = new URL(fetchRowsSpy.lastCall.args[0]).searchParams; + // last row is not the first page anymore + expect(beforeFilteringSearchParams.get('start')).to.not.equal('0'); + + apiRef.current.setFilterModel({ + items: [ + { + field: mockServer.columns[0].field, + value: '0', + operator: 'contains', + }, + ], + }); + + await waitFor(() => { + expect(fetchRowsSpy.callCount).to.equal(3); + }); + + const afterFilteringSearchParams = new URL(fetchRowsSpy.lastCall.args[0]).searchParams; + // last row is the end of the first page + expect(afterFilteringSearchParams.get('end')).to.equal('0'); + }); + }); + + describe('Infinite loading', () => { + beforeEach(() => { + // override rowCount + transformGetRowsResponse = (response) => ({ ...response, rowCount: -1 }); + }); + + it('should not render skeleton rows if rowCount is unknown', async () => { + render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + // The 11th row should not exist + expect(() => getRow(10)).to.throw(); + }); + + it('should make a new data source request in infinite loading mode once the bottom row is reached', async () => { + render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + // reset the spy call count + fetchRowsSpy.resetHistory(); + + // make one small and one big scroll that makes sure that the bottom of the grid window is reached + apiRef.current.scrollToIndexes({ rowIndex: 1 }); + apiRef.current.scrollToIndexes({ rowIndex: 9 }); + + // Only one additional fetch should have been made + await waitFor(() => { + expect(fetchRowsSpy.callCount).to.equal(1); + }); + }); + + it('should reset the scroll position when sorting is applied', async () => { + render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + apiRef.current.scrollToIndexes({ rowIndex: 9 }); + + // wait until the rows are rendered + await waitFor(() => expect(getRow(10)).not.to.be.undefined); + + const beforeSortingSearchParams = new URL(fetchRowsSpy.lastCall.args[0]).searchParams; + // last row is not the first page anymore + expect(beforeSortingSearchParams.get('end')).to.not.equal('9'); + + apiRef.current.sortColumn(mockServer.columns[0].field, 'asc'); + + const afterSortingSearchParams = new URL(fetchRowsSpy.lastCall.args[0]).searchParams; + // last row is the end of the first page + expect(afterSortingSearchParams.get('end')).to.equal('9'); + }); + + it('should reset the scroll position when filter is applied', async () => { + render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + apiRef.current.scrollToIndexes({ rowIndex: 9 }); + + // wait until the rows are rendered + await waitFor(() => expect(getRow(10)).not.to.be.undefined); + + const beforeFilteringSearchParams = new URL(fetchRowsSpy.lastCall.args[0]).searchParams; + // last row is not the first page anymore + expect(beforeFilteringSearchParams.get('end')).to.not.equal('9'); + + apiRef.current.setFilterModel({ + items: [ + { + field: mockServer.columns[0].field, + value: '0', + operator: 'contains', + }, + ], + }); + + const afterFilteringSearchParams = new URL(fetchRowsSpy.lastCall.args[0]).searchParams; + // last row is the end of the first page + expect(afterFilteringSearchParams.get('end')).to.equal('9'); + }); + }); + + describe('Row count updates', () => { + it('should add skeleton rows once the rowCount becomes known', async () => { + // override rowCount + transformGetRowsResponse = (response) => ({ ...response, rowCount: undefined }); + const { setProps } = render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + // The 11th row should not exist + expect(() => getRow(10)).to.throw(); + + // make the rowCount known + setProps({ rowCount: 100 }); + + // The 11th row should be a skeleton + expect(getRow(10).dataset.id).to.equal('auto-generated-skeleton-row-root-0'); + }); + + it('should reset the grid if the rowCount becomes unknown', async () => { + // override rowCount + transformGetRowsResponse = (response) => ({ ...response, rowCount: undefined }); + const { setProps } = render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + // The 11th row should not exist + expect(getRow(10).dataset.id).to.equal('auto-generated-skeleton-row-root-0'); + + // make the rowCount unknown + setProps({ rowCount: -1 }); + + // The 11th row should not exist + expect(() => getRow(10)).to.throw(); + }); + + it('should reset the grid if the rowCount becomes smaller than the actual row count', async () => { + // override rowCount + transformGetRowsResponse = (response) => ({ ...response, rowCount: undefined }); + const { setProps } = render( + , + ); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + const getRowsEventSpy = spy(); + apiRef.current.subscribeEvent('getRows', getRowsEventSpy); + + // reduce the rowCount to be more than the number of rows + setProps({ rowCount: 80 }); + expect(getRowsEventSpy.callCount).to.equal(0); + + // reduce the rowCount once more, but now to be less than the number of rows + setProps({ rowCount: 20 }); + expect(getRowsEventSpy.callCount).to.equal(1); + }); + + it('should allow setting the row count via API', async () => { + // override rowCount + transformGetRowsResponse = (response) => ({ ...response, rowCount: undefined }); + render(); + // wait until the rows are rendered + await waitFor(() => expect(getRow(0)).not.to.be.undefined); + + // The 11th row should not exist + expect(() => getRow(10)).to.throw(); + + // set the rowCount via API + apiRef.current.setRowCount(100); + + // wait until the rows are added + await waitFor(() => expect(getRow(10)).not.to.be.undefined); + // The 11th row should be a skeleton + expect(getRow(10).dataset.id).to.equal('auto-generated-skeleton-row-root-0'); + }); + }); +}); diff --git a/packages/x-data-grid-pro/src/typeOverloads/modules.ts b/packages/x-data-grid-pro/src/typeOverloads/modules.ts index 1d0fe7a306e6..78fb6c216270 100644 --- a/packages/x-data-grid-pro/src/typeOverloads/modules.ts +++ b/packages/x-data-grid-pro/src/typeOverloads/modules.ts @@ -3,6 +3,7 @@ import type { GridRowScrollEndParams, GridRowOrderChangeParams, GridFetchRowsParams, + GridGetRowsParams, } from '../models'; import type { GridRenderHeaderFilterProps } from '../components/headerFiltering/GridHeaderFilterCell'; import type { GridColumnPinningInternalCache } from '../hooks/features/columnPinning/gridColumnPinningInterface'; @@ -42,8 +43,20 @@ export interface GridEventLookupPro { rowOrderChange: { params: GridRowOrderChangeParams }; /** * Fired when a new batch of rows is requested to be loaded. Called with a [[GridFetchRowsParams]] object. + * Used to trigger `onFetchRows`. */ fetchRows: { params: GridFetchRowsParams }; + // Data source + /** + * Fired to make a new request through the data source's `getRows` method. + * @ignore - do not document. + */ + getRows: { params: GridGetRowsParams }; + /** + * Fired when the data request is resolved either via the data source or from the cache. + * @ignore - do not document. + */ + rowsFetched: {}; } export interface GridPipeProcessingLookupPro { diff --git a/packages/x-data-grid/src/hooks/core/strategyProcessing/gridStrategyProcessingApi.ts b/packages/x-data-grid/src/hooks/core/strategyProcessing/gridStrategyProcessingApi.ts index ef4094d3887f..6ad1fb8134e9 100644 --- a/packages/x-data-grid/src/hooks/core/strategyProcessing/gridStrategyProcessingApi.ts +++ b/packages/x-data-grid/src/hooks/core/strategyProcessing/gridStrategyProcessingApi.ts @@ -13,6 +13,7 @@ import { GridSortingMethodParams, GridSortingMethodValue, } from '../../features/sorting/gridSortingState'; +import { GridGetRowsParams, GridGetRowsResponse } from '../../../models/gridDataSource'; export type GridStrategyProcessorName = keyof GridStrategyProcessingLookup; @@ -20,6 +21,19 @@ export type GridStrategyGroup = GridStrategyProcessingLookup[keyof GridStrategyProcessingLookup]['group']; export interface GridStrategyProcessingLookup { + dataSourceRowsUpdate: { + group: 'dataSource'; + params: + | { + response: GridGetRowsResponse; + fetchParams: GridGetRowsParams; + } + | { + error: Error; + fetchParams: GridGetRowsParams; + }; + value: void; + }; rowTreeCreation: { group: 'rowTree'; params: GridRowTreeCreationParams; diff --git a/packages/x-data-grid/src/hooks/core/strategyProcessing/useGridStrategyProcessing.ts b/packages/x-data-grid/src/hooks/core/strategyProcessing/useGridStrategyProcessing.ts index 0dbe135f4d75..69d88c9a163c 100644 --- a/packages/x-data-grid/src/hooks/core/strategyProcessing/useGridStrategyProcessing.ts +++ b/packages/x-data-grid/src/hooks/core/strategyProcessing/useGridStrategyProcessing.ts @@ -14,6 +14,7 @@ export const GRID_DEFAULT_STRATEGY = 'none'; export const GRID_STRATEGIES_PROCESSORS: { [P in GridStrategyProcessorName]: GridStrategyProcessingLookup[P]['group']; } = { + dataSourceRowsUpdate: 'dataSource', rowTreeCreation: 'rowTree', filtering: 'rowTree', sorting: 'rowTree', @@ -59,10 +60,7 @@ type UntypedStrategyProcessors = { * ===================================================================================================================== * * Each processor name is part of a strategy group which can only have one active strategy at the time. - * For now, there is only one strategy group named `rowTree` which customize - * - row tree creation algorithm. - * - sorting algorithm. - * - filtering algorithm. + * For now, there are two groupes named `rowTree` and `dataSource`. */ export const useGridStrategyProcessing = (apiRef: React.MutableRefObject) => { const availableStrategies = React.useRef( diff --git a/packages/x-data-grid/src/hooks/features/rows/useGridRows.ts b/packages/x-data-grid/src/hooks/features/rows/useGridRows.ts index bd89c281f729..4e4653f9a172 100644 --- a/packages/x-data-grid/src/hooks/features/rows/useGridRows.ts +++ b/packages/x-data-grid/src/hooks/features/rows/useGridRows.ts @@ -464,6 +464,8 @@ export const useGridRows = ( ...state, rows: { ...state.rows, + loading: props.loading, + totalRowCount: Math.max(props.rowCount || 0, rootGroupChildren.length), dataRowIdToModelLookup, dataRowIdToIdLookup, dataRowIds, @@ -472,7 +474,7 @@ export const useGridRows = ( })); apiRef.current.publishEvent('rowsSet'); }, - [apiRef, props.signature, props.getRowId], + [apiRef, props.signature, props.getRowId, props.loading, props.rowCount], ); const rowApi: GridRowApi = { @@ -621,8 +623,11 @@ export const useGridRows = ( lastRowCount.current = props.rowCount; } + const currentRows = props.unstable_dataSource + ? Array.from(apiRef.current.getRowModels().values()) + : props.rows; const areNewRowsAlreadyInState = - apiRef.current.caches.rows.rowsBeforePartialUpdates === props.rows; + apiRef.current.caches.rows.rowsBeforePartialUpdates === currentRows; const isNewLoadingAlreadyInState = apiRef.current.caches.rows.loadingPropBeforePartialUpdates === props.loading; const isNewRowCountAlreadyInState = @@ -657,10 +662,10 @@ export const useGridRows = ( } } - logger.debug(`Updating all rows, new length ${props.rows?.length}`); + logger.debug(`Updating all rows, new length ${currentRows?.length}`); throttledRowsChange({ cache: createRowsInternalCache({ - rows: props.rows, + rows: currentRows, getRowId: props.getRowId, loading: props.loading, rowCount: props.rowCount, @@ -672,6 +677,7 @@ export const useGridRows = ( props.rowCount, props.getRowId, props.loading, + props.unstable_dataSource, logger, throttledRowsChange, apiRef, diff --git a/packages/x-data-grid/src/models/events/gridEventLookup.ts b/packages/x-data-grid/src/models/events/gridEventLookup.ts index b323518b8963..fd6628cfc071 100644 --- a/packages/x-data-grid/src/models/events/gridEventLookup.ts +++ b/packages/x-data-grid/src/models/events/gridEventLookup.ts @@ -389,6 +389,7 @@ export interface GridControlledStateReasonLookup { | 'restoreState' | 'removeAllFilterItems'; pagination: 'setPaginationModel' | 'stateRestorePreProcessing'; + rows: 'addSkeletonRows'; } export interface GridEventLookup diff --git a/packages/x-data-grid/src/models/gridDataSource.ts b/packages/x-data-grid/src/models/gridDataSource.ts index 44bd45159349..816891663099 100644 --- a/packages/x-data-grid/src/models/gridDataSource.ts +++ b/packages/x-data-grid/src/models/gridDataSource.ts @@ -12,7 +12,7 @@ export interface GridGetRowsParams { /** * Alternate to `start` and `end`, maps to `GridPaginationModel` interface. */ - paginationModel: GridPaginationModel; + paginationModel?: GridPaginationModel; /** * First row index to fetch (number) or cursor information (number | string). */ @@ -20,7 +20,7 @@ export interface GridGetRowsParams { /** * Last row index to fetch. */ - end: number; // last row index to fetch + end: number; /** * List of grouped columns (only applicable with `rowGrouping`). */