fix(checkbox): prevent error when disabling while focused (#12327)

* Fixes that Angular throws an ExpressionChangedAfterItHasBeenCheckedError when disabling the checkbox while the component has been focused.
* Adds missing test for `NgModel` states after value change through view.

Related #12323
This commit is contained in:
Paul Gschwendtner
2018-08-01 16:18:52 +02:00
committed by Jeremy Elbourn
parent a10bfa4995
commit c4cacceba9
2 changed files with 58 additions and 44 deletions
+52 -43
View File
@@ -1,4 +1,11 @@
import {ComponentFixture, fakeAsync, TestBed, tick, flush} from '@angular/core/testing';
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
flush,
flushMicrotasks,
} from '@angular/core/testing';
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
import {Component, DebugElement, ViewChild, Type} from '@angular/core';
import {By} from '@angular/platform-browser';
@@ -873,29 +880,63 @@ describe('MatCheckbox', () => {
let checkboxNativeElement: HTMLElement;
let checkboxInstance: MatCheckbox;
let inputElement: HTMLInputElement;
let ngModel: NgModel;
beforeEach(() => {
fixture = createComponent(CheckboxWithFormDirectives);
fixture = createComponent(CheckboxWithNgModel);
fixture.componentInstance.isRequired = false;
fixture.detectChanges();
checkboxDebugElement = fixture.debugElement.query(By.directive(MatCheckbox));
checkboxNativeElement = checkboxDebugElement.nativeElement;
checkboxInstance = checkboxDebugElement.componentInstance;
inputElement = <HTMLInputElement>checkboxNativeElement.querySelector('input');
ngModel = checkboxDebugElement.injector.get<NgModel>(NgModel);
});
it('should be in pristine, untouched, and valid states initially', fakeAsync(() => {
flush();
let checkboxElement = fixture.debugElement.query(By.directive(MatCheckbox));
let ngModel = checkboxElement.injector.get<NgModel>(NgModel);
it('should be pristine, untouched, and valid initially', () => {
expect(ngModel.valid).toBe(true);
expect(ngModel.pristine).toBe(true);
expect(ngModel.touched).toBe(false);
});
// TODO(jelbourn): test that `touched` and `pristine` state are modified appropriately.
// This is currently blocked on issues with async() and fakeAsync().
it('should have correct control states after interaction', fakeAsync(() => {
inputElement.click();
fixture.detectChanges();
// Flush the timeout that is being created whenever a `click` event has been fired by
// the underlying input.
flush();
// After the value change through interaction, the control should be dirty, but remain
// untouched as long as the focus is still on the underlying input.
expect(ngModel.pristine).toBe(false);
expect(ngModel.touched).toBe(false);
// If the input element loses focus, the control should remain dirty but should
// also turn touched.
dispatchFakeEvent(inputElement, 'blur');
fixture.detectChanges();
flushMicrotasks();
expect(ngModel.pristine).toBe(false);
expect(ngModel.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();
checkboxInstance.disabled = true;
fixture.detectChanges();
flushMicrotasks();
}).not.toThrow();
}));
it('should toggle checked state on click', () => {
@@ -911,29 +952,9 @@ describe('MatCheckbox', () => {
expect(checkboxInstance.checked).toBe(false);
});
});
describe('with required ngModel', () => {
let checkboxInstance: MatCheckbox;
let inputElement: HTMLInputElement;
let testComponent: CheckboxWithNgModel;
beforeEach(() => {
fixture = createComponent(CheckboxWithNgModel);
fixture.detectChanges();
let checkboxDebugElement = fixture.debugElement.query(By.directive(MatCheckbox));
let checkboxNativeElement = checkboxDebugElement.nativeElement;
testComponent = fixture.debugElement.componentInstance;
checkboxInstance = checkboxDebugElement.componentInstance;
inputElement = <HTMLInputElement>checkboxNativeElement.querySelector('input');
});
it('should validate with RequiredTrue validator', () => {
let checkboxElement = fixture.debugElement.query(By.directive(MatCheckbox));
let ngModel = checkboxElement.injector.get<NgModel>(NgModel);
testComponent.isRequired = true;
fixture.componentInstance.isRequired = true;
inputElement.click();
fixture.detectChanges();
@@ -1124,18 +1145,6 @@ class SingleCheckbox {
onCheckboxChange: (event?: MatCheckboxChange) => void = () => {};
}
/** Simple component for testing an MatCheckbox with ngModel in a form. */
@Component({
template: `
<form>
<mat-checkbox name="cb" [(ngModel)]="isGood">Be good</mat-checkbox>
</form>
`,
})
class CheckboxWithFormDirectives {
isGood: boolean = false;
}
/** Simple component for testing an MatCheckbox with required ngModel. */
@Component({
template: `<mat-checkbox [required]="isRequired" [(ngModel)]="isGood">Be good</mat-checkbox>`,
+6 -1
View File
@@ -341,7 +341,12 @@ export class MatCheckbox extends _MatCheckboxMixinBase implements ControlValueAc
this._focusRipple = null;
}
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());
}
}