From 85a542e324564fcf6eb62d361578eac2df0a81b8 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 11 Sep 2026 21:04:05 +0300 Subject: [PATCH 1/2] fix(grid): notify change detection when column state changes --- .../src/app/custom-strategy.spec.ts | 57 ++++++++++++++++++- .../column-actions.component.ts | 23 +++++++- ...id-toolbar-advanced-filtering.component.ts | 16 ++++-- .../core/src/toolbar/grid-toolbar.base.ts | 18 +++++- 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index d056d5c67af..2bcb193133e 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -14,6 +14,9 @@ import { IgcColumnLayoutComponent, IgcActionStripComponent, IgcGridEditingActionsComponent, + IgcGridToolbarComponent, + IgcGridToolbarActionsComponent, + IgcGridToolbarHidingComponent, } from './components'; import { defineComponents } from '../utils/register'; @@ -30,7 +33,10 @@ describe('Elements: ', () => { IgcPaginatorComponent, IgcGridStateComponent, IgcActionStripComponent, - IgcGridEditingActionsComponent + IgcGridEditingActionsComponent, + IgcGridToolbarComponent, + IgcGridToolbarActionsComponent, + IgcGridToolbarHidingComponent ); }); @@ -365,5 +371,54 @@ describe('Elements: ', () => { highlightedCell = gridEl.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); expect(highlightedCell).toBeNull(); }); + + it('should update the open column hiding dropdown when a column is removed', async () => { + // The column actions list renders grid._columns through pure pipes. With the dropdown + // already open, removing a column resets the QueryList with no click and no element + // insert - none of the triggers Angular's zoneless scheduler recognises - so without an + // explicit notification the list keeps rendering the collection as it was before. + const gridEl = document.createElement("igc-grid"); + const toolbar = document.createElement("igc-grid-toolbar"); + const actions = document.createElement("igc-grid-toolbar-actions"); + const hiding = document.createElement("igc-grid-toolbar-hiding"); + actions.appendChild(hiding); + toolbar.appendChild(actions); + gridEl.appendChild(toolbar); + + const columns = ["ProductID", "ProductName", "InStock"].map(field => { + const col = document.createElement("igc-column"); + col.setAttribute("field", field); + gridEl.appendChild(col); + return col; + }); + + gridEl.data = SampleTestData.foodProductData(); + testContainer.appendChild(gridEl); + + await firstValueFrom(fromEvent(gridEl, "childrenResolved")); + await firstValueFrom(fromEvent(gridEl, "dataChanged")); + + const listedColumns = () => + document.querySelectorAll('igx-column-actions .igx-column-actions__columns-item').length; + // A fixed SCHEDULE_DELAY wait is too short when grid init is slow, so poll until the + // rendered count settles instead of guessing how long it takes. A missing notification + // never settles and falls through to the expectation below. + const waitForListed = async (expected: number) => { + for (let waited = 0; waited < 3000 && listedColumns() !== expected; waited += 20) { + await firstValueFrom(timer(20)); + } + }; + + hiding.querySelector('button').click(); + await waitForListed(3); + expect(listedColumns()).toBe(3); + + const resolved = firstValueFrom(fromEvent(gridEl, "childrenResolved")); + gridEl.removeChild(columns[2]); + await resolved; + + await waitForListed(2); + expect(listedColumns()).toBe(2); + }); }); }); diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts index 61de8da3ee9..47a2820113a 100644 --- a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts @@ -15,8 +15,12 @@ import { forwardRef, inject, ChangeDetectionStrategy, + ChangeDetectorRef, + DestroyRef, + OnInit, ViewEncapsulation } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ColumnDisplayOrder } from '../common/enums'; import { GridType } from '../common/grid.interface'; import { IColumnToggledEventArgs } from '../common/events'; @@ -42,8 +46,10 @@ let NEXT_ID = 0; changeDetection: ChangeDetectionStrategy.Eager, imports: [IgxInputGroupComponent, FormsModule, IgxInputDirective, IgxCheckboxComponent, IgxButtonDirective, IgxRippleDirective, forwardRef(() => IgxColumnActionEnabledPipe), forwardRef(() => IgxFilterActionColumnsPipe), forwardRef(() => IgxSortActionColumnsPipe)] }) -export class IgxColumnActionsComponent implements DoCheck { +export class IgxColumnActionsComponent implements DoCheck, OnInit { private differs = inject(IterableDiffers); + private cdr = inject(ChangeDetectorRef); + private destroyRef = inject(DestroyRef); /** @@ -187,6 +193,21 @@ export class IgxColumnActionsComponent implements DoCheck { this._differ = this.differs.find([]).create(this.trackChanges); } + /** + * @hidden @internal + */ + public ngOnInit() { + // ngDoCheck only runs when something else checks this view. The list can live in a view the + // grid's own change detection does not reach - a separately attached host view in Elements, + // or an overlay - so a column change has to mark this view dirty itself. + this.grid?.columnList?.changes + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.pipeTrigger++; + this.cdr.markForCheck(); + }); + } + /** * Gets the prompt that is displayed in the filter input. * diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts index 6db1ccde0ce..2d5b2f84e98 100644 --- a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts @@ -1,4 +1,5 @@ -import { Component, Input, OnInit, inject, ChangeDetectionStrategy } from '@angular/core'; +import { Component, Input, OnInit, inject, ChangeDetectionStrategy, ChangeDetectorRef, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { IgxToolbarToken } from './token'; import { IgxButtonDirective, IgxRippleDirective } from 'igniteui-angular/directives'; import { IgxIconComponent } from 'igniteui-angular/icon'; @@ -31,6 +32,8 @@ import { IFilteringExpressionsTree, isTree, OverlaySettings } from 'igniteui-ang }) export class IgxGridToolbarAdvancedFilteringComponent implements OnInit { private toolbar = inject(IgxToolbarToken); + private cdr = inject(ChangeDetectorRef); + private destroyRef = inject(DestroyRef); protected numberOfColumns!: number; /** @@ -52,9 +55,14 @@ export class IgxGridToolbarAdvancedFilteringComponent implements OnInit { this.numberOfColumns = this.grid?.advancedFilteringExpressionsTree ? this.extractUniqueFieldNamesFromFilterTree(this.grid?.advancedFilteringExpressionsTree).length : 0; // Subscribing for future updates - this.grid?.advancedFilteringExpressionsTreeChange.subscribe(filteringTree => { - this.numberOfColumns = this.extractUniqueFieldNamesFromFilterTree(filteringTree).length; - }); + this.grid?.advancedFilteringExpressionsTreeChange + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(filteringTree => { + this.numberOfColumns = this.extractUniqueFieldNamesFromFilterTree(filteringTree).length; + // The tree is changed from the advanced filtering dialog, i.e. outside of a check of + // this view, so nothing else marks it dirty in a zoneless app. + this.cdr.markForCheck(); + }); } protected extractUniqueFieldNamesFromFilterTree(filteringTree?: IFilteringExpressionsTree) : string[] { diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts index 9d8baf24809..6c808c2b18c 100644 --- a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts @@ -1,4 +1,5 @@ -import { Directive, Input, EventEmitter, OnDestroy, Output, booleanAttribute, inject } from '@angular/core'; +import { Directive, Input, EventEmitter, OnDestroy, OnInit, Output, booleanAttribute, inject, ChangeDetectorRef, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Subject, Subscription } from 'rxjs'; import { first, takeUntil } from 'rxjs/operators'; @@ -163,7 +164,10 @@ export abstract class BaseToolbarDirective implements OnDestroy { * Base class for pinning/hiding column actions */ @Directive() -export abstract class BaseToolbarColumnActionsDirective extends BaseToolbarDirective { +export abstract class BaseToolbarColumnActionsDirective extends BaseToolbarDirective implements OnInit { + private cdr = inject(ChangeDetectorRef); + private destroyRef = inject(DestroyRef); + @Input({ transform: booleanAttribute }) public hideFilter = false; @@ -190,6 +194,16 @@ export abstract class BaseToolbarColumnActionsDirective extends BaseToolbarDirec protected columnActionsUI!: IgxColumnActionsComponent; + /** @hidden @internal */ + public ngOnInit() { + // The button label reads the pinned/hidden counts straight off the grid. Those change from + // the column actions dropdown or from the other toolbar action, neither of which checks + // this view, so in a zoneless app nothing marks it dirty and the label goes stale. + const markDirty = () => this.cdr.markForCheck(); + this.grid?.columnPinned.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(markDirty); + this.grid?.columnVisibilityChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(markDirty); + } + public checkAll() { this.columnActionsUI.checkAllColumns(); } From d73fe4f5411e5e074e56bb491704c23d6cb82726 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 14 Sep 2026 10:08:48 +0300 Subject: [PATCH 2/2] chore(*): address comments from PR review --- .../src/app/custom-strategy.spec.ts | 9 ++------- .../core/src/column-actions/column-actions.component.ts | 4 +--- .../toolbar/grid-toolbar-advanced-filtering.component.ts | 3 +-- .../grids/core/src/toolbar/grid-toolbar.base.ts | 4 +--- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index 2bcb193133e..c5619dd3743 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -373,10 +373,7 @@ describe('Elements: ', () => { }); it('should update the open column hiding dropdown when a column is removed', async () => { - // The column actions list renders grid._columns through pure pipes. With the dropdown - // already open, removing a column resets the QueryList with no click and no element - // insert - none of the triggers Angular's zoneless scheduler recognises - so without an - // explicit notification the list keeps rendering the collection as it was before. + // Removing a column resets the QueryList with no click and no element insert const gridEl = document.createElement("igc-grid"); const toolbar = document.createElement("igc-grid-toolbar"); const actions = document.createElement("igc-grid-toolbar-actions"); @@ -400,9 +397,7 @@ describe('Elements: ', () => { const listedColumns = () => document.querySelectorAll('igx-column-actions .igx-column-actions__columns-item').length; - // A fixed SCHEDULE_DELAY wait is too short when grid init is slow, so poll until the - // rendered count settles instead of guessing how long it takes. A missing notification - // never settles and falls through to the expectation below. + // Grid init timing varies, so poll instead of guessing a fixed delay const waitForListed = async (expected: number) => { for (let waited = 0; waited < 3000 && listedColumns() !== expected; waited += 20) { await firstValueFrom(timer(20)); diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts index 47a2820113a..4361888b871 100644 --- a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts @@ -197,9 +197,7 @@ export class IgxColumnActionsComponent implements DoCheck, OnInit { * @hidden @internal */ public ngOnInit() { - // ngDoCheck only runs when something else checks this view. The list can live in a view the - // grid's own change detection does not reach - a separately attached host view in Elements, - // or an overlay - so a column change has to mark this view dirty itself. + // ngDoCheck only runs if something else checks this view, which is not guaranteed this.grid?.columnList?.changes .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts index 2d5b2f84e98..5ef71a8762c 100644 --- a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts @@ -59,8 +59,7 @@ export class IgxGridToolbarAdvancedFilteringComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(filteringTree => { this.numberOfColumns = this.extractUniqueFieldNamesFromFilterTree(filteringTree).length; - // The tree is changed from the advanced filtering dialog, i.e. outside of a check of - // this view, so nothing else marks it dirty in a zoneless app. + // The dialog changes the tree outside a check of this view this.cdr.markForCheck(); }); } diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts index 6c808c2b18c..df56a33e00b 100644 --- a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts @@ -196,9 +196,7 @@ export abstract class BaseToolbarColumnActionsDirective extends BaseToolbarDirec /** @hidden @internal */ public ngOnInit() { - // The button label reads the pinned/hidden counts straight off the grid. Those change from - // the column actions dropdown or from the other toolbar action, neither of which checks - // this view, so in a zoneless app nothing marks it dirty and the label goes stale. + // The counts change from an action that never checks this view const markDirty = () => this.cdr.markForCheck(); this.grid?.columnPinned.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(markDirty); this.grid?.columnVisibilityChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(markDirty);