diff --git a/.changeset/measured-lane-at-estimate.md b/.changeset/measured-lane-at-estimate.md new file mode 100644 index 00000000..f3dc8774 --- /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 bd6e7cb2..410425ac 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -2165,6 +2165,17 @@ export class Virtualizer< // `_willUpdate` (#1266). this._flushHoldIfReady(true) this._retryClampedAdjustment() + } else if ( + this.options.lanes > 1 && + !this.options.useCachedMeasurements && + !this.laneAssignments.has(index) + ) { + // 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) } } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 7e609fd2..fd4272f3 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -200,6 +200,73 @@ 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 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, diff --git a/packages/virtual-core/tests/lane-equivalence.test.ts b/packages/virtual-core/tests/lane-equivalence.test.ts index 60bc2b66..0ba218fe 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,