UNPKG

@mui/x-data-grid-pro

Version:

The Pro plan edition of the MUI X Data Grid components.

742 lines (723 loc) 33.6 kB
'use client'; import _formatErrorMessage from "@mui/x-internals/formatErrorMessage"; import _extends from "@babel/runtime/helpers/esm/extends"; import * as React from 'react'; import { throttle } from '@mui/x-internals/throttle'; import { isDeepEqual } from '@mui/x-internals/isDeepEqual'; import useEventCallback from '@mui/utils/useEventCallback'; import debounce from '@mui/utils/debounce'; import { useGridEvent, gridSortModelSelector, gridFilterModelSelector, gridRowNodeSelector, GRID_ROOT_GROUP_ID, gridPaginationModelSelector, gridRowIdSelector, useGridSelector, gridFilteredSortedRowIdsSelector, gridExpandedSortedRowIdsSelector, gridRowSelector } from '@mui/x-data-grid'; import { buildRootGroup, getVisibleRows, gridRenderContextSelector, GridStrategyGroup, useGridRegisterStrategyProcessor, runIf, DataSourceRowsUpdateStrategy } from '@mui/x-data-grid/internals'; import { findSkeletonRowsSection } from "../lazyLoader/utils.mjs"; import { GRID_SKELETON_ROW_ROOT_ID } from "../lazyLoader/useGridLazyLoaderPreProcessors.mjs"; import { checkGroupChildrenExpansion } from "../../../utils/tree/utils.mjs"; const INTERVAL_CACHE_INITIAL_STATE = { firstRowToRender: 0, lastRowToRender: 0 }; const GRID_SKELETON_ROW_NESTED_ID = 'auto-generated-skeleton-row-nested'; const getSkeletonRowId = index => `${GRID_SKELETON_ROW_ROOT_ID}-${index}`; const getSkeletonNestedRowId = (index, parentId) => `${GRID_SKELETON_ROW_NESTED_ID}-${parentId}-${index}`; /** * Removes a row and its entire subtree from the working `tree`/`dataRowIdToModelLookup` * copies. Ids in `skip` are left untouched, so a row that moved within the replaced range * (and was re-added this pass) is kept. */ const deleteRowAndDescendants = (tree, dataRowIdToModelLookup, rowId, skip) => { if (skip?.has(rowId)) { return; } const node = tree[rowId]; if (node?.type === 'group') { node.children.forEach(childId => deleteRowAndDescendants(tree, dataRowIdToModelLookup, childId, skip)); } delete tree[rowId]; delete dataRowIdToModelLookup[rowId]; }; /** * @requires useGridRows (state) * @requires useGridPagination (state) * @requires useGridScroll (method */ export const useGridDataSourceNestedLazyLoader = (privateApiRef, props) => { const isDataNested = useGridSelector(privateApiRef, () => props.treeData ? true : (privateApiRef.current.unstable_applyPipeProcessors('getRowsParams', {})?.groupFields?.length ?? 0) > 0); const setStrategyAvailability = React.useCallback(() => { privateApiRef.current.setStrategyAvailability(GridStrategyGroup.DataSource, DataSourceRowsUpdateStrategy.LazyLoadedGroupedData, props.dataSource && props.lazyLoading && isDataNested ? () => true : () => false); }, [privateApiRef, props.lazyLoading, props.dataSource, isDataNested]); const [isStrategyActive, setIsStrategyActive] = React.useState(false); const renderedRowsIntervalCache = React.useRef(INTERVAL_CACHE_INITIAL_STATE); const rowsStale = React.useRef(false); const draggedRowId = React.useRef(null); const pollingIntervalRef = React.useRef(null); // Snapshot of the row tree taken right before a sort/filter triggered reset. // Used by nested data updates that fire later in the auto-expansion chain so // they can preserve expansion state below the first level. const previousTreeRef = React.useRef(null); const fetchRows = React.useCallback(params => { privateApiRef.current.dataSource.fetchRows(GRID_ROOT_GROUP_ID, params); }, [privateApiRef]); const debouncedFetchRows = React.useMemo(() => debounce(fetchRows, 0), [fetchRows]); // 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 => { if (typeof params.start !== 'number') { return params; } const paginationModel = gridPaginationModelSelector(privateApiRef); return _extends({}, params, { start: params.start - params.start % paginationModel.pageSize, end: params.end + paginationModel.pageSize - params.end % paginationModel.pageSize - 1 }); }, [privateApiRef]); const getChildrenCount = props.dataSource?.getChildrenCount; const getGroupKey = props.dataSource?.getGroupKey; const resetRowTree = React.useCallback(() => { const previousTree = privateApiRef.current.state.rows.tree; privateApiRef.current.caches.rows.dataRowIdToModelLookup = {}; privateApiRef.current.setState(state => _extends({}, state, { rows: _extends({}, state.rows, { tree: { [GRID_ROOT_GROUP_ID]: buildRootGroup() }, treeDepths: {}, dataRowIds: [], dataRowIdToModelLookup: {}, groupsToFetch: [] }) })); privateApiRef.current.publishEvent('rowsSet'); return previousTree; }, [privateApiRef]); const revalidateRows = useEventCallback((firstRowIndex, lastRowIndex) => { if (rowsStale.current || lastRowIndex <= firstRowIndex) { return; } const expandedSortedRowIds = gridExpandedSortedRowIdsSelector(privateApiRef); const sortModel = gridSortModelSelector(privateApiRef); const filterModel = gridFilterModelSelector(privateApiRef); const rowRangesByParent = new Map(); for (let i = firstRowIndex; i < lastRowIndex && i < expandedSortedRowIds.length; i += 1) { const rowId = expandedSortedRowIds[i]; const rowNode = gridRowNodeSelector(privateApiRef, rowId); if (!rowNode || rowNode.type === 'skeletonRow' || rowNode.parent == null) { continue; } const parentNode = gridRowNodeSelector(privateApiRef, rowNode.parent); if (!parentNode || parentNode.type !== 'group') { continue; } const childIndex = parentNode.children.indexOf(rowId); if (childIndex === -1) { continue; } const existingRange = rowRangesByParent.get(rowNode.parent); if (existingRange) { existingRange.start = Math.min(existingRange.start, childIndex); existingRange.end = Math.max(existingRange.end, childIndex); } else { rowRangesByParent.set(rowNode.parent, { start: childIndex, end: childIndex }); } } rowRangesByParent.forEach((range, parentId) => { if (parentId === GRID_ROOT_GROUP_ID) { debouncedFetchRows(adjustRowParams({ start: range.start, end: range.end, sortModel, filterModel, groupKeys: [] })); return; } privateApiRef.current.dataSource.fetchRows(parentId, _extends({}, adjustRowParams({ start: range.start, end: range.end, sortModel, filterModel }), { showChildrenLoading: false })); }); }); const stopPolling = React.useCallback(() => { if (pollingIntervalRef.current !== null) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } }, []); const startPolling = useEventCallback(() => { stopPolling(); if (props.dataSourceRevalidateMs <= 0) { return; } pollingIntervalRef.current = setInterval(() => { const { firstRowToRender, lastRowToRender } = renderedRowsIntervalCache.current; revalidateRows(firstRowToRender, lastRowToRender); }, props.dataSourceRevalidateMs); }); const addRootSkeletonRows = React.useCallback(() => { const tree = _extends({}, privateApiRef.current.state.rows.tree); const rootGroup = tree[GRID_ROOT_GROUP_ID]; const rootGroupChildren = [...rootGroup.children]; const pageRowCount = privateApiRef.current.state.pagination.rowCount; const rootChildrenCount = rootGroupChildren.length; if (rootChildrenCount === 0) { return; } let hasChanged = false; // Nested Lazy loading only support VIEWPORT loading trigger, no need for a specific check for (let i = 0; i < pageRowCount - rootChildrenCount; i += 1) { const skeletonId = getSkeletonRowId(i + rootChildrenCount); rootGroupChildren.push(skeletonId); const skeletonRowNode = { type: 'skeletonRow', id: skeletonId, parent: GRID_ROOT_GROUP_ID, depth: 0 }; tree[skeletonId] = skeletonRowNode; hasChanged = true; } if (!hasChanged) { return; } tree[GRID_ROOT_GROUP_ID] = _extends({}, rootGroup, { children: rootGroupChildren }); privateApiRef.current.setState(state => _extends({}, state, { rows: _extends({}, state.rows, { tree }) }), 'addSkeletonRows'); privateApiRef.current.publishEvent('rowsSet'); }, [privateApiRef]); const findSkeletonSectionAndFetchRows = React.useCallback((firstRowIndex, lastRowIndex, options = {}) => { const sortModel = gridSortModelSelector(privateApiRef); const filterModel = gridFilterModelSelector(privateApiRef); const currentVisibleRows = getVisibleRows(privateApiRef); const skeletonRowsSection = findSkeletonRowsSection({ apiRef: privateApiRef, visibleRows: currentVisibleRows.rows, range: { firstRowIndex, lastRowIndex } }); if (!skeletonRowsSection) { if (!options.skipFallbackRevalidation) { revalidateRows(firstRowIndex, lastRowIndex); startPolling(); } return false; } const expandedSortedRowIds = gridExpandedSortedRowIdsSelector(privateApiRef); const skeletonNodesGroupedByParents = new Map(); // Group skeleton rows by their parent for (let i = skeletonRowsSection.firstRowIndex; i <= skeletonRowsSection.lastRowIndex; i += 1) { const rowId = expandedSortedRowIds[i]; const rowNode = gridRowNodeSelector(privateApiRef, rowId); if (rowNode?.type === 'skeletonRow' && rowNode.parent != null) { if (!skeletonNodesGroupedByParents.has(rowNode.parent)) { skeletonNodesGroupedByParents.set(rowNode.parent, []); } skeletonNodesGroupedByParents.get(rowNode.parent).push(rowId); } } // Process each parent group separately skeletonNodesGroupedByParents.forEach((skeletonIds, parentId) => { const parentNode = gridRowNodeSelector(privateApiRef, parentId); if (!parentNode) { return; } const parentChildren = parentNode.children; // Find the first and last skeleton row indexes relative to parent const firstSkeletonIdx = parentChildren.indexOf(skeletonIds[0]); const lastSkeletonIdx = parentChildren.indexOf(skeletonIds[skeletonIds.length - 1]); if (firstSkeletonIdx === -1 || lastSkeletonIdx === -1) { return; } if (parentId === GRID_ROOT_GROUP_ID) { debouncedFetchRows(adjustRowParams({ start: firstSkeletonIdx, end: lastSkeletonIdx, sortModel, filterModel, groupKeys: [] })); } else { privateApiRef.current.dataSource.fetchRows(parentId, adjustRowParams({ start: firstSkeletonIdx, end: lastSkeletonIdx })); } }); return true; }, [privateApiRef, debouncedFetchRows, adjustRowParams, revalidateRows, startPolling]); const fetchVisibleSkeletonRows = React.useCallback((options = {}) => { const cachedInterval = renderedRowsIntervalCache.current; let firstRowIndex = cachedInterval.firstRowToRender; let lastRowIndex = cachedInterval.lastRowToRender; if (lastRowIndex <= firstRowIndex) { const renderContext = gridRenderContextSelector(privateApiRef); firstRowIndex = renderContext.firstRowIndex; lastRowIndex = renderContext.lastRowIndex; } if (lastRowIndex <= firstRowIndex) { return false; } return findSkeletonSectionAndFetchRows(firstRowIndex, lastRowIndex, options); }, [privateApiRef, findSkeletonSectionAndFetchRows]); const cleanUpParentNodeAndGenerateSkeletonRows = React.useCallback(parentId => { const dataRowIdToModelLookup = _extends({}, privateApiRef.current.state.rows.dataRowIdToModelLookup); const tree = _extends({}, privateApiRef.current.state.rows.tree); const deletedIds = new Set(); const rowNode = gridRowNodeSelector(privateApiRef, parentId); if (!rowNode || rowNode.type !== 'group' || rowNode.children.length === 0) { return; } // Remove current descendants from the tree const traverse = nodeId => { const node = gridRowNodeSelector(privateApiRef, nodeId); if (!node) { return; } // Recursively traverse children first (depth-first) if (node.type === 'group' && node.children.length > 0) { node.children.forEach(traverse); } if (deletedIds.has(nodeId)) { return; } deletedIds.add(nodeId); delete dataRowIdToModelLookup[nodeId]; delete tree[nodeId]; }; rowNode.children.forEach(traverse); // Add skeleton rows for the children const rowModel = gridRowSelector(privateApiRef, parentId); if (!rowModel) { return; } const childrenCount = getChildrenCount?.(rowModel) ?? 0; if (childrenCount <= 0) { return; } const newChildren = []; for (let i = 0; i < childrenCount; i += 1) { const skeletonId = getSkeletonNestedRowId(i, parentId); if (tree[skeletonId]) { newChildren.push(skeletonId); continue; } tree[skeletonId] = { type: 'skeletonRow', id: skeletonId, parent: parentId, depth: rowNode.depth + 1 }; newChildren.push(skeletonId); } tree[parentId] = _extends({}, rowNode, { children: newChildren }); privateApiRef.current.caches.rows.dataRowIdToModelLookup = dataRowIdToModelLookup; privateApiRef.current.setState(state => _extends({}, state, { rows: _extends({}, state.rows, { tree, dataRowIdToModelLookup, dataRowIds: state.rows.dataRowIds.filter(id => !deletedIds.has(id)) }) })); privateApiRef.current.publishEvent('rowsSet'); }, [privateApiRef, getChildrenCount]); const replaceNestedRows = React.useCallback((startIndex, response, fetchParams, parentId = GRID_ROOT_GROUP_ID, previousTree) => { if (response.rows.length === 0) { return false; } const currentTree = privateApiRef.current.state.rows.tree; const tree = _extends({}, currentTree); const treeToReadPreviousExpansionState = previousTree ?? currentTree; const dataRowIdToModelLookup = _extends({}, privateApiRef.current.state.rows.dataRowIdToModelLookup); const targetGroup = tree[parentId]; const targetGroupChildren = [...targetGroup.children]; const targetGroupChildrenFromPath = Object.assign(Object.create(null), targetGroup.childrenFromPath); const seenIds = new Set(); if (!getGroupKey) { throw new Error(process.env.NODE_ENV !== "production" ? 'MUI X: No `getGroupKey` method provided with the dataSource.' : _formatErrorMessage(121)); } if (!getChildrenCount) { throw new Error(process.env.NODE_ENV !== "production" ? 'MUI X: No `getChildrenCount` method provided with the dataSource.' : _formatErrorMessage(122)); } // Calculate parent depth (-1 for root, so children at root level have depth 0) const parentDepth = parentId === GRID_ROOT_GROUP_ID ? -1 : targetGroup.depth; const parentPath = parentId === GRID_ROOT_GROUP_ID ? [] : tree[parentId].path ?? []; const groupFields = props.treeData || !('groupFields' in fetchParams) ? [] : fetchParams.groupFields ?? []; // The Premium pivoting pipe processor adds `pivotModel` only while pivoting is active. // Match the non-lazy row grouping path by capping default expansion at the last // row-grouping level when pivoting is active. const isPivotingActive = !props.treeData && fetchParams.pivotModel != null; const maxDepth = isPivotingActive ? groupFields.length - 1 : undefined; let hasExpandedGroupToFetch = false; for (let i = 0; i < response.rows.length; i += 1) { const rowModel = response.rows[i]; const childrenCount = getChildrenCount(rowModel); if (childrenCount === -1) { throw new Error(process.env.NODE_ENV !== "production" ? 'MUI X Data Grid: Nested lazy loading does not support unknown children count for now.\nIf this is a use-case that you are interested in, please open an issue: https://github.com/mui/mui-x/issues/new?template=2.feature.yml' : _formatErrorMessage(284)); } const rowId = gridRowIdSelector(privateApiRef, rowModel); const [removedRowId] = targetGroupChildren.splice(startIndex + i, 1, rowId); // Drop the replaced row and any subtree it owned (skipping rows re-added this // pass) so a changed id swapping out an expanded group leaves no orphans behind. deleteRowAndDescendants(tree, dataRowIdToModelLookup, removedRowId, seenIds); let rowTreeNodeConfig; const groupingKey = getGroupKey(rowModel); const previousNode = treeToReadPreviousExpansionState[rowId]; const previousChildrenExpanded = previousNode?.type === 'group' ? previousNode.childrenExpanded : undefined; if (childrenCount === 0) { rowTreeNodeConfig = { id: rowId, depth: parentDepth + 1, parent: parentId, type: 'leaf', groupingKey }; } else { const children = []; for (let j = 0; j < childrenCount; j += 1) { const skeletonId = getSkeletonNestedRowId(j, rowId); children.push(skeletonId); const skeletonRowNode = { type: 'skeletonRow', id: skeletonId, parent: rowId, depth: parentDepth + 2 }; tree[skeletonId] = skeletonRowNode; } const groupNode = { id: rowId, depth: parentDepth + 1, parent: parentId, type: 'group', groupingKey, path: [...parentPath, groupingKey], children, isAutoGenerated: false, groupingField: props.treeData ? null : groupFields[parentPath.length] ?? null, serverChildrenCount: childrenCount, childrenFromPath: Object.create(null) }; const childrenExpanded = checkGroupChildrenExpansion(groupNode, props.defaultGroupingExpansionDepth, maxDepth, props.isGroupExpandedByDefault, previousChildrenExpanded); rowTreeNodeConfig = _extends({}, groupNode, { childrenExpanded }); hasExpandedGroupToFetch ||= childrenExpanded === true; } dataRowIdToModelLookup[rowId] = rowModel; tree[rowId] = rowTreeNodeConfig; // Update parent's childrenFromPath lookup const groupingFieldName = rowTreeNodeConfig.groupingField ?? '__no_field__'; const groupingKeyName = rowTreeNodeConfig.groupingKey ?? '__no_key__'; if (!targetGroupChildrenFromPath[groupingFieldName]) { targetGroupChildrenFromPath[groupingFieldName] = {}; } targetGroupChildrenFromPath[groupingFieldName][groupingKeyName.toString()] = rowId; seenIds.add(rowId); } // Keep skeleton rows in place (for both root and nested parents) instead of // filtering them out. Dropping them here would collapse any unfetched gap // above the replaced range — e.g. after a jump/fast scroll that loads a // middle range while the rows above it are still skeletons — because // `addRootSkeletonRows` only appends padding at the end. Leaving the // skeletons at their indices keeps loaded rows at their real positions. // The sort/filter reset path starts from an empty children list, so there are no // skeletons to preserve and `addRootSkeletonRows` pads normally. tree[parentId] = _extends({}, targetGroup, { children: targetGroupChildren, childrenFromPath: targetGroupChildrenFromPath }); // Removes potential remaining skeleton rows from the dataRowIds. // For the root parent the targetGroupChildren list IS the full root row order, // so we can replace dataRowIds wholesale. For nested parents it only contains // that subtree's children, so we merge into the existing list to avoid wiping // sibling subtrees' row IDs. let dataRowIds; if (parentId === GRID_ROOT_GROUP_ID) { dataRowIds = targetGroupChildren.filter(childId => tree[childId]?.type !== 'skeletonRow'); } else { const newIds = new Set(targetGroupChildren.filter(childId => tree[childId]?.type !== 'skeletonRow')); const previousIds = privateApiRef.current.state.rows.dataRowIds; dataRowIds = previousIds.filter(id => tree[id] !== undefined && tree[id].type !== 'skeletonRow' && !newIds.has(id)); newIds.forEach(id => dataRowIds.push(id)); } privateApiRef.current.caches.rows.dataRowIdToModelLookup = dataRowIdToModelLookup; privateApiRef.current.setState(state => _extends({}, state, { rows: _extends({}, state.rows, { dataRowIdToModelLookup, dataRowIds, tree: _extends({}, tree), totalRowCount: parentId === GRID_ROOT_GROUP_ID ? response.rowCount ?? -1 : state.rows.totalRowCount }) })); privateApiRef.current.publishEvent('rowsSet'); return hasExpandedGroupToFetch; }, [privateApiRef, getGroupKey, getChildrenCount, props.treeData, props.defaultGroupingExpansionDepth, props.isGroupExpandedByDefault]); const removeDuplicateRows = React.useCallback((rows, parentId = GRID_ROOT_GROUP_ID) => { const tree = _extends({}, privateApiRef.current.state.rows.tree); const dataRowIdToModelLookup = _extends({}, privateApiRef.current.state.rows.dataRowIdToModelLookup); const parentNode = tree[parentId]; if (!parentNode || parentNode.type !== 'group') { return; } const parentChildren = [...parentNode.children]; const isRoot = parentId === GRID_ROOT_GROUP_ID; const skeletonDepth = isRoot ? 0 : parentNode.depth + 1; let duplicateRowCount = 0; rows.forEach(row => { const rowId = gridRowIdSelector(privateApiRef, row); if (tree[rowId] || dataRowIdToModelLookup[rowId]) { const index = parentChildren.indexOf(rowId); if (index !== -1) { const skeletonId = isRoot ? getSkeletonRowId(index) : getSkeletonNestedRowId(index, parentId); parentChildren[index] = skeletonId; tree[skeletonId] = { type: 'skeletonRow', id: skeletonId, parent: parentId, depth: skeletonDepth }; } // Remove the duplicate together with any subtree it owned. // The following `replaceNestedRows` recomputes `dataRowIds` from the tree and re-inserts it. deleteRowAndDescendants(tree, dataRowIdToModelLookup, rowId); duplicateRowCount += 1; } }); if (duplicateRowCount > 0) { tree[parentId] = _extends({}, parentNode, { children: parentChildren }); privateApiRef.current.setState(state => _extends({}, state, { rows: _extends({}, state.rows, { tree, dataRowIdToModelLookup }) })); } }, [privateApiRef]); const updateLoadedRows = React.useCallback((parentId, startIndex, rows) => { if (rows.length === 0) { return true; } const tree = privateApiRef.current.state.rows.tree; const parentNode = tree[parentId]; if (!parentNode || parentNode.type !== 'group') { return false; } const firstTargetRow = parentNode.children[startIndex]; if (!firstTargetRow || tree[firstTargetRow]?.type === 'skeletonRow') { return false; } const existingRowIds = parentNode.children.slice(startIndex, startIndex + rows.length); const newRowIds = rows.map(row => gridRowIdSelector(privateApiRef, row)); const sameRowIds = existingRowIds.length === newRowIds.length && existingRowIds.every((rowId, index) => rowId === newRowIds[index]); if (!sameRowIds) { return false; } const dataRowIdToModelLookup = privateApiRef.current.state.rows.dataRowIdToModelLookup; const changedRows = rows.filter((row, index) => { const existingRow = dataRowIdToModelLookup[existingRowIds[index]]; return !isDeepEqual(row, existingRow); }); if (changedRows.length > 0) { const parentPath = parentId === GRID_ROOT_GROUP_ID ? [] : parentNode.path ?? []; privateApiRef.current.updateNestedRows(changedRows, parentPath); } return true; }, [privateApiRef]); const handleDataUpdate = React.useCallback(params => { if ('error' in params) { return; } const { response, fetchParams } = params; let hasExpandedGroupToFetch = false; const wasStale = rowsStale.current; const pageRowCount = privateApiRef.current.state.pagination.rowCount; if (fetchParams.groupKeys?.length === 0 && (response.rowCount !== undefined || pageRowCount === undefined)) { privateApiRef.current.setRowCount(response.rowCount === undefined ? -1 : response.rowCount); } // scroll to the top if the rows are stale and the new request is for the first page if (rowsStale.current && params.fetchParams.start === 0) { privateApiRef.current.scroll({ top: 0 }); // Keep the nested lazy-loading tree shape after sort/filter. // Plain `setRows()` would recreate expandable groups without child skeleton rows. const previousTree = resetRowTree(); previousTreeRef.current = previousTree; hasExpandedGroupToFetch = replaceNestedRows(0, response, fetchParams, GRID_ROOT_GROUP_ID, previousTree); } else { const filteredSortedRowIds = gridFilteredSortedRowIdsSelector(privateApiRef); const startingIndex = typeof fetchParams.start === 'string' ? Math.max(filteredSortedRowIds.indexOf(fetchParams.start), 0) : fetchParams.start; if (!updateLoadedRows(GRID_ROOT_GROUP_ID, startingIndex, response.rows)) { removeDuplicateRows(response.rows); hasExpandedGroupToFetch = replaceNestedRows(startingIndex, response, fetchParams); } } rowsStale.current = false; addRootSkeletonRows(); privateApiRef.current.setLoading(false); privateApiRef.current.unstable_applyPipeProcessors('processDataSourceRows', { params: params.fetchParams, response }, false); privateApiRef.current.requestPipeProcessorsApplication('hydrateRows'); // After sort/filter the tree is reset and skeletons are re-added by // `addRootSkeletonRows`, but the viewport's first/last indices may be unchanged // (e.g. scrolled to top already) so `renderedRowsIntervalChange` does not fire. // Trigger a scan so root-level skeletons sitting in the viewport get fetched. if (hasExpandedGroupToFetch || wasStale) { fetchVisibleSkeletonRows({ skipFallbackRevalidation: true }); } startPolling(); }, [privateApiRef, addRootSkeletonRows, replaceNestedRows, removeDuplicateRows, updateLoadedRows, fetchVisibleSkeletonRows, startPolling, resetRowTree]); const handleNestedDataUpdate = React.useCallback(params => { if ('error' in params) { return; } const { parentId, fetchParams, response } = params; // Get the relative start index from fetchParams const startIndex = typeof fetchParams.start === 'number' ? fetchParams.start : 0; let hasExpandedGroupToFetch = false; if (!updateLoadedRows(parentId, startIndex, response.rows)) { removeDuplicateRows(response.rows, parentId); hasExpandedGroupToFetch = replaceNestedRows(startIndex, response, fetchParams, parentId, previousTreeRef.current ?? undefined); } if (hasExpandedGroupToFetch) { fetchVisibleSkeletonRows({ skipFallbackRevalidation: true }); } startPolling(); }, [replaceNestedRows, removeDuplicateRows, updateLoadedRows, fetchVisibleSkeletonRows, startPolling]); const handleRowCountChange = React.useCallback(newRowCount => { if (rowsStale.current) { return; } // Show error if unknown row-count if (newRowCount < 0) { throw new Error(process.env.NODE_ENV !== "production" ? 'MUI X Data Grid: Row count is unknown. Please provide a valid row count for lazy loading to work.' : _formatErrorMessage(285)); } addRootSkeletonRows(); privateApiRef.current.requestPipeProcessorsApplication('hydrateRows'); }, [privateApiRef, addRootSkeletonRows]); const handleRenderedRowsIntervalChange = React.useCallback(params => { if (rowsStale.current) { return; } if (renderedRowsIntervalCache.current.firstRowToRender === params.firstRowIndex && renderedRowsIntervalCache.current.lastRowToRender === params.lastRowIndex) { return; } findSkeletonSectionAndFetchRows(params.firstRowIndex, params.lastRowIndex); renderedRowsIntervalCache.current = { firstRowToRender: params.firstRowIndex, lastRowToRender: params.lastRowIndex }; }, [findSkeletonSectionAndFetchRows]); const handleRowExpansionChange = React.useCallback(node => { // A manual expand/collapse ends the post-sort/filter restoration cascade. // Drop the pre-reset snapshot so subsequent nested fetches read expansion state // from the live tree instead of re-applying stale expansion from before the last reset. previousTreeRef.current = null; if (node.childrenExpanded) { fetchVisibleSkeletonRows(); return; } cleanUpParentNodeAndGenerateSkeletonRows(node.id); // Collapsing shifts rows below the group up into the viewport without firing // `renderedRowsIntervalChange` (same indices, different content), so scan again // for skeletons now exposed in the cached viewport range. fetchVisibleSkeletonRows(); }, [fetchVisibleSkeletonRows, cleanUpParentNodeAndGenerateSkeletonRows]); const throttledHandleRenderedRowsIntervalChange = React.useMemo(() => throttle(handleRenderedRowsIntervalChange, props.lazyLoadingRequestThrottleMs), [props.lazyLoadingRequestThrottleMs, handleRenderedRowsIntervalChange]); React.useEffect(() => { return () => { throttledHandleRenderedRowsIntervalChange.clear(); stopPolling(); }; }, [throttledHandleRenderedRowsIntervalChange, stopPolling]); React.useEffect(() => { if (!isStrategyActive || props.dataSourceRevalidateMs <= 0) { stopPolling(); } }, [isStrategyActive, props.dataSourceRevalidateMs, stopPolling]); const handleGridSortModelChange = React.useCallback(newSortModel => { rowsStale.current = true; renderedRowsIntervalCache.current = INTERVAL_CACHE_INITIAL_STATE; throttledHandleRenderedRowsIntervalChange.clear(); stopPolling(); const paginationModel = gridPaginationModelSelector(privateApiRef); const filterModel = gridFilterModelSelector(privateApiRef); const getRowsParams = { start: 0, end: paginationModel.pageSize - 1, sortModel: newSortModel, filterModel }; privateApiRef.current.setLoading(true); debouncedFetchRows(getRowsParams); }, [privateApiRef, debouncedFetchRows, throttledHandleRenderedRowsIntervalChange, stopPolling]); const handleGridFilterModelChange = React.useCallback(newFilterModel => { rowsStale.current = true; renderedRowsIntervalCache.current = INTERVAL_CACHE_INITIAL_STATE; throttledHandleRenderedRowsIntervalChange.clear(); stopPolling(); const paginationModel = gridPaginationModelSelector(privateApiRef); const sortModel = gridSortModelSelector(privateApiRef); const getRowsParams = { start: 0, end: paginationModel.pageSize - 1, sortModel, filterModel: newFilterModel }; privateApiRef.current.setLoading(true); debouncedFetchRows(getRowsParams); }, [privateApiRef, debouncedFetchRows, throttledHandleRenderedRowsIntervalChange, stopPolling]); const handleDragStart = React.useCallback(row => { draggedRowId.current = row.id; }, []); const handleDragEnd = React.useCallback(() => { draggedRowId.current = null; }, []); const handleStrategyActivityChange = React.useCallback(() => { setIsStrategyActive(privateApiRef.current.getActiveStrategy(GridStrategyGroup.DataSource) === DataSourceRowsUpdateStrategy.LazyLoadedGroupedData); }, [privateApiRef]); useGridRegisterStrategyProcessor(privateApiRef, DataSourceRowsUpdateStrategy.LazyLoadedGroupedData, 'dataSourceRootRowsUpdate', handleDataUpdate); useGridRegisterStrategyProcessor(privateApiRef, DataSourceRowsUpdateStrategy.LazyLoadedGroupedData, 'dataSourceNestedRowsUpdate', handleNestedDataUpdate); useGridEvent(privateApiRef, 'strategyAvailabilityChange', handleStrategyActivityChange); useGridEvent(privateApiRef, 'rowCountChange', runIf(isStrategyActive, handleRowCountChange)); useGridEvent(privateApiRef, 'rowExpansionChange', runIf(isStrategyActive, handleRowExpansionChange)); useGridEvent(privateApiRef, 'renderedRowsIntervalChange', runIf(isStrategyActive, throttledHandleRenderedRowsIntervalChange)); useGridEvent(privateApiRef, 'sortModelChange', runIf(isStrategyActive, handleGridSortModelChange)); useGridEvent(privateApiRef, 'filterModelChange', runIf(isStrategyActive, handleGridFilterModelChange)); useGridEvent(privateApiRef, 'rowDragStart', runIf(isStrategyActive, handleDragStart)); useGridEvent(privateApiRef, 'rowDragEnd', runIf(isStrategyActive, handleDragEnd)); React.useEffect(() => { setStrategyAvailability(); }, [setStrategyAvailability]); };