Skip to content

Commit 9c55fcb

Browse files
crisbetoatscott
authored andcommitted
feat(core): de-duplicate host directives
With host directives we can end up in a situation where the same directive applies multiple times to the same element, potentially with conflicting configurations. The runtime isn't set up for a directive to apply more than once so historically we were throwing an error when we detect duplicates. This ended up limiting the usefulness of host directives to library authors, because it meant that host directives couldn't be reused as much as authors wanted. To address the issue, these changes introduce logic in the compiler and runtime that will de-duplicate host directives with the following logic: 1. If a directive matches once in the template and more than once as a host directive, the host directive matches will be discarded and only the template match will apply. The mental model is that a host directive match represents `Partial<YourDirective>` while a template match represents the full `YourDirective`. 2. If a directive matches multiple times as a host directive, we merge the input/output mappings from all the instances into a single one. If we detect a case where an input/output is exposed under multiple names during the merging process, both the compiler and the runtime will produce an error. Fixes #57846.
1 parent 57432f1 commit 9c55fcb

File tree

3 files changed

+546
-233
lines changed

3 files changed

+546
-233
lines changed

packages/core/src/render3/assert.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -174,26 +174,6 @@ export function assertParentView(lView: LView | null, errMessage?: string) {
174174
);
175175
}
176176

177-
export function assertNoDuplicateDirectives(directives: DirectiveDef<unknown>[]): void {
178-
// The array needs at least two elements in order to have duplicates.
179-
if (directives.length < 2) {
180-
return;
181-
}
182-
183-
const seenDirectives = new Set<DirectiveDef<unknown>>();
184-
185-
for (const current of directives) {
186-
if (seenDirectives.has(current)) {
187-
throw new RuntimeError(
188-
RuntimeErrorCode.DUPLICATE_DIRECTIVE,
189-
`Directive ${current.type.name} matches multiple times on the same element. ` +
190-
`Directives can only match an element once.`,
191-
);
192-
}
193-
seenDirectives.add(current);
194-
}
195-
}
196-
197177
/**
198178
* This is a basic sanity check that the `injectorIndex` seems to point to what looks like a
199179
* NodeInjector data structure.

packages/core/src/render3/features/host_directives_feature.ts

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import {resolveForwardRef} from '../../di';
99
import {RuntimeError, RuntimeErrorCode} from '../../errors';
1010
import {assertEqual} from '../../util/assert';
11-
import {EMPTY_OBJ} from '../../util/empty';
1211
import {getComponentDef, getDirectiveDef} from '../def_getters';
1312
import {isComponentDef} from '../interfaces/type_checks';
1413
import type {
@@ -94,7 +93,7 @@ function resolveHostDirectives(matches: DirectiveDef<unknown>[]): HostDirectiveR
9493
hostDirectiveRanges ??= new Map();
9594

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

9998
// Note that these indexes are within the offset by `directiveStart`. We can't do the
10099
// offsetting here, because `directiveStart` hasn't been initialized on the TNode yet.
@@ -113,23 +112,37 @@ function resolveHostDirectives(matches: DirectiveDef<unknown>[]): HostDirectiveR
113112
allDirectiveDefs.push(matches[i]);
114113
}
115114

115+
// We need to patch the `declaredInputs` so that `ngOnChanges` can map the properties correctly.
116+
// Note that we do this at the end so that all host directive inputs have been merged.
117+
if (hostDirectiveDefs !== null) {
118+
hostDirectiveDefs.forEach((def, hostDirectiveDef) => {
119+
patchDeclaredInputs(hostDirectiveDef.declaredInputs, def.inputs);
120+
});
121+
}
122+
116123
return [allDirectiveDefs, hostDirectiveDefs, hostDirectiveRanges];
117124
}
118125

119126
function findHostDirectiveDefs(
120127
currentDef: DirectiveDef<unknown>,
121128
matchedDefs: DirectiveDef<unknown>[],
122129
hostDirectiveDefs: HostDirectiveDefs,
130+
templateMatches: readonly DirectiveDef<unknown>[],
123131
): void {
124132
if (currentDef.hostDirectives !== null) {
125133
for (const configOrFn of currentDef.hostDirectives) {
126134
if (typeof configOrFn === 'function') {
127135
const resolved = configOrFn();
128136
for (const config of resolved) {
129-
trackHostDirectiveDef(createHostDirectiveDef(config), matchedDefs, hostDirectiveDefs);
137+
trackHostDirectiveDef(
138+
createHostDirectiveDef(config),
139+
matchedDefs,
140+
hostDirectiveDefs,
141+
templateMatches,
142+
);
130143
}
131144
} else {
132-
trackHostDirectiveDef(configOrFn, matchedDefs, hostDirectiveDefs);
145+
trackHostDirectiveDef(configOrFn, matchedDefs, hostDirectiveDefs, templateMatches);
133146
}
134147
}
135148
}
@@ -138,29 +151,56 @@ function findHostDirectiveDefs(
138151
/** Tracks a single host directive during directive matching. */
139152
function trackHostDirectiveDef(
140153
def: HostDirectiveDef,
141-
matchedDefs: DirectiveDef<unknown>[],
154+
finalMatches: DirectiveDef<unknown>[],
142155
hostDirectiveDefs: HostDirectiveDefs,
156+
templateMatches: readonly DirectiveDef<unknown>[],
143157
) {
144158
const hostDirectiveDef = getDirectiveDef(def.directive)!;
145159

146160
if (typeof ngDevMode === 'undefined' || ngDevMode) {
147161
validateHostDirective(def, hostDirectiveDef);
148162
}
149163

150-
// We need to patch the `declaredInputs` so that
151-
// `ngOnChanges` can map the properties correctly.
152-
patchDeclaredInputs(hostDirectiveDef.declaredInputs, def.inputs);
153-
154164
// Host directives execute before the host so that its host bindings can be overwritten.
155-
findHostDirectiveDefs(hostDirectiveDef, matchedDefs, hostDirectiveDefs);
156-
hostDirectiveDefs.set(hostDirectiveDef, def);
157-
matchedDefs.push(hostDirectiveDef);
165+
findHostDirectiveDefs(hostDirectiveDef, finalMatches, hostDirectiveDefs, templateMatches);
166+
167+
if (hostDirectiveDefs.has(hostDirectiveDef)) {
168+
const existing = hostDirectiveDefs.get(hostDirectiveDef)!;
169+
mergeBindingMaps(existing, def.inputs, 'input');
170+
mergeBindingMaps(existing, def.outputs, 'output');
171+
} else if (!templateMatches.includes(hostDirectiveDef)) {
172+
hostDirectiveDefs.set(hostDirectiveDef, def);
173+
finalMatches.push(hostDirectiveDef);
174+
}
175+
}
176+
177+
function mergeBindingMaps(
178+
existingDef: HostDirectiveDef,
179+
newMap: HostDirectiveBindingMap,
180+
kind: 'input' | 'output',
181+
) {
182+
// Note: we don't do something like `existingDef[kind]` to avoid property renaming issues.
183+
const targetMap = kind === 'input' ? existingDef.inputs : existingDef.outputs;
184+
185+
Object.keys(newMap).forEach((publicName) => {
186+
const alias = newMap[publicName];
187+
188+
if (!targetMap.hasOwnProperty(publicName) || targetMap[publicName] === alias) {
189+
targetMap[publicName] = alias;
190+
} else if (typeof ngDevMode === 'undefined' || ngDevMode) {
191+
const message =
192+
`${kind === 'input' ? 'Input' : 'Output'} "${publicName}" from ${existingDef.directive.name} ` +
193+
`is exposed under the following conflicting names: "${targetMap[publicName]}" and "${alias}". ` +
194+
`An ${kind} can only be exposed under a single name.`;
195+
throw new RuntimeError(RuntimeErrorCode.HOST_DIRECTIVE_CONFLICTING_ALIAS, message);
196+
}
197+
});
158198
}
159199

