From acf16cbef07d885909a708d2b32ac1b0d1fc7cfc Mon Sep 17 00:00:00 2001 From: inokawa <48897392+inokawa@users.noreply.github.com> Date: Sun, 1 Dec 2024 16:51:16 +0900 Subject: [PATCH] Optimize scrolling performance with Binary Indexed Tree --- src/core/bit.ts | 66 ++++ src/core/cache.spec.ts | 682 +++++++++++++++-------------------------- src/core/cache.ts | 185 ++++------- src/core/store.ts | 12 +- src/core/utils.ts | 18 +- 5 files changed, 402 insertions(+), 561 deletions(-) create mode 100644 src/core/bit.ts diff --git a/src/core/bit.ts b/src/core/bit.ts new file mode 100644 index 00000000..7e0b00e0 --- /dev/null +++ b/src/core/bit.ts @@ -0,0 +1,66 @@ +// https://en.wikipedia.org/wiki/Fenwick_tree + +import { append, clz32 } from "./utils"; + +declare const bitSymbol: unique symbol; +/** @internal */ +export type BIT = { [bitSymbol]: never } & number[]; + +/** @internal */ +export const init = (array: readonly number[]): BIT => { + const length = array.length + 1; + const bit = append([], length, 0); + + for (let i = 1; i < length; i++) { + bit[i]! += array[i - 1]!; + const parent = i + (i & -i); + if (parent < length) { + bit[parent]! += bit[i]!; + } + } + return bit as BIT; +}; + +/** @internal */ +export const push = (bit: BIT, value: number) => { + const length = bit.length; + const k = length & -length; + for (let i = 1; i < k; i <<= 1) { + value += bit[length - i]!; + } + bit.push(value); +}; + +/** @internal */ +export const get = (bit: BIT, i: number): number => { + let sum = 0; + for (; i > 0; i -= i & -i) { + sum += bit[i]!; + } + return sum; +}; + +/** @internal */ +export const add = (bit: BIT, i: number, delta: number) => { + for (; i < bit.length; i += i & -i) { + bit[i]! += delta; + } +}; + +/** @internal */ +export const lowerBound = (bit: BIT, value: number): number => { + if (value <= 0) { + return 0; + } else { + const length = bit.length; + let index = 0; + for (let t = 1 << (31 - clz32(length - 1)); t > 0; t >>= 1) { + const nextIndex = index + t; + if (nextIndex < length && bit[nextIndex]! <= value) { + value -= bit[nextIndex]!; + index = nextIndex; + } + } + return index; + } +}; diff --git a/src/core/cache.spec.ts b/src/core/cache.spec.ts index 7562ee08..c3471c4d 100644 --- a/src/core/cache.spec.ts +++ b/src/core/cache.spec.ts @@ -2,16 +2,17 @@ import { describe, it, expect } from "vitest"; import { getItemSize, setItemSize, - computeTotalSize, - computeOffset, + getItemOffset, findIndex, type Cache, updateCacheLength, initCache, - computeRange, estimateDefaultItemSize, takeCacheSnapshot, + UNCACHED, } from "./cache"; +import { init } from "./bit"; +import { ItemsRange } from "./types"; const range = (length: number, cb: (i: number) => T): T[] => { const array: T[] = []; @@ -25,54 +26,20 @@ const sum = (cache: readonly number[]): number => { return cache.reduce((acc, c) => acc + c, 0); }; -const initCacheWithComputedOffsets = ( +const initCacheWithSizes = ( sizes: readonly number[], defaultSize: number ): Cache => { return { _length: sizes.length, _sizes: [...sizes], - _computedOffsetIndex: sizes.length - 1, - _offsets: sizes.reduce((acc, s, i) => { - acc.push(i === 0 ? 0 : acc[i - 1]! + s); - return acc; - }, [] as number[]), - _defaultItemSize: defaultSize, - }; -}; - -const initCacheWithEmptyOffsets = ( - sizes: readonly number[], - defaultSize: number -): Cache => { - return { - _length: sizes.length, - _sizes: [...sizes], - _computedOffsetIndex: -1, - _offsets: range(sizes.length, () => -1), - _defaultItemSize: defaultSize, - }; -}; - -const initCacheWithOffsets = ( - sizes: readonly number[], - offsets: readonly number[], - defaultSize: number -): Cache => { - if (sizes.length !== offsets.length) { - throw new Error("wrong offsets for sizes"); - } - return { - _length: sizes.length, - _sizes: [...sizes], - _computedOffsetIndex: offsets.findIndex((o) => o === -1) - 1, - _offsets: [...offsets], + _offsets: init(sizes.map((s) => (s === UNCACHED ? defaultSize : s))), _defaultItemSize: defaultSize, }; }; describe(getItemSize.name, () => { - const cache = initCacheWithEmptyOffsets([10, -1], 20); + const cache = initCacheWithSizes([10, -1], 20); it("should get height", () => { expect(getItemSize(cache, 0)).toBe(10); @@ -83,92 +50,40 @@ describe(getItemSize.name, () => { }); describe(setItemSize.name, () => { - describe("with offsets not measured", () => { - it("should set at first", () => { - const filledSizes = range(10, () => 20); - const cache = initCacheWithEmptyOffsets(filledSizes, 20); - const initialOffsets = [...cache._offsets]; - const initialComputedOffsetIndex = cache._computedOffsetIndex; - - setItemSize(cache, 0, 123); - expect(cache._sizes).toEqual([123, 20, 20, 20, 20, 20, 20, 20, 20, 20]); - expect(cache._offsets).toEqual(initialOffsets); - expect(cache._computedOffsetIndex).toBe(initialComputedOffsetIndex); - }); - - it("should set at middle", () => { - const filledSizes = range(10, () => 20); - const cache = initCacheWithEmptyOffsets(filledSizes, 20); - const initialOffsets = [...cache._offsets]; - const initialComputedOffsetIndex = cache._computedOffsetIndex; - - setItemSize(cache, 4, 123); - expect(cache._sizes).toEqual([20, 20, 20, 20, 123, 20, 20, 20, 20, 20]); - expect(cache._offsets).toEqual(initialOffsets); - expect(cache._computedOffsetIndex).toBe(initialComputedOffsetIndex); - }); + it("should set at first", () => { + const filledSizes = range(10, () => 20); + const cache = initCacheWithSizes(filledSizes, 20); + const initialOffsets = [...cache._offsets]; - it("should set at last", () => { - const filledSizes = range(10, () => 20); - const cache = initCacheWithEmptyOffsets(filledSizes, 20); - const initialOffsets = [...cache._offsets]; - const initialComputedOffsetIndex = cache._computedOffsetIndex; - - setItemSize(cache, cache._length - 1, 123); - expect(cache._sizes).toEqual([20, 20, 20, 20, 20, 20, 20, 20, 20, 123]); - expect(cache._offsets).toEqual(initialOffsets); - expect(cache._computedOffsetIndex).toBe(initialComputedOffsetIndex); - }); + setItemSize(cache, 0, 123); + expect(cache._sizes).toEqual([123, 20, 20, 20, 20, 20, 20, 20, 20, 20]); + expect(cache._offsets).not.toEqual(initialOffsets); }); - describe("with offsets measured", () => { - it("should update measuredOffsetIndex if size is changed before measuredOffsetIndex", () => { - const filledSizes = range(10, () => 20); - const cache = initCacheWithOffsets( - filledSizes, - [0, 10, 20, 30, 40, -1, -1, -1, -1, -1], - 20 - ); - - setItemSize(cache, 1, 123); - expect(cache._sizes).toEqual([20, 123, 20, 20, 20, 20, 20, 20, 20, 20]); - expect(cache._offsets).toEqual([0, 10, 20, 30, 40, -1, -1, -1, -1, -1]); - expect(cache._computedOffsetIndex).toBe(1); - }); - - it("should not update measuredOffsetIndex if size is changed at measuredOffsetIndex", () => { - const filledSizes = range(10, () => 20); - const cache = initCacheWithOffsets( - filledSizes, - [0, 10, 20, 30, 40, -1, -1, -1, -1, -1], - 20 - ); + it("should set at middle", () => { + const filledSizes = range(10, () => 20); + const cache = initCacheWithSizes(filledSizes, 20); + const initialOffsets = [...cache._offsets]; - setItemSize(cache, 4, 123); - expect(cache._sizes).toEqual([20, 20, 20, 20, 123, 20, 20, 20, 20, 20]); - expect(cache._offsets).toEqual([0, 10, 20, 30, 40, -1, -1, -1, -1, -1]); - expect(cache._computedOffsetIndex).toBe(4); - }); + setItemSize(cache, 4, 123); + expect(cache._sizes).toEqual([20, 20, 20, 20, 123, 20, 20, 20, 20, 20]); + expect(cache._offsets).not.toEqual(initialOffsets); + }); - it("should not update measuredOffsetIndex if size is changed after measuredOffsetIndex", () => { - const filledSizes = range(10, () => 20); - const cache = initCacheWithOffsets( - filledSizes, - [0, 10, 20, 30, 40, -1, -1, -1, -1, -1], - 20 - ); + it("should set at last", () => { + const filledSizes = range(10, () => 20); + const cache = initCacheWithSizes(filledSizes, 20); + const initialOffsets = [...cache._offsets]; - setItemSize(cache, 5, 123); - expect(cache._sizes).toEqual([20, 20, 20, 20, 20, 123, 20, 20, 20, 20]); - expect(cache._offsets).toEqual([0, 10, 20, 30, 40, -1, -1, -1, -1, -1]); - expect(cache._computedOffsetIndex).toBe(4); - }); + setItemSize(cache, cache._length - 1, 123); + expect(cache._sizes).toEqual([20, 20, 20, 20, 20, 20, 20, 20, 20, 123]); + expect(cache._offsets).not.toEqual(initialOffsets); }); describe("should return measurement status", () => { it("should return false if already measured", () => { const filledSizes = range(10, () => 20); - const cache = initCacheWithEmptyOffsets(filledSizes, 20); + const cache = initCacheWithSizes(filledSizes, 20); const res = setItemSize(cache, 0, 123); expect(res).toBe(false); @@ -176,7 +91,7 @@ describe(setItemSize.name, () => { it("should return true if not measured", () => { const emptySizes = range(10, () => -1); - const cache = initCacheWithEmptyOffsets(emptySizes, 20); + const cache = initCacheWithSizes(emptySizes, 20); const res = setItemSize(cache, 0, 123); expect(res).toBe(true); @@ -184,147 +99,66 @@ describe(setItemSize.name, () => { }); }); -describe(computeOffset.name, () => { +describe(getItemOffset.name, () => { it("should get 0 if index is at start", () => { const filledSizes = range(10, () => 20); - const cache = initCacheWithEmptyOffsets(filledSizes, 30); + const cache = initCacheWithSizes(filledSizes, 30); - expect(computeOffset(cache, 0)).toBe(0); - expect(cache._offsets).toEqual([0, -1, -1, -1, -1, -1, -1, -1, -1, -1]); + expect(getItemOffset(cache, 0)).toBe(0); }); it("should get 1 item if index is at start", () => { const filledSizes = range(10, () => 20); - const cache = initCacheWithEmptyOffsets(filledSizes, 30); + const cache = initCacheWithSizes(filledSizes, 30); - expect(computeOffset(cache, 1)).toBe(20); - expect(cache._offsets).toEqual([0, 20, -1, -1, -1, -1, -1, -1, -1, -1]); + expect(getItemOffset(cache, 1)).toBe(20); }); it("should get total - 1 item if index is at last", () => { const filledSizes = range(10, () => 20); - const cache = initCacheWithEmptyOffsets(filledSizes, 30); + const cache = initCacheWithSizes(filledSizes, 30); const last = filledSizes.length - 1; - expect(computeOffset(cache, last)).toBe( + expect(getItemOffset(cache, last)).toBe( sum(filledSizes) - filledSizes[last]! ); - expect(cache._offsets).toEqual([ - 0, 20, 40, 60, 80, 100, 120, 140, 160, 180, - ]); }); it("should resolve default height", () => { const emptySizes = range(10, () => -1); - const cache = initCacheWithEmptyOffsets(emptySizes, 30); + const cache = initCacheWithSizes(emptySizes, 30); - expect(computeOffset(cache, 2)).toBe(60); - expect(cache._offsets).toEqual([0, 30, 60, -1, -1, -1, -1, -1, -1, -1]); + expect(getItemOffset(cache, 2)).toBe(60); }); it("should return 0 if cache length is 0", () => { - const cache = initCacheWithEmptyOffsets([], 30); + const cache = initCacheWithSizes([], 30); - expect(computeOffset(cache, 0)).toBe(0); - expect(computeOffset(cache, 10)).toBe(0); - expect(cache._offsets).toEqual([]); + expect(getItemOffset(cache, 0)).toBe(0); + expect(getItemOffset(cache, 10)).toBe(0); }); - describe("with cached offsets", () => { - it("should return cached offset if index is before measuredOffsetIndex", () => { - const filledSizes = range(10, () => 20); - const cache = initCacheWithOffsets( - filledSizes, - [0, 11, 22, 33, -1, -1, -1, -1, -1, -1], - 30 - ); - - expect(computeOffset(cache, 2)).toBe(22); - expect(cache._offsets).toEqual([0, 11, 22, 33, -1, -1, -1, -1, -1, -1]); - }); - - it("should return cached offset if index is the same as measuredOffsetIndex", () => { - const filledSizes = range(10, () => 20); - const cache = initCacheWithOffsets( - filledSizes, - [0, 11, 22, 33, -1, -1, -1, -1, -1, -1], - 30 - ); - - expect(computeOffset(cache, 3)).toBe(33); - expect(cache._offsets).toEqual([0, 11, 22, 33, -1, -1, -1, -1, -1, -1]); - }); + describe("total size", () => { + const computeTotalSize = (cache: Cache) => + getItemOffset(cache, cache._length); - it("should start from cached offset if index is after measuredOffsetIndex", () => { + it("should succeed if sizes is filled", () => { const filledSizes = range(10, () => 20); - const cache = initCacheWithOffsets( - filledSizes, - [0, 11, 22, 33, -1, -1, -1, -1, -1, -1], - 30 - ); + const cache = initCacheWithSizes(filledSizes, 30); - expect(computeOffset(cache, 5)).toBe(33 + 20 * 2); - expect(cache._offsets).toEqual([0, 11, 22, 33, 53, 73, -1, -1, -1, -1]); + expect(computeTotalSize(cache)).toBe(sum(filledSizes)); }); - }); -}); -describe(computeTotalSize.name, () => { - it("should succeed if sizes is filled", () => { - const filledSizes = range(10, () => 20); - const cache = initCacheWithEmptyOffsets(filledSizes, 30); - - expect(computeTotalSize(cache)).toBe(sum(filledSizes)); - expect(cache._offsets).toEqual([ - 0, 20, 40, 60, 80, 100, 120, 140, 160, 180, - ]); - }); - - it("should succeed if sizes is not filled", () => { - const emptySizes = range(10, () => -1); - const cache = initCacheWithEmptyOffsets(emptySizes, 30); - - expect(computeTotalSize(cache)).toBe(sum(range(10, () => 30))); - expect(cache._offsets).toEqual([ - 0, 30, 60, 90, 120, 150, 180, 210, 240, 270, - ]); - }); - - it("should return 0 if cache length is 0", () => { - const cache = initCacheWithEmptyOffsets([], 30); - expect(computeTotalSize(cache)).toBe(0); - expect(cache._offsets).toEqual([]); - }); + it("should succeed if sizes is not filled", () => { + const emptySizes = range(10, () => -1); + const cache = initCacheWithSizes(emptySizes, 30); - describe("with cached offsets", () => { - it("should start from cached offset if measuredOffsetIndex is at cached", () => { - const filledSizes = range(10, () => 20); - const offsets = [0, 11, 22, 33, -1, -1, -1, -1, -1, -1]; - const cache: Cache = { - _length: filledSizes.length, - _sizes: filledSizes, - _computedOffsetIndex: 2, - _offsets: offsets, - _defaultItemSize: 30, - }; - expect(computeTotalSize(cache)).toBe(sum(range(8, () => 20)) + 22); - expect(cache._offsets).toEqual([ - 0, 11, 22, 42, 62, 82, 102, 122, 142, 162, - ]); + expect(computeTotalSize(cache)).toBe(sum(range(10, () => 30))); }); - it("should return cached offset + 1 item size if measuredOffsetIndex is at end", () => { - const filledSizes = range(10, () => 20); - const offsets = [0, 11, 22, 33, 44, 55, 66, 77, 88, 99]; - const cache: Cache = { - _length: filledSizes.length, - _sizes: filledSizes, - _computedOffsetIndex: 9, - _offsets: offsets, - _defaultItemSize: 30, - }; - expect(computeTotalSize(cache)).toBe(99 + 20); - expect(cache._offsets).toEqual([0, 11, 22, 33, 44, 55, 66, 77, 88, 99]); + it("should return 0 if cache length is 0", () => { + const cache = initCacheWithSizes([], 30); + expect(computeTotalSize(cache)).toBe(0); }); }); }); @@ -333,7 +167,7 @@ describe(findIndex.name, () => { const CACHE_LENGTH = 10; it("should resolve default height", () => { - const cache = initCacheWithEmptyOffsets( + const cache = initCacheWithSizes( range(10, () => -1), 25 ); @@ -341,7 +175,7 @@ describe(findIndex.name, () => { }); it("should get start if offset is at start", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -349,7 +183,7 @@ describe(findIndex.name, () => { }); it("should get start if offset is at start + 1px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -357,7 +191,7 @@ describe(findIndex.name, () => { }); it("should get start if offset is at start + 0.01px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -365,7 +199,7 @@ describe(findIndex.name, () => { }); it("should get start if offset is at start - 1px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -373,7 +207,7 @@ describe(findIndex.name, () => { }); it("should get start if offset is at start - 0.01px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -381,7 +215,7 @@ describe(findIndex.name, () => { }); it("should get end if offset is at end", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -389,7 +223,7 @@ describe(findIndex.name, () => { }); it("should get end if offset is at end + 1px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -397,7 +231,7 @@ describe(findIndex.name, () => { }); it("should get end if offset is at end + 0.01px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -405,7 +239,7 @@ describe(findIndex.name, () => { }); it("should get end if offset is at end - 1px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -413,7 +247,7 @@ describe(findIndex.name, () => { }); it("should get end if offset is at end - 0.01px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -421,7 +255,7 @@ describe(findIndex.name, () => { }); it("should get 1 if offset fits index 1", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -429,7 +263,7 @@ describe(findIndex.name, () => { }); it("should get 1 if offset fits index 1 + 1px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -437,7 +271,7 @@ describe(findIndex.name, () => { }); it("should get 1 if offset fits index 1 + 0.01px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -445,7 +279,7 @@ describe(findIndex.name, () => { }); it("should get 0 if offset fits index 1 - 1px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -453,7 +287,7 @@ describe(findIndex.name, () => { }); it("should get 0 if offset fits index 1 - 0.01px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -461,7 +295,7 @@ describe(findIndex.name, () => { }); it("should get 1 if offset fits index 1.5", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -469,7 +303,7 @@ describe(findIndex.name, () => { }); it("should get 1 if offset fits index 1.5 + 1px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -477,7 +311,7 @@ describe(findIndex.name, () => { }); it("should get 1 if offset fits index 1.5 + 0.01px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -485,7 +319,7 @@ describe(findIndex.name, () => { }); it("should get 1 if offset fits index 1.5 - 1px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); @@ -493,32 +327,35 @@ describe(findIndex.name, () => { }); it("should get 1 if offset fits index 1.5 - 0.01px", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); expect(findIndex(cache, 29.99)).toBe(1); }); -}); -describe(computeRange.name, () => { - const CACHE_LENGTH = 10; + describe("computeRange", () => { + const computeRange = ( + cache: Cache, + offset: number, + viewportSize: number + ): ItemsRange => { + return [ + findIndex(cache, offset), + findIndex(cache, offset + viewportSize), + ]; + }; + const CACHE_LENGTH = 10; - describe.each([ - [0], // start - [Math.floor(CACHE_LENGTH / 2)], // mid - [CACHE_LENGTH - 1], // end - ])("start from %i", (initialIndex) => { it("should get start if offset is at start", () => { expect( computeRange( - initCacheWithComputedOffsets( + initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ), 0, - 100, - initialIndex + 100 ) ).toEqual([0, 5]); }); @@ -526,84 +363,72 @@ describe(computeRange.name, () => { it("should get start + 1 if offset is at start + 1", () => { expect( computeRange( - initCacheWithComputedOffsets( + initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ), 20, - 100, - initialIndex + 100 ) ).toEqual([1, 6]); }); it("should get last if offset is at end", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); const last = cache._length - 1; - expect(computeRange(cache, sum(cache._sizes), 100, initialIndex)).toEqual( - [last, last] - ); + expect(computeRange(cache, sum(cache._sizes), 100)).toEqual([last, last]); }); it("should get last if offset is at end - 1", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); const last = cache._length - 1; - expect( - computeRange(cache, sum(cache._sizes) - 20, 100, initialIndex) - ).toEqual([last, last]); + expect(computeRange(cache, sum(cache._sizes) - 20, 100)).toEqual([ + last, + last, + ]); }); it("should get last - 1 if offset is at end - 1 and more", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); const last = cache._length - 1; - expect( - computeRange(cache, sum(cache._sizes) - 20 - 1, 100, initialIndex) - ).toEqual([last - 1, last]); + expect(computeRange(cache, sum(cache._sizes) - 20 - 1, 100)).toEqual([ + last - 1, + last, + ]); }); it("should get start if offset is before start", () => { expect( computeRange( - initCacheWithComputedOffsets( + initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ), -1000, - 100, - initialIndex + 100 ) ).toEqual([0, 0]); }); it("should get last if offset is after end", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(CACHE_LENGTH, () => 20), 30 ); const last = cache._length - 1; - expect( - computeRange(cache, sum(cache._sizes) + 1000, 100, initialIndex) - ).toEqual([last, last]); - }); - - it("should get prevStartIndex if offset fits prevStartIndex", () => { - const offset = (cache: Cache, i: number) => sum(cache._sizes.slice(0, i)); - const cache = initCacheWithComputedOffsets( - range(CACHE_LENGTH, () => 20), - 30 - ); - expect( - computeRange(cache, offset(cache, initialIndex), 100, initialIndex) - ).toEqual([initialIndex, expect.any(Number)]); + expect(computeRange(cache, sum(cache._sizes) + 1000, 100)).toEqual([ + last, + last, + ]); }); }); }); @@ -611,7 +436,7 @@ describe(computeRange.name, () => { describe(estimateDefaultItemSize.name, () => { describe("start", () => { it("should update with 1 entry", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(100, () => -1), 30 ); @@ -622,12 +447,11 @@ describe(estimateDefaultItemSize.name, () => { const diff = estimateDefaultItemSize(cache, 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( + const cache = initCacheWithSizes( range(100, () => -1), 30 ); @@ -638,12 +462,11 @@ describe(estimateDefaultItemSize.name, () => { const diff = estimateDefaultItemSize(cache, 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( + const cache = initCacheWithSizes( range(100, () => -1), 30 ); @@ -654,14 +477,13 @@ describe(estimateDefaultItemSize.name, () => { const diff = estimateDefaultItemSize(cache, 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( + const cache = initCacheWithSizes( range(100, () => -1), 30 ); @@ -672,12 +494,11 @@ describe(estimateDefaultItemSize.name, () => { const diff = estimateDefaultItemSize(cache, 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( + const cache = initCacheWithSizes( range(100, () => -1), 30 ); @@ -688,12 +509,11 @@ describe(estimateDefaultItemSize.name, () => { const diff = estimateDefaultItemSize(cache, 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( + const cache = initCacheWithSizes( range(100, () => -1), 30 ); @@ -704,12 +524,11 @@ describe(estimateDefaultItemSize.name, () => { const diff = estimateDefaultItemSize(cache, 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( + const cache = initCacheWithSizes( range(100, () => -1), 30 ); @@ -720,7 +539,6 @@ describe(estimateDefaultItemSize.name, () => { const diff = estimateDefaultItemSize(cache, 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)); }); }); @@ -732,20 +550,20 @@ describe(initCache.name, () => { const cache = initCache(itemLength, 23); expect(cache).toMatchInlineSnapshot(` { - "_computedOffsetIndex": -1, "_defaultItemSize": 23, "_length": 10, "_offsets": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, + 0, + 23, + 46, + 23, + 92, + 23, + 46, + 23, + 184, + 23, + 46, ], "_sizes": [ -1, @@ -763,7 +581,7 @@ describe(initCache.name, () => { `); expect(cache._length).toBe(itemLength); expect(cache._sizes.length).toBe(itemLength); - expect(cache._offsets.length).toBe(itemLength); + expect(cache._offsets.length).toBe(itemLength + 1); }); it("should restore cache from snapshot", () => { @@ -773,39 +591,39 @@ describe(initCache.name, () => { 123, ]); expect(cache).toMatchInlineSnapshot(` - { - "_computedOffsetIndex": -1, - "_defaultItemSize": 123, - "_length": 10, - "_offsets": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - ], - "_sizes": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - ], - } - `); + { + "_defaultItemSize": 123, + "_length": 10, + "_offsets": [ + 0, + 0, + 1, + 2, + 6, + 4, + 9, + 6, + 28, + 8, + 17, + ], + "_sizes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ], + } + `); expect(cache._length).toBe(itemLength); expect(cache._sizes.length).toBe(itemLength); - expect(cache._offsets.length).toBe(itemLength); + expect(cache._offsets.length).toBe(itemLength + 1); }); it("should restore cache from snapshot which has shorter length", () => { @@ -813,20 +631,20 @@ describe(initCache.name, () => { const cache = initCache(itemLength, 23, [[0, 1, 2, 3, 4], 123]); expect(cache).toMatchInlineSnapshot(` { - "_computedOffsetIndex": -1, "_defaultItemSize": 123, "_length": 10, "_offsets": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, + 0, + 0, + 1, + 2, + 6, + 4, + 127, + 123, + 379, + 123, + 246, ], "_sizes": [ 0, @@ -844,7 +662,7 @@ describe(initCache.name, () => { `); expect(cache._length).toBe(itemLength); expect(cache._sizes.length).toBe(itemLength); - expect(cache._offsets.length).toBe(itemLength); + expect(cache._offsets.length).toBe(itemLength + 1); }); it("should restore cache from snapshot which has longer length", () => { @@ -854,45 +672,45 @@ describe(initCache.name, () => { 123, ]); expect(cache).toMatchInlineSnapshot(` - { - "_computedOffsetIndex": -1, - "_defaultItemSize": 123, - "_length": 10, - "_offsets": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - ], - "_sizes": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - ], - } - `); + { + "_defaultItemSize": 123, + "_length": 10, + "_offsets": [ + 0, + 0, + 1, + 2, + 6, + 4, + 9, + 6, + 28, + 8, + 17, + ], + "_sizes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ], + } + `); expect(cache._length).toBe(itemLength); expect(cache._sizes.length).toBe(itemLength); - expect(cache._offsets.length).toBe(itemLength); + expect(cache._offsets.length).toBe(itemLength + 1); }); }); describe(takeCacheSnapshot.name, () => { it("smoke", () => { - const cache = initCacheWithComputedOffsets( + const cache = initCacheWithSizes( range(10, (i) => (i + 1) * 10), 40 ); @@ -931,25 +749,25 @@ describe(updateCacheLength.name, () => { expect(res).toEqual(40 * 5); expect(cache).toMatchInlineSnapshot(` { - "_computedOffsetIndex": -1, "_defaultItemSize": 40, "_length": 15, "_offsets": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, + 0, + 40, + 80, + 40, + 160, + 40, + 80, + 40, + 320, + 40, + 80, + 40, + 160, + 40, + 80, + 40, ], "_sizes": [ -1, @@ -979,15 +797,15 @@ describe(updateCacheLength.name, () => { expect(res).toEqual(-(40 * 4 + 123)); expect(cache).toMatchInlineSnapshot(` { - "_computedOffsetIndex": -1, "_defaultItemSize": 40, "_length": 5, "_offsets": [ - -1, - -1, - -1, - -1, - -1, + 0, + 40, + 80, + 40, + 160, + 40, ], "_sizes": [ -1, @@ -1014,25 +832,25 @@ describe(updateCacheLength.name, () => { expect(res).toEqual(40 * 5); expect(cache).toMatchInlineSnapshot(` { - "_computedOffsetIndex": -1, "_defaultItemSize": 40, "_length": 15, "_offsets": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, + 0, + 40, + 80, + 40, + 160, + 40, + 80, + 40, + 320, + 40, + 80, + 40, + 160, + 40, + 80, + 40, ], "_sizes": [ -1, @@ -1062,15 +880,15 @@ describe(updateCacheLength.name, () => { expect(res).toEqual(-(40 * 4 + 123)); expect(cache).toMatchInlineSnapshot(` { - "_computedOffsetIndex": -1, "_defaultItemSize": 40, "_length": 5, "_offsets": [ - -1, - -1, - -1, - -1, - -1, + 0, + 40, + 80, + 40, + 160, + 40, ], "_sizes": [ -1, diff --git a/src/core/cache.ts b/src/core/cache.ts index 0d923363..79f60cdf 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -1,8 +1,9 @@ -import { type InternalCacheSnapshot, type ItemsRange } from "./types"; -import { clamp, floor, max, min, sort } from "./utils"; +import * as bit from "./bit"; +import { type InternalCacheSnapshot } from "./types"; +import { clamp, max, min, sort, append } from "./utils"; type Writeable = { - -readonly [key in keyof T]: Writeable; + -readonly [key in keyof T]: T[key]; }; /** @internal */ @@ -11,23 +12,14 @@ export const UNCACHED = -1; /** * @internal */ -export type Cache = { +export interface Cache { readonly _length: number; // sizes - readonly _sizes: number[]; readonly _defaultItemSize: number; + readonly _sizes: number[]; // offsets - readonly _computedOffsetIndex: number; - readonly _offsets: number[]; -}; - -const fill = (array: number[], length: number, prepend?: boolean): number[] => { - const key = prepend ? "unshift" : "push"; - for (let i = 0; i < length; i++) { - array[key](UNCACHED); - } - return array; -}; + readonly _offsets: bit.BIT; +} /** * @internal @@ -41,55 +33,23 @@ export const getItemSize = (cache: Cache, index: number): number => { * @internal */ export const setItemSize = ( - cache: Writeable, + cache: Cache, index: number, size: number ): boolean => { + const prevSize = getItemSize(cache, index); const isInitialMeasurement = cache._sizes[index] === UNCACHED; cache._sizes[index] = size; - // mark as dirty - cache._computedOffsetIndex = min(index, cache._computedOffsetIndex); + /*#__NOINLINE__*/ bit.add(cache._offsets, index + 1, size - prevSize); return isInitialMeasurement; }; /** * @internal */ -export const computeOffset = ( - cache: Writeable, - index: number -): number => { - if (!cache._length) return 0; - if (cache._computedOffsetIndex >= index) { - return cache._offsets[index]!; - } - - if (cache._computedOffsetIndex < 0) { - // first offset must be 0 to avoid returning NaN, which can cause infinite rerender. - // https://github.com/inokawa/virtua/pull/160 - cache._offsets[0] = 0; - cache._computedOffsetIndex = 0; - } - let i = cache._computedOffsetIndex; - let top = cache._offsets[i]!; - while (i < index) { - top += getItemSize(cache, i); - cache._offsets[++i] = top; - } - // mark as measured - cache._computedOffsetIndex = index; - return top; -}; - -/** - * @internal - */ -export const computeTotalSize = (cache: Cache): number => { +export const getItemOffset = (cache: Cache, index: number): number => { if (!cache._length) return 0; - return ( - computeOffset(cache, cache._length - 1) + - getItemSize(cache, cache._length - 1) - ); + return /*#__NOINLINE__*/ bit.get(cache._offsets, index); }; /** @@ -97,51 +57,12 @@ export const computeTotalSize = (cache: Cache): number => { * * @internal */ -export const findIndex = ( - cache: Cache, - offset: number, - low: number = 0, - high: number = cache._length - 1 -): number => { - // Find with binary search - while (low <= high) { - const mid = floor((low + high) / 2); - const itemOffset = computeOffset(cache, mid); - if (itemOffset <= offset) { - if (itemOffset + getItemSize(cache, mid) > offset) { - return mid; - } - low = mid + 1; - } else { - high = mid - 1; - } - } - return clamp(low, 0, cache._length - 1); -}; - -/** - * @internal - */ -export const computeRange = ( - cache: Cache, - scrollOffset: number, - viewportSize: number, - prevStartIndex: number -): ItemsRange => { - // Clamp because prevStartIndex may exceed the limit when children decreased a lot after scrolling - prevStartIndex = min(prevStartIndex, cache._length - 1); - - if (computeOffset(cache, prevStartIndex) <= scrollOffset) { - // search forward - // start <= end, prevStartIndex <= start - const end = findIndex(cache, scrollOffset + viewportSize, prevStartIndex); - return [findIndex(cache, scrollOffset, prevStartIndex, end), end]; - } else { - // search backward - // start <= end, start <= prevStartIndex - const start = findIndex(cache, scrollOffset, undefined, prevStartIndex); - return [start, findIndex(cache, scrollOffset + viewportSize, start)]; - } +export const findIndex = (cache: Cache, offset: number): number => { + return clamp( + /*#__NOINLINE__*/ bit.lowerBound(cache._offsets, offset), + 0, + cache._length - 1 + ); }; /** @@ -163,9 +84,6 @@ export const estimateDefaultItemSize = ( } }); - // Discard cache for now - cache._computedOffsetIndex = -1; - // Calculate median const sorted = sort(measuredSizes); const len = sorted.length; @@ -174,14 +92,27 @@ export const estimateDefaultItemSize = ( len % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!; const prevDefaultItemSize = cache._defaultItemSize; + cache._defaultItemSize = median; + + // Discard cache for now + cache._offsets = initOffsets(cache._sizes, cache._defaultItemSize); // Calculate diff of unmeasured items before start return ( - ((cache._defaultItemSize = median) - prevDefaultItemSize) * + (cache._defaultItemSize - prevDefaultItemSize) * max(startIndex - measuredCountBeforeStart, 0) ); }; +const initOffsets = ( + sizes: readonly number[], + defaultSize: number +): bit.BIT => { + return /*#__NOINLINE__*/ bit.init( + sizes.map((s) => (s === UNCACHED ? defaultSize : s)) + ); +}; + /** * @internal */ @@ -190,19 +121,21 @@ export const initCache = ( itemSize: number, snapshot?: InternalCacheSnapshot ): Cache => { + const defaultSize = snapshot ? snapshot[1] : itemSize; + const sizes = + snapshot && snapshot[0] + ? // https://github.com/inokawa/virtua/issues/441 + append( + snapshot[0].slice(0, min(length, snapshot[0].length)), + max(0, length - snapshot[0].length), + UNCACHED + ) + : append([], length, UNCACHED); return { - _defaultItemSize: snapshot ? snapshot[1] : itemSize, - _sizes: - snapshot && snapshot[0] - ? // https://github.com/inokawa/virtua/issues/441 - fill( - snapshot[0].slice(0, min(length, snapshot[0].length)), - max(0, length - snapshot[0].length) - ) - : fill([], length), _length: length, - _computedOffsetIndex: -1, - _offsets: fill([], length), + _defaultItemSize: defaultSize, + _sizes: sizes, + _offsets: initOffsets(sizes, defaultSize), }; }; @@ -223,26 +156,36 @@ export const updateCacheLength = ( ): number => { const diff = length - cache._length; - cache._computedOffsetIndex = isShift - ? // Discard cache for now - -1 - : min(length - 1, cache._computedOffsetIndex); cache._length = length; if (diff > 0) { // Added - fill(cache._offsets, diff); - fill(cache._sizes, diff, isShift); + append(cache._sizes, diff, UNCACHED, isShift); + + if (isShift) { + cache._offsets = initOffsets(cache._sizes, cache._defaultItemSize); + } else { + for (let i = 0; i < diff; i++) { + /*#__NOINLINE__*/ bit.push(cache._offsets, cache._defaultItemSize); + } + } return cache._defaultItemSize * diff; } else { // Removed - cache._offsets.splice(diff); - return ( + const amount = ( isShift ? cache._sizes.splice(0, -diff) : cache._sizes.splice(diff) ).reduce( (acc, removed) => acc - (removed === UNCACHED ? cache._defaultItemSize : removed), 0 ); + if (isShift) { + cache._offsets = initOffsets(cache._sizes, cache._defaultItemSize); + } else { + for (let i = diff; i < 0; i++) { + cache._offsets.pop(); + } + } + return amount; } }; diff --git a/src/core/store.ts b/src/core/store.ts index dbb05de7..a7e66880 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -1,13 +1,11 @@ import { initCache, getItemSize as _getItemSize, - computeTotalSize, - computeOffset as computeStartOffset, + getItemOffset as _getItemOffset, UNCACHED, setItemSize, estimateDefaultItemSize, updateCacheLength, - computeRange, takeCacheSnapshot, findIndex, } from "./cache"; @@ -155,12 +153,12 @@ export const createVirtualStore = ( const subscribers = new Set<[number, Subscriber]>(); const getRelativeScrollOffset = () => scrollOffset - startSpacerSize; const getVisibleOffset = () => getRelativeScrollOffset() + pendingJump + jump; - const getRange = (offset: number) => { - return computeRange(cache, offset, viewportSize, _prevRange[0]); + const getRange = (offset: number): ItemsRange => { + return [findIndex(cache, offset), findIndex(cache, offset + viewportSize)]; }; - const getTotalSize = (): number => computeTotalSize(cache); + const getTotalSize = (): number => _getItemOffset(cache, cache._length); const getItemOffset = (index: number): number => { - return computeStartOffset(cache, index) - pendingJump; + return _getItemOffset(cache, index) - pendingJump; }; const getItemSize = (index: number): number => { return _getItemSize(cache, index); diff --git a/src/core/utils.ts b/src/core/utils.ts index 7b226ce0..05bb3617 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -2,7 +2,7 @@ export const NULL = null; /** @internal */ -export const { min, max, abs, floor } = Math; +export const { min, max, abs, clz32 } = Math; /** * @internal @@ -13,6 +13,22 @@ export const clamp = ( maxValue: number ): number => min(maxValue, max(minValue, value)); +/** + * @internal + */ +export const append = ( + array: number[], + length: number, + value: number, + prepend?: boolean +): number[] => { + const key = prepend ? "unshift" : "push"; + for (let i = 0; i < length; i++) { + array[key](value); + } + return array; +}; + /** * @internal */