Skip to content

Commit 8d1f58e

Browse files
committed
refactor(core): implement ɵɵforeignComponent instruction
Implement the `ɵɵforeignComponent` instruction to render foreign components (components from other frameworks) inside Angular templates. The instruction creates a host LContainer, instantiates a foreign view, executes the foreign component's RENDER function, inserts the returned native DOM nodes, and registers the disposal hook. Add unit tests to verify element rendering, property passing, dependency injection, and disposal on destruction.
1 parent 549231c commit 8d1f58e

5 files changed

Lines changed: 259 additions & 10 deletions

File tree

packages/core/src/interface/foreign_component.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,28 @@
99
/** Symbol used to store and retrieve the render function for a foreign component. */
1010
export const RENDER: unique symbol = Symbol('RENDER');
1111

12+
/**
13+
* A function returned by a {@link ForeignRenderFn} to perform cleanup when the
14+
* component is destroyed.
15+
*/
16+
export type DisposeFn = () => void;
17+
18+
/**
19+
* A function used to render a foreign component in an Angular template.
20+
*
21+
* The function accepts the component's properties as its only argument. It should return an array
22+
* of nodes rendered and owned by the foreign component. It may also return a {@link DisposeFn} to
23+
* be called when the component is destroyed.
24+
*
25+
* @template TProps The properties of the foreign component.
26+
*/
27+
export type ForeignRenderFn<TProps> = (props: TProps) => [Node[], DisposeFn?];
28+
1229
/**
1330
* Represents a component from another framework that Angular can import and render.
31+
*
32+
* @template TProps The properties of the foreign component.
1433
*/
15-
export interface ForeignComponent {
16-
readonly [RENDER]: Function;
34+
export interface ForeignComponent<TProps> {
35+
readonly [RENDER]: ForeignRenderFn<TProps>;
1736
}

packages/core/src/metadata/directives.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,7 +648,7 @@ export interface Component extends Directive {
648648
*
649649
* @internal
650650
*/
651-
foreignImports?: ForeignComponent[];
651+
foreignImports?: ForeignComponent<any>[];
652652

653653
/**
654654
* The `deferredImports` property specifies a standalone component's template dependencies,

packages/core/src/render3/foreign_import.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ForeignComponent, RENDER} from '../interface/foreign_component';
9+
import {ForeignComponent, ForeignRenderFn, RENDER} from '../interface/foreign_component';
1010

1111
/**
1212
* Returns a {@link ForeignComponent} for use in Angular components.
1313
*
14+
* @template TProps The properties of the foreign component.
1415
* @param render A function that renders a foreign component.
1516
*/
16-
export function foreignImport(render: Function): ForeignComponent {
17+
export function foreignImport<TProps>(render: ForeignRenderFn<TProps>): ForeignComponent<TProps> {
1718
return {[RENDER]: render};
1819
}

packages/core/src/render3/instructions/foreign_component.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ForeignComponent} from '../../interface/foreign_component';
9+
import {ForeignComponent, RENDER} from '../../interface/foreign_component';
10+
import {attachPatchData} from '../context_discovery';
11+
import {createForeignView} from '../foreign_view';
12+
import {TContainerNode, TNodeType} from '../interfaces/node';
13+
import {HEADER_OFFSET, RENDERER} from '../interfaces/view';
14+
import {appendChild} from '../node_manipulation';
15+
import {getLView, getTView, setCurrentTNodeAsNotParent} from '../state';
16+
import {getOrCreateTNode} from '../tnode_manipulation';
17+
import {addToEndOfViewTree} from '../view/construction';
18+
import {createLContainer} from '../view/container';
19+
import {NodeInjector} from '../di';
20+
import {runInInjectionContext} from '../../di';
1021

1122
/**
1223
* Creation phase instruction to render a foreign component.
@@ -16,10 +27,54 @@ import {ForeignComponent} from '../../interface/foreign_component';
1627
* @param props Aggregate properties and static attributes.
1728
* @codeGenApi
1829
*/
19-
export function ɵɵforeignComponent<TProps>(
30+
export function ɵɵforeignComponent(
2031
index: number,
21-
foreignComponent: ForeignComponent,
22-
props: TProps,
32+
foreignComponent: ForeignComponent<any>,
33+
props?: any,
2334
): void {
24-
// No-op for now!
35+
const lView = getLView();
36+
const tView = getTView();
37+
const adjustedIndex = index + HEADER_OFFSET;
38+
39+
// 1. Get or create TNode for this container slot
40+
let tNode: TContainerNode;
41+
if (tView.firstCreatePass) {
42+
tNode = getOrCreateTNode(tView, adjustedIndex, TNodeType.Container, null, null);
43+
} else {
44+
tNode = tView.data[adjustedIndex] as TContainerNode;
45+
}
46+
// `getOrCreateTNode` unconditionally sets the current node as a parent node, which it is not.
47+
setCurrentTNodeAsNotParent();
48+
49+
// 2. Create the anchor node in the DOM
50+
const renderer = lView[RENDERER];
51+
const comment = renderer.createComment(ngDevMode ? 'foreign-component' : '');
52+
appendChild(tView, lView, comment, tNode);
53+
attachPatchData(comment, lView);
54+
55+
// 3. Create the hosting LContainer
56+
const lContainer = createLContainer(comment, lView, comment, tNode);
57+
lView[adjustedIndex] = lContainer;
58+
addToEndOfViewTree(lView, lContainer);
59+
60+
// 4. Create the Foreign View and insert it at index 0 of the container
61+
const viewRef = createForeignView(lContainer, 0);
62+
63+
// 5. Call the RENDER function to get the nodes and DisposeFn
64+
const injector = new NodeInjector(tNode, lView);
65+
const [nodes, dispose] = runInInjectionContext(injector, () => foreignComponent[RENDER](props));
66+
67+
// 6. Insert the returned nodes into the foreign view, between its head and tail comment anchors.
68+
const tail = viewRef.tail;
69+
const parentNode = tail.parentNode;
70+
if (parentNode) {
71+
for (let i = 0; i < nodes.length; i++) {
72+
parentNode.insertBefore(nodes[i], tail);
73+
}
74+
}
75+
76+
// 7. Register the DisposeFn in the foreign view's LView destroy hooks.
77+
if (dispose) {
78+
viewRef.onDestroy(dispose);
79+
}
2580
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {ɵɵforeignComponent} from '../../src/render3/instructions/foreign_component';
10+
import {foreignImport} from '../../src/render3/foreign_import';
11+
import {destroyLView} from '../../src/render3/node_manipulation';
12+
import {ViewFixture} from './view_fixture';
13+
import {ɵɵelement, ɵɵelementEnd, ɵɵelementStart} from '../../src/render3/instructions/element';
14+
import {inject, InjectionToken} from '../../src/di';
15+
import {ɵɵdefineDirective} from '../../src/render3/definition';
16+
import {ɵɵProvidersFeature} from '../../src/render3/features/providers_feature';
17+
18+
describe('ɵɵforeignComponent', () => {
19+
afterEach(ViewFixture.cleanUp);
20+
21+
it("should render a foreign component's native elements", () => {
22+
const foreignComp = foreignImport(() => {
23+
const el = document.createElement('div');
24+
el.id = 'foreign-el';
25+
el.textContent = 'Foreign Content';
26+
return [[el]];
27+
});
28+
29+
const fixture = new ViewFixture({
30+
decls: 1,
31+
vars: 0,
32+
create: () => {
33+
ɵɵforeignComponent(0, foreignComp);
34+
},
35+
});
36+
37+
expect(fixture.host.innerHTML).toContain('<div id="foreign-el">Foreign Content</div>');
38+
});
39+
40+
it('should pass props to a foreign component', () => {
41+
let passedProps: any = null;
42+
const foreignComp = foreignImport<{name: string}>((props) => {
43+
passedProps = props;
44+
return [[]];
45+
});
46+
47+
new ViewFixture({
48+
decls: 1,
49+
vars: 0,
50+
create: () => {
51+
ɵɵforeignComponent(0, foreignComp, {name: 'Angular'});
52+
},
53+
});
54+
55+
expect(passedProps).toEqual({name: 'Angular'});
56+
});
57+
58+
it('should call the dispose function when the containing view is destroyed', () => {
59+
let disposeCalled = false;
60+
const foreignComp = foreignImport(() => {
61+
return [
62+
[],
63+
() => {
64+
disposeCalled = true;
65+
},
66+
];
67+
});
68+
69+
const fixture = new ViewFixture({
70+
decls: 1,
71+
vars: 0,
72+
create: () => {
73+
ɵɵforeignComponent(0, foreignComp);
74+
},
75+
});
76+
77+
expect(disposeCalled).toBeFalse();
78+
79+
destroyLView(fixture.tView, fixture.lView);
80+
81+
expect(disposeCalled).toBeTrue();
82+
});
83+
84+
it('should render foreign view between sibling elements', () => {
85+
const foreignComp = foreignImport(() => {
86+
const el = document.createElement('div');
87+
el.textContent = 'Foreign Content';
88+
return [[el]];
89+
});
90+
91+
const fixture = new ViewFixture({
92+
decls: 3,
93+
vars: 0,
94+
create: () => {
95+
ɵɵelement(0, 'p');
96+
ɵɵforeignComponent(1, foreignComp);
97+
ɵɵelement(2, 'span');
98+
},
99+
});
100+
101+
expect(fixture.host.innerHTML).toContain(
102+
'' +
103+
'<p></p>' +
104+
'<!--foreign-view-head-->' +
105+
'<div>Foreign Content</div>' +
106+
'<!--foreign-view-tail-->' +
107+
'<!--foreign-component-->' +
108+
'<span></span>',
109+
);
110+
});
111+
112+
it('should render foreign view as a child of a parent element', () => {
113+
const foreignComp = foreignImport(() => {
114+
const el = document.createElement('span');
115+
el.textContent = 'Foreign Content';
116+
return [[el]];
117+
});
118+
119+
const fixture = new ViewFixture({
120+
decls: 2,
121+
vars: 0,
122+
create: () => {
123+
ɵɵelementStart(0, 'div');
124+
ɵɵforeignComponent(1, foreignComp);
125+
ɵɵelementEnd();
126+
},
127+
});
128+
129+
expect(fixture.host.innerHTML).toContain(
130+
'' +
131+
'<div>' +
132+
'<!--foreign-view-head-->' +
133+
'<span>Foreign Content</span>' +
134+
'<!--foreign-view-tail-->' +
135+
'<!--foreign-component-->' +
136+
'</div>',
137+
);
138+
});
139+
140+
it('should execute the RENDER function inside the template injection context', () => {
141+
const TEST_TOKEN = new InjectionToken<string>('test-token');
142+
143+
const foreignComp = foreignImport(() => {
144+
const value = inject(TEST_TOKEN, {optional: true}) ?? 'null';
145+
const el = document.createElement('div');
146+
el.id = 'foreign-el';
147+
el.textContent = value;
148+
return [[el]];
149+
});
150+
151+
class ProviderDirective {
152+
static ɵfac = () => new ProviderDirective();
153+
static ɵdir = ɵɵdefineDirective({
154+
type: ProviderDirective,
155+
selectors: [['', 'provider-dir', '']],
156+
features: [ɵɵProvidersFeature([{provide: TEST_TOKEN, useValue: 'templated-value'}])],
157+
});
158+
}
159+
160+
const fixture = new ViewFixture({
161+
decls: 2,
162+
vars: 0,
163+
consts: [['provider-dir', '']],
164+
directives: [ProviderDirective],
165+
create: () => {
166+
ɵɵelementStart(0, 'div', 0);
167+
ɵɵforeignComponent(1, foreignComp);
168+
ɵɵelementEnd();
169+
},
170+
});
171+
172+
expect(fixture.host.innerHTML).toContain('<div id="foreign-el">templated-value</div>');
173+
});
174+
});

0 commit comments

Comments
 (0)