160200
/** Creates a `HostDirectiveDef` from a used-defined host directive configuration. */
161201
function createHostDirectiveDef(config: HostDirectiveConfig): HostDirectiveDef {
162202
return typeof config === 'function'
163-
? {directive: resolveForwardRef(config), inputs: EMPTY_OBJ, outputs: EMPTY_OBJ}
203+
? {directive: resolveForwardRef(config), inputs: {}, outputs: {}}
164204
: {
165205
directive: resolveForwardRef(config.directive),
166206
inputs: bindingArrayToMap(config.inputs),
@@ -173,14 +213,12 @@ function createHostDirectiveDef(config: HostDirectiveConfig): HostDirectiveDef {
173213
* a map in the form of `{publicName: 'alias', otherPublicName: 'otherAlias'}`.
174214
*/
175215
function bindingArrayToMap(bindings: string[] | undefined): HostDirectiveBindingMap {
176-
if (bindings === undefined || bindings.length === 0) {
177-
return EMPTY_OBJ;
178-
}
179-
180216
const result: HostDirectiveBindingMap = {};
181217

182-
for (let i = 0; i < bindings.length; i += 2) {
183-
result[bindings[i]] = bindings[i + 1];
218+
if (bindings !== undefined && bindings.length > 0) {
219+
for (let i = 0; i < bindings.length; i += 2) {
220+
result[bindings[i]] = bindings[i + 1];
221+
}
184222
}
185223

186224
return result;

0 commit comments

Comments
 (0)