Skip to content

Commit 96e32a6

Browse files
authored
fix: stabilize lane assignments in masonry layout (#1080)
1 parent 68f76ae commit 96e32a6

File tree

2 files changed

+113
-16
lines changed

2 files changed

+113
-16
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
fix: stabilize lane assignments in masonry layout
6+
7+
Added lane assignment caching to prevent items from jumping between lanes when viewport is resized. Previously, items could shift to different lanes during resize due to recalculating "shortest lane" with slightly different heights.
8+
9+
Changes:
10+
11+
- Added `laneAssignments` cache (Map<index, lane>) to persist lane assignments
12+
- Lane cache is cleared when `lanes` option changes or `measure()` is called
13+
- Lane cache is cleaned up when `count` decreases (removes stale entries)
14+
- Lane cache is cleared when virtualizer is disabled

packages/virtual-core/src/index.ts

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,11 @@ export class Virtualizer<
361361
isScrolling = false
362362
measurementsCache: Array<VirtualItem> = []
363363
private itemSizeCache = new Map<Key, number>()
364+
private laneAssignments = new Map<number, number>() // index → lane cache
364365
private pendingMeasuredCacheIndexes: Array<number> = []
366+
private prevLanes: number | undefined = undefined
367+
private lanesChangedFlag = false
368+
private lanesSettling = false
365369
scrollRect: Rect | null = null
366370
scrollOffset: number | null = null
367371
scrollDirection: ScrollDirection | null = null
@@ -617,15 +621,27 @@ export class Virtualizer<
617621
this.options.scrollMargin,
618622
this.options.getItemKey,
619623
this.options.enabled,
624+
this.options.lanes,
620625
],
621-
(count, paddingStart, scrollMargin, getItemKey, enabled) => {
626+
(count, paddingStart, scrollMargin, getItemKey, enabled, lanes) => {
627+
const lanesChanged =
628+
this.prevLanes !== undefined && this.prevLanes !== lanes
629+
630+
if (lanesChanged) {
631+
// Set flag for getMeasurements to handle
632+
this.lanesChangedFlag = true
633+
}
634+
635+
this.prevLanes = lanes
622636
this.pendingMeasuredCacheIndexes = []
637+
623638
return {
624639
count,
625640
paddingStart,
626641
scrollMargin,
627642
getItemKey,
628643
enabled,
644+
lanes,
629645
}
630646
},
631647
{
@@ -636,41 +652,108 @@ export class Virtualizer<
636652
private getMeasurements = memo(
637653
() => [this.getMeasurementOptions(), this.itemSizeCache],
638654
(
639-
{ count, paddingStart, scrollMargin, getItemKey, enabled },
655+
{ count, paddingStart, scrollMargin, getItemKey, enabled, lanes },
640656
itemSizeCache,
641657
) => {
642658
if (!enabled) {
643659
this.measurementsCache = []
644660
this.itemSizeCache.clear()
661+
this.laneAssignments.clear()
645662
return []
646663
}
647664

665+
// Clean up stale lane cache entries when count decreases
666+
if (this.laneAssignments.size > count) {
667+
for (const index of this.laneAssignments.keys()) {
668+
if (index >= count) {
669+
this.laneAssignments.delete(index)
670+
}
671+
}
672+
}
673+
674+
// ✅ Force complete recalculation when lanes change
675+
if (this.lanesChangedFlag) {
676+
this.lanesChangedFlag = false // Reset immediately
677+
this.lanesSettling = true // Start settling period
678+
this.measurementsCache = []
679+
this.itemSizeCache.clear()
680+
this.laneAssignments.clear() // Clear lane cache for new lane count
681+
// Clear pending indexes to force min = 0
682+
this.pendingMeasuredCacheIndexes = []
683+
}
684+
648685
if (this.measurementsCache.length === 0) {
649686
this.measurementsCache = this.options.initialMeasurementsCache
650687
this.measurementsCache.forEach((item) => {
651688
this.itemSizeCache.set(item.key, item.size)
652689
})
653690
}
654691

655-
const min =
656-
this.pendingMeasuredCacheIndexes.length > 0
692+
// ✅ During lanes settling, ignore pendingMeasuredCacheIndexes to prevent repositioning
693+
const min = this.lanesSettling
694+
? 0
695+
: this.pendingMeasuredCacheIndexes.length > 0
657696
? Math.min(...this.pendingMeasuredCacheIndexes)
658697
: 0
659698
this.pendingMeasuredCacheIndexes = []
660699

700+
// ✅ End settling period when cache is fully built
701+
if (this.lanesSettling && this.measurementsCache.length === count) {
702+
this.lanesSettling = false
703+
}
704+
661705
const measurements = this.measurementsCache.slice(0, min)
662706

707+
// ✅ Performance: Track last item index per lane for O(1) lookup
708+
const laneLastIndex: Array<number | undefined> = new Array(lanes).fill(
709+
undefined,
710+
)
711+
712+
// Initialize from existing measurements (before min)
713+
for (let m = 0; m < min; m++) {
714+
const item = measurements[m]
715+
if (item) {
716+
laneLastIndex[item.lane] = m
717+
}
718+
}
719+
663720
for (let i = min; i < count; i++) {
664721
const key = getItemKey(i)
665722

666-
const furthestMeasurement =
667-
this.options.lanes === 1
668-
? measurements[i - 1]
669-
: this.getFurthestMeasurement(measurements, i)
670-
671-
const start = furthestMeasurement
672-
? furthestMeasurement.end + this.options.gap
673-
: paddingStart + scrollMargin
723+
// Check for cached lane assignment
724+
const cachedLane = this.laneAssignments.get(i)
725+
let lane: number
726+
let start: number
727+
728+
if (cachedLane !== undefined && this.options.lanes > 1) {
729+
// Use cached lane - O(1) lookup for previous item in same lane
730+
lane = cachedLane
731+
const prevIndex = laneLastIndex[lane]
732+
const prevInLane =
733+
prevIndex !== undefined ? measurements[prevIndex] : undefined
734+
start = prevInLane
735+
? prevInLane.end + this.options.gap
736+
: paddingStart + scrollMargin
737+
} else {
738+
// No cache - use original logic (find shortest lane)
739+
const furthestMeasurement =
740+
this.options.lanes === 1
741+
? measurements[i - 1]
742+
: this.getFurthestMeasurement(measurements, i)
743+
744+
start = furthestMeasurement
745+
? furthestMeasurement.end + this.options.gap
746+
: paddingStart + scrollMargin
747+
748+
lane = furthestMeasurement
749+
? furthestMeasurement.lane
750+
: i % this.options.lanes
751+
752+
// Cache the lane assignment
753+
if (this.options.lanes > 1) {
754+
this.laneAssignments.set(i, lane)
755+
}
756+
}
674757

675758
const measuredSize = itemSizeCache.get(key)
676759
const size =
@@ -680,10 +763,6 @@ export class Virtualizer<
680763

681764
const end = start + size
682765

683-
const lane = furthestMeasurement
684-
? furthestMeasurement.lane
685-
: i % this.options.lanes
686-
687766
measurements[i] = {
688767
index: i,
689768
start,
@@ -692,6 +771,9 @@ export class Virtualizer<
692771
key,
693772
lane,
694773
}
774+
775+
// ✅ Performance: Update lane's last item index
776+
laneLastIndex[lane] = i
695777
}
696778

697779
this.measurementsCache = measurements
@@ -1077,6 +1159,7 @@ export class Virtualizer<
10771159

10781160
measure = () => {
10791161
this.itemSizeCache = new Map()
1162+
this.laneAssignments = new Map() // Clear lane cache for full re-layout
10801163
this.notify(false)
10811164
}
10821165
}

0 commit comments

Comments
 (0)