From d451a6bb2b2abd72dd4472ea5a56864b31d79273 Mon Sep 17 00:00:00 2001 From: meetbhalodi11 <42618929+meetbhalodi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:58:53 +0000 Subject: [PATCH] fix(material/tabs): endless toggling when selecting tabs in quick succession `selectedTabChange` is an async `EventEmitter`, so its subscribers are invoked in a `setTimeout`. When the selection changes several times before those callbacks run, a callback can be delivered with an index that is no longer current. Consumers that write `event.index` back into `[selectedIndex]` then push the group back to the stale index, which emits again and results in an endless toggle. Emits the event from the same microtask as `selectedIndexChange` so that emit time and delivery time coincide, which is why `selectedIndexChange` is already unaffected. Fixes #24096 --- src/material/tabs/tab-group.spec.ts | 39 +++++++++++++++++++++++++++++ src/material/tabs/tab-group.ts | 4 +-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/material/tabs/tab-group.spec.ts b/src/material/tabs/tab-group.spec.ts index 71705913bbfa..bfc7c9fd86e1 100644 --- a/src/material/tabs/tab-group.spec.ts +++ b/src/material/tabs/tab-group.spec.ts @@ -898,6 +898,29 @@ describe('MatTabGroup', () => { 'Child 3', ]); }); + + it('should emit selectedTabChange in the same task as selectedIndexChange', async () => { + const fixture = TestBed.createComponent(TabGroupWithBoundIndex); + fixture.detectChanges(); + + const tabGroup = fixture.componentInstance.tabGroup; + const emissions: string[] = []; + + tabGroup.selectedIndexChange.subscribe(() => emissions.push('selectedIndexChange')); + tabGroup.selectedTabChange.subscribe(() => emissions.push('selectedTabChange')); + + fixture.componentInstance.selectedIndex = 2; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + // Flush the microtask queue. Both events should have been delivered by now. If + // `selectedTabChange` is delivered in a later task, an index that has since been + // superseded can be handed to the app, which can push a stale value back into + // `selectedIndex` and cause an endless toggle. + await Promise.resolve(); + + expect(emissions).toEqual(['selectedIndexChange', 'selectedTabChange']); + }); }); describe('nested tabs', () => { @@ -1649,3 +1672,19 @@ class TabsWithAlignConfig {} changeDetection: ChangeDetectionStrategy.Eager, }) class TabsWithAlignCenter {} + +@Component({ + template: ` + + One + Two + Three + + `, + imports: [MatTabsModule], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class TabGroupWithBoundIndex { + @ViewChild(MatTabGroup) tabGroup!: MatTabGroup; + selectedIndex = 0; +} diff --git a/src/material/tabs/tab-group.ts b/src/material/tabs/tab-group.ts index e5cc623effc7..1389369f15af 100644 --- a/src/material/tabs/tab-group.ts +++ b/src/material/tabs/tab-group.ts @@ -282,7 +282,7 @@ export class MatTabGroup /** Event emitted when the tab selection has changed. */ @Output() readonly selectedTabChange: EventEmitter = - new EventEmitter(true); + new EventEmitter(); private _groupId: string; @@ -332,7 +332,6 @@ export class MatTabGroup const isFirstRun = this._selectedIndex == null; if (!isFirstRun) { - this.selectedTabChange.emit(this._createChangeEvent(indexToSelect)); // Preserve the height so page doesn't scroll up during tab change. // Fixes https://stackblitz.com/edit/mat-tabs-scroll-page-top-on-tab-change const wrapper = this._tabBodyWrapper.nativeElement; @@ -346,6 +345,7 @@ export class MatTabGroup if (!isFirstRun) { this.selectedIndexChange.emit(indexToSelect); + this.selectedTabChange.emit(this._createChangeEvent(indexToSelect)); // Clear the min-height, this was needed during tab change to avoid // unnecessary scrolling. this._tabBodyWrapper.nativeElement.style.minHeight = '';