Skip to content

fix(forms): Support Signal Forms in form controls - #17566

Open
rkaraivanov wants to merge 5 commits into
masterfrom
rkaraivanov/fix-17556
Open

fix(forms): Support Signal Forms in form controls#17566
rkaraivanov wants to merge 5 commits into
masterfrom
rkaraivanov/fix-17556

Conversation

@rkaraivanov

Copy link
Copy Markdown
Member

Description

The [formField] interop NgControl exposes signal-backed getters only. It has no statusChanges, valueChanges, validator, markAsTouched or setValue, so igxInput, checkbox, switch, radio group, select, combo, simple combo and the date, time and date range pickers threw on init.

Add NgControlAdapter in core as the single access path to the bound NgControl. It detects the backend and derives the missing observables from a root effect over the signal getters, keeping change detection order identical to the observable case. Controls no longer read NgControl internals directly.

Closes #17556

Type of Change (check all that apply):

  • Bug fix

How Has This Been Tested?

  • Unit tests

Checklist:

  • All relevant tags have been applied to this PR
  • This PR includes unit tests covering all the new code (test guidelines)
  • This PR includes CHANGELOG.MD updates for newly added functionality

The `[formField]` interop `NgControl` exposes signal-backed getters
only. It has no `statusChanges`, `valueChanges`, `validator`,
`markAsTouched` or `setValue`, so igxInput, checkbox, switch, radio
group, select, combo, simple combo and the date, time and date range
pickers threw on init.

Add `NgControlAdapter` in core as the single access path to the bound
`NgControl`. It detects the backend and derives the missing observables
from a root effect over the signal getters, keeping change detection
order identical to the observable case. Controls no longer read
`NgControl` internals directly.

Closes #17556
@rkaraivanov
rkaraivanov requested review from ChronosSF and a lite review from Copilot September 2, 2026 13:52
@rkaraivanov rkaraivanov added ❌ status: awaiting-test PRs awaiting manual verification forms forms: validation Forms validation related, including ngModel.status aka VALID/INVALID/TOUCHED/PRISTINE etc. signal-forms labels Sep 2, 2026
@rkaraivanov rkaraivanov added the squash-merge Merge PR with "Squash and Merge" option label Sep 2, 2026

Copilot AI left a comment

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.

🔵 Needs a closer look

It introduces a new core abstraction (NgControlAdapter) and updates multiple form controls’ initialization/validation wiring, which warrants final human review despite the added tests.

Pull request overview

This PR fixes initialization/runtime failures when Ignite UI form controls are bound via Angular Signal Forms ([formField]) by routing all NgControl access through a new adapter that normalizes missing observable APIs and control methods in the signal-backed interop control.

Changes:

  • Added NgControlAdapter in core to provide a single, backend-aware access path for NgControl (observable vs signal).
  • Updated multiple form controls (input, checkbox/switch base, radio group, select, combo/simple-combo, date/time pickers) to use the adapter instead of reading NgControl internals directly.
  • Added Signal Forms-focused unit tests for the affected components and updated docs/README/CHANGELOG to document the new compatibility.
