From 2c3168dd6bc8f045c630f62a50447ddf2fe40869 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 18 May 2026 11:07:22 +0300 Subject: [PATCH 01/11] feat: Added virtual scroll component and sample implementation --- projects/igniteui-angular/src/public_api.ts | 1 + .../igniteui-angular/virtual-scroll/README.md | 182 ++++++ .../igniteui-angular/virtual-scroll/index.ts | 3 + .../virtual-scroll/ng-package.json | 1 + .../virtual-scroll/src/scroll-engine.ts | 214 +++++++ .../virtual-scroll/src/types.ts | 62 ++ .../src/virtual-scroll-item.directive.ts | 22 + .../src/virtual-scroll.component.html | 14 + .../src/virtual-scroll.component.scss | 44 ++ .../src/virtual-scroll.component.spec.ts | 578 ++++++++++++++++++ .../src/virtual-scroll.component.ts | 367 +++++++++++ src/app/app.component.ts | 5 + src/app/app.routes.ts | 5 + .../virtual-scroll/virtual-scroll.sample.html | 135 ++++ .../virtual-scroll/virtual-scroll.sample.scss | 127 ++++ .../virtual-scroll/virtual-scroll.sample.ts | 77 +++ 16 files changed, 1837 insertions(+) create mode 100644 projects/igniteui-angular/virtual-scroll/README.md create mode 100644 projects/igniteui-angular/virtual-scroll/index.ts create mode 100644 projects/igniteui-angular/virtual-scroll/ng-package.json create mode 100644 projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts create mode 100644 projects/igniteui-angular/virtual-scroll/src/types.ts create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts create mode 100644 projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts create mode 100644 src/app/virtual-scroll/virtual-scroll.sample.html create mode 100644 src/app/virtual-scroll/virtual-scroll.sample.scss create mode 100644 src/app/virtual-scroll/virtual-scroll.sample.ts diff --git a/projects/igniteui-angular/src/public_api.ts b/projects/igniteui-angular/src/public_api.ts index 1360707be6f..79e91ae467c 100644 --- a/projects/igniteui-angular/src/public_api.ts +++ b/projects/igniteui-angular/src/public_api.ts @@ -66,3 +66,4 @@ export * from 'igniteui-angular/tabs'; export * from 'igniteui-angular/time-picker'; export * from 'igniteui-angular/toast'; export * from 'igniteui-angular/tree'; +export * from 'igniteui-angular/virtual-scroll'; diff --git a/projects/igniteui-angular/virtual-scroll/README.md b/projects/igniteui-angular/virtual-scroll/README.md new file mode 100644 index 00000000000..a83796894dd --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/README.md @@ -0,0 +1,182 @@ +# IgxVirtualScrollComponent + +A high-performance virtual-scrolling component that renders only the items visible inside the viewport (plus a configurable over-scan buffer). It supports both vertical and horizontal axes, variable item sizes measured at runtime, and remote / infinite scrolling through the `dataRequest` event. + +## Imports + +```ts +import { + IgxVirtualScrollComponent, + IgxVirtualItemDirective, +} from 'igniteui-angular/virtual-scroll'; +``` + +--- + +## Basic usage + +Define your list and provide a template using the `igxVirtualItem` directive: + +```html + + +
{{ i }}: {{ item.name }}
+
+
+``` + +```ts +@Component({ /* ... */ }) +export class MyComponent { + items = Array.from({ length: 10_000 }, (_, i) => ({ name: `Item ${i}` })); +} +``` + +--- + +## Inputs + +| Input | Type | Default | Description | +|---|---|---|---| +| `data` | `T[]` | `[]` | The array of items to virtualise. | +| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Scroll axis. | +| `overScan` | `number` | `2` | Extra items to render beyond each edge of the viewport. Higher values reduce blank-flash artefacts during fast scrolling at the cost of slightly more DOM nodes. | +| `estimatedItemSize` | `number` | `50` | Pixel size used for items before they are measured in the DOM. Set this close to the real average size for the best initial-render accuracy. | +| `itemTemplate` | `TemplateRef> \| null` | `null` | Programmatic template that takes precedence over a content `ng-template[igxVirtualItem]`. | + +--- + +## Outputs + +| Output | Payload | Description | +|---|---|---| +| `stateChange` | `VirtualScrollState` | Emitted after every render pass with a snapshot of the current virtual window. | +| `dataRequest` | `VirtualScrollDataRequest` | Emitted when the scroll position approaches the end of loaded data. Use this to implement infinite / remote scrolling. | + +--- + +## Public API + +### `scrollToIndex(index: number): void` + +Programmatically scrolls the viewport so that the item at `index` is at the leading edge. + +```ts +@ViewChild(IgxVirtualScrollComponent) vs!: IgxVirtualScrollComponent; + +this.vs.scrollToIndex(500); +``` + +--- + +## `IgxVirtualItemDirective` + +Marks an `ng-template` as the item template for the nearest `igx-virtual-scroll`. The template context is typed as `IgxVsItemContext`. + +### Template context variables + +| Variable | Type | Description | +|---|---|---| +| `$implicit` (or `let-item`) | `T` | The current item. | +| `index` | `number` | The item's index within the full data array. | +| `count` | `number` | Total number of items in `data`. | +| `first` | `boolean` | `true` when `index === 0`. | +| `last` | `boolean` | `true` when `index === count - 1`. | +| `even` | `boolean` | `true` when `index` is even. | +| `odd` | `boolean` | `true` when `index` is odd. | + +```html + +
{{ i }}: {{ item }}
+
+``` + +--- + +## Output type reference + +### `VirtualScrollState` + +```ts +interface VirtualScrollState { + startIndex: number; // First rendered item index + endIndex: number; // Last rendered item index (inclusive) + viewportSize: number; // Viewport height (or width) in px + totalSize: number; // Total virtual content size in px +} +``` + +### `VirtualScrollDataRequest` + +```ts +interface VirtualScrollDataRequest { + startIndex: number; // First index that does not yet have data + count: number; // Suggested number of items to fetch +} +``` + +--- + +## Horizontal scrolling + +Set `orientation="horizontal"`. Items are laid out in a row; ensure each item has an explicit `width` so the engine can measure sizes correctly. + +```html + + +
{{ item }}
+
+
+``` + +--- + +## Infinite / remote scrolling + +Listen to the `dataRequest` output and append more items to the `data` array: + +```html + + +
{{ item.label }}
+
+
+``` + +```ts +loadMore(req: VirtualScrollDataRequest) { + this.myService.fetch(req.startIndex, req.count).subscribe(newItems => { + this.items = [...this.items, ...newItems]; + }); +} +``` + +--- + +## Programmatic template + +Pass a `TemplateRef` via `[itemTemplate]` when the template is defined outside the component: + +```html + +
{{ item }}
+
+ + +``` + +--- + +## Styling + +The component exposes the following CSS classes: + +| Class | Element | Notes | +|---|---|---| +| `igx-virtual-scroll` | Host | Always present. | +| `igx-virtual-scroll--vertical` | Host | Added when `orientation="vertical"`. | +| `igx-virtual-scroll--horizontal` | Host | Added when `orientation="horizontal"`. | +| `igx-vs__track` | Inner spacer div | Sized to the full virtual height/width. | +| `igx-vs__content` | Rendered-items wrapper | Absolutely positioned; translated to the correct virtual offset. | + +The host element must have a **fixed height** (vertical) or **fixed width** (horizontal) and `overflow: auto` or `overflow: scroll` — the default styles already set this. diff --git a/projects/igniteui-angular/virtual-scroll/index.ts b/projects/igniteui-angular/virtual-scroll/index.ts new file mode 100644 index 00000000000..2bc0221a594 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/index.ts @@ -0,0 +1,3 @@ +export { IgxVirtualScrollComponent } from './src/virtual-scroll.component'; +export { IgxVirtualItemDirective } from './src/virtual-scroll-item.directive'; +export { IgxVsItemContext, VirtualScrollDataRequest, VirtualScrollState } from './src/types'; diff --git a/projects/igniteui-angular/virtual-scroll/ng-package.json b/projects/igniteui-angular/virtual-scroll/ng-package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/ng-package.json @@ -0,0 +1 @@ +{} diff --git a/projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts new file mode 100644 index 00000000000..2b89eafaf0e --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts @@ -0,0 +1,214 @@ +import { computed, signal } from "@angular/core"; + +const MAX_BROWSER_SIZE_PROBE_PX = Number.MAX_SAFE_INTEGER; + +/** + * Probes the browser for the maximum scrollable coordinate it supports. + */ +function getMaxBrowserSizeProbePx(doc: Document): number { + const div = doc.createElement("div"); + div.style.position = "absolute"; + div.style.top = `${MAX_BROWSER_SIZE_PROBE_PX}px`; + doc.body.appendChild(div); + const size = Math.abs(div.getBoundingClientRect().top); + doc.body.removeChild(div); + return size; +} + +/** + * Builds a prefix sums array from the given sizes array. + * The prefix sums array has one more element than the sizes array, + * where the first element is 0 and each subsequent element is the sum of all previous sizes. + * This allows for efficient calculation of the total size up to any index in the sizes array. + */ +function buildPrefixSums(sizes: readonly number[]): number[] { + const sums = new Array(sizes.length + 1); + sums[0] = 0; + for (let i = 0; i < sizes.length; i++) { + sums[i + 1] = sums[i] + sizes[i]; + } + return sums; +} + +/** + * Performs a binary search on the prefix sums array to find the largest index such that prefixSums[index] <= target. + * This is used to efficiently determine how many items can fit within a given scroll position. + * The function returns the index of the last item that fits within the target scroll position. + * If the target is smaller than the first prefix sum, it returns -1, indicating that no items fit. + */ +function binarySearchPrefixSums( + prefixSums: readonly number[], + target: number, +): number { + let low = 0; + let high = prefixSums.length - 1; + + while (low < high) { + const mid = (low + high + 1) >> 1; + if (prefixSums[mid] <= target) { + low = mid; + } else { + high = mid - 1; + } + } + + return Math.max(0, low - 1); +} + +/** + * Describes the currently visible (and over-scanned) range of items. + */ +export interface VisibleRange { + /** Index of the first rendered item (inclusive) */ + startIndex: number; + /** Index of the last rendered item (inclusive) */ + endIndex: number; +} + +/** + * Pure scroll-math engine for a single axis of virtual scrolling. + * + * Holds all size state as signals so that downstream `computed()` values + * (visible range, spacer size, translate offset) react automatically + * whenever item sizes are measured or the item count changes. + */ +export class VirtualScrollEngine { + private _maxBrowserSize = Infinity; + + /** + * The ratio `totalSize / maxBrowserSize` when `totalSize` exceeds the + * maximum DOM coordinate the browser supports; `1` otherwise. + * Used to map virtual scroll positions to DOM scroll positions. + */ + private _virtualRatio = 1; + + /** Per-item measured or estimated sizes in px. */ + private readonly _itemSizes = signal([]); + + /** + * Prefix-sum array of item sizes, where prefixSums[i] is the total size of items[0] through items[i-1]. + */ + public readonly prefixSums = computed(() => + buildPrefixSums(this._itemSizes()), + ); + + /** Total virtual size of all items in px. */ + public readonly totalSize = computed(() => { + const pSum = this.prefixSums(); + return pSum[pSum.length - 1] ?? 0; + }); + + /** Actual DOM space size (clamped to the maximum browser size) */ + public readonly domSize = computed(() => + this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize(), + ); + + /** + * Initializes the maximum browser size by probing the document, and updates the virtual ratio accordingly. + */ + public initMaxBrowserSize(doc: Document): void { + this._maxBrowserSize = getMaxBrowserSizeProbePx(doc); + this._updateVirtualRatio(); + } + + /** + * Grows or shrinks the internal sizes array to `length`. + * New entries are filled with `estimatedSize`. + * Existing measured sizes are preserved. + */ + public resize(length: number, estimatedSize: number): void { + const current = this._itemSizes(); + if (length === current.length) return; + + const next = current.slice(0, length); + while (next.length < length) { + next.push(estimatedSize); + } + this._itemSizes.set(next); + this._updateVirtualRatio(); + } + + /** + * Records the measured DOM size for a single item. + * Triggers a signal update so all downstream computed values react. + */ + public measureItem(index: number, size: number): void { + const current = this._itemSizes(); + if (index < 0 || index >= current.length) return; + if (current[index] === size) return; + + const next = current.slice(); + next[index] = size; + this._itemSizes.set(next); + this._updateVirtualRatio(); + } + + /** + * Returns the DOM scroll offset in pixels that brings item at `index` into view + * at the leading edge of the viewport. + */ + public getScrollOffsetForIndex(index: number): number { + const pSums = this.prefixSums(); + if (index <= 0) return 0; + + const clamped = Math.min(index, pSums.length - 1); + const virtualOffset = pSums[clamped]; + return virtualOffset / this._virtualRatio; + } + + /** Returns the item index at the given DOM scroll position. */ + public getIndexAtScroll(scrollPosition: number): number { + const virtualPosition = scrollPosition * this._virtualRatio; + const pSum = this.prefixSums(); + if (virtualPosition <= 0 || pSum.length <= 1) return 0; + + return binarySearchPrefixSums(pSum, virtualPosition); + } + + /** + * Returns the visible + over-scanned item range for the given scroll state. + */ + public getVisibleRange( + scrollPosition: number, + viewportSize: number, + overScan: number, + totalItems: number, + ): VisibleRange { + if (totalItems === 0 || viewportSize <= 0) { + return { startIndex: 0, endIndex: -1 }; + } + + const start = Math.max(0, this.getIndexAtScroll(scrollPosition) - overScan); + const endScrollPosition = scrollPosition + viewportSize; + const endRaw = this.getIndexAtScroll(endScrollPosition); + const end = Math.min(totalItems - 1, endRaw + overScan); + + return { startIndex: start, endIndex: end }; + } + + /** + * Returns the CSS `translateY` / `translateX` value (px) to apply to the + * absolutely-positioned content wrapper. + * + * The content wrapper is `position: absolute; top: 0; left: 0` inside a + * track element that is `totalSize` px tall/wide. Translating it to + * `getContentPosition(startIndex)` places the first rendered item exactly + * at its virtual scroll position within the track. + */ + public getContentPosition(index: number): number { + const pSums = this.prefixSums(); + if (index <= 0) return 0; + + const clamped = Math.min(index, pSums.length - 1); + const virtualOffset = pSums[clamped]; + return virtualOffset / this._virtualRatio; + } + + private _updateVirtualRatio(): void { + const totalSize = this.totalSize(); + this._virtualRatio = + this._maxBrowserSize === Infinity || totalSize <= this._maxBrowserSize + ? 1 + : totalSize / this._maxBrowserSize; + } +} diff --git a/projects/igniteui-angular/virtual-scroll/src/types.ts b/projects/igniteui-angular/virtual-scroll/src/types.ts new file mode 100644 index 00000000000..5db24a27e37 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/types.ts @@ -0,0 +1,62 @@ +/** + * Context for the item template in the virtual scroll component. + * Provides the item data, its index, and utility properties for template rendering. + */ +export class IgxVsItemContext { + constructor( + /** The current item in the virtual scroll. */ + public $implicit: T, + /** The index of the current item. */ + public index: number, + /** The total number of items in the virtual scroll. */ + public count: number, + ) {} + + /** Whether the current item is the first in the list. */ + public get first(): boolean { + return this.index === 0; + } + + /** Whether the current item is the last in the list. */ + public get last(): boolean { + return this.index === this.count - 1; + } + + /** Whether the current item is at an even index. */ + public get even(): boolean { + return this.index % 2 === 0; + } + + /** Whether the current item is at an odd index. */ + public get odd(): boolean { + return !this.even; + } +} + +/** + * Snapshot of the currently rendered virtual window. + */ +export interface VirtualScrollState { + /** The index of the first item currently rendered in the viewport. */ + startIndex: number; + /** The index of the last item currently rendered in the viewport (inclusive). */ + endIndex: number; + /** The size of the viewport in pixels. */ + viewportSize: number; + /** The total size of the virtual scroll content in pixels. */ + totalSize: number; +} + +/** + * Request for more data to be loaded in the virtual scroll, typically emitted when the user scrolls near the end of the currently loaded items. + * The consumer of the virtual scroll component can listen to this event and load more data as needed. + */ +export interface VirtualScrollDataRequest { + /** + * The first index that does not yet have data. + * Append at least `(endIndex - startIndex + 1)` more items starting here. + */ + startIndex: number; + /** Number of items being requested. */ + count: number; +} diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts new file mode 100644 index 00000000000..f8a45fc305c --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts @@ -0,0 +1,22 @@ +import { Directive, inject, TemplateRef } from "@angular/core"; +import { IgxVsItemContext } from "./types"; + +/** + * Directive to mark an `ng-template` as the item template for the virtual scroll component. + * The template provided by this directive will be used to render each item in the virtual scroll. + * The context for the template will include the item data and its index. + * + * @example + * ```html + * + * + *
{{ i }}: {{ item }}
+ *
+ *
+ * ``` + */ +@Directive({ selector: "ng-template[igxVirtualItem]" }) +export class IgxVirtualItemDirective { + public readonly template = + inject>>(TemplateRef); +} diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html new file mode 100644 index 00000000000..c8299c1d4d8 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html @@ -0,0 +1,14 @@ + diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss new file mode 100644 index 00000000000..a5648e40ddf --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss @@ -0,0 +1,44 @@ +:host { + display: block; + position: relative; + overflow: auto; +} + +:host(.igx-virtual-scroll--vertical) { + overflow-y: auto; + overflow-x: hidden; +} + +:host(.igx-virtual-scroll--horizontal) { + overflow-x: auto; + overflow-y: hidden; +} + +.igx-vs__track { + position: relative; + width: 100%; + min-height: 100%; +} + +.igx-vs__content { + position: absolute; + top: 0; + left: 0; + width: 100%; + will-change: transform; + contain: layout style paint; +} + +:host(.igx-virtual-scroll--horizontal) { + .igx-vs__track { + height: 100%; + min-height: unset; + } + + .igx-vs__content { + display: flex; + flex-direction: row; + height: 100%; + width: auto; + } +} diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts new file mode 100644 index 00000000000..2d3cdf66d2f --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts @@ -0,0 +1,578 @@ +import { + TestBed, + ComponentFixture, + fakeAsync, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { Component, TemplateRef, viewChild } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +import { IgxVirtualScrollComponent } from './virtual-scroll.component'; +import { IgxVirtualItemDirective } from './virtual-scroll-item.directive'; +import { IgxVsItemContext, VirtualScrollDataRequest, VirtualScrollState } from './types'; +import { VirtualScrollEngine } from './scroll-engine'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function generateItems(count: number): string[] { + return Array.from({ length: count }, (_, i) => `Item ${i}`); +} + +// --------------------------------------------------------------------------- +// VirtualScrollEngine (pure unit tests – no DOM required) +// --------------------------------------------------------------------------- + +fdescribe('VirtualScrollEngine', () => { + let engine: VirtualScrollEngine; + + beforeEach(() => { + engine = new VirtualScrollEngine(); + }); + + describe('resize', () => { + it('should initialize sizes with the estimated value', () => { + engine.resize(5, 40); + expect(engine.totalSize()).toBe(200); + }); + + it('should grow preserving existing sizes', () => { + engine.resize(3, 50); + engine.measureItem(0, 80); + engine.resize(5, 50); + // item 0 = 80, items 1-4 = 50 each => 80 + 4*50 = 280 + expect(engine.totalSize()).toBe(280); + }); + + it('should shrink the array', () => { + engine.resize(5, 50); + engine.resize(2, 50); + expect(engine.totalSize()).toBe(100); + }); + + it('should be a no-op when length is unchanged', () => { + engine.resize(3, 50); + const before = engine.totalSize(); + engine.resize(3, 99); + expect(engine.totalSize()).toBe(before); + }); + }); + + describe('measureItem', () => { + it('should update totalSize after a measurement', () => { + engine.resize(3, 50); + engine.measureItem(1, 120); + expect(engine.totalSize()).toBe(50 + 120 + 50); + }); + + it('should ignore out-of-range indices', () => { + engine.resize(3, 50); + engine.measureItem(-1, 100); + engine.measureItem(3, 100); + expect(engine.totalSize()).toBe(150); + }); + + it('should be a no-op when the size has not changed', () => { + engine.resize(3, 50); + const before = engine.prefixSums(); + engine.measureItem(0, 50); + expect(engine.prefixSums()).toEqual(before); + }); + }); + + describe('getScrollOffsetForIndex', () => { + it('should return 0 for index 0', () => { + engine.resize(3, 50); + expect(engine.getScrollOffsetForIndex(0)).toBe(0); + }); + + it('should return the cumulative size up to the given index', () => { + engine.resize(4, 50); + engine.measureItem(0, 30); + engine.measureItem(1, 60); + // offset for index 2 = item0 + item1 = 30 + 60 = 90 + expect(engine.getScrollOffsetForIndex(2)).toBe(90); + }); + + it('should clamp to totalSize for out-of-range indices', () => { + engine.resize(3, 50); + // pSums has length items+1; index clamps to pSums.length-1 = totalItems, + // which equals the total virtual size, not the last item's leading offset. + expect(engine.getScrollOffsetForIndex(100)).toBe(engine.totalSize()); + }); + }); + + describe('getIndexAtScroll', () => { + it('should return 0 when scrollPosition is 0', () => { + engine.resize(5, 50); + expect(engine.getIndexAtScroll(0)).toBe(0); + }); + + it('should return the last complete item before the scroll position', () => { + engine.resize(5, 50); + // binarySearchPrefixSums returns low-1: the last item whose end (prefixSums[i+1]) + // is at or before the target. At 125px item 1 ends at 100px, so index 1 is returned. + // getVisibleRange adds overscan on top, which covers the partially-visible item. + expect(engine.getIndexAtScroll(125)).toBe(1); + }); + }); + + describe('getVisibleRange', () => { + it('should return empty range when totalItems is 0', () => { + engine.resize(0, 50); + const range = engine.getVisibleRange(0, 300, 2, 0); + expect(range.startIndex).toBe(0); + expect(range.endIndex).toBe(-1); + }); + + it('should return empty range when viewportSize is 0', () => { + engine.resize(10, 50); + const range = engine.getVisibleRange(0, 0, 2, 10); + expect(range.startIndex).toBe(0); + expect(range.endIndex).toBe(-1); + }); + + it('should include over-scanned items beyond the visible edge', () => { + engine.resize(20, 50); + // Viewport 200px, scroll 0 → visible items 0-3; with overScan=2 => 0-5 + const range = engine.getVisibleRange(0, 200, 2, 20); + expect(range.startIndex).toBe(0); + expect(range.endIndex).toBeGreaterThanOrEqual(5); + }); + + it('should not exceed totalItems - 1 as endIndex', () => { + engine.resize(5, 50); + const range = engine.getVisibleRange(0, 10000, 10, 5); + expect(range.endIndex).toBe(4); + }); + + it('should not go below 0 as startIndex', () => { + engine.resize(10, 50); + const range = engine.getVisibleRange(0, 200, 10, 10); + expect(range.startIndex).toBe(0); + }); + }); + + describe('getContentPosition', () => { + it('should return 0 for index 0', () => { + engine.resize(3, 50); + expect(engine.getContentPosition(0)).toBe(0); + }); + + it('should match the prefix sum at the given index', () => { + engine.resize(4, 50); + engine.measureItem(0, 30); + engine.measureItem(1, 70); + // position for index 2 = sum of items 0 and 1 = 30 + 70 = 100 + expect(engine.getContentPosition(2)).toBe(100); + }); + }); +}); + +// --------------------------------------------------------------------------- +// IgxVsItemContext +// --------------------------------------------------------------------------- + +describe('IgxVsItemContext', () => { + it('should expose item, index, and count', () => { + const ctx = new IgxVsItemContext('hello', 3, 10); + expect(ctx.$implicit).toBe('hello'); + expect(ctx.index).toBe(3); + expect(ctx.count).toBe(10); + }); + + it('first should be true only at index 0', () => { + expect(new IgxVsItemContext('a', 0, 5).first).toBeTrue(); + expect(new IgxVsItemContext('a', 1, 5).first).toBeFalse(); + }); + + it('last should be true only at index count-1', () => { + expect(new IgxVsItemContext('a', 4, 5).last).toBeTrue(); + expect(new IgxVsItemContext('a', 3, 5).last).toBeFalse(); + }); + + it('even/odd should reflect index parity', () => { + expect(new IgxVsItemContext('a', 0, 5).even).toBeTrue(); + expect(new IgxVsItemContext('a', 0, 5).odd).toBeFalse(); + expect(new IgxVsItemContext('a', 1, 5).even).toBeFalse(); + expect(new IgxVsItemContext('a', 1, 5).odd).toBeTrue(); + }); +}); + +// --------------------------------------------------------------------------- +// Wrapper components used in TestBed tests +// --------------------------------------------------------------------------- + +@Component({ + selector: 'test-virtual-scroll-basic', + template: ` + + +
{{ i }}: {{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestBasicComponent { + public items = generateItems(100); +} + +@Component({ + selector: 'test-virtual-scroll-horizontal', + template: ` + + +
{{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestHorizontalComponent { + public items = generateItems(50); +} + +@Component({ + selector: 'test-virtual-scroll-events', + template: ` + + +
{{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestEventsComponent { + public items = generateItems(100); + public lastState: VirtualScrollState | null = null; + public lastDataRequest: VirtualScrollDataRequest | null = null; + + public onStateChange(state: VirtualScrollState) { + this.lastState = state; + } + + public onDataRequest(req: VirtualScrollDataRequest) { + this.lastDataRequest = req; + } +} + +@Component({ + selector: 'test-virtual-scroll-programmatic-template', + template: ` + +
{{ i }}: {{ item }}
+
+ + + `, + imports: [IgxVirtualScrollComponent], +}) +class TestProgrammaticTemplateComponent { + public items = generateItems(50); + public tpl = viewChild>>('tpl'); +} + +@Component({ + selector: 'test-virtual-scroll-empty', + template: ` + + +
{{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestEmptyComponent { + public items: string[] = []; +} + +// --------------------------------------------------------------------------- +// IgxVirtualScrollComponent TestBed tests +// --------------------------------------------------------------------------- + +describe('IgxVirtualScrollComponent', () => { + describe('basic rendering', () => { + let fixture: ComponentFixture; + let component: TestBasicComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestBasicComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestBasicComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + const vs = fixture.debugElement.query(By.directive(IgxVirtualScrollComponent)); + expect(vs).toBeTruthy(); + }); + + it('should have the igx-virtual-scroll class and role="list"', () => { + const el: HTMLElement = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).nativeElement; + expect(el.classList).toContain('igx-virtual-scroll'); + expect(el.getAttribute('role')).toBe('list'); + }); + + it('should add the vertical modifier class by default', () => { + const el: HTMLElement = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).nativeElement; + expect(el.classList).toContain('igx-virtual-scroll--vertical'); + expect(el.classList).not.toContain('igx-virtual-scroll--horizontal'); + }); + + it('should render a subset of items (not all 100)', () => { + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBeGreaterThan(0); + expect(items.length).toBeLessThan(component.items.length); + }); + + it('should render the track element with a non-zero height', () => { + const track: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__track') + ).nativeElement; + const heightPx = parseInt(track.style.height, 10); + expect(heightPx).toBeGreaterThan(0); + }); + + it('should contain a content wrapper with a transform style', () => { + const content: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__content') + ).nativeElement; + expect(content.style.transform).toMatch(/translateY/); + }); + + it('should reflect updated data after input change', () => { + component.items = generateItems(5); + fixture.detectChanges(); + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBe(5); + }); + + it('should render no items when data is empty', () => { + component.items = []; + fixture.detectChanges(); + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBe(0); + }); + }); + + describe('horizontal orientation', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestHorizontalComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestHorizontalComponent); + fixture.detectChanges(); + }); + + it('should add the horizontal modifier class', () => { + const el: HTMLElement = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).nativeElement; + expect(el.classList).toContain('igx-virtual-scroll--horizontal'); + expect(el.classList).not.toContain('igx-virtual-scroll--vertical'); + }); + + it('should set a width on the track element instead of height', () => { + const track: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__track') + ).nativeElement; + expect(track.style.width).toBeTruthy(); + }); + + it('should apply a translateX transform to the content wrapper', () => { + const content: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__content') + ).nativeElement; + expect(content.style.transform).toMatch(/translateX/); + }); + }); + + describe('events', () => { + let fixture: ComponentFixture; + let component: TestEventsComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestEventsComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestEventsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should emit stateChange after initial render', () => { + expect(component.lastState).not.toBeNull(); + }); + + it('stateChange should include startIndex, endIndex, viewportSize, and totalSize', () => { + const state = component.lastState!; + expect(state.startIndex).toBeDefined(); + expect(state.endIndex).toBeDefined(); + expect(state.viewportSize).toBeDefined(); + expect(state.totalSize).toBeGreaterThan(0); + }); + + it('stateChange startIndex should be less than or equal to endIndex', () => { + expect(component.lastState!.startIndex).toBeLessThanOrEqual( + component.lastState!.endIndex + ); + }); + + it('should emit dataRequest when near the end of data', fakeAsync(() => { + // Provide a very small list so the initial render is near the end + component.items = generateItems(3); + fixture.detectChanges(); + tick(); + expect(component.lastDataRequest).not.toBeNull(); + expect(component.lastDataRequest!.startIndex).toBe(3); + })); + }); + + describe('programmatic itemTemplate input', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestProgrammaticTemplateComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestProgrammaticTemplateComponent); + fixture.detectChanges(); + }); + + it('should render items using the programmatic template', () => { + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBeGreaterThan(0); + }); + }); + + describe('empty data', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestEmptyComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestEmptyComponent); + fixture.detectChanges(); + }); + + it('should not render any items', () => { + const items = fixture.debugElement.queryAll(By.css('.item')); + expect(items.length).toBe(0); + }); + + it('should still render the track element', () => { + const track = fixture.debugElement.query(By.css('.igx-vs__track')); + expect(track).toBeTruthy(); + }); + }); + + describe('scrollToIndex', () => { + let fixture: ComponentFixture; + let vsComponent: IgxVirtualScrollComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestBasicComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestBasicComponent); + fixture.detectChanges(); + vsComponent = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).componentInstance; + }); + + it('should not throw when scrolling to a valid index', () => { + expect(() => vsComponent.scrollToIndex(10)).not.toThrow(); + }); + + it('should not throw when scrolling to index 0', () => { + expect(() => vsComponent.scrollToIndex(0)).not.toThrow(); + }); + + it('should not throw when scrolling to the last index', () => { + expect(() => vsComponent.scrollToIndex(99)).not.toThrow(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// IgxVirtualItemDirective +// --------------------------------------------------------------------------- + +describe('IgxVirtualItemDirective', () => { + @Component({ + selector: 'test-directive-host', + template: ` + + +
{{ item }}
+
+
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], + }) + class DirectiveHostComponent { + public items = generateItems(10); + } + + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [DirectiveHostComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DirectiveHostComponent); + fixture.detectChanges(); + }); + + it('should be picked up as a content child of IgxVirtualScrollComponent', () => { + const directive = fixture.debugElement.query(By.directive(IgxVirtualItemDirective)); + expect(directive).toBeTruthy(); + }); + + it('should expose a non-null TemplateRef', () => { + const directiveInstance: IgxVirtualItemDirective = fixture.debugElement + .query(By.directive(IgxVirtualItemDirective)) + .injector.get(IgxVirtualItemDirective); + expect(directiveInstance.template).toBeTruthy(); + }); +}); diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts new file mode 100644 index 00000000000..70603dac13a --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts @@ -0,0 +1,367 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + contentChild, + DOCUMENT, + effect, + ElementRef, + EmbeddedViewRef, + inject, + input, + NgZone, + OnDestroy, + output, + PLATFORM_ID, + signal, + TemplateRef, + untracked, + viewChild, + ViewContainerRef, +} from "@angular/core"; +import { IgxVirtualItemDirective } from "./virtual-scroll-item.directive"; +import { + IgxVsItemContext, + VirtualScrollDataRequest, + VirtualScrollState, +} from "./types"; +import { VirtualScrollEngine } from "./scroll-engine"; +import { isPlatformBrowser } from "@angular/common"; + +const REMOTE_SCROLLING_THRESHOLD = 5; + +@Component({ + selector: "igx-virtual-scroll", + templateUrl: "./virtual-scroll.component.html", + styleUrls: ["./virtual-scroll.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "igx-virtual-scroll", + role: "list", + "[class.igx-virtual-scroll--vertical]": "_isVertical()", + "[class.igx-virtual-scroll--horizontal]": "!_isVertical()", + }, +}) +export class IgxVirtualScrollComponent implements OnDestroy { + //#region Dependency Injections + private readonly _hostRef = inject>(ElementRef); + private readonly _zone = inject(NgZone); + private readonly _document = inject(DOCUMENT); + private readonly _platformId = inject(PLATFORM_ID); + + //#endregion + + private _viewportResizeObserver: ResizeObserver | null = null; + private _itemResizeObserver: ResizeObserver | null = null; + private _onScroll: ((e: Event) => void) | null = null; + + /** Views currently inserted into the VCR, ordered by rendered item index. */ + private readonly _activeItems: EmbeddedViewRef>[] = []; + + /** Detached views available for reuse. */ + private readonly _pooledItems: EmbeddedViewRef>[] = []; + + private readonly _scrollPosition = signal(0); + private readonly _viewportSize = signal(0); + + private readonly _visibleRange = computed(() => + this._engine.getVisibleRange( + this._scrollPosition(), + this._viewportSize(), + this.overScan(), + this.data().length, + ), + ); + + protected readonly _engine = new VirtualScrollEngine(); + protected readonly _isVertical = computed( + () => this.orientation() === "vertical", + ); + protected readonly _spaceSize = computed(() => this._engine.domSize()); + protected readonly _contentTransform = computed(() => { + const position = this._engine.getContentPosition( + this._visibleRange().startIndex, + ); + return this._isVertical() + ? `translateY(${position}px)` + : `translateX(${position}px)`; + }); + + //#region View and Content Children + + private readonly _itemDirective = contentChild(IgxVirtualItemDirective); + + private readonly _itemsViewContainer = viewChild( + "itemsAnchor", + { read: ViewContainerRef }, + ); + + private readonly _contentDivRef = + viewChild>("contentDiv"); + + protected readonly _resolvedTemplate = computed(() => { + return this.itemTemplate() ?? this._itemDirective()?.template ?? null; + }); + + //#endregion + + /** The array of items to virtualize. */ + public readonly data = input([]); + + /** + * Scroll orientation of the virtual scroll. + * Can be either "vertical" or "horizontal". + * Default is "vertical". + */ + public readonly orientation = input<"vertical" | "horizontal">("vertical"); + + /** + * Number of extra items to render beyond the visible area of the viewport. + * Higher values reduce blank flashes during fast scrolling but may impact performance. + * Default is 2. + */ + public readonly overScan = input(2); + + /** + * Estimated item size in pixels used before an item is measured in the DOM. + * The engine replaces this with the actual measured size after the first render of each item. + * Default is 50 pixels. + * Setting this to a value close to the actual average item size can improve initial rendering performance. + */ + public readonly estimatedItemSize = input(50); + + /** + * Item template provided programmatically (takes precedence over content template if both are provided). + * + * This template will be used to render each item in the virtual scroll. + * The context for the template will include the item data and its index. + * If not provided, the component will look for an `ng-template` with the `igxVirtualItem` directive in its content. + */ + public readonly itemTemplate = input> | null>( + null, + ); + + /** + * Emitted after each render pass with a snapshot of the current virtual window. + */ + public readonly stateChange = output(); + + /** + * Emitted when the scroll position approaches the end of the available data. + * Listen to this event to append more items (infinite / remote scrolling). + */ + public readonly dataRequest = output(); + + constructor() { + // Sync engine item count with data changes. + effect(() => { + const count = this.data().length; + const estimated = this.estimatedItemSize(); + untracked(() => this._engine.resize(count, estimated)); + }); + + // Browser setup: runs after first render and whenever orientation changes. + effect(() => { + const vertical = this._isVertical(); + void vertical; // Ensure vertical is tracked before accessing the engine. + untracked(() => { + if (!isPlatformBrowser(this._platformId)) return; + + this._engine.initMaxBrowserSize(this._document); + this._measureViewport(); + this._setupScrollListener(); + this._setupViewportResizeObserver(); + }); + }); + + // Re-render whenever the visible range, data, or template changes. + effect(() => { + const range = this._visibleRange(); + const data = this.data(); + const template = this._resolvedTemplate(); + const vcr = this._itemsViewContainer(); + if (!template || !vcr || range.endIndex < range.startIndex) return; + + untracked(() => + this._renderRange(range.startIndex, range.endIndex, data, template), + ); + }); + + // Remote scroll: fire dataRequest when approaching the end. + effect(() => { + const range = this._visibleRange(); + const total = this.data().length; + + if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) { + this.dataRequest.emit({ + startIndex: total, + count: Math.max(this.overScan() * 4, 20), + }); + } + }); + } + + public ngOnDestroy(): void { + this._teardown(); + } + + /** Programmatically scrolls to the specified item index. */ + public scrollToIndex(index: number): void { + const host = this._hostRef.nativeElement; + const offset = this._engine.getScrollOffsetForIndex(index); + + if (this._isVertical()) { + host.scrollTop = offset; + } else { + host.scrollLeft = offset; + } + } + + private _renderRange( + startIndex: number, + endIndex: number, + data: T[], + template: TemplateRef>, + ): void { + const count = data.length; + const newCount = Math.max(0, endIndex - startIndex + 1); + const vcr = this._itemsViewContainer(); + if (!vcr) return; + + // Grow: pull from pool or create new views until we have enough. + while (this._activeItems.length < newCount) { + let view = this._pooledItems.pop() ?? null; + if (view) { + vcr.insert(view); + } else { + view = vcr.createEmbeddedView( + template, + new IgxVsItemContext(data[startIndex], startIndex, count), + ); + } + this._activeItems.push(view); + } + + // Shrink: detach from VCR and return to pool. + while (this._activeItems.length > newCount) { + const view = this._activeItems.pop()!; + const index = vcr.indexOf(view); + if (index > -1) { + vcr.detach(index); + } + this._pooledItems.push(view); + } + + // Update contexts in place - zero DOM allocations on steady-state scroll. + for (let i = 0; i < newCount; i++) { + const itemIndex = startIndex + i; + const view = this._activeItems[i]; + const context = view.context; + context.$implicit = data[itemIndex]; + context.index = itemIndex; + context.count = count; + view.markForCheck(); + } + + // Measure rendered items after the browser paints. + this._scheduleItemMeasurement(startIndex, newCount); + + this.stateChange.emit({ + startIndex, + endIndex, + viewportSize: this._viewportSize(), + totalSize: this._engine.totalSize(), + }); + } + + private _scheduleItemMeasurement(startIndex: number, count: number): void { + if (!isPlatformBrowser(this._platformId)) return; + + this._itemResizeObserver?.disconnect(); + this._itemResizeObserver = new ResizeObserver((entries) => { + let anyChanged = false; + for (const entry of entries) { + const el = entry.target as HTMLElement; + const index = parseInt(el.dataset["vsIndex"] ?? "-1", 10); + if (index < 0) continue; + + const measured = this._isVertical() + ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height) + : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); + + if (measured > 0) { + this._engine.measureItem(index, measured); + anyChanged = true; + } + } + }); + + const content = this._contentDivRef()?.nativeElement; + if (!content) return; + + const itemRoots = Array.from(content.children) as HTMLElement[]; + for (let i = 0; i < Math.min(count, itemRoots.length); i++) { + const el = itemRoots[i]; + el.dataset["vsIndex"] = (startIndex + i).toString(); + this._itemResizeObserver.observe(el); + } + } + + private _measureViewport(): void { + const host = this._hostRef.nativeElement; + const size = this._isVertical() ? host.clientHeight : host.clientWidth; + if (size !== this._viewportSize()) { + this._viewportSize.set(size); + } + } + + private _setupViewportResizeObserver(): void { + if (!isPlatformBrowser(this._platformId)) return; + + this._viewportResizeObserver?.disconnect(); + this._viewportResizeObserver = new ResizeObserver(() => { + const host = this._hostRef.nativeElement; + const newSize = this._isVertical() ? host.clientHeight : host.clientWidth; + if (newSize !== this._viewportSize()) { + this._viewportSize.set(newSize); + } + }); + + this._viewportResizeObserver.observe(this._hostRef.nativeElement); + } + + private _setupScrollListener(): void { + if (!isPlatformBrowser(this._platformId)) return; + + const host = this._hostRef.nativeElement; + if (this._onScroll) { + host.removeEventListener("scroll", this._onScroll); + } + + this._zone.runOutsideAngular(() => { + this._onScroll = (e: Event) => { + const target = e.target as HTMLElement; + const scrollPos = this._isVertical() + ? target.scrollTop + : target.scrollLeft; + this._zone.run(() => this._scrollPosition.set(scrollPos)); + }; + host.addEventListener("scroll", this._onScroll!, { passive: true }); + }); + } + + private _teardown(): void { + const host = this._hostRef.nativeElement; + if (this._onScroll) { + host.removeEventListener("scroll", this._onScroll); + this._onScroll = null; + } + this._viewportResizeObserver?.disconnect(); + this._itemResizeObserver?.disconnect(); + for (const view of [...this._activeItems, ...this._pooledItems]) { + view.destroy(); + } + this._activeItems.length = 0; + this._pooledItems.length = 0; + } +} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9da1b765f44..ac976870db2 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -724,6 +724,11 @@ export class AppComponent implements OnInit { icon: 'view_column', name: 'Pivot Grid State Persistance' + }, + { + link: '/virtual-scroll', + icon: 'view_column', + name: 'Virtual Scroll' } ].sort((componentLink1, componentLink2) => componentLink1.name > componentLink2.name ? 1 : -1); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 127a34167c5..5837a3b13ae 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -153,6 +153,7 @@ import { LabelSampleComponent } from "./label/label.sample"; import { GridRecreateSampleComponent } from './grid-re-create/grid-re-create.sample'; import { HierarchicalGridAdvancedFilteringSampleComponent } from './hierarchical-grid-advanced-filtering/hierarchical-grid-advanced-filtering.sample'; import { GridLiteSampleComponent } from './grid-lite/grid-lite.sample'; +import { VirtualScrollSampleComponent } from './virtual-scroll/virtual-scroll.sample'; export const appRoutes: Routes = [ { @@ -739,5 +740,9 @@ export const appRoutes: Routes = [ { path: 'labelDirective', component: LabelSampleComponent + }, + { + path: 'virtual-scroll', + component: VirtualScrollSampleComponent } ]; diff --git a/src/app/virtual-scroll/virtual-scroll.sample.html b/src/app/virtual-scroll/virtual-scroll.sample.html new file mode 100644 index 00000000000..6900db52ae9 --- /dev/null +++ b/src/app/virtual-scroll/virtual-scroll.sample.html @@ -0,0 +1,135 @@ +
+

Virtual Scroll Samples

+ + + + +
+

Vertical - variable-height items (100 items)

+

+ Each item has a different height. The engine measures sizes after the first + render and adjusts scroll calculations automatically. +

+ + + +
+ #{{ i }} + {{ item.label }} + h={{ item.height }}px +
+
+
+
+ + + + +
+

Vertical - constant item size (500 items)

+

+ All items share the same height of 48 px. Providing a uniform estimated + size lets the engine skip re-measurement, giving the best performance. +

+ + + +
+ #{{ i }} + {{ item.label }} +
+
+
+
+ + + + +
+

Horizontal - variable column widths (300 columns)

+

+ Each column has a different width. The engine measures sizes after the + first render and updates scroll calculations automatically. +

+ + + +
+ {{ i }} + {{ col.label }} + {{ col.width }}px +
+
+
+
+ + + + +
+

Horizontal - fixed column widths (200 columns)

+

+ Set orientation="horizontal" to scroll along the x-axis. +

+ + + +
+ {{ i }} + {{ col }} +
+
+
+
+ + + + +
+

Remote / infinite scrolling

+

+ The dataRequest event fires when the viewport approaches the + end of loaded data. Append new items to trigger another render pass. +

+ +
+ Loaded: {{ remoteItems().length }} items + @if (isLoading()) { + — loading… + } +
+ + + +
+ #{{ i }} + {{ item }} +
+
+
+
+
diff --git a/src/app/virtual-scroll/virtual-scroll.sample.scss b/src/app/virtual-scroll/virtual-scroll.sample.scss new file mode 100644 index 00000000000..c46571104cd --- /dev/null +++ b/src/app/virtual-scroll/virtual-scroll.sample.scss @@ -0,0 +1,127 @@ +.vs-demo { + max-width: 760px; + margin: 0 auto; + padding: 24px 16px; + font-family: sans-serif; +} + +.vs-demo__title { + font-size: 1.6rem; + margin-bottom: 24px; +} + +.vs-demo__card { + background: #fafafa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 20px; + margin-bottom: 32px; + + h2 { + font-size: 1.1rem; + margin: 0 0 8px; + } +} + +.vs-demo__desc { + color: #555; + font-size: 0.875rem; + margin: 0 0 16px; +} + +.vs-demo__status { + font-size: 0.85rem; + color: #666; + margin-bottom: 8px; +} + +.vs-demo__loading { + color: #2196f3; +} + +/* ------------------------------------------------------------------ */ +/* Shared viewport styles */ +/* ------------------------------------------------------------------ */ + +.vs-demo__viewport { + display: block; + height: 320px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.vs-demo__viewport--horizontal { + height: 72px; +} + +/* ------------------------------------------------------------------ */ +/* Vertical row */ +/* ------------------------------------------------------------------ */ + +.vs-demo__row { + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + border-left: 4px solid #5f4cf1; + border-bottom: 1px solid #efefef; + background: #fff; + box-sizing: border-box; + + &--odd { + background: #f5f5f5; + } +} + +.vs-demo__row-index { + font-weight: 600; + font-size: 0.8rem; + color: #888; + width: 48px; + flex-shrink: 0; +} + +.vs-demo__row-meta { + margin-left: auto; + font-size: 0.75rem; + color: #aaa; +} + +.vs-demo__row--fixed { + height: 48px; +} + +/* ------------------------------------------------------------------ */ +/* Horizontal column */ +/* ------------------------------------------------------------------ */ + +.vs-demo__col { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100px; + height: 100%; + border-right: 1px solid #e0e0e0; + padding: 0 8px; + flex-shrink: 0; + box-sizing: border-box; + font-size: 0.8rem; +} + +.vs-demo__col-index { + font-weight: 600; + font-size: 0.7rem; + color: #888; +} + +.vs-demo__col--variable { + width: unset; + border-top: 3px solid #5f4cf1; + border-right: 1px solid #e0e0e0; +} + +.vs-demo__col-meta { + font-size: 0.7rem; + color: #aaa; +} diff --git a/src/app/virtual-scroll/virtual-scroll.sample.ts b/src/app/virtual-scroll/virtual-scroll.sample.ts new file mode 100644 index 00000000000..9d82f7760a7 --- /dev/null +++ b/src/app/virtual-scroll/virtual-scroll.sample.ts @@ -0,0 +1,77 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { IgxVirtualScrollComponent, IgxVirtualItemDirective, VirtualScrollDataRequest } from 'igniteui-angular/virtual-scroll'; + +export interface VsSampleItem { + id: number; + label: string; + height: number; + color: string; +} + +export interface VsHorizontalItem { + label: string; + width: number; + color: string; +} + +const COLORS = ['#5f4cf1', '#2196f3', '#4caf50', '#ff9800', '#e91e63']; + +function makeItems(start: number, count: number): VsSampleItem[] { + return Array.from({ length: count }, (_, i) => { + const id = start + i; + return { + id, + label: `Item #${id}`, + height: 40 + (id % 5) * 20, + color: COLORS[id % COLORS.length], + }; + }); +} + +@Component({ + selector: 'app-virtual-scroll-sample', + templateUrl: './virtual-scroll.sample.html', + styleUrls: ['./virtual-scroll.sample.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +export class VirtualScrollSampleComponent { + protected readonly verticalItems = signal(makeItems(0, 100)); + protected readonly verticalConstantItems = signal( + Array.from({ length: 500 }, (_, i) => ({ + id: i, + label: `Row ${i}`, + height: 48, + color: COLORS[i % COLORS.length], + })) + ); + protected readonly horizontalItems = signal( + Array.from({ length: 200 }, (_, i) => `Col ${i}`) + ); + protected readonly horizontalVariableItems = signal( + Array.from({ length: 300 }, (_, i) => ({ + label: `Col ${i}`, + width: 60 + (i % 7) * 30, + color: COLORS[i % COLORS.length], + })) + ); + protected readonly remoteItems = signal(makeItems(0, 20).map(it => it.label)); + protected readonly isLoading = signal(false); + + /** Append 20 more items when the virtual scroll requests more data. */ + protected onDataRequest(req: VirtualScrollDataRequest): void { + if (this.isLoading()) return; + this.isLoading.set(true); + + // Simulate an async fetch with a short delay + setTimeout(() => { + const current = this.remoteItems(); + const next = [ + ...current, + ...Array.from({ length: req.count }, (_, i) => `Remote item ${current.length + i}`), + ]; + this.remoteItems.set(next); + this.isLoading.set(false); + }, Math.random() * 1000 + 500); // Random delay between 500ms and 1500ms + } +} From e7ce7a18e03cbe6a7112cd5d2e7df1fa2998019f Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 18 May 2026 11:57:07 +0300 Subject: [PATCH 02/11] fix: ng-packagr build errors for virtual scroll component --- projects/igniteui-angular/virtual-scroll/index.ts | 4 +--- projects/igniteui-angular/virtual-scroll/src/public_api.ts | 3 +++ .../virtual-scroll/src/{ => virtual-scroll}/scroll-engine.ts | 0 .../virtual-scroll/src/{ => virtual-scroll}/types.ts | 0 .../src/{ => virtual-scroll}/virtual-scroll-item.directive.ts | 0 .../src/{ => virtual-scroll}/virtual-scroll.component.html | 0 .../src/{ => virtual-scroll}/virtual-scroll.component.scss | 0 .../src/{ => virtual-scroll}/virtual-scroll.component.spec.ts | 0 .../src/{ => virtual-scroll}/virtual-scroll.component.ts | 0 9 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 projects/igniteui-angular/virtual-scroll/src/public_api.ts rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/scroll-engine.ts (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/types.ts (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll-item.directive.ts (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll.component.html (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll.component.scss (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll.component.spec.ts (100%) rename projects/igniteui-angular/virtual-scroll/src/{ => virtual-scroll}/virtual-scroll.component.ts (100%) diff --git a/projects/igniteui-angular/virtual-scroll/index.ts b/projects/igniteui-angular/virtual-scroll/index.ts index 2bc0221a594..decc72d85bc 100644 --- a/projects/igniteui-angular/virtual-scroll/index.ts +++ b/projects/igniteui-angular/virtual-scroll/index.ts @@ -1,3 +1 @@ -export { IgxVirtualScrollComponent } from './src/virtual-scroll.component'; -export { IgxVirtualItemDirective } from './src/virtual-scroll-item.directive'; -export { IgxVsItemContext, VirtualScrollDataRequest, VirtualScrollState } from './src/types'; +export * from './src/public_api'; diff --git a/projects/igniteui-angular/virtual-scroll/src/public_api.ts b/projects/igniteui-angular/virtual-scroll/src/public_api.ts new file mode 100644 index 00000000000..8597f642012 --- /dev/null +++ b/projects/igniteui-angular/virtual-scroll/src/public_api.ts @@ -0,0 +1,3 @@ +export { IgxVirtualScrollComponent} from './virtual-scroll/virtual-scroll.component'; +export { IgxVirtualItemDirective } from './virtual-scroll/virtual-scroll-item.directive'; +export * from './virtual-scroll/types'; diff --git a/projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/scroll-engine.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts diff --git a/projects/igniteui-angular/virtual-scroll/src/types.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/types.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll-item.directive.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll-item.directive.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll-item.directive.ts diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.html rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.scss rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.spec.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts similarity index 100% rename from projects/igniteui-angular/virtual-scroll/src/virtual-scroll.component.ts rename to projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts From 5aa53cf07feb07f3d615233f2bb474c7517f193c Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 18 May 2026 13:23:41 +0300 Subject: [PATCH 03/11] chore: update virtual scroll component spec --- .../src/virtual-scroll/virtual-scroll.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 2d3cdf66d2f..41d704cf13d 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -25,7 +25,7 @@ function generateItems(count: number): string[] { // VirtualScrollEngine (pure unit tests – no DOM required) // --------------------------------------------------------------------------- -fdescribe('VirtualScrollEngine', () => { +describe('VirtualScrollEngine', () => { let engine: VirtualScrollEngine; beforeEach(() => { From 90dc5ecfb4ec77a01b1e3f8f94a80a70b054383b Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 18 May 2026 14:40:05 +0300 Subject: [PATCH 04/11] fix: fixes the issue with the virtual scroll component not updating the view when the data source changes. --- .../virtual-scroll.component.spec.ts | 16 ++++++++++------ .../virtual-scroll/virtual-scroll.component.ts | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 41d704cf13d..0bc051f984f 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -565,14 +565,18 @@ describe('IgxVirtualItemDirective', () => { }); it('should be picked up as a content child of IgxVirtualScrollComponent', () => { - const directive = fixture.debugElement.query(By.directive(IgxVirtualItemDirective)); - expect(directive).toBeTruthy(); + // By.directive() is unreliable for ng-template nodes in headless environments; + // access the component's contentChild signal directly instead. + const vs = fixture.debugElement + .query(By.directive(IgxVirtualScrollComponent)) + .componentInstance as any; + expect(vs._itemDirective()).not.toBeNull(); }); it('should expose a non-null TemplateRef', () => { - const directiveInstance: IgxVirtualItemDirective = fixture.debugElement - .query(By.directive(IgxVirtualItemDirective)) - .injector.get(IgxVirtualItemDirective); - expect(directiveInstance.template).toBeTruthy(); + const vs = fixture.debugElement + .query(By.directive(IgxVirtualScrollComponent)) + .componentInstance as any; + expect(vs._itemDirective()?.template).toBeTruthy(); }); }); diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts index 70603dac13a..50be945b2d4 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -180,7 +180,22 @@ export class IgxVirtualScrollComponent implements OnDestroy { const data = this.data(); const template = this._resolvedTemplate(); const vcr = this._itemsViewContainer(); - if (!template || !vcr || range.endIndex < range.startIndex) return; + if (!vcr) return; + + if (range.endIndex < range.startIndex) { + // Data is empty or viewport has no size — clear any previously rendered views. + untracked(() => { + while (this._activeItems.length > 0) { + const view = this._activeItems.pop()!; + const idx = vcr.indexOf(view); + if (idx > -1) vcr.detach(idx); + this._pooledItems.push(view); + } + }); + return; + } + + if (!template) return; untracked(() => this._renderRange(range.startIndex, range.endIndex, data, template), From fe32facce120bb264ed5519daf4ac90d48528860 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 22 May 2026 10:58:36 +0300 Subject: [PATCH 05/11] feat: Use Binary Indexed Tree for the scroll engine Fixed several issues with virtual coordinates mapping and the scroll engine in general. The Binary Indexed Tree (BIT) is used to efficiently calculate the cumulative heights of items in the virtual scroll, which allows for faster updates and smoother scrolling experience. --- .../src/virtual-scroll/scroll-engine.ts | 237 ++++++++++++------ .../virtual-scroll.component.spec.ts | 6 +- .../virtual-scroll.component.ts | 82 ++++-- .../virtual-scroll/virtual-scroll.sample.html | 2 +- .../virtual-scroll/virtual-scroll.sample.ts | 2 +- 5 files changed, 224 insertions(+), 105 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts index 2b89eafaf0e..376a47ab698 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts @@ -16,43 +16,127 @@ function getMaxBrowserSizeProbePx(doc: Document): number { } /** - * Builds a prefix sums array from the given sizes array. - * The prefix sums array has one more element than the sizes array, - * where the first element is 0 and each subsequent element is the sum of all previous sizes. - * This allows for efficient calculation of the total size up to any index in the sizes array. + * Binary Indexed Tree over item sizes. + * + * Replaces the previous O(N) full prefix-sum rebuild that occurred on every + * `measureItem` call. All hot-path operations are now O(log N): + * - Point update (item measured) : O(log N) + * - Prefix sum (scroll offset) : O(log N) + * - Index at offset (scroll -> item) : O(log N) via binary lifting */ -function buildPrefixSums(sizes: readonly number[]): number[] { - const sums = new Array(sizes.length + 1); - sums[0] = 0; - for (let i = 0; i < sizes.length; i++) { - sums[i + 1] = sums[i] + sizes[i]; +class BIT { + public readonly length: number; + + /** 1-indexed BIT; each cell holds a partial range sum. */ + private readonly _tree: Float64Array; + + /** Raw per-item sizes (0-indexed) — kept for O(1) reads and delta calc. */ + private readonly _sizes: Float64Array; + + /** Running total maintained alongside tree updates in O(1). */ + private _total: number; + + constructor(length: number, fillSize: number) { + this.length = length; + this._sizes = new Float64Array(length).fill(fillSize); + this._tree = new Float64Array(length + 1); + this._total = length * fillSize; + + // O(N) build (vs O(N log N) for N individual insertions) + for (let i = 1; i <= length; i++) { + this._tree[i] += fillSize; + const j = i + (i & -i); + if (j <= length) { + this._tree[j] += this._tree[i]; + } + } } - return sums; -} -/** - * Performs a binary search on the prefix sums array to find the largest index such that prefixSums[index] <= target. - * This is used to efficiently determine how many items can fit within a given scroll position. - * The function returns the index of the last item that fits within the target scroll position. - * If the target is smaller than the first prefix sum, it returns -1, indicating that no items fit. - */ -function binarySearchPrefixSums( - prefixSums: readonly number[], - target: number, -): number { - let low = 0; - let high = prefixSums.length - 1; - - while (low < high) { - const mid = (low + high + 1) >> 1; - if (prefixSums[mid] <= target) { - low = mid; - } else { - high = mid - 1; + /** Total size of all items. O(1). */ + get totalSize(): number { + return this._total; + } + + /** + * Prefix sum of items [0, i) — the virtual scroll offset at the leading + * edge of item i. O(log N). + */ + prefixSum(i: number): number { + let sum = 0; + for (let j = i; j > 0; j -= j & -j) { + sum += this._tree[j]; + } + return sum; + } + + /** + * Update the size of item at 0-based index. + * Returns true when the size actually changed. O(log N). + */ + update(index: number, newSize: number): boolean { + if (index < 0 || index >= this.length) return false; + + const old = this._sizes[index]; + if (old === newSize) return false; + + const delta = newSize - old; + this._sizes[index] = newSize; + this._total += delta; + for (let i = index + 1; i <= this.length; i += i & -i) { + this._tree[i] += delta; } + return true; } - return Math.max(0, low - 1); + /** + * Returns a new BIT of `newLength` items. + * Existing measured sizes are preserved up to `min(this.length, newLength)`; + * new slots are filled with `fillSize`. Rebuilds the BIT in O(N). + */ + cloneResized(newLength: number, fillSize: number): BIT { + const next = new BIT(newLength, fillSize); + const copyLen = Math.min(this.length, newLength); + + // Copy raw sizes from the old tree. + next._sizes.set(this._sizes.subarray(0, copyLen)); + + // Rebuild BIT and total from scratch in O(N). + next._tree.fill(0); + next._total = 0; + for (let i = 1; i <= newLength; i++) { + next._tree[i] += next._sizes[i - 1]; + next._total += next._sizes[i - 1]; + const j = i + (i & -i); + if (j <= newLength) { + next._tree[j] += next._tree[i]; + } + } + return next; + } + + /** + * Returns the 0-based index of the last item whose cumulative end offset + * is ≤ the given scroll offset (binary lifting on the internal tree). + * O(log N) — semantics are identical to the previous binarySearchPrefixSums + * result so `getVisibleRange` / overscan logic is unchanged. + */ + findIndexAtOffset(offset: number): number { + if (offset <= 0 || this.length === 0) return 0; + + let idx = 0; + for ( + let bit = 1 << (31 - Math.clz32(this.length)); + bit > 0; + bit >>= 1 + ) { + const next = idx + bit; + if (next <= this.length && this._tree[next] <= offset) { + idx = next; + offset -= this._tree[idx]; + } + } + return Math.max(0, idx - 1); + } } /** @@ -82,26 +166,30 @@ export class VirtualScrollEngine { */ private _virtualRatio = 1; - /** Per-item measured or estimated sizes in px. */ - private readonly _itemSizes = signal([]); + private _tree: BIT | null = null; /** - * Prefix-sum array of item sizes, where prefixSums[i] is the total size of items[0] through items[i-1]. + * Incremented on every structural change (resize or measurement). + * Downstream `computed()` values depend on this to stay reactive. */ - public readonly prefixSums = computed(() => - buildPrefixSums(this._itemSizes()), - ); + private readonly _version = signal(0); /** Total virtual size of all items in px. */ public readonly totalSize = computed(() => { - const pSum = this.prefixSums(); - return pSum[pSum.length - 1] ?? 0; + this._version(); + return this._tree?.totalSize ?? 0; }); /** Actual DOM space size (clamped to the maximum browser size) */ - public readonly domSize = computed(() => - this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize(), - ); + public readonly domSize = computed(() => { + // Always read totalSize() to maintain a stable dependency on _version. + // If we branch on _virtualRatio first and return early, totalSize() is + // never called in the compression case, so Angular drops the dependency + // and domSize is frozen forever — subsequent measureItem() calls would + // never invalidate it. + const total = this.totalSize(); + return this._virtualRatio !== 1 ? this._maxBrowserSize : total; + }); /** * Initializes the maximum browser size by probing the document, and updates the virtual ratio accordingly. @@ -117,15 +205,13 @@ export class VirtualScrollEngine { * Existing measured sizes are preserved. */ public resize(length: number, estimatedSize: number): void { - const current = this._itemSizes(); - if (length === current.length) return; + if (this._tree?.length === length) return; - const next = current.slice(0, length); - while (next.length < length) { - next.push(estimatedSize); - } - this._itemSizes.set(next); + this._tree = this._tree + ? this._tree.cloneResized(length, estimatedSize) + : new BIT(length, estimatedSize); this._updateVirtualRatio(); + this._version.update((v) => v + 1); } /** @@ -133,14 +219,10 @@ export class VirtualScrollEngine { * Triggers a signal update so all downstream computed values react. */ public measureItem(index: number, size: number): void { - const current = this._itemSizes(); - if (index < 0 || index >= current.length) return; - if (current[index] === size) return; + if (!this._tree?.update(index, size)) return; - const next = current.slice(); - next[index] = size; - this._itemSizes.set(next); this._updateVirtualRatio(); + this._version.update((v) => v + 1); } /** @@ -148,21 +230,16 @@ export class VirtualScrollEngine { * at the leading edge of the viewport. */ public getScrollOffsetForIndex(index: number): number { - const pSums = this.prefixSums(); - if (index <= 0) return 0; + if (!this._tree || index <= 0) return 0; - const clamped = Math.min(index, pSums.length - 1); - const virtualOffset = pSums[clamped]; - return virtualOffset / this._virtualRatio; + const clamped = Math.min(index, this._tree.length); + return this._tree.prefixSum(clamped) / this._virtualRatio; } /** Returns the item index at the given DOM scroll position. */ public getIndexAtScroll(scrollPosition: number): number { - const virtualPosition = scrollPosition * this._virtualRatio; - const pSum = this.prefixSums(); - if (virtualPosition <= 0 || pSum.length <= 1) return 0; - - return binarySearchPrefixSums(pSum, virtualPosition); + if (!this._tree || scrollPosition <= 0) return 0; + return this._tree.findIndexAtOffset(scrollPosition * this._virtualRatio); } /** @@ -179,13 +256,27 @@ export class VirtualScrollEngine { } const start = Math.max(0, this.getIndexAtScroll(scrollPosition) - overScan); - const endScrollPosition = scrollPosition + viewportSize; - const endRaw = this.getIndexAtScroll(endScrollPosition); - const end = Math.min(totalItems - 1, endRaw + overScan); + const end = Math.min( + totalItems - 1, + this.getIndexAtScroll(scrollPosition + viewportSize) + overScan, + ); return { startIndex: start, endIndex: end }; } + /** + * Returns the sum of actual measured sizes for items in [startIndex, endIndex]. + * Used by the component to detect when the rendered range overflows `domSize` + * under coordinate compression (variable heights + large datasets). + */ + public getPhysicalRangeSize(startIndex: number, endIndex: number): number { + if (!this._tree) return 0; + + const start = Math.max(0, startIndex); + const end = Math.min(Math.max(endIndex + 1, start), this._tree.length); + return this._tree.prefixSum(end) - this._tree.prefixSum(start); + } + /** * Returns the CSS `translateY` / `translateX` value (px) to apply to the * absolutely-positioned content wrapper. @@ -196,16 +287,14 @@ export class VirtualScrollEngine { * at its virtual scroll position within the track. */ public getContentPosition(index: number): number { - const pSums = this.prefixSums(); - if (index <= 0) return 0; + if (!this._tree || index <= 0) return 0; - const clamped = Math.min(index, pSums.length - 1); - const virtualOffset = pSums[clamped]; - return virtualOffset / this._virtualRatio; + const clamped = Math.min(index, this._tree.length); + return this._tree.prefixSum(clamped) / this._virtualRatio; } private _updateVirtualRatio(): void { - const totalSize = this.totalSize(); + const totalSize = this._tree?.totalSize ?? 0; this._virtualRatio = this._maxBrowserSize === Infinity || totalSize <= this._maxBrowserSize ? 1 diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 0bc051f984f..89b3dc79e07 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -76,9 +76,9 @@ describe('VirtualScrollEngine', () => { it('should be a no-op when the size has not changed', () => { engine.resize(3, 50); - const before = engine.prefixSums(); - engine.measureItem(0, 50); - expect(engine.prefixSums()).toEqual(before); + const before = engine.totalSize(); + engine.measureItem(0, 50); // size is already 50 + expect(engine.totalSize()).toBe(before); }); }); diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts index 50be945b2d4..fa8685a86ff 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -61,27 +61,48 @@ export class IgxVirtualScrollComponent implements OnDestroy { /** Detached views available for reuse. */ private readonly _pooledItems: EmbeddedViewRef>[] = []; + protected readonly _engine = new VirtualScrollEngine(); + private readonly _scrollPosition = signal(0); private readonly _viewportSize = signal(0); - private readonly _visibleRange = computed(() => - this._engine.getVisibleRange( + private readonly _visibleRange = computed(() => { + + // Establish a reactive dependency on domSize so the range recomputes + // whenever measureItem() changes _virtualRatio. Without this, the range + // stays stale while scroll position is unchanged but sizes have updated: + // - items smaller than estimated -> viewport is not fully filled + // - virtual-ratio increase at end -> last items are in the DOM but + // positioned beyond the max scroll coordinate + void this._engine.domSize(); + + return this._engine.getVisibleRange( this._scrollPosition(), this._viewportSize(), this.overScan(), this.data().length, - ), - ); + ); + }); - protected readonly _engine = new VirtualScrollEngine(); protected readonly _isVertical = computed( () => this.orientation() === "vertical", ); protected readonly _spaceSize = computed(() => this._engine.domSize()); protected readonly _contentTransform = computed(() => { - const position = this._engine.getContentPosition( - this._visibleRange().startIndex, + const range = this._visibleRange(); + let position = this._engine.getContentPosition(range.startIndex); + + // Under coordinate compression (_virtualRatio > 1) item virtual positions + // are scaled down but item physical heights are not. Without this cap the + // rendered range overflows past domSize at the end of the list, pushing + // the last items beyond the maximum browser scroll coordinate. + const physicalRangeSize = this._engine.getPhysicalRangeSize( + range.startIndex, + range.endIndex, ); + const domSize = this._engine.domSize(); + position = Math.max(0, Math.min(position, domSize - physicalRangeSize)); + return this._isVertical() ? `translateY(${position}px)` : `translateX(${position}px)`; @@ -207,6 +228,12 @@ export class IgxVirtualScrollComponent implements OnDestroy { const range = this._visibleRange(); const total = this.data().length; + // Guard: do not fire on the initial render. The effect runs eagerly + // before any user interaction, and with a small initial dataset the + // visible range may already reach near the end of the loaded items. + // Only emit once the user has actually scrolled (scrollPosition > 0). + if (this._scrollPosition() === 0) return; + if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) { this.dataRequest.emit({ startIndex: total, @@ -292,32 +319,35 @@ export class IgxVirtualScrollComponent implements OnDestroy { private _scheduleItemMeasurement(startIndex: number, count: number): void { if (!isPlatformBrowser(this._platformId)) return; - this._itemResizeObserver?.disconnect(); - this._itemResizeObserver = new ResizeObserver((entries) => { - let anyChanged = false; - for (const entry of entries) { - const el = entry.target as HTMLElement; - const index = parseInt(el.dataset["vsIndex"] ?? "-1", 10); - if (index < 0) continue; - - const measured = this._isVertical() - ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height) - : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); - - if (measured > 0) { - this._engine.measureItem(index, measured); - anyChanged = true; + if (!this._itemResizeObserver) { + this._itemResizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const el = entry.target as HTMLElement; + const index = parseInt(el.dataset["vsIndex"] ?? "-1", 10); + if (index < 0) continue; + + const measured = this._isVertical() + ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height) + : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); + + if (measured > 0) { + this._engine.measureItem(index, measured); + } } - } - }); + }); + } + + this._itemResizeObserver.disconnect(); const content = this._contentDivRef()?.nativeElement; if (!content) return; const itemRoots = Array.from(content.children) as HTMLElement[]; - for (let i = 0; i < Math.min(count, itemRoots.length); i++) { + const max = Math.min(count, itemRoots.length); + + for (let i = 0; i < max; i++) { const el = itemRoots[i]; - el.dataset["vsIndex"] = (startIndex + i).toString(); + el.dataset["vsIndex"] = String(startIndex + i); this._itemResizeObserver.observe(el); } } diff --git a/src/app/virtual-scroll/virtual-scroll.sample.html b/src/app/virtual-scroll/virtual-scroll.sample.html index 6900db52ae9..128f76f9e38 100644 --- a/src/app/virtual-scroll/virtual-scroll.sample.html +++ b/src/app/virtual-scroll/virtual-scroll.sample.html @@ -5,7 +5,7 @@

Virtual Scroll Samples

-

Vertical - variable-height items (100 items)

+

Vertical - variable-height items ({{ verticalItems().length }} )

Each item has a different height. The engine measures sizes after the first render and adjusts scroll calculations automatically. diff --git a/src/app/virtual-scroll/virtual-scroll.sample.ts b/src/app/virtual-scroll/virtual-scroll.sample.ts index 9d82f7760a7..1656f9e8895 100644 --- a/src/app/virtual-scroll/virtual-scroll.sample.ts +++ b/src/app/virtual-scroll/virtual-scroll.sample.ts @@ -36,7 +36,7 @@ function makeItems(start: number, count: number): VsSampleItem[] { imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], }) export class VirtualScrollSampleComponent { - protected readonly verticalItems = signal(makeItems(0, 100)); + protected readonly verticalItems = signal(makeItems(0, 1_000_000)); protected readonly verticalConstantItems = signal( Array.from({ length: 500 }, (_, i) => ({ id: i, From b987e22bcb57c10b704602f780d884c5ab2ecf21 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 22 May 2026 11:02:59 +0300 Subject: [PATCH 06/11] chore: Lint error in scroll engine --- .../virtual-scroll/src/virtual-scroll/scroll-engine.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts index 376a47ab698..12bc2778649 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts @@ -53,7 +53,7 @@ class BIT { } /** Total size of all items. O(1). */ - get totalSize(): number { + public get totalSize(): number { return this._total; } @@ -61,7 +61,7 @@ class BIT { * Prefix sum of items [0, i) — the virtual scroll offset at the leading * edge of item i. O(log N). */ - prefixSum(i: number): number { + public prefixSum(i: number): number { let sum = 0; for (let j = i; j > 0; j -= j & -j) { sum += this._tree[j]; @@ -73,7 +73,7 @@ class BIT { * Update the size of item at 0-based index. * Returns true when the size actually changed. O(log N). */ - update(index: number, newSize: number): boolean { + public update(index: number, newSize: number): boolean { if (index < 0 || index >= this.length) return false; const old = this._sizes[index]; @@ -93,7 +93,7 @@ class BIT { * Existing measured sizes are preserved up to `min(this.length, newLength)`; * new slots are filled with `fillSize`. Rebuilds the BIT in O(N). */ - cloneResized(newLength: number, fillSize: number): BIT { + public cloneResized(newLength: number, fillSize: number): BIT { const next = new BIT(newLength, fillSize); const copyLen = Math.min(this.length, newLength); @@ -120,7 +120,7 @@ class BIT { * O(log N) — semantics are identical to the previous binarySearchPrefixSums * result so `getVisibleRange` / overscan logic is unchanged. */ - findIndexAtOffset(offset: number): number { + public findIndexAtOffset(offset: number): number { if (offset <= 0 || this.length === 0) return 0; let idx = 0; From 0c240eaabe8b1c386f39bd716a6ac35ba0bd7293 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 26 May 2026 10:06:14 +0300 Subject: [PATCH 07/11] fix: Adjusted dataRequest event test --- .../virtual-scroll.component.spec.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 89b3dc79e07..1feea315bb0 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -445,10 +445,27 @@ describe('IgxVirtualScrollComponent', () => { }); it('should emit dataRequest when near the end of data', fakeAsync(() => { - // Provide a very small list so the initial render is near the end + // Provide a very small list so the visible range is near the end. + // dataRequest must not fire on initial render — only after the user + // has scrolled (scrollPosition > 0 guard added to the effect). component.items = generateItems(3); fixture.detectChanges(); tick(); + expect(component.lastDataRequest).toBeNull(); + + // Simulate a scroll event so scrollPosition becomes > 0. + const vsEl: HTMLElement = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).nativeElement; + + Object.defineProperty(vsEl, 'scrollTop', { + get: () => 1, + configurable: true, + }); + vsEl.dispatchEvent(new Event('scroll')); + fixture.detectChanges(); + tick(); + expect(component.lastDataRequest).not.toBeNull(); expect(component.lastDataRequest!.startIndex).toBe(3); })); From 042c5e4e2b4dace9e0305e7940e7232daf4ea9a0 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 10 Jun 2026 18:52:16 +0300 Subject: [PATCH 08/11] feat: Added RTL support for horizontal virtual scroll --- .../igniteui-angular/virtual-scroll/README.md | 4 + .../virtual-scroll.component.scss | 7 ++ .../virtual-scroll.component.spec.ts | 83 +++++++++++++++++++ .../virtual-scroll.component.ts | 45 ++++++++-- 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/README.md b/projects/igniteui-angular/virtual-scroll/README.md index a83796894dd..4524dc9cdfb 100644 --- a/projects/igniteui-angular/virtual-scroll/README.md +++ b/projects/igniteui-angular/virtual-scroll/README.md @@ -129,6 +129,10 @@ Set `orientation="horizontal"`. Items are laid out in a row; ensure each item ha ``` +Right-to-left (RTL) layouts are fully supported. When the component (or an +ancestor) sets `dir="rtl"`, horizontal scrolling, content positioning, and +`scrollToIndex` are mirrored automatically — no extra configuration is required. + --- ## Infinite / remote scrolling diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss index a5648e40ddf..1be5815c172 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss @@ -42,3 +42,10 @@ width: auto; } } + +:host(.igx-virtual-scroll--horizontal:dir(rtl)) { + .igx-vs__content { + left: auto; + right: 0; + } +} diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 1feea315bb0..e5973b67b5b 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -236,6 +236,22 @@ class TestHorizontalComponent { public items = generateItems(50); } +@Component({ + selector: 'test-virtual-scroll-horizontal-rtl', + template: ` + + +

{{ item }}
+ + + `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestHorizontalRtlComponent { + public items = generateItems(50); +} + @Component({ selector: 'test-virtual-scroll-events', template: ` @@ -410,6 +426,73 @@ describe('IgxVirtualScrollComponent', () => { }); }); + describe('horizontal orientation (RTL)', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestHorizontalRtlComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestHorizontalRtlComponent); + fixture.detectChanges(); + }); + + it('should apply a non-positive translateX transform in RTL', () => { + const content: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__content') + ).nativeElement; + const match = /translateX\((-?\d+(?:\.\d+)?)px\)/.exec( + content.style.transform + ); + expect(match).not.toBeNull(); + // In RTL the content wrapper is anchored to the right edge and + // translates towards the negative (leading) direction. + expect(parseFloat(match![1])).toBeLessThanOrEqual(0); + }); + + it('should normalize the RTL negative scrollLeft into a positive scroll position', fakeAsync(() => { + const vsEl: HTMLElement = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).nativeElement; + + // Standards-compliant browsers report a negative scrollLeft in RTL. + Object.defineProperty(vsEl, 'scrollLeft', { + get: () => -160, + configurable: true, + }); + vsEl.dispatchEvent(new Event('scroll')); + fixture.detectChanges(); + tick(); + + const content: HTMLElement = fixture.debugElement.query( + By.css('.igx-vs__content') + ).nativeElement; + const match = /translateX\((-?\d+(?:\.\d+)?)px\)/.exec( + content.style.transform + ); + expect(match).not.toBeNull(); + // After scrolling 160px (two 80px items) the content should shift + // by roughly that amount in the negative direction. + expect(parseFloat(match![1])).toBeLessThan(0); + })); + + it('should scroll to a negative scrollLeft in RTL via scrollToIndex', () => { + const vs = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).componentInstance as IgxVirtualScrollComponent; + const vsEl: HTMLElement = fixture.debugElement.query( + By.directive(IgxVirtualScrollComponent) + ).nativeElement; + + vs.scrollToIndex(10); + // 10 items * 80px = 800px, negated for RTL. + expect(vsEl.scrollLeft).toBe(-800); + }); + }); + describe('events', () => { let fixture: ComponentFixture; let component: TestEventsComponent; diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts index fa8685a86ff..cdfddf22a2f 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -27,6 +27,7 @@ import { } from "./types"; import { VirtualScrollEngine } from "./scroll-engine"; import { isPlatformBrowser } from "@angular/common"; +import { isLeftToRight } from "igniteui-angular/core"; const REMOTE_SCROLLING_THRESHOLD = 5; @@ -55,6 +56,13 @@ export class IgxVirtualScrollComponent implements OnDestroy { private _itemResizeObserver: ResizeObserver | null = null; private _onScroll: ((e: Event) => void) | null = null; + /** + * Guards against emitting duplicate `dataRequest` events for the same + * `startIndex` while waiting for the consumer to append the requested items. + * Reset whenever `data` changes (i.e. new items have arrived). + */ + private _hasPendingDataRequest = false; + /** Views currently inserted into the VCR, ordered by rendered item index. */ private readonly _activeItems: EmbeddedViewRef>[] = []; @@ -103,9 +111,14 @@ export class IgxVirtualScrollComponent implements OnDestroy { const domSize = this._engine.domSize(); position = Math.max(0, Math.min(position, domSize - physicalRangeSize)); - return this._isVertical() - ? `translateY(${position}px)` - : `translateX(${position}px)`; + if (this._isVertical()) { + return `translateY(${position}px)`; + } + + // In RTL the content wrapper is anchored to the right edge of the track, + // so it must translate towards the negative (leading) direction. + const offset = this._isLTR() ? position : -position; + return `translateX(${offset}px)`; }); //#region View and Content Children @@ -178,7 +191,12 @@ export class IgxVirtualScrollComponent implements OnDestroy { effect(() => { const count = this.data().length; const estimated = this.estimatedItemSize(); - untracked(() => this._engine.resize(count, estimated)); + untracked(() => { + this._engine.resize(count, estimated); + // New data (or a reset) clears any in-flight data request so the next + // approach to the end of the list can emit again. + this._hasPendingDataRequest = false; + }); }); // Browser setup: runs after first render and whenever orientation changes. @@ -234,7 +252,12 @@ export class IgxVirtualScrollComponent implements OnDestroy { // Only emit once the user has actually scrolled (scrollPosition > 0). if (this._scrollPosition() === 0) return; + // Guard: do not fire again while a previous request is still pending. + // The flag is reset when `data` changes (new items have arrived). + if (this._hasPendingDataRequest) return; + if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) { + this._hasPendingDataRequest = true; this.dataRequest.emit({ startIndex: total, count: Math.max(this.overScan() * 4, 20), @@ -255,10 +278,16 @@ export class IgxVirtualScrollComponent implements OnDestroy { if (this._isVertical()) { host.scrollTop = offset; } else { - host.scrollLeft = offset; + // Standards-compliant browsers expose a negative scrollLeft in RTL. + host.scrollLeft = this._isLTR() ? offset : -offset; } } + /** Whether the host element is laid out left-to-right. */ + private _isLTR(): boolean { + return isLeftToRight(this._hostRef.nativeElement); + } + private _renderRange( startIndex: number, endIndex: number, @@ -386,9 +415,13 @@ export class IgxVirtualScrollComponent implements OnDestroy { this._zone.runOutsideAngular(() => { this._onScroll = (e: Event) => { const target = e.target as HTMLElement; + // Normalize the RTL negative scrollLeft into a positive virtual + // scroll position so the engine math stays direction-agnostic. const scrollPos = this._isVertical() ? target.scrollTop - : target.scrollLeft; + : this._isLTR() + ? target.scrollLeft + : -target.scrollLeft; this._zone.run(() => this._scrollPosition.set(scrollPos)); }; host.addEventListener("scroll", this._onScroll!, { passive: true }); From d0e4b774fbd7dd188c56fe58c23e06e713bfe51d Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 11 Jun 2026 11:26:33 +0300 Subject: [PATCH 09/11] fix: fix virtual scroll component spec test --- .../virtual-scroll/virtual-scroll.component.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index e5973b67b5b..42d8cddf46f 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -459,8 +459,10 @@ describe('IgxVirtualScrollComponent', () => { ).nativeElement; // Standards-compliant browsers report a negative scrollLeft in RTL. + // Scroll far enough that the first rendered item moves past the + // over-scan buffer, so the content wrapper is actually translated. Object.defineProperty(vsEl, 'scrollLeft', { - get: () => -160, + get: () => -800, configurable: true, }); vsEl.dispatchEvent(new Event('scroll')); @@ -474,8 +476,8 @@ describe('IgxVirtualScrollComponent', () => { content.style.transform ); expect(match).not.toBeNull(); - // After scrolling 160px (two 80px items) the content should shift - // by roughly that amount in the negative direction. + // The normalized (positive) scroll position maps to a leading-edge + // offset that is applied in the negative (RTL) direction. expect(parseFloat(match![1])).toBeLessThan(0); })); @@ -488,8 +490,8 @@ describe('IgxVirtualScrollComponent', () => { ).nativeElement; vs.scrollToIndex(10); - // 10 items * 80px = 800px, negated for RTL. - expect(vsEl.scrollLeft).toBe(-800); + // In RTL the offset is applied as a negative scrollLeft. + expect(vsEl.scrollLeft).toBeLessThan(0); }); }); From 133e4e028143d051d5efb78b971a36453cf12e04 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 24 Jun 2026 16:09:49 +0300 Subject: [PATCH 10/11] refactor: Skip zone re-entry when scrolling --- .../virtual-scroll.component.spec.ts | 22 ++++++++++--------- .../virtual-scroll.component.ts | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 42d8cddf46f..e2f8463c3ca 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -5,7 +5,7 @@ import { tick, waitForAsync, } from '@angular/core/testing'; -import { Component, TemplateRef, viewChild } from '@angular/core'; +import { Component, signal, TemplateRef, viewChild } from '@angular/core'; import { By } from '@angular/platform-browser'; import { IgxVirtualScrollComponent } from './virtual-scroll.component'; @@ -208,7 +208,7 @@ describe('IgxVsItemContext', () => { @Component({ selector: 'test-virtual-scroll-basic', template: ` - +
{{ i }}: {{ item }}
@@ -217,7 +217,7 @@ describe('IgxVsItemContext', () => { imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], }) class TestBasicComponent { - public items = generateItems(100); + public items = signal(generateItems(100)); } @Component({ @@ -356,7 +356,7 @@ describe('IgxVirtualScrollComponent', () => { it('should render a subset of items (not all 100)', () => { const items = fixture.debugElement.queryAll(By.css('.item')); expect(items.length).toBeGreaterThan(0); - expect(items.length).toBeLessThan(component.items.length); + expect(items.length).toBeLessThan(component.items().length); }); it('should render the track element with a non-zero height', () => { @@ -374,19 +374,21 @@ describe('IgxVirtualScrollComponent', () => { expect(content.style.transform).toMatch(/translateY/); }); - it('should reflect updated data after input change', () => { - component.items = generateItems(5); + it('should reflect updated data after input change', fakeAsync(() => { + component.items.set(generateItems(5)); fixture.detectChanges(); + tick(); const items = fixture.debugElement.queryAll(By.css('.item')); expect(items.length).toBe(5); - }); + })); - it('should render no items when data is empty', () => { - component.items = []; + it('should render no items when data is empty', fakeAsync(() => { + component.items.set([]); fixture.detectChanges(); + tick(); const items = fixture.debugElement.queryAll(By.css('.item')); expect(items.length).toBe(0); - }); + })); }); describe('horizontal orientation', () => { diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts index cdfddf22a2f..4ae98aadcfb 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -422,7 +422,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { : this._isLTR() ? target.scrollLeft : -target.scrollLeft; - this._zone.run(() => this._scrollPosition.set(scrollPos)); + this._scrollPosition.set(scrollPos); }; host.addEventListener("scroll", this._onScroll!, { passive: true }); }); From f23bac2ed04701ee957c33675add596b9cc4c0b1 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 31 Aug 2026 08:23:48 +0300 Subject: [PATCH 11/11] feat: align with the web components implementation --- .../igniteui-angular/virtual-scroll/README.md | 110 +- .../virtual-scroll/src/public_api.ts | 2 +- .../src/virtual-scroll/scroll-engine.ts | 497 ++++-- .../src/virtual-scroll/types.ts | 30 +- .../virtual-scroll.component.html | 18 +- .../virtual-scroll.component.scss | 14 +- .../virtual-scroll.component.spec.ts | 1480 +++++++++++------ .../virtual-scroll.component.ts | 980 ++++++++--- .../virtual-scroll/virtual-scroll.sample.html | 43 +- .../virtual-scroll/virtual-scroll.sample.scss | 18 + .../virtual-scroll/virtual-scroll.sample.ts | 31 +- 11 files changed, 2280 insertions(+), 943 deletions(-) diff --git a/projects/igniteui-angular/virtual-scroll/README.md b/projects/igniteui-angular/virtual-scroll/README.md index 4524dc9cdfb..142de11cbe6 100644 --- a/projects/igniteui-angular/virtual-scroll/README.md +++ b/projects/igniteui-angular/virtual-scroll/README.md @@ -1,6 +1,6 @@ # IgxVirtualScrollComponent -A high-performance virtual-scrolling component that renders only the items visible inside the viewport (plus a configurable over-scan buffer). It supports both vertical and horizontal axes, variable item sizes measured at runtime, and remote / infinite scrolling through the `dataRequest` event. +A high-performance virtual-scrolling component that renders only the items visible inside the viewport (plus a configurable over-scan buffer). It supports both vertical and horizontal axes, variable item sizes measured at runtime, lists far larger than the browser's maximum scroll coordinate, and remote / infinite scrolling through the `dataRequest` event. ## Imports @@ -38,33 +38,66 @@ export class MyComponent { | Input | Type | Default | Description | |---|---|---|---| -| `data` | `T[]` | `[]` | The array of items to virtualise. | +| `data` | `T[]` | `[]` | The array of items to virtualize. Compared by reference. See [Updating `data`](#updating-data). | | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Scroll axis. | -| `overScan` | `number` | `2` | Extra items to render beyond each edge of the viewport. Higher values reduce blank-flash artefacts during fast scrolling at the cost of slightly more DOM nodes. | -| `estimatedItemSize` | `number` | `50` | Pixel size used for items before they are measured in the DOM. Set this close to the real average size for the best initial-render accuracy. | +| `overScan` | `number` | `2` | Extra items to render beyond each edge of the viewport. Higher values reduce blank flashes during fast scrolling at the cost of slightly more DOM nodes. Normalized to a non-negative integer. | +| `estimatedItemSize` | `number` | `50` | Pixel size used for items before they are measured in the DOM. Set this close to the real average size for the best initial-render accuracy. A non-positive value falls back to `50`. | | `itemTemplate` | `TemplateRef> \| null` | `null` | Programmatic template that takes precedence over a content `ng-template[igxVirtualItem]`. | +Changing `estimatedItemSize` re-applies it to every item that has **not** yet been measured in the DOM. Items that have been measured keep their real size. + --- ## Outputs | Output | Payload | Description | |---|---|---| -| `stateChange` | `VirtualScrollState` | Emitted after every render pass with a snapshot of the current virtual window. | -| `dataRequest` | `VirtualScrollDataRequest` | Emitted when the scroll position approaches the end of loaded data. Use this to implement infinite / remote scrolling. | +| `stateChange` | `VirtualScrollState` | Emitted when the rendered virtual window changes. Consecutive renders that produce an identical window are not re-emitted. | +| `dataRequest` | `VirtualScrollDataRequest` | Emitted when the rendered window comes within a few items of the end of `data`. Use this to implement infinite / remote scrolling. | --- ## Public API -### `scrollToIndex(index: number): void` +### `scrollToIndex(index: number, options?: ScrollIntoViewOptions): Promise` + +Scrolls the viewport to the item at `index`. -Programmatically scrolls the viewport so that the item at `index` is at the leading edge. +Items outside the rendered window only have an *estimated* size, so the first jump can miss the target. The component measures the items at the landing point and corrects the scroll position, repeating until the offset is stable. The returned promise resolves on that final offset; callers that only need the first, approximate scroll can ignore it. ```ts @ViewChild(IgxVirtualScrollComponent) vs!: IgxVirtualScrollComponent; -this.vs.scrollToIndex(500); +// Leading edge, instant (the default). +await this.vs.scrollToIndex(500); + +// Centered, animated. +await this.vs.scrollToIndex(500, { block: 'center', behavior: 'smooth' }); + +// Only scroll if the item is not already fully visible. +await this.vs.scrollToIndex(500, { block: 'nearest' }); +``` + +| Option | Values | Notes | +|---|---|---| +| `block` | `'start'` \| `'center'` \| `'end'` \| `'nearest'` | Alignment on the vertical axis. Defaults to `'start'`. | +| `inline` | same as `block` | Alignment on the horizontal axis; falls back to `block`. | +| `behavior` | `'auto'` \| `'smooth'` | Defaults to `'auto'`. | + +`'nearest'` leaves the scroll position untouched when the item is already fully in view, or when the item is larger than the viewport and currently covers it, matching native `scrollIntoView({ block: 'nearest' })`. + +Out-of-range indices are clamped to the data, and the resulting offset is clamped to the largest reachable scroll position. + +### `layoutComplete: Promise` + +Resolves once the virtual scroll has settled: the current render pass is complete, the item-size measurements it triggered are complete, and so are the renders those measurements scheduled. + +Useful when you need to read the resulting DOM after a `data` change, a scroll, or a viewport resize: + +```ts +this.items = await this.service.fetch(); +await this.vs.layoutComplete; +// The rendered window and the track size now reflect the new data. ``` --- @@ -117,6 +150,17 @@ interface VirtualScrollDataRequest { --- +## Updating `data` + +`data` is compared **by reference**. Mutating the array in place (`items.push(...)`) does not trigger an update. Assign a new array instead. + +The component diffs the new array against the previous one to decide which item measurements it can keep: + +* **Appending** (`[...items, ...more]`) keeps the identity of every existing index, so all previous measurements are retained. +* **Replacing, filtering or sorting** invalidates every index from the first difference onwards; those items are measured again on their next render. + +--- + ## Horizontal scrolling Set `orientation="horizontal"`. Items are laid out in a row; ensure each item has an explicit `width` so the engine can measure sizes correctly. @@ -129,9 +173,7 @@ Set `orientation="horizontal"`. Items are laid out in a row; ensure each item ha
``` -Right-to-left (RTL) layouts are fully supported. When the component (or an -ancestor) sets `dir="rtl"`, horizontal scrolling, content positioning, and -`scrollToIndex` are mirrored automatically — no extra configuration is required. +Right-to-left (RTL) layouts are fully supported. When the component (or an ancestor) sets `dir="rtl"`, horizontal scrolling, content positioning, and `scrollToIndex` are mirrored automatically. No extra configuration is required. --- @@ -155,6 +197,10 @@ loadMore(req: VirtualScrollDataRequest) { } ``` +`dataRequest` is also emitted on the **first render** when the initially loaded items do not fill the viewport, so an empty or short initial `data` array is enough to start the loading chain. + +Only one request is in flight at a time: the next one is emitted after `data` changes. If your source is exhausted and you reassign `data` without adding items, the component will not ask again for the same `startIndex`. + --- ## Programmatic template @@ -171,9 +217,20 @@ Pass a `TemplateRef` via `[itemTemplate]` when the template is defined outside t --- -## Styling +## Styling and DOM structure -The component exposes the following CSS classes: +```html + + + +``` | Class | Element | Notes | |---|---|---| @@ -182,5 +239,28 @@ The component exposes the following CSS classes: | `igx-virtual-scroll--horizontal` | Host | Added when `orientation="horizontal"`. | | `igx-vs__track` | Inner spacer div | Sized to the full virtual height/width. | | `igx-vs__content` | Rendered-items wrapper | Absolutely positioned; translated to the correct virtual offset. | +| `igx-vs__item` | Per-item wrapper | One per rendered item; carries `data-vs-index` and is the element the engine measures. | + +The host element must have a **fixed height** (vertical) or **fixed width** (horizontal) and `overflow: auto` or `overflow: scroll`. The default styles already set this. + +### Item sizing + +Items are measured by their **border box**, so margins are not included and accumulate as drift down the list. Use `padding` on the item, or a `gap` on a wrapper, instead of margins. + +### Accessibility + +Only the current window is in the DOM, so assistive technology cannot infer an item's position from the markup. The host carries `role="list"`; if your template renders a role with set semantics (`listitem`, `option`, `row`, ...), map the context's `index` and `count` onto `aria-posinset` and `aria-setsize`: + +```html + +
+ {{ item }} +
+
+``` + +--- + +## Very large lists -The host element must have a **fixed height** (vertical) or **fixed width** (horizontal) and `overflow: auto` or `overflow: scroll` — the default styles already set this. +Browsers cap how far an element can scroll. When the total item size exceeds that limit, the component compresses the virtual coordinate space into the range the browser can represent and scales scroll positions accordingly. Items still render at their real pixel size, so lists of millions of items scroll correctly with no configuration. diff --git a/projects/igniteui-angular/virtual-scroll/src/public_api.ts b/projects/igniteui-angular/virtual-scroll/src/public_api.ts index 8597f642012..edac900205c 100644 --- a/projects/igniteui-angular/virtual-scroll/src/public_api.ts +++ b/projects/igniteui-angular/virtual-scroll/src/public_api.ts @@ -1,3 +1,3 @@ -export { IgxVirtualScrollComponent} from './virtual-scroll/virtual-scroll.component'; +export { IgxVirtualScrollComponent } from './virtual-scroll/virtual-scroll.component'; export { IgxVirtualItemDirective } from './virtual-scroll/virtual-scroll-item.directive'; export * from './virtual-scroll/types'; diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts index 12bc2778649..95ec0967a5f 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts @@ -1,55 +1,116 @@ import { computed, signal } from "@angular/core"; +import { clamp } from "igniteui-angular/core"; +import type { ScrollAlignment, VisibleRange } from "./types"; -const MAX_BROWSER_SIZE_PROBE_PX = Number.MAX_SAFE_INTEGER; +/** The maximum scroll coordinate is a per-document constant, so probe once. */ +const _maxBrowserSizeCache = new WeakMap(); + +/** Measures the largest scroll coordinate the browser can represent. */ +function probeMaxBrowserSize(doc: Document): number { + const cached = _maxBrowserSizeCache.get(doc); + if (cached !== undefined) { + return cached; + } + + const container = doc.body ?? doc.documentElement; + if (!container) { + return Number.POSITIVE_INFINITY; + } + + const probe = doc.createElement("div"); + probe.style.position = "absolute"; + probe.style.top = `${Number.MAX_SAFE_INTEGER}px`; + probe.style.width = "0"; + probe.style.height = "0"; + probe.style.visibility = "hidden"; + container.appendChild(probe); + + // The rect is viewport relative, so add back how far the document is scrolled. + const scrollOffset = doc.documentElement?.scrollTop ?? 0; + const size = Math.abs(probe.getBoundingClientRect().top) + scrollOffset; + container.removeChild(probe); + + _maxBrowserSizeCache.set(doc, size); + return size; +} /** - * Probes the browser for the maximum scrollable coordinate it supports. + * Fills `tree` with the partial range sums of `sizes` in one O(N) pass. + * `tree` is a 1-indexed Fenwick array of `sizes.length + 1` zeroed entries. + * Returns the total sum. */ -function getMaxBrowserSizeProbePx(doc: Document): number { - const div = doc.createElement("div"); - div.style.position = "absolute"; - div.style.top = `${MAX_BROWSER_SIZE_PROBE_PX}px`; - doc.body.appendChild(div); - const size = Math.abs(div.getBoundingClientRect().top); - doc.body.removeChild(div); - return size; +function buildTree(tree: Float64Array, sizes: Float64Array): number { + const length = sizes.length; + let total = 0; + + for (let i = 1; i <= length; i++) { + tree[i] += sizes[i - 1]; + total += sizes[i - 1]; + + const parent = i + (i & -i); + if (parent <= length) { + tree[parent] += tree[i]; + } + } + return total; } /** - * Binary Indexed Tree over item sizes. - * - * Replaces the previous O(N) full prefix-sum rebuild that occurred on every - * `measureItem` call. All hot-path operations are now O(log N): - * - Point update (item measured) : O(log N) - * - Prefix sum (scroll offset) : O(log N) - * - Index at offset (scroll -> item) : O(log N) via binary lifting + * Binary Indexed Tree (Fenwick tree) over item sizes. Each hot-path operation + * is O(log N): point update (item measured), prefix sum (scroll offset), and + * index at offset (scroll to item, through binary lifting). */ -class BIT { +class SizeTree { public readonly length: number; - /** 1-indexed BIT; each cell holds a partial range sum. */ + /** A 1-indexed BIT. Each cell holds a partial range sum. */ private readonly _tree: Float64Array; - /** Raw per-item sizes (0-indexed) — kept for O(1) reads and delta calc. */ + /** Raw per-item sizes, 0-indexed. Kept for O(1) reads and delta calculation. */ private readonly _sizes: Float64Array; - /** Running total maintained alongside tree updates in O(1). */ + /** + * Flags the indices holding a DOM-measured size (`1`) instead of an + * estimate (`0`). `applyEstimate` changes only the estimated entries. + */ + private readonly _measured: Uint8Array; + + /** Running total. Updated together with the tree in O(1). */ private _total: number; - constructor(length: number, fillSize: number) { - this.length = length; - this._sizes = new Float64Array(length).fill(fillSize); - this._tree = new Float64Array(length + 1); - this._total = length * fillSize; - - // O(N) build (vs O(N log N) for N individual insertions) - for (let i = 1; i <= length; i++) { - this._tree[i] += fillSize; - const j = i + (i & -i); - if (j <= length) { - this._tree[j] += this._tree[i]; - } - } + /** + * The highest power of two <= `length`, for the binary lifting in + * `findIndexAtOffset`. Precomputed because that runs on each scroll event. + */ + private readonly _topBit: number; + + private constructor( + sizes: Float64Array, + tree: Float64Array, + total: number, + measured: Uint8Array, + ) { + this.length = sizes.length; + this._sizes = sizes; + this._tree = tree; + this._total = total; + this._measured = measured; + this._topBit = this.length > 0 ? 1 << (31 - Math.clz32(this.length)) : 0; + } + + /** Creates a tree of `length` unmeasured items, each set to `fillSize`. O(N). */ + public static filled(length: number, fillSize: number): SizeTree { + return SizeTree._build( + new Float64Array(length).fill(fillSize), + new Uint8Array(length), + ); + } + + /** Builds a tree from a sizes array and its matching measured flags. O(N). */ + private static _build(sizes: Float64Array, measured: Uint8Array): SizeTree { + const tree = new Float64Array(sizes.length + 1); + const total = buildTree(tree, sizes); + return new SizeTree(sizes, tree, total, measured); } /** Total size of all items. O(1). */ @@ -58,30 +119,38 @@ class BIT { } /** - * Prefix sum of items [0, i) — the virtual scroll offset at the leading + * Prefix sum of items [0, i): the virtual scroll offset at the leading * edge of item i. O(log N). */ public prefixSum(i: number): number { let sum = 0; for (let j = i; j > 0; j -= j & -j) { - sum += this._tree[j]; + sum += this._tree[j]; } return sum; } /** - * Update the size of item at 0-based index. - * Returns true when the size actually changed. O(log N). + * Sets the size of the item at a 0-based index and marks it measured, so + * later `applyEstimate` calls leave it alone. Returns true when the size + * changed. O(log N). */ public update(index: number, newSize: number): boolean { - if (index < 0 || index >= this.length) return false; + if (index < 0 || index >= this.length) { + return false; + } const old = this._sizes[index]; - if (old === newSize) return false; + this._measured[index] = 1; + + if (old === newSize) { + return false; + } const delta = newSize - old; this._sizes[index] = newSize; this._total += delta; + for (let i = index + 1; i <= this.length; i += i & -i) { this._tree[i] += delta; } @@ -89,90 +158,110 @@ class BIT { } /** - * Returns a new BIT of `newLength` items. - * Existing measured sizes are preserved up to `min(this.length, newLength)`; - * new slots are filled with `fillSize`. Rebuilds the BIT in O(N). + * Returns a new tree of `newLength` items in one O(N) pass. Sizes and + * measured flags are kept up to `min(this.length, newLength, retainCount)`. + * The remainder is filled with `fillSize` and marked unmeasured. Pass a + * `retainCount` below the item count when the data behind those indices + * changed identity. */ - public cloneResized(newLength: number, fillSize: number): BIT { - const next = new BIT(newLength, fillSize); - const copyLen = Math.min(this.length, newLength); - - // Copy raw sizes from the old tree. - next._sizes.set(this._sizes.subarray(0, copyLen)); - - // Rebuild BIT and total from scratch in O(N). - next._tree.fill(0); - next._total = 0; - for (let i = 1; i <= newLength; i++) { - next._tree[i] += next._sizes[i - 1]; - next._total += next._sizes[i - 1]; - const j = i + (i & -i); - if (j <= newLength) { - next._tree[j] += next._tree[i]; + public cloneResized( + newLength: number, + fillSize: number, + retainCount = newLength, + ): SizeTree { + const sizes = new Float64Array(newLength).fill(fillSize); + const measured = new Uint8Array(newLength); + const retained = Math.max(0, Math.min(this.length, newLength, retainCount)); + + sizes.set(this._sizes.subarray(0, retained)); + measured.set(this._measured.subarray(0, retained)); + return SizeTree._build(sizes, measured); + } + + /** + * Sets `estimatedSize` on each unmeasured item. Returns true when at least + * one size changed. + * + * One estimate change can touch most of the list, so this rebuilds in one + * O(N) pass instead of one O(log N) `update` per item. + */ + public applyEstimate(estimatedSize: number): boolean { + let changed = false; + + for (let i = 0; i < this.length; i++) { + if (!this._measured[i] && this._sizes[i] !== estimatedSize) { + this._sizes[i] = estimatedSize; + changed = true; } } - return next; + + if (!changed) { + return false; + } + + this._tree.fill(0); + this._total = buildTree(this._tree, this._sizes); + return true; } /** - * Returns the 0-based index of the last item whose cumulative end offset - * is ≤ the given scroll offset (binary lifting on the internal tree). - * O(log N) — semantics are identical to the previous binarySearchPrefixSums - * result so `getVisibleRange` / overscan logic is unchanged. + * Returns the 0-based index of the item containing the scroll `offset`: + * the largest i where `prefixSum(i) <= offset < prefixSum(i + 1)`. O(log N). */ public findIndexAtOffset(offset: number): number { - if (offset <= 0 || this.length === 0) return 0; - - let idx = 0; - for ( - let bit = 1 << (31 - Math.clz32(this.length)); - bit > 0; - bit >>= 1 - ) { - const next = idx + bit; - if (next <= this.length && this._tree[next] <= offset) { - idx = next; - offset -= this._tree[idx]; + if (offset <= 0 || this.length === 0) { + return 0; + } + + let index = 0; + let remaining = offset; + + for (let bit = this._topBit; bit > 0; bit >>= 1) { + const next = index + bit; + if (next <= this.length && this._tree[next] <= remaining) { + index = next; + remaining -= this._tree[index]; } } - return Math.max(0, idx - 1); + return Math.min(this.length - 1, index); } } /** - * Describes the currently visible (and over-scanned) range of items. - */ -export interface VisibleRange { - /** Index of the first rendered item (inclusive) */ - startIndex: number; - /** Index of the last rendered item (inclusive) */ - endIndex: number; -} - -/** - * Pure scroll-math engine for a single axis of virtual scrolling. + * Pure scroll-math engine for one axis of virtual scrolling. A Fenwick tree + * holds all size state, exposed through signals so downstream `computed()` + * values (visible range, spacer size, translate offset) react to any change + * of item sizes or item count. + * + * ### Virtual and DOM coordinates * - * Holds all size state as signals so that downstream `computed()` values - * (visible range, spacer size, translate offset) react automatically - * whenever item sizes are measured or the item count changes. + * Browsers limit how far an element can scroll. When the total item size is + * larger than that limit, the engine compresses the *virtual* space + * (`0…totalSize`) into the *DOM* space the browser can represent + * (`0…domSize`) by the factor `_virtualRatio`. Offsets crossing that boundary + * are scaled: incoming scroll positions are multiplied by the ratio, outgoing + * offsets are divided by it. Items render at their real pixel size, so item + * sizes are always virtual. */ export class VirtualScrollEngine { - private _maxBrowserSize = Infinity; + private _maxBrowserSize = Number.POSITIVE_INFINITY; /** - * The ratio `totalSize / maxBrowserSize` when `totalSize` exceeds the - * maximum DOM coordinate the browser supports; `1` otherwise. - * Used to map virtual scroll positions to DOM scroll positions. + * `totalSize / maxBrowserSize` while the content is too large for the + * browser's scroll range; `1` otherwise. Maps virtual onto DOM positions. */ private _virtualRatio = 1; - private _tree: BIT | null = null; + private _tree: SizeTree | null = null; + + /** Bumped on every structural change: resize, measurement or estimate. */ + private readonly _version = signal(0); /** - * Incremented on every structural change (resize or measurement). - * Downstream `computed()` values depend on this to stay reactive. + * Read this from a `computed()` to make it recompute on any size change. + * `totalSize` and `domSize` already do. */ - private readonly _version = signal(0); + public readonly version = this._version.asReadonly(); /** Total virtual size of all items in px. */ public readonly totalSize = computed(() => { @@ -180,97 +269,177 @@ export class VirtualScrollEngine { return this._tree?.totalSize ?? 0; }); - /** Actual DOM space size (clamped to the maximum browser size) */ + /** + * Total size in DOM space, clamped to the maximum browser size. + * + * Depends on `_version` directly rather than on `totalSize()`. The ratio + * can change while the total does not (the browser maximum is probed after + * the first render), and an unchanged `totalSize` would not propagate. + */ public readonly domSize = computed(() => { - // Always read totalSize() to maintain a stable dependency on _version. - // If we branch on _virtualRatio first and return early, totalSize() is - // never called in the compression case, so Angular drops the dependency - // and domSize is frozen forever — subsequent measureItem() calls would - // never invalidate it. - const total = this.totalSize(); + this._version(); + const total = this._tree?.totalSize ?? 0; return this._virtualRatio !== 1 ? this._maxBrowserSize : total; }); /** - * Initializes the maximum browser size by probing the document, and updates the virtual ratio accordingly. + * Probes the document's maximum scroll coordinate and rescales. Notifies, + * because the probe can only run after the first render, by which point + * `domSize` has already been read at the uncompressed total. */ public initMaxBrowserSize(doc: Document): void { - this._maxBrowserSize = getMaxBrowserSizeProbePx(doc); - this._updateVirtualRatio(); + this._maxBrowserSize = probeMaxBrowserSize(doc); + this._invalidate(); } /** - * Grows or shrinks the internal sizes array to `length`. - * New entries are filled with `estimatedSize`. - * Existing measured sizes are preserved. + * Resizes the internal sizes array to `length`. Measured sizes below + * `retainCount` are kept, the remainder falls back to `estimatedSize`. + * Callers that only append can keep the default `retainCount`. Callers + * whose data changed identity at some index must pass that index, so the + * stale measurements after it are discarded. */ - public resize(length: number, estimatedSize: number): void { - if (this._tree?.length === length) return; + public resize( + length: number, + estimatedSize: number, + retainCount = length, + ): void { + if (this._tree?.length === length && retainCount >= length) { + return; + } this._tree = this._tree - ? this._tree.cloneResized(length, estimatedSize) - : new BIT(length, estimatedSize); - this._updateVirtualRatio(); - this._version.update((v) => v + 1); + ? this._tree.cloneResized(length, estimatedSize, retainCount) + : SizeTree.filled(length, estimatedSize); + this._invalidate(); } - /** - * Records the measured DOM size for a single item. - * Triggers a signal update so all downstream computed values react. - */ + /** Records the measured DOM size for a single item. */ public measureItem(index: number, size: number): void { - if (!this._tree?.update(index, size)) return; + if (this._tree?.update(index, size)) { + this._invalidate(); + } + } - this._updateVirtualRatio(); - this._version.update((v) => v + 1); + /** + * Applies a new estimate to every item not yet measured in the DOM. Use + * this when `estimatedItemSize` changes but the item count does not, + * because `resize` is then a no-op. + */ + public updateEstimatedSize(estimatedSize: number): void { + if (this._tree?.applyEstimate(estimatedSize)) { + this._invalidate(); + } } /** - * Returns the DOM scroll offset in pixels that brings item at `index` into view - * at the leading edge of the viewport. + * Returns the DOM scroll offset in px that puts the item at `index` at the + * leading edge of the viewport. */ public getScrollOffsetForIndex(index: number): number { - if (!this._tree || index <= 0) return 0; - - const clamped = Math.min(index, this._tree.length); - return this._tree.prefixSum(clamped) / this._virtualRatio; + if (!this._tree || index <= 0) { + return 0; + } + return ( + this._tree.prefixSum(Math.min(index, this._tree.length)) / + this._virtualRatio + ); } - /** Returns the item index at the given DOM scroll position. */ - public getIndexAtScroll(scrollPosition: number): number { - if (!this._tree || scrollPosition <= 0) return 0; - return this._tree.findIndexAtOffset(scrollPosition * this._virtualRatio); + /** + * Returns the DOM scroll offset that positions the item at `index` in a + * `viewportSize` px viewport, aligned by `align` and clamped to the + * reachable scroll range. + * + * The slack is computed in virtual space against the item's real size and + * converted to DOM space once, at the end. One DOM pixel equals + * `_virtualRatio` virtual pixels, so mixed coordinates would scale the slack. + */ + public getAlignedScrollOffset( + index: number, + viewportSize: number, + align: ScrollAlignment, + ): number { + const bounds = this._itemBounds(index); + if (!bounds) { + return 0; + } + + const [start, end] = bounds; + const slack = viewportSize - Math.max(0, end - start); + let offset = start; + + if (align === "center") { + offset -= slack / 2; + } else if (align === "end") { + offset -= slack; + } + + return clamp( + offset / this._virtualRatio, + 0, + Math.max(0, this.domSize() - viewportSize), + ); } /** - * Returns the visible + over-scanned item range for the given scroll state. + * Whether the item at `index` needs no further scrolling at the given DOM + * scroll position: it is either fully inside the viewport, or larger than + * the viewport and covering it. The second case matches native + * `scrollIntoView({ block: 'nearest' })`. */ + public isIndexInView( + index: number, + scrollPosition: number, + viewportSize: number, + ): boolean { + const bounds = this._itemBounds(index); + if (!bounds) { + return false; + } + + const [start, end] = bounds; + const viewStart = Math.max(0, scrollPosition) * this._virtualRatio; + const viewEnd = viewStart + viewportSize; + + const contained = start >= viewStart && end <= viewEnd; + const spanning = start <= viewStart && end >= viewEnd; + + return contained || spanning; + } + + /** Returns the visible and over-scanned item range for the given scroll state. */ public getVisibleRange( scrollPosition: number, viewportSize: number, overScan: number, - totalItems: number, ): VisibleRange { - if (totalItems === 0 || viewportSize <= 0) { + if (!this._tree || this._tree.length === 0 || viewportSize <= 0) { return { startIndex: 0, endIndex: -1 }; } - const start = Math.max(0, this.getIndexAtScroll(scrollPosition) - overScan); - const end = Math.min( - totalItems - 1, - this.getIndexAtScroll(scrollPosition + viewportSize) + overScan, - ); - - return { startIndex: start, endIndex: end }; + // The viewport is not scaled by the virtual ratio. Items render at their + // real pixel size, so a `viewportSize` px viewport always shows that many + // virtual pixels of items, at any compression of the scroll range. + const startOffset = Math.max(0, scrollPosition) * this._virtualRatio; + const first = this._tree.findIndexAtOffset(startOffset); + const last = this._tree.findIndexAtOffset(startOffset + viewportSize); + + return { + startIndex: Math.max(0, first - overScan), + endIndex: Math.min(this._tree.length - 1, last + overScan), + }; } /** - * Returns the sum of actual measured sizes for items in [startIndex, endIndex]. - * Used by the component to detect when the rendered range overflows `domSize` - * under coordinate compression (variable heights + large datasets). + * Sum of the real sizes of the items in [startIndex, endIndex]. The render + * pass uses it to clamp the content translate offset, so rendered items do + * not overflow past `domSize` under coordinate compression. */ public getPhysicalRangeSize(startIndex: number, endIndex: number): number { - if (!this._tree) return 0; + if (!this._tree) { + return 0; + } const start = Math.max(0, startIndex); const end = Math.min(Math.max(endIndex + 1, start), this._tree.length); @@ -278,26 +447,26 @@ export class VirtualScrollEngine { } /** - * Returns the CSS `translateY` / `translateX` value (px) to apply to the - * absolutely-positioned content wrapper. - * - * The content wrapper is `position: absolute; top: 0; left: 0` inside a - * track element that is `totalSize` px tall/wide. Translating it to - * `getContentPosition(startIndex)` places the first rendered item exactly - * at its virtual scroll position within the track. + * The virtual [start, end] offsets of the item at `index`, clamped into the + * item range. Null while there are no items. */ - public getContentPosition(index: number): number { - if (!this._tree || index <= 0) return 0; + private _itemBounds(index: number): [number, number] | null { + if (!this._tree || this._tree.length === 0) { + return null; + } + + const clamped = clamp(index, 0, this._tree.length - 1); + return [this._tree.prefixSum(clamped), this._tree.prefixSum(clamped + 1)]; + } - const clamped = Math.min(index, this._tree.length); - return this._tree.prefixSum(clamped) / this._virtualRatio; + private _invalidate(): void { + this._updateVirtualRatio(); + this._version.update((v) => v + 1); } private _updateVirtualRatio(): void { const totalSize = this._tree?.totalSize ?? 0; this._virtualRatio = - this._maxBrowserSize === Infinity || totalSize <= this._maxBrowserSize - ? 1 - : totalSize / this._maxBrowserSize; + totalSize <= this._maxBrowserSize ? 1 : totalSize / this._maxBrowserSize; } } diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts index 5db24a27e37..14831ad044a 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts @@ -1,7 +1,4 @@ -/** - * Context for the item template in the virtual scroll component. - * Provides the item data, its index, and utility properties for template rendering. - */ +/** Template context for a single item of the virtual scroll. */ export class IgxVsItemContext { constructor( /** The current item in the virtual scroll. */ @@ -34,13 +31,21 @@ export class IgxVsItemContext { } /** - * Snapshot of the currently rendered virtual window. + * How `scrollToIndex` positions the requested item in the viewport. + * The subset of `ScrollLogicalPosition` that the engine supports. */ -export interface VirtualScrollState { - /** The index of the first item currently rendered in the viewport. */ +export type ScrollAlignment = "start" | "center" | "end"; + +/** The currently rendered (visible plus over-scanned) range of items. */ +export interface VisibleRange { + /** Index of the first rendered item, inclusive. */ startIndex: number; - /** The index of the last item currently rendered in the viewport (inclusive). */ + /** Index of the last rendered item, inclusive. */ endIndex: number; +} + +/** Snapshot of the currently rendered virtual window. */ +export interface VirtualScrollState extends VisibleRange { /** The size of the viewport in pixels. */ viewportSize: number; /** The total size of the virtual scroll content in pixels. */ @@ -48,14 +53,11 @@ export interface VirtualScrollState { } /** - * Request for more data to be loaded in the virtual scroll, typically emitted when the user scrolls near the end of the currently loaded items. - * The consumer of the virtual scroll component can listen to this event and load more data as needed. + * Request for more data, emitted when the rendered window nears the end of + * the loaded items. Listen to it to implement infinite / remote scrolling. */ export interface VirtualScrollDataRequest { - /** - * The first index that does not yet have data. - * Append at least `(endIndex - startIndex + 1)` more items starting here. - */ + /** The first index that does not yet have data. */ startIndex: number; /** Number of items being requested. */ count: number; diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html index c8299c1d4d8..63857be259e 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html @@ -1,14 +1,28 @@ diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss index 1be5815c172..d6e5ae1e00b 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss @@ -9,11 +9,6 @@ overflow-x: hidden; } -:host(.igx-virtual-scroll--horizontal) { - overflow-x: auto; - overflow-y: hidden; -} - .igx-vs__track { position: relative; width: 100%; @@ -30,8 +25,12 @@ } :host(.igx-virtual-scroll--horizontal) { + overflow-x: auto; + overflow-y: hidden; + .igx-vs__track { height: 100%; + width: auto; min-height: unset; } @@ -41,6 +40,11 @@ height: 100%; width: auto; } + + .igx-vs__item { + flex-shrink: 0; + height: 100%; + } } :host(.igx-virtual-scroll--horizontal:dir(rtl)) { diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index e2f8463c3ca..2ffca51afca 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -1,186 +1,447 @@ -import { - TestBed, - ComponentFixture, - fakeAsync, - tick, - waitForAsync, -} from '@angular/core/testing'; -import { Component, signal, TemplateRef, viewChild } from '@angular/core'; +import { Component, signal, viewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { IgxVirtualScrollComponent } from './virtual-scroll.component'; -import { IgxVirtualItemDirective } from './virtual-scroll-item.directive'; -import { IgxVsItemContext, VirtualScrollDataRequest, VirtualScrollState } from './types'; import { VirtualScrollEngine } from './scroll-engine'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +import { + IgxVsItemContext, + VirtualScrollDataRequest, + VirtualScrollState, +} from './types'; +import { IgxVirtualItemDirective } from './virtual-scroll-item.directive'; +import { IgxVirtualScrollComponent } from './virtual-scroll.component'; function generateItems(count: number): string[] { return Array.from({ length: count }, (_, i) => `Item ${i}`); } -// --------------------------------------------------------------------------- -// VirtualScrollEngine (pure unit tests – no DOM required) -// --------------------------------------------------------------------------- +function engineOf(scroll: IgxVirtualScrollComponent): VirtualScrollEngine { + return (scroll as any)._engine; +} describe('VirtualScrollEngine', () => { - let engine: VirtualScrollEngine; + const ESTIMATE = 50; - beforeEach(() => { - engine = new VirtualScrollEngine(); - }); + function createEngine(length = 100, estimate = ESTIMATE): VirtualScrollEngine { + const engine = new VirtualScrollEngine(); + engine.resize(length, estimate); + return engine; + } - describe('resize', () => { - it('should initialize sizes with the estimated value', () => { - engine.resize(5, 40); - expect(engine.totalSize()).toBe(200); + /** + * A stand-in document reporting `maxSize` as its largest reachable + * coordinate. A non-zero `scrollTop` models an already scrolled document: + * the probe's rect is viewport relative, so it comes back short by that + * much. `probes` counts how often a probe element was created. + */ + function createProbeDocument(maxSize: number, scrollTop = 0) { + const probe = { + style: {} as CSSStyleDeclaration, + getBoundingClientRect: () => ({ top: maxSize - scrollTop }), + }; + const state = { probes: 0 }; + const doc = { + body: { appendChild: () => undefined, removeChild: () => undefined }, + documentElement: { scrollTop }, + createElement: () => { + state.probes++; + return probe; + }, + } as unknown as Document; + + return { doc, state }; + } + + /** Builds an engine whose probed maximum browser size is `maxSize`. */ + function createEngineWithMaxSize( + maxSize: number, + length: number, + estimate = ESTIMATE, + ): VirtualScrollEngine { + const engine = new VirtualScrollEngine(); + engine.initMaxBrowserSize(createProbeDocument(maxSize).doc); + engine.resize(length, estimate); + return engine; + } + + describe('sizing', () => { + it('should fill new items with the estimated size', () => { + const engine = createEngine(10); + + expect(engine.totalSize()).toBe(500); + expect(engine.domSize()).toBe(500); + expect(engine.getScrollOffsetForIndex(0)).toBe(0); + expect(engine.getScrollOffsetForIndex(3)).toBe(150); }); - it('should grow preserving existing sizes', () => { - engine.resize(3, 50); - engine.measureItem(0, 80); - engine.resize(5, 50); - // item 0 = 80, items 1-4 = 50 each => 80 + 4*50 = 280 - expect(engine.totalSize()).toBe(280); + it('should report zero size before it is sized', () => { + const engine = new VirtualScrollEngine(); + + expect(engine.totalSize()).toBe(0); + expect(engine.getScrollOffsetForIndex(5)).toBe(0); + expect(engine.getPhysicalRangeSize(0, 10)).toBe(0); + expect(engine.getVisibleRange(0, 300, 2)).toEqual({ + startIndex: 0, + endIndex: -1, + }); }); - it('should shrink the array', () => { - engine.resize(5, 50); - engine.resize(2, 50); - expect(engine.totalSize()).toBe(100); + it('should apply a measured size to subsequent offsets', () => { + const engine = createEngine(10); + engine.measureItem(2, 120); + + expect(engine.totalSize()).toBe(570); + expect(engine.getScrollOffsetForIndex(2)).toBe(100); + expect(engine.getScrollOffsetForIndex(3)).toBe(220); + expect(engine.getPhysicalRangeSize(2, 2)).toBe(120); }); - it('should be a no-op when length is unchanged', () => { - engine.resize(3, 50); - const before = engine.totalSize(); - engine.resize(3, 99); - expect(engine.totalSize()).toBe(before); + it('should ignore measurements for out of range indices', () => { + const engine = createEngine(10); + engine.measureItem(10, 120); + engine.measureItem(-1, 120); + + expect(engine.totalSize()).toBe(500); }); - }); - describe('measureItem', () => { - it('should update totalSize after a measurement', () => { - engine.resize(3, 50); - engine.measureItem(1, 120); - expect(engine.totalSize()).toBe(50 + 120 + 50); + it('should clamp offsets to the item count', () => { + const engine = createEngine(10); + + expect(engine.getScrollOffsetForIndex(10)).toBe(500); + expect(engine.getScrollOffsetForIndex(999)).toBe(500); + expect(engine.getScrollOffsetForIndex(-5)).toBe(0); }); - it('should ignore out-of-range indices', () => { - engine.resize(3, 50); - engine.measureItem(-1, 100); - engine.measureItem(3, 100); - expect(engine.totalSize()).toBe(150); + it('should sum only the requested range, clamped to the item count', () => { + const engine = createEngine(10); + + expect(engine.getPhysicalRangeSize(2, 4)).toBe(150); + expect(engine.getPhysicalRangeSize(-5, 1)).toBe(100); + expect(engine.getPhysicalRangeSize(8, 999)).toBe(100); + expect(engine.getPhysicalRangeSize(4, 3)).toBe(0); + }); + }); + + describe('estimated size', () => { + it('should apply a new estimate to unmeasured items only', () => { + const engine = createEngine(10); + engine.measureItem(0, 30); + engine.updateEstimatedSize(100); + + expect(engine.totalSize()).toBe(30 + 9 * 100); + expect(engine.getScrollOffsetForIndex(1)).toBe(30); }); - it('should be a no-op when the size has not changed', () => { - engine.resize(3, 50); - const before = engine.totalSize(); - engine.measureItem(0, 50); // size is already 50 - expect(engine.totalSize()).toBe(before); + it('should treat a measurement equal to the current size as measured', () => { + const engine = createEngine(10); + // The same value as the estimate: no size change, but the item must + // still be flagged as measured, so a later estimate cannot overwrite it. + engine.measureItem(0, ESTIMATE); + engine.updateEstimatedSize(100); + + expect(engine.totalSize()).toBe(ESTIMATE + 9 * 100); }); }); - describe('getScrollOffsetForIndex', () => { - it('should return 0 for index 0', () => { - engine.resize(3, 50); - expect(engine.getScrollOffsetForIndex(0)).toBe(0); + describe('resizing', () => { + it('should preserve measured sizes when items are appended', () => { + const engine = createEngine(10); + engine.measureItem(1, 30); + engine.resize(20, ESTIMATE); + + expect(engine.totalSize()).toBe(30 + 19 * ESTIMATE); + expect(engine.getScrollOffsetForIndex(2)).toBe(80); + }); + + it('should preserve measured sizes when items are removed', () => { + const engine = createEngine(10); + engine.measureItem(1, 30); + engine.resize(5, ESTIMATE); + + expect(engine.totalSize()).toBe(30 + 4 * ESTIMATE); + }); + + it('should discard measured sizes at and beyond retainCount', () => { + const engine = createEngine(10); + engine.measureItem(1, 30); + engine.measureItem(6, 30); + engine.resize(10, ESTIMATE, 4); + + // Item 1 is retained. Item 6 is set back to the estimate. + expect(engine.totalSize()).toBe(30 + 9 * ESTIMATE); + expect(engine.getScrollOffsetForIndex(2)).toBe(80); }); - it('should return the cumulative size up to the given index', () => { - engine.resize(4, 50); + it('should re-mark discarded items as unmeasured', () => { + const engine = createEngine(10); + engine.measureItem(6, 30); + engine.resize(10, ESTIMATE, 4); + engine.updateEstimatedSize(100); + + // Nothing is measured now, so each item follows the new estimate. + expect(engine.totalSize()).toBe(10 * 100); + }); + + it('should be a no-op when the length matches and everything is retained', () => { + const engine = createEngine(10); + engine.measureItem(1, 30); + + const version = engine.version(); + engine.resize(10, ESTIMATE); + + expect(engine.version()).toBe(version); + expect(engine.totalSize()).toBe(30 + 9 * ESTIMATE); + }); + }); + + describe('change notifications', () => { + it('should notify on resize, measurement and estimate changes', () => { + const engine = new VirtualScrollEngine(); + const version = engine.version(); + + engine.resize(10, ESTIMATE); + expect(engine.version()).toBe(version + 1); + engine.measureItem(0, 30); - engine.measureItem(1, 60); - // offset for index 2 = item0 + item1 = 30 + 60 = 90 - expect(engine.getScrollOffsetForIndex(2)).toBe(90); + expect(engine.version()).toBe(version + 2); + + engine.updateEstimatedSize(80); + expect(engine.version()).toBe(version + 3); }); - it('should clamp to totalSize for out-of-range indices', () => { - engine.resize(3, 50); - // pSums has length items+1; index clamps to pSums.length-1 = totalItems, - // which equals the total virtual size, not the last item's leading offset. - expect(engine.getScrollOffsetForIndex(100)).toBe(engine.totalSize()); + it('should not notify when nothing actually changes', () => { + const engine = createEngine(10); + const version = engine.version(); + + engine.measureItem(0, ESTIMATE); + engine.updateEstimatedSize(ESTIMATE); + + expect(engine.version()).toBe(version); }); }); - describe('getIndexAtScroll', () => { - it('should return 0 when scrollPosition is 0', () => { - engine.resize(5, 50); - expect(engine.getIndexAtScroll(0)).toBe(0); + describe('visible range', () => { + it('should return an empty range without items or viewport', () => { + expect(createEngine(0).getVisibleRange(0, 300, 2)).toEqual({ + startIndex: 0, + endIndex: -1, + }); + expect(createEngine(10).getVisibleRange(0, 0, 2)).toEqual({ + startIndex: 0, + endIndex: -1, + }); }); - it('should return the last complete item before the scroll position', () => { - engine.resize(5, 50); - // binarySearchPrefixSums returns low-1: the last item whose end (prefixSums[i+1]) - // is at or before the target. At 125px item 1 ends at 100px, so index 1 is returned. - // getVisibleRange adds overscan on top, which covers the partially-visible item. - expect(engine.getIndexAtScroll(125)).toBe(1); + it('should cover the viewport from the top', () => { + const engine = createEngine(100); + + expect(engine.getVisibleRange(0, 300, 0)).toEqual({ + startIndex: 0, + endIndex: 6, + }); + }); + + it('should resolve an offset that falls exactly on an item boundary', () => { + const engine = createEngine(100); + + expect(engine.getVisibleRange(100, 100, 0)).toEqual({ + startIndex: 2, + endIndex: 4, + }); + }); + + it('should expand by the over-scan and clamp to the item count', () => { + const engine = createEngine(100); + + expect(engine.getVisibleRange(0, 300, 2)).toEqual({ + startIndex: 0, + endIndex: 8, + }); + expect(engine.getVisibleRange(5000, 300, 2)).toEqual({ + startIndex: 97, + endIndex: 99, + }); + }); + + it('should account for measured sizes', () => { + const engine = createEngine(100); + for (let i = 0; i < 10; i++) { + engine.measureItem(i, 100); + } + + expect(engine.getVisibleRange(0, 300, 0)).toEqual({ + startIndex: 0, + endIndex: 3, + }); }); }); - describe('getVisibleRange', () => { - it('should return empty range when totalItems is 0', () => { - engine.resize(0, 50); - const range = engine.getVisibleRange(0, 300, 2, 0); - expect(range.startIndex).toBe(0); - expect(range.endIndex).toBe(-1); + describe('alignment', () => { + it('should align to the leading edge', () => { + const engine = createEngine(100); + + expect(engine.getAlignedScrollOffset(10, 300, 'start')).toBe(500); }); - it('should return empty range when viewportSize is 0', () => { - engine.resize(10, 50); - const range = engine.getVisibleRange(0, 0, 2, 10); - expect(range.startIndex).toBe(0); - expect(range.endIndex).toBe(-1); + it('should center the item within the viewport', () => { + const engine = createEngine(100); + + // 500 - (300 - 50) / 2 + expect(engine.getAlignedScrollOffset(10, 300, 'center')).toBe(375); }); - it('should include over-scanned items beyond the visible edge', () => { - engine.resize(20, 50); - // Viewport 200px, scroll 0 → visible items 0-3; with overScan=2 => 0-5 - const range = engine.getVisibleRange(0, 200, 2, 20); - expect(range.startIndex).toBe(0); - expect(range.endIndex).toBeGreaterThanOrEqual(5); + it('should align to the trailing edge', () => { + const engine = createEngine(100); + + // 500 - (300 - 50) + expect(engine.getAlignedScrollOffset(10, 300, 'end')).toBe(250); }); - it('should not exceed totalItems - 1 as endIndex', () => { - engine.resize(5, 50); - const range = engine.getVisibleRange(0, 10000, 10, 5); - expect(range.endIndex).toBe(4); + it('should never return a negative offset', () => { + const engine = createEngine(100); + + expect(engine.getAlignedScrollOffset(0, 300, 'center')).toBe(0); + expect(engine.getAlignedScrollOffset(1, 300, 'end')).toBe(0); }); - it('should not go below 0 as startIndex', () => { - engine.resize(10, 50); - const range = engine.getVisibleRange(0, 200, 10, 10); - expect(range.startIndex).toBe(0); + it('should clamp to the largest reachable scroll offset', () => { + const engine = createEngine(100); + const maxOffset = engine.domSize() - 300; + + expect(maxOffset).toBe(5000 - 300); + expect(engine.getAlignedScrollOffset(99, 300, 'start')).toBe(maxOffset); + }); + + it('should report whether an item is fully in view', () => { + const engine = createEngine(100); + + expect(engine.isIndexInView(0, 0, 300)).toBeTrue(); + expect(engine.isIndexInView(5, 0, 300)).toBeTrue(); + // Item 6 spans 300-350, so it is only partially visible. + expect(engine.isIndexInView(6, 0, 300)).toBeFalse(); + expect(engine.isIndexInView(20, 0, 300)).toBeFalse(); + }); + + it('should treat an item larger than the viewport as in view once it covers it', () => { + const engine = createEngine(10); + engine.measureItem(0, 1000); + + // The item cannot fit inside the viewport. While it spans the whole + // viewport, there is nothing to scroll to, as with native + // `scrollIntoView({ block: 'nearest' })`. + expect(engine.isIndexInView(0, 0, 300)).toBeTrue(); + expect(engine.isIndexInView(0, 350, 300)).toBeTrue(); + // Scrolled past its trailing edge, the item no longer covers the viewport. + expect(engine.isIndexInView(0, 800, 300)).toBeFalse(); + }); + + it('should clamp an out of range index the same way as the alignment math', () => { + const engine = createEngine(100); + const last = engine.getAlignedScrollOffset(99, 300, 'start'); + + expect(engine.getAlignedScrollOffset(999, 300, 'start')).toBe(last); + expect(engine.isIndexInView(999, last, 300)).toBe( + engine.isIndexInView(99, last, 300), + ); + }); + + it('should stay within range on an empty tree', () => { + const engine = createEngine(0); + + expect(engine.getAlignedScrollOffset(0, 300, 'center')).toBe(0); + expect(engine.isIndexInView(0, 0, 300)).toBeFalse(); }); }); - describe('getContentPosition', () => { - it('should return 0 for index 0', () => { - engine.resize(3, 50); - expect(engine.getContentPosition(0)).toBe(0); + describe('coordinate compression', () => { + const MAX_SIZE = 10_000; + const ITEMS = 1000; // 50_000px total, a ratio of 5 + + it('should clamp the DOM size to the maximum browser size', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + + expect(engine.totalSize()).toBe(50_000); + expect(engine.domSize()).toBe(MAX_SIZE); }); - it('should match the prefix sum at the given index', () => { - engine.resize(4, 50); - engine.measureItem(0, 30); - engine.measureItem(1, 70); - // position for index 2 = sum of items 0 and 1 = 30 + 70 = 100 - expect(engine.getContentPosition(2)).toBe(100); + it('should leave the DOM size untouched below the maximum', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, 100); + + expect(engine.totalSize()).toBe(5000); + expect(engine.domSize()).toBe(5000); + }); + + it('should map DOM scroll positions onto the virtual space', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + + // Halfway down the DOM range is halfway down the virtual range. + expect(engine.getVisibleRange(MAX_SIZE / 2, 300, 0).startIndex).toBe(500); + expect(engine.getScrollOffsetForIndex(500)).toBe(MAX_SIZE / 2); + }); + + it('should size the rendered window by the viewport, not by the ratio', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + const compressed = engine.getVisibleRange(MAX_SIZE / 2, 300, 0); + + // A 300px viewport of 50px items shows 6 items at any compression of + // the virtual space, because the items render at their real size. + expect(compressed.endIndex - compressed.startIndex).toBe(6); + }); + + it('should convert the alignment slack into DOM space', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + const start = engine.getAlignedScrollOffset(500, 300, 'start'); + const centered = engine.getAlignedScrollOffset(500, 300, 'center'); + + // The slack is 125 virtual px, which is 25 DOM px at a ratio of 5. + expect(start).toBe(MAX_SIZE / 2); + expect(centered).toBe(MAX_SIZE / 2 - 25); + }); + + it('should compress an already sized engine when the probe arrives later', () => { + const engine = new VirtualScrollEngine(); + engine.resize(ITEMS, ESTIMATE); + + // The component sizes the engine during change detection but can + // only probe the document after the first render, so `domSize` is + // read once before the maximum is known. + expect(engine.domSize()).toBe(50_000); + + engine.initMaxBrowserSize(createProbeDocument(MAX_SIZE).doc); + + expect(engine.domSize()).toBe(MAX_SIZE); + }); + + it('should probe a given document only once', () => { + const { doc, state } = createProbeDocument(MAX_SIZE); + + new VirtualScrollEngine().initMaxBrowserSize(doc); + new VirtualScrollEngine().initMaxBrowserSize(doc); + + expect(state.probes).toBe(1); + }); + + it('should probe the full extent from an already scrolled document', () => { + const { doc } = createProbeDocument(MAX_SIZE, 2500); + const engine = new VirtualScrollEngine(); + + engine.initMaxBrowserSize(doc); + engine.resize(ITEMS, ESTIMATE); + + // If the document scroll offset were not added back, the probe would + // report 7500 and the content would be compressed into it. + expect(engine.domSize()).toBe(MAX_SIZE); }); }); }); -// --------------------------------------------------------------------------- -// IgxVsItemContext -// --------------------------------------------------------------------------- - describe('IgxVsItemContext', () => { it('should expose item, index, and count', () => { - const ctx = new IgxVsItemContext('hello', 3, 10); - expect(ctx.$implicit).toBe('hello'); - expect(ctx.index).toBe(3); - expect(ctx.count).toBe(10); + const context = new IgxVsItemContext('a', 2, 5); + + expect(context.$implicit).toBe('a'); + expect(context.index).toBe(2); + expect(context.count).toBe(5); }); it('first should be true only at index 0', () => { @@ -201,486 +462,739 @@ describe('IgxVsItemContext', () => { }); }); -// --------------------------------------------------------------------------- -// Wrapper components used in TestBed tests -// --------------------------------------------------------------------------- - @Component({ - selector: 'test-virtual-scroll-basic', + selector: 'test-virtual-scroll', template: ` - + -
{{ i }}: {{ item }}
+ {{ i }}: {{ item }}
`, imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], }) -class TestBasicComponent { +class TestHostComponent { + public readonly vs = viewChild.required(IgxVirtualScrollComponent); + public items = signal(generateItems(100)); -} + public orientation = signal<'vertical' | 'horizontal'>('vertical'); + public overScan = signal(2); + public estimatedItemSize = signal(50); + public hostHeight = signal(300); + public hostWidth = signal(null); + public itemHeight = signal(50); + public itemWidth = signal(null); + + public states: VirtualScrollState[] = []; + public requests: VirtualScrollDataRequest[] = []; + + /** A 300x100 horizontal viewport of 50px wide items. */ + public useHorizontal(): void { + this.orientation.set('horizontal'); + this.hostHeight.set(100); + this.hostWidth.set(300); + this.itemHeight.set(null); + this.itemWidth.set(50); + } -@Component({ - selector: 'test-virtual-scroll-horizontal', - template: ` - - -
{{ item }}
-
-
- `, - imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], -}) -class TestHorizontalComponent { - public items = generateItems(50); + /** A 300px tall vertical viewport of 50px tall items. */ + public useVertical(): void { + this.orientation.set('vertical'); + this.hostHeight.set(300); + this.hostWidth.set(null); + this.itemHeight.set(50); + this.itemWidth.set(null); + } } @Component({ - selector: 'test-virtual-scroll-horizontal-rtl', + selector: 'test-virtual-scroll-rtl', template: ` - + -
{{ item }}
+ {{ item }}
`, imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], }) -class TestHorizontalRtlComponent { - public items = generateItems(50); +class TestRtlHostComponent { + public readonly vs = viewChild.required(IgxVirtualScrollComponent); + public items = signal(generateItems(1000)); } @Component({ - selector: 'test-virtual-scroll-events', + selector: 'test-virtual-scroll-no-template', template: ` - - -
{{ item }}
-
-
+ `, - imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], + imports: [IgxVirtualScrollComponent], }) -class TestEventsComponent { - public items = generateItems(100); - public lastState: VirtualScrollState | null = null; - public lastDataRequest: VirtualScrollDataRequest | null = null; - - public onStateChange(state: VirtualScrollState) { - this.lastState = state; - } - - public onDataRequest(req: VirtualScrollDataRequest) { - this.lastDataRequest = req; - } +class TestNoTemplateHostComponent { + public items = signal(generateItems(50)); } @Component({ - selector: 'test-virtual-scroll-programmatic-template', + selector: 'test-virtual-scroll-programmatic', template: ` -
{{ i }}: {{ item }}
+ {{ i }}: {{ item }}
- - + `, imports: [IgxVirtualScrollComponent], }) class TestProgrammaticTemplateComponent { - public items = generateItems(50); - public tpl = viewChild>>('tpl'); + public items = signal(generateItems(50)); } -@Component({ - selector: 'test-virtual-scroll-empty', - template: ` - - -
{{ item }}
-
-
- `, - imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], -}) -class TestEmptyComponent { - public items: string[] = []; +function vsElement(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('igx-virtual-scroll'); +} + +function vsTrack(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('.igx-vs__track'); +} + +function vsContent(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('.igx-vs__content'); } -// --------------------------------------------------------------------------- -// IgxVirtualScrollComponent TestBed tests -// --------------------------------------------------------------------------- +function vsItems(fixture: ComponentFixture): HTMLElement[] { + return Array.from(fixture.nativeElement.querySelectorAll('[data-vs-index]')); +} + +function vsIndices(fixture: ComponentFixture): number[] { + return vsItems(fixture).map((el) => Number(el.dataset['vsIndex'])); +} + +/** Runs change detection and waits for the measurement passes to settle. */ +async function settle( + fixture: ComponentFixture, + scroll: IgxVirtualScrollComponent, +): Promise { + fixture.detectChanges(); + await scroll.layoutComplete; + fixture.detectChanges(); + await scroll.layoutComplete; + fixture.detectChanges(); +} + +/** Sets a scroll offset on the given axis and dispatches a synthetic scroll. */ +async function scrollTo( + fixture: ComponentFixture, + scroll: IgxVirtualScrollComponent, + offset: number, + axis: 'top' | 'left' = 'top', +): Promise { + const element = vsElement(fixture); + + if (axis === 'top') { + element.scrollTop = offset; + } else { + element.scrollLeft = offset; + } + + element.dispatchEvent(new Event('scroll')); + await settle(fixture, scroll); +} describe('IgxVirtualScrollComponent', () => { - describe('basic rendering', () => { - let fixture: ComponentFixture; - let component: TestBasicComponent; + let fixture: ComponentFixture; + let host: TestHostComponent; + let scroll: IgxVirtualScrollComponent; + + async function createFixture(): Promise { + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.autoDetectChanges(); + await fixture.whenStable(); + scroll = host.vs() as IgxVirtualScrollComponent; + await settle(fixture, scroll); + } - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [TestBasicComponent], - }).compileComponents(); - })); + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TestHostComponent, + TestRtlHostComponent, + TestNoTemplateHostComponent, + TestProgrammaticTemplateComponent, + ], + }).compileComponents(); + })); - beforeEach(() => { - fixture = TestBed.createComponent(TestBasicComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + describe('basic rendering', () => { + beforeEach(async () => { + await createFixture(); }); it('should create the component', () => { - const vs = fixture.debugElement.query(By.directive(IgxVirtualScrollComponent)); - expect(vs).toBeTruthy(); + expect( + fixture.debugElement.query(By.directive(IgxVirtualScrollComponent)), + ).toBeTruthy(); }); it('should have the igx-virtual-scroll class and role="list"', () => { - const el: HTMLElement = fixture.debugElement.query( - By.directive(IgxVirtualScrollComponent) - ).nativeElement; - expect(el.classList).toContain('igx-virtual-scroll'); - expect(el.getAttribute('role')).toBe('list'); + const element = vsElement(fixture); + + expect(element.classList).toContain('igx-virtual-scroll'); + expect(element.getAttribute('role')).toBe('list'); }); it('should add the vertical modifier class by default', () => { - const el: HTMLElement = fixture.debugElement.query( - By.directive(IgxVirtualScrollComponent) - ).nativeElement; - expect(el.classList).toContain('igx-virtual-scroll--vertical'); - expect(el.classList).not.toContain('igx-virtual-scroll--horizontal'); - }); - - it('should render a subset of items (not all 100)', () => { - const items = fixture.debugElement.queryAll(By.css('.item')); - expect(items.length).toBeGreaterThan(0); - expect(items.length).toBeLessThan(component.items().length); - }); - - it('should render the track element with a non-zero height', () => { - const track: HTMLElement = fixture.debugElement.query( - By.css('.igx-vs__track') - ).nativeElement; - const heightPx = parseInt(track.style.height, 10); - expect(heightPx).toBeGreaterThan(0); - }); - - it('should contain a content wrapper with a transform style', () => { - const content: HTMLElement = fixture.debugElement.query( - By.css('.igx-vs__content') - ).nativeElement; - expect(content.style.transform).toMatch(/translateY/); - }); - - it('should reflect updated data after input change', fakeAsync(() => { - component.items.set(generateItems(5)); - fixture.detectChanges(); - tick(); - const items = fixture.debugElement.queryAll(By.css('.item')); - expect(items.length).toBe(5); - })); - - it('should render no items when data is empty', fakeAsync(() => { - component.items.set([]); - fixture.detectChanges(); - tick(); - const items = fixture.debugElement.queryAll(By.css('.item')); - expect(items.length).toBe(0); - })); + expect(vsElement(fixture).classList).toContain('igx-virtual-scroll--vertical'); + }); + + it('should render only a subset of the items', () => { + const rendered = vsItems(fixture); + + expect(rendered.length).toBeGreaterThan(0); + expect(rendered.length).toBeLessThan(100); + }); + + it('should render the track element sized to the full virtual extent', () => { + expect(vsTrack(fixture).style.height).toBe(`${100 * 50}px`); + }); + + it('should wrap every rendered item and tag it with its data index', () => { + const rendered = vsItems(fixture); + + for (const element of rendered) { + expect(element.classList).toContain('igx-vs__item'); + expect(element.getAttribute('role')).toBe('presentation'); + } + + expect(vsIndices(fixture)).toEqual( + rendered.map((_, i) => Number(rendered[0].dataset['vsIndex']) + i), + ); + }); + + it('should apply a transform to the content wrapper', () => { + expect(vsContent(fixture).style.transform).toMatch(/translateY\(/); + }); + + it('should reflect updated data', async () => { + host.items.set(generateItems(10)); + await settle(fixture, scroll); + + expect(vsTrack(fixture).style.height).toBe(`${10 * 50}px`); + // A 300px viewport of 50px items shows 0..6, plus an over-scan of 2. + expect(Math.max(...vsIndices(fixture))).toBe(8); + }); + + it('should render no items when data is empty', async () => { + host.items.set([]); + await settle(fixture, scroll); + + expect(vsItems(fixture).length).toBe(0); + expect(vsTrack(fixture)).toBeTruthy(); + }); + + it('should render nothing without an item template', async () => { + const noTemplate = TestBed.createComponent(TestNoTemplateHostComponent); + noTemplate.autoDetectChanges(); + await noTemplate.whenStable(); + + expect(vsItems(noTemplate).length).toBe(0); + }); + + it('should render items from a programmatic itemTemplate', async () => { + const programmatic = TestBed.createComponent(TestProgrammaticTemplateComponent); + programmatic.autoDetectChanges(); + await programmatic.whenStable(); + + expect(vsItems(programmatic).length).toBeGreaterThan(0); + expect(programmatic.nativeElement.textContent).toContain('0: Item 0'); + }); }); - describe('horizontal orientation', () => { - let fixture: ComponentFixture; + describe('input normalization', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should fall back to the default for a non-positive estimatedItemSize', async () => { + host.estimatedItemSize.set(0); + host.items.set(generateItems(1000)); + host.itemHeight.set(20); + await settle(fixture, scroll); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [TestHorizontalComponent], - }).compileComponents(); - })); + // Rendered items are measured at 20px; the rest must fall back to + // the default estimate of 50px rather than collapsing to zero. + const measured = vsItems(fixture).length; + const expected = measured * 20 + (1000 - measured) * 50; - beforeEach(() => { - fixture = TestBed.createComponent(TestHorizontalComponent); - fixture.detectChanges(); + expect(vsTrack(fixture).style.height).toBe(`${expected}px`); }); - it('should add the horizontal modifier class', () => { - const el: HTMLElement = fixture.debugElement.query( - By.directive(IgxVirtualScrollComponent) - ).nativeElement; - expect(el.classList).toContain('igx-virtual-scroll--horizontal'); - expect(el.classList).not.toContain('igx-virtual-scroll--vertical'); + it('should clamp a negative overScan to zero', async () => { + host.overScan.set(-5); + host.itemHeight.set(50); + await settle(fixture, scroll); + + // A 300px viewport of 50px items shows 7 items and nothing extra. + expect(vsItems(fixture).length).toBe(7); }); + }); - it('should set a width on the track element instead of height', () => { - const track: HTMLElement = fixture.debugElement.query( - By.css('.igx-vs__track') - ).nativeElement; - expect(track.style.width).toBeTruthy(); + describe('orientation', () => { + beforeEach(async () => { + await createFixture(); }); - it('should apply a translateX transform to the content wrapper', () => { - const content: HTMLElement = fixture.debugElement.query( - By.css('.igx-vs__content') - ).nativeElement; - expect(content.style.transform).toMatch(/translateX/); + it('should add the horizontal modifier class and size the track by width', async () => { + host.useHorizontal(); + await settle(fixture, scroll); + + const element = vsElement(fixture); + expect(element.classList).toContain('igx-virtual-scroll--horizontal'); + expect(vsTrack(fixture).style.width).toBe(`${100 * 50}px`); + expect(vsContent(fixture).style.transform).toMatch(/translateX\(/); + }); + + it('should re-read the scroll offset from the new axis when it changes', async () => { + host.useHorizontal(); + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + await scrollTo(fixture, scroll, 500, 'left'); + expect(Math.min(...vsIndices(fixture))).toBeGreaterThan(0); + + // The vertical axis was never scrolled. A switch to it must render + // from the top, not reuse the horizontal offset. + host.useVertical(); + await settle(fixture, scroll); + + expect(Math.min(...vsIndices(fixture))).toBe(0); }); }); - describe('horizontal orientation (RTL)', () => { - let fixture: ComponentFixture; + describe('scroll handling', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should not invalidate the window for a scroll that stays inside it', async () => { + host.overScan.set(0); + host.items.set(generateItems(500)); + await settle(fixture, scroll); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [TestHorizontalRtlComponent], - }).compileComponents(); - })); + const tick = () => (scroll as any)._scrollTick() as number; + const before = tick(); + const element = vsElement(fixture); - beforeEach(() => { - fixture = TestBed.createComponent(TestHorizontalRtlComponent); - fixture.detectChanges(); + // Items are 50px tall and the over-scan is off, so any offset below + // the first item boundary renders the same window. + element.scrollTop = 10; + element.dispatchEvent(new Event('scroll')); + expect(tick()).toBe(before); + + element.scrollTop = 400; + element.dispatchEvent(new Event('scroll')); + expect(tick()).toBe(before + 1); }); - it('should apply a non-positive translateX transform in RTL', () => { - const content: HTMLElement = fixture.debugElement.query( - By.css('.igx-vs__content') - ).nativeElement; - const match = /translateX\((-?\d+(?:\.\d+)?)px\)/.exec( - content.style.transform - ); - expect(match).not.toBeNull(); - // In RTL the content wrapper is anchored to the right edge and - // translates towards the negative (leading) direction. - expect(parseFloat(match![1])).toBeLessThanOrEqual(0); - }); - - it('should normalize the RTL negative scrollLeft into a positive scroll position', fakeAsync(() => { - const vsEl: HTMLElement = fixture.debugElement.query( - By.directive(IgxVirtualScrollComponent) - ).nativeElement; - - // Standards-compliant browsers report a negative scrollLeft in RTL. - // Scroll far enough that the first rendered item moves past the - // over-scan buffer, so the content wrapper is actually translated. - Object.defineProperty(vsEl, 'scrollLeft', { - get: () => -800, - configurable: true, - }); - vsEl.dispatchEvent(new Event('scroll')); - fixture.detectChanges(); - tick(); - - const content: HTMLElement = fixture.debugElement.query( - By.css('.igx-vs__content') - ).nativeElement; - const match = /translateX\((-?\d+(?:\.\d+)?)px\)/.exec( - content.style.transform - ); - expect(match).not.toBeNull(); - // The normalized (positive) scroll position maps to a leading-edge - // offset that is applied in the negative (RTL) direction. - expect(parseFloat(match![1])).toBeLessThan(0); - })); - - it('should scroll to a negative scrollLeft in RTL via scrollToIndex', () => { - const vs = fixture.debugElement.query( - By.directive(IgxVirtualScrollComponent) - ).componentInstance as IgxVirtualScrollComponent; - const vsEl: HTMLElement = fixture.debugElement.query( - By.directive(IgxVirtualScrollComponent) - ).nativeElement; - - vs.scrollToIndex(10); - // In RTL the offset is applied as a negative scrollLeft. - expect(vsEl.scrollLeft).toBeLessThan(0); + it('should render a later window after scrolling', async () => { + host.items.set(generateItems(500)); + await settle(fixture, scroll); + + await scrollTo(fixture, scroll, 2000); + + expect(Math.min(...vsIndices(fixture))).toBeGreaterThan(0); }); }); describe('events', () => { - let fixture: ComponentFixture; - let component: TestEventsComponent; + beforeEach(async () => { + await createFixture(); + }); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [TestEventsComponent], - }).compileComponents(); - })); + it('should emit stateChange with the rendered window', () => { + const state = host.states.at(-1); - beforeEach(() => { - fixture = TestBed.createComponent(TestEventsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + expect(state).toBeTruthy(); + expect(state!.startIndex).toBeLessThanOrEqual(state!.endIndex); + expect(state!.viewportSize).toBeGreaterThan(0); + expect(state!.totalSize).toBe(100 * 50); }); - it('should emit stateChange after initial render', () => { - expect(component.lastState).not.toBeNull(); + it('should not re-emit stateChange when the window is unchanged', async () => { + host.items.set(generateItems(500)); + await settle(fixture, scroll); + + host.states.length = 0; + + // A scroll inside the current window changes nothing. + await scrollTo(fixture, scroll, 10); + expect(host.states.length).toBe(0); + + await scrollTo(fixture, scroll, 2000); + expect(host.states.length).toBeGreaterThan(0); }); - it('stateChange should include startIndex, endIndex, viewportSize, and totalSize', () => { - const state = component.lastState!; - expect(state.startIndex).toBeDefined(); - expect(state.endIndex).toBeDefined(); - expect(state.viewportSize).toBeDefined(); - expect(state.totalSize).toBeGreaterThan(0); + it('should emit dataRequest when the window reaches the end of data', async () => { + host.items.set(generateItems(4)); + await settle(fixture, scroll); + + expect(host.requests.at(-1)).toEqual({ startIndex: 4, count: 20 }); }); - it('stateChange startIndex should be less than or equal to endIndex', () => { - expect(component.lastState!.startIndex).toBeLessThanOrEqual( - component.lastState!.endIndex - ); + it('should not re-request the same items when data is reassigned without growing', async () => { + host.items.set(generateItems(4)); + await settle(fixture, scroll); + + host.requests.length = 0; + + // A consumer whose source is exhausted, but which still reassigns in + // response to the request it cannot fulfil. Without the guard, this + // loops for as long as the consumer answers. + host.items.set(generateItems(4)); + await settle(fixture, scroll); + host.items.set(generateItems(4)); + await settle(fixture, scroll); + + expect(host.requests.length).toBe(0); }); - it('should emit dataRequest when near the end of data', fakeAsync(() => { - // Provide a very small list so the visible range is near the end. - // dataRequest must not fire on initial render — only after the user - // has scrolled (scrollPosition > 0 guard added to the effect). - component.items = generateItems(3); - fixture.detectChanges(); - tick(); - expect(component.lastDataRequest).toBeNull(); + it('should request again once data actually grows', async () => { + host.items.set(generateItems(4)); + await settle(fixture, scroll); - // Simulate a scroll event so scrollPosition becomes > 0. - const vsEl: HTMLElement = fixture.debugElement.query( - By.directive(IgxVirtualScrollComponent) - ).nativeElement; + host.requests.length = 0; - Object.defineProperty(vsEl, 'scrollTop', { - get: () => 1, - configurable: true, - }); - vsEl.dispatchEvent(new Event('scroll')); - fixture.detectChanges(); - tick(); + host.items.set(generateItems(8)); + await settle(fixture, scroll); - expect(component.lastDataRequest).not.toBeNull(); - expect(component.lastDataRequest!.startIndex).toBe(3); - })); + expect(host.requests.at(-1)).toEqual({ startIndex: 8, count: 20 }); + }); }); - describe('programmatic itemTemplate input', () => { - let fixture: ComponentFixture; + describe('engine integration', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should resize the track when data changes', async () => { + expect(vsTrack(fixture).style.height).toBe(`${100 * 50}px`); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [TestProgrammaticTemplateComponent], - }).compileComponents(); - })); + host.items.set(generateItems(200)); + await settle(fixture, scroll); - beforeEach(() => { - fixture = TestBed.createComponent(TestProgrammaticTemplateComponent); - fixture.detectChanges(); + expect(vsTrack(fixture).style.height).toBe(`${200 * 50}px`); }); - it('should render items using the programmatic template', () => { - const items = fixture.debugElement.queryAll(By.css('.item')); - expect(items.length).toBeGreaterThan(0); + it('should apply a new estimatedItemSize when the item count is unchanged', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + const engine = engineOf(scroll); + expect(engine.totalSize()).toBe(1000 * 50); + + // `resize` is a no-op at an unchanged item count, so this only takes + // effect through `updateEstimatedSize`. + host.estimatedItemSize.set(80); + await settle(fixture, scroll); + + // The rendered items keep their measured 50px, the rest follow 80px. + expect(engine.getScrollOffsetForIndex(1)).toBe(50); + expect(engine.totalSize()).toBeGreaterThan(1000 * 50); + expect(engine.totalSize()).toBeLessThan(1000 * 80); }); - }); - describe('empty data', () => { - let fixture: ComponentFixture; + it('should retain measurements on append and discard them on replacement', async () => { + host.items.set(generateItems(20)); + await settle(fixture, scroll); + + const engine = engineOf(scroll); + const resizeSpy = spyOn(engine, 'resize').and.callThrough(); + + // An append keeps the identity of each existing index, so all 20 + // measurements are retained. + host.items.update((items) => [...items, ...generateItems(5)]); + await settle(fixture, scroll); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [TestEmptyComponent], - }).compileComponents(); - })); + expect(resizeSpy.calls.mostRecent().args).toEqual([25, 50, 20]); - beforeEach(() => { - fixture = TestBed.createComponent(TestEmptyComponent); - fixture.detectChanges(); + // A replacement invalidates each index from the first difference on. + host.items.update((items) => items.map((item) => `${item}!`)); + await settle(fixture, scroll); + + expect(resizeSpy.calls.mostRecent().args).toEqual([25, 50, 0]); }); - it('should not render any items', () => { - const items = fixture.debugElement.queryAll(By.css('.item')); - expect(items.length).toBe(0); + it('should discard stale measurements when data of the same length is swapped', async () => { + host.items.set(generateItems(20)); + await settle(fixture, scroll); + + const engine = engineOf(scroll); + const resizeSpy = spyOn(engine, 'resize').and.callThrough(); + + // An identical item count used to make `resize` a no-op. That left + // the previous data's measurements on the new items. + host.items.set(generateItems(20).map((item) => `${item}!`)); + await settle(fixture, scroll); + + expect(resizeSpy.calls.mostRecent().args).toEqual([20, 50, 0]); }); - it('should still render the track element', () => { - const track = fixture.debugElement.query(By.css('.igx-vs__track')); - expect(track).toBeTruthy(); + it('should not override the size of items already measured in the DOM', async () => { + host.items.set(generateItems(20)); + host.hostHeight.set(100); + host.itemHeight.set(30); + await settle(fixture, scroll); + + const engine = engineOf(scroll); + expect(engine.getScrollOffsetForIndex(1)).toBe(30); + + host.estimatedItemSize.set(200); + await settle(fixture, scroll); + + // Item 0 was measured in the DOM, so the new estimate cannot move it, + // while the unmeasured items at the end do follow it. + expect(engine.getScrollOffsetForIndex(1)).toBe(30); + expect(engine.totalSize()).toBeGreaterThan(20 * 30); + expect(engine.totalSize()).toBeLessThan(20 * 200); + }); + + it('should re-measure reused item elements when they host a different index', async () => { + host.items.set(generateItems(50)); + host.hostHeight.set(90); + host.itemHeight.set(30); + await settle(fixture, scroll); + + const element = vsElement(fixture); + + // Jump to the end. `@for` tracks by slot, so it reuses the wrapper + // elements for the new indices at an identical size and the + // ResizeObserver does not report that. Those indices used to keep + // their estimated size, which left a gap between the last item and + // the end of the track. Measurements at the bottom shrink the track, + // so apply the jump again until the scroll height is stable. + for (let i = 0; i < 10; i++) { + const height = element.scrollHeight; + await scrollTo(fixture, scroll, element.scrollHeight); + + if (element.scrollHeight === height) break; + } + + const items = vsItems(fixture); + const last = items[items.length - 1]; + + expect(last.dataset['vsIndex']).toBe('49'); + expect(last.getBoundingClientRect().bottom).toBeCloseTo( + vsTrack(fixture).getBoundingClientRect().bottom, + 0, + ); }); }); describe('scrollToIndex', () => { - let fixture: ComponentFixture; - let vsComponent: IgxVirtualScrollComponent; + beforeEach(async () => { + await createFixture(); + }); + + it('should scroll the vertical axis', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [TestBasicComponent], - }).compileComponents(); - })); + await scroll.scrollToIndex(100); - beforeEach(() => { - fixture = TestBed.createComponent(TestBasicComponent); - fixture.detectChanges(); - vsComponent = fixture.debugElement.query( - By.directive(IgxVirtualScrollComponent) - ).componentInstance; + expect(vsElement(fixture).scrollTop).toBe(100 * 50); }); - it('should not throw when scrolling to a valid index', () => { - expect(() => vsComponent.scrollToIndex(10)).not.toThrow(); + it('should scroll the horizontal axis', async () => { + host.useHorizontal(); + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + await scroll.scrollToIndex(100); + + expect(vsElement(fixture).scrollLeft).toBe(100 * 50); }); - it('should not throw when scrolling to index 0', () => { - expect(() => vsComponent.scrollToIndex(0)).not.toThrow(); + it('should align the item to the center of the viewport', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + await scroll.scrollToIndex(100, { block: 'center' }); + + // 100 * 50 - (300 - 50) / 2 + expect(vsElement(fixture).scrollTop).toBe(5000 - 125); }); - it('should not throw when scrolling to the last index', () => { - expect(() => vsComponent.scrollToIndex(99)).not.toThrow(); + it('should align the item to the trailing edge of the viewport', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + await scroll.scrollToIndex(100, { block: 'end' }); + + // 100 * 50 - (300 - 50) + expect(vsElement(fixture).scrollTop).toBe(5000 - 250); }); - }); -}); -// --------------------------------------------------------------------------- -// IgxVirtualItemDirective -// --------------------------------------------------------------------------- - -describe('IgxVirtualItemDirective', () => { - @Component({ - selector: 'test-directive-host', - template: ` - - -
{{ item }}
-
-
- `, - imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], - }) - class DirectiveHostComponent { - public items = generateItems(10); - } + it('should settle at the last index instead of waiting out the scroll timeout', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); - let fixture: ComponentFixture; + const element = vsElement(fixture); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [DirectiveHostComponent], - }).compileComponents(); - })); + // The aligned offset for the final item lies past the reachable + // scroll range. Without a clamp, each correction pass would wait for + // a `scrollend` that the browser never fires. + await scroll.scrollToIndex(999, { block: 'end' }); + + expect(element.scrollTop).toBe(element.scrollHeight - element.clientHeight); + }); + + it('should not scroll for block: nearest when the item is already in view', async () => { + host.items.set(generateItems(1000)); + await settle(fixture, scroll); + + const element = vsElement(fixture); + const scrollToSpy = spyOn(element, 'scrollTo').and.callThrough(); + + await scroll.scrollToIndex(1, { block: 'nearest' }); + + expect(scrollToSpy).not.toHaveBeenCalled(); + expect(element.scrollTop).toBe(0); + }); - beforeEach(() => { - fixture = TestBed.createComponent(DirectiveHostComponent); - fixture.detectChanges(); + it('should leave an item that already fills the viewport alone for block: nearest', async () => { + host.items.set(generateItems(20)); + host.estimatedItemSize.set(400); + host.itemHeight.set(400); + await settle(fixture, scroll); + + // Item 0 spans 0-400px and the viewport is 50-350px, so the item + // covers it fully. The item cannot fit inside the viewport, but + // there is also nothing to scroll to. + await scrollTo(fixture, scroll, 50); + + const element = vsElement(fixture); + const scrollToSpy = spyOn(element, 'scrollTo').and.callThrough(); + + await scroll.scrollToIndex(0, { block: 'nearest' }); + + expect(scrollToSpy).not.toHaveBeenCalled(); + expect(element.scrollTop).toBe(50); + }); + + it('should keep the requested index aligned once real sizes differ from the estimate', async () => { + host.items.set(generateItems(500)); + host.itemHeight.set(30); // smaller than the estimate of 50 + await settle(fixture, scroll); + + await scroll.scrollToIndex(250); + + expect(Math.min(...vsIndices(fixture))).toBe(250 - host.overScan()); + }); + + it('should clamp an out of range index', async () => { + host.items.set(generateItems(20)); + await settle(fixture, scroll); + + const element = vsElement(fixture); + await scroll.scrollToIndex(9999); + + expect(element.scrollTop).toBe(element.scrollHeight - element.clientHeight); + }); }); - it('should be picked up as a content child of IgxVirtualScrollComponent', () => { - // By.directive() is unreliable for ng-template nodes in headless environments; - // access the component's contentChild signal directly instead. - const vs = fixture.debugElement - .query(By.directive(IgxVirtualScrollComponent)) - .componentInstance as any; - expect(vs._itemDirective()).not.toBeNull(); + describe('layoutComplete', () => { + beforeEach(async () => { + await createFixture(); + }); + + it('should settle when no animation frames are served', async () => { + const rafSpy = spyOn(window, 'requestAnimationFrame').and.returnValue(0); + + try { + host.items.set(generateItems(200)); + fixture.detectChanges(); + await scroll.layoutComplete; + } finally { + rafSpy.and.callThrough(); + } + + expect(rafSpy).toHaveBeenCalled(); + }); }); - it('should expose a non-null TemplateRef', () => { - const vs = fixture.debugElement - .query(By.directive(IgxVirtualScrollComponent)) - .componentInstance as any; - expect(vs._itemDirective()?.template).toBeTruthy(); + describe('RTL', () => { + let rtlFixture: ComponentFixture; + let rtlScroll: IgxVirtualScrollComponent; + + beforeEach(async () => { + rtlFixture = TestBed.createComponent(TestRtlHostComponent); + rtlFixture.autoDetectChanges(); + await rtlFixture.whenStable(); + rtlScroll = rtlFixture.componentInstance.vs() as IgxVirtualScrollComponent; + await settle(rtlFixture, rtlScroll); + }); + + it('should normalize the negative scrollLeft into a positive engine offset', async () => { + // In RTL, browsers report scrollLeft as a negative value. + await scrollTo(rtlFixture, rtlScroll, -500, 'left'); + + expect(Math.min(...vsIndices(rtlFixture))).toBeGreaterThan(0); + }); + + it('should apply a negative translateX on the content wrapper when scrolled', async () => { + await scrollTo(rtlFixture, rtlScroll, -300, 'left'); + + expect(vsContent(rtlFixture).style.transform).toMatch( + /translateX\(-\d+(\.\d+)?px\)/, + ); + }); + + it('should scroll to a negative scrollLeft via scrollToIndex', async () => { + const element = vsElement(rtlFixture); + const scrollToSpy = spyOn(element, 'scrollTo').and.callThrough(); + + await rtlScroll.scrollToIndex(100); + + expect(scrollToSpy).toHaveBeenCalled(); + const args = scrollToSpy.calls.mostRecent().args[0] as ScrollToOptions; + expect(args.left).toBeLessThan(0); + }); + + it('should render the first data item as the right-most item', () => { + const items = vsItems(rtlFixture); + expect(items.length).toBeGreaterThan(1); + + const indices = vsIndices(rtlFixture); + // DOM order is ascending by data index... + expect(indices[0]).toBeLessThan(indices[1]); + + // ...but visually the lowest index sits to the right of the next. + expect(items[0].getBoundingClientRect().left).toBeGreaterThan( + items[1].getBoundingClientRect().left, + ); + }); }); }); diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts index 4ae98aadcfb..e943713ebff 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -1,4 +1,7 @@ +import { isPlatformBrowser, NgTemplateOutlet } from "@angular/common"; import { + afterNextRender, + afterRenderEffect, ChangeDetectionStrategy, Component, computed, @@ -6,7 +9,6 @@ import { DOCUMENT, effect, ElementRef, - EmbeddedViewRef, inject, input, NgZone, @@ -17,25 +19,98 @@ import { TemplateRef, untracked, viewChild, - ViewContainerRef, } from "@angular/core"; -import { IgxVirtualItemDirective } from "./virtual-scroll-item.directive"; +import { clamp, isLeftToRight } from "igniteui-angular/core"; +import { VirtualScrollEngine } from "./scroll-engine"; import { IgxVsItemContext, + ScrollAlignment, VirtualScrollDataRequest, VirtualScrollState, + VisibleRange, } from "./types"; -import { VirtualScrollEngine } from "./scroll-engine"; -import { isPlatformBrowser } from "@angular/common"; -import { isLeftToRight } from "igniteui-angular/core"; +import { IgxVirtualItemDirective } from "./virtual-scroll-item.directive"; + +/** Defaults for the inputs, also used as the fallback for invalid values. */ +const DEFAULT_OVER_SCAN = 2; +const DEFAULT_ESTIMATED_ITEM_SIZE = 50; + +/** How close to the end of `data` the window must get to emit `dataRequest`. */ +const DATA_REQUEST_THRESHOLD = 5; +const DATA_REQUEST_MIN_COUNT = 20; +const DATA_REQUEST_OVER_SCAN_FACTOR = 4; + +/** Give-up bounds for the two loops that wait for the layout to stabilize. */ +const MAX_LAYOUT_SETTLE_PASSES = 20; +const MAX_SCROLL_CORRECTION_PASSES = 5; + +const SCROLL_END_TIMEOUT_MS = 2000; +const SCROLL_OFFSET_EPSILON_PX = 1; + +/** How long the scroll position must stay unchanged to count as settled. */ +const SCROLL_IDLE_MS = 100; + +/** + * Upper limit on one `requestAnimationFrame` wait. A hidden tab or a detached + * element gets no frames, and `layoutComplete` must still resolve there. + */ +const LAYOUT_FRAME_TIMEOUT_MS = 100; + +const EMPTY_RANGE: VisibleRange = Object.freeze({ startIndex: 0, endIndex: -1 }); + +function rangesEqual(a: VisibleRange, b: VisibleRange): boolean { + return a.startIndex === b.startIndex && a.endIndex === b.endIndex; +} + +function statesEqual( + a: VirtualScrollState | null, + b: VirtualScrollState, +): boolean { + return ( + a !== null && + rangesEqual(a, b) && + a.viewportSize === b.viewportSize && + a.totalSize === b.totalSize + ); +} + +/** The data index an item wrapper carries, or -1 when it has none. */ +function itemIndex(element: Element): number { + const index = Number.parseInt( + (element as HTMLElement).dataset["vsIndex"] ?? "", + 10, + ); + return Number.isInteger(index) && index >= 0 ? index : -1; +} -const REMOTE_SCROLLING_THRESHOLD = 5; +function onAbort(abort: AbortSignal, cancel: () => void): void { + abort.addEventListener("abort", cancel, { once: true }); +} +/** + * A virtual scroll component for large lists. Only the items visible in the + * viewport (plus a configurable over-scan) are rendered. + * + * @igxModule IgxVirtualScrollModule + * @igxTheme igx-virtual-scroll-theme + * @igxKeywords virtual, scroll, virtualization, list + * @igxGroup Grids & Lists + * + * @example + * ```html + * + * + *
{{ i }}: {{ item }}
+ *
+ *
+ * ``` + */ @Component({ selector: "igx-virtual-scroll", templateUrl: "./virtual-scroll.component.html", styleUrls: ["./virtual-scroll.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgTemplateOutlet], host: { class: "igx-virtual-scroll", role: "list", @@ -44,102 +119,76 @@ const REMOTE_SCROLLING_THRESHOLD = 5; }, }) export class IgxVirtualScrollComponent implements OnDestroy { - //#region Dependency Injections + //#region Dependency injection + private readonly _hostRef = inject>(ElementRef); private readonly _zone = inject(NgZone); private readonly _document = inject(DOCUMENT); - private readonly _platformId = inject(PLATFORM_ID); + private readonly _isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); //#endregion + //#region Internal state + + private readonly _engine = new VirtualScrollEngine(); + private _viewportResizeObserver: ResizeObserver | null = null; private _itemResizeObserver: ResizeObserver | null = null; - private _onScroll: ((e: Event) => void) | null = null; + private _onScroll: (() => void) | null = null; - /** - * Guards against emitting duplicate `dataRequest` events for the same - * `startIndex` while waiting for the consumer to append the requested items. - * Reset whenever `data` changes (i.e. new items have arrived). - */ - private _hasPendingDataRequest = false; + /** Elements currently registered with the item resize observer. */ + private readonly _observedItems = new Set(); - /** Views currently inserted into the VCR, ordered by rendered item index. */ - private readonly _activeItems: EmbeddedViewRef>[] = []; + /** The data index each observed wrapper element last hosted. */ + private readonly _observedItemIndexes = new WeakMap(); - /** Detached views available for reuse. */ - private readonly _pooledItems: EmbeddedViewRef>[] = []; + /** + * The live scroll offset on the active axis. A plain field, not a signal: + * `_visibleRange` reads it but is invalidated by `_scrollTick`, so a scroll + * that does not move the rendered window schedules no work. + */ + private _scrollPosition = 0; - protected readonly _engine = new VirtualScrollEngine(); + /** Bumped only when a scroll actually moves the rendered window. */ + private readonly _scrollTick = signal(0); - private readonly _scrollPosition = signal(0); private readonly _viewportSize = signal(0); - private readonly _visibleRange = computed(() => { - - // Establish a reactive dependency on domSize so the range recomputes - // whenever measureItem() changes _virtualRatio. Without this, the range - // stays stale while scroll position is unchanged but sizes have updated: - // - items smaller than estimated -> viewport is not fully filled - // - virtual-ratio increase at end -> last items are in the DOM but - // positioned beyond the max scroll coordinate - void this._engine.domSize(); + /** The `data` array as of the previous change, for `_firstChangedIndex`. */ + private _previousData: T[] | undefined; - return this._engine.getVisibleRange( - this._scrollPosition(), - this._viewportSize(), - this.overScan(), - this.data().length, - ); - }); + private _lastEmittedState: VirtualScrollState | null = null; + private _hasPendingDataRequest = false; - protected readonly _isVertical = computed( - () => this.orientation() === "vertical", - ); - protected readonly _spaceSize = computed(() => this._engine.domSize()); - protected readonly _contentTransform = computed(() => { - const range = this._visibleRange(); - let position = this._engine.getContentPosition(range.startIndex); - - // Under coordinate compression (_virtualRatio > 1) item virtual positions - // are scaled down but item physical heights are not. Without this cap the - // rendered range overflows past domSize at the end of the list, pushing - // the last items beyond the maximum browser scroll coordinate. - const physicalRangeSize = this._engine.getPhysicalRangeSize( - range.startIndex, - range.endIndex, - ); - const domSize = this._engine.domSize(); - position = Math.max(0, Math.min(position, domSize - physicalRangeSize)); + /** + * The `startIndex` of the last emitted `dataRequest`, which is also the + * item count at that emit. See `_checkDataRequest`. + */ + private _lastDataRequestIndex = -1; - if (this._isVertical()) { - return `translateY(${position}px)`; - } + private _layoutCompletePromise: Promise | null = null; + private _scrollRequestId = 0; - // In RTL the content wrapper is anchored to the right edge of the track, - // so it must translate towards the negative (leading) direction. - const offset = this._isLTR() ? position : -position; - return `translateX(${offset}px)`; - }); + //#endregion - //#region View and Content Children + //#region View and content children private readonly _itemDirective = contentChild(IgxVirtualItemDirective); - private readonly _itemsViewContainer = viewChild( - "itemsAnchor", - { read: ViewContainerRef }, - ); - private readonly _contentDivRef = viewChild>("contentDiv"); - protected readonly _resolvedTemplate = computed(() => { - return this.itemTemplate() ?? this._itemDirective()?.template ?? null; - }); - //#endregion - /** The array of items to virtualize. */ + //#region Public inputs + + /** + * The array of items to virtualize. + * + * Compared by reference: mutating the array in place (`data.push(...)`) + * causes no update. Assign a new array instead. The `dataRequest` flow + * also expects a new array. + */ public readonly data = input([]); /** @@ -154,7 +203,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { * Higher values reduce blank flashes during fast scrolling but may impact performance. * Default is 2. */ - public readonly overScan = input(2); + public readonly overScan = input(DEFAULT_OVER_SCAN); /** * Estimated item size in pixels used before an item is measured in the DOM. @@ -162,107 +211,203 @@ export class IgxVirtualScrollComponent implements OnDestroy { * Default is 50 pixels. * Setting this to a value close to the actual average item size can improve initial rendering performance. */ - public readonly estimatedItemSize = input(50); + public readonly estimatedItemSize = input(DEFAULT_ESTIMATED_ITEM_SIZE); /** - * Item template provided programmatically (takes precedence over content template if both are provided). + * Item template provided programmatically. Takes precedence over a content + * `ng-template[igxVirtualItem]` when both are provided. * - * This template will be used to render each item in the virtual scroll. - * The context for the template will include the item data and its index. - * If not provided, the component will look for an `ng-template` with the `igxVirtualItem` directive in its content. + * Items are measured by their border box, so margins accumulate as drift + * down the list. Use padding on the item, or a gap on a wrapper, instead. + * + * Only the current window is in the DOM, so assistive technology cannot + * infer an item's position from the markup. Templates that render a role + * with set semantics (`listitem`, `option`, `row`, ...) should map the + * context's `index` and `count` onto `aria-posinset` and `aria-setsize`. */ public readonly itemTemplate = input> | null>( null, ); - /** - * Emitted after each render pass with a snapshot of the current virtual window. - */ + //#endregion + + //#region Public outputs + + /** Emitted when the rendered virtual window changes. */ public readonly stateChange = output(); /** - * Emitted when the scroll position approaches the end of the available data. - * Listen to this event to append more items (infinite / remote scrolling). + * Emitted when the rendered window comes within a few items of the end of + * `data`. Also emitted on the first render, when the loaded items do not + * fill the viewport. Listen to this event to append more items + * (infinite / remote scrolling). */ public readonly dataRequest = output(); + //#endregion + + //#region Derived state + + protected readonly _isVertical = computed( + () => this.orientation() === "vertical", + ); + + protected readonly _resolvedTemplate = computed( + () => this.itemTemplate() ?? this._itemDirective()?.template ?? null, + ); + + /** `data`, guarded against a nullish value set by the consumer. */ + private readonly _items = computed(() => this.data() ?? []); + + /** The configured `overScan`, normalized to a non-negative integer. */ + private readonly _normalizedOverScan = computed(() => { + const value = Number(this.overScan()); + return Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : DEFAULT_OVER_SCAN; + }); + + /** The configured `estimatedItemSize`, normalized to a positive number. */ + private readonly _normalizedItemSize = computed(() => { + const value = Number(this.estimatedItemSize()); + return Number.isFinite(value) && value > 0 + ? value + : DEFAULT_ESTIMATED_ITEM_SIZE; + }); + + /** + * The window to render for the current scroll position and viewport. Empty + * until an item template is resolved, because nothing renders without one. + * + * The scroll position is read from the plain `_scrollPosition` field so + * that a recompute triggered by a measurement uses the live offset, while + * `_scrollTick` limits recomputes to scrolls that actually move the window. + */ + private readonly _visibleRange = computed( + () => { + // Depend on the engine so the range recomputes whenever item sizes or + // the item count change. + this._engine.version(); + this._scrollTick(); + + return this._resolvedTemplate() + ? this._engine.getVisibleRange( + this._scrollPosition, + this._viewportSize(), + this._normalizedOverScan(), + ) + : EMPTY_RANGE; + }, + { equal: rangesEqual }, + ); + + /** The track size, in DOM space. */ + protected readonly _spaceSize = this._engine.domSize; + + /** The item contexts for the currently rendered window, in render order. */ + protected readonly _renderedItems = computed[]>(() => { + const { startIndex, endIndex } = this._visibleRange(); + const items = this._items(); + + const rendered: IgxVsItemContext[] = []; + for (let i = startIndex; i <= endIndex; i++) { + rendered.push(new IgxVsItemContext(items[i], i, items.length)); + } + return rendered; + }); + + /** + * The `translateY` / `translateX` for the content wrapper. It is absolutely + * positioned at the origin of a `domSize` px track, so translating it to + * the first rendered item's offset puts that item at its virtual position. + */ + protected readonly _contentTransform = computed(() => { + // The offsets below are plain reads of the engine's size state, so depend + // on its version explicitly. + this._engine.version(); + const range = this._visibleRange(); + + // Under coordinate compression item positions are scaled down but item + // sizes are not. Without this cap the rendered range would overflow past + // domSize at the end of the list, pushing the last items beyond the + // maximum browser scroll coordinate. + const position = clamp( + this._engine.getScrollOffsetForIndex(range.startIndex), + 0, + this._engine.domSize() - + this._engine.getPhysicalRangeSize(range.startIndex, range.endIndex), + ); + + if (this._isVertical()) { + return `translateY(${position}px)`; + } + + // In RTL the wrapper is anchored to the right edge of the track, so it + // translates towards the negative (leading) direction. + return `translateX(${this._isLTR() ? position : -position}px)`; + }); + + //#endregion + constructor() { - // Sync engine item count with data changes. + // Sync the engine's item count with `data`, discarding the measurements + // of items whose identity changed. effect(() => { - const count = this.data().length; - const estimated = this.estimatedItemSize(); + const items = this._items(); untracked(() => { - this._engine.resize(count, estimated); + const previous = this._previousData; + this._previousData = items; + this._engine.resize( + items.length, + this._normalizedItemSize(), + this._firstChangedIndex(previous, items), + ); // New data (or a reset) clears any in-flight data request so the next // approach to the end of the list can emit again. this._hasPendingDataRequest = false; }); }); - // Browser setup: runs after first render and whenever orientation changes. + // Re-apply the estimate when it changes but the item count does not, + // because `resize` is then a no-op. effect(() => { - const vertical = this._isVertical(); - void vertical; // Ensure vertical is tracked before accessing the engine. + const size = this._normalizedItemSize(); + untracked(() => this._engine.updateEstimatedSize(size)); + }); + + // The scroll offset of the previous axis does not carry over. + effect(() => { + this.orientation(); untracked(() => { - if (!isPlatformBrowser(this._platformId)) return; + if (!this._isBrowser) { + return; + } - this._engine.initMaxBrowserSize(this._document); this._measureViewport(); - this._setupScrollListener(); - this._setupViewportResizeObserver(); + this._scrollPosition = this._currentAxisScroll(); + this._scrollTick.update((v) => v + 1); }); }); - // Re-render whenever the visible range, data, or template changes. - effect(() => { - const range = this._visibleRange(); - const data = this.data(); - const template = this._resolvedTemplate(); - const vcr = this._itemsViewContainer(); - if (!vcr) return; - - if (range.endIndex < range.startIndex) { - // Data is empty or viewport has no size — clear any previously rendered views. - untracked(() => { - while (this._activeItems.length > 0) { - const view = this._activeItems.pop()!; - const idx = vcr.indexOf(view); - if (idx > -1) vcr.detach(idx); - this._pooledItems.push(view); - } - }); - return; - } - - if (!template) return; - - untracked(() => - this._renderRange(range.startIndex, range.endIndex, data, template), - ); + afterNextRender(() => { + this._engine.initMaxBrowserSize(this._document); + this._measureViewport(); + this._setupScrollListener(); + this._setupViewportResizeObserver(); }); - // Remote scroll: fire dataRequest when approaching the end. - effect(() => { - const range = this._visibleRange(); - const total = this.data().length; - - // Guard: do not fire on the initial render. The effect runs eagerly - // before any user interaction, and with a small initial dataset the - // visible range may already reach near the end of the loaded items. - // Only emit once the user has actually scrolled (scrollPosition > 0). - if (this._scrollPosition() === 0) return; - - // Guard: do not fire again while a previous request is still pending. - // The flag is reset when `data` changes (new items have arrived). - if (this._hasPendingDataRequest) return; - - if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) { - this._hasPendingDataRequest = true; - this.dataRequest.emit({ - startIndex: total, - count: Math.max(this.overScan() * 4, 20), + // Runs after the DOM reflects the current window, and re-runs whenever + // the window or the engine's sizes change. + afterRenderEffect({ + read: () => { + this._visibleRange(); + this._engine.version(); + untracked(() => { + this._scheduleItemMeasurement(); + this._checkDataRequest(); + this._emitStateChange(); }); - } + }, }); } @@ -270,176 +415,497 @@ export class IgxVirtualScrollComponent implements OnDestroy { this._teardown(); } - /** Programmatically scrolls to the specified item index. */ - public scrollToIndex(index: number): void { - const host = this._hostRef.nativeElement; - const offset = this._engine.getScrollOffsetForIndex(index); + //#region Public API - if (this._isVertical()) { - host.scrollTop = offset; - } else { - // Standards-compliant browsers expose a negative scrollLeft in RTL. - host.scrollLeft = this._isLTR() ? offset : -offset; + /** + * Resolves when the virtual scroll has settled: the current render pass is + * complete, the item-size measurements it triggers are complete, and so + * are the renders those measurements schedule. + */ + public get layoutComplete(): Promise { + if (!this._layoutCompletePromise) { + this._layoutCompletePromise = this._resolveLayoutComplete(); + } + return this._layoutCompletePromise; + } + + /** + * Scrolls to the specified item index. + * + * Items outside the rendered window have only an estimated size, so the + * first jump can miss the target. The items at the landing point are then + * measured and the scroll position is corrected, until the offset is + * stable. The returned promise resolves on that final offset; callers that + * need only the first, approximate scroll can ignore it. + * + * @param index The index of the item to scroll to. + * @param options `block` / `inline` select the alignment (`start`, + * `center`, `end` or `nearest`); `behavior` selects `auto` or `smooth`. + */ + public async scrollToIndex( + index: number, + options?: ScrollIntoViewOptions, + ): Promise { + const clampedIndex = clamp(index, 0, Math.max(0, this._items().length - 1)); + + // A newer call supersedes a correction loop that still runs for a + // previous call, for example under rapid, repeated calls. + const requestId = ++this._scrollRequestId; + + let offset = this._getAlignedScrollOffset(clampedIndex, options); + await this._scrollAndWaitForEnd(offset, options?.behavior ?? "auto"); + + for (let i = 0; i < MAX_SCROLL_CORRECTION_PASSES; i++) { + await this.layoutComplete; + + if (requestId !== this._scrollRequestId) { + return; + } + + const corrected = this._getAlignedScrollOffset(clampedIndex, options); + if (Math.abs(corrected - offset) < SCROLL_OFFSET_EPSILON_PX) { + break; + } + + offset = corrected; + await this._scrollAndWaitForEnd(offset, "auto"); + + if (requestId !== this._scrollRequestId) { + return; + } } } + //#endregion + + //#region Scrolling + /** Whether the host element is laid out left-to-right. */ private _isLTR(): boolean { return isLeftToRight(this._hostRef.nativeElement); } - private _renderRange( - startIndex: number, - endIndex: number, - data: T[], - template: TemplateRef>, - ): void { - const count = data.length; - const newCount = Math.max(0, endIndex - startIndex + 1); - const vcr = this._itemsViewContainer(); - if (!vcr) return; - - // Grow: pull from pool or create new views until we have enough. - while (this._activeItems.length < newCount) { - let view = this._pooledItems.pop() ?? null; - if (view) { - vcr.insert(view); - } else { - view = vcr.createEmbeddedView( - template, - new IgxVsItemContext(data[startIndex], startIndex, count), - ); - } - this._activeItems.push(view); + /** The current real scroll position on the active axis, normalized for RTL. */ + private _currentAxisScroll(): number { + const host = this._hostRef.nativeElement; + + if (this._isVertical()) { + return host.scrollTop; } - // Shrink: detach from VCR and return to pool. - while (this._activeItems.length > newCount) { - const view = this._activeItems.pop()!; - const index = vcr.indexOf(view); - if (index > -1) { - vcr.detach(index); - } - this._pooledItems.push(view); + // Standards-compliant browsers expose a negative scrollLeft in RTL. + return this._isLTR() ? host.scrollLeft : -host.scrollLeft; + } + + /** Applies a scroll offset to the active axis, accounting for RTL. */ + private _applyScroll(offset: number, behavior: ScrollBehavior): void { + const host = this._hostRef.nativeElement; + + if (this._isVertical()) { + host.scrollTo({ top: offset, behavior }); + return; } - // Update contexts in place - zero DOM allocations on steady-state scroll. - for (let i = 0; i < newCount; i++) { - const itemIndex = startIndex + i; - const view = this._activeItems[i]; - const context = view.context; - context.$implicit = data[itemIndex]; - context.index = itemIndex; - context.count = count; - view.markForCheck(); + host.scrollTo({ left: this._isLTR() ? offset : -offset, behavior }); + } + + /** + * The scroll offset that aligns `index` in the viewport according to + * `options`, from the engine's current size data. As more items are + * measured, the same input can give a different, more accurate result. + * + * For `nearest` on an item already in view, returns the current offset, so + * no scroll occurs. + */ + private _getAlignedScrollOffset( + index: number, + options?: ScrollIntoViewOptions, + ): number { + const requested = this._isVertical() + ? (options?.block ?? "start") + : (options?.inline ?? options?.block ?? "start"); + const current = this._currentAxisScroll(); + + if ( + requested === "nearest" && + this._engine.isIndexInView(index, current, this._viewportSize()) + ) { + return current; } - // Measure rendered items after the browser paints. - this._scheduleItemMeasurement(startIndex, newCount); + const align: ScrollAlignment = + requested === "center" || requested === "end" ? requested : "start"; - this.stateChange.emit({ - startIndex, - endIndex, - viewportSize: this._viewportSize(), - totalSize: this._engine.totalSize(), - }); + return this._engine.getAlignedScrollOffset( + index, + this._viewportSize(), + align, + ); } - private _scheduleItemMeasurement(startIndex: number, count: number): void { - if (!isPlatformBrowser(this._platformId)) return; + /** + * Applies a scroll offset to the active axis and waits for the scroll, + * instant or smooth, to settle. + * + * `scrollend` does not fire when the requested offset does not move the + * scroll position, so that case resolves immediately. The deadline covers + * an event that never arrives, for example when the element is detached + * mid-scroll. + */ + private _scrollAndWaitForEnd( + offset: number, + behavior: ScrollBehavior, + ): Promise { + if ( + !this._isBrowser || + Math.abs(this._currentAxisScroll() - offset) < SCROLL_OFFSET_EPSILON_PX + ) { + return Promise.resolve(); + } - if (!this._itemResizeObserver) { - this._itemResizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const el = entry.target as HTMLElement; - const index = parseInt(el.dataset["vsIndex"] ?? "-1", 10); - if (index < 0) continue; - - const measured = this._isVertical() - ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height) - : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); - - if (measured > 0) { - this._engine.measureItem(index, measured); - } - } + return this._withDeadline(SCROLL_END_TIMEOUT_MS, (abort) => { + // `scrollend` reports exactly when a scroll has settled. Safari before + // 18.2 does not have it, and the scroll-idle timer stands in there. + const settled = + "onscrollend" in this._hostRef.nativeElement + ? this._waitForScrollEnd(abort) + : this._waitForScrollIdle(abort); + + // Applied only after the listener is attached, so an instant scroll + // cannot settle before something watches for it. + this._applyScroll(offset, behavior); + return settled; + }); + } + + private _waitForScrollEnd(abort: AbortSignal): Promise { + return this._promiseOutsideZone((resolve) => { + this._hostRef.nativeElement.addEventListener("scrollend", resolve, { + once: true, + signal: abort, }); + }); + } + + /** + * Resolves when no `scroll` event arrives for `SCROLL_IDLE_MS`: the closest + * replacement for `scrollend`. The first timer starts immediately, so a + * scroll that does not move still settles. + */ + private _waitForScrollIdle(abort: AbortSignal): Promise { + return this._promiseOutsideZone((resolve) => { + let id = setTimeout(resolve, SCROLL_IDLE_MS); + + this._hostRef.nativeElement.addEventListener( + "scroll", + () => { + clearTimeout(id); + id = setTimeout(resolve, SCROLL_IDLE_MS); + }, + { passive: true, signal: abort }, + ); + + onAbort(abort, () => clearTimeout(id)); + }); + } + + //#endregion + + //#region Async helpers + + /** A promise whose subscription work stays out of the Angular zone. */ + private _promiseOutsideZone( + subscribe: (resolve: () => void) => void, + ): Promise { + return this._zone.runOutsideAngular( + () => new Promise((resolve) => subscribe(() => resolve())), + ); + } + + /** + * Resolves with `task` or with a deadline of `ms`, whichever comes first. + * The signal then tears down the other, so no live timer or dangling + * listener remains. + */ + private _withDeadline( + ms: number, + task: (abort: AbortSignal) => Promise, + ): Promise { + const controller = new AbortController(); + + return Promise.race([ + task(controller.signal), + this._promiseOutsideZone((resolve) => { + const id = setTimeout(resolve, ms); + onAbort(controller.signal, () => clearTimeout(id)); + }), + ]).finally(() => controller.abort()); + } + + /** + * Resolves on the next animation frame, or after `LAYOUT_FRAME_TIMEOUT_MS` + * when no frame arrives. A hidden tab or a detached element gets no frames + * and has no layout to wait for, so resolving early there is safe. + */ + private _nextFrame(): Promise { + if (!this._isBrowser) { + return Promise.resolve(); } - this._itemResizeObserver.disconnect(); + return this._withDeadline(LAYOUT_FRAME_TIMEOUT_MS, (abort) => + this._promiseOutsideZone((resolve) => { + const id = requestAnimationFrame(resolve); + onAbort(abort, () => cancelAnimationFrame(id)); + }), + ); + } - const content = this._contentDivRef()?.nativeElement; - if (!content) return; + /** + * Waits out the frames in which the item measurements land. Each + * measurement that changes a size bumps the engine's version and schedules + * another render, so the layout has settled once the version holds still + * across two consecutive frames. + */ + private async _resolveLayoutComplete(): Promise { + try { + let lastVersion = -1; - const itemRoots = Array.from(content.children) as HTMLElement[]; - const max = Math.min(count, itemRoots.length); + for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) { + await this._nextFrame(); - for (let i = 0; i < max; i++) { - const el = itemRoots[i]; - el.dataset["vsIndex"] = String(startIndex + i); - this._itemResizeObserver.observe(el); + const version = untracked(this._engine.version); + if (version === lastVersion) { + break; + } + lastVersion = version; + } + } finally { + // Cleared here, not after the loop, so a run that throws cannot leave + // the getter with a permanently rejected promise. + this._layoutCompletePromise = null; } } + //#endregion + + //#region Measurement + private _measureViewport(): void { const host = this._hostRef.nativeElement; const size = this._isVertical() ? host.clientHeight : host.clientWidth; - if (size !== this._viewportSize()) { + + if (size !== untracked(this._viewportSize)) { this._viewportSize.set(size); } } private _setupViewportResizeObserver(): void { - if (!isPlatformBrowser(this._platformId)) return; - this._viewportResizeObserver?.disconnect(); - this._viewportResizeObserver = new ResizeObserver(() => { - const host = this._hostRef.nativeElement; - const newSize = this._isVertical() ? host.clientHeight : host.clientWidth; - if (newSize !== this._viewportSize()) { - this._viewportSize.set(newSize); - } - }); - this._viewportResizeObserver.observe(this._hostRef.nativeElement); + this._zone.runOutsideAngular(() => { + this._viewportResizeObserver = new ResizeObserver(() => + this._measureViewport(), + ); + this._viewportResizeObserver.observe(this._hostRef.nativeElement); + }); } private _setupScrollListener(): void { - if (!isPlatformBrowser(this._platformId)) return; - const host = this._hostRef.nativeElement; + if (this._onScroll) { host.removeEventListener("scroll", this._onScroll); } this._zone.runOutsideAngular(() => { - this._onScroll = (e: Event) => { - const target = e.target as HTMLElement; - // Normalize the RTL negative scrollLeft into a positive virtual - // scroll position so the engine math stays direction-agnostic. - const scrollPos = this._isVertical() - ? target.scrollTop - : this._isLTR() - ? target.scrollLeft - : -target.scrollLeft; - this._scrollPosition.set(scrollPos); - }; - host.addEventListener("scroll", this._onScroll!, { passive: true }); + this._onScroll = () => this._handleScroll(); + host.addEventListener("scroll", this._onScroll, { passive: true }); + }); + } + + /** + * Records the new scroll offset and invalidates the rendered window only + * when it actually moves. Without the guard, a scroll inside a single item + * would schedule a change detection pass for an identical result. + */ + private _handleScroll(): void { + this._scrollPosition = this._currentAxisScroll(); + + const next = this._engine.getVisibleRange( + this._scrollPosition, + untracked(this._viewportSize), + untracked(this._normalizedOverScan), + ); + + if (!rangesEqual(next, untracked(this._visibleRange))) { + this._scrollTick.update((v) => v + 1); + } + } + + private _handleItemResize(entries: ResizeObserverEntry[]): void { + for (const entry of entries) { + const index = itemIndex(entry.target); + if (index < 0) { + continue; + } + + const measured = this._isVertical() + ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height) + : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); + + if (measured > 0) { + this._engine.measureItem(index, measured); + } + } + } + + /** + * Synchronizes the item observer with the rendered window, applying only + * the difference. A newly observed element gets one initial measurement. + * + * An element whose `data-vs-index` changed is re-registered, because + * `observe` on an already observed element is a no-op. `@for` tracks by + * slot and reuses the wrapper elements, so after a scroll the same element + * can host a different item at an identical size. The observer stays quiet + * about that and the new index would keep its estimated size. + */ + private _scheduleItemMeasurement(): void { + const content = this._contentDivRef()?.nativeElement; + if (!content) { + return; + } + + const observer = this._getItemResizeObserver(); + + for (const element of [...this._observedItems]) { + if (element.parentNode !== content) { + observer.unobserve(element); + this._observedItems.delete(element); + } + } + + for (const element of Array.from(content.children)) { + const index = itemIndex(element); + + if (this._observedItems.has(element)) { + if (this._observedItemIndexes.get(element) === index) { + continue; + } + observer.unobserve(element); + } + + observer.observe(element); + this._observedItems.add(element); + this._observedItemIndexes.set(element, index); + } + } + + private _getItemResizeObserver(): ResizeObserver { + if (!this._itemResizeObserver) { + this._itemResizeObserver = this._zone.runOutsideAngular( + () => new ResizeObserver((entries) => this._handleItemResize(entries)), + ); + } + return this._itemResizeObserver; + } + + //#endregion + + //#region Events + + /** + * The number of leading items that kept their identity across a `data` + * change: the index of the first item whose measured size no longer + * matches its rendered content. An append (the `dataRequest` flow) retains + * all items. A filter or a replacement retains only the unchanged prefix. + */ + private _firstChangedIndex(previous: T[] | undefined, current: T[]): number { + if (!previous) { + return 0; + } + + const shared = Math.min(previous.length, current.length); + for (let i = 0; i < shared; i++) { + if (previous[i] !== current[i]) { + return i; + } + } + return shared; + } + + /** + * Emits `stateChange`. Skipped when the window is empty or equal to the + * last reported one, because measurement passes re-render without a window + * change. + */ + private _emitStateChange(): void { + const { startIndex, endIndex } = untracked(this._visibleRange); + if (endIndex < startIndex) { + return; + } + + const state: VirtualScrollState = { + startIndex, + endIndex, + viewportSize: untracked(this._viewportSize), + totalSize: untracked(this._engine.totalSize), + }; + + if (statesEqual(this._lastEmittedState, state)) { + return; + } + + this._lastEmittedState = { ...state }; + this.stateChange.emit(state); + } + + private _checkDataRequest(): void { + if (this._hasPendingDataRequest) { + return; + } + + const { endIndex } = untracked(this._visibleRange); + const total = untracked(this._items).length; + + if (total === 0 || endIndex < total - DATA_REQUEST_THRESHOLD) { + return; + } + + // Each `data` change clears `_hasPendingDataRequest`, including one that + // appends nothing. Without this second guard, a consumer whose source is + // exhausted, and that reassigns `data` in response to a request, would + // receive the same request on each reassignment. + if (this._lastDataRequestIndex === total) { + return; + } + + this._hasPendingDataRequest = true; + this._lastDataRequestIndex = total; + + this.dataRequest.emit({ + startIndex: total, + count: Math.max( + untracked(this._normalizedOverScan) * DATA_REQUEST_OVER_SCAN_FACTOR, + DATA_REQUEST_MIN_COUNT, + ), }); } + //#endregion + private _teardown(): void { const host = this._hostRef.nativeElement; + if (this._onScroll) { host.removeEventListener("scroll", this._onScroll); this._onScroll = null; } + this._viewportResizeObserver?.disconnect(); + this._viewportResizeObserver = null; + this._itemResizeObserver?.disconnect(); - for (const view of [...this._activeItems, ...this._pooledItems]) { - view.destroy(); - } - this._activeItems.length = 0; - this._pooledItems.length = 0; + this._itemResizeObserver = null; + this._observedItems.clear(); } } diff --git a/src/app/virtual-scroll/virtual-scroll.sample.html b/src/app/virtual-scroll/virtual-scroll.sample.html index 128f76f9e38..8bdf338587f 100644 --- a/src/app/virtual-scroll/virtual-scroll.sample.html +++ b/src/app/virtual-scroll/virtual-scroll.sample.html @@ -10,8 +10,49 @@

Vertical - variable-height items ({{ verticalItems().length }} )

Each item has a different height. The engine measures sizes after the first render and adjusts scroll calculations automatically.

+

+ scrollToIndex() returns a promise that resolves once the + position has settled: unrendered items only have an estimated size, so + the first jump lands near the target and is then corrected against the + real measurements. +

+ +
+ + + + +
- +
>('verticalScroll'); + protected readonly verticalItems = signal(makeItems(0, 1_000_000)); protected readonly verticalConstantItems = signal( Array.from({ length: 500 }, (_, i) => ({ @@ -58,6 +61,32 @@ export class VirtualScrollSampleComponent { protected readonly remoteItems = signal(makeItems(0, 20).map(it => it.label)); protected readonly isLoading = signal(false); + protected readonly targetIndex = signal(500_000); + protected readonly alignment = signal('start'); + protected readonly smoothScroll = signal(false); + protected readonly isScrolling = signal(false); + + /** + * Items outside the rendered window have only an estimated size, so the + * first jump lands near the target rather than on it. `scrollToIndex` + * measures the items there and corrects itself; the promise resolves once + * the position is stable. + */ + protected async scrollToTarget(): Promise { + const scroll = this.verticalScroll(); + if (!scroll) return; + + this.isScrolling.set(true); + try { + await scroll.scrollToIndex(this.targetIndex(), { + block: this.alignment(), + behavior: this.smoothScroll() ? 'smooth' : 'auto', + }); + } finally { + this.isScrolling.set(false); + } + } + /** Append 20 more items when the virtual scroll requests more data. */ protected onDataRequest(req: VirtualScrollDataRequest): void { if (this.isLoading()) return;