fix(forms): Allow canceled async validators to emit. (#55134)

With this change, If an async validator that should have emitted was cancelled by a non-emitting validator, the status change will be reported on the `AbstractControl.events` observable.

This issue can happen when a `FormControl` is added to a `FormGroup` and a FormGroupDirective/FormControlDirective trigger a non-emitting validation (which cancels the initial validator execution).

Note: The behavior remains the same of the existing `statusChanges` observable as the change was too breaking to land in G3.

fixes: angular#41519

PR Close #55134
This commit is contained in:
Matthieu Riegler 2024-05-04 16:08:39 +02:00 committed by Andrew Scott
parent 5775fd245e
commit 2e27ca9ddf
2 changed files with 67 additions and 13 deletions

View file

@ -479,10 +479,11 @@ export abstract class AbstractControl<TValue = any, TRawValue extends TValue = T
/**
* Indicates that a control has its own pending asynchronous validation in progress.
* It also stores if the control should emit events when the validation status changes.
*
* @internal
*/
_hasOwnPendingAsyncValidator = false;
_hasOwnPendingAsyncValidator: null | {emitEvent: boolean} = null;
/** @internal */
_pendingTouched = false;
@ -1332,12 +1333,15 @@ export abstract class AbstractControl<TValue = any, TRawValue extends TValue = T
this._updateValue();
if (this.enabled) {
this._cancelExistingSubscription();
const shouldHaveEmitted = this._cancelExistingSubscription();
(this as Writable<this>).errors = this._runValidator();
(this as Writable<this>).status = this._calculateStatus();
if (this.status === VALID || this.status === PENDING) {
this._runAsyncValidator(opts.emitEvent);
// If the canceled subscription should have emitted
// we make sure the async validator emits the status change on completion
this._runAsyncValidator(shouldHaveEmitted, opts.emitEvent);
}
}
@ -1368,26 +1372,32 @@ export abstract class AbstractControl<TValue = any, TRawValue extends TValue = T
return this.validator ? this.validator(this) : null;
}
private _runAsyncValidator(emitEvent?: boolean): void {
private _runAsyncValidator(shouldHaveEmitted: boolean, emitEvent?: boolean): void {
if (this.asyncValidator) {
(this as Writable<this>).status = PENDING;
this._hasOwnPendingAsyncValidator = true;
this._hasOwnPendingAsyncValidator = {emitEvent: emitEvent !== false};
const obs = toObservable(this.asyncValidator(this));
this._asyncValidationSubscription = obs.subscribe((errors: ValidationErrors | null) => {
this._hasOwnPendingAsyncValidator = false;
this._hasOwnPendingAsyncValidator = null;
// This will trigger the recalculation of the validation status, which depends on
// the state of the asynchronous validation (whether it is in progress or not). So, it is
// necessary that we have updated the `_hasOwnPendingAsyncValidator` boolean flag first.
this.setErrors(errors, {emitEvent});
this.setErrors(errors, {emitEvent, shouldHaveEmitted});
});
}
}
private _cancelExistingSubscription(): void {
private _cancelExistingSubscription(): boolean {
if (this._asyncValidationSubscription) {
this._asyncValidationSubscription.unsubscribe();
this._hasOwnPendingAsyncValidator = false;
// we're cancelling the validator subscribtion, we keep if it should have emitted
// because we want to emit eventually if it was required at least once.
const shouldHaveEmitted = this._hasOwnPendingAsyncValidator?.emitEvent ?? false;
this._hasOwnPendingAsyncValidator = null;
return shouldHaveEmitted;
}
return false;
}
/**
@ -1418,9 +1428,19 @@ export abstract class AbstractControl<TValue = any, TRawValue extends TValue = T
* expect(login.valid).toEqual(true);
* ```
*/
setErrors(errors: ValidationErrors | null, opts: {emitEvent?: boolean} = {}): void {
setErrors(errors: ValidationErrors | null, opts?: {emitEvent?: boolean}): void;
/** @internal */
setErrors(
errors: ValidationErrors | null,
opts?: {emitEvent?: boolean; shouldHaveEmitted?: boolean},
): void;
setErrors(
errors: ValidationErrors | null,
opts: {emitEvent?: boolean; shouldHaveEmitted?: boolean} = {},
): void {
(this as Writable<this>).errors = errors;
this._updateControlsErrors(opts.emitEvent !== false, this);
this._updateControlsErrors(opts.emitEvent !== false, this, opts.shouldHaveEmitted);
}
/**
@ -1565,16 +1585,26 @@ export abstract class AbstractControl<TValue = any, TRawValue extends TValue = T
}
/** @internal */
_updateControlsErrors(emitEvent: boolean, changedControl: AbstractControl): void {
_updateControlsErrors(
emitEvent: boolean,
changedControl: AbstractControl,
shouldHaveEmitted?: boolean,
): void {
(this as Writable<this>).status = this._calculateStatus();
if (emitEvent) {
(this.statusChanges as EventEmitter<FormControlStatus>).emit(this.status);
}
// The Events Observable expose a slight different bevahior than the statusChanges obs
// An async validator will still emit a StatusChangeEvent is a previously cancelled
// async validator has emitEvent set to true
if (emitEvent || shouldHaveEmitted) {
this._events.next(new StatusChangeEvent(this.status, changedControl));
}
if (this._parent) {
this._parent._updateControlsErrors(emitEvent, changedControl);
this._parent._updateControlsErrors(emitEvent, changedControl, shouldHaveEmitted);
}
}

View file

@ -9,11 +9,13 @@
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';
import {
AbstractControl,
ControlEvent,
FormArray,
FormControl,
FormGroup,
ValidationErrors,
Validators,
ValueChangeEvent,
} from '@angular/forms';
import {of} from 'rxjs';
@ -23,6 +25,7 @@ import {
currentStateOf,
simpleAsyncValidator,
} from './util';
import {StatusChangeEvent} from '../src/model/abstract_model';
(function () {
function simpleValidator(c: AbstractControl): ValidationErrors | null {
@ -2218,6 +2221,27 @@ import {
expect(logger).toEqual([]);
}));
it('should cancel initial run of the async validator and emit on the event Observable', fakeAsync(() => {
const c = new FormControl('', null, simpleAsyncValidator({timeout: 1, shouldFail: true}));
const events: ControlEvent[] = [];
c.events.subscribe((e) => events.push(e));
expect(events.length).toBe(0);
c.setValue('new!');
tick(1);
// validator was invoked twice (init + setValue)
// but since we cancel pending validators we only get 1 status update cycle
expect(events[0]).toBeInstanceOf(ValueChangeEvent);
expect(events[1]).toBeInstanceOf(StatusChangeEvent);
expect((events[1] as StatusChangeEvent).status).toBe('PENDING');
expect(events[2]).toBeInstanceOf(StatusChangeEvent);
expect((events[2] as StatusChangeEvent).status).toBe('INVALID');
}));
it('should run the sync validator on stand alone controls and set status to `INVALID`', fakeAsync(() => {
const logs: string[] = [];
const c = new FormControl('new!', Validators.required);