Skip to content

chore(input): switch to OnPush change detection #5692

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 25, 2017
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to also add a note to the Custom Error Matcher section of input.md that mentions this?

Note: An input's error state must be calculated every change detection cycle, so it is important to keep the logic in your error state matcher very lean or you risk destroying performance.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure there is. FWIW, the code currently in master will be called even more times per change detection cycle since we have the _isErrorState in multiple places.

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