Skip to content

Commit 1ecf892

Browse files
committed
Relax List rowHeight constraint (alt)
1 parent 1220d5c commit 1ecf892

25 files changed

+1203
-138
lines changed

lib/components/list/List.test.tsx

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import {
77
disableResizeObserverForCurrentTest,
88
setDefaultElementSize,
99
setElementSize,
10+
setElementSizeFunction,
1011
simulateUnsupportedEnvironmentForTest
1112
} from "../../utils/test/mockResizeObserver";
12-
import { List } from "./List";
13+
import { DATA_ATTRIBUTE_LIST_INDEX, List } from "./List";
1314
import { type ListImperativeAPI, type RowComponentProps } from "./types";
15+
import { useDynamicRowHeight } from "./useDynamicRowHeight";
1416
import { useListCallbackRef } from "./useListCallbackRef";
1517

1618
describe("List", () => {
@@ -555,6 +557,70 @@ describe("List", () => {
555557

556558
expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(4);
557559
});
560+
561+
test("type: DynamicRowHeight", () => {
562+
const onRowsRendered = vi.fn();
563+
564+
function Example() {
565+
const rowHeight = useDynamicRowHeight({
566+
defaultRowHeight: 25
567+
});
568+
569+
return (
570+
<List
571+
defaultHeight={100}
572+
overscanCount={0}
573+
onRowsRendered={onRowsRendered}
574+
rowCount={10}
575+
rowComponent={RowComponent}
576+
rowHeight={rowHeight}
577+
rowProps={EMPTY_OBJECT}
578+
/>
579+
);
580+
}
581+
582+
setElementSizeFunction((element) => {
583+
const attribute = element.getAttribute(DATA_ATTRIBUTE_LIST_INDEX);
584+
if (attribute !== null) {
585+
const index = parseInt(attribute);
586+
if (!Number.isNaN(index)) {
587+
return new DOMRect(0, 0, 100, (index + 1) * 20);
588+
}
589+
}
590+
});
591+
592+
const { container } = render(<Example />);
593+
594+
// 4 rows based on initial estimate
595+
// 3 rows after actual sizes have been measured
596+
expect(onRowsRendered).toHaveBeenCalledTimes(2);
597+
expect(onRowsRendered).nthCalledWith(
598+
1,
599+
{
600+
startIndex: 0,
601+
stopIndex: 3
602+
},
603+
{
604+
startIndex: 0,
605+
stopIndex: 3
606+
}
607+
);
608+
expect(onRowsRendered).nthCalledWith(
609+
2,
610+
{
611+
startIndex: 0,
612+
stopIndex: 2
613+
},
614+
{
615+
startIndex: 0,
616+
stopIndex: 2
617+
}
618+
);
619+
620+
expect(
621+
container.querySelector<HTMLDivElement>("[aria-hidden]")?.style.height
622+
).toBe("500px");
623+
});
558624
});
559625

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

lib/components/list/List.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ import {
88
type ReactNode
99
} from "react";
1010
import { useVirtualizer } from "../../core/useVirtualizer";
11+
import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect";
1112
import { useMemoizedObject } from "../../hooks/useMemoizedObject";
1213
import type { Align, TagNames } from "../../types";
1314
import { arePropsEqual } from "../../utils/arePropsEqual";
15+
import { isDynamicRowHeight as isDynamicRowHeightUtil } from "./isDynamicRowHeight";
1416
import type { ListProps } from "./types";
1517

18+
export const DATA_ATTRIBUTE_LIST_INDEX = "data-react-window-index";
19+
1620
export function List<
1721
RowProps extends object,
1822
TagName extends TagNames = "div"
@@ -26,7 +30,7 @@ export function List<
2630
overscanCount = 3,
2731
rowComponent: RowComponentProp,
2832
rowCount,
29-
rowHeight,
33+
rowHeight: rowHeightProp,
3034
rowProps: rowPropsUnstable,
3135
tagName = "div" as TagName,
3236
style,
@@ -40,6 +44,21 @@ export function List<
4044

4145
const [element, setElement] = useState<HTMLDivElement | null>(null);
4246

