fix(forms): allow FormRoot to be used without submission options (#67727)

The `[formRoot]` directive will no longer call `submit()` if the bound
form doesn't define its own submission options. This allows the
directive to be used solely for the default behavior it provides:
setting `novalidate` on the `<form>` and calling `preventDefault()` on
the `submit` event.

Fix #67367

PR Close #67727
This commit is contained in:
Leon Senft
2026-03-17 13:03:41 -07:00
parent f4a5b42ebe
commit 0eeb1b5f03
2 changed files with 39 additions and 5 deletions
@@ -6,10 +6,11 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {Directive, input} from '@angular/core';
import {Directive, input, untracked} from '@angular/core';
import {submit} from '../api/structure';
import {FieldTree} from '../api/types';
import {FieldState, FieldTree} from '../api/types';
import {FieldNode} from '../field/node';
/**
* A directive that binds a `FieldTree` to a `<form>` element.
@@ -17,7 +18,7 @@ import {FieldTree} from '../api/types';
* It automatically:
* 1. Sets `novalidate` on the form element to disable browser validation.
* 2. Listens for the `submit` event, prevents the default behavior, and calls `submit()` on the
* `FieldTree`.
* `FieldTree` if it defines its own submission options.
*
* @usageNotes
*
@@ -42,6 +43,14 @@ export class FormRoot<T> {
protected onSubmit(event: Event): void {
event.preventDefault();
submit(this.fieldTree());
untracked(() => {
const fieldTree = this.fieldTree();
const node = fieldTree() as FieldState<unknown> as FieldNode;
if (node.structure.fieldManager.submitOptions) {
submit(fieldTree);
}
});
}
}
@@ -44,7 +44,7 @@ describe('FormRoot', () => {
expect(formElement.hasAttribute('novalidate')).toBeTrue();
});
it('should call submit on the field tree when form is submitted', async () => {
it('should call submit if the field tree defines submit options', async () => {
const fixture = act(() => TestBed.createComponent(TestCmp));
const component = fixture.componentInstance;
const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
@@ -53,9 +53,34 @@ describe('FormRoot', () => {
act(() => formElement.dispatchEvent(event));
expect(event.defaultPrevented).toBe(true);
expect(component.f().touched()).toBeTrue();
expect(component.submitted).toBeTrue();
});
it('should not call submit if the field tree does not define submit options', async () => {
@Component({
template: `
<form [formRoot]="f">
<button type="submit">Submit</button>
</form>
`,
imports: [FormRoot],
})
class TestCmpNoSubmit {
readonly f = form(signal({}));
}
const fixture = act(() => TestBed.createComponent(TestCmpNoSubmit));
const component = fixture.componentInstance;
const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
const event = new Event('submit', {cancelable: true});
act(() => formElement.dispatchEvent(event));
expect(event.defaultPrevented).toBe(true);
expect(component.f().touched()).withContext('submit would mark this as touched').toBeFalse();
});
it('works when FormsModule is imported', () => {
@Component({
template: `