From fb8edf16211c473c1f600be91f703facc8f36f16 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sat, 27 Jan 2018 17:01:40 +0100 Subject: [PATCH] fix(autocomplete): make writeValue method synchronous Refactors the `MatAutocompleteTrigger.writeValue` method to avoid having to use a `Promise.resolve` to defer rendering. This makes it easier to test and avoids potential race conditions. It seems like the reason it was added in the first place was to be able to handle components that have a preselected value through a `FormControl` as well as a custom `displayWith` function. Fixes #3250. --- src/lib/autocomplete/autocomplete-trigger.ts | 28 +++++++++++++-- src/lib/autocomplete/autocomplete.spec.ts | 36 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index 63402cd777f2..8d6b34905d0f 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -20,6 +20,7 @@ import {TemplatePortal} from '@angular/cdk/portal'; import {DOCUMENT} from '@angular/common'; import {filter, take, switchMap, delay, tap, map} from 'rxjs/operators'; import { + AfterContentChecked, ChangeDetectorRef, Directive, ElementRef, @@ -114,7 +115,8 @@ export function getMatAutocompleteMissingPanelError(): Error { exportAs: 'matAutocompleteTrigger', providers: [MAT_AUTOCOMPLETE_VALUE_ACCESSOR] }) -export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy { +export class MatAutocompleteTrigger implements ControlValueAccessor, AfterContentChecked, + OnDestroy { private _overlayRef: OverlayRef | null; private _portal: TemplatePortal; private _componentDestroyed = false; @@ -135,6 +137,12 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy { /** Subscription to viewport size changes. */ private _viewportSubscription = Subscription.EMPTY; + /** Whether the component has been initializied. */ + private _isInitialized: boolean; + + /** Initial value that should be shown after the component is initialized. */ + private _initialValueToSelect: any; + /** Stream of keyboard events that can close the panel. */ private readonly _closeKeyEventStream = new Subject(); @@ -174,6 +182,15 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy { // @deletion-target 7.0.0 Make `_viewportRuler` required. private _viewportRuler?: ViewportRuler) {} + ngAfterContentChecked() { + if (!this._isInitialized && typeof this._initialValueToSelect !== 'undefined') { + this._setTriggerValue(this._initialValueToSelect); + this._initialValueToSelect = undefined; + } + + this._isInitialized = true; + } + ngOnDestroy() { this._viewportSubscription.unsubscribe(); this._componentDestroyed = true; @@ -289,7 +306,14 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy { // Implemented as part of ControlValueAccessor. writeValue(value: any): void { - Promise.resolve(null).then(() => this._setTriggerValue(value)); + if (this._isInitialized) { + this._setTriggerValue(value); + } else { + // If the component isn't initialized yet, defer until the first CD pass, otherwise we'll + // miss the initial `displayWith` value. By deferring until the first `AfterContentChecked` + // we avoid making the method async while preventing "changed after checked" errors. + this._initialValueToSelect = value; + } } // Implemented as part of ControlValueAccessor. diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index e4cd94717532..5d1f1e109853 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -1995,6 +1995,17 @@ describe('MatAutocomplete', () => { expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom), 'Expected autocomplete panel to align with the bottom of the new origin.'); }); + + it('should evaluate `displayWith` before assigning the initial value', fakeAsync(() => { + const fixture = createComponent(PreselectedAutocompleteDisplayWith); + const input = fixture.nativeElement.querySelector('input'); + + fixture.detectChanges(); + flush(); + + expect(input.value).toBe('Alaska'); + })); + }); @Component({ @@ -2364,3 +2375,28 @@ class AutocompleteWithDifferentOrigin { selectedValue: string; values = ['one', 'two', 'three']; } + +@Component({ + template: ` + + + + + + + {{ state.name }} + + + ` +}) +class PreselectedAutocompleteDisplayWith { + stateCtrl = new FormControl({code: 'AK', name: 'Alaska'}); + states = [ + {code: 'AL', name: 'Alabama'}, + {code: 'AK', name: 'Alaska'} + ]; + + displayFn(value: any): string { + return value && typeof value === 'object' ? value.name : value; + } +}