From b036e3c8249d2aea5a6b852fcbb7962e0bff909a Mon Sep 17 00:00:00 2001 From: leonsenft Date: Thu, 21 May 2026 16:09:44 -0700 Subject: [PATCH 1/4] refactor(core): add dev mode descriptions to foreign view boundaries Add descriptive text to foreign view head and tail comments in dev mode to assist in debugging. --- packages/core/src/render3/foreign_view.ts | 8 ++++++-- packages/core/test/render3/foreign_view_spec.ts | 12 +++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/core/src/render3/foreign_view.ts b/packages/core/src/render3/foreign_view.ts index 359577ec5379..c610ef7303d9 100644 --- a/packages/core/src/render3/foreign_view.ts +++ b/packages/core/src/render3/foreign_view.ts @@ -106,8 +106,12 @@ export function createForeignView(lContainer: LContainer, index: number): Foreig // 5. "Render" the view by creating the head and tail nodes, populating their slots, and marking // the view as created. This last step is normally handled by `renderView()` for native Angular // views with template functions. - const headComment = (lView[headTNode.index] = renderer.createComment('')); - const tailComment = (lView[tailTNode.index] = renderer.createComment('')); + const headComment = (lView[headTNode.index] = renderer.createComment( + ngDevMode ? 'foreign-view-head' : '', + )); + const tailComment = (lView[tailTNode.index] = renderer.createComment( + ngDevMode ? 'foreign-view-tail' : '', + )); lView[FLAGS] &= ~LViewFlags.CreationMode; // 6. Insert the view into the container diff --git a/packages/core/test/render3/foreign_view_spec.ts b/packages/core/test/render3/foreign_view_spec.ts index 43125c1bb333..452aa9195ba0 100644 --- a/packages/core/test/render3/foreign_view_spec.ts +++ b/packages/core/test/render3/foreign_view_spec.ts @@ -123,7 +123,17 @@ describe('foreign views', () => { vcr2.insert(viewRef); expect(fixture.nativeElement.innerHTML).toBe( - '
', + '' + + '
' + + // First
container + '' + + '
' + + // Foreign view + '' + + '' + + '' + + // Second
container + '', ); }); From acbfada7e796958d0f7e6a4f99b7266d47549a14 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Thu, 21 May 2026 16:09:59 -0700 Subject: [PATCH 2/4] =?UTF-8?q?refactor(core):=20implement=20=C9=B5=C9=B5f?= =?UTF-8?q?oreignComponent=20instruction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../test/ngtsc/template_typecheck_spec.ts | 8 +- .../core/src/interface/foreign_component.ts | 17 +- packages/core/src/metadata/directives.ts | 2 +- .../core/src/render3/dom_node_manipulation.ts | 2 +- packages/core/src/render3/foreign_import.ts | 5 +- .../render3/instructions/foreign_component.ts | 68 ++++++- .../test/render3/foreign_component_spec.ts | 174 ++++++++++++++++++ 7 files changed, 261 insertions(+), 15 deletions(-) create mode 100644 packages/core/test/render3/foreign_component_spec.ts diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 6024b1bd862d..1cf9fbfefd2c 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -2190,11 +2190,11 @@ runInEachFileSystem(() => { const foreignSetupCode = ` // We must redeclare foreignImports and ForeignComponent to test them since they are marked @internal. declare module '@angular/core' { - export interface ForeignComponent {} - export function foreignImport(render: Function): ForeignComponent; + export interface ForeignComponent {} + export function foreignImport(render: (props: TProps) => any): ForeignComponent; interface Component { - foreignImports?: ForeignComponent[]; + foreignImports?: ForeignComponent[]; } } @@ -2203,7 +2203,7 @@ runInEachFileSystem(() => { function FancyButton() {} - function frameworkImport(component: unknown): ForeignComponent { + function frameworkImport(component: unknown): ForeignComponent { return foreignImport(() => {}); } `; diff --git a/packages/core/src/interface/foreign_component.ts b/packages/core/src/interface/foreign_component.ts index 81025f90a5f5..25c7dc14e84e 100644 --- a/packages/core/src/interface/foreign_component.ts +++ b/packages/core/src/interface/foreign_component.ts @@ -9,9 +9,22 @@ /** Symbol used to store and retrieve the render function for a foreign component. */ export const RENDER: unique symbol = Symbol('RENDER'); +/** + * 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 callback to perform + * any necessary cleanup when the component is destroyed. + * + * @template TProps The properties of the foreign component. + */ +export type ForeignRenderFn = (props: TProps) => [Node[], VoidFunction?]; + /** * 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]: Function; +export interface ForeignComponent { + readonly [RENDER]: ForeignRenderFn; } diff --git a/packages/core/src/metadata/directives.ts b/packages/core/src/metadata/directives.ts index 11c923115a82..681337355c2b 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 // 3p-only */ - foreignImports?: ForeignComponent[]; + foreignImports?: ForeignComponent[]; /** * The `deferredImports` property specifies a standalone component's template dependencies, diff --git a/packages/core/src/render3/dom_node_manipulation.ts b/packages/core/src/render3/dom_node_manipulation.ts index d7258756843b..ea7f47609bc1 100644 --- a/packages/core/src/render3/dom_node_manipulation.ts +++ b/packages/core/src/render3/dom_node_manipulation.ts @@ -46,7 +46,7 @@ export function createElementNode( */ export function nativeInsertBefore( renderer: Renderer, - parent: RElement, + parent: RNode, child: RNode, beforeNode: RNode | null, isMove: boolean, diff --git a/packages/core/src/render3/foreign_import.ts b/packages/core/src/render3/foreign_import.ts index d8d6fa5bb2cb..8bfd6a28fcce 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 4d4958ba34a7..d633557d5438 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -6,7 +6,21 @@ * 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 {nativeInsertBefore} from '../dom_node_manipulation'; +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'; +import {Renderer} from '../interfaces/renderer'; +import {RNode} from '../interfaces/renderer_dom'; /** * Creation phase instruction to render a foreign component. @@ -16,10 +30,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] as 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 as RNode; + const parent = tail.parentNode; + if (parent) { + for (let i = 0; i < nodes.length; i++) { + nativeInsertBefore(renderer, parent, nodes[i], tail, false); + } + } + + // 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 000000000000..d938ec531f57 --- /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
'); + }); +}); From b5a3f96058f587b6195dd226669df9082b8f4c2e Mon Sep 17 00:00:00 2001 From: leonsenft Date: Tue, 26 May 2026 09:32:07 -0700 Subject: [PATCH 3/4] fix(compiler-cli): allow passing uninvoked signals as foreign component props Avoid triggering the `interpolated_signal_not_invoked` diagnostic when a signal is passed directly as a property binding to a foreign component. Foreign components may accept signals directly, so they should not be flagged as uninvoked in this context. To support testing this, the typecheck testing infrastructure was updated to allow defining mock foreign components in the test setup. --- .../interpolated_signal_not_invoked/index.ts | 4 ++ .../interpolated_signal_not_invoked_spec.ts | 32 ++++++++++++ .../src/ngtsc/typecheck/testing/index.ts | 50 ++++++++++++++++--- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts index 5060321e19a2..77f329a14a85 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts @@ -61,6 +61,10 @@ class InterpolatedSignalCheck extends TemplateCheckWithVisitor 0) { + // Allow signals to be passed directly to foreign components, without invocation. + if (ctx.templateTypeChecker.getForeignComponent(component, node) !== null) { + return []; + } const directivesOfElement = ctx.templateTypeChecker.getDirectivesOfNode(component, node); return node.inputs.flatMap((input) => checkBoundAttribute(ctx, component, directivesOfElement, input), diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts index 342ea892d915..260dc446780d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts @@ -353,6 +353,38 @@ runInEachFileSystem(() => { expect(diags.length).toBe(0); }); + it('should not produce a warning when a signal is not invoked in a property binding on a foreign component', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: { + 'TestCmp': ``, + }, + source: ` + import {signal} from '@angular/core'; + + export function FancyButton() {} + + export class TestCmp { + mySignal = signal(false); + }`, + foreignComponents: ['FancyButton'], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, + program.getTypeChecker(), + [interpolatedSignalFactory], + {}, + /* options */ + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + expect(diags.length).toBe(0); + }); + it('should produce a warning when a signal in a nested property read is not invoked', () => { const fileName = absoluteFrom('/main.ts'); const {program, templateTypeChecker} = setup([ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts index 03de2753259e..e2a65f0552bf 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts @@ -12,6 +12,7 @@ import { ClassPropertyMapping, CssSelector, DomSchemaChecker, + ForeignComponentMeta, MatchSource, OutOfBandDiagnosticRecorder, ParseSourceFile, @@ -78,6 +79,7 @@ import { AmbientImport, ClassDeclaration, isNamedClassDeclaration, + isNamedFunctionDeclaration, TypeScriptReflectionHost, } from '../../reflection'; import { @@ -362,6 +364,7 @@ export function tcb( config?: Partial, options?: {emitSpans?: boolean}, templateParserOptions?: ParseTemplateOptions, + foreignComponents: string[] = [], ): string { const codeLines = [ 'declare const ɵNgFieldDirective: unique symbol;', @@ -398,13 +401,15 @@ export function tcb( throw new Error('Template parse errors: \n' + errors.join('\n')); } - const {matcher, pipes} = prepareDeclarations( + const {matcher, pipes, foreignMatcher} = prepareDeclarations( declarations, (decl) => getClass(sf, decl.name), new Map(), selectorlessEnabled, + foreignComponents, + (name) => getFunction(sf, name), ); - const binder = new R3TargetBinder(matcher); + const binder = new R3TargetBinder(matcher, foreignMatcher); const boundTarget = binder.bind({template: nodes}); const id = 'tcb' as TypeCheckId; @@ -503,6 +508,11 @@ export interface TypeCheckingTarget { * components in this file. */ declarations?: TestDeclaration[]; + + /** + * Names of foreign components that are available in the template scope. + */ + foreignComponents?: string[]; } /** @@ -622,6 +632,7 @@ export function setup( } const declarations = target.declarations ?? []; + const foreignComponents = target.foreignComponents ?? []; for (const className of Object.keys(target.templates)) { const classDecl = getClass(sf, className); @@ -633,7 +644,7 @@ export function setup( throw new Error('Template parse errors: \n' + errors.join('\n')); } - const {matcher, pipes} = prepareDeclarations( + const {matcher, pipes, foreignMatcher} = prepareDeclarations( declarations, (decl) => { let declFile = sf; @@ -647,8 +658,10 @@ export function setup( }, fakeMetadataRegistry, overrides.parseOptions?.enableSelectorless ?? false, + foreignComponents, + (name) => getFunction(sf, name), ); - const binder = new R3TargetBinder(matcher); + const binder = new R3TargetBinder(matcher, foreignMatcher); const classRef = new Reference(classDecl); const templateContext: TemplateContext = { nodes, @@ -820,6 +833,8 @@ function prepareDeclarations( resolveDeclaration: DeclarationResolver, metadataRegistry: Map, selectorlessEnabled: boolean, + foreignComponentNames: string[] = [], + resolveForeignComponent: (name: string) => ClassDeclaration, ) { const pipes = new Map(); const hostDirectiveResolder = new HostDirectivesResolver( @@ -850,6 +865,17 @@ function prepareDeclarations( } } + const foreignRegistry = new Map(); + for (const name of foreignComponentNames) { + foreignRegistry.set(name, [ + { + name, + ref: new Reference(resolveForeignComponent(name)), + }, + ]); + } + const foreignMatcher = new SelectorlessMatcher(foreignRegistry); + // We need to make two passes over the directives so that all declarations // have been registered by the time we resolve the host directives. @@ -858,7 +884,7 @@ function prepareDeclarations( for (const meta of directives) { registry.set(meta.name, [meta, ...hostDirectiveResolder.resolve(meta)]); } - return {matcher: new SelectorlessMatcher(registry), pipes}; + return {matcher: new SelectorlessMatcher(registry), pipes, foreignMatcher}; } else { const matcher = new SelectorMatcher(); for (const meta of directives) { @@ -867,7 +893,7 @@ function prepareDeclarations( matcher.addSelectables(selector, matches); } - return {matcher, pipes}; + return {matcher, pipes, foreignMatcher}; } } @@ -880,6 +906,18 @@ export function getClass(sf: ts.SourceFile, name: string): ClassDeclaration { + for (const stmt of sf.statements) { + if (isNamedFunctionDeclaration(stmt) && stmt.name.text === name) { + return stmt; + } + } + throw new Error(`Function ${name} not found in file: ${sf.fileName}`); +} + function getDirectiveMetaFromDeclaration( decl: TestDirective, resolveDeclaration: DeclarationResolver, From d1c515db3c22896daeb55f614f5509b02c7383f0 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Wed, 27 May 2026 10:43:31 -0700 Subject: [PATCH 4/4] fix(core): set current tnode in foreign component instruction on reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the `ɵɵforeignComponent` instruction set the `currentTNode` state during the first template creation pass (via `getOrCreateTNode`), but failed to do so on subsequent instantiations when the `TNode` was accessed from cache. This resulted in the global `currentTNode` state remaining unchanged from the previous instruction. When closing a parent element (e.g., via `ɵɵelementEnd`), this mismatched state caused assertion failures because the framework attempted to close the wrong parent node. This change fixes the issue by calling `setCurrentTNode(tNode, false)` when the foreign component's `TNode` is retrieved from the cache. --- .../render3/instructions/foreign_component.ts | 7 ++- .../test/render3/foreign_component_spec.ts | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/core/src/render3/instructions/foreign_component.ts b/packages/core/src/render3/instructions/foreign_component.ts index d633557d5438..852312983b21 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -13,7 +13,7 @@ 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 {getLView, getTView, setCurrentTNode, setCurrentTNodeAsNotParent} from '../state'; import {getOrCreateTNode} from '../tnode_manipulation'; import {addToEndOfViewTree} from '../view/construction'; import {createLContainer} from '../view/container'; @@ -43,11 +43,12 @@ export function ɵɵforeignComponent( let tNode: TContainerNode; if (tView.firstCreatePass) { tNode = getOrCreateTNode(tView, adjustedIndex, TNodeType.Container, null, null); + // `getOrCreateTNode` unconditionally sets the current node as a parent node, which it is not. + setCurrentTNodeAsNotParent(); } else { tNode = tView.data[adjustedIndex] as TContainerNode; + setCurrentTNode(tNode, false); } - // `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] as Renderer; diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts index d938ec531f57..fd4308f86c89 100644 --- a/packages/core/test/render3/foreign_component_spec.ts +++ b/packages/core/test/render3/foreign_component_spec.ts @@ -14,6 +14,9 @@ import {ɵɵelement, ɵɵelementEnd, ɵɵelementStart} from '../../src/render3/i import {inject, InjectionToken} from '../../src/di'; import {ɵɵdefineDirective} from '../../src/render3/definition'; import {ɵɵProvidersFeature} from '../../src/render3/features/providers_feature'; +import {createLView} from '../../src/render3/view/construction'; +import {renderView} from '../../src/render3/instructions/render'; +import {LView, LViewFlags, PARENT, RENDERER, T_HOST} from '../../src/render3/interfaces/view'; describe('ɵɵforeignComponent', () => { afterEach(ViewFixture.cleanUp); @@ -171,4 +174,58 @@ describe('ɵɵforeignComponent', () => { expect(fixture.host.innerHTML).toContain('
templated-value
'); }); + + it('should support reusing the same template between multiple view instances', () => { + const foreignComp1 = foreignImport(() => { + return [[document.createTextNode('foreign content')]]; + }); + + const createFn = () => { + ɵɵelementStart(0, 'div'); + ɵɵforeignComponent(1, foreignComp1); + ɵɵelementEnd(); + }; + const expectedHtml = + '' + + '
' + + 'foreign content' + + '' + + '
'; + + const fixture = new ViewFixture({ + decls: 2, + vars: 0, + create: createFn, + }); + expect(fixture.host.innerHTML).toContain(expectedHtml); + + // Create second instance reusing the TView + const host2 = renderSecondInstance(fixture); + expect(fixture.host.innerHTML).toContain(expectedHtml); + expect(host2.innerHTML).toContain(expectedHtml); + }); }); + +function renderSecondInstance(fixture: ViewFixture): HTMLElement { + const hostLView = fixture.lView[PARENT] as LView; + const hostTNode = fixture.lView[T_HOST]; + const hostRenderer = hostLView[RENDERER]; + const host = hostRenderer.createElement('host-element') as HTMLElement; + + const lView = createLView( + hostLView, + fixture.tView, + {}, + LViewFlags.CheckAlways, + host, + hostTNode, + null, + null, + null, + null, + null, + ); + + renderView(fixture.tView, lView, {}); + return host; +}