В начале 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-а, иначе есть риск остаться без обновлений.
