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;
+}