Skip to content

Commit 5a92744

Browse files
committed
Relax List rowHeight constraint
1 parent d1af943 commit 5a92744

21 files changed

+1001
-101
lines changed

lib/components/list/List.tsx

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
useImperativeHandle,
55
useMemo,
66
useState,
7+
type CSSProperties,
78
type ReactNode
89
} from "react";
910
import { useVirtualizer } from "../../core/useVirtualizer";
@@ -35,7 +36,7 @@ export function List<RowProps extends object>({
3536
const [element, setElement] = useState<HTMLDivElement | null>(null);
3637

3738
const {
38-
getCellBounds,
39+
cachedBounds,
3940
getEstimatedSize,
4041
scrollToIndex,
4142
startIndex,
@@ -87,30 +88,53 @@ export function List<RowProps extends object>({
8788
}
8889
}, [onRowsRendered, startIndex, stopIndex]);
8990

91+
const hasRowHeight = rowHeight !== undefined;
92+
9093
const rows = useMemo(() => {
9194
const children: ReactNode[] = [];
9295
if (rowCount > 0) {
9396
for (let index = startIndex; index <= stopIndex; index++) {
94-
const bounds = getCellBounds(index);
97+
const bounds = cachedBounds.getItemBounds(index);
98+
99+
let style: CSSProperties = {};
100+
if (bounds) {
101+
style = {
102+
// position: "absolute",
103+
// left: 0,
104+
//transform: `translateY(${bounds.scrollOffset}px)`,
105+
height: hasRowHeight ? bounds.size : undefined,
106+
width: "100%"
107+
};
108+
} else {
109+
style = {
110+
// position: "absolute",
111+
// left: 0,
112+
width: "100%"
113+
};
114+
}
95115

96116
children.push(
97117
<RowComponent
98118
{...(rowProps as RowProps)}
99119
key={index}
100120
index={index}
101-
style={{
102-
position: "absolute",
103-
left: 0,
104-
transform: `translateY(${bounds.scrollOffset}px)`,
105-
height: bounds.size,
106-
width: "100%"
107-
}}
121+
style={style}
108122
/>
109123
);
110124
}
111125
}
112126
return children;
113-
}, [RowComponent, getCellBounds, rowCount, rowProps, startIndex, stopIndex]);
127+
}, [
128+
RowComponent,
129+
cachedBounds,
130+
hasRowHeight,
131+
rowCount,
132+
rowProps,
133+
startIndex,
134+
stopIndex
135+
]);
136+
137+
const offset = cachedBounds.getItemBounds(startIndex)?.scrollOffset ?? 0;
114138

115139
return (
116140
<div
@@ -128,6 +152,7 @@ export function List<RowProps extends object>({
128152
className={className}
129153
style={{
130154
height: getEstimatedSize(),
155+
paddingTop: `${offset}px`,
131156
position: "relative",
132157
width: "100%"
133158
}}

lib/components/list/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,15 @@ export type ListProps<RowProps extends object> = Omit<
8080
* - number of pixels (number)
8181
* - percentage of the grid's current height (string)
8282
* - function that returns the row height (in pixels) given an index and `cellProps`
83+
*
84+
* If this property is not provided, a ResizeObserver will be used to measure the height of a row's content.
85+
*
86+
* ⚠️ Row height should be provided if it is known ahead of time as that is the most efficient way to render the list.
8387
*/
84-
rowHeight: number | string | ((index: number, cellProps: RowProps) => number);
88+
rowHeight?:
89+
| number
90+
| string
91+
| ((index: number, cellProps: RowProps) => number);
8592

8693
/**
8794
* 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
}

lib/core/getEstimatedSize.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe("getEstimatedSize", () => {
4141
itemProps: EMPTY_OBJECT,
4242
itemSize
4343
});
44-
cachedBounds.get(4);
44+
cachedBounds.getItemBounds(4);
4545

4646
expect(
4747
getEstimatedSize({
@@ -59,7 +59,7 @@ describe("getEstimatedSize", () => {
5959
itemSize
6060
});
6161

62-
cachedBounds.get(9);
62+
cachedBounds.getItemBounds(9);
6363

6464
expect(
6565
getEstimatedSize({
@@ -86,4 +86,48 @@ describe("getEstimatedSize", () => {
8686
).toBe(250);
8787
});
8888
});
89+
90+
describe("itemSize: undefined (lazily measured)", () => {
91+
test("should return undefined if no measurements have been taken", () => {
92+
expect(
93+
getEstimatedSize({
94+
cachedBounds: createCachedBounds({
95+
itemCount: 10,
96+
itemProps: EMPTY_OBJECT,
97+
itemSize: undefined
98+
}),
99+
itemCount: 10,
100+
itemSize: undefined
101+
})
102+
).toBeUndefined();
103+
});
104+
105+
test("should return an estimated size based on measurements that have been taken", () => {
106+
const cachedBounds = createCachedBounds({
107+
itemCount: 0,
108+
itemProps: EMPTY_OBJECT,
109+
itemSize: undefined
110+
});
111+
cachedBounds.setItemSize(0, 10);
112+
cachedBounds.setItemSize(1, 20);
113+
114+
expect(
115+
getEstimatedSize({
116+
cachedBounds,
117+
itemCount: 10,
118+
itemSize: undefined
119+
})
120+
).toBe(150);
121+
122+
cachedBounds.setItemSize(2, 30);
123+
124+
expect(
125+
getEstimatedSize({
126+
cachedBounds,
127+
itemCount: 10,
128+
itemSize: undefined
129+
})
130+
).toBe(200);
131+
});
132+
});
89133
});

0 commit comments

Comments
 (0)