Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions lib/components/grid/Grid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { EMPTY_OBJECT } from "../../../src/constants";
import {
disableForCurrentTest,
updateMockResizeObserver
setDefaultElementSize
} from "../../utils/test/mockResizeObserver";
import { Grid } from "./Grid";
import type { CellComponentProps, GridImperativeAPI } from "./types";
Expand Down Expand Up @@ -35,7 +35,7 @@ describe("Grid", () => {
beforeEach(() => {
CellComponent.mockReset();

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

mountedCells = new Map();
});
Expand Down Expand Up @@ -272,7 +272,6 @@ describe("Grid", () => {

const items = screen.queryAllByRole("gridcell");
expect(items).toHaveLength(8);
// TODO
});

test("should call onCellsRendered", () => {
Expand Down
20 changes: 11 additions & 9 deletions lib/components/grid/Grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function Grid<
const isRtl = useIsRtl(element, dir);

const {
getCellBounds: getColumnBounds,
cachedBounds: cachedColumnBounds,
getEstimatedSize: getEstimatedWidth,
startIndexOverscan: columnStartIndexOverscan,
startIndexVisible: columnStartIndexVisible,
Expand All @@ -68,7 +68,7 @@ export function Grid<
});

const {
getCellBounds: getRowBounds,
cachedBounds: cachedRowBounds,
getEstimatedSize: getEstimatedHeight,
startIndexOverscan: rowStartIndexOverscan,
startIndexVisible: rowStartIndexVisible,
Expand Down Expand Up @@ -218,7 +218,8 @@ export function Grid<
rowIndex <= rowStopIndexOverscan;
rowIndex++
) {
const rowBounds = getRowBounds(rowIndex);
const rowBounds = cachedRowBounds.getItemBounds(rowIndex);
const rowOffset = rowBounds?.scrollOffset ?? 0;

const columns: ReactNode[] = [];

Expand All @@ -227,7 +228,8 @@ export function Grid<
columnIndex <= columnStopIndexOverscan;
columnIndex++
) {
const columnBounds = getColumnBounds(columnIndex);
const columnBounds = cachedColumnBounds.getItemBounds(columnIndex);
const columnOffset = columnBounds?.scrollOffset ?? 0;

columns.push(
<CellComponent
Expand All @@ -243,9 +245,9 @@ export function Grid<
position: "absolute",
left: isRtl ? undefined : 0,
right: isRtl ? 0 : undefined,
transform: `translate(${isRtl ? -columnBounds.scrollOffset : columnBounds.scrollOffset}px, ${rowBounds.scrollOffset}px)`,
height: rowBounds.size,
width: columnBounds.size
transform: `translate(${isRtl ? -columnOffset : columnOffset}px, ${rowOffset}px)`,
height: rowBounds?.size,
width: columnBounds?.size
}}
/>
);
Expand All @@ -260,13 +262,13 @@ export function Grid<
}
return children;
}, [
cachedColumnBounds,
cachedRowBounds,
CellComponent,
cellProps,
columnCount,
columnStartIndexOverscan,
columnStopIndexOverscan,
getColumnBounds,
getRowBounds,
isRtl,
rowCount,
rowStartIndexOverscan,
Expand Down
53 changes: 47 additions & 6 deletions lib/components/list/List.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { act, render, screen } from "@testing-library/react";
import { assert } from "../../utils/assert";
import { createRef, useLayoutEffect } from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { EMPTY_OBJECT } from "../../../src/constants";
import {
disableForCurrentTest,
updateMockResizeObserver
setDefaultElementSize,
setElementSize,
setElementSizeFunction
} from "../../utils/test/mockResizeObserver";
import { List } from "./List";
import { type ListImperativeAPI, type RowComponentProps } from "./types";
Expand Down Expand Up @@ -33,7 +36,7 @@ describe("List", () => {
beforeEach(() => {
RowComponent.mockReset();

updateMockResizeObserver(new DOMRect(0, 0, 50, 100));
setDefaultElementSize({ height: 100, width: 50 });

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

render(
const { container } = render(
<List
onResize={onResize}
overscanCount={0}
Expand Down Expand Up @@ -84,7 +87,14 @@ describe("List", () => {
);

act(() => {
updateMockResizeObserver(new DOMRect(0, 0, 50, 75));
const listElement = container.querySelector<HTMLElement>('[role="list"]');
assert(listElement !== null);

setElementSize({
element: listElement,
height: 75,
width: 50
});
});

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

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

act(() => {
updateMockResizeObserver(new DOMRect(0, 0, 50, 75));
const listElement = container.querySelector<HTMLElement>('[role="list"]');
assert(listElement !== null);

setElementSize({
element: listElement,
height: 75,
width: 50
});
});

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

expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(4);
});

// Most dynamic size tests are in useVirtualizer
test("type: dynamic (lazily measured)", () => {
setElementSizeFunction((element) => {
const attribute = element.getAttribute("data-react-window-index");
if (attribute !== null) {
const index = parseInt(attribute);
if (!Number.isNaN(index)) {
return new DOMRect(0, 0, 100, (index + 1) * 5);
}
}
});

const { container } = render(
<List
overscanCount={0}
rowCount={50}
rowComponent={RowComponent}
rowProps={EMPTY_OBJECT}
/>
);

expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(6);
});
});

describe("edge cases", () => {
Expand Down
40 changes: 30 additions & 10 deletions lib/components/list/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useImperativeHandle,
useMemo,
useState,
type CSSProperties,
type ReactNode
} from "react";
import { useVirtualizer } from "../../core/useVirtualizer";
Expand Down Expand Up @@ -41,7 +42,7 @@ export function List<
const [element, setElement] = useState<HTMLDivElement | null>(null);

const {
getCellBounds,
cachedBounds,
getEstimatedSize,
scrollToIndex,
startIndexOverscan,
Expand Down Expand Up @@ -113,6 +114,13 @@ export function List<
stopIndexVisible
]);

const hasRowHeight = rowHeight !== undefined;

const offset =
startIndexOverscan >= 0
? (cachedBounds.getItemBounds(startIndexOverscan)?.scrollOffset ?? 0)
: 0;

const rows = useMemo(() => {
const children: ReactNode[] = [];
if (rowCount > 0) {
Expand All @@ -121,7 +129,23 @@ export function List<
index <= stopIndexOverscan;
index++
) {
const bounds = getCellBounds(index);
const bounds = cachedBounds.getItemBounds(index);

let style: CSSProperties = {};
if (bounds) {
style = {
height: hasRowHeight ? bounds.size : undefined,
width: "100%"
};
} else {
style = {
width: "100%"
};
}

if (index === startIndexOverscan) {
style.marginTop = `${offset}px`;
}

children.push(
<RowComponent
Expand All @@ -133,21 +157,17 @@ export function List<
}}
key={index}
index={index}
style={{
position: "absolute",
left: 0,
transform: `translateY(${bounds.scrollOffset}px)`,
height: bounds.size,
width: "100%"
}}
style={style}
/>
);
}
}
return children;
}, [
RowComponent,
getCellBounds,
cachedBounds,
hasRowHeight,
offset,
rowCount,
rowProps,
startIndexOverscan,
Expand Down
9 changes: 8 additions & 1 deletion lib/components/list/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,15 @@ export type ListProps<
* - number of pixels (number)
* - percentage of the grid's current height (string)
* - function that returns the row height (in pixels) given an index and `cellProps`
*
* If this property is not provided, a ResizeObserver will be used to measure the height of a row's content.
*
* ⚠️ Row height should be provided if it is known ahead of time as that is the most efficient way to render the list.
*/
rowHeight: number | string | ((index: number, cellProps: RowProps) => number);
rowHeight?:
| number
| string
| ((index: number, cellProps: RowProps) => number);

/**
* Additional props to be passed to the row-rendering component.
Expand Down
67 changes: 62 additions & 5 deletions lib/core/createCachedBounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ describe("createCachedBounds", () => {
expect(itemSize).not.toHaveBeenCalled();
expect(cachedBounds.size).toBe(0);

expect(cachedBounds.get(2)).toEqual({
expect(cachedBounds.getItemBounds(2)).toEqual({
scrollOffset: 21,
size: 12
});
expect(itemSize).toHaveBeenCalledTimes(3);
expect(cachedBounds.size).toBe(3);

expect(cachedBounds.get(3)).toEqual({
expect(cachedBounds.getItemBounds(3)).toEqual({
scrollOffset: 33,
size: 13
});
Expand All @@ -40,13 +40,13 @@ describe("createCachedBounds", () => {
expect(itemSize).not.toHaveBeenCalled();
expect(cachedBounds.size).toBe(0);

cachedBounds.get(9);
cachedBounds.getItemBounds(9);

expect(itemSize).toHaveBeenCalledTimes(10);
expect(cachedBounds.size).toBe(10);

for (let index = 0; index < 10; index++) {
cachedBounds.get(index);
cachedBounds.getItemBounds(index);
}

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

expect(() => {
cachedBounds.get(1);
cachedBounds.getItemBounds(1);
}).toThrow("Invalid index 1");
});

test("should gracefully handle undefined item sizes", () => {
const cachedBounds = createCachedBounds({
itemCount: 10,
itemProps: {},
itemSize: undefined
});

cachedBounds.setItemSize(0, 10);

expect(cachedBounds.size).toBe(1);
expect(cachedBounds.getItemBounds(0)).toEqual({
scrollOffset: 0,
size: 10
});

cachedBounds.setItemSize(1, 20);

expect(cachedBounds.size).toBe(2);
expect(cachedBounds.getItemBounds(1)).toEqual({
scrollOffset: 10,
size: 20
});
});

test("should gracefully handle sparsely populated cache", () => {
const cachedBounds = createCachedBounds({
itemCount: 5,
itemProps: {},
itemSize: undefined
});

expect(cachedBounds.getEstimatedSize()).toBeUndefined();

cachedBounds.setItemSize(0, 10);
cachedBounds.setItemSize(2, 20);
cachedBounds.setItemSize(4, 30);

// Estimated average should be based on measured cells
expect(cachedBounds.getEstimatedSize()).toBe(20);

// Bounds offsets based on measured cells; gaps should be filled in by averages
expect(cachedBounds.getItemBounds(0)).toEqual({
scrollOffset: 0,
size: 10
});
expect(cachedBounds.getItemBounds(1)).toBeUndefined();
expect(cachedBounds.getItemBounds(2)).toEqual({
scrollOffset: 30,
size: 20
});
expect(cachedBounds.getItemBounds(3)).toBeUndefined();
expect(cachedBounds.getItemBounds(4)).toEqual({
scrollOffset: 70,
size: 30
});
});
});
Loading