Skip to content

Commit ef66ea2

Browse files
committed
refactor(compiler): support matching template elements to foreign components
We extract the identifier name from the `foreignImports` expression in `ComponentDecoratorHandler` and use a `SelectorlessMatcher` to match element tags against these names during template binding in `R3TargetBinder`. If an element matches both a regular Angular directive and a foreign component, a conflict error is thrown.
1 parent 57908a0 commit ef66ea2

6 files changed

Lines changed: 165 additions & 6 deletions

File tree

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
SelectorlessMatcher,
4646
MatchSource,
4747
TypeCheckId,
48+
ForeignComponentMeta,
4849
} from '@angular/compiler';
4950
import ts from 'typescript';
5051

@@ -1778,8 +1779,22 @@ export class ComponentDecoratorHandler implements DecoratorHandler<
17781779
}
17791780
}
17801781

1782+
// Extract foreign component names and create a matcher.
1783+
let foreignMatcher: SelectorlessMatcher<ForeignComponentMeta> | null = null;
1784+
if (analysis.resolvedForeignImports !== null && analysis.resolvedForeignImports.length > 0) {
1785+
const registry = new Map<string, ForeignComponentMeta[]>();
1786+
for (const ref of analysis.resolvedForeignImports) {
1787+
const name = ref.node.name.getText();
1788+
registry.set(name, [{name, ref}]);
1789+
}
1790+
foreignMatcher = new SelectorlessMatcher(registry);
1791+
}
1792+
17811793
// Set up the R3TargetBinder.
1782-
const binder = new R3TargetBinder(createMatcherFromScope(scope, this.hostDirectivesResolver));
1794+
const binder = new R3TargetBinder(
1795+
createMatcherFromScope(scope, this.hostDirectivesResolver),
1796+
foreignMatcher,
1797+
);
17831798
let allDependencies = dependencies;
17841799
let deferBlockBinder = binder;
17851800

packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,52 @@ runInEachFileSystem(() => {
10851085
expect((analysis?.resolvedForeignImports![0].node as any).name.text).toBe('FancyButton');
10861086
});
10871087