File summaries
File Description
skills/igniteui-angular-components/references/form-controls.md Documents how to use Ignite UI controls with Signal Forms via [formField].
projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts Uses NgControlAdapter for required/validity/status handling under Signal Forms.
projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts Adds Signal Forms coverage for required/invalid/disabled behaviors.
projects/igniteui-angular/switch/src/switch/switch.component.spec.ts Adds Signal Forms coverage for required/invalid/disabled behaviors.
projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts Adds Signal Forms coverage for required/invalid/disabled behaviors.
projects/igniteui-angular/select/src/select/select.component.ts Switches status/required/validity logic to the adapter for Signal Forms support.
projects/igniteui-angular/select/src/select/select.component.spec.ts Adds Signal Forms coverage for required/invalid/disabled behaviors.
projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts Uses the adapter to safely consume status/required/validators under Signal Forms.
projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts Adds Signal Forms coverage for required/invalid behaviors.
projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.ts Uses the adapter for status/value/touched tracking and write/touch interop.
projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.spec.ts Adds Signal Forms coverage for required/invalid/disabled/reset behaviors.
projects/igniteui-angular/input-group/README.md Notes igxInput compatibility with Signal Forms ([formField]).
projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts Switches checkbox/switch validity & required resolution to the adapter.
projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts Uses the adapter for status/required/validity + signal-backend revalidation hook.
projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts Adds Signal Forms coverage for required/invalid/disabled behaviors.
projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker-inputs.common.ts Uses adapter-based setValue handling to support signal-backend “ignored write” semantics.
projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts Switches status/required/validity logic to the adapter for Signal Forms support.
projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts Adds Signal Forms coverage for required/invalid/disabled behaviors.
projects/igniteui-angular/core/src/public_api.ts Exports the new NgControlAdapter from the core public API.
projects/igniteui-angular/core/src/core/ng-control-adapter.ts Introduces NgControlAdapter and signal-backed observable derivations via root effects.
projects/igniteui-angular/combo/src/combo/combo.component.spec.ts Adds Signal Forms coverage for required/invalid/disabled behaviors.
projects/igniteui-angular/combo/src/combo/combo.common.ts Switches combo validity/required/status wiring to use the adapter.
projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts Adds Signal Forms coverage for required/invalid/disabled behaviors.
CHANGELOG.md Adds an Unreleased entry documenting Signal Forms compatibility across form controls.
Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +291 to 295
if (this.control) {
this._statusChanges$ = this.control.statusChanges.subscribe(this.onStatusChanged.bind(this));
this._valueChanges$ = this.control.valueChanges.subscribe(this.onValueChanged.bind(this));
this._touchedChanges$ = this.control.touchedChanges.subscribe(this.updateValidityState.bind(this));
}
@viktorkombov viktorkombov added 💥 status: in-test PRs currently being tested and removed ❌ status: awaiting-test PRs awaiting manual verification labels Sep 8, 2026

@viktorkombov viktorkombov left a comment

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.

Tested on a zoneless demo page (provideZonelessChangeDetection()) with [formField] on each of the ten controls. All six also reproduce under zone.js, so none of them are zoneless-specific.

Two blocking:

  • Checkbox / switch / radio throw on any validator that reads control.value, because required detection now probes with {}. Reactive Forms path, no Signal Forms involved. Confirmed as a regression by reverting the two files to master individually.
  • Radio group gets stuck disabled under Signal Forms — it disables but never re-enables. Looks pre-existing rather than introduced here, but this is what makes it reachable.

Four more, not blocking:

  • The conditional required marker goes stale on select, combo and both pickers. It corrects on the user's first interaction with the field, then sticks for good.
  • A satisfied custom rule never reaches VALID, where Reactive Forms do.
  • The date range picker ends up drawing the asterisk for a rule that is switched off. Half of that one is pre-existing and hits Reactive Forms too.
  • Validators.requiredTrue now counts as required on checkbox/switch/radio where master said no.

Also checked, and clean, so nobody re-treads them:

  • the adapter's effects tear down on @if destruction and repeated mount/unmount, with no leaked emissions
  • switching [formField] between fields at runtime
  • submit() marking untouched fields touched
  • clear() writing back to the model
  • the date range picker's projected two-input write fallback, on both backends
  • rapid model updates coalescing into one transition

* Signal Forms `submit()` only marks fields touched, so touched changes count too
* or the errors would never surface.
*/
public get statusChanges(): Observable<unknown> {

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.

required isn't in the watched tuple, so a rule that flips while the field stays valid produces no emission and onStatusChanged doesn't run.

required on              igx-input-group--required = false   (stale)
first interaction        igx-input-group--required = true    (catches up)
required off             igx-input-group--required = true    (stale again)
any further interaction  igx-input-group--required = true    (stuck)

It corrects on the next transition of valid, invalid, pending, disabled, dirty or touched. In practice that's the user's first interaction, which flips touched and dirty.

Both of those are one-way, so nothing refreshes it after that. A programmatic value change doesn't help either.

Same on combo, date picker and time picker. igxInput only goes stale on aria-required, since its asterisk comes through the directive's own @Input.

Specific to the signal backend added here — the Reactive Forms path still emits through updateValueAndValidity() and is untouched.

}

