В начале 2024 года в Angular 17.1 появились signal inputs в статусе developer preview, а полноценную стабильность приобрели уже в 19 версии фреймворка.

Переход на signal inputs не казался чем-то сложным: выполнить миграцию с помощью schematic, поправить ошибки и запустить проект.

Однако неожиданно обнаружился баг в проекте: один из input-ов в динамически созданном компоненте оставался с дефолтным значением.
И все это без ошибок, без предупреждений.

Посмотрим подробнее.

Исходник

В проекте использовался общий механизм для динамического создания компонентов (дальнейший код упрощен для удобства).
Где-то в приложении имеется метод createComponent, который создает некоторый компонент и устанавливает в нем значение для input-а:

createComponent<T>(componentType: Type<T>, inputName: string, inputValue: unknown) {
  this.componentRef = this.viewContainerRef.createComponent(componentType); // создаем компонент
  this.componentRef.instance[inputName] = inputValue; // устанавливаем значение в input
}

Метод понятия не имеет, какое название input-а мы передадим и что за компонент.

Теперь требуется динамически создать компонент ChildComponent:

export class ChildComponent {   
 @Input('image-id') imageId: number | undefined; 
}

и установить для него input [image-id]="5".

По некоторым причинам в метод createComponent inputName приходит предварительно обработанный и исключительно в camelCase.
Поэтому вызываем createComponent следующим образом:

this.createComponent(Type<ChildComponent>, 'imageId', 5); // внимание: передаем не image-id

В данном случае установка input-а для ChildComponent выполняется корректно по следующей причине:

imageId — обычное поле класса, мы напрямую присвоили значение свойству экземпляра.
А input alias = 'image-id' использовался только в шаблонах. 

Затем была выполнена миграция на signal inputs, и кое-что пришлось поменять.

После миграции на signal inputs

imageId теперь является signal input, у которого все так же имеется alias:

export class ChildComponent {
  imageId = input<number | undefined>(
    undefined,
    { alias: 'image-id' }
  );
}

Далее пришлось внести изменения в общий метод createComponent: теперь простое присваивание значения полю для signal input недоступно, и нужно использовать специальный метод setInput:

createComponent<T>(componentType: Type<T>, inputName: string, inputValue: unknown) {
  this.componentRef = this.viewContainerRef.createComponent(componentType); // без изменений
  this.componentRef.setInput(inputName, inputValue); // заменяем на метод setInput
}

Ошибок нет, код компилируется. Но часть input-ов перестала обновляться.

Что произошло

ComponentRef.setInput() н�� делает прямого присваивания, типа:

instance[inputName] = value;

Он имитирует template binding:

<child-component [image-id]="5">...

setInput работает строго с public name (тем, что видит шаблон, в нашем случае c alias "image-id"), в то время как прямое обращение к instance работало с internal name (именем поля в компоненте).

Если передать:

this.componentRef.setInput('imageId', 5);

setInput просто не обнаружит input с таким именем и ничего не установит.

Нужно сделать так:

this.componentRef.setInput('image-id', 5); // используем alias

И значение будет установлено.

Почему это легко пропустить

Код обобщенный, конкретных имен не видно, плюс никаких признаков проблемы.
Значение input-a просто останется дефолтным.

Вывод

Переход на signal inputs не только про смену синтаксиса, но и про более строгий API.

Раньше мы могли обращаться к input-у как к обычному полю класса. Это позволяло устанавливать данные в обход правил Angular, игнорируя alias и жизненный цикл.
Теперь же при использовании setInput мы должны учитывать alias input-а, иначе есть риск остаться без обновлений.