From fe8f6a55bab012a4b34ce4202859bf861faabb4a Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Wed, 25 Mar 2026 17:05:13 -0700 Subject: [PATCH 1/6] refactor(core): create ViewRef and public APIs for foreign views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates the foreign view mechanism to support standard ViewContainerRef using a dedicated ɵForeignViewRef implementation. Updates tests to use public APIs (vcr.detach and vcr.insert), asserts on total innerHTML, and exports ɵForeignViewRef privately from core. --- .../core/src/core_render3_private_export.ts | 1 + .../core/src/render3/collect_native_nodes.ts | 17 +- packages/core/src/render3/foreign_view.ts | 142 ++++++++++++++++ packages/core/src/render3/interfaces/view.ts | 5 + .../core/src/render3/node_manipulation.ts | 57 ++++++- .../core/test/render3/foreign_view_spec.ts | 159 ++++++++++++++++++ 6 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/render3/foreign_view.ts create mode 100644 packages/core/test/render3/foreign_view_spec.ts diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 207367fb41a8..4e46f59570a9 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -32,6 +32,7 @@ export { NgModuleTransitiveScopes as ɵNgModuleTransitiveScopes, } from './metadata/ng_module_def'; export {AfterRenderManager as ɵAfterRenderManager} from './render3/after_render/manager'; +export {ɵForeignViewRef, ɵcreateAndInsertForeignView} from './render3/foreign_view'; export {inferTagNameFromDefinition as ɵinferTagNameFromDefinition} from './render3/component_ref'; export {getLContext as ɵgetLContext} from './render3/context_discovery'; export {depsTracker as ɵdepsTracker} from './render3/deps_tracker/deps_tracker'; diff --git a/packages/core/src/render3/collect_native_nodes.ts b/packages/core/src/render3/collect_native_nodes.ts index 25690865e860..f4ac1e5c83a7 100644 --- a/packages/core/src/render3/collect_native_nodes.ts +++ b/packages/core/src/render3/collect_native_nodes.ts @@ -12,7 +12,7 @@ import {CONTAINER_HEADER_OFFSET, LContainer, NATIVE} from './interfaces/containe import {TIcuContainerNode, TNode, TNodeType} from './interfaces/node'; import {RNode} from './interfaces/renderer_dom'; import {isLContainer} from './interfaces/type_checks'; -import {DECLARATION_COMPONENT_VIEW, HOST, LView, TVIEW, TView} from './interfaces/view'; +import {DECLARATION_COMPONENT_VIEW, HOST, LView, TVIEW, TView, TViewType} from './interfaces/view'; import {assertTNodeType} from './node_assert'; import {getProjectionNodes} from './node_manipulation'; import {getLViewParent, unwrapRNode} from './util/view_utils'; @@ -24,6 +24,21 @@ export function collectNativeNodes( result: any[], isProjection: boolean = false, ): any[] { + if (tView.type === TViewType.Foreign) { + const headTNode = tView.firstChild!; + const tailTNode = headTNode.next!; + const head = unwrapRNode(lView[headTNode.index]); + const tail = unwrapRNode(lView[tailTNode.index]); + + let current: RNode | null = head; + while (current !== null) { + result.push(current); + if (current === tail) break; + current = current.nextSibling; + } + return result; + } + while (tNode !== null) { // Let declarations don't have corresponding DOM nodes so we skip over them. if (tNode.type === TNodeType.LetDeclaration) { diff --git a/packages/core/src/render3/foreign_view.ts b/packages/core/src/render3/foreign_view.ts new file mode 100644 index 000000000000..a86a1d7a917e --- /dev/null +++ b/packages/core/src/render3/foreign_view.ts @@ -0,0 +1,142 @@ +/** + * @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 {createLView, createTView} from './view/construction'; +import {createTNode} from './tnode_manipulation'; +import { + FLAGS, + HEADER_OFFSET, + LView, + LViewFlags, + PARENT, + RENDERER, + TViewType, + T_HOST, + TVIEW, +} from './interfaces/view'; +import {TNodeType} from './interfaces/node'; +import {LContainer} from './interfaces/container'; +import {addLViewToLContainer} from './view/container'; +import {isLContainer} from './interfaces/type_checks'; +import {ViewContainerRef} from '../linker/view_container_ref'; +import {ViewRef} from './view_ref'; + +export class ɵForeignViewRef extends ViewRef { + get head(): any { + const lView = this._lView; + const tView = lView[TVIEW]; + return lView[tView.firstChild!.index]; + } + + get tail(): any { + const lView = this._lView; + const tView = lView[TVIEW]; + return lView[tView.firstChild!.next!.index]; + } +} + +/** + * Creates and inserts a foreign view context programmatically into a container. + * A foreign view is bounded by `head` and `tail` comment nodes and can hold dynamic nodes. + * + * @param container The target container (LContainer or ViewContainerRef) where the view will be inserted. + * @param index The index at which to insert the view. + */ +export function ɵcreateAndInsertForeignView( + container: LContainer | ViewContainerRef, + index: number, +): ɵForeignViewRef { + const lContainer = isLContainer(container) + ? container + : ((container as any)['_lContainer'] as LContainer); + const declLView = lContainer[PARENT] as LView; + const declTNode = lContainer[T_HOST]; + const renderer = declLView[RENDERER]; + + // 1. Create TView with 3 slots (head, tail, fragment) + const tView = createTView( + TViewType.Foreign, + declTNode, // link to container host + null, // templateFn (safe! Checked by renderView) + 3, // decls + 0, // vars + null, + null, + null, + null, + null, + null, + ); + + // 2. Create comment nodes themselves using parent's renderer + const headComment = renderer.createComment(''); + const tailComment = renderer.createComment(''); + + // 3. Create TNodes for head and tail to make them addressable + const headTNode = (tView.data[HEADER_OFFSET] = createTNode( + tView, + null, + TNodeType.Element, + HEADER_OFFSET, + '', + null, + )); + const tailTNode = (tView.data[HEADER_OFFSET + 1] = createTNode( + tView, + null, + TNodeType.Element, + HEADER_OFFSET + 1, + '', + null, + )); + + // 4. Link them in the view + tView.firstChild = headTNode; + headTNode.next = tailTNode; + tailTNode.prev = headTNode; + + // 5. Create LView + const lView = createLView( + declLView, + tView, + null, + 0 as LViewFlags, // Do not set CheckAlways for foreign views + null, + null, + null, + renderer, // pass it through + null, + null, + null, + ); + + lView[FLAGS] &= ~LViewFlags.CreationMode; + + // 6. Populate slots with the created comment nodes + lView[headTNode.index] = headComment; + lView[tailTNode.index] = tailComment; + + // 7. Insert the view into the container + const viewRef = new ɵForeignViewRef(lView); + if (isLContainer(container)) { + addLViewToLContainer(container, lView, index); + } else { + // When using ViewContainerRef, go through standard public insert() to register in VIEW_REFS cache! + container.insert(viewRef, index); + } + + if (!headComment.parentNode) { + const fragment = document.createDocumentFragment(); + fragment.appendChild(headComment as unknown as Comment); + fragment.appendChild(tailComment as unknown as Comment); + const fragmentSlotIndex = tailTNode.index + 1; + lView[fragmentSlotIndex] = fragment; + } + + return viewRef; +} diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 6b603c9b51aa..e367206094e5 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -599,6 +599,11 @@ export const enum TViewType { * can have zero or more `Embedded` `TView`s. */ Embedded = 2, + + /** + * Foreign `TView` associated with a range of nodes between `head` and `tail` comment nodes. + */ + Foreign = 3, } /** diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index ea147f04bf79..275d13058908 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -86,7 +86,7 @@ import {cancelLeavingNodes, reusedNodes, trackLeavingNodes} from '../animation/u import {Injector} from '../di'; import {maybeQueueEnterAnimation, runLeaveAnimationsWithCallback} from './node_animations'; -const enum WalkTNodeTreeAction { +export const enum WalkTNodeTreeAction { /** node create in the native environment. Run on initial creation. */ Create = 0, @@ -871,7 +871,7 @@ function applyNodes( * @param parentRElement parent DOM element for insertion (Removal does not need it). * @param beforeNode Before which node the insertions should happen. */ -function applyView( +export function applyView( tView: TView, lView: LView, renderer: Renderer, @@ -879,7 +879,7 @@ function applyView( parentRElement: null, beforeNode: null, ): void; -function applyView( +export function applyView( tView: TView, lView: LView, renderer: Renderer, @@ -887,7 +887,7 @@ function applyView( parentRElement: RElement | null, beforeNode: RNode | null, ): void; -function applyView( +export function applyView( tView: TView, lView: LView, renderer: Renderer, @@ -895,7 +895,54 @@ function applyView( parentRElement: RElement | null, beforeNode: RNode | null, ): void { - applyNodes(renderer, action, tView.firstChild, lView, parentRElement, beforeNode, false); + if (tView.type === TViewType.Foreign) { + applyForeignNodes(renderer, action, lView, parentRElement, beforeNode); + } else { + applyNodes(renderer, action, tView.firstChild, lView, parentRElement, beforeNode, false); + } +} + +function applyForeignNodes( + renderer: Renderer, + action: WalkTNodeTreeAction, + lView: LView, + parent: RElement | null, + beforeNode: RNode | null, +) { + const tView = lView[TVIEW]; + const headTNode = tView.firstChild!; + const tailTNode = headTNode.next!; + const head = unwrapRNode(lView[headTNode.index]); + const tail = unwrapRNode(lView[tailTNode.index]); + + const fragmentSlotIndex = tailTNode.index + 1; + let fragment = lView[fragmentSlotIndex] as any; + + if (action === WalkTNodeTreeAction.Insert || action === WalkTNodeTreeAction.Create) { + if (parent !== null) { + if (fragment && fragment.hasChildNodes()) { + nativeInsertBefore(renderer, parent, fragment, beforeNode, true); + } else { + nativeInsertBefore(renderer, parent, head!, beforeNode, true); + nativeInsertBefore(renderer, parent, tail!, beforeNode, true); + } + } + } else if (action === WalkTNodeTreeAction.Detach) { + if (!fragment) { + fragment = document.createDocumentFragment(); + lView[fragmentSlotIndex] = fragment; + } + if (head && head.parentNode === fragment) { + return; + } + let current: RNode | null = head; + while (current !== null) { + const next: RNode | null = current.nextSibling; + fragment.appendChild(current); + if (current === tail) break; + current = next; + } + } } /** diff --git a/packages/core/test/render3/foreign_view_spec.ts b/packages/core/test/render3/foreign_view_spec.ts new file mode 100644 index 000000000000..e567ac39cfe0 --- /dev/null +++ b/packages/core/test/render3/foreign_view_spec.ts @@ -0,0 +1,159 @@ +/** + * @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 {Component, Directive, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; +import {TestBed} from '../../testing'; +import {ɵcreateAndInsertForeignView} from '../../src/render3/foreign_view'; +import {HEADER_OFFSET, TVIEW, TViewType} from '../../src/render3/interfaces/view'; + +@Component({ + template: ` +
+
+ `, + standalone: true, +}) +class TestComponent { + @ViewChild('container', {read: ViewContainerRef, static: true}) vcr!: ViewContainerRef; + @ViewChild('container2', {read: ViewContainerRef, static: true}) vcr2!: ViewContainerRef; +} + +@Directive({ + selector: '[foreign]', + standalone: true, +}) +class ForeignDirective { + constructor(vcr: ViewContainerRef) { + const viewRef = ɵcreateAndInsertForeignView(vcr, 0); + const head = viewRef.head; + + const span = document.createElement('span'); + span.textContent = 'foreign node'; + head.after(span); + } +} + +@Component({ + template: ` + + + +
+ `, + standalone: true, + imports: [ForeignDirective], +}) +class TestTemplateComponent { + @ViewChild('tmpl', {read: TemplateRef, static: true}) tmpl!: TemplateRef; + @ViewChild('target', {read: ViewContainerRef, static: true}) target!: ViewContainerRef; +} + +describe('foreign views', () => { + it('should create a foreign view inside a ViewContainerRef', () => { + TestBed.configureTestingModule({}); + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + const vcr = fixture.componentInstance.vcr; + expect(vcr).toBeDefined(); + + const viewRef = ɵcreateAndInsertForeignView(vcr, 0); + const foreignLView = (viewRef as any)._lView; // internal LView! + + expect(foreignLView).toBeDefined(); + const tView = foreignLView[TVIEW]; + expect(tView.type).toBe(TViewType.Foreign); + + // Verify slots + const headTNode = tView.firstChild!; + expect(headTNode.index).toBe(HEADER_OFFSET); + + const tailTNode = headTNode.next!; + expect(tailTNode.index).toBe(HEADER_OFFSET + 1); + + const headComment = foreignLView[headTNode.index]; + expect(headComment).toBeDefined(); + + const tailComment = foreignLView[tailTNode.index]; + expect(tailComment).toBeDefined(); + }); + + it('should support moving foreign views between containers', () => { + TestBed.configureTestingModule({}); + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + const vcr = fixture.componentInstance.vcr; + const vcr2 = fixture.componentInstance.vcr2; + expect(vcr).toBeDefined(); + expect(vcr2).toBeDefined(); + + const viewRef = ɵcreateAndInsertForeignView(vcr, 0); + const foreignLView = (viewRef as any)._lView; // internal LView! + + const tView = foreignLView[TVIEW]; + const tailTNode = tView.firstChild!.next!; + const headComment = foreignLView[tView.firstChild!.index]; + const tailComment = foreignLView[tailTNode.index]; + + // Mock some foreign elements between head and tail + const doc = (foreignLView[0] as any).ownerDocument; + const span1 = doc.createElement('span'); + const span2 = doc.createElement('span'); + const parentNode = (headComment as any).parentNode; + + parentNode.insertBefore(span1, tailComment); + parentNode.insertBefore(span2, tailComment); + + const initialParentCount = parentNode.childNodes.length; + + // 1. Detach view using public API + const retrievedViewRef = vcr.get(0)!; + expect(retrievedViewRef).toBeDefined(); + vcr.detach(0); + + expect(parentNode.childNodes.length).toBe(initialParentCount - 4); // minus head, span1, span2, tail + + // 2. Reattach view to container2 + vcr2.insert(viewRef); + + expect(fixture.nativeElement.innerHTML).toBe( + '
', + ); + }); + + it('should not break when triggering change detection on host', () => { + TestBed.configureTestingModule({}); + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + const vcr = fixture.componentInstance.vcr; + ɵcreateAndInsertForeignView(vcr, 0); + + // Trigger change detection again - should not throw + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should support foreign views inside ng-template', () => { + TestBed.configureTestingModule({}); + const fixture = TestBed.createComponent(TestTemplateComponent); + fixture.detectChanges(); + + const comp = fixture.componentInstance; + const viewRef = comp.target.createEmbeddedView(comp.tmpl); + fixture.detectChanges(); + + // Verify the foreign element is there + expect(fixture.nativeElement.innerHTML).toContain('foreign node'); + + // Verify rootNodes of the outer embedded view includes the span! + const rootNodes = viewRef.rootNodes; + const hasSpan = rootNodes.some((node: any) => node.nodeName === 'SPAN'); + expect(hasSpan).toBeTrue(); + }); +}); From e4c27247c528702e9cfa20abed936ba6d26715d2 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Wed, 22 Apr 2026 12:58:56 -0700 Subject: [PATCH 2/6] test(core): add new symbol to bundle goldens This adds the new (internal) `applyForeignNodes()` function to the bundle goldens. --- .../bundling/animations-standalone/bundle.golden_symbols.json | 1 + .../test/bundling/create_component/bundle.golden_symbols.json | 1 + packages/core/test/bundling/defer/bundle.golden_symbols.json | 1 + .../core/test/bundling/forms_reactive/bundle.golden_symbols.json | 1 + .../bundling/forms_template_driven/bundle.golden_symbols.json | 1 + packages/core/test/bundling/hydration/bundle.golden_symbols.json | 1 + packages/core/test/bundling/router/bundle.golden_symbols.json | 1 + .../bundling/standalone_bootstrap/bundle.golden_symbols.json | 1 + 8 files changed, 8 insertions(+) diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index dc46be2b2d32..4a87a8b60a3a 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -328,6 +328,7 @@ "animationFailed", "appendChild", "applyContainer", + "applyForeignNodes", "applyNodes", "applyParamDefaults", "applyProjectionRecursive", diff --git a/packages/core/test/bundling/create_component/bundle.golden_symbols.json b/packages/core/test/bundling/create_component/bundle.golden_symbols.json index 86902ed85fc3..233707d9c15d 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -254,6 +254,7 @@ "angularZoneInstanceIdProperty", "appendChild", "applyContainer", + "applyForeignNodes", "applyNodes", "applyProjectionRecursive", "applyRootElementTransform", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 7264dacfe691..8a605a751920 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -300,6 +300,7 @@ "applyContainer", "applyDeferBlockState", "applyDeferBlockStateWithSchedulingImpl", + "applyForeignNodes", "applyNodes", "applyProjectionRecursive", "applyRootElementTransform", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index e76c856d1d47..89e513db10e6 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -371,6 +371,7 @@ "angularZoneInstanceIdProperty", "appendChild", "applyContainer", + "applyForeignNodes", "applyNodes", "applyProjectionRecursive", "applyRootElementTransform", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index e2370aaf01ee..6d28e8fe5a0d 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -372,6 +372,7 @@ "angularZoneInstanceIdProperty", "appendChild", "applyContainer", + "applyForeignNodes", "applyNodes", "applyProjectionRecursive", "applyRootElementTransform", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index b1b3dd417e90..ca572325a378 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -370,6 +370,7 @@ "applyContainer", "applyDeferBlockState", "applyDeferBlockStateWithSchedulingImpl", + "applyForeignNodes", "applyNodes", "applyProjectionRecursive", "applyRootElementTransform", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 1a7444bfed72..40019c115bcd 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -409,6 +409,7 @@ "angularZoneInstanceIdProperty", "appendChild", "applyContainer", + "applyForeignNodes", "applyNodes", "applyProjectionRecursive", "applyRootElementTransform", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 760f22a9a0a1..ff29b5cefe3a 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -235,6 +235,7 @@ "angularZoneInstanceIdProperty", "appendChild", "applyContainer", + "applyForeignNodes", "applyNodes", "applyProjectionRecursive", "applyRootElementTransform", From 6da09fa4ededf7bb61f29e0b28ea9df890fc88d1 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Wed, 22 Apr 2026 14:55:33 -0700 Subject: [PATCH 3/6] fixup! refactor(core): create ViewRef and public APIs for foreign views --- packages/core/test/render3/foreign_view_spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/test/render3/foreign_view_spec.ts b/packages/core/test/render3/foreign_view_spec.ts index e567ac39cfe0..40ce29c03da1 100644 --- a/packages/core/test/render3/foreign_view_spec.ts +++ b/packages/core/test/render3/foreign_view_spec.ts @@ -6,9 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component, Directive, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; +import { + Component, + Directive, + TemplateRef, + ViewChild, + ViewContainerRef, + ɵcreateAndInsertForeignView, +} from '@angular/core'; import {TestBed} from '../../testing'; -import {ɵcreateAndInsertForeignView} from '../../src/render3/foreign_view'; import {HEADER_OFFSET, TVIEW, TViewType} from '../../src/render3/interfaces/view'; @Component({ @@ -16,7 +22,6 @@ import {HEADER_OFFSET, TVIEW, TViewType} from '../../src/render3/interfaces/view
`, - standalone: true, }) class TestComponent { @ViewChild('container', {read: ViewContainerRef, static: true}) vcr!: ViewContainerRef; @@ -25,7 +30,6 @@ class TestComponent { @Directive({ selector: '[foreign]', - standalone: true, }) class ForeignDirective { constructor(vcr: ViewContainerRef) { @@ -45,7 +49,6 @@ class ForeignDirective {
`, - standalone: true, imports: [ForeignDirective], }) class TestTemplateComponent { From a5af7d99081b12c2a96385e3c8012f2a230d8760 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Fri, 8 May 2026 11:53:38 -0700 Subject: [PATCH 4/6] feat(compiler): add support for importing foreign components - Added the ForeignComponent type in @angular/core. - Added Component.foreignImports for importing ForeignComponents. - Updated the compiler to handle ForeignComponent in template dependencies. - Updated ngtsc to extract foreignImports from standalone components. Note the compiler doesn't do anything yet with these imports. --- goldens/public-api/core/index.api.md | 6 + .../annotations/component/src/handler.ts | 26 ++++- .../annotations/component/src/metadata.ts | 1 + .../ngtsc/annotations/component/src/util.ts | 107 ++++++++++++++---- .../component/test/component_spec.ts | 44 ++++++- .../annotations/directive/src/handler.ts | 1 + .../src/ngtsc/metadata/src/api.ts | 8 ++ .../src/ngtsc/metadata/src/dts.ts | 1 + .../src/ngtsc/scope/test/local_spec.ts | 1 + .../src/ngtsc/typecheck/testing/index.ts | 1 + .../compiler/src/render3/partial/component.ts | 2 + packages/compiler/src/render3/view/api.ts | 16 ++- packages/core/src/core.ts | 1 + .../core/src/interface/foreign_component.ts | 19 ++++ packages/core/src/metadata/directives.ts | 7 ++ 15 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/interface/foreign_component.ts diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index c440ad9f433d..15fcc934f72b 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -274,6 +274,7 @@ export interface Component extends Directive { animations?: any[]; changeDetection?: ChangeDetectionStrategy; encapsulation?: ViewEncapsulation; + foreignImports?: ForeignComponent[]; imports?: (Type | ReadonlyArray)[]; preserveWhitespaces?: boolean; schemas?: SchemaMetadata[]; @@ -744,6 +745,11 @@ export interface FactorySansProvider { useFactory: Function; } +// @public +export type ForeignComponent = T & { + ɵrender: Function; +}; + // @public export function forwardRef(forwardRefFn: ForwardRefFn): Type; diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index fb64b31e607a..2fc4a00df2a4 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -190,6 +190,7 @@ import { legacyAnimationTriggerResolver, collectLegacyAnimationNames, validateAndFlattenComponentImports, + validateAndFlattenForeignImports, } from './util'; import {getTemplateDiagnostics} from '../../../typecheck'; import {JitDeclarationRegistry} from '../../common/src/jit_declaration_registry'; @@ -599,16 +600,22 @@ export class ComponentDecoratorHandler implements DecoratorHandler< } let resolvedImports: Reference[] | null = null; + let resolvedForeignImports: Reference[] | null = null; let resolvedDeferredImports: Reference[] | null = null; let rawImports: ts.Expression | null = component.get('imports') ?? null; let rawDeferredImports: ts.Expression | null = component.get('deferredImports') ?? null; + let rawForeignImports: ts.Expression | null = component.get('foreignImports') ?? null; - if ((rawImports || rawDeferredImports) && !metadata.isStandalone) { + if ((rawImports || rawDeferredImports || rawForeignImports) && !metadata.isStandalone) { if (diagnostics === undefined) { diagnostics = []; } - const importsField = rawImports ? 'imports' : 'deferredImports'; + const importsField = rawImports + ? 'imports' + : rawDeferredImports + ? 'deferredImports' + : 'foreignImports'; diagnostics.push( makeDiagnostic( ErrorCode.COMPONENT_NOT_STANDALONE, @@ -627,7 +634,7 @@ export class ComponentDecoratorHandler implements DecoratorHandler< isPoisoned = true; } else if ( this.compilationMode !== CompilationMode.LOCAL && - (rawImports || rawDeferredImports) + (rawImports || rawDeferredImports || rawForeignImports) ) { const importResolvers = combineResolvers([ createModuleWithProvidersResolver(this.reflector, this.isCore), @@ -662,6 +669,17 @@ export class ComponentDecoratorHandler implements DecoratorHandler< rawDeferredImports = expr; } + if (rawForeignImports) { + const expr = rawForeignImports; + const imported = this.evaluator.evaluate(expr, importResolvers); + const {foreignImports: foreign, diagnostics} = validateAndFlattenForeignImports( + imported, + expr, + ); + importDiagnostics.push(...diagnostics); + resolvedForeignImports = foreign; + } + if (importDiagnostics.length > 0) { isPoisoned = true; if (diagnostics === undefined) { @@ -1025,6 +1043,7 @@ export class ComponentDecoratorHandler implements DecoratorHandler< legacyAnimationTriggerNames: legacyAnimationTriggerNames, rawImports, resolvedImports, + resolvedForeignImports, rawDeferredImports, resolvedDeferredImports, explicitlyDeferredTypes, @@ -1076,6 +1095,7 @@ export class ComponentDecoratorHandler implements DecoratorHandler< isStandalone: analysis.meta.isStandalone, isSignal: analysis.meta.isSignal, imports: analysis.resolvedImports, + foreignImports: analysis.resolvedForeignImports, rawImports: analysis.rawImports, deferredImports: analysis.resolvedDeferredImports, animationTriggerNames: analysis.legacyAnimationTriggerNames, diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts index 3d3f57f11916..ef55f733bc79 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts @@ -92,6 +92,7 @@ export interface ComponentAnalysisData { rawImports: ts.Expression | null; resolvedImports: Reference[] | null; + resolvedForeignImports: Reference[] | null; rawDeferredImports: ts.Expression | null; resolvedDeferredImports: Reference[] | null; diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/util.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/util.ts index 086018392c10..58c5fed79eab 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/util.ts @@ -19,7 +19,11 @@ import { ResolvedValueMap, SyntheticValue, } from '../../../partial_evaluator'; -import {ClassDeclaration, isNamedClassDeclaration} from '../../../reflection'; +import { + ClassDeclaration, + isNamedClassDeclaration, + isNamedFunctionDeclaration, +} from '../../../reflection'; import {createValueHasWrongTypeError, getOriginNodeForDiagnostics} from '../../common'; /** @@ -143,24 +147,12 @@ export function validateAndFlattenComponentImports( ), ); } else { - let diagnosticNode: ts.Node; - let diagnosticValue: ResolvedValue; - - // Reporting a diagnostic on the entire array can be noisy, especially if the user has a - // large array. Attempt to determine the most accurate position within the `imports` expression to report the - // diagnostic on. - if (ref instanceof DynamicValue && isWithinExpression(ref.node, expr)) { - // Use the dynamic value position itself if it occurs within the `imports` expression. - diagnosticNode = ref.node; - diagnosticValue = ref; - } else if (refExpr !== expr) { - // The reference comes from a specific element in `expr`, so use that element to report the diagnostic on. - diagnosticNode = refExpr; - diagnosticValue = ref; - } else { - diagnosticNode = expr; - diagnosticValue = imports; - } + const {node: diagnosticNode, value: diagnosticValue} = getDiagnosticOrigin( + ref, + expr, + refExpr, + imports, + ); diagnostics.push( createValueHasWrongTypeError(diagnosticNode, diagnosticValue, errorMessage).toDiagnostic(), @@ -171,6 +163,83 @@ export function validateAndFlattenComponentImports( return {imports: flattened, diagnostics}; } +export function validateAndFlattenForeignImports( + imports: ResolvedValue, + expr: ts.Expression, +): { + foreignImports: Reference[]; + diagnostics: ts.Diagnostic[]; +} { + const flattened: Reference[] = []; + const errorMessage = `'foreignImports' must be an array of ForeignComponents.`; + + if (!Array.isArray(imports)) { + const error = createValueHasWrongTypeError(expr, imports, errorMessage).toDiagnostic(); + return { + foreignImports: [], + diagnostics: [error], + }; + } + + const diagnostics: ts.Diagnostic[] = []; + + for (let i = 0; i < imports.length; i++) { + const ref = imports[i]; + let refExpr = expr; + if ( + ts.isArrayLiteralExpression(expr) && + expr.elements.length === imports.length && + !expr.elements.some(ts.isSpreadAssignment) + ) { + refExpr = expr.elements[i]; + } + + if (Array.isArray(ref)) { + const {foreignImports: childForeignImports, diagnostics: childDiagnostics} = + validateAndFlattenForeignImports(ref, refExpr); + flattened.push(...childForeignImports); + diagnostics.push(...childDiagnostics); + } else if (ref instanceof Reference && isNamedFunctionDeclaration(ref.node)) { + flattened.push(ref as Reference); + } else { + const {node: diagnosticNode, value: diagnosticValue} = getDiagnosticOrigin( + ref, + expr, + refExpr, + imports, + ); + + diagnostics.push( + createValueHasWrongTypeError(diagnosticNode, diagnosticValue, errorMessage).toDiagnostic(), + ); + } + } + + return {foreignImports: flattened, diagnostics}; +} + +/** + * Reporting a diagnostic on the entire array can be noisy, especially if the user has a + * large array. Attempt to determine the most accurate position within the array expression to report the + * diagnostic on. + */ +function getDiagnosticOrigin( + ref: ResolvedValue, + expr: ts.Expression, + refExpr: ts.Expression, + fallbackValue: ResolvedValue, +): {node: ts.Node; value: ResolvedValue} { + if (ref instanceof DynamicValue && isWithinExpression(ref.node, expr)) { + // Use the dynamic value position itself if it occurs within the expression. + return {node: ref.node, value: ref}; + } else if (refExpr !== expr) { + // The reference comes from a specific element in `expr`, so use that element to report the diagnostic on. + return {node: refExpr, value: ref}; + } else { + return {node: expr, value: fallbackValue}; + } +} + function isWithinExpression(node: ts.Node, expr: ts.Expression): boolean { let current: ts.Node | undefined = node; while (current !== undefined) { diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts index 00c116d8715a..0a87acc2990f 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts @@ -121,7 +121,7 @@ function setup( metaReader, scopeRegistry, { - getCanonicalFileName: (fileName) => fileName, + getCanonicalFileName: (fileName: string) => fileName, }, scopeRegistry, typeCheckScopeRegistry, @@ -1042,6 +1042,48 @@ runInEachFileSystem(() => { expect(diagnostics).toBeUndefined(); }); + it('should populate foreignImports with ForeignComponents', () => { + const {program, options, host} = makeProgram([ + { + name: _('/node_modules/@angular/core/index.d.ts'), + contents: ` + export const Component: any; + export type ForeignComponent = T & { ɵrender: () => void }; + `, + }, + { + name: _('/entry.ts'), + contents: ` + import {Component, ForeignComponent} from '@angular/core'; + + function FancyButton() {} + + function foreignImport(type: T): ForeignComponent { + return type as any; + } + + @Component({ + selector: 'main', + template: '', + foreignImports: [foreignImport(FancyButton)], + }) class TestCmp {} + `, + }, + ]); + const {reflectionHost, handler} = setup(program, options, host); + const TestCmp = getDeclaration(program, _('/entry.ts'), 'TestCmp', isNamedClassDeclaration); + const detected = handler.detect( + TestCmp, + reflectionHost.getDecoratorsOfDeclaration(TestCmp), + ); + if (detected === undefined) { + return fail('Failed to recognize @Component'); + } + const {analysis} = handler.analyze(TestCmp, detected.metadata); + expect(analysis?.resolvedForeignImports?.length).toBe(1); + expect((analysis?.resolvedForeignImports![0].node as any).name.text).toBe('FancyButton'); + }); + it('should produce diagnostic for imports in non-standalone component', () => { const {program, options, host} = makeProgram( [ diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts index 484629043353..fe8d9041aa41 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts @@ -320,6 +320,7 @@ export class DirectiveDecoratorHandler implements DecoratorHandler< isStandalone: analysis.meta.isStandalone, isSignal: analysis.meta.isSignal, imports: null, + foreignImports: null, rawImports: null, deferredImports: null, schemas: null, diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts index 55b2e5ebba33..9d93dd9b2e3a 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts @@ -250,6 +250,14 @@ export interface DirectiveMeta extends T2DirectiveMeta, DirectiveTypeCheckMeta { */ imports: Reference[] | null; + /** + * For standalone components, the list of imported foreign components. + * + * Note that while a foreign import is not likely to be a class, this type is used + * because it includes the expected identifier we'll need, making further code simpler. + */ + foreignImports: Reference[] | null; + /** * Node declaring the `imports` of a standalone component. Used to produce diagnostics. */ diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts index 0462e8bd3f2a..c3350b5cc19c 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts @@ -207,6 +207,7 @@ export class DtsMetadataReader implements MetadataReader { // Imports are tracked in metadata only for template type-checking purposes, // so standalone components from .d.ts files don't have any. imports: null, + foreignImports: null, rawImports: null, deferredImports: null, // The same goes for schemas. diff --git a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts index 639f6b48bde9..d157df205f84 100644 --- a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts +++ b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts @@ -360,6 +360,7 @@ function fakeDirective(ref: Reference): DirectiveMeta { isStandalone: false, isSignal: false, imports: null, + foreignImports: null, rawImports: null, schemas: null, decorator: null, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts index 3216dedc4a44..5c768c6db5db 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts @@ -962,6 +962,7 @@ function makeScope(program: ts.Program, sf: ts.SourceFile, decls: TestDeclaratio isStandalone: false, isSignal: false, imports: null, + foreignImports: null, rawImports: null, deferredImports: null, schemas: null, diff --git a/packages/compiler/src/render3/partial/component.ts b/packages/compiler/src/render3/partial/component.ts index 77bca73a71c1..3da73c38096e 100644 --- a/packages/compiler/src/render3/partial/component.ts +++ b/packages/compiler/src/render3/partial/component.ts @@ -235,6 +235,8 @@ function compileUsedDependenciesMetadata( ngModuleMeta.set('kind', o.literal('ngmodule')); ngModuleMeta.set('type', wrapType(decl.type)); return ngModuleMeta.toLiteralMap(); + case R3TemplateDependencyKind.ForeignComponent: + throw new Error('Foreign components are not supported in partial compilation'); } }); } diff --git a/packages/compiler/src/render3/view/api.ts b/packages/compiler/src/render3/view/api.ts index 471c9209caf9..221008f063eb 100644 --- a/packages/compiler/src/render3/view/api.ts +++ b/packages/compiler/src/render3/view/api.ts @@ -336,6 +336,7 @@ export enum R3TemplateDependencyKind { Directive = 0, Pipe = 1, NgModule = 2, + ForeignComponent = 3, } /** @@ -356,7 +357,8 @@ export interface R3TemplateDependency { export type R3TemplateDependencyMetadata = | R3DirectiveDependencyMetadata | R3PipeDependencyMetadata - | R3NgModuleDependencyMetadata; + | R3NgModuleDependencyMetadata + | R3ForeignComponentDependencyMetadata; /** * Information about a directive that is used in a component template. Only the stable, public @@ -401,6 +403,18 @@ export interface R3NgModuleDependencyMetadata extends R3TemplateDependency { kind: R3TemplateDependencyKind.NgModule; } +/** + * Information about a foreign component that is used in a component template. + */ +export interface R3ForeignComponentDependencyMetadata extends R3TemplateDependency { + kind: R3TemplateDependencyKind.ForeignComponent; + + /** + * The foreign component's name. + */ + name: string; +} + /** * Information needed to compile a query (view or content). */ diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 5eefb4241887..6737d4b89ee3 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -77,6 +77,7 @@ export { TRANSLATIONS_FORMAT, } from './i18n/tokens'; export {AbstractType, Type} from './interface/type'; +export {ForeignComponent} from './interface/foreign_component'; export * from './linker'; export * from './linker/ng_module_factory_loader_impl'; export * from './metadata'; diff --git a/packages/core/src/interface/foreign_component.ts b/packages/core/src/interface/foreign_component.ts new file mode 100644 index 000000000000..afbd88bcfde8 --- /dev/null +++ b/packages/core/src/interface/foreign_component.ts @@ -0,0 +1,19 @@ +/** + * @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 + */ + +/** + * Represents a component from another framework. + * + * @publicApi + */ +export type ForeignComponent = T & { + /** + * A function that renders this component. + */ + ɵrender: Function; +}; diff --git a/packages/core/src/metadata/directives.ts b/packages/core/src/metadata/directives.ts index 2df2ff379ffb..6d16ecd4696a 100644 --- a/packages/core/src/metadata/directives.ts +++ b/packages/core/src/metadata/directives.ts @@ -7,6 +7,7 @@ */ import {ChangeDetectionStrategy} from '../change_detection/constants'; +import {ForeignComponent} from '../interface/foreign_component'; import {Provider} from '../di/interface/provider'; import {Type} from '../interface/type'; import {compileComponent, compileDirective} from '../render3/jit/directive'; @@ -641,6 +642,12 @@ export interface Component extends Directive { */ imports?: (Type | ReadonlyArray)[]; + /** + * The foreignImports property specifies components from other frameworks that can be used + * within this component's template. + */ + foreignImports?: ForeignComponent[]; + /** * The `deferredImports` property specifies a standalone component's template dependencies, * which should be defer-loaded as a part of the `@defer` block. Angular *always* generates From 57908a0c422daef83a54eeb6920975f17e3d4ef8 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Fri, 8 May 2026 11:53:38 -0700 Subject: [PATCH 5/6] feat(compiler): add support for importing foreign components - Added the ForeignComponent type in @angular/core. - Added Component.foreignImports for importing ForeignComponents. - Updated the compiler to handle ForeignComponent in template dependencies. - Updated ngtsc to extract foreignImports from standalone components. Note the compiler doesn't do anything yet with these imports. --- .../compiler-cli/src/ngtsc/annotations/component/src/handler.ts | 1 + .../src/ngtsc/annotations/component/test/component_spec.ts | 1 + packages/compiler-cli/src/ngtsc/core/src/compiler.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index 2fc4a00df2a4..b2e1c0d02654 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -240,6 +240,7 @@ export class ComponentDecoratorHandler implements DecoratorHandler< constructor( private reflector: ReflectionHost, private evaluator: PartialEvaluator, + private checker: ts.TypeChecker, private metaRegistry: MetadataRegistry, private metaReader: MetadataReader, private scopeReader: ComponentScopeReader, diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts index 0a87acc2990f..b4b576f5b2a2 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts @@ -117,6 +117,7 @@ function setup( const handler = new ComponentDecoratorHandler( reflectionHost, evaluator, + checker, metaRegistry, metaReader, scopeRegistry, diff --git a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts index 17df0f53ac50..463a988e18ff 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts @@ -1490,6 +1490,7 @@ export class NgCompiler { new ComponentDecoratorHandler( reflector, evaluator, + checker, metaRegistry, metaReader, scopeReader, From ef66ea27bb69b5611807469edfa39b2de2807a3c Mon Sep 17 00:00:00 2001 From: leonsenft Date: Mon, 11 May 2026 11:12:02 -0700 Subject: [PATCH 6/6] refactor(compiler): support matching template elements to foreign components We extract the identifier name from the `foreignImports` expression in `ComponentDecoratorHandler` and use a `SelectorlessMatcher` to match element tags against these names during template binding in `R3TargetBinder`. If an element matches both a regular Angular directive and a foreign component, a conflict error is thrown. --- .../annotations/component/src/handler.ts | 17 ++++++- .../component/test/component_spec.ts | 46 +++++++++++++++++++ .../src/ngtsc/typecheck/src/tcb_adapter.ts | 2 + packages/compiler/src/render3/view/t2_api.ts | 27 +++++++++++ .../compiler/src/render3/view/t2_binder.ts | 45 ++++++++++++++++-- .../test/render3/view/binding_spec.ts | 34 +++++++++++++- 6 files changed, 165 insertions(+), 6 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index b2e1c0d02654..3075706d9915 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -45,6 +45,7 @@ import { SelectorlessMatcher, MatchSource, TypeCheckId, + ForeignComponentMeta, } from '@angular/compiler'; import ts from 'typescript'; @@ -1778,8 +1779,22 @@ export class ComponentDecoratorHandler implements DecoratorHandler< } } + // Extract foreign component names and create a matcher. + let foreignMatcher: SelectorlessMatcher | null = null; + if (analysis.resolvedForeignImports !== null && analysis.resolvedForeignImports.length > 0) { + const registry = new Map(); + for (const ref of analysis.resolvedForeignImports) { + const name = ref.node.name.getText(); + registry.set(name, [{name, ref}]); + } + foreignMatcher = new SelectorlessMatcher(registry); + } + // Set up the R3TargetBinder. - const binder = new R3TargetBinder(createMatcherFromScope(scope, this.hostDirectivesResolver)); + const binder = new R3TargetBinder( + createMatcherFromScope(scope, this.hostDirectivesResolver), + foreignMatcher, + ); let allDependencies = dependencies; let deferBlockBinder = binder; diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts index b4b576f5b2a2..4289d84c3af5 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts @@ -1085,6 +1085,52 @@ runInEachFileSystem(() => { expect((analysis?.resolvedForeignImports![0].node as any).name.text).toBe('FancyButton'); }); + it('should match template elements to foreign components', () => { + const {program, options, host} = makeProgram([ + { + name: _('/node_modules/@angular/core/index.d.ts'), + contents: ` + export const Component: any; + export type ForeignComponent = T & { ɵrender: () => void }; + `, + }, + { + name: _('/entry.ts'), + contents: ` + import {Component, ForeignComponent} from '@angular/core'; + + function FancyButton() {} + + function foreignImport(type: T): ForeignComponent { + return type as any; + } + + @Component({ + selector: 'main', + template: '', + foreignImports: [foreignImport(FancyButton)], + standalone: true, + }) class TestCmp {} + `, + }, + ]); + const {reflectionHost, handler} = setup(program, options, host); + const TestCmp = getDeclaration(program, _('/entry.ts'), 'TestCmp', isNamedClassDeclaration); + const detected = handler.detect( + TestCmp, + reflectionHost.getDecoratorsOfDeclaration(TestCmp), + ); + if (detected === undefined) { + return fail('Failed to recognize @Component'); + } + const {analysis} = handler.analyze(TestCmp, detected.metadata); + handler.register(TestCmp, analysis!); + + const symbol = handler.symbol(TestCmp, analysis!); + const result = handler.resolve(TestCmp, analysis!, symbol); + expect(result.data).toBeDefined(); + }); + it('should produce diagnostic for imports in non-standalone component', () => { const {program, options, host} = makeProgram( [ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts index 5f9329bb7f45..9ae5635a4d75 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts @@ -152,6 +152,8 @@ export function adaptTypeCheckBlockMetadata( const dirs = meta.boundTarget.getDirectivesOfNode(node); return dirs ? dirs.map(convertDir) : null; }, + getForeignComponentOfNode: (node) => meta.boundTarget.getForeignComponentOfNode(node), + getMatchedForeignComponents: () => meta.boundTarget.getMatchedForeignComponents(), getReferenceTarget: (ref) => { const target = meta.boundTarget.getReferenceTarget(ref); if (target && 'directive' in target) { diff --git a/packages/compiler/src/render3/view/t2_api.ts b/packages/compiler/src/render3/view/t2_api.ts index a2ca23659496..7500b8625692 100644 --- a/packages/compiler/src/render3/view/t2_api.ts +++ b/packages/compiler/src/render3/view/t2_api.ts @@ -172,6 +172,22 @@ export interface DirectiveMeta { matchSource: MatchSource; } +/** + * Metadata regarding a foreign component that's needed to match it against template elements. + */ +export interface ForeignComponentMeta { + /** + * Name of the foreign component (used for matching and debugging). + */ + name: string; + + /** Reference to the foreign component declaration site. */ + ref: { + /** Key that uniquely identifies the reference. */ + key: string; + }; +} + /** * Possible ways that a directive can be matched. */ @@ -213,6 +229,17 @@ export interface BoundTarget { */ getDirectivesOfNode(node: DirectiveOwner): DirectiveT[] | null; + /** + * For a given template node (usually an `Element`), get the foreign component that matched + * the node, if any. + */ + getForeignComponentOfNode(node: DirectiveOwner): ForeignComponentMeta | null; + + /** + * Get all foreign components matched in the target. + */ + getMatchedForeignComponents(): ForeignComponentMeta[]; + /** * For a given `Reference`, get the reference's target - either an `Element`, a `Template`, or * a directive on a particular node. diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index b6eb3a18bb3d..cb4b2996bb18 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -58,6 +58,7 @@ import { ConflictingHostDirectiveBinding, DirectiveMeta, DirectiveOwner, + ForeignComponentMeta, MatchSource, ReferenceTarget, ScopedNode, @@ -170,7 +171,10 @@ export type DirectiveMatcher = * target. */ export class R3TargetBinder implements TargetBinder { - constructor(private directiveMatcher: DirectiveMatcher | null) {} + constructor( + private directiveMatcher: DirectiveMatcher | null, + private foreignComponentMatcher: SelectorlessMatcher | null = null, + ) {} /** * Perform a binding operation on the given `Target` and return a `BoundTarget` which contains @@ -182,6 +186,7 @@ export class R3TargetBinder implements TargetB } const directives: MatchedDirectives = new Map(); + const foreignComponents = new Map(); const eagerDirectives: DirectiveT[] = []; const missingDirectives = new Set(); const bindings: BindingsMap = new Map(); @@ -214,7 +219,9 @@ export class R3TargetBinder implements TargetB DirectiveBinder.apply( target.template, this.directiveMatcher, + this.foreignComponentMatcher, directives, + foreignComponents, eagerDirectives, missingDirectives, bindings, @@ -255,6 +262,7 @@ export class R3TargetBinder implements TargetB return new R3BoundTarget( target, directives, + foreignComponents, eagerDirectives, missingDirectives, bindings, @@ -516,7 +524,9 @@ class DirectiveBinder implements Visitor { private constructor( private directiveMatcher: DirectiveMatcher | null, + private foreignMatcher: SelectorlessMatcher | null, private directives: MatchedDirectives, + private foreignComponents: Map, private eagerDirectives: DirectiveT[], private missingDirectives: Set, private bindings: BindingsMap, @@ -542,7 +552,9 @@ class DirectiveBinder implements Visitor { static apply( template: Node[], directiveMatcher: DirectiveMatcher | null, + foreignMatcher: SelectorlessMatcher | null, directives: MatchedDirectives, + foreignComponents: Map, eagerDirectives: DirectiveT[], missingDirectives: Set, bindings: BindingsMap, @@ -554,7 +566,9 @@ class DirectiveBinder implements Visitor { ): void { const matcher = new DirectiveBinder( directiveMatcher, + foreignMatcher, directives, + foreignComponents, eagerDirectives, missingDirectives, bindings, @@ -665,11 +679,12 @@ class DirectiveBinder implements Visitor { } private visitElementOrTemplate(node: Element | Template): void { + const matchedDirectives: DirectiveT[] = []; + if (this.directiveMatcher instanceof SelectorMatcher) { - const directives: DirectiveT[] = []; const cssSelector = createCssSelectorFromNode(node); - this.directiveMatcher.match(cssSelector, (_, results) => directives.push(...results)); - this.trackSelectorBasedBindingsAndDirectives(node, directives); + this.directiveMatcher.match(cssSelector, (_, results) => matchedDirectives.push(...results)); + this.trackSelectorBasedBindingsAndDirectives(node, matchedDirectives); } else { node.references.forEach((ref) => { if (ref.value.trim() === '') { @@ -678,6 +693,19 @@ class DirectiveBinder implements Visitor { }); } + if (this.foreignMatcher && node instanceof Element) { + const foreignMatches = this.foreignMatcher.match(node.name); + if (foreignMatches.length > 0) { + if (matchedDirectives.length > 0) { + throw new Error( + `Conflict: Element '${node.name}' matches both an Angular directive and a foreign component.`, + ); + } + // We assume at most one foreign component matches by name. + this.foreignComponents.set(node, foreignMatches[0]); + } + } + node.directives.forEach((directive) => directive.visit(this)); node.children.forEach((child) => child.visit(this)); } @@ -1182,6 +1210,7 @@ class R3BoundTarget implements BoundTarget, private directives: MatchedDirectives, + private foreignComponents: Map, private eagerDirectives: DirectiveT[], private missingDirectives: Set, private bindings: BindingsMap, @@ -1210,6 +1239,14 @@ class R3BoundTarget implements BoundTarget | null { return this.references.get(ref) || null; } diff --git a/packages/compiler/test/render3/view/binding_spec.ts b/packages/compiler/test/render3/view/binding_spec.ts index a35a72982da2..4b1e1ca08ffa 100644 --- a/packages/compiler/test/render3/view/binding_spec.ts +++ b/packages/compiler/test/render3/view/binding_spec.ts @@ -8,7 +8,7 @@ import * as e from '../../../src/expression_parser/ast'; import * as a from '../../../src/render3/r3_ast'; -import {DirectiveMeta, MatchSource} from '../../../src/render3/view/t2_api'; +import {DirectiveMeta, MatchSource, ForeignComponentMeta} from '../../../src/render3/view/t2_api'; import {ClassPropertyMapping} from '../../../src/property_mapping'; import {findMatchingDirectivesAndPipes, R3TargetBinder} from '../../../src/render3/view/t2_binder'; import {parseTemplate, ParseTemplateOptions} from '../../../src/render3/view/template'; @@ -1595,5 +1595,37 @@ describe('t2 binding', () => { expect(mergedHost.outputs.toDirectMappedObject()).toEqual({one: 'oneAlias'}); expect(res.getConflictingHostDirectiveBindings(element)).toBe(null); }); + + it('should match foreign components by tag name', () => { + const template = parseTemplate('', '', {}); + const registry = new Map(); + registry.set('FancyButton', [{name: 'FancyButton', ref: {key: 'FancyButtonKey'}}]); + const foreignMatcher = new SelectorlessMatcher(registry); + + const binder = new R3TargetBinder(new SelectorMatcher(), foreignMatcher); + const res = binder.bind({template: template.nodes}); + + const el = template.nodes[0] as a.Element; + const foreignComp = res.getForeignComponentOfNode(el); + expect(foreignComp).not.toBeNull(); + expect(foreignComp?.name).toBe('FancyButton'); + + const allMatched = res.getMatchedForeignComponents(); + expect(allMatched.length).toBe(1); + expect(allMatched[0].name).toBe('FancyButton'); + }); + + it('should throw an error when tag matches both directive and foreign component', () => { + const template = parseTemplate('', '', {}); + const registry = new Map(); + registry.set('comp', [{name: 'comp', ref: {key: 'compKey'}}]); + const foreignMatcher = new SelectorlessMatcher(registry); + + const binder = new R3TargetBinder(makeSelectorMatcher(), foreignMatcher); + + expect(() => binder.bind({template: template.nodes})).toThrowError( + "Conflict: Element 'comp' matches both an Angular directive and a foreign component.", + ); + }); }); });