Skip to content

Commit 0bbb23d

Browse files
committed
Relax List rowHeight constraint
1 parent 4f2a0f2 commit 0bbb23d

29 files changed

+1374
-291
lines changed

lib/components/grid/Grid.test.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
44
import { EMPTY_OBJECT } from "../../../src/constants";
55
import {
66
disableForCurrentTest,
7-
updateMockResizeObserver
7+
setDefaultElementSize
88
} from "../../utils/test/mockResizeObserver";
99
import { Grid } from "./Grid";
1010
import type { CellComponentProps, GridImperativeAPI } from "./types";
@@ -35,7 +35,7 @@ describe("Grid", () => {
3535
beforeEach(() => {
3636
CellComponent.mockReset();
3737

38-
updateMockResizeObserver(new DOMRect(0, 0, 100, 40));
38+
setDefaultElementSize({ height: 40, width: 100 });
3939

4040
mountedCells = new Map();
4141
});
@@ -272,7 +272,6 @@ describe("Grid", () => {
272272

273273
const items = screen.queryAllByRole("gridcell");
274274
expect(items).toHaveLength(8);
275-
// TODO
276275
});
277276

278277
test("should call onCellsRendered", () => {

lib/components/grid/Grid.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function Grid<
4848
const isRtl = useIsRtl(element, dir);
4949

5050
const {
51-
getCellBounds: getColumnBounds,
51+
cachedBounds: cachedColumnBounds,
5252
getEstimatedSize: getEstimatedWidth,
5353
startIndexOverscan: columnStartIndexOverscan,
5454
startIndexVisible: columnStartIndexVisible,
@@ -68,7 +68,7 @@ export function Grid<
6868
});
6969

7070
const {
71-
getCellBounds: getRowBounds,
71+
cachedBounds: cachedRowBounds,
7272
getEstimatedSize: getEstimatedHeight,
7373
startIndexOverscan: rowStartIndexOverscan,
7474
startIndexVisible: rowStartIndexVisible,
@@ -218,7 +218,8 @@ export function Grid<
218218
rowIndex <= rowStopIndexOverscan;
219219
rowIndex++
220220
) {
221-
const rowBounds = getRowBounds(rowIndex);
221+
const rowBounds = cachedRowBounds.getItemBounds(rowIndex);
222+
const rowOffset = rowBounds?.scrollOffset ?? 0;
222223

223224
const columns: ReactNode[] = [];
224225

@@ -227,7 +228,8 @@ export function Grid<
227228
columnIndex <= columnStopIndexOverscan;
228229
columnIndex++
229230
) {
230-
const columnBounds = getColumnBounds(columnIndex);
231+
const columnBounds = cachedColumnBounds.getItemBounds(columnIndex);
232+
const columnOffset = columnBounds?.scrollOffset ?? 0;
231233

232234
columns.push(
233235
<CellComponent
@@ -243,9 +245,9 @@ export function Grid<
243245
position: "absolute",
244246
left: isRtl ? undefined : 0,
245247
right: isRtl ? 0 : undefined,
246-
transform: `translate(${isRtl ? -columnBounds.scrollOffset : columnBounds.scrollOffset}px, ${rowBounds.scrollOffset}px)`,
247-
height: rowBounds.size,
248-
width: columnBounds.size
248+
transform: `translate(${isRtl ? -columnOffset : columnOffset}px, ${rowOffset}px)`,
249+
height: rowBounds?.size,
250+
width: columnBounds?.size
249251
}}
250252
/>
251253
);
@@ -260,13 +262,13 @@ export function Grid<
260262
}
261263
return children;
262264
}, [
265+
cachedColumnBounds,
266+
cachedRowBounds,
263267
CellComponent,
264268
cellProps,
265269
columnCount,
266270
columnStartIndexOverscan,
267271
columnStopIndexOverscan,
268-
getColumnBounds,
269-
getRowBounds,
270272
isRtl,
271273
rowCount,
272274
rowStartIndexOverscan,

lib/components/list/List.test.tsx

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { act, render, screen } from "@testing-library/react";
2+
import { assert } from "../../utils/assert";
23
import { createRef, useLayoutEffect } from "react";
34
import { beforeEach, describe, expect, test, vi } from "vitest";
45
import { EMPTY_OBJECT } from "../../../src/constants";
56
import {
67
disableForCurrentTest,
7-
updateMockResizeObserver
8+
setDefaultElementSize,
9+
setElementSize,
10+
setElementSizeFunction
811
} from "../../utils/test/mockResizeObserver";
912
import { List } from "./List";
1013
import { type ListImperativeAPI, type RowComponentProps } from "./types";
@@ -33,7 +36,7 @@ describe("List", () => {
3336
beforeEach(() => {
3437
RowComponent.mockReset();
3538

36-
updateMockResizeObserver(new DOMRect(0, 0, 50, 100));
39+
setDefaultElementSize({ height: 100, width: 50 });
3740

3841
mountedRows = new Map();
3942
});
@@ -55,7 +58,7 @@ describe("List", () => {
5558
test("should render enough rows to fill the available height", () => {
5659
const onResize = vi.fn();
5760

58-
render(
61+
const { container } = render(
5962
<List
6063
onResize={onResize}
6164
overscanCount={0}
@@ -84,7 +87,14 @@ describe("List", () => {
8487
);
8588

8689
act(() => {
87-
updateMockResizeObserver(new DOMRect(0, 0, 50, 75));
90+
const listElement = container.querySelector<HTMLElement>('[role="list"]');
91+
assert(listElement !== null);
92+
93+
setElementSize({
94+
element: listElement,
95+
height: 75,
96+
width: 50
97+
});
8898
});
8999

90100
items = screen.queryAllByRole("listitem");
@@ -106,7 +116,7 @@ describe("List", () => {
106116
});
107117

108118
test("should render enough rows to fill the available height with overscan", () => {
109-
render(
119+
const { container } = render(
110120
<List
111121
overscanCount={2}
112122
rowCount={100}
@@ -122,7 +132,14 @@ describe("List", () => {
122132
expect(items[5]).toHaveTextContent("Row 5");
123133

124134
act(() => {
125-
updateMockResizeObserver(new DOMRect(0, 0, 50, 75));
135+
const listElement = container.querySelector<HTMLElement>('[role="list"]');
136+
assert(listElement !== null);
137+
138+
setElementSize({
139+
element: listElement,
140+
height: 75,
141+
width: 50
142+
});
126143
});
127144

128145
items = screen.queryAllByRole("listitem");
@@ -538,6 +555,30 @@ describe("List", () => {
538555

539556
expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(4);
540557
});
558+
559+
// Most dynamic size tests are in useVirtualizer
560+
test("type: dynamic (lazily measured)", () => {
561+
setElementSizeFunction((element) => {
562+
const attribute = element.getAttribute("data-react-window-index");
563+
if (attribute !== null) {
564+
const index = parseInt(attribute);
565+
if (!Number.isNaN(index)) {
566+
return new DOMRect(0, 0, 100, (index + 1) * 5);
567+
}
568+
}
569+
});
570+
571+
const { container } = render(
572+
<List
573+
overscanCount={0}
574+
rowCount={50}
575+
rowComponent={RowComponent}
576+
rowProps={EMPTY_OBJECT}
577+
/>
578+
);
579+
580+
expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(6);
581+
});
541582
});
542583

543584
describe("edge cases", () => {

lib/components/list/List.tsx

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
useImperativeHandle,
66
useMemo,
77
useState,
8+
type CSSProperties,
89
type ReactNode
910
} from "react";
1011
import { useVirtualizer } from "../../core/useVirtualizer";
@@ -41,7 +42,7 @@ export function List<
4142
const [element, setElement] = useState<HTMLDivElement | null>(null);
4243

4344
const {
44-
getCellBounds,
45+
cachedBounds,
4546
getEstimatedSize,
4647
scrollToIndex,
4748
startIndexOverscan,
@@ -113,6 +114,13 @@ export function List<
113114
stopIndexVisible
114115
]);
115116

117+
const hasRowHeight = rowHeight !== undefined;
118+
119+
const offset =
120+
startIndexOverscan >= 0
121+
? (cachedBounds.getItemBounds(startIndexOverscan)?.scrollOffset ?? 0)
122+
: 0;
123+
116124
const rows = useMemo(() => {
117125
const children: ReactNode[] = [];
118126
if (rowCount > 0) {
@@ -121,7 +129,23 @@ export function List<
121129
index <= stopIndexOverscan;
122130
index++
123131
) {
124-
const bounds = getCellBounds(index);
132+
const bounds = cachedBounds.getItemBounds(index);
133+
134+
let style: CSSProperties = {};
135+
if (bounds) {
136+
style = {
137+
height: hasRowHeight ? bounds.size : undefined,
138+
width: "100%"
139+
};
140+
} else {
141+
style = {
142+
width: "100%"
143+
};
144+
}
145+
146+
if (index === startIndexOverscan) {
147+
style.marginTop = `${offset}px`;
148+
}
125149

126150
children.push(
127151
<RowComponent
@@ -133,21 +157,17 @@ export function List<
133157
}}
134158
key={index}
135159
index={index}
136-
style={{
137-
position: "absolute",
138-
left: 0,
139-
transform: `translateY(${bounds.scrollOffset}px)`,
140-
height: bounds.size,
141-
width: "100%"
142-
}}
160+
style={style}
143161
/>
144162
);
145163
}
146164
}
147165
return children;
148166
}, [
149167
RowComponent,
150-
getCellBounds,
168+
cachedBounds,
169+
hasRowHeight,
170+
offset,
151171
rowCount,
152172
rowProps,
153173
startIndexOverscan,

lib/components/list/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,15 @@ export type ListProps<
9595
* - number of pixels (number)
9696
* - percentage of the grid's current height (string)
9797
* - function that returns the row height (in pixels) given an index and `cellProps`
98+
*
99+
* If this property is not provided, a ResizeObserver will be used to measure the height of a row's content.
100+
*
101+
* ⚠️ Row height should be provided if it is known ahead of time as that is the most efficient way to render the list.
98102
*/
99-
rowHeight: number | string | ((index: number, cellProps: RowProps) => number);
103+
rowHeight?:
104+
| number
105+
| string
106+
| ((index: number, cellProps: RowProps) => number);
100107

101108
/**
102109
* Additional props to be passed to the row-rendering component.

lib/core/createCachedBounds.test.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ describe("createCachedBounds", () => {
1313
expect(itemSize).not.toHaveBeenCalled();
1414
expect(cachedBounds.size).toBe(0);
1515

16-
expect(cachedBounds.get(2)).toEqual({
16+
expect(cachedBounds.getItemBounds(2)).toEqual({
1717
scrollOffset: 21,
1818
size: 12
1919
});
2020
expect(itemSize).toHaveBeenCalledTimes(3);
2121
expect(cachedBounds.size).toBe(3);
2222

23-
expect(cachedBounds.get(3)).toEqual({
23+
expect(cachedBounds.getItemBounds(3)).toEqual({
2424
scrollOffset: 33,
2525
size: 13
2626
});
@@ -40,13 +40,13 @@ describe("createCachedBounds", () => {
4040
expect(itemSize).not.toHaveBeenCalled();
4141
expect(cachedBounds.size).toBe(0);
4242

43-
cachedBounds.get(9);
43+
cachedBounds.getItemBounds(9);
4444

4545
expect(itemSize).toHaveBeenCalledTimes(10);
4646
expect(cachedBounds.size).toBe(10);
4747

4848
for (let index = 0; index < 10; index++) {
49-
cachedBounds.get(index);
49+
cachedBounds.getItemBounds(index);
5050
}
5151

5252
expect(itemSize).toHaveBeenCalledTimes(10);
@@ -63,7 +63,64 @@ describe("createCachedBounds", () => {
6363
expect(cachedBounds.size).toBe(0);
6464

6565
expect(() => {
66-
cachedBounds.get(1);
66+
cachedBounds.getItemBounds(1);
6767
}).toThrow("Invalid index 1");
6868
});
69+
70+
test("should gracefully handle undefined item sizes", () => {
71+
const cachedBounds = createCachedBounds({
72+
itemCount: 10,
73+
itemProps: {},
74+
itemSize: undefined
75+
});
76+
77+
cachedBounds.setItemSize(0, 10);
78+
79+
expect(cachedBounds.size).toBe(1);
80+
expect(cachedBounds.getItemBounds(0)).toEqual({
81+
scrollOffset: 0,
82+
size: 10
83+
});
84+
85+
cachedBounds.setItemSize(1, 20);
86+
87+
expect(cachedBounds.size).toBe(2);
88+
expect(cachedBounds.getItemBounds(1)).toEqual({
89+
scrollOffset: 10,
90+
size: 20
91+
});
92+
});
93+
94+
test("should gracefully handle sparsely populated cache", () => {
95+
const cachedBounds = createCachedBounds({
96+
itemCount: 5,
97+
itemProps: {},
98+
itemSize: undefined
99+
});
100+
101+
expect(cachedBounds.getEstimatedSize()).toBeUndefined();
102+
103+
cachedBounds.setItemSize(0, 10);
104+
cachedBounds.setItemSize(2, 20);
105+
cachedBounds.setItemSize(4, 30);
106+
107+
// Estimated average should be based on measured cells
108+
expect(cachedBounds.getEstimatedSize()).toBe(20);
109+
110+
// Bounds offsets based on measured cells; gaps should be filled in by averages
111+
expect(cachedBounds.getItemBounds(0)).toEqual({
112+
scrollOffset: 0,
113+
size: 10
114+
});
115+
expect(cachedBounds.getItemBounds(1)).toBeUndefined();
116+
expect(cachedBounds.getItemBounds(2)).toEqual({
117+
scrollOffset: 30,
118+
size: 20
119+
});
120+
expect(cachedBounds.getItemBounds(3)).toBeUndefined();
121+
expect(cachedBounds.getItemBounds(4)).toEqual({
122+
scrollOffset: 70,
123+
size: 30
124+
});
125+
});
69126
});

0 commit comments

Comments
 (0)