A few fixes on table performance (#18304)

## RecordTable Performance Investigation & Optimization (WIP)

Investigates what makes the RecordTable slow (14 components, ~12 hooks
per cell) and starts applying fixes.

### Key Findings (2,000 cells benchmark)

![Cell Render
Benchmark](https://github.com/twentyhq/twenty/blob/table-perf/packages/twenty-front/src/modules/object-record/record-table/__perf__/cell-render-benchmark.png?raw=true)

![State Access
Benchmark](https://github.com/twentyhq/twenty/blob/table-perf/packages/twenty-front/src/modules/object-record/record-table/__perf__/state-access-benchmark.png?raw=true)

- **Jotai atoms are the dominant cost**: 10 atom reads/cell = +312%.
Full sim with atoms = +476%.
- **Derived atoms are 3x cheaper** than individual reads (12 sources:
+93% vs +294%).
- **Component depth is expensive**: 4-level nesting = +82%, 14 wrappers
= +109%.
- **Styling engines are comparable**: Linaria vs Emotion is within
noise.
- **Context reads and useState are nearly free** vs baseline.

### Optimizations Applied

1. **Static focus providers** — replaced per-cell `useState(false)` with
static context. Eliminates 400 useState instances.
2. **Delegated onMouseMove** — single handler on table body instead of
400 per-cell handlers.
3. **Hoisted `useObjectMetadataItems()`** — moved from per-cell to
table-level context. Eliminates 400 global atom reads.

### Tooling

- Perf page at `/__perf__/table`: 17 cell render + 13 state access
benchmarks
- Render profiler: `window.__RECORD_TABLE_PROFILE = true`
- Full plan in `__perf__/PERFORMANCE_PLAN.md`

### Remaining Phases (not high priority to-be-honest)

| Phase | What | Status |
|-------|------|--------|
| 1 | Separate display from interaction | Partial |
| 2 | Flatten hierarchy (14 → ~5 components/cell) | TODO |
| 3 | Reduce atom reads per cell | Partial |
| 4 | CSS-only hover/focus | TODO |
| 5 | Event delegation, lazy Draggable | TODO |
This commit is contained in:
Charles Bochet
2026-02-28 15:12:44 +01:00
committed by GitHub
parent b341704a0e
commit 159bb9d70a
13 changed files with 101 additions and 44 deletions
@@ -2,6 +2,16 @@ import { useState } from 'react';
import { FieldFocusContext } from '@/object-record/record-field/ui/contexts/FieldFocusContext';
const STATIC_UNFOCUSED_VALUE = {
isFocused: false,
setIsFocused: () => {},
};
const STATIC_FOCUSED_VALUE = {
isFocused: true,
setIsFocused: () => {},
};
export const FieldFocusContextProvider = ({ children }: any) => {
const [isFocused, setIsFocused] = useState(false);
@@ -16,3 +26,23 @@ export const FieldFocusContextProvider = ({ children }: any) => {
</FieldFocusContext.Provider>
);
};
export const FieldFocusStaticUnfocusedProvider = ({
children,
}: {
children: React.ReactNode;
}) => (
<FieldFocusContext.Provider value={STATIC_UNFOCUSED_VALUE}>
{children}
</FieldFocusContext.Provider>
);
export const FieldFocusStaticFocusedProvider = ({
children,
}: {
children: React.ReactNode;
}) => (
<FieldFocusContext.Provider value={STATIC_FOCUSED_VALUE}>
{children}
</FieldFocusContext.Provider>
);
@@ -12,9 +12,9 @@ import { recordTableHoverPositionComponentState } from '@/object-record/record-t
import { isSomeCellInEditModeComponentSelector } from '@/object-record/record-table/states/selectors/isSomeCellInEditModeComponentSelector';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import styled from '@emotion/styled';
import { useCallback, useRef, useState } from 'react';
import { useStore } from 'jotai';
@@ -73,10 +73,11 @@ export const RecordTableContent = ({
const { visibleRecordFields } = useRecordTableContextOrThrow();
const setRecordTableHoverPosition = useSetAtomComponentState(
recordTableHoverPositionComponentState,
recordTableId,
);
const recordTableHoverPositionCallbackState =
useAtomComponentStateCallbackState(
recordTableHoverPositionComponentState,
recordTableId,
);
const isSomeCellInEditMode = useAtomComponentSelectorCallbackState(
isSomeCellInEditModeComponentSelector,
@@ -87,9 +88,42 @@ export const RecordTableContent = ({
const cellInEditMode = store.get(isSomeCellInEditMode);
if (!cellInEditMode) {
setRecordTableHoverPosition(null);
store.set(recordTableHoverPositionCallbackState, null);
}
}, [store, isSomeCellInEditMode, setRecordTableHoverPosition]);
}, [store, isSomeCellInEditMode, recordTableHoverPositionCallbackState]);
const handleDelegatedMouseMove = useCallback(
(event: React.MouseEvent) => {
if (store.get(isSomeCellInEditMode)) {
return;
}
const target = event.target as HTMLElement;
const cellElement = target.closest<HTMLElement>(
'[data-record-table-col]',
);
if (!cellElement) {
return;
}
const column = Number(cellElement.dataset.recordTableCol);
const row = Number(cellElement.dataset.recordTableRow);
if (isNaN(column) || isNaN(row)) {
return;
}
const lastPosition = store.get(recordTableHoverPositionCallbackState);
if (lastPosition?.column === column && lastPosition?.row === row) {
return;
}
store.set(recordTableHoverPositionCallbackState, { column, row });
},
[store, isSomeCellInEditMode, recordTableHoverPositionCallbackState],
);
return (
<StyledTableContainer ref={containerRef}>
@@ -98,6 +132,7 @@ export const RecordTableContent = ({
isDragging={isDragging}
visibleRecordFields={visibleRecordFields}
id={RECORD_TABLE_HTML_ID}
onMouseMove={handleDelegatedMouseMove}
onMouseLeave={handleMouseLeave}
hasRecordGroups={hasRecordGroups}
>
@@ -1,6 +1,7 @@
import { type ReactNode } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { RecordTableContextProvider as RecordTableContextInternalProvider } from '@/object-record/record-table/contexts/RecordTableContext';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
@@ -32,6 +33,8 @@ export const RecordTableContextProvider = ({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
@@ -57,6 +60,7 @@ export const RecordTableContextProvider = ({
value={{
viewBarId,
objectMetadataItem,
objectMetadataItems,
recordTableId,
objectNameSingular,
objectPermissions,
@@ -163,6 +163,7 @@ const meta: Meta = {
viewBarId: mockPerformance.recordId,
// TODO: update performance mocks with new data, and merge with common mocks if possible
objectMetadataItem: mockPerformance.objectMetadataItem as any,
objectMetadataItems: [],
objectNameSingular:
mockPerformance.objectMetadataItem.nameSingular,
objectPermissions: {
@@ -8,6 +8,7 @@ type RecordTableContextValue = {
viewBarId: string;
objectNameSingular: string;
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
objectPermissions: ObjectPermission;
visibleRecordFields: RecordField[];
onRecordIdentifierClick?: (rowIndex: number, recordId: string) => void;
@@ -1,11 +1,11 @@
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldFocusContextProvider } from '@/object-record/record-field/ui/contexts/FieldFocusContextProvider';
import { FieldFocusStaticUnfocusedProvider } from '@/object-record/record-field/ui/contexts/FieldFocusContextProvider';
import { RecordTableCellContainer } from '@/object-record/record-table/record-table-cell/components/RecordTableCellContainer';
export const RecordTableCell = () => {
return (
<FieldFocusContextProvider>
<FieldFocusStaticUnfocusedProvider>
<RecordTableCellContainer nonEditModeContent={<FieldDisplay />} />
</FieldFocusContextProvider>
</FieldFocusStaticUnfocusedProvider>
);
};
@@ -2,11 +2,9 @@ import { styled } from '@linaria/react';
import { useContext, type ReactNode } from 'react';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
import { isFieldIdentifierDisplay } from '@/object-record/record-field/ui/meta-types/display/utils/isFieldIdentifierDisplay';
import { RECORD_CHIP_CLICK_OUTSIDE_ID } from '@/object-record/record-table/constants/RecordChipClickOutsideId';
import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight';
import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { ThemeContext } from 'twenty-ui/theme';
@@ -60,7 +58,6 @@ export const RecordTableCellBaseContainer = ({
fieldDefinition,
isLabelIdentifier,
} = useContext(FieldContext);
const { setIsFocused } = useFieldFocus();
const { openTableCell } = useOpenRecordTableCellFromCell();
const { theme } = useContext(ThemeContext);
@@ -70,26 +67,13 @@ export const RecordTableCellBaseContainer = ({
fieldDefinition,
isLabelIdentifier,
);
const { onMoveHoverToCurrentCell } = useRecordTableBodyContextOrThrow();
const handleContainerMouseMove = () => {
setIsFocused(true);
onMoveHoverToCurrentCell(cellPosition);
};
const handleContainerMouseLeave = () => {
setIsFocused(false);
};
const handleContainerClick = () => {
onMoveHoverToCurrentCell(cellPosition);
openTableCell();
};
return (
<StyledBaseContainer
onMouseLeave={handleContainerMouseLeave}
onMouseMove={handleContainerMouseMove}
onClick={handleContainerClick}
backgroundColorTransparentSecondary={
theme.background.transparent.secondary
@@ -100,6 +84,8 @@ export const RecordTableCellBaseContainer = ({
fontColorMedium={theme.border.color.medium}
isReadOnly={isReadOnly ?? false}
id={`record-table-cell-${cellPosition.column}-${cellPosition.row}`}
data-record-table-col={cellPosition.column}
data-record-table-row={cellPosition.row}
data-click-outside-id={
isChipDisplay ? RECORD_CHIP_CLICK_OUTSIDE_ID : undefined
}
@@ -1,4 +1,3 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly';
@@ -15,7 +14,6 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useContext, type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
type RecordTableCellFieldContextGenericProps = {
recordField: RecordField;
children: ReactNode;
@@ -27,7 +25,7 @@ export const RecordTableCellFieldContextGeneric = ({
}: RecordTableCellFieldContextGenericProps) => {
const { recordId, isRecordReadOnly } = useRecordTableRowContextOrThrow();
const { objectMetadataItem, objectPermissions } =
const { objectMetadataItem, objectMetadataItems, objectPermissions } =
useRecordTableContextOrThrow();
const {
@@ -35,8 +33,6 @@ export const RecordTableCellFieldContextGeneric = ({
fieldDefinitionByFieldMetadataItemId,
} = useRecordIndexContextOrThrow();
const { objectMetadataItems } = useObjectMetadataItems();
const fieldDefinition =
fieldDefinitionByFieldMetadataItemId[recordField.fieldMetadataItemId];
@@ -1,5 +1,6 @@
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { FieldFocusStaticFocusedProvider } from '@/object-record/record-field/ui/contexts/FieldFocusContextProvider';
import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly';
import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight';
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
@@ -78,15 +79,17 @@ export const RecordTableCellHoveredPortalContent = () => {
showInteractiveStyle={showInteractiveStyle}
isRecordTableRowActive={isRecordTableRowActive}
>
{isFieldInputOnly ? (
<RecordTableCellEditMode>
<RecordTableCellFieldInput />
</RecordTableCellEditMode>
) : (
<RecordTableCellDisplayMode>
<FieldDisplay />
</RecordTableCellDisplayMode>
)}
<FieldFocusStaticFocusedProvider>
{isFieldInputOnly ? (
<RecordTableCellEditMode>
<RecordTableCellFieldInput />
</RecordTableCellEditMode>
) : (
<RecordTableCellDisplayMode>
<FieldDisplay />
</RecordTableCellDisplayMode>
)}
</FieldFocusStaticFocusedProvider>
{showButton && <RecordTableCellEditButton />}
</StyledRecordTableCellHoveredPortalContent>
);
@@ -15,7 +15,6 @@ import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { forwardRef, type ReactNode } from 'react';
type RecordTableTrProps = {
children: ReactNode;
recordId: string;
@@ -13,8 +13,8 @@ import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScro
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useDebouncedCallback } from 'use-debounce';
export const SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_ACTIVATE_LOW_DETAILS = 120;
export const SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_DEACTIVATE_LOW_DETAILS = 30;
export const SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_ACTIVATE_LOW_DETAILS = 200;
export const SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_DEACTIVATE_LOW_DETAILS = 50;
export const TIME_BEFORE_DEACTIVATING_LOW_DETAILS = 20;
export const NUMBER_OF_EVENTS_TO_COMPUTE_AVERAGE = 10;
@@ -1 +1 @@
export const TABLE_VIRTUALIZATION_NUMBER_OF_OVERSCAN_PAGES = 3;
export const TABLE_VIRTUALIZATION_NUMBER_OF_OVERSCAN_PAGES = 5;
@@ -104,6 +104,7 @@ const InternalTableContextProviders = ({
children: React.ReactNode;
objectMetadataItem: ObjectMetadataItem;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const currentRecordFields = useAtomComponentStateValue(
@@ -174,6 +175,7 @@ const InternalTableContextProviders = ({
value={{
objectNameSingular: objectMetadataItem.nameSingular,
objectMetadataItem: objectMetadataItem,
objectMetadataItems: objectMetadataItems,
recordTableId: objectMetadataItem.namePlural,
viewBarId: 'view-bar',
objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({