• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IgniteUI / igniteui-webcomponents / 30342920749

28 Jul 2026 08:34AM UTC coverage: 98.312% (-0.02%) from 98.332%
30342920749

Pull #2222

github

web-flow
Merge e6feaa26e into 2b4e1a5a0
Pull Request #2222: feat: Added virtualization component

6124 of 6447 branches covered (94.99%)

Branch coverage included in aggregate %.

1084 of 1099 new or added lines in 7 files covered. (98.64%)

43251 of 43776 relevant lines covered (98.8%)

1546.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.61
/src/components/virtualization/engine.ts
1
/**
9✔
2
 * Caches the result of `getMaxBrowserSizeProbePx` per `Document`. The
9✔
3
 * maximum scrollable coordinate a browser supports doesn't change between
9✔
4
 * calls, so repeated probes across multiple virtual-scroll instances in the
9✔
5
 * same document are wasted DOM work.
9✔
6
 */
9✔
7
const _maxBrowserSizeCache = new WeakMap<Document, number>();
9✔
8

9✔
9
/**
9✔
10
 * Probes the browser for the maximum scrollable coordinate it supports.
9✔
11
 */
9✔
12
function getMaxBrowserSizeProbePx(doc: Document): number {
131✔
13
  const cached = _maxBrowserSizeCache.get(doc);
131✔
14
  if (cached !== undefined) {
131✔
15
    return cached;
129✔
16
  }
129✔
17

2✔
18
  const container = doc.body ?? doc.documentElement;
131!
19
  if (!container) {
131!
NEW
20
    return Number.POSITIVE_INFINITY;
×
NEW
21
  }
✔
22

2✔
23
  const div = doc.createElement('div');
2✔
24
  div.style.position = 'absolute';
2✔
25
  div.style.top = `${Number.MAX_SAFE_INTEGER}px`;
2✔
26
  container.appendChild(div);
2✔
27
  const size = Math.abs(div.getBoundingClientRect().top);
2✔
28
  container.removeChild(div);
2✔
29

2✔
30
  _maxBrowserSizeCache.set(doc, size);
2✔
31
  return size;
2✔
32
}
2✔
33

9✔
34
/**
9✔
35
 * Binary Indexed Tree (Fenwick tree) over item sizes.
9✔
36
 *
9✔
37
 * Replaces the previous O(N) prefix-sum rebuild that occurred on every
9✔
38
 * `measureItem` call. All hot-path operations are O(log N):
9✔
39
 *   - Point update (item measured)        : O(log N)
9✔
40
 *   - Prefix sum (scroll offset)          : O(log N)
9✔
41
 *   - Index at offset (scroll → item)     : O(log N) via binary lifting
9✔
42
 */
