Skip to content

Commit 90f3254

Browse files
[IndexTable] Improve reflow perf (#6840)
Reduce reflows in index table
1 parent 7dafdee commit 90f3254

File tree

7 files changed

+140
-84
lines changed

7 files changed

+140
-84
lines changed

.changeset/dry-trees-double.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/polaris': patch
3+
---
4+
5+
Removed additional reflows from IndexTable

polaris-react/src/components/IndexTable/IndexTable.tsx

Lines changed: 56 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ export interface TableHeadingRect {
8282
}
8383

8484
const SCROLL_BAR_PADDING = 4;
85-
const SIXTY_FPS = 1000 / 60;
8685
const SCROLL_BAR_DEBOUNCE_PERIOD = 300;
8786

8887
function IndexTableBase({
@@ -179,59 +178,52 @@ function IndexTableBase({
179178

180179
const resizeTableHeadings = useMemo(
181180
() =>
182-
debounce(
183-
() => {
184-
if (!tableElement.current || !scrollableContainerElement.current) {
185-
return;
186-
}
181+
debounce(() => {
182+
if (!tableElement.current || !scrollableContainerElement.current) {
183+
return;
184+
}
187185

188-
const boundingRect =
189-
scrollableContainerElement.current.getBoundingClientRect();
190-
tablePosition.current = {
191-
top: boundingRect.top,
192-
left: boundingRect.left,
193-
};
186+
const boundingRect =
187+
scrollableContainerElement.current.getBoundingClientRect();
188+
tablePosition.current = {
189+
top: boundingRect.top,
190+
left: boundingRect.left,
191+
};
194192

195-
tableHeadingRects.current = tableHeadings.current.map((heading) => ({
196-
offsetWidth: heading.offsetWidth || 0,
197-
offsetLeft: heading.offsetLeft || 0,
198-
}));
193+
tableHeadingRects.current = tableHeadings.current.map((heading) => ({
194+
offsetWidth: heading.offsetWidth || 0,
195+
offsetLeft: heading.offsetLeft || 0,
196+
}));
199197

200-
if (tableHeadings.current.length === 0) {
201-
return;
202-
}
198+
if (tableHeadings.current.length === 0) {
199+
return;
200+
}
203201

204-
// update left offset for first column
205-
if (selectable && tableHeadings.current.length > 1)
206-
tableHeadings.current[1].style.left = `${tableHeadingRects.current[0].offsetWidth}px`;
202+
// update left offset for first column
203+
if (selectable && tableHeadings.current.length > 1)
204+
tableHeadings.current[1].style.left = `${tableHeadingRects.current[0].offsetWidth}px`;
207205

208-
// update the min width of the checkbox to be the be the un-padded width of the first heading
209-
if (selectable && firstStickyHeaderElement?.current) {
210-
const elementStyle = getComputedStyle(tableHeadings.current[0]);
211-
const boxWidth = tableHeadings.current[0].offsetWidth;
212-
firstStickyHeaderElement.current.style.minWidth = `calc(${boxWidth}px - ${elementStyle.paddingLeft} - ${elementStyle.paddingRight} + 2px)`;
206+
// update the min width of the checkbox to be the be the un-padded width of the first heading
207+
if (selectable && firstStickyHeaderElement?.current) {
208+
const elementStyle = getComputedStyle(tableHeadings.current[0]);
209+
const boxWidth = tableHeadings.current[0].offsetWidth;
210+
firstStickyHeaderElement.current.style.minWidth = `calc(${boxWidth}px - ${elementStyle.paddingLeft} - ${elementStyle.paddingRight} + 2px)`;
211+
}
212+
213+
// update sticky header min-widths
214+
stickyTableHeadings.current.forEach((heading, index) => {
215+
let minWidth = 0;
216+
if (index === 0 && (!isBreakpointsXS() || !selectable)) {
217+
minWidth = calculateFirstHeaderOffset();
218+
} else if (selectable && tableHeadingRects.current.length > index) {
219+
minWidth = tableHeadingRects.current[index]?.offsetWidth || 0;
220+
} else if (!selectable && tableHeadingRects.current.length >= index) {
221+
minWidth = tableHeadingRects.current[index - 1]?.offsetWidth || 0;
213222
}
214223

215-
// update sticky header min-widths
216-
stickyTableHeadings.current.forEach((heading, index) => {
217-
let minWidth = 0;
218-
if (index === 0 && (!isBreakpointsXS() || !selectable)) {
219-
minWidth = calculateFirstHeaderOffset();
220-
} else if (selectable && tableHeadingRects.current.length > index) {
221-
minWidth = tableHeadingRects.current[index]?.offsetWidth || 0;
222-
} else if (
223-
!selectable &&
224-
tableHeadingRects.current.length >= index
225-
) {
226-
minWidth = tableHeadingRects.current[index - 1]?.offsetWidth || 0;
227-
}
228-
229-
heading.style.minWidth = `${minWidth}px`;
230-
});
231-
},
232-
SIXTY_FPS,
233-
{leading: true, trailing: true, maxWait: SIXTY_FPS},
234-
),
224+
heading.style.minWidth = `${minWidth}px`;
225+
});
226+
}),
235227
[calculateFirstHeaderOffset, selectable],
236228
);
237229

@@ -259,21 +251,25 @@ function IndexTableBase({
259251

260252
const [canScrollRight, setCanScrollRight] = useState(true);
261253

262-
const handleCanScrollRight = useCallback(() => {
263-
if (
264-
!lastColumnSticky ||
265-
!tableElement.current ||
266-
!scrollableContainerElement.current
267-
) {
268-
return;
269-
}
254+
// eslint-disable-next-line react-hooks/exhaustive-deps
255+
const handleCanScrollRight = useCallback(
256+
debounce(() => {
257+
if (
258+
!lastColumnSticky ||
259+
!tableElement.current ||
260+
!scrollableContainerElement.current
261+
) {
262+
return;
263+
}
270264

271-
const tableRect = tableElement.current.getBoundingClientRect();
272-
const scrollableRect =
273-
scrollableContainerElement.current.getBoundingClientRect();
265+
const tableRect = tableElement.current.getBoundingClientRect();
266+
const scrollableRect =
267+
scrollableContainerElement.current.getBoundingClientRect();
274268

275-
setCanScrollRight(tableRect.width > scrollableRect.width);
276-
}, [lastColumnSticky]);
269+
setCanScrollRight(tableRect.width > scrollableRect.width);
270+
}),
271+
[lastColumnSticky],
272+
);
277273

278274
useEffect(() => {
279275
handleCanScrollRight();

polaris-react/src/components/IndexTable/components/Checkbox/Checkbox.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import React, {
88
ReactNode,
99
} from 'react';
1010

11+
import {debounce} from '../../../../utilities/debounce';
1112
import {useI18n} from '../../../../utilities/i18n';
1213
import {classNames} from '../../../../utilities/css';
1314
import {RowContext} from '../../../../utilities/index-table';
@@ -58,14 +59,19 @@ interface CheckboxWrapperProps {
5859
}
5960

6061
export function CheckboxWrapper({children}: CheckboxWrapperProps) {
62+
const {position} = useContext(RowContext);
6163
const checkboxNode = useRef<HTMLTableDataCellElement>(null);
6264

63-
const handleResize = useCallback(() => {
64-
if (!checkboxNode.current) return;
65+
// eslint-disable-next-line react-hooks/exhaustive-deps
66+
const handleResize = useCallback(
67+
debounce(() => {
68+
if (position !== 0 || !checkboxNode.current) return;
6569

66-
const {width} = checkboxNode.current.getBoundingClientRect();
67-
setRootProperty('--pc-checkbox-offset', `${width}px`);
68-
}, []);
70+
const {width} = checkboxNode.current.getBoundingClientRect();
71+
setRootProperty('--pc-checkbox-offset', `${width}px`);
72+
}),
73+
[position],
74+
);
6975

7076
useEffect(() => {
7177
handleResize();

polaris-react/src/components/IndexTable/components/Checkbox/tests/Checkbox.test.tsx

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, {ReactElement} from 'react';
22
import type {ThenType} from '@shopify/useful-types';
33
import {mountWithApp} from 'tests/utilities';
44
import type {Element as ElementType} from '@shopify/react-testing';
5+
import {act} from 'react-dom/test-utils';
56

67
import {Checkbox as PolarisCheckbox} from '../../../../Checkbox';
78
import {IndexTable, IndexTableProps} from '../../../IndexTable';
@@ -11,6 +12,11 @@ import * as setRootPropertyModule from '../../../../../utilities/set-root-proper
1112

1213
const defaultId = 'id';
1314

15+
jest.mock('../../../../../utilities/debounce', () => ({
16+
...jest.requireActual('../../../../../utilities/debounce'),
17+
debounce: (callback: () => void) => () => callback(),
18+
}));
19+
1420
describe('<Checkbox />', () => {
1521
let getBoundingClientRectSpy: jest.SpyInstance;
1622
let setRootPropertySpy: jest.SpyInstance;
@@ -119,24 +125,58 @@ describe('<Checkbox />', () => {
119125
expect(checkbox).toContainReactComponent(CheckboxWrapper);
120126
});
121127

122-
it('sets `--pc-checkbox-offset` custom property', () => {
123-
mountWithTable(<Checkbox />, defaultTableProps);
124-
125-
expect(setRootPropertySpy).toHaveBeenLastCalledWith(
126-
'--pc-checkbox-offset',
127-
'0px',
128-
);
129-
});
130-
131-
it('updates `--pc-checkbox-offset` custom property on resize', () => {
132-
mountWithTable(<Checkbox />, defaultTableProps);
133-
setGetBoundingClientRect(200);
134-
window.dispatchEvent(new Event('resize'));
135-
136-
expect(setRootPropertySpy).toHaveBeenLastCalledWith(
137-
'--pc-checkbox-offset',
138-
'200px',
139-
);
128+
describe('--pc-checkbox-offset', () => {
129+
it('sets `--pc-checkbox-offset` custom property when position is 0', () => {
130+
mountWithTable(<Checkbox />, {
131+
...defaultTableProps,
132+
rowProps: {position: 0},
133+
});
134+
135+
expect(setRootPropertySpy).toHaveBeenLastCalledWith(
136+
'--pc-checkbox-offset',
137+
'0px',
138+
);
139+
});
140+
141+
it('updates `--pc-checkbox-offset` custom property on resize when position is 0', () => {
142+
mountWithTable(<Checkbox />, {
143+
...defaultTableProps,
144+
rowProps: {position: 0},
145+
});
146+
setGetBoundingClientRect(200);
147+
148+
act(() => {
149+
window.dispatchEvent(new Event('resize'));
150+
});
151+
152+
expect(setRootPropertySpy).toHaveBeenLastCalledWith(
153+
'--pc-checkbox-offset',
154+
'200px',
155+
);
156+
});
157+
158+
it('does not set `--pc-checkbox-offset` custom property when position is above 1', () => {
159+
mountWithTable(<Checkbox />, {
160+
...defaultTableProps,
161+
rowProps: {position: 1},
162+
});
163+
164+
expect(setRootPropertySpy).not.toHaveBeenCalled();
165+
});
166+
167+
it('does not update `--pc-checkbox-offset` custom property on resize when position is above 1', () => {
168+
mountWithTable(<Checkbox />, {
169+
...defaultTableProps,
170+
rowProps: {position: 1},
171+
});
172+
setGetBoundingClientRect(200);
173+
174+
act(() => {
175+
window.dispatchEvent(new Event('resize'));
176+
});
177+
178+
expect(setRootPropertySpy).not.toHaveBeenCalled();
179+
});
140180
});
141181

142182
it('renders table data', () => {

polaris-react/src/components/IndexTable/components/Row/Row.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,11 @@ export const Row = memo(function Row({
6262
() => ({
6363
itemId: id,
6464
selected,
65+
position,
6566
onInteraction: handleInteraction,
6667
disabled,
6768
}),
68-
[id, selected, disabled, handleInteraction],
69+
[id, selected, disabled, position, handleInteraction],
6970
);
7071

7172
const primaryLinkElement = useRef<HTMLAnchorElement | null>(null);

polaris-react/src/components/IndexTable/tests/IndexTable.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ jest.mock('../utilities', () => ({
2626
getTableHeadingsBySelector: jest.fn(),
2727
}));
2828

29+
jest.mock('../../../utilities/debounce', () => ({
30+
...jest.requireActual('../../../utilities/debounce'),
31+
debounce: (callback: () => void) => () => {
32+
callback();
33+
},
34+
}));
35+
2936
const mockTableItems = [
3037
{
3138
id: 'item-1',

polaris-react/src/utilities/index-table/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ interface RowContextType {
44
itemId?: string;
55
selected?: boolean;
66
disabled?: boolean;
7+
position?: number;
78
onInteraction?: (event: React.MouseEvent | React.KeyboardEvent) => void;
89
}
910

0 commit comments

Comments
 (0)