From 7b1be19af9ee2cec48e537b1b502494c40a133f7 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 28 Nov 2025 07:45:39 +0100 Subject: [PATCH] fix(material/timepicker): valueChanges emitting on init Fixes that the `valueChanges` stream from reactive forms was emitting on init for the timepicker input. It was because we had an `effect` that was meant to trigger `min`/`max` revalidation. I also ended up consolidating all the validation updates into a single effect so we don't have multiple sources of changes. Fixes #32423. --- src/material/timepicker/timepicker-input.ts | 78 ++++++++++++--------- src/material/timepicker/timepicker.spec.ts | 14 ++++ 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/src/material/timepicker/timepicker-input.ts b/src/material/timepicker/timepicker-input.ts index 656860228778..708990e09535 100644 --- a/src/material/timepicker/timepicker-input.ts +++ b/src/material/timepicker/timepicker-input.ts @@ -97,6 +97,8 @@ export class MatTimepickerInput private _timepickerSubscription: OutputRefSubscription | undefined; private _validator: ValidatorFn; private _lastValueValid = true; + private _minValid = true; + private _maxValid = true; private _lastValidDate: D | null = null; /** Value of the `aria-activedescendant` attribute. */ @@ -173,8 +175,7 @@ export class MatTimepickerInput const renderer = inject(Renderer2); this._validator = this._getValidator(); - this._respondToValueChanges(); - this._respondToMinMaxChanges(); + this._updateFormsState(); this._registerTimepicker(); this._localeSubscription = this._dateAdapter.localeChanges.subscribe(() => { if (!this._hasFocus()) { @@ -334,24 +335,37 @@ export class MatTimepickerInput } } - /** Sets up the code that watches for changes in the value and adjusts the input. */ - private _respondToValueChanges(): void { + /** Sets up the code that keeps the input state in sync with the forms module. */ + private _updateFormsState(): void { effect(() => { - const value = this._dateAdapter.deserialize(this.value()); - const wasValid = this._lastValueValid; - this._lastValueValid = this._isValid(value); + const { + _dateAdapter: adapter, + _lastValueValid: prevValueValid, + _minValid: prevMinValid, + _maxValid: prevMaxValid, + } = this; + const value = adapter.deserialize(this.value()); + const min = this.min(); + const max = this.max(); + const valueValid = (this._lastValueValid = this._isValid(value)); + this._minValid = !min || !value || !valueValid || adapter.compareTime(min, value) <= 0; + this._maxValid = !max || !value || !valueValid || adapter.compareTime(max, value) >= 0; + const stateChanged = + prevValueValid !== valueValid || + prevMinValid !== this._minValid || + prevMaxValid !== this._maxValid; // Reformat the value if it changes while the user isn't interacting. if (!this._hasFocus()) { this._formatValue(value); } - if (value && this._lastValueValid) { + if (value && valueValid) { this._lastValidDate = value; } // Trigger the validator if the state changed. - if (wasValid !== this._lastValueValid) { + if (stateChanged) { this._validatorOnChange?.(); } }); @@ -366,16 +380,6 @@ export class MatTimepickerInput }); } - /** Sets up the logic that adjusts the input if the min/max changes. */ - private _respondToMinMaxChanges(): void { - effect(() => { - // Read the min/max so the effect knows when to fire. - this.min(); - this.max(); - this._validatorOnChange?.(); - }); - } - /** * Assigns a value set by the user to the input's model. * @param selection Time selected by the user that should be assigned. @@ -441,24 +445,28 @@ export class MatTimepickerInput this._lastValueValid ? null : {'matTimepickerParse': {'text': this._elementRef.nativeElement.value}}, - control => { - const controlValue = this._dateAdapter.getValidDateOrNull( - this._dateAdapter.deserialize(control.value), - ); - const min = this.min(); - return !min || !controlValue || this._dateAdapter.compareTime(min, controlValue) <= 0 + control => + this._minValid ? null - : {'matTimepickerMin': {'min': min, 'actual': controlValue}}; - }, - control => { - const controlValue = this._dateAdapter.getValidDateOrNull( - this._dateAdapter.deserialize(control.value), - ); - const max = this.max(); - return !max || !controlValue || this._dateAdapter.compareTime(max, controlValue) >= 0 + : { + 'matTimepickerMin': { + 'min': this.min(), + 'actual': this._dateAdapter.getValidDateOrNull( + this._dateAdapter.deserialize(control.value), + ), + }, + }, + control => + this._maxValid ? null - : {'matTimepickerMax': {'max': max, 'actual': controlValue}}; - }, + : { + 'matTimepickerMax': { + 'max': this.max(), + 'actual': this._dateAdapter.getValidDateOrNull( + this._dateAdapter.deserialize(control.value), + ), + }, + }, ])!; } } diff --git a/src/material/timepicker/timepicker.spec.ts b/src/material/timepicker/timepicker.spec.ts index d340714006c3..22d973678b32 100644 --- a/src/material/timepicker/timepicker.spec.ts +++ b/src/material/timepicker/timepicker.spec.ts @@ -1225,6 +1225,20 @@ describe('MatTimepicker', () => { expectSameTime(eventValue, controlValue); subscription.unsubscribe(); }); + + it('should not emit toValueOnChanges on init', () => { + const fixture = TestBed.createComponent(TimepickerWithForms); + const spy = jasmine.createSpy('valueChanges'); + const subscription = fixture.componentInstance.control.valueChanges.subscribe(spy); + fixture.detectChanges(); + expect(spy).not.toHaveBeenCalled(); + + typeInElement(getInput(fixture), '1:37 PM'); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalled(); + subscription.unsubscribe(); + }); }); describe('timepicker toggle', () => {