Skip to content

Commit 8fcdd95

Browse files
committed
fix: stabilize lane assignments in masonry layout
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. Changes: - Added `laneAssignments` cache (Map<index, lane>) to persist lane assignments - Lane cache is cleared when `lanes` option changes or `measure()` is called - Lane cache is cleaned up when `count` decreases (removes stale entries) - Lane cache is cleared when virtualizer is disabled
1 parent 68f76ae commit 8fcdd95

File tree

2 files changed

+106
-15
lines changed

2 files changed

+106
-15
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
- Added `laneAssignments` cache (Map<index, lane>) to persist lane assignments
11+
- Lane cache is cleared when `lanes` option changes or `measure()` is called
12+
- Lane cache is cleaned up when `count` decreases (removes stale entries)
13+
- Lane cache is cleared when virtualizer is disabled

packages/virtual-core/src/index.ts

Lines changed: 93 additions & 15 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
@@ -610,22 +614,47 @@ export class Virtualizer<
610614
: undefined
611615
}
612616

617+
// Helper to find the last item in a specific lane before currentIndex
618+
private findLastItemInLane = (
619+
measurements: Array<VirtualItem>,
620+
currentIndex: number,
621+
lane: number,
622+
): VirtualItem | undefined => {
623+
for (let m = currentIndex - 1; m >= 0; m--) {
624+
if (measurements[m]?.lane === lane) {
625+
return measurements[m]
626+
}
627+
}
628+
return undefined
629+
}
630+
613631
private getMeasurementOptions = memo(
614632
() => [
615633
this.options.count,
616634
this.options.paddingStart,
617635
this.options.scrollMargin,
618636
this.options.getItemKey,
619637
this.options.enabled,
638+
this.options.lanes,
620639
],
621-
(count, paddingStart, scrollMargin, getItemKey, enabled) => {
640+
(count, paddingStart, scrollMargin, getItemKey, enabled, lanes) => {
641+
const lanesChanged = this.prevLanes !== undefined && this.prevLanes !== lanes
642+
643+
if (lanesChanged) {
644+
// Set flag for getMeasurements to handle
645+
this.lanesChangedFlag = true
646+
}
647+
648+
this.prevLanes = lanes
622649
this.pendingMeasuredCacheIndexes = []
650+
623651
return {
624652
count,
625653
paddingStart,
626654
scrollMargin,
627655
getItemKey,
628656
enabled,
657+
lanes,
629658
}
630659
},
631660
{
@@ -636,41 +665,93 @@ export class Virtualizer<
636665
private getMeasurements = memo(
637666
() => [this.getMeasurementOptions(), this.itemSizeCache],
638667
(
639-
{ count, paddingStart, scrollMargin, getItemKey, enabled },
668+
{ count, paddingStart, scrollMargin, getItemKey, enabled, lanes },
640669
itemSizeCache,
641670
) => {
642671
if (!enabled) {
643672
this.measurementsCache = []
644673
this.itemSizeCache.clear()
674+
this.laneAssignments.clear()
645675
return []
646676
}
647677

678+
// Clean up stale lane cache entries when count decreases
679+
if (this.laneAssignments.size > count) {
680+
for (const index of this.laneAssignments.keys()) {
681+
if (index >= count) {
682+
this.laneAssignments.delete(index)
683+
}
684+
}
685+
}
686+
687+
// ✅ Force complete recalculation when lanes change
688+
if (this.lanesChangedFlag) {
689+
this.lanesChangedFlag = false // Reset immediately
690+
this.lanesSettling = true // Start settling period
691+
this.measurementsCache = []
692+
this.itemSizeCache.clear()
693+
this.laneAssignments.clear() // Clear lane cache for new lane count
694+
// Clear pending indexes to force min = 0
695+
this.pendingMeasuredCacheIndexes = []
696+
}
697+
648698
if (this.measurementsCache.length === 0) {
649699
this.measurementsCache = this.options.initialMeasurementsCache
650700
this.measurementsCache.forEach((item) => {
651701
this.itemSizeCache.set(item.key, item.size)
652702
})
653703
}
654704

655-
const min =
705+
// ✅ During lanes settling, ignore pendingMeasuredCacheIndexes to prevent repositioning
706+
const min = this.lanesSettling ? 0 : (
656707
this.pendingMeasuredCacheIndexes.length > 0
657708
? Math.min(...this.pendingMeasuredCacheIndexes)
658709
: 0
710+
)
659711
this.pendingMeasuredCacheIndexes = []
660712

713+
// ✅ End settling period when cache is fully built
714+
if (this.lanesSettling && this.measurementsCache.length === count) {
715+
this.lanesSettling = false
716+
}
717+
661718
const measurements = this.measurementsCache.slice(0, min)
662719

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 - find previous item in same lane for start position
730+
lane = cachedLane
731+
const prevInLane = this.findLastItemInLane(measurements, i, lane)
732+
start = prevInLane
733+
? prevInLane.end + this.options.gap
734+
: paddingStart + scrollMargin
735+
} else {
736+
// No cache - use original logic (find shortest lane)
737+
const furthestMeasurement =
738+
this.options.lanes === 1
739+
? measurements[i - 1]
740+
: this.getFurthestMeasurement(measurements, i)
741+
742+
start = furthestMeasurement
743+
? furthestMeasurement.end + this.options.gap
744+
: paddingStart + scrollMargin
745+
746+
lane = furthestMeasurement
747+
? furthestMeasurement.lane
748+
: i % this.options.lanes
749+
750+
// Cache the lane assignment
751+
if (this.options.lanes > 1) {
752+
this.laneAssignments.set(i, lane)
753+
}
754+
}
674755

675756
const measuredSize = itemSizeCache.get(key)
676757
const size =
@@ -680,10 +761,6 @@ export class Virtualizer<
680761

681762
const end = start + size
682763

683-
const lane = furthestMeasurement
684-
? furthestMeasurement.lane
685-
: i % this.options.lanes
686-
687764
measurements[i] = {
688765
index: i,
689766
start,
@@ -1077,6 +1154,7 @@ export class Virtualizer<
10771154

10781155
measure = () => {
10791156
this.itemSizeCache = new Map()
1157+
this.laneAssignments = new Map() // Clear lane cache for full re-layout
10801158
this.notify(false)
10811159
}
10821160
}

0 commit comments

Comments
 (0)