-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(angular): Migrate to signal input functions #24777
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Conversation
Replaces traditional @input() properties with Angular's new input() signal API in multiple components, directives, and templates. Updates all usages to call input signal functions, ensuring compatibility and reactivity. This change modernizes the codebase, improves type safety, and prepares for future Angular features.
Replaces @input with Angular's new signal-based input and effect for reactive updates. Simplifies lifecycle management by removing OnInit and handling input changes via effect, improving code clarity and reactivity.
Replaces HostBinding and getter methods with Angular signals and computed properties for class, style, and title bindings. Improves reactivity and code clarity by leveraging Angular's new reactive primitives.
Migrates multiple components and directives to use Angular's new input() and signal() APIs, replacing legacy @input and @output decorators. Updates templates to use signal-based accessors, improves reactivity with effect(), and modernizes event emitters with output(). This refactor enhances type safety, consistency, and future-proofs the codebase for upcoming Angular versions.
Refactors extensible form, table, and related components to use Angular's signal-based input() API instead of @input and ngOnChanges. Updates internal state management to use signals and effects, improves type safety with ReadonlyPropData, and updates templates to use new signal-based accessors. Removes legacy OnChanges logic and adapts utility and directive code to the new reactive paradigm.
Migrates Tree, FeatureManagement, and PermissionManagement components to use Angular's new signal-based input and output APIs. Updates templates and internal logic to access input values as functions, replaces EventEmitter with output signals, and synchronizes internal state using signals and effects. Also updates related model interfaces to use InputSignal and OutputEmitterRef types.
…gement Replaced Angular InputSignal and OutputEmitterRef types with standard string, boolean, and EventEmitter types in feature-management and permission-management models. Updated corresponding components to remove interface implementations, simplifying input/output handling and improving compatibility.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR migrates Angular packages from decorator-based @Input() and @Output() to Angular's new signal-based input() and output() functions, following the Angular v21 migration guide for signal inputs. This is a large-scale refactoring across multiple packages in the ABP Framework's Angular implementation.
Changes:
- Migrated all
@Input()decorators toinput()signal functions across directives, components, and services - Migrated all
@Output()decorators tooutput()signal functions - Added
effect()to handle reactive logic previously in setters orngOnChanges - Removed
OnChangeslifecycle hook from components where signal-based reactivity replaces it - Updated templates to invoke signals with
()syntax - Modified extensible framework models to support both signal and non-signal inputs for backward compatibility
Reviewed changes
Copilot reviewed 57 out of 57 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| visible.directive.ts | Migrated input to signal, added effect for reactive subscription management |
| ngx-datatable-list.directive.ts | Migrated required list input to signal, updated all usages with () invocations |
| ngx-datatable-default.directive.ts | Migrated class input to signal, updated HostBinding getter |
| loading.directive.ts | Migrated inputs to signals with aliases, replaced ComponentFactoryResolver with ViewContainerRef |
| ellipsis.directive.ts | Migrated to signals with computed properties for host bindings |
| disabled.directive.ts | Migrated but contains critical bug - ngOnChanges won't trigger |
| toast components | Migrated inputs to signals, updated templates with signal invocations |
| form components | Migrated inputs/outputs to signals, properly handled in templates |
| button.component.ts | Added backward-compatible loading property getter/setter alongside signal |
| loader-bar.component.ts | Complex migration with signal state management |
| permission-management.component.ts | Large component with effect for visible state synchronization |
| feature-management.component.ts | Similar pattern to permission management |
| tree.component.ts | Complex state with internal signals for two-way binding |
| page.component.ts | Effect-based toolbar state management |
| chart.component.ts | Effect with untracked for chart recreation |
| extensible framework | PropData/ActionData models support InputSignal for backward compatibility |
| extensible-table.component.ts | Contains critical bug with selected array mutation |
| extensible-form components | Proper migration with effect-based reactivity |
| for.directive.ts | Migrated but contains critical bug - ngOnChanges won't trigger |
| permission.directive.ts | Migrated inputs but ngOnChanges still present (not in diff) |
| other core directives | Various signal migrations with proper patterns |
| setting-management/package.json | Angular aria version downgraded from 21.1.0 to 21.0.0 |
Comments suppressed due to low confidence (2)
npm/ng-packs/packages/theme-shared/src/lib/directives/disabled.directive.ts:17
- The
OnChangesinterface is implemented andngOnChangesis used to react to changes, but with signal inputs,ngOnChangeswill not be called automatically. Angular'sngOnChangeslifecycle hook does not trigger for signal-based inputs.
This directive needs to be refactored to use effect() instead to react to signal changes:
constructor() {
effect(() => {
const disabled = this.abpDisabled();
if (this.ngControl.control) {
this.ngControl.control[disabled ? 'disable' : 'enable']();
}
});
}Then remove the OnChanges interface and ngOnChanges method.
npm/ng-packs/packages/core/src/lib/directives/for.directive.ts:205
- The
OnChangesinterface is implemented andngOnChangesmethod exists, but with signal inputs,ngOnChangeswill not be called for signal-based inputs. The directive should useeffect()instead to react to signal changes.
Consider refactoring to use effect():
constructor() {
effect(() => {
// Track all signal inputs
this.items();
this.orderBy();
this.orderDir();
this.filterBy();
this.filterVal();
this.emptyRef();
// Then perform the logic currently in ngOnChanges
this.processItems();
});
}
private processItems() {
const itemsValue = this.items();
if (!itemsValue) return;
// ... rest of the ngOnChanges logic
} ngOnChanges() {
const itemsValue = this.items();
if (!itemsValue) return;
// Recreate differ if items array reference changed
if (this.lastItemsRef !== itemsValue) {
this.differ = null;
this.lastItemsRef = itemsValue;
}
let items = clone(itemsValue) as any[];
if (!Array.isArray(items)) return;
const compareFn = this.compareFn;
const filterBy = this.filterBy();
const filterVal = this.filterVal();
if (
typeof filterBy !== 'undefined' &&
typeof filterVal !== 'undefined' &&
filterVal !== ''
) {
items = items.filter(item => compareFn(item[filterBy], this.filterVal()));
}
switch (this.orderDir()) {
case 'ASC':
this.sortItems(items);
this.projectItems(items);
break;
case 'DESC':
this.sortItems(items);
items.reverse();
this.projectItems(items);
break;
default:
this.projectItems(items);
}
}
| // Getter/setter for backward compatibility (used by ModalComponent) | ||
| get loading(): boolean { | ||
| return this._loading(); | ||
| } | ||
| set loading(value: boolean) { | ||
| this._loading.set(value); | ||
| } |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The loading property getter/setter provides backward compatibility, but there's a potential issue: external code setting loading = true will update the internal _loading signal, but the loadingInput input signal won't be synchronized. This could lead to inconsistent state if both the input binding and programmatic setter are used.
Consider documenting this limitation or adding a warning comment that explains the property setter should only be used when not binding to the input.
| constructor() { | ||
| // Sync visible input to internal signal | ||
| effect(() => { | ||
| const inputValue = this.visibleInput(); | ||
| if (this._visible() !== inputValue) { | ||
| if (inputValue) { | ||
| this.openModal().subscribe(() => { | ||
| this._visible.set(true); | ||
| this.visibleChange.emit(true); | ||
| concat(this.selectAllInAllTabsRef.changes, this.selectAllInThisTabsRef.changes) | ||
| .pipe(take(1)) | ||
| .subscribe(() => { | ||
| this.initModal(); | ||
| }); | ||
| }); | ||
| } else { | ||
| this.setSelectedGroup(null); | ||
| this._visible.set(false); | ||
| this.visibleChange.emit(false); | ||
| this.filter.set(''); | ||
| } | ||
| } | ||
| }); | ||
| } |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The effect is duplicating the logic from the visible getter/setter. When visibleInput() changes, the effect will call openModal() or reset state. However, external code might also call the visible setter directly (for backward compatibility), which will also call openModal() or reset state.
If the input changes from false to true, the effect will trigger and call openModal(), which sets _visible.set(true) and emits visibleChange.emit(true). Then if external code checks the state, everything is fine. But this duplication of logic between the effect and the setter could lead to issues if the logic diverges during maintenance.
Consider refactoring to have a single source of truth for this logic, or adding a comment explaining why both paths exist.
| }, | ||
| "peerDependencies": { | ||
| "@angular/aria": "~21.1.0" | ||
| "@angular/aria": "~21.0.0" |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Angular aria version is being downgraded from ~21.1.0 to ~21.0.0. This appears to be unintentional or should be documented in the PR description. Please verify if this version change is intentional and if it's related to the signal inputs migration.
| // Note: disabled needs to remain as a regular property because setDisabledState assigns to it | ||
| disabled?: boolean; |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The disabled property needs to remain as a regular property (not a signal input) because setDisabledState from ControlValueAccessor assigns to it directly (this.disabled = isDisabled). However, this creates an inconsistency where readonly, valueFn, and valueLimitFn are signal inputs but disabled is not.
For better consistency and to avoid confusion, consider documenting why disabled must remain a regular property, or explore if there's a way to handle setDisabledState with a signal (though this would require the setter pattern which is already used for value).
| get data(): ReadonlyPropData<R> { | ||
| return { | ||
| getInjected: this.getInjected, | ||
| index: this.index, | ||
| record: this.record, | ||
| index: isSignal(this.index) ? this.index() : this.index, | ||
| record: isSignal(this.record) ? this.record() : this.record, | ||
| }; | ||
| } |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The data getter uses isSignal() to check if index and record are signals, but there's a potential TypeScript typing issue. The index property is typed as number | InputSignal<number | undefined>, and record is typed as R | InputSignal<R>.
When isSignal(this.index) is true, TypeScript should narrow the type to InputSignal<number | undefined>, so calling this.index() should be safe. However, when it's false, the type should be number | undefined directly.
The logic appears correct, but consider adding a runtime check or comment to clarify why this dual-type approach is needed. This pattern exists to support both old non-signal usage and new signal-based usage for backward compatibility.
| constructor() { | ||
| effect(() => { | ||
| const data = this.data(); | ||
| const options = this.options(); | ||
|
|
||
| untracked(() => { | ||
| if (this.chart) { | ||
| this.chart.destroy(); | ||
| this.initChart(); | ||
| } | ||
| }); | ||
| }); |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The effect watches data() and options() signals, and when they change, it destroys and recreates the chart. However, using untracked() around the chart recreation logic means that if this.chart itself is a signal (which it isn't), reading it wouldn't trigger the effect.
The usage here is correct since this.chart is a regular property, but the pattern is unusual. The untracked() wrapper is unnecessary here since you're not reading any signals inside it that you want to exclude from dependency tracking. The effect will only re-run when data() or options() change, regardless of whether the inner code is wrapped in untracked().
Consider removing the untracked() wrapper for clarity, unless there's a specific reason for it that should be documented.
|
|
||
| export function generateFormFromProps<R = any>(data: PropData<R>) { | ||
| export function generateFormFromProps<R = any>(propData: PropData<R>) { | ||
| // Use .data to get resolved values (handles InputSignal) |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The change from PropData<R> to ReadonlyPropData<R> for the data constant is correct since .data getter returns ReadonlyPropData. However, the comment "Use .data to get resolved values (handles InputSignal)" could be clearer.
The purpose of calling .data is to resolve any signal inputs to their actual values, creating a plain readonly object. Consider enhancing the comment to explain that this ensures backward compatibility with code expecting plain values rather than signals.
| // Use .data to get resolved values (handles InputSignal) | |
| // Use .data to resolve any signal inputs into plain readonly values (preserves backward compatibility for code expecting plain values instead of signals) |
| const data = this.data(); | ||
| const options = this.options(); |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused variable data.
| const data = this.data(); | |
| const options = this.options(); | |
| // Access signals to establish reactive dependencies when data or options change | |
| this.data(); | |
| this.options(); |
| const data = this.data(); | ||
| const options = this.options(); |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused variable options.
| const data = this.data(); | |
| const options = this.options(); | |
| this.data(); | |
| this.options(); |
| @@ -1,4 +1,4 @@ | |||
| import { Directive, HostBinding, Input } from '@angular/core'; | |||
| import { Directive, effect, inject, input, signal, ElementRef, Renderer2 } from '@angular/core'; | |||
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused import signal.
| import { Directive, effect, inject, input, signal, ElementRef, Renderer2 } from '@angular/core'; | |
| import { Directive, effect, inject, input, ElementRef, Renderer2 } from '@angular/core'; |
| chart: any; | ||
|
|
||
| constructor() { | ||
| effect(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is better to use local variables for this.data() / this.options() inside the effect to emphasize they are read-once and keep the body cleaner.
effect(() => {
const data = this.data();
const options = this.options();
untracked(() => {
if (!this.chart) return;
this.chart.destroy();
this.initChart(data, options);
});
});| return this.disabledFn(this.data().data); | ||
| } | ||
|
|
||
| constructor() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| }); | ||
| } | ||
|
|
||
| if (options) this.options$ = options(this.data().data); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The repeated this.data().data is noisy and easy to get wrong. It is better capturing const data = this.data().data; once inside the effect and helpers, and use that.
effect(() => {
const prop = this.prop();
const data = this.data().data;
if (!prop) return;
const { options, readonly, disabled, validators, className, template } = prop;
// ...
if (options) this.options$ = options(data);
if (readonly) this.readonly = readonly(data);
// ...
});|
|
||
| ngAfterViewInit() { | ||
| if (this.isFirstGroup && this.first && this.fieldRef) { | ||
| if (this.isFirstGroup() && this.first() && this.fieldRef) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is safer to make the intent clearer by coalescing undefined to false like this
if (!!this.isFirstGroup() && !!this.first() && this.fieldRef) { ... }| this.list().totalCount = this.recordsTotal(); | ||
| } | ||
|
|
||
| this._data.set(dataValue.map((record: any, index: number) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic that decorates records with _prop.value is now inside an effect. It is better to move it to a pure method prepareRecord(record, index) and call it from the effect to keep effect bodies small and testable.
private prepareRecord(record: any, index: number): any { /* existing body */ }
effect(() => {
const dataValue = this.dataInput();
if (!dataValue) return;
this._data.set(dataValue.map((r, i) => this.prepareRecord(r, i)));
});| readonly actionsColumnWidthInput = input<number | undefined>(undefined, { alias: 'actionsColumnWidth' }); | ||
| readonly actionsTemplate = input<TemplateRef<any> | undefined>(undefined); | ||
| readonly selectable = input(false); | ||
| readonly selectionTypeInput = input<SelectionType | string>(SelectionType.multiClick, { alias: 'selectionType' }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider always working with SelectionType in code and only converting string aliases at the public boundary; this simplifies conditions like selectionType() === 'single'.
readonly selectionTypeInput = input<SelectionType | keyof typeof SelectionType>(SelectionType.multiClick);
protected readonly selectionType = computed(() => {
const value = this.selectionTypeInput();
return typeof value === 'string' ? SelectionType[value] : value;
});| onSelectedNodeChange(node: NzTreeNode) { | ||
| this.selectedNode = node.origin.entity; | ||
| if (this.changeCheckboxWithNode) { | ||
| this._selectedNode.set(node.origin.entity); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of repeatedly calling this._checkedKeys() / this._expandedKeys(), assign to a local const keys = this._checkedKeys(); first, mutate, then set. This reads clearer and avoids accidental multiple reads.
| get delay() { | ||
| return this._delay; | ||
| } | ||
| readonly delay = input(0, { alias: 'autofocus', transform: numberAttribute }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to keep the old behavior here, so it is better to pass a transform function that tolerates invalid values to 0, not rely on numberAttribute’s default.
readonly delay = input(0, {
alias: 'autofocus',
transform: (v: unknown) => Number(v) || 0,
});| constructor() { | ||
| effect(() => { | ||
| const value = this.abpVisible(); | ||
| this.condition$ = checkType(value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
checkType(value) is fine, but you could short-circuit when the new value is strictly equal to the previous one to avoid extra subscriptions.
private lastInput: VisibleInput;
effect(() => {
const value = this.abpVisible();
if (value === this.lastInput) return;
this.lastInput = value;
this.condition$ = checkType(value);
this.subscribeToCondition();
});| PermissionManagement.PermissionManagementComponentInputs, | ||
| PermissionManagement.PermissionManagementComponentOutputs | ||
| { | ||
| export class PermissionManagementComponent { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Methods like setGrantCheckboxState, openModal, and shouldFetchAppConfig repeatedly call the signals. Capturing once is better for readability and consistency with how you handle other signals.
Multiple fixes and refactors across components and directives: - chart.component.ts: Make initChart accept data and options, pass them when initializing/reinitializing, and avoid unnecessary early returns in effects. - extensible-form-prop.component.ts: Guard against missing data, reuse a local data variable, pass record to injector providers, and tighten autofocus condition checks. - extensible-table.component.ts: Extract record preparation into prepareRecord to simplify data mapping logic. - tree.component.ts: Cache checked keys once when toggling node selection to avoid repeated calls. - autofocus.directive.ts: Remove deprecated numberAttribute and use a transform that safely converts input to Number. - permission-management.component.ts: Cache providerName/providerKey, validate them before fetching, and use cached values in filters and checks. - visible.directive.ts: Avoid redundant condition updates by tracking the last input value. These changes improve safety (null checks), readability (helper extraction), and performance (reduced repeated computations).
Return early in ExtensibleFormPropComponent effect when this.data()?.data is falsy. Previously the effect only checked currentProp and could attempt to access properties on undefined data, causing runtime errors; this adds a defensive check to avoid that.
| {{ prop().displayName | abpLocalization }} | ||
| } | ||
| } | ||
| {{ asterisk }} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| @Input() | ||
| entityDisplayName: string | undefined; | ||
| // Classic inputs for ReplaceableTemplateDirective compatibility | ||
| @Input() providerName!: string; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The @Input() decorator seems mistakenly rolled back.
| <ng-template [ngTemplateOutlet]="label" [ngTemplateOutletContext]="{ $implicit: 'form-check-label' }" /> | ||
| </div> | ||
| } | ||
| @case ('select') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.



Description
This PR migrates Angular packages to signal input functions
Resolves #24674 (write the related issue number if available)