From 8f360f54eec94bb9339bda2a8af91e5b0dd14f9c Mon Sep 17 00:00:00 2001 From: seob171 Date: Sat, 22 Nov 2025 18:32:20 +0900 Subject: [PATCH 1/3] 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) 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 --- .changeset/stable-lane-assignments.md | 13 ++++ packages/virtual-core/src/index.ts | 108 ++++++++++++++++++++++---- 2 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 .changeset/stable-lane-assignments.md diff --git a/.changeset/stable-lane-assignments.md b/.changeset/stable-lane-assignments.md new file mode 100644 index 00000000..228e47ea --- /dev/null +++ b/.changeset/stable-lane-assignments.md @@ -0,0 +1,13 @@ +--- +"@tanstack/virtual-core": patch +--- + +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) 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 diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index b4794e06..4a6a54de 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -361,7 +361,11 @@ export class Virtualizer< isScrolling = false measurementsCache: Array = [] private itemSizeCache = new Map() + private laneAssignments = new Map() // index → lane cache private pendingMeasuredCacheIndexes: Array = [] + private prevLanes: number | undefined = undefined + private lanesChangedFlag = false + private lanesSettling = false scrollRect: Rect | null = null scrollOffset: number | null = null scrollDirection: ScrollDirection | null = null @@ -610,6 +614,20 @@ export class Virtualizer< : undefined } + // Helper to find the last item in a specific lane before currentIndex + private findLastItemInLane = ( + measurements: Array, + currentIndex: number, + lane: number, + ): VirtualItem | undefined => { + for (let m = currentIndex - 1; m >= 0; m--) { + if (measurements[m]?.lane === lane) { + return measurements[m] + } + } + return undefined + } + private getMeasurementOptions = memo( () => [ this.options.count, @@ -617,15 +635,26 @@ export class Virtualizer< this.options.scrollMargin, this.options.getItemKey, this.options.enabled, + this.options.lanes, ], - (count, paddingStart, scrollMargin, getItemKey, enabled) => { + (count, paddingStart, scrollMargin, getItemKey, enabled, lanes) => { + const lanesChanged = this.prevLanes !== undefined && this.prevLanes !== lanes + + if (lanesChanged) { + // Set flag for getMeasurements to handle + this.lanesChangedFlag = true + } + + this.prevLanes = lanes this.pendingMeasuredCacheIndexes = [] + return { count, paddingStart, scrollMargin, getItemKey, enabled, + lanes, } }, { @@ -636,15 +665,36 @@ export class Virtualizer< private getMeasurements = memo( () => [this.getMeasurementOptions(), this.itemSizeCache], ( - { count, paddingStart, scrollMargin, getItemKey, enabled }, + { count, paddingStart, scrollMargin, getItemKey, enabled, lanes }, itemSizeCache, ) => { if (!enabled) { this.measurementsCache = [] this.itemSizeCache.clear() + this.laneAssignments.clear() return [] } + // Clean up stale lane cache entries when count decreases + if (this.laneAssignments.size > count) { + for (const index of this.laneAssignments.keys()) { + if (index >= count) { + this.laneAssignments.delete(index) + } + } + } + + // ✅ Force complete recalculation when lanes change + if (this.lanesChangedFlag) { + this.lanesChangedFlag = false // Reset immediately + this.lanesSettling = true // Start settling period + this.measurementsCache = [] + this.itemSizeCache.clear() + this.laneAssignments.clear() // Clear lane cache for new lane count + // Clear pending indexes to force min = 0 + this.pendingMeasuredCacheIndexes = [] + } + if (this.measurementsCache.length === 0) { this.measurementsCache = this.options.initialMeasurementsCache this.measurementsCache.forEach((item) => { @@ -652,25 +702,56 @@ export class Virtualizer< }) } - const min = + // ✅ During lanes settling, ignore pendingMeasuredCacheIndexes to prevent repositioning + const min = this.lanesSettling ? 0 : ( this.pendingMeasuredCacheIndexes.length > 0 ? Math.min(...this.pendingMeasuredCacheIndexes) : 0 + ) this.pendingMeasuredCacheIndexes = [] + // ✅ End settling period when cache is fully built + if (this.lanesSettling && this.measurementsCache.length === count) { + this.lanesSettling = false + } + const measurements = this.measurementsCache.slice(0, min) for (let i = min; i < count; i++) { const key = getItemKey(i) - const furthestMeasurement = - this.options.lanes === 1 - ? measurements[i - 1] - : this.getFurthestMeasurement(measurements, i) - - const start = furthestMeasurement - ? furthestMeasurement.end + this.options.gap - : paddingStart + scrollMargin + // Check for cached lane assignment + const cachedLane = this.laneAssignments.get(i) + let lane: number + let start: number + + if (cachedLane !== undefined && this.options.lanes > 1) { + // Use cached lane - find previous item in same lane for start position + lane = cachedLane + const prevInLane = this.findLastItemInLane(measurements, i, lane) + start = prevInLane + ? prevInLane.end + this.options.gap + : paddingStart + scrollMargin + } else { + // No cache - use original logic (find shortest lane) + const furthestMeasurement = + this.options.lanes === 1 + ? measurements[i - 1] + : this.getFurthestMeasurement(measurements, i) + + start = furthestMeasurement + ? furthestMeasurement.end + this.options.gap + : paddingStart + scrollMargin + + lane = furthestMeasurement + ? furthestMeasurement.lane + : i % this.options.lanes + + // Cache the lane assignment + if (this.options.lanes > 1) { + this.laneAssignments.set(i, lane) + } + } const measuredSize = itemSizeCache.get(key) const size = @@ -680,10 +761,6 @@ export class Virtualizer< const end = start + size - const lane = furthestMeasurement - ? furthestMeasurement.lane - : i % this.options.lanes - measurements[i] = { index: i, start, @@ -1077,6 +1154,7 @@ export class Virtualizer< measure = () => { this.itemSizeCache = new Map() + this.laneAssignments = new Map() // Clear lane cache for full re-layout this.notify(false) } } From b6fc37a7054b8d93402133e9130df282f66be7b7 Mon Sep 17 00:00:00 2001 From: seob171 Date: Sat, 22 Nov 2025 19:50:06 +0900 Subject: [PATCH 2/3] perf: optimize lane lookup from O(n) to O(1) Use laneLastIndex array to track last item per lane instead of iterating through all measurements --- packages/virtual-core/src/index.ts | 33 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 4a6a54de..ee8a4aaf 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -614,20 +614,6 @@ export class Virtualizer< : undefined } - // Helper to find the last item in a specific lane before currentIndex - private findLastItemInLane = ( - measurements: Array, - currentIndex: number, - lane: number, - ): VirtualItem | undefined => { - for (let m = currentIndex - 1; m >= 0; m--) { - if (measurements[m]?.lane === lane) { - return measurements[m] - } - } - return undefined - } - private getMeasurementOptions = memo( () => [ this.options.count, @@ -717,6 +703,17 @@ export class Virtualizer< const measurements = this.measurementsCache.slice(0, min) + // ✅ Performance: Track last item index per lane for O(1) lookup + const laneLastIndex: Array = new Array(lanes).fill(undefined) + + // Initialize from existing measurements (before min) + for (let m = 0; m < min; m++) { + const item = measurements[m] + if (item) { + laneLastIndex[item.lane] = m + } + } + for (let i = min; i < count; i++) { const key = getItemKey(i) @@ -726,9 +723,10 @@ export class Virtualizer< let start: number if (cachedLane !== undefined && this.options.lanes > 1) { - // Use cached lane - find previous item in same lane for start position + // Use cached lane - O(1) lookup for previous item in same lane lane = cachedLane - const prevInLane = this.findLastItemInLane(measurements, i, lane) + const prevIndex = laneLastIndex[lane] + const prevInLane = prevIndex !== undefined ? measurements[prevIndex] : undefined start = prevInLane ? prevInLane.end + this.options.gap : paddingStart + scrollMargin @@ -769,6 +767,9 @@ export class Virtualizer< key, lane, } + + // ✅ Performance: Update lane's last item index + laneLastIndex[lane] = i } this.measurementsCache = measurements From 2ee9f57bd05086939a70d5cf38d5d7d90820fc89 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:43:56 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- .changeset/stable-lane-assignments.md | 3 ++- packages/virtual-core/src/index.ts | 16 ++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.changeset/stable-lane-assignments.md b/.changeset/stable-lane-assignments.md index 228e47ea..2d8820de 100644 --- a/.changeset/stable-lane-assignments.md +++ b/.changeset/stable-lane-assignments.md @@ -1,5 +1,5 @@ --- -"@tanstack/virtual-core": patch +'@tanstack/virtual-core': patch --- fix: stabilize lane assignments in masonry layout @@ -7,6 +7,7 @@ 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) 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) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index ee8a4aaf..57a151c4 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -624,7 +624,8 @@ export class Virtualizer< this.options.lanes, ], (count, paddingStart, scrollMargin, getItemKey, enabled, lanes) => { - const lanesChanged = this.prevLanes !== undefined && this.prevLanes !== lanes + const lanesChanged = + this.prevLanes !== undefined && this.prevLanes !== lanes if (lanesChanged) { // Set flag for getMeasurements to handle @@ -689,11 +690,11 @@ export class Virtualizer< } // ✅ During lanes settling, ignore pendingMeasuredCacheIndexes to prevent repositioning - const min = this.lanesSettling ? 0 : ( - this.pendingMeasuredCacheIndexes.length > 0 + const min = this.lanesSettling + ? 0 + : this.pendingMeasuredCacheIndexes.length > 0 ? Math.min(...this.pendingMeasuredCacheIndexes) : 0 - ) this.pendingMeasuredCacheIndexes = [] // ✅ End settling period when cache is fully built @@ -704,7 +705,9 @@ export class Virtualizer< const measurements = this.measurementsCache.slice(0, min) // ✅ Performance: Track last item index per lane for O(1) lookup - const laneLastIndex: Array = new Array(lanes).fill(undefined) + const laneLastIndex: Array = new Array(lanes).fill( + undefined, + ) // Initialize from existing measurements (before min) for (let m = 0; m < min; m++) { @@ -726,7 +729,8 @@ export class Virtualizer< // Use cached lane - O(1) lookup for previous item in same lane lane = cachedLane const prevIndex = laneLastIndex[lane] - const prevInLane = prevIndex !== undefined ? measurements[prevIndex] : undefined + const prevInLane = + prevIndex !== undefined ? measurements[prevIndex] : undefined start = prevInLane ? prevInLane.end + this.options.gap : paddingStart + scrollMargin