diff --git a/packages/core/src/interface/foreign_component.ts b/packages/core/src/interface/foreign_component.ts index 81025f90a5f..f03916671b1 100644 --- a/packages/core/src/interface/foreign_component.ts +++ b/packages/core/src/interface/foreign_component.ts @@ -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 = (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 { + readonly [RENDER]: ForeignRenderFn; } diff --git a/packages/core/src/metadata/directives.ts b/packages/core/src/metadata/directives.ts index dea9012cddd..955c1182924 100644 --- a/packages/core/src/metadata/directives.ts +++ b/packages/core/src/metadata/directives.ts @@ -648,7 +648,7 @@ export interface Component extends Directive { * * @internal */ - foreignImports?: ForeignComponent[]; + foreignImports?: ForeignComponent[]; /** * The `deferredImports` property specifies a standalone component's template dependencies, diff --git a/packages/core/src/render3/foreign_import.ts b/packages/core/src/render3/foreign_import.ts index d8d6fa5bb2c..8bfd6a28fcc 100644 --- a/packages/core/src/render3/foreign_import.ts +++ b/packages/core/src/render3/foreign_import.ts @@ -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(render: ForeignRenderFn): ForeignComponent { return {[RENDER]: render}; } diff --git a/packages/core/src/render3/instructions/foreign_component.ts b/packages/core/src/render3/instructions/foreign_component.ts index 4d4958ba34a..4597d4a4902 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -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( +export function ɵɵforeignComponent( index: number, - foreignComponent: ForeignComponent, - props: TProps, + foreignComponent: ForeignComponent, + 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); + } } diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts new file mode 100644 index 00000000000..d938ec531f5 --- /dev/null +++ b/packages/core/test/render3/foreign_component_spec.ts @@ -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('
Foreign Content
'); + }); + + 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( + '' + + '

' + + '' + + '
Foreign Content
' + + '' + + '' + + '', + ); + }); + + 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( + '' + + '
' + + '' + + 'Foreign Content' + + '' + + '' + + '
', + ); + }); + + it('should execute the RENDER function inside the template injection context', () => { + const TEST_TOKEN = new InjectionToken('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('
templated-value
'); + }); +});