Skip to content

Commit 97a204d

Browse files
authored
feat(virtual-core): add laneAssignmentMode option (#1115)
1 parent c939785 commit 97a204d

File tree

5 files changed

+140
-6
lines changed

5 files changed

+140
-6
lines changed

.changeset/loud-insects-itch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': minor
3+
---
4+
5+
feat(virtual-core): add laneAssignmentMode option

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 estimated by `estimateSize` by default; set `laneAssignmentMode: 'measured'` 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: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,20 @@ This option allows you to set the spacing between items in the virtualized list.
230230
lanes: number
231231
```
232232

233-
The number of lanes the list is divided into (aka columns for vertical lists and rows for horizontal lists).
233+
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).
234+
235+
### `laneAssignmentMode`
236+
237+
```tsx
238+
laneAssignmentMode?: 'estimate' | 'measured'
239+
```
240+
241+
**Default**: `'estimate'`
242+
243+
Controls when lane assignments are cached in a masonry layout.
244+
245+
- `'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.
246+
- `'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.
234247

235248
### `isScrollingResetDelay`
236249

packages/virtual-core/src/index.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ export const elementScroll = <T extends Element>(
292292
})
293293
}
294294

295+
type LaneAssignmentMode = 'estimate' | 'measured'
296+
295297
export interface VirtualizerOptions<
296298
TScrollElement extends Element | Window,
297299
TItemElement extends Element,
@@ -346,6 +348,7 @@ export interface VirtualizerOptions<
346348
enabled?: boolean
347349
isRtl?: boolean
348350
useAnimationFrameWithResizeObserver?: boolean
351+
laneAssignmentMode?: LaneAssignmentMode
349352
}
350353

351354
type ScrollState = {
@@ -476,6 +479,7 @@ export class Virtualizer<
476479
isRtl: false,
477480
useScrollendEvent: false,
478481
useAnimationFrameWithResizeObserver: false,
482+
laneAssignmentMode: 'estimate',
479483
...opts,
480484
}
481485
}
@@ -727,8 +731,17 @@ export class Virtualizer<
727731
this.options.getItemKey,
728732
this.options.enabled,
729733
this.options.lanes,
734+
this.options.laneAssignmentMode,
730735
],
731-
(count, paddingStart, scrollMargin, getItemKey, enabled, lanes) => {
736+
(
737+
count,
738+
paddingStart,
739+
scrollMargin,
740+
getItemKey,
741+
enabled,
742+
lanes,
743+
laneAssignmentMode,
744+
) => {
732745
const lanesChanged =
733746
this.prevLanes !== undefined && this.prevLanes !== lanes
734747

@@ -747,6 +760,7 @@ export class Virtualizer<
747760
getItemKey,
748761
enabled,
749762
lanes,
763+
laneAssignmentMode,
750764
}
751765
},
752766
{
@@ -757,7 +771,15 @@ export class Virtualizer<
757771
private getMeasurements = memo(
758772
() => [this.getMeasurementOptions(), this.itemSizeCache],
759773
(
760-
{ count, paddingStart, scrollMargin, getItemKey, enabled, lanes },
774+
{
775+
count,
776+
paddingStart,
777+
scrollMargin,
778+
getItemKey,
779+
enabled,
780+
lanes,
781+
laneAssignmentMode,
782+
},
761783
itemSizeCache,
762784
) => {
763785
if (!enabled) {
@@ -832,6 +854,9 @@ export class Virtualizer<
832854
let lane: number
833855
let start: number
834856

857+
const shouldCacheLane =
858+
laneAssignmentMode === 'estimate' || itemSizeCache.has(key)
859+
835860
if (cachedLane !== undefined && this.options.lanes > 1) {
836861
// Use cached lane - O(1) lookup for previous item in same lane
837862
lane = cachedLane
@@ -856,8 +881,7 @@ export class Virtualizer<
856881
? furthestMeasurement.lane
857882
: i % this.options.lanes
858883

859-
// Cache the lane assignment
860-
if (this.options.lanes > 1) {
884+
if (this.options.lanes > 1 && shouldCacheLane) {
861885
this.laneAssignments.set(i, lane)
862886
}
863887
}

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,97 @@ test('should not throw when component unmounts during scrollToIndex rAF loop', (
233233
}).not.toThrow()
234234
})
235235

236+
test("should defer lane caching until measurement when laneAssignmentMode is 'measured'", () => {
237+
const virtualizer = new Virtualizer({
238+
count: 4,
239+
lanes: 2,
240+
estimateSize: () => 100,
241+
laneAssignmentMode: 'measured',
242+
getScrollElement: () => null,
243+
scrollToFn: vi.fn(),
244+
observeElementRect: vi.fn(),
245+
observeElementOffset: vi.fn(),
246+
})
247+
248+
virtualizer['getMeasurements']()
249+
250+
// No laneAssignments cached yet
251+
expect(virtualizer['laneAssignments'].size).toBe(0)
252+
253+
// Simulate measurements
254+
virtualizer.resizeItem(0, 200)
255+
virtualizer.resizeItem(1, 50)
256+
virtualizer.resizeItem(2, 80)
257+
virtualizer.resizeItem(3, 120)
258+
259+
const measurements = virtualizer['getMeasurements']()
260+
261+
// After measurement: lane assignments based on actual sizes + cached
262+
expect(virtualizer['laneAssignments'].size).toBe(4)
263+
expect(measurements[2].lane).toBe(1) // lane 1 is shorter, so assigned there
264+
265+
// Lane assignments remain stable after size changes
266+
const lanesBeforeResize = measurements.map((m) => m.lane)
267+
virtualizer.resizeItem(0, 50)
268+
virtualizer.resizeItem(1, 200)
269+
const lanesAfterResize = virtualizer['getMeasurements']().map((m) => m.lane)
270+
expect(lanesBeforeResize).toEqual(lanesAfterResize)
271+
})
272+
273+
test("should cache lanes incrementally as items are measured when laneAssignmentMode is 'measured'", () => {
274+
const virtualizer = new Virtualizer({
275+
count: 4,
276+
lanes: 2,
277+
estimateSize: () => 100,
278+
laneAssignmentMode: 'measured',
279+
getScrollElement: () => null,
280+
scrollToFn: vi.fn(),
281+
observeElementRect: vi.fn(),
282+
observeElementOffset: vi.fn(),
283+
})
284+
285+
virtualizer['getMeasurements']()
286+
expect(virtualizer['laneAssignments'].size).toBe(0)
287+
288+
// Measure only the first 2 items (simulating viewport-visible items)
289+
virtualizer.resizeItem(0, 200)
290+
virtualizer.resizeItem(1, 50)
291+
292+
const m1 = virtualizer['getMeasurements']()
293+
expect(virtualizer['laneAssignments'].size).toBe(2)
294+
295+
const lane0 = m1[0].lane
296+
const lane1 = m1[1].lane
297+
298+
// Measure the remaining items
299+
virtualizer.resizeItem(2, 80)
300+
virtualizer.resizeItem(3, 120)
301+
302+
const m2 = virtualizer['getMeasurements']()
303+
expect(virtualizer['laneAssignments'].size).toBe(4)
304+
305+
// Previously cached lanes must remain stable
306+
expect(m2[0].lane).toBe(lane0)
307+
expect(m2[1].lane).toBe(lane1)
308+
})
309+
310+
test("should cache lanes immediately when laneAssignmentMode is 'estimate' (default)", () => {
311+
const virtualizer = new Virtualizer({
312+
count: 4,
313+
lanes: 2,
314+
estimateSize: () => 100,
315+
laneAssignmentMode: 'estimate',
316+
getScrollElement: () => null,
317+
scrollToFn: vi.fn(),
318+
observeElementRect: vi.fn(),
319+
observeElementOffset: vi.fn(),
320+
})
321+
322+
virtualizer['getMeasurements']()
323+
324+
expect(virtualizer['laneAssignments'].size).toBe(4)
325+
})
326+
236327
function createMockEnvironment() {
237328
const rafCallbacks: Array<FrameRequestCallback> = []
238329
let rafIdCounter = 0

0 commit comments

Comments
 (0)