From bbfc4bfd8604121090a83eefe8a991d5fbebf326 Mon Sep 17 00:00:00 2001 From: Divyam Upadhyay Date: Sun, 24 May 2026 00:49:35 +0530 Subject: [PATCH 1/3] feat(core): implement built-in scroll-scaling for large lists --- .changeset/scroll-scaling.md | 23 ++ docs/api/virtualizer.md | 51 ++++ packages/virtual-core/src/index.ts | 80 +++++- packages/virtual-core/tests/index.test.ts | 299 ++++++++++++++++++++++ 4 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 .changeset/scroll-scaling.md diff --git a/.changeset/scroll-scaling.md b/.changeset/scroll-scaling.md new file mode 100644 index 00000000..e3f2bf12 --- /dev/null +++ b/.changeset/scroll-scaling.md @@ -0,0 +1,23 @@ +--- +'@tanstack/virtual-core': minor +--- + +feat: Add built-in scroll-scaling to bypass browser scroll height limits + +Browsers cap `scrollHeight` at approximately 33.5 million pixels, making items at the end of very large lists unreachable. The virtualizer now automatically detects when the total virtual size exceeds the configurable `maxScrollSize` (default: 33,000,000 px) and applies a transparent scale transform to compress the scroll range. + +**New option:** +- `maxScrollSize` — Maximum physical scroll container size in pixels. Set to `Infinity` to disable scaling. Default: `33_000_000`. + +**New property:** +- `scale` — The current scale factor (1 when no scaling is active). + +When scaling is active: +- `getTotalSize()` returns the capped physical size for use as the container's CSS height/width. +- `getVirtualItems()` returns items with physical coordinates — use `item.start` directly for positioning. +- `scrollToIndex()`, `scrollToOffset()`, and `scrollBy()` work transparently. +- Scroll anchoring (resize adjustments for items above the viewport) works correctly through the scale transform. + +When scaling is **not** active (the vast majority of use cases), there is zero overhead — the existing code paths are unchanged. + +This feature is implemented entirely in `virtual-core` and works across all framework adapters (React, Vue, Solid, Svelte, Angular, Lit) with no adapter changes required. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index a7af4d63..5d9f9412 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -245,6 +245,43 @@ Controls when lane assignments are cached in a masonry layout. - `'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. - `'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. +### `maxScrollSize` + +```tsx +maxScrollSize?: number +``` + +**Default**: `33_000_000` + +Maximum physical scroll container size in pixels. Browsers cap `scrollHeight` at approximately 33.5 million pixels. When the total virtual size of all items exceeds `maxScrollSize`, the virtualizer automatically applies a scale factor to compress the scroll range so that all items remain reachable. + +When scaling is active: +- `getTotalSize()` returns the capped physical size (use this for your container's CSS height/width) +- `getVirtualItems()` returns items with physical coordinates (use `item.start` directly for `translateY`/`translateX`) +- `scrollToIndex()` and `scrollToOffset()` work transparently +- The `scale` property reflects the current scale factor + +Set to `Infinity` to disable scaling entirely. + +```tsx +// Example: 1 million items at 40px each = 40M px (exceeds browser limit) +const virtualizer = useVirtualizer({ + count: 1_000_000, + estimateSize: () => 40, + getScrollElement: () => parentRef.current, + // maxScrollSize defaults to 33M — scaling activates automatically +}) + +// Everything works as normal — no code changes needed: +
{/* capped at ~33M */} + {virtualizer.getVirtualItems().map(item => ( +
{/* physical */} + ... +
+ ))} +
+``` + ### `isScrollingResetDelay` ```tsx @@ -397,6 +434,8 @@ getTotalSize: () => number Returns the total size in pixels for the virtualized items. This measurement will incrementally change if you choose to dynamically measure your elements as they are rendered. +When scroll-scaling is active (i.e., the virtual total exceeds `maxScrollSize`), this returns the capped physical size suitable for use as the container's CSS height/width. Use the `scale` property to recover the uncapped virtual total if needed. + ### `measure` ```tsx @@ -511,3 +550,15 @@ scrollOffset: number ``` This option represents the current scroll position along the scrolling axis. It is measured in pixels from the starting point of the scrollable area. + +When scroll-scaling is active, this value is in virtual (unscaled) coordinate space, which may be larger than the physical scroll position reported by the browser. + +### `scale` + +```tsx +scale: number +``` + +The current scale factor applied by the virtualizer. Returns `1` when the total virtual size is within the `maxScrollSize` limit (no scaling needed). When scaling is active, this value is greater than `1`. + +You can use this to recover the real (unscaled) size of an item: `realSize = item.size * virtualizer.scale`. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index a1e9dcc7..0bf355f7 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -334,6 +334,10 @@ export interface VirtualizerOptions< isRtl?: boolean useAnimationFrameWithResizeObserver?: boolean laneAssignmentMode?: LaneAssignmentMode + /** Maximum physical scroll container size in pixels. When the virtual total + * size exceeds this value, the virtualizer applies a scale factor to compress + * the scroll range. Defaults to 33_000_000. Set to Infinity to disable. */ + maxScrollSize?: number } type ScrollState = { @@ -378,6 +382,17 @@ export class Virtualizer< scrollOffset: number | null = null scrollDirection: ScrollDirection | null = null private scrollAdjustments = 0 + + private getScale = (): number => { + const virtualTotal = this.getTotalVirtualSize() + const max = this.options.maxScrollSize + return virtualTotal > max ? virtualTotal / max : 1 + } + + /** Current scale factor. Returns 1 when no scaling is active. */ + get scale(): number { + return this.getScale() + } // Sum of size-change deltas above-viewport that were skipped during // iOS momentum scroll (writing scrollTop mid-momentum cancels it). // Flushed in a single scrollTo when iOS is fully settled. @@ -504,6 +519,7 @@ export class Virtualizer< useScrollendEvent: false, useAnimationFrameWithResizeObserver: false, laneAssignmentMode: 'estimate', + maxScrollSize: 33_000_000, } as unknown as Required> for (const key in opts) { @@ -602,6 +618,8 @@ export class Virtualizer< // self-write — by the time the user has moved 1.5 px, the // intended value will already have been consumed by a prior // scroll event and cleared. + // Note: both offset and _intendedScrollOffset are in physical + // space at this point, so the comparison is correct. if ( this._intendedScrollOffset !== null && Math.abs(offset - this._intendedScrollOffset) < 1.5 @@ -610,13 +628,19 @@ export class Virtualizer< } this._intendedScrollOffset = null + // Convert physical scroll offset to virtual coordinate space. + // All internal state (scrollOffset, scrollDirection, etc.) operates + // in virtual space. + const scale = this.getScale() + const virtualOffset = offset * scale + this.scrollAdjustments = 0 this.scrollDirection = isScrolling - ? this.getScrollOffset() < offset + ? this.getScrollOffset() < virtualOffset ? 'forward' : 'backward' : null - this.scrollOffset = offset + this.scrollOffset = virtualOffset this.isScrolling = isScrolling // Flush deferred iOS adjustments if we're now fully settled. @@ -750,13 +774,19 @@ export class Virtualizer< : this.scrollState.lastTargetOffset // Require one stable frame where target matches scroll offset. - // approxEqual() already tolerates minor fluctuations, so one frame is sufficient - // to confirm scroll has reached its target without premature cleanup. + // The tolerance accounts for subpixel browser rounding, so one frame is + // sufficient to confirm scroll has reached its target. const STABLE_FRAMES = 1 const targetChanged = targetOffset !== this.scrollState.lastTargetOffset - if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) { + // When scroll-scaling is active, a 1px physical browser rounding error + // maps to `scale` px in virtual space. Scale the tolerance accordingly + // so the reconcile loop can settle. + const scale = this.getScale() + const tolerance = scale > 1 ? scale * 1.5 : 1.01 + + if (!targetChanged && Math.abs(targetOffset - this.getScrollOffset()) < tolerance) { this.scrollState.stableFrames++ if (this.scrollState.stableFrames >= STABLE_FRAMES) { // Final-pass exact landing. The reconcile-stable check uses a 1.01px @@ -1344,12 +1374,26 @@ export class Virtualizer< () => [this.getVirtualIndexes(), this.getMeasurements()], (indexes, measurements) => { const virtualItems: Array = [] + const scale = this.getScale() for (let k = 0, len = indexes.length; k < len; k++) { const i = indexes[k]! const measurement = measurements[i]! - virtualItems.push(measurement) + if (scale === 1) { + // No scaling — push reference directly (zero overhead, same as before) + virtualItems.push(measurement) + } else { + // Scaling active — create physical copy for consumer positioning + virtualItems.push({ + key: measurement.key, + index: measurement.index, + start: measurement.start / scale, + end: measurement.end / scale, + size: measurement.size / scale, + lane: measurement.lane, + }) + } } return virtualItems @@ -1384,18 +1428,21 @@ export class Virtualizer< private getMaxScrollOffset = () => { if (!this.scrollElement) return 0 + let physicalMax: number if ('scrollHeight' in this.scrollElement) { // Element - return this.options.horizontal + physicalMax = this.options.horizontal ? this.scrollElement.scrollWidth - this.scrollElement.clientWidth : this.scrollElement.scrollHeight - this.scrollElement.clientHeight } else { // Window const doc = this.scrollElement.document.documentElement - return this.options.horizontal + physicalMax = this.options.horizontal ? doc.scrollWidth - this.scrollElement.innerWidth : doc.scrollHeight - this.scrollElement.innerHeight } + // Upscale physical DOM value to virtual coordinate space + return physicalMax * this.getScale() } getOffsetForAlignment = ( @@ -1533,7 +1580,7 @@ export class Virtualizer< this.scheduleScrollReconcile() } - getTotalSize = () => { + private getTotalVirtualSize = () => { const measurements = this.getMeasurements() let end: number @@ -1574,6 +1621,10 @@ export class Virtualizer< ) } + getTotalSize = () => { + return this.getTotalVirtualSize() / this.getScale() + } + /** * Returns a snapshot of currently-measured items suitable for round- * tripping through state storage (sessionStorage, history, etc.) and @@ -1617,10 +1668,15 @@ export class Virtualizer< behavior: ScrollBehavior | undefined }, ) => { - // Record the intended logical scroll target so the next scroll event + // Convert virtual coordinates to physical for the DOM write. + const scale = this.getScale() + const physicalOffset = offset / scale + const physicalAdj = adjustments != null ? adjustments / scale : undefined + // Record the intended physical scroll target so the next scroll event // can reconcile against subpixel rounding by the browser. - this._intendedScrollOffset = offset + (adjustments ?? 0) - this.options.scrollToFn(offset, { behavior, adjustments }, this) + // _intendedScrollOffset is physical (compared against physical scrollTop readback). + this._intendedScrollOffset = physicalOffset + (physicalAdj ?? 0) + this.options.scrollToFn(physicalOffset, { behavior, adjustments: physicalAdj }, this) } measure = () => { diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index d49db8aa..930c40e6 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2526,3 +2526,302 @@ test('observeWindowOffset: reads scrollX when horizontal', () => { listeners.get('scroll')!({} as Event) expect(cb).toHaveBeenCalledWith(75, true) }) + +// ─── Scroll-scaling tests ──────────────────────────────────────────────────── + +test('scale should be 1 when total size is under maxScrollSize', () => { + const virtualizer = new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + // 100 × 50 = 5000, well under 33M default + expect(virtualizer.scale).toBe(1) + expect(virtualizer.getTotalSize()).toBe(5000) +}) + +test('scale should activate when total size exceeds maxScrollSize', () => { + const virtualizer = new Virtualizer({ + count: 1_000_000, + estimateSize: () => 40, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + // 1M × 40 = 40M, exceeds 33M default + expect(virtualizer.scale).toBeGreaterThan(1) + expect(virtualizer.scale).toBeCloseTo(40_000_000 / 33_000_000, 5) + expect(virtualizer.getTotalSize()).toBeLessThanOrEqual(33_000_000) + expect(virtualizer.getTotalSize()).toBeCloseTo(33_000_000, -2) +}) + +test('maxScrollSize: Infinity should disable scaling entirely', () => { + const virtualizer = new Virtualizer({ + count: 1_000_000, + estimateSize: () => 40, + maxScrollSize: Infinity, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + expect(virtualizer.scale).toBe(1) + expect(virtualizer.getTotalSize()).toBe(40_000_000) +}) + +test('custom maxScrollSize should control the scaling threshold', () => { + const virtualizer = new Virtualizer({ + count: 100, + estimateSize: () => 50, + maxScrollSize: 2000, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + // 100 × 50 = 5000 > 2000 custom max + expect(virtualizer.scale).toBeCloseTo(5000 / 2000, 5) + expect(virtualizer.getTotalSize()).toBeCloseTo(2000, 0) +}) + +test('getVirtualItems should return physical coordinates when scaling is active', () => { + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 400, + scrollHeight: 1000, + clientWidth: 400, + clientHeight: 400, + offsetWidth: 400, + offsetHeight: 400, + ownerDocument: { + defaultView: { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })), + }, + }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const virtualizer = new Virtualizer({ + count: 100, + estimateSize: () => 50, + maxScrollSize: 2000, // Force scaling: 100×50=5000 > 2000 + getScrollElement: () => mockScrollElement, + scrollToFn: vi.fn(), + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 400 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + + const items = virtualizer.getVirtualItems() + expect(items.length).toBeGreaterThan(0) + + const scale = virtualizer.scale + expect(scale).toBeGreaterThan(1) + + // Internal measurements should be virtual + const measurements = virtualizer['getMeasurements']() + const firstMeasurement = measurements[0]! + + // Consumer items should have physical (downscaled) coordinates + const firstItem = items[0]! + expect(firstItem.start).toBeCloseTo(firstMeasurement.start / scale, 5) + expect(firstItem.size).toBeCloseTo(firstMeasurement.size / scale, 5) + expect(firstItem.end).toBeCloseTo(firstMeasurement.end / scale, 5) + + // Identity fields should be unchanged + expect(firstItem.index).toBe(firstMeasurement.index) + expect(firstItem.key).toBe(firstMeasurement.key) + expect(firstItem.lane).toBe(firstMeasurement.lane) +}) + +test('getVirtualItems should return references (not copies) when scale is 1', () => { + const virtualizer = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + // No scaling — items should be same references as measurements + const measurements = virtualizer['getMeasurements']() + // Without a scroll element, getVirtualItems returns [] + // so let's test via the measurements directly + expect(virtualizer.scale).toBe(1) + // Verify the total size matches (no scaling applied) + expect(virtualizer.getTotalSize()).toBe(500) +}) + +test('scroll offset should be upscaled from physical to virtual', () => { + let offsetCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + + const virtualizer = new Virtualizer({ + count: 100, + estimateSize: () => 50, + maxScrollSize: 2000, // scale = 5000/2000 = 2.5 + getScrollElement: () => + ({ + scrollTop: 0, + offsetWidth: 400, + offsetHeight: 400, + ownerDocument: { + defaultView: { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })), + }, + }, + scrollTo: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as HTMLDivElement, + scrollToFn: vi.fn(), + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 400 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + offsetCallback = cb + return () => {} + }, + }) + + virtualizer._willUpdate() + + // Simulate a physical scroll to 1000px + offsetCallback!(1000, true) + + // Internal scroll offset should be virtual (upscaled) + const scale = virtualizer.scale + expect(virtualizer.scrollOffset).toBeCloseTo(1000 * scale, 5) +}) + +test('_scrollToOffset should downscale virtual to physical', () => { + const scrollToFn = vi.fn() + + const virtualizer = new Virtualizer({ + count: 100, + estimateSize: () => 50, + maxScrollSize: 2000, // scale = 2.5 + getScrollElement: () => null, + scrollToFn, + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + // Call the private _scrollToOffset with a virtual offset + const scale = virtualizer.scale + const virtualOffset = 2500 + virtualizer['_scrollToOffset'](virtualOffset, { + adjustments: undefined, + behavior: undefined, + }) + + // scrollToFn should receive physical offset + expect(scrollToFn).toHaveBeenCalled() + const physicalOffset = scrollToFn.mock.calls[0]![0] + expect(physicalOffset).toBeCloseTo(virtualOffset / scale, 5) +}) + +test('resize anchoring should work correctly with scaling active', () => { + const scrollToFn = vi.fn() + let offsetCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 400, + scrollHeight: 2000, + clientWidth: 400, + clientHeight: 400, + offsetWidth: 400, + offsetHeight: 400, + ownerDocument: { + defaultView: { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })), + }, + }, + scrollTo: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as HTMLDivElement + + const virtualizer = new Virtualizer({ + count: 100, + estimateSize: () => 50, + maxScrollSize: 2000, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 400 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + offsetCallback = cb + return () => {} + }, + }) + + virtualizer._willUpdate() + + // Simulate scrolling to a position where item 0 is above the viewport + const scale = virtualizer.scale + const physicalScroll = 800 + offsetCallback!(physicalScroll, false) + + scrollToFn.mockClear() + + // Resize item 0 (above viewport) — should trigger scroll anchoring + virtualizer.resizeItem(0, 100) // was 50, now 100, delta = +50 + + // scrollToFn should be called with adjusted physical offset + expect(scrollToFn).toHaveBeenCalled() + const calledPhysicalOffset = scrollToFn.mock.calls[0]![0] as number + const calledAdjustments = (scrollToFn.mock.calls[0]![1] as any) + ?.adjustments as number | undefined + const totalPhysical = calledPhysicalOffset + (calledAdjustments ?? 0) + // The adjustment (delta=50 in virtual, 50/scale in physical) should be reflected + expect(totalPhysical).toBeGreaterThan(physicalScroll) + expect(totalPhysical).toBeCloseTo( + physicalScroll + 50 / scale, + 0, + ) +}) From 431a45e2a1e49dc9a4607a0ac58cbe27d2d81f9a Mon Sep 17 00:00:00 2001 From: Divyam Upadhyay Date: Sun, 24 May 2026 01:40:29 +0530 Subject: [PATCH 2/3] Guard maxScrollSize against invalid values to avoid broken scale math. --- packages/virtual-core/src/index.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 0bf355f7..976c2b25 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -386,6 +386,8 @@ export class Virtualizer< private getScale = (): number => { const virtualTotal = this.getTotalVirtualSize() const max = this.options.maxScrollSize + // Invalid values disable scaling to keep math stable. + if (!Number.isFinite(max) || max <= 0) return 1 return virtualTotal > max ? virtualTotal / max : 1 } @@ -786,7 +788,10 @@ export class Virtualizer< const scale = this.getScale() const tolerance = scale > 1 ? scale * 1.5 : 1.01 - if (!targetChanged && Math.abs(targetOffset - this.getScrollOffset()) < tolerance) { + if ( + !targetChanged && + Math.abs(targetOffset - this.getScrollOffset()) < tolerance + ) { this.scrollState.stableFrames++ if (this.scrollState.stableFrames >= STABLE_FRAMES) { // Final-pass exact landing. The reconcile-stable check uses a 1.01px @@ -1676,7 +1681,11 @@ export class Virtualizer< // can reconcile against subpixel rounding by the browser. // _intendedScrollOffset is physical (compared against physical scrollTop readback). this._intendedScrollOffset = physicalOffset + (physicalAdj ?? 0) - this.options.scrollToFn(physicalOffset, { behavior, adjustments: physicalAdj }, this) + this.options.scrollToFn( + physicalOffset, + { behavior, adjustments: physicalAdj }, + this, + ) } measure = () => { From f40ad74f8d917dbd788a144fc38442e94b5f9a86 Mon Sep 17 00:00:00 2001 From: Divyam Upadhyay Date: Sun, 24 May 2026 01:54:41 +0530 Subject: [PATCH 3/3] test(core): assert reference identity and cloning at scaling boundaries --- packages/virtual-core/tests/index.test.ts | 57 +++++++++++++++++++---- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 930c40e6..d364f553 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2655,25 +2655,64 @@ test('getVirtualItems should return physical coordinates when scaling is active' expect(firstItem.index).toBe(firstMeasurement.index) expect(firstItem.key).toBe(firstMeasurement.key) expect(firstItem.lane).toBe(firstMeasurement.lane) + + // When scaling is active, items must be new objects (not same reference) + expect(firstItem).not.toBe(firstMeasurement) }) test('getVirtualItems should return references (not copies) when scale is 1', () => { + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 400, + scrollHeight: 500, + clientWidth: 400, + clientHeight: 400, + offsetWidth: 400, + offsetHeight: 400, + ownerDocument: { + defaultView: { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })), + }, + }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + const virtualizer = new Virtualizer({ count: 10, estimateSize: () => 50, - getScrollElement: () => null, + getScrollElement: () => mockScrollElement, scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 400 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, }) - // No scaling — items should be same references as measurements - const measurements = virtualizer['getMeasurements']() - // Without a scroll element, getVirtualItems returns [] - // so let's test via the measurements directly + virtualizer._willUpdate() + expect(virtualizer.scale).toBe(1) - // Verify the total size matches (no scaling applied) - expect(virtualizer.getTotalSize()).toBe(500) + + const measurements = virtualizer['getMeasurements']() + const items = virtualizer.getVirtualItems() + + expect(items.length).toBeGreaterThan(0) + + // Verify strict reference equality (===) between returned items and measurements + items.forEach((item) => { + expect(item).toBe(measurements[item.index]) + }) }) test('scroll offset should be upscaled from physical to virtual', () => {