From f89d0e4c8fcbae1fbc6a4197942ab4103fff8f4b Mon Sep 17 00:00:00 2001 From: leonsenft Date: Thu, 28 May 2026 10:23:42 -0700 Subject: [PATCH 1/7] refactor(compiler): support passing children to foreign components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, any children nested inside a foreign component were ignored during template ingestion. With this change, the compiler now: 1. Identifies when a foreign component has children in the template AST. 2. Compiles these children into a separate template view (using the standard TemplateOp). 3. Passes a `ɵɵforeignContent` expression under the `children` prop inside the foreign component's `props` object. At runtime, the new `ɵɵforeignContent(index)` instruction instantiates the template at the specified slot index in memory (detached from the DOM), extracts its root DOM nodes, and returns them. These root nodes are then passed directly to the foreign component's `props.children` so they can be rendered by the foreign framework. The instantiated children view is registered in the parent LView's child tree, ensuring its change detection and destruction are managed automatically as part of the standard Angular view tree lifecycle. --- .../standalone/GOLDEN_PARTIAL.js | 30 ++++++++++++++ .../standalone/foreign_component.js | 29 ++++++++++++++ .../standalone/foreign_component.local.js | 29 ++++++++++++++ .../standalone/foreign_component.ts | 18 +++++++++ .../compiler/src/render3/r3_identifiers.ts | 1 + .../src/template/pipeline/ir/src/enums.ts | 5 +++ .../template/pipeline/ir/src/expression.ts | 32 +++++++++++++++ .../src/template/pipeline/src/ingest.ts | 24 ++++++++++++ .../src/template/pipeline/src/phases/reify.ts | 4 ++ .../core/src/core_render3_private_export.ts | 1 + packages/core/src/render3/index.ts | 1 + .../render3/instructions/foreign_component.ts | 36 ++++++++++++++++- packages/core/src/render3/jit/environment.ts | 1 + .../test/render3/foreign_component_spec.ts | 39 ++++++++++++++++++- 14 files changed, 247 insertions(+), 3 deletions(-) diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js index c102a57fbcac..51301badf01c 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js @@ -416,6 +416,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE ], }] }] }); +export class TestCmpChildren { + title = 'Submit'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpChildren, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmpChildren, isStandalone: true, selector: "main-children", ngImport: i0, template: ` + + Click me! + + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpChildren, decorators: [{ + type: Component, + args: [{ + selector: 'main-children', + template: ` + + Click me! + + `, + // @ts-ignore: @angular/core does not expose the `foreignImports` property. + foreignImports: [ + // @ts-ignore: @angular/core does not expose the `ForeignComponent` type this expects. + frameworkImport(FancyButton) + ], + }] + }] }); /**************************************************************************************************** * PARTIAL FILE: foreign_component.d.ts @@ -427,4 +452,9 @@ export declare class TestCmp { static ɵfac: i0.ɵɵFactoryDeclaration; static ɵcmp: i0.ɵɵComponentDeclaration; } +export declare class TestCmpChildren { + title: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js index 1ddad2ca8bad..9c75cd95a3a1 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js @@ -1,3 +1,13 @@ +function TestCmpChildren_Children_0_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵdomElementStart(0, "span"); + i0.ɵɵtext(1, "Click me!"); + i0.ɵɵdomElementEnd(); + } +} + +… + export class TestCmp { // ... static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ @@ -13,3 +23,22 @@ export class TestCmp { encapsulation: 2 }); } + +… + +export class TestCmpChildren { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmpChildren, + selectors: [["main-children"]], + decls: 2, + vars: 0, + template: function TestCmpChildren_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵdomTemplate(0, TestCmpChildren_Children_0_Template, 2, 0); + i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, children: i0.ɵɵforeignContent(0) }); + } + }, + encapsulation: 2 + }); +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js index 1ddad2ca8bad..b637f2297e77 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js @@ -1,3 +1,13 @@ +function TestCmpChildren_Children_0_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelementStart(0, "span"); + i0.ɵɵtext(1, "Click me!"); + i0.ɵɵelementEnd(); + } +} + +… + export class TestCmp { // ... static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ @@ -13,3 +23,22 @@ export class TestCmp { encapsulation: 2 }); } + +… + +export class TestCmpChildren { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmpChildren, + selectors: [["main-children"]], + decls: 2, + vars: 0, + template: function TestCmpChildren_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵtemplate(0, TestCmpChildren_Children_0_Template, 2, 0); + i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, children: i0.ɵɵforeignContent(0) }); + } + }, + encapsulation: 2 + }); +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts index 7e3ea2ed3984..15d56dee09e2 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts @@ -26,3 +26,21 @@ function frameworkImport(component: {}): Function { export class TestCmp { title = 'Submit'; } + +@Component({ + selector: 'main-children', + template: ` + + Click me! + + `, + // @ts-ignore: @angular/core does not expose the `foreignImports` property. + foreignImports: [ + // @ts-ignore: @angular/core does not expose the `ForeignComponent` type this expects. + frameworkImport(FancyButton) + ], +}) +export class TestCmpChildren { + title = 'Submit'; +} + diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 73fdad09c1af..15535e3b9aa1 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -26,6 +26,7 @@ export class Identifiers { static elementEnd: o.ExternalReference = {name: 'ɵɵelementEnd', moduleName: CORE}; static foreignComponent: o.ExternalReference = {name: 'ɵɵforeignComponent', moduleName: CORE}; + static foreignContent: o.ExternalReference = {name: 'ɵɵforeignContent', moduleName: CORE}; static domElement: o.ExternalReference = {name: 'ɵɵdomElement', moduleName: CORE}; static domElementStart: o.ExternalReference = {name: 'ɵɵdomElementStart', moduleName: CORE}; diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index c36a5fd32670..87d57efc429e 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -460,6 +460,11 @@ export enum ExpressionKind { */ TwoWayBindingSet, + /** + * Renders foreign content (children of a foreign component) and extracts its root DOM nodes. + */ + ForeignContent, + /** * Definition of an arrow function inside of an expression. */ diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 5221bb7c6bc0..7dfd21a39e6c 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -31,6 +31,7 @@ import { export type Expression = | LexicalReadExpr | ReferenceExpr + | ForeignContentExpr | ContextExpr | NextContextExpr | GetCurrentViewExpr @@ -150,6 +151,37 @@ export class ReferenceExpr extends ExpressionBase { } } +/** + * Runtime operation to render foreign content (children of a foreign component) + * and extract its root DOM nodes. + */ +export class ForeignContentExpr extends ExpressionBase { + override readonly kind = ExpressionKind.ForeignContent; + + constructor( + readonly childrenViewXref: XrefId, + readonly childrenViewHandle: SlotHandle, + ) { + super(); + } + + override visitExpression(): void {} + + override isEquivalent(e: o.Expression): boolean { + return e instanceof ForeignContentExpr && e.childrenViewXref === this.childrenViewXref; + } + + override isConstant(): boolean { + return false; + } + + override transformInternalExpressions(): void {} + + override clone(): ForeignContentExpr { + return new ForeignContentExpr(this.childrenViewXref, this.childrenViewHandle); + } +} + export class StoreLetExpr extends ExpressionBase implements ConsumesVarsTrait, DependsOnSlotContextOpTrait diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 9f0b703c152e..1c6e92328dd8 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -307,6 +307,30 @@ function ingestElement(unit: ViewCompilationUnit, element: t.Element): void { quoted: isUnsafeObjectKey(input.name), }); } + + if (element.children.length > 0) { + const childView = unit.job.allocateView(unit.xref); + ingestNodes(childView, element.children); + + const childrenTemplateOp = ir.createTemplateOp( + childView.xref, + ir.TemplateKind.NgTemplate, + null, + 'Children', + ir.Namespace.HTML, + undefined, + element.startSourceSpan, + element.sourceSpan, + ); + unit.create.push(childrenTemplateOp); + + propEntries.push({ + key: 'children', + value: new ir.ForeignContentExpr(childrenTemplateOp.xref, childrenTemplateOp.handle), + quoted: false, + }); + } + const props = propEntries.length > 0 ? o.literalMap(propEntries) : null; // Foreign components are created in the creation block. Updates are triggered reactively diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 929950c54ab4..483dc6c8c005 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -788,6 +788,10 @@ function reifyIrExpression(unit: CompilationUnit, expr: o.Expression): o.Express return ng.nextContext(expr.steps); case ir.ExpressionKind.Reference: return ng.reference(expr.targetSlot.slot! + 1 + expr.offset); + case ir.ExpressionKind.ForeignContent: + return o + .importExpr(Identifiers.foreignContent) + .callFn([o.literal(expr.childrenViewHandle.slot!)]); case ir.ExpressionKind.LexicalRead: throw new Error(`AssertionError: unresolved LexicalRead of ${expr.name}`); case ir.ExpressionKind.TwoWayBindingSet: diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 9d7e16606573..c770b6c3c7e7 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -151,6 +151,7 @@ export { ɵɵelementEnd, ɵɵelementStart, ɵɵforeignComponent, + ɵɵforeignContent, ɵɵenableBindings, ɵɵExternalStylesFeature, ɵɵFactoryDeclaration, diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 97ae322fe89d..ffadd96dad44 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -84,6 +84,7 @@ export { ɵɵelementEnd, ɵɵelementStart, ɵɵforeignComponent, + ɵɵforeignContent, ɵɵgetCurrentView, ɵɵdomProperty, ɵɵinjectAttribute, diff --git a/packages/core/src/render3/instructions/foreign_component.ts b/packages/core/src/render3/instructions/foreign_component.ts index 852312983b21..1d34a45fd88f 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -11,16 +11,20 @@ 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 {HEADER_OFFSET, RENDERER, TVIEW} from '../interfaces/view'; import {appendChild} from '../node_manipulation'; import {getLView, getTView, setCurrentTNode, setCurrentTNodeAsNotParent} from '../state'; import {getOrCreateTNode} from '../tnode_manipulation'; import {addToEndOfViewTree} from '../view/construction'; -import {createLContainer} from '../view/container'; +import {createLContainer, addLViewToLContainer} from '../view/container'; import {NodeInjector} from '../di'; import {runInInjectionContext} from '../../di'; import {Renderer} from '../interfaces/renderer'; import {RNode} from '../interfaces/renderer_dom'; +import {createAndRenderEmbeddedLView} from '../view_manipulation'; +import {collectNativeNodes} from '../collect_native_nodes'; +import {assertLContainer} from '../assert'; +import {LContainer} from '../interfaces/container'; /** * Creation phase instruction to render a foreign component. @@ -82,3 +86,31 @@ export function ɵɵforeignComponent( viewRef.onDestroy(dispose); } } + +/** + * Creation phase instruction to render foreign content (children of a foreign component) + * and extract its root DOM nodes. + * + * @param index The index of the container in the data array. + * @codeGenApi + */ +export function ɵɵforeignContent(index: number): any[] { + const lView = getLView(); + const adjustedIndex = index + HEADER_OFFSET; + + // The template is already declared at adjustedIndex, so lContainer must exist. + const lContainer = lView[adjustedIndex] as LContainer; + ngDevMode && assertLContainer(lContainer); + + const tView = getTView(); + const tNode = tView.data[adjustedIndex] as TContainerNode; + + // Instantiate and render the embedded view inside the container, + // but do NOT add its elements to the DOM at the container anchor. + const embeddedLView = createAndRenderEmbeddedLView(lView, tNode, null); + addLViewToLContainer(lContainer, embeddedLView, 0, /* addToDOM */ false); + + // Extract and return the root nodes of the created view + const embeddedTView = embeddedLView[TVIEW]; + return collectNativeNodes(embeddedTView, embeddedLView, embeddedTView.firstChild, []); +} diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 67b3417085b4..33fff49492b7 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -56,6 +56,7 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({ 'ɵɵelementEnd': r3.ɵɵelementEnd, 'ɵɵelement': r3.ɵɵelement, 'ɵɵforeignComponent': r3.ɵɵforeignComponent, + 'ɵɵforeignContent': r3.ɵɵforeignContent, 'ɵɵelementContainerStart': r3.ɵɵelementContainerStart, 'ɵɵelementContainerEnd': r3.ɵɵelementContainerEnd, 'ɵɵdomElement': r3.ɵɵdomElement, diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts index fd4308f86c89..5bf84c6e3754 100644 --- a/packages/core/test/render3/foreign_component_spec.ts +++ b/packages/core/test/render3/foreign_component_spec.ts @@ -6,11 +6,16 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ɵɵforeignComponent} from '../../src/render3/instructions/foreign_component'; +import { + ɵɵforeignComponent, + ɵɵforeignContent, +} 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 {ɵɵdomTemplate} from '../../src/render3/instructions/template'; import {ɵɵelement, ɵɵelementEnd, ɵɵelementStart} from '../../src/render3/instructions/element'; +import {ɵɵtext} from '../../src/render3/instructions/text'; import {inject, InjectionToken} from '../../src/di'; import {ɵɵdefineDirective} from '../../src/render3/definition'; import {ɵɵProvidersFeature} from '../../src/render3/features/providers_feature'; @@ -204,6 +209,38 @@ describe('ɵɵforeignComponent', () => { expect(fixture.host.innerHTML).toContain(expectedHtml); expect(host2.innerHTML).toContain(expectedHtml); }); + + it('should render children using ɵɵforeignContent and pass root nodes to the children prop', () => { + const foreignComp = foreignImport<{children: Node[]}>((props) => { + const p = document.createElement('p'); + // Domino doesn't implement Element.append(). + for (const child of props.children) { + p.appendChild(child); + } + return [[p]]; + }); + + const childrenTemplate = (rf: number, ctx: any) => { + if (rf & 1) { + ɵɵelementStart(0, 'span'); + ɵɵtext(1, 'Hello, world!'); + ɵɵelementEnd(); + } + }; + + const fixture = new ViewFixture({ + decls: 2, + vars: 0, + create: () => { + ɵɵdomTemplate(0, childrenTemplate, 2, 0); + ɵɵforeignComponent(1, foreignComp, { + children: ɵɵforeignContent(0), + }); + }, + }); + + expect(fixture.host.innerHTML).toContain('' + '

' + 'Hello, world!' + '

'); + }); }); function renderSecondInstance(fixture: ViewFixture): HTMLElement { From f19bbe59dd9a341c776dd902b932d608ed8fbd39 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Thu, 28 May 2026 14:27:03 -0700 Subject: [PATCH 2/7] refactor(compiler): support passing content to specific foreign component props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `@content(propName)` blocks for passing template content to foreign component properties by name. Previously, only a single set of direct children could be passed to a foreign component via the default `children` property. With this change, developers can project distinct template content to multiple specific properties on the foreign component: ```html @content(icon) { Icon } @content(description) { Description text } Other children ``` Specifically: - Add support to the HTML lexer for `@content` blocks. - Introduce `ContentBlock` AST node to represent `@content` blocks. - Implement validation ensuring `@content` blocks have exactly one parameter representing a valid JS identifier. - Throw an error during ingestion if a `@content` block is placed anywhere other than as a direct child of a foreign component. - Map `@content` blocks to properties of the props object passed to `ɵɵforeignComponent`. - Update compliance and unit tests to cover these changes. ``` --- .../standalone/GOLDEN_PARTIAL.js | 16 ++- .../standalone/foreign_component.js | 26 +++- .../standalone/foreign_component.local.js | 26 +++- .../standalone/foreign_component.ts | 8 +- packages/compiler/src/combined_visitor.ts | 4 + packages/compiler/src/compiler.ts | 1 + packages/compiler/src/ml_parser/lexer.ts | 1 + packages/compiler/src/render3/r3_ast.ts | 22 ++++ .../compiler/src/render3/r3_content_blocks.ts | 60 ++++++++++ .../compiler/src/render3/r3_control_flow.ts | 4 +- .../src/render3/r3_template_transform.ts | 5 + packages/compiler/src/render3/util.ts | 3 + packages/compiler/src/render3/view/t2_api.ts | 2 + .../compiler/src/render3/view/t2_binder.ts | 15 +++ .../src/template/pipeline/ir/src/enums.ts | 5 + .../template/pipeline/ir/src/expression.ts | 5 +- .../template/pipeline/ir/src/ops/create.ts | 53 ++++++++- .../src/template/pipeline/src/emit.ts | 2 + .../src/template/pipeline/src/ingest.ts | 111 ++++++++++-------- .../src/template/pipeline/src/phases/reify.ts | 13 +- .../src/phases/resolve_foreign_content.ts | 55 +++++++++ .../test/render3/r3_ast_spans_spec.ts | 10 ++ .../render3/r3_template_transform_spec.ts | 37 ++++++ .../compiler/test/render3/util/expression.ts | 4 + .../schematics/utils/template_ast_visitor.ts | 2 + .../test/render3/foreign_component_spec.ts | 74 ++++++++++-- .../language-service/src/semantic_tokens.ts | 5 + .../language-service/src/template_target.ts | 5 + 28 files changed, 492 insertions(+), 82 deletions(-) create mode 100644 packages/compiler/src/render3/r3_content_blocks.ts create mode 100644 packages/compiler/src/template/pipeline/src/phases/resolve_foreign_content.ts diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js index 51301badf01c..6401ae0b1f8c 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js @@ -421,7 +421,13 @@ export class TestCmpChildren { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpChildren, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmpChildren, isStandalone: true, selector: "main-children", ngImport: i0, template: ` - Click me! + @content(icon) { + Icon! + } + @content(description) { + Description text + } + Other children `, isInline: true }); } @@ -431,7 +437,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE selector: 'main-children', template: ` - Click me! + @content(icon) { + Icon! + } + @content(description) { + Description text + } + Other children `, // @ts-ignore: @angular/core does not expose the `foreignImports` property. diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js index 9c75cd95a3a1..fcc01f41c713 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js @@ -1,7 +1,23 @@ -function TestCmpChildren_Children_0_Template(rf, ctx) { +function TestCmpChildren_Icon_0_Template(rf, ctx) { if (rf & 1) { i0.ɵɵdomElementStart(0, "span"); - i0.ɵɵtext(1, "Click me!"); + i0.ɵɵtext(1, "Icon!"); + i0.ɵɵdomElementEnd(); + } +} + +function TestCmpChildren_Description_1_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵdomElementStart(0, "span"); + i0.ɵɵtext(1, "Description text"); + i0.ɵɵdomElementEnd(); + } +} + +function TestCmpChildren_Children_2_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵdomElementStart(0, "span"); + i0.ɵɵtext(1, "Other children"); i0.ɵɵdomElementEnd(); } } @@ -31,12 +47,12 @@ export class TestCmpChildren { static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: TestCmpChildren, selectors: [["main-children"]], - decls: 2, + decls: 4, vars: 0, template: function TestCmpChildren_Template(rf, ctx) { if (rf & 1) { - i0.ɵɵdomTemplate(0, TestCmpChildren_Children_0_Template, 2, 0); - i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, children: i0.ɵɵforeignContent(0) }); + i0.ɵɵdomTemplate(0, TestCmpChildren_Icon_0_Template, 2, 0)(1, TestCmpChildren_Description_1_Template, 2, 0)(2, TestCmpChildren_Children_2_Template, 2, 0); + i0.ɵɵforeignComponent(3, frameworkImport(FancyButton), { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) }); } }, encapsulation: 2 diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js index b637f2297e77..53919dd68262 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js @@ -1,7 +1,23 @@ -function TestCmpChildren_Children_0_Template(rf, ctx) { +function TestCmpChildren_Icon_0_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "span"); - i0.ɵɵtext(1, "Click me!"); + i0.ɵɵtext(1, "Icon!"); + i0.ɵɵelementEnd(); + } +} + +function TestCmpChildren_Description_1_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelementStart(0, "span"); + i0.ɵɵtext(1, "Description text"); + i0.ɵɵelementEnd(); + } +} + +function TestCmpChildren_Children_2_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelementStart(0, "span"); + i0.ɵɵtext(1, "Other children"); i0.ɵɵelementEnd(); } } @@ -31,12 +47,12 @@ export class TestCmpChildren { static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: TestCmpChildren, selectors: [["main-children"]], - decls: 2, + decls: 4, vars: 0, template: function TestCmpChildren_Template(rf, ctx) { if (rf & 1) { - i0.ɵɵtemplate(0, TestCmpChildren_Children_0_Template, 2, 0); - i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, children: i0.ɵɵforeignContent(0) }); + i0.ɵɵtemplate(0, TestCmpChildren_Icon_0_Template, 2, 0)(1, TestCmpChildren_Description_1_Template, 2, 0)(2, TestCmpChildren_Children_2_Template, 2, 0); + i0.ɵɵforeignComponent(3, frameworkImport(FancyButton), { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) }); } }, encapsulation: 2 diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts index 15d56dee09e2..9a48a1605da4 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts @@ -31,7 +31,13 @@ export class TestCmp { selector: 'main-children', template: ` - Click me! + @content(icon) { + Icon! + } + @content(description) { + Description text + } + Other children `, // @ts-ignore: @angular/core does not expose the `foreignImports` property. diff --git a/packages/compiler/src/combined_visitor.ts b/packages/compiler/src/combined_visitor.ts index 942dcba373c8..cec017a04b15 100644 --- a/packages/compiler/src/combined_visitor.ts +++ b/packages/compiler/src/combined_visitor.ts @@ -46,6 +46,10 @@ export class CombinedRecursiveAstVisitor extends RecursiveAstVisitor implements this.visitAllTemplateNodes(content.children); } + visitContentBlock(block: t.ContentBlock): void { + this.visitAllTemplateNodes(block.children); + } + visitBoundAttribute(attribute: t.BoundAttribute): void { this.visit(attribute.value); } diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 60a4ebf3abee..d4b59dbf7935 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -188,6 +188,7 @@ export { HostElement as TmplAstHostElement, Component as TmplAstComponent, Directive as TmplAstDirective, + ContentBlock as TmplAstContentBlock, visitAll as tmplAstVisitAll, Visitor as TmplAstVisitor, } from './render3/r3_ast'; diff --git a/packages/compiler/src/ml_parser/lexer.ts b/packages/compiler/src/ml_parser/lexer.ts index 2800f3642087..3886bcbabc0a 100644 --- a/packages/compiler/src/ml_parser/lexer.ts +++ b/packages/compiler/src/ml_parser/lexer.ts @@ -153,6 +153,7 @@ const SUPPORTED_BLOCKS = [ '@placeholder', '@loading', '@error', + '@content', ] as const; const INTERPOLATION = {start: '{{', end: '}}'} as const; diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts index 8c7fa4a79098..8baf78f1be1c 100644 --- a/packages/compiler/src/render3/r3_ast.ts +++ b/packages/compiler/src/render3/r3_ast.ts @@ -339,6 +339,24 @@ export class DeferredBlockError extends BlockNode implements Node { } } +export class ContentBlock extends BlockNode implements Node { + constructor( + public name: string, + public children: Node[], + nameSpan: ParseSourceSpan, + sourceSpan: ParseSourceSpan, + startSourceSpan: ParseSourceSpan, + endSourceSpan: ParseSourceSpan | null, + public i18n?: I18nMeta, + ) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } + + visit(visitor: Visitor): Result { + return visitor.visitContentBlock(this); + } +} + export interface DeferredBlockTriggers { when?: BoundDeferredTrigger; idle?: IdleDeferredTrigger; @@ -762,6 +780,7 @@ export interface Visitor { visitLetDeclaration(decl: LetDeclaration): Result; visitComponent(component: Component): Result; visitDirective(directive: Directive): Result; + visitContentBlock(block: ContentBlock): Result; } export class RecursiveVisitor implements Visitor { @@ -846,6 +865,9 @@ export class RecursiveVisitor implements Visitor { visitDeferredTrigger(trigger: DeferredTrigger): void {} visitUnknownBlock(block: UnknownBlock): void {} visitLetDeclaration(decl: LetDeclaration): void {} + visitContentBlock(block: ContentBlock): void { + visitAll(this, block.children); + } } export function visitAll(visitor: Visitor, nodes: Node[]): Result[] { diff --git a/packages/compiler/src/render3/r3_content_blocks.ts b/packages/compiler/src/render3/r3_content_blocks.ts new file mode 100644 index 000000000000..c00c9cf2084b --- /dev/null +++ b/packages/compiler/src/render3/r3_content_blocks.ts @@ -0,0 +1,60 @@ +/** + * @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 * as html from '../ml_parser/ast'; +import {ParseError} from '../parse_util'; + +import * as t from './r3_ast'; +import {IDENTIFIER_PATTERN} from './util'; + +/** Creates a content block from an HTML AST node. */ +export function createContentBlock( + ast: html.Block, + visitor: html.Visitor, +): {node: t.ContentBlock | null; errors: ParseError[]} { + const errors: ParseError[] = []; + if (ast.parameters.length !== 1) { + errors.push( + new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'), + ); + return {node: null, errors}; + } + + const param = ast.parameters[0]; + let expr = param.expression.trim(); + if (expr.startsWith('(') && expr.endsWith(')')) { + expr = expr.slice(1, -1).trim(); + } + + const parts = expr.split(',').map((p) => p.trim()); + if (parts.length !== 1 || parts[0] === '') { + errors.push( + new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'), + ); + return {node: null, errors}; + } + + const name = parts[0]; + if (!IDENTIFIER_PATTERN.test(name)) { + errors.push( + new ParseError(param.sourceSpan, '@content name must be a valid JavaScript identifier'), + ); + return {node: null, errors}; + } + + const node = new t.ContentBlock( + name, + html.visitAll(visitor, ast.children, ast.children), + ast.nameSpan, + ast.sourceSpan, + ast.startSourceSpan, + ast.endSourceSpan, + ast.i18n, + ); + return {node, errors}; +} diff --git a/packages/compiler/src/render3/r3_control_flow.ts b/packages/compiler/src/render3/r3_control_flow.ts index 98ea387c7e30..128ab5889510 100644 --- a/packages/compiler/src/render3/r3_control_flow.ts +++ b/packages/compiler/src/render3/r3_control_flow.ts @@ -12,6 +12,7 @@ import {ParseError, ParseSourceSpan} from '../parse_util'; import {BindingParser} from '../template_parser/binding_parser'; import * as t from './r3_ast'; +import {IDENTIFIER_PATTERN} from './util'; /** Pattern for the expression in a for loop block. */ const FOR_LOOP_EXPRESSION_PATTERN = /^\s*([0-9A-Za-z_$]*)\s+of\s+([\S\s]*)/; @@ -28,9 +29,6 @@ const ELSE_IF_PATTERN = /^else[^\S\r\n]+if/; /** Pattern used to identify a `let` parameter. */ const FOR_LOOP_LET_PATTERN = /^let\s+([\S\s]*)/; -/** Pattern used to validate a JavaScript identifier. */ -const IDENTIFIER_PATTERN = /^[$A-Z_][0-9A-Z_$]*$/i; - /** * Pattern to group a string into leading whitespace, non whitespace, and trailing whitespace. * Useful for getting the variable name span when a span can contain leading and trailing space. diff --git a/packages/compiler/src/render3/r3_template_transform.ts b/packages/compiler/src/render3/r3_template_transform.ts index bf268b1cb31a..dfde0a63ed41 100644 --- a/packages/compiler/src/render3/r3_template_transform.ts +++ b/packages/compiler/src/render3/r3_template_transform.ts @@ -33,6 +33,7 @@ import { isConnectedIfLoopBlock, } from './r3_control_flow'; import {createDeferredBlock, isConnectedDeferLoopBlock} from './r3_deferred_blocks'; +import {createContentBlock} from './r3_content_blocks'; import {I18N_ICU_VAR_PREFIX} from './view/i18n/util'; const BIND_NAME_REGEXP = /^(?:(bind-)|(let-)|(ref-|#)|(on-)|(bindon-)|(@))(.*)$/; @@ -485,6 +486,10 @@ class HtmlAstToIvyAst implements html.Visitor { result = createSwitchBlock(block, this, this.bindingParser); break; + case 'content': + result = createContentBlock(block, this); + break; + case 'for': result = createForLoop( block, diff --git a/packages/compiler/src/render3/util.ts b/packages/compiler/src/render3/util.ts index a2d38f2771ed..9662ff0d468e 100644 --- a/packages/compiler/src/render3/util.ts +++ b/packages/compiler/src/render3/util.ts @@ -14,6 +14,9 @@ import {Identifiers} from './r3_identifiers'; /** Regex that includes unsafe characters in an object literal property name. */ const UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/; +/** Pattern used to validate a JavaScript identifier. */ +export const IDENTIFIER_PATTERN = /^[$A-Z_][0-9A-Z_$]*$/i; + export function typeWithParameters(type: o.Expression, numParams: number): o.ExpressionType { if (numParams === 0) { return o.expressionType(type); diff --git a/packages/compiler/src/render3/view/t2_api.ts b/packages/compiler/src/render3/view/t2_api.ts index 7ce03af4dfa3..e52443f4128a 100644 --- a/packages/compiler/src/render3/view/t2_api.ts +++ b/packages/compiler/src/render3/view/t2_api.ts @@ -31,6 +31,7 @@ import { Template, TextAttribute, Variable, + ContentBlock, } from '../r3_ast'; /** Node that has a `Scope` associated with it. */ @@ -45,6 +46,7 @@ export type ScopedNode = | DeferredBlockLoading | DeferredBlockPlaceholder | Content + | ContentBlock | HostElement; /** Possible values that a reference can be resolved to. */ diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index 28e89060ced0..e71417427a18 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -21,6 +21,7 @@ import { Comment, Component, Content, + ContentBlock, DeferredBlock, DeferredBlockError, DeferredBlockLoading, @@ -353,6 +354,7 @@ class Scope implements Visitor { nodeOrNodes instanceof DeferredBlockError || nodeOrNodes instanceof DeferredBlockPlaceholder || nodeOrNodes instanceof DeferredBlockLoading || + nodeOrNodes instanceof ContentBlock || nodeOrNodes instanceof Content ) { nodeOrNodes.children.forEach((node) => node.visit(this)); @@ -439,6 +441,10 @@ class Scope implements Visitor { this.ingestScopedNode(content); } + visitContentBlock(block: ContentBlock) { + this.ingestScopedNode(block); + } + visitLetDeclaration(decl: LetDeclaration) { this.maybeDeclare(decl); } @@ -651,6 +657,10 @@ class DirectiveBinder implements Visitor { content.children.forEach((child) => child.visit(this)); } + visitContentBlock(block: ContentBlock): void { + block.children.forEach((child) => child.visit(this)); + } + visitComponent(node: Component): void { if (this.directiveMatcher instanceof SelectorlessMatcher) { const componentMatches = this.directiveMatcher.match(node.componentName); @@ -1040,6 +1050,7 @@ class TemplateBinder extends CombinedRecursiveAstVisitor { nodeOrNodes instanceof DeferredBlockError || nodeOrNodes instanceof DeferredBlockPlaceholder || nodeOrNodes instanceof DeferredBlockLoading || + nodeOrNodes instanceof ContentBlock || nodeOrNodes instanceof Content ) { nodeOrNodes.children.forEach((node) => node.visit(this)); @@ -1134,6 +1145,10 @@ class TemplateBinder extends CombinedRecursiveAstVisitor { this.ingestScopedNode(content); } + override visitContentBlock(block: ContentBlock) { + this.ingestScopedNode(block); + } + override visitLetDeclaration(decl: LetDeclaration) { super.visitLetDeclaration(decl); diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index 87d57efc429e..ce246ad16d2d 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -207,6 +207,11 @@ export enum OpKind { */ Projection, + /** + * Represents a projected `@content` block for a foreign component. + */ + Content, + /** * Create a repeater creation instruction op. */ diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 7dfd21a39e6c..06fc464530b7 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -1299,8 +1299,8 @@ export function transformExpressionsInOp( op.value = transformExpressionsInExpression(op.value, transform, flags); break; case OpKind.ForeignComponent: - if (op.props !== null) { - op.props = transformExpressionsInExpression(op.props, transform, flags); + for (const [key, expr] of op.props) { + op.props.set(key, transformExpressionsInExpression(expr, transform, flags)); } break; case OpKind.Advance: @@ -1326,6 +1326,7 @@ export function transformExpressionsInOp( case OpKind.ProjectionDef: case OpKind.EnableIncrementalHydrationRuntime: case OpKind.Template: + case OpKind.Content: case OpKind.Text: case OpKind.I18nAttributes: case OpKind.IcuPlaceholder: diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index 2a17afa96e1a..2af4897345ad 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -60,6 +60,7 @@ export type CreateOp = | ProjectionDefOp | EnableIncrementalHydrationRuntimeOp | ProjectionOp + | ContentOp | ExtractedAttributeOp | DeferOp | DeferOnOp @@ -255,9 +256,9 @@ export interface ForeignComponentOp extends Op, ConsumesSlotOpTrait { foreignComponentRef: o.Expression; /** - * Static attributes and property bindings aggregated as an object literal. + * Static attributes and property bindings. */ - props: o.Expression | null; + props: Map; sourceSpan: ParseSourceSpan | null; } @@ -268,7 +269,7 @@ export interface ForeignComponentOp extends Op, ConsumesSlotOpTrait { export function createForeignComponentOp( xref: XrefId, foreignComponentRef: o.Expression, - props: o.Expression | null, + props: Map, sourceSpan: ParseSourceSpan | null, ): ForeignComponentOp { return { @@ -283,6 +284,52 @@ export function createForeignComponentOp( }; } +/** + * Logical operation representing a project `@content` block for a foreign component. + */ +export interface ContentOp extends Op { + kind: OpKind.Content; + + /** + * The `XrefId` of the foreign component this content is projected into. + */ + target: XrefId; + + /** + * The name of the property on the foreign component to assign this content to. + */ + propertyName: string; + + /** + * The `XrefId` of the view containing the content. + */ + view: XrefId; + + startSourceSpan: ParseSourceSpan; + sourceSpan: ParseSourceSpan; +} + +/** + * Create a `ContentOp`. + */ +export function createContentOp( + target: XrefId, + view: XrefId, + propertyName: string, + startSourceSpan: ParseSourceSpan, + sourceSpan: ParseSourceSpan, +): ContentOp { + return { + kind: OpKind.Content, + target, + propertyName, + view, + startSourceSpan, + sourceSpan, + ...NEW_OP, + }; +} + /** * Logical operation representing an element with no children in the creation IR. */ diff --git a/packages/compiler/src/template/pipeline/src/emit.ts b/packages/compiler/src/template/pipeline/src/emit.ts index 91fc45431029..9c66263f51a5 100644 --- a/packages/compiler/src/template/pipeline/src/emit.ts +++ b/packages/compiler/src/template/pipeline/src/emit.ts @@ -73,6 +73,7 @@ import {removeUnusedI18nAttributesOps} from './phases/remove_unused_i18n_attrs'; import {resolveContexts} from './phases/resolve_contexts'; import {resolveDeferDepsFns} from './phases/resolve_defer_deps_fns'; import {resolveDollarEvent} from './phases/resolve_dollar_event'; +import {resolveForeignContent} from './phases/resolve_foreign_content'; import {resolveI18nElementPlaceholders} from './phases/resolve_i18n_element_placeholders'; import {resolveI18nExpressionPlaceholders} from './phases/resolve_i18n_expression_placeholders'; import {resolveI18nAttrSanitizers} from './phases/resolve_i18n_attr_sanitizers'; @@ -107,6 +108,7 @@ type Phase = }; const phases: Phase[] = [ + {kind: Kind.Tmpl, fn: resolveForeignContent}, {kind: Kind.Tmpl, fn: removeContentSelectors}, {kind: Kind.Both, fn: optimizeRegularExpressions}, {kind: Kind.Host, fn: parseHostStyleProperties}, diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 1c6e92328dd8..45ef4273907c 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -14,7 +14,6 @@ import {splitNsName} from '../../../ml_parser/tags'; import * as o from '../../../output/output_ast'; import {ParseSourceSpan} from '../../../parse_util'; import * as t from '../../../render3/r3_ast'; -import {isUnsafeObjectKey} from '../../../render3/util'; import { DeferBlockDepsEmitMode, R3ComponentDeferMetadata, @@ -269,6 +268,8 @@ function ingestNodes(unit: ViewCompilationUnit, template: t.Node[]): void { ingestForBlock(unit, node); } else if (node instanceof t.LetDeclaration) { ingestLetDeclaration(unit, node); + } else if (node instanceof t.ContentBlock) { + throw new Error(`@content blocks are only valid as direct children of foreign components.`); } else if (node instanceof t.Component) { // TODO(crisbeto): account for selectorless nodes. } else { @@ -292,53 +293,7 @@ function ingestElement(unit: ViewCompilationUnit, element: t.Element): void { const foreignComp = unit.job.getForeignComponent(element); if (foreignComp) { - const propEntries: {key: string; quoted: boolean; value: o.Expression}[] = []; - for (const attr of element.attributes) { - propEntries.push({ - key: attr.name, - value: o.literal(attr.value), - quoted: isUnsafeObjectKey(attr.name), - }); - } - for (const input of element.inputs) { - propEntries.push({ - key: input.name, - value: convertAst(input.value, unit.job, input.sourceSpan), - quoted: isUnsafeObjectKey(input.name), - }); - } - - if (element.children.length > 0) { - const childView = unit.job.allocateView(unit.xref); - ingestNodes(childView, element.children); - - const childrenTemplateOp = ir.createTemplateOp( - childView.xref, - ir.TemplateKind.NgTemplate, - null, - 'Children', - ir.Namespace.HTML, - undefined, - element.startSourceSpan, - element.sourceSpan, - ); - unit.create.push(childrenTemplateOp); - - propEntries.push({ - key: 'children', - value: new ir.ForeignContentExpr(childrenTemplateOp.xref, childrenTemplateOp.handle), - quoted: false, - }); - } - - const props = propEntries.length > 0 ? o.literalMap(propEntries) : null; - - // Foreign components are created in the creation block. Updates are triggered reactively - // through directly passed signal properties, alleviating the need for any explicit update - // operations. - unit.create.push( - ir.createForeignComponentOp(id, foreignComp.component, props, element.startSourceSpan), - ); + ingestForeignComponent(unit, id, element, foreignComp); return; } @@ -384,6 +339,66 @@ function ingestElement(unit: ViewCompilationUnit, element: t.Element): void { } } +/** + * Ingest a foreign component's element AST from the template into the given `ViewCompilation`. + */ +function ingestForeignComponent( + unit: ViewCompilationUnit, + id: ir.XrefId, + element: t.Element, + foreignComp: R3ForeignComponentMetadata, +): void { + const props = new Map(); + for (const attr of element.attributes) { + props.set(attr.name, o.literal(attr.value)); + } + for (const input of element.inputs) { + props.set(input.name, convertAst(input.value, unit.job, input.sourceSpan)); + } + + const contentBlocks: t.ContentBlock[] = []; + const childNodes: t.Node[] = []; + + for (const child of element.children) { + if (child instanceof t.ContentBlock) { + contentBlocks.push(child); + } else { + childNodes.push(child); + } + } + + for (const block of contentBlocks) { + const blockView = unit.job.allocateView(unit.xref); + ingestNodes(blockView, block.children); + + unit.create.push( + ir.createContentOp(id, blockView.xref, block.name, block.startSourceSpan, block.sourceSpan), + ); + } + + if (childNodes.length > 0) { + const childView = unit.job.allocateView(unit.xref); + ingestNodes(childView, childNodes); + + unit.create.push( + ir.createContentOp( + id, + childView.xref, + 'children', + element.startSourceSpan, + element.sourceSpan, + ), + ); + } + + // Foreign components are created in the creation block. Updates are triggered reactively + // through directly passed signal properties, alleviating the need for any explicit update + // operations. + unit.create.push( + ir.createForeignComponentOp(id, foreignComp.component, props, element.startSourceSpan), + ); +} + /** * Ingest an `ng-template` node from the AST into the given `ViewCompilation`. */ diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 483dc6c8c005..eb49fc3567eb 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -8,6 +8,7 @@ import * as o from '../../../../output/output_ast'; import {CONTEXT_NAME} from '../../../../render3/view/util'; +import {isUnsafeObjectKey} from '../../../../render3/util'; import {Identifiers} from '../../../../render3/r3_identifiers'; import * as ir from '../../ir'; import { @@ -143,9 +144,19 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList 0 + ? o.literalMap( + Array.from(op.props.entries()).map(([key, value]) => ({ + key, + value, + quoted: isUnsafeObjectKey(key), + })), + ) + : null; ir.OpList.replace( op, - ng.foreignComponent(op.handle.slot!, op.foreignComponentRef, op.props, op.sourceSpan), + ng.foreignComponent(op.handle.slot!, op.foreignComponentRef, propsExpr, op.sourceSpan), ); break; case ir.OpKind.ElementEnd: diff --git a/packages/compiler/src/template/pipeline/src/phases/resolve_foreign_content.ts b/packages/compiler/src/template/pipeline/src/phases/resolve_foreign_content.ts new file mode 100644 index 000000000000..ddd7d4e43dc5 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/resolve_foreign_content.ts @@ -0,0 +1,55 @@ +/** + * @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 * as o from '../../../../output/output_ast'; +import * as ir from '../../ir'; +import type {CompilationJob} from '../compilation'; + +/** + * Resolves `ContentOp`s by replacing them with a `TemplateOp` and adding a corresponding + * property to the target `ForeignComponentOp`. + */ +export function resolveForeignContent(job: CompilationJob): void { + for (const unit of job.units) { + // Collect all foreign components in this unit + const foreignComponents = new Map(); + for (const op of unit.create) { + if (op.kind === ir.OpKind.ForeignComponent) { + foreignComponents.set(op.xref, op); + } + } + + for (const op of unit.create) { + if (op.kind !== ir.OpKind.Content) { + continue; + } + + const target = foreignComponents.get(op.target); + if (target === undefined) { + throw new Error(`AssertionError: ContentOp target not found`); + } + + const templateName = op.propertyName.charAt(0).toUpperCase() + op.propertyName.slice(1); + const templateOp = ir.createTemplateOp( + op.view, + ir.TemplateKind.NgTemplate, + null, // tagName + templateName, + ir.Namespace.HTML, + undefined, + op.startSourceSpan, + op.sourceSpan, + ); + + ir.OpList.replace(op, templateOp); + + const foreignContent = new ir.ForeignContentExpr(templateOp.xref, templateOp.handle); + target.props.set(op.propertyName, foreignContent); + } + } +} diff --git a/packages/compiler/test/render3/r3_ast_spans_spec.ts b/packages/compiler/test/render3/r3_ast_spans_spec.ts index 3f4bee356eab..d11e3e2e3d52 100644 --- a/packages/compiler/test/render3/r3_ast_spans_spec.ts +++ b/packages/compiler/test/render3/r3_ast_spans_spec.ts @@ -56,6 +56,16 @@ class R3AstSourceSpans implements t.Visitor { this.visitAll([content.attributes, content.children]); } + visitContentBlock(block: t.ContentBlock) { + this.result.push([ + 'ContentBlock', + humanizeSpan(block.sourceSpan), + humanizeSpan(block.startSourceSpan), + humanizeSpan(block.endSourceSpan), + ]); + this.visitAll([block.children]); + } + visitVariable(variable: t.Variable) { this.result.push([ 'Variable', diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index 7ecd2ec69931..0ff97f49a90d 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -59,6 +59,11 @@ class R3AstHumanizer implements t.Visitor { this.visitAll([content.attributes, content.children]); } + visitContentBlock(block: t.ContentBlock) { + this.result.push(['ContentBlock', block.name]); + this.visitAll([block.children]); + } + visitVariable(variable: t.Variable) { this.result.push(['Variable', variable.name, variable.value]); } @@ -3010,4 +3015,36 @@ describe('R3 template transform', () => { const errors = parse(template, {ignoreError: true}).errors; expect(errors.length).toBe(0); }); + + describe('@content blocks', () => { + it('should parse a valid @content block', () => { + expectFromHtml(` + @content(icon) { + Icon content + } + `).toEqual([ + ['ContentBlock', 'icon'], + ['Element', 'span'], + ['Text', 'Icon content'], + ]); + }); + + it('should error on invalid name', () => { + expect(() => parse(`@content(inv-alid) {}`)).toThrowError( + /@content name must be a valid JavaScript identifier/, + ); + }); + + it('should error if @content block has missing parameter', () => { + expect(() => parse(`@content {}`)).toThrowError( + /@content block must have exactly one parameter/, + ); + }); + + it('should error if @content block has multiple parameters', () => { + expect(() => parse(`@content(icon, text) {}`)).toThrowError( + /@content block must have exactly one parameter/, + ); + }); + }); }); diff --git a/packages/compiler/test/render3/util/expression.ts b/packages/compiler/test/render3/util/expression.ts index 522f151c1116..a49567b49b08 100644 --- a/packages/compiler/test/render3/util/expression.ts +++ b/packages/compiler/test/render3/util/expression.ts @@ -258,6 +258,10 @@ class ExpressionSourceHumanizer extends e.RecursiveAstVisitor implements t.Visit t.visitAll(this, ast.inputs); t.visitAll(this, ast.outputs); } + + visitContentBlock(block: t.ContentBlock) { + t.visitAll(this, block.children); + } } /** diff --git a/packages/core/schematics/utils/template_ast_visitor.ts b/packages/core/schematics/utils/template_ast_visitor.ts index 5259b8c7baa1..ee250783a4e8 100644 --- a/packages/core/schematics/utils/template_ast_visitor.ts +++ b/packages/core/schematics/utils/template_ast_visitor.ts @@ -37,6 +37,7 @@ import type { TmplAstTextAttribute, TmplAstUnknownBlock, TmplAstVariable, + TmplAstContentBlock, } from '@angular/compiler'; /** @@ -89,6 +90,7 @@ export class TemplateAstVisitor implements TmplAstRecursiveVisitor { visitComponent(component: TmplAstComponent): void {} visitDirective(directive: TmplAstDirective): void {} visitSwitchExhaustiveCheck(block: TmplAstSwitchExhaustiveCheck): void {} + visitContentBlock(block: TmplAstContentBlock): void {} /** * Visits all the provided nodes in order using this Visitor's visit methods. diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts index 5bf84c6e3754..716b24683c8b 100644 --- a/packages/core/test/render3/foreign_component_spec.ts +++ b/packages/core/test/render3/foreign_component_spec.ts @@ -210,36 +210,86 @@ describe('ɵɵforeignComponent', () => { expect(host2.innerHTML).toContain(expectedHtml); }); - it('should render children using ɵɵforeignContent and pass root nodes to the children prop', () => { - const foreignComp = foreignImport<{children: Node[]}>((props) => { - const p = document.createElement('p'); - // Domino doesn't implement Element.append(). + it('should support passing ɵɵforeignContent to props', () => { + const foreignComp = foreignImport<{ + icon: Node[]; + description: Node[]; + children: Node[]; + }>((props) => { + const div = document.createElement('div'); + div.id = 'container'; + + const iconContainer = document.createElement('div'); + iconContainer.id = 'icon-container'; + for (const child of props.icon) { + iconContainer.appendChild(child); + } + div.appendChild(iconContainer); + + const descContainer = document.createElement('div'); + descContainer.id = 'desc-container'; + for (const child of props.description) { + descContainer.appendChild(child); + } + div.appendChild(descContainer); + + const mainChildren = document.createElement('div'); + mainChildren.id = 'children-container'; for (const child of props.children) { - p.appendChild(child); + mainChildren.appendChild(child); } - return [[p]]; + div.appendChild(mainChildren); + + return [[div]]; }); + const iconTemplate = (rf: number, ctx: any) => { + if (rf & 1) { + ɵɵelementStart(0, 'span'); + ɵɵtext(1, 'Icon Content'); + ɵɵelementEnd(); + } + }; + + const descriptionTemplate = (rf: number, ctx: any) => { + if (rf & 1) { + ɵɵelementStart(0, 'p'); + ɵɵtext(1, 'Description Content'); + ɵɵelementEnd(); + } + }; + const childrenTemplate = (rf: number, ctx: any) => { if (rf & 1) { ɵɵelementStart(0, 'span'); - ɵɵtext(1, 'Hello, world!'); + ɵɵtext(1, 'Main Children Content'); ɵɵelementEnd(); } }; const fixture = new ViewFixture({ - decls: 2, + decls: 4, vars: 0, create: () => { - ɵɵdomTemplate(0, childrenTemplate, 2, 0); - ɵɵforeignComponent(1, foreignComp, { - children: ɵɵforeignContent(0), + ɵɵdomTemplate(0, iconTemplate, 2, 0); + ɵɵdomTemplate(1, descriptionTemplate, 2, 0); + ɵɵdomTemplate(2, childrenTemplate, 2, 0); + ɵɵforeignComponent(3, foreignComp, { + icon: ɵɵforeignContent(0), + description: ɵɵforeignContent(1), + children: ɵɵforeignContent(2), }); }, }); - expect(fixture.host.innerHTML).toContain('' + '

' + 'Hello, world!' + '

'); + expect(fixture.host.innerHTML).toContain( + '' + + '
' + + '
Icon Content
' + + '

Description Content

' + + '
Main Children Content
' + + '
', + ); }); }); diff --git a/packages/language-service/src/semantic_tokens.ts b/packages/language-service/src/semantic_tokens.ts index ee474f867548..ede48b4aec2b 100644 --- a/packages/language-service/src/semantic_tokens.ts +++ b/packages/language-service/src/semantic_tokens.ts @@ -13,6 +13,7 @@ import { TmplAstBoundText, TmplAstComponent, TmplAstContent, + TmplAstContentBlock, TmplAstDeferredBlock, TmplAstDeferredBlockError, TmplAstDeferredBlockLoading, @@ -141,6 +142,10 @@ class ClassificationVisitor implements TmplAstVisitor { this.visitAll(content.children); } + visitContentBlock(block: TmplAstContentBlock) { + this.visitAll(block.children); + } + visitVariable(variable: TmplAstVariable) {} visitReference(reference: TmplAstReference) {} visitTextAttribute(attribute: TmplAstTextAttribute) {} diff --git a/packages/language-service/src/template_target.ts b/packages/language-service/src/template_target.ts index 17282274f643..3609a23e0def 100644 --- a/packages/language-service/src/template_target.ts +++ b/packages/language-service/src/template_target.ts @@ -24,6 +24,7 @@ import { TmplAstBoundText, TmplAstComponent, TmplAstContent, + TmplAstContentBlock, TmplAstDeferredBlock, TmplAstDeferredBlockError, TmplAstDeferredBlockLoading, @@ -588,6 +589,10 @@ class TemplateTargetVisitor implements TmplAstVisitor { this.visitAll(content.children); } + visitContentBlock(block: TmplAstContentBlock) { + this.visitAll(block.children); + } + visitVariable(variable: TmplAstVariable) { // Variable has no template nodes or expression nodes. } From 56607967dbba25e63f86fd128ec0c1c4ce3e5232 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Fri, 29 May 2026 16:19:24 -0700 Subject: [PATCH 3/7] fix(core): introduce logical-only containers for foreign content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a logical-only container flag (`LContainerFlags.LogicalOnly`) to support Angular features (like change detection and queries) on projected content within foreign components, while relinquishing control over their placement in the DOM. When content is projected into a foreign component via `ɵɵforeignContent`, the foreign component receives the native DOM nodes directly and assumes control over their DOM placement. Therefore, Angular must skip all platform-level view operations (insert, move, delete) on these projected views. To achieve this: 1. Introduce Logical-Only Containers: - Added `LContainerFlags.LogicalOnly` to represent view containers whose nodes are managed logically (by the consuming foreign component) rather than by the renderer. - Flagged `ɵɵforeignContent` containers with the `LogicalOnly` annotation. - Updated `applyContainer` in `node_manipulation.ts` to return early and skip platform DOM manipulations (insert, detach, destroy) on containers marked as logical-only. 2. Guard `collectNativeNodes`: - Updated `collectNativeNodes` in `collect_native_nodes.ts` to skip descending into logical-only containers. This prevents nested projected child elements (which are already claimed and placed inside nested foreign components) from being re-collected at the parent component's projection root level. 3. Unit and Acceptance Tests: - Added a comprehensive set of categorized acceptance tests in `foreign_component_spec.ts` covering nested foreign projections, projecting foreign components into Angular components, Signal-based view queries (`viewChildren`), event handlers, and change detection. --- .../core/src/render3/collect_native_nodes.ts | 14 +- .../render3/instructions/foreign_component.ts | 11 +- .../core/src/render3/interfaces/container.ts | 6 + .../core/src/render3/node_manipulation.ts | 11 +- .../acceptance/foreign_component/BUILD.bazel | 30 ++ .../foreign_component_spec.ts | 405 ++++++++++++++++++ 6 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 packages/core/test/acceptance/foreign_component/BUILD.bazel create mode 100644 packages/core/test/acceptance/foreign_component/foreign_component_spec.ts diff --git a/packages/core/src/render3/collect_native_nodes.ts b/packages/core/src/render3/collect_native_nodes.ts index f4ac1e5c83a7..94e5be21fded 100644 --- a/packages/core/src/render3/collect_native_nodes.ts +++ b/packages/core/src/render3/collect_native_nodes.ts @@ -8,11 +8,19 @@ import {assertParentView} from './assert'; import {icuContainerIterate} from './i18n/i18n_tree_shaking'; -import {CONTAINER_HEADER_OFFSET, LContainer, NATIVE} from './interfaces/container'; +import {CONTAINER_HEADER_OFFSET, LContainer, LContainerFlags, NATIVE} from './interfaces/container'; 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, TViewType} from './interfaces/view'; +import { + DECLARATION_COMPONENT_VIEW, + FLAGS, + 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'; @@ -60,7 +68,7 @@ export function collectNativeNodes( // A given lNode can represent either a native node or a LContainer (when it is a host of a // ViewContainerRef). When we find a LContainer we need to descend into it to collect root nodes // from the views in this container. - if (isLContainer(lNode)) { + if (isLContainer(lNode) && !(lNode[FLAGS] & LContainerFlags.LogicalOnly)) { collectNativeNodesInLContainer(lNode, result); } diff --git a/packages/core/src/render3/instructions/foreign_component.ts b/packages/core/src/render3/instructions/foreign_component.ts index 1d34a45fd88f..a5a7d9bfebc9 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -11,7 +11,7 @@ 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, TVIEW} from '../interfaces/view'; +import {HEADER_OFFSET, RENDERER, TVIEW, FLAGS} from '../interfaces/view'; import {appendChild} from '../node_manipulation'; import {getLView, getTView, setCurrentTNode, setCurrentTNodeAsNotParent} from '../state'; import {getOrCreateTNode} from '../tnode_manipulation'; @@ -20,11 +20,11 @@ import {createLContainer, addLViewToLContainer} from '../view/container'; import {NodeInjector} from '../di'; import {runInInjectionContext} from '../../di'; import {Renderer} from '../interfaces/renderer'; -import {RNode} from '../interfaces/renderer_dom'; +import {RElement, RNode} from '../interfaces/renderer_dom'; import {createAndRenderEmbeddedLView} from '../view_manipulation'; import {collectNativeNodes} from '../collect_native_nodes'; import {assertLContainer} from '../assert'; -import {LContainer} from '../interfaces/container'; +import {LContainer, LContainerFlags} from '../interfaces/container'; /** * Creation phase instruction to render a foreign component. @@ -101,12 +101,13 @@ export function ɵɵforeignContent(index: number): any[] { // The template is already declared at adjustedIndex, so lContainer must exist. const lContainer = lView[adjustedIndex] as LContainer; ngDevMode && assertLContainer(lContainer); + lContainer[FLAGS] |= LContainerFlags.LogicalOnly; const tView = getTView(); const tNode = tView.data[adjustedIndex] as TContainerNode; - // Instantiate and render the embedded view inside the container, - // but do NOT add its elements to the DOM at the container anchor. + // Instantiate and render the embedded view inside the container, but do not add its elements to + // the DOM at the container anchor since the nodes will be projected into a foreign view. const embeddedLView = createAndRenderEmbeddedLView(lView, tNode, null); addLViewToLContainer(lContainer, embeddedLView, 0, /* addToDOM */ false); diff --git a/packages/core/src/render3/interfaces/container.ts b/packages/core/src/render3/interfaces/container.ts index 92bae6e40c54..715b31840ea7 100644 --- a/packages/core/src/render3/interfaces/container.ts +++ b/packages/core/src/render3/interfaces/container.ts @@ -127,4 +127,10 @@ export const enum LContainerFlags { * This flag, once set, is never unset for the `LContainer`. */ HasTransplantedViews = 1 << 1, + + /** + * Flag to signify that this `LContainer` is logical-only and its views should not be added + * to or removed from the rendering tree by the platform renderer. + */ + LogicalOnly = 1 << 2, } diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index 275d13058908..e4d881cd4d87 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -35,7 +35,13 @@ import { nativeRemoveNode, } from './dom_node_manipulation'; import {icuContainerIterate} from './i18n/i18n_tree_shaking'; -import {CONTAINER_HEADER_OFFSET, LContainer, MOVED_VIEWS, NATIVE} from './interfaces/container'; +import { + CONTAINER_HEADER_OFFSET, + LContainer, + LContainerFlags, + MOVED_VIEWS, + NATIVE, +} from './interfaces/container'; import {ComponentDef} from './interfaces/definition'; import {NodeInjectorFactory} from './interfaces/injector'; import {unregisterLView} from './interfaces/lview_tracking'; @@ -1082,6 +1088,9 @@ function applyContainer( beforeNode, ); } + if ((lContainer[FLAGS] & LContainerFlags.LogicalOnly) !== 0) { + return; + } for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) { const lView = lContainer[i] as LView; applyView(lView[TVIEW], lView, renderer, action, parentRElement, anchor); diff --git a/packages/core/test/acceptance/foreign_component/BUILD.bazel b/packages/core/test/acceptance/foreign_component/BUILD.bazel new file mode 100644 index 000000000000..f31526f1f9a4 --- /dev/null +++ b/packages/core/test/acceptance/foreign_component/BUILD.bazel @@ -0,0 +1,30 @@ +load("//tools:defaults.bzl", "angular_jasmine_test", "ng_project", "ng_web_test_suite") + +package(default_visibility = ["//visibility:private"]) + +ng_project( + name = "foreign_component_test_lib", + testonly = True, + srcs = glob(["**/*.ts"]), + visibility = ["//:__pkg__"], + deps = [ + "//packages/core", + "//packages/core/src/interface", + "//packages/core/testing", + ], +) + +angular_jasmine_test( + name = "foreign_component", + data = [ + ":foreign_component_test_lib", + "//:node_modules/source-map", + ], +) + +ng_web_test_suite( + name = "foreign_component_web", + deps = [ + ":foreign_component_test_lib", + ], +) diff --git a/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts new file mode 100644 index 000000000000..ce51096766e7 --- /dev/null +++ b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts @@ -0,0 +1,405 @@ +/** + * @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, ElementRef, signal, viewChildren} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {ForeignComponent} from '../../../src/interface/foreign_component'; +import {foreignImport} from '../../../src/render3/foreign_import'; + +function frameworkImport(component: (props: TProps) => Node[]): ForeignComponent { + return foreignImport((props) => [component(props)]); +} + +function FancyButton(props: {children: Node[]}): Node[] { + const button = document.createElement('button'); + for (const child of props.children) { + button.appendChild(child); + } + return [button]; +} + +describe('foreign components', () => { + describe('content projection', () => { + it('should update foreign content', async () => { + @Component({ + selector: 'test-cmp', + template: ` + + {{ buttonIcon() }} + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyButton)], + }) + class TestUpdateForeignContent { + readonly buttonIcon = signal('⭐'); + } + + const fixture = TestBed.createComponent(TestUpdateForeignContent); + await fixture.whenStable(); + + const icon = fixture.nativeElement.querySelector('#icon'); + expect(icon).toBeTruthy(); + expect(icon.textContent).toBe('⭐'); + + fixture.componentInstance.buttonIcon.set('🔥'); + await fixture.whenStable(); + + expect(icon.textContent).toBe('🔥'); + }); + + it('should not reparent content to next to its original container when added to the DOM', async () => { + @Component({ + selector: 'test-cmp', + template: ` + @if (true) { + + + + } + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyButton)], + }) + class TestNoReparenting {} + + const fixture = TestBed.createComponent(TestNoReparenting); + + // Change detection triggers insertion of the @if view, which contains the foreign component. + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toBe( + '' + + // The container in which @content is initially rendered. + '' + + // The start of the foreign view created for the foreign component. + '' + + '' + + // The end of the foreign view created for the foreign component. + '' + + // The container in which the foreign view is rendered. + '' + + // The container anchor for the @if block. + '', + ); + }); + + it('should support multiple @content blocks', async () => { + function Card(props: {header: Node[]; children: Node[]; footer: Node[]}): Node[] { + const card = document.createElement('div'); + card.className = 'card'; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'header'; + for (const child of props.header) { + headerDiv.appendChild(child); + } + card.appendChild(headerDiv); + + const bodyDiv = document.createElement('div'); + bodyDiv.className = 'body'; + for (const child of props.children) { + bodyDiv.appendChild(child); + } + card.appendChild(bodyDiv); + + const footerDiv = document.createElement('div'); + footerDiv.className = 'footer'; + for (const child of props.footer) { + footerDiv.appendChild(child); + } + card.appendChild(footerDiv); + + return [card]; + } + + @Component({ + selector: 'test-cmp', + template: ` + + @content(header) { +

My Title

+ } +

Card body content

+ @content(footer) { + + } +
+ `, + // @ts-ignore + foreignImports: [frameworkImport(Card)], + }) + class TestMultiContent {} + + const fixture = TestBed.createComponent(TestMultiContent); + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toContain( + '' + + // The start of the foreign view created for the foreign component. + '' + + '
' + + '
' + + '

My Title

' + + '
' + + '
' + + '

Card body content

' + + '
' + + '' + + '
' + + // The end of the foreign view created for the foreign component. + '' + + // The container in which the foreign view is rendered. + '', + ); + }); + + it('should support conditional (@if) in projected foreign content', async () => { + @Component({ + selector: 'test-cmp', + template: ` + + @if (show()) { + Active + } @else { + Inactive + } + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyButton)], + }) + class TestConditionalForeignContent { + readonly show = signal(true); + } + + const fixture = TestBed.createComponent(TestConditionalForeignContent); + await fixture.whenStable(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button).toBeTruthy(); + expect(button.textContent).toContain('Active'); + expect(button.textContent).not.toContain('Inactive'); + + fixture.componentInstance.show.set(false); + await fixture.whenStable(); + + expect(button.textContent).toContain('Inactive'); + expect(button.textContent).not.toContain('Active'); + }); + + it('should support loops (@for) in projected foreign content', async () => { + @Component({ + selector: 'test-cmp', + template: ` + + @for (item of items(); track item) { + {{ item }} + } + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyButton)], + }) + class TestForLoopForeignContent { + readonly items = signal(['A', 'B', 'C']); + } + + const fixture = TestBed.createComponent(TestForLoopForeignContent); + await fixture.whenStable(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button).toBeTruthy(); + + let items = button.querySelectorAll('.item'); + expect(items.length).toBe(3); + expect(items[0].textContent).toBe('A'); + expect(items[1].textContent).toBe('B'); + expect(items[2].textContent).toBe('C'); + + // 1. Reorder and remove + fixture.componentInstance.items.set(['C', 'A']); + await fixture.whenStable(); + + items = button.querySelectorAll('.item'); + expect(items.length).toBe(2); + expect(items[0].textContent).toBe('C'); + expect(items[1].textContent).toBe('A'); + + // 2. Add new item + fixture.componentInstance.items.set(['C', 'D', 'A']); + await fixture.whenStable(); + + items = button.querySelectorAll('.item'); + expect(items.length).toBe(3); + expect(items[0].textContent).toBe('C'); + expect(items[1].textContent).toBe('D'); + expect(items[2].textContent).toBe('A'); + }); + + it('should support projecting a foreign component into a foreign component', async () => { + function SimpleWrapper(props: {children: Node[]}): Node[] { + const div = document.createElement('div'); + div.className = 'wrapper'; + for (const child of props.children) { + div.appendChild(child); + } + return [div]; + } + + @Component({ + selector: 'test-cmp', + template: ` + + + Inside wrapper button + + + `, + // @ts-ignore + foreignImports: [frameworkImport(SimpleWrapper), frameworkImport(FancyButton)], + }) + class TestNestedForeignComponents {} + + const fixture = TestBed.createComponent(TestNestedForeignComponents); + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toBe( + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '', + ); + }); + + it('should support projecting a foreign component into an Angular component', async () => { + @Component({ + selector: 'angular-wrapper', + template: ``, + }) + class AngularWrapper {} + + @Component({ + selector: 'test-cmp', + imports: [AngularWrapper], + template: ` + + + Inside Angular + + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyButton)], + }) + class TestProjectForeignIntoAngular {} + + const fixture = TestBed.createComponent(TestProjectForeignIntoAngular); + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toBe( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + ); + }); + }); + + describe('queries', () => { + it('should support querying elements inside projected foreign content', async () => { + @Component({ + selector: 'test-cmp', + template: ` + + @if (show()) { + + } + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyButton)], + }) + class TestQueryForeignContent { + readonly show = signal(true); + readonly targets = viewChildren>('target'); + } + + const fixture = TestBed.createComponent(TestQueryForeignContent); + await fixture.whenStable(); + + expect(fixture.componentInstance.targets().length).toBe(1); + expect(fixture.componentInstance.targets()[0].nativeElement.id).toBe('icon'); + + fixture.componentInstance.show.set(false); + await fixture.whenStable(); + + expect(fixture.componentInstance.targets().length).toBe(0); + + fixture.componentInstance.show.set(true); + await fixture.whenStable(); + + expect(fixture.componentInstance.targets().length).toBe(1); + expect(fixture.componentInstance.targets()[0].nativeElement.id).toBe('icon'); + }); + }); + + describe('event handlers', () => { + it('should support event handlers on elements inside projected foreign content', async () => { + let clicked = false; + + @Component({ + selector: 'test-cmp', + template: ` + + + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyButton)], + }) + class TestEventForeignContent { + handleClick() { + clicked = true; + } + } + + const fixture = TestBed.createComponent(TestEventForeignContent); + await fixture.whenStable(); + + const icon = fixture.nativeElement.querySelector('#icon'); + expect(icon).toBeTruthy(); + expect(clicked).toBeFalse(); + + icon.click(); + await fixture.whenStable(); + + expect(clicked).toBeTrue(); + }); + }); +}); From daa47b4aad868cf2d86435929af1060e0f564034 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Thu, 4 Jun 2026 12:14:59 -0700 Subject: [PATCH 4/7] refactor(compiler-cli): validate foreign component bindings during analysis Refactors the unsupported bindings validation for foreign components from the template semantics checker phase (during type-checking) to the component analysis phase. Surfacing this check during component analysis means it will be correctly reported during local compilation (which skips full template type-checking). Specifically: - Creates a new helper `analyzeForeignComponentFeatures` in `foreign_component.ts` that traverses template elements and checks for unsupported outputs, references, and non-property inputs on foreign components. - Removes the legacy validation from `template_semantics_checker.ts`. - Invokes the validation during component analysis in `ComponentDecoratorHandler.analyze()`. --- .../ngtsc/annotations/component/BUILD.bazel | 1 + .../component/src/foreign_component.ts | 97 +++++++++++++++++++ .../annotations/component/src/handler.ts | 9 ++ .../src/template_semantics_checker.ts | 61 +----------- 4 files changed, 109 insertions(+), 59 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/BUILD.bazel b/packages/compiler-cli/src/ngtsc/annotations/component/BUILD.bazel index 38c16fb9f393..255904d02487 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/annotations/component/BUILD.bazel @@ -30,6 +30,7 @@ ts_project( "//packages/compiler-cli/src/ngtsc/transform", "//packages/compiler-cli/src/ngtsc/typecheck", "//packages/compiler-cli/src/ngtsc/typecheck/api", + "//packages/compiler-cli/src/ngtsc/typecheck/diagnostics", "//packages/compiler-cli/src/ngtsc/typecheck/extended/api", "//packages/compiler-cli/src/ngtsc/typecheck/template_semantics/api", "//packages/compiler-cli/src/ngtsc/util", diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts new file mode 100644 index 000000000000..8c3a1a81112c --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts @@ -0,0 +1,97 @@ +/** + * @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 { + BindingType, + SelectorlessMatcher, + TmplAstElement, + TmplAstRecursiveVisitor, + tmplAstVisitAll, + TypeCheckId, +} from '@angular/compiler'; +import ts from 'typescript'; + +import {ParsedTemplateWithSource} from './resources'; +import {ErrorCode, ngErrorCode} from '../../../diagnostics'; +import {ForeignComponentMeta} from '../../../metadata'; +import {makeTemplateDiagnostic} from '../../../typecheck/diagnostics'; +import {SourceMapping} from '../../../typecheck/api'; + +/** + * Analyzes the template for invalid use of features relating to foreign components. + * + * @param template The template to analyze. + * @param foreignMatcher A matcher that can be used to identify foreign components. + * @returns A list of diagnostics that should be reported for the template. + */ +export function analyzeForeignComponentFeatures( + template: ParsedTemplateWithSource, + foreignMatcher: SelectorlessMatcher | null, +): ts.Diagnostic[] { + const analyzer = new ForeignComponentFeatureAnalyzer(foreignMatcher, template.sourceMapping); + tmplAstVisitAll(analyzer, template.nodes); + return analyzer.diagnostics; +} + +class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { + readonly diagnostics: ts.Diagnostic[] = []; + + constructor( + private readonly foreignMatcher: SelectorlessMatcher | null, + private readonly sourceMapping: SourceMapping, + ) { + super(); + } + + private elementIsForeignComponent(tagName: string): boolean { + return this.foreignMatcher !== null && this.foreignMatcher.match(tagName).length > 0; + } + + override visitElement(element: TmplAstElement): void { + if (this.elementIsForeignComponent(element.name)) { + if (element.outputs.length > 0) { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + element.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING), + 'Foreign components do not support event bindings.', + ), + ); + } + if (element.references.length > 0) { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + element.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING), + 'Foreign components do not support references.', + ), + ); + } + if (element.inputs.some((input) => input.type !== BindingType.Property)) { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + element.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING), + 'Foreign components only support static attributes and property bindings.', + ), + ); + } + } + + super.visitElement(element); + } +} 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 a612e4ef2918..ebdbe75a7af7 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -177,6 +177,7 @@ import {getTemplateDiagnostics} from '../../../typecheck'; import {getProjectRelativePath} from '../../../util/src/path'; import {JitDeclarationRegistry} from '../../common/src/jit_declaration_registry'; import {analyzeTemplateForAnimations} from './animations'; +import {analyzeForeignComponentFeatures} from './foreign_component'; import {checkCustomElementSelectorForErrors, makeCyclicImportInfo} from './diagnostics'; import { ComponentAnalysisData, @@ -863,6 +864,14 @@ export class ComponentDecoratorHandler implements DecoratorHandler< } } + const foreignMatcher = createForeignComponentMatcher(foreignImports); + const foreignComponentDiagnostics = analyzeForeignComponentFeatures(template, foreignMatcher); + if (foreignComponentDiagnostics.length > 0) { + isPoisoned = true; + diagnostics ??= []; + diagnostics.push(...foreignComponentDiagnostics); + } + // Figure out the set of styles. The ordering here is important: external resources (styleUrls) // precede inline styles, and styles defined in the template override styles defined in the // component. diff --git a/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts index a3b8fa9eef4a..d880b807198d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts @@ -9,14 +9,12 @@ import { AST, ASTWithSource, - BindingType, ImplicitReceiver, ParsedEventType, PropertyRead, Binary, RecursiveAstVisitor, TmplAstBoundEvent, - TmplAstElement, TmplAstLetDeclaration, TmplAstNode, TmplAstRecursiveVisitor, @@ -43,12 +41,7 @@ export class TemplateSemanticsCheckerImpl implements TemplateSemanticsChecker { /** Visitor that verifies the semantics of a template. */ class TemplateSemanticsVisitor extends TmplAstRecursiveVisitor { - private constructor( - private expressionVisitor: ExpressionsSemanticsVisitor, - private templateTypeChecker: TemplateTypeChecker, - private component: ts.ClassDeclaration, - private diagnostics: TemplateDiagnostic[], - ) { + private constructor(private expressionVisitor: ExpressionsSemanticsVisitor) { super(); } @@ -63,12 +56,7 @@ class TemplateSemanticsVisitor extends TmplAstRecursiveVisitor { component, diagnostics, ); - const templateVisitor = new TemplateSemanticsVisitor( - expressionVisitor, - templateTypeChecker, - component, - diagnostics, - ); + const templateVisitor = new TemplateSemanticsVisitor(expressionVisitor); nodes.forEach((node) => node.visit(templateVisitor)); return diagnostics; } @@ -77,51 +65,6 @@ class TemplateSemanticsVisitor extends TmplAstRecursiveVisitor { super.visitBoundEvent(event); event.handler.visit(this.expressionVisitor, event); } - - override visitElement(element: TmplAstElement): void { - super.visitElement(element); - - const foreignMeta = this.templateTypeChecker.getForeignComponent(this.component, element); - if (foreignMeta !== null) { - this.validateForeignComponent(element); - } - } - - private validateForeignComponent(element: TmplAstElement) { - if (element.outputs.length > 0) { - this.diagnostics.push( - this.templateTypeChecker.makeTemplateDiagnostic( - this.component, - element.sourceSpan, - ts.DiagnosticCategory.Error, - ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING, - `Foreign components do not support event bindings.`, - ), - ); - } - if (element.references.length > 0) { - this.diagnostics.push( - this.templateTypeChecker.makeTemplateDiagnostic( - this.component, - element.sourceSpan, - ts.DiagnosticCategory.Error, - ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING, - `Foreign components do not support references.`, - ), - ); - } - if (element.inputs.some((input) => input.type !== BindingType.Property)) { - this.diagnostics.push( - this.templateTypeChecker.makeTemplateDiagnostic( - this.component, - element.sourceSpan, - ts.DiagnosticCategory.Error, - ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING, - `Foreign components only support static attributes and property bindings.`, - ), - ); - } - } } /** Visitor that verifies the semantics of the expressions within a template. */ From 1f6e843648f0756d2e17df49e9dd38f015840c8e Mon Sep 17 00:00:00 2001 From: leonsenft Date: Thu, 4 Jun 2026 12:15:24 -0700 Subject: [PATCH 5/7] refactor(compiler-cli): validate `@content` block placement Adds validation to verify that `@content` blocks are only used as direct children of foreign components. Specifically: - Defines a new compile diagnostic code `INVALID_CONTENT_PLACEMENT = 8026`. - Updates `ForeignComponentFeatureAnalyzer` to traverse content blocks and report `INVALID_CONTENT_PLACEMENT` diagnostics if they are placed incorrectly. - Removes the raw error thrown during ingestion in `packages/compiler/src/template/pipeline/src/ingest.ts`. - Adds integration tests in `template_typecheck_spec.ts`. --- .../public-api/compiler-cli/error_code.api.md | 1 + .../component/src/foreign_component.ts | 129 ++++++++++++++++++ .../src/ngtsc/diagnostics/src/error_code.ts | 5 + .../test/ngtsc/template_typecheck_spec.ts | 84 ++++++++++++ .../src/template/pipeline/src/ingest.ts | 2 - 5 files changed, 219 insertions(+), 2 deletions(-) diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index 979393176de9..252c4c570144 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -81,6 +81,7 @@ export enum ErrorCode { INLINE_TYPE_CTOR_REQUIRED = 8901, INTERPOLATED_SIGNAL_NOT_INVOKED = 8109, INVALID_BANANA_IN_BOX = 8101, + INVALID_CONTENT_PLACEMENT = 8026, LET_USED_BEFORE_DEFINITION = 8016, LOCAL_COMPILATION_UNRESOLVED_CONST = 11001, LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION = 11003, diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts index 8c3a1a81112c..84cbf1e3dfc6 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts @@ -9,8 +9,22 @@ import { BindingType, SelectorlessMatcher, + TmplAstContent, + TmplAstContentBlock, + TmplAstDeferredBlock, + TmplAstDeferredBlockError, + TmplAstDeferredBlockLoading, + TmplAstDeferredBlockPlaceholder, TmplAstElement, + TmplAstForLoopBlock, + TmplAstForLoopBlockEmpty, + TmplAstIfBlock, + TmplAstIfBlockBranch, + TmplAstNode, TmplAstRecursiveVisitor, + TmplAstSwitchBlock, + TmplAstSwitchBlockCaseGroup, + TmplAstTemplate, tmplAstVisitAll, TypeCheckId, } from '@angular/compiler'; @@ -39,6 +53,7 @@ export function analyzeForeignComponentFeatures( } class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { + private currentParent: TmplAstNode | null = null; readonly diagnostics: ts.Diagnostic[] = []; constructor( @@ -52,6 +67,14 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { return this.foreignMatcher !== null && this.foreignMatcher.match(tagName).length > 0; } + private parentNodeIsForeignComponent(): boolean { + return ( + this.currentParent !== null && + this.currentParent instanceof TmplAstElement && + this.elementIsForeignComponent(this.currentParent.name) + ); + } + override visitElement(element: TmplAstElement): void { if (this.elementIsForeignComponent(element.name)) { if (element.outputs.length > 0) { @@ -92,6 +115,112 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { } } + const prevParent = this.currentParent; + this.currentParent = element; super.visitElement(element); + this.currentParent = prevParent; + } + + override visitTemplate(template: TmplAstTemplate): void { + const prevParent = this.currentParent; + this.currentParent = template; + super.visitTemplate(template); + this.currentParent = prevParent; + } + + override visitDeferredBlock(deferred: TmplAstDeferredBlock): void { + const prevParent = this.currentParent; + this.currentParent = deferred; + super.visitDeferredBlock(deferred); + this.currentParent = prevParent; + } + + override visitDeferredBlockPlaceholder(block: TmplAstDeferredBlockPlaceholder): void { + const prevParent = this.currentParent; + this.currentParent = block; + super.visitDeferredBlockPlaceholder(block); + this.currentParent = prevParent; + } + + override visitDeferredBlockError(block: TmplAstDeferredBlockError): void { + const prevParent = this.currentParent; + this.currentParent = block; + super.visitDeferredBlockError(block); + this.currentParent = prevParent; + } + + override visitDeferredBlockLoading(block: TmplAstDeferredBlockLoading): void { + const prevParent = this.currentParent; + this.currentParent = block; + super.visitDeferredBlockLoading(block); + this.currentParent = prevParent; + } + + override visitSwitchBlock(block: TmplAstSwitchBlock): void { + const prevParent = this.currentParent; + this.currentParent = block; + super.visitSwitchBlock(block); + this.currentParent = prevParent; + } + + override visitSwitchBlockCaseGroup(block: TmplAstSwitchBlockCaseGroup): void { + const prevParent = this.currentParent; + this.currentParent = block; + super.visitSwitchBlockCaseGroup(block); + this.currentParent = prevParent; + } + + override visitForLoopBlock(block: TmplAstForLoopBlock): void { + const prevParent = this.currentParent; + this.currentParent = block; + super.visitForLoopBlock(block); + this.currentParent = prevParent; + } + + override visitForLoopBlockEmpty(block: TmplAstForLoopBlockEmpty): void { + const prevParent = this.currentParent; + this.currentParent = block; + super.visitForLoopBlockEmpty(block); + this.currentParent = prevParent; + } + + override visitIfBlock(block: TmplAstIfBlock): void { + const prevParent = this.currentParent; + this.currentParent = block; + super.visitIfBlock(block); + this.currentParent = prevParent; + } + + override visitIfBlockBranch(block: TmplAstIfBlockBranch): void { + const prevParent = this.currentParent; + this.currentParent = block; + super.visitIfBlockBranch(block); + this.currentParent = prevParent; + } + + override visitContent(content: TmplAstContent): void { + const prevParent = this.currentParent; + this.currentParent = content; + super.visitContent(content); + this.currentParent = prevParent; + } + + override visitContentBlock(block: TmplAstContentBlock): void { + if (!this.parentNodeIsForeignComponent()) { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + block.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.INVALID_CONTENT_PLACEMENT), + '@content blocks are only valid as direct children of foreign components.', + ), + ); + } + const prevParent = this.currentParent; + this.currentParent = block; + super.visitContentBlock(block); + this.currentParent = prevParent; } } diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index 504de50ccfe4..dff4b9c65f73 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -464,6 +464,11 @@ export enum ErrorCode { */ FOREIGN_COMPONENT_UNSUPPORTED_BINDING = 8025, + /** + * Raised when a `@content` block is not used as a direct child of a foreign component. + */ + INVALID_CONTENT_PLACEMENT = 8026, + /** * A two way binding in a template has an incorrect syntax, * parentheses outside brackets. For example: diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 1cf9fbfefd2c..a08f040d7bb7 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -2320,6 +2320,90 @@ runInEachFileSystem(() => { ); env.driveMain(); }); + + it('should allow @content block when used as a direct child of a foreign component', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: ' @content(icon) {} ', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(0); + }); + + it('should detect @content block used outside of a foreign component', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: '
@content(icon) {}
', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.INVALID_CONTENT_PLACEMENT)); + expect(diags[0].messageText).toEqual( + '@content blocks are only valid as direct children of foreign components.', + ); + }); + + it('should detect @content block nested inside a foreign component but not as a direct child', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: '
@content(icon) {}
', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.INVALID_CONTENT_PLACEMENT)); + expect(diags[0].messageText).toEqual( + '@content blocks are only valid as direct children of foreign components.', + ); + }); + + it('should detect @content block nested inside a block (like @if) within a foreign component', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: ' @if (true) { @content (icon) {} } ', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.INVALID_CONTENT_PLACEMENT)); + expect(diags[0].messageText).toEqual( + '@content blocks are only valid as direct children of foreign components.', + ); + }); }); it('should detect a duplicate variable declaration', () => { diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 45ef4273907c..91d18a981768 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -268,8 +268,6 @@ function ingestNodes(unit: ViewCompilationUnit, template: t.Node[]): void { ingestForBlock(unit, node); } else if (node instanceof t.LetDeclaration) { ingestLetDeclaration(unit, node); - } else if (node instanceof t.ContentBlock) { - throw new Error(`@content blocks are only valid as direct children of foreign components.`); } else if (node instanceof t.Component) { // TODO(crisbeto): account for selectorless nodes. } else { From 337442453b77203b5b2e9bdb808323be3855a6f8 Mon Sep 17 00:00:00 2001 From: leonsenft Date: Thu, 4 Jun 2026 14:27:47 -0700 Subject: [PATCH 6/7] refactor(compiler-cli): disallow `@content (children)` in favor of implicit children Defining a `@content (children)` block explicitly is unnecessary because children should always be passed implicitly as direct nested content of the foreign component. Using an explicit block could also lead to conflicts and silent template rendering issues where implicit content (like whitespace) accidentally overwrote the explicit block in the compiler's template representation. This change introduces a compilation error (`FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN`) when an explicit `@content (children)` block is detected, guiding developers to pass children implicitly instead. --- .../public-api/compiler-cli/error_code.api.md | 1 + .../component/src/foreign_component.ts | 16 +++++++++++- .../src/ngtsc/diagnostics/src/error_code.ts | 6 +++++ .../test/ngtsc/template_typecheck_spec.ts | 25 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index 252c4c570144..176c6c76363c 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -55,6 +55,7 @@ export enum ErrorCode { DUPLICATE_DECORATED_PROPERTIES = 1012, DUPLICATE_VARIABLE_DECLARATION = 8006, FORBIDDEN_REQUIRED_INITIALIZER_INVOCATION = 8118, + FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN = 8027, FOREIGN_COMPONENT_UNSUPPORTED_BINDING = 8025, FORM_FIELD_UNSUPPORTED_BINDING = 8022, HOST_BINDING_PARSE_ERROR = 5001, diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts index 84cbf1e3dfc6..1cf2f912b4f8 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts @@ -206,7 +206,21 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { } override visitContentBlock(block: TmplAstContentBlock): void { - if (!this.parentNodeIsForeignComponent()) { + if (this.parentNodeIsForeignComponent()) { + if (block.name === 'children') { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + block.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN), + 'Defining a @content (children) block is unnecessary. ' + + 'Pass children as direct nested content of the foreign component instead.', + ), + ); + } + } else { this.diagnostics.push( makeTemplateDiagnostic( '' as TypeCheckId, diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index dff4b9c65f73..bac3ddb1454f 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -469,6 +469,12 @@ export enum ErrorCode { */ INVALID_CONTENT_PLACEMENT = 8026, + /** + * Raised when a `@content` block is named 'children', which is unnecessary because children should be passed + * implicitly. + */ + FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN = 8027, + /** * A two way binding in a template has an incorrect syntax, * parentheses outside brackets. For example: diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index a08f040d7bb7..1fb5b794a6eb 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -2404,6 +2404,31 @@ runInEachFileSystem(() => { '@content blocks are only valid as direct children of foreign components.', ); }); + + it('should detect unnecessary @content (children) block on a foreign component', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: ' @content (children) {} ', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual( + ngErrorCode(ErrorCode.FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN), + ); + expect(diags[0].messageText).toEqual( + 'Defining a @content (children) block is unnecessary. ' + + 'Pass children as direct nested content of the foreign component instead.', + ); + }); }); it('should detect a duplicate variable declaration', () => { From 79e5d5d75f0e6ad2782bd4b4d1efadfcdf55634c Mon Sep 17 00:00:00 2001 From: leonsenft Date: Thu, 4 Jun 2026 15:41:57 -0700 Subject: [PATCH 7/7] refactor(compiler-cli): validate @content block names for conflicts Ensures `@content` blocks on foreign components have unique names and do not conflict with static attributes or input property bindings. Specifically, this commit introduces two new template diagnostics: 1. `CONFLICTING_CONTENT_DECLARATION` (8028): Raised when multiple `@content` blocks with the same name are defined under the same foreign component. 2. `CONFLICTING_CONTENT_AND_PROPERTY` (8029): Raised when a `@content` block's name matches an attribute or input property binding on the parent foreign component. Both diagnostics include related information pointing to the location of the conflicting declaration or property. --- .../public-api/compiler-cli/error_code.api.md | 2 + .../component/src/foreign_component.ts | 224 ++++++++++++++---- .../src/ngtsc/diagnostics/src/error_code.ts | 10 + .../test/ngtsc/template_typecheck_spec.ts | 165 +++++++++++++ 4 files changed, 352 insertions(+), 49 deletions(-) diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index 176c6c76363c..e969dfd34333 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -28,6 +28,8 @@ export enum ErrorCode { CONFIG_FLAT_MODULE_NO_INDEX = 4001, // (undocumented) CONFIG_STRICT_TEMPLATES_IMPLIES_FULL_TEMPLATE_TYPECHECK = 4002, + CONFLICTING_CONTENT_AND_PROPERTY = 8029, + CONFLICTING_CONTENT_DECLARATION = 8028, CONFLICTING_HOST_DIRECTIVE_BINDING = -8024, CONFLICTING_INPUT_TRANSFORM = 2020, CONFLICTING_LET_DECLARATION = 8017, diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts index 1cf2f912b4f8..dfcfaa77beac 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts @@ -36,6 +36,11 @@ import {ForeignComponentMeta} from '../../../metadata'; import {makeTemplateDiagnostic} from '../../../typecheck/diagnostics'; import {SourceMapping} from '../../../typecheck/api'; +/** + * The intrinsic property name used to project children into a foreign component. + */ +const CHILDREN = 'children'; + /** * Analyzes the template for invalid use of features relating to foreign components. * @@ -55,6 +60,10 @@ export function analyzeForeignComponentFeatures( class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { private currentParent: TmplAstNode | null = null; readonly diagnostics: ts.Diagnostic[] = []; + // Tracks the named @content blocks defined for each foreign component element. + // This is used to detect duplicate @content declarations under the same parent + // during the recursive AST traversal. + private readonly seenContentBlocks = new Map>(); constructor( private readonly foreignMatcher: SelectorlessMatcher | null, @@ -77,42 +86,7 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { override visitElement(element: TmplAstElement): void { if (this.elementIsForeignComponent(element.name)) { - if (element.outputs.length > 0) { - this.diagnostics.push( - makeTemplateDiagnostic( - '' as TypeCheckId, - this.sourceMapping, - element.sourceSpan, - ts.DiagnosticCategory.Error, - ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING), - 'Foreign components do not support event bindings.', - ), - ); - } - if (element.references.length > 0) { - this.diagnostics.push( - makeTemplateDiagnostic( - '' as TypeCheckId, - this.sourceMapping, - element.sourceSpan, - ts.DiagnosticCategory.Error, - ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING), - 'Foreign components do not support references.', - ), - ); - } - if (element.inputs.some((input) => input.type !== BindingType.Property)) { - this.diagnostics.push( - makeTemplateDiagnostic( - '' as TypeCheckId, - this.sourceMapping, - element.sourceSpan, - ts.DiagnosticCategory.Error, - ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING), - 'Foreign components only support static attributes and property bindings.', - ), - ); - } + this.validateForeignComponent(element); } const prevParent = this.currentParent; @@ -121,6 +95,84 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { this.currentParent = prevParent; } + private validateForeignComponent(element: TmplAstElement): void { + if (element.outputs.length > 0) { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + element.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING), + 'Foreign components do not support event bindings.', + ), + ); + } + if (element.references.length > 0) { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + element.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING), + 'Foreign components do not support references.', + ), + ); + } + if (element.inputs.some((input) => input.type !== BindingType.Property)) { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + element.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING), + 'Foreign components only support static attributes and property bindings.', + ), + ); + } + + // A foreign component maps implicit child nodes to a 'children' property. + // If the user also explicitly binds to '[children]' or sets a static 'children' attribute, + // this is a conflict. + const childrenInput = element.inputs.find( + (input) => input.type === BindingType.Property && input.name === CHILDREN, + ); + const childrenAttr = element.attributes.find((attr) => attr.name === CHILDREN); + const conflictingSource = childrenInput ?? childrenAttr; + if (conflictingSource === undefined) { + return; + } + + // Explicit `@content` blocks (TmplAstContentBlock) are mapped to properties by their name, so + // they do not conflict with the default 'children' property. We only care about child nodes + // that are not content blocks, as those are implicitly passed to the 'children' property. + const firstChild = element.children.find((child) => !(child instanceof TmplAstContentBlock)); + if (firstChild === undefined) { + return; + } + + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + conflictingSource.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.CONFLICTING_CONTENT_AND_PROPERTY), + `A foreign component cannot have both a '${CHILDREN}' property and child nodes.`, + [ + { + text: 'Child nodes are defined here.', + start: firstChild.sourceSpan.start.offset, + end: firstChild.sourceSpan.end.offset, + sourceFile: this.sourceMapping.node.getSourceFile(), + }, + ], + ), + ); + } + override visitTemplate(template: TmplAstTemplate): void { const prevParent = this.currentParent; this.currentParent = template; @@ -207,19 +259,7 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { override visitContentBlock(block: TmplAstContentBlock): void { if (this.parentNodeIsForeignComponent()) { - if (block.name === 'children') { - this.diagnostics.push( - makeTemplateDiagnostic( - '' as TypeCheckId, - this.sourceMapping, - block.sourceSpan, - ts.DiagnosticCategory.Error, - ngErrorCode(ErrorCode.FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN), - 'Defining a @content (children) block is unnecessary. ' + - 'Pass children as direct nested content of the foreign component instead.', - ), - ); - } + this.validateContentBlock(block); } else { this.diagnostics.push( makeTemplateDiagnostic( @@ -232,9 +272,95 @@ class ForeignComponentFeatureAnalyzer extends TmplAstRecursiveVisitor { ), ); } + const prevParent = this.currentParent; this.currentParent = block; super.visitContentBlock(block); this.currentParent = prevParent; } + + private validateContentBlock(block: TmplAstContentBlock): void { + const parent = this.currentParent as TmplAstElement; + + // Retrieve or initialize the map of @content blocks seen so far for this parent. + // Since the visitor is recursive, we must track declarations per-parent to + // only report duplicates within the scope of the same foreign component. + let seen = this.seenContentBlocks.get(parent); + if (seen === undefined) { + seen = new Map(); + this.seenContentBlocks.set(parent, seen); + } + + if (seen.has(block.name)) { + const firstDecl = seen.get(block.name)!; + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + block.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.CONFLICTING_CONTENT_DECLARATION), + `A @content block with the name '${block.name}' has already been defined for this ` + + 'component.', + [ + { + text: `The @content block '${block.name}' was first defined here.`, + start: firstDecl.sourceSpan.start.offset, + end: firstDecl.sourceSpan.end.offset, + sourceFile: this.sourceMapping.node.getSourceFile(), + }, + ], + ), + ); + } else { + seen.set(block.name, block); + } + + // A @content block projects content into a property of the foreign component. + // If the parent element also binds to this property (either via a property binding + // or a static attribute), it creates a conflict as both try to write to the same prop. + const conflictInput = parent.inputs.find( + (input) => input.type === BindingType.Property && input.name === block.name, + ); + const conflictAttr = parent.attributes.find((attr) => attr.name === block.name); + const conflict = conflictInput ?? conflictAttr; + + if (conflict !== undefined) { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + block.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.CONFLICTING_CONTENT_AND_PROPERTY), + `A @content block with the name '${block.name}' conflicts with a property on the ` + + 'parent component.', + [ + { + text: `The property '${block.name}' is defined here.`, + start: conflict.sourceSpan.start.offset, + end: conflict.sourceSpan.end.offset, + sourceFile: this.sourceMapping.node.getSourceFile(), + }, + ], + ), + ); + } + + // Explicitly defining a `@content(children)` block is unnecessary because child nodes are + // implicitly passed to the `children` property. + if (block.name === CHILDREN) { + this.diagnostics.push( + makeTemplateDiagnostic( + '' as TypeCheckId, + this.sourceMapping, + block.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN), + `Defining a @content (${CHILDREN}) block is unnecessary. ` + + 'Pass children as direct nested content of the foreign component instead.', + ), + ); + } + } } diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index bac3ddb1454f..bdd6e05cd8fa 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -475,6 +475,16 @@ export enum ErrorCode { */ FOREIGN_COMPONENT_CONTENT_UNNECESSARY_FOR_CHILDREN = 8027, + /** + * Raised when multiple `@content` blocks with the same name are defined for a foreign component. + */ + CONFLICTING_CONTENT_DECLARATION = 8028, + + /** + * Raised when a `@content` block name conflicts with an input binding on the parent foreign component. + */ + CONFLICTING_CONTENT_AND_PROPERTY = 8029, + /** * A two way binding in a template has an incorrect syntax, * parentheses outside brackets. For example: diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 1fb5b794a6eb..6cac8f683440 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -2429,6 +2429,171 @@ runInEachFileSystem(() => { 'Pass children as direct nested content of the foreign component instead.', ); }); + + it('should detect duplicate @content blocks under the same foreign component', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: ' @content (icon) {} @content (icon) {} ', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.CONFLICTING_CONTENT_DECLARATION)); + expect(diags[0].messageText).toEqual( + "A @content block with the name 'icon' has already been defined for this component.", + ); + expect(getSourceCodeForDiagnostic(diags[0])).toEqual('@content (icon) {}'); + expect(diags[0].relatedInformation).toBeDefined(); + expect(diags[0].relatedInformation!.length).toEqual(1); + expect(diags[0].relatedInformation![0].messageText).toEqual( + "The @content block 'icon' was first defined here.", + ); + }); + + it('should allow the same @content block name under different foreign components', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: \` + @content (icon) {} + @content (icon) {} + \`, + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(0); + }); + + it('should detect a conflict between a @content block name and an input property binding', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: ' @content (icon) {square} ', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp { + myIcon = document.createTextNode('circle'); + } + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.CONFLICTING_CONTENT_AND_PROPERTY)); + expect(diags[0].messageText).toEqual( + "A @content block with the name 'icon' conflicts with a property on the parent component.", + ); + expect(getSourceCodeForDiagnostic(diags[0])).toEqual('@content (icon) {square}'); + expect(diags[0].relatedInformation).toBeDefined(); + expect(diags[0].relatedInformation!.length).toEqual(1); + expect(diags[0].relatedInformation![0].messageText).toEqual( + "The property 'icon' is defined here.", + ); + }); + + it('should detect a conflict between a @content block name and a static attribute', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: ' @content (icon) {square} ', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.CONFLICTING_CONTENT_AND_PROPERTY)); + expect(diags[0].messageText).toEqual( + "A @content block with the name 'icon' conflicts with a property on the parent component.", + ); + expect(getSourceCodeForDiagnostic(diags[0])).toEqual('@content (icon) {square}'); + expect(diags[0].relatedInformation).toBeDefined(); + expect(diags[0].relatedInformation!.length).toEqual(1); + expect(diags[0].relatedInformation![0].messageText).toEqual( + "The property 'icon' is defined here.", + ); + }); + + it('should detect a conflict between implicit children and a [children] property binding', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: '
child
', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp { + myChildren = []; + } + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.CONFLICTING_CONTENT_AND_PROPERTY)); + expect(diags[0].messageText).toEqual( + "A foreign component cannot have both a 'children' property and child nodes.", + ); + expect(getSourceCodeForDiagnostic(diags[0])).toEqual('[children]="myChildren"'); + expect(diags[0].relatedInformation).toBeDefined(); + expect(diags[0].relatedInformation!.length).toEqual(1); + expect(diags[0].relatedInformation![0].messageText).toEqual( + 'Child nodes are defined here.', + ); + }); + + it('should detect a conflict between implicit children and a children static attribute', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: 'Hello, content!', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.CONFLICTING_CONTENT_AND_PROPERTY)); + expect(diags[0].messageText).toEqual( + "A foreign component cannot have both a 'children' property and child nodes.", + ); + expect(getSourceCodeForDiagnostic(diags[0])).toEqual('children="Hello, property!"'); + expect(diags[0].relatedInformation).toBeDefined(); + expect(diags[0].relatedInformation!.length).toEqual(1); + expect(diags[0].relatedInformation![0].messageText).toEqual( + 'Child nodes are defined here.', + ); + }); }); it('should detect a duplicate variable declaration', () => {