Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fbfdf93
chore(*): Wrap each row in container w resize observer and update.
Jan 22, 2026
4f1a12d
Merge branch 'master' into mkirova/resize-observer-refactor
Jan 26, 2026
80fdb9f
chore(*): Add cache for view sizes, use when updating caches after sc…
Jan 26, 2026
1080c0f
chore(*): Clean console logs.
Jan 26, 2026
125d464
chore(*): Fix lint error.
Jan 26, 2026
7d66443
chore(*): Make override arg optional too.
Jan 26, 2026
2d96c12
chore(*): Update cache for all views but update size cached only for …
Jan 27, 2026
807ef0b
chore(*): Use resize observer only for measurements.
Jan 27, 2026
2147886
chore(*): Remove unnecessary recalc.
Jan 27, 2026
443310a
chore(*): Add back since it still needs update in some scenarios.
Jan 27, 2026
e7dff9d
chore(*): Add sample with hgrid for measuring perf.
Jan 28, 2026
d3f49c5
chore(*): Fix wrapping container in hgrid. Remove DOM read ops on rec…
Jan 28, 2026
ffd6a83
chore(*): Set null width to skip resize on attach/detach child grids.
Jan 28, 2026
4914468
chore(*): Add container to size in all other grids as well.
Jan 28, 2026
278ebcb
chore(*): Caches only for IgxGridForOfDirective. Retain old logic for…
Jan 28, 2026
9b8c5ad
chore(*): Fix timing so that tests do not disconnect.
Jan 29, 2026
af81bb0
chore(*): Remove zone run since it's unnecessary.
Jan 29, 2026
0633724
chore(*): Check if the await causes a disconnect in tests.
Jan 29, 2026
379aac5
chore(*): Update tests according to new structure.
Jan 29, 2026
e9192e0
chore(*): Remove unnecessary size recalc during scroll position update.
Jan 30, 2026
ab14780
chore(*): Update groupBy tests according to new DOM structure.
Jan 30, 2026
100d904
chore(*): Update test timing.
Jan 30, 2026
6aeb4fe
chore(*): Clean up imports and fix formatting.
Jan 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion projects/igniteui-angular-performance/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
import { GridComponent } from './grid/grid.component';
import { TreeGridComponent } from './tree-grid/tree-grid.component';
import { PivotGridComponent } from './pivot-grid/pivot-grid.component';
import { HierarchicalGridComponent } from './hierarchical-grid/hierarchical-grid.component';

export const routes: Routes = [
{
Expand Down Expand Up @@ -45,6 +46,12 @@ export const routes: Routes = [
pathMatch: 'full',
component: GridComponent,
data: { rows: 1000 }
}
},
{
path: "hierarchical-grid-100k",
title: "Hierarchical Grid 100k records",
component: HierarchicalGridComponent,
data: { rows: 100_000 }
},

];
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<div class="grid-wrapper">
<igx-hierarchical-grid
[showExpandAll]="true"
#grid
[data]="data"
[allowFiltering]="false"
[height]="'100%'"
[width]="'100%'"
>
@for (col of columns; track col) {
<igx-column
[field]="col.field"
[header]="col.header"
[sortable]="col.sortable"
[dataType]="col.dataType"
[width]="col.width"
[groupable]="col.groupable"
>
</igx-column>
}
<igx-row-island [height]="null" [width]="null" [key]="'childData'" [autoGenerate]="false">
@for (col of columns; track col) {
<igx-column
[field]="col.field"
[header]="col.header"
[sortable]="col.sortable"
[dataType]="col.dataType"
[width]="col.width"
[groupable]="col.groupable"
>
</igx-column>
}
</igx-row-island>
</igx-hierarchical-grid>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}

.grid-wrapper {
height: 100%;
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Component, inject, ViewChild } from '@angular/core';
import { GridColumnDataType, IgxColumnComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent } from "igniteui-angular"
import { DataService } from '../services/data.service';
import { ActivatedRoute } from '@angular/router';

