diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index 979393176de9..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, @@ -55,6 +57,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, @@ -81,6 +84,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/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..dfcfaa77beac --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/foreign_component.ts @@ -0,0 +1,366 @@ +/** + * @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, + TmplAstContent, + TmplAstContentBlock, + TmplAstDeferredBlock, + TmplAstDeferredBlockError, + TmplAstDeferredBlockLoading, + TmplAstDeferredBlockPlaceholder, + TmplAstElement, + TmplAstForLoopBlock, + TmplAstForLoopBlockEmpty, + TmplAstIfBlock, + TmplAstIfBlockBranch, + TmplAstNode, + TmplAstRecursiveVisitor, + TmplAstSwitchBlock, + TmplAstSwitchBlockCaseGroup, + TmplAstTemplate, + 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'; + +/** + * 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. + * + * @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 { + 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, + private readonly sourceMapping: SourceMapping, + ) { + super(); + } + + private elementIsForeignComponent(tagName: string): boolean { + 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)) { + this.validateForeignComponent(element); + } + + const prevParent = this.currentParent; + this.currentParent = element; + super.visitElement(element); + 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; + 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.validateContentBlock(block); + } else { + 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; + } + + 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/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/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index 504de50ccfe4..bdd6e05cd8fa 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,27 @@ 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, + + /** + * Raised when a `@content` block is named 'children', which is unnecessary because children should be passed + * implicitly. + */ + 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/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. */ 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..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 @@ -416,6 +416,43 @@ 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: ` + + @content(icon) { + Icon! + } + @content(description) { + Description text + } + Other children + + `, 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: ` + + @content(icon) { + Icon! + } + @content(description) { + Description text + } + Other children + + `, + // @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 +464,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..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,3 +1,29 @@ +function TestCmpChildren_Icon_0_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵdomElementStart(0, "span"); + 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(); + } +} + +… + export class TestCmp { // ... static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ @@ -13,3 +39,22 @@ export class TestCmp { encapsulation: 2 }); } + +… + +export class TestCmpChildren { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmpChildren, + selectors: [["main-children"]], + decls: 4, + vars: 0, + template: function TestCmpChildren_Template(rf, ctx) { + if (rf & 1) { + 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 1ddad2ca8bad..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,3 +1,29 @@ +function TestCmpChildren_Icon_0_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelementStart(0, "span"); + 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(); + } +} + +… + export class TestCmp { // ... static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ @@ -13,3 +39,22 @@ export class TestCmp { encapsulation: 2 }); } + +… + +export class TestCmpChildren { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmpChildren, + selectors: [["main-children"]], + decls: 4, + vars: 0, + template: function TestCmpChildren_Template(rf, ctx) { + if (rf & 1) { + 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 7e3ea2ed3984..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 @@ -26,3 +26,27 @@ function frameworkImport(component: {}): Function { export class TestCmp { title = 'Submit'; } + +@Component({ + selector: 'main-children', + template: ` + + @content(icon) { + Icon! + } + @content(description) { + Description text + } + Other children + + `, + // @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-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 1cf9fbfefd2c..6cac8f683440 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -2320,6 +2320,280 @@ 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 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 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', () => { 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_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/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 c36a5fd32670..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. */ @@ -460,6 +465,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..06fc464530b7 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 @@ -1267,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: @@ -1294,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 9f0b703c152e..91d18a981768 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, @@ -292,29 +291,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), - }); - } - 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; } @@ -360,6 +337,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 929950c54ab4..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: @@ -788,6 +799,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/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/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/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/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..a5a7d9bfebc9 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, FLAGS} 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 {RElement, RNode} from '../interfaces/renderer_dom'; +import {createAndRenderEmbeddedLView} from '../view_manipulation'; +import {collectNativeNodes} from '../collect_native_nodes'; +import {assertLContainer} from '../assert'; +import {LContainer, LContainerFlags} from '../interfaces/container'; /** * Creation phase instruction to render a foreign component. @@ -82,3 +86,32 @@ 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); + 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 since the nodes will be projected into a foreign view. + 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/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/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/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(); + }); + }); +}); diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts index fd4308f86c89..716b24683c8b 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,88 @@ describe('ɵɵforeignComponent', () => { expect(fixture.host.innerHTML).toContain(expectedHtml); expect(host2.innerHTML).toContain(expectedHtml); }); + + 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) { + mainChildren.appendChild(child); + } + 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, 'Main Children Content'); + ɵɵelementEnd(); + } + }; + + const fixture = new ViewFixture({ + decls: 4, + vars: 0, + create: () => { + ɵɵ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( + '' + + '
' + + '
Icon Content
' + + '

Description Content

' + + '
Main Children Content
' + + '
', + ); + }); }); function renderSecondInstance(fixture: ViewFixture): HTMLElement { 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. }