Skip to content

Commit

Permalink
Improve automatic item size estimation
Browse files Browse the repository at this point in the history
  • Loading branch information
inokawa committed Feb 11, 2024
1 parent 557d4d1 commit 9658ebb
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 21 deletions.
119 changes: 119 additions & 0 deletions src/core/cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
updateCacheLength,
initCache,
computeRange,
estimateDefaultItemSize,
} from "./cache";

const range = <T>(length: number, cb: (i: number) => T): T[] => {
Expand Down Expand Up @@ -627,6 +628,124 @@ describe(findIndex.name, () => {
});
});

describe(estimateDefaultItemSize.name, () => {
describe("start", () => {
it("should update with 1 entry", () => {
const cache = initCacheWithComputedOffsets(
range(100, () => 20),
30
);
const indexes = [0];
indexes.forEach((i) => setItemSize(cache, i, 50));
const init = structuredClone(cache);

const diff = estimateDefaultItemSize(cache, indexes, 0);
expect(cache._defaultItemSize).toBe(50);
expect(cache._sizes).toEqual(init._sizes);
expect(cache._computedOffsetIndex).toEqual(-1);
expect(diff).toBe(0);
});

it("should update with some entry", () => {
const cache = initCacheWithComputedOffsets(
range(100, () => 20),
30
);
const indexes = [0, 1, 2, 3];
indexes.forEach((i) => setItemSize(cache, i, 50));
const init = structuredClone(cache);

const diff = estimateDefaultItemSize(cache, indexes, 0);
expect(cache._defaultItemSize).toBe(50);
expect(cache._sizes).toEqual(init._sizes);
expect(cache._computedOffsetIndex).toEqual(-1);
expect(diff).toBe(0);
});

it("should update with some entry from outside", () => {
const cache = initCacheWithComputedOffsets(
range(100, () => 20),
30
);
const indexes = [20, 21, 22, 23];
indexes.forEach((i) => setItemSize(cache, i, 50));
const init = structuredClone(cache);

const diff = estimateDefaultItemSize(cache, indexes, 0);
expect(cache._defaultItemSize).toBe(50);
expect(cache._sizes).toEqual(init._sizes);
expect(cache._computedOffsetIndex).toEqual(-1);
expect(diff).toBe(0);
});
});

describe("end", () => {
it("should update with 1 entry", () => {
const cache = initCacheWithComputedOffsets(
range(100, () => 20),
30
);
const indexes = [92];
indexes.forEach((i) => setItemSize(cache, i, 50));
const init = structuredClone(cache);

const diff = estimateDefaultItemSize(cache, indexes, cache._length - 10);
expect(cache._defaultItemSize).toBe(50);
expect(cache._sizes).toEqual(init._sizes);
expect(cache._computedOffsetIndex).toEqual(-1);
expect(diff).toBe((50 - 30) * 90);
});

it("should update with some entry", () => {
const cache = initCacheWithComputedOffsets(
range(100, () => 20),
30
);
const indexes = [92, 93, 94, 95];
indexes.forEach((i) => setItemSize(cache, i, 50));
const init = structuredClone(cache);

const diff = estimateDefaultItemSize(cache, indexes, cache._length - 10);
expect(cache._defaultItemSize).toBe(50);
expect(cache._sizes).toEqual(init._sizes);
expect(cache._computedOffsetIndex).toEqual(-1);
expect(diff).toBe((50 - 30) * 90);
});

it("should update with some entry from outside", () => {
const cache = initCacheWithComputedOffsets(
range(100, () => 20),
30
);
const indexes = [20, 21, 22, 23];
indexes.forEach((i) => setItemSize(cache, i, 50));
const init = structuredClone(cache);

const diff = estimateDefaultItemSize(cache, indexes, cache._length - 10);
expect(cache._defaultItemSize).toBe(50);
expect(cache._sizes).toEqual(init._sizes);
expect(cache._computedOffsetIndex).toEqual(-1);
expect(diff).toBe((50 - 30) * (90 - 4));
});

it("should update with some entry from near bound", () => {
const cache = initCacheWithComputedOffsets(
range(100, () => 20),
30
);
const indexes = [88, 89, 90, 91];
indexes.forEach((i) => setItemSize(cache, i, 50));
const init = structuredClone(cache);

const diff = estimateDefaultItemSize(cache, indexes, cache._length - 10);
expect(cache._defaultItemSize).toBe(50);
expect(cache._sizes).toEqual(init._sizes);
expect(cache._computedOffsetIndex).toEqual(-1);
expect(diff).toBe((50 - 30) * (90 - 2));
});
});
});

