Skip to content

Conversation

@fahrigedik
Copy link
Member

Description

This PR migrates Angular packages to signal input functions

Resolves #24674 (write the related issue number if available)

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.
Copilot AI review requested due to automatic review settings January 31, 2026 15:34
Copy link
Contributor

Copilot AI left a 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 to input() signal functions across directives, components, and services
  • Migrated all @Output() decorators to output() signal functions
  • Added effect() to handle reactive logic previously in setters or ngOnChanges
  • Removed OnChanges lifecycle 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 OnChanges interface is implemented and ngOnChanges is used to react to changes, but with signal inputs, ngOnChanges will not be called automatically. Angular's ngOnChanges lifecycle 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 OnChanges interface is implemented and ngOnChanges method exists, but with signal inputs, ngOnChanges will not be called for signal-based inputs. The directive should use effect() 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);
    }
  }

Comment on lines +54 to +60
// Getter/setter for backward compatibility (used by ModalComponent)
get loading(): boolean {
return this._loading();
}
set loading(value: boolean) {
this._loading.set(value);
}
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 211 to 234
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('');
}
}
});
}
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
},
"peerDependencies": {
"@angular/aria": "~21.1.0"
"@angular/aria": "~21.0.0"
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to 15
// Note: disabled needs to remain as a regular property because setDisabledState assigns to it
disabled?: boolean;
Copy link

Copilot AI Jan 31, 2026

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).

Copilot uses AI. Check for mistakes.
Comment on lines 17 to 23
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,
};
}
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 57 to 68
constructor() {
effect(() => {
const data = this.data();
const options = this.options();

untracked(() => {
if (this.chart) {
this.chart.destroy();
this.initChart();
}
});
});
Copy link

Copilot AI Jan 31, 2026

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.

Copilot uses AI. Check for mistakes.

export function generateFormFromProps<R = any>(data: PropData<R>) {
export function generateFormFromProps<R = any>(propData: PropData<R>) {
// Use .data to get resolved values (handles InputSignal)
Copy link

Copilot AI Jan 31, 2026

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.

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +60
const data = this.data();
const options = this.options();
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

Unused variable data.

Suggested change
const data = this.data();
const options = this.options();
// Access signals to establish reactive dependencies when data or options change
this.data();
this.options();

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +60
const data = this.data();
const options = this.options();
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

Unused variable options.

Suggested change
const data = this.data();
const options = this.options();
this.data();
this.options();

Copilot uses AI. Check for mistakes.
@@ -1,4 +1,4 @@
import { Directive, HostBinding, Input } from '@angular/core';
import { Directive, effect, inject, input, signal, ElementRef, Renderer2 } from '@angular/core';
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

Unused import signal.

Suggested change
import { Directive, effect, inject, input, signal, ElementRef, Renderer2 } from '@angular/core';
import { Directive, effect, inject, input, ElementRef, Renderer2 } from '@angular/core';

Copilot uses AI. Check for mistakes.
chart: any;

constructor() {
effect(() => {
Copy link
Contributor

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() {
Copy link
Contributor

Choose a reason for hiding this comment

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

The extensible table actions/structure have some problems. Probably a timing issue emerged while resolving the related values.

Image

});
}

if (options) this.options$ = options(this.data().data);
Copy link
Contributor

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) {
Copy link
Contributor

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) => {
Copy link
Contributor

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' });
Copy link
Contributor

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);
Copy link
Contributor

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 });
Copy link
Contributor

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);
Copy link
Contributor

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 {
Copy link
Contributor

Choose a reason for hiding this comment

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

Methods like setGrantCheckboxStateopenModal, 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 }}
Copy link
Contributor

Choose a reason for hiding this comment

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

The validation indicator does not seem working as it should show an asterisk for the required values.

Image

@Input()
entityDisplayName: string | undefined;
// Classic inputs for ReplaceableTemplateDirective compatibility
@Input() providerName!: string;
Copy link
Contributor

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') {
Copy link
Contributor

Choose a reason for hiding this comment

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

The selection case values are not correctly resolved. It can be produced in a tenant creation modal as an example

Image

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Angular - Migration to signal inputs

2 participants