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)   - **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:
+30
@@ -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>
|
||||
);
|
||||
|
||||
+42
-7
@@ -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}
|
||||
>
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+1
@@ -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: {
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ type RecordTableContextValue = {
|
||||
viewBarId: string;
|
||||
objectNameSingular: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
objectMetadataItems: ObjectMetadataItem[];
|
||||
objectPermissions: ObjectPermission;
|
||||
visibleRecordFields: RecordField[];
|
||||
onRecordIdentifierClick?: (rowIndex: number, recordId: string) => void;
|
||||
|
||||
+3
-3
@@ -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
-16
@@ -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
-5
@@ -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];
|
||||
|
||||
|
||||
+12
-9
@@ -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>
|
||||
);
|
||||
|
||||
-1
@@ -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;
|
||||
|
||||
+2
-2
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user