Skip to content

Commit ddb4fb3

Browse files
committed
Relax List rowHeight constraint
1 parent 0727e22 commit ddb4fb3

23 files changed

+1010
-110
lines changed

lib/components/grid/Grid.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useMemoizedObject } from "../../hooks/useMemoizedObject";
1313
import type { Align, TagNames } from "../../types";
1414
import { arePropsEqual } from "../../utils/arePropsEqual";
1515
import type { GridProps } from "./types";
16+
import { assert } from "../../utils/assert";
1617

1718
export function Grid<
1819
CellProps extends object,
@@ -48,7 +49,7 @@ export function Grid<
4849
const isRtl = useIsRtl(element, dir);
4950

5051
const {
51-
getCellBounds: getColumnBounds,
52+
cachedBounds: cachedColumnBounds,
5253
getEstimatedSize: getEstimatedWidth,
5354
startIndexOverscan: columnStartIndexOverscan,
5455
startIndexVisible: columnStartIndexVisible,
@@ -68,7 +69,7 @@ export function Grid<
6869
});
6970

7071
const {
71-
getCellBounds: getRowBounds,
72+
cachedBounds: cachedRowBounds,
7273
getEstimatedSize: getEstimatedHeight,
7374
startIndexOverscan: rowStartIndexOverscan,
7475
startIndexVisible: rowStartIndexVisible,
@@ -218,7 +219,11 @@ export function Grid<
218219
rowIndex <= rowStopIndexOverscan;
219220
rowIndex++
220221
) {
221-
const rowBounds = getRowBounds(rowIndex);
222+
const rowBounds = cachedRowBounds.getItemBounds(rowIndex);
223+
assert(
224+
rowBounds !== undefined,
225+
`Bounds not found for row at index ${rowIndex}`
226+
);
222227

223228
const columns: ReactNode[] = [];
224229

@@ -227,7 +232,11 @@ export function Grid<
227232
columnIndex <= columnStopIndexOverscan;
228233
columnIndex++
229234
) {
230-
const columnBounds = getColumnBounds(columnIndex);
235+
const columnBounds = cachedColumnBounds.getItemBounds(columnIndex);
236+
assert(
237+
columnBounds !== undefined,
238+
`Bounds not found for column at index ${columnIndex}`
239+
);
231240

232241
columns.push(
233242
<CellComponent
@@ -260,13 +269,13 @@ export function Grid<
260269
}
261270
return children;
262271
}, [
272+
cachedColumnBounds,
273+
cachedRowBounds,
263274
CellComponent,
264275
cellProps,
265276
columnCount,
266277
columnStartIndexOverscan,
267278
columnStopIndexOverscan,
268-
getColumnBounds,
269-
getRowBounds,
270279
isRtl,
271280
rowCount,
272281
rowStartIndexOverscan,

lib/components/list/List.tsx

Lines changed: 27 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,8 @@ export function List<
113114
stopIndexVisible
114115
]);
115116

117+
const hasRowHeight = rowHeight !== undefined;
118+
116119
const rows = useMemo(() => {
117120
const children: ReactNode[] = [];
118121
if (rowCount > 0) {
@@ -121,7 +124,24 @@ export function List<
121124
index <= stopIndexOverscan;
122125
index++
123126
) {
124-
const bounds = getCellBounds(index);
127+
const bounds = cachedBounds.getItemBounds(index);
128+
129+
let style: CSSProperties = {};
130+
if (bounds) {
131+
style = {
132+
// position: "absolute",
133+
// left: 0,
134+
//transform: `translateY(${bounds.scrollOffset}px)`,
135+
height: hasRowHeight ? bounds.size : undefined,
136+
width: "100%"
137+
};
138+
} else {
139+
style = {
140+
// position: "absolute",
141+
// left: 0,
142+
width: "100%"
143+
};
144+
}
125145

126146
children.push(
127147
<RowComponent
@@ -133,27 +153,24 @@ export function List<
133153
}}
134154
key={index}
135155
index={index}
136-
style={{
137-
position: "absolute",
138-
left: 0,
139-
transform: `translateY(${bounds.scrollOffset}px)`,
140-
height: bounds.size,
141-
width: "100%"
142-
}}
156+
style={style}
143157
/>
144158
);
145159
}
146160
}
147161
return children;
148162
}, [
149163
RowComponent,
150-
getCellBounds,
164+
cachedBounds,
165+
hasRowHeight,
151166
rowCount,
152167
rowProps,
153168
startIndexOverscan,
154169
stopIndexOverscan
155170
]);
156171

172+
// const offset = cachedBounds.getItemBounds(startIndexOverscan)?.scrollOffset ?? 0;
173+
157174
const sizingElement = (
158175
<div
159176
aria-hidden

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: 29 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,31 @@ 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+
});
6993
});

lib/core/createCachedBounds.ts

