Skip to content

Commit 0a08871

Browse files
committed
refactor(compiler-cli): validate @content block names for conflicts
Ensures `@content` blocks on foreign components have unique names and do not conflict with static attributes or input property bindings. Specifically, this commit introduces two new template diagnostics: 1. `CONFLICTING_CONTENT_DECLARATION` (8028): Raised when multiple `@content` blocks with the same name are defined under the same foreign component. 2. `CONFLICTING_CONTENT_AND_PROPERTY` (8029): Raised when a `@content` block's name matches an attribute or input property binding on the parent foreign component. Both diagnostics include related information pointing to the location of the conflicting declaration or property.
1 parent 2736b98 commit 0a08871

4 files changed

Lines changed: 349 additions & 49 deletions

File tree

goldens/public-api/compiler-cli/error_code.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export enum ErrorCode {
2828
CONFIG_FLAT_MODULE_NO_INDEX = 4001,
2929
// (undocumented)
3030
CONFIG_STRICT_TEMPLATES_IMPLIES_FULL_TEMPLATE_TYPECHECK = 4002,
31+
CONFLICTING_CONTENT_AND_PROPERTY = 8029,
32+
CONFLICTING_CONTENT_DECLARATION = 8028,
3133
CONFLICTING_HOST_DIRECTIVE_BINDING = -8024,
3234
CONFLICTING_INPUT_TRANSFORM = 2020,
3335
CONFLICTING_LET_DECLARATION = 8017,

packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts

Lines changed: 172 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ import {ForeignComponentMeta} from '../../../metadata';
3636
import {makeTemplateDiagnostic} from '../../../typecheck/diagnostics';
3737
import {SourceMapping} from '../../../typecheck/api';
3838

39+
/**
40+
* The intrinsic property name used to project children into a foreign component.
41+
*/
42+
const CHILDREN = 'children';
43+
3944
/**
4045
* Analyzes the template for invalid use of features relating to foreign components.
4146
*
@@ -55,6 +60,10 @@ export function analyzeForeignComponentFeatures(
5560
class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor {
5661
private currentParent: TmplAstNode | null = null;
5762
readonly diagnostics: ts.Diagnostic[] = [];
63+
// Tracks the named @content blocks defined for each foreign component element.
64+
// This is used to detect duplicate @content declarations under the same parent
65+
// during the recursive AST traversal.
66+
private readonly seenContentBlocks = new Map<TmplAstElement, Map<string, TmplAstContentBlock>>();
5867

5968
constructor(
6069
private readonly foreignMatcher: SelectorlessMatcher<ForeignComponentMeta> | null,
@@ -77,42 +86,7 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor {
7786

7887
override visitElement(element: TmplAstElement): void {
7988
if (this.elementIsForeignComponent(element.name)) {
80-
if (element.outputs.length > 0) {
81-
this.diagnostics.push(
82-
makeTemplateDiagnostic(
83-
'' as TypeCheckId,
84-
this.sourceMapping,
85-
element.sourceSpan,
86-
ts.DiagnosticCategory.Error,
87-
ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING),
88-
'Foreign components do not support event bindings.',
89-
),
90-
);
91-
}
92-
if (element.references.length > 0) {
93-
this.diagnostics.push(
94-
makeTemplateDiagnostic(
95-
'' as TypeCheckId,
96-
this.sourceMapping,
97-
element.sourceSpan,
98-
ts.DiagnosticCategory.Error,
99-
ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING),
100-
'Foreign components do not support references.',
101-
),
102-
);
103-
}
104-
if (element.inputs.some((input) => input.type !== BindingType.Property)) {
105-
this.diagnostics.push(
106-
makeTemplateDiagnostic(
107-
'' as TypeCheckId,
108-
this.sourceMapping,
109-
element.sourceSpan,
110-
ts.DiagnosticCategory.Error,
111-
ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING),
112-
'Foreign components only support static attributes and property bindings.',
113-
),
114-
);
115-
}
89+
this.validateForeignComponent(element);
11690
}
11791

11892
const prevParent = this.currentParent;
@@ -121,6 +95,83 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor {
12195
this.currentParent = prevParent;
12296
}
12397

98+
private validateForeignComponent(element: TmplAstElement): void {
99+
if (element.outputs.length > 0) {
100+
this.diagnostics.push(
101+
makeTemplateDiagnostic(
102+
'' as TypeCheckId,
103+
this.sourceMapping,
104+
element.sourceSpan,
105+
ts.DiagnosticCategory.Error,
106+
ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING),
107+
'Foreign components do not support event bindings.',
108+
),
109+
);
110+
}
111+
if (element.references.length > 0) {
112+
this.diagnostics.push(
113+
makeTemplateDiagnostic(
114+
'' as TypeCheckId,
115+
this.sourceMapping,
116+
element.sourceSpan,
117+
ts.DiagnosticCategory.Error,
118+
ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING),
119+
'Foreign components do not support references.',
120+
),
121+
);
122+
}
123+
if (element.inputs.some((input) => input.type !== BindingType.Property)) {
124+
this.diagnostics.push(
125+
makeTemplateDiagnostic(
126+
'' as TypeCheckId,
127+
this.sourceMapping,
128+
element.sourceSpan,
129+
ts.DiagnosticCategory.Error,
130+
ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING),
131+
'Foreign components only support static attributes and property bindings.',
132+
),
133+
);
134+
}
135+
136+
// A foreign component maps implicit child nodes to a 'children' property.
137+
// If the user also explicitly binds to '[children]' or sets a static 'children' attribute, this is a conflict.
138+
const childrenInput = element.inputs.find(
139+
(input) => input.type === BindingType.Property && input.name === CHILDREN,
140+
);
141+
const childrenAttr = element.attributes.find((attr) => attr.name === CHILDREN);
142+
const conflictingSource = childrenInput ?? childrenAttr;
143+
if (conflictingSource === undefined) {
144+
return;
145+
}
146+
147+
// Explicit `@content` blocks (TmplAstContentBlock) are mapped to properties by their name, so
148+
// they do not conflict with the default 'children' property. We only care about child nodes
149+
// that are not content blocks, as those are implicitly passed to the 'children' property.
150+
const firstChild = element.children.find((child) => !(child instanceof TmplAstContentBlock));
151+
if (firstChild === undefined) {
152+
return;
153+
}
154+
155+
this.diagnostics.push(
156+
makeTemplateDiagnostic(
157+
'' as TypeCheckId,
158+
this.sourceMapping,
159+
conflictingSource.sourceSpan,
160+
ts.DiagnosticCategory.Error,
161+
ngErrorCode(ErrorCode.CONFLICTING_CONTENT_AND_PROPERTY),
162+
`A foreign component cannot have both a '${CHILDREN}' property and child nodes.`,
163+
[
164+
{
165+
text: 'Child nodes are defined here.',
166+
start: firstChild.sourceSpan.start.offset,
167+
end: firstChild.sourceSpan.end.offset,
168+
sourceFile: this.sourceMapping.node.getSourceFile(),
169+
},
170+
],
171+
),
172+
);
173+
}
174+
124175
override visitTemplate(template: TmplAstTemplate): void {
125176
const prevParent = this.currentParent;
126177
this.currentParent = template;
@@ -207,19 +258,7 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor {
207258

208259
override visitContentBlock(block: TmplAstContentBlock): void {
209260
if (this.parentNodeIsForeignComponent()) {
210-
if (block.name === 'children') {
211-
this.diagnostics.push(
212-
makeTemplateDiagnostic(
213-
'' as TypeCheckId,
214-
this.sourceMapping,
215-
block.sourceSpan,
216-
ts.DiagnosticCategory.Error,
217-
ngErrorCode(ErrorCode.FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN),
218-
'Defining a @content (children) block is unnecessary. ' +
219-
'Pass children as direct nested content of the foreign component instead.',
220-
),
221-
);
222-
}
261+
this.validateContentBlock(block);
223262
} else {
224263
this.diagnostics.push(
225264
makeTemplateDiagnostic(
@@ -232,9 +271,93 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor {
232271
),
233272
);
234273
}
274+
235275
const prevParent = this.currentParent;
236276
this.currentParent = block;
237277
super.visitContentBlock(block);
238278
this.currentParent = prevParent;
239279
}
280+
281+
private validateContentBlock(block: TmplAstContentBlock): void {
282+
const parent = this.currentParent as TmplAstElement;
283+
284+
// Retrieve or initialize the map of @content blocks seen so far for this parent.
285+
// Since the visitor is recursive, we must track declarations per-parent to
286+
// only report duplicates within the scope of the same foreign component.
287+
let seen = this.seenContentBlocks.get(parent);
288+
if (seen === undefined) {
289+
seen = new Map<string, TmplAstContentBlock>();
290+
this.seenContentBlocks.set(parent, seen);
291+
}
292+
293+
if (seen.has(block.name)) {
294+
const firstDecl = seen.get(block.name)!;
295+
this.diagnostics.push(
296+
makeTemplateDiagnostic(
297+
'' as TypeCheckId,
298+
this.sourceMapping,
299+
block.sourceSpan,
300+
ts.DiagnosticCategory.Error,
301+
ngErrorCode(ErrorCode.CONFLICTING_CONTENT_DECLARATION),
302+
`A @content block with the name '${block.name}' has already been defined for this component.`,
303+
[
304+
{
305+
text: `The @content block '${block.name}' was first defined here.`,
306+
start: firstDecl.sourceSpan.start.offset,
307+
end: firstDecl.sourceSpan.end.offset,
308+
sourceFile: this.sourceMapping.node.getSourceFile(),
309+
},
310+
],
311+
),
312+
);
313+
} else {
314+
seen.set(block.name, block);
315+
}
316+
317+
// A @content block projects content into a property of the foreign component.
318+
// If the parent element also binds to this property (either via a property binding
319+
// or a static attribute), it creates a conflict as both try to write to the same prop.
320+
const conflictInput = parent.inputs.find(
321+
(input) => input.type === BindingType.Property && input.name === block.name,
322+
);
323+
const conflictAttr = parent.attributes.find((attr) => attr.name === block.name);
324+
const conflict = conflictInput ?? conflictAttr;
325+
326+
if (conflict !== undefined) {
327+
this.diagnostics.push(
328+
makeTemplateDiagnostic(
329+
'' as TypeCheckId,
330+
this.sourceMapping,
331+
block.sourceSpan,
332+
ts.DiagnosticCategory.Error,
333+
ngErrorCode(ErrorCode.CONFLICTING_CONTENT_AND_PROPERTY),
334+
`A @content block with the name '${block.name}' conflicts with a property on the parent component.`,
335+
[
336+
{
337+
text: `The property '${block.name}' is defined here.`,
338+
start: conflict.sourceSpan.start.offset,
339+
end: conflict.sourceSpan.end.offset,
340+
sourceFile: this.sourceMapping.node.getSourceFile(),
341+
},
342+
],
343+
),
344+
);
345+
}
346+
347+
// Explicitly defining a `@content(children)` block is unnecessary because child nodes are
348+
// implicitly passed to the `children` property.
349+
if (block.name === CHILDREN) {
350+
this.diagnostics.push(
351+
makeTemplateDiagnostic(
352+
'' as TypeCheckId,
353+
this.sourceMapping,
354+
block.sourceSpan,
355+
ts.DiagnosticCategory.Error,
356+
ngErrorCode(ErrorCode.FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN),
357+
`Defining a @content (${CHILDREN}) block is unnecessary. ` +
358+
'Pass children as direct nested content of the foreign component instead.',
359+
),
360+
);
361+
}
362+
}
240363
}

packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,16 @@ export enum ErrorCode {
475475
*/
476476
FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN = 8027,
477477

478+
/**
479+
* Raised when multiple `@content` blocks with the same name are defined for a foreign component.
480+
*/
481+
CONFLICTING_CONTENT_DECLARATION = 8028,
482+
483+
/**
484+
* Raised when a `@content` block name conflicts with an input binding on the parent foreign component.
485+
*/
486+
CONFLICTING_CONTENT_AND_PROPERTY = 8029,
487+
478488
/**
479489
* A two way binding in a template has an incorrect syntax,
480490
* parentheses outside brackets. For example:

0 commit comments

Comments
 (0)