From 0eb5ec12c664031cb6886a996034777709648b73 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 25 Jul 2018 15:19:22 +0200 Subject: [PATCH] fix(slide-toggle): error when disabling while focused * Fixes that Angular throws an `ExpressionChangedAfterItHasBeenCheckedError` when disabling the slide-toggle while the component has been focused. Fixes #12323 --- src/lib/slide-toggle/slide-toggle.spec.ts | 36 +++++++++++++++++++---- src/lib/slide-toggle/slide-toggle.ts | 7 ++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/lib/slide-toggle/slide-toggle.spec.ts b/src/lib/slide-toggle/slide-toggle.spec.ts index f9e512118463..84eaee5e0d72 100644 --- a/src/lib/slide-toggle/slide-toggle.spec.ts +++ b/src/lib/slide-toggle/slide-toggle.spec.ts @@ -1,7 +1,14 @@ import {MutationObserverFactory} from '@angular/cdk/observers'; import {dispatchFakeEvent} from '@angular/cdk/testing'; import {Component} from '@angular/core'; -import {ComponentFixture, fakeAsync, flushMicrotasks, TestBed, tick} from '@angular/core/testing'; +import { + ComponentFixture, + fakeAsync, + flush, + flushMicrotasks, + TestBed, + tick, +} from '@angular/core/testing'; import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms'; import {defaultRippleAnimationConfig} from '@angular/material/core'; import {By, HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; @@ -773,7 +780,7 @@ describe('MatSlideToggle with forms', () => { expect(slideToggleElement.classList).toContain('mat-checked'); })); - it('should have the correct control state initially and after interaction', () => { + it('should have the correct control state initially and after interaction', fakeAsync(() => { // The control should start off valid, pristine, and untouched. expect(slideToggleModel.valid).toBe(true); expect(slideToggleModel.pristine).toBe(true); @@ -795,13 +802,31 @@ describe('MatSlideToggle with forms', () => { // also turn touched. dispatchFakeEvent(inputElement, 'blur'); fixture.detectChanges(); + flushMicrotasks(); expect(slideToggleModel.valid).toBe(true); expect(slideToggleModel.pristine).toBe(false); expect(slideToggleModel.touched).toBe(true); - }); + })); + + it('should not throw an error when disabling while focused', fakeAsync(() => { + expect(() => { + // Focus the input element because after disabling, the `blur` event should automatically + // fire and not result in a changed after checked exception. Related: #12323 + inputElement.focus(); + + // Flush the two nested timeouts from the FocusMonitor that are being created on `focus`. + flush(); + + slideToggle.disabled = true; + fixture.detectChanges(); + flushMicrotasks(); + }).not.toThrow(); + })); + + it('should not set the control to touched when changing the state programmatically', + fakeAsync(() => { - it('should not set the control to touched when changing the state programmatically', () => { // The control should start off with being untouched. expect(slideToggleModel.touched).toBe(false); @@ -815,10 +840,11 @@ describe('MatSlideToggle with forms', () => { // also turn touched. dispatchFakeEvent(inputElement, 'blur'); fixture.detectChanges(); + flushMicrotasks(); expect(slideToggleModel.touched).toBe(true); expect(slideToggleElement.classList).toContain('mat-checked'); - }); + })); it('should not set the control to touched when changing the model', fakeAsync(() => { // The control should start off with being untouched. diff --git a/src/lib/slide-toggle/slide-toggle.ts b/src/lib/slide-toggle/slide-toggle.ts index bb4e02cded50..b6cee9826866 100644 --- a/src/lib/slide-toggle/slide-toggle.ts +++ b/src/lib/slide-toggle/slide-toggle.ts @@ -289,7 +289,12 @@ export class MatSlideToggle extends _MatSlideToggleMixinBase implements OnDestro // For keyboard focus show a persistent ripple as focus indicator. this._focusRipple = this._ripple.launch(0, 0, {persistent: true}); } else if (!focusOrigin) { - this.onTouched(); + // When a focused element becomes disabled, the browser *immediately* fires a blur event. + // Angular does not expect events to be raised during change detection, so any state change + // (such as a form control's 'ng-touched') will cause a changed-after-checked error. + // See https://github.com/angular/angular/issues/17793. To work around this, we defer telling + // the form control it has been touched until the next tick. + Promise.resolve().then(() => this.onTouched()); // Fade out and clear the focus ripple if one is currently present. if (this._focusRipple) {