describe(initCache.name, () => {
it("should create cache", () => {
expect(initCache(10, 23)).toMatchInlineSnapshot(`
Expand Down
33 changes: 25 additions & 8 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { clamp, median, min } from "./utils";
import { clamp, max, median, min } from "./utils";

type Writeable<T> = {
-readonly [key in keyof T]: Writeable<T[key]>;
Expand Down Expand Up @@ -133,16 +133,33 @@ export const computeRange = (
/**
* @internal
*/
export const estimateDefaultItemSize = (cache: Writeable<Cache>) => {
const measuredSizes = cache._sizes.filter((s) => s !== UNCACHED);
export const estimateDefaultItemSize = (
cache: Writeable<Cache>,
measuredIndexes: readonly number[],
startIndex: number
): number => {
const measuredSizes = measuredIndexes.map((i) => getItemSize(cache, i));
// This function will be called after measurement so measured size array must be longer than 0
const startItemSize = measuredSizes[0]!;

cache._defaultItemSize = measuredSizes.every((s) => s === startItemSize)
? // Maybe a fixed size array
startItemSize
: // Maybe a variable size array
median(measuredSizes);
const prevDefaultItemSize = cache._defaultItemSize;

// Discard cache for now
cache._computedOffsetIndex = -1;

// Calculate diff of unmeasured items before start
return (
((cache._defaultItemSize = measuredSizes.every((s) => s === startItemSize)
? // Maybe a fixed size array
startItemSize
: // Maybe a variable size array
median(measuredSizes)) -
prevDefaultItemSize) *
max(
startIndex - measuredIndexes.filter((index) => index < startIndex).length,
0
)
);
};

/**
Expand Down
29 changes: 16 additions & 13 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export const createVirtualStore = (
itemSize: number = 40,
ssrCount: number = 0,
cacheSnapshot?: CacheSnapshot | undefined,
shouldAutoEstimateItemSize?: boolean | undefined,
shouldAutoEstimateItemSize: boolean = false,
startSpacerSize: number = 0,
endSpacerSize: number = 0
): VirtualStore => {
Expand All @@ -181,6 +181,7 @@ export const createVirtualStore = (
const cache =
(cacheSnapshot as Cache | undefined) || initCache(elementsCount, itemSize);
const subscribers = new Set<[number, Subscriber]>();
const measuredIndexes = new Set<number>();
const getTotalSize = (): number => computeTotalSize(cache);
const getScrollableSize = (): number =>
getTotalSize() + startSpacerSize + endSpacerSize;
Expand Down Expand Up @@ -326,21 +327,23 @@ export const createVirtualStore = (
}

// Update item sizes
let isNewItemMeasured = false;
updated.forEach(([index, size]) => {
if (setItemSize(cache, index, size)) {
isNewItemMeasured = true;
for (const [index, size] of updated) {
if (setItemSize(cache, index, size) && shouldAutoEstimateItemSize) {
measuredIndexes.add(index);
}
});
}

// Estimate initial item size from measured sizes
if (
shouldAutoEstimateItemSize &&
isNewItemMeasured &&
// TODO support reverse scroll also
!scrollOffset
) {
estimateDefaultItemSize(cache);
if (shouldAutoEstimateItemSize && viewportSize) {
const indexes = [...measuredIndexes];
if (
indexes.reduce((acc, i) => acc + getItemSize(cache, i), 0) >
viewportSize
) {
applyJump(estimateDefaultItemSize(cache, indexes, _prevRange[0]));
shouldAutoEstimateItemSize = false;
measuredIndexes.clear();
}
}
mutated = UPDATE_SIZE_STATE;

Expand Down

0 comments on commit 9658ebb

Please sign in to comment.