From cd1956defd01e491a3306008af0457b6e6f71678 Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Thu, 8 Jan 2026 01:34:41 +0900 Subject: [PATCH 1/2] feat(virtual-core): add deferLaneAssignment option --- docs/api/virtual-item.md | 3 +- docs/api/virtualizer.md | 13 +++++- packages/virtual-core/src/index.ts | 10 ++++- packages/virtual-core/tests/index.test.ts | 53 +++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/docs/api/virtual-item.md b/docs/api/virtual-item.md index 2393788b1..778ed20e0 100644 --- a/docs/api/virtual-item.md +++ b/docs/api/virtual-item.md @@ -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). \ No newline at end of file diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index 930a2f6b8..0e76abbdf 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -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` diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 6c1cafa4f..dca96ad30 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -346,6 +346,7 @@ export interface VirtualizerOptions< enabled?: boolean isRtl?: boolean useAnimationFrameWithResizeObserver?: boolean + deferLaneAssignment?: boolean } export class Virtualizer< @@ -446,6 +447,7 @@ export class Virtualizer< isRtl: false, useScrollendEvent: false, useAnimationFrameWithResizeObserver: false, + deferLaneAssignment: false, ...opts, } } @@ -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 @@ -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) } } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 6a00c3980..d4e39b60d 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -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) +}) From 7a2e0141c919bd76193e25ca752a0e76e00b56d5 Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Thu, 8 Jan 2026 18:28:09 +0900 Subject: [PATCH 2/2] chore: add changeset --- .changeset/loud-insects-itch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/loud-insects-itch.md diff --git a/.changeset/loud-insects-itch.md b/.changeset/loud-insects-itch.md new file mode 100644 index 000000000..ebc0ff93f --- /dev/null +++ b/.changeset/loud-insects-itch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +feat(virtual-core): add deferLaneAssignment option