Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const enum RuntimeErrorCode {
NO_BINDING_TARGET = 315,
INVALID_BINDING_TARGET = 316,
INVALID_SET_INPUT_CALL = 317,
DUPLICATE_DIRECTIVE_INPUTS = 318,

// Bootstrap Errors
MULTIPLE_PLATFORMS = 400,
Expand Down
23 changes: 22 additions & 1 deletion packages/core/src/render3/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {Type} from '../core';
import {RuntimeError, RuntimeErrorCode} from '../errors';
import {
assertDefined,
Expand All @@ -18,7 +19,7 @@ import {

import {getComponentDef, getNgModuleDef} from './def_getters';
import {LContainer} from './interfaces/container';
import {DirectiveDef} from './interfaces/definition';
import {DirectiveDef, HostDirectiveDef} from './interfaces/definition';
import {TIcu} from './interfaces/i18n';
import {NodeInjectorOffset} from './interfaces/injector';
import {TNode} from './interfaces/node';
Expand Down Expand Up @@ -196,6 +197,26 @@ export function assertNoDuplicateDirectives(directives: DirectiveDef<unknown>[])
}
}

export function assertNoDuplicateHostDirectives(hostDirectiveDefs: HostDirectiveDef[]): void {
// The array needs at least two elements in order to have duplicates.
if (hostDirectiveDefs.length < 2) {
return;
}

const seenDirectives = new Set<Type<unknown>>();

for (const current of hostDirectiveDefs) {
if (seenDirectives.has(current.directive)) {
throw new RuntimeError(
RuntimeErrorCode.DUPLICATE_DIRECTIVE,
`Directive ${current.directive.name} matches multiple times on the same element. ` +
`Directives can only match an element once.`,
);
}
seenDirectives.add(current.directive);
}
}

/**
* This is a basic sanity check that the `injectorIndex` seems to point to what looks like a
* NodeInjector data structure.
Expand Down
87 changes: 73 additions & 14 deletions packages/core/src/render3/features/host_directives_feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
HostDirectiveRanges,
HostDirectiveResolution,
} from '../interfaces/definition';
import {assertNoDuplicateHostDirectives} from '../assert';

/**
* This feature adds the host directives behavior to a directive definition by patching a
Expand Down Expand Up @@ -69,7 +70,7 @@ export function ɵɵHostDirectivesFeature(
* @param matches Directives resolved through selector matching.
*/
function resolveHostDirectives(matches: DirectiveDef<unknown>[]): HostDirectiveResolution {
const allDirectiveDefs: DirectiveDef<unknown>[] = [];
const allDirectiveDefs = new Set<DirectiveDef<unknown>>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The reason why we haven't done this ourselves, but rather we throw the error, is that directives can influence the values during DI, as well as the order in which host bindings are applied. There isn't really a good way to know if we should pick the first match or one of the subsequent ones.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Could you provide an example of code that will not work with that?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If you had something like this, assuming that dirFoo is a directive that is applied twice as a host directive:

<div dirFoo dirBar dirBaz dirFoo></div>

The host bindings on the div will execute based on the matching order of the directives. This means that depending on if you decide to take the first instance of dirFoo or the second one, the result can be different. E.g. if both dirFoo and dirBar had a host binding of [class.active], taking the first instance of dirFoo will allow dirBar to override it, whereas if you take the second instance, dirFoo will override dirBar.

Copy link
Copy Markdown
Author

@MillerSvt MillerSvt Sep 29, 2025

Choose a reason for hiding this comment

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

<div dirFoo dirFoo></div> still produces NG0309
hostDirectives: [DirFoo, DirFoo] as well

(look at the tests)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I was using the HTML to illustrate the execution order. This situation can happen if the host directives are applied through a selector-matched directive.

Copy link
Copy Markdown
Contributor

@waterplea waterplea Oct 2, 2025

Choose a reason for hiding this comment

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

We were discussing this within the team, but it wasn't actually clear in which specific cases it comes up. Can you point to some examples where this is a problem?

Whenever you actually use Directive Composition API for Composition -- you run into this all the time. I have a case in the issue linked to this PR, other examples:

  • I have Icons directive for displaying icons that is used across all other directives, such as buttons, chips, badges, textfields. Some of those directives can be combined on one element to be sum of both things, but I cannot do that because I get two instances of icon directive.
  • I have Appearance directive for displaying interactive state. It is used everywhere as well. Again that is preventing composition of two directives that on their own both have this already applied.
  • I have a directive that disables CSS transition initially so that there is no initial transition until it stabilizes (for various reasons CSS reflow can be triggered between addition to the DOM and applying host binding for current state which causes CSS transition) - this prevents me from combining entities that each need this safeguard on their own.
  • I use directives rather than components everywhere because I need composition and I can have as many directives as I want but only one component per element. But directive infamously cannot have styles. I solved that by using dynamic components to store styles and wanted to use host directive for that, see this comment, but I can't due to double match issue.

These are just global encounters off the top of my head.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Regarding having to alias everything.

  • I have a directive that controls dropdowns, opening and closing them when user clicks or navigates away. Let's call it [(dropdownOpen)]. This directive is used, for example, in textfields to open/close datalist for selects/date-pickers/autocomplete etc. Now imagine users also import this directive on their own because the need it on some other element like a dropdown menu button in the same view. Without aliasing they now get double match since the textfield already has it as a host directive.

I'm making these as concrete as I can to illustrate the issue, but really this is just a huge problem that hinders Directive Composition API very very much Basically every time you move some reusable logic into directive A and have directive B that needs it - you need to worry that there is no directive C that also needs it and that can be applied with B at the same time. And this happens a lot.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@crisbeto can we prioritize this please? We need any solution as soon as possible. Currently we forced to create many class Directive1 extends Directive to avoid this error.

I'm not pushing exactly this solution, but we need any.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We did decide to address this some time ago, but I haven't had time to sit down and figure out how we should handle it because of other priorities. I'll see if I can carve out some time...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thank you!

let hasComponent = false;
let hostDirectiveDefs: HostDirectiveDefs | null = null;
let hostDirectiveRanges: HostDirectiveRanges | null = null;
Expand All @@ -88,57 +89,66 @@ function resolveHostDirectives(matches: DirectiveDef<unknown>[]): HostDirectiveR
const def = matches[i];

if (def.hostDirectives !== null) {
const start = allDirectiveDefs.length;
const start = allDirectiveDefs.size;

hostDirectiveDefs ??= new Map();
hostDirectiveRanges ??= new Map();

// TODO(pk): probably could return matches instead of taking in an array to fill in?
findHostDirectiveDefs(def, allDirectiveDefs, hostDirectiveDefs);
findHostDirectiveDefs(def, hostDirectiveDefs);

for (const hostDirectiveDef of hostDirectiveDefs.keys()) {
allDirectiveDefs.add(hostDirectiveDef);
}

// Note that these indexes are within the offset by `directiveStart`. We can't do the
// offsetting here, because `directiveStart` hasn't been initialized on the TNode yet.
hostDirectiveRanges.set(def, [start, allDirectiveDefs.length - 1]);
hostDirectiveRanges.set(def, [start, allDirectiveDefs.size - 1]);
}

// Component definition is always first and needs to be
// pushed early to maintain the correct ordering.
if (i === 0 && isComponentDef(def)) {
hasComponent = true;
allDirectiveDefs.push(def);
allDirectiveDefs.add(def);
}
}

for (let i = hasComponent ? 1 : 0; i < matches.length; i++) {
allDirectiveDefs.push(matches[i]);
allDirectiveDefs.add(matches[i]);
}

return [allDirectiveDefs, hostDirectiveDefs, hostDirectiveRanges];
return [[...allDirectiveDefs], hostDirectiveDefs, hostDirectiveRanges];
}

