Skip to content

Commit 14fe8b0

Browse files
crisbetojelbourn
authored andcommitted
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.
1 parent a34787d commit 14fe8b0

File tree

1 file changed

+107
-53
lines changed

1 file changed

+107
-53
lines changed

src/lib/input/input-container.ts

Lines changed: 107 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ import {
2525
Self,
2626
ViewChild,
2727
ViewEncapsulation,
28-
Inject
28+
Inject,
29+
ChangeDetectionStrategy,
30+
OnChanges,
31+
OnDestroy,
32+
DoCheck,
2933
} from '@angular/core';
3034
import {animate, state, style, transition, trigger} from '@angular/animations';
3135
import {coerceBooleanProperty, Platform} from '../core';
@@ -48,6 +52,8 @@ import {
4852
ErrorOptions,
4953
MD_ERROR_GLOBAL_OPTIONS
5054
} from '../core/error/error-options';
55+
import {Subject} from 'rxjs/Subject';
56+
import {filter} from '../core/rxjs/index';
5157

5258
// Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError.
5359
const MD_INPUT_INVALID_TYPES = [
@@ -128,53 +134,51 @@ export class MdSuffix {}
128134
'[disabled]': 'disabled',
129135
'[required]': 'required',
130136
'[attr.aria-describedby]': 'ariaDescribedby || null',
131-
'[attr.aria-invalid]': '_isErrorState()',
132-
'(blur)': '_onBlur()',
133-
'(focus)': '_onFocus()',
137+
'[attr.aria-invalid]': '_isErrorState',
138+
'(blur)': '_focusChanged(false)',
139+
'(focus)': '_focusChanged(true)',
134140
'(input)': '_onInput()',
135141
}
136142
})
137-
export class MdInputDirective {
143+
export class MdInputDirective implements OnChanges, OnDestroy, DoCheck {
138144

139145
/** Variables used as cache for getters and setters. */
140146
private _type = 'text';
141147
private _placeholder: string = '';
142148
private _disabled = false;
143149
private _required = false;
144150
private _id: string;
145-
private _cachedUid: string;
151+
private _uid = `md-input-${nextUniqueId++}`;
146152
private _errorOptions: ErrorOptions;
153+
private _previousNativeValue = this.value;
154+
155+
/** Whether the input is in an error state. */
156+
_isErrorState = false;
147157

148158
/** Whether the element is focused or not. */
149159
focused = false;
150160

151161
/** Sets the aria-describedby attribute on the input for improved a11y. */
152162
ariaDescribedby: string;
153163

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

164175
/** Unique id of the element. */
165176
@Input()
166177
get id() { return this._id; }
167-
set id(value: string) {this._id = value || this._uid; }
178+
set id(value: string) { this._id = value || this._uid; }
168179

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

179183
/** Whether the element is required. */
180184
@Input()
@@ -203,11 +207,6 @@ export class MdInputDirective {
203207
get value() { return this._elementRef.nativeElement.value; }
204208
set value(value: string) { this._elementRef.nativeElement.value = value; }
205209

206-
/**
207-
* Emits an event when the placeholder changes so that the `md-input-container` can re-validate.
208-
*/
209-
@Output() _placeholderChange = new EventEmitter<string>();
210-
211210
/** Whether the input is empty. */
212211
get empty() {
213212
return !this._isNeverEmpty() &&
@@ -218,8 +217,6 @@ export class MdInputDirective {
218217
!this._isBadInput();
219218
}
220219

221-
private get _uid() { return this._cachedUid = this._cachedUid || `md-input-${nextUniqueId++}`; }
222-
223220
private _neverEmptyInputTypes = [
224221
'date',
225222
'datetime',
@@ -232,24 +229,51 @@ export class MdInputDirective {
232229
constructor(private _elementRef: ElementRef,
233230
private _renderer: Renderer2,
234231
private _platform: Platform,
232+
private _changeDetectorRef: ChangeDetectorRef,
235233
@Optional() @Self() public _ngControl: NgControl,
236234
@Optional() private _parentForm: NgForm,
237235
@Optional() private _parentFormGroup: FormGroupDirective,
238236
@Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {
239237

240238
// Force setter to be called in case id was not specified.
241239
this.id = this.id;
242-
243240
this._errorOptions = errorOptions ? errorOptions : {};
244241
this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher;
245242
}
246243

247-
/** Focuses the input element. */
248-
focus() { this._elementRef.nativeElement.focus(); }
244+
ngOnChanges() {
245+
this._stateChanges.next();
246+
}
249247

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

252-
_onBlur() { this.focused = false; }
265+
/** Focuses the input element. */
266+
focus() {
267+
this._elementRef.nativeElement.focus();
268+
}
269+
270+
/** Callback for the cases where the focused state of the input changes. */
271+
_focusChanged(isFocused: boolean) {
272+
if (isFocused !== this.focused) {
273+
this.focused = isFocused;
274+
this._stateChanges.next();
275+
}
276+
}
253277

254278
_onInput() {
255279
// This is a noop function and is used to let Angular know whenever the value changes.
@@ -261,22 +285,42 @@ export class MdInputDirective {
261285
// FormsModule or ReactiveFormsModule, because Angular forms also listens to input events.
262286
}
263287

264-
/** Whether the input is in an error state. */
265-
_isErrorState(): boolean {
288+
/** Re-evaluates the error state. This is only relevant with @angular/forms. */
289+
private _updateErrorState() {
290+
const oldState = this._isErrorState;
266291
const control = this._ngControl;
267-
const form = this._parentFormGroup || this._parentForm;
268-
return control && this.errorStateMatcher(control.control as FormControl, form);
292+
const parent = this._parentFormGroup || this._parentForm;
293+
const newState = control && this.errorStateMatcher(control.control as FormControl, parent);
294+
295+
if (newState !== oldState) {
296+
this._isErrorState = newState;
297+
this._stateChanges.next();
298+
}
299+
}
300+
301+
/** Does some manual dirty checking on the native input `value` property. */
302+
private _dirtyCheckNativeValue() {
303+
const newValue = this.value;
304+
305+
if (this._previousNativeValue !== newValue) {
306+
this._previousNativeValue = newValue;
307+
this._stateChanges.next();
308+
}
269309
}
270310

271311
/** Make sure the input is a supported type. */
272312
private _validateType() {
273-
if (MD_INPUT_INVALID_TYPES.indexOf(this._type) !== -1) {
313+
if (MD_INPUT_INVALID_TYPES.indexOf(this._type) > -1) {
274314
throw getMdInputContainerUnsupportedTypeError(this._type);
275315
}
276316
}
277317

278-
private _isNeverEmpty() { return this._neverEmptyInputTypes.indexOf(this._type) !== -1; }
318+
/** Checks whether the input type isn't one of the types that are never empty. */
319+
private _isNeverEmpty() {
320+
return this._neverEmptyInputTypes.indexOf(this._type) > -1;
321+
}
279322

323+
/** Checks whether the input is invalid based on the native validation. */
280324
private _isBadInput() {
281325
// The `validity` property won't be present on platform-server.
282326
let validity = (this._elementRef.nativeElement as HTMLInputElement).validity;
@@ -317,7 +361,7 @@ export class MdInputDirective {
317361
// Remove align attribute to prevent it from interfering with layout.
318362
'[attr.align]': 'null',
319363
'class': 'mat-input-container',
320-
'[class.mat-input-invalid]': '_mdInputChild._isErrorState()',
364+
'[class.mat-input-invalid]': '_mdInputChild._isErrorState',
321365
'[class.mat-focused]': '_mdInputChild.focused',
322366
'[class.ng-untouched]': '_shouldForward("untouched")',
323367
'[class.ng-touched]': '_shouldForward("touched")',
@@ -329,6 +373,7 @@ export class MdInputDirective {
329373
'(click)': '_focusInput()',
330374
},
331375
encapsulation: ViewEncapsulation.None,
376+
changeDetection: ChangeDetectionStrategy.OnPush,
332377
})
333378

334379
export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterContentChecked {
@@ -337,7 +382,7 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
337382
/** Color of the input divider, based on the theme. */
338383
@Input() color: 'primary' | 'accent' | 'warn' = 'primary';
339384

340-
/** @deprecated Use color instead. */
385+
/** @deprecated Use `color` instead. */
341386
@Input()
342387
get dividerColor() { return this.color; }
343388
set dividerColor(value) { this.color = value; }
@@ -381,17 +426,11 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
381426

382427
/** Reference to the input's underline element. */
383428
@ViewChild('underline') underlineRef: ElementRef;
384-
385429
@ContentChild(MdInputDirective) _mdInputChild: MdInputDirective;
386-
387430
@ContentChild(MdPlaceholder) _placeholderChild: MdPlaceholder;
388-
389431
@ContentChildren(MdErrorDirective) _errorChildren: QueryList<MdErrorDirective>;
390-
391432
@ContentChildren(MdHint) _hintChildren: QueryList<MdHint>;
392-
393433
@ContentChildren(MdPrefix) _prefixChildren: QueryList<MdPrefix>;
394-
395434
@ContentChildren(MdSuffix) _suffixChildren: QueryList<MdSuffix>;
396435

397436
constructor(
@@ -407,9 +446,20 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
407446
this._processHints();
408447
this._validatePlaceholders();
409448

410-
// Re-validate when things change.
449+
// Subscribe to changes in the child input state in order to update the container UI.
450+
this._mdInputChild._stateChanges.subscribe(() => {
451+
this._validatePlaceholders();
452+
this._changeDetectorRef.markForCheck();
453+
});
454+
455+
if (this._mdInputChild._ngControl && this._mdInputChild._ngControl.valueChanges) {
456+
this._mdInputChild._ngControl.valueChanges.subscribe(() => {
457+
this._changeDetectorRef.markForCheck();
458+
});
459+
}
460+
461+
// Re-validate when the amount of hints changes.
411462
this._hintChildren.changes.subscribe(() => this._processHints());
412-
this._mdInputChild._placeholderChange.subscribe(() => this._validatePlaceholders());
413463
}
414464

415465
ngAfterContentChecked() {
@@ -429,15 +479,19 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
429479
}
430480

431481
/** Whether the input has a placeholder. */
432-
_hasPlaceholder() { return !!(this._mdInputChild.placeholder || this._placeholderChild); }
482+
_hasPlaceholder() {
483+
return !!(this._mdInputChild.placeholder || this._placeholderChild);
484+
}
433485

434486
/** Focuses the underlying input. */
435-
_focusInput() { this._mdInputChild.focus(); }
487+
_focusInput() {
488+
this._mdInputChild.focus();
489+
}
436490

437491
/** Determines whether to display hints or errors. */
438492
_getDisplayedMessages(): 'error' | 'hint' {
439493
let input = this._mdInputChild;
440-
return (this._errorChildren.length > 0 && input._isErrorState()) ? 'error' : 'hint';
494+
return (this._errorChildren.length > 0 && input._isErrorState) ? 'error' : 'hint';
441495
}
442496

443497
/**

0 commit comments

Comments
 (0)