Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loud-insects-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

feat(virtual-core): add deferLaneAssignment option
3 changes: 2 additions & 1 deletion docs/api/virtual-item.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 measured by `estimateSize` by default; use `deferLaneAssignment: true` 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).
13 changes: 12 additions & 1 deletion docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,18 @@ 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. Lane assignments are cached immediately based on `estimateSize` to prevent items from jumping between lanes.

### `deferLaneAssignment`

```tsx
deferLaneAssignment?: boolean
```

**Default**: `false`

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.


### `isScrollingResetDelay`

Expand Down
10 changes: 8 additions & 2 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ export interface VirtualizerOptions<
enabled?: boolean
isRtl?: boolean
useAnimationFrameWithResizeObserver?: boolean
deferLaneAssignment?: boolean
}

export class Virtualizer<
Expand Down Expand Up @@ -446,6 +447,7 @@ export class Virtualizer<
isRtl: false,
useScrollendEvent: false,
useAnimationFrameWithResizeObserver: false,
deferLaneAssignment: false,
...opts,
}
}
Expand Down Expand Up @@ -726,6 +728,10 @@ export class Virtualizer<
let lane: number
let start: number

// Check if this item has been measured (for deferLaneAssignment mode)
const isMeasured = itemSizeCache.has(key)
const shouldDeferLane = this.options.deferLaneAssignment && !isMeasured

if (cachedLane !== undefined && this.options.lanes > 1) {
// Use cached lane - O(1) lookup for previous item in same lane
lane = cachedLane
Expand All @@ -750,8 +756,8 @@ export class Virtualizer<
? furthestMeasurement.lane
: i % this.options.lanes

// Cache the lane assignment
if (this.options.lanes > 1) {
// Cache the lane assignment (skip if deferring and not yet measured)
if (this.options.lanes > 1 && !shouldDeferLane) {
this.laneAssignments.set(i, lane)
}
}
Expand Down
53 changes: 53 additions & 0 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,56 @@ test('should update getTotalSize() when count option changes (filtering/search)'

expect(virtualizer.getTotalSize()).toBe(5000) // 100 × 50
})

test('should defer lane caching until measurement when deferLaneAssignment is true', () => {
const virtualizer = new Virtualizer({
count: 4,
lanes: 2,
estimateSize: () => 100,
deferLaneAssignment: true,
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 immediately when deferLaneAssignment is false (default)', () => {
const virtualizer = new Virtualizer({
count: 4,
lanes: 2,
estimateSize: () => 100,
getScrollElement: () => null,
scrollToFn: vi.fn(),
observeElementRect: vi.fn(),
observeElementOffset: vi.fn(),
})

virtualizer['getMeasurements']()

expect(virtualizer['laneAssignments'].size).toBe(4)
})