From 14fe8b09eb993af54982910c75e1b7e057eafe4e Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 11 Jul 2017 21:31:07 +0200 Subject: [PATCH] chore(input): switch to OnPush change detection Switches the `MdInputContainer` to `OnPush` change detection and sorts out the various change detection-related issues. Relates to #5035. --- src/lib/input/input-container.ts | 160 +++++++++++++++++++++---------- 1 file changed, 107 insertions(+), 53 deletions(-) diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index 07fea22ced03..85d49bcb0536 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -25,7 +25,11 @@ import { Self, ViewChild, ViewEncapsulation, - Inject + Inject, + ChangeDetectionStrategy, + OnChanges, + OnDestroy, + DoCheck, } from '@angular/core'; import {animate, state, style, transition, trigger} from '@angular/animations'; import {coerceBooleanProperty, Platform} from '../core'; @@ -48,6 +52,8 @@ import { ErrorOptions, MD_ERROR_GLOBAL_OPTIONS } from '../core/error/error-options'; +import {Subject} from 'rxjs/Subject'; +import {filter} from '../core/rxjs/index'; // Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError. const MD_INPUT_INVALID_TYPES = [ @@ -128,13 +134,13 @@ export class MdSuffix {} '[disabled]': 'disabled', '[required]': 'required', '[attr.aria-describedby]': 'ariaDescribedby || null', - '[attr.aria-invalid]': '_isErrorState()', - '(blur)': '_onBlur()', - '(focus)': '_onFocus()', + '[attr.aria-invalid]': '_isErrorState', + '(blur)': '_focusChanged(false)', + '(focus)': '_focusChanged(true)', '(input)': '_onInput()', } }) -export class MdInputDirective { +export class MdInputDirective implements OnChanges, OnDestroy, DoCheck { /** Variables used as cache for getters and setters. */ private _type = 'text'; @@ -142,8 +148,12 @@ export class MdInputDirective { private _disabled = false; private _required = false; private _id: string; - private _cachedUid: string; + private _uid = `md-input-${nextUniqueId++}`; private _errorOptions: ErrorOptions; + private _previousNativeValue = this.value; + + /** Whether the input is in an error state. */ + _isErrorState = false; /** Whether the element is focused or not. */ focused = false; @@ -151,30 +161,24 @@ export class MdInputDirective { /** Sets the aria-describedby attribute on the input for improved a11y. */ ariaDescribedby: string; + /** + * Stream that emits whenever the state of the input changes. This allows for other components + * (mostly `md-input-container`) that depend on the properties of `mdInput` to update their view. + */ + _stateChanges = new Subject(); + /** Whether the element is disabled. */ @Input() - get disabled() { - return this._ngControl ? this._ngControl.disabled : this._disabled; - } - - set disabled(value: any) { - this._disabled = coerceBooleanProperty(value); - } + get disabled() { return this._ngControl ? this._ngControl.disabled : this._disabled; } + set disabled(value: any) { this._disabled = coerceBooleanProperty(value); } /** Unique id of the element. */ @Input() get id() { return this._id; } - set id(value: string) {this._id = value || this._uid; } + set id(value: string) { this._id = value || this._uid; } /** Placeholder attribute of the element. */ - @Input() - get placeholder() { return this._placeholder; } - set placeholder(value: string) { - if (this._placeholder !== value) { - this._placeholder = value; - this._placeholderChange.emit(this._placeholder); - } - } + @Input() placeholder: string = ''; /** Whether the element is required. */ @Input() @@ -203,11 +207,6 @@ export class MdInputDirective { get value() { return this._elementRef.nativeElement.value; } set value(value: string) { this._elementRef.nativeElement.value = value; } - /** - * Emits an event when the placeholder changes so that the `md-input-container` can re-validate. - */ - @Output() _placeholderChange = new EventEmitter(); - /** Whether the input is empty. */ get empty() { return !this._isNeverEmpty() && @@ -218,8 +217,6 @@ export class MdInputDirective { !this._isBadInput(); } - private get _uid() { return this._cachedUid = this._cachedUid || `md-input-${nextUniqueId++}`; } - private _neverEmptyInputTypes = [ 'date', 'datetime', @@ -232,6 +229,7 @@ export class MdInputDirective { constructor(private _elementRef: ElementRef, private _renderer: Renderer2, private _platform: Platform, + private _changeDetectorRef: ChangeDetectorRef, @Optional() @Self() public _ngControl: NgControl, @Optional() private _parentForm: NgForm, @Optional() private _parentFormGroup: FormGroupDirective, @@ -239,17 +237,43 @@ export class MdInputDirective { // Force setter to be called in case id was not specified. this.id = this.id; - this._errorOptions = errorOptions ? errorOptions : {}; this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher; } - /** Focuses the input element. */ - focus() { this._elementRef.nativeElement.focus(); } + ngOnChanges() { + this._stateChanges.next(); + } - _onFocus() { this.focused = true; } + ngOnDestroy() { + this._stateChanges.complete(); + } + + ngDoCheck() { + if (this._ngControl) { + // We need to re-evaluate this on every change detection cycle, because there are some + // error triggers that we can't subscribe to (e.g. parent form submissions). This means + // that whatever logic is in here has to be super lean or we risk destroying the performance. + this._updateErrorState(); + } else { + // When the input isn't used together with `@angular/forms`, we need to check manually for + // changes to the native `value` property in order to update the floating label. + this._dirtyCheckNativeValue(); + } + } - _onBlur() { this.focused = false; } + /** Focuses the input element. */ + focus() { + this._elementRef.nativeElement.focus(); + } + + /** Callback for the cases where the focused state of the input changes. */ + _focusChanged(isFocused: boolean) { + if (isFocused !== this.focused) { + this.focused = isFocused; + this._stateChanges.next(); + } + } _onInput() { // This is a noop function and is used to let Angular know whenever the value changes. @@ -261,22 +285,42 @@ export class MdInputDirective { // FormsModule or ReactiveFormsModule, because Angular forms also listens to input events. } - /** Whether the input is in an error state. */ - _isErrorState(): boolean { + /** Re-evaluates the error state. This is only relevant with @angular/forms. */ + private _updateErrorState() { + const oldState = this._isErrorState; const control = this._ngControl; - const form = this._parentFormGroup || this._parentForm; - return control && this.errorStateMatcher(control.control as FormControl, form); + const parent = this._parentFormGroup || this._parentForm; + const newState = control && this.errorStateMatcher(control.control as FormControl, parent); + + if (newState !== oldState) { + this._isErrorState = newState; + this._stateChanges.next(); + } + } + + /** Does some manual dirty checking on the native input `value` property. */ + private _dirtyCheckNativeValue() { + const newValue = this.value; + + if (this._previousNativeValue !== newValue) { + this._previousNativeValue = newValue; + this._stateChanges.next(); + } } /** Make sure the input is a supported type. */ private _validateType() { - if (MD_INPUT_INVALID_TYPES.indexOf(this._type) !== -1) { + if (MD_INPUT_INVALID_TYPES.indexOf(this._type) > -1) { throw getMdInputContainerUnsupportedTypeError(this._type); } } - private _isNeverEmpty() { return this._neverEmptyInputTypes.indexOf(this._type) !== -1; } + /** Checks whether the input type isn't one of the types that are never empty. */ + private _isNeverEmpty() { + return this._neverEmptyInputTypes.indexOf(this._type) > -1; + } + /** Checks whether the input is invalid based on the native validation. */ private _isBadInput() { // The `validity` property won't be present on platform-server. let validity = (this._elementRef.nativeElement as HTMLInputElement).validity; @@ -317,7 +361,7 @@ export class MdInputDirective { // Remove align attribute to prevent it from interfering with layout. '[attr.align]': 'null', 'class': 'mat-input-container', - '[class.mat-input-invalid]': '_mdInputChild._isErrorState()', + '[class.mat-input-invalid]': '_mdInputChild._isErrorState', '[class.mat-focused]': '_mdInputChild.focused', '[class.ng-untouched]': '_shouldForward("untouched")', '[class.ng-touched]': '_shouldForward("touched")', @@ -329,6 +373,7 @@ export class MdInputDirective { '(click)': '_focusInput()', }, encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterContentChecked { @@ -337,7 +382,7 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC /** Color of the input divider, based on the theme. */ @Input() color: 'primary' | 'accent' | 'warn' = 'primary'; - /** @deprecated Use color instead. */ + /** @deprecated Use `color` instead. */ @Input() get dividerColor() { return this.color; } set dividerColor(value) { this.color = value; } @@ -381,17 +426,11 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC /** Reference to the input's underline element. */ @ViewChild('underline') underlineRef: ElementRef; - @ContentChild(MdInputDirective) _mdInputChild: MdInputDirective; - @ContentChild(MdPlaceholder) _placeholderChild: MdPlaceholder; - @ContentChildren(MdErrorDirective) _errorChildren: QueryList; - @ContentChildren(MdHint) _hintChildren: QueryList; - @ContentChildren(MdPrefix) _prefixChildren: QueryList; - @ContentChildren(MdSuffix) _suffixChildren: QueryList; constructor( @@ -407,9 +446,20 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC this._processHints(); this._validatePlaceholders(); - // Re-validate when things change. + // Subscribe to changes in the child input state in order to update the container UI. + this._mdInputChild._stateChanges.subscribe(() => { + this._validatePlaceholders(); + this._changeDetectorRef.markForCheck(); + }); + + if (this._mdInputChild._ngControl && this._mdInputChild._ngControl.valueChanges) { + this._mdInputChild._ngControl.valueChanges.subscribe(() => { + this._changeDetectorRef.markForCheck(); + }); + } + + // Re-validate when the amount of hints changes. this._hintChildren.changes.subscribe(() => this._processHints()); - this._mdInputChild._placeholderChange.subscribe(() => this._validatePlaceholders()); } ngAfterContentChecked() { @@ -429,15 +479,19 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC } /** Whether the input has a placeholder. */ - _hasPlaceholder() { return !!(this._mdInputChild.placeholder || this._placeholderChild); } + _hasPlaceholder() { + return !!(this._mdInputChild.placeholder || this._placeholderChild); + } /** Focuses the underlying input. */ - _focusInput() { this._mdInputChild.focus(); } + _focusInput() { + this._mdInputChild.focus(); + } /** Determines whether to display hints or errors. */ _getDisplayedMessages(): 'error' | 'hint' { let input = this._mdInputChild; - return (this._errorChildren.length > 0 && input._isErrorState()) ? 'error' : 'hint'; + return (this._errorChildren.length > 0 && input._isErrorState) ? 'error' : 'hint'; } /**