diff --git a/adev/src/content/guide/forms/signals/migration.md b/adev/src/content/guide/forms/signals/migration.md
index 5188f9ea6db..89f170125b1 100644
--- a/adev/src/content/guide/forms/signals/migration.md
+++ b/adev/src/content/guide/forms/signals/migration.md
@@ -83,7 +83,8 @@ In the template, use standard reactive syntax by binding the underlying control:
### Integrating a `FormGroup` into a signal form
-You can also wrap an entire `FormGroup`. This is common when a reusable sub-section of a form—such as an **Address Block**—is still managed by legacy Reactive Forms.
+You can also wrap an entire `FormGroup`. This is common when a reusable sub-section of a form—such as an **Address Block
+**—is still managed by legacy Reactive Forms.
```typescript
import {signal} from '@angular/core';
@@ -206,11 +207,148 @@ const formValue = computed(() => ({
## Bottom-up migration
-This is coming soon.
+### Integrating a Signal Form into a `FormGroup`
+
+You can use `SignalFormControl` to expose a signal-based form as a standard `FormControl`. This is useful when you want
+to migrate leaf nodes of a form to Signals while keeping the parent `FormGroup` structure.
+
+```typescript
+import {Component, inject, Injector, signal} from '@angular/core';
+import {ReactiveFormsModule, FormGroup} from '@angular/forms';
+import {SignalFormControl} from '@angular/forms/signals/compat';
+import {required} from '@angular/forms/signals';
+
+@Component({
+ // ...
+ imports: [ReactiveFormsModule],
+})
+export class UserProfile {
+ private injector = inject(Injector);
+
+ // 1. Create a SignalFormControl, use signal form rules.
+ // Note: SignalFormControl requires an Injector
+ emailControl = new SignalFormControl('', this.injector, (p) => {
+ required(p, {message: 'Email is required'});
+ });
+
+ // 2. Use it in a legacy FormGroup
+ form = new FormGroup({
+ email: this.emailControl,
+ });
+}
+```
+
+The `SignalFormControl` synchronizes values and validation status bi-directionally:
+
+- **Signal -> Control**: Changing `email.set(...)` updates `emailControl.value` and the parent `form.value`.
+- **Control -> Signal**: Typing in the input (updating `emailControl`) updates the `email` signal.
+- **Validation**: Schema validators (like `required`) propagate errors to `emailControl.errors`.
+
+### Disabling/Enabling control.
+
+Imperative APIs for changing the enabled/disabled state (like `enable()`, `disable()`) are intentionally not supported
+in `SignalFormControl`. This is because the state of the control should be derived from the signal state and rules.
+
+Attempting to call disable/enable would throw an error.
+
+```typescript {avoid}
+import {signal, effect} from '@angular/core';
+
+export class UserProfile {
+ readonly emailControl = new SignalFormControl('', this.injector);
+
+ readonly isLoading = signal(false);
+
+ constructor() {
+ // This will throw an error
+ effect(() => {
+ if (this.isLoading()) {
+ this.emailControl.disable();
+ } else {
+ this.emailControl.enable();
+ }
+ });
+ }
+}
+```
+
+Instead, use disabled rule:
+
+```typescript {prefer}
+import {signal} from '@angular/core';
+import {SignalFormControl} from '@angular/forms/signals/compat';
+import {disabled} from '@angular/forms/signals';
+
+export class UserProfile {
+ readonly isLoading = signal(false);
+
+ readonly emailControl = new SignalFormControl('', this.injector, (p) => {
+ // The control becomes disabled whenever isLoading is true
+ disabled(p, () => this.isLoading());
+ });
+
+ async saveData() {
+ this.isLoading.set(true);
+ // ... perform save ...
+ this.isLoading.set(false);
+ }
+}
+```
+
+### Dynamic manipulation
+
+Imperative APIs for adding or removing validators (like `addValidators()`, `removeValidators()`, `setValidators()`) are intentionally not supported in `SignalFormControl`.
+
+Attempting to call these methods will throw an error.
+
+```typescript {avoid}
+export class UserProfile {
+ readonly emailControl = new SignalFormControl('', this.injector);
+ readonly isRequired = signal(false);
+
+ toggleRequired() {
+ this.isRequired.update((v) => !v);
+ // This will throw an error
+ if (this.isRequired()) {
+ this.emailControl.addValidators(Validators.required);
+ } else {
+ this.emailControl.removeValidators(Validators.required);
+ }
+ }
+}
+```
+
+Instead, use `applyWhen` rule to conditionally apply validators:
+
+```typescript {prefer}
+import {signal} from '@angular/core';
+import {SignalFormControl} from '@angular/forms/signals/compat';
+import {applyWhen, required} from '@angular/forms/signals';
+
+export class UserProfile {
+ readonly isRequired = signal(false);
+
+ readonly emailControl = new SignalFormControl('', this.injector, (p) => {
+ // The control becomes required whenever isRequired is true
+ applyWhen(
+ p,
+ () => this.isRequired(),
+ (p) => {
+ required(p);
+ },
+ );
+ });
+}
+```
+
+### Manual Error Selection
+
+The `setErrors()` and `markAsPending()` methods are not supported. In Signal Forms, errors are derived from validation rules and async validation status. If you need to report an error, it should be done declaratively via a validation rule in the schema.
## Automatic status classes
-Reactive/Template Forms automatically adds [class attributes](/guide/forms/template-driven-forms#track-control-states) (such as `.ng-valid` or `.ng-dirty`) to facilitate styling control states. Signal Forms does not do that.
+Reactive/Template Forms automatically adds [class attributes](/guide/forms/template-driven-forms#track-control-states) (
+such as `.ng-valid` or `.ng-dirty`) to facilitate styling control states. Signal Forms does not do that.
If you want to preserve this behavior, you can provide the `NG_STATUS_CLASSES` preset:
diff --git a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts
index e219c7bedb1..a63f7d6d72b 100644
--- a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts
+++ b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts
@@ -6,7 +6,15 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import {EventEmitter, inject, Injector, signal, WritableSignal, effect} from '@angular/core';
+import {
+ EventEmitter,
+ inject,
+ Injector,
+ signal,
+ WritableSignal,
+ effect,
+ ɵRuntimeError as RuntimeError,
+} from '@angular/core';
import {
AbstractControl,
ControlEvent,
@@ -25,6 +33,7 @@ import {compatForm} from '../api/compat_form';
import {signalErrorsToValidationErrors} from '../../../src/api/rules';
import {FormOptions} from '../../../src/api/structure';
import {FieldState, FieldTree, SchemaFn} from '../../../src/api/types';
+import {SignalFormsErrorCode} from '../../../src/errors';
import {normalizeFormArgs} from '../../../src/util/normalize_form_args';
import {removeListItem} from '../../../../src/util';
@@ -42,6 +51,31 @@ export type ValueUpdateOptions = {
* This class provides a bridge between Signal Forms and Reactive Forms, allowing
* signal-based controls to be used within a standard `FormGroup` or `FormArray`.
*
+ * A control could be created using signal forms, and integrated with an existing FormGroup
+ * propagating all the statuses and validity.
+ *
+ * @usageNotes
+ *
+ * ### Basic usage
+ *
+ * ```angular-ts
+ * const form = new FormGroup({
+ * // You can create SignalFormControl with signal form rules, and add it to a FormGroup.
+ * name: new SignalFormControl('Alice', p => {
+ * required(p);
+ * }),
+ * age: new FormControl(25),
+ * });
+ * ```
+ * In the template you can get the underlying `fieldTree` and bind it:
+ *
+ * ```angular-html
+ *
+ * ```
+ *
* @experimental
*/
export class SignalFormControl extends AbstractControl {
@@ -289,18 +323,42 @@ export class SignalFormControl extends AbstractControl {
return this.fieldState.dirty();
}
+ override set dirty(_: boolean) {
+ throw unsupportedFeatureError(
+ 'Setting dirty directly is not supported. Instead use markAsDirty().',
+ );
+ }
+
override get pristine(): boolean {
return !this.dirty;
}
+ override set pristine(_: boolean) {
+ throw unsupportedFeatureError(
+ 'Setting pristine directly is not supported. Instead use reset().',
+ );
+ }
+
override get touched(): boolean {
return this.fieldState.touched();
}
+ override set touched(_: boolean) {
+ throw unsupportedFeatureError(
+ 'Setting touched directly is not supported. Instead use markAsTouched() or reset().',
+ );
+ }
+
override get untouched(): boolean {
return !this.touched;
}
+ override set untouched(_: boolean) {
+ throw unsupportedFeatureError(
+ 'Setting untouched directly is not supported. Instead use reset().',
+ );
+ }
+
override markAsTouched(opts?: {onlySelf?: boolean}): void {
this.fieldState.markAsTouched();
this.propagateToParent(opts, (parent) => parent.markAsTouched(opts));
@@ -351,6 +409,58 @@ export class SignalFormControl extends AbstractControl {
_syncPendingControls(): boolean {
return false;
}
+
+ override disable(_opts?: {onlySelf?: boolean; emitEvent?: boolean}): void {
+ throw unsupportedDisableEnableError();
+ }
+
+ override enable(_opts?: {onlySelf?: boolean; emitEvent?: boolean}): void {
+ throw unsupportedDisableEnableError();
+ }
+
+ override setValidators(_validators: any): void {
+ throw unsupportedValidatorsError();
+ }
+
+ override setAsyncValidators(_validators: any): void {
+ throw unsupportedValidatorsError();
+ }
+
+ override addValidators(_validators: any): void {
+ throw unsupportedValidatorsError();
+ }
+
+ override addAsyncValidators(_validators: any): void {
+ throw unsupportedValidatorsError();
+ }
+
+ override removeValidators(_validators: any): void {
+ throw unsupportedValidatorsError();
+ }
+
+ override removeAsyncValidators(_validators: any): void {
+ throw unsupportedValidatorsError();
+ }
+
+ override clearValidators(): void {
+ throw unsupportedValidatorsError();
+ }
+
+ override clearAsyncValidators(): void {
+ throw unsupportedValidatorsError();
+ }
+
+ override setErrors(_errors: any, _opts?: {emitEvent?: boolean}): void {
+ throw unsupportedFeatureError(
+ 'Imperatively setting errors is not supported in signal forms. Errors are derived from validation rules.',
+ );
+ }
+
+ override markAsPending(_opts?: {onlySelf?: boolean; emitEvent?: boolean}): void {
+ throw unsupportedFeatureError(
+ 'Imperatively marking as pending is not supported in signal forms. Pending state is derived from async validation status.',
+ );
+ }
}
class CachingWeakMap {
@@ -419,3 +529,19 @@ function isFormControlState(formState: unknown): formState is {value: any; disab
'disabled' in formState
);
}
+
+function unsupportedFeatureError(message: string): RuntimeError {
+ return new RuntimeError(SignalFormsErrorCode.UNSUPPORTED_FEATURE as any, ngDevMode && message);
+}
+
+function unsupportedDisableEnableError(): RuntimeError {
+ return unsupportedFeatureError(
+ 'Imperatively changing enabled/disabled status in form control is not supported in signal forms. Instead use a "disabled" rule to derive the disabled status from a signal.',
+ );
+}
+
+function unsupportedValidatorsError(): RuntimeError {
+ return unsupportedFeatureError(
+ 'Dynamically adding and removing validators is not supported in signal forms. Instead use the "applyWhen" rule to conditionally apply validators based on a signal.',
+ );
+}
diff --git a/packages/forms/signals/src/controls/interop_ng_control.ts b/packages/forms/signals/src/controls/interop_ng_control.ts
index c8f60ee4843..80153d34bcb 100644
--- a/packages/forms/signals/src/controls/interop_ng_control.ts
+++ b/packages/forms/signals/src/controls/interop_ng_control.ts
@@ -8,6 +8,7 @@
import {ɵRuntimeError as RuntimeError} from '@angular/core';
import {SignalFormsErrorCode} from '../errors';
+import {signalErrorsToValidationErrors} from '../api/rules/validation/validation_errors';
import {
ControlValueAccessor,
@@ -85,15 +86,7 @@ export class InteropNgControl
}
get errors(): ValidationErrors | null {
- const errors = this.field().errors();
- if (errors.length === 0) {
- return null;
- }
- const errObj: ValidationErrors = {};
- for (const error of errors) {
- errObj[error.kind] = error;
- }
- return errObj;
+ return signalErrorsToValidationErrors(this.field().errors());
}
get pristine(): boolean {
diff --git a/packages/forms/signals/test/node/compat/signal_form_control.spec.ts b/packages/forms/signals/test/node/compat/signal_form_control.spec.ts
new file mode 100644
index 00000000000..d34f62d60a0
--- /dev/null
+++ b/packages/forms/signals/test/node/compat/signal_form_control.spec.ts
@@ -0,0 +1,586 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import {ApplicationRef, Injector, resource} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {ControlEvent, FormArray, FormControlStatus, FormGroup} from '@angular/forms';
+import {disabled, required, validateAsync, ValidationError} from '@angular/forms/signals';
+import {SchemaFn} from '../../../src/api/types';
+import {SignalFormControl} from '../../../compat/src/signal_form_control/signal_form_control';
+
+function createSignalFormControl(initialValue: T, schema?: SchemaFn) {
+ const injector = TestBed.inject(Injector);
+ return new SignalFormControl(initialValue, schema, {injector});
+}
+
+function promiseWithResolvers(): {
+ promise: Promise;
+ resolve: (value: T | PromiseLike) => void;
+ reject: (reason?: any) => void;
+} {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: any) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return {promise, resolve, reject};
+}
+
+describe('SignalFormControl', () => {
+ describe('value and state access', () => {
+ it('should have the same value as the signal', () => {
+ const form = createSignalFormControl(10);
+
+ expect(form.value).toBe(10);
+ form.setValue(20);
+ expect(form.value).toBe(20);
+ });
+
+ it('should expose fieldTree', () => {
+ const form = createSignalFormControl(10);
+ expect(form.fieldTree().value()).toBe(10);
+
+ form.setValue(20);
+ expect(form.fieldTree().value()).toBe(20);
+ });
+
+ it('should return value for getRawValue', () => {
+ const form = createSignalFormControl(10);
+ expect(form.getRawValue()).toBe(10);
+ });
+ });
+
+ describe('validation', () => {
+ it('should validate', () => {
+ const form = createSignalFormControl('', (p) => {
+ required(p);
+ });
+
+ expect(form.valid).toBe(false);
+
+ form.setValue('pirojok');
+ expect(form.valid).toBe(true);
+
+ form.setValue('');
+ expect(form.valid).toBe(false);
+ });
+
+ it('should expose validation errors through the errors getter', () => {
+ const form = createSignalFormControl('', (p) => {
+ required(p);
+ });
+
+ const errors = form.errors;
+ expect(errors).not.toBeNull();
+ expect(errors!['required']).toEqual(jasmine.objectContaining({kind: 'required'}));
+
+ form.setValue(1);
+ expect(form.errors).toBeNull();
+ });
+
+ it('should expose pending status for async validators', async () => {
+ let deferred = promiseWithResolvers();
+ const resolveNext = (errors: ValidationError[]) => {
+ TestBed.tick();
+ deferred.resolve(errors);
+ deferred = promiseWithResolvers();
+ };
+
+ const form = createSignalFormControl('initial', (p) => {
+ validateAsync(p, {
+ params: ({value}) => value(),
+ factory: (params) =>
+ resource({
+ params,
+ loader: () => deferred.promise,
+ }),
+ onSuccess: (errors) => errors,
+ onError: () => null,
+ });
+ });
+ const appRef = TestBed.inject(ApplicationRef);
+
+ expect(form.pending).toBe(true);
+ expect(form.status).toBe('PENDING');
+
+ resolveNext([]);
+ await appRef.whenStable();
+
+ expect(form.pending).toBe(false);
+ expect(form.status).toBe('VALID');
+
+ form.setValue('invalid');
+
+ expect(form.pending).toBe(true);
+ expect(form.status).toBe('PENDING');
+
+ resolveNext([{kind: 'async-invalid'}]);
+ await appRef.whenStable();
+
+ expect(form.pending).toBe(false);
+ expect(form.status).toBe('INVALID');
+ expect(form.errors?.['async-invalid']).toEqual(
+ jasmine.objectContaining({kind: 'async-invalid'}),
+ );
+ });
+
+ it('should support disabled via rules', () => {
+ const form = createSignalFormControl(10, (p) => {
+ disabled(p, ({value}) => value() > 15);
+ });
+
+ expect(form.disabled).toBe(false);
+ expect(form.status).toBe('VALID');
+
+ form.setValue(20);
+
+ expect(form.disabled).toBe(true);
+ expect(form.status).toBe('DISABLED');
+ });
+ });
+
+ describe('status management (dirty/touched)', () => {
+ it('should support markAsTouched', () => {
+ const form = createSignalFormControl(10);
+
+ expect(form.touched).toBe(false);
+ form.markAsTouched();
+ expect(form.touched).toBe(true);
+ });
+
+ it('should support markAsDirty', () => {
+ const form = createSignalFormControl(10);
+
+ expect(form.dirty).toBe(false);
+ form.markAsDirty();
+ expect(form.dirty).toBe(true);
+ });
+
+ it('should support markAsPristine', () => {
+ const form = createSignalFormControl(10);
+
+ form.markAsDirty();
+ expect(form.dirty).toBe(true);
+
+ form.markAsPristine();
+ expect(form.dirty).toBe(false);
+ });
+
+ it('should preserve touched state when markAsPristine is called', () => {
+ const form = createSignalFormControl(10);
+
+ form.markAsDirty();
+ form.markAsTouched();
+ expect(form.dirty).toBe(true);
+ expect(form.touched).toBe(true);
+
+ form.markAsPristine();
+ expect(form.dirty).toBe(false);
+ expect(form.touched).toBe(true);
+ });
+
+ it('should support markAsUntouched', () => {
+ const form = createSignalFormControl(10);
+
+ form.markAsTouched();
+ expect(form.touched).toBe(true);
+
+ form.markAsUntouched();
+ expect(form.touched).toBe(false);
+ });
+
+ it('should preserve dirty state when markAsUntouched is called', () => {
+ const form = createSignalFormControl(10);
+
+ form.markAsDirty();
+ form.markAsTouched();
+ expect(form.dirty).toBe(true);
+ expect(form.touched).toBe(true);
+
+ form.markAsUntouched();
+ expect(form.touched).toBe(false);
+ expect(form.dirty).toBe(true);
+ });
+
+ it('should propagate dirty status to parent FormGroup immediately', () => {
+ const child = createSignalFormControl('meow');
+ const group = new FormGroup({
+ child: child,
+ });
+
+ expect(group.dirty).toBe(false);
+ child.markAsDirty();
+ expect(group.dirty).toBe(true);
+ });
+
+ it('should not propagate dirty status to parent when onlySelf is true', () => {
+ const child = createSignalFormControl('meow');
+ const group = new FormGroup({child});
+
+ child.markAsDirty({onlySelf: true});
+
+ expect(child.dirty).toBe(true);
+ expect(group.dirty).toBe(false);
+ });
+
+ it('should propagate touched status to parent FormGroup immediately', () => {
+ const child = createSignalFormControl('meow');
+ const group = new FormGroup({
+ child: child,
+ });
+
+ expect(group.touched).toBe(false);
+ child.markAsTouched();
+ expect(group.touched).toBe(true);
+ });
+
+ it('should not propagate touched status to parent when onlySelf is true', () => {
+ const child = createSignalFormControl('meow');
+ const group = new FormGroup({child});
+
+ child.markAsTouched({onlySelf: true});
+
+ expect(child.touched).toBe(true);
+ expect(group.touched).toBe(false);
+ });
+
+ it('should not propagate pristine status to parent when onlySelf is true', () => {
+ const child = createSignalFormControl('meow');
+ const group = new FormGroup({child});
+
+ group.markAsDirty();
+ expect(group.dirty).toBe(true);
+
+ child.markAsPristine({onlySelf: true});
+
+ expect(child.pristine).toBe(true);
+ expect(group.dirty).toBe(true);
+ });
+
+ it('should not propagate untouched status to parent when onlySelf is true', () => {
+ const child = createSignalFormControl('meow');
+ const group = new FormGroup({child});
+
+ group.markAsTouched();
+ expect(group.touched).toBe(true);
+
+ child.markAsUntouched({onlySelf: true});
+
+ expect(child.untouched).toBe(true);
+ expect(group.touched).toBe(true);
+ });
+
+ it('should propagate dirty status to parent FormGroup from fieldTree update', () => {
+ const child = createSignalFormControl('meow');
+ const group = new FormGroup({
+ child: child,
+ });
+
+ expect(group.dirty).toBe(false);
+ child.fieldTree().markAsDirty();
+ TestBed.tick();
+ // TODO: kirjs
+ //expect(group.dirty).toBe(true);
+ });
+ });
+
+ describe('observables and events', () => {
+ it('should emit valueChanges when the value updates', () => {
+ const form = createSignalFormControl(10);
+ const emissions: number[] = [];
+
+ form.valueChanges.subscribe((v: number) => emissions.push(v));
+
+ form.setValue(20);
+ TestBed.tick();
+ expect(emissions).toEqual([20]);
+
+ form.setValue(30);
+ TestBed.tick();
+ expect(emissions).toEqual([20, 30]);
+ });
+
+ it('should emit statusChanges when validity toggles', () => {
+ const form = createSignalFormControl(undefined, (p) => {
+ required(p);
+ });
+ const statuses: FormControlStatus[] = [];
+
+ form.statusChanges.subscribe((status: FormControlStatus) => statuses.push(status));
+
+ form.setValue(1);
+ TestBed.tick();
+ expect(statuses).toEqual(['VALID']);
+
+ form.setValue(undefined);
+ TestBed.tick();
+ expect(statuses).toEqual(['VALID', 'INVALID']);
+
+ form.setValue(10);
+ TestBed.tick();
+ expect(statuses).toEqual(['VALID', 'INVALID', 'VALID']);
+ });
+
+ it('should emit ValueChangeEvent on events observable', () => {
+ const form = createSignalFormControl(10);
+ const events: any[] = [];
+
+ form.events.subscribe((e: ControlEvent) => events.push(e));
+
+ form.setValue(20);
+ TestBed.tick();
+
+ const valueEvents = events.filter((e) => e.constructor.name === 'ValueChangeEvent');
+ expect(valueEvents.length).toBeGreaterThan(0);
+ expect(valueEvents[valueEvents.length - 1].value).toBe(20);
+ });
+
+ it('should emit StatusChangeEvent on events observable when status changes', () => {
+ const form = createSignalFormControl(10, (p) => required(p));
+
+ TestBed.tick();
+
+ const events: any[] = [];
+ form.events.subscribe((e: ControlEvent) => events.push(e));
+
+ form.setValue(undefined);
+ TestBed.tick();
+
+ const statusEvents = events.filter((e) => e.constructor.name === 'StatusChangeEvent');
+ expect(statusEvents.length).toBeGreaterThan(0);
+ expect(statusEvents[statusEvents.length - 1].status).toBe('INVALID');
+ });
+
+ it('should emit TouchedChangeEvent on events observable', () => {
+ const form = createSignalFormControl(10);
+
+ TestBed.tick();
+
+ const events: any[] = [];
+ form.events.subscribe((e: ControlEvent) => events.push(e));
+
+ form.markAsTouched();
+ TestBed.tick();
+
+ expect(events.length).toBe(1);
+ expect(events[0].touched).toBe(true);
+ });
+
+ it('should emit PristineChangeEvent on events observable when dirty changes', () => {
+ const form = createSignalFormControl(10);
+
+ TestBed.tick();
+
+ const events: any[] = [];
+ form.events.subscribe((e: ControlEvent) => events.push(e));
+ form.markAsDirty();
+ TestBed.tick();
+
+ expect(events.length).toBe(1);
+ expect(events[0].pristine).toBe(false);
+ });
+ });
+
+ describe('reset', () => {
+ it('should reset touched and dirty state', () => {
+ const form = createSignalFormControl(10);
+
+ form.markAsTouched();
+ form.markAsDirty();
+ expect(form.touched).toBe(true);
+ expect(form.dirty).toBe(true);
+
+ form.reset(10);
+ expect(form.touched).toBe(false);
+ expect(form.dirty).toBe(false);
+ expect(form.value).toBe(10);
+ });
+
+ it('should reset with a new value', () => {
+ const form = createSignalFormControl('pirojok');
+
+ form.markAsTouched();
+ form.markAsDirty();
+
+ form.reset('buterbrod');
+ expect(form.value).toBe('buterbrod');
+ expect(form.sourceValue()).toBe('buterbrod');
+ expect(form.touched).toBe(false);
+ expect(form.dirty).toBe(false);
+ });
+
+ it('should unbox value in reset', () => {
+ const form = createSignalFormControl(10);
+ form.reset({value: 20, disabled: true});
+
+ expect(form.value).toBe(20);
+ expect(form.disabled).toBe(false);
+ });
+
+ it('should NOT unbox value in reset if it has extra keys', () => {
+ const form = createSignalFormControl(10);
+ const complexValue = {value: 20, disabled: true, extra: 1};
+ form.reset(complexValue);
+ expect(form.value).toEqual(complexValue);
+ });
+
+ it('should emit FormResetEvent on reset', () => {
+ const form = createSignalFormControl(10);
+ const events: any[] = [];
+ form.events.subscribe((e: ControlEvent) => events.push(e));
+
+ form.reset(20);
+ expect(events.length).toBe(1);
+ // TODO check event
+ });
+
+ it('should NOT emit FormResetEvent on reset when emitEvent is false', () => {
+ const form = createSignalFormControl(10);
+ const events: any[] = [];
+ form.events.subscribe((e: ControlEvent) => events.push(e));
+
+ form.reset(20, {emitEvent: false});
+ expect(events.length).toBe(0);
+ });
+ });
+
+ describe('unsupported methods', () => {
+ it('should throw error when calling disable()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.disable()).toThrowError(
+ /Imperatively changing enabled\/disabled status in form control is not supported/,
+ );
+ });
+
+ it('should throw error when calling enable()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.enable()).toThrowError(
+ /Imperatively changing enabled\/disabled status in form control is not supported/,
+ );
+ });
+
+ it('should throw error when calling setValidators()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.setValidators(null)).toThrowError(
+ /Dynamically adding and removing validators is not supported/,
+ );
+ });
+
+ it('should throw error when calling setAsyncValidators()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.setAsyncValidators(null)).toThrowError(
+ /Dynamically adding and removing validators is not supported/,
+ );
+ });
+
+ it('should throw error when calling addValidators()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.addValidators([])).toThrowError(
+ /Dynamically adding and removing validators is not supported/,
+ );
+ });
+
+ it('should throw error when calling addAsyncValidators()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.addAsyncValidators([])).toThrowError(
+ /Dynamically adding and removing validators is not supported/,
+ );
+ });
+
+ it('should throw error when calling removeValidators()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.removeValidators([])).toThrowError(
+ /Dynamically adding and removing validators is not supported/,
+ );
+ });
+
+ it('should throw error when calling removeAsyncValidators()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.removeAsyncValidators([])).toThrowError(
+ /Dynamically adding and removing validators is not supported/,
+ );
+ });
+
+ it('should throw error when calling clearValidators()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.clearValidators()).toThrowError(
+ /Dynamically adding and removing validators is not supported/,
+ );
+ });
+
+ it('should throw error when calling clearAsyncValidators()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.clearAsyncValidators()).toThrowError(
+ /Dynamically adding and removing validators is not supported/,
+ );
+ });
+
+ it('should throw error when calling setErrors()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.setErrors(null)).toThrowError(
+ /Imperatively setting errors is not supported in signal forms/,
+ );
+ });
+
+ it('should throw error when calling markAsPending()', () => {
+ const form = createSignalFormControl(10);
+ expect(() => form.markAsPending()).toThrowError(
+ /Imperatively marking as pending is not supported in signal forms/,
+ );
+ });
+
+ it('should throw error when setting dirty directly', () => {
+ const form = createSignalFormControl(10);
+ expect(() => ((form as any).dirty = true)).toThrowError(
+ /Setting dirty directly is not supported. Instead use markAsDirty\(\)/,
+ );
+ });
+
+ it('should throw error when setting pristine directly', () => {
+ const form = createSignalFormControl(10);
+ expect(() => ((form as any).pristine = true)).toThrowError(
+ /Setting pristine directly is not supported. Instead use reset\(\)/,
+ );
+ });
+
+ it('should throw error when setting touched directly', () => {
+ const form = createSignalFormControl(10);
+ expect(() => ((form as any).touched = true)).toThrowError(
+ /Setting touched directly is not supported. Instead use markAsTouched\(\) or reset\(\)/,
+ );
+ });
+
+ it('should throw error when setting untouched directly', () => {
+ const form = createSignalFormControl(10);
+ expect(() => ((form as any).untouched = true)).toThrowError(
+ /Setting untouched directly is not supported. Instead use reset\(\)/,
+ );
+ });
+ });
+
+ describe('callback registration', () => {
+ it('should call registered onDisabledChange callback when disabled state changes', () => {
+ const form = createSignalFormControl(10, (p) => {
+ disabled(p, ({value}) => value() > 15);
+ });
+ const callback = jasmine.createSpy('onDisabledChange');
+
+ form.registerOnDisabledChange(callback);
+ TestBed.inject(ApplicationRef).tick();
+
+ expect(callback).toHaveBeenCalledWith(false);
+ callback.calls.reset();
+
+ form.setValue(20);
+ TestBed.inject(ApplicationRef).tick();
+
+ expect(callback).toHaveBeenCalledWith(true);
+ });
+ });
+});
diff --git a/packages/forms/signals/test/node/compat/signal_form_control_in_form_group.spec.ts b/packages/forms/signals/test/node/compat/signal_form_control_in_form_group.spec.ts
new file mode 100644
index 00000000000..c2e00a0c46b
--- /dev/null
+++ b/packages/forms/signals/test/node/compat/signal_form_control_in_form_group.spec.ts
@@ -0,0 +1,395 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import {Injector} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {FormArray, FormControlStatus, FormGroup} from '@angular/forms';
+import {SignalFormControl} from '../../../compat/src/signal_form_control/signal_form_control';
+import {required} from '../../../public_api';
+import {SchemaFn} from '../../../src/api/types';
+
+function createSignalFormControl(value: T, schema?: SchemaFn) {
+ const injector = TestBed.inject(Injector);
+ return new SignalFormControl(value, schema, {injector});
+}
+
+// TODO: Organize this test better
+describe('SignalFormControl in FormGroup', () => {
+ it('should reflect value and value changes', () => {
+ const form = createSignalFormControl(10);
+ const group = new FormGroup({
+ n: form,
+ });
+
+ expect(group.value).toEqual({n: 10});
+ form.setValue(20);
+ expect(group.value).toEqual({n: 20});
+ });
+
+ it('should propagate patchValue updates from child to parent', () => {
+ const form = createSignalFormControl(5);
+ const value = form.sourceValue;
+ const group = new FormGroup({
+ n: form,
+ });
+
+ const emissions: any[] = [];
+ group.valueChanges.subscribe((v) => emissions.push(v));
+
+ form.patchValue(15);
+
+ expect(group.value).toEqual({n: 15});
+ expect(emissions).toEqual([{n: 15}]);
+ expect(form.value).toBe(15);
+ expect(value()).toBe(15);
+
+ form.patchValue(25);
+
+ expect(group.value).toEqual({n: 25});
+ expect(emissions).toEqual([{n: 15}, {n: 25}]);
+ expect(form.value).toBe(25);
+ expect(value()).toBe(25);
+ });
+
+ it('should reflect validity changes', () => {
+ const form = createSignalFormControl(10, (p) => required(p));
+ const group = new FormGroup({
+ n: form,
+ });
+
+ expect(group.status).toBe('VALID');
+
+ const statuses: FormControlStatus[] = [];
+ group.statusChanges.subscribe((status) => statuses.push(status));
+
+ form.setValue(undefined);
+ expect(group.status).toBe('INVALID');
+
+ form.setValue(10);
+ expect(group.status).toBe('VALID');
+
+ expect(statuses).toEqual(['INVALID', 'VALID']);
+ });
+
+ it('should update signal when parent setValue is called', () => {
+ const form = createSignalFormControl(10);
+ const value = form.sourceValue;
+ const group = new FormGroup({
+ n: form,
+ });
+
+ group.setValue({n: 20});
+
+ expect(value()).toBe(20);
+ expect(form.value).toBe(20);
+ });
+
+ it('should update signal when parent patchValue is called', () => {
+ const form = createSignalFormControl(10);
+ const value = form.sourceValue;
+ const group = new FormGroup({
+ n: form,
+ });
+
+ group.patchValue({n: 30});
+
+ expect(value()).toBe(30);
+ expect(form.value).toBe(30);
+ });
+
+ it('should reset child value and state when parent reset is called', () => {
+ const child = createSignalFormControl(10);
+ const value = child.sourceValue;
+ const group = new FormGroup({
+ n: child,
+ });
+
+ child.markAsDirty();
+ child.markAsTouched();
+ expect(child.dirty).toBe(true);
+ expect(child.touched).toBe(true);
+
+ group.reset({n: 50});
+
+ expect(value()).toBe(50);
+ expect(child.dirty).toBe(false);
+ expect(child.touched).toBe(false);
+ });
+
+ it('should mark child as touched when parent markAllAsTouched is called', () => {
+ const form = createSignalFormControl(10);
+ const group = new FormGroup({
+ n: form,
+ });
+
+ expect(form.touched).toBe(false);
+ group.markAllAsTouched();
+ expect(form.touched).toBe(true);
+ });
+
+ it('should mark child as pristine when parent markAsPristine is called', () => {
+ const form = createSignalFormControl(10);
+ const group = new FormGroup({
+ n: form,
+ });
+
+ form.markAsDirty();
+ expect(form.dirty).toBe(true);
+
+ group.markAsPristine();
+
+ expect(form.dirty).toBe(false);
+ expect(form.pristine).toBe(true);
+ });
+
+ it('should mark child as untouched when parent markAsUntouched is called', () => {
+ const form = createSignalFormControl(10);
+ const group = new FormGroup({
+ n: form,
+ });
+
+ form.markAsTouched();
+ expect(form.touched).toBe(true);
+
+ group.markAsUntouched();
+
+ expect(form.touched).toBe(false);
+ });
+
+ it('should include child value in parent getRawValue', () => {
+ const form = createSignalFormControl(10);
+ const group = new FormGroup({
+ n: form,
+ });
+
+ expect(group.getRawValue()).toEqual({n: 10});
+
+ form.setValue(99);
+ expect(group.getRawValue()).toEqual({n: 99});
+ });
+
+ it('should support cross-field validators on parent', () => {
+ const form = createSignalFormControl(10);
+ const group = new FormGroup(
+ {
+ n: form,
+ },
+ {
+ validators: (g) => {
+ const val = g.get('n')?.value;
+ return val > 5 ? null : {min: true};
+ },
+ },
+ );
+
+ expect(group.valid).toBe(true);
+
+ form.setValue(1);
+
+ expect(group.valid).toBe(false);
+ expect(group.errors).toEqual({min: true});
+ });
+
+ it('should allow retrieving child control using get()', () => {
+ const form = createSignalFormControl(10);
+ const group = new FormGroup({
+ n: form,
+ });
+
+ const retrieved = group.get('n');
+ expect(retrieved).toBe(form);
+ expect(retrieved?.value).toBe(10);
+ });
+
+ it('should emit parent statusChanges when child validity changes', () => {
+ const form = createSignalFormControl(10, (p) => required(p));
+ const group = new FormGroup({
+ n: form,
+ });
+
+ const statuses: FormControlStatus[] = [];
+ group.statusChanges.subscribe((s) => statuses.push(s));
+
+ form.setValue(undefined);
+
+ expect(statuses).toContain('INVALID');
+ expect(group.status).toBe('INVALID');
+ });
+
+ it('should pass sourceControl correctly when signal value changes synchronously', () => {
+ const form = createSignalFormControl(10);
+ const group = new FormGroup({
+ n: form,
+ });
+
+ const sourceControls: any[] = [];
+ group.events.subscribe((event: any) => {
+ if (event.source) {
+ sourceControls.push(event.source);
+ }
+ });
+
+ form.fieldTree().value.set(20);
+ expect(sourceControls[0]).toBe(form);
+ });
+
+ it('should not notify parent when onlySelf is true', () => {
+ const form = createSignalFormControl(10);
+ const value = form.sourceValue;
+ const group = new FormGroup({
+ n: form,
+ });
+
+ const parentEmissions: unknown[] = [];
+ group.valueChanges.subscribe((v) => parentEmissions.push(v));
+
+ form.setValue(20, {onlySelf: true});
+
+ expect(parentEmissions.length).toBe(0);
+ expect(group.value).toEqual({n: 10});
+
+ expect(value()).toBe(20);
+ expect(form.value).toBe(20);
+ });
+
+ describe('integration with parent', () => {
+ it('should synchronize value with parent FormGroup immediately', () => {
+ const child = createSignalFormControl('meow');
+ const group = new FormGroup({
+ child: child,
+ });
+
+ child.fieldTree().value.set('wuf');
+ expect(group.value).toEqual({child: 'wuf'});
+ });
+
+ it('should synchronize nested value with parent FormGroup immediately', () => {
+ const child = createSignalFormControl({name: 'pirojok', says: 'meow'});
+ const group = new FormGroup({
+ child: child,
+ });
+
+ child.fieldTree.says().value.set('wuf');
+ expect(group.value).toEqual({child: {name: 'pirojok', says: 'wuf'}});
+ });
+
+ it('should synchronize multiple value sets with parent FormGroup immediately', () => {
+ const child = createSignalFormControl({name: 'a', count: 0});
+ const group = new FormGroup({child});
+
+ child.fieldTree.name().value.set('b');
+ expect(group.value).toEqual({child: {name: 'b', count: 0}});
+
+ child.fieldTree.count().value.set(1);
+ expect(group.value).toEqual({child: {name: 'b', count: 1}});
+
+ child.fieldTree.name().value.set('c');
+ expect(group.value).toEqual({child: {name: 'c', count: 1}});
+
+ child.fieldTree.count().value.set(2);
+ expect(group.value).toEqual({child: {name: 'c', count: 2}});
+ });
+
+ it('should return the same child fieldTree instance on repeated access', () => {
+ const child = createSignalFormControl({name: 'test', count: 0});
+
+ const name1 = child.fieldTree.name;
+ const name2 = child.fieldTree.name;
+
+ expect(name1 === name2).toBe(true);
+ });
+
+ it('should return the same fieldState instance on repeated calls', () => {
+ const child = createSignalFormControl({name: 'test', count: 0});
+
+ const state1 = child.fieldTree();
+ const state2 = child.fieldTree();
+
+ expect(state1 === state2).toBe(true);
+ });
+
+ it('should return the same child fieldState instance on repeated calls', () => {
+ const child = createSignalFormControl({name: 'test', count: 0});
+
+ const state1 = child.fieldTree.name();
+ const state2 = child.fieldTree.name();
+
+ expect(state1 === state2).toBe(true);
+ });
+
+ describe('array fieldTree', () => {
+ it('should access length property', () => {
+ const child = createSignalFormControl(['a', 'b', 'c']);
+
+ expect(child.fieldTree.length).toBe(3);
+ });
+
+ it('should access element children via index', () => {
+ const child = createSignalFormControl(['first', 'second', 'third']);
+
+ expect(child.fieldTree[0]().value()).toBe('first');
+ expect(child.fieldTree[1]().value()).toBe('second');
+ expect(child.fieldTree[2]().value()).toBe('third');
+ });
+
+ it('should set element value via index', () => {
+ const child = createSignalFormControl(['a', 'b', 'c']);
+ const group = new FormGroup({child});
+
+ child.fieldTree[1]().value.set('updated');
+
+ expect(group.value).toEqual({child: ['a', 'updated', 'c']});
+ });
+
+ it('should iterate over object fields', () => {
+ const child = createSignalFormControl({x: 'a', y: 'b', z: 'c'});
+ const values: string[] = [];
+
+ for (const [, field] of child.fieldTree) {
+ values.push(field!().value());
+ }
+
+ expect(values).toEqual(['a', 'b', 'c']);
+ });
+ });
+
+ it('should propagate validity to parent FormGroup immediately', () => {
+ const child = createSignalFormControl('meow-meow', (p) => required(p));
+ const group = new FormGroup({
+ child: child,
+ });
+
+ expect(group.valid).withContext('Valid initially').toBe(true);
+ child.fieldTree().value.set('');
+ expect(group.valid).withContext('Invalid immediately on value change').toBe(false);
+ group.controls.child.setValue('meow');
+ expect(group.valid).withContext('Valid initially').toBe(true);
+ });
+
+ describe('FormArray', () => {
+ it('should synchronize value with parent FormArray immediately', () => {
+ const child = createSignalFormControl('meow');
+ const array = new FormArray([child]);
+
+ child.fieldTree().value.set('wuf');
+ expect(array.value).toEqual(['wuf']);
+ });
+
+ it('should propagate validity to parent FormArray immediately', () => {
+ const child = createSignalFormControl('valid', (p) => required(p));
+ const array = new FormArray([child]);
+
+ expect(array.valid).withContext('Valid initially').toBe(true);
+ child.fieldTree().value.set('');
+ expect(array.valid).withContext('Invalid immediately on value change').toBe(false);
+ array.at(0).setValue('meow');
+ expect(array.valid).withContext('Valid initially').toBe(true);
+ });
+ });
+ });
+});
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
new file mode 100644
index 00000000000..a06514865a3
--- /dev/null
+++ b/packages/forms/signals/test/web/signal_form_control_web.spec.ts
@@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import {Component, Injector, inject, provideZonelessChangeDetection, signal} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
+import {disabled} from '@angular/forms/signals';
+
+import {SignalFormControl} from '../../compat';
+import {FormField} from '../../src/api/form_field_directive';
+
+describe('SignalFormControl (web)', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [provideZonelessChangeDetection()],
+ imports: [ReactiveFormsModule, FormField],
+ });
+ });
+
+ it('binds to formField directive', () => {
+ @Component({
+ standalone: true,
+ imports: [ReactiveFormsModule, FormField],
+ template: ``,
+ })
+ class TestCmp {
+ readonly signalControl = new SignalFormControl('initial', undefined, {
+ injector: inject(Injector),
+ });
+ readonly control = this.signalControl as unknown as FormControl;
+ }
+
+ const fixture = act(() => TestBed.createComponent(TestCmp));
+ const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
+
+ expect(input.value).toBe('initial');
+ act(() => fixture.componentInstance.control.setValue('changed'));
+ expect(input.value).toBe('changed');
+
+ act(() => {
+ input.value = 'view';
+ input.dispatchEvent(new Event('input'));
+ });
+ expect(fixture.componentInstance.signalControl.sourceValue()).toBe('view');
+ });
+
+ it('binds inside nested FormGroup via formGroupName', () => {
+ @Component({
+ standalone: true,
+ imports: [ReactiveFormsModule, FormField],
+ template: `
+