Skip to content

Commit e4e72fe

Browse files
LaurentClaesclaude
andcommitted
Optimize Table component rendering performance
- Default row-level React.memo: all data-driven rows now use MemoizedTRow (previously opt-in via memoDataValues only) - Stabilize RowsContext value with useCallback/useMemo to prevent context changes from bypassing row memo on every render - Memoize table head and body separately with useMemo so scroll state changes don't rebuild the row tree - Throttle scroll/resize handlers with requestAnimationFrame - Gate checkComponentProps behind NODE_ENV !== 'production' - Add CSS content-visibility: auto on tbody for native off-screen skip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2d28fcd commit e4e72fe

1 file changed

Lines changed: 164 additions & 133 deletions

File tree

  • packages/react-drylus/src/components

packages/react-drylus/src/components/Table.tsx

Lines changed: 164 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import React, {
77
Fragment,
88
ReactNode,
99
createContext,
10+
useCallback,
1011
useContext,
1112
useEffect,
13+
useMemo,
1214
useRef,
1315
useState,
1416
} from 'react';
@@ -236,6 +238,8 @@ const styles = {
236238
`,
237239
body: css`
238240
color: ${sv.colorPrimary};
241+
content-visibility: auto;
242+
contain-intrinsic-block-size: auto 500px;
239243
`,
240244
withToggle: css`
241245
position: relative;
@@ -521,7 +525,9 @@ export const TRow = ({
521525
}
522526
: {};
523527

524-
checkComponentProps({ children }, { children: TCell });
528+
if (process.env.NODE_ENV !== 'production') {
529+
checkComponentProps({ children }, { children: TCell });
530+
}
525531

526532
return (
527533
<m.tr
@@ -575,7 +581,9 @@ export interface THeadProps {
575581
}
576582

577583
export const THead = ({ children, responsive = true }: THeadProps) => {
578-
checkComponentProps({ children }, { children: TCell });
584+
if (process.env.NODE_ENV !== 'production') {
585+
checkComponentProps({ children }, { children: TCell });
586+
}
579587

580588
return (
581589
<thead className={cx(styles.header, { [styles.responsiveHeader]: responsive })}>
@@ -628,7 +636,9 @@ export const TBody = ({ children, animated }: TBodyProps) => {
628636
}
629637
: {};
630638

631-
checkComponentProps({ children }, { children: TRow });
639+
if (process.env.NODE_ENV !== 'production') {
640+
checkComponentProps({ children }, { children: TRow });
641+
}
632642

633643
return (
634644
<m.tbody {...animationProps} className={styles.body}>
@@ -885,36 +895,24 @@ function _generateTable({
885895
const hasData = data.data != null;
886896
const rowData = hasData ? omit(data, 'data') : data;
887897
const uniqId = Object.values(rowData).reduce<string>((memo, v) => `${memo}-${String(v)}`, '');
888-
const parentRow =
889-
memoDataValues != null && !Array.isArray(memoDataValues) ? (
890-
<MemoizedTRow
891-
responsive={responsive}
892-
header={header}
893-
memoData={memoDataValues}
894-
rowData={rowData}
895-
animated={animated}
896-
key={uniqId}
897-
parent={hasData ? uniqId : undefined}
898-
onClick={(e) => onClickRow(rowData, e)}
899-
onEnter={() => onEnterRow(rowData)}
900-
onExit={() => onExitRow(rowData)}
901-
clickable={clickable}
902-
highlighted={activeRow != null && rowData.id != null && activeRow === rowData.id}
903-
/>
904-
) : (
905-
<TRow
906-
responsive={responsive}
907-
animated={animated}
908-
key={uniqId}
909-
parent={hasData ? uniqId : undefined}
910-
onClick={(e) => onClickRow(rowData, e)}
911-
onEnter={() => onEnterRow(rowData)}
912-
onExit={() => onExitRow(rowData)}
913-
clickable={clickable}
914-
highlighted={activeRow != null && rowData.id != null && activeRow === rowData.id}>
915-
{_generateRowChildren({ header, rowData })}
916-
</TRow>
917-
);
898+
const memoData =
899+
memoDataValues != null && !Array.isArray(memoDataValues) ? memoDataValues : rowData;
900+
const parentRow = (
901+
<MemoizedTRow
902+
responsive={responsive}
903+
header={header}
904+
memoData={memoData}
905+
rowData={rowData}
906+
animated={animated}
907+
key={uniqId}
908+
parent={hasData ? uniqId : undefined}
909+
onClick={(e) => onClickRow(rowData, e)}
910+
onEnter={() => onEnterRow(rowData)}
911+
onExit={() => onExitRow(rowData)}
912+
clickable={clickable}
913+
highlighted={activeRow != null && rowData.id != null && activeRow === rowData.id}
914+
/>
915+
);
918916

919917
if (hasData) {
920918
return [
@@ -970,7 +968,7 @@ export interface TableProps {
970968
/** If passed, the table will be generated from this, and children will be ignored */
971969
data?: TableData;
972970

973-
/** When given, each table row will be shallowly compared to prevent unnecessary renders. Should have the same structure as the data prop to enable 1-to-1 equivalence */
971+
/** Rows are memoized by default using their data for comparison. When row cells contain React elements (e.g. Input, Checkbox), pass this prop with primitive-only values matching the `data` shape so the comparator can detect changes accurately */
974972
memoDataValues?: TableData;
975973

976974
/** Array of strings to generate the header of the table (each string is a label). data prop keys will be filtered by these */
@@ -1083,9 +1081,14 @@ export const Table = ({
10831081
const [xScrollAmount, setXScrollAmount] = useState<number>();
10841082
const [divisorHeight, setDivisorHeight] = useState<number>();
10851083

1086-
checkComponentProps({ children }, { children: [TBody, THead] });
1084+
if (process.env.NODE_ENV !== 'production') {
1085+
checkComponentProps({ children }, { children: [TBody, THead] });
1086+
}
1087+
1088+
const scrollRafRef = useRef<number>();
1089+
const resizeRafRef = useRef<number>();
10871090

1088-
const handleScrollTable = () => {
1091+
const updateScrollAmount = useCallback(() => {
10891092
if (scrollableRef.current != null && tableRef.current != null) {
10901093
const { scrollLeft, clientWidth } = scrollableRef.current;
10911094
const difference = tableRef.current.clientWidth - clientWidth;
@@ -1094,125 +1097,153 @@ export const Table = ({
10941097
setXScrollAmount(amount);
10951098
}
10961099
}
1097-
};
1100+
}, []);
10981101

1099-
const handleResize = () => {
1100-
const height = tableRef.current?.clientHeight;
1101-
setDivisorHeight(height);
1102+
const handleScrollTable = useCallback(() => {
1103+
if (scrollRafRef.current != null) {
1104+
cancelAnimationFrame(scrollRafRef.current);
1105+
}
1106+
scrollRafRef.current = requestAnimationFrame(updateScrollAmount);
1107+
}, [updateScrollAmount]);
11021108

1103-
if (scrollableRef.current != null && tableRef.current != null) {
1104-
const { clientWidth } = scrollableRef.current;
1105-
const difference = tableRef.current.clientWidth - clientWidth;
1106-
if (difference === 0) {
1107-
setXScrollAmount(undefined);
1108-
} else {
1109-
handleScrollTable();
1110-
}
1109+
const handleResize = useCallback(() => {
1110+
if (resizeRafRef.current != null) {
1111+
cancelAnimationFrame(resizeRafRef.current);
11111112
}
1112-
};
1113+
resizeRafRef.current = requestAnimationFrame(() => {
1114+
const height = tableRef.current?.clientHeight;
1115+
setDivisorHeight(height);
1116+
1117+
if (scrollableRef.current != null && tableRef.current != null) {
1118+
const { clientWidth } = scrollableRef.current;
1119+
const difference = tableRef.current.clientWidth - clientWidth;
1120+
if (difference === 0) {
1121+
setXScrollAmount(undefined);
1122+
} else {
1123+
updateScrollAmount();
1124+
}
1125+
}
1126+
});
1127+
}, [updateScrollAmount]);
11131128

11141129
useEffect(() => {
11151130
if (scrollableRef.current != null) {
11161131
scrollableRef.current.addEventListener('scroll', handleScrollTable, false);
11171132
window.addEventListener('resize', handleResize, false);
1118-
setTimeout(handleScrollTable, 50); // trigger calculation once
1119-
setTimeout(handleResize, 50);
1133+
handleScrollTable();
1134+
handleResize();
11201135
}
11211136

11221137
return () => {
11231138
scrollableRef.current?.removeEventListener('scroll', handleScrollTable, false);
11241139
window.removeEventListener('resize', handleResize, false);
1140+
if (scrollRafRef.current != null) cancelAnimationFrame(scrollRafRef.current);
1141+
if (resizeRafRef.current != null) cancelAnimationFrame(resizeRafRef.current);
11251142
};
1126-
}, [scrollableRef, tableRef]);
1143+
}, [handleScrollTable, handleResize]);
11271144

11281145
useEffect(() => {
11291146
handleScrollTable();
11301147
handleResize();
1131-
}, [data?.length, isLoading]);
1148+
}, [data?.length, isLoading, handleScrollTable, handleResize]);
11321149

1133-
const handleSetRowState = (state: Record<string | number, boolean>) =>
1134-
setRowState({ ...rowsStates, ...state });
1150+
const handleSetRowState = useCallback(
1151+
(state: Record<string | number, boolean>) =>
1152+
setRowState((prev) => ({ ...prev, ...state })),
1153+
[],
1154+
);
1155+
1156+
const rowsContextValue = useMemo<
1157+
[Record<string, boolean>, (val: Record<string, boolean>) => void]
1158+
>(() => [rowsStates, handleSetRowState], [rowsStates, handleSetRowState]);
11351159

11361160
const hasNestedData = data != null ? data.some((d) => d.data) : false;
11371161

1162+
const tableHead = useMemo(() => {
1163+
if (data == null || isLoading || emptyContent) return null;
1164+
return (
1165+
<THead key="head" responsive={responsive}>
1166+
{header.map((hItem, i) => {
1167+
const value =
1168+
typeof hItem === 'string' || typeof hItem === 'number' ? hItem : hItem.value;
1169+
const label =
1170+
typeof hItem === 'string' || typeof hItem === 'number' ? hItem : hItem.label;
1171+
const cellContent =
1172+
sortableBy?.includes(value) && screenSize > ScreenSizes.L ? (
1173+
<div
1174+
className={cx(styles.headerWithArrows, {
1175+
[styles.activeHeader]: activeHeader?.key === value,
1176+
})}
1177+
onClick={() => (onClickHeader != null ? onClickHeader(value) : null)}>
1178+
<div
1179+
className={cx(styles.sortableIcons, {
1180+
[styles.up]:
1181+
activeHeader?.key === value && activeHeader?.direction === 'asc',
1182+
[styles.down]:
1183+
activeHeader?.key === value && activeHeader?.direction === 'desc',
1184+
})}>
1185+
<Icon name="chevron-up" />
1186+
<Icon name="chevron-down" />
1187+
</div>
1188+
<span>{label}</span>
1189+
</div>
1190+
) : (
1191+
label
1192+
);
1193+
const noZIndex =
1194+
xScrollAmount == null ||
1195+
(i === 0 && xScrollAmount === 0) ||
1196+
(i === header.length - 1 && xScrollAmount === 1);
1197+
return (
1198+
<TCell
1199+
key={value}
1200+
style={{ zIndex: noZIndex ? 'auto' : undefined }}
1201+
responsive={responsive}>
1202+
{cellContent}
1203+
{i === 0 && scrollable ? (
1204+
<div
1205+
className={styles.leftDivisor}
1206+
style={{
1207+
height: divisorHeight,
1208+
opacity: xScrollAmount != null && xScrollAmount > 0 ? 1 : 0,
1209+
}}
1210+
/>
1211+
) : null}
1212+
{i === header.length - 1 && scrollable ? (
1213+
<div
1214+
className={styles.rightDivisor}
1215+
style={{
1216+
height: divisorHeight,
1217+
opacity: xScrollAmount != null && xScrollAmount < 1 ? 1 : 0,
1218+
}}
1219+
/>
1220+
) : null}
1221+
</TCell>
1222+
);
1223+
})}
1224+
</THead>
1225+
);
1226+
}, [header, sortableBy, activeHeader, screenSize, onClickHeader, xScrollAmount, divisorHeight, scrollable, responsive, data, isLoading, emptyContent]);
1227+
1228+
const tableBody = useMemo(() => {
1229+
if (data == null || isLoading || emptyContent) return null;
1230+
return _generateTable({
1231+
data,
1232+
memoDataValues,
1233+
header,
1234+
childHeader,
1235+
onClickRow,
1236+
onEnterRow,
1237+
onExitRow,
1238+
clickable,
1239+
activeRow,
1240+
animated,
1241+
responsive,
1242+
});
1243+
}, [data, memoDataValues, header, childHeader, onClickRow, onEnterRow, onExitRow, clickable, activeRow, animated, responsive, isLoading, emptyContent]);
1244+
11381245
const tableContents =
1139-
data != null && !isLoading && !emptyContent
1140-
? [
1141-
<THead key="head" responsive={responsive}>
1142-
{header.map((hItem, i) => {
1143-
const value =
1144-
typeof hItem === 'string' || typeof hItem === 'number' ? hItem : hItem.value;
1145-
const label =
1146-
typeof hItem === 'string' || typeof hItem === 'number' ? hItem : hItem.label;
1147-
const cellContent =
1148-
sortableBy?.includes(value) && screenSize > ScreenSizes.L ? (
1149-
<div
1150-
className={cx(styles.headerWithArrows, {
1151-
[styles.activeHeader]: activeHeader?.key === value,
1152-
})}
1153-
onClick={() => (onClickHeader != null ? onClickHeader(value) : null)}>
1154-
<div
1155-
className={cx(styles.sortableIcons, {
1156-
[styles.up]:
1157-
activeHeader?.key === value && activeHeader?.direction === 'asc',
1158-
[styles.down]:
1159-
activeHeader?.key === value && activeHeader?.direction === 'desc',
1160-
})}>
1161-
<Icon name="chevron-up" />
1162-
<Icon name="chevron-down" />
1163-
</div>
1164-
<span>{label}</span>
1165-
</div>
1166-
) : (
1167-
label
1168-
);
1169-
const noZIndex =
1170-
xScrollAmount == null ||
1171-
(i === 0 && xScrollAmount === 0) ||
1172-
(i === header.length - 1 && xScrollAmount === 1);
1173-
return (
1174-
<TCell
1175-
key={value}
1176-
style={{ zIndex: noZIndex ? 'auto' : undefined }}
1177-
responsive={responsive}>
1178-
{cellContent}
1179-
{i === 0 && scrollable ? (
1180-
<div
1181-
className={styles.leftDivisor}
1182-
style={{
1183-
height: divisorHeight,
1184-
opacity: xScrollAmount != null && xScrollAmount > 0 ? 1 : 0,
1185-
}}
1186-
/>
1187-
) : null}
1188-
{i === header.length - 1 && scrollable ? (
1189-
<div
1190-
className={styles.rightDivisor}
1191-
style={{
1192-
height: divisorHeight,
1193-
opacity: xScrollAmount != null && xScrollAmount < 1 ? 1 : 0,
1194-
}}
1195-
/>
1196-
) : null}
1197-
</TCell>
1198-
);
1199-
})}
1200-
</THead>,
1201-
_generateTable({
1202-
data,
1203-
memoDataValues,
1204-
header,
1205-
childHeader,
1206-
onClickRow,
1207-
onEnterRow,
1208-
onExitRow,
1209-
clickable,
1210-
activeRow,
1211-
animated,
1212-
responsive,
1213-
}),
1214-
]
1215-
: children;
1246+
tableHead != null && tableBody != null ? [tableHead, tableBody] : children;
12161247

12171248
const transformedChildren =
12181249
screenSize <= ScreenSizes.L && responsive
@@ -1245,7 +1276,7 @@ export const Table = ({
12451276
},
12461277
className,
12471278
)}>
1248-
<RowsContext.Provider value={[rowsStates, handleSetRowState]}>
1279+
<RowsContext.Provider value={rowsContextValue}>
12491280
{run(() => {
12501281
if (header && isLoading) {
12511282
return <LoadingTable animated={animated} columns={header} rows={loadingRows} />;

0 commit comments

Comments
 (0)