Привет, Хабр! Я Святослав Зайцев, занимаюсь разработкой Angular-приложений и внутренних библиотек компонентов в Т-Банке.

Недавно в Angular v22 появилось изменение, которое прошло почти незаметно на фоне более громких новинок. Тем не менее оно снимает одно из ключевых ограничений Directive Composition API.

Если вы используете hostDirectives для переиспользования поведения между директивами и компонентами, то могли сталкиваться с ситуациями, когда вполне логичная композиция внезапно заканчивалась ошибкой NG0309.

В статье разберем, почему так происходило раньше, что изменилось в Angular v22 и как новый механизм дедупликации host directives влияет на архитектуру компонентов и дизайн-систем.

Как host directives работали до Angular v22

Directive Composition API позволяет директивам и компонентам подключать другие директивы к своему host-элементу через hostDirectives. Например:

@Directive({
  host: {
    '[class.interactive]': 'true',
  },
})
export class AppearanceDirective {}

@Directive({
  selector: '[appButton]',
  hostDirectives: [AppearanceDirective],
})
export class ButtonDirective {}

@Directive({
  selector: '[appDropdownItem]',
  hostDirectives: [AppearanceDirective],
})
export class DropdownItemDirective {}

Пока все выглядит вполне логично. AppearanceDirective содержит общий визуальный функционал, а ButtonDirective и DropdownItemDirective его переиспользуют. Но реальные UI-примитивы часто комбинируются. Один и тот же элемент вполне может одновременно быть и кнопкой, и пунктом выпадающего меню:

<div appButton appDropdownItem>
  Settings
</div>

До Angular v22 фреймворк обходил обе цепочки директив и находил AppearanceDirective дважды на одном элементе:

appButton
  └─ AppearanceDirective
appDropdownItem
  └─ AppearanceDirective

В результате появлялась ошибка:

NG0309: Directive AppearanceDirective matches multiple times on the same element.

Directives can only match an element once.

Проблема заключалась не в том, что разработчик явно добавил одну и ту же директиву дважды. Дубликат возникал косвенно — как следствие переиспользуемой композиции. Из-за этого hostDirectives становились хрупким инструментом. Авторам библиотек приходилось заранее учитывать все возможные комбинации директив. Общая базовая директива могла внезапно стать непригодной для использования, если две более высокоуровневые директивы оказывались на одном элементе.

Обычно проблему решали одним из трех способов:

  • убирали общую host-директиву из одной из веток;

  • дублировали логику;

  • создавали специальные версии директив для отдельных случаев.

Все эти варианты усложняли поддержку библиотек. Это ограничение обсуждалось в Angular Issue #57846, а реализация нового поведения появилась в PR #67996.

Что изменилось в механизме обработки host directives в Angular v22

На мой взгляд, самое важное изменение состоит из двух частей.

Приоритет получает директива из шаблона. Если Angular находит одну и ту же директиву одновременно через template и через hostDirectives, приоритет получает директива, явно указанная в шаблоне.

Рассмотрим пример:

@Directive({
  selector: '[appTooltip]',
})
export class TooltipDirective {
  text = input('');
}

@Directive({
  selector: '[appIconButton]',
  hostDirectives: [
    {
      directive: TooltipDirective,
      inputs: ['text: tooltipText'],
    },
  ],
})
export class IconButtonDirective {}

Теперь повесим обе директивы на один элемент:

<button
  appIconButton
  appTooltip
  text="Delete item"
>
  ?️
</button>

Angular обнаружит TooltipDirective дважды:

Template match:
  appTooltip → TooltipDirective

Host directive match:
  appIconButton → IconButtonDirective → TooltipDirective

С новым алгоритмом Angular оставляет директиву, найденную через шаблон, и отбрасывает host directive match. В результате остается исходный API директивы, а не алиас из конфигурации host directive:

<button
  appIconButton
  appTooltip
  text="Delete item <!-- ✓ доступно -->" 
  tooltipText="Delete item <!-- ✗ недоступно -->"
>
  ?️
</button>

Такое поведение сделано намеренно. Директива, найденная через шаблон, считается полноценным экземпляром директивы со всем ее API. В свою очередь, host directive может публиковать только часть API или переименовывать входные и выходные параметры через алиасы.

Поэтому, если Angular приходится выбирать между двумя вариантами, выигрывает явное использование директивы в шаблоне. Другими словами, если разработчик специально добавил директиву в шаблон, Angular предполагает, что он хочет работать с ее оригинальным API, а не с оберткой поверх нее.

Дубликаты host directives объединяются. Если дубликат возникает только через hostDirectives, Angular создает единственный экземпляр директивы.

@Directive()
export class SharedTriggerState {
  id = trigger-${crypto.randomUUID()};
}

@Directive({
  selector: '[appPopoverTrigger]',
  hostDirectives: [SharedTriggerState],
})
export class PopoverTriggerDirective {
  state = inject(SharedTriggerState);
}

