diff --git a/packages/forms/src/directives/ng_form.ts b/packages/forms/src/directives/ng_form.ts index a0bdeb12997e..fc031ad01ba4 100644 --- a/packages/forms/src/directives/ng_form.ts +++ b/packages/forms/src/directives/ng_form.ts @@ -14,6 +14,7 @@ import { forwardRef, Inject, Input, + OnDestroy, Optional, Provider, Self, @@ -21,6 +22,8 @@ import { untracked, ɵWritable as Writable, } from '@angular/core'; +import {of, Subject, Subscription} from 'rxjs'; +import {filter, map, switchMap, take} from 'rxjs/operators'; import {AbstractControl, FormHooks, FormSubmittedEvent} from '../model/abstract_model'; import {FormControl} from '../model/form_control'; @@ -124,7 +127,7 @@ const resolvedPromise = (() => Promise.resolve())(); exportAs: 'ngForm', standalone: false, }) -export class NgForm extends ControlContainer implements Form, AfterViewInit { +export class NgForm extends ControlContainer implements Form, AfterViewInit, OnDestroy { /** * @description * Returns whether the form submission has been triggered. @@ -161,6 +164,23 @@ export class NgForm extends ControlContainer implements Form, AfterViewInit { */ @Input('ngFormOptions') options!: {updateOn?: FormHooks}; + /** + * @description + * When set to `true`, the `ngSubmit` event will not emit until all pending async validators + * on the form have completed. Defaults to `false` to preserve existing behavior. + * + * @usageNotes + * ```html + *
+ * ... + *
+ * ``` + */ + @Input() awaitAsyncValidators: boolean = false; + + private readonly _pendingSubmit$ = new Subject(); + private _pendingSubmitSub!: Subscription; + constructor( @Optional() @Self() @Inject(NG_VALIDATORS) validators: (Validator | ValidatorFn)[], @Optional() @@ -177,6 +197,26 @@ export class NgForm extends ControlContainer implements Form, AfterViewInit { composeValidators(validators), composeAsyncValidators(asyncValidators), ); + this._pendingSubmitSub = this._pendingSubmit$ + .pipe( + switchMap((event) => + this.awaitAsyncValidators && this.form.status === 'PENDING' + ? (() => { + const statusChanges$ = this.form.statusChanges.pipe( + filter((status) => status !== 'PENDING'), + take(1), + map(() => event), + ); + this.form._updateTreeValidity({emitEvent: true}); + return statusChanges$; + })() + : of(event), + ), + ) + .subscribe((event) => { + this.ngSubmit.emit(event); + this.form._events.next(new FormSubmittedEvent(this.control)); + }); } /** @docs-private */ @@ -334,13 +374,20 @@ export class NgForm extends ControlContainer implements Form, AfterViewInit { onSubmit($event: Event): boolean { this.submittedReactive.set(true); syncPendingControls(this.form, this._directives); - this.ngSubmit.emit($event); - this.form._events.next(new FormSubmittedEvent(this.control)); + + this._pendingSubmit$.next($event); + // Forms with `method="dialog"` have some special behavior // that won't reload the page and that shouldn't be prevented. return ($event?.target as HTMLFormElement | null)?.method === 'dialog'; } + /** @nodoc */ + ngOnDestroy(): void { + this._pendingSubmit$.complete(); + this._pendingSubmitSub.unsubscribe(); + } + /** * @description * Method called when the "reset" event is triggered on the form. diff --git a/packages/forms/src/directives/reactive_directives/abstract_form.directive.ts b/packages/forms/src/directives/reactive_directives/abstract_form.directive.ts index 6c2505e4532e..ac0fc9f42fd9 100644 --- a/packages/forms/src/directives/reactive_directives/abstract_form.directive.ts +++ b/packages/forms/src/directives/reactive_directives/abstract_form.directive.ts @@ -10,6 +10,7 @@ import { Directive, EventEmitter, Inject, + Input, OnChanges, OnDestroy, Optional, @@ -19,6 +20,8 @@ import { signal, untracked, } from '@angular/core'; +import {of, Subject, Subscription} from 'rxjs'; +import {filter, map, switchMap, take} from 'rxjs/operators'; import {FormGroup} from '../../model/form_group'; import {FormArray} from '../../model/form_array'; @@ -102,6 +105,23 @@ export abstract class AbstractFormDirective */ abstract ngSubmit: EventEmitter; + /** + * @description + * When set to `true`, the `ngSubmit` event will not emit until all pending async validators + * on the form have completed. Defaults to `false` to preserve existing behavior. + * + * @usageNotes + * ```html + *
+ * ... + *
+ * ``` + */ + @Input() awaitAsyncValidators: boolean = false; + + private readonly _pendingSubmit$ = new Subject(); + private _pendingSubmitSub!: Subscription; + constructor( @Optional() @Self() @Inject(NG_VALIDATORS) validators: (Validator | ValidatorFn)[], @Optional() @@ -115,6 +135,26 @@ export abstract class AbstractFormDirective super(); this._setValidators(validators); this._setAsyncValidators(asyncValidators); + this._pendingSubmitSub = this._pendingSubmit$ + .pipe( + switchMap((event) => + this.awaitAsyncValidators && this.form.status === 'PENDING' + ? (() => { + const statusChanges$ = this.form.statusChanges.pipe( + filter((status) => status !== 'PENDING'), + take(1), + map(() => event), + ); + this.form._updateTreeValidity({emitEvent: true}); + return statusChanges$; + })() + : of(event), + ), + ) + .subscribe((event) => { + this.ngSubmit.emit(event); + this.form._events.next(new FormSubmittedEvent(this.control)); + }); } /** @nodoc */ @@ -140,6 +180,8 @@ export abstract class AbstractFormDirective /** @nodoc */ protected onDestroy() { + this._pendingSubmit$.complete(); + this._pendingSubmitSub.unsubscribe(); if (this.form) { cleanUpValidators(this.form, this); @@ -312,8 +354,8 @@ export abstract class AbstractFormDirective onSubmit($event: Event): boolean { (this as {submitted: boolean}).submitted = true; syncPendingControls(this.form, this.directives); - this.ngSubmit.emit($event); - this.form._events.next(new FormSubmittedEvent(this.control)); + + this._pendingSubmit$.next($event); // Forms with `method="dialog"` have some special behavior that won't reload the page and that // shouldn't be prevented. Note that we need to null check the `event` and the `target`, because diff --git a/packages/forms/test/reactive_integration_spec.ts b/packages/forms/test/reactive_integration_spec.ts index 5a74939949f0..73c7e5df65b6 100644 --- a/packages/forms/test/reactive_integration_spec.ts +++ b/packages/forms/test/reactive_integration_spec.ts @@ -29,7 +29,7 @@ import { } from '@angular/private/testing'; import {expect} from '@angular/private/testing/matchers'; import {merge, NEVER, Observable, of, Subject, Subscription, timer} from 'rxjs'; -import {map, tap} from 'rxjs/operators'; +import {map, take, tap} from 'rxjs/operators'; import { AbstractControl, AsyncValidator, @@ -1108,6 +1108,96 @@ describe('reactive forms integration tests', () => { form.reset(); expect(loginEl.value).toBe(''); }); + + describe('awaitAsyncValidators', () => { + let pendingSubject: Subject; + + beforeEach(() => { + pendingSubject = new Subject(); + }); + + it('should emit ngSubmit immediately when awaitAsyncValidators is false (default)', () => { + const asyncValidator = () => pendingSubject.asObservable().pipe(take(1)); + const fixture = initTest(FormGroupComp); + fixture.componentInstance.form = new FormGroup({ + 'login': new FormControl('', null, asyncValidator), + }); + fixture.detectChanges(); + + const formGroupDir = fixture.debugElement.children[0].injector.get(FormGroupDirective); + const submitted: Event[] = []; + formGroupDir.ngSubmit.subscribe((e: Event) => submitted.push(e)); + + expect(fixture.componentInstance.form.status).toBe('PENDING'); + + dispatchEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + fixture.detectChanges(); + + expect(submitted.length) + .withContext('Expected ngSubmit to fire immediately when awaitAsyncValidators is false') + .toBe(1); + }); + + it('should defer ngSubmit until async validators complete when awaitAsyncValidators is true', async () => { + const asyncValidator = () => pendingSubject.asObservable().pipe(take(1)); + const fixture = initTest(FormGroupComp); + fixture.componentInstance.form = new FormGroup({ + 'login': new FormControl('', null, asyncValidator), + }); + fixture.detectChanges(); + + const formGroupDir = fixture.debugElement.children[0].injector.get(FormGroupDirective); + formGroupDir.awaitAsyncValidators = true; + + const submitted: Event[] = []; + formGroupDir.ngSubmit.subscribe((e: Event) => submitted.push(e)); + + expect(fixture.componentInstance.form.status).toBe('PENDING'); + + dispatchEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + fixture.detectChanges(); + + expect(submitted.length) + .withContext('Expected ngSubmit not to fire while form is PENDING') + .toBe(0); + + pendingSubject.next(null); + await timeout(); + fixture.detectChanges(); + + expect(submitted.length) + .withContext('Expected ngSubmit to fire after async validators complete') + .toBe(1); + }); + + it('should emit ngSubmit immediately when awaitAsyncValidators is true but form is not PENDING', async () => { + const asyncValidator = () => pendingSubject.asObservable().pipe(take(1)); + const fixture = initTest(FormGroupComp); + fixture.componentInstance.form = new FormGroup({ + 'login': new FormControl('', null, asyncValidator), + }); + fixture.detectChanges(); + + const formGroupDir = fixture.debugElement.children[0].injector.get(FormGroupDirective); + formGroupDir.awaitAsyncValidators = true; + + pendingSubject.next(null); + await timeout(); + fixture.detectChanges(); + + expect(fixture.componentInstance.form.status).not.toBe('PENDING'); + + const submitted: Event[] = []; + formGroupDir.ngSubmit.subscribe((e: Event) => submitted.push(e)); + + dispatchEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + fixture.detectChanges(); + + expect(submitted.length) + .withContext('Expected ngSubmit to fire immediately when form is not PENDING') + .toBe(1); + }); + }); }); describe('value changes and status changes', () => { diff --git a/packages/forms/test/template_integration_spec.ts b/packages/forms/test/template_integration_spec.ts index 29a137ddd876..4a8c6dfe4164 100644 --- a/packages/forms/test/template_integration_spec.ts +++ b/packages/forms/test/template_integration_spec.ts @@ -20,7 +20,8 @@ import { import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {dispatchEvent, sortedClassList, timeout, useAutoTick} from '@angular/private/testing'; -import {merge} from 'rxjs'; +import {merge, Subject} from 'rxjs'; +import {take} from 'rxjs/operators'; import { AbstractControl, AsyncValidator, @@ -1164,6 +1165,84 @@ describe('template-driven forms integration tests', () => { expect(event.defaultPrevented).toBe(false); }); + + describe('awaitAsyncValidators', () => { + beforeEach(() => { + NgPendingAsyncValidator.subject = new Subject(); + }); + + it('should emit ngSubmit immediately when awaitAsyncValidators is false (default)', async () => { + const fixture = initTest(NgModelAwaitAsyncValidatorsForm, NgPendingAsyncValidator); + fixture.detectChanges(); + await timeout(); + + const form = fixture.debugElement.children[0].injector.get(NgForm); + const submitted: Event[] = []; + form.ngSubmit.subscribe((e: Event) => submitted.push(e)); + + expect(form.status).toBe('PENDING'); + + dispatchEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + fixture.detectChanges(); + + expect(submitted.length) + .withContext('Expected ngSubmit to fire immediately when awaitAsyncValidators is false') + .toBe(1); + }); + + it('should defer ngSubmit until async validators complete when awaitAsyncValidators is true', async () => { + const fixture = initTest(NgModelAwaitAsyncValidatorsForm, NgPendingAsyncValidator); + fixture.componentInstance.awaitAsyncValidators = true; + fixture.detectChanges(); + await timeout(); + + const form = fixture.debugElement.children[0].injector.get(NgForm); + const submitted: Event[] = []; + form.ngSubmit.subscribe((e: Event) => submitted.push(e)); + + expect(form.status).toBe('PENDING'); + + dispatchEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + fixture.detectChanges(); + + expect(submitted.length) + .withContext('Expected ngSubmit not to fire while form is PENDING') + .toBe(0); + + NgPendingAsyncValidator.subject.next(null); + await timeout(); + fixture.detectChanges(); + + expect(submitted.length) + .withContext('Expected ngSubmit to fire after async validators complete') + .toBe(1); + }); + + it('should emit ngSubmit immediately when awaitAsyncValidators is true but form is not PENDING', async () => { + const fixture = initTest(NgModelAwaitAsyncValidatorsForm, NgPendingAsyncValidator); + fixture.componentInstance.awaitAsyncValidators = true; + fixture.detectChanges(); + await timeout(); + + const form = fixture.debugElement.children[0].injector.get(NgForm); + + NgPendingAsyncValidator.subject.next(null); + await timeout(); + fixture.detectChanges(); + + expect(form.status).not.toBe('PENDING'); + + const submitted: Event[] = []; + form.ngSubmit.subscribe((e: Event) => submitted.push(e)); + + dispatchEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + fixture.detectChanges(); + + expect(submitted.length) + .withContext('Expected ngSubmit to fire immediately when form is not PENDING') + .toBe(1); + }); + }); }); describe('ngFormOptions', () => { @@ -3124,3 +3203,36 @@ class NgModelNoMinMaxValidator { class NativeDialogForm { @ViewChild('form') form!: ElementRef; } + +@Directive({ + selector: '[ng-pending-async-validator]', + providers: [ + { + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => NgPendingAsyncValidator), + multi: true, + }, + ], + standalone: false, +}) +class NgPendingAsyncValidator implements AsyncValidator { + static subject = new Subject(); + + validate(_c: AbstractControl) { + return NgPendingAsyncValidator.subject.asObservable().pipe(take(1)); + } +} + +@Component({ + selector: 'ng-model-await-async-validators-form', + template: ` +
+ +
+ `, + standalone: false, + changeDetection: ChangeDetectionStrategy.Eager, +}) +class NgModelAwaitAsyncValidatorsForm { + awaitAsyncValidators = false; +}