mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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.
This commit is contained in:
parent
549231cd56
commit
8d1f58e7a7
5 changed files with 260 additions and 11 deletions
|
|
@ -10,8 +10,27 @@
|
|||
export const RENDER: unique symbol = Symbol('RENDER');
|
||||
|
||||
/**
|
||||
* Represents a component from another framework that Angular can import and render.
|
||||
* A function returned by a {@link ForeignRenderFn} to perform cleanup when the
|
||||
* component is destroyed.
|
||||
*/
|
||||
export interface ForeignComponent {
|
||||
readonly [RENDER]: Function;
|
||||
export type DisposeFn = () => void;
|
||||
|
||||
/**
|
||||
* A function used to render a foreign component in an Angular template.
|
||||
*
|
||||
* The function accepts the component's properties as its only argument. It should return an array
|
||||
* of nodes rendered and owned by the foreign component. It may also return a {@link DisposeFn} to
|
||||
* be called when the component is destroyed.
|
||||
*
|
||||
* @template TProps The properties of the foreign component.
|
||||
*/
|
||||
export type ForeignRenderFn<TProps> = (props: TProps) => [Node[], DisposeFn?];
|
||||
|
||||
/**
|
||||
* Represents a component from another framework that Angular can import and render.
|
||||
*
|
||||
* @template TProps The properties of the foreign component.
|
||||
*/
|
||||
export interface ForeignComponent<TProps> {
|
||||
readonly [RENDER]: ForeignRenderFn<TProps>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -648,7 +648,7 @@ export interface Component extends Directive {
|
|||
*
|
||||
* @internal
|
||||
*/
|
||||
foreignImports?: ForeignComponent[];
|
||||
foreignImports?: ForeignComponent<any>[];
|
||||
|
||||
/**
|
||||
* The `deferredImports` property specifies a standalone component's template dependencies,
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@
|
|||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {ForeignComponent, RENDER} from '../interface/foreign_component';
|
||||
import {ForeignComponent, ForeignRenderFn, RENDER} from '../interface/foreign_component';
|
||||
|
||||
/**
|
||||
* Returns a {@link ForeignComponent} for use in Angular components.
|
||||
*
|
||||
* @template TProps The properties of the foreign component.
|
||||
* @param render A function that renders a foreign component.
|
||||
*/
|
||||
export function foreignImport(render: Function): ForeignComponent {
|
||||
export function foreignImport<TProps>(render: ForeignRenderFn<TProps>): ForeignComponent<TProps> {
|
||||
return {[RENDER]: render};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,18 @@
|
|||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {ForeignComponent} from '../../interface/foreign_component';
|
||||
import {ForeignComponent, RENDER} from '../../interface/foreign_component';
|
||||
import {attachPatchData} from '../context_discovery';
|
||||
import {createForeignView} from '../foreign_view';
|
||||
import {TContainerNode, TNodeType} from '../interfaces/node';
|
||||
import {HEADER_OFFSET, RENDERER} from '../interfaces/view';
|
||||
import {appendChild} from '../node_manipulation';
|
||||
import {getLView, getTView, setCurrentTNodeAsNotParent} from '../state';
|
||||
import {getOrCreateTNode} from '../tnode_manipulation';
|
||||
import {addToEndOfViewTree} from '../view/construction';
|
||||
import {createLContainer} from '../view/container';
|
||||
import {NodeInjector} from '../di';
|
||||
import {runInInjectionContext} from '../../di';
|
||||
|
||||
/**
|
||||
* Creation phase instruction to render a foreign component.
|
||||
|
|
@ -16,10 +27,54 @@ import {ForeignComponent} from '../../interface/foreign_component';
|
|||
* @param props Aggregate properties and static attributes.
|
||||
* @codeGenApi
|
||||
*/
|
||||
export function ɵɵforeignComponent<TProps>(
|
||||
export function ɵɵforeignComponent(
|
||||
index: number,
|
||||
foreignComponent: ForeignComponent,
|
||||
props: TProps,
|
||||
foreignComponent: ForeignComponent<any>,
|
||||
props?: any,
|
||||
): void {
|
||||
// No-op for now!
|
||||
const lView = getLView();
|
||||
const tView = getTView();
|
||||
const adjustedIndex = index + HEADER_OFFSET;
|
||||
|
||||
// 1. Get or create TNode for this container slot
|
||||
let tNode: TContainerNode;
|
||||
if (tView.firstCreatePass) {
|
||||
tNode = getOrCreateTNode(tView, adjustedIndex, TNodeType.Container, null, null);
|
||||
} else {
|
||||
tNode = tView.data[adjustedIndex] as TContainerNode;
|
||||
}
|
||||
// `getOrCreateTNode` unconditionally sets the current node as a parent node, which it is not.
|
||||
setCurrentTNodeAsNotParent();
|
||||
|
||||
// 2. Create the anchor node in the DOM
|
||||
const renderer = lView[RENDERER];
|
||||
const comment = renderer.createComment(ngDevMode ? 'foreign-component' : '');
|
||||
appendChild(tView, lView, comment, tNode);
|
||||
attachPatchData(comment, lView);
|
||||
|
||||
// 3. Create the hosting LContainer
|
||||
const lContainer = createLContainer(comment, lView, comment, tNode);
|
||||
lView[adjustedIndex] = lContainer;
|
||||
addToEndOfViewTree(lView, lContainer);
|
||||
|
||||
// 4. Create the Foreign View and insert it at index 0 of the container
|
||||
const viewRef = createForeignView(lContainer, 0);
|
||||
|
||||
// 5. Call the RENDER function to get the nodes and DisposeFn
|
||||
const injector = new NodeInjector(tNode, lView);
|
||||
const [nodes, dispose] = runInInjectionContext(injector, () => foreignComponent[RENDER](props));
|
||||
|
||||
// 6. Insert the returned nodes into the foreign view, between its head and tail comment anchors.
|
||||
const tail = viewRef.tail;
|
||||
const parentNode = tail.parentNode;
|
||||
if (parentNode) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
parentNode.insertBefore(nodes[i], tail);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Register the DisposeFn in the foreign view's LView destroy hooks.
|
||||
if (dispose) {
|
||||
viewRef.onDestroy(dispose);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
174
packages/core/test/render3/foreign_component_spec.ts
Normal file
174
packages/core/test/render3/foreign_component_spec.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {ɵɵforeignComponent} from '../../src/render3/instructions/foreign_component';
|
||||
import {foreignImport} from '../../src/render3/foreign_import';
|
||||
import {destroyLView} from '../../src/render3/node_manipulation';
|
||||
import {ViewFixture} from './view_fixture';
|
||||
import {ɵɵelement, ɵɵelementEnd, ɵɵelementStart} from '../../src/render3/instructions/element';
|
||||
import {inject, InjectionToken} from '../../src/di';
|
||||
import {ɵɵdefineDirective} from '../../src/render3/definition';
|
||||
import {ɵɵProvidersFeature} from '../../src/render3/features/providers_feature';
|
||||
|
||||
describe('ɵɵforeignComponent', () => {
|
||||
afterEach(ViewFixture.cleanUp);
|
||||
|
||||
it("should render a foreign component's native elements", () => {
|
||||
const foreignComp = foreignImport(() => {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'foreign-el';
|
||||
el.textContent = 'Foreign Content';
|
||||
return [[el]];
|
||||
});
|
||||
|
||||
const fixture = new ViewFixture({
|
||||
decls: 1,
|
||||
vars: 0,
|
||||
create: () => {
|
||||
ɵɵforeignComponent(0, foreignComp);
|
||||
},
|
||||
});
|
||||
|
||||
expect(fixture.host.innerHTML).toContain('<div id="foreign-el">Foreign Content</div>');
|
||||
});
|
||||
|
||||
it('should pass props to a foreign component', () => {
|
||||
let passedProps: any = null;
|
||||
const foreignComp = foreignImport<{name: string}>((props) => {
|
||||
passedProps = props;
|
||||
return [[]];
|
||||
});
|
||||
|
||||
new ViewFixture({
|
||||
decls: 1,
|
||||
vars: 0,
|
||||
create: () => {
|
||||
ɵɵforeignComponent(0, foreignComp, {name: 'Angular'});
|
||||
},
|
||||
});
|
||||
|
||||
expect(passedProps).toEqual({name: 'Angular'});
|
||||
});
|
||||
|
||||
it('should call the dispose function when the containing view is destroyed', () => {
|
||||
let disposeCalled = false;
|
||||
const foreignComp = foreignImport(() => {
|
||||
return [
|
||||
[],
|
||||
() => {
|
||||
disposeCalled = true;
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const fixture = new ViewFixture({
|
||||
decls: 1,
|
||||
vars: 0,
|
||||
create: () => {
|
||||
ɵɵforeignComponent(0, foreignComp);
|
||||
},
|
||||
});
|
||||
|
||||
expect(disposeCalled).toBeFalse();
|
||||
|
||||
destroyLView(fixture.tView, fixture.lView);
|
||||
|
||||
expect(disposeCalled).toBeTrue();
|
||||
});
|
||||
|
||||
it('should render foreign view between sibling elements', () => {
|
||||
const foreignComp = foreignImport(() => {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = 'Foreign Content';
|
||||
return [[el]];
|
||||
});
|
||||
|
||||
const fixture = new ViewFixture({
|
||||
decls: 3,
|
||||
vars: 0,
|
||||
create: () => {
|
||||
ɵɵelement(0, 'p');
|
||||
ɵɵforeignComponent(1, foreignComp);
|
||||
ɵɵelement(2, 'span');
|
||||
},
|
||||
});
|
||||
|
||||
expect(fixture.host.innerHTML).toContain(
|
||||
'' +
|
||||
'<p></p>' +
|
||||
'<!--foreign-view-head-->' +
|
||||
'<div>Foreign Content</div>' +
|
||||
'<!--foreign-view-tail-->' +
|
||||
'<!--foreign-component-->' +
|
||||
'<span></span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render foreign view as a child of a parent element', () => {
|
||||
const foreignComp = foreignImport(() => {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = 'Foreign Content';
|
||||
return [[el]];
|
||||
});
|
||||
|
||||
const fixture = new ViewFixture({
|
||||
decls: 2,
|
||||
vars: 0,
|
||||
create: () => {
|
||||
ɵɵelementStart(0, 'div');
|
||||
ɵɵforeignComponent(1, foreignComp);
|
||||
ɵɵelementEnd();
|
||||
},
|
||||
});
|
||||
|
||||
expect(fixture.host.innerHTML).toContain(
|
||||
'' +
|
||||
'<div>' +
|
||||
'<!--foreign-view-head-->' +
|
||||
'<span>Foreign Content</span>' +
|
||||
'<!--foreign-view-tail-->' +
|
||||
'<!--foreign-component-->' +
|
||||
'</div>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute the RENDER function inside the template injection context', () => {
|
||||
const TEST_TOKEN = new InjectionToken<string>('test-token');
|
||||
|
||||
const foreignComp = foreignImport(() => {
|
||||
const value = inject(TEST_TOKEN, {optional: true}) ?? 'null';
|
||||
const el = document.createElement('div');
|
||||
el.id = 'foreign-el';
|
||||
el.textContent = value;
|
||||
return [[el]];
|
||||
});
|
||||
|
||||
class ProviderDirective {
|
||||
static ɵfac = () => new ProviderDirective();
|
||||
static ɵdir = ɵɵdefineDirective({
|
||||
type: ProviderDirective,
|
||||
selectors: [['', 'provider-dir', '']],
|
||||
features: [ɵɵProvidersFeature([{provide: TEST_TOKEN, useValue: 'templated-value'}])],
|
||||
});
|
||||
}
|
||||
|
||||
const fixture = new ViewFixture({
|
||||
decls: 2,
|
||||
vars: 0,
|
||||
consts: [['provider-dir', '']],
|
||||
directives: [ProviderDirective],
|
||||
create: () => {
|
||||
ɵɵelementStart(0, 'div', 0);
|
||||
ɵɵforeignComponent(1, foreignComp);
|
||||
ɵɵelementEnd();
|
||||
},
|
||||
});
|
||||
|
||||
expect(fixture.host.innerHTML).toContain('<div id="foreign-el">templated-value</div>');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue