From a5cebe60b9f792228b31ff4154c33c98041a324a Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Tue, 17 Mar 2026 11:39:44 -0700 Subject: [PATCH 1/3] refactor(forms): rename directive files for consistency - **FormField**: `form_field_directive.ts` -> `form_field.ts` - **FormRoot**: `ng_signal_form.ts` -> `form_root.ts` --- packages/forms/signals/public_api.ts | 4 ++-- packages/forms/signals/src/api/control.ts | 2 +- .../signals/src/api/rules/validation/validation_errors.ts | 2 +- packages/forms/signals/src/api/types.ts | 2 +- packages/forms/signals/src/directive/control_custom.ts | 2 +- packages/forms/signals/src/directive/control_cva.ts | 2 +- packages/forms/signals/src/directive/control_native.ts | 2 +- .../src/directive/{form_field_directive.ts => form_field.ts} | 0 .../signals/src/directive/{ng_signal_form.ts => form_root.ts} | 0 packages/forms/signals/src/field/node.ts | 2 +- packages/forms/signals/src/field/state.ts | 2 +- .../test/node/{ng_signal_form.spec.ts => form_root.spec.ts} | 0 .../web/{form_field_directive.spec.ts => form_field.spec.ts} | 0 .../forms/signals/test/web/signal_form_control_web.spec.ts | 2 +- 14 files changed, 11 insertions(+), 11 deletions(-) rename packages/forms/signals/src/directive/{form_field_directive.ts => form_field.ts} (100%) rename packages/forms/signals/src/directive/{ng_signal_form.ts => form_root.ts} (100%) rename packages/forms/signals/test/node/{ng_signal_form.spec.ts => form_root.spec.ts} (100%) rename packages/forms/signals/test/web/{form_field_directive.spec.ts => form_field.spec.ts} (100%) diff --git a/packages/forms/signals/public_api.ts b/packages/forms/signals/public_api.ts index 6b5a578c02c5..706073975552 100644 --- a/packages/forms/signals/public_api.ts +++ b/packages/forms/signals/public_api.ts @@ -20,5 +20,5 @@ export * from './src/api/rules/validation/validation_errors'; export * from './src/api/structure'; export * from './src/api/transformed_value'; export * from './src/api/types'; -export * from './src/directive/form_field_directive'; -export * from './src/directive/ng_signal_form'; +export * from './src/directive/form_field'; +export * from './src/directive/form_root'; diff --git a/packages/forms/signals/src/api/control.ts b/packages/forms/signals/src/api/control.ts index 22168fddf9d4..4c30c8c42542 100644 --- a/packages/forms/signals/src/api/control.ts +++ b/packages/forms/signals/src/api/control.ts @@ -7,7 +7,7 @@ */ import {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef} from '@angular/core'; -import type {FormFieldBindingOptions} from '../directive/form_field_directive'; +import type {FormFieldBindingOptions} from '../directive/form_field'; import type {ValidationError, WithOptionalFieldTree} from './rules/validation/validation_errors'; import type {DisabledReason} from './types'; diff --git a/packages/forms/signals/src/api/rules/validation/validation_errors.ts b/packages/forms/signals/src/api/rules/validation/validation_errors.ts index 0c1589239e14..834dbe51cb7d 100644 --- a/packages/forms/signals/src/api/rules/validation/validation_errors.ts +++ b/packages/forms/signals/src/api/rules/validation/validation_errors.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import type {FormField} from '../../../directive/form_field_directive'; +import type {FormField} from '../../../directive/form_field'; import type {ReadonlyFieldTree} from '../../types'; import type {StandardSchemaValidationError} from './standard_schema'; diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index bc2b6b6d2317..b624f350cb04 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -8,7 +8,7 @@ import {Injector, Signal, WritableSignal} from '@angular/core'; import {AbstractControl} from '@angular/forms'; -import type {FormField} from '../directive/form_field_directive'; +import type {FormField} from '../directive/form_field'; import type {MetadataKey, ValidationError} from './rules'; /** diff --git a/packages/forms/signals/src/directive/control_custom.ts b/packages/forms/signals/src/directive/control_custom.ts index 28a1b386c9ac..d4767a61e7db 100644 --- a/packages/forms/signals/src/directive/control_custom.ts +++ b/packages/forms/signals/src/directive/control_custom.ts @@ -7,7 +7,7 @@ */ import type {ɵControlDirectiveHost as ControlDirectiveHost} from '@angular/core'; -import type {FormField} from './form_field_directive'; +import type {FormField} from './form_field'; import { bindingUpdated, CONTROL_BINDING_NAMES, diff --git a/packages/forms/signals/src/directive/control_cva.ts b/packages/forms/signals/src/directive/control_cva.ts index 67f46514d739..1103ff2fd2de 100644 --- a/packages/forms/signals/src/directive/control_cva.ts +++ b/packages/forms/signals/src/directive/control_cva.ts @@ -15,7 +15,7 @@ import { readFieldStateBindingValue, } from './bindings'; import {setNativeDomProperty} from './native'; -import type {FormField} from './form_field_directive'; +import type {FormField} from './form_field'; export function cvaControlCreate( host: ControlDirectiveHost, diff --git a/packages/forms/signals/src/directive/control_native.ts b/packages/forms/signals/src/directive/control_native.ts index 07be95d7fb28..d90537886b38 100644 --- a/packages/forms/signals/src/directive/control_native.ts +++ b/packages/forms/signals/src/directive/control_native.ts @@ -19,7 +19,7 @@ import { readFieldStateBindingValue, type ControlBindingKey, } from './bindings'; -import type {FormField} from './form_field_directive'; +import type {FormField} from './form_field'; import {InputValidityMonitor} from './input_validity_monitor'; import { getNativeControlValue, diff --git a/packages/forms/signals/src/directive/form_field_directive.ts b/packages/forms/signals/src/directive/form_field.ts similarity index 100% rename from packages/forms/signals/src/directive/form_field_directive.ts rename to packages/forms/signals/src/directive/form_field.ts diff --git a/packages/forms/signals/src/directive/ng_signal_form.ts b/packages/forms/signals/src/directive/form_root.ts similarity index 100% rename from packages/forms/signals/src/directive/ng_signal_form.ts rename to packages/forms/signals/src/directive/form_root.ts diff --git a/packages/forms/signals/src/field/node.ts b/packages/forms/signals/src/field/node.ts index 55b5a85a69bc..7df69c2b269e 100644 --- a/packages/forms/signals/src/field/node.ts +++ b/packages/forms/signals/src/field/node.ts @@ -33,7 +33,7 @@ import type { FieldTree, MarkAsTouchedOptions, } from '../api/types'; -import type {FormField} from '../directive/form_field_directive'; +import type {FormField} from '../directive/form_field'; import {DYNAMIC} from '../schema/logic'; import {LogicNode} from '../schema/logic_node'; import {FieldPathNode} from '../schema/path_node'; diff --git a/packages/forms/signals/src/field/state.ts b/packages/forms/signals/src/field/state.ts index 347f4aa2d212..2e979ca9a755 100644 --- a/packages/forms/signals/src/field/state.ts +++ b/packages/forms/signals/src/field/state.ts @@ -7,7 +7,7 @@ */ import {computed, signal, Signal} from '@angular/core'; -import type {FormField} from '../directive/form_field_directive'; +import type {FormField} from '../directive/form_field'; import type {Debouncer, DisabledReason} from '../api/types'; import {DEBOUNCER} from './debounce'; import type {FieldNode} from './node'; diff --git a/packages/forms/signals/test/node/ng_signal_form.spec.ts b/packages/forms/signals/test/node/form_root.spec.ts similarity index 100% rename from packages/forms/signals/test/node/ng_signal_form.spec.ts rename to packages/forms/signals/test/node/form_root.spec.ts diff --git a/packages/forms/signals/test/web/form_field_directive.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts similarity index 100% rename from packages/forms/signals/test/web/form_field_directive.spec.ts rename to packages/forms/signals/test/web/form_field.spec.ts diff --git a/packages/forms/signals/test/web/signal_form_control_web.spec.ts b/packages/forms/signals/test/web/signal_form_control_web.spec.ts index 66684ec1b33f..da4ca61b3381 100644 --- a/packages/forms/signals/test/web/signal_form_control_web.spec.ts +++ b/packages/forms/signals/test/web/signal_form_control_web.spec.ts @@ -12,7 +12,7 @@ import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {disabled} from '@angular/forms/signals'; import {SignalFormControl} from '../../compat'; -import {FormField} from '../../src/directive/form_field_directive'; +import {FormField} from '../../src/directive/form_field'; describe('SignalFormControl (web)', () => { beforeEach(() => { From 609bfd269eeab73ca011900b51214a944ca91b30 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Tue, 17 Mar 2026 13:03:41 -0700 Subject: [PATCH 2/3] fix(forms): allow `FormRoot` to be used without submission options 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 `
` and calling `preventDefault()` on the `submit` event. Fix #67367 --- .../forms/signals/src/directive/form_root.ts | 15 ++++++++--- .../forms/signals/test/node/form_root.spec.ts | 27 ++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/forms/signals/src/directive/form_root.ts b/packages/forms/signals/src/directive/form_root.ts index 7ba799d49a5b..5237e23deb55 100644 --- a/packages/forms/signals/src/directive/form_root.ts +++ b/packages/forms/signals/src/directive/form_root.ts @@ -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 `` 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,12 @@ export class FormRoot { protected onSubmit(event: Event): void { event.preventDefault(); - submit(this.fieldTree()); + + const fieldTree = untracked(this.fieldTree); + const node = untracked(fieldTree) as FieldState as FieldNode; + + if (node.structure.fieldManager.submitOptions) { + submit(fieldTree); + } } } diff --git a/packages/forms/signals/test/node/form_root.spec.ts b/packages/forms/signals/test/node/form_root.spec.ts index 8944a8e64339..19bd60b45f80 100644 --- a/packages/forms/signals/test/node/form_root.spec.ts +++ b/packages/forms/signals/test/node/form_root.spec.ts @@ -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: ` + + + + `, + 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: ` From aaedfb21727b0533f39c30c587f4d4ef51310fd7 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Wed, 18 Mar 2026 16:23:12 -0700 Subject: [PATCH 3/3] fixup! fix(forms): allow `FormRoot` to be used without submission options --- packages/forms/signals/src/directive/form_root.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/forms/signals/src/directive/form_root.ts b/packages/forms/signals/src/directive/form_root.ts index 5237e23deb55..9bb073f43d4a 100644 --- a/packages/forms/signals/src/directive/form_root.ts +++ b/packages/forms/signals/src/directive/form_root.ts @@ -44,11 +44,13 @@ export class FormRoot { protected onSubmit(event: Event): void { event.preventDefault(); - const fieldTree = untracked(this.fieldTree); - const node = untracked(fieldTree) as FieldState as FieldNode; + untracked(() => { + const fieldTree = this.fieldTree(); + const node = fieldTree() as FieldState as FieldNode; - if (node.structure.fieldManager.submitOptions) { - submit(fieldTree); - } + if (node.structure.fieldManager.submitOptions) { + submit(fieldTree); + } + }); } }