Greg
Greg

Reputation: 175

Control with async validator changes its state to pending when calling updateValueAndValidity on another control

stackblitz example

steps to reproduce:

  1. Open console to see the logs
  2. Type something in input (name control) - when async validation is finished name control emits statusChanges event.
  3. Check the checkbox
  4. Uncheck the checkbox
  5. Name control is in pending state

Now, the question: why name control changes its status? How updateValueAndValidity() in hello.component for unrelated control affects what happens with name control status?

From the docs of updateValueAndValidity it should update only control and its ancestors, not siblings... Please, help me to understand this.

Upvotes: 5

Views: 6647

Answers (2)

pook developer
pook developer

Reputation: 346

I have this solution for wait asyncValidatos:

const asyncValidationObservables = this.formUtilsService.validateRecursiveForm(this.form);
this.formUtilsService.getObservableValidations(asyncValidationObservables).subscribe((results) => {
  if (this.form.valid) {
    this.movimientoValidado.emit(this.form.value);
  }
});


validateRecursiveForm(abstractControl: FormControl | FormGroup | FormArray | AbstractControl, emitEvent = true): any[] {
  const asyncValidations: any[] = [];
  if (abstractControl instanceof FormGroup || abstractControl instanceof FormArray) {
    for (const control in abstractControl.controls) {
      const currentControl = abstractControl.get(control);
      const validaciones = this.validateRecursiveForm(currentControl, emitEvent);
      if (validaciones.length > 0) {
        asyncValidations.concat(validaciones);
      }
      // Iteramos hacia dentro antes de llamar a las validaciones del padre
      console.log('===> asyncValidations ', asyncValidations);
      this.validateAbstractControl(currentControl, asyncValidations, emitEvent);
      console.log('===> asyncValidations ', asyncValidations);
    }
  } else if (abstractControl instanceof FormControl) {
    console.log('===> abstractControl ', abstractControl);
    this.validateAbstractControl(abstractControl, asyncValidations, emitEvent);
  }
  console.log('===> asyncValidations ', asyncValidations);
  return asyncValidations;
}

private validateAbstractControl(
  abstractControl: FormControl < any > | FormGroup < any > | FormArray < any > | AbstractControl < any > ,
  asyncValidations: any[],
  emitEvent: boolean
) {
  if (abstractControl.asyncValidator) {
    abstractControl.markAsTouched();
    asyncValidations.push(abstractControl.asyncValidator(abstractControl));
  } else if (abstractControl.validator) {
    abstractControl.markAsTouched();
    abstractControl.updateValueAndValidity({
      emitEvent: emitEvent
    });
  }
}

getObservableValidations(asyncValidationObservables) {
  return (asyncValidationObservables ? .length > 0 ? forkJoin(asyncValidationObservables) : of (null)).pipe(take(1));
}

Upvotes: 0

Luke
Luke

Reputation: 86

The cause of the behavior that you are noticing is very subtle and not documented.

Reactive Forms are built using the FormControl, FormArray, and FormGroup objects. These classes expose the ability to define and execute validators, disable controls, and change values. If you never bind any of these controls to the form using a directive ([formControl], [formControlName], [formGroup], etc.) you will see the desired/expected behavior from the validators.

However, if you have async validators and bind your controls to the form while those validators are running they will become "stuck" in PENDING. The underlying status will be updated upon completion of the async validator but the statusChanges subject will not emit an event until some other action causes it to run.

This happens in the binding of the directives used above. Like all directives in Angular, the ngOnChanges hook will be called on instantiation. This is the the implementation of ngOnChanges for form_group_directive.ts.

this._checkFormPresent();
if (changes.hasOwnProperty('form')) {
  this._updateValidators();
  this._updateDomValue();
  this._updateRegistrations();
  this._oldForm = this.form;
}

The important line above is this._updateDomValue();. The call to updateDomValue() will in turn call this.form._updateTreeValidity({emitEvent: false});. The call to _updateTreeValidity() will then call this.updateValueAndValidity({onlySelf: true, emitEvent: opts.emitEvent});. The call to updateValueAndValidity() will then do the following:

this._setInitialStatus();
this._updateValue();

if (this.enabled) {
  this._cancelExistingSubscription();
  (this as {errors: ValidationErrors | null}).errors = this._runValidator();
  (this as {status: FormControlStatus}).status = this._calculateStatus();

  if (this.status === VALID || this.status === PENDING) {
    this._runAsyncValidator(opts.emitEvent);
  }
}

if (opts.emitEvent !== false) {
  (this.valueChanges as EventEmitter<any>).emit(this.value);
  (this.statusChanges as EventEmitter<FormControlStatus>).emit(this.status);
}

if (this._parent && !opts.onlySelf) {
  this._parent.updateValueAndValidity(opts);
}

There is a lot going on here, but here are the important parts. First, the call to this._cancelExistingSubscription(). This will cancel any currently running async validators, leaving the control in PENDING status. Next, the call to this._runAsyncValidator(opts.emitEvent) will be called. This will trigger the async validator to run again with an important change. The opts.emitEvent value passed to the function will now be false which can be seen from the earlier call to _updateTreeValidity(). Since this new execution of the validators will not emit an event. The validator will complete, set status to the appropriate result, but without an event you will not receive that status change from statusChanges.

This single example is demonstrative of what causes the issue that you are seeing. If at any time updateValueAndValidity() can be called with emitEvent = true the statusChanges subject will get "stuck" in PENDING.

Now for the specifics of your issue.

why name control changes its status?

I haven't debugged completely through your example, the above example is one cause of what you are seeing. Below could be another:

  1. You call this.form.setControl(FormFields.Documents, control); in app.component.ts.
  2. setControl() calls this._onCollectionChange(). The [formGroup] directive sets this to this._updateDomValue().
  3. _updateDomValue() calls this.form._updateTreeValidity({emitEvent: false});. This is the same in my example above. Notice that emitEvent is false.
  4. _updateTreeValidity() calls _updateTreeValidity() for all its children. This includes your "name" control.

How updateValueAndValidity() in hello.component for unrelated control affects what happens with name control status?

I don't think that this affecting the "name" control status. I see console output for this.form.valueChanges, but not for this.form.get(FormFields.Name).statusChanges. The subscription to valueChanges is outputting the status for the "name" control, but that is because the form's value has changed. The validator is never running because it is using valueChanges which is why it is staying in PENDING. This may or may not be what you want. Async validator already trigger on value changes of the input. If you want to ensure that it doesn't make too many HTTP calls I might suggest using timer() from rxjs or setting updateOn to blur.

Example of timer:

nameValidatorAsync(): AsyncValidatorFn {
  return (control) =>
    timer(300).pipe(
      switchMap(() =>
        this.http.get(FAKE_URL).pipe(map(() => Math.random() > 0.5))
      ),
      map((isValid) => (isValid ? null : { invalidName: true })),
      first()
    );
}

Example of updateOn

...
[FormFields.Name]: ['', {
  validators: [],
  asyncValidators: [this.nameValidatorAsync()],
  updateOn: 'blur'
}]
...

I don't have any great answers on how to work around these items. I also don't think that they will be fixed any time soon. This ticket has some suggestions for creating an observable for status that may help you.

Upvotes: 7

Related Questions