1088+
it('should match template elements to foreign components', () => {
1089+
const {program, options, host} = makeProgram([
1090+
{
1091+
name: _('/node_modules/@angular/core/index.d.ts'),
1092+
contents: `
1093+
export const Component: any;
1094+
export type ForeignComponent<T> = T & { ɵrender: () => void };
1095+
`,
1096+
},
1097+
{
1098+
name: _('/entry.ts'),
1099+
contents: `
1100+
import {Component, ForeignComponent} from '@angular/core';
1101+
1102+
function FancyButton() {}
1103+
1104+
function foreignImport<T>(type: T): ForeignComponent<T> {
1105+
return type as any;
1106+
}
1107+
1108+
@Component({
1109+
selector: 'main',
1110+
template: '<FancyButton></FancyButton>',
1111+
foreignImports: [foreignImport(FancyButton)],
1112+
standalone: true,
1113+
}) class TestCmp {}
1114+
`,
1115+
},
1116+
]);
1117+
const {reflectionHost, handler} = setup(program, options, host);
1118+
const TestCmp = getDeclaration(program, _('/entry.ts'), 'TestCmp', isNamedClassDeclaration);
1119+
const detected = handler.detect(
1120+
TestCmp,
1121+
reflectionHost.getDecoratorsOfDeclaration(TestCmp),
1122+
);
1123+
if (detected === undefined) {
1124+
return fail('Failed to recognize @Component');
1125+
}
1126+
const {analysis} = handler.analyze(TestCmp, detected.metadata);
1127+
handler.register(TestCmp, analysis!);
1128+
1129+
const symbol = handler.symbol(TestCmp, analysis!);
1130+
const result = handler.resolve(TestCmp, analysis!, symbol);
1131+
expect(result.data).toBeDefined();
1132+
});
1133+
10881134
it('should produce diagnostic for imports in non-standalone component', () => {
10891135
const {program, options, host} = makeProgram(
10901136
[

packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ export function adaptTypeCheckBlockMetadata(
152152
const dirs = meta.boundTarget.getDirectivesOfNode(node);
153153
return dirs ? dirs.map(convertDir) : null;
154154
},
155+
getForeignComponentOfNode: (node) => meta.boundTarget.getForeignComponentOfNode(node),
156+
getMatchedForeignComponents: () => meta.boundTarget.getMatchedForeignComponents(),
155157
getReferenceTarget: (ref) => {
156158
const target = meta.boundTarget.getReferenceTarget(ref);
157159
if (target && 'directive' in target) {

packages/compiler/src/render3/view/t2_api.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,22 @@ export interface DirectiveMeta {
172172
matchSource: MatchSource;
173173
}
174174

175+
/**
176+
* Metadata regarding a foreign component that's needed to match it against template elements.
177+
*/
178+
export interface ForeignComponentMeta {
179+
/**
180+
* Name of the foreign component (used for matching and debugging).
181+
*/
182+
name: string;
183+
184+
/** Reference to the foreign component declaration site. */
185+
ref: {
186+
/** Key that uniquely identifies the reference. */
187+
key: string;
188+
};
189+
}
190+
175191
/**
176192
* Possible ways that a directive can be matched.
177193
*/
@@ -213,6 +229,17 @@ export interface BoundTarget<DirectiveT extends DirectiveMeta> {
213229
*/
214230
getDirectivesOfNode(node: DirectiveOwner): DirectiveT[] | null;
215231

232+
/**
233+
* For a given template node (usually an `Element`), get the foreign component that matched
234+
* the node, if any.
235+
*/
236+
getForeignComponentOfNode(node: DirectiveOwner): ForeignComponentMeta | null;
237+
238+
/**
239+
* Get all foreign components matched in the target.
240+
*/
241+
getMatchedForeignComponents(): ForeignComponentMeta[];
242+
216243
/**
217244
* For a given `Reference`, get the reference's target - either an `Element`, a `Template`, or
218245
* a directive on a particular node.

packages/compiler/src/render3/view/t2_binder.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
ConflictingHostDirectiveBinding,
5959
DirectiveMeta,
6060
DirectiveOwner,
61+
ForeignComponentMeta,
6162
MatchSource,
6263
ReferenceTarget,
6364
ScopedNode,
@@ -170,7 +171,10 @@ export type DirectiveMatcher<DirectiveT extends DirectiveMeta> =
170171
* target.
171172
*/
172173
export class R3TargetBinder<DirectiveT extends DirectiveMeta> implements TargetBinder<DirectiveT> {
173-
constructor(private directiveMatcher: DirectiveMatcher<DirectiveT> | null) {}
174+
constructor(
175+
private directiveMatcher: DirectiveMatcher<DirectiveT> | null,
176+
private foreignComponentMatcher: SelectorlessMatcher<ForeignComponentMeta> | null = null,
177+
) {}
174178

175179
/**
176180
* Perform a binding operation on the given `Target` and return a `BoundTarget` which contains
@@ -182,6 +186,7 @@ export class R3TargetBinder<DirectiveT extends DirectiveMeta> implements TargetB
182186
}
183187

184188
const directives: MatchedDirectives<DirectiveT> = new Map();
189+
const foreignComponents = new Map<DirectiveOwner, ForeignComponentMeta>();
185190
const eagerDirectives: DirectiveT[] = [];
186191
const missingDirectives = new Set<string>();
187192
const bindings: BindingsMap<DirectiveT> = new Map();
@@ -214,7 +219,9 @@ export class R3TargetBinder<DirectiveT extends DirectiveMeta> implements TargetB
214219
DirectiveBinder.apply(
215220
target.template,
216221
this.directiveMatcher,
222+
this.foreignComponentMatcher,
217223
directives,
224+
foreignComponents,
218225
eagerDirectives,
219226
missingDirectives,
220227
bindings,
@@ -255,6 +262,7 @@ export class R3TargetBinder<DirectiveT extends DirectiveMeta> implements TargetB
255262
return new R3BoundTarget(
256263
target,
257264
directives,
265+
foreignComponents,
258266
eagerDirectives,
259267
missingDirectives,
260268
bindings,
@@ -516,7 +524,9 @@ class DirectiveBinder<DirectiveT extends DirectiveMeta> implements Visitor {
516524

517525
private constructor(
518526
private directiveMatcher: DirectiveMatcher<DirectiveT> | null,
527+
private foreignMatcher: SelectorlessMatcher<ForeignComponentMeta> | null,
519528
private directives: MatchedDirectives<DirectiveT>,
529+
private foreignComponents: Map<DirectiveOwner, ForeignComponentMeta>,
520530
private eagerDirectives: DirectiveT[],
521531
private missingDirectives: Set<string>,
522532
private bindings: BindingsMap<DirectiveT>,
@@ -542,7 +552,9 @@ class DirectiveBinder<DirectiveT extends DirectiveMeta> implements Visitor {
542552
static apply<DirectiveT extends DirectiveMeta>(
543553
template: Node[],
544554
directiveMatcher: DirectiveMatcher<DirectiveT> | null,
555+
foreignMatcher: SelectorlessMatcher<ForeignComponentMeta> | null,
545556
directives: MatchedDirectives<DirectiveT>,
557+
foreignComponents: Map<DirectiveOwner, ForeignComponentMeta>,
546558
eagerDirectives: DirectiveT[],
547559
missingDirectives: Set<string>,
548560
bindings: BindingsMap<DirectiveT>,
@@ -554,7 +566,9 @@ class DirectiveBinder<DirectiveT extends DirectiveMeta> implements Visitor {
554566
): void {
555567
const matcher = new DirectiveBinder(
556568
directiveMatcher,
569+
foreignMatcher,
557570
directives,
571+
foreignComponents,
558572
eagerDirectives,
559573
missingDirectives,
560574
bindings,
@@ -665,11 +679,12 @@ class DirectiveBinder<DirectiveT extends DirectiveMeta> implements Visitor {
665679
}
666680

667681
private visitElementOrTemplate(node: Element | Template): void {
682+
const matchedDirectives: DirectiveT[] = [];
683+
668684
if (this.directiveMatcher instanceof SelectorMatcher) {
669-
const directives: DirectiveT[] = [];
670685
const cssSelector = createCssSelectorFromNode(node);
671-
this.directiveMatcher.match(cssSelector, (_, results) => directives.push(...results));
672-
this.trackSelectorBasedBindingsAndDirectives(node, directives);
686+
this.directiveMatcher.match(cssSelector, (_, results) => matchedDirectives.push(...results));
687+
this.trackSelectorBasedBindingsAndDirectives(node, matchedDirectives);
673688
} else {
674689
node.references.forEach((ref) => {
675690
if (ref.value.trim() === '') {
@@ -678,6 +693,19 @@ class DirectiveBinder<DirectiveT extends DirectiveMeta> implements Visitor {
678693
});
679694
}
680695

696+
if (this.foreignMatcher && node instanceof Element) {
697+
const foreignMatches = this.foreignMatcher.match(node.name);
698+
if (foreignMatches.length > 0) {
699+
if (matchedDirectives.length > 0) {
700+
throw new Error(
701+
`Conflict: Element '${node.name}' matches both an Angular directive and a foreign component.`,
702+
);
703+
}
704+
// We assume at most one foreign component matches by name.
705+
this.foreignComponents.set(node, foreignMatches[0]);
706+
}
707+
}
708+
681709
node.directives.forEach((directive) => directive.visit(this));
682710
node.children.forEach((child) => child.visit(this));
683711
}
@@ -1182,6 +1210,7 @@ class R3BoundTarget<DirectiveT extends DirectiveMeta> implements BoundTarget<Dir
11821210
constructor(
11831211
readonly target: Target<DirectiveT>,
11841212
private directives: MatchedDirectives<DirectiveT>,
1213+
private foreignComponents: Map<DirectiveOwner, ForeignComponentMeta>,
11851214
private eagerDirectives: DirectiveT[],
11861215
private missingDirectives: Set<string>,
11871216
private bindings: BindingsMap<DirectiveT>,
@@ -1210,6 +1239,14 @@ class R3BoundTarget<DirectiveT extends DirectiveMeta> implements BoundTarget<Dir
12101239
return this.directives.get(node) || null;
12111240
}
12121241

1242+
getForeignComponentOfNode(node: DirectiveOwner): ForeignComponentMeta | null {
1243+
return this.foreignComponents.get(node) || null;
1244+
}
1245+
1246+
getMatchedForeignComponents(): ForeignComponentMeta[] {
1247+
return Array.from(this.foreignComponents.values());
1248+
}
1249+
12131250
getReferenceTarget(ref: Reference): ReferenceTarget<DirectiveT> | null {
12141251
return this.references.get(ref) || null;
12151252
}

packages/compiler/test/render3/view/binding_spec.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import * as e from '../../../src/expression_parser/ast';
1010
import * as a from '../../../src/render3/r3_ast';
11-
import {DirectiveMeta, MatchSource} from '../../../src/render3/view/t2_api';
11+
import {DirectiveMeta, MatchSource, ForeignComponentMeta} from '../../../src/render3/view/t2_api';
1212
import {ClassPropertyMapping} from '../../../src/property_mapping';
1313
import {findMatchingDirectivesAndPipes, R3TargetBinder} from '../../../src/render3/view/t2_binder';
1414
import {parseTemplate, ParseTemplateOptions} from '../../../src/render3/view/template';
@@ -1595,5 +1595,37 @@ describe('t2 binding', () => {
15951595
expect(mergedHost.outputs.toDirectMappedObject()).toEqual({one: 'oneAlias'});
15961596
expect(res.getConflictingHostDirectiveBindings(element)).toBe(null);
15971597
});
1598+
1599+
it('should match foreign components by tag name', () => {
1600+
const template = parseTemplate('<FancyButton></FancyButton>', '', {});
1601+
const registry = new Map<string, ForeignComponentMeta[]>();
1602+
registry.set('FancyButton', [{name: 'FancyButton', ref: {key: 'FancyButtonKey'}}]);
1603+
const foreignMatcher = new SelectorlessMatcher(registry);
1604+
1605+
const binder = new R3TargetBinder(new SelectorMatcher<DirectiveMeta[]>(), foreignMatcher);
1606+
const res = binder.bind({template: template.nodes});
1607+
1608+
const el = template.nodes[0] as a.Element;
1609+
const foreignComp = res.getForeignComponentOfNode(el);
1610+
expect(foreignComp).not.toBeNull();
1611+
expect(foreignComp?.name).toBe('FancyButton');
1612+
1613+
const allMatched = res.getMatchedForeignComponents();
1614+
expect(allMatched.length).toBe(1);
1615+
expect(allMatched[0].name).toBe('FancyButton');
1616+
});
1617+
1618+
it('should throw an error when tag matches both directive and foreign component', () => {
1619+
const template = parseTemplate('<comp></comp>', '', {});
1620+
const registry = new Map<string, ForeignComponentMeta[]>();
1621+
registry.set('comp', [{name: 'comp', ref: {key: 'compKey'}}]);
1622+
const foreignMatcher = new SelectorlessMatcher(registry);
1623+
1624+
const binder = new R3TargetBinder(makeSelectorMatcher(), foreignMatcher);
1625+
1626+
expect(() => binder.bind({template: template.nodes})).toThrowError(
1627+
"Conflict: Element 'comp' matches both an Angular directive and a foreign component.",
1628+
);
1629+
});
15981630
});
15991631
});

0 commit comments

Comments
 (0)