skip to content

Since Angular 22, what happens when the same directive appears twice in an element's resolved host directive tree, and when does it still fail?

level: seniorimportance: nice to knowfreq 15%

answer

  1. the diamond problem
  2. one instance, merged mappings
  3. template match beats composed match
  4. two aliases for one binding

basics

~20 s

Angular 22 de-duplicates it: duplicate host directive matches merge into one instance with combined mappings, and a template selector match beats host directive matches. It fails with NG8024 when merged paths give one binding different aliases.

solid answer

~50 s

Before v22, a directive reaching an element twice, for example because two composed directives both listed it in `hostDirectives`, threw NG0309: "Directive X matches multiple times on the same element." Angular 22 resolves it with two deterministic rules. First, if the directive is matched by a template selector and also appears as a host directive, only the template match is kept, since it represents the full directive with its whole public API. Second, if it appears more than once as a host directive, the matches are merged into a single instance and their input and output mappings are combined, which solves the diamond problem: `PopoverTrigger` and `DropdownTrigger` both composing `TriggerRef` share one `TriggerRef`, and both get that instance from `inject()`. The remaining failure is NG8024 at compile time, when two paths expose the same input or output under different aliases, so the merge cannot pick one public name.

go deeper

for a junior

Recall that Angular 22 merges a directive that appears twice through composition into one instance instead of throwing an error.

for a middle

Explain the two rules, template match first and merged host matches, and the NG8024 alias conflict.

for a senior

Design shared behaviours so that merging is safe: identity-like state, one alias per binding, and tests for the combined case.

for a principal

Use the v22 rules to simplify a library's composition graph, and set alias conventions so that independently built components compose without conflicts.

## The problem being solved Host directive composition encourages small, reusable behaviours. That leads naturally to a **diamond**: two directives each compose the same shared directive, and both are applied to one element. ```ts @Directive({ host: { '[attr.data-trigger-id]': 'triggerId()' } }) export class TriggerRef { readonly triggerId = input(`trigger-${crypto.randomUUID()}`); } @Directive({ selector: '[popoverTrigger]', hostDirectives: [TriggerRef] }) export class PopoverTrigger { readonly ref = inject(TriggerRef); } @Directive({ selector: '[dropdownTrigger]', hostDirectives: [TriggerRef] }) export class DropdownTrigger { readonly ref = inject(TriggerRef); } ``` `<button popoverTrigger dropdownTrigger>` resolves `TriggerRef` twice. Before Angular 22 this threw the runtime error NG0309, "Directive TriggerRef matches multiple times on the same element. Directives can only match an element once.", which made shared behaviours awkward to compose. ## The two v22 rules Angular 22 **de-duplicates** instead of throwing, using two rules. 1. **Template match takes precedence.** If a directive matches the element through a **template selector** and also appears as a **host directive**, Angular keeps only the template match and discards the host directive matches. The docs' mental model: a host directive match is a partial application of the directive, exposing only the listed bindings, while a template match is the full directive with its complete public API. 2. **Multiple host directive matches are merged.** If the directive appears more than once **as a host directive**, Angular creates **one instance** and combines the input and output mappings from every path. In the diamond above, one `TriggerRef` exists, both triggers inject the same instance, and the button gets a single `data-trigger-id`. ## When it still fails Merging needs the mappings to agree. If two paths expose the **same binding under different aliases**, Angular cannot give that binding two public names: | Path through | Mapping | Result | | --- | --- | --- | | `PopoverTrigger` | `inputs: ['triggerId: popoverTriggerId']` | Conflicting aliases for `triggerId` | | `DropdownTrigger` | `inputs: ['triggerId: dropdownTriggerId']` | NG8024 at compile time | | Both paths | `inputs: ['triggerId: triggerId']` or no exposure | Merges cleanly | The fix is to expose the shared binding under the **same alias** on every path, or not to expose it at all. ## Consequences for design - **Shared state is now shared.** Because the duplicates merge into one instance, both composing directives see the same state. That is usually the intent for identity-like behaviour, such as one trigger id per element, but it means the shared directive must not assume it has a single owner. - **Template use and composition coexist.** A consumer can write `<app-button hoverable>` even though `Button` already composes `Hoverable`; the template match wins, with its full API. - **Alias conventions matter more.** A library whose components compose the same behaviour under inconsistent aliases will hit NG8024 as soon as two of them meet on one element. - **Upgrades can remove workarounds.** Code that avoided NG0309 by duplicating a behaviour's host bindings, or by adding a wrapper element, can often be simplified after moving to v22. ## How the rules play out on one element Consider `<button popoverTrigger dropdownTrigger triggerRef>` where `TriggerRef` also has a `[triggerRef]` selector: 1. Angular matches `PopoverTrigger`, `DropdownTrigger` and `TriggerRef` by their selectors. 2. It resolves the host directives: both triggers list `TriggerRef`. 3. Because `TriggerRef` is already a template match, both host directive matches are dropped, and the element keeps the single template-matched `TriggerRef` with its full API. 4. Both triggers inject that one instance. Remove the `triggerRef` attribute and step 3 changes: the two host directive matches merge into one instance, whose exposed bindings are the union of both paths' mappings. ## Checklist when composing shared behaviours 1. Decide whether the shared directive is identity-like (one per element) or not; de-duplication assumes the former. 2. Pick one alias per shared binding across the library, or keep it unexposed. 3. Test the combined case, both composing directives on one element, not only each alone.

  • A component composes `Hoverable`, and a consumer also writes the `hoverable` attribute on it; which inputs are available?
    Since v22 the template selector match is kept and the host directive match is discarded, so the element has the full `Hoverable` directive with its complete public API, not just the inputs the component chose to expose. Before v22 the same markup threw NG0309.
  • Why does NG8024 happen at compile time rather than silently picking one alias?
    The merged instance can expose each binding under only one public name. Choosing either alias silently would break templates written against the other, so the compiler reports the conflict and asks the author to align the aliases or stop exposing the binding.

saying these in an interview costs you the question

  • Since v22 a duplicated host directive gets two separate instances, one per path.
  • The host directive match wins over a template selector match.
  • Different aliases for the same binding are merged into two public names.
  • Duplicate host directives still throw NG0309 in Angular 22.
  • De-duplication happens only for components, not for directives with hostDirectives.