9✔
43
class BIT {
9✔
44
  public readonly length: number;
9✔
45

9✔
46
  /** 1-indexed BIT; each cell holds a partial range sum. */
9✔
47
  private readonly _tree: Float64Array;
9✔
48

9✔
49
  /** Raw per-item sizes (0-indexed) — kept for O(1) reads and delta calc. */
9✔
50
  private readonly _sizes: Float64Array;
9✔
51

9✔
52
  /**
9✔
53
   * Tracks which indices hold a real, DOM-measured size (`1`) as opposed to
9✔
54
   * a placeholder/estimated size that was never actually measured (`0`).
9✔
55
   * Only unmeasured indices are affected by `applyEstimate()`.
9✔
56
   */
9✔
57
  private readonly _measured: Uint8Array;
9✔
58

9✔
59
  /** Running total maintained alongside tree updates in O(1). */
9✔
60
  private _total: number;
9✔
61

9✔
62
  /**
9✔
63
   * Highest power-of-two ≤ `length`, precomputed once for the binary
9✔
64
   * lifting in `findIndexAtOffset` instead of recomputing it via
9✔
65
   * `Math.clz32` on every call (this runs on every scroll event).
9✔
66
   */
9✔
67
  private readonly _topBit: number;
9✔
68

9✔
69
  private constructor(
9✔
70
    length: number,
170✔
71
    sizes: Float64Array,
170✔
72
    tree: Float64Array,
170✔
73
    total: number,
170✔
74
    measured: Uint8Array
170✔
75
  ) {
170✔
76
    this.length = length;
170✔
77
    this._sizes = sizes;
170✔
78
    this._tree = tree;
170✔
79
    this._total = total;
170✔
80
    this._measured = measured;
170✔
81
    this._topBit = length > 0 ? 1 << (31 - Math.clz32(length)) : 0;
170✔
82
  }
170✔
83

9✔
84
  /**
9✔
85
   * Creates a BIT of `length` items all initialized to `fillSize`, none of
9✔
86
   * which are considered measured yet. O(N).
9✔
87
   */
9✔
88
  public static filled(length: number, fillSize: number): BIT {
9✔
89
    return BIT._build(
131✔
90
      new Float64Array(length).fill(fillSize),
131✔
91
      new Uint8Array(length)
131✔
92
    );
131✔
93
  }
131✔
94

9✔
95
  /**
9✔
96
   * Builds a BIT from a sizes array and its matching measured-flags array. O(N).
9✔
97
   */
9✔
98
  private static _build(sizes: Float64Array, measured: Uint8Array): BIT {
9✔
99
    const length = sizes.length;
170✔
100
    const tree = new Float64Array(length + 1);
170✔
101
    let total = 0;
170✔
102

170✔
103
    for (let i = 1; i <= length; i++) {
170✔
104
      tree[i] += sizes[i - 1];
13,605✔
105
      total += sizes[i - 1];
13,605✔
106
      const j = i + (i & -i);
13,605✔
107
      if (j <= length) {
13,605✔
108
        tree[j] += tree[i];
13,360✔
109
      }
13,360✔
110
    }
13,605✔
111
    return new BIT(length, sizes, tree, total, measured);
170✔
112
  }
170✔
113

9✔
114
  /** Total size of all items. O(1). */
9✔
115
  public get totalSize(): number {
9✔
116
    return this._total;
3,112✔
117
  }
3,112✔
118

9✔
119
  /**
9✔
120
   * Prefix sum of items [0, i) — the virtual scroll offset at the leading
9✔
121
   * edge of item i. O(log N).
9✔
122
   */
9✔
123
  public prefixSum(i: number): number {
9✔
124
    let sum = 0;
1,486✔
125
    for (let j = i; j > 0; j -= j & -j) {
1,486✔
126
      sum += this._tree[j];
3,584✔
127
    }
3,584✔
128
    return sum;
1,486✔
129
  }
1,486✔
130

9✔
131
  /**
9✔
132
   * Update the size of the item at 0-based index. Marks the item as
9✔
133
   * explicitly measured, exempting it from future `applyEstimate()` calls.
9✔
134
   * Returns true when the size actually changed. O(log N).
9✔
135
   */
9✔
136
  public update(index: number, newSize: number): boolean {
9✔
137
    if (index < 0 || index >= this.length) return false;
1,913!
138

1,913✔
139
    const old = this._sizes[index];
1,913✔
140
    this._measured[index] = 1;
1,913✔
141
    if (old === newSize) return false;
1,913✔
142

1,333✔
143
    const delta = newSize - old;
1,333✔
144
    this._sizes[index] = newSize;
1,333✔
145
    this._total += delta;
1,333✔
146
    for (let i = index + 1; i <= this.length; i += i & -i) {
1,913✔
147
      this._tree[i] += delta;
7,630✔
148
    }
7,630✔
149
    return true;
1,333✔
150
  }
1,913✔
151

9✔
152
  /**
9✔
153
   * Returns a new BIT of `newLength` items.
9✔
154
   * Existing measured sizes (and their measured/estimated status) are
9✔
155
   * preserved up to `min(this.length, newLength)`; new slots are filled
9✔
156
   * with `fillSize` and marked unmeasured. Single O(N) build pass.
9✔
157
   */
9✔
158
  public cloneResized(newLength: number, fillSize: number): BIT {
9✔
159
    const sizes = new Float64Array(newLength).fill(fillSize);
39✔
160
    const measured = new Uint8Array(newLength);
39✔
161
    const retained = Math.min(this.length, newLength);
39✔
162
    sizes.set(this._sizes.subarray(0, retained));
39✔
163
    measured.set(this._measured.subarray(0, retained));
39✔
164
    return BIT._build(sizes, measured);
39✔
165
  }
39✔
166

9✔
167
  /**
9✔
168
   * Applies `estimatedSize` to every item that hasn't been explicitly
9✔
169
   * measured yet, leaving already-measured items untouched. A single
9✔
170
   * estimate change can affect most of the items at once (e.g. a whole
9✔
171
   * never-scrolled-to list), so this rebuilds the tree in one O(N) pass
9✔
172
   * rather than issuing one O(log N) `update()` per affected item.
9✔
173
   * Returns true when at least one item's size actually changed.
9✔
174
   */
9✔
175
  public applyEstimate(estimatedSize: number): boolean {
9✔
176
    let changed = false;
133✔
177
    for (let i = 0; i < this.length; i++) {
133✔
178
      if (!this._measured[i] && this._sizes[i] !== estimatedSize) {
13,379✔
179
        this._sizes[i] = estimatedSize;
24✔
180
        changed = true;
24✔
181
      }
24✔
182
    }
13,379✔
183
    if (!changed) return false;
133✔
184

2✔
185
    this._tree.fill(0);
2✔
186
    let total = 0;
2✔
187
    for (let i = 1; i <= this.length; i++) {
22✔
188
      this._tree[i] += this._sizes[i - 1];
30✔
189
      total += this._sizes[i - 1];
30✔
190
      const j = i + (i & -i);
30✔
191
      if (j <= this.length) {
30✔
192
        this._tree[j] += this._tree[i];
26✔
193
      }
26✔
194
    }
30✔
195
    this._total = total;
2✔
196
    return true;
2✔
197
  }
133✔
198

9✔
199
  /**
9✔
200
   * Returns the 0-based index of the item that contains the given scroll `offset`,
9✔
201
   * i.e. the largest i such that `prefixSum(i) <= offset < prefixSum(i + 1)`.
9✔
202
   * O(log N) via binary lifting on the internal tree.
9✔
203
   */
9✔
204
  public findIndexAtOffset(offset: number): number {
9✔
205
    if (offset <= 0 || this.length === 0) return 0;
589!
206

589✔
207
    let idx = 0;
589✔
208
    let remaining = offset;
589✔
209

589✔
210
    for (let bit = this._topBit; bit > 0; bit >>= 1) {
589✔
211
      const next = idx + bit;
5,720✔
212
      if (next <= this.length && this._tree[next] <= remaining) {
5,720✔
213
        idx = next;
2,466✔
214
        remaining -= this._tree[idx];
2,466✔
215
      }
2,466✔
216
    }
5,720✔
217
    return Math.min(this.length - 1, idx);
589✔
218
  }
589✔
219
}
9✔
220

