From 6dea781b659538d2691501f1b35494a691cbcad2 Mon Sep 17 00:00:00 2001 From: Marius Schulz Date: Fri, 12 Jun 2026 10:46:21 +0000 Subject: [PATCH 1/3] bench(core): add scroll-loop benchmark for per-frame cost Exercises `calculateRange` and the memo machinery the way a real scroll does: bump `scrollOffset`, recompute range. None of the existing benches covered the per-scroll-frame path. --- packages/virtual-core/tests/bench.bench.ts | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/virtual-core/tests/bench.bench.ts b/packages/virtual-core/tests/bench.bench.ts index ea9464c0..d3b3f8d8 100644 --- a/packages/virtual-core/tests/bench.bench.ts +++ b/packages/virtual-core/tests/bench.bench.ts @@ -200,6 +200,29 @@ describe('Layer 2: setOptions() — simulating React render storm', () => { }) }) +// ─── Scroll loop: per-scroll-frame cost (calculateRange + memo machinery) ───── + +describe('Scroll loop: 10k scroll events on warm virtualizer', () => { + for (const n of [1000, 100000]) { + bench(`n=${n}, 10k scrolls`, () => { + const v = makeVirt(n) + v.scrollRect = { width: 800, height: 600 } + for (let i = 0; i < 10_000; i++) { + v.scrollOffset = i * 5 + ;(v as any).calculateRange() + } + }) + bench(`n=${n}, 10k scrolls + getVirtualItems`, () => { + const v = makeVirt(n) + v.scrollRect = { width: 800, height: 600 } + for (let i = 0; i < 10_000; i++) { + v.scrollOffset = i * 5 + v.getVirtualItems() + } + }) + } +}) + // ─── Layer 6: defaultRangeExtractor ────────────────────────────────────────── describe('Layer 6: defaultRangeExtractor', () => { From bc4d81eec495616e584cdb4009b60219b00f77ef Mon Sep 17 00:00:00 2001 From: Marius Schulz Date: Fri, 12 Jun 2026 10:48:27 +0000 Subject: [PATCH 2/3] perf(core): drop closures from `calculateRange` on the `lanes===1` hot path `scrollOffset` is a memo dep, so the body runs on every scroll event. Before: a 5-field options object, two `getStart`/`getEnd` closures, and polymorphic indirect calls during binary search. After: positional args; a monomorphic `findNearestBinarySearchFlat` that indexes the `Float64Array` directly. --- .changeset/calculate-range-fewer-allocs.md | 5 + packages/virtual-core/src/index.ts | 106 +++++++++++++-------- 2 files changed, 69 insertions(+), 42 deletions(-) create mode 100644 .changeset/calculate-range-fewer-allocs.md diff --git a/.changeset/calculate-range-fewer-allocs.md b/.changeset/calculate-range-fewer-allocs.md new file mode 100644 index 00000000..6f1e53b7 --- /dev/null +++ b/.changeset/calculate-range-fewer-allocs.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Cut per-scroll-frame allocations on the default `lanes === 1` path. Range computation previously allocated an options object and two closures on every scroll event; it now does the same work allocation-free, reducing GC pressure during continuous scroll. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 7af791d1..86c37f31 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1334,22 +1334,22 @@ export class Virtualizer< this.options.lanes, ], (measurements, outerSize, scrollOffset, lanes) => { - return (this.range = - measurements.length > 0 && outerSize > 0 - ? calculateRange({ - measurements, - outerSize, - scrollOffset, - lanes, - // Pass the typed array so binary search + forward-walk can - // read start/end directly from Float64Array, skipping the - // Proxy traps that materialize a full VirtualItem per probe. - flat: - lanes === 1 && this._flatMeasurements != null - ? this._flatMeasurements - : null, - }) - : null) + if (measurements.length === 0 || outerSize === 0) { + this.range = null + return null + } + this.range = calculateRangeImpl( + measurements, + outerSize, + scrollOffset, + lanes, + // Pass the typed array so binary search + forward-walk can read + // start/end directly from Float64Array, skipping the Proxy traps. + lanes === 1 && this._flatMeasurements != null + ? this._flatMeasurements + : null, + ) + return this.range }, { key: process.env.NODE_ENV !== 'production' && 'calculateRange', @@ -1895,45 +1895,67 @@ const findNearestBinarySearch = ( } } -function calculateRange({ - measurements, - outerSize, - scrollOffset, - lanes, - flat, -}: { - measurements: Array - outerSize: number - scrollOffset: number - lanes: number - flat: Float64Array | null -}) { +// Monomorphic Float64Array variant — reads start values directly at stride +// 2 instead of through a getter closure. JITs the inner load to a typed- +// array bounds-check + load with no indirect call. +function findNearestBinarySearchFlat( + flat: Float64Array, + high: number, + value: number, +) { + let low = 0 + while (low <= high) { + const middle = ((low + high) / 2) | 0 + const currentValue = flat[middle * 2]! + + if (currentValue < value) { + low = middle + 1 + } else if (currentValue > value) { + high = middle - 1 + } else { + return middle + } + } + return low > 0 ? low - 1 : 0 +} + +function calculateRangeImpl( + measurements: Array, + outerSize: number, + scrollOffset: number, + lanes: number, + flat: Float64Array | null, +) { const lastIndex = measurements.length - 1 - // When the lanes===1 fast-path is active, read start/end directly from the - // flat Float64Array instead of going through the lazy-view Proxy. Cuts - // ~17 Proxy.get traps per scroll for the binary search alone. - const getStart = flat - ? (index: number) => flat[index * 2]! - : (index: number) => measurements[index]!.start - const getEnd = flat - ? (index: number) => flat[index * 2]! + flat[index * 2 + 1]! - : (index: number) => measurements[index]!.end // handle case when item count is less than or equal to lanes if (measurements.length <= lanes) { - return { - startIndex: 0, - endIndex: lastIndex, + return { startIndex: 0, endIndex: lastIndex } + } + + if (lanes === 1 && flat !== null) { + // Hot single-lane path: typed-array reads, no closures, no Proxy traps. + const startIndex = findNearestBinarySearchFlat(flat, lastIndex, scrollOffset) + let endIndex = startIndex + const limit = scrollOffset + outerSize + while ( + endIndex < lastIndex && + flat[endIndex * 2]! + flat[endIndex * 2 + 1]! < limit + ) { + endIndex++ } + return { startIndex, endIndex } } + // Fallback (lanes > 1 or no flat array): closure-based reads. + const getStart = (index: number) => measurements[index]!.start let startIndex = findNearestBinarySearch(0, lastIndex, getStart, scrollOffset) let endIndex = startIndex if (lanes === 1) { while ( endIndex < lastIndex && - getEnd(endIndex) < scrollOffset + outerSize + measurements[endIndex]!.end < scrollOffset + outerSize ) { endIndex++ } From 7b5413ee62c3f1a8cb7fb5d521daf12c9926f172 Mon Sep 17 00:00:00 2001 From: Marius Schulz Date: Tue, 23 Jun 2026 08:10:41 +0000 Subject: [PATCH 3/3] bench(core): wrap scroll-loop offsets at max scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For `n=1000` (30k px content, 600 px viewport), `i * 5` reached ~50k — past the ~29.4k max scroll — so a large share of iterations benched the degenerate end-of-list range instead of a representative position. Wrap with `% maxOffset` so every iteration does real binary-search and forward-walk work. --- packages/virtual-core/tests/bench.bench.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/virtual-core/tests/bench.bench.ts b/packages/virtual-core/tests/bench.bench.ts index d3b3f8d8..8f54275e 100644 --- a/packages/virtual-core/tests/bench.bench.ts +++ b/packages/virtual-core/tests/bench.bench.ts @@ -204,11 +204,15 @@ describe('Layer 2: setOptions() — simulating React render storm', () => { describe('Scroll loop: 10k scroll events on warm virtualizer', () => { for (const n of [1000, 100000]) { + // Wrap offsets at the real max scroll so every iteration exercises a + // representative range (not the degenerate end-of-list state once + // i*5 exceeds totalSize - viewport). + const maxOffset = n * 30 - 600 bench(`n=${n}, 10k scrolls`, () => { const v = makeVirt(n) v.scrollRect = { width: 800, height: 600 } for (let i = 0; i < 10_000; i++) { - v.scrollOffset = i * 5 + v.scrollOffset = (i * 5) % maxOffset ;(v as any).calculateRange() } }) @@ -216,7 +220,7 @@ describe('Scroll loop: 10k scroll events on warm virtualizer', () => { const v = makeVirt(n) v.scrollRect = { width: 800, height: 600 } for (let i = 0; i < 10_000; i++) { - v.scrollOffset = i * 5 + v.scrollOffset = (i * 5) % maxOffset v.getVirtualItems() } })