From 97a204dc5526669114458685552b7569b60d2940 Mon Sep 17 00:00:00 2001 From: wnhlee <2wheeh@gmail.com> Date: Wed, 15 Apr 2026 05:26:11 +0900 Subject: [PATCH] feat(virtual-core): add laneAssignmentMode option (#1115) --- .changeset/loud-insects-itch.md | 5 ++ docs/api/virtual-item.md | 3 +- docs/api/virtualizer.md | 15 +++- packages/virtual-core/src/index.ts | 32 +++++++- packages/virtual-core/tests/index.test.ts | 91 +++++++++++++++++++++++ 5 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 .changeset/loud-insects-itch.md diff --git a/.changeset/loud-insects-itch.md b/.changeset/loud-insects-itch.md new file mode 100644 index 000000000..4dc5470d5 --- /dev/null +++ b/.changeset/loud-insects-itch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': minor +--- + +feat(virtual-core): add laneAssignmentMode option diff --git a/docs/api/virtual-item.md b/docs/api/virtual-item.md index 2393788b1..35bbd5970 100644 --- a/docs/api/virtual-item.md +++ b/docs/api/virtual-item.md @@ -62,4 +62,5 @@ The size of the item. This is usually mapped to a css property like `width/heigh lane: number ``` -The lane index of the item. In regular lists it will always be set to `0` but becomes useful for masonry layouts (see variable examples for more details). +The lane index of the item. Items are assigned to the shortest lane. Lane assignments are cached immediately based on the size estimated by `estimateSize` by default; set `laneAssignmentMode: 'measured'` to base assignments on measured sizes instead. +In regular lists it will always be set to `0` but becomes useful for masonry layouts (see variable examples for more details). \ No newline at end of file diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index cee8b2d19..54542063f 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -230,7 +230,20 @@ This option allows you to set the spacing between items in the virtualized list. lanes: number ``` -The number of lanes the list is divided into (aka columns for vertical lists and rows for horizontal lists). +The number of lanes the list is divided into (aka columns for vertical lists and rows for horizontal lists). Items are assigned to the lane with the shortest total size. By default, lane assignments are cached immediately based on `estimateSize` to prevent items from jumping between lanes (see `laneAssignmentMode` below to change this behavior). + +### `laneAssignmentMode` + +```tsx +laneAssignmentMode?: 'estimate' | 'measured' +``` + +**Default**: `'estimate'` + +Controls when lane assignments are cached in a masonry layout. + +- `'estimate'` (default): lane assignments are cached immediately based on `estimateSize`. This keeps items from jumping between lanes, but assignments may be suboptimal when the estimate is inaccurate. +- `'measured'`: lane caching is deferred until items are measured via `measureElement`, so assignments reflect actual measured sizes. After the initial measurement, lanes are cached and remain stable. ### `isScrollingResetDelay` diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 64e9bd6d5..75dcbdb75 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -292,6 +292,8 @@ export const elementScroll = ( }) } +type LaneAssignmentMode = 'estimate' | 'measured' + export interface VirtualizerOptions< TScrollElement extends Element | Window, TItemElement extends Element, @@ -346,6 +348,7 @@ export interface VirtualizerOptions< enabled?: boolean isRtl?: boolean useAnimationFrameWithResizeObserver?: boolean + laneAssignmentMode?: LaneAssignmentMode } type ScrollState = { @@ -476,6 +479,7 @@ export class Virtualizer< isRtl: false, useScrollendEvent: false, useAnimationFrameWithResizeObserver: false, + laneAssignmentMode: 'estimate', ...opts, } } @@ -727,8 +731,17 @@ export class Virtualizer< this.options.getItemKey, this.options.enabled, this.options.lanes, + this.options.laneAssignmentMode, ], - (count, paddingStart, scrollMargin, getItemKey, enabled, lanes) => { + ( + count, + paddingStart, + scrollMargin, + getItemKey, + enabled, + lanes, + laneAssignmentMode, + ) => { const lanesChanged = this.prevLanes !== undefined && this.prevLanes !== lanes @@ -747,6 +760,7 @@ export class Virtualizer< getItemKey, enabled, lanes, + laneAssignmentMode, } }, { @@ -757,7 +771,15 @@ export class Virtualizer< private getMeasurements = memo( () => [this.getMeasurementOptions(), this.itemSizeCache], ( - { count, paddingStart, scrollMargin, getItemKey, enabled, lanes }, + { + count, + paddingStart, + scrollMargin, + getItemKey, + enabled, + lanes, + laneAssignmentMode, + }, itemSizeCache, ) => { if (!enabled) { @@ -832,6 +854,9 @@ export class Virtualizer< let lane: number let start: number + const shouldCacheLane = + laneAssignmentMode === 'estimate' || itemSizeCache.has(key) + if (cachedLane !== undefined && this.options.lanes > 1) { // Use cached lane - O(1) lookup for previous item in same lane lane = cachedLane @@ -856,8 +881,7 @@ export class Virtualizer< ? furthestMeasurement.lane : i % this.options.lanes - // Cache the lane assignment - if (this.options.lanes > 1) { + if (this.options.lanes > 1 && shouldCacheLane) { this.laneAssignments.set(i, lane) } } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 7f8fc8626..3134298f7 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -233,6 +233,97 @@ test('should not throw when component unmounts during scrollToIndex rAF loop', ( }).not.toThrow() }) +test("should defer lane caching until measurement when laneAssignmentMode is 'measured'", () => { + const virtualizer = new Virtualizer({ + count: 4, + lanes: 2, + estimateSize: () => 100, + laneAssignmentMode: 'measured', + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + + // No laneAssignments cached yet + expect(virtualizer['laneAssignments'].size).toBe(0) + + // Simulate measurements + virtualizer.resizeItem(0, 200) + virtualizer.resizeItem(1, 50) + virtualizer.resizeItem(2, 80) + virtualizer.resizeItem(3, 120) + + const measurements = virtualizer['getMeasurements']() + + // After measurement: lane assignments based on actual sizes + cached + expect(virtualizer['laneAssignments'].size).toBe(4) + expect(measurements[2].lane).toBe(1) // lane 1 is shorter, so assigned there + + // Lane assignments remain stable after size changes + const lanesBeforeResize = measurements.map((m) => m.lane) + virtualizer.resizeItem(0, 50) + virtualizer.resizeItem(1, 200) + const lanesAfterResize = virtualizer['getMeasurements']().map((m) => m.lane) + expect(lanesBeforeResize).toEqual(lanesAfterResize) +}) + +test("should cache lanes incrementally as items are measured when laneAssignmentMode is 'measured'", () => { + const virtualizer = new Virtualizer({ + count: 4, + lanes: 2, + estimateSize: () => 100, + laneAssignmentMode: 'measured', + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + expect(virtualizer['laneAssignments'].size).toBe(0) + + // Measure only the first 2 items (simulating viewport-visible items) + virtualizer.resizeItem(0, 200) + virtualizer.resizeItem(1, 50) + + const m1 = virtualizer['getMeasurements']() + expect(virtualizer['laneAssignments'].size).toBe(2) + + const lane0 = m1[0].lane + const lane1 = m1[1].lane + + // Measure the remaining items + virtualizer.resizeItem(2, 80) + virtualizer.resizeItem(3, 120) + + const m2 = virtualizer['getMeasurements']() + expect(virtualizer['laneAssignments'].size).toBe(4) + + // Previously cached lanes must remain stable + expect(m2[0].lane).toBe(lane0) + expect(m2[1].lane).toBe(lane1) +}) + +test("should cache lanes immediately when laneAssignmentMode is 'estimate' (default)", () => { + const virtualizer = new Virtualizer({ + count: 4, + lanes: 2, + estimateSize: () => 100, + laneAssignmentMode: 'estimate', + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + + expect(virtualizer['laneAssignments'].size).toBe(4) +}) + function createMockEnvironment() { const rafCallbacks: Array = [] let rafIdCounter = 0