Skip to content

fix(material/tabs): endless toggling when selecting tabs in quick succession - #33755

Open
meetbhalodi11 wants to merge 1 commit into
angular:mainfrom
meetbhalodi11:fix-tabs-24096
Open

fix(material/tabs): endless toggling when selecting tabs in quick succession#33755
meetbhalodi11 wants to merge 1 commit into
angular:mainfrom
meetbhalodi11:fix-tabs-24096

Conversation

@meetbhalodi11

Copy link
Copy Markdown

Fixes #24096

The problem

selectedTabChange is declared as an async emitter:

@Output() readonly selectedTabChange: EventEmitter<MatTabChangeEvent> =
  new EventEmitter<MatTabChangeEvent>(true);

Angular wraps subscribers of async emitters in setTimeout, so handlers run a macrotask after the event is emitted. In ngAfterContentChecked the event is emitted before _selectedIndex is committed, and the handler runs later still.

When the selection changes more than once before those callbacks run, a callback is delivered carrying an index that is no longer current. Apps that write event.index back into [selectedIndex] — a common pattern — then push the group back to the stale index, which emits again, and the two values ping-pong indefinitely.

This needs two conditions, which is why some people cannot reproduce it:

  1. [selectedIndex] is bound, and
  2. the selectedTabChange handler writes the index back into it.

It also requires zone-based change detection, since the loop is sustained by a change detection pass running between the queued callbacks. It does not reproduce under provideZonelessChangeDetection().

Reproduction

https://stackblitz.com/edit/stackblitz-starters-2fiwdygt

Press "Reproduce — 10 real tab clicks" once and then stop interacting with the page. Ten clicks produce 2000+ emissions and the tabs keep toggling on their own; the demo stops only because of a built-in safety cap. Unticking the checkbox runs the identical trigger against selectedIndexChange, which settles at exactly 10.

The demo also logs each delivery as delivered=<event index> committed=<group index>. Every delivery is marked STALE, i.e. the event never matches the selection that was actually current when the handler ran.

The fix

Emit selectedTabChange from the same microtask as selectedIndexChange, and make it a synchronous emitter so that emit time and delivery time coincide. This is precisely why selectedIndexChange is already unaffected, and why switching to it is the workaround people have been using for the last four years.

Note on the previously suggested fix

The most upvoted comment on the issue suggests applying 67e02b0 to selectedTabChange as well. That commit deferred the emit into a microtask, which worked for selectedIndexChange because it is a synchronous emitter — deferring the emit also defers the delivery.

selectedTabChange is async, so emit time and delivery time are decoupled. Deferring only the emit still leaves the payload to be handed over a macrotask later, and a change detection pass in between can commit a different index. Dropping the async flag is therefore also required.

Behaviour change

selectedTabChange handlers now run in a microtask rather than a macrotask, and after selectedIndexChange in the same tick instead of a later one. Code that relied on the extra delay could be affected. Please let me know if you would like this called out as a BREAKING CHANGE: in the commit footer.

Testing

  • Added a regression test that fails on main and passes with this change. Without the fix it reports Expected $.length = 1 to equal 2, because only selectedIndexChange has been delivered once the microtask queue is flushed.
  • pnpm test tabs --no-watch — 148/148 pass, including the 147 pre-existing tests.
  • The test asserts delivery timing rather than the loop itself, so that it is deterministic and works in the zoneless test environment.

@google-cla

google-cla Bot commented Sep 1, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

…cession

`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 angular#24096
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mat Tabs endless toggling issue on fast changing of tabs

1 participant