Skip to content
Draft
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
45 changes: 35 additions & 10 deletions lib/components/list/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useImperativeHandle,
useMemo,
useState,
type CSSProperties,
type ReactNode
} from "react";
import { useVirtualizer } from "../../core/useVirtualizer";
Expand Down Expand Up @@ -35,7 +36,7 @@ export function List<RowProps extends object>({
const [element, setElement] = useState<HTMLDivElement | null>(null);

const {
getCellBounds,
cachedBounds,
getEstimatedSize,
scrollToIndex,
startIndex,
Expand Down Expand Up @@ -87,30 +88,53 @@ export function List<RowProps extends object>({
}
}, [onRowsRendered, startIndex, stopIndex]);

const hasRowHeight = rowHeight !== undefined;

const rows = useMemo(() => {
const children: ReactNode[] = [];
if (rowCount > 0) {
for (let index = startIndex; index <= stopIndex; index++) {
const bounds = getCellBounds(index);
const bounds = cachedBounds.getItemBounds(index);

let style: CSSProperties = {};
if (bounds) {
style = {
// position: "absolute",
// left: 0,
//transform: `translateY(${bounds.scrollOffset}px)`,
height: hasRowHeight ? bounds.size : undefined,
width: "100%"
};
} else {
style = {
// position: "absolute",
// left: 0,
width: "100%"
};
}

children.push(
<RowComponent
{...(rowProps as RowProps)}
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, rowCount, rowProps, startIndex, stopIndex]);
}, [
RowComponent,
cachedBounds,
hasRowHeight,
rowCount,
rowProps,
startIndex,
stopIndex
]);

const offset = cachedBounds.getItemBounds(startIndex)?.scrollOffset ?? 0;

return (
<div
Expand All @@ -128,6 +152,7 @@ export function List<RowProps extends object>({
className={className}
style={{
height: getEstimatedSize(),
paddingTop: `${offset}px`,
position: "relative",
width: "100%"
}}
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 @@ -80,8 +80,15 @@ export type ListProps<RowProps extends object> = Omit<
* - 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
34 changes: 29 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,31 @@ 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
});
});
});
127 changes: 92 additions & 35 deletions lib/core/createCachedBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,62 +8,119 @@ export function createCachedBounds<Props extends object>({
}: {
itemCount: number;
itemProps: Props;
itemSize: number | SizeFunction<Props>;
itemSize: number | SizeFunction<Props> | undefined;
}): CachedBounds {
const cache = new Map<number, Bounds>();

return {
get(index: number) {
const api = {
getEstimatedSize() {
const lastBounds = cache.get(cache.size - 1);
if (lastBounds) {
return (lastBounds.scrollOffset + lastBounds.size) / cache.size;
}
},
getItemBounds(index: number) {
assert(index < itemCount, `Invalid index ${index}`);

while (cache.size - 1 < index) {
const currentIndex = cache.size;
if (itemSize) {
while (cache.size - 1 < index) {
const currentIndex = cache.size;

let size: number;
switch (typeof itemSize) {
case "function": {
size = itemSize(currentIndex, itemProps);
break;
let size: number;
switch (typeof itemSize) {
case "function": {
size = itemSize(currentIndex, itemProps);
break;
}
case "number": {
size = itemSize;
break;
}
}
case "number": {
size = itemSize;
break;

if (currentIndex === 0) {
cache.set(currentIndex, {
size,
scrollOffset: 0
});
} else {
const previousRowBounds = cache.get(currentIndex - 1);
assert(
previousRowBounds !== undefined,
`Unexpected bounds cache miss for index ${index}`
);

cache.set(currentIndex, {
scrollOffset:
previousRowBounds.scrollOffset + previousRowBounds.size,
size
});
}
}

if (currentIndex === 0) {
cache.set(currentIndex, {
size,
scrollOffset: 0
});
const bounds = cache.get(index);
assert(
bounds !== undefined,
`Unexpected bounds cache miss for index ${index}`
);

return bounds;
} else {
return cache.get(index);
}
},
hasItemBounds(index: number) {
return cache.has(index);
},
setItemSize(index: number, size: number) {
// Note this function assumes items are measured in sequence;
// I think that's a safe assumption but if it turns out not to be we'll need to rethink things
let scrollOffset = 0;
if (index > 0) {
if (cache.size >= index) {
const bounds = cache.get(index - 1);
assert(bounds, `Unexpected cache miss at index ${index - 1}`);

scrollOffset = bounds.scrollOffset + bounds.size;
} else {
const previousRowBounds = cache.get(currentIndex - 1);
const lastBounds = cache.get(cache.size - 1);
assert(
previousRowBounds !== undefined,
`Unexpected bounds cache miss for index ${index}`
lastBounds,
`Unexpected cache miss at index ${cache.size - 1}`
);

cache.set(currentIndex, {
scrollOffset:
previousRowBounds.scrollOffset + previousRowBounds.size,
size
});
const estimatedSize = api.getEstimatedSize();
assert(
estimatedSize !== undefined,
"Expected at least one measurement"
);

const numEstimated = index - cache.size;
scrollOffset =
lastBounds.scrollOffset +
lastBounds.size +
estimatedSize * numEstimated;
}
}

const bounds = cache.get(index);
assert(
bounds !== undefined,
`Unexpected bounds cache miss for index ${index}`
);
cache.set(index, { scrollOffset, size });

return bounds;
},
set(index: number, bounds: Bounds) {
cache.set(index, bounds);
// Adjust offset for items afterward in the cache
while (index < cache.size) {
const bounds = cache.get(index);
assert(bounds, `Unexpected cache miss at index ${index}`);

bounds.scrollOffset = scrollOffset;

scrollOffset += bounds.size;

index++;
}
},
get size() {
return cache.size;
}
};

return api;
}
48 changes: 46 additions & 2 deletions lib/core/getEstimatedSize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("getEstimatedSize", () => {
itemProps: EMPTY_OBJECT,
itemSize
});
cachedBounds.get(4);
cachedBounds.getItemBounds(4);

expect(
getEstimatedSize({
Expand All @@ -59,7 +59,7 @@ describe("getEstimatedSize", () => {
itemSize
});

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

expect(
getEstimatedSize({
Expand All @@ -86,4 +86,48 @@ describe("getEstimatedSize", () => {
).toBe(250);
});
});

describe("itemSize: undefined (lazily measured)", () => {
test("should return undefined if no measurements have been taken", () => {
expect(
getEstimatedSize({
cachedBounds: createCachedBounds({
itemCount: 10,
itemProps: EMPTY_OBJECT,
itemSize: undefined
}),
itemCount: 10,
itemSize: undefined
})
).toBeUndefined();
});

test("should return an estimated size based on measurements that have been taken", () => {
const cachedBounds = createCachedBounds({
itemCount: 0,
itemProps: EMPTY_OBJECT,
itemSize: undefined
});
cachedBounds.setItemSize(0, 10);
cachedBounds.setItemSize(1, 20);

expect(
getEstimatedSize({
cachedBounds,
itemCount: 10,
itemSize: undefined
})
).toBe(150);

cachedBounds.setItemSize(2, 30);

expect(
getEstimatedSize({
cachedBounds,
itemCount: 10,
itemSize: undefined
})
).toBe(200);
});
});
});
Loading
Loading