@Component({
selector: 'app-hierarchical-grid',
imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent],
templateUrl: './hierarchical-grid.component.html',
styleUrl: './hierarchical-grid.component.scss'
})
export class HierarchicalGridComponent {
protected columns: any[] = []
protected data: any[] = [];
protected performanceDataList: PerformanceEntryList = [];
private dataService = inject(DataService);
private activatedRoute = inject(ActivatedRoute);

@ViewChild(IgxHierarchicalGridComponent, { static: true })
public grid: IgxHierarchicalGridComponent;

constructor() {
this.data = this.dataService.generateHierarchicalData(this.activatedRoute.snapshot.data.rows)
this.columns = [
{ field: "Id", dataType: GridColumnDataType.Number, sortable: true, width: 'auto', groupable: true },
{ field: "Name", dataType: GridColumnDataType.String, sortable: true, width: 'auto', groupable: true },
{ field: "AthleteNumber", dataType: GridColumnDataType.Number, sortable: true, width: 'auto', groupable: true },
{ field: "Registered", dataType: GridColumnDataType.DateTime, sortable: true, width: 'auto', groupable: true },
{ field: "CountryName", dataType: GridColumnDataType.String, sortable: true, width: 'auto', groupable: true },
{ field: "FirstAppearance", dataType: GridColumnDataType.Time, sortable: true, width: 'auto', groupable: true },
{ field: "CareerStart", dataType: GridColumnDataType.Date, sortable: true, width: 'auto', groupable: true },
{ field: "Active", dataType: GridColumnDataType.Boolean, sortable: true, width: 'auto', groupable: true },
{ field: "NetWorth", dataType: GridColumnDataType.Currency, sortable: true, width: 'auto', groupable: true },
{ field: "CountryFlag", dataType: GridColumnDataType.Image, sortable: true, width: 'auto', groupable: true },
{ field: "SuccessRate", dataType: GridColumnDataType.Percent, sortable: true, width: 'auto', groupable: true },
{ field: "Position", dataType: GridColumnDataType.String, sortable: true, width: 'auto', groupable: true },
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export class DataService {
return data;
}

public generateHierarchicalData(rows: number): any[] {
const rnd = new Mulberry32(1234);
const data = this.generateAthletesData(rnd, rows, true);
return data;
}

public generateTreeData(rows: number): any[] {
const rnd = new Mulberry32(1234);
const data = this.generateEmployeesData(rnd, rows);
Expand Down Expand Up @@ -114,7 +120,7 @@ export class DataService {
return currData;
}

private generateAthletesData(rnd: Mulberry32, rows: number): any[] {
private generateAthletesData(rnd: Mulberry32, rows: number, children = false): any[] {
const currData = [];
for (let i = 0; i < rows; i++) {
const rand = Math.floor(rnd.random() * Math.floor(athletesData.length));
Expand All @@ -125,6 +131,10 @@ export class DataService {
dataObj["Active"] = this.randomizeBoolean(rnd);
dataObj["SuccessRate"] = this.randomizePercentage(rnd);
dataObj["AthleteNumber"] = this.randomizeAthleteNumber(dataObj["AthleteNumber"], rnd);
if (children) {
const rnd = new Mulberry32(i);
dataObj["childData"] = this.generateAthletesData(rnd, 5);
}
currData.push(dataObj);
}
return currData;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NgForOfContext } from '@angular/common';
import { ChangeDetectorRef, ComponentRef, Directive, EmbeddedViewRef, EventEmitter, Input, IterableChanges, IterableDiffer, IterableDiffers, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewContainerRef, AfterViewInit, booleanAttribute, DOCUMENT, inject, afterNextRender, runInInjectionContext, EnvironmentInjector } from '@angular/core';
import { ChangeDetectorRef, ComponentRef, Directive, EmbeddedViewRef, EventEmitter, Input, IterableChanges, IterableDiffer, IterableDiffers, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewContainerRef, booleanAttribute, DOCUMENT, inject, afterNextRender, runInInjectionContext, EnvironmentInjector, AfterViewInit } from '@angular/core';

import { DisplayContainerComponent } from './display.container';
import { HVirtualHelperComponent } from './horizontal.virtual.helper.component';
Expand Down Expand Up @@ -84,7 +84,7 @@ export abstract class IgxForOfToken<T, U extends T[] = T[]> {
],
standalone: true
})
export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U> implements OnInit, OnChanges, OnDestroy, AfterViewInit {
export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U> implements OnInit, AfterViewInit, OnChanges, OnDestroy {
private _viewContainer = inject(ViewContainerRef);
protected _template = inject<TemplateRef<NgForOfContext<T>>>(TemplateRef);
protected _differs = inject(IterableDiffers);
Expand All @@ -95,6 +95,7 @@ export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U
protected platformUtil = inject(PlatformUtil);
protected document = inject(DOCUMENT);
private _igxForOf: U & T[] | null = null;
private _embeddedViewSizesCache = new Map<EmbeddedViewRef<any>, number>();

/**
* Sets the data to be rendered.
Expand Down Expand Up @@ -274,6 +275,8 @@ export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U
protected _embeddedViews: Array<EmbeddedViewRef<any>> = [];
protected contentResizeNotify = new Subject<void>();
protected contentObserver: ResizeObserver;
protected viewObserver: ResizeObserver;
protected viewResizeNotify = new Subject<ResizeObserverEntry[]>();
/** Size that is being virtualized. */
protected _virtSize = 0;
/**
Expand Down Expand Up @@ -389,10 +392,10 @@ export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U
if (!this.getScroll()) {
return true;
}
const scrollHeight = this.getScroll().scrollHeight;
const scrollHeight = this.scrollComponent.size;
// Use === and not >= because `scrollTop + container size` can't be bigger than `scrollHeight`, unless something isn't updated.
// Also use Math.round because Chrome has some inconsistencies and `scrollTop + container` can be float when zooming the page.
return Math.round(this.getScroll().scrollTop + this.igxForContainerSize) === scrollHeight;
return Math.round(this.scrollComponent.scrollAmount + this.igxForContainerSize) === scrollHeight;
}

private get _isAtBottomIndex() {
Expand Down Expand Up @@ -509,6 +512,17 @@ export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U
}
}

protected subscribeToViewObserver(target: Element) {
if (this.igxForScrollOrientation === 'vertical' && this.viewObserver) {
this._zone.runOutsideAngular(() => {
if (this.platformUtil.isBrowser) {
this.viewObserver = new (getResizeObserver())((entries: ResizeObserverEntry[]) => this.viewResizeNotify.next(entries));
this.viewObserver.observe(target);
}
});
}
}

/**
* @hidden
*/
Expand Down Expand Up @@ -819,35 +833,36 @@ export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U
|| containerSize && endTopOffset - containerSize > 5;
}


/**
* @hidden
* Function that recalculates and updates cache sizes.
*/
public recalcUpdateSizes() {
public recalcUpdateSizes(prevState?: IForOfState) {
if (prevState && prevState.startIndex === this.state.startIndex && prevState.chunkSize === this.state.chunkSize) {
// nothing changed
return;
}
const dimension = this.igxForScrollOrientation === 'horizontal' ?
this.igxForSizePropName : 'height';
this.igxForSizePropName : 'height';
const diffs = [];
let totalDiff = 0;
const l = this._embeddedViews.length;
const rNodes = this.embeddedViewNodes;
for (let i = 0; i < l; i++) {
const rNode = rNodes[i];
if (rNode) {
const height = window.getComputedStyle(rNode).getPropertyValue('height');
const h = parseFloat(height) || parseInt(this.igxForItemSize, 10);
const index = this.state.startIndex + i;
if (!this.isRemote && !this.igxForOf[index]) {
continue;
}
const margin = this.getMargin(rNode, dimension);
const oldVal = this.individualSizeCache[index];
const newVal = (dimension === 'height' ? h : rNode.clientWidth) + margin;
this.individualSizeCache[index] = newVal;
const currDiff = newVal - oldVal;
diffs.push(currDiff);
totalDiff += currDiff;
this.sizesCache[index + 1] = (this.sizesCache[index] || 0) + newVal;
}
for (let index = 0; index < this._embeddedViews.length; index++) {
const rNode = rNodes[index];
const targetIndex = this.state.startIndex + index;
const view = this._embeddedViews[index];
const nodeSize = this._embeddedViewSizesCache.get(view) ??
dimension === 'height' ?
rNode.clientHeight + this.getMargin(rNode, dimension):
rNode.clientWidth + this.getMargin(rNode, dimension);
const oldVal = this.individualSizeCache[targetIndex];
const newVal = nodeSize;
const currDiff = newVal - oldVal;
diffs.push(currDiff);
totalDiff += currDiff;
this.individualSizeCache[targetIndex] = nodeSize;
this.sizesCache[targetIndex + 1] = (this.sizesCache[targetIndex] || 0) + newVal;
}
// update cache
if (Math.abs(totalDiff) > 0) {
Expand Down Expand Up @@ -972,6 +987,16 @@ export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U
}
}

protected updateViewSizes(entries:ResizeObserverEntry[] ) {
entries.forEach((entry) => {
const index = parseInt(entry.target.getAttribute('data-index'), 0);
const height = entry.contentRect.height;
const embView = this._embeddedViews[index - this.state.startIndex];
this._embeddedViewSizesCache.set(embView, height);
});
this.recalcUpdateSizes();
}

/**
* @hidden
*/
Expand Down Expand Up @@ -1415,9 +1440,11 @@ export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U
protected removeLastElem() {
const oldElem = this._embeddedViews.pop();
this.beforeViewDestroyed.emit(oldElem);
this.viewObserver?.unobserve(oldElem.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE) || oldElem.rootNodes[0].nextElementSibling);
// also detach from ViewContainerRef to make absolutely sure this is removed from the view container.
this.dc.instance._vcr.detach(this.dc.instance._vcr.length - 1);
oldElem.destroy();
this._embeddedViewSizesCache.delete(oldElem);

this.state.chunkSize--;
}
Expand Down Expand Up @@ -1499,7 +1526,6 @@ export class IgxForOfDirective<T, U extends T[] = T[]> extends IgxForOfToken<T,U
// if data has been changed while container is scrolled
// should update scroll top/left according to change so that same startIndex is in view
if (Math.abs(sizeDiff) > 0 && this.scrollPosition > 0) {
this.recalcUpdateSizes();
const offset = this.igxForScrollOrientation === 'horizontal' ?
parseInt(this.dc.instance._viewContainer.element.nativeElement.style.left, 10) :
Number(this.dc.instance._viewContainer.element.nativeElement.style.transform?.match(/translateY\((-?\d+\.?\d*)px\)/)?.[1]);
Expand Down Expand Up @@ -1598,9 +1624,9 @@ export class IgxGridForOfDirective<T, U extends T[] = T[]> extends IgxForOfDirec
return this.igxForSizePropName || 'height';
}

public override recalcUpdateSizes() {
public override recalcUpdateSizes(prevState?: IForOfState) {
if (this.igxGridForOfVariableSizes && this.igxForScrollOrientation === 'vertical') {
super.recalcUpdateSizes();
super.recalcUpdateSizes(prevState);
}
}

Expand All @@ -1627,6 +1653,12 @@ export class IgxGridForOfDirective<T, U extends T[] = T[]> extends IgxForOfDirec
this.syncService.setMaster(this);
super.ngOnInit();
this.removeScrollEventListeners();
const destructor = takeUntil<any>(this.destroy$);
this.viewObserver = new (getResizeObserver())((entries: ResizeObserverEntry[]) => this.viewResizeNotify.next(entries));
this.viewResizeNotify.pipe(
filter(() => this.igxForContainerSize && this.igxForOf && this.igxForOf.length > 0),
destructor
).subscribe((entries: ResizeObserverEntry[]) => () => this.updateViewSizes(entries));
}

public override ngOnChanges(changes: SimpleChanges) {
Expand Down Expand Up @@ -1714,12 +1746,13 @@ export class IgxGridForOfDirective<T, U extends T[] = T[]> extends IgxForOfDirec
} else {
this._bScrollInternal = false;
}
const prevState = Object.assign({}, this.state);
const scrollOffset = this.fixedUpdateAllElements(this._virtScrollPosition);
runInInjectionContext(this._injector, () => {
afterNextRender({
write: () => {
this.dc.instance._viewContainer.element.nativeElement.style.transform = `translateY(${-scrollOffset}px)`;
this._zone.onStable.pipe(first()).subscribe(this.recalcUpdateSizes.bind(this));
this._zone.onStable.pipe(first()).subscribe(this.recalcUpdateSizes.bind(this, prevState));
}
});
});
Expand Down Expand Up @@ -1851,6 +1884,7 @@ export class IgxGridForOfDirective<T, U extends T[] = T[]> extends IgxForOfDirec
);

this._embeddedViews.push(embeddedView);
this.subscribeToViewObserver(embeddedView.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE) || embeddedView.rootNodes[0].nextElementSibling);
this.state.chunkSize++;
}

Expand Down
12 changes: 8 additions & 4 deletions projects/igniteui-angular/grids/grid/src/cell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,18 @@ describe('IgxGrid - Cell component #grid', () => {
it('should fit last cell in the available display container when there is vertical and horizontal scroll.', (async () => {
fix.componentInstance.columns = fix.componentInstance.generateCols(100);
fix.componentInstance.data = fix.componentInstance.generateData(1000);
await wait();
fix.detectChanges();
await wait(16);
fix.detectChanges();

const firsCell = GridFunctions.getRowCells(fix, 1)[0];
expect(GridFunctions.getValueFromCellElement(firsCell)).toEqual('0');

fix.componentInstance.scrollLeft(999999);
await wait();
const scrollbar = grid.headerContainer.getScroll();
scrollbar.scrollLeft = 999999;

await wait(16);

// This won't work always in debugging mode due to the angular native events behavior, so errors are expected
fix.detectChanges();
const cells = GridFunctions.getRowCells(fix, 1);
Expand Down Expand Up @@ -238,7 +242,7 @@ describe('IgxGrid - Cell component #grid', () => {
const scrollbar = grid.headerContainer.getScroll();
scrollbar.scrollLeft = 10000;
fix.detectChanges();
await wait();
await wait(16);
const lastColumnCells = grid.columnList.get(grid.columnList.length - 1).cells;
fix.detectChanges();
lastColumnCells.forEach((item) => {
Expand Down
2 changes: 2 additions & 0 deletions projects/igniteui-angular/grids/grid/src/grid.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
[igxForItemSize]="hasColumnLayouts ? rowHeight * multiRowLayoutRowSize + 1 : renderedRowHeight"
[igxForTrackBy]="trackChanges"
#verticalScrollContainer (chunkPreload)="dataLoading($event)" (dataChanging)="dataRebinding($event)" (dataChanged)="dataRebound($event)">
<div [attr.data-index]="rowIndex">
<ng-template
[igxTemplateOutlet]="getRowTemplate(rowData)"
[igxTemplateOutletContext]="getContext(rowData, rowIndex)"
Expand All @@ -112,6 +113,7 @@
(beforeViewDetach)="viewDetachHandler($event)"
(viewMoved)="viewMovedHandler($event)">
</ng-template>
</div>
</ng-template>
<ng-container *ngTemplateOutlet="hasPinnedRecords && !isRowPinningToTop ? pinnedRecordsTemplate : null">
</ng-container>
Expand Down
Loading
Loading