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 = '';