Lines changed: 92 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,62 +8,119 @@ export function createCachedBounds<Props extends object>({
88
}: {
99
itemCount: number;
1010
itemProps: Props;
11-
itemSize: number | SizeFunction<Props>;
11+
itemSize: number | SizeFunction<Props> | undefined;
1212
}): CachedBounds {
1313
const cache = new Map<number, Bounds>();
1414

15-
return {
16-
get(index: number) {
15+
const api = {
16+
getEstimatedSize() {
17+
const lastBounds = cache.get(cache.size - 1);
18+
if (lastBounds) {
19+
return (lastBounds.scrollOffset + lastBounds.size) / cache.size;
20+
}
21+
},
22+
getItemBounds(index: number) {
1723
assert(index < itemCount, `Invalid index ${index}`);
1824

19-
while (cache.size - 1 < index) {
20-
const currentIndex = cache.size;
25+
if (itemSize) {
26+
while (cache.size - 1 < index) {
27+
const currentIndex = cache.size;
2128

22-
let size: number;
23-
switch (typeof itemSize) {
24-
case "function": {
25-
size = itemSize(currentIndex, itemProps);
26-
break;
29+
let size: number;
30+
switch (typeof itemSize) {
31+
case "function": {
32+
size = itemSize(currentIndex, itemProps);
33+
break;
34+
}
35+
case "number": {
36+
size = itemSize;
37+
break;
38+
}
2739
}
28-
case "number": {
29-
size = itemSize;
30-
break;
40+
41+
if (currentIndex === 0) {
42+
cache.set(currentIndex, {
43+
size,
44+
scrollOffset: 0
45+
});
46+
} else {
47+
const previousRowBounds = cache.get(currentIndex - 1);
48+
assert(
49+
previousRowBounds !== undefined,
50+
`Unexpected bounds cache miss for index ${index}`
51+
);
52+
53+
cache.set(currentIndex, {
54+
scrollOffset:
55+
previousRowBounds.scrollOffset + previousRowBounds.size,
56+
size
57+
});
3158
}
3259
}
3360

34-
if (currentIndex === 0) {
35-
cache.set(currentIndex, {
36-
size,
37-
scrollOffset: 0
38-
});
61+
const bounds = cache.get(index);
62+
assert(
63+
bounds !== undefined,
64+
`Unexpected bounds cache miss for index ${index}`
65+
);
66+
67+
return bounds;
68+
} else {
69+
return cache.get(index);
70+
}
71+
},
72+
hasItemBounds(index: number) {
73+
return cache.has(index);
74+
},
75+
setItemSize(index: number, size: number) {
76+
// Note this function assumes items are measured in sequence;
77+
// I think that's a safe assumption but if it turns out not to be we'll need to rethink things
78+
let scrollOffset = 0;
79+
if (index > 0) {
80+
if (cache.size >= index) {
81+
const bounds = cache.get(index - 1);
82+
assert(bounds, `Unexpected cache miss at index ${index - 1}`);
83+
84+
scrollOffset = bounds.scrollOffset + bounds.size;
3985
} else {
40-
const previousRowBounds = cache.get(currentIndex - 1);
86+
const lastBounds = cache.get(cache.size - 1);
4187
assert(
42-
previousRowBounds !== undefined,
43-
`Unexpected bounds cache miss for index ${index}`
88+
lastBounds,
89+
`Unexpected cache miss at index ${cache.size - 1}`
4490
);
4591

46-
cache.set(currentIndex, {
47-
scrollOffset:
48-
previousRowBounds.scrollOffset + previousRowBounds.size,
49-
size
50-
});
92+
const estimatedSize = api.getEstimatedSize();
93+
assert(
94+
estimatedSize !== undefined,
95+
"Expected at least one measurement"
96+
);
97+
98+
const numEstimated = index - cache.size;
99+
scrollOffset =
100+
lastBounds.scrollOffset +
101+
lastBounds.size +
102+
estimatedSize * numEstimated;
51103
}
52104
}
53105

54-
const bounds = cache.get(index);
55-
assert(
56-
bounds !== undefined,
57-
`Unexpected bounds cache miss for index ${index}`
58-
);
106+
cache.set(index, { scrollOffset, size });
59107

60-
return bounds;
61-
},
62-
set(index: number, bounds: Bounds) {
63-
cache.set(index, bounds);
108+
// Adjust offset for items afterward in the cache
109+
while (index < cache.size) {
110+
const bounds = cache.get(index);
111+
assert(bounds, `Unexpected cache miss at index ${index}`);
112+
113+
bounds.scrollOffset = scrollOffset;
114+
115+
scrollOffset += bounds.size;
116+
117+
index++;
118+
}
64119
},
65120
get size() {
66121
return cache.size;
67122
}
68123
};
124+
125+
return api;
69126
}

0 commit comments

Comments
 (0)