Skip to content

Commit 4d71210

Browse files
authored
Relax List rowHeight constraint (alt) (#857)
1 parent 1220d5c commit 4d71210

28 files changed

+1274
-149
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## 2.2.0
4+
5+
- Support for dynamic row heights via new `useDynamicRowHeight` hook.
6+
7+
```tsx
8+
const rowHeight = useDynamicRowHeight({
9+
defaultRowHeight: 50
10+
});
11+
12+
return <List rowHeight={rowHeight} {...rest} />;
13+
```
14+
15+
- Smaller NPM bundle; (docs are no longer included as part of the bundle due to the added size)
16+
317
## 2.1.2
418

519
Prevent `ResizeObserver` API from being called at all if an explicit `List` height (or `Grid` width and height) is provided.

README.md

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,6 @@ npm install react-window
2828

2929
Documentation for this project is available at [react-window.vercel.app](https://react-window.vercel.app/).
3030

31-
Each release also ships with its own copy of the documentation (in the `docs` folder) which can be viewed by running:
32-
33-
```sh
34-
# From the package directory
35-
npx serve -s docs
36-
37-
# Or as an NPM-installed dependency
38-
npx serve -s ./node_modules/react-window/docs
39-
```
40-
4131
> **Note**: Older version 1.x documentation can be found at [react-window-v1.vercel.app](https://react-window-v1.vercel.app/) or on the NPM page for a specific version, e.g. [1.8.11](https://www.npmjs.com/package/react-window/v/1.8.11).)
4232
4333
## TypeScript types

lib/components/list/List.test.tsx

Lines changed: 123 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,126 @@ describe("List", () => {
555557

556558
expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(4);
557559
});
560+
561+
describe("type: DynamicRowHeight", () => {
562+
let onRowsRendered: ReturnType<typeof vi.fn>;
563+
564+
function Example() {
565+
const rowHeight = useDynamicRowHeight({
566+
defaultRowHeight: 25
567+
});
568+
return (
569+
<List
570+
defaultHeight={100}
571+
overscanCount={0}
572+
onRowsRendered={onRowsRendered}
573+
rowCount={10}
574+
rowComponent={RowComponent}
575+
rowHeight={rowHeight}
576+
rowProps={EMPTY_OBJECT}
577+
/>
578+
);
579+
}
580+
581+
function setMockRowHeights(
582+
indexToHeight: Map<number, number>,
583+
defaultHeight: number = 25
584+
) {
585+
setElementSizeFunction((element) => {
586+
const attribute = element.getAttribute(DATA_ATTRIBUTE_LIST_INDEX);
587+
if (attribute !== null) {
588+
const index = parseInt(attribute);
589+
const height = indexToHeight.get(index) ?? defaultHeight;
590+
return new DOMRect(0, 0, 100, height);
591+
}
592+
});
593+
}
594+
595+
beforeEach(() => {
596+
onRowsRendered = vi.fn();
597+
});
598+
599+
test("initial measuring", () => {
600+
setMockRowHeights(
601+
new Map([
602+
[0, 20],
603+
[1, 40],
604+
[2, 60],
605+
[3, 80]
606+
])
607+
);
608+
609+
const { container } = render(<Example />);
610+
611+
// 4 rows based on initial estimate
612+
// 3 rows after actual sizes have been measured
613+
expect(onRowsRendered).toHaveBeenCalledTimes(2);
614+
expect(onRowsRendered).nthCalledWith(
615+
1,
616+
{
617+
startIndex: 0,
618+
stopIndex: 3
619+
},
620+
{
621+
startIndex: 0,
622+
stopIndex: 3
623+
}
624+
);
625+
expect(onRowsRendered).nthCalledWith(
626+
2,
627+
{
628+
startIndex: 0,
629+
stopIndex: 2
630+
},
631+
{
632+
startIndex: 0,
633+
stopIndex: 2
634+
}
635+
);
636+
637+
expect(
638+
container.querySelector<HTMLDivElement>("[aria-hidden]")?.style.height
639+
).toBe("500px");
640+
});
641+
642+
test("caching and invalidation", () => {
643+
setMockRowHeights(
644+
new Map([
645+
[0, 20],
646+
[1, 40],
647+
[2, 60],
648+
[3, 80]
649+
])
650+
);
651+
652+
render(<Example />);
653+
654+
expect(RowComponent).toHaveBeenCalledTimes(4 + 3);
655+
656+
RowComponent.mockReset();
657+
658+
act(() => {
659+
setMockRowHeights(
660+
new Map([
661+
[0, 20],
662+
[1, 50], // Changed
663+
[2, 60],
664+
[3, 80]
665+
])
666+
);
667+
});
668+
669+
// Only the row that has been nudged down should be re-rendered;
670+
// the other two should be memoized
671+
expect(RowComponent).toHaveBeenCalledTimes(1);
672+
expect(RowComponent).toHaveBeenLastCalledWith(
673+
expect.objectContaining({
674+
index: 2
675+
}),
676+
undefined
677+
);
678+
});
679+
});
558680
});
559681

560682
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)