/** Signal Forms expose no validator list, only `required` and the current errors. */
public get hasValidators(): boolean {

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.

required || invalid — a valid, non-required field with a custom rule is neither. So it reports no validators, and updateValidityState drops to INITIAL where Reactive Forms give VALID.

To see it: use validate(p.role, ...) instead of required(), then satisfy it while the control has focus. The success state never appears. Same for a resolved validateAsync.

The observable branch below keeps the old validator || asyncValidator check, so this is specific to the signal backend added here.

Note the check sits inside the touchedOrDirty branch, which makes the result order-dependent.

Validators.required
);
if (this.control.hasValidators) {
this._required = this.control.required;

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.

NgControlAdapter.required probes the validator with a bare {}, so every custom validator gets a control with value === undefined. Anything that dereferences the value throws:

const v = (c: AbstractControl): ValidationErrors | null =>
    (c.value as string[]).length === 0 ? { empty: true } : null;

form = new FormGroup({ accepted: new FormControl<unknown>([], v) });
<igx-checkbox formControlName="accepted">Accept</igx-checkbox>

TypeError: Cannot read properties of undefined (reading 'length') out of ngAfterViewInit, and change detection stops at that control. Master returns false for the same setup. radio-group.directive.ts:520 has the same change and the same failure.

if (this._ngControl) {
this._statusChanges$ = this._ngControl.statusChanges!.subscribe(this.onStatusChanged.bind(this));
if (this._control) {
this._statusChanges$ = this._control.statusChanges.subscribe(this.onStatusChanged.bind(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.

The asterisk is CSS on .igx-input-group--required, and that class is a host binding over inputGroup.isRequired, so the value has to be written and then painted. Two things go wrong.

Cause one: this subscription doesn't emit for a required-only change. onStatusChanged never runs, setRequiredToInputs() (line 938) isn't called, and nothing is written.

Cause two: when something else does make it run, it writes inside a Promise.resolve().then() (line 1078) with no markForCheck(). The write sits unpainted until a later pass — by which point the rule may have flipped back:

                    isRequired written      --required painted
required on         false, false, false     false, false, false
fields touched      true,  true,  true      false, false, false
required off        true,  true,  true      true,  true,  true

On where these come from. The first is new, since it's the signal backend of the adapter this PR adds. The second is not — it reproduces on the plain Reactive Forms path with no Signal Forms involved, which is worth knowing on its own:

ctrl.addValidators(Validators.required);
ctrl.updateValueAndValidity();
// isRequired written = true, --required painted = false, and it never paints

So fixing the adapter won't clear this one. With the adapter watching required, the marker still didn't render until a markForCheck() went in.

Two reasons the new spec stays green:

  • its setup is detectChanges(); tick(); detectChanges();, which manually does the job of the missing markForCheck()
  • the test component uses required(path.range) with no when, so the rule is on from the start and the initial emission covers it

Validators.required
);
if (this.control.hasValidators) {
this._required = this.control.required;

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.

Side effect of the same change: checkbox, switch and radio now report required = true and aria-required="true" for Validators.requiredTrue, where master reported false. igxInput already reported true on master, so this aligns them. Flagging in case it should be in the CHANGELOG.

@viktorkombov

Copy link
Copy Markdown
Contributor

radio-group.directive.ts:524, inside initialize():

if (this.ngControl!.disabled) {
    button.disabled = this.ngControl!.disabled;
}

The write is guarded by the condition it's writing, so it only ever sets true:

isDisabled = signal(false);
f = form(this.model, p => disabled(p.season, { when: () => this.isDisabled() }));

Set isDisabled true, then false. IgxRadioComponent.disabled stays true on every button.

Not introduced here — IgxRadioGroupDirective has no setDisabledState, so with Reactive Forms control.disable() never reaches the buttons at all, and initialize() sits in an effect() that doesn't re-run for a non-signal ngControl.disabled. Signal Forms make that effect reactive, which is what surfaces the latch.

Also, going into disabled the native <input> stays clickable even though the component flag is true. The group writes the property without marking anything dirty, so an OnPush host never gets checked.

@viktorkombov

Copy link
Copy Markdown
Contributor
  • input.directive.ts:294 — master had if (this.ngControl.control) around this. It's unconditional now, and touchedChanges does this.ngControl.control!.events. Was the guard safe to drop, or can control still be null at ngAfterViewInit?
  • radio-group.directive.ts:516subscribe(() => this.invalid = false). The signal backend emits an initial value where the observable one emits nothing, so invalid gets wiped at a point master didn't wipe it.
  • A field in PENDING renders as INVALID on both backends. Pre-existing and unchanged here, but Signal Forms inherit it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💥 status: in-test PRs currently being tested forms: validation Forms validation related, including ngModel.status aka VALID/INVALID/TOUCHED/PRISTINE etc. forms signal-forms squash-merge Merge PR with "Squash and Merge" option

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Signal Forms interop NgControl causes Ignite UI Angular form control initialization failure

5 participants