@Directive({
  selector: '[appDropdownTrigger]',
  hostDirectives: [SharedTriggerState],
})
export class DropdownTriggerDirective {
  state = inject(SharedTriggerState);
}

Использование двух директив, которые подключают одну и ту же host directive:

<button appPopoverTrigger appDropdownTrigger>
  Actions
</button>

До Angular v22:

NG0309: SharedTriggerState matches multiple times

Начиная с Angular v22: SharedTriggerState создается один раз, PopoverTriggerDirective получает этот экземпляр через inject(), а  DropdownTriggerDirective получает тот же самый экземпляр.

Именно дедупликация host directive и создание одного общего экземпляра является главным улучшением. Теперь Angular рассматривает композицию директив как граф, в котором разные ветки могут сходиться в одной общей директиве, а не как ошибочную ситуацию с дубликатами.

Inputs и Outputs тоже объединяются

Есть еще один важный нюанс нового механизма — объединение публичного API дублирующихся host directives. По умолчанию host directives не публикуют свои inputs и outputs. Это нужно делать явно.

@Directive()
export class SharedTriggerState {
  triggerId = input.required<string>();
}

Две разные директивы могут публиковать один и тот же input общей host-директивы.

@Directive({
  selector: '[appPopoverTrigger]',
  hostDirectives: [
    {
      directive: SharedTriggerState,
      inputs: ['triggerId'],
    },
  ],
})
export class PopoverTriggerDirective {}

@Directive({
  selector: '[appDropdownTrigger]',
  hostDirectives: [
    {
      directive: SharedTriggerState,
      inputs: ['triggerId'],
    },
  ],
})
export class DropdownTriggerDirective {}

Теперь есть возможность использовать обе директивы на одном элементе:

<button
  appPopoverTrigger
  appDropdownTrigger
  triggerId="main-actions"
>
  Actions
</button>

Angular объединяет дублирующиеся host directives и оставляет единственный mapping для triggerId.

Конфликтующие алиасы по-прежнему приводят к ошибке

Дедупликация не означает, что Angular пытается угадывать, какой API выбрать.

Код считается некорректным, потому что две разные цепочки host directives публикуют один и тот же input triggerId под разными публичными именами:

@Directive({
  selector: '[appPopoverTrigger]',
  hostDirectives: [
    {
      directive: SharedTriggerState,
      inputs: ['triggerId: popoverTriggerId'],
    },
  ],
})
export class PopoverTriggerDirective {}

@Directive({
  selector: '[appDropdownTrigger]',
  hostDirectives: [
    {
      directive: SharedTriggerState,
      inputs: ['triggerId: dropdownTriggerId'],
    },
  ],
})
export class DropdownTriggerDirective {}

При использовании двух таких директив на одном элементе <button appPopoverTrigger appDropdownTrigger></button>, Angular не может безопасно представить один и тот же input одновременно как popoverTriggerId и dropdownTriggerId. В таком случае ошибка останется.

Исправить ситуацию можно двумя способами:

  • использовать одинаковый алиас в обеих ветках;

  • публиковать input только через одну из цепочек композиции.

Что дедупликация host directives означает для Angular-разработчиков

Для большинства разработчиков приложений дедупликация, скорее всего, будет проявляться просто как исчезновение некоторых ошибок NG0309. Для авторов библиотек и дизайн-систем влияние гораздо серьезнее. Теперь можно свободнее строить композицию поведения из небольших специализированных директив, не опасаясь, что через несколько уровней hostDirectives Angular внезапно обнаружит дубликат.

Directive Composition API стала ближе к тому, какой ее изначально представляли разработчики: механизмом сборки сложного поведения из небольших независимых блоков. Это не громкая новая фича и не очередной синтаксический сахар для шаблонов. Но для дизайн-систем, библиотек компонентов и сложных UI-примитивов это изменение снимает серьезное архитектурное ограничение.

Итоги

До Angular v22 одна и та же директива не могла попасть на элемент через несколько путей композиции. Даже если разработчик явно не добавлял ее дважды, Angular завершал работу с ошибкой NG0309.

Теперь фреймворк умеет дедуплицировать такие директивы, объединять их публичный API и создавать единый экземпляр там, где это безопасно.

Используете ли вы hostDirectives в своих проектах? Сталкивались ли с ошибкой NG0309 или похожими ограничениями композиции? Будет интересно обсудить это в комментариях.

Комментарии (2)


  1. radtie
    19.06.2026 09:11

    Еще бы добавили возможность в bindings использовать выражения, чтобы было возможно устанавливать инпутам статические значения или внутренние сигналы, тогда вообще было бы здорово.


    1. MillerSvt Автор
      19.06.2026 09:11

      Да, согласен. Насколько я знаю, сейчас там проблема в том, что у инпута один источник истины. А если разрешить ставить инпуты из хост директив, то это сломается. Может появиться гонка биндингов. В общем пока имеем что имеем.

      В целом сейчас можно через DI это разрулить. Не блокирующая проблема. А вот дедупликация - блокирующая. Её никак не обойти.