Skip to content

Commit 80f6929

Browse files
devversionjelbourn
authored andcommitted
fix(slide-toggle): prevent error when disabling while focused (#12325)
Fixes Angular throwing an `ExpressionChangedAfterItHasBeenCheckedError` when disabling the slide-toggle while the component has been focused. Fixes #12323
1 parent 0c746c1 commit 80f6929

File tree

2 files changed

+37
-6
lines changed

2 files changed

+37
-6
lines changed

src/lib/slide-toggle/slide-toggle.spec.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import {MutationObserverFactory} from '@angular/cdk/observers';
22
import {dispatchFakeEvent} from '@angular/cdk/testing';
33
import {Component} from '@angular/core';
4-
import {ComponentFixture, fakeAsync, flushMicrotasks, TestBed, tick} from '@angular/core/testing';
4+
import {
5+
ComponentFixture,
6+
fakeAsync,
7+
flush,
8+
flushMicrotasks,
9+
TestBed,
10+
tick,
11+
} from '@angular/core/testing';
512
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
613
import {defaultRippleAnimationConfig} from '@angular/material/core';
714
import {By, HAMMER_GESTURE_CONFIG} from '@angular/platform-browser';
@@ -773,7 +780,7 @@ describe('MatSlideToggle with forms', () => {
773780
expect(slideToggleElement.classList).toContain('mat-checked');
774781
}));
775782

776-
it('should have the correct control state initially and after interaction', () => {
783+
it('should have the correct control state initially and after interaction', fakeAsync(() => {
777784
// The control should start off valid, pristine, and untouched.
778785
expect(slideToggleModel.valid).toBe(true);
779786
expect(slideToggleModel.pristine).toBe(true);
@@ -795,13 +802,31 @@ describe('MatSlideToggle with forms', () => {
795802
// also turn touched.
796803
dispatchFakeEvent(inputElement, 'blur');
797804
fixture.detectChanges();
805+
flushMicrotasks();
798806

799807
expect(slideToggleModel.valid).toBe(true);
800808
expect(slideToggleModel.pristine).toBe(false);
801809
expect(slideToggleModel.touched).toBe(true);
802-
});
810+
}));
811+
812+
it('should not throw an error when disabling while focused', fakeAsync(() => {
813+
expect(() => {
814+
// Focus the input element because after disabling, the `blur` event should automatically
815+
// fire and not result in a changed after checked exception. Related: #12323
816+
inputElement.focus();
817+
818+
// Flush the two nested timeouts from the FocusMonitor that are being created on `focus`.
819+
flush();
820+
821+
slideToggle.disabled = true;
822+
fixture.detectChanges();
823+
flushMicrotasks();
824+
}).not.toThrow();
825+
}));
826+
827+
it('should not set the control to touched when changing the state programmatically',
828+
fakeAsync(() => {
803829

804-
it('should not set the control to touched when changing the state programmatically', () => {
805830
// The control should start off with being untouched.
806831
expect(slideToggleModel.touched).toBe(false);
807832

@@ -815,10 +840,11 @@ describe('MatSlideToggle with forms', () => {
815840
// also turn touched.
816841
dispatchFakeEvent(inputElement, 'blur');
817842
fixture.detectChanges();
843+
flushMicrotasks();
818844

819845
expect(slideToggleModel.touched).toBe(true);
820846
expect(slideToggleElement.classList).toContain('mat-checked');
821-
});
847+
}));
822848

823849
it('should not set the control to touched when changing the model', fakeAsync(() => {
824850
// The control should start off with being untouched.

src/lib/slide-toggle/slide-toggle.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,12 @@ export class MatSlideToggle extends _MatSlideToggleMixinBase implements OnDestro
289289
// For keyboard focus show a persistent ripple as focus indicator.
290290
this._focusRipple = this._ripple.launch(0, 0, {persistent: true});
291291
} else if (!focusOrigin) {
292-
this.onTouched();
292+
// When a focused element becomes disabled, the browser *immediately* fires a blur event.
293+
// Angular does not expect events to be raised during change detection, so any state change
294+
// (such as a form control's 'ng-touched') will cause a changed-after-checked error.
295+
// See https://github.com/angular/angular/issues/17793. To work around this, we defer telling
296+
// the form control it has been touched until the next tick.
297+
Promise.resolve().then(() => this.onTouched());
293298

294299
// Fade out and clear the focus ripple if one is currently present.
295300
if (this._focusRipple) {

0 commit comments

Comments
 (0)