From 8380eaed4f6d9d8804b772a1e255830dac96616e Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Wed, 14 Jun 2023 07:26:36 -0400 Subject: [PATCH] [DataGrid] Scroll performance improvements (#9037) Signed-off-by: Rom Grk Co-authored-by: Andrew Cherniavskii Co-authored-by: Matheus Wichman --- .../data-grid/overview/DataGridProDemo.js | 10 +- .../data-grid/overview/DataGridProDemo.tsx | 10 +- .../overview/DataGridProDemo.tsx.preview | 4 - ...dWithReactMemo.js => GridVisualization.js} | 24 +- ...ithReactMemo.tsx => GridVisualization.tsx} | 24 +- ....preview => GridVisualization.tsx.preview} | 5 +- .../data/data-grid/performance/performance.md | 68 +- docs/data/data-grid/state/state.md | 3 +- package.json | 1 + .../src/tests/rowEditing.DataGridPro.test.tsx | 2 +- .../src/tests/rows.DataGridPro.test.tsx | 7 +- .../src/components/GridColumnHeaders.tsx | 5 +- .../x-data-grid/src/components/GridRow.tsx | 189 ++---- .../src/components/cell/GridCell.tsx | 598 ++++++++++++++++-- .../x-data-grid/src/components/cell/index.ts | 3 +- .../menu/columnMenu/GridColumnHeaderMenu.tsx | 2 +- .../virtualization/GridVirtualScroller.tsx | 5 +- .../GridVirtualScrollerContent.tsx | 21 +- .../constants/defaultGridSlotsComponents.ts | 4 +- .../hooks/core/useGridApiInitialization.ts | 6 +- .../hooks/core/useGridStateInitialization.ts | 9 +- .../columnHeaders/useGridColumnHeaders.tsx | 23 +- .../src/hooks/features/focus/useGridFocus.ts | 37 +- .../useGridKeyboardNavigation.ts | 13 +- .../hooks/features/rows/useGridParamsApi.ts | 26 +- .../virtualization/useGridVirtualScroller.tsx | 69 +- .../grid/x-data-grid/src/hooks/utils/index.ts | 2 +- .../src/hooks/utils/useGridSelector.ts | 60 +- .../x-data-grid/src/hooks/utils/useLazyRef.ts | 13 + .../x-data-grid/src/hooks/utils/useOnMount.ts | 9 + .../x-data-grid/src/models/api/gridCoreApi.ts | 6 + .../src/tests/keyboard.DataGrid.test.tsx | 6 +- .../src/tests/rowSelection.DataGrid.test.tsx | 13 +- packages/grid/x-data-grid/src/utils/Store.ts | 32 + .../src/utils/doesSupportPreventScroll.ts | 13 + .../grid/x-data-grid/src/utils/fastMemo.ts | 6 + .../src/utils/fastObjectShallowCompare.ts | 35 + scripts/x-data-grid-premium.exports.json | 2 +- scripts/x-data-grid-pro.exports.json | 2 +- scripts/x-data-grid.exports.json | 2 +- 40 files changed, 958 insertions(+), 411 deletions(-) rename docs/data/data-grid/performance/{GridWithReactMemo.js => GridVisualization.js} (68%) rename docs/data/data-grid/performance/{GridWithReactMemo.tsx => GridVisualization.tsx} (68%) rename docs/data/data-grid/performance/{GridWithReactMemo.tsx.preview => GridVisualization.tsx.preview} (53%) create mode 100644 packages/grid/x-data-grid/src/hooks/utils/useLazyRef.ts create mode 100644 packages/grid/x-data-grid/src/hooks/utils/useOnMount.ts create mode 100644 packages/grid/x-data-grid/src/utils/Store.ts create mode 100644 packages/grid/x-data-grid/src/utils/doesSupportPreventScroll.ts create mode 100644 packages/grid/x-data-grid/src/utils/fastMemo.ts create mode 100644 packages/grid/x-data-grid/src/utils/fastObjectShallowCompare.ts diff --git a/docs/data/data-grid/overview/DataGridProDemo.js b/docs/data/data-grid/overview/DataGridProDemo.js index 3e0a22b660475..10cc6a348fe17 100644 --- a/docs/data/data-grid/overview/DataGridProDemo.js +++ b/docs/data/data-grid/overview/DataGridProDemo.js @@ -1,12 +1,8 @@ import * as React from 'react'; import Box from '@mui/material/Box'; -import { DataGridPro, GridRow, GridColumnHeaders } from '@mui/x-data-grid-pro'; +import { DataGridPro } from '@mui/x-data-grid-pro'; import { useDemoData } from '@mui/x-data-grid-generator'; -const MemoizedRow = React.memo(GridRow); - -const MemoizedColumnHeaders = React.memo(GridColumnHeaders); - export default function DataGridProDemo() { const { data } = useDemoData({ dataSet: 'Commodity', @@ -22,10 +18,6 @@ export default function DataGridProDemo() { rowHeight={38} checkboxSelection disableRowSelectionOnClick - components={{ - Row: MemoizedRow, - ColumnHeaders: MemoizedColumnHeaders, - }} /> ); diff --git a/docs/data/data-grid/overview/DataGridProDemo.tsx b/docs/data/data-grid/overview/DataGridProDemo.tsx index 3e0a22b660475..10cc6a348fe17 100644 --- a/docs/data/data-grid/overview/DataGridProDemo.tsx +++ b/docs/data/data-grid/overview/DataGridProDemo.tsx @@ -1,12 +1,8 @@ import * as React from 'react'; import Box from '@mui/material/Box'; -import { DataGridPro, GridRow, GridColumnHeaders } from '@mui/x-data-grid-pro'; +import { DataGridPro } from '@mui/x-data-grid-pro'; import { useDemoData } from '@mui/x-data-grid-generator'; -const MemoizedRow = React.memo(GridRow); - -const MemoizedColumnHeaders = React.memo(GridColumnHeaders); - export default function DataGridProDemo() { const { data } = useDemoData({ dataSet: 'Commodity', @@ -22,10 +18,6 @@ export default function DataGridProDemo() { rowHeight={38} checkboxSelection disableRowSelectionOnClick - components={{ - Row: MemoizedRow, - ColumnHeaders: MemoizedColumnHeaders, - }} /> ); diff --git a/docs/data/data-grid/overview/DataGridProDemo.tsx.preview b/docs/data/data-grid/overview/DataGridProDemo.tsx.preview index 272b35f8a747b..1f8c5e44257e8 100644 --- a/docs/data/data-grid/overview/DataGridProDemo.tsx.preview +++ b/docs/data/data-grid/overview/DataGridProDemo.tsx.preview @@ -4,8 +4,4 @@ rowHeight={38} checkboxSelection disableRowSelectionOnClick - components={{ - Row: MemoizedRow, - ColumnHeaders: MemoizedColumnHeaders, - }} /> \ No newline at end of file diff --git a/docs/data/data-grid/performance/GridWithReactMemo.js b/docs/data/data-grid/performance/GridVisualization.js similarity index 68% rename from docs/data/data-grid/performance/GridWithReactMemo.js rename to docs/data/data-grid/performance/GridVisualization.js index 1a4c6650f32e4..b87db7d566d4d 100644 --- a/docs/data/data-grid/performance/GridWithReactMemo.js +++ b/docs/data/data-grid/performance/GridVisualization.js @@ -1,7 +1,7 @@ import * as React from 'react'; import Box from '@mui/material/Box'; import { unstable_useForkRef as useForkRef } from '@mui/utils'; -import { DataGridPro, GridRow, GridColumnHeaders } from '@mui/x-data-grid-pro'; +import { DataGridPro, GridCell } from '@mui/x-data-grid-pro'; import { useDemoData } from '@mui/x-data-grid-generator'; const TraceUpdates = React.forwardRef((props, ref) => { @@ -26,18 +26,15 @@ const TraceUpdates = React.forwardRef((props, ref) => { return ; }); -const RowWithTracer = React.forwardRef((props, ref) => { - return ; +const CellWithTracer = React.forwardRef((props, ref) => { + return ; }); -const ColumnHeadersWithTracer = React.forwardRef((props, ref) => { - return ; -}); - -const MemoizedRow = React.memo(RowWithTracer); -const MemoizedColumnHeaders = React.memo(ColumnHeadersWithTracer); +const slots = { + cell: CellWithTracer, +}; -export default function GridWithReactMemo() { +export default function GridVisualization() { const { data } = useDemoData({ dataSet: 'Commodity', rowLength: 100, @@ -57,7 +54,7 @@ export default function GridWithReactMemo() { }), }, '&&& .updating': { - backgroundColor: 'rgb(92 199 68 / 25%)', + backgroundColor: 'rgb(92 199 68 / 20%)', outline: '1px solid rgb(92 199 68 / 35%)', outlineOffset: '-1px', transition: 'none', @@ -69,10 +66,7 @@ export default function GridWithReactMemo() { rowHeight={38} checkboxSelection disableRowSelectionOnClick - slots={{ - row: MemoizedRow, - columnHeaders: MemoizedColumnHeaders, - }} + slots={slots} /> ); diff --git a/docs/data/data-grid/performance/GridWithReactMemo.tsx b/docs/data/data-grid/performance/GridVisualization.tsx similarity index 68% rename from docs/data/data-grid/performance/GridWithReactMemo.tsx rename to docs/data/data-grid/performance/GridVisualization.tsx index db1a5ea400bb1..2d62ee4dada1f 100644 --- a/docs/data/data-grid/performance/GridWithReactMemo.tsx +++ b/docs/data/data-grid/performance/GridVisualization.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import Box from '@mui/material/Box'; import { unstable_useForkRef as useForkRef } from '@mui/utils'; -import { DataGridPro, GridRow, GridColumnHeaders } from '@mui/x-data-grid-pro'; +import { DataGridPro, GridCell } from '@mui/x-data-grid-pro'; import { useDemoData } from '@mui/x-data-grid-generator'; const TraceUpdates = React.forwardRef((props, ref) => { @@ -26,18 +26,15 @@ const TraceUpdates = React.forwardRef((props, ref) => { return ; }); -const RowWithTracer = React.forwardRef((props, ref) => { - return ; +const CellWithTracer = React.forwardRef((props, ref) => { + return ; }); -const ColumnHeadersWithTracer = React.forwardRef((props, ref) => { - return ; -}); - -const MemoizedRow = React.memo(RowWithTracer); -const MemoizedColumnHeaders = React.memo(ColumnHeadersWithTracer); +const slots = { + cell: CellWithTracer, +}; -export default function GridWithReactMemo() { +export default function GridVisualization() { const { data } = useDemoData({ dataSet: 'Commodity', rowLength: 100, @@ -57,7 +54,7 @@ export default function GridWithReactMemo() { }), }, '&&& .updating': { - backgroundColor: 'rgb(92 199 68 / 25%)', + backgroundColor: 'rgb(92 199 68 / 20%)', outline: '1px solid rgb(92 199 68 / 35%)', outlineOffset: '-1px', transition: 'none', @@ -69,10 +66,7 @@ export default function GridWithReactMemo() { rowHeight={38} checkboxSelection disableRowSelectionOnClick - slots={{ - row: MemoizedRow, - columnHeaders: MemoizedColumnHeaders, - }} + slots={slots} /> ); diff --git a/docs/data/data-grid/performance/GridWithReactMemo.tsx.preview b/docs/data/data-grid/performance/GridVisualization.tsx.preview similarity index 53% rename from docs/data/data-grid/performance/GridWithReactMemo.tsx.preview rename to docs/data/data-grid/performance/GridVisualization.tsx.preview index f1bd1aea7658a..beb57bed0de15 100644 --- a/docs/data/data-grid/performance/GridWithReactMemo.tsx.preview +++ b/docs/data/data-grid/performance/GridVisualization.tsx.preview @@ -3,8 +3,5 @@ rowHeight={38} checkboxSelection disableRowSelectionOnClick - slots={{ - row: MemoizedRow, - columnHeaders: MemoizedColumnHeaders, - }} + slots={slots} /> \ No newline at end of file diff --git a/docs/data/data-grid/performance/performance.md b/docs/data/data-grid/performance/performance.md index a26c29d8d89d4..1eaa98dcf03e5 100644 --- a/docs/data/data-grid/performance/performance.md +++ b/docs/data/data-grid/performance/performance.md @@ -2,43 +2,51 @@

Improve the performance of the DataGrid using the recommendations from this guide.

-## Memoize inner components with `React.memo` +## Extract static objects and memoize root props -The `DataGrid` component is composed of a central state object where all data is stored. -When an API method is called, a prop changes, or the user interacts with the UI (e.g. filtering a column), this state object is updated with the changes made. -To reflect the changes in the interface, the component must re-render. -Since the state behaves like `React.useState`, the `DataGrid` component will re-render its children, including column headers, rows, and cells. -With smaller datasets, this is not a problem for concern, but it can become a bottleneck if the number of rows increases, especially if many columns render [custom content](/x/react-data-grid/column-definition/#rendering-cells). -One way to overcome this issue is using `React.memo` to only re-render the child components when their props have changed. -To start using memoization, import the inner components, then pass their memoized version to the respective slots, as follow: +The `DataGrid` component uses `React.memo` to optimize its performance, which means itself and its subcomponents only +re-render when their props change. But it's very easy to cause unnecessary re-renders if the root props of your +`DataGrid` aren't memoized. Take the example below, the `slots` and `initialState` objects are re-created on every +render, which means the `DataGrid` itself has no choice but to re-render as well. ```tsx -import { - GridRow, - GridColumnHeaders, - DataGrid, // or DataGridPro, DataGridPremium -} from '@mui/x-data-grid'; - -const MemoizedRow = React.memo(GridRow); -const MemoizedColumnHeaders = React.memo(GridColumnHeaders); - -; +function Component({ rows }) { + return ( + + ); +} ``` -The following demo show this trick in action. -It also contains additional logic to highlight the components when they re-render. +An easy way to prevent re-renders is to extract any object that can be a static object, and to memoize any object that +depends on another object. This applies to any prop that is an object or a function. -{{"demo": "GridWithReactMemo.js", "bg": "inline", "defaultCodeOpen": false}} +```tsx +const slots = { + row: CustomRow, +}; + +function Component({ rows }) { + const cellModesModel = React.useMemo( + () => ({ [rows[0].id]: { name: { mode: GridCellModes.Edit } } }), + [rows], + ); + + return ; +} +``` + +## Visualization + +The DataGrid memoizes some of its subcomponents to avoid re-rendering more than needed. Below is a visualization that +shows you which cells re-render in reaction to your interaction with the grid. -:::warning -We do not ship the components above already wrapped with `React.memo` because if you have rows whose cells display custom content not derived from the received props, e.g. selectors, these cells may display outdated information. -If you define a column with a custom cell renderer where content comes from a [selector](/x/react-data-grid/state/#catalog-of-selectors) that changes more often than the props passed to `GridRow`, the row component should not be memoized. -::: +{{"demo": "GridVisualization.js", "bg": "inline", "defaultCodeOpen": false}} ## API diff --git a/docs/data/data-grid/state/state.md b/docs/data/data-grid/state/state.md index dc3501325c658..fc6d84fb87cd1 100644 --- a/docs/data/data-grid/state/state.md +++ b/docs/data/data-grid/state/state.md @@ -54,7 +54,8 @@ const paginationModel = gridPaginationModelSelector( ### With useGridSelector -If you only need to access the state value in the render of your components, use the `useGridSelector` hook: +If you only need to access the state value in the render of your components, use the `useGridSelector` hook. +This hook ensures there is a reactive binding such that when the state changes, the component in which this hook is used is re-rendered. ```tsx const paginationModel = useGridSelector(apiRef, gridPaginationModelSelector); diff --git a/package.json b/package.json index ae3f0e572f0a1..d403cad6d8609 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "l10n": "babel-node -x .ts ./scripts/l10n.ts", "jsonlint": "node ./scripts/jsonlint.mjs", "eslint": "eslint . --cache --report-unused-disable-directives --ext .js,.ts,.tsx --max-warnings 0", + "eslint:fix": "yarn eslint --fix", "eslint:ci": "eslint . --report-unused-disable-directives --ext .js,.ts,.tsx --max-warnings 0", "markdownlint": "markdownlint-cli2 \"**/*.md\"", "postinstall": "patch-package", diff --git a/packages/grid/x-data-grid-pro/src/tests/rowEditing.DataGridPro.test.tsx b/packages/grid/x-data-grid-pro/src/tests/rowEditing.DataGridPro.test.tsx index 16abc7fa74224..cd7361ff0898f 100644 --- a/packages/grid/x-data-grid-pro/src/tests/rowEditing.DataGridPro.test.tsx +++ b/packages/grid/x-data-grid-pro/src/tests/rowEditing.DataGridPro.test.tsx @@ -168,7 +168,7 @@ describe(' - Row Editing', () => { apiRef.current.setEditCellValue({ id: 0, field: 'currencyPair', value: ' usdgbp ' }), ); await act(() => apiRef.current.setEditCellValue({ id: 0, field: 'price1M', value: 100 })); - expect(renderEditCell1.lastCall.args[0].row).to.deep.equal({ + expect(renderEditCell2.lastCall.args[0].row).to.deep.equal({ ...defaultData.rows[0], currencyPair: 'usdgbp', price1M: 100, diff --git a/packages/grid/x-data-grid-pro/src/tests/rows.DataGridPro.test.tsx b/packages/grid/x-data-grid-pro/src/tests/rows.DataGridPro.test.tsx index b3cf08ab69b37..2791f94b63071 100644 --- a/packages/grid/x-data-grid-pro/src/tests/rows.DataGridPro.test.tsx +++ b/packages/grid/x-data-grid-pro/src/tests/rows.DataGridPro.test.tsx @@ -300,15 +300,12 @@ describe(' - Rows', () => { ); } - // For some reason the number of renders in test env is 2x the number of renders in the browser - const renrederMultiplier = 2; - render(); const initialRendersCount = 2; - expect(renderCellSpy.callCount).to.equal(initialRendersCount * renrederMultiplier); + expect(renderCellSpy.callCount).to.equal(initialRendersCount); act(() => apiRef.current.updateRows([{ id: 1, name: 'John' }])); - expect(renderCellSpy.callCount).to.equal((initialRendersCount + 2) * renrederMultiplier); + expect(renderCellSpy.callCount).to.equal(initialRendersCount + 2); }); }); diff --git a/packages/grid/x-data-grid/src/components/GridColumnHeaders.tsx b/packages/grid/x-data-grid/src/components/GridColumnHeaders.tsx index 2ab53c7bebf0b..917099255831a 100644 --- a/packages/grid/x-data-grid/src/components/GridColumnHeaders.tsx +++ b/packages/grid/x-data-grid/src/components/GridColumnHeaders.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import PropTypes from 'prop-types'; +import { fastMemo } from '../utils/fastMemo'; import { useGridColumnHeaders, UseGridColumnHeadersProps, @@ -113,4 +114,6 @@ GridColumnHeaders.propTypes = { visibleColumns: PropTypes.arrayOf(PropTypes.object).isRequired, } as any; -export { GridColumnHeaders }; +const MemoizedGridColumnHeaders = fastMemo(GridColumnHeaders); + +export { MemoizedGridColumnHeaders as GridColumnHeaders }; diff --git a/packages/grid/x-data-grid/src/components/GridRow.tsx b/packages/grid/x-data-grid/src/components/GridRow.tsx index 066f0421356a0..125bb597a189c 100644 --- a/packages/grid/x-data-grid/src/components/GridRow.tsx +++ b/packages/grid/x-data-grid/src/components/GridRow.tsx @@ -5,8 +5,9 @@ import { unstable_composeClasses as composeClasses, unstable_useForkRef as useForkRef, } from '@mui/utils'; +import { fastMemo } from '../utils/fastMemo'; import { GridRowEventLookup } from '../models/events'; -import { GridRowId, GridRowModel, GridTreeNodeWithRender } from '../models/gridRows'; +import { GridRowId, GridRowModel } from '../models/gridRows'; import { GridEditModes, GridRowModes, GridCellModes } from '../models/gridEditRowModel'; import { useGridApiContext } from '../hooks/utils/useGridApiContext'; import { getDataGridUtilityClass, gridClasses } from '../constants/gridClasses'; @@ -14,18 +15,18 @@ import { useGridRootProps } from '../hooks/utils/useGridRootProps'; import type { DataGridProcessedProps } from '../models/props/DataGridProps'; import { GridStateColDef } from '../models/colDef/gridColDef'; import { gridColumnsTotalWidthSelector } from '../hooks/features/columns/gridColumnsSelector'; -import { useGridSelector } from '../hooks/utils/useGridSelector'; +import { useGridSelector, objectShallowCompare } from '../hooks/utils/useGridSelector'; import { GridRowClassNameParams } from '../models/params/gridRowParams'; import { useGridVisibleRows } from '../hooks/utils/useGridVisibleRows'; import { findParentElementFromClassName } from '../utils/domUtils'; import { GRID_CHECKBOX_SELECTION_COL_DEF } from '../colDef/gridCheckboxSelectionColDef'; import { GRID_ACTIONS_COLUMN_TYPE } from '../colDef/gridActionsColDef'; -import { GridRenderEditCellParams } from '../models/params/gridCellParams'; import { GRID_DETAIL_PANEL_TOGGLE_FIELD } from '../constants/gridDetailPanelToggleField'; import { gridSortModelSelector } from '../hooks/features/sorting/gridSortingSelector'; import { gridRowMaximumTreeDepthSelector } from '../hooks/features/rows/gridRowsSelector'; import { gridColumnGroupsHeaderMaxDepthSelector } from '../hooks/features/columnGrouping/gridColumnGroupsSelector'; import { randomNumberBetween } from '../utils/utils'; +import { GridCellWrapper, GridCellV7 } from './cell/GridCell'; import type { GridCellProps } from './cell/GridCell'; import { gridEditRowsStateSelector } from '../hooks/features/editing/gridEditingSelectors'; @@ -256,141 +257,51 @@ const GridRow = React.forwardRef(function GridRow( [apiRef, onClick, publish, rowId], ); - const { - slots, - slotProps, - classes: rootClasses, - disableColumnReorder, - getCellClassName, - } = rootProps; + const { slots, slotProps, disableColumnReorder } = rootProps; + const CellComponent = slots.cell === GridCellV7 ? GridCellV7 : GridCellWrapper; const rowReordering = (rootProps as any).rowReordering as boolean; - const CellComponent = slots.cell; - - const getCell = React.useCallback( - ( - column: GridStateColDef, - cellProps: Pick< - GridCellProps, - 'width' | 'colSpan' | 'showRightBorder' | 'indexRelativeToAllColumns' - >, - ) => { - const cellParams = apiRef.current.getCellParams( - rowId, - column.field, - ); - - const classNames = apiRef.current.unstable_applyPipeProcessors('cellClassName', [], { - id: rowId, - field: column.field, - }); - - const disableDragEvents = - (disableColumnReorder && column.disableReorder) || - (!rowReordering && - !!sortModel.length && - treeDepth > 1 && - Object.keys(editRowsState).length > 0); - - if (column.cellClassName) { - classNames.push( - clsx( - typeof column.cellClassName === 'function' - ? column.cellClassName(cellParams) - : column.cellClassName, - ), - ); - } - - const editCellState = editRowsState[rowId] ? editRowsState[rowId][column.field] : null; - let content: React.ReactNode; - - if (editCellState == null && column.renderCell) { - content = column.renderCell({ ...cellParams, api: apiRef.current }); - // TODO move to GridCell - classNames.push( - clsx(gridClasses['cell--withRenderer'], rootClasses?.['cell--withRenderer']), - ); - } - - if (editCellState != null && column.renderEditCell) { - const updatedRow = apiRef.current.getRowWithUpdatedValues(rowId, column.field); - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { changeReason, unstable_updateValueOnRender, ...editCellStateRest } = editCellState; - - const params: GridRenderEditCellParams = { - ...cellParams, - row: updatedRow, - ...editCellStateRest, - api: apiRef.current, - }; - - content = column.renderEditCell(params); - // TODO move to GridCell - classNames.push(clsx(gridClasses['cell--editing'], rootClasses?.['cell--editing'])); - } - - if (getCellClassName) { - // TODO move to GridCell - classNames.push(getCellClassName(cellParams)); - } + const getCell = ( + column: GridStateColDef, + cellProps: Pick< + GridCellProps, + 'width' | 'colSpan' | 'showRightBorder' | 'indexRelativeToAllColumns' + >, + ) => { + const disableDragEvents = + (disableColumnReorder && column.disableReorder) || + (!rowReordering && + !!sortModel.length && + treeDepth > 1 && + Object.keys(editRowsState).length > 0); + + const editCellState = editRowsState[rowId]?.[column.field] ?? null; + + return ( + + ); + }; - const hasFocus = focusedCell === column.field; - const tabIndex = tabbableCell === column.field ? 0 : -1; - - const isSelected = apiRef.current.unstable_applyPipeProcessors('isCellSelected', false, { - id: rowId, - field: column.field, - }); - - return ( - - {content} - - ); - }, - [ - apiRef, - rowId, - disableColumnReorder, - rowReordering, - sortModel.length, - treeDepth, - editRowsState, - getCellClassName, - focusedCell, - tabbableCell, - CellComponent, - rowHeight, - slotProps?.cell, - rootClasses, - ], + const sizes = useGridSelector( + apiRef, + () => ({ ...apiRef.current.unstable_getRowInternalSizes(rowId) }), + objectShallowCompare, ); - const sizes = apiRef.current.unstable_getRowInternalSizes(rowId); - let minHeight = rowHeight; if (minHeight === 'auto' && sizes) { let numberOfBaseSizes = 0; @@ -448,7 +359,13 @@ const GridRow = React.forwardRef(function GridRow( } const randomNumber = randomNumberBetween(10000, 20, 80); - const rowType = apiRef.current.getRowNode(rowId)!.type; + + const rowNode = apiRef.current.getRowNode(rowId); + if (!rowNode) { + return null; + } + + const rowType = rowNode.type; const cells: JSX.Element[] = []; for (let i = 0; i < renderedColumns.length; i += 1) { @@ -475,7 +392,7 @@ const GridRow = React.forwardRef(function GridRow( const contentWidth = Math.round(randomNumber()); cells.push( - { +type GridCellV7Props = { align: GridAlignment; className?: string; colIndex: number; - field: string; + column: GridColDef; rowId: GridRowId; - formattedValue?: F; - hasFocus?: boolean; height: number | 'auto'; - isEditable?: boolean; - isSelected?: boolean; showRightBorder?: boolean; - value?: V; width: number; - cellMode?: GridCellMode; - children: React.ReactNode; - tabIndex: 0 | -1; colSpan?: number; disableDragEvents?: boolean; + editCellState: GridEditCellProps | null; onClick?: React.MouseEventHandler; onDoubleClick?: React.MouseEventHandler; onMouseDown?: React.MouseEventHandler; @@ -50,23 +54,53 @@ export interface GridCellProps { onDragEnter?: React.DragEventHandler; onDragOver?: React.DragEventHandler; [x: string]: any; -} - -// Based on https://stackoverflow.com/a/59518678 -let cachedSupportsPreventScroll: boolean; -function doesSupportPreventScroll(): boolean { - if (cachedSupportsPreventScroll === undefined) { - document.createElement('div').focus({ - get preventScroll() { - cachedSupportsPreventScroll = true; - return false; - }, - }); - } - return cachedSupportsPreventScroll; -} +}; + +type GridCellWrapperProps = GridCellV7Props; + +export type GridCellProps = GridCellWrapperProps & { + field: string; + formattedValue?: F; + hasFocus?: boolean; + isEditable?: boolean; + isSelected?: boolean; + value?: V; + cellMode?: GridCellMode; + children: React.ReactNode; + tabIndex: 0 | -1; +}; -type OwnerState = Pick & { +type CellParamsWithAPI = GridCellParams & { + api: GridApiCommunity; +}; +const EMPTY_CELL_PARAMS: CellParamsWithAPI = { + id: -1, + field: '__unset__', + row: {}, + rowNode: { + id: -1, + depth: 0, + type: 'leaf', + parent: -1, + groupingKey: null, + }, + colDef: { + type: 'string', + field: '__unset__', + computedWidth: 0, + }, + cellMode: GridCellModes.View, + hasFocus: false, + tabIndex: -1, + value: null, + formattedValue: '__unset__', + isEditable: false, + api: {} as any, +}; + +type OwnerState = Pick & { + isEditable?: boolean; + isSelected?: boolean; classes?: DataGridProcessedProps['classes']; }; @@ -90,12 +124,131 @@ const useUtilityClasses = (ownerState: OwnerState) => { let warnedOnce = false; +// GridCellWrapper is a compatibility layer for the V6 cell slot. If we can use the more efficient +// `GridCellV7`, we should. That component is a merge of `GridCellWrapper` and `GridCell`. +// TODO(v7): Remove the wrapper & cellV6 and use the cellV7 exclusively. +// TODO(v7): Removing the wrapper will break the docs performance visualization demo. +const GridCellWrapper = React.forwardRef((props, ref) => { + const { column, rowId, editCellState } = props; + + const apiRef = useGridApiContext(); + const rootProps = useGridRootProps(); + + const field = column.field; + + const cellParamsWithAPI = useGridSelector( + apiRef, + () => { + // This is required because `.getCellParams` tries to get the `state.rows.tree` entry + // associated with `rowId`/`fieldId`, but this selector runs after the state has been + // updated, while `rowId`/`fieldId` reference an entry in the old state. + try { + const cellParams = apiRef.current.getCellParams( + rowId, + field, + ); + + const result = cellParams as CellParamsWithAPI; + result.api = apiRef.current; + return result; + } catch (e) { + if (e instanceof MissingRowIdError) { + return EMPTY_CELL_PARAMS; + } + throw e; + } + }, + objectShallowCompare, + ); + + const isSelected = useGridSelector(apiRef, () => + apiRef.current.unstable_applyPipeProcessors('isCellSelected', false, { + id: rowId, + field, + }), + ); + + if (cellParamsWithAPI === EMPTY_CELL_PARAMS) { + return null; + } + + const { cellMode, hasFocus, isEditable, value, formattedValue } = cellParamsWithAPI; + + const managesOwnFocus = column.type === 'actions'; + const tabIndex = + (cellMode === 'view' || !isEditable) && !managesOwnFocus ? cellParamsWithAPI.tabIndex : -1; + + const { classes: rootClasses, getCellClassName } = rootProps; + + const classNames = apiRef.current.unstable_applyPipeProcessors('cellClassName', [], { + id: rowId, + field, + }) as (string | undefined)[]; + + if (column.cellClassName) { + classNames.push( + typeof column.cellClassName === 'function' + ? column.cellClassName(cellParamsWithAPI) + : column.cellClassName, + ); + } + + if (getCellClassName) { + classNames.push(getCellClassName(cellParamsWithAPI)); + } + + let children: React.ReactNode; + if (editCellState == null && column.renderCell) { + children = column.renderCell(cellParamsWithAPI); + classNames.push(gridClasses['cell--withRenderer']); + classNames.push(rootClasses?.['cell--withRenderer']); + } + + if (editCellState != null && column.renderEditCell) { + const updatedRow = apiRef.current.getRowWithUpdatedValues(rowId, column.field); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { changeReason, unstable_updateValueOnRender, ...editCellStateRest } = editCellState; + + const params: GridRenderEditCellParams = { + ...cellParamsWithAPI, + row: updatedRow, + ...editCellStateRest, + }; + + children = column.renderEditCell(params); + classNames.push(gridClasses['cell--editing']); + classNames.push(rootClasses?.['cell--editing']); + } + + const { slots } = rootProps; + + const CellComponent = slots.cell; + + const cellProps: GridCellProps = { + ...props, + ref, + field, + formattedValue, + hasFocus, + isEditable, + isSelected, + value, + cellMode, + children, + tabIndex, + className: clsx(classNames), + }; + + return React.createElement(CellComponent, cellProps); +}); + const GridCell = React.forwardRef((props, ref) => { const { align, - children, + children: childrenProp, colIndex, - colDef, + column, cellMode, field, formattedValue, @@ -130,7 +283,6 @@ const GridCell = React.forwardRef((props, ref) => const handleRef = useForkRef(ref, cellRef); const focusElementRef = React.useRef(null); const apiRef = useGridApiContext(); - const rootProps = useGridRootProps(); const ownerState = { align, showRightBorder, isEditable, classes: rootProps.classes, isSelected }; const classes = useUtilityClasses(ownerState); @@ -235,25 +387,21 @@ const GridCell = React.forwardRef((props, ref) => }; } - const column = apiRef.current.getColumn(field); const managesOwnFocus = column.type === 'actions'; - const renderChildren = () => { - if (children === undefined) { - const valueString = valueToRender?.toString(); - return ( -
- {valueString} -
- ); - } - - if (React.isValidElement(children) && managesOwnFocus) { - return React.cloneElement(children, { focusElementRef }); - } + let children: React.ReactNode = childrenProp; + if (children === undefined) { + const valueString = valueToRender?.toString(); + children = ( +
+ {valueString} +
+ ); + } - return children; - }; + if (React.isValidElement(children) && managesOwnFocus) { + children = React.cloneElement(children, { focusElementRef }); + } const draggableEventHandlers = disableDragEvents ? null @@ -272,7 +420,7 @@ const GridCell = React.forwardRef((props, ref) => aria-colindex={colIndex + 1} aria-colspan={colSpan} style={style} - tabIndex={(cellMode === 'view' || !isEditable) && !managesOwnFocus ? tabIndex : -1} + tabIndex={tabIndex} onClick={publish('cellClick', onClick)} onDoubleClick={publish('cellDoubleClick', onDoubleClick)} onMouseOver={publish('cellMouseOver', onMouseOver)} @@ -284,12 +432,36 @@ const GridCell = React.forwardRef((props, ref) => {...other} onFocus={handleFocus} > - {renderChildren()} + {children} ); }); -const MemoizedCell = React.memo(GridCell); +const MemoizedCellWrapper = fastMemo(GridCellWrapper); + +GridCellWrapper.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + align: PropTypes.oneOf(['center', 'left', 'right']), + className: PropTypes.string, + colIndex: PropTypes.number, + colSpan: PropTypes.number, + column: PropTypes.object, + disableDragEvents: PropTypes.bool, + height: PropTypes.oneOfType([PropTypes.oneOf(['auto']), PropTypes.number]), + onClick: PropTypes.func, + onDoubleClick: PropTypes.func, + onDragEnter: PropTypes.func, + onDragOver: PropTypes.func, + onKeyDown: PropTypes.func, + onMouseDown: PropTypes.func, + onMouseUp: PropTypes.func, + rowId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + showRightBorder: PropTypes.bool, + width: PropTypes.number, +} as any; GridCell.propTypes = { // ----------------------------- Warning -------------------------------- @@ -302,13 +474,319 @@ GridCell.propTypes = { className: PropTypes.string, colIndex: PropTypes.number, colSpan: PropTypes.number, + column: PropTypes.object, + disableDragEvents: PropTypes.bool, + editCellState: PropTypes.shape({ + changeReason: PropTypes.oneOf(['debouncedSetEditCellValue', 'setEditCellValue']), + isProcessingProps: PropTypes.bool, + isValidating: PropTypes.bool, + value: PropTypes.any, + }), + height: PropTypes.oneOfType([PropTypes.oneOf(['auto']), PropTypes.number]), + onClick: PropTypes.func, + onDoubleClick: PropTypes.func, + onDragEnter: PropTypes.func, + onDragOver: PropTypes.func, + onKeyDown: PropTypes.func, + onMouseDown: PropTypes.func, + onMouseUp: PropTypes.func, + rowId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + showRightBorder: PropTypes.bool, + width: PropTypes.number, +} as any; + +export { MemoizedCellWrapper as GridCellWrapper, GridCell }; + +const GridCellV7 = React.forwardRef((props, ref) => { + const { + column, + rowId, + editCellState, + align, + children: childrenProp, + colIndex, + height, + width, + className, + showRightBorder, + extendRowFullWidth, + row, + colSpan, + disableDragEvents, + onClick, + onDoubleClick, + onMouseDown, + onMouseUp, + onMouseOver, + onKeyDown, + onKeyUp, + onDragEnter, + onDragOver, + ...other + } = props; + + const apiRef = useGridApiContext(); + const rootProps = useGridRootProps(); + + const field = column.field; + + const cellParamsWithAPI = useGridSelector( + apiRef, + () => { + // This is required because `.getCellParams` tries to get the `state.rows.tree` entry + // associated with `rowId`/`fieldId`, but this selector runs after the state has been + // updated, while `rowId`/`fieldId` reference an entry in the old state. + try { + const cellParams = apiRef.current.getCellParams( + rowId, + field, + ); + + const result = cellParams as CellParamsWithAPI; + result.api = apiRef.current; + return result; + } catch (e) { + if (e instanceof MissingRowIdError) { + return EMPTY_CELL_PARAMS; + } + throw e; + } + }, + objectShallowCompare, + ); + + const isSelected = useGridSelector(apiRef, () => + apiRef.current.unstable_applyPipeProcessors('isCellSelected', false, { + id: rowId, + field, + }), + ); + + const { cellMode, hasFocus, isEditable, value, formattedValue } = cellParamsWithAPI; + + const managesOwnFocus = column.type === 'actions'; + const tabIndex = + (cellMode === 'view' || !isEditable) && !managesOwnFocus ? cellParamsWithAPI.tabIndex : -1; + + const { classes: rootClasses, getCellClassName } = rootProps; + + const classNames = apiRef.current.unstable_applyPipeProcessors('cellClassName', [], { + id: rowId, + field, + }) as (string | undefined)[]; + + if (column.cellClassName) { + classNames.push( + typeof column.cellClassName === 'function' + ? column.cellClassName(cellParamsWithAPI) + : column.cellClassName, + ); + } + + if (getCellClassName) { + classNames.push(getCellClassName(cellParamsWithAPI)); + } + + const valueToRender = formattedValue == null ? value : formattedValue; + const cellRef = React.useRef(null); + const handleRef = useForkRef(ref, cellRef); + const focusElementRef = React.useRef(null); + const ownerState = { align, showRightBorder, isEditable, classes: rootProps.classes, isSelected }; + const classes = useUtilityClasses(ownerState); + + const publishMouseUp = React.useCallback( + (eventName: GridEvents) => (event: React.MouseEvent) => { + const params = apiRef.current.getCellParams(rowId, field || ''); + apiRef.current.publishEvent(eventName as any, params as any, event); + + if (onMouseUp) { + onMouseUp(event); + } + }, + [apiRef, field, onMouseUp, rowId], + ); + + const publishMouseDown = React.useCallback( + (eventName: GridEvents) => (event: React.MouseEvent) => { + const params = apiRef.current.getCellParams(rowId, field || ''); + apiRef.current.publishEvent(eventName as any, params as any, event); + + if (onMouseDown) { + onMouseDown(event); + } + }, + [apiRef, field, onMouseDown, rowId], + ); + + const publish = React.useCallback( + (eventName: keyof GridCellEventLookup, propHandler: any) => + (event: React.SyntheticEvent) => { + // The row might have been deleted during the click + if (!apiRef.current.getRow(rowId)) { + return; + } + + const params = apiRef.current.getCellParams(rowId!, field || ''); + apiRef.current.publishEvent(eventName, params, event as any); + + if (propHandler) { + propHandler(event); + } + }, + [apiRef, field, rowId], + ); + + const style = { + minWidth: width, + maxWidth: width, + minHeight: height, + maxHeight: height === 'auto' ? 'none' : height, // max-height doesn't support "auto" + }; + + React.useEffect(() => { + if (!hasFocus || cellMode === GridCellModes.Edit) { + return; + } + + const doc = ownerDocument(apiRef.current.rootElementRef!.current)!; + + if (cellRef.current && !cellRef.current.contains(doc.activeElement!)) { + const focusableElement = cellRef.current!.querySelector('[tabindex="0"]'); + const elementToFocus = focusElementRef.current || focusableElement || cellRef.current; + + if (doesSupportPreventScroll()) { + elementToFocus.focus({ preventScroll: true }); + } else { + const scrollPosition = apiRef.current.getScrollPosition(); + elementToFocus.focus(); + apiRef.current.scroll(scrollPosition); + } + } + }, [hasFocus, cellMode, apiRef]); + + if (cellParamsWithAPI === EMPTY_CELL_PARAMS) { + return null; + } + + let handleFocus: any = other.onFocus; + + if ( + process.env.NODE_ENV === 'test' && + rootProps.experimentalFeatures?.warnIfFocusStateIsNotSynced + ) { + handleFocus = (event: React.FocusEvent) => { + const focusedCell = gridFocusCellSelector(apiRef); + if (focusedCell?.id === rowId && focusedCell.field === field) { + if (typeof other.onFocus === 'function') { + other.onFocus(event); + } + return; + } + + if (!warnedOnce) { + console.warn( + [ + `MUI: The cell with id=${rowId} and field=${field} received focus.`, + `According to the state, the focus should be at id=${focusedCell?.id}, field=${focusedCell?.field}.`, + "Not syncing the state may cause unwanted behaviors since the `cellFocusIn` event won't be fired.", + 'Call `fireEvent.mouseUp` before the `fireEvent.click` to sync the focus with the state.', + ].join('\n'), + ); + + warnedOnce = true; + } + }; + } + + let children: React.ReactNode; + if (editCellState == null && column.renderCell) { + children = column.renderCell(cellParamsWithAPI); + classNames.push(gridClasses['cell--withRenderer']); + classNames.push(rootClasses?.['cell--withRenderer']); + } + + if (editCellState != null && column.renderEditCell) { + const updatedRow = apiRef.current.getRowWithUpdatedValues(rowId, column.field); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { changeReason, unstable_updateValueOnRender, ...editCellStateRest } = editCellState; + + const params: GridRenderEditCellParams = { + ...cellParamsWithAPI, + row: updatedRow, + ...editCellStateRest, + }; + + children = column.renderEditCell(params); + classNames.push(gridClasses['cell--editing']); + classNames.push(rootClasses?.['cell--editing']); + } + + if (children === undefined) { + const valueString = valueToRender?.toString(); + children = ( +
+ {valueString} +
+ ); + } + + if (React.isValidElement(children) && managesOwnFocus) { + children = React.cloneElement(children, { focusElementRef }); + } + + const draggableEventHandlers = disableDragEvents + ? null + : { + onDragEnter: publish('cellDragEnter', onDragEnter), + onDragOver: publish('cellDragOver', onDragOver), + }; + + return ( +
+ {children} +
+ ); +}); + +GridCellV7.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + align: PropTypes.oneOf(['center', 'left', 'right']), + className: PropTypes.string, + colIndex: PropTypes.number, + colSpan: PropTypes.number, + column: PropTypes.object, disableDragEvents: PropTypes.bool, - field: PropTypes.string, - formattedValue: PropTypes.any, - hasFocus: PropTypes.bool, + editCellState: PropTypes.shape({ + changeReason: PropTypes.oneOf(['debouncedSetEditCellValue', 'setEditCellValue']), + isProcessingProps: PropTypes.bool, + isValidating: PropTypes.bool, + value: PropTypes.any, + }), height: PropTypes.oneOfType([PropTypes.oneOf(['auto']), PropTypes.number]), - isEditable: PropTypes.bool, - isSelected: PropTypes.bool, onClick: PropTypes.func, onDoubleClick: PropTypes.func, onDragEnter: PropTypes.func, @@ -318,9 +796,9 @@ GridCell.propTypes = { onMouseUp: PropTypes.func, rowId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), showRightBorder: PropTypes.bool, - tabIndex: PropTypes.oneOf([-1, 0]), - value: PropTypes.any, width: PropTypes.number, } as any; -export { MemoizedCell as GridCell }; +const MemoizedGridCellV7 = fastMemo(GridCellV7); + +export { MemoizedGridCellV7 as GridCellV7 }; diff --git a/packages/grid/x-data-grid/src/components/cell/index.ts b/packages/grid/x-data-grid/src/components/cell/index.ts index 2a54901cc8c11..c92abc5571bba 100644 --- a/packages/grid/x-data-grid/src/components/cell/index.ts +++ b/packages/grid/x-data-grid/src/components/cell/index.ts @@ -1,4 +1,5 @@ -export * from './GridCell'; +export { GridCell } from './GridCell'; +export type { GridCellProps } from './GridCell'; export * from './GridBooleanCell'; export * from './GridEditBooleanCell'; export * from './GridEditDateCell'; diff --git a/packages/grid/x-data-grid/src/components/menu/columnMenu/GridColumnHeaderMenu.tsx b/packages/grid/x-data-grid/src/components/menu/columnMenu/GridColumnHeaderMenu.tsx index 5c067a68b37f5..6785a115ba93c 100644 --- a/packages/grid/x-data-grid/src/components/menu/columnMenu/GridColumnHeaderMenu.tsx +++ b/packages/grid/x-data-grid/src/components/menu/columnMenu/GridColumnHeaderMenu.tsx @@ -40,7 +40,7 @@ function GridColumnHeaderMenu({ [apiRef, target], ); - if (!target) { + if (!target || !colDef) { return null; } diff --git a/packages/grid/x-data-grid/src/components/virtualization/GridVirtualScroller.tsx b/packages/grid/x-data-grid/src/components/virtualization/GridVirtualScroller.tsx index 00da9b239deb2..4390ebb329ea4 100644 --- a/packages/grid/x-data-grid/src/components/virtualization/GridVirtualScroller.tsx +++ b/packages/grid/x-data-grid/src/components/virtualization/GridVirtualScroller.tsx @@ -36,16 +36,15 @@ const GridVirtualScroller = React.forwardRef< HTMLDivElement, React.HTMLAttributes & { sx?: SxProps } >(function GridVirtualScroller(props, ref) { - const { className, ...other } = props; const rootProps = useGridRootProps(); const classes = useUtilityClasses(rootProps); return ( ); }); diff --git a/packages/grid/x-data-grid/src/components/virtualization/GridVirtualScrollerContent.tsx b/packages/grid/x-data-grid/src/components/virtualization/GridVirtualScrollerContent.tsx index 47d3bc2fff0df..95e05d1a48a55 100644 --- a/packages/grid/x-data-grid/src/components/virtualization/GridVirtualScrollerContent.tsx +++ b/packages/grid/x-data-grid/src/components/virtualization/GridVirtualScrollerContent.tsx @@ -6,10 +6,10 @@ import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; import { getDataGridUtilityClass } from '../../constants/gridClasses'; import { DataGridProcessedProps } from '../../models/props/DataGridProps'; -type OwnerState = DataGridProcessedProps & { overflowedContent: boolean }; +type OwnerState = DataGridProcessedProps; -const useUtilityClasses = (ownerState: OwnerState) => { - const { classes, overflowedContent } = ownerState; +const useUtilityClasses = (props: DataGridProcessedProps, overflowedContent: boolean) => { + const { classes } = props; const slots = { root: ['virtualScrollerContent', overflowedContent && 'virtualScrollerContent--overflowed'], @@ -28,21 +28,16 @@ const GridVirtualScrollerContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes & { sx?: SxProps } >(function GridVirtualScrollerContent(props, ref) { - const { className, style, ...other } = props; const rootProps = useGridRootProps(); - const ownerState = { - ...rootProps, - overflowedContent: !rootProps.autoHeight && style?.minHeight === 'auto', - }; - const classes = useUtilityClasses(ownerState); + const overflowedContent = !rootProps.autoHeight && props.style?.minHeight === 'auto'; + const classes = useUtilityClasses(rootProps, overflowedContent); return ( ); }); diff --git a/packages/grid/x-data-grid/src/constants/defaultGridSlotsComponents.ts b/packages/grid/x-data-grid/src/constants/defaultGridSlotsComponents.ts index 784b5b052bea5..def1b9bea037f 100644 --- a/packages/grid/x-data-grid/src/constants/defaultGridSlotsComponents.ts +++ b/packages/grid/x-data-grid/src/constants/defaultGridSlotsComponents.ts @@ -1,6 +1,5 @@ import { GridSlotsComponent } from '../models'; import { - GridCell, GridSkeletonCell, GridColumnsPanel, GridFilterPanel, @@ -13,6 +12,7 @@ import { GridRow, GridColumnHeaderFilterIconButton, } from '../components'; +import { GridCellV7 } from '../components/cell/GridCell'; import { GridColumnHeaders } from '../components/GridColumnHeaders'; import { GridColumnMenu } from '../components/menu/columnMenu/GridColumnMenu'; import { GridNoResultsOverlay } from '../components/GridNoResultsOverlay'; @@ -22,7 +22,7 @@ import materialSlots from '../material'; // Remove then need to call `uncapitalizeObjectKeys`. export const DATA_GRID_DEFAULT_SLOTS_COMPONENTS: GridSlotsComponent = { ...materialSlots, - Cell: GridCell, + Cell: GridCellV7, SkeletonCell: GridSkeletonCell, ColumnHeaderFilterIconButton: GridColumnHeaderFilterIconButton, ColumnMenu: GridColumnMenu, diff --git a/packages/grid/x-data-grid/src/hooks/core/useGridApiInitialization.ts b/packages/grid/x-data-grid/src/hooks/core/useGridApiInitialization.ts index c5086ff638db9..ddcb9ef60e86f 100644 --- a/packages/grid/x-data-grid/src/hooks/core/useGridApiInitialization.ts +++ b/packages/grid/x-data-grid/src/hooks/core/useGridApiInitialization.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { Store } from '../../utils/Store'; import { useGridApiMethod } from '../utils/useGridApiMethod'; import { GridSignature } from '../utils/useGridApiEventHandler'; import { DataGridProcessedProps } from '../../models/props/DataGridProps'; @@ -60,8 +61,11 @@ export function useGridApiInitialization< const publicApiRef = React.useRef() as React.MutableRefObject; if (!publicApiRef.current) { + const state = {} as Api['state']; + publicApiRef.current = { - state: {} as Api['state'], + state, + store: Store.create(state), instanceId: { id: globalId }, } as Api; diff --git a/packages/grid/x-data-grid/src/hooks/core/useGridStateInitialization.ts b/packages/grid/x-data-grid/src/hooks/core/useGridStateInitialization.ts index ffcf0197ee047..860d4d5055783 100644 --- a/packages/grid/x-data-grid/src/hooks/core/useGridStateInitialization.ts +++ b/packages/grid/x-data-grid/src/hooks/core/useGridStateInitialization.ts @@ -19,12 +19,7 @@ export const useGridStateInitialization = ['registerControlState'] >((controlStateItem) => { - const { stateId, ...others } = controlStateItem; - - controlStateMapRef.current[stateId] = { - ...others, - stateId, - }; + controlStateMapRef.current[controlStateItem.stateId] = controlStateItem; }, []); const setState = React.useCallback['setState']>( @@ -87,6 +82,8 @@ export const useGridStateInitialization = { const rootProps = useGridRootProps(); const innerRef = React.useRef(null); const handleInnerRef = useForkRef(innerRefProp, innerRef); - const [renderContext, setRenderContext] = React.useState(null); + const [renderContext, setRenderContextRaw] = React.useState(null); const prevRenderContext = React.useRef(renderContext); const prevScrollLeft = React.useRef(0); const currentPage = useGridVisibleRows(apiRef, rootProps); const totalHeaderHeight = getTotalHeaderHeight(apiRef, rootProps.columnHeaderHeight); const headerHeight = Math.floor(rootProps.columnHeaderHeight * densityFactor); + const setRenderContext = React.useCallback( + (nextRenderContext: GridRenderContext | null) => { + if ( + renderContext && + nextRenderContext && + areRenderContextsEqual(renderContext, nextRenderContext) + ) { + return; + } + setRenderContextRaw(nextRenderContext); + }, + [renderContext], + ); + React.useEffect(() => { apiRef.current.columnHeadersContainerElementRef!.current!.scrollLeft = 0; }, [apiRef]); @@ -210,7 +227,7 @@ export const useGridColumnHeaders = (props: UseGridColumnHeadersProps) => { updateInnerPosition(nextRenderContext); } }, - [updateInnerPosition], + [updateInnerPosition, setRenderContext], ); const handleColumnResizeStart = React.useCallback>( diff --git a/packages/grid/x-data-grid/src/hooks/features/focus/useGridFocus.ts b/packages/grid/x-data-grid/src/hooks/features/focus/useGridFocus.ts index 992534803521e..33ee2e250583e 100644 --- a/packages/grid/x-data-grid/src/hooks/features/focus/useGridFocus.ts +++ b/packages/grid/x-data-grid/src/hooks/features/focus/useGridFocus.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { unstable_ownerDocument as ownerDocument } from '@mui/utils'; +import { gridClasses } from '../../../constants/gridClasses'; import { GridEventListener, GridEventLookup } from '../../../models/events'; import { GridPrivateApiCommunity } from '../../../models/api/gridApiCommunity'; import { GridFocusApi, GridFocusPrivateApi } from '../../../models/api/gridFocusApi'; @@ -304,8 +305,6 @@ export const useGridFocus = ( [apiRef], ); - const focussedColumnGroup = unstable_gridFocusColumnGroupHeaderSelector(apiRef); - const handleColumnGroupHeaderFocus = React.useCallback< GridEventListener<'columnGroupHeaderFocus'> >( @@ -313,26 +312,38 @@ export const useGridFocus = ( if (event.target !== event.currentTarget) { return; } + const focusedColumnGroup = unstable_gridFocusColumnGroupHeaderSelector(apiRef); if ( - focussedColumnGroup !== null && - focussedColumnGroup.depth === depth && - fields.includes(focussedColumnGroup.field) + focusedColumnGroup !== null && + focusedColumnGroup.depth === depth && + fields.includes(focusedColumnGroup.field) ) { // This group cell has already been focused return; } apiRef.current.setColumnGroupHeaderFocus(fields[0], depth, event); }, - [apiRef, focussedColumnGroup], + [apiRef], ); - const handleBlur = React.useCallback>(() => { - logger.debug(`Clearing focus`); - apiRef.current.setState((state) => ({ - ...state, - focus: { cell: null, columnHeader: null, columnHeaderFilter: null, columnGroupHeader: null }, - })); - }, [logger, apiRef]); + const handleBlur = React.useCallback>( + (_, ev) => { + if (ev.relatedTarget?.className.includes(gridClasses.columnHeader)) { + return; + } + logger.debug(`Clearing focus`); + apiRef.current.setState((state) => ({ + ...state, + focus: { + cell: null, + columnHeader: null, + columnHeaderFilter: null, + columnGroupHeader: null, + }, + })); + }, + [logger, apiRef], + ); const handleCellMouseDown = React.useCallback>((params) => { lastClickedCell.current = params; diff --git a/packages/grid/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts b/packages/grid/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts index d52065399e7a0..bf58d31341507 100644 --- a/packages/grid/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts +++ b/packages/grid/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts @@ -18,7 +18,6 @@ import { GridRowEntry, GridRowId } from '../../../models'; import { gridPinnedRowsSelector } from '../rows/gridRowsSelector'; import { unstable_gridFocusColumnGroupHeaderSelector } from '../focus'; import { gridColumnGroupsHeaderMaxDepthSelector } from '../columnGrouping/gridColumnGroupsSelector'; -import { useGridSelector } from '../../utils/useGridSelector'; import { unstable_gridHeaderFilteringEditFieldSelector, unstable_gridHeaderFilteringMenuSelector, @@ -421,7 +420,6 @@ export const useGridKeyboardNavigation = ( ], ); - const focusedColumnGroup = useGridSelector(apiRef, unstable_gridFocusColumnGroupHeaderSelector); const handleColumnGroupHeaderKeyDown = React.useCallback< GridEventListener<'columnGroupHeaderKeyDown'> >( @@ -431,6 +429,7 @@ export const useGridKeyboardNavigation = ( return; } + const focusedColumnGroup = unstable_gridFocusColumnGroupHeaderSelector(apiRef); if (focusedColumnGroup === null) { return; } @@ -517,15 +516,7 @@ export const useGridKeyboardNavigation = ( event.preventDefault(); } }, - [ - apiRef, - focusedColumnGroup, - currentPageRows.length, - goToHeader, - goToGroupHeader, - goToCell, - getRowIdFromIndex, - ], + [apiRef, currentPageRows.length, goToHeader, goToGroupHeader, goToCell, getRowIdFromIndex], ); const handleCellKeyDown = React.useCallback>( diff --git a/packages/grid/x-data-grid/src/hooks/features/rows/useGridParamsApi.ts b/packages/grid/x-data-grid/src/hooks/features/rows/useGridParamsApi.ts index 67f86592f8354..a7b0df731337d 100644 --- a/packages/grid/x-data-grid/src/hooks/features/rows/useGridParamsApi.ts +++ b/packages/grid/x-data-grid/src/hooks/features/rows/useGridParamsApi.ts @@ -12,16 +12,7 @@ import { import { useGridApiMethod } from '../../utils/useGridApiMethod'; import { gridFocusCellSelector, gridTabIndexCellSelector } from '../focus/gridFocusStateSelector'; -let warnedOnceMissingColumn = false; -function warnMissingColumn(field: string) { - console.warn( - [ - `MUI: You are calling getValue('${field}') but the column \`${field}\` is not defined.`, - `Instead, you can access the data from \`params.row.${field}\`.`, - ].join('\n'), - ); - warnedOnceMissingColumn = true; -} +export class MissingRowIdError extends Error {} /** * @requires useGridColumns (method) @@ -45,7 +36,7 @@ export function useGridParamsApi(apiRef: React.MutableRefObject(id); if (!row || !rowNode) { - throw new Error(`No row with id #${id} found`); + throw new MissingRowIdError(`No row with id #${id} found`); } const cellFocus = gridFocusCellSelector(apiRef); @@ -96,7 +87,7 @@ export function useGridParamsApi(apiRef: React.MutableRefObject { const colDef = apiRef.current.getColumn(field); - if (process.env.NODE_ENV !== 'production') { - if (!colDef && !warnedOnceMissingColumn) { - warnMissingColumn(field); - } - } - if (!colDef || !colDef.valueGetter) { const rowModel = apiRef.current.getRow(id); if (!rowModel) { - throw new Error(`No row with id #${id} found`); + throw new MissingRowIdError(`No row with id #${id} found`); } return rowModel[field]; diff --git a/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx b/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx index 2549a4dea8075..a2ee064c3f264 100644 --- a/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx +++ b/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx @@ -3,6 +3,7 @@ import * as ReactDOM from 'react-dom'; import { unstable_useForkRef as useForkRef, unstable_useEnhancedEffect as useEnhancedEffect, + unstable_useEventCallback as useEventCallback, } from '@mui/utils'; import { useTheme } from '@mui/material/styles'; import { defaultMemoize } from 'reselect'; @@ -80,7 +81,10 @@ export const getRenderableIndexes = ({ ]; }; -const areRenderContextsEqual = (context1: GridRenderContext, context2: GridRenderContext) => { +export const areRenderContextsEqual = ( + context1: GridRenderContext, + context2: GridRenderContext, +) => { if (context1 === context2) { return true; } @@ -106,6 +110,10 @@ interface ContainerDimensions { height: number | null; } +// The `maxSize` is 3 so that reselect caches the `renderedColumns` values for the pinned left, +// unpinned, and pinned right sections. +const MEMOIZE_OPTIONS = { maxSize: 3 }; + export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { const apiRef = useGridPrivateApiContext(); const rootProps = useGridRootProps(); @@ -149,6 +157,7 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { (columns: GridStateColDef[], firstColumnToRender: number, lastColumnToRender: number) => { return columns.slice(firstColumnToRender, lastColumnToRender); }, + MEMOIZE_OPTIONS, ), ); @@ -369,7 +378,7 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { apiRef.current.publishEvent('scrollPositionChange', params); }, [apiRef, computeRenderContext, containerDimensions.width, updateRenderContext]); - const handleScroll = (event: React.UIEvent) => { + const handleScroll = useEventCallback((event: React.UIEvent) => { const { scrollTop, scrollLeft } = event.currentTarget; scrollPosition.current.top = scrollTop; scrollPosition.current.left = scrollLeft; @@ -432,15 +441,15 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { }); prevTotalWidth.current = columnsTotalWidth; } - }; + }); - const handleWheel = (event: React.WheelEvent) => { + const handleWheel = useEventCallback((event: React.WheelEvent) => { apiRef.current.publishEvent('virtualScrollerWheel', {}, event); - }; + }); - const handleTouchMove = (event: React.TouchEvent) => { + const handleTouchMove = useEventCallback((event: React.TouchEvent) => { apiRef.current.publishEvent('virtualScrollerTouchMove', {}, event); - }; + }); const getRows = ( params: { @@ -627,24 +636,36 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { minHeight: shouldExtendContent ? '100%' : 'auto', }; + if (rootProps.autoHeight && currentPage.rows.length === 0) { + size.height = getMinimalContentHeight(apiRef, rootProps.rowHeight); // Give room to show the overlay when there no rows. + } + return size; - }, [rootRef, columnsTotalWidth, rowsMeta.currentPageTotalHeight, needsHorizontalScrollbar]); + }, [ + apiRef, + rootRef, + columnsTotalWidth, + rowsMeta.currentPageTotalHeight, + needsHorizontalScrollbar, + rootProps.autoHeight, + rootProps.rowHeight, + currentPage.rows.length, + ]); React.useEffect(() => { apiRef.current.publishEvent('virtualScrollerContentSizeChange'); }, [apiRef, contentSize]); - if (rootProps.autoHeight && currentPage.rows.length === 0) { - contentSize.height = getMinimalContentHeight(apiRef, rootProps.rowHeight); // Give room to show the overlay when there no rows. - } - - const rootStyle = {} as React.CSSProperties; - if (!needsHorizontalScrollbar) { - rootStyle.overflowX = 'hidden'; - } - if (rootProps.autoHeight) { - rootStyle.overflowY = 'hidden'; - } + const rootStyle = React.useMemo(() => { + const style = {} as React.CSSProperties; + if (!needsHorizontalScrollbar) { + style.overflowX = 'hidden'; + } + if (rootProps.autoHeight) { + style.overflowY = 'hidden'; + } + return style; + }, [needsHorizontalScrollbar, rootProps.autoHeight]); const getRenderContext = React.useCallback((): GridRenderContext => { return prevRenderContext.current!; @@ -656,15 +677,17 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { renderContext, updateRenderZonePosition, getRows, - getRootProps: ({ style = {}, ...other } = {}) => ({ + getRootProps: (inputProps: { style?: object } = {}) => ({ ref: handleRef, onScroll: handleScroll, onWheel: handleWheel, onTouchMove: handleTouchMove, - style: { ...style, ...rootStyle }, - ...other, + ...inputProps, + style: inputProps.style ? { ...inputProps.style, ...rootStyle } : rootStyle, + }), + getContentProps: ({ style }: { style?: object } = {}) => ({ + style: style ? { ...style, ...contentSize } : contentSize, }), - getContentProps: ({ style = {} } = {}) => ({ style: { ...style, ...contentSize } }), getRenderZoneProps: () => ({ ref: renderZoneRef }), }; }; diff --git a/packages/grid/x-data-grid/src/hooks/utils/index.ts b/packages/grid/x-data-grid/src/hooks/utils/index.ts index 30ce9b567da3a..00201c605b971 100644 --- a/packages/grid/x-data-grid/src/hooks/utils/index.ts +++ b/packages/grid/x-data-grid/src/hooks/utils/index.ts @@ -1,6 +1,6 @@ export * from './useGridApiEventHandler'; export * from './useGridApiMethod'; export * from './useGridLogger'; -export * from './useGridSelector'; +export { useGridSelector } from './useGridSelector'; export * from './useGridNativeEventListener'; export * from './useFirstRender'; diff --git a/packages/grid/x-data-grid/src/hooks/utils/useGridSelector.ts b/packages/grid/x-data-grid/src/hooks/utils/useGridSelector.ts index 5aaddf04fcf61..5902549e2e3f6 100644 --- a/packages/grid/x-data-grid/src/hooks/utils/useGridSelector.ts +++ b/packages/grid/x-data-grid/src/hooks/utils/useGridSelector.ts @@ -1,7 +1,15 @@ import * as React from 'react'; import { GridApiCommon } from '../../models/api/gridApiCommon'; import { OutputSelector } from '../../utils/createSelector'; +import { useLazyRef } from './useLazyRef'; +import { useOnMount } from './useOnMount'; import { buildWarning } from '../../utils/warning'; +import { fastObjectShallowCompare } from '../../utils/fastObjectShallowCompare'; + +const stateNotInitializedWarning = buildWarning([ + 'MUI: `useGridSelector` has been called before the initialization of the state.', + 'This hook can only be used inside the context of the grid.', +]); function isOutputSelector( selector: any, @@ -9,14 +17,25 @@ function isOutputSelector( return selector.acceptsApiRef; } -const stateNotInitializedWarning = buildWarning([ - 'MUI: `useGridSelector` has been called before the initialization of the state.', - 'This hook can only be used inside the context of the grid.', -]); +function applySelector( + apiRef: React.MutableRefObject, + selector: ((state: Api['state']) => T) | OutputSelector, +) { + if (isOutputSelector(selector)) { + return selector(apiRef); + } + return selector(apiRef.current.state); +} + +const defaultCompare = Object.is; +export const objectShallowCompare = fastObjectShallowCompare; + +const createRefs = () => ({ state: null, equals: null, selector: null } as any); export const useGridSelector = ( apiRef: React.MutableRefObject, selector: ((state: Api['state']) => T) | OutputSelector, + equals: (a: T, b: T) => boolean = defaultCompare, ) => { if (process.env.NODE_ENV !== 'production') { if (!apiRef.current.state) { @@ -24,9 +43,34 @@ export const useGridSelector = ( } } - if (isOutputSelector(selector)) { - return selector(apiRef); - } + const refs = useLazyRef< + { + state: T; + equals: typeof equals; + selector: typeof selector; + }, + never + >(createRefs); + const didInit = refs.current.selector !== null; - return selector(apiRef.current.state); + const [state, setState] = React.useState( + // We don't use an initialization function to avoid allocations + (didInit ? null : applySelector(apiRef, selector)) as T, + ); + + refs.current.state = state; + refs.current.equals = equals; + refs.current.selector = selector; + + useOnMount(() => { + return apiRef.current.store.subscribe(() => { + const newState = applySelector(apiRef, refs.current.selector); + if (!refs.current.equals(refs.current.state, newState)) { + refs.current.state = newState; + setState(newState); + } + }); + }); + + return state; }; diff --git a/packages/grid/x-data-grid/src/hooks/utils/useLazyRef.ts b/packages/grid/x-data-grid/src/hooks/utils/useLazyRef.ts new file mode 100644 index 0000000000000..1adcde01a3e68 --- /dev/null +++ b/packages/grid/x-data-grid/src/hooks/utils/useLazyRef.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; + +const UNINITIALIZED = {}; + +export function useLazyRef(init: (arg?: U) => T, initArg?: U) { + const ref = React.useRef(UNINITIALIZED as unknown as T); + + if (ref.current === UNINITIALIZED) { + ref.current = init(initArg); + } + + return ref; +} diff --git a/packages/grid/x-data-grid/src/hooks/utils/useOnMount.ts b/packages/grid/x-data-grid/src/hooks/utils/useOnMount.ts new file mode 100644 index 0000000000000..fe624200f7b2d --- /dev/null +++ b/packages/grid/x-data-grid/src/hooks/utils/useOnMount.ts @@ -0,0 +1,9 @@ +import * as React from 'react'; + +const EMPTY = [] as unknown[]; + +export function useOnMount(fn: React.EffectCallback) { + /* eslint-disable react-hooks/exhaustive-deps */ + React.useEffect(fn, EMPTY); + /* eslint-enable react-hooks/exhaustive-deps */ +} diff --git a/packages/grid/x-data-grid/src/models/api/gridCoreApi.ts b/packages/grid/x-data-grid/src/models/api/gridCoreApi.ts index cdf04b24c0f71..e69a0917f7a66 100644 --- a/packages/grid/x-data-grid/src/models/api/gridCoreApi.ts +++ b/packages/grid/x-data-grid/src/models/api/gridCoreApi.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { GridEventPublisher, GridEventListener, GridEvents } from '../events'; +import { Store } from '../../utils/Store'; import { EventManager, EventListenerOptions } from '../../utils/EventManager'; import { GridApiCaches } from '../gridApiCaches'; import type { GridApiCommon, GridPrivateApiCommon } from './gridApiCommon'; @@ -37,6 +38,11 @@ export interface GridCoreApi { * @ignore - do not document. */ instanceId: { id: number }; + /** + * The pub/sub store containing a reference to the public state. + * @ignore - do not document. + */ + store: Store; } export interface GridCorePrivateApi< diff --git a/packages/grid/x-data-grid/src/tests/keyboard.DataGrid.test.tsx b/packages/grid/x-data-grid/src/tests/keyboard.DataGrid.test.tsx index 38a7cfaaba715..06f067396b145 100644 --- a/packages/grid/x-data-grid/src/tests/keyboard.DataGrid.test.tsx +++ b/packages/grid/x-data-grid/src/tests/keyboard.DataGrid.test.tsx @@ -685,13 +685,13 @@ describe(' - Keyboard', () => { , ); - expect(renderCell.callCount).to.equal(4); + expect(renderCell.callCount).to.equal(2); const input = screen.getByTestId('custom-input'); input.focus(); fireEvent.keyDown(input, { key: 'a' }); - expect(renderCell.callCount).to.equal(6); + expect(renderCell.callCount).to.equal(4); fireEvent.keyDown(input, { key: 'b' }); - expect(renderCell.callCount).to.equal(6); + expect(renderCell.callCount).to.equal(4); }); it('should not scroll horizontally when cell is wider than viewport', () => { diff --git a/packages/grid/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx b/packages/grid/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx index 07b3b25aa382d..f24c7f694e12e 100644 --- a/packages/grid/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx +++ b/packages/grid/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx @@ -10,7 +10,6 @@ import { GridEditModes, useGridApiRef, GridApi, - GridRow, } from '@mui/x-data-grid'; import { getCell, @@ -777,10 +776,7 @@ describe(' - Row Selection', () => { }); describe('performance', () => { - it('should not rerender unrelated rows', () => { - // TODO: remove this requirement, find ways to scope down react tree rerenders. - const MemoizedRow = React.memo(GridRow); - + it('should not rerender unrelated nodes', () => { // Couldn't use because we need to track multiple components let commits: any[] = []; function CustomCell(props: any) { @@ -803,9 +799,6 @@ describe(' - Row Selection', () => { renderCell: (params) => , }, ]} - slots={{ - row: MemoizedRow, - }} rows={[ { id: 0, currencyPair: 'USDGBP' }, { id: 1, currencyPair: 'USDEUR' }, @@ -821,8 +814,8 @@ describe(' - Row Selection', () => { fireEvent.click(getCell(0, 1)); expect(getSelectedRowIds()).to.deep.equal([0]); expect(getRow(0).querySelector('input')).to.have.property('checked', true); - // It shouldn't rerender rowId 1 - expect(commits).to.deep.equal([{ rowId: 0 }]); + // It shouldn't rerender any of the custom cells + expect(commits).to.deep.equal([]); }); }); }); diff --git a/packages/grid/x-data-grid/src/utils/Store.ts b/packages/grid/x-data-grid/src/utils/Store.ts new file mode 100644 index 0000000000000..4380d4c231719 --- /dev/null +++ b/packages/grid/x-data-grid/src/utils/Store.ts @@ -0,0 +1,32 @@ +type Listener = (value: T) => void; + +export class Store { + value: T; + + listeners: Set>; + + static create(value: T) { + return new Store(value); + } + + constructor(value: T) { + this.value = value; + this.listeners = new Set>(); + } + + subscribe = (fn: Listener) => { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + }; + + getSnapshot = () => { + return this.value; + }; + + update = (value: T) => { + this.value = value; + this.listeners.forEach((l) => l(value)); + }; +} diff --git a/packages/grid/x-data-grid/src/utils/doesSupportPreventScroll.ts b/packages/grid/x-data-grid/src/utils/doesSupportPreventScroll.ts new file mode 100644 index 0000000000000..f51b2f11ce465 --- /dev/null +++ b/packages/grid/x-data-grid/src/utils/doesSupportPreventScroll.ts @@ -0,0 +1,13 @@ +// Based on https://stackoverflow.com/a/59518678 +let cachedSupportsPreventScroll: boolean; +export function doesSupportPreventScroll(): boolean { + if (cachedSupportsPreventScroll === undefined) { + document.createElement('div').focus({ + get preventScroll() { + cachedSupportsPreventScroll = true; + return false; + }, + }); + } + return cachedSupportsPreventScroll; +} diff --git a/packages/grid/x-data-grid/src/utils/fastMemo.ts b/packages/grid/x-data-grid/src/utils/fastMemo.ts new file mode 100644 index 0000000000000..86780baa5e551 --- /dev/null +++ b/packages/grid/x-data-grid/src/utils/fastMemo.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; +import { fastObjectShallowCompare } from './fastObjectShallowCompare'; + +export function fastMemo(component: T): T { + return React.memo(component as any, fastObjectShallowCompare) as unknown as T; +} diff --git a/packages/grid/x-data-grid/src/utils/fastObjectShallowCompare.ts b/packages/grid/x-data-grid/src/utils/fastObjectShallowCompare.ts new file mode 100644 index 0000000000000..940220d63849d --- /dev/null +++ b/packages/grid/x-data-grid/src/utils/fastObjectShallowCompare.ts @@ -0,0 +1,35 @@ +const is = Object.is; + +export function fastObjectShallowCompare | null>(a: T, b: T) { + if (a === b) { + return true; + } + if (!(a instanceof Object) || !(b instanceof Object)) { + return false; + } + + let aLength = 0; + let bLength = 0; + + /* eslint-disable no-restricted-syntax */ + /* eslint-disable guard-for-in */ + for (const key in a) { + aLength += 1; + + if (!is(a[key], b[key])) { + return false; + } + if (!(key in b)) { + return false; + } + } + + /* eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars */ + for (const _ in b) { + bLength += 1; + } + /* eslint-enable no-restricted-syntax */ + /* eslint-enable guard-for-in */ + + return aLength === bLength; +} diff --git a/scripts/x-data-grid-premium.exports.json b/scripts/x-data-grid-premium.exports.json index 127b2d2ae22b5..72543f44050a3 100644 --- a/scripts/x-data-grid-premium.exports.json +++ b/scripts/x-data-grid-premium.exports.json @@ -128,7 +128,7 @@ { "name": "GridCellModes", "kind": "Enum" }, { "name": "GridCellModesModel", "kind": "TypeAlias" }, { "name": "GridCellParams", "kind": "Interface" }, - { "name": "GridCellProps", "kind": "Interface" }, + { "name": "GridCellProps", "kind": "TypeAlias" }, { "name": "GridCellSelectionApi", "kind": "Interface" }, { "name": "GridCellSelectionModel", "kind": "TypeAlias" }, { "name": "GridCheckCircleIcon", "kind": "Variable" }, diff --git a/scripts/x-data-grid-pro.exports.json b/scripts/x-data-grid-pro.exports.json index 364a0fc3d5925..671b5a7feb983 100644 --- a/scripts/x-data-grid-pro.exports.json +++ b/scripts/x-data-grid-pro.exports.json @@ -106,7 +106,7 @@ { "name": "GridCellModes", "kind": "Enum" }, { "name": "GridCellModesModel", "kind": "TypeAlias" }, { "name": "GridCellParams", "kind": "Interface" }, - { "name": "GridCellProps", "kind": "Interface" }, + { "name": "GridCellProps", "kind": "TypeAlias" }, { "name": "GridCheckCircleIcon", "kind": "Variable" }, { "name": "GridCheckIcon", "kind": "Variable" }, { "name": "GridChildrenFromPathLookup", "kind": "TypeAlias" }, diff --git a/scripts/x-data-grid.exports.json b/scripts/x-data-grid.exports.json index 92ab4e601356c..7d8b7de2a4e51 100644 --- a/scripts/x-data-grid.exports.json +++ b/scripts/x-data-grid.exports.json @@ -99,7 +99,7 @@ { "name": "GridCellModes", "kind": "Enum" }, { "name": "GridCellModesModel", "kind": "TypeAlias" }, { "name": "GridCellParams", "kind": "Interface" }, - { "name": "GridCellProps", "kind": "Interface" }, + { "name": "GridCellProps", "kind": "TypeAlias" }, { "name": "GridCheckCircleIcon", "kind": "Variable" }, { "name": "GridCheckIcon", "kind": "Variable" }, { "name": "GridChildrenFromPathLookup", "kind": "TypeAlias" },