From 93f00d7fd1c2014839491165e4fe3a0f85c157ec Mon Sep 17 00:00:00 2001 From: Yeongbin Jeong Date: Mon, 9 Nov 2026 16:35:20 +0900 Subject: [PATCH 1/3] fix(virtual-core): keep the lane of a row measured at its estimate In measured mode a lane is cached only for a row with a recorded measurement, and resizeItem recorded one only when the size changed. A row whose first measurement equals its estimate stayed uncached, so a row growing above it could move it to another lane. --- .changeset/measured-lane-at-estimate.md | 5 ++ packages/virtual-core/src/index.ts | 12 ++++ packages/virtual-core/tests/index.test.ts | 60 +++++++++++++++++++ .../tests/lane-equivalence.test.ts | 3 +- 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 .changeset/measured-lane-at-estimate.md diff --git a/.changeset/measured-lane-at-estimate.md b/.changeset/measured-lane-at-estimate.md new file mode 100644 index 000000000..f3dc87744 --- /dev/null +++ b/.changeset/measured-lane-at-estimate.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Keep the lane of a row measured at exactly its estimate in `laneAssignmentMode: 'measured'`. Such a row never had its lane cached, so a row growing above it could move it to another lane. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index bd6e7cb23..016825b4f 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -2165,6 +2165,18 @@ export class Virtualizer< // `_willUpdate` (#1266). this._flushHoldIfReady(true) this._retryClampedAdjustment() + } else if ( + this.options.lanes > 1 && + this.options.laneAssignmentMode === 'measured' && + !this.options.useCachedMeasurements + ) { + // Measured at its estimate: nothing moves, but the row counts as measured + // and its lane must not change later. A pending rebuild from this row or + // above caches the lane; otherwise none will, so cache it here. + this.itemSizeCache.set(key, size) + if (this.pendingMin === null || index < this.pendingMin) { + this.laneAssignments.set(index, this.measurementsCache[index]!.lane) + } } } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 7e609fd27..928ac3e77 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -200,6 +200,66 @@ test("should defer lane caching until measurement when laneAssignmentMode is 'me expect(lanesBeforeResize).toEqual(lanesAfterResize) }) +const measuredLanes = () => { + const virtualizer = new Virtualizer({ + count: 8, + lanes: 2, + estimateSize: () => 100, + laneAssignmentMode: 'measured', + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const lanes = () => virtualizer['getMeasurements']().map((m) => m.lane) + lanes() + return { virtualizer, lanes } +} + +test("should keep the lane of a row measured at its estimate when laneAssignmentMode is 'measured'", () => { + const { virtualizer, lanes } = measuredLanes() + const before = lanes() + + for (let i = 0; i < 8; i++) virtualizer.resizeItem(i, 100) + virtualizer.resizeItem(1, 500) + + expect(lanes()).toEqual(before) +}) + +test('should keep the lane of a row measured at its estimate after a row above it changed in the same batch', () => { + const { virtualizer, lanes } = measuredLanes() + + // Rows 2 and on are measured after row 1 changed, with no rebuild in between. + virtualizer.resizeItem(1, 300) + for (let i = 2; i < 8; i++) virtualizer.resizeItem(i, 100) + const before = lanes() + expect(before).toEqual([0, 1, 0, 0, 1, 0, 1, 0]) + virtualizer.resizeItem(1, 500) + + expect(lanes()).toEqual(before) +}) + +test.each([{ lanes: 1 }, { lanes: 2, useCachedMeasurements: true }])( + 'should not cache a lane for a size equal to the estimate with %o', + (options) => { + const virtualizer = new Virtualizer({ + count: 4, + estimateSize: () => 100, + laneAssignmentMode: 'measured', + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + ...options, + }) + virtualizer['getMeasurements']() + + virtualizer.resizeItem(0, 100) + + expect(virtualizer['laneAssignments'].size).toBe(0) + }, +) + test("should cache lanes incrementally as items are measured when laneAssignmentMode is 'measured'", () => { const virtualizer = new Virtualizer({ count: 4, diff --git a/packages/virtual-core/tests/lane-equivalence.test.ts b/packages/virtual-core/tests/lane-equivalence.test.ts index 60bc2b66f..0ba218fed 100644 --- a/packages/virtual-core/tests/lane-equivalence.test.ts +++ b/packages/virtual-core/tests/lane-equivalence.test.ts @@ -285,7 +285,8 @@ describe('multi-lane placement equivalence under measured-mode rebuilds', () => const gap = Math.floor(rng() * 10) const paddingStart = Math.floor(rng() * 30) const scrollMargin = Math.floor(rng() * 30) - const estimateSize = (i: number) => 20 + ((i * 37) % 90) + // Fractional, so a measurement below never equals its estimate. + const estimateSize = (i: number) => 20.5 + ((i * 37) % 90) const reference = new ReferenceVirtualizer({ count, From 5773a1a61aad49ec8e7ae348c53b915f3fbb2eac Mon Sep 17 00:00:00 2001 From: Yeongbin Jeong Date: Mon, 9 Nov 2026 17:39:33 +0900 Subject: [PATCH 2/3] fix(virtual-core): cache the lane at its estimate from current measurements Read the row's lane from getMeasurements() instead of recording the size and tracking the pending rebuild, so the branch has one effect. A row whose lane is already cached, a single lane and useCachedMeasurements stay out of it. --- packages/virtual-core/src/index.ts | 14 +++++--------- packages/virtual-core/tests/index.test.ts | 7 +++++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 016825b4f..7dc5b73ba 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -2167,16 +2167,12 @@ export class Virtualizer< this._retryClampedAdjustment() } else if ( this.options.lanes > 1 && - this.options.laneAssignmentMode === 'measured' && - !this.options.useCachedMeasurements + !this.options.useCachedMeasurements && + !this.laneAssignments.has(index) ) { - // Measured at its estimate: nothing moves, but the row counts as measured - // and its lane must not change later. A pending rebuild from this row or - // above caches the lane; otherwise none will, so cache it here. - this.itemSizeCache.set(key, size) - if (this.pendingMin === null || index < this.pendingMin) { - this.laneAssignments.set(index, this.measurementsCache[index]!.lane) - } + // Measured at its estimate, so no rebuild will cache its lane. + const item = this.getMeasurements()[index] + if (item) this.laneAssignments.set(index, item.lane) } } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 928ac3e77..fd4272f31 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -260,6 +260,13 @@ test.each([{ lanes: 1 }, { lanes: 2, useCachedMeasurements: true }])( }, ) +test("should not throw when a row is measured at its estimate right after 'enabled' turns off", () => { + const { virtualizer } = measuredLanes() + virtualizer.setOptions({ ...virtualizer.options, enabled: false }) + + expect(() => virtualizer.resizeItem(0, 100)).not.toThrow() +}) + test("should cache lanes incrementally as items are measured when laneAssignmentMode is 'measured'", () => { const virtualizer = new Virtualizer({ count: 4, From 0e5547712df0afbd24eb32f7ee3cee8150c43d70 Mon Sep 17 00:00:00 2001 From: Yeongbin Jeong Date: Mon, 9 Nov 2026 18:11:30 +0900 Subject: [PATCH 3/3] docs(virtual-core): explain when a lane is cached at its estimate --- packages/virtual-core/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 7dc5b73ba..410425ac6 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -2170,7 +2170,10 @@ export class Virtualizer< !this.options.useCachedMeasurements && !this.laneAssignments.has(index) ) { - // Measured at its estimate, so no rebuild will cache its lane. + // Measured at its estimate, so no rebuild will cache its lane. A single + // lane has none to cache, a size under useCachedMeasurements is not a + // measurement, and a lane already cached (as always in 'estimate' mode) + // must not change. const item = this.getMeasurements()[index] if (item) this.laneAssignments.set(index, item.lane) }