Skip to content

Commit

Permalink
chore(input): switch to OnPush change detection (#5692)
Browse files Browse the repository at this point in the history
* 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.

* chore: linter errors
  • Loading branch information
crisbeto authored and andrewseguin committed Jul 25, 2017
1 parent 89e9621 commit 940f1a2
Showing 1 changed file with 106 additions and 59 deletions.
165 changes: 106 additions & 59 deletions src/lib/input/input-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@ import {
ContentChildren,
Directive,
ElementRef,
EventEmitter,
Input,
Optional,
Output,
QueryList,
Renderer2,
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';
Expand All @@ -48,6 +50,7 @@ import {
ErrorOptions,
MD_ERROR_GLOBAL_OPTIONS
} from '../core/error/error-options';
import {Subject} from 'rxjs/Subject';

// Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError.
const MD_INPUT_INVALID_TYPES = [
Expand Down Expand Up @@ -128,13 +131,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';
Expand All @@ -143,39 +146,37 @@ export class MdInputDirective {
private _required = false;
private _readonly = 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;

/** 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<void>();

/** 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()
Expand Down Expand Up @@ -209,11 +210,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<string>();

/** Whether the input is empty. */
get empty() {
return !this._isNeverEmpty() &&
Expand All @@ -224,8 +220,6 @@ export class MdInputDirective {
!this._isBadInput();
}

private get _uid() { return this._cachedUid = this._cachedUid || `md-input-${nextUniqueId++}`; }

private _neverEmptyInputTypes = [
'date',
'datetime',
Expand All @@ -238,28 +232,57 @@ 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,
@Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {

// 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();
}

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();
}
}

_onFocus() {
if (!this._readonly) {
this.focused = true;
}
}

_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.
Expand All @@ -271,22 +294,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;
Expand Down Expand Up @@ -327,7 +370,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")',
Expand All @@ -339,6 +382,7 @@ export class MdInputDirective {
'(click)': '_focusInput()',
},
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})

export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterContentChecked {
Expand All @@ -347,7 +391,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; }
Expand Down Expand Up @@ -391,17 +435,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<MdErrorDirective>;

@ContentChildren(MdHint) _hintChildren: QueryList<MdHint>;

@ContentChildren(MdPrefix) _prefixChildren: QueryList<MdPrefix>;

@ContentChildren(MdSuffix) _suffixChildren: QueryList<MdSuffix>;

constructor(
Expand All @@ -417,15 +455,20 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
this._processHints();
this._validatePlaceholders();

// Re-validate when things change.
this._hintChildren.changes.subscribe(() => this._processHints());
this._mdInputChild._placeholderChange.subscribe(() => this._validatePlaceholders());
// Subscribe to changes in the child input state in order to update the container UI.
this._mdInputChild._stateChanges.subscribe(() => {
this._validatePlaceholders();
this._changeDetectorRef.markForCheck();
});

// Mark for check when the input's value changes to recalculate whether input is empty
const control = this._mdInputChild._ngControl;
if (control && control.valueChanges) {
control.valueChanges.subscribe(() => 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());
}

ngAfterContentChecked() {
Expand All @@ -445,15 +488,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';
}

/**
Expand Down

0 comments on commit 940f1a2

Please sign in to comment.