9✔
221
/**
9✔
222
 * Describes the currently visible (and over-scanned) range of items.
9✔
223
 */
9✔
224
export interface VisibleRange {
9✔
225
  /** Index of the first rendered item (inclusive) */
9✔
226
  startIndex: number;
9✔
227
  /** Index of the last rendered item (inclusive) */
9✔
228
  endIndex: number;
9✔
229
}
9✔
230

9✔
231
/**
9✔
232
 * Pure scroll-math engine for a single axis of virtual scrolling.
9✔
233
 *
9✔
234
 * All size state is held as plain arrays. Consumers can register an
9✔
235
 * `onSizeChange` callback to react whenever item sizes or the item count
9✔
236
 * changes (e.g. to trigger a Lit `requestUpdate()`).
9✔
237
 */
9✔
238
export class VirtualScrollEngine {
9✔
239
  private _maxBrowserSize = Number.POSITIVE_INFINITY;
131✔
240

131✔
241
  /**
131✔
242
   * The ratio `totalSize / maxBrowserSize` when `totalSize` exceeds the
131✔
243
   * maximum DOM coordinate the browser supports; `1` otherwise.
131✔
244
   * Used to map virtual scroll positions to DOM scroll positions.
131✔
245
   */
131✔
246
  private _virtualRatio = 1;
131✔
247

131✔
248
  /** Binary Indexed Tree for O(log N) size queries and updates. */
131✔
249
  private _tree: BIT | null = null;
131✔
250

131✔
251
  /**
131✔
252
   * Called whenever item sizes or the item count change.
131✔
253
   * Assign a callback (e.g. `() => this.requestUpdate()`) to react to size updates.
131✔
254
   */
131✔
255
  public onSizeChange: (() => void) | null = null;
131✔
256

9✔
257
  /** Total virtual size of all items in px. */
9✔
258
  public get totalSize(): number {
9✔
259
    return this._tree?.totalSize ?? 0;
1,607!
260
  }
1,607✔
261

9✔
262
  /** Actual DOM space size (clamped to the maximum browser size) */
9✔
263
  public get domSize(): number {
9✔
264
    return this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize;
1,206!
265
  }
1,206✔
266

9✔
267
  /**
9✔
268
   * Initializes the maximum browser size by probing the document, and updates the virtual ratio accordingly.
9✔
269
   */
9✔
270
  public initMaxBrowserSize(doc: Document): void {
9✔
271
    this._maxBrowserSize = getMaxBrowserSizeProbePx(doc);
131✔
272
    this._updateVirtualRatio();
131✔
273
  }
131✔
274

9✔
275
  /**
9✔
276
   * Grows or shrinks the internal sizes array to `length`.
9✔
277
   * New entries are filled with `estimatedSize`.
9✔
278
   * Existing measured sizes are preserved.
9✔
279
   */
9✔
280
  public resize(length: number, estimatedSize: number): void {
9✔
281
    if (this._tree?.length === length) return;
178✔
282

170✔
283
    this._tree = this._tree
170✔
284
      ? this._tree.cloneResized(length, estimatedSize)
39✔
285
      : BIT.filled(length, estimatedSize);
131✔
286
    this._updateVirtualRatio();
178✔
287
    this.onSizeChange?.();
178✔
288
  }
178✔
289

9✔
290
  /**
9✔
291
   * Records the measured DOM size for a single item.
9✔
292
   */
9✔
293
  public measureItem(index: number, size: number): void {
9✔
294
    if (!this._tree?.update(index, size)) return;
1,913✔
295

1,333✔
296
    this._updateVirtualRatio();
1,333✔
297
    this.onSizeChange?.();
1,333✔
298
  }
1,913✔
299

9✔
300
  /**
9✔
301
   * Applies a new estimated size to every item that hasn't been explicitly
9✔
302
   * measured in the DOM yet. Already-measured items keep their real size.
9✔
303
   * Use this when `estimatedItemSize` changes but the item count doesn't
9✔
304
   * (so `resize()` would otherwise be a no-op).
9✔
305
   */
9✔
306
  public updateEstimatedSize(estimatedSize: number): void {
9✔
307
    if (!this._tree?.applyEstimate(estimatedSize)) return;
133✔
308

2✔
309
    this._updateVirtualRatio();
2✔
310
    this.onSizeChange?.();
2✔
311
  }
133✔
312

9✔
313
  /**
9✔
314
   * Returns the DOM scroll offset in pixels that brings item at `index` into view
9✔
315
   * at the leading edge of the viewport.
9✔
316
   */
9✔
317
  public getScrollOffsetForIndex(index: number): number {
9✔
318
    if (!this._tree || index <= 0) return 0;
697✔
319

280✔
320
    const clamped = Math.min(index, this._tree.length);
280✔
321
    return this._tree.prefixSum(clamped) / this._virtualRatio;
280✔
322
  }
697✔
323

9✔
324
  /** Returns the item index at the given DOM scroll position. */
9✔
325
  public getIndexAtScroll(scrollPosition: number): number {
9✔
326
    if (!this._tree || scrollPosition <= 0) return 0;
802✔
327
    return this._tree.findIndexAtOffset(scrollPosition * this._virtualRatio);
589✔
328
  }
802✔
329

9✔
330
  /**
9✔
331
   * Returns the visible + over-scanned item range for the given scroll state.
9✔
332
   */
9✔
333
  public getVisibleRange(
9✔
334
    scrollPosition: number,
603✔
335
    viewportSize: number,
603✔
336
    overScan: number,
603✔
337
    totalItems: number
603✔
338
  ): VisibleRange {
603✔
339
    if (totalItems === 0 || viewportSize <= 0) {
603✔
340
      return { startIndex: 0, endIndex: -1 };
202✔
341
    }
202✔
342

401✔
343
    const start = Math.max(0, this.getIndexAtScroll(scrollPosition) - overScan);
401✔
344
    const endScrollPosition = scrollPosition + viewportSize;
401✔
345
    const endRaw = this.getIndexAtScroll(endScrollPosition);
401✔
346
    const end = Math.min(totalItems - 1, endRaw + overScan);
401✔
347

401✔
348
    return { startIndex: start, endIndex: end };
401✔
349
  }
603✔
350

9✔
351
  /**
9✔
352
   * Returns the CSS `translateY` / `translateX` value (px) to apply to the
9✔
353
   * absolutely-positioned content wrapper.
9✔
354
   *
9✔
355
   * The content wrapper is `position: absolute; top: 0; left: 0` inside a
9✔
356
   * track element that is `totalSize` px tall/wide. Translating it to
9✔
357
   * `getContentPosition(startIndex)` places the first rendered item exactly
9✔
358
   * at its virtual scroll position within the track.
9✔
359
   */
9✔
360
  public getContentPosition(index: number): number {
9✔
361
    return this.getScrollOffsetForIndex(index);
603✔
362
  }
603✔
363

9✔
364
  /**
9✔
365
   * Returns the sum of actual sizes for items in [startIndex, endIndex].
9✔
366
   * Used to clamp the content translate offset under coordinate compression
9✔
367
   * so rendered items never overflow past `domSize`.
9✔
368
   */
9✔
369
  public getPhysicalRangeSize(startIndex: number, endIndex: number): number {
9✔
370
    if (!this._tree) return 0;
603!
371

603✔
372
    const start = Math.max(0, startIndex);
603✔
373
    const end = Math.min(Math.max(endIndex + 1, start), this._tree.length);
603✔
374
    return this._tree.prefixSum(end) - this._tree.prefixSum(start);
603✔
375
  }
603✔
376

9✔
377
  private _updateVirtualRatio(): void {
9✔
378
    const totalSize = this._tree?.totalSize ?? 0;
1,636✔
379
    this._virtualRatio =
1,636✔
380
      this._maxBrowserSize === Number.POSITIVE_INFINITY ||
1,636✔
381
      totalSize <= this._maxBrowserSize
1,636✔
382
        ? 1
1,636!
NEW
383
        : totalSize / this._maxBrowserSize;
×
384
  }
1,636✔
385
}
9✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc