Skip to content

refactor(checkbox,switch,radio): migrate to signals and OnPush - #17604

Draft
mddragnev wants to merge 8 commits into
masterfrom
mdragnev/checkbox-signals-migration
Draft

mddragnev wants to merge 8 commits into
masterfrom
mdragnev/checkbox-signals-migration

Conversation

@mddragnev

@mddragnev mddragnev commented Sep 14, 2026

Copy link
Copy Markdown
Member

Related:
#17635 #17633 #17634

Description

Migrates IgxCheckboxComponent, IgxSwitchComponent, IgxRadioComponent and their shared CheckboxBaseDirective to Angular signals, and switches all three components from ChangeDetectionStrategy.Eager to OnPush.

  • Internal state is signal-backed. checked, disabled, readonly, indeterminate, invalid, focused, required, id, labelId, value, name, tabindex, labelPosition, disableRipple, ariaLabelledBy and ariaLabel now read and write signal()s behind their existing accessors.
  • The public API is unchanged. Every component keeps its @Input()/@Output() decorator and plain property shape, so checkbox.checked = true and [checked]="x" behave exactly as before. There is no input()/output()/model() conversion.
  • OnPush. Signal writes mark the view dirty, so these components are no longer re-checked on every application-wide change detection pass.
  • Derived state is memoized using computed
  • Templates read the backing signals directly.
  • RxJS plumbing modernised. The destroy$/takeUntil pair around ngControl.statusChanges is replaced with takeUntilDestroyed(). destroy$ was never completed anywhere, so it never actually fired.
  • Bug fix. IgxRadioGroupDirective now releases its subscriptions to a radio button when that button is destroyed, rather than holding them until the whole group is destroyed.

Deliberately left alone

required stays a getter rather than becoming a computed(): it falls back to nativeElement.hasAttribute('required'), which is not part of the reactive graph, so memoizing it would go stale and silently drop the fallback.

labelId and ariaLabelledBy keep their existing snapshot semantics — they capture id once on initialization rather than deriving from it.

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. Backing the state with signals is what makes OnPush 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)
  • Refactoring (no functional changes)
  • Documentation
  • Demos
  • CI/CD
  • Tests
  • Changelog
  • Skills/Agents

Component(s) / Area(s) Affected:

IgxCheckboxComponent, IgxSwitchComponent, IgxRadioComponent, IgxRadioGroupDirective, CheckboxBaseDirective

How Has This Been Tested?

  • Unit tests
  • Manual testing
  • Automated e2e tests

Test Configuration:

  • Angular version: 22.1.1
  • Browser(s): Chrome Headless 152
  • OS: macOS

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 API docs for newly added methods/properties (api docs guidelines)
  • This PR includes feature/README.MD updates for the feature docs
  • This PR includes general feature table updates in the root README.MD
  • This PR includes CHANGELOG.MD updates for newly added functionality
  • This PR contains breaking changes
  • This PR includes ng update migrations for the breaking changes (migrations guidelines)
  • This PR includes behavioral changes and the feature specification has been updated with them
  • Accessibility (ARIA, keyboard navigation, focus management) has been verified

mddragnev and others added 7 commits September 14, 2026 13:28
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant