You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
refactor(checkbox,switch,radio): migrate to signals and OnPush - #17604
Internal state is signal-backed. checked, disabled, readonly, indeterminate, invalid, focused, required, id, labelId, value, name, tabindex, labelPosition, disableRipple, ariaLabelledBy and ariaLabel read and write signal()s behind their existing accessors.
Derived state is memoized with computed(), and the templates read the backing signals directly.
CheckboxBaseDirective stays abstract. disabled is now an abstract accessor that each control implements, so the radio can combine its own disabled input with the group's form control state.
The destroy$/takeUntil pair around ngControl.statusChanges is replaced with takeUntilDestroyed(). destroy$ was never completed, so it never actually fired.
Radio group
State is signal-backed: value, selected, name, required, invalid, disabled and alignment. The --before and --disabled host classes are computed() over the registered buttons, and every host binding and listener lives in host: {} metadata.
Buttons register synchronously in the radio's ngOnInit and get the group's name, required, disabled state and selection immediately. This replaces the state-writing effect() and the Promise.resolve() push.
Form control wiring runs once, in ngAfterContentInit. Signal Forms keep required in sync at runtime.
Each radio's tab order is a computed() over the group's checked button (roving tabindex). This replaces ngDoCheck, which wrote tabIndex on every change detection pass and fought the template binding.
Radios call the group directly for selection, blur, keyup and value changes. The group no longer subscribes to radio events.
Public API is unchanged. Every @Input()/@Output() keeps its name, alias, type and transform, so radio.checked = true, group.value = x and [checked]="x" behave exactly as before. There is no input()/output()/model() conversion. The only removals are @hidden @internal members: the radio's blurRadio and groupDisabled, and the group's cssClass, ngDoCheck and ngOnDestroy.
Bug fixes (IgxRadioGroupDirective)
Arrow-key navigation follows the rendered order of the radio buttons, including buttons inserted in the middle of an @for or created with ViewContainerRef.createComponent(). This applies in the browser only; elsewhere the order of registration is kept.
selected is cleared when value is null or matches no radio button.
Only the checked radio is in the tab order from the first render, and every radio gets its own tabindex back when the value is cleared.
Radio buttons removed from the group are no longer kept subscribed to for the group's lifetime.
Deliberately left alone
required stays a getter rather than becoming a computed(). It falls back to nativeElement.hasAttribute('required'), which isn't part of the reactive graph, so memoizing it would go stale and silently drop the fallback.
labelId and ariaLabelledBy keep their snapshot semantics: they capture id once on initialization rather than deriving from it.
The group still pushes name, required, invalid and checked into its radios instead of the radios deriving them. Deriving would change what radio.name or radio.checked return after a consumer sets them directly.
Motivation / Context
Part of the ongoing move to signals and away from zone-based change detection.
Eager (CheckAlways) meant these components were re-checked on every change detection pass across the whole application, and the radio group relied on ngDoCheck and markForCheck() to keep its children in sync. Backing the state with signals is what makes OnPush and zoneless safe here.
The constraint was no public API changes and no breaking changes, which is why the @Input()/@Output() decorators and accessor shapes are preserved rather than converted to signal inputs.
Type of Change (check all that apply):
Bug fix
New functionality
Breaking change (fix or feature that would cause existing functionality to not work as expected)
Replace the plain backing fields of the shared checkbox/switch/radio base
directive with Angular signals, grouped at the top of the class so that each
JSDoc block documents the public accessor rather than the backing field.
The public API is unchanged: every member keeps its @input()/@output()
decorator and plain property shape, so `checkbox.checked = true` and
[checked]="x" behave exactly as before.
Also:
- replace the destroy$/takeUntil cleanup around ngControl.statusChanges with
takeUntilDestroyed(); destroy$ was never completed, so it never fired
- move the @HostBinding/@HostListener declarations into the decorator's host
metadata
- expose destroyRef so a parent can scope subscriptions to a single instance
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switch from ChangeDetectionStrategy.Eager to OnPush, now that the state the
component renders is signal-backed and marks the view dirty on every write.
Also moves the @HostBinding declarations into the decorator's host metadata.
The public API is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switch from ChangeDetectionStrategy.Eager to OnPush, now that the state the
component renders is signal-backed and marks the view dirty on every write.
Also moves the @HostBinding declarations into the decorator's host metadata.
The public API is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switch from ChangeDetectionStrategy.Eager to OnPush, now that the state the
component renders is signal-backed and marks the view dirty on every write.
Also moves the @HostBinding/@HostListener declarations into the decorator's
host metadata.
The public API is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Convert the ariaChecked and labelClass getters to memoized computed() signals
and read the backing signals directly from the templates.
Both getters ran once per binding on every dirty change-detection pass, with
labelClass allocating a new string each time. As computed() they recompute
only when their dependencies change: measured 10 -> 0 recomputations over 10
dirty passes with labelPosition unchanged.
The public ariaChecked/labelClass getters are removed. Both were marked
@hidden@internal and were referenced only by these three templates.
required deliberately stays a getter: it falls back to
nativeElement.hasAttribute('required'), which is not part of the reactive
graph, so a computed() would go stale and drop the fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_setRadioButtonEvents subscribed to each button's change/blurRadio/keyup
streams but only tore them down when the whole group was destroyed, so
cycling buttons through a structural directive accumulated subscriptions for
the lifetime of the group.
The existing takeUntil(button.destroy$) never fired - destroy$ was declared on
CheckboxBaseDirective but never completed - so the intended per-button cleanup
was inert. Scope the subscriptions to the button's own DestroyRef instead, and
cover it with a regression test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Document the OnPush switch for the checkbox, switch and radio components
under Behavioral Changes, and the radio group's per-button subscription
cleanup under Bug Fixes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
value = null does not always clear selected: when _value is already null (for example, after assigning selected to a radio whose value is null), this guard skips _selectRadioButton() and leaves that radio selected even though null is defined as the cleared state. Run the synchronization for null even when the stored value is unchanged.
Keep Unreleased as the leading changelog section
CHANGELOG.md:5
Unreleased was the leading changelog section, but this inserts a versioned section above it. That reverses the established chronology and separates these entries from the other pending changes; keep Unreleased first, moving this section below it only as part of a release cut (otherwise merge these entries into it).
I tested the PR in a zone-based and a zoneless app. The tests pass and the grid, tree and select still work fine with these controls. However, I found a few issues that seem to be regressions:
Two radios checked.<igx-radio-group value="Large"> with @for (item of items(); track $index). Change items from ['Small', 'Medium', 'Large'] to ['Medium', 'Large', 'XL']. Both Large and XL are checked.
An effect reverts the user's click.effect(() => checkbox.checked = accepted()), where accepted never changes. Click the checkbox and it unchecks itself. Same with group.value. The setters read their own signals, wrapping them in untracked() fixes it.
selected = null doesn't clear the value. Render A, B, C with @for, select C, remove it from the list and set group.selected = null. group.value is still 'C', and C is checked again when added back.
Slow with many radios. 2,000 radios in one group take about 9 s to render, vs 0.6 s on master (dev build with ng serve, headless Chrome 151, same machine for both).
change subscribers see the old value. Subscribe to a radio's change after init and click it. In the handler group.value is still the previous value.
aria-required stays true. A radio with its own form control that has a non-required validator (e.g. one that always fails), inside <igx-radio-group [required]="required()">. Set required to false and the input keeps aria-required="true".
The radio's own control wins over the group. A radio whose form control has Validators.required is required, even though the group isn't.
Focus ring stays after Tab. Put a link in an unchecked radio's label, click the checked radio and press Tab. Focus goes to the link, but the checked radio keeps its focus ring.
Also, subclasses that redeclare these properties as fields, use cdr or call super.ngDoCheck() don't compile anymore, but the changelog says the public API is unchanged.
All of them are in a sample in the attached patch. Apply it on this branch with git apply checkbox-radio-review-sample.patch, run npm start and open /checkbox-radio-review. A red status line means the issue is showing.
The radio emitted change before the group updated, so subscribers saw
the previous group value. Update the group state first, then notify
the radio (change, onChange), then the group, like a native radio.
A keyup from a focusable element in the label, such as a link, bubbled
to the radio host and marked the checked radio as focused. Only treat
keyups from the radio's input or host as keyboard focus.
I tested the PR in a zone-based and a zoneless app. The tests pass and the grid, tree and select still work fine with these controls. However, I found a few issues that seem to be regressions:
Two radios checked.<igx-radio-group value="Large"> with @for (item of items(); track $index). Change items from ['Small', 'Medium', 'Large'] to ['Medium', 'Large', 'XL']. Both Large and XL are checked.
An effect reverts the user's click.effect(() => checkbox.checked = accepted()), where accepted never changes. Click the checkbox and it unchecks itself. Same with group.value. The setters read their own signals, wrapping them in untracked() fixes it.
selected = null doesn't clear the value. Render A, B, C with @for, select C, remove it from the list and set group.selected = null. group.value is still 'C', and C is checked again when added back.
Slow with many radios. 2,000 radios in one group take about 9 s to render, vs 0.6 s on master (dev build with ng serve, headless Chrome 151, same machine for both).
change subscribers see the old value. Subscribe to a radio's change after init and click it. In the handler group.value is still the previous value.
aria-required stays true. A radio with its own form control that has a non-required validator (e.g. one that always fails), inside <igx-radio-group [required]="required()">. Set required to false and the input keeps aria-required="true".
The radio's own control wins over the group. A radio whose form control has Validators.required is required, even though the group isn't.
Focus ring stays after Tab. Put a link in an unchecked radio's label, click the checked radio and press Tab. Focus goes to the link, but the checked radio keeps its focus ring.
Also, subclasses that redeclare these properties as fields, use cdr or call super.ngDoCheck() don't compile anymore, but the changelog says the public API is unchanged.
All of them are in a sample in the attached patch. Apply it on this branch with git apply checkbox-radio-review-sample.patch, run npm start and open /checkbox-radio-review. A red status line means the issue is showing.
@viktorkombov
2. Angular discourages using effects to push state into components, however, I fixed it because it is valid code and somebody might do it.
3. I'm not fully convinced that selected = null should clear the group's value. I see selected as only "which radio button is currently checked", so when no button is checked, selected = null would be a no-op, and you'd set value = null to drop a pending value. But master clears the value in this case, so I've kept that behavior to avoid a regression.
I've updated the CHANGELOG with a Breaking Changes section about subclasses that no longer compile.
The other issues are fixed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related:
#17635 #17633 #17634
Description
Checkbox, switch, radio and
CheckboxBaseDirectivechecked,disabled,readonly,indeterminate,invalid,focused,required,id,labelId,value,name,tabindex,labelPosition,disableRipple,ariaLabelledByandariaLabelread and writesignal()s behind their existing accessors.computed(), and the templates read the backing signals directly.CheckboxBaseDirectivestaysabstract.disabledis now an abstract accessor that each control implements, so the radio can combine its owndisabledinput with the group's form control state.destroy$/takeUntilpair aroundngControl.statusChangesis replaced withtakeUntilDestroyed().destroy$was never completed, so it never actually fired.Radio group
value,selected,name,required,invalid, disabled and alignment. The--beforeand--disabledhost classes arecomputed()over the registered buttons, and every host binding and listener lives inhost: {}metadata.ngOnInitand get the group'sname,required, disabled state and selection immediately. This replaces the state-writingeffect()and thePromise.resolve()push.ngAfterContentInit. Signal Forms keeprequiredin sync at runtime.computed()over the group's checked button (roving tabindex). This replacesngDoCheck, which wrotetabIndexon every change detection pass and fought the template binding.Public API is unchanged. Every
@Input()/@Output()keeps its name, alias, type and transform, soradio.checked = true,group.value = xand[checked]="x"behave exactly as before. There is noinput()/output()/model()conversion. The only removals are@hidden @internalmembers: the radio'sblurRadioandgroupDisabled, and the group'scssClass,ngDoCheckandngOnDestroy.Bug fixes (
IgxRadioGroupDirective)@foror created withViewContainerRef.createComponent(). This applies in the browser only; elsewhere the order of registration is kept.selectedis cleared whenvalueisnullor matches no radio button.tabindexback when the value is cleared.Deliberately left alone
requiredstays a getter rather than becoming acomputed(). It falls back tonativeElement.hasAttribute('required'), which isn't part of the reactive graph, so memoizing it would go stale and silently drop the fallback.labelIdandariaLabelledBykeep their snapshot semantics: they captureidonce on initialization rather than deriving from it.name,required,invalidandcheckedinto its radios instead of the radios deriving them. Deriving would change whatradio.nameorradio.checkedreturn after a consumer sets them directly.Motivation / Context
Part of the ongoing move to signals and away from zone-based change detection.
Eager(CheckAlways) meant these components were re-checked on every change detection pass across the whole application, and the radio group relied onngDoCheckandmarkForCheck()to keep its children in sync. Backing the state with signals is what makes OnPush and zoneless safe here.The constraint was no public API changes and no breaking changes, which is why the
@Input()/@Output()decorators and accessor shapes are preserved rather than converted to signal inputs.Type of Change (check all that apply):
Component(s) / Area(s) Affected:
IgxCheckboxComponent,IgxSwitchComponent,IgxRadioComponent,IgxRadioGroupDirective,CheckboxBaseDirectiveHow Has This Been Tested?
Test Configuration:
Checklist:
feature/README.MDupdates for the feature docsREADME.MDCHANGELOG.MDupdates for newly added functionalityng updatemigrations for the breaking changes (migrations guidelines)