From 33df82bf5fb74a045680fcc35e4c7de1c4f97753 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 15 Jun 2026 10:23:38 +0300 Subject: [PATCH 1/2] fix(grid): fix zone.onStable patterns broken in zoneless change detection --- .../grids/grid/src/grid-base.directive.ts | 54 ++- .../grids/grid/src/grid.zoneless.spec.ts | 369 ++++++++++++++++++ .../pivot-grid/src/pivot-grid.component.ts | 10 +- 3 files changed, 416 insertions(+), 17 deletions(-) create mode 100644 projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 9201378861b..bb08c4fac52 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -4188,9 +4188,13 @@ export abstract class IgxGridBaseDirective implements GridType, if (this.hasColumnsToAutosize) { this.headerContainer?.dataChanged.pipe(takeUntil(this.destroy$)).subscribe(() => { this.cdr.detectChanges(); - this.zone.onStable.pipe(first()).subscribe(() => { + if (this.isZonelessChangeDetection()) { this.autoSizeColumnsInView(); - }); + } else { + this.zone.onStable.pipe(first()).subscribe(() => { + this.autoSizeColumnsInView(); + }); + } }); } // Window resize observer not needed because when you resize the window element the tbody container always resize so @@ -4703,10 +4707,15 @@ export abstract class IgxGridBaseDirective implements GridType, // reset auto-size and calculate it again. this._columns.forEach(x => x.autoSize = undefined); this.resetCaches(); - this.zone.onStable.pipe(first()).subscribe(() => { + if (this.isZonelessChangeDetection()) { this.cdr.detectChanges(); this.autoSizeColumnsInView(); - }); + } else { + this.zone.onStable.pipe(first()).subscribe(() => { + this.cdr.detectChanges(); + this.autoSizeColumnsInView(); + }); + } } /** @@ -6399,7 +6408,7 @@ export abstract class IgxGridBaseDirective implements GridType, const tmplId = args.context.templateID.type; const index = args.context.index; args.view.detectChanges(); - this.zone.onStable.pipe(first()).subscribe(() => { + const restoreState = () => { const row = tmplId === 'dataRow' ? this.gridAPI.get_row_by_index(index) : null; const summaryRow = tmplId === 'summaryRow' ? this.summariesRowList.find((sr) => sr.dataRowIndex === index) : null; if (row && row instanceof IgxRowDirective) { @@ -6407,7 +6416,12 @@ export abstract class IgxGridBaseDirective implements GridType, } else if (summaryRow) { this._restoreVirtState(summaryRow); } - }); + }; + if (this.isZonelessChangeDetection()) { + restoreState(); + } else { + this.zone.onStable.pipe(first()).subscribe(restoreState); + } } } @@ -7113,9 +7127,13 @@ export abstract class IgxGridBaseDirective implements GridType, this.resetCaches(recalcFeatureWidth); if (this.hasColumnsToAutosize) { this.cdr.detectChanges(); - this.zone.onStable.pipe(first()).subscribe(() => { + if (this.isZonelessChangeDetection()) { this._autoSizeColumnsNotify.next(); - }); + } else { + this.zone.onStable.pipe(first()).subscribe(() => { + this._autoSizeColumnsNotify.next(); + }); + } } // in case horizontal scrollbar has appeared recalc to size correctly. @@ -7830,14 +7848,20 @@ export abstract class IgxGridBaseDirective implements GridType, this._horizontalForOfs.forEach(vfor => vfor.onHScroll(scrollLeft)); this.cdr.markForCheck(); - this.zone.run(() => { - this.zone.onStable.pipe(first()).subscribe(() => { - this.parentVirtDir.chunkLoad.emit(this.headerContainer.state); - requestAnimationFrame(() => { - this.autoSizeColumnsInView(); - }); + const emitChunkLoad = () => { + this.parentVirtDir.chunkLoad.emit(this.headerContainer.state); + requestAnimationFrame(() => { + this.autoSizeColumnsInView(); }); - }); + }; + if (this.isZonelessChangeDetection()) { + this.cdr.detectChanges(); + emitChunkLoad(); + } else { + this.zone.run(() => { + this.zone.onStable.pipe(first()).subscribe(emitChunkLoad); + }); + } if (!this.navigation.isColumnFullyVisible(this.navigation.lastColumnIndex)) { this.hideOverlays(); } diff --git a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts new file mode 100644 index 00000000000..ae112719085 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts @@ -0,0 +1,369 @@ +/** + * Zoneless change-detection regression tests for IgxGrid. + * + * Each describe block resets the TestBed and adds provideZonelessChangeDetection() + * so tests run exactly as a consumer app would when Zone.js CD scheduling is absent. + * + * Constraint: after the action under test, fixture.detectChanges() is NOT called. + * Rendered updates must appear via the Angular zoneless scheduler (markForCheck + + * PendingTasks) confirmed with fixture.whenStable(), or via observable / event spies. + * + * Patterns covered + * ──────────────── + * 1. Initial render – grid displays rows on first detectChanges() + * 2. Async data change – sort / filter trigger notifyChanges() → markForCheck() + * → zoneless scheduler → ngDoCheck() → detectChanges() + * 3. Browser callback – filteringDone emitted from a requestAnimationFrame callback + * 4. Horizontal scroll – parentVirtDir.chunkLoad emitted after hScroll event + * (broken: zone.onStable never fires in NoopNgZone) + * 5. fit-content column API – recalculateAutoSizes() and calculateGridSizes() must + * reach autoSizeColumnsInView() without zone.onStable + * + * Patterns NOT covered here (separate PRs) + * ───────────────────────────────────────── + * - Pivot grid auto-size (fixed in pivot-grid.component.ts but no test added here) + * - IgxForOfDirective/IgxGridForOfDirective zone.onStable fixes — covered by vkombov/fix-17280 + * - IntersectionObserver zone.run fix in grid-base.directive.ts — covered by vkombov/fix-17280 + * - Row editing overlay position (zone.onStable in RowEditPositionStrategy) + * - cachedViewLoaded() virt-state restoration after view recycling during H-scroll + */ + +import { Component, ViewChild, provideZonelessChangeDetection } from '@angular/core'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { SortingDirection, IgxStringFilteringOperand } from 'igniteui-angular/core'; +import { IgxGridComponent } from './grid.component'; +import { IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; + +// ─── Reusable test host components ────────────────────────────────────────── + +/** Simple grid with ID / Name / LastName columns; data from personIDNameRegionData (7 rows). */ +@Component({ + template: ` + + + + + + `, + standalone: true, + imports: [IgxGridComponent, IgxColumnComponent] +}) +class ZonelessSimpleGridComponent { + @ViewChild('grid', { static: true }) public grid: IgxGridComponent; + public data = SampleTestData.personIDNameRegionData(); +} + +/** + * Wide grid (5 × 200 px columns in a 400 px container) that forces horizontal + * virtualization so we can verify parentVirtDir.chunkLoad after scrolling. + */ +@Component({ + template: ` + + + + + + + + `, + standalone: true, + imports: [IgxGridComponent, IgxColumnComponent] +}) +class ZonelessWideGridComponent { + @ViewChild('grid', { static: true }) public grid: IgxGridComponent; + public data = Array.from({ length: 5 }, (_, i) => ({ + col0: i, col1: i * 2, col2: i * 3, col3: i * 4, col4: i * 5 + })); +} + +/** + * Grid with fit-content columns so hasColumnsToAutosize is true. + * Used to exercise recalculateAutoSizes() and calculateGridSizes() autosize paths. + */ +@Component({ + template: ` + + + + + + `, + standalone: true, + imports: [IgxGridComponent, IgxColumnComponent] +}) +class ZonelessAutoSizeGridComponent { + @ViewChild('grid', { static: true }) public grid: IgxGridComponent; + public data = SampleTestData.personIDNameRegionData(); +} + +// ─── Test suite ───────────────────────────────────────────────────────────── + +describe('IgxGrid - Zoneless Change Detection #grid', () => { + + // ── Pattern 1 & 2: initial render + async data changes ────────────────── + + describe('Basic rendering and async data changes', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ZonelessSimpleGridComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + }); + + it('should render data rows on initial detectChanges()', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + + const rows = fix.debugElement.queryAll(By.css('igx-grid-row')); + expect(rows.length).toBe(7, 'expected all 7 data rows to be rendered'); + }); + + it('should update row order after sort() without calling detectChanges() again', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + + // personIDNameRegionData has IDs [2,1,6,7,5,4,3]; ascending sort → 1 first + grid.sort({ fieldName: 'ID', dir: SortingDirection.Asc, ignoreCase: false }); + // No fixture.detectChanges() here — the zoneless scheduler must run it + await fix.whenStable(); + + const firstRowCells = fix.debugElement + .query(By.css('igx-grid-row')) + .queryAll(By.css('igx-grid-cell')); + expect(firstRowCells[0].nativeElement.textContent.trim()).toBe('1'); + }); + + it('should update row order after sort() desc without calling detectChanges() again', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + + grid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); + await fix.whenStable(); + + const firstRowCells = fix.debugElement + .query(By.css('igx-grid-row')) + .queryAll(By.css('igx-grid-cell')); + // highest ID is 7 + expect(firstRowCells[0].nativeElement.textContent.trim()).toBe('7'); + }); + + it('should reduce visible rows after filter() without calling detectChanges() again', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + + // personIDNameRegionData has 2 rows named "Rick" + grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); + await fix.whenStable(); + + const rows = fix.debugElement.queryAll(By.css('igx-grid-row')); + expect(rows.length).toBe(2, 'expected exactly 2 "Rick" rows after filter'); + }); + + it('should restore full row count after clearFilter() without calling detectChanges() again', async () => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + + grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); + await fix.whenStable(); + + grid.clearFilter('Name'); + await fix.whenStable(); + + const rows = fix.debugElement.queryAll(By.css('igx-grid-row')); + expect(rows.length).toBe(7, 'expected all rows restored after clearFilter'); + }); + }); + + // ── Pattern 3: browser callback (requestAnimationFrame) ───────────────── + + describe('filteringDone event (requestAnimationFrame callback)', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ZonelessSimpleGridComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + }); + + it('should emit filteringDone after filter() in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + const emittedArgs: any[] = []; + grid.filteringDone.subscribe(args => emittedArgs.push(args)); + + grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); + // requestAnimationFrame is treated as a macrotask in fakeAsync; tick flushes it + tick(16); + + expect(emittedArgs.length).toBe(1, 'filteringDone must emit exactly once'); + expect(emittedArgs[0]).toBeTruthy(); + })); + + it('should emit filteringDone after clearFilter() in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessSimpleGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + grid.filter('Name', 'Rick', IgxStringFilteringOperand.instance().condition('equals')); + tick(16); + + const emittedArgs: any[] = []; + grid.filteringDone.subscribe(args => emittedArgs.push(args)); + + grid.clearFilter('Name'); + tick(16); + + expect(emittedArgs.length).toBe(1, 'filteringDone must emit after clearFilter'); + })); + }); + + // ── Pattern 4: horizontal-scroll chunkLoad (zone.onStable issue) ──────── + + describe('Horizontal scroll – parentVirtDir.chunkLoad emission', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ZonelessWideGridComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + }); + + /** + * Regression: in NoopNgZone (zoneless), zone.onStable never emits. + * horizontalScrollHandler() gated parentVirtDir.chunkLoad.emit() behind + * zone.onStable.pipe(first()).subscribe(), so the event was never raised + * after a horizontal scroll in a zoneless consumer app. + * + * Fix: apply the same isZonelessChangeDetection() guard used in + * verticalScrollHandler() and call emit() directly. + */ + it('should emit parentVirtDir.chunkLoad after horizontal scroll in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessWideGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + const chunkLoadSpy = jasmine.createSpy('parentVirtDir.chunkLoad'); + grid.parentVirtDir.chunkLoad.subscribe(chunkLoadSpy); + + // Trigger the horizontal scroll handler the same way the real scroller does + const hScroller = grid.headerContainer.getScroll(); + hScroller.scrollLeft = 300; + hScroller.dispatchEvent(new Event('scroll')); + tick(100); + + expect(chunkLoadSpy).toHaveBeenCalled(); + })); + + it('should render updated column data after horizontal scroll in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessWideGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + + // Wait for chunkLoad as the reliable signal that virtualization has settled + let chunkLoaded = false; + grid.parentVirtDir.chunkLoad.subscribe(() => { chunkLoaded = true; }); + + const hScroller = grid.headerContainer.getScroll(); + hScroller.scrollLeft = 400; + hScroller.dispatchEvent(new Event('scroll')); + tick(100); + + expect(chunkLoaded).toBe(true, 'chunkLoad must emit so the grid knows columns shifted'); + })); + }); + + // ── Pattern 5: fit-content column auto-sizing ──────────────────────────── + + describe('fit-content column auto-sizing', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ZonelessAutoSizeGridComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + }); + + /** + * Regression: recalculateAutoSizes() first resets col.autoSize to undefined, + * then gates the re-measurement behind zone.onStable.pipe(first()).subscribe(). + * In NoopNgZone that subscription never fires, so the method silently does nothing. + * + * Fix: detect zoneless with isZonelessChangeDetection() and call + * cdr.detectChanges() + autoSizeColumnsInView() directly. + * + * Note on hasColumnsToAutosize: after the initial render ChromeHeadless measures + * real header widths > 0, so col.autoSize becomes a number and col.width returns + * "Npx", making hasColumnsToAutosize false. recalculateAutoSizes() itself resets + * col.autoSize to undefined before calling the measurement — we only need to verify + * that the measurement is actually invoked, so we spy on the protected method. + */ + it('recalculateAutoSizes() should call autoSizeColumnsInView() in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessAutoSizeGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + const spy = spyOn(grid as any, 'autoSizeColumnsInView').and.callThrough(); + + grid.recalculateAutoSizes(); + tick(16); + + expect(spy).toHaveBeenCalled(); + })); + + /** + * Regression: _zoneBegoneListeners() subscribes to headerContainer.dataChanged and + * gates autoSizeColumnsInView() behind zone.onStable.pipe(first()).subscribe(). + * In NoopNgZone that subscription never fires, so columns are never re-measured + * when the header virtual scroll data changes (column visibility toggle, H-scroll). + * + * Fix: detect zoneless with isZonelessChangeDetection() and call + * autoSizeColumnsInView() directly after detectChanges(). + * + * Setup: hide then show a column, which causes headerContainer.dataChanged to emit. + * The spy is placed between the two visibility changes so only the "show" emission + * is captured. + */ + it('toggling a fit-content column visible should call autoSizeColumnsInView() via dataChanged in zoneless mode', fakeAsync(() => { + const fix = TestBed.createComponent(ZonelessAutoSizeGridComponent); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + const col = grid.getColumnByName('LastName'); + col.hidden = true; + fix.detectChanges(); + tick(16); + + const spy = spyOn(grid as any, 'autoSizeColumnsInView').and.callThrough(); + + // Making the column visible emits headerContainer.dataChanged which must reach + // autoSizeColumnsInView() without zone.onStable in between. + col.hidden = false; + tick(16); + + expect(spy).toHaveBeenCalled(); + })); + }); +}); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts index aebbdbb6831..9ad7dbe2e60 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts @@ -2153,11 +2153,17 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni super.calculateGridSizes(recalcFeatureWidth); if (this.hasDimensionsToAutosize) { this.cdr.detectChanges(); - this.zone.onStable.pipe(first()).subscribe(() => { + if (this.isZonelessChangeDetection()) { requestAnimationFrame(() => { this.autoSizeDimensionsInView(); }); - }); + } else { + this.zone.onStable.pipe(first()).subscribe(() => { + requestAnimationFrame(() => { + this.autoSizeDimensionsInView(); + }); + }); + } } } From a3c606c5914d573d6f3ab0072c417d9a451f7ff7 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 15 Jun 2026 12:51:56 +0300 Subject: [PATCH 2/2] chore(*): fix a lint error --- .../igniteui-angular/grids/grid/src/grid.zoneless.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts index ae112719085..a58b84ff333 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.zoneless.spec.ts @@ -282,7 +282,9 @@ describe('IgxGrid - Zoneless Change Detection #grid', () => { // Wait for chunkLoad as the reliable signal that virtualization has settled let chunkLoaded = false; - grid.parentVirtDir.chunkLoad.subscribe(() => { chunkLoaded = true; }); + grid.parentVirtDir.chunkLoad.subscribe(() => { + chunkLoaded = true; + }); const hScroller = grid.headerContainer.getScroll(); hScroller.scrollLeft = 400;