Skip to content
Merged
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
4 changes: 4 additions & 0 deletions lib/components/list/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,13 @@ export function List<
return rowHeightProp.observeRowElements(rows);
}
}, [
RowComponent,
element,
isDynamicRowHeight,
rowCount,
rowHeightProp,
rowKey,
rowProps,
startIndexOverscan,
stopIndexOverscan
]);
Expand Down
15 changes: 15 additions & 0 deletions lib/core/createCachedBounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,18 @@ describe("createCachedBounds", () => {
}).toThrow("Invalid index 1");
});
});

test("fixed-size jumps do not allocate bounds for preceding items", () => {
const cachedBounds = createCachedBounds({
itemCount: 1000000,
itemProps: {},
itemSize: 20
});
expect(cachedBounds.get(999999)).toEqual({
scrollOffset: 19999980,
size: 20
});
expect(cachedBounds.size).toBe(1);
expect(cachedBounds.get(0)).toEqual({ scrollOffset: 0, size: 20 });
expect(cachedBounds.size).toBe(2);
});
23 changes: 12 additions & 11 deletions lib/core/createCachedBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,24 @@ export function createCachedBounds<Props extends object>({
const cache = new Map<number, Bounds>();

return {
itemSize: typeof itemSize === "number" ? itemSize : undefined,
get(index: number) {
assert(index < itemCount, `Invalid index ${index}`);

if (typeof itemSize === "number") {
assert(index >= 0, `Invalid index ${index}`);
let bounds = cache.get(index);
if (bounds === undefined) {
bounds = { size: itemSize, scrollOffset: index * itemSize };
cache.set(index, bounds);
}
return bounds;
}

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

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

if (currentIndex === 0) {
cache.set(currentIndex, {
Expand Down
72 changes: 72 additions & 0 deletions lib/core/getOffsetForIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,75 @@ describe("getOffsetForIndex", () => {
});
});
});

test("variable-size start alignment measures target before estimating total", () => {
const itemSize = (index: number) => (index < 10 ? 10 : 100);
const cachedBounds = createCachedBounds({
itemCount: 100,
itemSize,
itemProps: {}
});
cachedBounds.get(9);
expect(
getOffsetForIndex({
align: "start",
cachedBounds,
itemCount: 100,
itemSize,
index: 90,
containerScrollOffset: 0,
containerSize: 100
})
).toBe(8100);
});

test("center alignment uses the item midpoint near the start", () => {
const cachedBounds = createCachedBounds({
itemCount: 100,
itemSize: 100,
itemProps: {}
});
expect(
getOffsetForIndex({
align: "center",
cachedBounds,
itemCount: 100,
itemSize: 100,
index: 2,
containerScrollOffset: 0,
containerSize: 450
})
).toBe(25);
});

test.each([
{
itemSize: 1000,
itemCount: 10,
index: 0,
containerSize: 100,
expected: 450
},
{ itemSize: 10, itemCount: 2, index: 1, containerSize: 100, expected: 0 },
{ itemSize: 100, itemCount: 10, index: 9, containerSize: 450, expected: 550 }
])(
"clamps centered items to the scrollable extent: $expected",
({ itemSize, itemCount, index, containerSize, expected }) => {
const cachedBounds = createCachedBounds({
itemSize,
itemCount,
itemProps: {}
});
expect(
getOffsetForIndex({
align: "center",
cachedBounds,
itemSize,
itemCount,
index,
containerSize,
containerScrollOffset: 0
})
).toBe(expected);
}
);
19 changes: 7 additions & 12 deletions lib/core/getOffsetForIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,13 @@ export function getOffsetForIndex<Props extends object>({
return minOffset;
}
case "center": {
if (bounds.scrollOffset <= containerSize / 2) {
// Too near the beginning to center-align
return 0;
} else if (
bounds.scrollOffset + bounds.size / 2 >=
estimatedTotalSize - containerSize / 2
) {
// Too near the end to center-align
return estimatedTotalSize - containerSize;
} else {
return bounds.scrollOffset + bounds.size / 2 - containerSize / 2;
}
return Math.max(
0,
Math.min(
estimatedTotalSize - containerSize,
bounds.scrollOffset + bounds.size / 2 - containerSize / 2
)
);
}
case "auto":
default: {
Expand Down
120 changes: 120 additions & 0 deletions lib/core/getStartStopIndices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,123 @@ describe("getStartStopIndices", () => {
});
});
});

test("quantify warm-cache work near the end of a large fixed-size list", () => {
const cachedBounds = createCachedBounds({
itemCount: 100000,
itemProps: {},
itemSize: 20
});
cachedBounds.get(99999);
let reads = 0;
const counted = {
...cachedBounds,
get: (index: number) => {
reads++;
return cachedBounds.get(index);
}
};
const result = getStartStopIndices({
cachedBounds: counted,
containerScrollOffset: 1900000,
containerSize: 500,
itemCount: 100000,
overscanCount: 3
});
expect(result.startIndexVisible).toBe(95000);
expect(reads).toBe(0);
});

test("searches a populated variable-size cache without rescanning earlier rows", () => {
const cachedBounds = createCachedBounds({
itemCount: 100000,
itemProps: {},
itemSize: () => 20
});
cachedBounds.get(99999);
let reads = 0;
const counted = {
...cachedBounds,
get: (index: number) => {
reads++;
return cachedBounds.get(index);
}
};
const result = getStartStopIndices({
cachedBounds: counted,
containerScrollOffset: 1900000,
containerSize: 500,
itemCount: 100000,
overscanCount: 3
});
expect(result).toEqual({
startIndexVisible: 95000,
stopIndexVisible: 95024,
startIndexOverscan: 94997,
stopIndexOverscan: 95027
});
expect(reads).toBeLessThan(50);
});

test("initial variable-size lookup only measures the viewport", () => {
let measurements = 0;
const cachedBounds = createCachedBounds({
itemCount: 100000,
itemProps: {},
itemSize: () => {
measurements++;
return 20;
}
});
getStartStopIndices({
cachedBounds,
containerScrollOffset: 0,
containerSize: 500,
itemCount: 100000,
overscanCount: 3
});
expect(measurements).toBe(25);
});

test("matches a linear lookup across boundaries, zero sizes and scroll directions", () => {
for (const sizes of [
[],
[0],
[20],
[0, 10, 0, 30, 5, 0, 15],
Array.from({ length: 100 }, (_, i) => (i * 17) % 31)
]) {
const cachedBounds = createCachedBounds({
itemCount: sizes.length,
itemProps: {},
itemSize: (index) => sizes[index]
});
const ends = sizes.map((_, index) =>
sizes.slice(0, index + 1).reduce((sum, size) => sum + size, 0)
);
for (const offset of [-10, 0, 1, 10, 40, 10000, 900, 100, 0]) {
for (const containerSize of [0, 1, 20, 100]) {
let start = 0;
while (start < sizes.length - 1 && ends[start] <= offset) start++;
let stop = start;
while (stop < sizes.length - 1 && ends[stop] < offset + containerSize)
stop++;
stop = Math.min(sizes.length - 1, stop);
expect(
getStartStopIndices({
cachedBounds,
containerScrollOffset: offset,
containerSize,
itemCount: sizes.length,
overscanCount: 2
})
).toEqual({
startIndexVisible: start,
stopIndexVisible: stop,
startIndexOverscan: Math.max(0, start - 2),
stopIndexOverscan: Math.min(sizes.length - 1, stop + 2)
});
}
}
}
});
Loading
Loading