47+
const isDynamicRowHeight = isDynamicRowHeightUtil(rowHeightProp);
48+
49+
const rowHeight = useMemo(() => {
50+
if (isDynamicRowHeight) {
51+
return (index: number) => {
52+
return (
53+
rowHeightProp.getRowHeight(index) ??
54+
rowHeightProp.getAverageRowHeight()
55+
);
56+
};
57+
}
58+
59+
return rowHeightProp;
60+
}, [isDynamicRowHeight, rowHeightProp]);
61+
4362
const {
4463
getCellBounds,
4564
getEstimatedSize,
@@ -93,6 +112,34 @@ export function List<
93112
[element, scrollToIndex]
94113
);
95114

115+
useIsomorphicLayoutEffect(() => {
116+
if (!element) {
117+
return;
118+
}
119+
120+
const rows = Array.from(element.children).filter((item, index) => {
121+
if (item.hasAttribute("aria-hidden")) {
122+
// Ignore sizing element
123+
return false;
124+
}
125+
126+
const attribute = `${startIndexOverscan + index}`;
127+
item.setAttribute(DATA_ATTRIBUTE_LIST_INDEX, attribute);
128+
129+
return true;
130+
});
131+
132+
if (isDynamicRowHeight) {
133+
return rowHeightProp.observeRowElements(rows);
134+
}
135+
}, [
136+
element,
137+
isDynamicRowHeight,
138+
rowHeightProp,
139+
startIndexOverscan,
140+
stopIndexOverscan
141+
]);
142+
96143
useEffect(() => {
97144
if (startIndexOverscan >= 0 && stopIndexOverscan >= 0 && onRowsRendered) {
98145
onRowsRendered(
@@ -138,7 +185,9 @@ export function List<
138185
position: "absolute",
139186
left: 0,
140187
transform: `translateY(${bounds.scrollOffset}px)`,
141-
height: bounds.size,
188+
// In case of dynamic row heights, don't specify a height style
189+
// otherwise a default/estimated height would mask the actual height
190+
height: isDynamicRowHeight ? undefined : bounds.size,
142191
width: "100%"
143192
}}
144193
/>
@@ -149,6 +198,7 @@ export function List<
149198
}, [
150199
RowComponent,
151200
getCellBounds,
201+
isDynamicRowHeight,
152202
rowCount,
153203
rowProps,
154204
startIndexOverscan,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { DynamicRowHeight } from "./types";
2+
3+
export function isDynamicRowHeight(value: unknown): value is DynamicRowHeight {
4+
return (
5+
value != null &&
6+
typeof value === "object" &&
7+
"getAverageRowHeight" in value &&
8+
typeof value.getAverageRowHeight === "function"
9+
);
10+
}

lib/components/list/types.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import type {
77
} from "react";
88
import type { TagNames } from "../../types";
99

10+
export type DynamicRowHeight = {
11+
getAverageRowHeight(): number;
12+
getRowHeight(index: number): number | undefined;
13+
setRowHeight(index: number, size: number): void;
14+
observeRowElements: (elements: Element[] | NodeListOf<Element>) => () => void;
15+
};
16+
1017
type ForbiddenKeys = "ariaAttributes" | "index" | "style";
1118
type ExcludeForbiddenKeys<Type> = {
1219
[Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key];
@@ -95,8 +102,16 @@ export type ListProps<
95102
* - number of pixels (number)
96103
* - percentage of the grid's current height (string)
97104
* - function that returns the row height (in pixels) given an index and `cellProps`
105+
* - dynamic row height cache returned by the `useDynamicRowHeight` hook
106+
*
107+
* ⚠️ Dynamic row heights are not as efficient as predetermined sizes.
108+
* It's recommended to provide your own height values if they can be determined ahead of time.
98109
*/
99-
rowHeight: number | string | ((index: number, cellProps: RowProps) => number);
110+
rowHeight:
111+
| number
112+
| string
113+
| ((index: number, cellProps: RowProps) => number)
114+
| DynamicRowHeight;
100115

101116
/**
102117
* Additional props to be passed to the row-rendering component.

0 commit comments

Comments
 (0)