UNPKG

@mui/x-data-grid-pro

Version:

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

499 lines (485 loc) 22 kB
'use client'; 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, GRID_ROOT_GROUP_ID, gridPaginationModelSelector, gridFilteredSortedRowIdsSelector, gridRowIdSelector } from '@mui/x-data-grid'; import { getVisibleRows, gridRenderContextSelector, GridStrategyGroup, useGridRegisterStrategyProcessor, useGridRegisterPipeProcessor, runIf, DataSourceRowsUpdateStrategy } from '@mui/x-data-grid/internals'; import { findSkeletonRowsSection, adjustRowParams } from "../lazyLoader/utils.js"; import { GRID_SKELETON_ROW_ROOT_ID } from "../lazyLoader/useGridLazyLoaderPreProcessors.js"; var LoadingTrigger = /*#__PURE__*/function (LoadingTrigger) { LoadingTrigger[LoadingTrigger["VIEWPORT"] = 0] = "VIEWPORT"; LoadingTrigger[LoadingTrigger["SCROLL_END"] = 1] = "SCROLL_END"; return LoadingTrigger; }(LoadingTrigger || {}); const INTERVAL_CACHE_INITIAL_STATE = { firstRowToRender: 0, lastRowToRender: 0 }; const getSkeletonRowId = index => `${GRID_SKELETON_ROW_ROOT_ID}-${index}`; /** * @requires useGridRows (state) * @requires useGridPagination (state) * @requires useGridScroll (method */ export const useGridDataSourceLazyLoader = (privateApiRef, props) => { const setStrategyAvailability = React.useCallback(() => { privateApiRef.current.setStrategyAvailability(GridStrategyGroup.DataSource, DataSourceRowsUpdateStrategy.LazyLoading, props.dataSource && props.lazyLoading ? () => true : () => false); }, [privateApiRef, props.lazyLoading, props.dataSource]); const [lazyLoadingRowsUpdateStrategyActive, setLazyLoadingRowsUpdateStrategyActive] = React.useState(false); const renderedRowsIntervalCache = React.useRef(INTERVAL_CACHE_INITIAL_STATE); const previousLastRowIndex = React.useRef(0); const loadingTrigger = React.useRef(null); const rowsStale = React.useRef(false); const draggedRowId = React.useRef(null); const pollingIntervalRef = 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]); const revalidate = useEventCallback(params => { if (rowsStale.current) { return; } // Check cache first — if data is still cached, skip entirely // (no backend call, no diffing needed) const cache = privateApiRef.current.dataSource.cache; const cachedResponse = cache.get(params); if (cachedResponse !== undefined) { return; } // Cache is stale/expired — fetch in background (no loading indicator) debouncedFetchRows(params); }); const stopPolling = React.useCallback(() => { if (pollingIntervalRef.current !== null) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } }, []); const startPolling = useEventCallback(params => { stopPolling(); if (props.dataSourceRevalidateMs <= 0) { return; } pollingIntervalRef.current = setInterval(() => { revalidate(params); }, props.dataSourceRevalidateMs); }); const resetGrid = React.useCallback(() => { privateApiRef.current.setLoading(true); privateApiRef.current.dataSource.cache.clear(); rowsStale.current = true; previousLastRowIndex.current = 0; const paginationModel = gridPaginationModelSelector(privateApiRef); const sortModel = gridSortModelSelector(privateApiRef); const filterModel = gridFilterModelSelector(privateApiRef); const getRowsParams = { start: 0, end: paginationModel.pageSize - 1, sortModel, filterModel }; fetchRows(getRowsParams); }, [privateApiRef, fetchRows]); const ensureValidRowCount = React.useCallback((previousLoadingTrigger, newLoadingTrigger) => { // 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]; 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(() => { const tree = 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; /** * Do nothing if * - children count is 0 */ if (rootChildrenCount === 0) { return; } let hasChanged = false; // SWR: Only add skeleton padding for never-fetched positions beyond current data. // Previously fetched rows are kept in place (not skeletonized) to avoid flicker on scroll-back. // Should only happen with VIEWPORT loading trigger if (loadingTrigger.current === LoadingTrigger.VIEWPORT) { // fill the grid with skeleton rows for (let i = 0; i < pageRowCount - rootChildrenCount; i += 1) { const skeletonId = getSkeletonRowId(i + rootChildrenCount); // to avoid duplicate keys on rebuild 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]); const updateLoadingTrigger = React.useCallback(rowCount => { const newLoadingTrigger = rowCount === -1 ? LoadingTrigger.SCROLL_END : LoadingTrigger.VIEWPORT; if (loadingTrigger.current !== null) { ensureValidRowCount(loadingTrigger.current, newLoadingTrigger); } if (loadingTrigger.current !== newLoadingTrigger) { loadingTrigger.current = newLoadingTrigger; } }, [ensureValidRowCount]); const handleDataUpdate = React.useCallback(params => { if ('error' in params) { return; } const { response, fetchParams } = params; const pageRowCount = privateApiRef.current.state.pagination.rowCount; const tree = privateApiRef.current.state.rows.tree; const dataRowIdToModelLookup = privateApiRef.current.state.rows.dataRowIdToModelLookup; if (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 }); // the rows can safely be replaced. skeleton rows will be added later privateApiRef.current.setRows(response.rows); } else { const rootGroup = tree[GRID_ROOT_GROUP_ID]; const rootGroupChildren = [...rootGroup.children]; const filteredSortedRowIds = gridFilteredSortedRowIdsSelector(privateApiRef); const startingIndex = typeof fetchParams.start === 'string' ? Math.max(filteredSortedRowIds.indexOf(fetchParams.start), 0) : fetchParams.start; // Determine if this is a background revalidation (target rows are real, not skeletons) const firstTargetRow = rootGroupChildren[startingIndex]; const isRevalidation = firstTargetRow && tree[firstTargetRow]?.type !== 'skeletonRow'; if (isRevalidation) { // --- SWR PATH --- // Compare response row IDs with existing row IDs at target positions const newRowIds = response.rows.map(row => gridRowIdSelector(privateApiRef, row)); const existingRowIds = rootGroupChildren.slice(startingIndex, startingIndex + response.rows.length); const sameRowIds = existingRowIds.length === newRowIds.length && existingRowIds.every((id, i) => id === newRowIds[i]); if (sameRowIds) { // SAME ROW IDs — check for data changes only const changedRows = response.rows.filter((newRow, i) => { const existingRow = dataRowIdToModelLookup[existingRowIds[i]]; return !isDeepEqual(newRow, existingRow); }); if (changedRows.length === 0) { // No changes — skip update entirely. Cache already refreshed by fetchRows. privateApiRef.current.setLoading(false); return; } // Efficient data-only update — no tree restructuring needed privateApiRef.current.updateRows(changedRows); // Cache is already updated by fetchRows in useGridDataSourceBase } else { // DIFFERENT ROW IDs — server returned new rows for this range // 1. Remove old rows at target positions for (let i = startingIndex; i < startingIndex + response.rows.length && i < rootGroupChildren.length; i += 1) { const oldRowId = rootGroupChildren[i]; if (oldRowId && tree[oldRowId]?.type !== 'skeletonRow') { delete tree[oldRowId]; delete dataRowIdToModelLookup[oldRowId]; const skeletonId = getSkeletonRowId(i); rootGroupChildren[i] = skeletonId; tree[skeletonId] = { type: 'skeletonRow', id: skeletonId, parent: GRID_ROOT_GROUP_ID, depth: 0 }; } } // 2. Duplicate detection for incoming rows let duplicateRowCount = 0; response.rows.forEach(row => { const rowId = gridRowIdSelector(privateApiRef, row); if (tree[rowId] || dataRowIdToModelLookup[rowId]) { const index = rootGroupChildren.indexOf(rowId); if (index !== -1) { const skeletonId = getSkeletonRowId(index); rootGroupChildren[index] = skeletonId; tree[skeletonId] = { type: 'skeletonRow', id: skeletonId, parent: GRID_ROOT_GROUP_ID, depth: 0 }; } delete tree[rowId]; delete dataRowIdToModelLookup[rowId]; duplicateRowCount += 1; } }); if (duplicateRowCount > 0) { tree[GRID_ROOT_GROUP_ID] = _extends({}, rootGroup, { children: rootGroupChildren }); privateApiRef.current.setState(state => _extends({}, state, { rows: _extends({}, state.rows, { tree, dataRowIdToModelLookup }) })); } // 3. Replace rows privateApiRef.current.unstable_replaceRows(startingIndex, response.rows); } } else { // --- ORIGINAL PATH (skeleton → real row replacement) --- // Check for duplicate rows let duplicateRowCount = 0; response.rows.forEach(row => { const rowId = gridRowIdSelector(privateApiRef, row); if (tree[rowId] || dataRowIdToModelLookup[rowId]) { const index = rootGroupChildren.indexOf(rowId); if (index !== -1) { const skeletonId = getSkeletonRowId(index); rootGroupChildren[index] = skeletonId; tree[skeletonId] = { type: 'skeletonRow', id: skeletonId, parent: GRID_ROOT_GROUP_ID, depth: 0 }; } delete tree[rowId]; delete dataRowIdToModelLookup[rowId]; duplicateRowCount += 1; } }); if (duplicateRowCount > 0) { tree[GRID_ROOT_GROUP_ID] = _extends({}, rootGroup, { children: rootGroupChildren }); privateApiRef.current.setState(state => _extends({}, state, { rows: _extends({}, state.rows, { tree, dataRowIdToModelLookup }) })); } privateApiRef.current.unstable_replaceRows(startingIndex, response.rows); } } rowsStale.current = false; if (loadingTrigger.current === null) { updateLoadingTrigger(privateApiRef.current.state.pagination.rowCount); } addSkeletonRows(); privateApiRef.current.setLoading(false); privateApiRef.current.unstable_applyPipeProcessors('processDataSourceRows', { params: params.fetchParams, response }, false); if (loadingTrigger.current === LoadingTrigger.VIEWPORT) { startPolling(params.fetchParams); } privateApiRef.current.requestPipeProcessorsApplication('hydrateRows'); }, [privateApiRef, updateLoadingTrigger, addSkeletonRows, startPolling]); const handleRowCountChange = React.useCallback(() => { if (rowsStale.current || loadingTrigger.current === null) { return; } updateLoadingTrigger(privateApiRef.current.state.pagination.rowCount); addSkeletonRows(); privateApiRef.current.requestPipeProcessorsApplication('hydrateRows'); }, [privateApiRef, updateLoadingTrigger, addSkeletonRows]); const handleIntersection = useEventCallback(() => { if (rowsStale.current || loadingTrigger.current !== LoadingTrigger.SCROLL_END) { return; } const renderContext = gridRenderContextSelector(privateApiRef); if (previousLastRowIndex.current >= renderContext.lastRowIndex) { return; } previousLastRowIndex.current = renderContext.lastRowIndex; const paginationModel = gridPaginationModelSelector(privateApiRef); const sortModel = gridSortModelSelector(privateApiRef); const filterModel = gridFilterModelSelector(privateApiRef); const getRowsParams = { start: renderContext.lastRowIndex, end: renderContext.lastRowIndex + paginationModel.pageSize - 1, sortModel, filterModel }; privateApiRef.current.setLoading(true); fetchRows(adjustRowParams(getRowsParams, { pageSize: paginationModel.pageSize, rowCount: privateApiRef.current.state.pagination.rowCount })); }); const handleRenderedRowsIntervalChange = React.useCallback(renderContext => { if (rowsStale.current) { return; } const sortModel = gridSortModelSelector(privateApiRef); const filterModel = gridFilterModelSelector(privateApiRef); const getRowsParams = { start: renderContext.firstRowIndex, end: renderContext.lastRowIndex - 1, sortModel, filterModel }; if (renderedRowsIntervalCache.current.firstRowToRender === renderContext.firstRowIndex && renderedRowsIntervalCache.current.lastRowToRender === renderContext.lastRowIndex) { return; } renderedRowsIntervalCache.current = { firstRowToRender: renderContext.firstRowIndex, lastRowToRender: renderContext.lastRowIndex }; const currentVisibleRows = getVisibleRows(privateApiRef); const skeletonRowsSection = findSkeletonRowsSection({ apiRef: privateApiRef, visibleRows: currentVisibleRows.rows, range: renderContext }); const paginationModel = gridPaginationModelSelector(privateApiRef); if (!skeletonRowsSection) { // SWR: No skeleton rows in viewport — all visible rows have real data. // Schedule background revalidation if cache has expired for this range. if (loadingTrigger.current === LoadingTrigger.VIEWPORT) { const adjustedParams = adjustRowParams(getRowsParams, { pageSize: paginationModel.pageSize, rowCount: privateApiRef.current.state.pagination.rowCount }); revalidate(adjustedParams); startPolling(adjustedParams); } return; } getRowsParams.start = skeletonRowsSection.firstRowIndex; getRowsParams.end = skeletonRowsSection.lastRowIndex; fetchRows(adjustRowParams(getRowsParams, { pageSize: paginationModel.pageSize, rowCount: privateApiRef.current.state.pagination.rowCount })); }, [privateApiRef, fetchRows, revalidate, startPolling]); const throttledHandleRenderedRowsIntervalChange = React.useMemo(() => throttle(handleRenderedRowsIntervalChange, props.lazyLoadingRequestThrottleMs), [props.lazyLoadingRequestThrottleMs, handleRenderedRowsIntervalChange]); React.useEffect(() => { return () => { throttledHandleRenderedRowsIntervalChange.clear(); stopPolling(); }; }, [throttledHandleRenderedRowsIntervalChange, stopPolling]); // Stop polling when dataSourceRevalidateMs is set to 0 React.useEffect(() => { if (props.dataSourceRevalidateMs <= 0) { stopPolling(); } }, [props.dataSourceRevalidateMs, stopPolling]); React.useEffect(() => stopPolling, [stopPolling]); const handleGridSortModelChange = React.useCallback(newSortModel => { rowsStale.current = true; throttledHandleRenderedRowsIntervalChange.clear(); stopPolling(); previousLastRowIndex.current = 0; 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; throttledHandleRenderedRowsIntervalChange.clear(); stopPolling(); previousLastRowIndex.current = 0; 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(() => { setLazyLoadingRowsUpdateStrategyActive(privateApiRef.current.getActiveStrategy(GridStrategyGroup.DataSource) === DataSourceRowsUpdateStrategy.LazyLoading); }, [privateApiRef]); // Provide render context based start/end for lazy loading so that // `apiRef.current.dataSource.fetchRows()` without params // re-fetches the currently visible range instead of always using the // pagination-model state. const addGetRowsParams = React.useCallback(params => { if (!lazyLoadingRowsUpdateStrategyActive) { return params; } const renderContext = gridRenderContextSelector(privateApiRef); // On initial load the grid hasn't rendered yet — keep the defaults. if (renderContext.lastRowIndex === 0) { return params; } const paginationModel = gridPaginationModelSelector(privateApiRef); const adjustedParams = adjustRowParams({ start: renderContext.firstRowIndex, end: renderContext.lastRowIndex - 1 }, { pageSize: paginationModel.pageSize, rowCount: privateApiRef.current.state.pagination.rowCount }); return _extends({}, params, { start: adjustedParams.start, end: adjustedParams.end }); }, [privateApiRef, lazyLoadingRowsUpdateStrategyActive]); useGridRegisterPipeProcessor(privateApiRef, 'getRowsParams', addGetRowsParams); useGridRegisterStrategyProcessor(privateApiRef, DataSourceRowsUpdateStrategy.LazyLoading, 'dataSourceRowsUpdate', handleDataUpdate); useGridEvent(privateApiRef, 'strategyAvailabilityChange', handleStrategyActivityChange); useGridEvent(privateApiRef, 'rowCountChange', runIf(lazyLoadingRowsUpdateStrategyActive, handleRowCountChange)); useGridEvent(privateApiRef, 'rowsScrollEndIntersection', runIf(lazyLoadingRowsUpdateStrategyActive, handleIntersection)); useGridEvent(privateApiRef, 'renderedRowsIntervalChange', runIf(lazyLoadingRowsUpdateStrategyActive, throttledHandleRenderedRowsIntervalChange)); useGridEvent(privateApiRef, 'sortModelChange', runIf(lazyLoadingRowsUpdateStrategyActive, handleGridSortModelChange)); useGridEvent(privateApiRef, 'filterModelChange', runIf(lazyLoadingRowsUpdateStrategyActive, handleGridFilterModelChange)); useGridEvent(privateApiRef, 'rowDragStart', runIf(lazyLoadingRowsUpdateStrategyActive, handleDragStart)); useGridEvent(privateApiRef, 'rowDragEnd', runIf(lazyLoadingRowsUpdateStrategyActive, handleDragEnd)); React.useEffect(() => { setStrategyAvailability(); }, [setStrategyAvailability]); };