@mui/x-data-grid
Version:
The Community plan edition of the MUI X Data Grid components.
409 lines (405 loc) • 20 kB
JavaScript
'use client';
import _extends from "@babel/runtime/helpers/esm/extends";
import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
const _excluded = ["skipCache", "keepChildrenExpanded", "showChildrenLoading"];
import * as React from 'react';
import useLazyRef from '@mui/utils/useLazyRef';
import useEventCallback from '@mui/utils/useEventCallback';
import debounce from '@mui/utils/debounce';
import { warnOnce } from '@mui/x-internals/warning';
import { isDeepEqual } from '@mui/x-internals/isDeepEqual';
import { GRID_ROOT_GROUP_ID, getReplaceRow, isReplaceUpdate } from "../rows/gridRowsUtils.mjs";
import { runIf } from "../../../utils/utils.mjs";
import { GridStrategyGroup } from "../../core/strategyProcessing/index.mjs";
import { useGridSelector } from "../../utils/useGridSelector.mjs";
import { gridPaginationModelSelector, gridVisibleRowsSelector } from "../pagination/gridPaginationSelector.mjs";
import { gridRowTreeSelector } from "../rows/gridRowsSelector.mjs";
import { gridColumnLookupSelector } from "../columns/index.mjs";
import { removeIncompleteFilterItems } from "../filter/gridFilterUtils.mjs";
import { gridGetRowsParamsSelector } from "./gridDataSourceSelector.mjs";
import { useGridDataSourceFilterModelChange } from "./useGridDataSourceFilterModelChange.mjs";
import { CacheChunkManager, DataSourceRowsUpdateStrategy } from "./utils.mjs";
import { GridDataSourceCacheDefault } from "./cache.mjs";
import { GridGetRowsError, GridUpdateRowError } from "./gridDataSourceError.mjs";
const noopCache = {
clear: () => {},
get: () => undefined,
set: () => {}
};
function getCache(cacheProp, options = {}) {
if (cacheProp === null) {
return noopCache;
}
return cacheProp ?? new GridDataSourceCacheDefault(options);
}
export const useGridDataSourceBase = (apiRef, props, options = {}) => {
const setStrategyAvailability = React.useCallback(() => {
apiRef.current.setStrategyAvailability(GridStrategyGroup.DataSource, DataSourceRowsUpdateStrategy.Default, props.dataSource ? () => true : () => false);
}, [apiRef, props.dataSource]);
const [currentStrategy, setCurrentStrategy] = React.useState(apiRef.current.getActiveStrategy(GridStrategyGroup.DataSource));
const standardRowsUpdateStrategyActive = React.useMemo(() => {
return currentStrategy === DataSourceRowsUpdateStrategy.Default || currentStrategy === DataSourceRowsUpdateStrategy.GroupedData;
}, [currentStrategy]);
const paginationModel = useGridSelector(apiRef, gridPaginationModelSelector);
const lastRequestId = React.useRef(0);
// `false` while a request is in flight, and for any request that errors or is discarded as
// stale. Lets the mount effect tell "rows are already displayed" from "the fetch never landed".
const rowsAreUpToDate = React.useRef(false);
// Requests that are still running and will apply their response when they settle.
const pendingRequestCount = React.useRef(0);
const pollingIntervalRef = React.useRef(null);
const onDataSourceErrorProp = props.onDataSourceError;
const revalidateMs = props.dataSourceRevalidateMs;
const cacheChunkManager = useLazyRef(() => {
if (!props.pagination) {
return new CacheChunkManager(paginationModel.pageSize);
}
const sortedPageSizeOptions = props.pageSizeOptions.map(option => typeof option === 'number' ? option : option.value).sort((a, b) => a - b);
const cacheChunkSize = Math.min(paginationModel.pageSize, sortedPageSizeOptions[0]);
return new CacheChunkManager(cacheChunkSize);
}).current;
const [cache, setCache] = React.useState(() => getCache(props.dataSourceCache, options.cacheOptions));
const fetchRows = React.useCallback(async (parentId, params) => {
const getRows = props.dataSource?.getRows;
if (!getRows) {
return;
}
const _ref = params || {},
{
skipCache,
keepChildrenExpanded,
showChildrenLoading
} = _ref,
getRowsParams = _objectWithoutPropertiesLoose(_ref, _excluded);
const fetchParams = _extends({}, gridGetRowsParamsSelector(apiRef), apiRef.current.unstable_applyPipeProcessors('getRowsParams', {}), getRowsParams);
// The selector prunes the incomplete items, but a caller passing its own `filterModel`
// overrides it, so prune again on the merged params.
fetchParams.filterModel = removeIncompleteFilterItems(fetchParams.filterModel, gridColumnLookupSelector(apiRef));
if (parentId && parentId !== GRID_ROOT_GROUP_ID && props.signature !== 'DataGrid') {
options.fetchRowChildren?.([parentId], [fetchParams], showChildrenLoading);
return;
}
options.clearDataSourceState?.();
const cacheKeys = cacheChunkManager.getCacheKeys(fetchParams);
const responses = cacheKeys.map(cacheKey => cache.get(cacheKey));
if (!skipCache && responses.every(response => response !== undefined)) {
// Bump the request id so any cache-miss request still in flight is treated as
// stale and won't override the cached data we're about to apply.
lastRequestId.current += 1;
apiRef.current.applyStrategyProcessor('dataSourceRootRowsUpdate', {
response: CacheChunkManager.mergeResponses(responses),
fetchParams,
options: {
skipCache,
keepChildrenExpanded
}
});
rowsAreUpToDate.current = true;
if (standardRowsUpdateStrategyActive) {
apiRef.current.setLoading(false);
}
return;
}
// Manage loading state only for the default strategy
if (standardRowsUpdateStrategyActive || apiRef.current.getRowsCount() === 0) {
apiRef.current.setLoading(true);
}
const requestId = lastRequestId.current + 1;
lastRequestId.current = requestId;
rowsAreUpToDate.current = false;
pendingRequestCount.current += 1;
try {
const getRowsResponse = await getRows(fetchParams);
const cacheResponses = cacheChunkManager.splitResponse(fetchParams, getRowsResponse);
cacheResponses.forEach((response, key) => cache.set(key, response));
if (lastRequestId.current === requestId) {
apiRef.current.applyStrategyProcessor('dataSourceRootRowsUpdate', {
response: getRowsResponse,
fetchParams,
options: {
skipCache,
keepChildrenExpanded
}
});
rowsAreUpToDate.current = true;
}
} catch (originalError) {
if (lastRequestId.current === requestId) {
apiRef.current.applyStrategyProcessor('dataSourceRootRowsUpdate', {
error: originalError,
fetchParams,
options: {
skipCache,
keepChildrenExpanded
}
});
if (typeof onDataSourceErrorProp === 'function') {
onDataSourceErrorProp(new GridGetRowsError({
message: originalError?.message,
params: fetchParams,
cause: originalError
}));
} else if (process.env.NODE_ENV !== 'production') {
warnOnce(['MUI X: A call to `dataSource.getRows()` threw an error which was not handled because `onDataSourceError()` is missing.', 'To handle the error pass a callback to the `onDataSourceError` prop, for example `<DataGrid onDataSourceError={(error) => ...} />`.', 'For more detail, see https://mui.com/x/react-data-grid/server-side-data/#error-handling.'], 'error');
}
}
} finally {
pendingRequestCount.current -= 1;
if (standardRowsUpdateStrategyActive && lastRequestId.current === requestId) {
apiRef.current.setLoading(false);
}
}
}, [cacheChunkManager, cache, apiRef, standardRowsUpdateStrategyActive, props.dataSource?.getRows, onDataSourceErrorProp, options, props.signature]);
const handleStrategyActivityChange = React.useCallback(() => {
setCurrentStrategy(apiRef.current.getActiveStrategy(GridStrategyGroup.DataSource));
}, [apiRef]);
const fetchRowChildrenOption = options.fetchRowChildren;
const revalidate = useEventCallback(async () => {
const getRows = props.dataSource?.getRows;
if (!getRows || !standardRowsUpdateStrategyActive) {
return;
}
const revalidateExpandedGroups = () => {
if (currentStrategy !== DataSourceRowsUpdateStrategy.GroupedData || !fetchRowChildrenOption) {
return;
}
const rowTree = gridRowTreeSelector(apiRef);
const visibleRows = gridVisibleRowsSelector(apiRef).rows;
const expandedGroupIds = visibleRows.reduce((acc, row) => {
const node = rowTree[row.id];
if (node.type === 'group' && node.id !== GRID_ROOT_GROUP_ID && node.childrenExpanded === true) {
acc.push(row.id);
}
return acc;
}, []);
if (expandedGroupIds.length > 0) {
fetchRowChildrenOption(expandedGroupIds, [], false);
}
};
const fetchParams = _extends({}, gridGetRowsParamsSelector(apiRef), apiRef.current.unstable_applyPipeProcessors('getRowsParams', {}));
const cacheKeys = cacheChunkManager.getCacheKeys(fetchParams);
const responses = cacheKeys.map(cacheKey => cache.get(cacheKey));
if (responses.every(response => response !== undefined)) {
revalidateExpandedGroups();
return;
}
try {
const response = await getRows(fetchParams);
const currentParams = _extends({}, gridGetRowsParamsSelector(apiRef), apiRef.current.unstable_applyPipeProcessors('getRowsParams', {}));
if (!isDeepEqual(fetchParams, currentParams)) {
return;
}
const cacheResponses = cacheChunkManager.splitResponse(fetchParams, response);
cacheResponses.forEach((cacheResponse, key) => cache.set(key, cacheResponse));
apiRef.current.applyStrategyProcessor('dataSourceRootRowsUpdate', {
response,
fetchParams,
options: {}
});
revalidateExpandedGroups();
} catch {
// Ignore background revalidation errors.
}
});
const stopPolling = React.useCallback(() => {
if (pollingIntervalRef.current !== null) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}, []);
const startPolling = useEventCallback(() => {
stopPolling();
if (revalidateMs <= 0 || !standardRowsUpdateStrategyActive) {
return;
}
pollingIntervalRef.current = setInterval(revalidate, revalidateMs);
});
const handleDataUpdate = React.useCallback(params => {
if ('error' in params) {
// Reset the rows on error, even with `dataSourceKeepPreviousData`. The previous
// rows belong to the previous query while the pagination/sorting/filtering controls
// already reflect the failed request, so keeping them would present stale data as if
// it satisfied the new query. This matches TanStack Query, where `keepPreviousData`
// clears the placeholder once the query settles with an error.
apiRef.current.setRows([]);
return;
}
const {
response
} = params;
if (response.rowCount !== undefined) {
apiRef.current.setRowCount(response.rowCount);
}
apiRef.current.setRows(response.rows);
apiRef.current.unstable_applyPipeProcessors('processDataSourceRows', {
params: params.fetchParams,
response
}, true);
startPolling();
}, [apiRef, startPolling]);
const dataSourceUpdateRow = props.dataSource?.updateRow;
const handleEditRowOption = options.handleEditRow;
const editRow = React.useCallback(async params => {
if (!dataSourceUpdateRow) {
return undefined;
}
try {
const finalRowUpdate = await dataSourceUpdateRow(params);
// `dataSource.updateRow()` can resolve with a `{ _action: 'replace', row }` update.
// The row update methods unwrap it themselves, everything else works with the row it
// holds: comparing the envelope with the previous row would never match.
const updatedRow = finalRowUpdate && isReplaceUpdate(finalRowUpdate) ? getReplaceRow(finalRowUpdate) : finalRowUpdate;
if (updatedRow && !isDeepEqual(updatedRow, params.previousRow)) {
// Reset the outdated cache, only if the row is _actually_ updated
apiRef.current.dataSource.cache.clear();
}
if (typeof handleEditRowOption === 'function') {
handleEditRowOption(params, finalRowUpdate);
} else {
apiRef.current.updateNestedRows([finalRowUpdate], []);
}
return updatedRow;
} catch (errorThrown) {
if (typeof onDataSourceErrorProp === 'function') {
onDataSourceErrorProp(new GridUpdateRowError({
message: errorThrown?.message,
params,
cause: errorThrown
}));
} else if (process.env.NODE_ENV !== 'production') {
warnOnce(['MUI X: A call to `dataSource.updateRow()` threw an error which was not handled because `onDataSourceError()` is missing.', 'To handle the error pass a callback to the `onDataSourceError` prop, for example `<DataGrid onDataSourceError={(error) => ...} />`.', 'For more detail, see https://mui.com/x/react-data-grid/server-side-data/#error-handling.'], 'error');
}
throw errorThrown; // Let the caller handle the error further
}
}, [apiRef, dataSourceUpdateRow, onDataSourceErrorProp, handleEditRowOption]);
const dataSourceApi = {
dataSource: {
fetchRows,
cache,
editRow
}
};
const debouncedFetchRows = React.useMemo(() => debounce(fetchRows, 0), [fetchRows]);
const handleFetchRowsOnParamsChange = React.useCallback(() => {
// `dataSourceKeepPreviousData` only applies to the flat `Default` strategy. For
// `GroupedData` (tree data / row grouping), skipping the synchronous `setRows([])`
// would leave the existing tree merged on top of the new response and render rows
// in stale sort order (https://github.com/mui/mui-x/pull/21619).
// This handler is only wired up when a standard strategy is active via the `runIf`
// guards on the returned `events` object.
const activeStrategy = apiRef.current.getActiveStrategy(GridStrategyGroup.DataSource);
const keepPreviousData = props.dataSourceKeepPreviousData && activeStrategy === DataSourceRowsUpdateStrategy.Default;
if (!keepPreviousData) {
// Clear the rows first and immediately mark the grid as loading so the overlay
// selector never observes the intermediate `rows=[] && loading=false` state that
// would otherwise pick `noRowsOverlay`. Order matters: `setRows([])` rebuilds
// `state.rows` from `props.loading`, so the `setLoading(true)` call must come after
// it to survive the rebuild.
apiRef.current.setRows([]);
}
apiRef.current.setLoading(true);
stopPolling();
debouncedFetchRows();
}, [apiRef, props.dataSourceKeepPreviousData, stopPolling, debouncedFetchRows]);
const hasFilterModelChanged = useGridDataSourceFilterModelChange(apiRef);
const handleFetchRowsOnFilterModelChange = React.useCallback(newFilterModel => {
if (!hasFilterModelChanged(newFilterModel)) {
return;
}
handleFetchRowsOnParamsChange();
}, [hasFilterModelChanged, handleFetchRowsOnParamsChange]);
const isFirstRender = React.useRef(true);
React.useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (props.dataSourceCache === undefined) {
return;
}
const newCache = getCache(props.dataSourceCache, options.cacheOptions);
setCache(prevCache => prevCache !== newCache ? newCache : prevCache);
}, [props.dataSourceCache, options.cacheOptions]);
React.useEffect(() => {
if (!standardRowsUpdateStrategyActive) {
stopPolling();
}
}, [standardRowsUpdateStrategyActive, stopPolling]);
React.useEffect(() => {
if (revalidateMs <= 0) {
stopPolling();
}
}, [revalidateMs, stopPolling]);
React.useEffect(() => stopPolling, [stopPolling]);
const lastApiRef = React.useRef(apiRef);
const lastStrategy = React.useRef(currentStrategy);
const lastDataSource = React.useRef(props.dataSource);
React.useEffect(() => {
// `<Activity mode="hidden">` leaves the root element in the document, a real unmount does not.
// Only discard the in-flight response in the latter case, so hiding lets the request land.
const rootElement = apiRef.current?.rootElementRef?.current ?? null;
const ignoreInFlightRequest = () => {
if (!rootElement?.isConnected) {
lastRequestId.current += 1;
}
};
// Return early if the proper strategy isn't set yet
// Context: https://github.com/mui/mui-x/issues/19650
if (currentStrategy !== DataSourceRowsUpdateStrategy.Default && currentStrategy !== DataSourceRowsUpdateStrategy.LazyLoading && currentStrategy !== DataSourceRowsUpdateStrategy.GroupedData && currentStrategy !== DataSourceRowsUpdateStrategy.LazyLoadedGroupedData) {
return undefined;
}
const dependenciesChanged = lastApiRef.current !== apiRef || lastStrategy.current !== currentStrategy || lastDataSource.current !== props.dataSource;
lastApiRef.current = apiRef;
lastStrategy.current = currentStrategy;
lastDataSource.current = props.dataSource;
// Re-mounting the effect (`<Activity />` becoming visible again, for instance) must not
// re-fetch data that is already displayed.
if (!dependenciesChanged && (rowsAreUpToDate.current || pendingRequestCount.current > 0)) {
return ignoreInFlightRequest;
}
if (props.dataSource) {
stopPolling();
// `dataSourceKeepPreviousData` only applies to the flat `Default` strategy (mirroring
// `handleFetchRowsOnParamsChange`). Keep the previous rows visible when the `dataSource`
// reference changes so the feature isn't silently defeated for a non-memoized
// `dataSource`. Other strategies must still reset the rows to keep their order consistent
// with the new response (https://github.com/mui/mui-x/pull/21619).
if (!props.dataSourceKeepPreviousData || currentStrategy !== DataSourceRowsUpdateStrategy.Default) {
apiRef.current.setRows([]);
}
apiRef.current.dataSource.cache.clear();
apiRef.current.dataSource.fetchRows();
}
return ignoreInFlightRequest;
}, [apiRef, props.dataSource, props.dataSourceKeepPreviousData, currentStrategy, stopPolling]);
React.useEffect(() => {
// `dataSourceKeepPreviousData` is a no-op for tree data and row grouping: those
// strategies always reset the rows on refetch to keep their order consistent with the
// response. Warn so the limitation is discoverable at runtime, not only in the docs.
if (process.env.NODE_ENV !== 'production' && props.dataSourceKeepPreviousData && (currentStrategy === DataSourceRowsUpdateStrategy.GroupedData || currentStrategy === DataSourceRowsUpdateStrategy.LazyLoadedGroupedData)) {
warnOnce(['MUI X: The `dataSourceKeepPreviousData` prop only applies to flat data.', 'It is ignored when tree data or row grouping is enabled, because the rows are always reset on refetch to keep their order consistent with the response.', 'For more details, see https://mui.com/x/react-data-grid/server-side-data/#keep-previous-data-while-fetching.']);
}
}, [props.dataSourceKeepPreviousData, currentStrategy]);
return {
api: {
public: dataSourceApi
},
debouncedFetchRows,
strategyProcessor: {
strategyName: DataSourceRowsUpdateStrategy.Default,
group: 'dataSourceRootRowsUpdate',
processor: handleDataUpdate
},
setStrategyAvailability,
startPolling,
stopPolling,
cacheChunkManager,
cache,
events: {
strategyAvailabilityChange: handleStrategyActivityChange,
sortModelChange: runIf(standardRowsUpdateStrategyActive, handleFetchRowsOnParamsChange),
filterModelChange: runIf(standardRowsUpdateStrategyActive, handleFetchRowsOnFilterModelChange),
paginationModelChange: runIf(standardRowsUpdateStrategyActive, handleFetchRowsOnParamsChange)
}
};
};