Skip to content

Commit cd1956d

Browse files
committed
feat(virtual-core): add deferLaneAssignment option
1 parent 5d6acc9 commit cd1956d

4 files changed

Lines changed: 75 additions & 4 deletions

File tree

docs/api/virtual-item.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,5 @@ The size of the item. This is usually mapped to a css property like `width/heigh
6262
lane: number
6363
```
6464

65-
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).
65+
The lane index of the item. Items are assigned to the shortest lane. Lane assignments are cached immediately based on the size measured by `estimateSize` by default; use `deferLaneAssignment: true` to base assignments on measured sizes instead.
66+
In regular lists it will always be set to `0` but becomes useful for masonry layouts (see variable examples for more details).

docs/api/virtualizer.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,18 @@ This option allows you to set the spacing between items in the virtualized list.
232232
lanes: number
233233
```
234234

235-
The number of lanes the list is divided into (aka columns for vertical lists and rows for horizontal lists).
235+
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. Lane assignments are cached immediately based on `estimateSize` to prevent items from jumping between lanes.
236+
237+
### `deferLaneAssignment`
238+
239+
```tsx
240+
deferLaneAssignment?: boolean
241+
```
242+
243+
**Default**: `false`
244+
245+
When `true`, defers lane caching until items are measured via `measureElement`. This allows lane assignments to be based on actual measured sizes rather than `estimateSize`. After initial measurement, lanes are cached and remain stable.
246+
236247

237248
### `isScrollingResetDelay`
238249

packages/virtual-core/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ export interface VirtualizerOptions<
346346
enabled?: boolean
347347
isRtl?: boolean
348348
useAnimationFrameWithResizeObserver?: boolean
349+
deferLaneAssignment?: boolean
349350
}
350351

351352
export class Virtualizer<
@@ -446,6 +447,7 @@ export class Virtualizer<
446447
isRtl: false,
447448
useScrollendEvent: false,
448449
useAnimationFrameWithResizeObserver: false,
450+
deferLaneAssignment: false,
449451
...opts,
450452
}
451453
}
@@ -726,6 +728,10 @@ export class Virtualizer<
726728
let lane: number
727729
let start: number
728730

731+
// Check if this item has been measured (for deferLaneAssignment mode)
732+
const isMeasured = itemSizeCache.has(key)
733+
const shouldDeferLane = this.options.deferLaneAssignment && !isMeasured
734+
729735
if (cachedLane !== undefined && this.options.lanes > 1) {
730736
// Use cached lane - O(1) lookup for previous item in same lane
731737
lane = cachedLane
@@ -750,8 +756,8 @@ export class Virtualizer<
750756
? furthestMeasurement.lane
751757
: i % this.options.lanes
752758

753-
// Cache the lane assignment
754-
if (this.options.lanes > 1) {
759+
// Cache the lane assignment (skip if deferring and not yet measured)
760+
if (this.options.lanes > 1 && !shouldDeferLane) {
755761
this.laneAssignments.set(i, lane)
756762
}
757763
}

packages/virtual-core/tests/index.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,56 @@ test('should update getTotalSize() when count option changes (filtering/search)'
158158

159159
expect(virtualizer.getTotalSize()).toBe(5000) // 100 × 50
160160
})
161+
162+
test('should defer lane caching until measurement when deferLaneAssignment is true', () => {
163+
const virtualizer = new Virtualizer({
164+
count: 4,
165+
lanes: 2,
166+
estimateSize: () => 100,
167+
deferLaneAssignment: true,
168+
getScrollElement: () => null,
169+
scrollToFn: vi.fn(),
170+
observeElementRect: vi.fn(),
171+
observeElementOffset: vi.fn(),
172+
})
173+
174+
virtualizer['getMeasurements']()
175+
176+
// No laneAssignments cached yet
177+
expect(virtualizer['laneAssignments'].size).toBe(0)
178+
179+
// Simulate measurements
180+
virtualizer.resizeItem(0, 200)
181+
virtualizer.resizeItem(1, 50)
182+
virtualizer.resizeItem(2, 80)
183+
virtualizer.resizeItem(3, 120)
184+
185+
const measurements = virtualizer['getMeasurements']()
186+
187+
// After measurement: lane assignments based on actual sizes + cached
188+
expect(virtualizer['laneAssignments'].size).toBe(4)
189+
expect(measurements[2].lane).toBe(1) // lane 1 is shorter, so assigned there
190+
191+
// Lane assignments remain stable after size changes
192+
const lanesBeforeResize = measurements.map((m) => m.lane)
193+
virtualizer.resizeItem(0, 50)
194+
virtualizer.resizeItem(1, 200)
195+
const lanesAfterResize = virtualizer['getMeasurements']().map((m) => m.lane)
196+
expect(lanesBeforeResize).toEqual(lanesAfterResize)
197+
})
198+
199+
test('should cache lanes immediately when deferLaneAssignment is false (default)', () => {
200+
const virtualizer = new Virtualizer({
201+
count: 4,
202+
lanes: 2,
203+
estimateSize: () => 100,
204+
getScrollElement: () => null,
205+
scrollToFn: vi.fn(),
206+
observeElementRect: vi.fn(),
207+
observeElementOffset: vi.fn(),
208+
})
209+
210+
virtualizer['getMeasurements']()
211+
212+
expect(virtualizer['laneAssignments'].size).toBe(4)
213+
})

0 commit comments

Comments
 (0)