Skip to content

Commit 940f1a2

Browse files
crisbetoandrewseguin
authored andcommitted
chore(input): switch to OnPush change detection (#5692)
* 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
1 parent 89e9621 commit 940f1a2

File tree

1 file changed

+106
-59
lines changed

1 file changed

+106
-59
lines changed

src/lib/input/input-container.ts

Lines changed: 106 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,18 @@ import {
1616
ContentChildren,
1717
Directive,
1818
ElementRef,
19-
EventEmitter,
2019
Input,
2120
Optional,
22-
Output,
2321
QueryList,
2422
Renderer2,
2523
Self,
2624
ViewChild,
2725
ViewEncapsulation,
28-
Inject
26+
Inject,
27+
ChangeDetectionStrategy,
28+
OnChanges,
29+
OnDestroy,
30+
DoCheck,
2931
} from '@angular/core';
3032
import {animate, state, style, transition, trigger} from '@angular/animations';
3133
import {coerceBooleanProperty, Platform} from '../core';
@@ -48,6 +50,7 @@ import {
4850
ErrorOptions,
4951
MD_ERROR_GLOBAL_OPTIONS
5052
} from '../core/error/error-options';
53+
import {Subject} from 'rxjs/Subject';
5154

5255
// Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError.
5356
const MD_INPUT_INVALID_TYPES = [
@@ -128,13 +131,13 @@ export class MdSuffix {}
128131
'[disabled]': 'disabled',
129132
'[required]': 'required',
130133
'[attr.aria-describedby]': 'ariaDescribedby || null',
131-
'[attr.aria-invalid]': '_isErrorState()',
132-
'(blur)': '_onBlur()',
133-
'(focus)': '_onFocus()',
134+
'[attr.aria-invalid]': '_isErrorState',
135+
'(blur)': '_focusChanged(false)',
136+
'(focus)': '_focusChanged(true)',
134137
'(input)': '_onInput()',
135138
}
136139
})
137-
export class MdInputDirective {
140+
export class MdInputDirective implements OnChanges, OnDestroy, DoCheck {
138141

139142
/** Variables used as cache for getters and setters. */
140143
private _type = 'text';
@@ -143,39 +146,37 @@ export class MdInputDirective {
143146
private _required = false;
144147
private _readonly = false;
145148
private _id: string;
146-
private _cachedUid: string;
149+
private _uid = `md-input-${nextUniqueId++}`;
147150
private _errorOptions: ErrorOptions;
151+
private _previousNativeValue = this.value;
152+
153+
/** Whether the input is in an error state. */
154+
_isErrorState = false;
148155

149156
/** Whether the element is focused or not. */
150157
focused = false;
151158

152159
/** Sets the aria-describedby attribute on the input for improved a11y. */
153160
ariaDescribedby: string;
154161

162+
/**
163+
* Stream that emits whenever the state of the input changes. This allows for other components
164+
* (mostly `md-input-container`) that depend on the properties of `mdInput` to update their view.
165+
*/
166+
_stateChanges = new Subject<void>();
167+
155168
/** Whether the element is disabled. */
156169
@Input()
157-
get disabled() {
158-
return this._ngControl ? this._ngControl.disabled : this._disabled;
159-
}
160-
161-
set disabled(value: any) {
162-
this._disabled = coerceBooleanProperty(value);
163-
}
170+
get disabled() { return this._ngControl ? this._ngControl.disabled : this._disabled; }
171+
set disabled(value: any) { this._disabled = coerceBooleanProperty(value); }
164172

165173
/** Unique id of the element. */
166174
@Input()
167175
get id() { return this._id; }
168-
set id(value: string) {this._id = value || this._uid; }
176+
set id(value: string) { this._id = value || this._uid; }
169177

170178
/** Placeholder attribute of the element. */
171-
@Input()
172-
get placeholder() { return this._placeholder; }
173-
set placeholder(value: string) {
174-
if (this._placeholder !== value) {
175-
this._placeholder = value;
176-
this._placeholderChange.emit(this._placeholder);
177-
}
178-
}
179+
@Input() placeholder: string = '';
179180

180181
/** Whether the element is required. */
181182
@Input()
@@ -209,11 +210,6 @@ export class MdInputDirective {
209210
get value() { return this._elementRef.nativeElement.value; }
210211
set value(value: string) { this._elementRef.nativeElement.value = value; }
211212

212-
/**
213-
* Emits an event when the placeholder changes so that the `md-input-container` can re-validate.
214-
*/
215-
@Output() _placeholderChange = new EventEmitter<string>();
216-
217213
/** Whether the input is empty. */
218214
get empty() {
219215
return !this._isNeverEmpty() &&
@@ -224,8 +220,6 @@ export class MdInputDirective {
224220
!this._isBadInput();
225221
}
226222

227-
private get _uid() { return this._cachedUid = this._cachedUid || `md-input-${nextUniqueId++}`; }
228-
229223
private _neverEmptyInputTypes = [
230224
'date',
231225
'datetime',
@@ -238,28 +232,57 @@ export class MdInputDirective {
238232
constructor(private _elementRef: ElementRef,
239233
private _renderer: Renderer2,
240234
private _platform: Platform,
235+
private _changeDetectorRef: ChangeDetectorRef,
241236
@Optional() @Self() public _ngControl: NgControl,
242237
@Optional() private _parentForm: NgForm,
243238
@Optional() private _parentFormGroup: FormGroupDirective,
244239
@Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {
245240

246241
// Force setter to be called in case id was not specified.
247242
this.id = this.id;
248-
249243
this._errorOptions = errorOptions ? errorOptions : {};
250244
this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher;
251245
}
252246

253-
/** Focuses the input element. */
254-
focus() { this._elementRef.nativeElement.focus(); }
247+
ngOnChanges() {
248+
this._stateChanges.next();
249+
}
250+
251+
ngOnDestroy() {
252+
this._stateChanges.complete();
253+
}
254+
255+
ngDoCheck() {
256+
if (this._ngControl) {
257+
// We need to re-evaluate this on every change detection cycle, because there are some
258+
// error triggers that we can't subscribe to (e.g. parent form submissions). This means
259+
// that whatever logic is in here has to be super lean or we risk destroying the performance.
260+
this._updateErrorState();
261+
} else {
262+
// When the input isn't used together with `@angular/forms`, we need to check manually for
263+
// changes to the native `value` property in order to update the floating label.
264+
this._dirtyCheckNativeValue();
265+
}
266+
}
255267

256268
_onFocus() {
257269
if (!this._readonly) {
258270
this.focused = true;
259271
}
260272
}
261273

262-
_onBlur() { this.focused = false; }
274+
/** Focuses the input element. */
275+
focus() {
276+
this._elementRef.nativeElement.focus();
277+
}
278+
279+
/** Callback for the cases where the focused state of the input changes. */
280+
_focusChanged(isFocused: boolean) {
281+
if (isFocused !== this.focused) {
282+
this.focused = isFocused;
283+
this._stateChanges.next();
284+
}
285+
}
263286

264287
_onInput() {
265288
// This is a noop function and is used to let Angular know whenever the value changes.
@@ -271,22 +294,42 @@ export class MdInputDirective {
271294
// FormsModule or ReactiveFormsModule, because Angular forms also listens to input events.
272295
}
273296

274-
/** Whether the input is in an error state. */
275-
_isErrorState(): boolean {
297+
/** Re-evaluates the error state. This is only relevant with @angular/forms. */
298+
private _updateErrorState() {
299+
const oldState = this._isErrorState;
276300
const control = this._ngControl;
277-
const form = this._parentFormGroup || this._parentForm;
278-
return control && this.errorStateMatcher(control.control as FormControl, form);
301+
const parent = this._parentFormGroup || this._parentForm;
302+
const newState = control && this.errorStateMatcher(control.control as FormControl, parent);
303+
304+
if (newState !== oldState) {
305+
this._isErrorState = newState;
306+
this._stateChanges.next();
307+
}
308+
}
309+
310+
/** Does some manual dirty checking on the native input `value` property. */
311+
private _dirtyCheckNativeValue() {
312+
const newValue = this.value;
313+
314+
if (this._previousNativeValue !== newValue) {
315+
this._previousNativeValue = newValue;
316+
this._stateChanges.next();
317+
}
279318
}
280319

281320
/** Make sure the input is a supported type. */
282321
private _validateType() {
283-
if (MD_INPUT_INVALID_TYPES.indexOf(this._type) !== -1) {
322+
if (MD_INPUT_INVALID_TYPES.indexOf(this._type) > -1) {
284323
throw getMdInputContainerUnsupportedTypeError(this._type);
285324
}
286325
}
287326

288-
private _isNeverEmpty() { return this._neverEmptyInputTypes.indexOf(this._type) !== -1; }
327+
/** Checks whether the input type isn't one of the types that are never empty. */
328+
private _isNeverEmpty() {
329+
return this._neverEmptyInputTypes.indexOf(this._type) > -1;
330+
}
289331

332+
/** Checks whether the input is invalid based on the native validation. */
290333
private _isBadInput() {
291334
// The `validity` property won't be present on platform-server.
292335
let validity = (this._elementRef.nativeElement as HTMLInputElement).validity;
@@ -327,7 +370,7 @@ export class MdInputDirective {
327370
// Remove align attribute to prevent it from interfering with layout.
328371
'[attr.align]': 'null',
329372
'class': 'mat-input-container',
330-
'[class.mat-input-invalid]': '_mdInputChild._isErrorState()',
373+
'[class.mat-input-invalid]': '_mdInputChild._isErrorState',
331374
'[class.mat-focused]': '_mdInputChild.focused',
332375
'[class.ng-untouched]': '_shouldForward("untouched")',
333376
'[class.ng-touched]': '_shouldForward("touched")',
@@ -339,6 +382,7 @@ export class MdInputDirective {
339382
'(click)': '_focusInput()',
340383
},
341384
encapsulation: ViewEncapsulation.None,
385+
changeDetection: ChangeDetectionStrategy.OnPush,
342386
})
343387

344388
export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterContentChecked {
@@ -347,7 +391,7 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
347391
/** Color of the input divider, based on the theme. */
348392
@Input() color: 'primary' | 'accent' | 'warn' = 'primary';
349393

350-
/** @deprecated Use color instead. */
394+
/** @deprecated Use `color` instead. */
351395
@Input()
352396
get dividerColor() { return this.color; }
353397
set dividerColor(value) { this.color = value; }
@@ -391,17 +435,11 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
391435

392436
/** Reference to the input's underline element. */
393437
@ViewChild('underline') underlineRef: ElementRef;
394-
395438
@ContentChild(MdInputDirective) _mdInputChild: MdInputDirective;
396-
397439
@ContentChild(MdPlaceholder) _placeholderChild: MdPlaceholder;
398-
399440
@ContentChildren(MdErrorDirective) _errorChildren: QueryList<MdErrorDirective>;
400-
401441
@ContentChildren(MdHint) _hintChildren: QueryList<MdHint>;
402-
403442
@ContentChildren(MdPrefix) _prefixChildren: QueryList<MdPrefix>;
404-
405443
@ContentChildren(MdSuffix) _suffixChildren: QueryList<MdSuffix>;
406444

407445
constructor(
@@ -417,15 +455,20 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
417455
this._processHints();
418456
this._validatePlaceholders();
419457

420-
// Re-validate when things change.
421-
this._hintChildren.changes.subscribe(() => this._processHints());
422-
this._mdInputChild._placeholderChange.subscribe(() => this._validatePlaceholders());
458+
// Subscribe to changes in the child input state in order to update the container UI.
459+
this._mdInputChild._stateChanges.subscribe(() => {
460+
this._validatePlaceholders();
461+
this._changeDetectorRef.markForCheck();
462+
});
423463

424-
// Mark for check when the input's value changes to recalculate whether input is empty
425-
const control = this._mdInputChild._ngControl;
426-
if (control && control.valueChanges) {
427-
control.valueChanges.subscribe(() => this._changeDetectorRef.markForCheck());
464+
if (this._mdInputChild._ngControl && this._mdInputChild._ngControl.valueChanges) {
465+
this._mdInputChild._ngControl.valueChanges.subscribe(() => {
466+
this._changeDetectorRef.markForCheck();
467+
});
428468
}
469+
470+
// Re-validate when the amount of hints changes.
471+
this._hintChildren.changes.subscribe(() => this._processHints());
429472
}
430473

431474
ngAfterContentChecked() {
@@ -445,15 +488,19 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
445488
}
446489

447490
/** Whether the input has a placeholder. */
448-
_hasPlaceholder() { return !!(this._mdInputChild.placeholder || this._placeholderChild); }
491+
_hasPlaceholder() {
492+
return !!(this._mdInputChild.placeholder || this._placeholderChild);
493+
}
449494

450495
/** Focuses the underlying input. */
451-
_focusInput() { this._mdInputChild.focus(); }
496+
_focusInput() {
497+
this._mdInputChild.focus();
498+
}
452499

453500
/** Determines whether to display hints or errors. */
454501
_getDisplayedMessages(): 'error' | 'hint' {
455502
let input = this._mdInputChild;
456-
return (this._errorChildren.length > 0 && input._isErrorState()) ? 'error' : 'hint';
503+
return (this._errorChildren.length > 0 && input._isErrorState) ? 'error' : 'hint';
457504
}
458505

459506
/**

0 commit comments

Comments
 (0)