Skip to content

Add SignalFormControl for bottom-up migration to Signal Forms#66614

Merged
leonsenft merged 20 commits intoangular:mainfrom
kirjs:compat5
Feb 2, 2026
Merged

Add SignalFormControl for bottom-up migration to Signal Forms#66614
leonsenft merged 20 commits intoangular:mainfrom
kirjs:compat5

Conversation

@kirjs
Copy link
Copy Markdown
Contributor

@kirjs kirjs commented Jan 16, 2026

This would allow create something the looks like a FormControl and can be integrated with a FormGroup.

  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,
  });

Note: I asked a robot to break this code in sizeable commits

That said, I'm very happy to finally introduce at least some kind of SFCs to Angular 🤣

@angular-robot angular-robot Bot added detected: feature PR contains a feature commit area: forms labels Jan 16, 2026
@ngbot ngbot Bot added this to the Backlog milestone Jan 16, 2026
@kirjs kirjs requested review from leonsenft and mmalerba and removed request for leonsenft January 16, 2026 20:06
@pullapprove pullapprove Bot requested a review from atscott January 16, 2026 20:20
: compatForm(this.sourceValue, {injector});
this.fieldState = this.fieldTree();

Object.defineProperty(this, 'value', {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are these defined like this instead of just a normal getter property?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be fixed in #66672

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we land #66672 first and update this PR accordingly?

}

override getRawValue(): T {
return this.value;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should either throw an error or properly filter out disabled fields for this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the question here. What's the issue?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in reactive forms getRawValue was supposed to be the value with disabled fields filtered out? (At least that's what I remember from the previous meeting where this was discussed). So if that's the case it feels like a lie to just return the full value without filtering out the disabled fields

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed that, and since this is a control, not a group, we should not take disabled into account

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove the TODO comment now.

Comment thread packages/forms/signals/src/api/rules/validation/validation_errors.ts Outdated
@pullapprove pullapprove Bot requested a review from crisbeto January 16, 2026 20:57
Comment thread packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts Outdated
Comment thread packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts Outdated
Comment thread adev/src/content/guide/forms/signals/migration.md
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jan 20, 2026

Deployed adev-preview for bf6c3c5 to: https://ng-dev-previews-fw--pr-angular-angular-66614-adev-prev-5cqju58f.web.app

Note: As new commits are pushed to this pull request, this link is updated after the preview is rebuilt.

Comment thread packages/forms/signals/src/api/rules/validation/validation_errors.ts Outdated
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions, options?: FormOptions) {
super(null, null);

const [model, schema, opts] = normalizeFormArgs<T>([signal(value), schemaOrOptions, options]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we take a value–from which we create a signal–rather than a signal like the form() function?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because we need to immediately update the value instead of using effect (hence patching).
I added a TODO to document it better in the markdown.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry I don't follow. Where are we immediately updating the value?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we own the value, we can wrap it with wrapFieldTreeForSyncUpdates below, which does it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We pass rawTree to wrapFieldTreeForSyncUpdates, which is derived from sourceValue which is signal(value). Why can't sourceValue just be a signal that the user provides? Does something break? Or is it a conceptual issue of ownership?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to never expose the source signal, this way people would only be able to change values through the form, where we'd react to changes immediately.

If we took a signal, we woudn't be able to react to changes

Comment thread packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts Outdated
Comment thread packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts Outdated
Comment thread packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts Outdated
Comment thread adev/src/content/guide/forms/signals/migration.md Outdated
Comment thread packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts Outdated
Comment thread packages/forms/signals/test/node/compat/signal_form_control_in_form_group.spec.ts Outdated
Comment thread goldens/public-api/forms/signals/index.api.md Outdated
}

override getRawValue(): T {
return this.value;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the question here. What's the issue?

@kirjs kirjs force-pushed the compat5 branch 3 times, most recently from e87c138 to 35e7840 Compare January 26, 2026 16:29
@kirjs kirjs requested review from leonsenft and mmalerba January 26, 2026 16:29
: compatForm(this.sourceValue, {injector});
this.fieldState = this.fieldTree();

Object.defineProperty(this, 'value', {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we land #66672 first and update this PR accordingly?

Comment thread packages/forms/signals/src/compat/validation_errors.ts
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions, options?: FormOptions) {
super(null, null);

const [model, schema, opts] = normalizeFormArgs<T>([signal(value), schemaOrOptions, options]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry I don't follow. Where are we immediately updating the value?

}

override getRawValue(): T {
return this.value;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove the TODO comment now.

Comment thread packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts Outdated
```

<!-- TODO: include some high level usage comment about how people should mostly interact with this via the signal forms API exposed on .fieldTree, not via the reactive forms methods. -->
<!-- TODO: Elaborate on why the value taken is not a signal. -->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these TODOs for this PR or the future? If not this PR, should they be added as items to our project?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be the next thing i'd do, I just want to get the big parts in first

@kirjs kirjs force-pushed the compat5 branch 2 times, most recently from 90aa71c to a0c5a05 Compare January 30, 2026 20:13
kirjs added 18 commits January 30, 2026 17:45
- Add dirty, pristine, touched, untouched getters
- Add markAsTouched, markAsDirty methods
- Add markAsPristine, markAsUntouched methods (preserve other state)
- Add reset() method with optional value parameter
- Support FormControlState unboxing ({value, disabled} format)
- Add isFormControlState helper function
- Add valueChanges EventEmitter that emits when source signal changes
- Add statusChanges EventEmitter that emits when status changes
- Set up effects to emit to observables
- Emit ValueChangeEvent, StatusChangeEvent on changes
- Emit TouchedChangeEvent, PristineChangeEvent on status changes
- Emit FormResetEvent on reset()
- Add emitControlEvent helper method
- Add ValueUpdateOptions type with onlySelf, emitEvent options
- Add parent notification on value changes via effect
- Add parent notification helpers: scheduleParentUpdate, notifyParentUnlessPending
- Propagate dirty/touched/pristine/untouched to parent
- Support onlySelf option to prevent parent propagation
- Add CachingWeakMap utility for memoization
- Add wrapFieldTreeForSyncUpdates Proxy wrapper
- Intercept fieldTree().value.set() calls to sync parent immediately
- Cache wrapped trees and states to preserve identity
- Add registerOnChange, _unregisterOnChange for value change callbacks
- Add registerOnDisabledChange, _unregisterOnDisabledChange for disabled callbacks
- Add disabled changes effect to notify registered callbacks
- Call onChange callbacks from updateValue with emitModelEvent flag
- Add disable, enable methods that throw with helpful messages
- Add validator methods (set/add/remove/clear) that throw
- Add setErrors, markAsPending methods that throw
- Add setters for dirty/pristine/touched/untouched that throw
- Add JSDoc with @usageNotes examples
- Add comprehensive unit tests for SignalFormControl
- Add FormGroup/FormArray integration tests
- Add web tests for CVA directive lifecycle
- Update migration docs with SignalFormControl usage
Consolidate everything related to converting errors in one place
Make them tree-shakeable and fix the naming
- Add more comments and docs
- In signalErrorsToValidationErrors return null for empty object
- Drop messages in prod mode
Add some TODOs for future things
Document that injectors are optional
Untrack callbacks, so they are not called when signals change
Make the way reset works for it to be more consistent
Clean up tests, drop old todos
We have to do this because Abstract control doesn't allow us to have value as a getter type-wise
@kirjs kirjs added action: merge The PR is ready for merge by the caretaker target: patch This PR is targeted for the next patch release target: minor This PR is targeted for the next minor release and removed target: patch This PR is targeted for the next patch release labels Jan 30, 2026
Copy link
Copy Markdown
Contributor

@leonsenft leonsenft left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed-for: public-api

@leonsenft leonsenft merged commit c750b3c into angular:main Feb 2, 2026
24 checks passed
@leonsenft
Copy link
Copy Markdown
Contributor

This PR was merged into the repository. The changes were merged into the following branches:

@angular-automatic-lock-bot
Copy link
Copy Markdown

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot Bot locked and limited conversation to collaborators Mar 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

action: merge The PR is ready for merge by the caretaker adev: preview area: forms detected: feature PR contains a feature commit target: minor This PR is targeted for the next minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants