Skip to content

Commit 43496c3

Browse files
committed
refactor(compiler): support passing children to foreign components
Previously, any children nested inside a foreign component were ignored during template ingestion. With this change, the compiler now: 1. Identifies when a foreign component has children in the template AST. 2. Compiles these children into a separate template view (using the standard TemplateOp). 3. Passes a `ɵɵforeignContent` expression under the `children` prop inside the foreign component's `props` object. At runtime, the new `ɵɵforeignContent(index)` instruction instantiates the template at the specified slot index in memory (detached from the DOM), extracts its root DOM nodes, and returns them. These root nodes are then passed directly to the foreign component's `props.children` so they can be rendered by the foreign framework. The instantiated children view is registered in the parent LView's child tree, ensuring its change detection and destruction are managed automatically as part of the standard Angular view tree lifecycle.
1 parent d0dd9b6 commit 43496c3

14 files changed

Lines changed: 247 additions & 3 deletions

File tree

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
416416
],
417417
}]
418418
}] });
419+
export class TestCmpChildren {
420+
title = 'Submit';
421+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpChildren, deps: [], target: i0.ɵɵFactoryTarget.Component });
422+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmpChildren, isStandalone: true, selector: "main-children", ngImport: i0, template: `
423+
<FancyButton [label]="title">
424+
<span>Click me!</span>
425+
</FancyButton>
426+
`, isInline: true });
427+
}
428+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpChildren, decorators: [{
429+
type: Component,
430+
args: [{
431+
selector: 'main-children',
432+
template: `
433+
<FancyButton [label]="title">
434+
<span>Click me!</span>
435+
</FancyButton>
436+
`,
437+
// @ts-ignore: @angular/core does not expose the `foreignImports` property.
438+
foreignImports: [
439+
// @ts-ignore: @angular/core does not expose the `ForeignComponent` type this expects.
440+
frameworkImport(FancyButton)
441+
],
442+
}]
443+
}] });
419444

420445
/****************************************************************************************************
421446
* PARTIAL FILE: foreign_component.d.ts
@@ -427,4 +452,9 @@ export declare class TestCmp {
427452
static ɵfac: i0.ɵɵFactoryDeclaration<TestCmp, never>;
428453
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmp, "main", never, {}, {}, never, never, true, never>;
429454
}
455+
export declare class TestCmpChildren {
456+
title: string;
457+
static ɵfac: i0.ɵɵFactoryDeclaration<TestCmpChildren, never>;
458+
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmpChildren, "main-children", never, {}, {}, never, never, true, never>;
459+
}
430460

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
function TestCmpChildren_Children_0_Template(rf, ctx) {
2+
if (rf & 1) {
3+
i0.ɵɵdomElementStart(0, "span");
4+
i0.ɵɵtext(1, "Click me!");
5+
i0.ɵɵdomElementEnd();
6+
}
7+
}
8+
9+
10+
111
export class TestCmp {
212
// ...
313
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
@@ -13,3 +23,22 @@ export class TestCmp {
1323
encapsulation: 2
1424
});
1525
}
26+
27+
28+
29+
export class TestCmpChildren {
30+
// ...
31+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
32+
type: TestCmpChildren,
33+
selectors: [["main-children"]],
34+
decls: 2,
35+
vars: 0,
36+
template: function TestCmpChildren_Template(rf, ctx) {
37+
if (rf & 1) {
38+
i0.ɵɵdomTemplate(0, TestCmpChildren_Children_0_Template, 2, 0);
39+
i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, children: i0.ɵɵforeignContent(0) });
40+
}
41+
},
42+
encapsulation: 2
43+
});
44+
}

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
function TestCmpChildren_Children_0_Template(rf, ctx) {
2+
if (rf & 1) {
3+
i0.ɵɵelementStart(0, "span");
4+
i0.ɵɵtext(1, "Click me!");
5+
i0.ɵɵelementEnd();
6+
}
7+
}
8+
9+
10+
111
export class TestCmp {
212
// ...
313
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
@@ -13,3 +23,22 @@ export class TestCmp {
1323
encapsulation: 2
1424
});
1525
}
26+
27+
28+
29+
export class TestCmpChildren {
30+
// ...
31+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
32+
type: TestCmpChildren,
33+
selectors: [["main-children"]],
34+
decls: 2,
35+
vars: 0,
36+
template: function TestCmpChildren_Template(rf, ctx) {
37+
if (rf & 1) {
38+
i0.ɵɵtemplate(0, TestCmpChildren_Children_0_Template, 2, 0);
39+
i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, children: i0.ɵɵforeignContent(0) });
40+
}
41+
},
42+
encapsulation: 2
43+
});
44+
}

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,21 @@ function frameworkImport(component: {}): Function {
2626
export class TestCmp {
2727
title = 'Submit';
2828
}
29+
30+
@Component({
31+
selector: 'main-children',
32+
template: `
33+
<FancyButton [label]="title">
34+
<span>Click me!</span>
35+
</FancyButton>
36+
`,
37+
// @ts-ignore: @angular/core does not expose the `foreignImports` property.
38+
foreignImports: [
39+
// @ts-ignore: @angular/core does not expose the `ForeignComponent` type this expects.
40+
frameworkImport(FancyButton)
41+
],
42+
})
43+
export class TestCmpChildren {
44+
title = 'Submit';
45+
}
46+

packages/compiler/src/render3/r3_identifiers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class Identifiers {
2626
static elementEnd: o.ExternalReference = {name: 'ɵɵelementEnd', moduleName: CORE};
2727

2828
static foreignComponent: o.ExternalReference = {name: 'ɵɵforeignComponent', moduleName: CORE};
29+
static foreignContent: o.ExternalReference = {name: 'ɵɵforeignContent', moduleName: CORE};
2930

3031
static domElement: o.ExternalReference = {name: 'ɵɵdomElement', moduleName: CORE};
3132
static domElementStart: o.ExternalReference = {name: 'ɵɵdomElementStart', moduleName: CORE};

packages/compiler/src/template/pipeline/ir/src/enums.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,11 @@ export enum ExpressionKind {
460460
*/
461461
TwoWayBindingSet,
462462

463+
/**
464+
* Renders foreign content (children of a foreign component) and extracts its root DOM nodes.
465+
*/
466+
ForeignContent,
467+
463468
/**
464469
* Definition of an arrow function inside of an expression.
465470
*/

packages/compiler/src/template/pipeline/ir/src/expression.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
export type Expression =
3232
| LexicalReadExpr
3333
| ReferenceExpr
34+
| ForeignContentExpr
3435
| ContextExpr
3536
| NextContextExpr
3637
| GetCurrentViewExpr
@@ -150,6 +151,37 @@ export class ReferenceExpr extends ExpressionBase {
150151
}
151152
}
152153

154+
/**
155+
* Runtime operation to render foreign content (children of a foreign component)
156+
* and extract its root DOM nodes.
157+
*/
158+
export class ForeignContentExpr extends ExpressionBase {
159+
override readonly kind = ExpressionKind.ForeignContent;
160+
161+
constructor(
162+
readonly childrenViewXref: XrefId,
163+
readonly childrenViewHandle: SlotHandle,
164+
) {
165+
super();
166+
}
167+
168+
override visitExpression(): void {}
169+
170+
override isEquivalent(e: o.Expression): boolean {
171+
return e instanceof ForeignContentExpr && e.childrenViewXref === this.childrenViewXref;
172+
}
173+
174+
override isConstant(): boolean {
175+
return false;
176+
}
177+
178+
override transformInternalExpressions(): void {}
179+
180+
override clone(): ForeignContentExpr {
181+
return new ForeignContentExpr(this.childrenViewXref, this.childrenViewHandle);
182+
}
183+
}
184+
153185
export class StoreLetExpr
154186
extends ExpressionBase
155187
implements ConsumesVarsTrait, DependsOnSlotContextOpTrait

packages/compiler/src/template/pipeline/src/ingest.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,30 @@ function ingestElement(unit: ViewCompilationUnit, element: t.Element): void {
307307
quoted: isUnsafeObjectKey(input.name),
308308
});
309309
}
310+
311+
if (element.children.length > 0) {
312+
const childView = unit.job.allocateView(unit.xref);
313+
ingestNodes(childView, element.children);
314+
315+
const childrenTemplateOp = ir.createTemplateOp(
316+
childView.xref,
317+
ir.TemplateKind.NgTemplate,
318+
null,
319+
'Children',
320+
ir.Namespace.HTML,
321+
undefined,
322+
element.startSourceSpan,
323+
element.sourceSpan,
324+
);
325+
unit.create.push(childrenTemplateOp);
326+
327+
propEntries.push({
328+
key: 'children',
329+
value: new ir.ForeignContentExpr(childrenTemplateOp.xref, childrenTemplateOp.handle),
330+
quoted: false,
331+
});
332+
}
333+
310334
const props = propEntries.length > 0 ? o.literalMap(propEntries) : null;
311335

312336
// Foreign components are created in the creation block. Updates are triggered reactively

packages/compiler/src/template/pipeline/src/phases/reify.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,10 @@ function reifyIrExpression(unit: CompilationUnit, expr: o.Expression): o.Express
788788
return ng.nextContext(expr.steps);
789789
case ir.ExpressionKind.Reference:
790790
return ng.reference(expr.targetSlot.slot! + 1 + expr.offset);
791+
case ir.ExpressionKind.ForeignContent:
792+
return o
793+
.importExpr(Identifiers.foreignContent)
794+
.callFn([o.literal(expr.childrenViewHandle.slot!)]);
791795
case ir.ExpressionKind.LexicalRead:
792796
throw new Error(`AssertionError: unresolved LexicalRead of ${expr.name}`);
793797
case ir.ExpressionKind.TwoWayBindingSet:

packages/core/src/core_render3_private_export.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export {
151151
ɵɵelementEnd,
152152
ɵɵelementStart,
153153
ɵɵforeignComponent,
154+
ɵɵforeignContent,
154155
ɵɵenableBindings,
155156
ɵɵExternalStylesFeature,
156157
ɵɵFactoryDeclaration,

0 commit comments

Comments
 (0)