From 9e828e1aae3d5297b975e3d1629d03c89f23a115 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 22 Sep 2026 17:51:10 -0400 Subject: [PATCH] Perf regression bugfix --- lib/core/getStartStopIndices.test.ts | 35 ++++++++++++++++++++++++++++ lib/core/getStartStopIndices.ts | 18 +++++++------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/lib/core/getStartStopIndices.test.ts b/lib/core/getStartStopIndices.test.ts index 97452964..a43fdeda 100644 --- a/lib/core/getStartStopIndices.test.ts +++ b/lib/core/getStartStopIndices.test.ts @@ -291,6 +291,41 @@ test("initial variable-size lookup only measures the viewport", () => { expect(measurements).toBe(25); }); +test.each([0, 25])( + "deep variable-size lookup with %i cached rows only measures through the viewport", + (cachedRowCount) => { + let measurements = 0; + const cachedBounds = createCachedBounds({ + itemCount: 100000, + itemProps: {}, + itemSize: (index) => { + measurements++; + return index % 2 === 0 ? 10 : 30; + } + }); + if (cachedRowCount > 0) { + cachedBounds.get(cachedRowCount - 1); + } + measurements = 0; + + const result = getStartStopIndices({ + cachedBounds, + containerScrollOffset: 50000, + containerSize: 80, + itemCount: 100000, + overscanCount: 3 + }); + + expect(result).toEqual({ + startIndexVisible: 2500, + stopIndexVisible: 2503, + startIndexOverscan: 2497, + stopIndexOverscan: 2506 + }); + expect(measurements).toBe(2504 - cachedRowCount); + } +); + test("matches a linear lookup across boundaries, zero sizes and scroll directions", () => { for (const sizes of [ [], diff --git a/lib/core/getStartStopIndices.ts b/lib/core/getStartStopIndices.ts index d8416bde..ee38f6a3 100644 --- a/lib/core/getStartStopIndices.ts +++ b/lib/core/getStartStopIndices.ts @@ -46,18 +46,9 @@ export function getStartStopIndices({ ) ); } else { - // Search the measured prefix first. Grow the search exponentially only - // when scrolling beyond it, so an initial render doesn't measure the list. + // Binary search the measured prefix let low = 0; let high = Math.min(maxIndex, Math.max(0, cachedBounds.size - 1)); - let step = 1; - while (high < maxIndex) { - const bounds = cachedBounds.get(high); - if (bounds.scrollOffset + bounds.size > containerScrollOffset) break; - low = high + 1; - high = Math.min(maxIndex, high + step); - step *= 2; - } while (low < high) { const middle = Math.floor((low + high) / 2); @@ -69,6 +60,13 @@ export function getStartStopIndices({ } } + // Extend sequentially beyond the measured prefix to avoid evaluating item sizes past the viewport + while (low < maxIndex) { + const bounds = cachedBounds.get(low); + if (bounds.scrollOffset + bounds.size > containerScrollOffset) break; + low++; + } + startIndexVisible = low; stopIndexVisible = low; while (stopIndexVisible < maxIndex) {