function findHostDirectiveDefs(
currentDef: DirectiveDef<unknown>,
matchedDefs: DirectiveDef<unknown>[],
hostDirectiveDefs: HostDirectiveDefs,
): void {
const currentHostDirectivesDefs: HostDirectiveDef[] = [];

if (currentDef.hostDirectives !== null) {
for (const configOrFn of currentDef.hostDirectives) {
if (typeof configOrFn === 'function') {
const resolved = configOrFn();
for (const config of resolved) {
trackHostDirectiveDef(createHostDirectiveDef(config), matchedDefs, hostDirectiveDefs);
const hostDirectiveDef = createHostDirectiveDef(config);
trackHostDirectiveDef(hostDirectiveDef, hostDirectiveDefs);
currentHostDirectivesDefs.push(hostDirectiveDef);
}
} else {
trackHostDirectiveDef(configOrFn, matchedDefs, hostDirectiveDefs);
trackHostDirectiveDef(configOrFn, hostDirectiveDefs);
currentHostDirectivesDefs.push(configOrFn);
}
}
}

assertNoDuplicateHostDirectives(currentHostDirectivesDefs);
}

/** Tracks a single host directive during directive matching. */
function trackHostDirectiveDef(
def: HostDirectiveDef,
matchedDefs: DirectiveDef<unknown>[],
hostDirectiveDefs: HostDirectiveDefs,
) {
const hostDirectiveDef = getDirectiveDef(def.directive)!;
Expand All @@ -152,9 +162,58 @@ function trackHostDirectiveDef(
patchDeclaredInputs(hostDirectiveDef.declaredInputs, def.inputs);

// Host directives execute before the host so that its host bindings can be overwritten.
findHostDirectiveDefs(hostDirectiveDef, matchedDefs, hostDirectiveDefs);
hostDirectiveDefs.set(hostDirectiveDef, def);
matchedDefs.push(hostDirectiveDef);
findHostDirectiveDefs(hostDirectiveDef, hostDirectiveDefs);
hostDirectiveDefs.set(
hostDirectiveDef,
mergeHostDirectiveDefs(
hostDirectiveDefs.get(hostDirectiveDef),
def,
),
);
}

function mergeHostDirectiveDefInputs(existingInputs: HostDirectiveBindingMap, newInputs: HostDirectiveBindingMap, hostDirectiveName: string): HostDirectiveBindingMap {
const inputs: HostDirectiveBindingMap = {...existingInputs};

for (const [input, alias] of Object.entries(newInputs)) {
if (input in inputs && inputs[input] !== alias) {
throw new RuntimeError(
RuntimeErrorCode.DUPLICATE_DIRECTIVE_INPUTS,
(typeof ngDevMode === 'undefined' || ngDevMode) &&
`Input \`${input}\` from host directive \`${hostDirectiveName}\`, exposed with different aliases: \`${inputs[input]}\` and \`${alias}\`.`,
);
}

inputs[input] = alias;
}

return inputs;
}

function mergeHostDirectiveDefOutputs(existingOutputs: HostDirectiveBindingMap, newOutputs: HostDirectiveBindingMap): HostDirectiveBindingMap {
return {
...existingOutputs,
...newOutputs,
};
}

function mergeHostDirectiveDefs(existingDef: HostDirectiveDef | undefined, newDef: HostDirectiveDef): HostDirectiveDef {
if (!existingDef) {
return newDef;
}

if (existingDef.directive !== newDef.directive) {
throw new Error(
`Host directives merge conflict: tried to merge different directives ` +
`("${existingDef.directive.name}" vs "${newDef.directive.name}"). This should never happen.`
);
}

return {
directive: existingDef.directive,
inputs: mergeHostDirectiveDefInputs(existingDef.inputs, newDef.inputs, existingDef.directive.name),
outputs: mergeHostDirectiveDefOutputs(existingDef.outputs, newDef.outputs),
};
}

/** Creates a `HostDirectiveDef` from a used-defined host directive configuration. */
Expand Down
Loading
Loading